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"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
@@ -62,7 +63,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
files := uploadFiles(r)
files := uploadIncomingFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID 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
// 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.
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))
if batch == "" {
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
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
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 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
// upload in the batch returns a working deletion URL.
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
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
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)
}
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 {
return 0, ""
}
sizes := make([]int64, 0, len(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)
}
@@ -383,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
return "ip:" + uploadClientIP(r)
}
func totalUploadBytes(files []*multipart.FileHeader) int64 {
func totalUploadBytes(files []services.IncomingFile) int64 {
var total int64
for _, file := range files {
total += file.Size
total += file.Size()
}
return total
}
@@ -409,13 +410,48 @@ func statusForDownloadError(err error) int {
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 {
return nil
}
files := make([]*multipart.FileHeader, 0)
files = append(files, r.MultipartForm.File["file"]...)
files = append(files, r.MultipartForm.File["sharex"]...)
fileHeaders := r.MultipartForm.File["file"]
shareXHeaders := 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
}

View File

@@ -17,6 +17,7 @@ import (
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
"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+`"`) {
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)
}
}
@@ -817,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
t.Fatalf("NewBanService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}

View File

@@ -22,6 +22,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/image/font"
@@ -38,8 +39,12 @@ type ThumbnailJobResult struct {
Failed int
}
var thumbnailJobs sync.WaitGroup
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
thumbnailJobs.Add(1)
go func() {
defer thumbnailJobs.Done()
box, err := uploadService.GetBox(boxID)
if err != nil {
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 {
return job{
name: "thumbnail",

View File

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