refactor(upload): use IncomingFile interface instead of multipart headers
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m58s

Refactors the upload handler to use the `services.IncomingFile` interface instead of concrete `*multipart.FileHeader` pointers. This decouples the core upload logic from the HTTP multipart implementation, allowing for more flexible file sources.

Changes include:
- Introducing `namedMultipartFile` to adapt multipart headers to the new interface.
- Updating `createOrAppendBox`, `checkUploadPolicy`, and `totalUploadBytes` to accept `IncomingFile`.
- Renaming service calls to `CreateBoxFromIncoming` and `AppendIncomingFiles`.
This commit is contained in:
2026-06-10 18:19:45 +03:00
parent 5d77b36634
commit 6a7590493c
4 changed files with 76 additions and 23 deletions

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
@@ -62,7 +63,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
files := uploadFiles(r) files := uploadIncomingFiles(r)
totalBytes := totalUploadBytes(files) totalBytes := totalUploadBytes(files)
var ownerID string var ownerID string
var collectionID string var collectionID string
@@ -164,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
// uploadGroupWindow are folded into one box. Without the header the behaviour is // uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many // identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting. // boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) { func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader)) batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" { if batch == "" {
if enforceBoxLimits { if enforceBoxLimits {
@@ -172,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil return services.UploadResult{}, 0, status, message, nil
} }
} }
result, err := a.uploadService.CreateBox(files, opts) result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil { if err != nil {
return services.UploadResult{}, 0, 0, "", err return services.UploadResult{}, 0, 0, "", err
} }
@@ -193,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow { if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil { if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil { if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every // Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL. // upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL result.ManageURL = entry.manageURL
@@ -209,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil return services.UploadResult{}, 0, status, message, nil
} }
} }
result, err := a.uploadService.CreateBox(files, opts) result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil { if err != nil {
return services.UploadResult{}, 0, 0, "", err return services.UploadResult{}, 0, 0, "", err
} }
@@ -229,13 +230,13 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r) return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
} }
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) { func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []services.IncomingFile, totalBytes int64) (int, string) {
if len(files) == 0 { if len(files) == 0 {
return 0, "" return 0, ""
} }
sizes := make([]int64, 0, len(files)) sizes := make([]int64, 0, len(files))
for _, file := range files { for _, file := range files {
sizes = append(sizes, file.Size) sizes = append(sizes, file.Size())
} }
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes) return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
} }
@@ -383,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
return "ip:" + uploadClientIP(r) return "ip:" + uploadClientIP(r)
} }
func totalUploadBytes(files []*multipart.FileHeader) int64 { func totalUploadBytes(files []services.IncomingFile) int64 {
var total int64 var total int64
for _, file := range files { for _, file := range files {
total += file.Size total += file.Size()
} }
return total return total
} }
@@ -409,13 +410,48 @@ func statusForDownloadError(err error) int {
return http.StatusForbidden return http.StatusForbidden
} }
func uploadFiles(r *http.Request) []*multipart.FileHeader { type namedMultipartFile struct {
header *multipart.FileHeader
name string
}
func (f namedMultipartFile) Name() string {
if strings.TrimSpace(f.name) != "" {
return f.name
}
return f.header.Filename
}
func (f namedMultipartFile) Size() int64 {
return f.header.Size
}
func (f namedMultipartFile) ContentType() string {
return f.header.Header.Get("Content-Type")
}
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
return f.header.Open()
}
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
if r.MultipartForm == nil { if r.MultipartForm == nil {
return nil return nil
} }
files := make([]*multipart.FileHeader, 0) fileHeaders := r.MultipartForm.File["file"]
files = append(files, r.MultipartForm.File["file"]...) shareXHeaders := r.MultipartForm.File["sharex"]
files = append(files, r.MultipartForm.File["sharex"]...) paths := r.MultipartForm.Value["file_path"]
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
for index, header := range fileHeaders {
name := ""
if index < len(paths) {
name = paths[index]
}
files = append(files, namedMultipartFile{header: header, name: name})
}
for _, header := range shareXHeaders {
files = append(files, namedMultipartFile{header: header})
}
return files return files
} }

View File

@@ -17,6 +17,7 @@ import (
"time" "time"
"warpbox.dev/backend/libs/config" "warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
@@ -127,7 +128,7 @@ func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) { if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("download page did not render text thumbnail image: %s", body) t.Fatalf("download page did not render text thumbnail image: %s", body)
} }
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") { if !strings.Contains(body, "Open to preview or download") {
t.Fatalf("social preview body missing preview/download description: %s", body) t.Fatalf("social preview body missing preview/download description: %s", body)
} }
} }
@@ -817,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
t.Fatalf("NewBanService returned error: %v", err) t.Fatalf("NewBanService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() { return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -22,6 +22,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/image/font" "golang.org/x/image/font"
@@ -38,8 +39,12 @@ type ThumbnailJobResult struct {
Failed int Failed int
} }
var thumbnailJobs sync.WaitGroup
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) { func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
thumbnailJobs.Add(1)
go func() { go func() {
defer thumbnailJobs.Done()
box, err := uploadService.GetBox(boxID) box, err := uploadService.GetBox(boxID)
if err != nil { if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error()) logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
@@ -61,6 +66,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger
}() }()
} }
func WaitForThumbnailJobs() {
thumbnailJobs.Wait()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job { func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{ return job{
name: "thumbnail", name: "thumbnail",

View File

@@ -411,7 +411,7 @@
data: { url: window.Warpbox.absoluteURL(url || "/") }, data: { url: window.Warpbox.absoluteURL(url || "/") },
}; };
try { try {
const registration = await navigator.serviceWorker?.ready; const registration = navigator.serviceWorker ? await navigator.serviceWorker.ready : null;
if (registration && registration.showNotification) { if (registration && registration.showNotification) {
await registration.showNotification(title, options); await registration.showNotification(title, options);
return; return;
@@ -419,14 +419,18 @@
} catch (error) { } catch (error) {
/* fall through to page notification */ /* fall through to page notification */
} }
const notification = new Notification(title, options); try {
notification.onclick = () => { const notification = new Notification(title, options);
window.focus(); notification.onclick = () => {
if (url) { window.focus();
window.location.href = window.Warpbox.absoluteURL(url); if (url) {
} window.location.href = window.Warpbox.absoluteURL(url);
notification.close(); }
}; notification.close();
};
} catch (error) {
/* notifications are best-effort */
}
} }
function notify(variant, message, options) { function notify(variant, message, options) {
@@ -1410,8 +1414,10 @@
function uploadFormData() { function uploadFormData() {
const formData = new FormData(form); const formData = new FormData(form);
formData.delete("file"); formData.delete("file");
formData.delete("file_path");
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
formData.append("file", file, uploadName(file)); formData.append("file", file, uploadName(file));
formData.append("file_path", uploadName(file));
}); });
return formData; return formData;
} }