package handlers import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" "warpbox.dev/backend/libs/helpers" "warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/web" ) type downloadPageData struct { Box boxView Files []fileView ZipURL string Locked bool Obfuscated bool CanPreview bool DownloadCount int MaxDownloads int ExpiresLabel string EmojiTabs []emojiTabView } type boxView struct { ID string } type fileView struct { ID string Name string Size string SizeBytes int64 ContentType string PreviewKind string URL string DownloadURL string ThumbnailURL string SceneURL string ArchiveURL string HasThumbnail bool HasScene bool HasArchive bool IconURL string IconRetroURL string ReactURL string Reactions []reactionView ReactionMore int Reacted bool Processing bool } type reactionView struct { EmojiID string `json:"emojiId"` URL string `json:"url"` Label string `json:"label"` Count int `json:"count"` Visible bool `json:"visible"` } type emojiTabView struct { ID string Label string Emojis []emojiOptionView } type emojiOptionView struct { ID string `json:"id"` URL string `json:"url"` Label string `json:"label"` } type previewPageData struct { Box boxView File fileView Locked bool DownloadURL string } func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...) http.NotFound(w, r) return } if err := a.uploadService.CanDownload(box); err != nil { a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...) a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{ Title: "Download unavailable", Description: "This Warpbox link is no longer available.", Data: downloadPageData{ Box: boxView{ID: box.ID}, ExpiresLabel: err.Error(), }, }) return } locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 { file := box.Files[0] if file.Processing { http.Error(w, "file is still processing", http.StatusAccepted) return } 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 { a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...) } files := make([]fileView, 0, len(box.Files)) if !(locked && box.Obfuscate) { for _, file := range box.Files { files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID])) } } 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())...) } 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)) // All user uploads are private/temporary — noindex by default. robots := web.RobotsNone a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{ Title: title, Description: description, CanonicalURL: pageURL, Robots: robots, ImageURL: ogImage, ImageAlt: imageAlt, ImageType: imageType, Data: downloadPageData{ Box: boxView{ID: box.ID}, Files: files, ZipURL: fmt.Sprintf("/d/%s/zip", box.ID), Locked: locked, Obfuscated: box.Obfuscate, DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, ExpiresLabel: expiresLabel, EmojiTabs: emojiTabs, }, }) a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...) } func plural(n int) string { if n == 1 { return "" } 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 { return } locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) if isSocialPreviewBot(r) && !locked { if file.Processing { http.Error(w, "file is still processing", http.StatusAccepted) return } 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 } } view := a.fileView(box, file) fileSize := helpers.FormatBytes(file.Size) title := 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)) a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{ Title: title, 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, Locked: locked, DownloadURL: view.DownloadURL, }, }) a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...) } func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") box, file, ok := a.loadFileForRequest(w, r) if !ok { return } if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...) http.Error(w, "password required", http.StatusUnauthorized) return } if file.Processing { http.Error(w, "file is still processing", http.StatusAccepted) return } a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1") a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...) } func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") box, file, ok := a.loadFileForRequest(w, r) if !ok { return } if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) { a.servePlaceholderThumbnail(w, r) return } 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 // thumbnail lands. The real thumbnail below is content-stable, so it // gets a long immutable cache. 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+"-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. 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")) } func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { http.NotFound(w, r) return } 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")) { a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...) 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, }) a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...) 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 { 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"))...) http.NotFound(w, r) return services.Box{}, services.File{}, false } if err := a.uploadService.CanDownload(box); err != nil { 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())...) http.Error(w, err.Error(), statusForDownloadError(err)) return services.Box{}, services.File{}, false } file, err := a.uploadService.FindFile(box, r.PathValue("fileID")) if err != nil { a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...) http.NotFound(w, r) return services.Box{}, services.File{}, false } return box, file, true } func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) { object, err := a.uploadService.OpenFileObject(r.Context(), box, file) if err != nil { 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())...) http.NotFound(w, r) return } defer object.Body.Close() w.Header().Set("Content-Type", file.ContentType) disposition := "inline" if attachment { disposition = "attachment" } w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name)) 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) } if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error()) } } func contentDisposition(disposition, name string) string { filename := cleanDownloadFilename(name) return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename)) } func cleanDownloadFilename(name string) string { clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")) clean = filepath.Base(clean) if clean == "" || clean == "." || clean == "/" { return "download" } return clean } func asciiFilenameFallback(name string) string { var fallback strings.Builder for _, char := range name { switch { case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';': fallback.WriteByte('_') case char <= 0x7e: fallback.WriteRune(char) default: fallback.WriteByte('_') } } clean := strings.TrimSpace(fallback.String()) if clean == "" { return "download" } return clean } func readSeekCloser(source io.ReadCloser) io.ReadSeeker { data, err := io.ReadAll(source) if err != nil { return bytes.NewReader(nil) } return bytes.NewReader(data) } func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...) http.NotFound(w, r) return } if err := a.uploadService.CanDownload(box); err != nil { a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...) http.Error(w, err.Error(), statusForDownloadError(err)) return } if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...) http.Error(w, "password required", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip")) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) if err := a.uploadService.WriteZip(w, box); err != nil { a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error()) return } if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error()) } a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...) } func (a *App) fileView(box services.Box, file services.File) fileView { return a.fileViewWithReactions(box, file, nil, false) } func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView { icon := a.fileIcons.lookup(file.Name, file.ContentType) reactionViews := a.reactionViews(reactions) return fileView{ ID: file.ID, Name: file.Name, Size: helpers.FormatBytes(file.Size), SizeBytes: 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), 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), Reactions: reactionViews, ReactionMore: reactionOverflowCount(reactionViews), Reacted: reacted, Processing: file.Processing, } } func (a *App) ReactToFile(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) { 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)) for index, reaction := range reactions { views = append(views, reactionView{ EmojiID: reaction.EmojiID, URL: emojiURL(reaction.EmojiID), Label: emojiLabel(reaction.EmojiID), Count: reaction.Count, Visible: index < 2, }) } return views } func reactionOverflowCount(reactions []reactionView) int { if len(reactions) <= 2 { return 0 } return len(reactions) - 2 } 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) } 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) } // 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) } 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" } return fmt.Sprintf("%s://%s%s", scheme, r.Host, path) } 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 }