All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m51s
When an incoming file has an empty content type or is marked as "application/octet-stream", attempt to detect the actual MIME type by reading the first 512 bytes of the file. This improves content type accuracy for generic binary uploads.
1110 lines
29 KiB
Go
1110 lines
29 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.etcd.io/bbolt"
|
|
"warpbox.dev/backend/libs/helpers"
|
|
)
|
|
|
|
var boxesBucket = []byte("boxes")
|
|
|
|
type UploadService struct {
|
|
maxUploadSize int64
|
|
baseURL string
|
|
dataDir string
|
|
filesDir string
|
|
db *bbolt.DB
|
|
logger *slog.Logger
|
|
storage *StorageService
|
|
}
|
|
|
|
type UploadOptions struct {
|
|
MaxDays int
|
|
ExpiresInMinutes int
|
|
MaxDownloads int
|
|
Password string
|
|
PasswordSalt string
|
|
PasswordHash string
|
|
ObfuscateMetadata bool
|
|
OwnerID string
|
|
CollectionID string
|
|
SkipSizeLimit bool
|
|
CreatorIP string
|
|
StorageBackendID string
|
|
}
|
|
|
|
type IncomingFile interface {
|
|
Name() string
|
|
Size() int64
|
|
ContentType() string
|
|
Open() (io.ReadCloser, error)
|
|
}
|
|
|
|
type multipartIncomingFile struct {
|
|
header *multipart.FileHeader
|
|
}
|
|
|
|
func (f multipartIncomingFile) Name() string {
|
|
return f.header.Filename
|
|
}
|
|
|
|
func (f multipartIncomingFile) Size() int64 {
|
|
return f.header.Size
|
|
}
|
|
|
|
func (f multipartIncomingFile) ContentType() string {
|
|
return f.header.Header.Get("Content-Type")
|
|
}
|
|
|
|
func (f multipartIncomingFile) Open() (io.ReadCloser, error) {
|
|
return f.header.Open()
|
|
}
|
|
|
|
type StagedUploadFile struct {
|
|
Filename string
|
|
FileSize int64
|
|
MIMEType string
|
|
Path string
|
|
}
|
|
|
|
func (f StagedUploadFile) Name() string {
|
|
return f.Filename
|
|
}
|
|
|
|
func (f StagedUploadFile) Size() int64 {
|
|
return f.FileSize
|
|
}
|
|
|
|
func (f StagedUploadFile) ContentType() string {
|
|
return f.MIMEType
|
|
}
|
|
|
|
func (f StagedUploadFile) Open() (io.ReadCloser, error) {
|
|
return os.Open(f.Path)
|
|
}
|
|
|
|
type Box struct {
|
|
ID string `json:"id"`
|
|
OwnerID string `json:"ownerId,omitempty"`
|
|
CollectionID string `json:"collectionId,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
MaxDownloads int `json:"maxDownloads"`
|
|
DownloadCount int `json:"downloadCount"`
|
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
|
PasswordHash string `json:"passwordHash,omitempty"`
|
|
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
|
Obfuscate bool `json:"obfuscate"`
|
|
CreatorIP string `json:"creatorIp,omitempty"`
|
|
StorageBackendID string `json:"storageBackendId,omitempty"`
|
|
Files []File `json:"files"`
|
|
}
|
|
|
|
type File struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
StoredName string `json:"storedName"`
|
|
Size int64 `json:"size"`
|
|
ContentType string `json:"contentType"`
|
|
PreviewKind string `json:"previewKind"`
|
|
Thumbnail string `json:"thumbnail,omitempty"`
|
|
ObjectKey string `json:"objectKey,omitempty"`
|
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
|
Processing bool `json:"processing,omitempty"`
|
|
ProcessingError string `json:"processingError,omitempty"`
|
|
UploadedAt time.Time `json:"uploadedAt"`
|
|
}
|
|
|
|
type UploadResult struct {
|
|
BoxID string `json:"boxId"`
|
|
BoxURL string `json:"boxUrl"`
|
|
ZipURL string `json:"zipUrl"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
ManageURL string `json:"manageUrl"`
|
|
DeleteURL string `json:"deleteUrl"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
Files []ResultFile `json:"files"`
|
|
}
|
|
|
|
type ResultFile struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Size string `json:"size"`
|
|
URL string `json:"url"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
Processing bool `json:"processing,omitempty"`
|
|
}
|
|
|
|
type AdminStats struct {
|
|
TotalBoxes int
|
|
TotalFiles int
|
|
TotalSize int64
|
|
UploadsLast24H int
|
|
ExpiredBoxes int
|
|
ProtectedBoxes int
|
|
TotalDownloads int
|
|
TotalSizeLabel string
|
|
}
|
|
|
|
type AdminBox struct {
|
|
ID string
|
|
OwnerID string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
FileCount int
|
|
TotalSize int64
|
|
TotalSizeLabel string
|
|
DownloadCount int
|
|
MaxDownloads int
|
|
Protected bool
|
|
Expired bool
|
|
}
|
|
|
|
type UserBox struct {
|
|
Box Box
|
|
CollectionName string
|
|
TotalSizeLabel string
|
|
}
|
|
|
|
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
|
filesDir := filepath.Join(dataDir, "files")
|
|
dbDir := filepath.Join(dataDir, "db")
|
|
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := db.Update(func(tx *bbolt.Tx) error {
|
|
_, err := tx.CreateBucketIfNotExists(boxesBucket)
|
|
return err
|
|
}); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
storage, err := NewStorageService(db, dataDir)
|
|
if err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return &UploadService{
|
|
maxUploadSize: maxUploadSize,
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
dataDir: dataDir,
|
|
filesDir: filesDir,
|
|
db: db,
|
|
logger: logger,
|
|
storage: storage,
|
|
}, nil
|
|
}
|
|
|
|
func (s *UploadService) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *UploadService) DB() *bbolt.DB {
|
|
return s.db
|
|
}
|
|
|
|
func (s *UploadService) MaxUploadSize() int64 {
|
|
return s.maxUploadSize
|
|
}
|
|
|
|
func (s *UploadService) MaxUploadSizeLabel() string {
|
|
return helpers.FormatBytes(s.maxUploadSize)
|
|
}
|
|
|
|
func (s *UploadService) Storage() *StorageService {
|
|
return s.storage
|
|
}
|
|
|
|
func (s *UploadService) ValidateSize(size int64) error {
|
|
if size > s.maxUploadSize {
|
|
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
|
return s.CreateBoxFromIncoming(multipartIncomingFiles(files), opts)
|
|
}
|
|
|
|
func (s *UploadService) CreateBoxFromIncoming(files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
|
return s.CreateBoxFromIncomingContext(context.Background(), files, opts)
|
|
}
|
|
|
|
func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
|
if len(files) == 0 {
|
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
|
}
|
|
now := time.Now().UTC()
|
|
var expiresAt time.Time
|
|
switch {
|
|
case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
|
|
// "Forever" — a date far enough out that the box effectively never
|
|
// expires. No schema change; CanDownload/cleanup keep working as-is.
|
|
expiresAt = now.AddDate(100, 0, 0)
|
|
case opts.ExpiresInMinutes > 0:
|
|
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
|
|
default:
|
|
days := opts.MaxDays
|
|
if days <= 0 {
|
|
days = 7
|
|
}
|
|
expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
|
|
}
|
|
|
|
box := Box{
|
|
ID: randomID(10),
|
|
OwnerID: strings.TrimSpace(opts.OwnerID),
|
|
CollectionID: strings.TrimSpace(opts.CollectionID),
|
|
CreatorIP: strings.TrimSpace(opts.CreatorIP),
|
|
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
|
|
CreatedAt: now,
|
|
ExpiresAt: expiresAt,
|
|
MaxDownloads: opts.MaxDownloads,
|
|
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
|
Files: make([]File, 0, len(files)),
|
|
}
|
|
deleteToken := randomID(32)
|
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
|
if strings.TrimSpace(opts.PasswordHash) != "" {
|
|
box.PasswordSalt = opts.PasswordSalt
|
|
box.PasswordHash = opts.PasswordHash
|
|
} else if strings.TrimSpace(opts.Password) != "" {
|
|
salt, hash := hashPassword(opts.Password)
|
|
box.PasswordSalt = salt
|
|
box.PasswordHash = hash
|
|
}
|
|
|
|
if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
|
|
if err := s.SaveBox(box); err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
|
|
s.logger.Info("upload complete",
|
|
"source", "user-upload",
|
|
"severity", "user_activity",
|
|
"code", 2001,
|
|
"box_id", box.ID,
|
|
"file_count", len(box.Files),
|
|
)
|
|
|
|
return s.resultForBox(box, deleteToken), nil
|
|
}
|
|
|
|
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
|
|
// selection into a single box). The box keeps its original expiry, password and
|
|
// other settings; only the new files are written.
|
|
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
|
return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts)
|
|
}
|
|
|
|
func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
|
if len(files) == 0 {
|
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
|
}
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
if err := s.writeIncomingFilesToBox(context.Background(), &box, files, opts); err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
if err := s.SaveBox(box); err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
s.logger.Info("upload appended",
|
|
"source", "user-upload",
|
|
"severity", "user_activity",
|
|
"code", 2001,
|
|
"box_id", box.ID,
|
|
"added", len(files),
|
|
"file_count", len(box.Files),
|
|
)
|
|
return s.resultForBox(box, ""), nil
|
|
}
|
|
|
|
// writeFilesToBox streams each uploaded file into the box's storage backend and
|
|
// appends the file metadata to box.Files. The box's StorageBackendID determines
|
|
// where files land, so it works for both new and existing boxes.
|
|
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
|
|
return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts)
|
|
}
|
|
|
|
func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile {
|
|
incoming := make([]IncomingFile, 0, len(files))
|
|
for _, file := range files {
|
|
incoming = append(incoming, multipartIncomingFile{header: file})
|
|
}
|
|
return incoming
|
|
}
|
|
|
|
func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error {
|
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, incoming := range files {
|
|
if !opts.SkipSizeLimit {
|
|
if err := s.ValidateSize(incoming.Size()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
maxSize := s.maxUploadSize
|
|
if opts.SkipSizeLimit {
|
|
maxSize = 0
|
|
}
|
|
|
|
file, err := incoming.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fileID := randomID(8)
|
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
|
objectKey := boxObjectKey(box.ID, storedName)
|
|
contentType := incoming.ContentType()
|
|
if contentType == "" || contentType == "application/octet-stream" {
|
|
buffer := make([]byte, 512)
|
|
n, _ := file.Read(buffer)
|
|
contentType = http.DetectContentType(buffer[:n])
|
|
if seeker, ok := file.(io.Seeker); ok {
|
|
_, _ = seeker.Seek(0, io.SeekStart)
|
|
}
|
|
}
|
|
|
|
if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
|
|
file.Close()
|
|
_ = backend.Delete(context.Background(), objectKey)
|
|
return err
|
|
}
|
|
file.Close()
|
|
|
|
box.Files = append(box.Files, File{
|
|
ID: fileID,
|
|
Name: filepath.Base(incoming.Name()),
|
|
StoredName: storedName,
|
|
Size: incoming.Size(),
|
|
ContentType: contentType,
|
|
PreviewKind: previewKind(contentType),
|
|
ObjectKey: objectKey,
|
|
UploadedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) GetBox(id string) (Box, error) {
|
|
var box Box
|
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
|
data := tx.Bucket(boxesBucket).Get([]byte(id))
|
|
if data == nil {
|
|
return os.ErrNotExist
|
|
}
|
|
return json.Unmarshal(data, &box)
|
|
})
|
|
if err != nil {
|
|
return Box{}, err
|
|
}
|
|
return box, nil
|
|
}
|
|
|
|
func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
|
|
boxes := make([]Box, 0)
|
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
|
cursor := tx.Bucket(boxesBucket).Cursor()
|
|
for key, value := cursor.Last(); key != nil; key, value = cursor.Prev() {
|
|
var box Box
|
|
if err := json.Unmarshal(value, &box); err != nil {
|
|
return err
|
|
}
|
|
boxes = append(boxes, box)
|
|
if limit > 0 && len(boxes) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return boxes, err
|
|
}
|
|
|
|
func (s *UploadService) ActiveBoxCountForUser(userID string) (int, error) {
|
|
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == userID })
|
|
}
|
|
|
|
func (s *UploadService) ActiveBoxCountForIP(ip string) (int, error) {
|
|
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == "" && box.CreatorIP == ip })
|
|
}
|
|
|
|
func (s *UploadService) activeBoxCount(match func(Box) bool) (int, error) {
|
|
boxes, err := s.ListBoxes(0)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
now := time.Now().UTC()
|
|
count := 0
|
|
for _, box := range boxes {
|
|
if match(box) && box.ExpiresAt.After(now) {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (s *UploadService) AdminStats() (AdminStats, error) {
|
|
boxes, err := s.ListBoxes(0)
|
|
if err != nil {
|
|
return AdminStats{}, err
|
|
}
|
|
|
|
var stats AdminStats
|
|
cutoff := time.Now().UTC().Add(-24 * time.Hour)
|
|
now := time.Now().UTC()
|
|
for _, box := range boxes {
|
|
stats.TotalBoxes++
|
|
stats.TotalDownloads += box.DownloadCount
|
|
if box.CreatedAt.After(cutoff) {
|
|
stats.UploadsLast24H++
|
|
}
|
|
if box.ExpiresAt.Before(now) {
|
|
stats.ExpiredBoxes++
|
|
}
|
|
if s.IsProtected(box) {
|
|
stats.ProtectedBoxes++
|
|
}
|
|
for _, file := range box.Files {
|
|
stats.TotalFiles++
|
|
stats.TotalSize += file.Size
|
|
}
|
|
}
|
|
stats.TotalSizeLabel = helpers.FormatBytes(stats.TotalSize)
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
|
boxes, err := s.ListBoxes(limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
rows := make([]AdminBox, 0, len(boxes))
|
|
for _, box := range boxes {
|
|
var size int64
|
|
for _, file := range box.Files {
|
|
size += file.Size
|
|
}
|
|
rows = append(rows, AdminBox{
|
|
ID: box.ID,
|
|
OwnerID: box.OwnerID,
|
|
CreatedAt: box.CreatedAt,
|
|
ExpiresAt: box.ExpiresAt,
|
|
FileCount: len(box.Files),
|
|
TotalSize: size,
|
|
TotalSizeLabel: helpers.FormatBytes(size),
|
|
DownloadCount: box.DownloadCount,
|
|
MaxDownloads: box.MaxDownloads,
|
|
Protected: s.IsProtected(box),
|
|
Expired: box.ExpiresAt.Before(now),
|
|
})
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
|
|
boxes, err := s.ListBoxes(0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows := make([]UserBox, 0)
|
|
for _, box := range boxes {
|
|
if box.OwnerID != userID {
|
|
continue
|
|
}
|
|
var size int64
|
|
for _, file := range box.Files {
|
|
size += file.Size
|
|
}
|
|
rows = append(rows, UserBox{
|
|
Box: box,
|
|
CollectionName: collectionNames[box.CollectionID],
|
|
TotalSizeLabel: helpers.FormatBytes(size),
|
|
})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
|
|
})
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
|
|
return s.userStorageUsed(userID, false)
|
|
}
|
|
|
|
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
|
|
return s.userStorageUsed(userID, true)
|
|
}
|
|
|
|
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
|
|
boxes, err := s.ListBoxes(0)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var total int64
|
|
now := time.Now().UTC()
|
|
for _, box := range boxes {
|
|
if box.OwnerID != userID {
|
|
continue
|
|
}
|
|
if activeOnly && !box.ExpiresAt.After(now) {
|
|
continue
|
|
}
|
|
for _, file := range box.Files {
|
|
total += file.Size
|
|
}
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if box.OwnerID != userID {
|
|
return os.ErrPermission
|
|
}
|
|
box.Title = strings.TrimSpace(title)
|
|
return s.SaveBox(box)
|
|
}
|
|
|
|
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if box.OwnerID != userID {
|
|
return os.ErrPermission
|
|
}
|
|
box.CollectionID = strings.TrimSpace(collectionID)
|
|
return s.SaveBox(box)
|
|
}
|
|
|
|
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if box.OwnerID != userID {
|
|
return os.ErrPermission
|
|
}
|
|
return s.DeleteBoxWithSource(boxID, "user-delete")
|
|
}
|
|
|
|
func (s *UploadService) DeleteBox(boxID string) error {
|
|
return s.DeleteBoxWithSource(boxID, "admin")
|
|
}
|
|
|
|
func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
|
|
backendID = normalizeBackendID(backendID)
|
|
if backendID == StorageBackendLocal {
|
|
return 0, fmt.Errorf("local storage cannot be deleted")
|
|
}
|
|
boxes, err := s.ListBoxes(0)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
deleted := 0
|
|
for _, box := range boxes {
|
|
if s.BoxStorageBackendID(box) != backendID {
|
|
continue
|
|
}
|
|
if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
|
|
return deleted, err
|
|
}
|
|
deleted++
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.VerifyDeleteToken(box, token) {
|
|
return os.ErrPermission
|
|
}
|
|
return s.DeleteBoxWithSource(boxID, "anonymous-delete")
|
|
}
|
|
|
|
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
|
box, _ := s.GetBox(boxID)
|
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
|
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if box.ID != "" {
|
|
backendID := s.BoxStorageBackendID(box)
|
|
backend, err := s.storage.Backend(backendID)
|
|
if err != nil {
|
|
backend, err = s.storage.BackendForMaintenance(backendID)
|
|
}
|
|
if err == nil {
|
|
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
|
|
return nil
|
|
}
|
|
|
|
// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and
|
|
// removes it from the box. If it was the box's last file, the whole box is
|
|
// deleted. Returns whether the box itself was removed.
|
|
func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
index := -1
|
|
for i, file := range box.Files {
|
|
if file.ID == fileID {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
if index < 0 {
|
|
return false, os.ErrNotExist
|
|
}
|
|
file := box.Files[index]
|
|
|
|
backendID := s.BoxStorageBackendID(box)
|
|
backend, err := s.storage.Backend(backendID)
|
|
if err != nil {
|
|
backend, err = s.storage.BackendForMaintenance(backendID)
|
|
}
|
|
if err == nil {
|
|
if key := s.FileObjectKey(box, file); key != "" {
|
|
_ = backend.Delete(context.Background(), key)
|
|
}
|
|
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
|
_ = backend.Delete(context.Background(), key)
|
|
}
|
|
}
|
|
|
|
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
|
if len(box.Files) == 0 {
|
|
if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
if err := s.SaveBox(box); err != nil {
|
|
return false, err
|
|
}
|
|
s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID)
|
|
return false, nil
|
|
}
|
|
|
|
// AdminUpdateBox lets an admin change a box's expiry, download limit, and
|
|
// optionally clear password protection.
|
|
func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error {
|
|
box, err := s.GetBox(boxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !expiresAt.IsZero() {
|
|
box.ExpiresAt = expiresAt.UTC()
|
|
}
|
|
if maxDownloads < 0 {
|
|
maxDownloads = 0
|
|
}
|
|
box.MaxDownloads = maxDownloads
|
|
if removePassword {
|
|
box.PasswordHash = ""
|
|
box.PasswordSalt = ""
|
|
box.Obfuscate = false
|
|
}
|
|
if err := s.SaveBox(box); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID)
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
|
|
for _, file := range box.Files {
|
|
if file.ID == fileID {
|
|
return file, nil
|
|
}
|
|
}
|
|
return File{}, os.ErrNotExist
|
|
}
|
|
|
|
func (s *UploadService) FilePath(box Box, file File) string {
|
|
return filepath.Join(s.filesDir, box.ID, file.StoredName)
|
|
}
|
|
|
|
func (s *UploadService) ThumbnailPath(box Box, file File) string {
|
|
if file.Thumbnail == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(s.filesDir, box.ID, file.Thumbnail)
|
|
}
|
|
|
|
func (s *UploadService) BoxMetadataPath(box Box) string {
|
|
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
|
|
}
|
|
|
|
func (s *UploadService) BoxStorageBackendID(box Box) string {
|
|
return normalizeBackendID(box.StorageBackendID)
|
|
}
|
|
|
|
func (s *UploadService) FileObjectKey(box Box, file File) string {
|
|
if file.ObjectKey != "" {
|
|
return file.ObjectKey
|
|
}
|
|
return boxObjectKey(box.ID, file.StoredName)
|
|
}
|
|
|
|
func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
|
if file.ThumbnailObjectKey != "" {
|
|
return file.ThumbnailObjectKey
|
|
}
|
|
if file.Thumbnail == "" {
|
|
return ""
|
|
}
|
|
return boxObjectKey(box.ID, file.Thumbnail)
|
|
}
|
|
|
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
|
if file.Processing {
|
|
return StorageObject{}, fmt.Errorf("file is still processing")
|
|
}
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
|
if err != nil {
|
|
return StorageObject{}, err
|
|
}
|
|
return backend.Get(ctx, s.FileObjectKey(box, file))
|
|
}
|
|
|
|
func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
|
key := s.ThumbnailObjectKey(box, file)
|
|
if key == "" {
|
|
return StorageObject{}, os.ErrNotExist
|
|
}
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
|
if err != nil {
|
|
return StorageObject{}, err
|
|
}
|
|
return backend.Get(ctx, key)
|
|
}
|
|
|
|
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
key := boxObjectKey(box.ID, name)
|
|
return key, backend.Put(ctx, key, body, size, contentType)
|
|
}
|
|
|
|
func (s *UploadService) IsProtected(box Box) bool {
|
|
return box.PasswordHash != "" && box.PasswordSalt != ""
|
|
}
|
|
|
|
func (s *UploadService) VerifyPassword(box Box, password string) bool {
|
|
if !s.IsProtected(box) {
|
|
return true
|
|
}
|
|
hash := passwordHash(box.PasswordSalt, password)
|
|
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.PasswordHash)) == 1
|
|
}
|
|
|
|
func (s *UploadService) UnlockToken(box Box) string {
|
|
sum := sha256.Sum256([]byte(box.ID + ":" + box.PasswordHash))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s *UploadService) VerifyDeleteToken(box Box, token string) bool {
|
|
if box.DeleteTokenHash == "" || strings.TrimSpace(token) == "" {
|
|
return false
|
|
}
|
|
hash := deleteTokenHash(box.ID, token)
|
|
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.DeleteTokenHash)) == 1
|
|
}
|
|
|
|
func (s *UploadService) CanDownload(box Box) error {
|
|
if time.Now().UTC().After(box.ExpiresAt) {
|
|
return fmt.Errorf("box has expired")
|
|
}
|
|
if box.MaxDownloads > 0 && box.DownloadCount >= box.MaxDownloads {
|
|
return fmt.Errorf("download limit reached")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) RecordDownload(boxID string) error {
|
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
|
bucket := tx.Bucket(boxesBucket)
|
|
data := bucket.Get([]byte(boxID))
|
|
if data == nil {
|
|
return os.ErrNotExist
|
|
}
|
|
|
|
var box Box
|
|
if err := json.Unmarshal(data, &box); err != nil {
|
|
return err
|
|
}
|
|
box.DownloadCount++
|
|
|
|
next, err := json.Marshal(box)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := bucket.Put([]byte(boxID), next); err != nil {
|
|
return err
|
|
}
|
|
return s.writeBoxMetadata(box)
|
|
})
|
|
}
|
|
|
|
func (s *UploadService) WriteZip(w io.Writer, box Box) error {
|
|
archive := zip.NewWriter(w)
|
|
defer archive.Close()
|
|
|
|
for _, file := range box.Files {
|
|
object, err := s.OpenFileObject(context.Background(), box, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
source := object.Body
|
|
|
|
header := &zip.FileHeader{
|
|
Name: file.Name,
|
|
Method: zip.Deflate,
|
|
Modified: file.UploadedAt,
|
|
}
|
|
target, err := archive.CreateHeader(header)
|
|
if err != nil {
|
|
source.Close()
|
|
return err
|
|
}
|
|
|
|
if _, err := io.Copy(target, source); err != nil {
|
|
source.Close()
|
|
return err
|
|
}
|
|
source.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) SaveBox(box Box) error {
|
|
if err := s.saveBoxRecord(box); err != nil {
|
|
return err
|
|
}
|
|
return s.writeBoxMetadata(box)
|
|
}
|
|
|
|
func (s *UploadService) saveBoxRecord(box Box) error {
|
|
if box.StorageBackendID == "" {
|
|
box.StorageBackendID = StorageBackendLocal
|
|
}
|
|
data, err := json.Marshal(box)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
|
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
|
|
})
|
|
}
|
|
|
|
func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
|
files := make([]ResultFile, 0, len(box.Files))
|
|
for _, file := range box.Files {
|
|
files = append(files, ResultFile{
|
|
ID: file.ID,
|
|
Name: file.Name,
|
|
Size: helpers.FormatBytes(file.Size),
|
|
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
|
|
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
|
|
Processing: file.Processing,
|
|
})
|
|
}
|
|
|
|
// The box-level thumbnail points at the most recently added file, so a
|
|
// per-file ShareX upload previews the file it just sent.
|
|
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
|
|
if len(files) > 0 {
|
|
thumbnailURL = files[len(files)-1].ThumbnailURL
|
|
}
|
|
|
|
result := UploadResult{
|
|
BoxID: box.ID,
|
|
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
|
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
|
ThumbnailURL: thumbnailURL,
|
|
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
|
Files: files,
|
|
}
|
|
if deleteToken != "" {
|
|
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
|
result.DeleteURL = fmt.Sprintf("%s/d/%s/manage/%s/delete", s.baseURL, box.ID, deleteToken)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func writeUploadedFile(path string, source multipart.File, maxSize int64) error {
|
|
target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer target.Close()
|
|
|
|
var written int64
|
|
if maxSize <= 0 {
|
|
written, err = io.Copy(target, source)
|
|
} else {
|
|
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
|
|
}
|
|
if err != nil {
|
|
os.Remove(path)
|
|
return err
|
|
}
|
|
if maxSize > 0 && written > maxSize {
|
|
os.Remove(path)
|
|
return fmt.Errorf("file exceeds max upload size")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source io.Reader, size, maxSize int64, contentType string) error {
|
|
var reader io.Reader = source
|
|
putSize := size
|
|
if maxSize > 0 {
|
|
if size > maxSize {
|
|
return fmt.Errorf("file exceeds max upload size")
|
|
}
|
|
reader = io.LimitReader(source, maxSize)
|
|
putSize = size
|
|
}
|
|
if ctx != nil {
|
|
reader = contextReader{ctx: ctx, reader: reader}
|
|
}
|
|
return backend.Put(ctx, key, reader, putSize, contentType)
|
|
}
|
|
|
|
type contextReader struct {
|
|
ctx context.Context
|
|
reader io.Reader
|
|
}
|
|
|
|
func (r contextReader) Read(p []byte) (int, error) {
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return 0, r.ctx.Err()
|
|
default:
|
|
return r.reader.Read(p)
|
|
}
|
|
}
|
|
|
|
func boxObjectKey(boxID, name string) string {
|
|
return filepath.ToSlash(filepath.Join(boxID, name))
|
|
}
|
|
|
|
func randomID(byteCount int) string {
|
|
data := make([]byte, byteCount)
|
|
if _, err := rand.Read(data); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
}
|
|
|
|
func RandomPublicToken(byteCount int) string {
|
|
return randomID(byteCount)
|
|
}
|
|
|
|
func hashPassword(password string) (string, string) {
|
|
salt := randomID(18)
|
|
return salt, passwordHash(salt, password)
|
|
}
|
|
|
|
func passwordHash(salt, password string) string {
|
|
sum := sha256.Sum256([]byte(salt + ":" + password))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func deleteTokenHash(boxID, token string) string {
|
|
sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func previewKind(contentType string) string {
|
|
switch {
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
return "image"
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
return "video"
|
|
case strings.HasPrefix(contentType, "audio/"):
|
|
return "audio"
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func (s *UploadService) writeBoxMetadata(box Box) error {
|
|
data, err := json.MarshalIndent(box, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return backend.Put(context.Background(), boxObjectKey(box.ID, ".warpbox.box.json"), bytes.NewReader(data), int64(len(data)), "application/json")
|
|
}
|