Remove hyphens from compound adjectives such as "logged-in", "one-time", "password-protected", "full-height", "multi-file", and "S3-compatible" in comments, test error messages, and UI labels to improve readability and consistency.
1043 lines
38 KiB
Go
1043 lines
38 KiB
Go
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
|
|
Failed bool
|
|
Error string
|
|
}
|
|
|
|
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. Open 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 file.ProcessingError != "" {
|
|
a.logger.Warn("failed file preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4241, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
|
return
|
|
}
|
|
if services.BoxHasTrouble(box) {
|
|
a.logger.Warn("failed box preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4245, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
|
return
|
|
}
|
|
if shouldServeRawSocialMedia(file) {
|
|
a.serveFileContent(w, r, box, file, false)
|
|
a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
|
|
return
|
|
}
|
|
}
|
|
if file.ProcessingError != "" && !locked {
|
|
a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
|
return
|
|
}
|
|
if services.BoxHasTrouble(box) && !locked {
|
|
a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if file.ProcessingError != "" {
|
|
a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
|
return
|
|
}
|
|
if services.BoxHasTrouble(box) {
|
|
a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
|
return
|
|
}
|
|
|
|
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
|
a.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
|
|
}
|
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
|
a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
|
a.servePlaceholderThumbnail(w, r)
|
|
return
|
|
}
|
|
|
|
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
|
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
|
|
}
|
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
|
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
|
a.servePlaceholderThumbnail(w, r)
|
|
return
|
|
}
|
|
|
|
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
|
if err != nil {
|
|
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
|
|
file.SceneThumbnail = scene
|
|
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
|
if err == nil {
|
|
defer object.Body.Close()
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
|
return
|
|
}
|
|
}
|
|
a.servePlaceholderThumbnail(w, r)
|
|
return
|
|
}
|
|
defer object.Body.Close()
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
|
}
|
|
|
|
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
|
box, file, ok := a.loadFileForRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !jobs.NeedsArchiveListing(file) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
|
http.Error(w, "password required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
|
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
|
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
|
|
return
|
|
}
|
|
|
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
|
file.ArchiveListing = listing
|
|
file.ArchiveListingObjectKey = ""
|
|
}
|
|
}
|
|
|
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
|
if err != nil {
|
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
|
file.ArchiveListing = listing
|
|
file.ArchiveListingObjectKey = ""
|
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
|
if err == nil {
|
|
defer object.Body.Close()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
|
return
|
|
}
|
|
}
|
|
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer object.Body.Close()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
|
}
|
|
|
|
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
|
|
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
|
return ""
|
|
}
|
|
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
|
|
if err != nil || thumbnail == "" {
|
|
if err != nil {
|
|
a.logger.Warn("on-demand thumbnail generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4102, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
}
|
|
return ""
|
|
}
|
|
for i := range box.Files {
|
|
if box.Files[i].ID == file.ID {
|
|
box.Files[i].Thumbnail = thumbnail
|
|
break
|
|
}
|
|
}
|
|
if err := a.uploadService.SaveBox(box); err != nil {
|
|
a.logger.Warn("on-demand thumbnail metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4103, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
return ""
|
|
}
|
|
return thumbnail
|
|
}
|
|
|
|
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
|
|
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
|
return ""
|
|
}
|
|
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
|
|
if err != nil || scene == "" {
|
|
if err != nil {
|
|
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
}
|
|
return ""
|
|
}
|
|
for i := range box.Files {
|
|
if box.Files[i].ID == file.ID {
|
|
box.Files[i].SceneThumbnail = scene
|
|
break
|
|
}
|
|
}
|
|
if err := a.uploadService.SaveBox(box); err != nil {
|
|
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
return ""
|
|
}
|
|
return scene
|
|
}
|
|
|
|
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
|
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
|
return ""
|
|
}
|
|
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
|
|
if err != nil || listing == "" {
|
|
if err != nil {
|
|
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
}
|
|
return ""
|
|
}
|
|
for i := range box.Files {
|
|
if box.Files[i].ID == file.ID {
|
|
box.Files[i].ArchiveListing = listing
|
|
box.Files[i].ArchiveListingObjectKey = ""
|
|
break
|
|
}
|
|
}
|
|
if err := a.uploadService.SaveBox(box); err != nil {
|
|
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
|
return ""
|
|
}
|
|
return listing
|
|
}
|
|
|
|
func troubleReasonForLog(box services.Box, file services.File) string {
|
|
if services.FileHasTrouble(file) {
|
|
return file.ProcessingError
|
|
}
|
|
return services.BoxTroubleReason(box)
|
|
}
|
|
|
|
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
|
// 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)
|
|
w.Header().Set("Cache-Control", "no-transform")
|
|
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 {
|
|
size := object.Size
|
|
if size < 0 {
|
|
size = file.Size
|
|
}
|
|
if size >= 0 {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", 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
|
|
}
|
|
for _, file := range box.Files {
|
|
if file.Processing {
|
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
|
return
|
|
}
|
|
if file.ProcessingError != "" {
|
|
a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
|
return
|
|
}
|
|
}
|
|
if services.BoxHasTrouble(box) {
|
|
a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...)
|
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
|
return
|
|
}
|
|
|
|
tempDir := filepath.Join(a.cfg.DataDir, "tmp", "downloads")
|
|
if err := os.MkdirAll(tempDir, 0o700); err != nil {
|
|
a.logger.Error("zip staging directory creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
|
|
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
archive, err := os.CreateTemp(tempDir, "warpbox-*.zip")
|
|
if err != nil {
|
|
a.logger.Error("zip staging file creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
|
|
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
archivePath := archive.Name()
|
|
defer func() {
|
|
archive.Close()
|
|
if err := os.Remove(archivePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
a.logger.Warn("failed to remove staged zip", "source", "download", "severity", "warn", "box_id", box.ID, "error", err.Error())
|
|
}
|
|
}()
|
|
|
|
if err := a.uploadService.WriteZip(archive, box); err != nil {
|
|
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
|
|
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
stat, err := archive.Stat()
|
|
if err != nil {
|
|
a.logger.Error("staged zip stat failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
|
|
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
name := "warpbox-" + box.ID + ".zip"
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Cache-Control", "no-transform")
|
|
w.Header().Set("Content-Disposition", contentDisposition("attachment", name))
|
|
http.ServeContent(w, r, name, stat.ModTime(), archive)
|
|
|
|
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: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
|
|
HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
|
|
HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
|
|
IconURL: fileIconURL("standard", icon.Standard),
|
|
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,
|
|
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
|
|
Error: troubleReasonForLog(box, file),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|