2026-05-25 15:36:49 +03:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-25 16:26:47 +03:00
|
|
|
"archive/zip"
|
|
|
|
|
"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:52:57 +03:00
|
|
|
"image"
|
|
|
|
|
_ "image/gif"
|
|
|
|
|
"image/jpeg"
|
|
|
|
|
_ "image/jpeg"
|
|
|
|
|
_ "image/png"
|
2026-05-25 16:26:47 +03:00
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
"os"
|
2026-05-25 16:52:57 +03:00
|
|
|
"os/exec"
|
2026-05-25 16:26:47 +03:00
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"go.etcd.io/bbolt"
|
2026-05-25 16:52:57 +03:00
|
|
|
_ "golang.org/x/image/webp"
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type UploadOptions struct {
|
2026-05-25 16:52:57 +03:00
|
|
|
MaxDays int
|
|
|
|
|
MaxDownloads int
|
|
|
|
|
Password string
|
|
|
|
|
ObfuscateMetadata bool
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Box struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
|
|
|
MaxDownloads int `json:"maxDownloads"`
|
|
|
|
|
DownloadCount int `json:"downloadCount"`
|
2026-05-25 16:52:57 +03:00
|
|
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
|
|
|
|
PasswordHash string `json:"passwordHash,omitempty"`
|
|
|
|
|
Obfuscate bool `json:"obfuscate"`
|
2026-05-25 16:26:47 +03:00
|
|
|
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"`
|
2026-05-25 16:52:57 +03:00
|
|
|
PreviewKind string `json:"previewKind"`
|
|
|
|
|
Thumbnail string `json:"thumbnail,omitempty"`
|
2026-05-25 16:26:47 +03:00
|
|
|
UploadedAt time.Time `json:"uploadedAt"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type UploadResult struct {
|
|
|
|
|
BoxID string `json:"boxId"`
|
|
|
|
|
BoxURL string `json:"boxUrl"`
|
|
|
|
|
ZipURL string `json:"zipUrl"`
|
|
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
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 ThumbnailJobResult struct {
|
|
|
|
|
Scanned int
|
|
|
|
|
Generated int
|
|
|
|
|
Failed int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AdminBox struct {
|
|
|
|
|
ID string
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
ExpiresAt time.Time
|
|
|
|
|
FileCount int
|
|
|
|
|
TotalSize int64
|
|
|
|
|
TotalSizeLabel string
|
|
|
|
|
DownloadCount int
|
|
|
|
|
MaxDownloads int
|
|
|
|
|
Protected bool
|
|
|
|
|
Expired bool
|
|
|
|
|
}
|
|
|
|
|
|
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(filesDir, 0o755); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(dbDir, 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &UploadService{
|
|
|
|
|
maxUploadSize: maxUploadSize,
|
|
|
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
|
|
|
dataDir: dataDir,
|
|
|
|
|
filesDir: filesDir,
|
|
|
|
|
db: db,
|
|
|
|
|
logger: logger,
|
|
|
|
|
}, 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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) MaxUploadSize() int64 {
|
|
|
|
|
return s.maxUploadSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) MaxUploadSizeLabel() string {
|
|
|
|
|
return helpers.FormatBytes(s.maxUploadSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
if len(files) == 0 {
|
|
|
|
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
|
|
|
|
}
|
|
|
|
|
if opts.MaxDays <= 0 {
|
|
|
|
|
opts.MaxDays = 7
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
box := Box{
|
|
|
|
|
ID: randomID(10),
|
|
|
|
|
CreatedAt: time.Now().UTC(),
|
|
|
|
|
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
|
|
|
|
MaxDownloads: opts.MaxDownloads,
|
2026-05-25 16:52:57 +03:00
|
|
|
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
2026-05-25 16:26:47 +03:00
|
|
|
Files: make([]File, 0, len(files)),
|
|
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
if strings.TrimSpace(opts.Password) != "" {
|
|
|
|
|
salt, hash := hashPassword(opts.Password)
|
|
|
|
|
box.PasswordSalt = salt
|
|
|
|
|
box.PasswordHash = hash
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
boxDir := filepath.Join(s.filesDir, box.ID)
|
|
|
|
|
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, header := range files {
|
|
|
|
|
if err := s.ValidateSize(header.Size); err != nil {
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, err := header.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileID := randomID(8)
|
2026-05-25 16:52:57 +03:00
|
|
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
2026-05-25 16:26:47 +03:00
|
|
|
storedPath := filepath.Join(boxDir, storedName)
|
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
contentType = "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil {
|
|
|
|
|
file.Close()
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
file.Close()
|
|
|
|
|
|
|
|
|
|
box.Files = append(box.Files, File{
|
|
|
|
|
ID: fileID,
|
|
|
|
|
Name: filepath.Base(header.Filename),
|
|
|
|
|
StoredName: storedName,
|
|
|
|
|
Size: header.Size,
|
|
|
|
|
ContentType: contentType,
|
2026-05-25 16:52:57 +03:00
|
|
|
PreviewKind: previewKind(contentType),
|
2026-05-25 16:26:47 +03:00
|
|
|
UploadedAt: time.Now().UTC(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.saveBox(box); err != nil {
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.logger.Info("upload complete",
|
|
|
|
|
"source", "user-upload",
|
2026-05-25 16:52:57 +03:00
|
|
|
"severity", "user_activity",
|
2026-05-25 16:26:47 +03:00
|
|
|
"code", 2001,
|
|
|
|
|
"box_id", box.ID,
|
|
|
|
|
"file_count", len(box.Files),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return s.resultForBox(box), 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
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
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) DeleteBox(boxID string) error {
|
|
|
|
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
|
|
|
|
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.logger.Info("box deleted", "source", "admin", "severity", "user_activity", "code", 2101, "box_id", boxID)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) CleanupExpired() (int, error) {
|
|
|
|
|
boxes, err := s.ListBoxes(0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
cleaned := 0
|
|
|
|
|
for _, box := range boxes {
|
|
|
|
|
if box.ExpiresAt.After(now) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := s.DeleteBox(box.ID); err != nil {
|
|
|
|
|
return cleaned, err
|
|
|
|
|
}
|
|
|
|
|
cleaned++
|
|
|
|
|
}
|
|
|
|
|
if cleaned > 0 {
|
|
|
|
|
s.logger.Info("expired boxes cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2201, "cleaned", cleaned)
|
|
|
|
|
}
|
|
|
|
|
return cleaned, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) GenerateMissingThumbnails() (ThumbnailJobResult, error) {
|
|
|
|
|
boxes, err := s.ListBoxes(0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ThumbnailJobResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ThumbnailJobResult
|
|
|
|
|
for _, box := range boxes {
|
|
|
|
|
if time.Now().UTC().After(box.ExpiresAt) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
changed := false
|
|
|
|
|
for i := range box.Files {
|
|
|
|
|
file := &box.Files[i]
|
|
|
|
|
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
result.Scanned++
|
|
|
|
|
|
|
|
|
|
path := s.FilePath(box, *file)
|
|
|
|
|
thumbnail := s.generateThumbnail(box.ID, file.ID, path, file.ContentType)
|
|
|
|
|
if thumbnail == "" {
|
|
|
|
|
result.Failed++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file.Thumbnail = thumbnail
|
|
|
|
|
changed = true
|
|
|
|
|
result.Generated++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if changed {
|
|
|
|
|
if err := s.saveBox(box); err != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Generated > 0 || result.Failed > 0 {
|
|
|
|
|
s.logger.Info("thumbnail job complete",
|
|
|
|
|
"source", "thumbnail",
|
|
|
|
|
"severity", "user_activity",
|
|
|
|
|
"code", 2203,
|
|
|
|
|
"scanned", result.Scanned,
|
|
|
|
|
"generated", result.Generated,
|
|
|
|
|
"failed", result.Failed,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, 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")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-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 {
|
|
|
|
|
path := s.FilePath(box, file)
|
|
|
|
|
source, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
data, err := json.Marshal(box)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
2026-05-25 16:52:57 +03:00
|
|
|
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return s.writeBoxMetadata(box)
|
2026-05-25 16:26:47 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) resultForBox(box Box) 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),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 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),
|
|
|
|
|
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
|
|
|
|
Files: files,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
written, err := io.Copy(target, io.LimitReader(source, maxSize+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
os.Remove(path)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if written > maxSize {
|
|
|
|
|
os.Remove(path)
|
|
|
|
|
return fmt.Errorf("file exceeds max upload size")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
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 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 needsThumbnail(file File) bool {
|
|
|
|
|
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) generateThumbnail(boxID, fileID, path, contentType string) string {
|
|
|
|
|
thumbnailName := "@thumb@" + fileID + ".jpg"
|
|
|
|
|
thumbnailPath := filepath.Join(s.filesDir, boxID, thumbnailName)
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
|
|
|
err = createImageThumbnail(path, thumbnailPath)
|
|
|
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
|
|
|
err = createVideoThumbnail(path, thumbnailPath)
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", fileID, "error", err.Error())
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return thumbnailName
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createImageThumbnail(sourcePath, targetPath string) error {
|
|
|
|
|
source, err := os.Open(sourcePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer source.Close()
|
|
|
|
|
|
|
|
|
|
img, _, err := image.Decode(source)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumb := resizeNearest(img, 360, 240)
|
|
|
|
|
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer target.Close()
|
|
|
|
|
|
|
|
|
|
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createVideoThumbnail(sourcePath, targetPath string) error {
|
|
|
|
|
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
|
|
|
|
bounds := src.Bounds()
|
|
|
|
|
width := bounds.Dx()
|
|
|
|
|
height := bounds.Dy()
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
return image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
|
|
|
|
|
if scale > 1 {
|
|
|
|
|
scale = 1
|
|
|
|
|
}
|
|
|
|
|
targetWidth := max(1, int(float64(width)*scale))
|
|
|
|
|
targetHeight := max(1, int(float64(height)*scale))
|
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
|
|
|
|
|
|
|
|
|
for y := 0; y < targetHeight; y++ {
|
|
|
|
|
for x := 0; x < targetWidth; x++ {
|
|
|
|
|
srcX := bounds.Min.X + int(float64(x)/scale)
|
|
|
|
|
srcY := bounds.Min.Y + int(float64(y)/scale)
|
|
|
|
|
dst.Set(x, y, src.At(srcX, srcY))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dst
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UploadService) writeBoxMetadata(box Box) error {
|
|
|
|
|
path := s.BoxMetadataPath(box)
|
|
|
|
|
data, err := json.MarshalIndent(box, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return os.WriteFile(path, data, 0o600)
|
|
|
|
|
}
|