package db import ( "bytes" "encoding/hex" "hash/crc32" "io" "net/http" "net/url" "os" "path" "path/filepath" "time" "github.com/google/uuid" "github.com/pkg/errors" "gorm.io/gorm" ) // Use the Castagnoli checksum because of the acceleration on Intel CPUs var checksumTable = crc32.MakeTable(crc32.Castagnoli) // FileStore holds the path to a file storage location. type FileStore struct { path string } // FileUploadState determines the current state of a FileUpload object. type FileUploadState int // FileUpload models an uploaded file. type FileUpload struct { ID uint `gorm:"primaryKey"` // State of the FileUpload (present/deleted/etc). State FileUploadState `gorm:"index"` // UUID publically identifies this FileUpload. PubID uuid.UUID `gorm:"uniqueIndex"` // User ID that created this file CreatedBy uint `gorm:"index"` // FileName contains the original filename of this FileUpload. FileName string // Content type as determined by http.DetectContentType. ContentType string // Checksum holds a crc32c checksum of the file. // // This checksum is only meant to allow for the detection of random // database corruption. Checksum uint32 CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt } const ( dirMode os.FileMode = 0750 fileMode os.FileMode = 0640 ) const ( // FileUploadStateUndef is an undefined FileUpload. FileUploadStateUndef FileUploadState = 0 // FileUploadStatePresent denotes the normal (existing) state. FileUploadStatePresent FileUploadState = 1 // FileUploadStateDeleted denotes a deleted state. FileUploadStateDeleted FileUploadState = 2 ) func (t FileUploadState) String() string { switch t { case FileUploadStateUndef: return "unknown" case FileUploadStatePresent: return "present" case FileUploadStateDeleted: return "deleted" default: return "invalid" } } // ErrFileUploadDoesNotExist occurs when a key does not exist in the database. var ErrFileUploadDoesNotExist = errors.New("file not found in the database") // OpenFileStoreFromEnvironment tries to open a file store located at ${RUSHLINK_FILE_STORE_PATH}. func OpenFileStoreFromEnvironment() (*FileStore, error) { path, prs := os.LookupEnv(EnvFileStorePath) if !prs { err := errors.Errorf("%v environment variable is not set", EnvFileStorePath) return nil, errors.Wrap(err, "opening file store") } return OpenFileStore(path) } // OpenFileStore opens the file storage at path. func OpenFileStore(path string) (*FileStore, error) { if path == "" { return nil, errors.New("file-store not set") } // Try to create the file store directory if it does not yet exist if err := os.MkdirAll(path, dirMode); err != nil { return nil, errors.Wrap(err, "creating file store directory") } return &FileStore{path[:]}, nil } // Path returns the path of the FileStore root. func (fs *FileStore) Path() string { return fs.path } // filePath resolves the path of a file in the FileStore given some id and filename. func (fs *FileStore) filePath(pubID uuid.UUID, fileName string) string { if fs.path == "" { panic("fileStoreDir called while the file store path has not been set") } return path.Join(fs.path, hex.EncodeToString(pubID[:]), fileName) } // NewFileUpload creates a new FileUpload object. // // Internally, this function detects the type of the file stored in `r` using // `http.DetectContentType`. func NewFileUpload(fs *FileStore, r io.Reader, user *User, fileName string) (*FileUpload, error) { // Generate a file ID pubID, err := uuid.NewRandom() if err != nil { return nil, errors.Wrap(err, "generating UUID") } // Construct a checksum for this file hash := crc32.New(checksumTable) tee := io.TeeReader(r, hash) // Detect the file type var tmpBuf bytes.Buffer tmpBuf.Grow(512) io.CopyN(&tmpBuf, tee, 512) contentType := http.DetectContentType(tmpBuf.Bytes()) // Open the file on disk for writing baseName := filepath.Base(fileName) filePath := fs.filePath(pubID, baseName) if err := os.Mkdir(path.Dir(filePath), dirMode); err != nil { return nil, errors.Wrap(err, "creating file dir") } file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, fileMode) if err != nil { return nil, errors.Wrap(err, "opening file") } defer file.Close() // Write the file to disk _, err = io.Copy(file, &tmpBuf) if err != nil { return nil, errors.Wrap(err, "writing to file") } _, err = io.Copy(file, tee) if err != nil { return nil, errors.Wrap(err, "writing to file") } fu := &FileUpload{ State: FileUploadStatePresent, PubID: pubID, CreatedBy: user.ID, FileName: baseName, ContentType: contentType, Checksum: hash.Sum32(), } return fu, nil } // GetFileUpload tries to retrieve a FileUpload object from the bolt database. func GetFileUpload(db *gorm.DB, pubID uuid.UUID) (*FileUpload, error) { var fus []FileUpload if err := db.Unscoped().Limit(1).Where("pub_id = ?", pubID).Find(&fus).Error; err != nil { return nil, err } if len(fus) == 0 { return nil, ErrFileUploadDoesNotExist } return &fus[0], nil } // AllFileUploads tries to retrieve all FileUpload objects from the bolt database. func AllFileUploads(db *gorm.DB) ([]FileUpload, error) { var fus []FileUpload if err := db.Find(&fus).Error; err != nil { return nil, err } return fus, nil } // Save saves a FileUpload in the database. func (fu *FileUpload) Save(db *gorm.DB) error { return db.Save(fu).Error } // Delete deletes a FileUpload from the database. func (fu *FileUpload) Delete(db *gorm.DB, fs *FileStore) error { // Remove the file in the backend filePath := fu.Path(fs) if err := os.Remove(filePath); err != nil { return err } // Update the file in the server if err := db.Delete(fu).Error; err != nil { return err } // Cleanup the parent directory wrap := "deletion succeeded, but removing the file directory has failed" return errors.Wrap(os.Remove(path.Dir(filePath)), wrap) } // Path returns the path to this FileUpload in the FileStore provided in fs. func (fu *FileUpload) Path(fs *FileStore) string { return fs.filePath(fu.PubID, fu.FileName) } // URL returns the URL for the FileUpload. func (fu *FileUpload) URL() *url.URL { rawurl := "/uploads/" + hex.EncodeToString(fu.PubID[:]) + "/" + fu.FileName urlParse, err := url.Parse(rawurl) if err != nil { panic("could not construct /uploads/ url") } return urlParse } // Ext returns the extension of the file attached to this FileUpload. func (fu *FileUpload) Ext() string { return filepath.Ext(fu.FileName) }