URL shortener and file dump for hashru.link
https://hashru.link
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
240 lines
6.3 KiB
240 lines
6.3 KiB
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"` |
|
|
|
// 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, 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, |
|
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) |
|
}
|
|
|