2026-05-25 15:36:49 +03:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-25 16:26:47 +03:00
|
|
|
"archive/zip"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
2026-05-25 15:36:49 +03:00
|
|
|
"fmt"
|
2026-05-25 16:26:47 +03:00
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type UploadOptions struct {
|
|
|
|
|
MaxDays int
|
|
|
|
|
MaxDownloads int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
|
|
|
|
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"`
|
|
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
Files: make([]File, 0, len(files)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
storedName := fileID + strings.ToLower(filepath.Ext(header.Filename))
|
|
|
|
|
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,
|
|
|
|
|
UploadedAt: time.Now().UTC(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.saveBox(box); err != nil {
|
|
|
|
|
return UploadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.logger.Info("upload complete",
|
|
|
|
|
"source", "user-upload",
|
|
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) 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
|
|
|
|
|
}
|
|
|
|
|
return bucket.Put([]byte(boxID), next)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|