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:
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
173
backend/static/css/19-popups.css
Normal file
173
backend/static/css/19-popups.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
43
backend/static/js/02-pwa.js
Normal file
43
backend/static/js/02-pwa.js
Normal 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;
|
||||||
|
});
|
||||||
|
})();
|
||||||
174
backend/static/js/03-popups.js
Normal file
174
backend/static/js/03-popups.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isShareTargetLaunch()) {
|
||||||
|
loadSharedTargetFiles();
|
||||||
|
} else {
|
||||||
recoverResumableSessions();
|
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";
|
||||||
|
|||||||
110
backend/static/js/service-worker.js
Normal file
110
backend/static/js/service-worker.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}">
|
||||||
@@ -50,6 +61,7 @@
|
|||||||
</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>
|
||||||
<div class="file-browser-titlebar">
|
<div class="file-browser-titlebar">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user