feat: add admin console, cleanup, and thumbnail workers

- Implement a token-authenticated admin console at `/admin` with overview metrics and file management.
- Add a background worker to periodically clean up expired boxes based on `WARPBOX_CLEANUP_EVERY`.
- Add a background worker to generate image and video thumbnails based on `WARPBOX_THUMBNAIL_EVERY`.
- Update file storage paths to use `@each@` and `@thumb@` prefixes to separate original files from thumbnails.
- Add severity fields to startup logs and update configuration templates.
This commit is contained in:
2026-05-25 16:52:57 +03:00
parent e12878887c
commit 26619bacbc
28 changed files with 1576 additions and 178 deletions

View File

@@ -3,18 +3,28 @@ package services
import (
"archive/zip"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
_ "image/png"
"io"
"log/slog"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.etcd.io/bbolt"
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
)
@@ -31,8 +41,10 @@ type UploadService struct {
}
type UploadOptions struct {
MaxDays int
MaxDownloads int
MaxDays int
MaxDownloads int
Password string
ObfuscateMetadata bool
}
type Box struct {
@@ -41,6 +53,9 @@ type Box struct {
ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
PasswordSalt string `json:"passwordSalt,omitempty"`
PasswordHash string `json:"passwordHash,omitempty"`
Obfuscate bool `json:"obfuscate"`
Files []File `json:"files"`
}
@@ -50,6 +65,8 @@ type File struct {
StoredName string `json:"storedName"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
}
@@ -68,6 +85,36 @@ type ResultFile struct {
URL string `json:"url"`
}
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
}
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db")
@@ -133,8 +180,14 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
MaxDownloads: opts.MaxDownloads,
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
Files: make([]File, 0, len(files)),
}
if strings.TrimSpace(opts.Password) != "" {
salt, hash := hashPassword(opts.Password)
box.PasswordSalt = salt
box.PasswordHash = hash
}
boxDir := filepath.Join(s.filesDir, box.ID)
if err := os.MkdirAll(boxDir, 0o755); err != nil {
@@ -152,7 +205,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
}
fileID := randomID(8)
storedName := fileID + strings.ToLower(filepath.Ext(header.Filename))
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
storedPath := filepath.Join(boxDir, storedName)
contentType := header.Header.Get("Content-Type")
if contentType == "" {
@@ -171,6 +224,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
StoredName: storedName,
Size: header.Size,
ContentType: contentType,
PreviewKind: previewKind(contentType),
UploadedAt: time.Now().UTC(),
})
}
@@ -181,6 +235,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
@@ -204,6 +259,173 @@ func (s *UploadService) GetBox(id string) (Box, error) {
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) 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
}
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
for _, file := range box.Files {
if file.ID == fileID {
@@ -217,6 +439,34 @@ 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) 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) CanDownload(box Box) error {
if time.Now().UTC().After(box.ExpiresAt) {
return fmt.Errorf("box has expired")
@@ -245,7 +495,10 @@ func (s *UploadService) RecordDownload(boxID string) error {
if err != nil {
return err
}
return bucket.Put([]byte(boxID), next)
if err := bucket.Put([]byte(boxID), next); err != nil {
return err
}
return s.writeBoxMetadata(box)
})
}
@@ -288,7 +541,10 @@ func (s *UploadService) saveBox(box Box) error {
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
return err
}
return s.writeBoxMetadata(box)
})
}
@@ -338,3 +594,112 @@ func randomID(byteCount int) string {
}
return base64.RawURLEncoding.EncodeToString(data)
}
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)
}