diff --git a/README.md b/README.md index d227ac1..ce79184 100644 --- a/README.md +++ b/README.md @@ -357,3 +357,9 @@ bbolt database and JSON logs always remain local under `./data/db` and `./data/l The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter caching for CSS/JS, and gzip compression for compressible responses. + +## AI Usage + +I have used AI to accelerate development, all of the code has been reviewed by humans. I have mostly used self-hosted models as well as big models from big companies for a monthly subscription fee. + +I have nothing against AI as long as you can tell me what every single line of your code does. That's how I personally view things. \ No newline at end of file diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 96c0422..7a42d6f 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -54,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /", a.Home) mux.HandleFunc("GET /api", a.APIDocs) + mux.HandleFunc("GET /service-worker.js", a.ServiceWorker) + mux.HandleFunc("POST /share-target", a.ShareTargetFallback) mux.HandleFunc("GET /register", a.Register) mux.HandleFunc("POST /register", a.RegisterPost) mux.HandleFunc("GET /login", a.Login) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 857268f..d45a414 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -59,6 +59,8 @@ type fileView struct { ReactionMore int Reacted bool Processing bool + Failed bool + Error string } type reactionView struct { @@ -242,12 +244,32 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { http.Error(w, "file is still processing", http.StatusAccepted) return } + if file.ProcessingError != "" { + a.logger.Warn("failed file preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4241, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...) + http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency) + return + } + if services.BoxHasTrouble(box) { + a.logger.Warn("failed box preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4245, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...) + http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency) + return + } if shouldServeRawSocialMedia(file) { a.serveFileContent(w, r, box, file, false) a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...) return } } + if file.ProcessingError != "" && !locked { + a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...) + http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency) + return + } + if services.BoxHasTrouble(box) && !locked { + a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...) + http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency) + return + } view := a.fileView(box, file) fileSize := helpers.FormatBytes(file.Size) title := file.Name @@ -306,6 +328,16 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) { http.Error(w, "file is still processing", http.StatusAccepted) return } + if file.ProcessingError != "" { + a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...) + http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency) + return + } + if services.BoxHasTrouble(box) { + a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...) + http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency) + return + } a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1") a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...) @@ -321,6 +353,11 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { a.servePlaceholderThumbnail(w, r) return } + if services.BoxHasTrouble(box) || services.FileHasTrouble(file) { + a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...) + a.servePlaceholderThumbnail(w, r) + return + } object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file) if err != nil { @@ -363,6 +400,11 @@ func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) { a.servePlaceholderThumbnail(w, r) return } + if services.BoxHasTrouble(box) || services.FileHasTrouble(file) { + a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...) + a.servePlaceholderThumbnail(w, r) + return + } object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file) if err != nil { @@ -400,6 +442,11 @@ func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) { http.Error(w, "password required", http.StatusUnauthorized) return } + if services.BoxHasTrouble(box) || services.FileHasTrouble(file) { + a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...) + http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency) + return + } if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" { if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" { @@ -432,7 +479,7 @@ func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) { } func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string { - if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { + if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing { return "" } thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file) @@ -456,7 +503,7 @@ func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.B } func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string { - if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) { + if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing { return "" } scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file) @@ -480,7 +527,7 @@ func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services } func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string { - if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) { + if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing { return "" } listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file) @@ -504,6 +551,13 @@ func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box servi return listing } +func troubleReasonForLog(box services.Box, file services.File) string { + if services.FileHasTrouble(file) { + return file.ProcessingError + } + return services.BoxTroubleReason(box) +} + // servePlaceholderThumbnail serves the fallback image with no-store so the // browser re-requests on the next load and picks up the real thumbnail as soon // as it has been generated. @@ -651,6 +705,22 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { http.Error(w, "password required", http.StatusUnauthorized) return } + for _, file := range box.Files { + if file.Processing { + http.Error(w, "file is still processing", http.StatusAccepted) + return + } + if file.ProcessingError != "" { + a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...) + http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency) + return + } + } + if services.BoxHasTrouble(box) { + a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...) + http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency) + return + } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip")) @@ -685,9 +755,9 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID), ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID), - HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file), - HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file), - HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file), + HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)), + HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)), + HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)), IconURL: fileIconURL("standard", icon.Standard), IconRetroURL: fileIconURL("retro", icon.Retro), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), @@ -695,6 +765,8 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti ReactionMore: reactionOverflowCount(reactionViews), Reacted: reacted, Processing: file.Processing, + Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file), + Error: troubleReasonForLog(box, file), } } diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index 852cc5e..3159068 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -10,6 +10,7 @@ import ( type homeData struct { MaxUploadSize string + MaxUploadBytes int64 LimitSummary string Collections []collectionView IsAdmin bool @@ -57,7 +58,7 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) { "actor", actor, "user_id", user.ID, )...) - maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin) + maxUploadSize, maxUploadBytes, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin) expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin) a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{ Title: "Upload your files", @@ -68,6 +69,7 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) { CurrentUser: currentUser, Data: homeData{ MaxUploadSize: maxUploadSize, + MaxUploadBytes: maxUploadBytes, LimitSummary: limitSummary, Collections: collections, IsAdmin: isAdmin, @@ -155,22 +157,25 @@ func expiryLabel(minutes int) string { } } -func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) { +func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, int64, string) { if isAdmin { - return "No file size limit", "Admin uploads bypass storage and daily caps." + return "No file size limit", -1, "Admin uploads bypass storage and daily caps." } if !loggedIn { if !settings.AnonymousUploadsEnabled { - return "Anonymous uploads disabled", "Sign in to upload files." + return "Anonymous uploads disabled", 0, "Sign in to upload files." } - return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max." + return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), services.MegabytesToBytes(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max." } policy := a.settingsService.EffectivePolicyForUser(settings, user) maxUpload := a.uploadService.MaxUploadSizeLabel() + maxUploadBytes := a.uploadService.MaxUploadSize() if policy.MaxUploadMB < 0 { maxUpload = "unlimited" + maxUploadBytes = -1 } else if policy.MaxUploadMB > 0 { maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB) + maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB) } quota := "unlimited" if policy.StorageQuotaSet { @@ -180,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use if policy.MaxDays < 0 { expiryLimit = "no expiry limit." } - return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit + return maxUpload, maxUploadBytes, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit } diff --git a/backend/libs/handlers/resumable.go b/backend/libs/handlers/resumable.go index 26075be..352b2d0 100644 --- a/backend/libs/handlers/resumable.go +++ b/backend/libs/handlers/resumable.go @@ -180,7 +180,7 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) { if !ok { return } - if session.Status == services.ResumableStatusCompleted || session.Status == services.ResumableStatusProcessing { + if session.Status == services.ResumableStatusCompleted { result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID) if err != nil { a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...) @@ -191,6 +191,17 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, result) return } + if session.Status == services.ResumableStatusProcessing { + result, err := a.uploadService.FinalizeProcessingResumableSession(r.Context(), session.ID) + if err != nil { + a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...) + helpers.WriteJSONError(w, http.StatusBadRequest, err.Error()) + return + } + a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", session.ID, "box_id", result.BoxID, "files", len(result.Files))...) + helpers.WriteJSON(w, http.StatusOK, result) + return + } user, loggedIn, _ := a.currentUserWithAuthError(r) isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn) diff --git a/backend/libs/handlers/static.go b/backend/libs/handlers/static.go index ab882b1..ab43672 100644 --- a/backend/libs/handlers/static.go +++ b/backend/libs/handlers/static.go @@ -34,6 +34,17 @@ func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, path) } +func (a *App) ServiceWorker(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Header().Set("Service-Worker-Allowed", "/") + http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "js", "service-worker.js")) +} + +func (a *App) ShareTargetFallback(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/?share-target=unsupported", http.StatusSeeOther) +} + func setStaticCacheHeaders(w http.ResponseWriter, path string) { ext := strings.ToLower(filepath.Ext(path)) diff --git a/backend/libs/handlers/static_test.go b/backend/libs/handlers/static_test.go index 5a038e7..bd0afbd 100644 --- a/backend/libs/handlers/static_test.go +++ b/backend/libs/handlers/static_test.go @@ -1,7 +1,11 @@ package handlers import ( + "encoding/json" + "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) @@ -24,3 +28,76 @@ func TestSetStaticCacheHeaders(t *testing.T) { } } } + +func TestWebManifestIncludesShareTarget(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest")) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + var manifest struct { + ShareTarget struct { + Action string `json:"action"` + Method string `json:"method"` + EncType string `json:"enctype"` + Params struct { + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` + Files []struct { + Name string `json:"name"` + Accept []string `json:"accept"` + } `json:"files"` + } `json:"params"` + } `json:"share_target"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if manifest.ShareTarget.Action != "/share-target" || manifest.ShareTarget.Method != "POST" || manifest.ShareTarget.EncType != "multipart/form-data" { + t.Fatalf("unexpected share_target config: %+v", manifest.ShareTarget) + } + if manifest.ShareTarget.Params.Title != "title" || manifest.ShareTarget.Params.Text != "text" || manifest.ShareTarget.Params.URL != "url" { + t.Fatalf("unexpected share_target params: %+v", manifest.ShareTarget.Params) + } + if len(manifest.ShareTarget.Params.Files) != 1 || manifest.ShareTarget.Params.Files[0].Name != "files" || len(manifest.ShareTarget.Params.Files[0].Accept) != 1 || manifest.ShareTarget.Params.Files[0].Accept[0] != "*/*" { + t.Fatalf("unexpected share_target files: %+v", manifest.ShareTarget.Params.Files) + } +} + +func TestServiceWorkerServedFromRootScope(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + request := httptest.NewRequest(http.MethodGet, "/service-worker.js", nil) + response := httptest.NewRecorder() + app.ServiceWorker(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if got := response.Header().Get("Service-Worker-Allowed"); got != "/" { + t.Fatalf("Service-Worker-Allowed = %q, want /", got) + } + if got := response.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if response.Body.Len() == 0 { + t.Fatalf("service worker body missing") + } +} + +func TestShareTargetFallbackRedirectsHome(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + request := httptest.NewRequest(http.MethodPost, "/share-target", nil) + response := httptest.NewRecorder() + app.ShareTargetFallback(response, request) + + if response.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther) + } + if got := response.Header().Get("Location"); got != "/?share-target=unsupported" { + t.Fatalf("Location = %q", got) + } +} diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 8ce7602..299d3b1 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -53,6 +53,11 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { } if err := r.ParseMultipartForm(parseLimit); err != nil { a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit") + return + } helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") return } @@ -244,7 +249,7 @@ func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, log maxBytes := services.MegabytesToBytes(policy.MaxUploadMB) for _, fileSize := range fileSizes { if fileSize > maxBytes { - return http.StatusRequestEntityTooLarge, "file exceeds upload size limit" + return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB) } } } diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index 7b9316a..fb42f66 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -218,6 +218,44 @@ func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) { } } +func TestDownloadPageShowsProcessingFailure(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + payload := uploadThroughApp(t, app) + box, err := app.uploadService.GetBox(payload.BoxID) + if err != nil { + t.Fatalf("GetBox returned error: %v", err) + } + box.Files[0].Processing = false + box.Files[0].ProcessingError = "Access Denied." + if err := app.uploadService.SaveBox(box); err != nil { + t.Fatalf("SaveBox returned error: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil) + request.SetPathValue("boxID", payload.BoxID) + response := httptest.NewRecorder() + app.DownloadPage(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, want := range []string{ + "Upload processing failed", + "Access Denied.", + "is-failed", + "Failed", + } { + if !strings.Contains(body, want) { + t.Fatalf("download page missing %q: %s", want, body) + } + } + if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) { + t.Fatalf("failed file still exposed download context: %s", body) + } +} + func TestFileDownloadUsesOriginalFilename(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index 6d53229..88e3504 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -45,6 +45,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error()) return } + if services.BoxHasTrouble(box) { + logger.Warn("thumbnail one-shot skipped trouble box", "source", "thumbnail", "severity", "warn", "code", 4206, "box_id", boxID, "error", services.BoxTroubleReason(box)) + return + } result, err := generateMissingThumbnailsForBox(uploadService, logger, box) if err != nil { @@ -91,6 +95,9 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl if !box.ExpiresAt.After(now) { continue } + if services.BoxHasTrouble(box) { + continue + } boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box) result.Scanned += boxResult.Scanned @@ -109,10 +116,16 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg if !box.ExpiresAt.After(time.Now().UTC()) { return result, nil } + if services.BoxHasTrouble(box) { + return result, nil + } changed := false for i := range box.Files { file := &box.Files[i] + if file.Processing || services.FileHasTrouble(*file) { + continue + } needsPrimary := file.Thumbnail == "" && needsThumbnail(*file) needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file) needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file) @@ -206,6 +219,15 @@ func GenerateArchiveListingForFile(uploadService *services.UploadService, box se } func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { + if services.BoxHasTrouble(box) { + return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box)) + } + if file.Processing { + return "", fmt.Errorf("file is still processing") + } + if services.FileHasTrouble(file) { + return "", fmt.Errorf("file processing failed: %s", file.ProcessingError) + } thumbnailName := "@thumb@" + file.ID + ".jpg" object, err := uploadService.OpenFileObject(context.Background(), box, file) if err != nil { @@ -244,6 +266,15 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser if !needsVideoScenes(file) { return "", nil } + if services.BoxHasTrouble(box) { + return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box)) + } + if file.Processing { + return "", fmt.Errorf("file is still processing") + } + if services.FileHasTrouble(file) { + return "", fmt.Errorf("file processing failed: %s", file.ProcessingError) + } sceneName := "@scene@" + file.ID + ".jpg" object, err := uploadService.OpenFileObject(context.Background(), box, file) if err != nil { @@ -263,6 +294,15 @@ func generateArchiveListing(uploadService *services.UploadService, box services. if !needsArchiveListing(file) { return "", nil } + if services.BoxHasTrouble(box) { + return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box)) + } + if file.Processing { + return "", fmt.Errorf("file is still processing") + } + if services.FileHasTrouble(file) { + return "", fmt.Errorf("file processing failed: %s", file.ProcessingError) + } listingName := "@archive@" + file.ID + ".json" object, err := uploadService.OpenFileObject(context.Background(), box, file) if err != nil { diff --git a/backend/libs/jobs/thumbnails_test.go b/backend/libs/jobs/thumbnails_test.go index 07e8056..0142f22 100644 --- a/backend/libs/jobs/thumbnails_test.go +++ b/backend/libs/jobs/thumbnails_test.go @@ -50,6 +50,36 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) { } } +func TestGenerateMissingThumbnailsForTroubleBoxSkipsWork(t *testing.T) { + service := newThumbnailTestUploadService(t) + result := createThumbnailTestBox(t, service) + box, err := service.GetBox(result.BoxID) + if err != nil { + t.Fatalf("GetBox returned error: %v", err) + } + box.Trouble = true + box.TroubleReason = "storage backend failed" + if err := service.SaveBox(box); err != nil { + t.Fatalf("SaveBox returned error: %v", err) + } + + jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box) + if err != nil { + t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err) + } + if jobResult != (ThumbnailJobResult{}) { + t.Fatalf("job result = %+v, want no work for trouble box", jobResult) + } + + updated, err := service.GetBox(result.BoxID) + if err != nil { + t.Fatalf("GetBox after job returned error: %v", err) + } + if updated.Files[0].Thumbnail != "" { + t.Fatalf("thumbnail was generated for trouble box: %+v", updated.Files[0]) + } +} + func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) { data, err := createTextThumbnail(services.File{ Name: "notes.md", diff --git a/backend/libs/services/resumable.go b/backend/libs/services/resumable.go index face09f..cbb79c3 100644 --- a/backend/libs/services/resumable.go +++ b/backend/libs/services/resumable.go @@ -369,19 +369,20 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context, } backend, err := s.storage.Backend(box.StorageBackendID) if err != nil { + _ = s.markProcessingBoxFailed(box, err) return UploadResult{}, err } for i, incoming := range staged { source, err := incoming.Open() if err != nil { + _ = s.markProcessingBoxFailed(box, err) return UploadResult{}, err } file := box.Files[i] if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil { source.Close() _ = backend.Delete(context.Background(), file.ObjectKey) - box.Files[i].ProcessingError = err.Error() - _ = s.saveBoxRecord(box) + _ = s.markProcessingBoxFailed(box, err) return UploadResult{}, err } source.Close() @@ -406,6 +407,35 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context, return s.resultForBox(box, ""), nil } +func (s *UploadService) markProcessingBoxFailed(box Box, cause error) error { + message := "upload processing failed" + if cause != nil && strings.TrimSpace(cause.Error()) != "" { + message = cause.Error() + } + s.logger.Warn("resumable upload box marked failed", "source", "user-upload", "severity", "warn", "code", 4021, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "files", len(box.Files), "error", message) + now := time.Now().UTC() + box.Trouble = true + box.TroubleReason = message + for i := range box.Files { + if box.Files[i].Processing || box.Files[i].ProcessingError == "" { + box.Files[i].Processing = false + box.Files[i].ProcessingError = message + if box.Files[i].UploadedAt.IsZero() { + box.Files[i].UploadedAt = now + } + } + } + if err := s.saveBoxRecord(box); err != nil { + s.logger.Warn("failed to save failed upload box state", "source", "user-upload", "severity", "warn", "code", 4022, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error()) + return err + } + if err := s.writeBoxMetadata(box); err != nil { + s.logger.Warn("failed to write failed upload box metadata", "source", "user-upload", "severity", "warn", "code", 4023, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error()) + return err + } + return nil +} + func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) { session, err := s.GetResumableSession(sessionID) if err != nil { diff --git a/backend/libs/services/storage_s3.go b/backend/libs/services/storage_s3.go index cad07d8..f345c1a 100644 --- a/backend/libs/services/storage_s3.go +++ b/backend/libs/services/storage_s3.go @@ -35,26 +35,35 @@ func (b *s3StorageBackend) ID() string { return b.cfg.ID } func (b *s3StorageBackend) Type() string { return StorageBackendS3 } func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { + cleanKey := cleanObjectKey(key) opts := minio.PutObjectOptions{ContentType: contentType} - _, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts) - return err + _, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanKey, body, size, opts) + if err != nil { + return fmt.Errorf("s3 put object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err) + } + return nil } func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { - object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{}) + cleanKey := cleanObjectKey(key) + object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanKey, minio.GetObjectOptions{}) if err != nil { - return StorageObject{}, err + return StorageObject{}, fmt.Errorf("s3 get object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err) } info, err := object.Stat() if err != nil { object.Close() - return StorageObject{}, err + return StorageObject{}, fmt.Errorf("s3 stat object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err) } return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil } func (b *s3StorageBackend) Delete(ctx context.Context, key string) error { - return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{}) + cleanKey := cleanObjectKey(key) + if err := b.client.RemoveObject(ctx, b.cfg.Bucket, cleanKey, minio.RemoveObjectOptions{}); err != nil { + return fmt.Errorf("s3 delete object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err) + } + return nil } func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error { @@ -62,7 +71,7 @@ func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) erro objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) for object := range objects { if object.Err != nil { - return object.Err + return fmt.Errorf("s3 list prefix %q in bucket %q failed: %w", prefix, b.cfg.Bucket, object.Err) } if err := b.Delete(ctx, object.Key); err != nil { return err @@ -75,7 +84,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) { var total int64 for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) { if object.Err != nil { - return 0, object.Err + return 0, fmt.Errorf("s3 usage list bucket %q failed: %w", b.cfg.Bucket, object.Err) } total += object.Size } @@ -85,7 +94,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) { func (b *s3StorageBackend) Test(ctx context.Context) error { exists, err := b.client.BucketExists(ctx, b.cfg.Bucket) if err != nil { - return err + return fmt.Errorf("s3 bucket check for %q failed: %w", b.cfg.Bucket, err) } if !exists { return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket) diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 26ff838..620ae80 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -117,6 +117,8 @@ type Box struct { Obfuscate bool `json:"obfuscate"` CreatorIP string `json:"creatorIp,omitempty"` StorageBackendID string `json:"storageBackendId,omitempty"` + Trouble bool `json:"trouble,omitempty"` + TroubleReason string `json:"troubleReason,omitempty"` Files []File `json:"files"` } @@ -139,6 +141,37 @@ type File struct { UploadedAt time.Time `json:"uploadedAt"` } +func BoxHasTrouble(box Box) bool { + if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" { + return true + } + for _, file := range box.Files { + if FileHasTrouble(file) { + return true + } + } + return false +} + +func BoxTroubleReason(box Box) string { + if strings.TrimSpace(box.TroubleReason) != "" { + return box.TroubleReason + } + for _, file := range box.Files { + if strings.TrimSpace(file.ProcessingError) != "" { + return file.ProcessingError + } + } + if box.Trouble { + return "box has failed processing" + } + return "" +} + +func FileHasTrouble(file File) bool { + return strings.TrimSpace(file.ProcessingError) != "" +} + type UploadResult struct { BoxID string `json:"boxId"` BoxURL string `json:"boxUrl"` diff --git a/backend/libs/services/upload_test.go b/backend/libs/services/upload_test.go index 2935d19..8d860cd 100644 --- a/backend/libs/services/upload_test.go +++ b/backend/libs/services/upload_test.go @@ -230,6 +230,47 @@ func TestResumableCompleteRejectsMissingChunks(t *testing.T) { } } +func TestProcessingResumableFailureMarksBoxFailed(t *testing.T) { + service := newTestUploadService(t) + session, err := service.CreateResumableSession([]ResumableFileInput{{ + Name: "note.txt", + Size: 4, + ContentType: "text/plain", + }}, UploadOptions{MaxDays: 1, StorageBackendID: "missing"}, 4, time.Hour, "") + if err != nil { + t.Fatalf("CreateResumableSession returned error: %v", err) + } + if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("note")); err != nil { + t.Fatalf("PutResumableChunk returned error: %v", err) + } + result, processing, err := service.CreateProcessingBoxFromResumable(session.ID) + if err != nil { + t.Fatalf("CreateProcessingBoxFromResumable returned error: %v", err) + } + if processing.Status != ResumableStatusProcessing { + t.Fatalf("session status = %q, want processing", processing.Status) + } + if _, err := service.FinalizeProcessingResumableSession(testContext(), session.ID); err == nil { + t.Fatalf("FinalizeProcessingResumableSession accepted missing backend") + } + box := getTestBox(t, service, result.BoxID) + if len(box.Files) != 1 { + t.Fatalf("box files = %+v", box.Files) + } + if box.Files[0].Processing { + t.Fatalf("failed file is still marked processing: %+v", box.Files[0]) + } + if box.Files[0].ProcessingError == "" { + t.Fatalf("failed file did not store processing error: %+v", box.Files[0]) + } + if !box.Trouble { + t.Fatalf("failed box was not marked as trouble: %+v", box) + } + if box.TroubleReason == "" { + t.Fatalf("failed box did not store trouble reason: %+v", box) + } +} + func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) { service := newTestUploadService(t) session, err := service.CreateResumableSession([]ResumableFileInput{ diff --git a/backend/static/css/19-popups.css b/backend/static/css/19-popups.css new file mode 100644 index 0000000..03c0fd1 --- /dev/null +++ b/backend/static/css/19-popups.css @@ -0,0 +1,173 @@ +.warpbox-popups { + position: fixed; + z-index: 120; + inset-block-start: calc(1rem + env(safe-area-inset-top)); + inset-inline-end: calc(1rem + env(safe-area-inset-right)); + width: min(26rem, calc(100vw - 2rem)); + display: grid; + gap: 0.75rem; + pointer-events: none; +} + +.warpbox-popup { + pointer-events: auto; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.25rem); + background: color-mix(in srgb, var(--card) 96%, transparent); + color: var(--card-foreground); + box-shadow: var(--shadow); + opacity: 0; + transform: translateY(-0.55rem); + transition: opacity 160ms ease, transform 160ms ease; + overflow: hidden; +} + +.warpbox-popup.is-visible { + opacity: 1; + transform: translateY(0); +} + +.warpbox-popup-chrome { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: start; + padding: 0.95rem; +} + +.warpbox-popup-icon { + width: 1.6rem; + height: 1.6rem; + display: grid; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 20%, transparent); + color: var(--primary); + font-weight: 800; + line-height: 1; +} + +.warpbox-popup-warning .warpbox-popup-icon { + background: color-mix(in srgb, var(--primary) 26%, transparent); + color: var(--primary-hover); +} + +.warpbox-popup-error .warpbox-popup-icon { + background: color-mix(in srgb, var(--danger) 18%, transparent); + color: var(--danger); +} + +.warpbox-popup-title { + display: block; + margin: 0 0 0.18rem; + font-size: 0.92rem; + line-height: 1.2; +} + +.warpbox-popup-message { + margin: 0; + color: var(--muted-foreground); + font-size: 0.84rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.warpbox-popup-close { + min-height: 1.8rem; + width: 1.8rem; + padding: 0; + border-color: var(--border); + color: var(--muted-foreground); + background: var(--surface-1); + font-size: 1rem; + line-height: 1; +} + +.warpbox-popup-close:hover { + color: var(--foreground); + background: var(--surface-1-hover); +} + +.warpbox-popup-actions { + display: flex; + justify-content: flex-end; + gap: 0.55rem; + padding: 0 0.95rem 0.95rem; +} + +:root[data-theme="retro"] .warpbox-popups { + inset-block-start: 2.65rem; + font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif; +} + +:root[data-theme="retro"] .warpbox-popup { + border: 1px solid #000000; + background: #c0c0c0; + color: #000000; + box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 3px 3px 0 rgba(0, 0, 0, 0.45); +} + +:root[data-theme="retro"] .warpbox-popup::before { + content: "Warpbox"; + display: block; + margin: 0.18rem 0.18rem 0; + padding: 0.22rem 0.35rem; + background: linear-gradient(to right, #000078, 80%, #0f80cd); + color: #ffffff; + font-size: 0.78rem; + font-weight: 700; +} + +:root[data-theme="retro"] .warpbox-popup-error::before { + content: "Warpbox - Error"; +} + +:root[data-theme="retro"] .warpbox-popup-warning::before { + content: "Warpbox - Warning"; +} + +:root[data-theme="retro"] .warpbox-popup-info::before { + content: "Warpbox - Info"; +} + +:root[data-theme="retro"] .warpbox-popup-chrome { + padding: 0.8rem; +} + +:root[data-theme="retro"] .warpbox-popup-icon { + border: 1px solid #000000; + background: #ffffff; + color: #000078; + box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; +} + +:root[data-theme="retro"] .warpbox-popup-warning .warpbox-popup-icon { + color: #9a5b00; +} + +:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon { + color: #c00000; +} + +:root[data-theme="retro"] .warpbox-popup-message { + color: #000000; +} + +:root[data-theme="retro"] .warpbox-popup-close { + width: 1.45rem; + height: 1.25rem; + min-height: 1.25rem; + background: #c0c0c0; + color: #000000; + border: 1px solid #000000; + box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf; + font-size: 0.78rem; + font-weight: 700; +} + +@media (max-width: 640px) { + .warpbox-popups { + inset-inline: 1rem; + width: auto; + } +} diff --git a/backend/static/css/20-upload.css b/backend/static/css/20-upload.css index 7ba6a6a..e06c7bd 100644 --- a/backend/static/css/20-upload.css +++ b/backend/static/css/20-upload.css @@ -56,6 +56,10 @@ display: none !important; } +.install-pwa-button[hidden] { + display: none !important; +} + .hero-copy { text-align: center; } @@ -395,6 +399,10 @@ button { text-align: right; } +.upload-file-state-shared { + color: var(--primary); +} + .upload-recovery-overlay { position: fixed; inset: 0; diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index 0ac4b91..005c70f 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -698,6 +698,12 @@ text-decoration: none; } +.button.is-disabled { + opacity: .62; + cursor: not-allowed; + pointer-events: none; +} + .upload-processing-alert { margin: 1rem 0; padding: .85rem 1rem; @@ -707,6 +713,11 @@ color: var(--foreground); } +.upload-processing-alert-error { + border-color: color-mix(in srgb, var(--danger) 55%, transparent); + background: color-mix(in srgb, var(--danger) 14%, transparent); +} + .thumb-link { flex: 0 0 4.75rem; width: 4.75rem; @@ -870,6 +881,24 @@ cursor: wait; } +.file-card.is-failed { + border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); + background: color-mix(in srgb, var(--danger) 8%, var(--background)); +} + +.file-card.is-failed .file-open { + cursor: not-allowed; +} + +.file-error { + display: block; + max-width: 100%; + margin-top: 0.18rem; + color: var(--danger); + white-space: normal; + overflow-wrap: anywhere; +} + .file-reaction-dock { position: static; z-index: 2; diff --git a/backend/static/js/02-pwa.js b/backend/static/js/02-pwa.js new file mode 100644 index 0000000..4779d1d --- /dev/null +++ b/backend/static/js/02-pwa.js @@ -0,0 +1,43 @@ +(function () { + let installPrompt = null; + + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/service-worker.js").catch(() => { + /* Service workers are progressive enhancement here. */ + }); + }); + } + + window.addEventListener("beforeinstallprompt", (event) => { + const button = document.querySelector("[data-install-pwa]"); + if (!button) { + return; + } + event.preventDefault(); + installPrompt = event; + button.hidden = false; + button.addEventListener("click", async () => { + if (!installPrompt) { + return; + } + button.disabled = true; + try { + await installPrompt.prompt(); + await installPrompt.userChoice; + } finally { + installPrompt = null; + button.hidden = true; + button.disabled = false; + } + }, { once: true }); + }); + + window.addEventListener("appinstalled", () => { + const button = document.querySelector("[data-install-pwa]"); + if (button) { + button.hidden = true; + } + installPrompt = null; + }); +})(); diff --git a/backend/static/js/03-popups.js b/backend/static/js/03-popups.js new file mode 100644 index 0000000..c85f6b9 --- /dev/null +++ b/backend/static/js/03-popups.js @@ -0,0 +1,174 @@ +(function () { + const DEFAULT_DURATION = 6200; + const VARIANTS = ["info", "warning", "error"]; + const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment."; + + window.Warpbox = window.Warpbox || {}; + let lastGlobalErrorAt = 0; + + function ensureRegion() { + let region = document.querySelector("[data-warpbox-popups]"); + if (region) { + return region; + } + region = document.createElement("div"); + region.className = "warpbox-popups"; + region.setAttribute("data-warpbox-popups", ""); + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "false"); + document.body.append(region); + return region; + } + + function normalizeOptions(options, message) { + if (typeof options === "string") { + options = { message: options }; + } else { + options = options || {}; + } + if (message) { + options.message = message; + } + const variant = VARIANTS.includes(options.variant) ? options.variant : "info"; + return { + variant, + title: options.title || defaultTitle(variant), + message: options.message || "", + duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION, + actions: Array.isArray(options.actions) ? options.actions : [], + }; + } + + function defaultTitle(variant) { + if (variant === "error") { + return "Error"; + } + if (variant === "warning") { + return "Warning"; + } + return "Info"; + } + + function notify(options, message) { + const config = normalizeOptions(options, message); + const region = ensureRegion(); + const popup = document.createElement("section"); + popup.className = "warpbox-popup warpbox-popup-" + config.variant; + popup.setAttribute("role", config.variant === "error" ? "alert" : "status"); + + const chrome = document.createElement("div"); + chrome.className = "warpbox-popup-chrome"; + + const icon = document.createElement("span"); + icon.className = "warpbox-popup-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i"; + + const body = document.createElement("div"); + body.className = "warpbox-popup-body"; + + const title = document.createElement("strong"); + title.className = "warpbox-popup-title"; + title.textContent = config.title; + + const text = document.createElement("p"); + text.className = "warpbox-popup-message"; + text.textContent = config.message; + + body.append(title, text); + + const close = document.createElement("button"); + close.type = "button"; + close.className = "warpbox-popup-close"; + close.setAttribute("aria-label", "Dismiss notification"); + close.textContent = "x"; + close.addEventListener("click", () => dismiss(popup)); + + chrome.append(icon, body, close); + popup.append(chrome); + + if (config.actions.length > 0) { + const actions = document.createElement("div"); + actions.className = "warpbox-popup-actions"; + config.actions.forEach((action) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline"); + button.textContent = action.label || "Action"; + button.addEventListener("click", () => { + if (typeof action.onClick === "function") { + action.onClick(); + } + if (action.dismiss !== false) { + dismiss(popup); + } + }); + actions.append(button); + }); + popup.append(actions); + } + + region.append(popup); + window.requestAnimationFrame(() => popup.classList.add("is-visible")); + + let timer = null; + if (config.duration > 0) { + timer = window.setTimeout(() => dismiss(popup), config.duration); + } + + return { + element: popup, + close: function closePopup() { + if (timer) { + window.clearTimeout(timer); + } + dismiss(popup); + }, + }; + } + + function dismiss(popup) { + if (!popup || popup.dataset.closing === "true") { + return; + } + popup.dataset.closing = "true"; + popup.classList.remove("is-visible"); + window.setTimeout(() => popup.remove(), 180); + } + + window.Warpbox.notify = notify; + window.Warpbox.info = function info(message, options) { + return notify({ ...(options || {}), variant: "info", message }); + }; + window.Warpbox.warning = function warning(message, options) { + return notify({ ...(options || {}), variant: "warning", message }); + }; + window.Warpbox.error = function error(message, options) { + return notify({ ...(options || {}), variant: "error", message }); + }; + + function showGlobalError() { + const now = Date.now(); + if (now - lastGlobalErrorAt < 2500) { + return; + } + lastGlobalErrorAt = now; + notify({ + variant: "error", + title: "Page error", + message: GENERIC_ERROR_MESSAGE, + duration: 9000, + }); + } + + window.addEventListener("error", function (event) { + if (event && event.target && event.target !== window) { + return; + } + showGlobalError(); + }); + + window.addEventListener("unhandledrejection", function () { + showGlobalError(); + }); +})(); diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js index 29dc673..a33ff12 100644 --- a/backend/static/js/40-upload.js +++ b/backend/static/js/40-upload.js @@ -15,6 +15,8 @@ const manageLink = document.querySelector("#manage-link"); const newUpload = document.querySelector("#new-upload"); const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; + const SHARE_CACHE = "warpbox-share-target-v1"; + const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; if (!form || !dropZone || !fileInput) { return; @@ -47,6 +49,9 @@ let uploadLocked = false; let recoveredDraft = null; let resumeMode = false; + let sharedTargetDraft = null; + const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10); + const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit"); ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { @@ -93,6 +98,12 @@ event.preventDefault(); if (selectedFiles.length === 0) { updateStatus("Choose at least one file first."); + notify("warning", "Choose at least one file first.", { + title: "No files selected", + }); + return; + } + if (!validateSelectedFilesWithinLimit(selectedFiles)) { return; } @@ -108,8 +119,10 @@ try { const payload = await uploadResumable(form.action, formData, selectedFiles); renderResult(payload); + await clearSharedTargetPayload(); form.reset(); selectedFiles = []; + sharedTargetDraft = null; resumeMode = false; recoveredDraft = null; fileInput.value = ""; @@ -123,6 +136,7 @@ } } catch (error) { updateStatus(error.message || "Upload failed"); + notifyUploadError(error); } finally { setLoading(false, submit); } @@ -136,26 +150,168 @@ if (newUpload) { newUpload.addEventListener("click", () => { + if (sharedTargetDraft) { + clearSharedTargetPayload().finally(() => resetFreshUploadState()); + return; + } cancelRecoveredDraft().catch((error) => { updateStatus(error.message || "Upload draft could not be deleted"); }); }); } - recoverResumableSessions(); + if (isShareTargetLaunch()) { + loadSharedTargetFiles(); + } else { + recoverResumableSessions(); + } function addSelectedFiles(files) { if (uploadLocked) { return; } + const rejected = []; Array.from(files || []).forEach((file) => { + if (fileExceedsUploadLimit(file)) { + rejected.push(file); + return; + } if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) { selectedFiles.push(file); } }); + if (rejected.length > 0) { + notifyRejectedFiles(rejected); + } updateSelectedState(); } + function fileExceedsUploadLimit(file) { + return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes; + } + + function validateSelectedFilesWithinLimit(files) { + const rejected = Array.from(files || []).filter(fileExceedsUploadLimit); + if (rejected.length === 0) { + return true; + } + selectedFiles = selectedFiles.filter((file) => !fileExceedsUploadLimit(file)); + notifyRejectedFiles(rejected); + updateSelectedState(); + return false; + } + + function notifyRejectedFiles(files) { + const names = files.slice(0, 3).map((file) => `"${file.name}" (${window.Warpbox.formatBytes(file.size)})`).join(", "); + const extra = files.length > 3 ? `, and ${files.length - 3} more` : ""; + const message = `${names}${extra} ${files.length === 1 ? "is" : "are"} over the ${maxUploadLabel} upload limit.`; + updateStatus(message); + notify("error", message, { + title: "Upload limit exceeded", + duration: 9000, + }); + } + + function notifyUploadError(error) { + const message = error && error.message ? error.message : "Upload failed"; + const lower = message.toLowerCase(); + const isLimit = lower.includes("limit") || lower.includes("quota") || lower.includes("too large") || lower.includes("exceeds"); + notify("error", message, { + title: isLimit ? "Upload limit reached" : "Upload failed", + duration: isLimit ? 9000 : 7200, + }); + } + + function notify(variant, message, options) { + if (window.Warpbox && typeof window.Warpbox.notify === "function") { + window.Warpbox.notify({ ...(options || {}), variant, message }); + } + } + + function isShareTargetLaunch() { + const params = new URLSearchParams(window.location.search || ""); + return params.has("share-target"); + } + + async function loadSharedTargetFiles() { + if (!("caches" in window) || typeof File === "undefined") { + updateStatus("Shared files could not be loaded in this browser."); + recoverResumableSessions(); + return; + } + updateStatus("Loading shared files..."); + try { + const cache = await caches.open(SHARE_CACHE); + const metadataResponse = await cache.match(SHARE_LATEST_KEY); + if (!metadataResponse) { + updateStatus(new URLSearchParams(window.location.search).get("share-target") === "unsupported" + ? "Install Warpbox as an app to share files into it from your device." + : "No shared files were found."); + recoverResumableSessions(); + return; + } + const metadata = await metadataResponse.json(); + if (metadata.error) { + updateStatus(metadata.error); + recoverResumableSessions(); + return; + } + const files = []; + for (const item of metadata.files || []) { + if (!item.key) { + continue; + } + const response = await cache.match(item.key); + if (!response) { + continue; + } + const blob = await response.blob(); + files.push(new File([blob], item.name || "shared-file", { + type: item.type || blob.type || "application/octet-stream", + lastModified: item.lastModified || Date.now(), + })); + } + sharedTargetDraft = metadata; + selectedFiles = files; + resumeMode = false; + recoveredDraft = null; + validateSelectedFilesWithinLimit(selectedFiles); + if (selectedFiles.length > 0) { + renderQueue(selectedFiles, "queued", { shared: true }); + updateStatus("Shared files ready."); + } else { + updateStatus("No files were included in this share."); + } + updateSelectedState(); + } catch (error) { + updateStatus(error.message || "Shared files could not be loaded."); + recoverResumableSessions(); + } + } + + async function clearSharedTargetPayload() { + const draft = sharedTargetDraft; + sharedTargetDraft = null; + if (!draft || !("caches" in window)) { + sharedTargetDraft = null; + return; + } + try { + const cache = await caches.open(SHARE_CACHE); + for (const item of draft.files || []) { + if (item.key) { + await cache.delete(item.key); + } + } + if (draft.id) { + await cache.delete("/__warpbox_share_target__/meta/" + encodeURIComponent(draft.id)); + } + await cache.delete(SHARE_LATEST_KEY); + } catch (error) { + /* ignore cache cleanup failures */ + } + } + function removeSelectedFile(index) { if (uploadLocked) { return; @@ -175,12 +331,18 @@ fileSummary.textContent = count === 0 ? "Reselect missing files to resume, or add extra files to this upload." : `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`; + } else if (sharedTargetDraft) { + fileSummary.textContent = count === 0 + ? "No shared files were received." + : `${count} shared file${count === 1 ? "" : "s"} ready. Review options, then upload.`; } else { fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; } } if (resumeMode && recoveredDraft) { renderResumeQueue(recoveredDraft.session, selectedFiles); + } else if (sharedTargetDraft && count > 0) { + renderQueue(selectedFiles, "queued", { shared: true }); } else if (count > 0) { renderQueue(selectedFiles, "queued"); } else if (uploadQueue) { @@ -194,7 +356,7 @@ if (!newUpload) { return; } - const visible = Boolean(resumeMode && recoveredDraft); + const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft); newUpload.hidden = !visible; newUpload.style.display = visible ? "" : "none"; } @@ -803,6 +965,7 @@ selectedFiles = []; resumeMode = false; recoveredDraft = null; + sharedTargetDraft = null; fileInput.value = ""; result.hidden = true; if (resultList) { @@ -913,20 +1076,22 @@ return Math.max(0, Math.min(100, Math.round((bytes / total) * 100))); } - function renderQueue(files, status) { + function renderQueue(files, status, options) { if (!uploadQueue) { return; } + const shared = Boolean(options && options.shared); uploadQueue.hidden = files.length === 0; uploadQueue.replaceChildren(); files.forEach((file, index) => { uploadQueue.append(createFileRow({ name: file.name, - meta: window.Warpbox.formatBytes(file.size), + meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size), progress: status === "queued" ? 0 : 100, status, index, removable: status === "queued", + shared, })); }); } @@ -965,6 +1130,12 @@ badge.textContent = "Needs local file"; side.append(badge); } + if (file.shared) { + const badge = document.createElement("small"); + badge.className = "upload-file-state upload-file-state-shared"; + badge.textContent = "Shared from device"; + side.append(badge); + } if (file.removable) { const remove = document.createElement("button"); remove.className = "upload-file-remove"; diff --git a/backend/static/js/service-worker.js b/backend/static/js/service-worker.js new file mode 100644 index 0000000..a68f546 --- /dev/null +++ b/backend/static/js/service-worker.js @@ -0,0 +1,110 @@ +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") { + event.respondWith(handleShareTarget(event.request)); + } +}); + +const SHARE_CACHE = "warpbox-share-target-v1"; +const SHARE_PREFIX = "/__warpbox_share_target__/"; +const LATEST_KEY = SHARE_PREFIX + "latest"; + +async function handleShareTarget(request) { + const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); + try { + const formData = await request.formData(); + const files = collectSharedFiles(formData); + const cache = await caches.open(SHARE_CACHE); + const metadata = { + id, + title: stringValue(formData.get("title")), + text: stringValue(formData.get("text")), + url: stringValue(formData.get("url")), + createdAt: new Date().toISOString(), + files: [], + }; + + await deletePreviousShare(cache); + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index; + metadata.files.push({ + key, + name: file.name || "shared-file", + type: file.type || "application/octet-stream", + size: file.size || 0, + lastModified: file.lastModified || Date.now(), + }); + await cache.put(key, new Response(file, { + headers: { + "Content-Type": file.type || "application/octet-stream", + "Cache-Control": "no-store", + }, + })); + } + + await cache.put(LATEST_KEY, jsonResponse(metadata)); + await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata)); + } catch (error) { + await storeShareError(id, error); + } + + return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303); +} + +function collectSharedFiles(formData) { + const files = []; + ["files", "file", "sharex"].forEach((name) => { + formData.getAll(name).forEach((value) => { + if (value instanceof File && value.size > 0) { + files.push(value); + } + }); + }); + return files; +} + +function stringValue(value) { + return typeof value === "string" ? value : ""; +} + +function jsonResponse(payload) { + return new Response(JSON.stringify(payload), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); +} + +async function storeShareError(id, error) { + const cache = await caches.open(SHARE_CACHE); + await cache.put(LATEST_KEY, jsonResponse({ + id, + error: error && error.message ? error.message : "Shared files could not be staged.", + createdAt: new Date().toISOString(), + files: [], + })); +} + +async function deletePreviousShare(cache) { + const previous = await cache.match(LATEST_KEY); + if (!previous) { + return; + } + let metadata = null; + try { + metadata = await previous.json(); + } catch (error) { + metadata = null; + } + for (const file of metadata && metadata.files ? metadata.files : []) { + if (file.key) { + await cache.delete(file.key); + } + } + if (metadata && metadata.id) { + await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id)); + } + await cache.delete(LATEST_KEY); +} diff --git a/backend/static/site.webmanifest b/backend/static/site.webmanifest index e0070fa..1c77d88 100644 --- a/backend/static/site.webmanifest +++ b/backend/static/site.webmanifest @@ -7,6 +7,22 @@ "display": "standalone", "background_color": "#0b0b16", "theme_color": "#8b5cf6", + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["*/*"] + } + ] + } + }, "icons": [ { "src": "/static/android-chrome-192x192.png", diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 8d67bec..0a69102 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -59,6 +59,7 @@ + @@ -67,6 +68,8 @@ + + diff --git a/backend/templates/pages/download.html b/backend/templates/pages/download.html index 001b96c..92dea3c 100644 --- a/backend/templates/pages/download.html +++ b/backend/templates/pages/download.html @@ -25,11 +25,17 @@ {{if .Data.Files}} {{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}} + {{$failed := false}}{{range .Data.Files}}{{if .Failed}}{{$failed = true}}{{end}}{{end}} {{if $processing}}
Some files are still processing. You can share this link now, but processing files will become available shortly.
{{end}} + {{if $failed}} + + {{end}} {{$single := eq (len .Data.Files) 1}}
Expires {{.Data.ExpiresLabel}} @@ -37,6 +43,11 @@
{{if not .Data.Locked}} + {{if or $processing $failed}} + + {{if $failed}}Download unavailable{{else}}Files processing{{end}} + + {{else}} {{if $single}} {{$first := index .Data.Files 0}} @@ -49,6 +60,7 @@ Download zip {{end}} + {{end}} {{end}}
@@ -80,8 +92,8 @@
{{range .Data.Files}} -
- {{if .Processing}}{{else}}{{end}} {{if not $.Data.Locked}}
diff --git a/backend/templates/pages/home.html b/backend/templates/pages/home.html index c1bb781..1604065 100644 --- a/backend/templates/pages/home.html +++ b/backend/templates/pages/home.html @@ -10,7 +10,7 @@ {{end}}
-
+
{{if .CurrentUser}} @@ -76,6 +76,7 @@