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-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
ContentType string
PreviewKind string
URL string
DownloadURL string
ThumbnailURL string
2026-06-02 13:02:51 +03:00
HasThumbnail bool
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
}
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 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 )
if locked && box . Obfuscate {
title = "Protected Warpbox link"
description = "This shared box is password protected."
}
2026-05-30 17:23:20 +03:00
a . renderPage ( w , r , http . StatusOK , "download.html" , web . PageData {
2026-05-31 17:57:56 +03:00
Title : title ,
Description : description ,
ImageURL : absoluteURL ( r , fmt . Sprintf ( "/d/%s/og-image.jpg" , box . ID ) ) ,
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-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 )
view := a . fileView ( box , file )
title := file . Name
description := fmt . Sprintf ( "%s shared via Warpbox" , helpers . FormatBytes ( file . Size ) )
imageURL := absoluteURL ( r , view . ThumbnailURL )
if locked && box . Obfuscate {
title = "Protected Warpbox file"
description = "This shared file is password protected."
imageURL = absoluteURL ( r , "/static/img/file-placeholder.webp" )
}
2026-05-30 17:23:20 +03:00
a . renderPage ( w , r , http . StatusOK , "preview.html" , web . PageData {
2026-05-25 16:52:57 +03:00
Title : title ,
Description : description ,
ImageURL : imageURL ,
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 ) {
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
}
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 ) {
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-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-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-05-25 16:52:57 +03:00
if attachment {
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=%q" , 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-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 ) {
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" )
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=%q" , "warpbox-" + box . ID + ".zip" ) )
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 ) ,
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-02 13:02:51 +03:00
HasThumbnail : file . Thumbnail != "" ,
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 ,
}
}
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
}