- Replace middle dots (·) and em-dashes (—) with pipes (|) and standard punctuation in page titles, descriptions, and image alt texts. - Shorten the homepage description to be more concise and direct. - Update file share description phrasing for better readability, changing "click to preview" to "Open to preview" and capitalizing "Expires".
1008 lines
36 KiB
Go
1008 lines
36 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)
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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: !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
|
|
}
|