diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 299d3b1..5d3b1b2 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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 } diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index fb42f66..707ea8f 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -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) } diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index 88e3504..05d58ba 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -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", diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js index e7d1896..6e48242 100644 --- a/backend/static/js/40-upload.js +++ b/backend/static/js/40-upload.js @@ -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; }