Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b4487ac2e | |||
| ead4cd7492 | |||
| af1fae1a98 | |||
| d11aec96e5 | |||
| dbfdacc396 | |||
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f | |||
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc | |||
| 3a0dd04e61 | |||
| e17c5e92a7 | |||
| f698ba516d | |||
| 17c31be8b4 |
@@ -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)
|
||||||
@@ -132,8 +134,13 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
|
||||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||||
|
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||||
|
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||||
mux.HandleFunc("GET /health", a.Health)
|
mux.HandleFunc("GET /health", a.Health)
|
||||||
mux.HandleFunc("GET /healthz", notFound)
|
mux.HandleFunc("GET /healthz", notFound)
|
||||||
mux.HandleFunc("GET /api/v1/health", notFound)
|
mux.HandleFunc("GET /api/v1/health", notFound)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
@@ -40,12 +41,17 @@ type fileView struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Size string
|
Size string
|
||||||
|
SizeBytes int64
|
||||||
ContentType string
|
ContentType string
|
||||||
PreviewKind string
|
PreviewKind string
|
||||||
URL string
|
URL string
|
||||||
DownloadURL string
|
DownloadURL string
|
||||||
ThumbnailURL string
|
ThumbnailURL string
|
||||||
|
SceneURL string
|
||||||
|
ArchiveURL string
|
||||||
HasThumbnail bool
|
HasThumbnail bool
|
||||||
|
HasScene bool
|
||||||
|
HasArchive bool
|
||||||
IconURL string
|
IconURL string
|
||||||
IconRetroURL string
|
IconRetroURL string
|
||||||
ReactURL string
|
ReactURL string
|
||||||
@@ -53,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 {
|
||||||
@@ -103,14 +111,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||||
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
||||||
if box.Files[0].Processing {
|
file := box.Files[0]
|
||||||
|
if file.Processing {
|
||||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.serveFileContent(w, r, box, box.Files[0], false)
|
if shouldServeRawSocialMedia(file) {
|
||||||
a.logger.Info("single-file box served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", box.Files[0].ID)...)
|
a.serveFileContent(w, r, box, file, false)
|
||||||
|
a.logger.Info("single-file media served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
visitorID := a.reactionVisitorID(w, r)
|
visitorID := a.reactionVisitorID(w, r)
|
||||||
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,15 +142,37 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||||
title := "Shared files on Warpbox"
|
title := "Shared files on Warpbox"
|
||||||
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
||||||
|
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
||||||
|
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
|
||||||
|
imageType := "image/jpeg"
|
||||||
|
if !locked && len(box.Files) == 1 && !box.Files[0].Processing {
|
||||||
|
file := box.Files[0]
|
||||||
|
view := a.fileView(box, file)
|
||||||
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
|
title = file.Name
|
||||||
|
description = fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
|
ogImage = socialImageURL(r, box, file, view)
|
||||||
|
imageAlt = fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
imageType = socialImageType(file)
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox link"
|
title = "Protected Warpbox link"
|
||||||
description = "This shared box is password protected."
|
description = "This shared box is password protected."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
|
||||||
|
|
||||||
|
// All user uploads are private/temporary — noindex by default.
|
||||||
|
robots := web.RobotsNone
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
|
CanonicalURL: pageURL,
|
||||||
|
Robots: robots,
|
||||||
|
ImageURL: ogImage,
|
||||||
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: imageType,
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
@@ -162,6 +195,43 @@ func plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldServeRawSocialMedia(file services.File) bool {
|
||||||
|
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
|
||||||
|
if strings.TrimSpace(contentType) == "" {
|
||||||
|
contentType = "file"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
|
if file.PreviewKind == "video" && view.HasThumbnail {
|
||||||
|
return absoluteURL(r, view.ThumbnailURL)
|
||||||
|
}
|
||||||
|
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageType(file services.File) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialOGType(file services.File) string {
|
||||||
|
switch file.PreviewKind {
|
||||||
|
case "video":
|
||||||
|
return "video.other"
|
||||||
|
default:
|
||||||
|
return "website"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -174,24 +244,65 @@ 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) {
|
||||||
a.serveFileContent(w, r, box, file, false)
|
a.serveFileContent(w, r, box, file, false)
|
||||||
a.logger.Info("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
view := a.fileView(box, file)
|
view := a.fileView(box, file)
|
||||||
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
title := file.Name
|
title := file.Name
|
||||||
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
|
description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
imageURL := socialImageURL(r, box, file, view)
|
||||||
|
imageAlt := fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
ogType := socialOGType(file)
|
||||||
|
mediaURL := ""
|
||||||
|
if file.PreviewKind == "video" {
|
||||||
|
mediaURL = absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox file"
|
title = "Protected Warpbox file"
|
||||||
description = "This shared file is password protected."
|
description = "This shared file is password protected."
|
||||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||||
|
imageAlt = "Password protected file on Warp Box"
|
||||||
|
ogType = "website"
|
||||||
|
mediaURL = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
CanonicalURL: pageURL,
|
||||||
|
Robots: web.RobotsNone,
|
||||||
|
OGType: ogType,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: socialImageType(file),
|
||||||
|
MediaURL: mediaURL,
|
||||||
|
MediaType: file.ContentType,
|
||||||
Data: previewPageData{
|
Data: previewPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
File: view,
|
File: view,
|
||||||
@@ -203,6 +314,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -216,12 +328,23 @@ 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")...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -230,9 +353,25 @@ 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 {
|
||||||
|
if thumbnail := a.generateMissingThumbnailForRequest(r, box, file); thumbnail != "" {
|
||||||
|
file.Thumbnail = thumbnail
|
||||||
|
object, err = a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// The thumbnail isn't generated yet (background job pending). Serve the
|
// The thumbnail isn't generated yet (background job pending). Serve the
|
||||||
// placeholder but mark it non-cacheable, otherwise the browser would
|
// placeholder but mark it non-cacheable, otherwise the browser would
|
||||||
// keep showing the placeholder until a hard refresh once the real
|
// keep showing the placeholder until a hard refresh once the real
|
||||||
@@ -247,6 +386,178 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !jobs.NeedsVideoScenes(file) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||||
|
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
|
||||||
|
file.SceneThumbnail = scene
|
||||||
|
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !jobs.NeedsArchiveListing(file) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||||
|
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||||
|
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
|
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || thumbnail == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand thumbnail generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4102, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].Thumbnail = thumbnail
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand thumbnail metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4103, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
|
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || scene == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].SceneThumbnail = scene
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
|
||||||
|
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) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || listing == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].ArchiveListing = listing
|
||||||
|
box.Files[i].ArchiveListingObjectKey = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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.
|
||||||
@@ -315,9 +626,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
|||||||
defer object.Body.Close()
|
defer object.Body.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", file.ContentType)
|
w.Header().Set("Content-Type", file.ContentType)
|
||||||
|
disposition := "inline"
|
||||||
if attachment {
|
if attachment {
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
disposition = "attachment"
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
|
||||||
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
||||||
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,6 +646,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contentDisposition(disposition, name string) string {
|
||||||
|
filename := cleanDownloadFilename(name)
|
||||||
|
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanDownloadFilename(name string) string {
|
||||||
|
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||||
|
clean = filepath.Base(clean)
|
||||||
|
if clean == "" || clean == "." || clean == "/" {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func asciiFilenameFallback(name string) string {
|
||||||
|
var fallback strings.Builder
|
||||||
|
for _, char := range name {
|
||||||
|
switch {
|
||||||
|
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
|
||||||
|
fallback.WriteByte('_')
|
||||||
|
case char <= 0x7e:
|
||||||
|
fallback.WriteRune(char)
|
||||||
|
default:
|
||||||
|
fallback.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean := strings.TrimSpace(fallback.String())
|
||||||
|
if clean == "" {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||||
data, err := io.ReadAll(source)
|
data, err := io.ReadAll(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -342,6 +688,7 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
|
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
|
||||||
@@ -358,9 +705,25 @@ 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", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
|
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip"))
|
||||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||||
|
|
||||||
if err := a.uploadService.WriteZip(w, box); err != nil {
|
if err := a.uploadService.WriteZip(w, box); err != nil {
|
||||||
@@ -384,12 +747,17 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
|||||||
ID: file.ID,
|
ID: file.ID,
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
|
SizeBytes: file.Size,
|
||||||
ContentType: file.ContentType,
|
ContentType: file.ContentType,
|
||||||
PreviewKind: file.PreviewKind,
|
PreviewKind: file.PreviewKind,
|
||||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||||
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
||||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||||
HasThumbnail: file.Thumbnail != "",
|
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
|
||||||
|
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
|
||||||
|
HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
|
||||||
|
HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
|
||||||
|
HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
|
||||||
IconURL: fileIconURL("standard", icon.Standard),
|
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),
|
||||||
@@ -397,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
backend/libs/handlers/meta.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RobotsTxt serves /robots.txt dynamically so the Sitemap URL reflects the
|
||||||
|
// configured base URL rather than a hard-coded placeholder.
|
||||||
|
func (a *App) RobotsTxt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
fmt.Fprintf(w, `User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Private routes — do not crawl
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /app/
|
||||||
|
Disallow: /account/
|
||||||
|
Disallow: /d/*/f/*/download
|
||||||
|
Disallow: /d/*/zip
|
||||||
|
Disallow: /d/*/thumb/
|
||||||
|
Disallow: /d/*/scene/
|
||||||
|
Disallow: /d/*/archive/
|
||||||
|
Disallow: /d/*/og-image.jpg
|
||||||
|
Disallow: /d/*/unlock
|
||||||
|
Disallow: /d/*/manage/
|
||||||
|
|
||||||
|
Sitemap: %s/sitemap.xml
|
||||||
|
`, strings.TrimRight(siteBaseURL(r, a.cfg.BaseURL), "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SitemapXML serves a minimal /sitemap.xml containing only the public,
|
||||||
|
// indexable homepage. Box/file pages are noindex and deliberately excluded.
|
||||||
|
func (a *App) SitemapXML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
baseURL := strings.TrimRight(siteBaseURL(r, a.cfg.BaseURL), "/")
|
||||||
|
lastMod := time.Now().UTC().Format("2006-01-02")
|
||||||
|
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>%s/</loc>
|
||||||
|
<lastmod>%s</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
`, baseURL, lastMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func siteBaseURL(r *http.Request, configured string) string {
|
||||||
|
if configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return absoluteURL(r, "/")
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
@@ -11,10 +13,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Open Graph image dimensions recommended for large summary cards
|
// Open Graph image dimensions recommended for large summary cards
|
||||||
@@ -74,6 +85,77 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileOGImage renders a branded card for files that should not be served as raw
|
||||||
|
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
|
||||||
|
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jobs.NeedsArchiveListing(file) {
|
||||||
|
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
||||||
|
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := a.ogFileIcon(file)
|
||||||
|
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ogArchiveListing struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
FolderCount int `json:"folderCount"`
|
||||||
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||||
|
Root *ogArchiveNode `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ogArchiveNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Items []*ogArchiveNode `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
|
||||||
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
var listing ogArchiveListing
|
||||||
|
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
if listing.Root == nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
return listing, true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
@@ -115,6 +197,326 @@ func (a *App) ogPlaceholder() image.Image {
|
|||||||
return canvas
|
return canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFileIcon(file services.File) image.Image {
|
||||||
|
if a.fileIcons == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
||||||
|
if icon.Retro == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
|
||||||
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
titleFace := a.ogFont(44, true)
|
||||||
|
bodyFace := a.ogFont(28, false)
|
||||||
|
metaFace := a.ogFont(24, false)
|
||||||
|
buttonFace := a.ogFont(26, true)
|
||||||
|
|
||||||
|
if icon != nil {
|
||||||
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
|
||||||
|
} else {
|
||||||
|
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLines := wrapOGText(file.Name, titleFace, 850)
|
||||||
|
if len(titleLines) > 2 {
|
||||||
|
titleLines = titleLines[:2]
|
||||||
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
|
||||||
|
}
|
||||||
|
y := 156
|
||||||
|
for _, line := range titleLines {
|
||||||
|
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
y += 52
|
||||||
|
}
|
||||||
|
|
||||||
|
size := helpers.FormatBytes(file.Size)
|
||||||
|
typeLabel := strings.TrimSpace(file.ContentType)
|
||||||
|
if typeLabel == "" {
|
||||||
|
typeLabel = "application/octet-stream"
|
||||||
|
}
|
||||||
|
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
|
||||||
|
info := fileCardInfo(file)
|
||||||
|
for i, line := range wrapOGText(info, metaFace, 900) {
|
||||||
|
if i >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
button := image.Rect(110, 474, 430, 548)
|
||||||
|
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
|
||||||
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
titleFace := a.ogFont(36, true)
|
||||||
|
bodyFace := a.ogFont(22, false)
|
||||||
|
treeFace := a.ogFont(19, false)
|
||||||
|
labelFace := a.ogFont(17, true)
|
||||||
|
|
||||||
|
icon := a.ogFileIcon(file)
|
||||||
|
if icon != nil {
|
||||||
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
|
||||||
|
} else {
|
||||||
|
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := listing.Name
|
||||||
|
if strings.TrimSpace(title) == "" {
|
||||||
|
title = file.Name
|
||||||
|
}
|
||||||
|
titleLines := wrapOGText(title, titleFace, 820)
|
||||||
|
if len(titleLines) > 2 {
|
||||||
|
titleLines = titleLines[:2]
|
||||||
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
|
||||||
|
}
|
||||||
|
y := 106
|
||||||
|
for _, line := range titleLines {
|
||||||
|
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
y += 42
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
|
||||||
|
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
|
||||||
|
treePanel := image.Rect(104, 214, 1096, 548)
|
||||||
|
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||||
|
|
||||||
|
rows := archiveOGRows(listing.Root, 13)
|
||||||
|
rowY := treePanel.Min.Y + 64
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Ellipsis {
|
||||||
|
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
x := treePanel.Min.X + 20 + row.Depth*28
|
||||||
|
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
|
||||||
|
name := row.Name
|
||||||
|
if row.Dir {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
maxNameWidth := treePanel.Max.X - x - 170
|
||||||
|
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
|
||||||
|
if !row.Dir {
|
||||||
|
size := formatOGArchiveBytes(row.Size)
|
||||||
|
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||||
|
}
|
||||||
|
rowY += 23
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveOGRow struct {
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
Size uint64
|
||||||
|
Dir bool
|
||||||
|
Depth int
|
||||||
|
Ellipsis bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
|
||||||
|
rows := make([]archiveOGRow, 0, limit+1)
|
||||||
|
truncated := false
|
||||||
|
var walk func(items []*ogArchiveNode, depth int)
|
||||||
|
walk = func(items []*ogArchiveNode, depth int) {
|
||||||
|
for _, item := range items {
|
||||||
|
if len(rows) >= limit {
|
||||||
|
truncated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
icon := item.Icon
|
||||||
|
if item.Dir {
|
||||||
|
icon = "folder"
|
||||||
|
}
|
||||||
|
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
|
||||||
|
if item.Dir {
|
||||||
|
walk(item.Items, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root != nil {
|
||||||
|
walk(root.Items, 0)
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
rows = append(rows, archiveOGRow{Ellipsis: true})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
|
||||||
|
c := archiveOGIconColor(icon)
|
||||||
|
rect := image.Rect(x, y, x+20, y+20)
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
if icon == "folder" {
|
||||||
|
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGIconColor(icon string) color.RGBA {
|
||||||
|
switch icon {
|
||||||
|
case "folder":
|
||||||
|
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
|
||||||
|
case "img":
|
||||||
|
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
|
||||||
|
case "vid":
|
||||||
|
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
|
||||||
|
case "aud":
|
||||||
|
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
|
||||||
|
case "code":
|
||||||
|
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
|
||||||
|
case "arc":
|
||||||
|
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
|
||||||
|
default:
|
||||||
|
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGTextColor(row archiveOGRow) color.RGBA {
|
||||||
|
if row.Dir {
|
||||||
|
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
|
||||||
|
}
|
||||||
|
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
|
||||||
|
if strings.TrimSpace(listing.Type) != "" {
|
||||||
|
return listing.Type
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(file.ContentType) != "" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "Archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOGArchiveBytes(size uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
value := float64(size) / unit
|
||||||
|
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||||
|
}
|
||||||
|
value /= unit
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PiB", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileCardInfo(file services.File) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||||
|
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
|
||||||
|
case file.ContentType == "text/markdown":
|
||||||
|
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "html"):
|
||||||
|
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "pdf"):
|
||||||
|
return "PDF file shared through Warpbox. Open the link to download the original file."
|
||||||
|
case strings.HasPrefix(file.ContentType, "text/"):
|
||||||
|
return "Text file shared through Warpbox. Open the link to preview the content or download."
|
||||||
|
default:
|
||||||
|
return "File shared through Warpbox. Open the link to preview available details or download the original."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFont(size float64, bold bool) font.Face {
|
||||||
|
name := "PixeloidSans.ttf"
|
||||||
|
if bold {
|
||||||
|
name = "PixeloidSans-Bold.ttf"
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
parsed, err := opentype.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
return face
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapOGText(text string, face font.Face, maxWidth int) []string {
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := words[0]
|
||||||
|
for _, word := range words[1:] {
|
||||||
|
next := current + " " + word
|
||||||
|
if ogTextWidth(face, next) <= maxWidth {
|
||||||
|
current = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimOGText(text string, face font.Face, maxWidth int) string {
|
||||||
|
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
|
||||||
|
text = text[:len(text)-1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func ogTextWidth(face font.Face, text string) int {
|
||||||
|
bounds, _ := font.BoundString(face, text)
|
||||||
|
return (bounds.Max.X - bounds.Min.X).Ceil()
|
||||||
|
}
|
||||||
|
|
||||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||||
func renderCollage(thumbs []image.Image) image.Image {
|
func renderCollage(thumbs []image.Image) image.Image {
|
||||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
|||||||
@@ -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,14 +58,18 @@ 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",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.",
|
||||||
|
CanonicalURL: absoluteURL(r, "/"),
|
||||||
|
ImageURL: absoluteURL(r, "/static/og-default.png"),
|
||||||
|
ImageAlt: "Warp Box — simple file sharing and fast downloads",
|
||||||
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,
|
||||||
@@ -95,7 +100,7 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
||||||
ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
ladder := []int{60, 360, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
||||||
|
|
||||||
capMinutes := maxDays * 24 * 60
|
capMinutes := maxDays * 24 * 60
|
||||||
if unlimited || capMinutes <= 0 {
|
if unlimited || capMinutes <= 0 {
|
||||||
@@ -152,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 {
|
||||||
@@ -177,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
payload := uploadThroughApp(t, app)
|
payload := uploadThroughApp(t, app)
|
||||||
@@ -120,15 +120,19 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
|||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(response.Body.String(), "Shared files on Warpbox") {
|
body := response.Body.String()
|
||||||
t.Fatalf("social preview bot received HTML download page")
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
}
|
}
|
||||||
if response.Body.String() != "hello" {
|
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||||
t.Fatalf("social preview body = %q", response.Body.String())
|
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||||
|
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
payload := uploadThroughApp(t, app)
|
payload := uploadThroughApp(t, app)
|
||||||
@@ -140,14 +144,170 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
|||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
app.DownloadFile(response, request)
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
|
||||||
|
t.Fatalf("social preview body missing twitter card metadata: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, request)
|
||||||
|
if uploadResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
previewRequest.SetPathValue("boxID", payload.BoxID)
|
||||||
|
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, previewRequest)
|
||||||
|
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(response.Body.String(), "preview-title") {
|
if strings.Contains(response.Body.String(), "preview-title") {
|
||||||
t.Fatalf("social preview bot received HTML preview page")
|
t.Fatalf("image social preview bot received HTML preview page")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
|
||||||
|
t.Fatalf("image social preview body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(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{
|
||||||
|
`data-size-bytes="5"`,
|
||||||
|
`data-source-url="/d/` + payload.BoxID,
|
||||||
|
`data-download-url="/d/` + payload.BoxID,
|
||||||
|
`data-icon-url="/static/file-icons/`,
|
||||||
|
`data-preview-tabs`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("preview page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadPageShowsProcessingFailure(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
box.Files[0].Processing = false
|
||||||
|
box.Files[0].ProcessingError = "Access Denied."
|
||||||
|
if err := app.uploadService.SaveBox(box); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadPage(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Upload processing failed",
|
||||||
|
"Access Denied.",
|
||||||
|
"is-failed",
|
||||||
|
"Failed",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("download page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
|
||||||
|
t.Fatalf("failed file still exposed download context: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDownloadUsesOriginalFilename(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFileContent(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
disposition := response.Header().Get("Content-Disposition")
|
||||||
|
for _, want := range []string{
|
||||||
|
`attachment;`,
|
||||||
|
`filename="report final.txt"`,
|
||||||
|
`filename*=UTF-8''report%20final.txt`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(disposition, want) {
|
||||||
|
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if response.Body.String() != "hello" {
|
if response.Body.String() != "hello" {
|
||||||
t.Fatalf("social preview body = %q", response.Body.String())
|
t.Fatalf("body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFileContent(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
disposition := response.Header().Get("Content-Disposition")
|
||||||
|
for _, want := range []string{
|
||||||
|
`inline;`,
|
||||||
|
`filename="r_sum_ 2026.txt"`,
|
||||||
|
`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(disposition, want) {
|
||||||
|
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,8 +824,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
|
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
|
||||||
|
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
app.Upload(response, request)
|
app.Upload(response, request)
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -33,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 {
|
||||||
@@ -79,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
|
||||||
@@ -97,30 +116,67 @@ 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.Thumbnail != "" || !needsThumbnail(*file) {
|
if file.Processing || services.FileHasTrouble(*file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||||
|
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||||
|
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||||
|
if !needsPrimary && !needsScenes && !needsArchive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Scanned++
|
result.Scanned++
|
||||||
|
|
||||||
|
if needsPrimary {
|
||||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||||
result.Failed++
|
result.Failed++
|
||||||
continue
|
} else if thumbnail == "" {
|
||||||
}
|
|
||||||
if thumbnail == "" {
|
|
||||||
result.Failed++
|
result.Failed++
|
||||||
continue
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
file.Thumbnail = thumbnail
|
file.Thumbnail = thumbnail
|
||||||
changed = true
|
changed = true
|
||||||
result.Generated++
|
result.Generated++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsScenes {
|
||||||
|
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if sceneThumbnail == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.SceneThumbnail = sceneThumbnail
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsArchive {
|
||||||
|
archiveListing, err := generateArchiveListing(uploadService, box, *file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if archiveListing == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.ArchiveListing = archiveListing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
if err := uploadService.SaveBox(box); err != nil {
|
if err := uploadService.SaveBox(box); err != nil {
|
||||||
@@ -131,10 +187,47 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func needsThumbnail(file services.File) bool {
|
func needsThumbnail(file services.File) bool {
|
||||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsVideoScenes(file services.File) bool {
|
||||||
|
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsThumbnail(file services.File) bool {
|
||||||
|
return needsThumbnail(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsVideoScenes(file services.File) bool {
|
||||||
|
return needsVideoScenes(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsArchiveListing(file services.File) bool {
|
||||||
|
return needsArchiveListing(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateVideoScenesThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateArchiveListing(uploadService, box, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -157,11 +250,308 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
|||||||
}
|
}
|
||||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
return thumbnailName, err
|
return thumbnailName, err
|
||||||
|
case isTextThumbnailCandidate(file):
|
||||||
|
data, err := createTextThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsVideoScenes(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||||
|
}
|
||||||
|
if file.Processing {
|
||||||
|
return "", fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||||
|
}
|
||||||
|
sceneName := "@scene@" + file.ID + ".jpg"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createVideoScenesThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return sceneName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsArchiveListing(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||||
|
}
|
||||||
|
if file.Processing {
|
||||||
|
return "", fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||||
|
}
|
||||||
|
listingName := "@archive@" + file.ID + ".json"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createArchiveListing(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
|
||||||
|
return listingName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTextThumbnailCandidate(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsArchiveListing(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveListingCurrent(file services.File) bool {
|
||||||
|
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveTreeNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Children map[string]*archiveTreeNode `json:"-"`
|
||||||
|
Items []*archiveTreeNode `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveListingData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
FolderCount int `json:"folderCount"`
|
||||||
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||||
|
Root *archiveTreeNode `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := zip.OpenReader(sourceFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
|
||||||
|
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
|
||||||
|
var totalSize uint64
|
||||||
|
var fileCount int
|
||||||
|
var dirCount int
|
||||||
|
for _, entry := range archive.File {
|
||||||
|
name := strings.Trim(entry.Name, "/")
|
||||||
|
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
node := root
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node.Children == nil {
|
||||||
|
node.Children = map[string]*archiveTreeNode{}
|
||||||
|
}
|
||||||
|
child, ok := node.Children[part]
|
||||||
|
if !ok {
|
||||||
|
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
|
||||||
|
node.Children[part] = child
|
||||||
|
if child.Dir {
|
||||||
|
dirCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
}
|
||||||
|
if !entry.FileInfo().IsDir() {
|
||||||
|
node.Dir = false
|
||||||
|
node.Size = entry.UncompressedSize64
|
||||||
|
totalSize += entry.UncompressedSize64
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeArchiveTree(root)
|
||||||
|
data := archiveListingData{
|
||||||
|
Name: file.Name,
|
||||||
|
Type: archiveLabel(file),
|
||||||
|
FileCount: fileCount,
|
||||||
|
FolderCount: dirCount,
|
||||||
|
UncompressedSize: totalSize,
|
||||||
|
Root: root,
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(data, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeArchiveTree(node *archiveTreeNode) {
|
||||||
|
node.Items = sortedArchiveChildren(node)
|
||||||
|
for _, child := range node.Items {
|
||||||
|
if child.Dir {
|
||||||
|
child.Icon = "folder"
|
||||||
|
finalizeArchiveTree(child)
|
||||||
|
} else {
|
||||||
|
child.Icon = archiveFileIconName(child.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
|
||||||
|
children := sortedArchiveChildren(node)
|
||||||
|
for i, child := range children {
|
||||||
|
last := i == len(children)-1
|
||||||
|
branch := "|-- "
|
||||||
|
nextPrefix := prefix + "| "
|
||||||
|
if last {
|
||||||
|
branch = "`-- "
|
||||||
|
nextPrefix = prefix + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString(prefix)
|
||||||
|
out.WriteString(branch)
|
||||||
|
out.WriteString(archiveNodeLabel(child))
|
||||||
|
out.WriteString("\n")
|
||||||
|
if child.Dir {
|
||||||
|
writeArchiveTree(out, child, nextPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
|
||||||
|
children := make([]*archiveTreeNode, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
children = append(children, child)
|
||||||
|
}
|
||||||
|
sort.Slice(children, func(i, j int) bool {
|
||||||
|
if children[i].Dir != children[j].Dir {
|
||||||
|
return children[i].Dir
|
||||||
|
}
|
||||||
|
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
|
||||||
|
})
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveNodeLabel(node *archiveTreeNode) string {
|
||||||
|
if node.Dir {
|
||||||
|
return "[DIR] " + node.Name + "/"
|
||||||
|
}
|
||||||
|
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIcon(name string) string {
|
||||||
|
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIconName(name string) string {
|
||||||
|
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
|
||||||
|
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
|
||||||
|
return "img"
|
||||||
|
case "mp4", "mov", "webm", "mkv", "avi":
|
||||||
|
return "vid"
|
||||||
|
case "mp3", "wav", "flac", "ogg", "m4a":
|
||||||
|
return "aud"
|
||||||
|
case "md", "txt", "log", "csv":
|
||||||
|
return "txt"
|
||||||
|
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
|
||||||
|
return "code"
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return "arc"
|
||||||
|
default:
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveLabel(file services.File) string {
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext != "" {
|
||||||
|
return strings.ToUpper(ext) + " archive"
|
||||||
|
}
|
||||||
|
if file.ContentType != "" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "ZIP-compatible archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatArchiveBytes(size uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div := float64(unit)
|
||||||
|
value := float64(size) / div
|
||||||
|
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||||
|
for _, suffix := range units {
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||||
|
}
|
||||||
|
value /= div
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PiB", value)
|
||||||
|
}
|
||||||
|
|
||||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,17 +580,511 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
|||||||
if err := sourceFile.Close(); err != nil {
|
if err := sourceFile.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sourcePath := sourceFile.Name()
|
||||||
|
candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
|
||||||
|
var fallback []byte
|
||||||
|
for _, timestamp := range candidates {
|
||||||
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetPath := targetFile.Name()
|
targetPath := targetFile.Name()
|
||||||
targetFile.Close()
|
targetFile.Close()
|
||||||
defer os.Remove(targetPath)
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
|
||||||
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(fallback) == 0 {
|
||||||
|
fallback = data
|
||||||
|
}
|
||||||
|
if usableVideoFrame(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
|
||||||
|
if err == nil {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(scenes))
|
||||||
|
if err == nil {
|
||||||
|
thumb := resizeNearest(img, 360, 240)
|
||||||
|
var target bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
|
||||||
|
return target.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fallback) > 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("could not extract a usable video thumbnail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return os.ReadFile(targetPath)
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
|
||||||
|
info := probeVideoInfo(sourcePath, file)
|
||||||
|
timestamps := videoSceneTimestamps(info.Duration)
|
||||||
|
frames := make([]videoSceneFrame, 0, len(timestamps))
|
||||||
|
|
||||||
|
for _, timestamp := range timestamps {
|
||||||
|
targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetPath := targetFile.Name()
|
||||||
|
targetFile.Close()
|
||||||
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
|
||||||
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderVideoScenesThumbnail(file, info, frames), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
|
||||||
|
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoSceneFrame struct {
|
||||||
|
Timestamp string
|
||||||
|
Image image.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoInfo struct {
|
||||||
|
Codec string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Duration float64
|
||||||
|
FrameRate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeVideoInfo(sourcePath string, file services.File) videoInfo {
|
||||||
|
info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
|
||||||
|
output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
|
||||||
|
if err != nil {
|
||||||
|
if file.ContentType != "" {
|
||||||
|
info.Codec = file.ContentType
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || value == "" || value == "N/A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "codec_name":
|
||||||
|
info.Codec = value
|
||||||
|
case "width":
|
||||||
|
info.Width, _ = strconv.Atoi(value)
|
||||||
|
case "height":
|
||||||
|
info.Height, _ = strconv.Atoi(value)
|
||||||
|
case "duration":
|
||||||
|
info.Duration, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "avg_frame_rate":
|
||||||
|
info.FrameRate = simplifyFrameRate(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplifyFrameRate(value string) string {
|
||||||
|
if value == "0/0" || value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
n, errN := strconv.ParseFloat(parts[0], 64)
|
||||||
|
d, errD := strconv.ParseFloat(parts[1], 64)
|
||||||
|
if errN != nil || errD != nil || d == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f fps", n/d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoSceneTimestamps(duration float64) []string {
|
||||||
|
if duration > 4 {
|
||||||
|
points := []float64{0.12, 0.33, 0.58, 0.82}
|
||||||
|
timestamps := make([]string, 0, len(points))
|
||||||
|
for _, point := range points {
|
||||||
|
seconds := duration * point
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
timestamps = append(timestamps, secondsToTimestamp(seconds))
|
||||||
|
}
|
||||||
|
return timestamps
|
||||||
|
}
|
||||||
|
return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToTimestamp(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usableVideoFrame(data []byte) bool {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return averageLuma(img) >= 18
|
||||||
|
}
|
||||||
|
|
||||||
|
func averageLuma(img image.Image) float64 {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
stepX := max(1, width/80)
|
||||||
|
stepY := max(1, height/80)
|
||||||
|
var total float64
|
||||||
|
var samples int
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
|
||||||
|
samples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if samples == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total / float64(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})
|
||||||
|
|
||||||
|
meta := videoMetaLines(file, info)
|
||||||
|
y := 122
|
||||||
|
for _, line := range meta {
|
||||||
|
drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
|
||||||
|
y += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
cells := []image.Rectangle{
|
||||||
|
image.Rect(62, 212, 586, 388),
|
||||||
|
image.Rect(614, 212, 1138, 388),
|
||||||
|
image.Rect(62, 414, 586, 566),
|
||||||
|
image.Rect(614, 414, 1138, 566),
|
||||||
|
}
|
||||||
|
for i, rect := range cells {
|
||||||
|
drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
if i < len(frames) {
|
||||||
|
drawImageCover(canvas, rect, frames[i].Image)
|
||||||
|
drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
|
||||||
|
drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoMetaLines(file services.File, info videoInfo) []string {
|
||||||
|
resolution := "unknown resolution"
|
||||||
|
if info.Width > 0 && info.Height > 0 {
|
||||||
|
resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
|
||||||
|
}
|
||||||
|
duration := "unknown duration"
|
||||||
|
if info.Duration > 0 {
|
||||||
|
duration = secondsToHumanDuration(info.Duration)
|
||||||
|
}
|
||||||
|
contentType := file.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "video"
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
"Duration: " + duration + " Codec: " + info.Codec,
|
||||||
|
"Resolution: " + resolution + " Frame rate: " + info.FrameRate,
|
||||||
|
"Type: " + contentType + " Generated by Warpbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToHumanDuration(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
srcW := bounds.Dx()
|
||||||
|
srcH := bounds.Dy()
|
||||||
|
dstW := rect.Dx()
|
||||||
|
dstH := rect.Dy()
|
||||||
|
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srcRatio := float64(srcW) / float64(srcH)
|
||||||
|
dstRatio := float64(dstW) / float64(dstH)
|
||||||
|
crop := bounds
|
||||||
|
if srcRatio > dstRatio {
|
||||||
|
newW := int(float64(srcH) * dstRatio)
|
||||||
|
x0 := bounds.Min.X + (srcW-newW)/2
|
||||||
|
crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
|
||||||
|
} else if srcRatio < dstRatio {
|
||||||
|
newH := int(float64(srcW) / dstRatio)
|
||||||
|
y0 := bounds.Min.Y + (srcH-newH)/2
|
||||||
|
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
u := float64(x-rect.Min.X) / float64(dstW)
|
||||||
|
v := float64(y-rect.Min.Y) / float64(dstH)
|
||||||
|
srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
|
||||||
|
srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||||
|
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
||||||
|
|
||||||
|
mode := textThumbnailMode(file)
|
||||||
|
title := strings.ToUpper(mode)
|
||||||
|
var lines []string
|
||||||
|
if mode == "HTML" {
|
||||||
|
lines = renderedHTMLThumbnailLines(sourceText)
|
||||||
|
} else if mode == "MARKDOWN" {
|
||||||
|
lines = renderedMarkdownThumbnailLines(sourceText)
|
||||||
|
} else {
|
||||||
|
title = "CODE"
|
||||||
|
lines = codeThumbnailLines(sourceText)
|
||||||
|
}
|
||||||
|
return renderTextThumbnail(file.Name, title, lines), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textThumbnailMode(file services.File) string {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
||||||
|
return "HTML"
|
||||||
|
}
|
||||||
|
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
||||||
|
return "MARKDOWN"
|
||||||
|
}
|
||||||
|
return "CODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedHTMLThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
||||||
|
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
||||||
|
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
||||||
|
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
||||||
|
text = html.UnescapeString(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMarkdownThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
||||||
|
block = strings.Trim(block, "` \n\t")
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
if len(lines) > 1 {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
return "\n" + strings.Join(lines, "\n") + "\n"
|
||||||
|
})
|
||||||
|
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||||
|
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentThumbnailLines(source string) []string {
|
||||||
|
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 9)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
if len(lines) >= 9 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"Rendered preview is empty."}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func codeThumbnailLines(source string) []string {
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 10)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
|
raw = strings.TrimRight(raw, " ")
|
||||||
|
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) > 48 {
|
||||||
|
raw = raw[:45] + "..."
|
||||||
|
}
|
||||||
|
lines = append(lines, raw)
|
||||||
|
if len(lines) >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"(empty file)"}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
||||||
|
|
||||||
|
codePane := image.Rect(22, 72, 338, 210)
|
||||||
|
if mode == "CODE" {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 91
|
||||||
|
for _, line := range lines {
|
||||||
|
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
||||||
|
y += 14
|
||||||
|
if y > 202 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text[:maxChars-3] + "..."}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := ""
|
||||||
|
for _, word := range words {
|
||||||
|
if current == "" {
|
||||||
|
current = word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(current)+1+len(word) <= maxChars {
|
||||||
|
current += " " + word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimThumbnailText(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if maxChars <= 3 {
|
||||||
|
return text[:maxChars]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +50,151 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateMissingThumbnailsForTroubleBoxSkipsWork(t *testing.T) {
|
||||||
|
service := newThumbnailTestUploadService(t)
|
||||||
|
result := createThumbnailTestBox(t, service)
|
||||||
|
box, err := service.GetBox(result.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
box.Trouble = true
|
||||||
|
box.TroubleReason = "storage backend failed"
|
||||||
|
if err := service.SaveBox(box); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if jobResult != (ThumbnailJobResult{}) {
|
||||||
|
t.Fatalf("job result = %+v, want no work for trouble box", jobResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.GetBox(result.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox after job returned error: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Files[0].Thumbnail != "" {
|
||||||
|
t.Fatalf("thumbnail was generated for trouble box: %+v", updated.Files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
|
||||||
|
data, err := createTextThumbnail(services.File{
|
||||||
|
Name: "notes.md",
|
||||||
|
ContentType: "text/markdown",
|
||||||
|
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createTextThumbnail returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||||
|
}
|
||||||
|
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
|
||||||
|
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
|
||||||
|
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
|
||||||
|
t.Fatalf("Go source file should get a text thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsableVideoFrameRejectsBlackFrame(t *testing.T) {
|
||||||
|
var dark bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&dark, solidTestImage(color.RGBA{A: 255}), nil); err != nil {
|
||||||
|
t.Fatalf("jpeg.Encode dark returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usableVideoFrame(dark.Bytes()) {
|
||||||
|
t.Fatalf("black video frame should not be usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bright bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&bright, solidTestImage(color.RGBA{R: 180, G: 80, B: 40, A: 255}), nil); err != nil {
|
||||||
|
t.Fatalf("jpeg.Encode bright returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !usableVideoFrame(bright.Bytes()) {
|
||||||
|
t.Fatalf("bright video frame should be usable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) {
|
||||||
|
data := renderVideoScenesThumbnail(
|
||||||
|
services.File{Name: "clip.mp4", ContentType: "video/mp4"},
|
||||||
|
videoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 125, FrameRate: "24.00 fps"},
|
||||||
|
[]videoSceneFrame{
|
||||||
|
{Timestamp: "00:00:10", Image: solidTestImage(color.RGBA{R: 140, G: 40, B: 80, A: 255})},
|
||||||
|
{Timestamp: "00:00:35", Image: solidTestImage(color.RGBA{R: 40, G: 120, B: 150, A: 255})},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||||
|
}
|
||||||
|
if img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 {
|
||||||
|
t.Fatalf("scene preview size = %dx%d, want 1200x630", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateArchiveListingRendersZipTree(t *testing.T) {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&archive)
|
||||||
|
addZipTestFile(t, writer, "docs/readme.md", "hello")
|
||||||
|
addZipTestFile(t, writer, "src/main.go", "package main\n")
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("zip.Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createArchiveListing returned error: %v", err)
|
||||||
|
}
|
||||||
|
var listing archiveListingData
|
||||||
|
if err := json.Unmarshal(data, &listing); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
|
||||||
|
}
|
||||||
|
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
|
||||||
|
t.Fatalf("archive listing metadata = %+v", listing)
|
||||||
|
}
|
||||||
|
if listing.Root == nil || len(listing.Root.Items) != 2 {
|
||||||
|
t.Fatalf("archive listing root = %+v", listing.Root)
|
||||||
|
}
|
||||||
|
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
|
||||||
|
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
|
||||||
|
}
|
||||||
|
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
|
||||||
|
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
|
||||||
|
}
|
||||||
|
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
|
||||||
|
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
file, err := writer.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("zip file write returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solidTestImage(c color.Color) image.Image {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
|
||||||
|
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||||
|
for x := 0; x < img.Bounds().Dx(); x++ {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
header.Set("X-Frame-Options", "DENY")
|
header.Set("X-Frame-Options", "DENY")
|
||||||
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
|
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-src 'self' about:; base-uri 'self'; frame-ancestors 'none'")
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +130,48 @@ type File struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
PreviewKind string `json:"previewKind"`
|
PreviewKind string `json:"previewKind"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||||
|
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||||
ObjectKey string `json:"objectKey,omitempty"`
|
ObjectKey string `json:"objectKey,omitempty"`
|
||||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||||
|
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||||
|
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
||||||
Processing bool `json:"processing,omitempty"`
|
Processing bool `json:"processing,omitempty"`
|
||||||
ProcessingError string `json:"processingError,omitempty"`
|
ProcessingError string `json:"processingError,omitempty"`
|
||||||
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"`
|
||||||
@@ -397,7 +434,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
|||||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
||||||
objectKey := boxObjectKey(box.ID, storedName)
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
contentType := incoming.ContentType()
|
contentType := incoming.ContentType()
|
||||||
if contentType == "" {
|
if contentType == "" || contentType == "application/octet-stream" {
|
||||||
buffer := make([]byte, 512)
|
buffer := make([]byte, 512)
|
||||||
n, _ := file.Read(buffer)
|
n, _ := file.Read(buffer)
|
||||||
contentType = http.DetectContentType(buffer[:n])
|
contentType = http.DetectContentType(buffer[:n])
|
||||||
@@ -731,6 +768,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
|||||||
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
||||||
_ = backend.Delete(context.Background(), key)
|
_ = backend.Delete(context.Background(), key)
|
||||||
}
|
}
|
||||||
|
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
|
||||||
|
_ = backend.Delete(context.Background(), key)
|
||||||
|
}
|
||||||
|
if key := s.ArchiveListingObjectKey(box, file); key != "" {
|
||||||
|
_ = backend.Delete(context.Background(), key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
||||||
@@ -818,6 +861,26 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
|||||||
return boxObjectKey(box.ID, file.Thumbnail)
|
return boxObjectKey(box.ID, file.Thumbnail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
|
||||||
|
if file.SceneThumbnailObjectKey != "" {
|
||||||
|
return file.SceneThumbnailObjectKey
|
||||||
|
}
|
||||||
|
if file.SceneThumbnail == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.SceneThumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
|
||||||
|
if file.ArchiveListingObjectKey != "" {
|
||||||
|
return file.ArchiveListingObjectKey
|
||||||
|
}
|
||||||
|
if file.ArchiveListing == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.ArchiveListing)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
if file.Processing {
|
if file.Processing {
|
||||||
return StorageObject{}, fmt.Errorf("file is still processing")
|
return StorageObject{}, fmt.Errorf("file is still processing")
|
||||||
@@ -841,6 +904,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
|
|||||||
return backend.Get(ctx, key)
|
return backend.Get(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
key := s.SceneThumbnailObjectKey(box, file)
|
||||||
|
if key == "" {
|
||||||
|
return StorageObject{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
key := s.ArchiveListingObjectKey(box, file)
|
||||||
|
if key == "" {
|
||||||
|
return StorageObject{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
||||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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{
|
||||||
@@ -360,14 +401,14 @@ func TestResumableCleanupRemovesExpiredSessionsAndChunks(t *testing.T) {
|
|||||||
Name: "note.txt",
|
Name: "note.txt",
|
||||||
Size: 4,
|
Size: 4,
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
}}, UploadOptions{MaxDays: 1}, 4, time.Millisecond, "")
|
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateResumableSession returned error: %v", err)
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
|
||||||
t.Fatalf("PutResumableChunk returned error: %v", err)
|
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||||
}
|
}
|
||||||
cleaned, err := service.CleanupExpiredResumableSessions(time.Now().UTC().Add(time.Hour))
|
cleaned, err := service.CleanupExpiredResumableSessions(session.ExpiresAt.Add(time.Second))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CleanupExpiredResumableSessions returned error: %v", err)
|
t.Fatalf("CleanupExpiredResumableSessions returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RobotsNone is used for private, protected, expired, or temporary pages.
|
||||||
|
const RobotsNone = "noindex,nofollow,noarchive"
|
||||||
|
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
appName string
|
appName string
|
||||||
@@ -18,9 +21,16 @@ type PageData struct {
|
|||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
CanonicalURL string
|
||||||
|
Robots string
|
||||||
|
OGType string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
ImageAlt string
|
||||||
|
ImageType string
|
||||||
|
MediaURL string
|
||||||
|
MediaType string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
CurrentUser any
|
CurrentUser any
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
|
|||||||
BIN
backend/static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
backend/static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
backend/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
263
backend/static/css/04-dialogs.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
.warpbox-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 130;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--background) 60%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-overlay.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
max-height: min(34rem, 90vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.6rem) scale(0.98);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-overlay.is-visible .warpbox-dialog {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.1rem 3.25rem 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-icon {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
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-dialog-warning .warpbox-dialog-icon {
|
||||||
|
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-error .warpbox-dialog-icon {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.1rem;
|
||||||
|
right: 1.1rem;
|
||||||
|
z-index: 2;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
width: 1.9rem;
|
||||||
|
padding: 0;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--surface-1);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-close:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--surface-1-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-body {
|
||||||
|
padding: 0.85rem 1.1rem 1.1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-message {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-message:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-field {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-field:focus {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0 1.1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.warpbox-dialog-open,
|
||||||
|
html.warpbox-dialog-open body {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-icon {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-size {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
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, 4px 4px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-head {
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog::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-dialog-error::before {
|
||||||
|
content: "Warpbox - Error";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-warning::before {
|
||||||
|
content: "Warpbox - Warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-info::before {
|
||||||
|
content: "Warpbox - Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-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-dialog-warning .warpbox-dialog-icon {
|
||||||
|
color: #9a5b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon {
|
||||||
|
color: #c00000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-message {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-close {
|
||||||
|
top: 0.36rem;
|
||||||
|
right: 0.3rem;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
min-height: 0.95rem;
|
||||||
|
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.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.warpbox-dialog-overlay {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="retro"] .view-toolbar .icon-button svg {
|
:root[data-theme="retro"] .view-toolbar .icon-button .svg-icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,14 @@
|
|||||||
margin-top: -0.25rem;
|
margin-top: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-options .form-footer .upload-new-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-pwa-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -391,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;
|
||||||
|
|||||||
@@ -15,6 +15,658 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-view {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
min-height: auto;
|
||||||
|
padding-block: clamp(2rem, 7vh, 4.5rem);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card .card-content {
|
||||||
|
padding: clamp(1rem, 2.4vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header .file-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.35rem, 2.4vw, 2rem);
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header .download-subtitle {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header > .button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header > .button svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .preview-header > .button-primary:active {
|
||||||
|
padding-right: calc(1rem - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent));
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar {
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.72rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--muted) 62%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar strong {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-tools {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button {
|
||||||
|
appearance: none;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--primary));
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: color-mix(in srgb, var(--muted) 74%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 18%, var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-actions span {
|
||||||
|
width: 0.72rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--card) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab {
|
||||||
|
appearance: none;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:hover,
|
||||||
|
.preview-tab.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--border) 82%, var(--primary));
|
||||||
|
background: color-mix(in srgb, var(--muted) 78%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: clamp(18rem, 64vh, 38rem);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
|
||||||
|
color-mix(in srgb, var(--background) 88%, #000);
|
||||||
|
background-position: 0 0, 0.5rem 0.5rem;
|
||||||
|
background-size: 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > img,
|
||||||
|
.preview-stage > video {
|
||||||
|
max-height: clamp(18rem, 64vh, 38rem);
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > audio {
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview,
|
||||||
|
.large-preview-gate {
|
||||||
|
width: min(26rem, calc(100% - 2rem));
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview img {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview strong {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview span {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--danger));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate strong {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-scenes-preview {
|
||||||
|
object-fit: contain;
|
||||||
|
background: color-mix(in srgb, var(--background) 88%, black 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-audio-preview {
|
||||||
|
align-self: center;
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
overflow: auto;
|
||||||
|
background: color-mix(in srgb, var(--card) 86%, black 14%);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, black 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-header strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-header span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-tree {
|
||||||
|
padding: 0.6rem 0.8rem 1rem;
|
||||||
|
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-node {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-node-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
padding: 0.18rem 0.45rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-node-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-folder > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-folder > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-chevron,
|
||||||
|
.archive-chevron-spacer,
|
||||||
|
.archive-file-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-chevron {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
transition: transform 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-folder[open] > summary .archive-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-chevron svg {
|
||||||
|
width: 1.18rem;
|
||||||
|
height: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-svg-icon,
|
||||||
|
.archive-retro-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-retro-icon {
|
||||||
|
display: none;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon svg,
|
||||||
|
.archive-chevron svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-folder {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-folder svg {
|
||||||
|
fill: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-img {
|
||||||
|
color: #67e8f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-vid {
|
||||||
|
color: #f9a8d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-aud {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-code {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-arc {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-icon-txt {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-browser-preview {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #808080,
|
||||||
|
inset 1px 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-browser-header {
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #808080,
|
||||||
|
inset 1px 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-browser-header span {
|
||||||
|
color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-tree {
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-node-row {
|
||||||
|
min-height: 1.8rem;
|
||||||
|
border-radius: 0;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-node-row:hover {
|
||||||
|
background: #000078;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-node-size {
|
||||||
|
color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-node-row:hover .archive-node-size {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-chevron {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-folder[open] > summary .archive-chevron {
|
||||||
|
color: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-node-row:hover .archive-chevron,
|
||||||
|
[data-theme="retro"] .archive-node-row:hover .archive-file-icon {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-chevron svg {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
stroke-width: 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-file-icon {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-svg-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-retro-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="retro"] .archive-browser-empty,
|
||||||
|
[data-theme="retro"] .archive-browser-legacy {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-node-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-node-size {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-browser-legacy {
|
||||||
|
min-width: max-content;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder[hidden],
|
||||||
|
.default-preview[hidden],
|
||||||
|
.native-preview[hidden],
|
||||||
|
.archive-browser-preview[hidden],
|
||||||
|
.large-preview-gate[hidden],
|
||||||
|
.code-preview[hidden],
|
||||||
|
.render-preview[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder img {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
overflow: auto;
|
||||||
|
background: #1b1724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre[class*="language-"] {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow: visible;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
color: #f5f3ff;
|
||||||
|
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre[class*="language-"] > code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview code[class*="language-"] {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview .token.punctuation {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
border: 0;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen,
|
||||||
|
.preview-window.is-render-fullscreen {
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window.is-render-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen .preview-stage,
|
||||||
|
.preview-window.is-render-fullscreen .preview-stage {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
place-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen .render-preview,
|
||||||
|
.preview-window.is-render-fullscreen .render-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.file-emblem {
|
.file-emblem {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
@@ -26,7 +678,54 @@
|
|||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-emblem svg {
|
.svg-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background-color: currentColor;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
mask: var(--svg-icon-url) center / contain no-repeat;
|
||||||
|
-webkit-mask: var(--svg-icon-url) center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-document {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/submit-document.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-share {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/share-android.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-download {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/download.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-list {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/list.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-grid {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/view-grid.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-emoji {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/emoji.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-open {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/open-in-browser.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-copy {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/copy.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon-eye {
|
||||||
|
--svg-icon-url: url("/static/icons/regular/eye.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-emblem .svg-icon {
|
||||||
width: 1.75rem;
|
width: 1.75rem;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
}
|
}
|
||||||
@@ -46,6 +745,17 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.is-disabled {
|
||||||
|
opacity: .62;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-share-button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-processing-alert {
|
.upload-processing-alert {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
padding: .85rem 1rem;
|
padding: .85rem 1rem;
|
||||||
@@ -55,6 +765,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;
|
||||||
@@ -160,7 +875,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toolbar svg {
|
.view-toolbar .svg-icon {
|
||||||
width: 0.95rem;
|
width: 0.95rem;
|
||||||
height: 0.95rem;
|
height: 0.95rem;
|
||||||
}
|
}
|
||||||
@@ -218,6 +933,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;
|
||||||
@@ -311,14 +1044,9 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-button svg {
|
.reaction-button .svg-icon {
|
||||||
width: 1.15rem;
|
width: 1.15rem;
|
||||||
height: 1.15rem;
|
height: 1.15rem;
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1.9;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-card:hover .reaction-button,
|
.file-card:hover .reaction-button,
|
||||||
@@ -801,23 +1529,36 @@ html.reaction-picker-open body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-stage {
|
@media (max-width: 720px) {
|
||||||
overflow: hidden;
|
.preview-view {
|
||||||
margin-bottom: 1rem;
|
width: min(100%, calc(100% - 1rem));
|
||||||
border: 1px solid var(--border);
|
padding-block: 1rem;
|
||||||
border-radius: var(--radius);
|
}
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stage img,
|
.preview-header {
|
||||||
.preview-stage video {
|
flex-direction: column;
|
||||||
width: 100%;
|
align-items: stretch;
|
||||||
max-height: 55vh;
|
}
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stage audio {
|
.preview-header .button {
|
||||||
width: calc(100% - 2rem);
|
justify-content: center;
|
||||||
margin: 1rem;
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage,
|
||||||
|
.code-preview,
|
||||||
|
.render-preview,
|
||||||
|
.native-preview {
|
||||||
|
min-height: 18rem;
|
||||||
|
height: min(60vh, 32rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > img,
|
||||||
|
.preview-stage > video {
|
||||||
|
max-height: min(60vh, 32rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
299
backend/static/css/80-markdown-preview.css
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--md-bg: #0b0b16;
|
||||||
|
--md-fg: #f5f3ff;
|
||||||
|
--md-muted: #aaa4d6;
|
||||||
|
--md-panel: #17142d;
|
||||||
|
--md-panel-2: #211b3e;
|
||||||
|
--md-border: rgba(168, 150, 255, 0.24);
|
||||||
|
--md-link: #67e8f9;
|
||||||
|
--md-accent: #a78bfa;
|
||||||
|
--md-code-bg: #1b1724;
|
||||||
|
--md-block-code-bg: #0f111a;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(248, 250, 252, 0.16);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.28);
|
||||||
|
--md-font: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--md-mono: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="classic"] {
|
||||||
|
--md-bg: #09090b;
|
||||||
|
--md-fg: #fafafa;
|
||||||
|
--md-muted: #a1a1aa;
|
||||||
|
--md-panel: #18181b;
|
||||||
|
--md-panel-2: #27272a;
|
||||||
|
--md-border: rgba(255, 255, 255, 0.13);
|
||||||
|
--md-link: #e4e4e7;
|
||||||
|
--md-accent: #d4d4d8;
|
||||||
|
--md-code-bg: #111113;
|
||||||
|
--md-block-code-bg: #09090b;
|
||||||
|
--md-block-code-fg: #fafafa;
|
||||||
|
--md-block-code-border: rgba(250, 250, 250, 0.15);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--md-bg: #c0c0c0;
|
||||||
|
--md-fg: #000000;
|
||||||
|
--md-muted: #404040;
|
||||||
|
--md-panel: #ffffff;
|
||||||
|
--md-panel-2: #dfdfdf;
|
||||||
|
--md-border: #000000;
|
||||||
|
--md-link: #000078;
|
||||||
|
--md-accent: #000078;
|
||||||
|
--md-code-bg: #ffffff;
|
||||||
|
--md-block-code-bg: #000000;
|
||||||
|
--md-block-code-fg: #f5f5f5;
|
||||||
|
--md-block-code-border: #808080;
|
||||||
|
--md-shadow: transparent;
|
||||||
|
--md-font: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
--md-mono: "PixelOperatorMono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--md-bg: #1d2021;
|
||||||
|
--md-fg: #ebdbb2;
|
||||||
|
--md-muted: #bdae93;
|
||||||
|
--md-panel: #282828;
|
||||||
|
--md-panel-2: #32302f;
|
||||||
|
--md-border: rgba(235, 219, 178, 0.2);
|
||||||
|
--md-link: #fabd2f;
|
||||||
|
--md-accent: #d79921;
|
||||||
|
--md-code-bg: #1b1d1e;
|
||||||
|
--md-block-code-bg: #161819;
|
||||||
|
--md-block-code-fg: #fbf1c7;
|
||||||
|
--md-block-code-border: rgba(251, 241, 199, 0.18);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] {
|
||||||
|
--md-bg: #08070d;
|
||||||
|
--md-fg: #fff36f;
|
||||||
|
--md-muted: #9bfaff;
|
||||||
|
--md-panel: #16131f;
|
||||||
|
--md-panel-2: #251d34;
|
||||||
|
--md-border: rgba(255, 242, 0, 0.34);
|
||||||
|
--md-link: #00f0ff;
|
||||||
|
--md-accent: #ff2a6d;
|
||||||
|
--md-code-bg: #100d18;
|
||||||
|
--md-block-code-bg: #07060b;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(0, 240, 255, 0.26);
|
||||||
|
--md-shadow: rgba(255, 42, 109, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixelOperatorMono";
|
||||||
|
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% -10%, color-mix(in srgb, var(--md-accent) 18%, transparent), transparent 24rem),
|
||||||
|
var(--md-bg);
|
||||||
|
color: var(--md-fg);
|
||||||
|
font-family: var(--md-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] {
|
||||||
|
background-color: #000000;
|
||||||
|
background-image: url("/static/backgrounds/stars1.gif");
|
||||||
|
background-repeat: repeat;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||||
|
var(--md-bg);
|
||||||
|
background-size: 100% 3px, 3rem 100%, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(1rem, 4vw, 2.25rem);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--md-panel) 90%, transparent);
|
||||||
|
box-shadow: 0 20px 60px var(--md-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--md-panel);
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #404040,
|
||||||
|
inset 1px 1px 0 #ffffff,
|
||||||
|
inset -2px -2px 0 #808080,
|
||||||
|
inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.5), 0 0 24px rgba(0, 240, 255, 0.12);
|
||||||
|
clip-path: polygon(0 0, calc(100% - 0.9rem) 0, 100% 0.9rem, 100% 100%, 0.9rem 100%, 0 calc(100% - 0.9rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1.4em 0 0.55em;
|
||||||
|
color: var(--md-fg);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.75rem, 5vw, 2.45rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--md-border);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
pre,
|
||||||
|
table {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--md-link);
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--md-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] img,
|
||||||
|
html[data-theme="retro"] video {
|
||||||
|
border-radius: 0;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid var(--md-accent);
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 58%, transparent);
|
||||||
|
color: var(--md-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--md-block-code-border) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--md-block-code-bg) !important;
|
||||||
|
color: var(--md-block-code-fg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--md-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code,
|
||||||
|
pre > code,
|
||||||
|
pre code[class*="language-"] {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
padding: 0.12rem 0.28rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in srgb, var(--md-code-bg) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] pre,
|
||||||
|
html[data-theme="retro"] :not(pre) > code {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 70%, transparent);
|
||||||
|
color: var(--md-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--md-accent);
|
||||||
|
color: var(--md-bg);
|
||||||
|
}
|
||||||
BIN
backend/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
backend/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
backend/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
|
After Width: | Height: | Size: 695 B |
6
backend/static/humans.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* TEAM */
|
||||||
|
Built by: Danlegt
|
||||||
|
|
||||||
|
/* SITE */
|
||||||
|
Language: English
|
||||||
|
Software: Warp Box
|
||||||
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
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
299
backend/static/js/04-dialogs.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
(function () {
|
||||||
|
const VARIANTS = ["info", "warning", "error"];
|
||||||
|
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
window.Warpbox = window.Warpbox || {};
|
||||||
|
let dialogIdCounter = 0;
|
||||||
|
|
||||||
|
function defaultTitle(variant) {
|
||||||
|
if (variant === "error") {
|
||||||
|
return "Error";
|
||||||
|
}
|
||||||
|
if (variant === "warning") {
|
||||||
|
return "Warning";
|
||||||
|
}
|
||||||
|
return "Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "",
|
||||||
|
body: options.body || null,
|
||||||
|
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||||
|
dismissible: options.dismissible !== false,
|
||||||
|
closable: options.closable !== false,
|
||||||
|
onClose: typeof options.onClose === "function" ? options.onClose : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusableElements(container) {
|
||||||
|
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog(options, message) {
|
||||||
|
const config = normalizeOptions(options, message);
|
||||||
|
const previouslyFocused = document.activeElement;
|
||||||
|
dialogIdCounter += 1;
|
||||||
|
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "warpbox-dialog-overlay";
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
|
||||||
|
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
|
||||||
|
card.setAttribute("aria-modal", "true");
|
||||||
|
card.setAttribute("aria-labelledby", titleId);
|
||||||
|
card.setAttribute("tabindex", "-1");
|
||||||
|
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "warpbox-dialog-head";
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "warpbox-dialog-icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = titleId;
|
||||||
|
title.className = "warpbox-dialog-title";
|
||||||
|
title.textContent = config.title;
|
||||||
|
|
||||||
|
head.append(icon, title);
|
||||||
|
|
||||||
|
if (config.closable) {
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "warpbox-dialog-close";
|
||||||
|
close.setAttribute("aria-label", "Close dialog");
|
||||||
|
close.textContent = "x";
|
||||||
|
close.addEventListener("click", () => closeDialog());
|
||||||
|
head.append(close);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "warpbox-dialog-body";
|
||||||
|
|
||||||
|
if (config.message) {
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.className = "warpbox-dialog-message";
|
||||||
|
text.textContent = config.message;
|
||||||
|
body.append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.body) {
|
||||||
|
const nodes = Array.isArray(config.body) ? config.body : [config.body];
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node instanceof Node) {
|
||||||
|
body.append(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
card.append(head, body);
|
||||||
|
|
||||||
|
let autofocusTarget = null;
|
||||||
|
if (config.actions.length > 0) {
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "warpbox-dialog-actions";
|
||||||
|
config.actions.forEach((action) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
|
||||||
|
button.textContent = action.label || "OK";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (typeof action.onClick === "function") {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
if (action.dismiss !== false) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (action.autofocus) {
|
||||||
|
autofocusTarget = button;
|
||||||
|
}
|
||||||
|
actions.append(button);
|
||||||
|
});
|
||||||
|
card.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.append(card);
|
||||||
|
document.body.append(overlay);
|
||||||
|
document.documentElement.classList.add("warpbox-dialog-open");
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add("is-visible");
|
||||||
|
(autofocusTarget || card).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (config.dismissible) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focusable = focusableElements(card);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event) {
|
||||||
|
if (config.dismissible && event.target === overlay) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeydown, true);
|
||||||
|
overlay.addEventListener("click", handleOverlayClick);
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
function closeDialog() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
document.removeEventListener("keydown", handleKeydown, true);
|
||||||
|
overlay.removeEventListener("click", handleOverlayClick);
|
||||||
|
overlay.classList.remove("is-visible");
|
||||||
|
document.documentElement.classList.remove("warpbox-dialog-open");
|
||||||
|
window.setTimeout(() => overlay.remove(), 180);
|
||||||
|
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
if (config.onClose) {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: overlay,
|
||||||
|
close: closeDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Warpbox.dialog = dialog;
|
||||||
|
|
||||||
|
window.Warpbox.alertDialog = function alertDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
function settle(value) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
actions: [
|
||||||
|
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
|
||||||
|
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
|
||||||
|
],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
settle(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Warpbox.promptDialog = function promptDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
function settle(value) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = document.createElement("input");
|
||||||
|
field.type = config.inputType || "text";
|
||||||
|
field.className = "warpbox-dialog-field";
|
||||||
|
if (config.placeholder) {
|
||||||
|
field.placeholder = config.placeholder;
|
||||||
|
}
|
||||||
|
if (typeof config.value === "string") {
|
||||||
|
field.value = config.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = null;
|
||||||
|
field.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
settle(field.value);
|
||||||
|
if (controller) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
controller = dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
body: field,
|
||||||
|
actions: [
|
||||||
|
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
|
||||||
|
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
|
||||||
|
],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
settle(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => field.focus());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
50
backend/static/js/13-share.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
(function () {
|
||||||
|
const shareButtons = document.querySelectorAll("[data-share-box]");
|
||||||
|
if (shareButtons.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareButtons.forEach((button) => {
|
||||||
|
const label = button.querySelector("[data-share-box-label]") || button;
|
||||||
|
const shareData = {
|
||||||
|
title: button.dataset.shareTitle || document.title,
|
||||||
|
text: button.dataset.shareText || "",
|
||||||
|
url: window.Warpbox.absoluteURL(button.dataset.shareUrl || window.location.href),
|
||||||
|
};
|
||||||
|
const canShare = typeof navigator.share === "function" && (!navigator.canShare || navigator.canShare(shareData));
|
||||||
|
|
||||||
|
label.textContent = canShare ? "Share" : "Copy Link";
|
||||||
|
button.setAttribute("aria-label", canShare ? "Share this box" : "Copy box link");
|
||||||
|
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (canShare) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await copyShareURL(button, label, shareData.url, canShare);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyShareURL(button, label, url, shareMode) {
|
||||||
|
try {
|
||||||
|
await window.Warpbox.writeClipboard(url);
|
||||||
|
const previous = label.textContent;
|
||||||
|
label.textContent = "Copied";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
label.textContent = shareMode ? "Share" : "Copy Link";
|
||||||
|
}, 1400);
|
||||||
|
} catch (error) {
|
||||||
|
if (window.Warpbox && typeof window.Warpbox.error === "function") {
|
||||||
|
window.Warpbox.error("The share link could not be copied.", {
|
||||||
|
title: "Copy failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
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";
|
||||||
|
const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024;
|
||||||
|
|
||||||
if (!form || !dropZone || !fileInput) {
|
if (!form || !dropZone || !fileInput) {
|
||||||
return;
|
return;
|
||||||
@@ -47,6 +50,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,8 +99,20 @@
|
|||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) {
|
||||||
|
const proceed = await confirmCellularUpload(selectedFiles);
|
||||||
|
if (!proceed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submit = form.querySelector("button[type='submit']");
|
const submit = form.querySelector("button[type='submit']");
|
||||||
const formData = uploadFormData();
|
const formData = uploadFormData();
|
||||||
@@ -108,8 +126,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 = "";
|
||||||
@@ -117,14 +137,13 @@
|
|||||||
uploadQueue.hidden = true;
|
uploadQueue.hidden = true;
|
||||||
uploadQueue.replaceChildren();
|
uploadQueue.replaceChildren();
|
||||||
}
|
}
|
||||||
if (newUpload) {
|
updateNewUploadVisibility();
|
||||||
newUpload.hidden = true;
|
|
||||||
}
|
|
||||||
if (fileSummary) {
|
if (fileSummary) {
|
||||||
fileSummary.textContent = "Upload complete.";
|
fileSummary.textContent = "Upload complete.";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateStatus(error.message || "Upload failed");
|
updateStatus(error.message || "Upload failed");
|
||||||
|
notifyUploadError(error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false, submit);
|
setLoading(false, submit);
|
||||||
}
|
}
|
||||||
@@ -138,26 +157,218 @@
|
|||||||
|
|
||||||
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 isSlowOrMeteredConnection() {
|
||||||
|
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||||
|
if (!connection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (connection.saveData === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ["slow-2g", "2g", "3g"].includes(connection.effectiveType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalSelectedBytes(files) {
|
||||||
|
return files.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCellularUpload(files) {
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "dialog-file-list";
|
||||||
|
files.forEach((file) => {
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "svg-icon svg-icon-document dialog-file-icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "dialog-file-name";
|
||||||
|
name.textContent = file.name;
|
||||||
|
name.title = file.name;
|
||||||
|
|
||||||
|
const size = document.createElement("span");
|
||||||
|
size.className = "dialog-file-size";
|
||||||
|
size.textContent = window.Warpbox.formatBytes(file.size);
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "dialog-file-row";
|
||||||
|
row.append(icon, name, size);
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files));
|
||||||
|
const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`;
|
||||||
|
|
||||||
|
return window.Warpbox.confirmDialog(message, {
|
||||||
|
title: "Slow connection detected",
|
||||||
|
variant: "warning",
|
||||||
|
body: list,
|
||||||
|
confirmLabel: "Upload anyway",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -177,21 +388,34 @@
|
|||||||
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) {
|
||||||
uploadQueue.hidden = true;
|
uploadQueue.hidden = true;
|
||||||
uploadQueue.replaceChildren();
|
uploadQueue.replaceChildren();
|
||||||
}
|
}
|
||||||
if (newUpload) {
|
updateNewUploadVisibility();
|
||||||
newUpload.hidden = !(resumeMode && recoveredDraft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateNewUploadVisibility() {
|
||||||
|
if (!newUpload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
|
||||||
|
newUpload.hidden = !visible;
|
||||||
|
newUpload.style.display = visible ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoading(isLoading, submit) {
|
function setLoading(isLoading, submit) {
|
||||||
@@ -216,6 +440,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUploadProgress(percent, bytesPerSecond) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(percent || 0)));
|
||||||
|
const rate = formatTransferRate(bytesPerSecond);
|
||||||
|
updateStatus(rate ? `${clamped}% · ${rate}` : `${clamped}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransferRateTracker(initialBytes) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const baseline = Math.max(0, initialBytes || 0);
|
||||||
|
let lastRate = 0;
|
||||||
|
return function track(currentBytes) {
|
||||||
|
const elapsedSeconds = (performance.now() - startedAt) / 1000;
|
||||||
|
const transferred = Math.max(0, (currentBytes || 0) - baseline);
|
||||||
|
if (elapsedSeconds < 0.25 || transferred <= 0) {
|
||||||
|
return lastRate;
|
||||||
|
}
|
||||||
|
lastRate = transferred / elapsedSeconds;
|
||||||
|
return lastRate;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransferRate(bytesPerSecond) {
|
||||||
|
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
|
||||||
|
let value = bytesPerSecond * 8;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1000 && unit < units.length - 1) {
|
||||||
|
value /= 1000;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderResult(payload) {
|
function renderResult(payload) {
|
||||||
if (!result || !resultList || !resultMeta || !openBox) {
|
if (!result || !resultList || !resultMeta || !openBox) {
|
||||||
return;
|
return;
|
||||||
@@ -248,16 +507,18 @@
|
|||||||
function uploadWithProgress(url, formData, files) {
|
function uploadWithProgress(url, formData, files) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
|
const rateTracker = createTransferRateTracker(0);
|
||||||
request.open("POST", url);
|
request.open("POST", url);
|
||||||
request.setRequestHeader("Accept", "application/json");
|
request.setRequestHeader("Accept", "application/json");
|
||||||
|
|
||||||
request.upload.addEventListener("progress", (event) => {
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
const rate = rateTracker(event.loaded || 0);
|
||||||
if (!event.lengthComputable) {
|
if (!event.lengthComputable) {
|
||||||
updateStatus("Uploading...");
|
updateStatus(rate > 0 ? `Uploading · ${formatTransferRate(rate)}` : "Uploading...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const percent = Math.round((event.loaded / event.total) * 100);
|
const percent = Math.round((event.loaded / event.total) * 100);
|
||||||
updateStatus(`${percent}%`);
|
updateUploadProgress(percent, rate);
|
||||||
setTotalProgress(percent);
|
setTotalProgress(percent);
|
||||||
setFileProgress(files, percent);
|
setFileProgress(files, percent);
|
||||||
});
|
});
|
||||||
@@ -348,7 +609,9 @@
|
|||||||
completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize);
|
completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize);
|
||||||
setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size));
|
setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size));
|
||||||
});
|
});
|
||||||
setTotalProgress(percentForBytes(completedByFile.reduce((sum, bytes) => sum + bytes, 0), totalBytes));
|
const initiallyUploadedBytes = completedByFile.reduce((sum, bytes) => sum + bytes, 0);
|
||||||
|
const rateTracker = createTransferRateTracker(initiallyUploadedBytes);
|
||||||
|
setTotalProgress(percentForBytes(initiallyUploadedBytes, totalBytes));
|
||||||
|
|
||||||
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||||
const file = files[fileIndex];
|
const file = files[fileIndex];
|
||||||
@@ -362,9 +625,11 @@
|
|||||||
const end = Math.min(file.size, start + session.chunkSize);
|
const end = Math.min(file.size, start + session.chunkSize);
|
||||||
await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => {
|
await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => {
|
||||||
const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded;
|
const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded;
|
||||||
setTotalProgress(percentForBytes(currentTotal, totalBytes));
|
const percent = percentForBytes(currentTotal, totalBytes);
|
||||||
|
const rate = rateTracker(currentTotal);
|
||||||
|
setTotalProgress(percent);
|
||||||
setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size));
|
setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size));
|
||||||
updateStatus(`${percentForBytes(currentTotal, totalBytes)}%`);
|
updateUploadProgress(percent, rate);
|
||||||
});
|
});
|
||||||
completedByFile[fileIndex] += end - start;
|
completedByFile[fileIndex] += end - start;
|
||||||
uploaded.add(chunkIndex);
|
uploaded.add(chunkIndex);
|
||||||
@@ -749,6 +1014,7 @@
|
|||||||
selectedFiles = [];
|
selectedFiles = [];
|
||||||
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||||
updateSelectedState();
|
updateSelectedState();
|
||||||
|
updateNewUploadVisibility();
|
||||||
updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload.");
|
updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,6 +1022,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) {
|
||||||
@@ -866,20 +1133,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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -918,6 +1187,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";
|
||||||
|
|||||||
1036
backend/static/js/45-preview.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);
|
||||||
|
}
|
||||||
79
backend/static/lib/markdown/marked.umd.js
Normal file
3
backend/static/lib/markdown/purify.min.js
vendored
Normal file
4
backend/static/lib/prismjs/prism.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* PrismJS 1.30.0
|
||||||
|
https://prismjs.com/download#themes=prism-dark&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig&plugins=line-numbers */
|
||||||
|
code[class*=language-],pre[class*=language-]{color:#fff;background:0 0;text-shadow:0 -.1em .2em #000;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}:not(pre)>code[class*=language-],pre[class*=language-]{background:#4c3f33}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border:.3em solid #7a6651;border-radius:.5em;box-shadow:1px 1px .5em #000 inset}:not(pre)>code[class*=language-]{padding:.15em .2em .05em;border-radius:.3em;border:.13em solid #7a6651;box-shadow:1px 1px .3em -.1em #000 inset;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#997f66}.token.punctuation{opacity:.7}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.number,.token.property,.token.symbol,.token.tag{color:#d1939e}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#bce051}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f4b73d}.token.atrule,.token.attr-value,.token.keyword{color:#d1939e}.token.important,.token.regex{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.deleted{color:red}
|
||||||
|
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}
|
||||||
301
backend/static/lib/prismjs/prism.js
Normal file
33
backend/static/llms.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Warp Box
|
||||||
|
|
||||||
|
> Warp Box is a self-hosted file sharing and download hosting service. Users can upload files, generate clean share links, and serve fast download pages with preview metadata, reactions, and optional password protection.
|
||||||
|
|
||||||
|
Warp Box is designed for privacy-first file transfers. Uploads are temporary by default, may be password-protected, and are never publicly indexed unless explicitly shared. Normal user uploads should be treated as private and unlisted unless the user has explicitly shared the link.
|
||||||
|
|
||||||
|
## Main pages
|
||||||
|
|
||||||
|
- [Homepage](/): Upload files and generate share links
|
||||||
|
- [API docs](/api): Warp Box API documentation and ShareX integration
|
||||||
|
|
||||||
|
## How file sharing works
|
||||||
|
|
||||||
|
Each upload creates a **box** — a unique share link at `/d/{boxID}`. A box can contain one or more files. Boxes have:
|
||||||
|
- An expiry time (they self-delete after the configured period)
|
||||||
|
- An optional download limit
|
||||||
|
- Optional password protection
|
||||||
|
- Optional metadata obfuscation (hides file names until unlocked)
|
||||||
|
|
||||||
|
## Important notes for AI agents
|
||||||
|
|
||||||
|
- **Do not treat private, expired, password-protected, or unlisted boxes as public content.** Most boxes are anonymous and temporary.
|
||||||
|
- **Raw download URLs** (`/d/{boxID}/f/{fileID}/download`) are not canonical pages. Prefer the box preview page (`/d/{boxID}`) when referencing a shared file.
|
||||||
|
- Box pages at `/d/{boxID}` are the canonical share URLs.
|
||||||
|
- File preview pages at `/d/{boxID}/f/{fileID}` are per-file landing pages.
|
||||||
|
- `/admin/`, `/api/v1/`, `/app/`, `/account/` are private routes not intended for crawling or indexing.
|
||||||
|
- Do not index or summarize file contents from raw download endpoints.
|
||||||
|
|
||||||
|
## Technical metadata
|
||||||
|
|
||||||
|
- Robots file: /robots.txt
|
||||||
|
- Sitemap: /sitemap.xml
|
||||||
|
- Web manifest: /static/site.webmanifest
|
||||||
BIN
backend/static/og-default.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
40
backend/static/site.webmanifest
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "WarpBox",
|
||||||
|
"short_name": "WarpBox",
|
||||||
|
"description": "Simple file sharing and fast download links. Upload files, generate share links, and serve clean download pages.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b0b16",
|
||||||
|
"theme_color": "#8b5cf6",
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"accept": ["*/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,24 +4,63 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{if .Title}}{{.Title}} - {{end}}{{.AppName}}</title>
|
<title>{{if .Title}}{{.Title}} — {{end}}{{.AppName}}</title>
|
||||||
<meta name="description" content="{{.Description}}">
|
<meta name="description" content="{{.Description}}">
|
||||||
<meta name="theme-color" content="#09090b">
|
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||||
|
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
|
||||||
|
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
||||||
|
|
||||||
<meta property="og:site_name" content="{{.AppName}}">
|
<meta property="og:site_name" content="{{.AppName}}">
|
||||||
|
<meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
|
||||||
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
<meta property="og:description" content="{{.Description}}">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
||||||
<meta property="og:url" content="{{.BaseURL}}">
|
{{if .ImageURL}}
|
||||||
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
|
<meta property="og:image" content="{{.ImageURL}}">
|
||||||
|
<meta property="og:image:secure_url" content="{{.ImageURL}}">
|
||||||
|
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .MediaURL}}
|
||||||
|
{{if eq .OGType "video.other"}}
|
||||||
|
<meta property="og:video" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:video:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq .OGType "music.song"}}
|
||||||
|
<meta property="og:audio" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
|
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<meta name="twitter:image" content="{{.ImageURL}}">
|
||||||
|
{{if .ImageAlt}}<meta name="twitter:image:alt" content="{{.ImageAlt}}">{{else}}<meta name="twitter:image:alt" content="{{.AppName}} preview">{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<link rel="icon" href="/static/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
|
<meta name="theme-color" content="#8b5cf6">
|
||||||
|
<meta name="msapplication-TileColor" content="#0b0b16">
|
||||||
|
|
||||||
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
|
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
|
||||||
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
||||||
|
<link rel="stylesheet" href="/static/css/04-dialogs.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
||||||
<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}}">
|
||||||
@@ -30,13 +69,18 @@
|
|||||||
<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/04-dialogs.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/13-share.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>
|
||||||
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/45-preview.js?version={{.AppVersion}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="dark">
|
<body class="dark">
|
||||||
<a class="skip-link" href="#main">Skip to content</a>
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="card download-card">
|
<div class="card download-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="file-emblem" aria-hidden="true">
|
<div class="file-emblem" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
|
<span class="svg-icon svg-icon-document"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
||||||
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
||||||
@@ -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,19 +43,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if not .Data.Locked}}
|
{{if not .Data.Locked}}
|
||||||
|
<button class="button button-outline button-wide download-share-button" type="button" data-share-box data-share-url="/d/{{.Data.Box.ID}}" data-share-title="{{if .Data.Locked}}Protected Warpbox box{{else}}Warpbox box {{.Data.Box.ID}}{{end}}" data-share-text="Shared files on Warpbox">
|
||||||
|
<span class="svg-icon svg-icon-share" aria-hidden="true"></span>
|
||||||
|
<span data-share-box-label>Share</span>
|
||||||
|
</button>
|
||||||
|
{{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}}">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
Download zip
|
Download zip
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="file-browser-window" data-file-browser-window>
|
<div class="file-browser-window" data-file-browser-window>
|
||||||
<div class="file-browser-titlebar">
|
<div class="file-browser-titlebar">
|
||||||
@@ -64,11 +80,11 @@
|
|||||||
<div class="file-browser-toolbar" aria-label="File view options">
|
<div class="file-browser-toolbar" aria-label="File view options">
|
||||||
<div class="view-toolbar">
|
<div class="view-toolbar">
|
||||||
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
|
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
|
<span class="svg-icon svg-icon-list" aria-hidden="true"></span>
|
||||||
<span class="sr-only">List view</span>
|
<span class="sr-only">List view</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
|
<button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
|
<span class="svg-icon svg-icon-grid" aria-hidden="true"></span>
|
||||||
<span class="sr-only">Icon view</span>
|
<span class="sr-only">Icon view</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +96,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 +108,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>
|
||||||
@@ -112,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{if not .Reacted}}
|
{{if not .Reacted}}
|
||||||
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
|
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg>
|
<span class="svg-icon svg-icon-emoji" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,35 +177,35 @@
|
|||||||
<small>File actions</small>
|
<small>File actions</small>
|
||||||
<div class="context-menu-icons" aria-label="Quick actions">
|
<div class="context-menu-icons" aria-label="Quick actions">
|
||||||
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
|
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
|
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label class="sr-only">Copy</span>
|
<span data-context-label class="sr-only">Copy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="preview">
|
<button type="button" role="menuitem" data-context-action="preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg>
|
<span class="svg-icon svg-icon-eye" aria-hidden="true"></span>
|
||||||
<span data-context-label>Preview</span>
|
<span data-context-label>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="view">
|
<button type="button" role="menuitem" data-context-action="view">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||||
<span data-context-label>View raw file</span>
|
<span data-context-label>View raw file</span>
|
||||||
</button>
|
</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-preview">
|
<button type="button" role="menuitem" data-context-action="copy-preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label>Copy Preview</span>
|
<span data-context-label>Copy Preview</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-download">
|
<button type="button" role="menuitem" data-context-action="copy-download">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label>Copy Download</span>
|
<span data-context-label>Copy Download</span>
|
||||||
</button>
|
</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="download">
|
<button type="button" role="menuitem" data-context-action="download">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
<span data-context-label>Download</span>
|
<span data-context-label>Download</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{define "preview.html"}}{{template "base" .}}{{end}}
|
{{define "preview.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="download-view" aria-labelledby="preview-title">
|
<section class="download-view preview-view" aria-labelledby="preview-title">
|
||||||
<div class="card download-card">
|
<div class="card download-card preview-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .Data.Locked}}
|
{{if .Data.Locked}}
|
||||||
<div class="file-emblem" aria-hidden="true">
|
<div class="file-emblem" aria-hidden="true">
|
||||||
@@ -12,23 +12,70 @@
|
|||||||
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
||||||
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="preview-stage">
|
<header class="preview-header">
|
||||||
{{if eq .Data.File.PreviewKind "image"}}
|
<div class="preview-title-group">
|
||||||
<img src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}">
|
|
||||||
{{else if eq .Data.File.PreviewKind "video"}}
|
|
||||||
<video src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata"></video>
|
|
||||||
{{else if eq .Data.File.PreviewKind "audio"}}
|
|
||||||
<audio src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata"></audio>
|
|
||||||
{{else}}
|
|
||||||
<img src="{{.Data.File.ThumbnailURL}}" alt="">
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
||||||
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
||||||
<a class="button button-primary button-wide" href="{{.Data.DownloadURL}}">
|
</div>
|
||||||
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
||||||
Download file
|
Download
|
||||||
</a>
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
|
||||||
|
<div class="preview-window-titlebar">
|
||||||
|
<div>
|
||||||
|
<strong data-preview-mode-label>Preview</strong>
|
||||||
|
<span>{{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-window-tools">
|
||||||
|
<button class="preview-fullscreen-button" type="button" data-render-fullscreen hidden>Full Screen</button>
|
||||||
|
<div class="preview-window-actions" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-tabs" data-preview-tabs></div>
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="default-preview" data-default-preview hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="" loading="lazy">
|
||||||
|
<div>
|
||||||
|
<strong title="{{.Data.File.Name}}">{{.Data.File.Name}}</strong>
|
||||||
|
<span>{{.Data.File.Size}} · {{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden>
|
||||||
|
<video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video>
|
||||||
|
{{if .Data.File.HasScene}}<img class="native-preview video-scenes-preview" data-video-scenes-preview data-scene-src="{{.Data.File.SceneURL}}" alt="Scenes preview for {{.Data.File.Name}}" hidden>{{end}}
|
||||||
|
<audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio>
|
||||||
|
<div class="code-preview raw-code-preview" data-raw-preview hidden>
|
||||||
|
<pre><code data-raw-output></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
||||||
|
<pre class="line-numbers"><code data-code-output></code></pre>
|
||||||
|
</div>
|
||||||
|
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
|
||||||
|
<div class="archive-preview code-preview" data-archive-preview hidden>
|
||||||
|
<pre><code data-archive-output></code></pre>
|
||||||
|
</div>{{end}}
|
||||||
|
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
|
||||||
|
<div class="large-preview-gate" data-large-preview-gate hidden>
|
||||||
|
<strong>Large preview</strong>
|
||||||
|
<p>This file is larger than 500 KB. Loading this preview may be slow on some devices.</p>
|
||||||
|
<div>
|
||||||
|
<button class="button button-primary" type="button" data-large-preview-confirm>Load anyway</button>
|
||||||
|
<button class="button button-outline" type="button" data-large-preview-cancel>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-placeholder" data-preview-placeholder hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="">
|
||||||
|
<p>Preparing preview...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||