2026-05-25 15:36:49 +03:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-25 16:26:47 +03:00
|
|
|
"archive/zip"
|
2026-05-31 02:14:10 +03:00
|
|
|
"bytes"
|
|
|
|
|
"context"
|
2026-05-25 16:26:47 +03:00
|
|
|
"crypto/rand"
|
2026-05-25 16:52:57 +03:00
|
|
|
"crypto/sha256"
|
|
|
|
|
"crypto/subtle"
|
2026-05-25 16:26:47 +03:00
|
|
|
"encoding/base64"
|
2026-05-25 16:52:57 +03:00
|
|
|
"encoding/hex"
|
2026-05-25 16:26:47 +03:00
|
|
|
"encoding/json"
|
2026-05-25 15:36:49 +03:00
|
|
|
"fmt"
|
2026-05-25 16:26:47 +03:00
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"mime/multipart"
|
2026-05-31 02:14:10 +03:00
|
|
|
"net/http"
|
2026-05-25 16:26:47 +03:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-05-30 15:42:35 +03:00
|
|
|
"sort"
|
2026-05-25 16:26:47 +03:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"go.etcd.io/bbolt"
|
2026-05-25 15:36:49 +03:00
|
|
|
"warpbox.dev/backend/libs/helpers"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
var boxesBucket = []byte("boxes")
|
|
|
|
|
|
2026-05-25 15:36:49 +03:00
|
|
|
type UploadService struct {
|
|
|
|
|
maxUploadSize int64
|
2026-05-25 16:26:47 +03:00
|
|
|
baseURL string
|
|
|
|
|
dataDir string
|
|
|
|
|
filesDir string
|
|
|
|
|
db *bbolt.DB
|
|
|
|
|
logger *slog.Logger
|
2026-05-31 02:14:10 +03:00
|
|
|
storage *StorageService
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type UploadOptions struct {
|
2026-05-25 16:52:57 +03:00
|
|
|
MaxDays int
|
2026-05-31 15:30:53 +03:00
|
|
|
ExpiresInMinutes int
|
2026-05-25 16:52:57 +03:00
|
|
|
MaxDownloads int
|
|
|
|
|
Password string
|
2026-06-02 17:41:41 +03:00
|
|
|
PasswordSalt string
|
|
|
|
|
PasswordHash string
|
2026-05-25 16:52:57 +03:00
|
|
|
ObfuscateMetadata bool
|
2026-05-30 15:42:35 +03:00
|
|
|
OwnerID string
|
|
|
|
|
CollectionID string
|
|
|
|
|
SkipSizeLimit bool
|
2026-05-31 02:14:10 +03:00
|
|
|
CreatorIP string
|
|
|
|
|
StorageBackendID string
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
type Box struct {
|
2026-05-31 02:14:10 +03:00
|
|
|
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"`
|
2026-06-08 11:53:37 +03:00
|
|
|
Trouble bool `json:"trouble,omitempty"`
|
|
|
|
|
TroubleReason string `json:"troubleReason,omitempty"`
|
2026-05-31 02:14:10 +03:00
|
|
|
Files []File `json:"files"`
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type File struct {
|
2026-06-05 10:42:30 +03:00
|
|
|
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"`
|
|
|
|
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
2026-06-08 03:43:43 +03:00
|
|
|
ArchiveListing string `json:"archiveListing,omitempty"`
|
2026-06-05 10:42:30 +03:00
|
|
|
ObjectKey string `json:"objectKey,omitempty"`
|
|
|
|
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
|
|
|
|
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
2026-06-08 03:43:43 +03:00
|
|
|
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
2026-06-05 10:42:30 +03:00
|
|
|
Processing bool `json:"processing,omitempty"`
|
|
|
|
|
ProcessingError string `json:"processingError,omitempty"`
|
|
|
|
|
UploadedAt time.Time `json:"uploadedAt"`
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:53:37 +03:00
|
|
|
func BoxHasTrouble(box Box) bool {
|
|
|
|
|
if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
for _, file := range box.Files {
|
|
|
|
|
if FileHasTrouble(file) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func BoxTroubleReason(box Box) string {
|
|
|
|
|
if strings.TrimSpace(box.TroubleReason) != "" {
|
|
|
|
|
return box.TroubleReason
|
|
|
|
|
}
|
|
|
|
|
for _, file := range box.Files {
|
|
|
|
|
if strings.TrimSpace(file.ProcessingError) != "" {
|
|
|
|
|
return file.ProcessingError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if box.Trouble {
|
|
|
|
|
return "box has failed processing"
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func FileHasTrouble(file File) bool {
|
|
|
|
|
return strings.TrimSpace(file.ProcessingError) != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
type UploadResult struct {
|
2026-05-31 22:27:43 +03:00
|
|
|
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"`
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ResultFile struct {
|
2026-05-31 22:27:43 +03:00
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Size string `json:"size"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
2026-06-02 22:13:54 +03:00
|
|
|
Processing bool `json:"processing,omitempty"`
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
type AdminStats struct {
|
|
|
|
|
TotalBoxes int
|
|
|
|
|
TotalFiles int
|
|
|
|
|
TotalSize int64
|
|
|
|
|
UploadsLast24H int
|
|
|
|
|
ExpiredBoxes int
|
|
|
|
|
ProtectedBoxes int
|
|
|
|
|
TotalDownloads int
|
|
|
|
|
TotalSizeLabel string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AdminBox struct {
|
|
|
|
|
ID string
|
2026-05-30 15:42:35 +03:00
|
|
|
OwnerID string
|
2026-05-25 16:52:57 +03:00
|
|
|
CreatedAt time.Time
|
|
|
|
|
ExpiresAt time.Time
|
|
|
|
|
FileCount int
|
|
|
|
|
TotalSize int64
|
|
|
|
|
TotalSizeLabel string
|
|
|
|
|
DownloadCount int
|
|
|
|
|
MaxDownloads int
|
|
|
|
|
Protected bool
|
|
|
|
|
Expired bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
type UserBox struct {
|
|
|
|
|
Box Box
|
|
|
|
|
CollectionName string
|
|
|
|
|
TotalSizeLabel string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-02 11:30:33 +03:00
|
|
|
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
storage, err := NewStorageService(db, dataDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
db.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
return &UploadService{
|
|
|
|
|
maxUploadSize: maxUploadSize,
|
|
|
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
|
|
|
dataDir: dataDir,
|
|
|
|
|
filesDir: filesDir,
|
|
|
|
|
db: db,
|
|
|
|
|
logger: logger,
|
2026-05-31 02:14:10 +03:00
|
|
|
storage: storage,
|
2026-05-25 16:26:47 +03:00
|
|
|
}, nil
|
2026-05-25 15:36:49 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
func (s *UploadService) Close() error {
|
|
|
|
|
return s.db.Close()
|
2026-05-25 15:36:49 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
func (s *UploadService) DB() *bbolt.DB {
|
|
|
|
|
return s.db
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 15:36:49 +03:00
|
|
|
func (s *UploadService) MaxUploadSize() int64 {
|
|
|
|
|
return s.maxUploadSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) MaxUploadSizeLabel() string {
|
|
|
|
|
return helpers.FormatBytes(s.maxUploadSize)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
func (s *UploadService) Storage() *StorageService {
|
|
|
|
|
return s.storage
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 15:36:49 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
2026-06-02 17:41:41 +03:00
|
|
|
return s.CreateBoxFromIncoming(multipartIncomingFiles(files), opts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) CreateBoxFromIncoming(files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
2026-06-02 22:13:54 +03:00
|
|
|
return s.CreateBoxFromIncomingContext(context.Background(), files, opts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
2026-05-25 16:26:47 +03:00
|
|
|
if len(files) == 0 {
|
|
|
|
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
|
|
|
|
}
|
2026-05-31 15:30:53 +03:00
|
|
|
now := time.Now().UTC()
|
2026-05-31 22:40:48 +03:00
|
|
|
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:
|
2026-05-31 15:30:53 +03:00
|
|
|
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
|
2026-05-31 22:40:48 +03:00
|
|
|
default:
|
|
|
|
|
days := opts.MaxDays
|
|
|
|
|
if days <= 0 {
|
|
|
|
|
days = 7
|
|
|
|
|
}
|
|
|
|
|
expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
|
2026-05-31 15:30:53 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
box := Box{
|
2026-05-31 02:14:10 +03:00
|
|
|
ID: randomID(10),
|
|
|
|
|
OwnerID: strings.TrimSpace(opts.OwnerID),
|
|
|
|
|
CollectionID: strings.TrimSpace(opts.CollectionID),
|
|
|
|
|
CreatorIP: strings.TrimSpace(opts.CreatorIP),
|
|
|
|
|
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
|
2026-05-31 15:30:53 +03:00
|
|
|
CreatedAt: now,
|
|
|
|
|
ExpiresAt: expiresAt,
|
2026-05-31 02:14:10 +03:00
|
|
|
MaxDownloads: opts.MaxDownloads,
|
|
|
|
|
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
|
|
|
|
Files: make([]File, 0, len(files)),
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
2026-05-29 23:44:05 +03:00
|
|
|
deleteToken := randomID(32)
|
|
|
|
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
2026-06-02 17:41:41 +03:00
|
|
|
if strings.TrimSpace(opts.PasswordHash) != "" {
|
|
|
|
|
box.PasswordSalt = opts.PasswordSalt
|
|
|
|
|
box.PasswordHash = opts.PasswordHash
|
|
|
|
|
} else if strings.TrimSpace(opts.Password) != "" {
|
2026-05-25 16:52:57 +03:00
|
|
|
salt, hash := hashPassword(opts.Password)
|
|
|
|
|
box.PasswordSalt = salt
|
|
|
|
|
box.PasswordHash = hash
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
2026-06-02 22:13:54 +03:00
|
|
|
if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil {
|
2026-05-31 22:27:43 +03:00
|
|
|
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) {
|
2026-06-02 17:41:41 +03:00
|
|
|
return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
2026-05-31 22:27:43 +03:00
|
|
|
if len(files) == 0 {
|
|
|
|
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
|
|
|
|
}
|
|
|
|
|
box, err := s.GetBox(boxID)
|
2026-05-31 02:14:10 +03:00
|
|
|
if err != nil {
|
2026-05-25 16:26:47 +03:00
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
2026-06-02 22:13:54 +03:00
|
|
|
if err := s.writeIncomingFilesToBox(context.Background(), &box, files, opts); err != nil {
|
2026-05-31 22:27:43 +03:00
|
|
|
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 {
|
2026-06-02 22:13:54 +03:00
|
|
|
return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts)
|
2026-06-02 17:41:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile {
|
|
|
|
|
incoming := make([]IncomingFile, 0, len(files))
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
incoming = append(incoming, multipartIncomingFile{header: file})
|
|
|
|
|
}
|
|
|
|
|
return incoming
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 22:13:54 +03:00
|
|
|
func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error {
|
2026-05-31 22:27:43 +03:00
|
|
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
for _, incoming := range files {
|
2026-05-30 15:42:35 +03:00
|
|
|
if !opts.SkipSizeLimit {
|
2026-06-02 17:41:41 +03:00
|
|
|
if err := s.ValidateSize(incoming.Size()); err != nil {
|
2026-05-31 22:27:43 +03:00
|
|
|
return err
|
2026-05-30 15:42:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
maxSize := s.maxUploadSize
|
|
|
|
|
if opts.SkipSizeLimit {
|
|
|
|
|
maxSize = 0
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
file, err := incoming.Open()
|
2026-05-25 16:26:47 +03:00
|
|
|
if err != nil {
|
2026-05-31 22:27:43 +03:00
|
|
|
return err
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileID := randomID(8)
|
2026-06-02 17:41:41 +03:00
|
|
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
2026-05-31 02:14:10 +03:00
|
|
|
objectKey := boxObjectKey(box.ID, storedName)
|
2026-06-02 17:41:41 +03:00
|
|
|
contentType := incoming.ContentType()
|
2026-06-03 15:31:18 +03:00
|
|
|
if contentType == "" || contentType == "application/octet-stream" {
|
2026-05-31 02:14:10 +03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 22:13:54 +03:00
|
|
|
if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
|
2026-05-25 16:26:47 +03:00
|
|
|
file.Close()
|
2026-06-02 22:13:54 +03:00
|
|
|
_ = backend.Delete(context.Background(), objectKey)
|
2026-05-31 22:27:43 +03:00
|
|
|
return err
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
file.Close()
|
|
|
|
|
|
|
|
|
|
box.Files = append(box.Files, File{
|
|
|
|
|
ID: fileID,
|
2026-06-02 17:41:41 +03:00
|
|
|
Name: filepath.Base(incoming.Name()),
|
2026-05-25 16:26:47 +03:00
|
|
|
StoredName: storedName,
|
2026-06-02 17:41:41 +03:00
|
|
|
Size: incoming.Size(),
|
2026-05-25 16:26:47 +03:00
|
|
|
ContentType: contentType,
|
2026-05-25 16:52:57 +03:00
|
|
|
PreviewKind: previewKind(contentType),
|
2026-05-31 02:14:10 +03:00
|
|
|
ObjectKey: objectKey,
|
2026-05-25 16:26:47 +03:00
|
|
|
UploadedAt: time.Now().UTC(),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-31 22:27:43 +03:00
|
|
|
return nil
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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,
|
2026-05-30 15:42:35 +03:00
|
|
|
OwnerID: box.OwnerID,
|
2026-05-25 16:52:57 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
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) {
|
2026-05-30 17:23:20 +03:00
|
|
|
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) {
|
2026-05-30 15:42:35 +03:00
|
|
|
boxes, err := s.ListBoxes(0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
var total int64
|
2026-05-30 17:23:20 +03:00
|
|
|
now := time.Now().UTC()
|
2026-05-30 15:42:35 +03:00
|
|
|
for _, box := range boxes {
|
|
|
|
|
if box.OwnerID != userID {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-05-30 17:23:20 +03:00
|
|
|
if activeOnly && !box.ExpiresAt.After(now) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-05-30 15:42:35 +03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
func (s *UploadService) DeleteBox(boxID string) error {
|
2026-05-29 22:25:59 +03:00
|
|
|
return s.DeleteBoxWithSource(boxID, "admin")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:24:51 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:25:59 +03:00
|
|
|
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
2026-05-31 02:14:10 +03:00
|
|
|
box, _ := s.GetBox(boxID)
|
2026-05-25 16:52:57 +03:00
|
|
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
|
|
|
|
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
if box.ID != "" {
|
2026-06-01 02:24:51 +03:00
|
|
|
backendID := s.BoxStorageBackendID(box)
|
|
|
|
|
backend, err := s.storage.Backend(backendID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
backend, err = s.storage.BackendForMaintenance(backendID)
|
|
|
|
|
}
|
|
|
|
|
if err == nil {
|
2026-05-31 02:14:10 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
|
2026-05-25 16:52:57 +03:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 03:39:45 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-06-05 10:42:30 +03:00
|
|
|
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
|
|
|
|
|
_ = backend.Delete(context.Background(), key)
|
|
|
|
|
}
|
2026-06-08 03:43:43 +03:00
|
|
|
if key := s.ArchiveListingObjectKey(box, file); key != "" {
|
|
|
|
|
_ = backend.Delete(context.Background(), key)
|
|
|
|
|
}
|
2026-06-01 03:39:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
|
|
|
|
|
if file.SceneThumbnailObjectKey != "" {
|
|
|
|
|
return file.SceneThumbnailObjectKey
|
|
|
|
|
}
|
|
|
|
|
if file.SceneThumbnail == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return boxObjectKey(box.ID, file.SceneThumbnail)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:43:43 +03:00
|
|
|
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
|
|
|
|
|
if file.ArchiveListingObjectKey != "" {
|
|
|
|
|
return file.ArchiveListingObjectKey
|
|
|
|
|
}
|
|
|
|
|
if file.ArchiveListing == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return boxObjectKey(box.ID, file.ArchiveListing)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
2026-06-02 22:13:54 +03:00
|
|
|
if file.Processing {
|
|
|
|
|
return StorageObject{}, fmt.Errorf("file is still processing")
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
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))
|
2026-06-05 10:42:30 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return StorageObject{}, err
|
|
|
|
|
}
|
|
|
|
|
return backend.Get(ctx, key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
|
|
|
|
key := s.SceneThumbnailObjectKey(box, file)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return StorageObject{}, os.ErrNotExist
|
|
|
|
|
}
|
|
|
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
2026-06-08 03:43:43 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return StorageObject{}, err
|
|
|
|
|
}
|
|
|
|
|
return backend.Get(ctx, key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
|
|
|
|
key := s.ArchiveListingObjectKey(box, file)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return StorageObject{}, os.ErrNotExist
|
|
|
|
|
}
|
|
|
|
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
2026-05-31 02:14:10 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
if err := bucket.Put([]byte(boxID), next); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return s.writeBoxMetadata(box)
|
2026-05-25 16:26:47 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) WriteZip(w io.Writer, box Box) error {
|
|
|
|
|
archive := zip.NewWriter(w)
|
|
|
|
|
defer archive.Close()
|
|
|
|
|
|
|
|
|
|
for _, file := range box.Files {
|
2026-05-31 02:14:10 +03:00
|
|
|
object, err := s.OpenFileObject(context.Background(), box, file)
|
2026-05-25 16:26:47 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
source := object.Body
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:25:59 +03:00
|
|
|
func (s *UploadService) SaveBox(box Box) error {
|
2026-06-02 22:13:54 +03:00
|
|
|
if err := s.saveBoxRecord(box); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return s.writeBoxMetadata(box)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) saveBoxRecord(box Box) error {
|
2026-05-31 02:14:10 +03:00
|
|
|
if box.StorageBackendID == "" {
|
|
|
|
|
box.StorageBackendID = StorageBackendLocal
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
data, err := json.Marshal(box)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
2026-06-02 22:13:54 +03:00
|
|
|
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
|
2026-05-25 16:26:47 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
2026-05-25 16:26:47 +03:00
|
|
|
files := make([]ResultFile, 0, len(box.Files))
|
|
|
|
|
for _, file := range box.Files {
|
|
|
|
|
files = append(files, ResultFile{
|
2026-05-31 22:27:43 +03:00
|
|
|
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),
|
2026-06-02 22:13:54 +03:00
|
|
|
Processing: file.Processing,
|
2026-05-25 16:26:47 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:27:43 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
result := UploadResult{
|
2026-05-31 22:27:43 +03:00
|
|
|
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,
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
2026-05-29 23:44:05 +03:00
|
|
|
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
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
var written int64
|
|
|
|
|
if maxSize <= 0 {
|
|
|
|
|
written, err = io.Copy(target, source)
|
|
|
|
|
} else {
|
|
|
|
|
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
if err != nil {
|
|
|
|
|
os.Remove(path)
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-30 15:42:35 +03:00
|
|
|
if maxSize > 0 && written > maxSize {
|
2026-05-25 16:26:47 +03:00
|
|
|
os.Remove(path)
|
|
|
|
|
return fmt.Errorf("file exceeds max upload size")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source io.Reader, size, maxSize int64, contentType string) error {
|
2026-05-31 02:14:10 +03:00
|
|
|
var reader io.Reader = source
|
2026-06-02 17:41:41 +03:00
|
|
|
putSize := size
|
2026-05-31 02:14:10 +03:00
|
|
|
if maxSize > 0 {
|
2026-06-02 17:41:41 +03:00
|
|
|
if size > maxSize {
|
2026-05-31 02:14:10 +03:00
|
|
|
return fmt.Errorf("file exceeds max upload size")
|
|
|
|
|
}
|
2026-06-02 17:41:41 +03:00
|
|
|
reader = io.LimitReader(source, maxSize)
|
|
|
|
|
putSize = size
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-06-02 22:13:54 +03:00
|
|
|
if ctx != nil {
|
|
|
|
|
reader = contextReader{ctx: ctx, reader: reader}
|
|
|
|
|
}
|
2026-06-02 17:41:41 +03:00
|
|
|
return backend.Put(ctx, key, reader, putSize, contentType)
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 22:13:54 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
func boxObjectKey(boxID, name string) string {
|
|
|
|
|
return filepath.ToSlash(filepath.Join(boxID, name))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
|
2026-06-02 11:30:33 +03:00
|
|
|
func RandomPublicToken(byteCount int) string {
|
|
|
|
|
return randomID(byteCount)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
func deleteTokenHash(boxID, token string) string {
|
|
|
|
|
sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token))
|
|
|
|
|
return hex.EncodeToString(sum[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
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")
|
2026-05-25 16:52:57 +03:00
|
|
|
}
|