feat(backend): handle processing errors and add PWA routes

- Block file downloads and previews with a 424 StatusFailedDependency if file processing failed or the box has issues.
- Register routes for `/service-worker.js` and `/share-target` to support PWA features.
- Update README.md with an AI usage disclosure.
This commit is contained in:
2026-06-08 11:53:37 +03:00
parent dbfdacc396
commit d11aec96e5
26 changed files with 1186 additions and 35 deletions

View File

@@ -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 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. 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.

View File

@@ -54,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
func (a *App) RegisterRoutes(mux *http.ServeMux) { func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home) mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs) 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("GET /register", a.Register)
mux.HandleFunc("POST /register", a.RegisterPost) mux.HandleFunc("POST /register", a.RegisterPost)
mux.HandleFunc("GET /login", a.Login) mux.HandleFunc("GET /login", a.Login)

View File

@@ -59,6 +59,8 @@ type fileView struct {
ReactionMore int ReactionMore int
Reacted bool Reacted bool
Processing bool Processing bool
Failed bool
Error string
} }
type reactionView struct { 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) http.Error(w, "file is still processing", http.StatusAccepted)
return 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) { if shouldServeRawSocialMedia(file) {
a.serveFileContent(w, r, box, file, false) 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)...) 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 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) view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size) fileSize := helpers.FormatBytes(file.Size)
title := file.Name 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) http.Error(w, "file is still processing", http.StatusAccepted)
return 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.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")...) 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) a.servePlaceholderThumbnail(w, r)
return 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) object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err != nil { if err != nil {
@@ -363,6 +400,11 @@ func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
a.servePlaceholderThumbnail(w, r) a.servePlaceholderThumbnail(w, r)
return 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) object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
if err != nil { if err != nil {
@@ -400,6 +442,11 @@ func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
http.Error(w, "password required", http.StatusUnauthorized) http.Error(w, "password required", http.StatusUnauthorized)
return 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 strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" { 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 { 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 "" return ""
} }
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file) 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 { 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 "" return ""
} }
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file) 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 { 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 "" return ""
} }
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file) listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
@@ -504,6 +551,13 @@ func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box servi
return listing 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 // 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 // browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated. // 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) http.Error(w, "password required", http.StatusUnauthorized)
return 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-Type", "application/zip")
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".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), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
SceneURL: fmt.Sprintf("/d/%s/scene/%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), ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file), HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file), HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file), HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
IconURL: fileIconURL("standard", icon.Standard), IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro), IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), 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), ReactionMore: reactionOverflowCount(reactionViews),
Reacted: reacted, Reacted: reacted,
Processing: file.Processing, Processing: file.Processing,
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
Error: troubleReasonForLog(box, file),
} }
} }

View File

@@ -10,6 +10,7 @@ import (
type homeData struct { type homeData struct {
MaxUploadSize string MaxUploadSize string
MaxUploadBytes int64
LimitSummary string LimitSummary string
Collections []collectionView Collections []collectionView
IsAdmin bool IsAdmin bool
@@ -57,7 +58,7 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
"actor", actor, "actor", actor,
"user_id", user.ID, "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) expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files", Title: "Upload your files",
@@ -68,6 +69,7 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
CurrentUser: currentUser, CurrentUser: currentUser,
Data: homeData{ Data: homeData{
MaxUploadSize: maxUploadSize, MaxUploadSize: maxUploadSize,
MaxUploadBytes: maxUploadBytes,
LimitSummary: limitSummary, LimitSummary: limitSummary,
Collections: collections, Collections: collections,
IsAdmin: isAdmin, 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 { 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 !loggedIn {
if !settings.AnonymousUploadsEnabled { 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) policy := a.settingsService.EffectivePolicyForUser(settings, user)
maxUpload := a.uploadService.MaxUploadSizeLabel() maxUpload := a.uploadService.MaxUploadSizeLabel()
maxUploadBytes := a.uploadService.MaxUploadSize()
if policy.MaxUploadMB < 0 { if policy.MaxUploadMB < 0 {
maxUpload = "unlimited" maxUpload = "unlimited"
maxUploadBytes = -1
} else if policy.MaxUploadMB > 0 { } else if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB) maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB)
} }
quota := "unlimited" quota := "unlimited"
if policy.StorageQuotaSet { if policy.StorageQuotaSet {
@@ -180,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if policy.MaxDays < 0 { if policy.MaxDays < 0 {
expiryLimit = "no expiry limit." 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
} }

View File

@@ -180,7 +180,7 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return 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) result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
if err != nil { 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())...) 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) helpers.WriteJSON(w, http.StatusOK, result)
return 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) user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn) settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)

View File

@@ -34,6 +34,17 @@ func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path) 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) { func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))

View File

@@ -1,7 +1,11 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "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)
}
}

View File

@@ -53,6 +53,11 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
if err := r.ParseMultipartForm(parseLimit); err != nil { 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())...) 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") helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return return
} }
@@ -244,7 +249,7 @@ func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, log
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB) maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, fileSize := range fileSizes { for _, fileSize := range fileSizes {
if fileSize > maxBytes { if fileSize > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit" return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
} }
} }
} }

View File

@@ -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) { func TestFileDownloadUsesOriginalFilename(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()

View File

@@ -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()) logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
return 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) result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
if err != nil { if err != nil {
@@ -91,6 +95,9 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
if !box.ExpiresAt.After(now) { if !box.ExpiresAt.After(now) {
continue continue
} }
if services.BoxHasTrouble(box) {
continue
}
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box) boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
result.Scanned += boxResult.Scanned result.Scanned += boxResult.Scanned
@@ -109,10 +116,16 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
if !box.ExpiresAt.After(time.Now().UTC()) { if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil return result, nil
} }
if services.BoxHasTrouble(box) {
return result, nil
}
changed := false changed := false
for i := range box.Files { for i := range box.Files {
file := &box.Files[i] file := &box.Files[i]
if file.Processing || services.FileHasTrouble(*file) {
continue
}
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file) needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file) needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*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) { 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" thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file) object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil { if err != nil {
@@ -244,6 +266,15 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser
if !needsVideoScenes(file) { if !needsVideoScenes(file) {
return "", nil 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" sceneName := "@scene@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file) object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil { if err != nil {
@@ -263,6 +294,15 @@ func generateArchiveListing(uploadService *services.UploadService, box services.
if !needsArchiveListing(file) { if !needsArchiveListing(file) {
return "", nil 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" listingName := "@archive@" + file.ID + ".json"
object, err := uploadService.OpenFileObject(context.Background(), box, file) object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil { if err != nil {

View File

@@ -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) { func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
data, err := createTextThumbnail(services.File{ data, err := createTextThumbnail(services.File{
Name: "notes.md", Name: "notes.md",

View File

@@ -369,19 +369,20 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
} }
backend, err := s.storage.Backend(box.StorageBackendID) backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil { if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err return UploadResult{}, err
} }
for i, incoming := range staged { for i, incoming := range staged {
source, err := incoming.Open() source, err := incoming.Open()
if err != nil { if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err return UploadResult{}, err
} }
file := box.Files[i] file := box.Files[i]
if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil { if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil {
source.Close() source.Close()
_ = backend.Delete(context.Background(), file.ObjectKey) _ = backend.Delete(context.Background(), file.ObjectKey)
box.Files[i].ProcessingError = err.Error() _ = s.markProcessingBoxFailed(box, err)
_ = s.saveBoxRecord(box)
return UploadResult{}, err return UploadResult{}, err
} }
source.Close() source.Close()
@@ -406,6 +407,35 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
return s.resultForBox(box, ""), nil 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) { func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
session, err := s.GetResumableSession(sessionID) session, err := s.GetResumableSession(sessionID)
if err != nil { if err != nil {

View File

@@ -35,26 +35,35 @@ func (b *s3StorageBackend) ID() string { return b.cfg.ID }
func (b *s3StorageBackend) Type() string { return StorageBackendS3 } func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { 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} opts := minio.PutObjectOptions{ContentType: contentType}
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts) _, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanKey, body, size, opts)
return err 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) { 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 { 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() info, err := object.Stat()
if err != nil { if err != nil {
object.Close() 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 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 { 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 { 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}) objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
for object := range objects { for object := range objects {
if object.Err != nil { 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 { if err := b.Delete(ctx, object.Key); err != nil {
return err return err
@@ -75,7 +84,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
var total int64 var total int64
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) { for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if object.Err != nil { 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 total += object.Size
} }
@@ -85,7 +94,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
func (b *s3StorageBackend) Test(ctx context.Context) error { func (b *s3StorageBackend) Test(ctx context.Context) error {
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket) exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
if err != nil { if err != nil {
return err return fmt.Errorf("s3 bucket check for %q failed: %w", b.cfg.Bucket, err)
} }
if !exists { if !exists {
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket) return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)

View File

@@ -117,6 +117,8 @@ type Box struct {
Obfuscate bool `json:"obfuscate"` Obfuscate bool `json:"obfuscate"`
CreatorIP string `json:"creatorIp,omitempty"` CreatorIP string `json:"creatorIp,omitempty"`
StorageBackendID string `json:"storageBackendId,omitempty"` StorageBackendID string `json:"storageBackendId,omitempty"`
Trouble bool `json:"trouble,omitempty"`
TroubleReason string `json:"troubleReason,omitempty"`
Files []File `json:"files"` Files []File `json:"files"`
} }
@@ -139,6 +141,37 @@ type File struct {
UploadedAt time.Time `json:"uploadedAt"` 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 { type UploadResult struct {
BoxID string `json:"boxId"` BoxID string `json:"boxId"`
BoxURL string `json:"boxUrl"` BoxURL string `json:"boxUrl"`

View File

@@ -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) { func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) {
service := newTestUploadService(t) service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{ session, err := service.CreateResumableSession([]ResumableFileInput{

View File

@@ -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;
}
}

View File

@@ -56,6 +56,10 @@
display: none !important; display: none !important;
} }
.install-pwa-button[hidden] {
display: none !important;
}
.hero-copy { .hero-copy {
text-align: center; text-align: center;
} }
@@ -395,6 +399,10 @@ button {
text-align: right; text-align: right;
} }
.upload-file-state-shared {
color: var(--primary);
}
.upload-recovery-overlay { .upload-recovery-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -698,6 +698,12 @@
text-decoration: none; text-decoration: none;
} }
.button.is-disabled {
opacity: .62;
cursor: not-allowed;
pointer-events: none;
}
.upload-processing-alert { .upload-processing-alert {
margin: 1rem 0; margin: 1rem 0;
padding: .85rem 1rem; padding: .85rem 1rem;
@@ -707,6 +713,11 @@
color: var(--foreground); 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 { .thumb-link {
flex: 0 0 4.75rem; flex: 0 0 4.75rem;
width: 4.75rem; width: 4.75rem;
@@ -870,6 +881,24 @@
cursor: wait; 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 { .file-reaction-dock {
position: static; position: static;
z-index: 2; z-index: 2;

View File

@@ -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;
});
})();

View File

@@ -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();
});
})();

View File

@@ -15,6 +15,8 @@
const manageLink = document.querySelector("#manage-link"); const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload"); const newUpload = document.querySelector("#new-upload");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; 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) { if (!form || !dropZone || !fileInput) {
return; return;
@@ -47,6 +49,9 @@
let uploadLocked = false; let uploadLocked = false;
let recoveredDraft = null; let recoveredDraft = null;
let resumeMode = false; 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) => { ["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => { dropZone.addEventListener(eventName, (event) => {
@@ -93,6 +98,12 @@
event.preventDefault(); event.preventDefault();
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
updateStatus("Choose at least one file first."); updateStatus("Choose at least one file first.");
notify("warning", "Choose at least one file first.", {
title: "No files selected",
});
return;
}
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
return; return;
} }
@@ -108,8 +119,10 @@
try { try {
const payload = await uploadResumable(form.action, formData, selectedFiles); const payload = await uploadResumable(form.action, formData, selectedFiles);
renderResult(payload); renderResult(payload);
await clearSharedTargetPayload();
form.reset(); form.reset();
selectedFiles = []; selectedFiles = [];
sharedTargetDraft = null;
resumeMode = false; resumeMode = false;
recoveredDraft = null; recoveredDraft = null;
fileInput.value = ""; fileInput.value = "";
@@ -123,6 +136,7 @@
} }
} catch (error) { } catch (error) {
updateStatus(error.message || "Upload failed"); updateStatus(error.message || "Upload failed");
notifyUploadError(error);
} finally { } finally {
setLoading(false, submit); setLoading(false, submit);
} }
@@ -136,26 +150,168 @@
if (newUpload) { if (newUpload) {
newUpload.addEventListener("click", () => { newUpload.addEventListener("click", () => {
if (sharedTargetDraft) {
clearSharedTargetPayload().finally(() => resetFreshUploadState());
return;
}
cancelRecoveredDraft().catch((error) => { cancelRecoveredDraft().catch((error) => {
updateStatus(error.message || "Upload draft could not be deleted"); updateStatus(error.message || "Upload draft could not be deleted");
}); });
}); });
} }
recoverResumableSessions(); if (isShareTargetLaunch()) {
loadSharedTargetFiles();
} else {
recoverResumableSessions();
}
function addSelectedFiles(files) { function addSelectedFiles(files) {
if (uploadLocked) { if (uploadLocked) {
return; return;
} }
const rejected = [];
Array.from(files || []).forEach((file) => { Array.from(files || []).forEach((file) => {
if (fileExceedsUploadLimit(file)) {
rejected.push(file);
return;
}
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) { if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
selectedFiles.push(file); selectedFiles.push(file);
} }
}); });
if (rejected.length > 0) {
notifyRejectedFiles(rejected);
}
updateSelectedState(); 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) { function removeSelectedFile(index) {
if (uploadLocked) { if (uploadLocked) {
return; return;
@@ -175,12 +331,18 @@
fileSummary.textContent = count === 0 fileSummary.textContent = count === 0
? "Reselect missing files to resume, or add extra files to this upload." ? "Reselect missing files to resume, or add extra files to this upload."
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered 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 { } else {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
} }
} }
if (resumeMode && recoveredDraft) { if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles); renderResumeQueue(recoveredDraft.session, selectedFiles);
} else if (sharedTargetDraft && count > 0) {
renderQueue(selectedFiles, "queued", { shared: true });
} else if (count > 0) { } else if (count > 0) {
renderQueue(selectedFiles, "queued"); renderQueue(selectedFiles, "queued");
} else if (uploadQueue) { } else if (uploadQueue) {
@@ -194,7 +356,7 @@
if (!newUpload) { if (!newUpload) {
return; return;
} }
const visible = Boolean(resumeMode && recoveredDraft); const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
newUpload.hidden = !visible; newUpload.hidden = !visible;
newUpload.style.display = visible ? "" : "none"; newUpload.style.display = visible ? "" : "none";
} }
@@ -803,6 +965,7 @@
selectedFiles = []; selectedFiles = [];
resumeMode = false; resumeMode = false;
recoveredDraft = null; recoveredDraft = null;
sharedTargetDraft = null;
fileInput.value = ""; fileInput.value = "";
result.hidden = true; result.hidden = true;
if (resultList) { if (resultList) {
@@ -913,20 +1076,22 @@
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100))); return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
} }
function renderQueue(files, status) { function renderQueue(files, status, options) {
if (!uploadQueue) { if (!uploadQueue) {
return; return;
} }
const shared = Boolean(options && options.shared);
uploadQueue.hidden = files.length === 0; uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren(); uploadQueue.replaceChildren();
files.forEach((file, index) => { files.forEach((file, index) => {
uploadQueue.append(createFileRow({ uploadQueue.append(createFileRow({
name: file.name, 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, progress: status === "queued" ? 0 : 100,
status, status,
index, index,
removable: status === "queued", removable: status === "queued",
shared,
})); }));
}); });
} }
@@ -965,6 +1130,12 @@
badge.textContent = "Needs local file"; badge.textContent = "Needs local file";
side.append(badge); 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) { if (file.removable) {
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "upload-file-remove"; remove.className = "upload-file-remove";

View File

@@ -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);
}

View File

@@ -7,6 +7,22 @@
"display": "standalone", "display": "standalone",
"background_color": "#0b0b16", "background_color": "#0b0b16",
"theme_color": "#8b5cf6", "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": [ "icons": [
{ {
"src": "/static/android-chrome-192x192.png", "src": "/static/android-chrome-192x192.png",

View File

@@ -59,6 +59,7 @@
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/19-popups.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
@@ -67,6 +68,8 @@
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/02-pwa.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/03-popups.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script> <script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script> <script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script> <script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>

View File

@@ -25,11 +25,17 @@
{{if .Data.Files}} {{if .Data.Files}}
{{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}} {{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}}
{{$failed := false}}{{range .Data.Files}}{{if .Failed}}{{$failed = true}}{{end}}{{end}}
{{if $processing}} {{if $processing}}
<div class="upload-processing-alert" role="status"> <div class="upload-processing-alert" role="status">
Some files are still processing. You can share this link now, but processing files will become available shortly. Some files are still processing. You can share this link now, but processing files will become available shortly.
</div> </div>
{{end}} {{end}}
{{if $failed}}
<div class="upload-processing-alert upload-processing-alert-error" role="alert">
Upload processing failed for one or more files. The original upload could not be finalized by the storage backend.
</div>
{{end}}
{{$single := eq (len .Data.Files) 1}} {{$single := eq (len .Data.Files) 1}}
<div class="badge-row"> <div class="badge-row">
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span> <span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
@@ -37,6 +43,11 @@
</div> </div>
{{if not .Data.Locked}} {{if not .Data.Locked}}
{{if or $processing $failed}}
<span class="button button-outline button-wide is-disabled" aria-disabled="true">
{{if $failed}}Download unavailable{{else}}Files processing{{end}}
</span>
{{else}}
{{if $single}} {{if $single}}
{{$first := index .Data.Files 0}} {{$first := index .Data.Files 0}}
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}"> <a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
@@ -49,6 +60,7 @@
Download zip Download zip
</a> </a>
{{end}} {{end}}
{{end}}
{{end}} {{end}}
<div class="file-browser-window" data-file-browser-window> <div class="file-browser-window" data-file-browser-window>
@@ -80,8 +92,8 @@
</div> </div>
<div class="download-list file-browser is-thumbs" data-file-browser> <div class="download-list file-browser is-thumbs" data-file-browser>
{{range .Data.Files}} {{range .Data.Files}}
<article class="download-item file-card {{if .Processing}}is-processing{{end}}" data-kind="{{.PreviewKind}}" {{if not .Processing}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}"> <article class="download-item file-card {{if .Processing}}is-processing{{end}} {{if .Failed}}is-failed{{end}}" data-kind="{{.PreviewKind}}" {{if and (not .Processing) (not .Failed)}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
{{if .Processing}}<div class="file-open" aria-label="{{.Name}} is processing">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}} {{if or .Processing .Failed}}<div class="file-open" aria-label="{{.Name}} {{if .Failed}}failed processing{{else}}is processing{{end}}">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
<span class="file-media"> <span class="file-media">
{{if .HasThumbnail}} {{if .HasThumbnail}}
<img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy"> <img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy">
@@ -92,11 +104,12 @@
</span> </span>
<span class="file-main"> <span class="file-main">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong> <strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small> <small>{{.Size}} · {{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
{{if .Failed}}<small class="file-error">{{.Error}}</small>{{end}}
</span> </span>
<span class="file-type">{{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span> <span class="file-type">{{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
<span class="file-size">{{.Size}}</span> <span class="file-size">{{.Size}}</span>
{{if .Processing}}</div>{{else}}</a>{{end}} {{if or .Processing .Failed}}</div>{{else}}</a>{{end}}
{{if not $.Data.Locked}} {{if not $.Data.Locked}}
<div class="file-reaction-dock" data-reaction-dock> <div class="file-reaction-dock" data-reaction-dock>
<div class="file-reactions" data-reaction-list> <div class="file-reactions" data-reaction-list>

View File

@@ -10,7 +10,7 @@
{{end}} {{end}}
</div> </div>
<form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data"> <form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data" data-max-upload-bytes="{{.Data.MaxUploadBytes}}" data-max-upload-label="{{.Data.MaxUploadSize}}">
<div class="card upload-main"> <div class="card upload-main">
<div class="card-content"> <div class="card-content">
{{if .CurrentUser}} {{if .CurrentUser}}
@@ -76,6 +76,7 @@
<div class="form-footer"> <div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p> <p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-primary" type="submit">Upload files</button> <button class="button button-primary" type="submit">Upload files</button>
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button> <button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
</div> </div>