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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user