Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f | |||
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc |
@@ -132,7 +132,10 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
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}/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}/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 /robots.txt", a.RobotsTxt)
|
||||
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
@@ -46,7 +47,11 @@ type fileView struct {
|
||||
URL string
|
||||
DownloadURL string
|
||||
ThumbnailURL string
|
||||
SceneURL string
|
||||
ArchiveURL string
|
||||
HasThumbnail bool
|
||||
HasScene bool
|
||||
HasArchive bool
|
||||
IconURL string
|
||||
IconRetroURL string
|
||||
ReactURL string
|
||||
@@ -104,14 +109,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||
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)
|
||||
return
|
||||
}
|
||||
a.serveFileContent(w, r, box, box.Files[0], false)
|
||||
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)...)
|
||||
if shouldServeRawSocialMedia(file) {
|
||||
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
|
||||
}
|
||||
}
|
||||
visitorID := a.reactionVisitorID(w, r)
|
||||
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
||||
if err != nil {
|
||||
@@ -132,13 +140,25 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||
title := "Shared files on Warpbox"
|
||||
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 {
|
||||
title = "Protected Warpbox link"
|
||||
description = "This shared box is password protected."
|
||||
}
|
||||
|
||||
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
|
||||
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
||||
|
||||
// All user uploads are private/temporary — noindex by default.
|
||||
robots := web.RobotsNone
|
||||
@@ -149,7 +169,8 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
CanonicalURL: pageURL,
|
||||
Robots: robots,
|
||||
ImageURL: ogImage,
|
||||
ImageAlt: fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files))),
|
||||
ImageAlt: imageAlt,
|
||||
ImageType: imageType,
|
||||
Data: downloadPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
Files: files,
|
||||
@@ -172,6 +193,43 @@ func plural(n int) string {
|
||||
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) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
@@ -184,21 +242,30 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if shouldServeRawSocialMedia(file) {
|
||||
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
|
||||
}
|
||||
}
|
||||
view := a.fileView(box, file)
|
||||
fileSize := helpers.FormatBytes(file.Size)
|
||||
title := file.Name
|
||||
description := fmt.Sprintf("%s · %s file shared via Warp Box", fileSize, file.ContentType)
|
||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
||||
imageAlt := fmt.Sprintf("Preview of %s", file.Name)
|
||||
description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||
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 {
|
||||
title = "Protected Warpbox file"
|
||||
description = "This shared file is password protected."
|
||||
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))
|
||||
@@ -208,8 +275,12 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
Description: description,
|
||||
CanonicalURL: pageURL,
|
||||
Robots: web.RobotsNone,
|
||||
OGType: ogType,
|
||||
ImageURL: imageURL,
|
||||
ImageAlt: imageAlt,
|
||||
ImageType: socialImageType(file),
|
||||
MediaURL: mediaURL,
|
||||
MediaType: file.ContentType,
|
||||
Data: previewPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
File: view,
|
||||
@@ -253,6 +324,17 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||
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
|
||||
// placeholder but mark it non-cacheable, otherwise the browser would
|
||||
// keep showing the placeholder until a hard refresh once the real
|
||||
@@ -267,6 +349,161 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
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 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) {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
||||
// browser re-requests on the next load and picks up the real thumbnail as soon
|
||||
// as it has been generated.
|
||||
@@ -411,7 +648,11 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
||||
URL: fmt.Sprintf("/d/%s/f/%s", 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),
|
||||
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: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
|
||||
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
|
||||
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
|
||||
IconURL: fileIconURL("standard", icon.Standard),
|
||||
IconRetroURL: fileIconURL("retro", icon.Retro),
|
||||
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||
|
||||
@@ -23,6 +23,8 @@ 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/
|
||||
|
||||
@@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
@@ -11,10 +13,19 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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"
|
||||
_ "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
|
||||
@@ -74,6 +85,77 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
@@ -115,6 +197,326 @@ func (a *App) ogPlaceholder() image.Image {
|
||||
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.
|
||||
func renderCollage(thumbs []image.Image) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
||||
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadThroughApp(t, app)
|
||||
@@ -120,15 +120,19 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "Shared files on Warpbox") {
|
||||
t.Fatalf("social preview bot received HTML download page")
|
||||
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 response.Body.String() != "hello" {
|
||||
t.Fatalf("social preview body = %q", response.Body.String())
|
||||
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||
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)
|
||||
defer cleanup()
|
||||
payload := uploadThroughApp(t, app)
|
||||
@@ -143,11 +147,46 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "preview-title") {
|
||||
t.Fatalf("social preview bot received HTML preview page")
|
||||
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 response.Body.String() != "hello" {
|
||||
t.Fatalf("social preview body = %q", response.Body.String())
|
||||
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 {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "preview-title") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
_ "golang.org/x/image/webp"
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -101,26 +113,57 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
||||
changed := false
|
||||
for i := range box.Files {
|
||||
file := &box.Files[i]
|
||||
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
||||
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||
if !needsPrimary && !needsScenes && !needsArchive {
|
||||
continue
|
||||
}
|
||||
result.Scanned++
|
||||
|
||||
if needsPrimary {
|
||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||
if err != nil {
|
||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
if thumbnail == "" {
|
||||
} else if thumbnail == "" {
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
} else {
|
||||
file.Thumbnail = thumbnail
|
||||
changed = true
|
||||
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 err := uploadService.SaveBox(box); err != nil {
|
||||
@@ -131,7 +174,35 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -157,11 +228,290 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
||||
}
|
||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||
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:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
if !needsVideoScenes(file) {
|
||||
return "", nil
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
img, _, err := image.Decode(source)
|
||||
if err != nil {
|
||||
@@ -190,17 +540,511 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
||||
if err := sourceFile.Close(); err != nil {
|
||||
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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPath := targetFile.Name()
|
||||
targetFile.Close()
|
||||
defer os.Remove(targetPath)
|
||||
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 {
|
||||
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); 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 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 {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -46,6 +50,121 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
@@ -128,8 +128,12 @@ type File struct {
|
||||
ContentType string `json:"contentType"`
|
||||
PreviewKind string `json:"previewKind"`
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||
ObjectKey string `json:"objectKey,omitempty"`
|
||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
||||
Processing bool `json:"processing,omitempty"`
|
||||
ProcessingError string `json:"processingError,omitempty"`
|
||||
UploadedAt time.Time `json:"uploadedAt"`
|
||||
@@ -397,7 +401,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
||||
objectKey := boxObjectKey(box.ID, storedName)
|
||||
contentType := incoming.ContentType()
|
||||
if contentType == "" {
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := file.Read(buffer)
|
||||
contentType = http.DetectContentType(buffer[:n])
|
||||
@@ -731,6 +735,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
||||
if key := s.ThumbnailObjectKey(box, file); 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:]...)
|
||||
@@ -818,6 +828,26 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
||||
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) {
|
||||
if file.Processing {
|
||||
return StorageObject{}, fmt.Errorf("file is still processing")
|
||||
@@ -841,6 +871,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
|
||||
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) {
|
||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||
if err != nil {
|
||||
|
||||
@@ -23,10 +23,14 @@ type PageData struct {
|
||||
BaseURL string
|
||||
CanonicalURL string
|
||||
Robots string
|
||||
OGType string
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
ImageAlt string
|
||||
ImageType string
|
||||
MediaURL string
|
||||
MediaType string
|
||||
CurrentYear int
|
||||
CurrentUser any
|
||||
CSRFToken string
|
||||
|
||||
@@ -54,6 +54,20 @@
|
||||
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));
|
||||
@@ -260,12 +274,281 @@
|
||||
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;
|
||||
@@ -278,6 +561,7 @@
|
||||
.preview-placeholder[hidden],
|
||||
.default-preview[hidden],
|
||||
.native-preview[hidden],
|
||||
.archive-browser-preview[hidden],
|
||||
.large-preview-gate[hidden],
|
||||
.code-preview[hidden],
|
||||
.render-preview[hidden] {
|
||||
|
||||
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 B |
@@ -16,6 +16,8 @@
|
||||
sourceURL: preview.dataset.sourceUrl || "",
|
||||
downloadURL: preview.dataset.downloadUrl || "",
|
||||
iconURL: preview.dataset.iconUrl || "",
|
||||
sceneURL: preview.dataset.sceneUrl || "",
|
||||
archiveURL: preview.dataset.archiveUrl || "",
|
||||
activeMode: "",
|
||||
defaultMode: "default",
|
||||
pendingMode: "",
|
||||
@@ -24,6 +26,11 @@
|
||||
rawLoaded: false,
|
||||
prismLoaded: false,
|
||||
renderLoaded: false,
|
||||
sceneLoaded: false,
|
||||
archiveLoaded: false,
|
||||
archiveUIRendered: false,
|
||||
archiveData: null,
|
||||
archiveText: "",
|
||||
renderFullscreenFallback: false,
|
||||
confirmedLargeModes: {},
|
||||
tabs: []
|
||||
@@ -35,11 +42,15 @@
|
||||
defaultPane: preview.querySelector("[data-default-preview]"),
|
||||
imagePane: preview.querySelector("[data-image-preview]"),
|
||||
videoPane: preview.querySelector("[data-video-preview]"),
|
||||
videoScenesPane: preview.querySelector("[data-video-scenes-preview]"),
|
||||
browserAudioPane: preview.querySelector("[data-browser-audio-preview]"),
|
||||
rawPane: preview.querySelector("[data-raw-preview]"),
|
||||
rawOutput: preview.querySelector("[data-raw-output]"),
|
||||
codePane: preview.querySelector("[data-code-preview]"),
|
||||
codeOutput: preview.querySelector("[data-code-output]"),
|
||||
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
|
||||
archivePane: preview.querySelector("[data-archive-preview]"),
|
||||
archiveOutput: preview.querySelector("[data-archive-output]"),
|
||||
renderPane: preview.querySelector("[data-render-preview]"),
|
||||
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
||||
gatePane: preview.querySelector("[data-large-preview-gate]"),
|
||||
@@ -65,6 +76,7 @@
|
||||
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
|
||||
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
|
||||
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
|
||||
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
|
||||
|
||||
return {
|
||||
extension: extension,
|
||||
@@ -76,6 +88,7 @@
|
||||
isImage: isImage,
|
||||
isVideo: isVideo,
|
||||
isAudio: isAudio,
|
||||
isArchive: isArchive,
|
||||
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
||||
};
|
||||
}
|
||||
@@ -90,6 +103,9 @@
|
||||
|
||||
if (type.isVideo) {
|
||||
tabs.push({ mode: "video", label: "Video Preview" });
|
||||
if (state.sceneURL && els.videoScenesPane) {
|
||||
tabs.push({ mode: "scenes", label: "Scenes Preview" });
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@@ -98,6 +114,12 @@
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isArchive) {
|
||||
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
|
||||
tabs.push({ mode: "archive", label: "Text Tree" });
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isTextLike) {
|
||||
if (type.isHTML || type.isMarkdown) {
|
||||
tabs.push({ mode: "render", label: "Render Preview" });
|
||||
@@ -110,19 +132,21 @@
|
||||
}
|
||||
|
||||
function chooseDefaultMode(type, tabs) {
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (type.isImage) {
|
||||
return "image";
|
||||
}
|
||||
if (type.isVideo) {
|
||||
return "video";
|
||||
}
|
||||
if (type.isArchive) {
|
||||
return "archive-ui";
|
||||
}
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
if (type.isAudio) {
|
||||
return "browser-audio";
|
||||
}
|
||||
@@ -182,6 +206,9 @@
|
||||
show(els.imagePane);
|
||||
} else if (mode === "video") {
|
||||
show(els.videoPane);
|
||||
} else if (mode === "scenes") {
|
||||
show(els.videoScenesPane);
|
||||
ensureScenesPreview();
|
||||
} else if (mode === "browser-audio") {
|
||||
show(els.browserAudioPane);
|
||||
} else if (mode === "raw") {
|
||||
@@ -190,6 +217,12 @@
|
||||
} else if (mode === "code") {
|
||||
show(els.codePane);
|
||||
ensurePrismPreview();
|
||||
} else if (mode === "archive-ui") {
|
||||
show(els.archiveBrowserPane);
|
||||
ensureArchiveBrowserPreview();
|
||||
} else if (mode === "archive") {
|
||||
show(els.archivePane);
|
||||
ensureArchivePreview();
|
||||
} else if (mode === "render") {
|
||||
show(els.renderPane);
|
||||
if (fileType.isMarkdown) {
|
||||
@@ -404,9 +437,12 @@
|
||||
hide(els.defaultPane);
|
||||
hide(els.imagePane);
|
||||
hide(els.videoPane);
|
||||
hide(els.videoScenesPane);
|
||||
hide(els.browserAudioPane);
|
||||
hide(els.rawPane);
|
||||
hide(els.codePane);
|
||||
hide(els.archiveBrowserPane);
|
||||
hide(els.archivePane);
|
||||
hide(els.renderPane);
|
||||
hide(els.gatePane);
|
||||
hide(els.placeholder);
|
||||
@@ -499,14 +535,275 @@
|
||||
"default": "Default",
|
||||
"image": "Image preview",
|
||||
"video": "Video preview",
|
||||
"scenes": "Scenes preview",
|
||||
"browser-audio": "Browser preview",
|
||||
"raw": "Raw preview",
|
||||
"code": "Code preview",
|
||||
"archive-ui": "Archive preview",
|
||||
"archive": "Archive preview",
|
||||
"render": "Render preview"
|
||||
};
|
||||
return labels[mode] || "Preview";
|
||||
}
|
||||
|
||||
function ensureScenesPreview() {
|
||||
if (state.sceneLoaded || !els.videoScenesPane) {
|
||||
return;
|
||||
}
|
||||
var src = els.videoScenesPane.dataset.sceneSrc || state.sceneURL;
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
els.videoScenesPane.src = src;
|
||||
state.sceneLoaded = true;
|
||||
}
|
||||
|
||||
function ensureArchivePreview() {
|
||||
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
var text = state.archiveText || archiveDataToText(state.archiveData);
|
||||
els.archiveOutput.textContent = text;
|
||||
state.archiveLoaded = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archivePane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveBrowserPreview() {
|
||||
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
renderArchiveBrowser(state.archiveData);
|
||||
state.archiveUIRendered = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archiveBrowserPane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveData() {
|
||||
if (state.archiveData || state.archiveText) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
showLoading("Loading archive contents...");
|
||||
return fetch(state.archiveURL, { credentials: "same-origin" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("Archive preview could not be loaded.");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
try {
|
||||
state.archiveData = JSON.parse(text);
|
||||
} catch (error) {
|
||||
state.archiveText = text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderArchiveBrowser(data) {
|
||||
if (!els.archiveBrowserPane) {
|
||||
return;
|
||||
}
|
||||
els.archiveBrowserPane.innerHTML = "";
|
||||
if (!data || !data.root) {
|
||||
var fallback = document.createElement("pre");
|
||||
fallback.className = "archive-browser-legacy";
|
||||
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
|
||||
els.archiveBrowserPane.appendChild(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
var header = document.createElement("div");
|
||||
header.className = "archive-browser-header";
|
||||
header.innerHTML = "<strong></strong><span></span>";
|
||||
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
|
||||
header.querySelector("span").textContent = [
|
||||
data.type || "Archive",
|
||||
formatArchiveCount(data.fileCount, "file"),
|
||||
formatArchiveCount(data.folderCount, "folder"),
|
||||
formatBytes(data.uncompressedSize || 0)
|
||||
].filter(Boolean).join(" · ");
|
||||
els.archiveBrowserPane.appendChild(header);
|
||||
|
||||
var tree = document.createElement("div");
|
||||
tree.className = "archive-tree";
|
||||
var items = data.root.items || [];
|
||||
if (items.length === 0) {
|
||||
var emptyTree = document.createElement("p");
|
||||
emptyTree.className = "archive-browser-empty";
|
||||
emptyTree.textContent = "This archive is empty.";
|
||||
tree.appendChild(emptyTree);
|
||||
} else {
|
||||
items.forEach(function (item) {
|
||||
tree.appendChild(renderArchiveNode(item, 0));
|
||||
});
|
||||
}
|
||||
els.archiveBrowserPane.appendChild(tree);
|
||||
}
|
||||
|
||||
function renderArchiveNode(node, depth) {
|
||||
var row = document.createElement(node.dir ? "details" : "div");
|
||||
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
|
||||
if (node.dir && depth < 1) {
|
||||
row.open = true;
|
||||
}
|
||||
|
||||
var summary = document.createElement(node.dir ? "summary" : "div");
|
||||
summary.className = "archive-node-row";
|
||||
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
|
||||
|
||||
if (node.dir) {
|
||||
summary.appendChild(createArchiveChevron());
|
||||
} else {
|
||||
var spacer = document.createElement("span");
|
||||
spacer.className = "archive-chevron-spacer";
|
||||
summary.appendChild(spacer);
|
||||
}
|
||||
|
||||
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "archive-node-name";
|
||||
name.textContent = node.name + (node.dir ? "/" : "");
|
||||
summary.appendChild(name);
|
||||
|
||||
if (!node.dir) {
|
||||
var size = document.createElement("span");
|
||||
size.className = "archive-node-size";
|
||||
size.textContent = formatBytes(node.size || 0);
|
||||
summary.appendChild(size);
|
||||
}
|
||||
|
||||
row.appendChild(summary);
|
||||
if (node.dir) {
|
||||
(node.items || []).forEach(function (child) {
|
||||
row.appendChild(renderArchiveNode(child, depth + 1));
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function createArchiveChevron() {
|
||||
var chevron = document.createElement("span");
|
||||
chevron.className = "archive-chevron";
|
||||
chevron.setAttribute("aria-hidden", "true");
|
||||
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
|
||||
return chevron;
|
||||
}
|
||||
|
||||
function createArchiveIcon(icon) {
|
||||
var element = document.createElement("span");
|
||||
element.className = "archive-file-icon archive-file-icon-" + icon;
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
element.innerHTML = '<span class="archive-svg-icon">' + archiveIconSVG(icon) + '</span>';
|
||||
var retroURL = archiveRetroIconURL(icon);
|
||||
if (retroURL) {
|
||||
var retro = document.createElement("img");
|
||||
retro.className = "archive-retro-icon";
|
||||
retro.src = retroURL;
|
||||
retro.alt = "";
|
||||
retro.decoding = "async";
|
||||
retro.loading = "lazy";
|
||||
element.appendChild(retro);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function archiveDataToText(data) {
|
||||
if (!data || !data.root) {
|
||||
return state.archiveText || "";
|
||||
}
|
||||
var lines = [
|
||||
"Archive preview",
|
||||
"Name: " + (data.name || state.fileName || "Archive"),
|
||||
"Type: " + (data.type || "Archive"),
|
||||
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
|
||||
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
|
||||
"",
|
||||
"."
|
||||
];
|
||||
appendArchiveTextLines(lines, data.root.items || [], "");
|
||||
if (!(data.root.items || []).length) {
|
||||
lines.push("(empty archive)");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendArchiveTextLines(lines, items, prefix) {
|
||||
items.forEach(function (item, index) {
|
||||
var last = index === items.length - 1;
|
||||
var branch = last ? "`-- " : "|-- ";
|
||||
var nextPrefix = prefix + (last ? " " : "| ");
|
||||
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
|
||||
lines.push(prefix + branch + label);
|
||||
if (item.dir) {
|
||||
appendArchiveTextLines(lines, item.items || [], nextPrefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function archiveIconSVG(icon) {
|
||||
var icons = {
|
||||
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
|
||||
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
|
||||
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
|
||||
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
|
||||
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
|
||||
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
|
||||
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
|
||||
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
|
||||
};
|
||||
return icons[icon] || icons.file;
|
||||
}
|
||||
|
||||
function archiveRetroIconURL(icon) {
|
||||
var base = "/static/file-icons/retro/";
|
||||
var icons = {
|
||||
folder: "directory_open_file_mydocs-4.png",
|
||||
img: "shimgvw.dll_14_1-2.png",
|
||||
vid: "wmploc.dll_14_504-2.png",
|
||||
aud: "wmploc.dll_14_610-2.png",
|
||||
txt: "shell32.dll_14_151-2.png",
|
||||
code: "mshtml.dll_14_2660-2.png",
|
||||
arc: "zipfldr.dll_14_101-2.png",
|
||||
file: "shell32.dll_14_152-2.png"
|
||||
};
|
||||
return base + (icons[icon] || icons.file);
|
||||
}
|
||||
|
||||
function formatArchiveCount(value, label) {
|
||||
value = Number(value || 0);
|
||||
return value + " " + label + (value === 1 ? "" : "s");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
value = Number(value || 0);
|
||||
if (value < 1024) {
|
||||
return value + " B";
|
||||
}
|
||||
var units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
var size = value / 1024;
|
||||
for (var i = 0; i < units.length; i++) {
|
||||
if (size < 1024 || i === units.length - 1) {
|
||||
return size.toFixed(1) + " " + units[i];
|
||||
}
|
||||
size /= 1024;
|
||||
}
|
||||
return value + " B";
|
||||
}
|
||||
|
||||
function loadPrism() {
|
||||
if (window.Prism) {
|
||||
return Promise.resolve();
|
||||
@@ -633,6 +930,28 @@
|
||||
return parts.length > 1 ? parts.pop() : "";
|
||||
}
|
||||
|
||||
function isArchiveFile(extension, baseType) {
|
||||
var archiveExtensions = {
|
||||
"apk": true,
|
||||
"docx": true,
|
||||
"ear": true,
|
||||
"epub": true,
|
||||
"jar": true,
|
||||
"pptx": true,
|
||||
"war": true,
|
||||
"xlsx": true,
|
||||
"zip": true
|
||||
};
|
||||
var archiveTypes = {
|
||||
"application/epub+zip": true,
|
||||
"application/java-archive": true,
|
||||
"application/vnd.android.package-archive": true,
|
||||
"application/x-zip-compressed": true,
|
||||
"application/zip": true
|
||||
};
|
||||
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
|
||||
}
|
||||
|
||||
function languageFor(extension, baseType) {
|
||||
var extensionMap = {
|
||||
"c": "c",
|
||||
|
||||
@@ -11,16 +11,30 @@
|
||||
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
||||
|
||||
<meta property="og:site_name" content="{{.AppName}}">
|
||||
<meta property="og:type" content="website">
|
||||
<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:description" content="{{.Description}}">
|
||||
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
||||
{{if .ImageURL}}
|
||||
<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:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</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}}">
|
||||
<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>
|
||||
@@ -49,6 +49,7 @@
|
||||
</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>
|
||||
@@ -56,6 +57,10 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user