rushlink/internal/db/fileupload.go

245 lines
6.4 KiB
Go

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)
}