2026-05-25 16:26:47 +03:00
package handlers
import (
2026-05-31 02:14:10 +03:00
"bytes"
2026-06-02 11:30:33 +03:00
"encoding/json"
2026-05-25 16:26:47 +03:00
"errors"
"fmt"
2026-05-31 02:14:10 +03:00
"io"
2026-05-25 16:26:47 +03:00
"net/http"
2026-06-02 11:30:33 +03:00
"net/url"
2026-05-25 16:26:47 +03:00
"os"
2026-05-25 16:52:57 +03:00
"path/filepath"
2026-06-02 11:30:33 +03:00
"sort"
2026-05-25 16:52:57 +03:00
"strings"
2026-05-25 16:26:47 +03:00
"time"
"warpbox.dev/backend/libs/helpers"
2026-06-03 15:20:26 +03:00
"warpbox.dev/backend/libs/jobs"
2026-05-25 16:52:57 +03:00
"warpbox.dev/backend/libs/services"
2026-05-25 16:26:47 +03:00
"warpbox.dev/backend/libs/web"
)
type downloadPageData struct {
Box boxView
Files [ ] fileView
ZipURL string
2026-05-25 16:52:57 +03:00
Locked bool
Obfuscated bool
CanPreview bool
2026-05-25 16:26:47 +03:00
DownloadCount int
MaxDownloads int
ExpiresLabel string
2026-06-02 11:30:33 +03:00
EmojiTabs [ ] emojiTabView
2026-05-25 16:26:47 +03:00
}
type boxView struct {
ID string
}
type fileView struct {
2026-05-25 16:52:57 +03:00
ID string
Name string
Size string
2026-06-03 14:28:50 +03:00
SizeBytes int64
2026-05-25 16:52:57 +03:00
ContentType string
PreviewKind string
URL string
DownloadURL string
ThumbnailURL string
2026-06-05 10:42:30 +03:00
SceneURL string
2026-06-08 03:43:43 +03:00
ArchiveURL string
2026-06-02 13:02:51 +03:00
HasThumbnail bool
2026-06-05 10:42:30 +03:00
HasScene bool
2026-06-08 03:43:43 +03:00
HasArchive bool
2026-06-02 13:02:51 +03:00
IconURL string
IconRetroURL string
2026-06-02 11:30:33 +03:00
ReactURL string
Reactions [ ] reactionView
2026-06-02 14:43:16 +03:00
ReactionMore int
2026-06-02 11:30:33 +03:00
Reacted bool
2026-06-02 22:13:54 +03:00
Processing bool
2026-06-02 11:30:33 +03:00
}
type reactionView struct {
EmojiID string ` json:"emojiId" `
URL string ` json:"url" `
Label string ` json:"label" `
Count int ` json:"count" `
2026-06-02 14:43:16 +03:00
Visible bool ` json:"visible" `
2026-06-02 11:30:33 +03:00
}
type emojiTabView struct {
ID string
Label string
Emojis [ ] emojiOptionView
}
type emojiOptionView struct {
ID string ` json:"id" `
URL string ` json:"url" `
Label string ` json:"label" `
2026-05-25 16:52:57 +03:00
}
type previewPageData struct {
Box boxView
File fileView
Locked bool
DownloadURL string
2026-05-25 16:26:47 +03:00
}
func ( a * App ) DownloadPage ( w http . ResponseWriter , r * http . Request ) {
box , err := a . uploadService . GetBox ( r . PathValue ( "boxID" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "download page missing box" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4040 , "box_id" , r . PathValue ( "boxID" ) ) ... )
2026-05-25 16:26:47 +03:00
http . NotFound ( w , r )
return
}
if err := a . uploadService . CanDownload ( box ) ; err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "download page unavailable" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , statusForDownloadError ( err ) , "box_id" , box . ID , "error" , err . Error ( ) ) ... )
2026-05-30 17:23:20 +03:00
a . renderPage ( w , r , http . StatusForbidden , "download.html" , web . PageData {
2026-05-25 16:26:47 +03:00
Title : "Download unavailable" ,
Description : "This Warpbox link is no longer available." ,
Data : downloadPageData {
Box : boxView { ID : box . ID } ,
ExpiresLabel : err . Error ( ) ,
} ,
} )
return
}
2026-05-25 16:52:57 +03:00
locked := a . uploadService . IsProtected ( box ) && ! a . isBoxUnlocked ( r , box )
2026-06-02 22:13:54 +03:00
if isSocialPreviewBot ( r ) && ! locked && len ( box . Files ) == 1 {
2026-06-03 14:55:19 +03:00
file := box . Files [ 0 ]
if file . Processing {
2026-06-02 22:13:54 +03:00
http . Error ( w , "file is still processing" , http . StatusAccepted )
return
}
2026-06-03 14:55:19 +03:00
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
}
2026-06-02 22:13:54 +03:00
}
2026-06-02 11:30:33 +03:00
visitorID := a . reactionVisitorID ( w , r )
reactionsByFile , reactedByFile , err := a . reactionService . SummaryForBox ( box . ID , visitorID )
if err != nil {
a . logger . Warn ( "failed to load file reactions" , withRequestLogAttrs ( r , "source" , "reactions" , "severity" , "warn" , "code" , 4300 , "box_id" , box . ID , "error" , err . Error ( ) ) ... )
}
2026-05-25 16:26:47 +03:00
files := make ( [ ] fileView , 0 , len ( box . Files ) )
2026-05-25 16:52:57 +03:00
if ! ( locked && box . Obfuscate ) {
for _ , file := range box . Files {
2026-06-02 11:30:33 +03:00
files = append ( files , a . fileViewWithReactions ( box , file , reactionsByFile [ file . ID ] , reactedByFile [ file . ID ] ) )
2026-05-25 16:52:57 +03:00
}
2026-05-25 16:26:47 +03:00
}
2026-06-02 11:30:33 +03:00
emojiTabs , err := a . emojiTabs ( )
if err != nil {
a . logger . Warn ( "failed to load emoji tabs" , withRequestLogAttrs ( r , "source" , "reactions" , "severity" , "warn" , "code" , 4301 , "box_id" , box . ID , "error" , err . Error ( ) ) ... )
}
2026-05-25 16:26:47 +03:00
2026-05-31 22:40:48 +03:00
expiresLabel := boxExpiryLabel ( box . ExpiresAt , "Jan 2, 2006 15:04 MST" )
2026-05-31 17:57:56 +03:00
title := "Shared files on Warpbox"
description := fmt . Sprintf ( "%d file%s shared via Warpbox · expires %s" , len ( box . Files ) , plural ( len ( box . Files ) ) , expiresLabel )
2026-06-03 14:55:19 +03:00
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 )
}
2026-05-31 17:57:56 +03:00
if locked && box . Obfuscate {
title = "Protected Warpbox link"
description = "This shared box is password protected."
}
2026-06-03 12:15:49 +03:00
pageURL := absoluteURL ( r , fmt . Sprintf ( "/d/%s" , box . ID ) )
// All user uploads are private/temporary — noindex by default.
robots := web . RobotsNone
2026-05-30 17:23:20 +03:00
a . renderPage ( w , r , http . StatusOK , "download.html" , web . PageData {
2026-06-03 12:15:49 +03:00
Title : title ,
Description : description ,
CanonicalURL : pageURL ,
Robots : robots ,
ImageURL : ogImage ,
2026-06-03 14:55:19 +03:00
ImageAlt : imageAlt ,
ImageType : imageType ,
2026-05-25 16:26:47 +03:00
Data : downloadPageData {
Box : boxView { ID : box . ID } ,
Files : files ,
ZipURL : fmt . Sprintf ( "/d/%s/zip" , box . ID ) ,
2026-05-25 16:52:57 +03:00
Locked : locked ,
Obfuscated : box . Obfuscate ,
2026-05-25 16:26:47 +03:00
DownloadCount : box . DownloadCount ,
MaxDownloads : box . MaxDownloads ,
2026-05-31 17:57:56 +03:00
ExpiresLabel : expiresLabel ,
2026-06-02 11:30:33 +03:00
EmojiTabs : emojiTabs ,
2026-05-25 16:26:47 +03:00
} ,
} )
2026-06-01 11:30:38 +03:00
a . logger . Info ( "download page viewed" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "user_activity" , "code" , 2003 , "box_id" , box . ID , "locked" , locked ) ... )
2026-05-25 16:26:47 +03:00
}
2026-05-31 17:57:56 +03:00
func plural ( n int ) string {
if n == 1 {
return ""
}
return "s"
}
2026-06-03 14:55:19 +03:00
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"
}
}
2026-05-25 16:26:47 +03:00
func ( a * App ) DownloadFile ( w http . ResponseWriter , r * http . Request ) {
2026-05-25 16:52:57 +03:00
box , file , ok := a . loadFileForRequest ( w , r )
if ! ok {
return
}
locked := a . uploadService . IsProtected ( box ) && ! a . isBoxUnlocked ( r , box )
2026-06-02 22:13:54 +03:00
if isSocialPreviewBot ( r ) && ! locked {
if file . Processing {
http . Error ( w , "file is still processing" , http . StatusAccepted )
return
}
2026-06-03 14:55:19 +03:00
if shouldServeRawSocialMedia ( file ) {
a . serveFileContent ( w , r , box , file , false )
a . logger . Info ( "media file served inline for social preview" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "user_activity" , "code" , 2009 , "box_id" , box . ID , "file_id" , file . ID ) ... )
return
}
2026-06-02 22:13:54 +03:00
}
2026-05-25 16:52:57 +03:00
view := a . fileView ( box , file )
2026-06-03 12:15:49 +03:00
fileSize := helpers . FormatBytes ( file . Size )
2026-05-25 16:52:57 +03:00
title := file . Name
2026-06-03 14:55:19 +03:00
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" )
}
2026-05-25 16:52:57 +03:00
if locked && box . Obfuscate {
title = "Protected Warpbox file"
description = "This shared file is password protected."
imageURL = absoluteURL ( r , "/static/img/file-placeholder.webp" )
2026-06-03 12:15:49 +03:00
imageAlt = "Password protected file on Warp Box"
2026-06-03 14:55:19 +03:00
ogType = "website"
mediaURL = ""
2026-05-25 16:52:57 +03:00
}
2026-06-03 12:15:49 +03:00
pageURL := absoluteURL ( r , fmt . Sprintf ( "/d/%s/f/%s" , box . ID , file . ID ) )
2026-05-30 17:23:20 +03:00
a . renderPage ( w , r , http . StatusOK , "preview.html" , web . PageData {
2026-06-03 12:15:49 +03:00
Title : title ,
Description : description ,
CanonicalURL : pageURL ,
Robots : web . RobotsNone ,
2026-06-03 14:55:19 +03:00
OGType : ogType ,
2026-06-03 12:15:49 +03:00
ImageURL : imageURL ,
ImageAlt : imageAlt ,
2026-06-03 14:55:19 +03:00
ImageType : socialImageType ( file ) ,
MediaURL : mediaURL ,
MediaType : file . ContentType ,
2026-05-25 16:52:57 +03:00
Data : previewPageData {
Box : boxView { ID : box . ID } ,
File : view ,
Locked : locked ,
DownloadURL : view . DownloadURL ,
} ,
} )
2026-06-01 11:30:38 +03:00
a . logger . Info ( "file preview page viewed" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "user_activity" , "code" , 2004 , "box_id" , box . ID , "file_id" , file . ID ) ... )
2026-05-25 16:52:57 +03:00
}
func ( a * App ) DownloadFileContent ( w http . ResponseWriter , r * http . Request ) {
2026-06-03 12:15:49 +03:00
w . Header ( ) . Set ( "X-Robots-Tag" , "noindex, nofollow, noarchive" )
2026-05-25 16:52:57 +03:00
box , file , ok := a . loadFileForRequest ( w , r )
if ! ok {
return
}
if a . uploadService . IsProtected ( box ) && ! a . isBoxUnlocked ( r , box ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "protected file download blocked" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4013 , "box_id" , box . ID , "file_id" , file . ID ) ... )
2026-05-25 16:52:57 +03:00
http . Error ( w , "password required" , http . StatusUnauthorized )
return
}
2026-06-02 22:13:54 +03:00
if file . Processing {
http . Error ( w , "file is still processing" , http . StatusAccepted )
return
}
2026-05-25 16:52:57 +03:00
a . serveFileContent ( w , r , box , file , r . URL . Query ( ) . Get ( "inline" ) != "1" )
2026-06-01 11:30:38 +03:00
a . logger . Info ( "file content served" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "user_activity" , "code" , 2005 , "box_id" , box . ID , "file_id" , file . ID , "attachment" , r . URL . Query ( ) . Get ( "inline" ) != "1" ) ... )
2026-05-25 16:52:57 +03:00
}
func ( a * App ) Thumbnail ( w http . ResponseWriter , r * http . Request ) {
2026-06-03 12:15:49 +03:00
w . Header ( ) . Set ( "X-Robots-Tag" , "noindex, nofollow, noarchive" )
2026-05-25 16:52:57 +03:00
box , file , ok := a . loadFileForRequest ( w , r )
if ! ok {
return
}
if a . uploadService . IsProtected ( box ) && box . Obfuscate && ! a . isBoxUnlocked ( r , box ) {
2026-05-31 17:57:56 +03:00
a . servePlaceholderThumbnail ( w , r )
2026-05-25 16:52:57 +03:00
return
}
2026-05-31 02:14:10 +03:00
object , err := a . uploadService . OpenThumbnailObject ( r . Context ( ) , box , file )
if err != nil {
2026-06-03 15:20:26 +03:00
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
}
}
2026-05-31 17:57:56 +03:00
// 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
// thumbnail lands. The real thumbnail below is content-stable, so it
// gets a long immutable cache.
a . servePlaceholderThumbnail ( w , r )
2026-05-25 16:52:57 +03:00
return
}
2026-05-31 02:14:10 +03:00
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 ) )
2026-05-25 16:52:57 +03:00
}
2026-06-05 10:42:30 +03:00
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 ) )
}
2026-06-08 03:43:43 +03:00
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 ) )
}
2026-06-03 15:20:26 +03:00
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
}
2026-06-05 10:42:30 +03:00
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
}
2026-06-08 03:43:43 +03:00
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
}
2026-05-31 17:57:56 +03:00
// 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.
func ( a * App ) servePlaceholderThumbnail ( w http . ResponseWriter , r * http . Request ) {
w . Header ( ) . Set ( "Cache-Control" , "no-store, must-revalidate" )
http . ServeFile ( w , r , filepath . Join ( a . cfg . StaticDir , "img" , "file-placeholder.webp" ) )
}
2026-05-25 16:52:57 +03:00
func ( a * App ) UnlockBox ( w http . ResponseWriter , r * http . Request ) {
2026-05-25 16:26:47 +03:00
box , err := a . uploadService . GetBox ( r . PathValue ( "boxID" ) )
if err != nil {
http . NotFound ( w , r )
return
}
2026-05-25 16:52:57 +03:00
if err := r . ParseForm ( ) ; err != nil {
http . Redirect ( w , r , fmt . Sprintf ( "/d/%s" , box . ID ) , http . StatusSeeOther )
return
}
if ! a . uploadService . VerifyPassword ( box , r . FormValue ( "password" ) ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "box unlock failed" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "warn" , "code" , 4011 , "box_id" , box . ID ) ... )
2026-05-25 16:52:57 +03:00
http . Redirect ( w , r , fmt . Sprintf ( "/d/%s" , box . ID ) , http . StatusSeeOther )
return
}
http . SetCookie ( w , & http . Cookie {
Name : unlockCookieName ( box . ID ) ,
Value : a . uploadService . UnlockToken ( box ) ,
Path : "/d/" + box . ID ,
HttpOnly : true ,
SameSite : http . SameSiteLaxMode ,
Secure : r . TLS != nil ,
Expires : box . ExpiresAt ,
} )
2026-06-01 11:30:38 +03:00
a . logger . Info ( "box unlocked" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "user_activity" , "code" , 2002 , "box_id" , box . ID ) ... )
2026-05-25 16:52:57 +03:00
http . Redirect ( w , r , fmt . Sprintf ( "/d/%s" , box . ID ) , http . StatusSeeOther )
}
func ( a * App ) loadFileForRequest ( w http . ResponseWriter , r * http . Request ) ( services . Box , services . File , bool ) {
box , err := a . uploadService . GetBox ( r . PathValue ( "boxID" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "file request missing box" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4041 , "box_id" , r . PathValue ( "boxID" ) , "file_id" , r . PathValue ( "fileID" ) ) ... )
2026-05-25 16:52:57 +03:00
http . NotFound ( w , r )
return services . Box { } , services . File { } , false
}
2026-05-25 16:26:47 +03:00
if err := a . uploadService . CanDownload ( box ) ; err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "file request unavailable" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , statusForDownloadError ( err ) , "box_id" , box . ID , "file_id" , r . PathValue ( "fileID" ) , "error" , err . Error ( ) ) ... )
2026-05-25 16:26:47 +03:00
http . Error ( w , err . Error ( ) , statusForDownloadError ( err ) )
2026-05-25 16:52:57 +03:00
return services . Box { } , services . File { } , false
2026-05-25 16:26:47 +03:00
}
file , err := a . uploadService . FindFile ( box , r . PathValue ( "fileID" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "file request missing file" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4042 , "box_id" , box . ID , "file_id" , r . PathValue ( "fileID" ) ) ... )
2026-05-25 16:26:47 +03:00
http . NotFound ( w , r )
2026-05-25 16:52:57 +03:00
return services . Box { } , services . File { } , false
2026-05-25 16:26:47 +03:00
}
2026-05-25 16:52:57 +03:00
return box , file , true
}
2026-05-25 16:26:47 +03:00
2026-05-25 16:52:57 +03:00
func ( a * App ) serveFileContent ( w http . ResponseWriter , r * http . Request , box services . Box , file services . File , attachment bool ) {
2026-05-31 02:14:10 +03:00
object , err := a . uploadService . OpenFileObject ( r . Context ( ) , box , file )
2026-05-25 16:26:47 +03:00
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "file object missing" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4043 , "box_id" , box . ID , "file_id" , file . ID , "error" , err . Error ( ) ) ... )
2026-05-25 16:26:47 +03:00
http . NotFound ( w , r )
return
}
2026-05-31 02:14:10 +03:00
defer object . Body . Close ( )
2026-05-25 16:26:47 +03:00
w . Header ( ) . Set ( "Content-Type" , file . ContentType )
2026-06-08 10:53:20 +03:00
disposition := "inline"
2026-05-25 16:52:57 +03:00
if attachment {
2026-06-08 10:53:20 +03:00
disposition = "attachment"
2026-05-25 16:52:57 +03:00
}
2026-06-08 10:53:20 +03:00
w . Header ( ) . Set ( "Content-Disposition" , contentDisposition ( disposition , file . Name ) )
2026-05-31 02:14:10 +03:00
if seeker , ok := object . Body . ( io . ReadSeeker ) ; ok {
http . ServeContent ( w , r , file . Name , object . ModTime , seeker )
} else {
if object . Size > 0 {
w . Header ( ) . Set ( "Content-Length" , fmt . Sprintf ( "%d" , object . Size ) )
}
w . WriteHeader ( http . StatusOK )
_ , _ = io . Copy ( w , object . Body )
}
2026-05-25 16:26:47 +03:00
if err := a . uploadService . RecordDownload ( box . ID ) ; err != nil && ! errors . Is ( err , os . ErrNotExist ) {
2026-05-25 16:52:57 +03:00
a . logger . Warn ( "failed to record file download" , "source" , "download" , "severity" , "warn" , "code" , 4002 , "box_id" , box . ID , "error" , err . Error ( ) )
2026-05-25 16:26:47 +03:00
}
}
2026-06-08 10:53:20 +03:00
func contentDisposition ( disposition , name string ) string {
filename := cleanDownloadFilename ( name )
return fmt . Sprintf ( ` %s; filename="%s"; filename*=UTF-8''%s ` , disposition , asciiFilenameFallback ( filename ) , url . PathEscape ( filename ) )
}
func cleanDownloadFilename ( name string ) string {
clean := strings . TrimSpace ( strings . ReplaceAll ( name , "\\" , "/" ) )
clean = filepath . Base ( clean )
if clean == "" || clean == "." || clean == "/" {
return "download"
}
return clean
}
func asciiFilenameFallback ( name string ) string {
var fallback strings . Builder
for _ , char := range name {
switch {
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';' :
fallback . WriteByte ( '_' )
case char <= 0x7e :
fallback . WriteRune ( char )
default :
fallback . WriteByte ( '_' )
}
}
clean := strings . TrimSpace ( fallback . String ( ) )
if clean == "" {
return "download"
}
return clean
}
2026-05-31 02:14:10 +03:00
func readSeekCloser ( source io . ReadCloser ) io . ReadSeeker {
data , err := io . ReadAll ( source )
if err != nil {
return bytes . NewReader ( nil )
}
return bytes . NewReader ( data )
}
2026-05-25 16:26:47 +03:00
func ( a * App ) DownloadZip ( w http . ResponseWriter , r * http . Request ) {
2026-06-03 12:15:49 +03:00
w . Header ( ) . Set ( "X-Robots-Tag" , "noindex, nofollow, noarchive" )
2026-05-25 16:26:47 +03:00
box , err := a . uploadService . GetBox ( r . PathValue ( "boxID" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "zip request missing box" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4044 , "box_id" , r . PathValue ( "boxID" ) ) ... )
2026-05-25 16:26:47 +03:00
http . NotFound ( w , r )
return
}
if err := a . uploadService . CanDownload ( box ) ; err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "zip request unavailable" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , statusForDownloadError ( err ) , "box_id" , box . ID , "error" , err . Error ( ) ) ... )
2026-05-25 16:26:47 +03:00
http . Error ( w , err . Error ( ) , statusForDownloadError ( err ) )
return
}
2026-05-25 16:52:57 +03:00
if a . uploadService . IsProtected ( box ) && ! a . isBoxUnlocked ( r , box ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "protected zip download blocked" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "warn" , "code" , 4014 , "box_id" , box . ID ) ... )
2026-05-25 16:52:57 +03:00
http . Error ( w , "password required" , http . StatusUnauthorized )
return
}
2026-05-25 16:26:47 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/zip" )
2026-06-08 10:53:20 +03:00
w . Header ( ) . Set ( "Content-Disposition" , contentDisposition ( "attachment" , "warpbox-" + box . ID + ".zip" ) )
2026-05-25 16:26:47 +03:00
w . Header ( ) . Set ( "Last-Modified" , time . Now ( ) . UTC ( ) . Format ( http . TimeFormat ) )
if err := a . uploadService . WriteZip ( w , box ) ; err != nil {
2026-05-25 16:52:57 +03:00
a . logger . Error ( "zip download failed" , "source" , "download" , "severity" , "error" , "code" , 5002 , "box_id" , box . ID , "error" , err . Error ( ) )
2026-05-25 16:26:47 +03:00
return
}
if err := a . uploadService . RecordDownload ( box . ID ) ; err != nil && ! errors . Is ( err , os . ErrNotExist ) {
2026-05-25 16:52:57 +03:00
a . logger . Warn ( "failed to record zip download" , "source" , "download" , "severity" , "warn" , "code" , 4003 , "box_id" , box . ID , "error" , err . Error ( ) )
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "zip downloaded" , withRequestLogAttrs ( r , "source" , "download" , "severity" , "user_activity" , "code" , 2006 , "box_id" , box . ID , "files" , len ( box . Files ) ) ... )
2026-05-25 16:52:57 +03:00
}
func ( a * App ) fileView ( box services . Box , file services . File ) fileView {
2026-06-02 11:30:33 +03:00
return a . fileViewWithReactions ( box , file , nil , false )
}
func ( a * App ) fileViewWithReactions ( box services . Box , file services . File , reactions [ ] services . ReactionSummary , reacted bool ) fileView {
2026-06-02 13:02:51 +03:00
icon := a . fileIcons . lookup ( file . Name , file . ContentType )
2026-06-02 14:43:16 +03:00
reactionViews := a . reactionViews ( reactions )
2026-05-25 16:52:57 +03:00
return fileView {
ID : file . ID ,
Name : file . Name ,
Size : helpers . FormatBytes ( file . Size ) ,
2026-06-03 14:28:50 +03:00
SizeBytes : file . Size ,
2026-05-25 16:52:57 +03:00
ContentType : file . ContentType ,
PreviewKind : file . PreviewKind ,
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 ) ,
2026-06-05 10:42:30 +03:00
SceneURL : fmt . Sprintf ( "/d/%s/scene/%s" , box . ID , file . ID ) ,
2026-06-08 03:43:43 +03:00
ArchiveURL : fmt . Sprintf ( "/d/%s/archive/%s" , box . ID , file . ID ) ,
2026-06-03 15:22:58 +03:00
HasThumbnail : file . Thumbnail != "" || jobs . NeedsThumbnail ( file ) ,
2026-06-05 10:42:30 +03:00
HasScene : file . SceneThumbnail != "" || jobs . NeedsVideoScenes ( file ) ,
2026-06-08 03:43:43 +03:00
HasArchive : file . ArchiveListing != "" || jobs . NeedsArchiveListing ( file ) ,
2026-06-02 13:02:51 +03:00
IconURL : fileIconURL ( "standard" , icon . Standard ) ,
IconRetroURL : fileIconURL ( "retro" , icon . Retro ) ,
2026-06-02 11:30:33 +03:00
ReactURL : fmt . Sprintf ( "/d/%s/f/%s/react" , box . ID , file . ID ) ,
2026-06-02 14:43:16 +03:00
Reactions : reactionViews ,
ReactionMore : reactionOverflowCount ( reactionViews ) ,
2026-06-02 11:30:33 +03:00
Reacted : reacted ,
2026-06-02 22:13:54 +03:00
Processing : file . Processing ,
2026-06-02 11:30:33 +03:00
}
}
func ( a * App ) ReactToFile ( w http . ResponseWriter , r * http . Request ) {
box , file , ok := a . loadFileForRequest ( w , r )
if ! ok {
return
2026-05-25 16:52:57 +03:00
}
2026-06-02 11:30:33 +03:00
if a . uploadService . IsProtected ( box ) && ! a . isBoxUnlocked ( r , box ) {
http . Error ( w , "password required" , http . StatusUnauthorized )
return
}
if err := r . ParseForm ( ) ; err != nil {
http . Error ( w , "invalid reaction" , http . StatusBadRequest )
return
}
emojiID := strings . TrimSpace ( r . FormValue ( "emoji_id" ) )
if ! a . validEmojiID ( emojiID ) {
http . Error ( w , "unknown emoji" , http . StatusBadRequest )
return
}
visitorID := a . reactionVisitorID ( w , r )
reactions , err := a . reactionService . Add ( box . ID , file . ID , visitorID , emojiID )
if errors . Is ( err , os . ErrExist ) {
writeJSON ( w , http . StatusConflict , map [ string ] any { "error" : "already reacted" } )
return
}
if err != nil {
a . logger . Warn ( "file reaction failed" , withRequestLogAttrs ( r , "source" , "reactions" , "severity" , "warn" , "code" , 4302 , "box_id" , box . ID , "file_id" , file . ID , "error" , err . Error ( ) ) ... )
http . Error ( w , "could not save reaction" , http . StatusInternalServerError )
return
}
a . logger . Info ( "file reaction added" , withRequestLogAttrs ( r , "source" , "reactions" , "severity" , "user_activity" , "code" , 2301 , "box_id" , box . ID , "file_id" , file . ID , "emoji_id" , emojiID ) ... )
writeJSON ( w , http . StatusCreated , map [ string ] any {
"reactions" : a . reactionViews ( reactions ) ,
"reacted" : true ,
} )
}
func ( a * App ) reactionViews ( reactions [ ] services . ReactionSummary ) [ ] reactionView {
views := make ( [ ] reactionView , 0 , len ( reactions ) )
2026-06-02 14:43:16 +03:00
for index , reaction := range reactions {
2026-06-02 11:30:33 +03:00
views = append ( views , reactionView {
EmojiID : reaction . EmojiID ,
URL : emojiURL ( reaction . EmojiID ) ,
Label : emojiLabel ( reaction . EmojiID ) ,
Count : reaction . Count ,
2026-06-02 14:43:16 +03:00
Visible : index < 2 ,
2026-06-02 11:30:33 +03:00
} )
}
return views
}
2026-06-02 14:43:16 +03:00
func reactionOverflowCount ( reactions [ ] reactionView ) int {
if len ( reactions ) <= 2 {
return 0
}
return len ( reactions ) - 2
}
2026-06-02 11:30:33 +03:00
func ( a * App ) emojiTabs ( ) ( [ ] emojiTabView , error ) {
root := a . emojiRoot ( )
entries , err := os . ReadDir ( root )
if err != nil {
if errors . Is ( err , os . ErrNotExist ) {
return nil , nil
}
return nil , err
}
tabs := make ( [ ] emojiTabView , 0 , len ( entries ) )
for _ , entry := range entries {
if ! entry . IsDir ( ) {
continue
}
tabID := entry . Name ( )
files , err := os . ReadDir ( filepath . Join ( root , tabID ) )
if err != nil {
return nil , err
}
tab := emojiTabView { ID : tabID , Label : emojiTabLabel ( tabID ) }
for _ , file := range files {
if file . IsDir ( ) || ! isEmojiFile ( file . Name ( ) ) {
continue
}
emojiID := tabID + "/" + file . Name ( )
tab . Emojis = append ( tab . Emojis , emojiOptionView {
ID : emojiID ,
URL : emojiURL ( emojiID ) ,
Label : emojiLabel ( emojiID ) ,
} )
}
sort . Slice ( tab . Emojis , func ( i , j int ) bool { return tab . Emojis [ i ] . ID < tab . Emojis [ j ] . ID } )
if len ( tab . Emojis ) > 0 {
tabs = append ( tabs , tab )
}
}
sort . Slice ( tabs , func ( i , j int ) bool { return tabs [ i ] . ID < tabs [ j ] . ID } )
return tabs , nil
}
func ( a * App ) validEmojiID ( id string ) bool {
id = strings . TrimSpace ( id )
if id == "" || strings . Contains ( id , "\\" ) || strings . Contains ( id , ".." ) || strings . HasPrefix ( id , "/" ) {
return false
}
parts := strings . Split ( id , "/" )
if len ( parts ) != 2 || parts [ 0 ] == "" || parts [ 1 ] == "" || ! isEmojiFile ( parts [ 1 ] ) {
return false
}
info , err := os . Stat ( filepath . Join ( a . emojiRoot ( ) , parts [ 0 ] , parts [ 1 ] ) )
return err == nil && ! info . IsDir ( )
}
func ( a * App ) emojiRoot ( ) string {
return filepath . Join ( a . cfg . DataDir , "emoji" )
}
func ( a * App ) reactionVisitorID ( w http . ResponseWriter , r * http . Request ) string {
const cookieName = "warpbox_reactor"
if cookie , err := r . Cookie ( cookieName ) ; err == nil && strings . TrimSpace ( cookie . Value ) != "" {
return cookie . Value
}
visitorID := services . RandomPublicToken ( 32 )
http . SetCookie ( w , & http . Cookie {
Name : cookieName ,
Value : visitorID ,
Path : "/" ,
HttpOnly : true ,
SameSite : http . SameSiteLaxMode ,
Secure : r . TLS != nil ,
Expires : time . Now ( ) . AddDate ( 1 , 0 , 0 ) ,
} )
return visitorID
}
func isEmojiFile ( name string ) bool {
ext := strings . ToLower ( filepath . Ext ( name ) )
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}
func emojiTabLabel ( id string ) string {
label := strings . NewReplacer ( "-" , " " , "_" , " " ) . Replace ( id )
if label == "" {
return "Emoji"
}
return strings . ToUpper ( label [ : 1 ] ) + label [ 1 : ]
}
func emojiLabel ( id string ) string {
base := strings . TrimSuffix ( filepath . Base ( id ) , filepath . Ext ( id ) )
return strings . ReplaceAll ( base , "-" , " " )
}
func emojiURL ( id string ) string {
parts := strings . Split ( id , "/" )
if len ( parts ) != 2 {
return ""
}
return "/emoji/" + url . PathEscape ( parts [ 0 ] ) + "/" + url . PathEscape ( parts [ 1 ] )
}
func writeJSON ( w http . ResponseWriter , status int , value any ) {
w . Header ( ) . Set ( "Content-Type" , "application/json; charset=utf-8" )
w . WriteHeader ( status )
_ = json . NewEncoder ( w ) . Encode ( value )
2026-05-25 16:52:57 +03:00
}
func ( a * App ) isBoxUnlocked ( r * http . Request , box services . Box ) bool {
if ! a . uploadService . IsProtected ( box ) {
return true
}
cookie , err := r . Cookie ( unlockCookieName ( box . ID ) )
if err != nil {
return false
}
return cookie . Value == a . uploadService . UnlockToken ( box )
}
func unlockCookieName ( boxID string ) string {
return "warpbox_unlock_" + strings . NewReplacer ( "-" , "_" , "." , "_" ) . Replace ( boxID )
}
2026-05-31 22:40:48 +03:00
// neverExpires reports whether a box's expiry is far enough out to be treated as
// "forever" (set via the unlimited / -1 expiry option).
func neverExpires ( t time . Time ) bool {
return time . Until ( t ) > 50 * 365 * 24 * time . Hour
}
// boxExpiryLabel formats a box's expiry with the given layout, rendering
// "forever" boxes as "Never" instead of a meaningless far-future date.
func boxExpiryLabel ( t time . Time , layout string ) string {
if neverExpires ( t ) {
return "Never"
}
return t . Format ( layout )
}
2026-05-25 16:52:57 +03:00
func absoluteURL ( r * http . Request , path string ) string {
if strings . HasPrefix ( path , "http://" ) || strings . HasPrefix ( path , "https://" ) {
return path
}
scheme := "http"
if r . TLS != nil || r . Header . Get ( "X-Forwarded-Proto" ) == "https" {
scheme = "https"
2026-05-25 16:26:47 +03:00
}
2026-05-25 16:52:57 +03:00
return fmt . Sprintf ( "%s://%s%s" , scheme , r . Host , path )
2026-05-25 16:26:47 +03:00
}
2026-06-02 22:13:54 +03:00
func isSocialPreviewBot ( r * http . Request ) bool {
agent := strings . ToLower ( r . UserAgent ( ) )
if agent == "" {
return false
}
bots := [ ] string {
"discordbot" ,
"twitterbot" ,
"facebookexternalhit" ,
"telegrambot" ,
"whatsapp" ,
"slackbot" ,
"linkedinbot" ,
"skypeuripreview" ,
"embedly" ,
"pinterest" ,
"vkshare" ,
"mattermost" ,
"mastodon" ,
}
for _ , bot := range bots {
if strings . Contains ( agent , bot ) {
return true
}
}
return false
}