Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc |
@@ -132,6 +132,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
@@ -104,14 +105,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
||||
if box.Files[0].Processing {
|
||||
file := box.Files[0]
|
||||
if file.Processing {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
a.serveFileContent(w, r, box, box.Files[0], false)
|
||||
a.logger.Info("single-file box served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", box.Files[0].ID)...)
|
||||
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 {
|
||||
@@ -132,13 +136,25 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
||||
|
||||
// All user uploads are private/temporary — noindex by default.
|
||||
robots := web.RobotsNone
|
||||
@@ -149,7 +165,8 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
CanonicalURL: pageURL,
|
||||
Robots: robots,
|
||||
ImageURL: ogImage,
|
||||
ImageAlt: fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files))),
|
||||
ImageAlt: imageAlt,
|
||||
ImageType: imageType,
|
||||
Data: downloadPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
Files: files,
|
||||
@@ -172,6 +189,43 @@ func plural(n int) string {
|
||||
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 {
|
||||
@@ -184,21 +238,30 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if shouldServeRawSocialMedia(file) {
|
||||
a.serveFileContent(w, r, box, file, false)
|
||||
a.logger.Info("file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
|
||||
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 := fmt.Sprintf("%s · %s file shared via Warp Box", fileSize, file.ContentType)
|
||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
||||
imageAlt := fmt.Sprintf("Preview of %s", 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))
|
||||
@@ -208,8 +271,12 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -253,6 +320,17 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
@@ -267,6 +345,30 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -411,7 +513,7 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
||||
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),
|
||||
HasThumbnail: file.Thumbnail != "",
|
||||
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
|
||||
IconURL: fileIconURL("standard", icon.Standard),
|
||||
IconRetroURL: fileIconURL("retro", icon.Retro),
|
||||
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
@@ -11,10 +12,18 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
// Open Graph image dimensions recommended for large summary cards
|
||||
@@ -74,6 +83,22 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||
}
|
||||
|
||||
// FileOGImage renders a branded card for files that should not be served as raw
|
||||
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
|
||||
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||
return
|
||||
}
|
||||
|
||||
icon := a.ogFileIcon(file)
|
||||
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||
}
|
||||
|
||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
@@ -115,6 +140,158 @@ func (a *App) ogPlaceholder() image.Image {
|
||||
return canvas
|
||||
}
|
||||
|
||||
func (a *App) ogFileIcon(file services.File) image.Image {
|
||||
if a.fileIcons == nil {
|
||||
return nil
|
||||
}
|
||||
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
||||
if icon.Retro == "" {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
|
||||
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||
|
||||
titleFace := a.ogFont(44, true)
|
||||
bodyFace := a.ogFont(28, false)
|
||||
metaFace := a.ogFont(24, false)
|
||||
buttonFace := a.ogFont(26, true)
|
||||
|
||||
if icon != nil {
|
||||
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
|
||||
} else {
|
||||
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
titleLines := wrapOGText(file.Name, titleFace, 850)
|
||||
if len(titleLines) > 2 {
|
||||
titleLines = titleLines[:2]
|
||||
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
|
||||
}
|
||||
y := 156
|
||||
for _, line := range titleLines {
|
||||
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||
y += 52
|
||||
}
|
||||
|
||||
size := helpers.FormatBytes(file.Size)
|
||||
typeLabel := strings.TrimSpace(file.ContentType)
|
||||
if typeLabel == "" {
|
||||
typeLabel = "application/octet-stream"
|
||||
}
|
||||
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
|
||||
info := fileCardInfo(file)
|
||||
for i, line := range wrapOGText(info, metaFace, 900) {
|
||||
if i >= 2 {
|
||||
break
|
||||
}
|
||||
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
|
||||
}
|
||||
|
||||
button := image.Rect(110, 474, 430, 548)
|
||||
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
|
||||
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
return canvas
|
||||
}
|
||||
|
||||
func fileCardInfo(file services.File) string {
|
||||
switch {
|
||||
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
|
||||
case file.ContentType == "text/markdown":
|
||||
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
|
||||
case strings.Contains(file.ContentType, "html"):
|
||||
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
|
||||
case strings.Contains(file.ContentType, "pdf"):
|
||||
return "PDF file shared through Warpbox. Open the link to download the original file."
|
||||
case strings.HasPrefix(file.ContentType, "text/"):
|
||||
return "Text file shared through Warpbox. Open the link to preview the content or download."
|
||||
default:
|
||||
return "File shared through Warpbox. Open the link to preview available details or download the original."
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ogFont(size float64, bold bool) font.Face {
|
||||
name := "PixeloidSans.ttf"
|
||||
if bold {
|
||||
name = "PixeloidSans-Bold.ttf"
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
|
||||
if err != nil {
|
||||
return basicfont.Face7x13
|
||||
}
|
||||
parsed, err := opentype.Parse(data)
|
||||
if err != nil {
|
||||
return basicfont.Face7x13
|
||||
}
|
||||
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return basicfont.Face7x13
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||
d := font.Drawer{
|
||||
Dst: dst,
|
||||
Src: image.NewUniform(c),
|
||||
Face: face,
|
||||
Dot: fixed.P(x, y),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func wrapOGText(text string, face font.Face, maxWidth int) []string {
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return []string{text}
|
||||
}
|
||||
lines := []string{}
|
||||
current := words[0]
|
||||
for _, word := range words[1:] {
|
||||
next := current + " " + word
|
||||
if ogTextWidth(face, next) <= maxWidth {
|
||||
current = next
|
||||
continue
|
||||
}
|
||||
lines = append(lines, current)
|
||||
current = word
|
||||
}
|
||||
lines = append(lines, current)
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimOGText(text string, face font.Face, maxWidth int) string {
|
||||
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
return strings.TrimSpace(text) + "..."
|
||||
}
|
||||
|
||||
func ogTextWidth(face font.Face, text string) int {
|
||||
bounds, _ := font.BoundString(face, text)
|
||||
return (bounds.Max.X - bounds.Min.X).Ceil()
|
||||
}
|
||||
|
||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||
func renderCollage(thumbs []image.Image) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
||||
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadThroughApp(t, app)
|
||||
@@ -120,15 +120,19 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "Shared files on Warpbox") {
|
||||
t.Fatalf("social preview bot received HTML download page")
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||
}
|
||||
if response.Body.String() != "hello" {
|
||||
t.Fatalf("social preview body = %q", response.Body.String())
|
||||
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
||||
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadThroughApp(t, app)
|
||||
@@ -143,11 +147,46 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "preview-title") {
|
||||
t.Fatalf("social preview bot received HTML preview page")
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||
}
|
||||
if response.Body.String() != "hello" {
|
||||
t.Fatalf("social preview body = %q", response.Body.String())
|
||||
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
|
||||
t.Fatalf("social preview body missing twitter card metadata: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
uploadResponse := httptest.NewRecorder()
|
||||
app.Upload(uploadResponse, request)
|
||||
if uploadResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||
}
|
||||
var payload services.UploadResult
|
||||
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
|
||||
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
|
||||
previewRequest.SetPathValue("boxID", payload.BoxID)
|
||||
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.DownloadFile(response, previewRequest)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "preview-title") {
|
||||
t.Fatalf("image social preview bot received HTML preview page")
|
||||
}
|
||||
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
|
||||
t.Fatalf("image social preview body = %q", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,25 @@ package jobs
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
_ "golang.org/x/image/webp"
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -131,7 +138,15 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
||||
}
|
||||
|
||||
func needsThumbnail(file services.File) bool {
|
||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||
}
|
||||
|
||||
func NeedsThumbnail(file services.File) bool {
|
||||
return needsThumbnail(file)
|
||||
}
|
||||
|
||||
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
return generateThumbnail(uploadService, box, file)
|
||||
}
|
||||
|
||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
@@ -157,11 +172,39 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
||||
}
|
||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||
return thumbnailName, err
|
||||
case isTextThumbnailCandidate(file):
|
||||
data, err := createTextThumbnail(file, object.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||
return thumbnailName, err
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTextThumbnailCandidate(file services.File) bool {
|
||||
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:i])
|
||||
}
|
||||
if strings.HasPrefix(contentType, "text/") {
|
||||
return true
|
||||
}
|
||||
switch contentType {
|
||||
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
||||
return true
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
switch ext {
|
||||
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||
img, _, err := image.Decode(source)
|
||||
if err != nil {
|
||||
@@ -203,6 +246,197 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
||||
return os.ReadFile(targetPath)
|
||||
}
|
||||
|
||||
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
||||
|
||||
mode := textThumbnailMode(file)
|
||||
title := strings.ToUpper(mode)
|
||||
var lines []string
|
||||
if mode == "HTML" {
|
||||
lines = renderedHTMLThumbnailLines(sourceText)
|
||||
} else if mode == "MARKDOWN" {
|
||||
lines = renderedMarkdownThumbnailLines(sourceText)
|
||||
} else {
|
||||
title = "CODE"
|
||||
lines = codeThumbnailLines(sourceText)
|
||||
}
|
||||
return renderTextThumbnail(file.Name, title, lines), nil
|
||||
}
|
||||
|
||||
func textThumbnailMode(file services.File) string {
|
||||
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:i])
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
||||
return "HTML"
|
||||
}
|
||||
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
||||
return "MARKDOWN"
|
||||
}
|
||||
return "CODE"
|
||||
}
|
||||
|
||||
func renderedHTMLThumbnailLines(source string) []string {
|
||||
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
||||
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
||||
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
||||
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
||||
text = html.UnescapeString(text)
|
||||
return documentThumbnailLines(text)
|
||||
}
|
||||
|
||||
func renderedMarkdownThumbnailLines(source string) []string {
|
||||
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
||||
block = strings.Trim(block, "` \n\t")
|
||||
lines := strings.Split(block, "\n")
|
||||
if len(lines) > 1 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
return "\n" + strings.Join(lines, "\n") + "\n"
|
||||
})
|
||||
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
||||
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
||||
return documentThumbnailLines(text)
|
||||
}
|
||||
|
||||
func documentThumbnailLines(source string) []string {
|
||||
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
||||
rawLines := strings.Split(source, "\n")
|
||||
lines := make([]string, 0, 9)
|
||||
for _, raw := range rawLines {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
||||
lines = append(lines, line)
|
||||
if len(lines) >= 9 {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"Rendered preview is empty."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func codeThumbnailLines(source string) []string {
|
||||
rawLines := strings.Split(source, "\n")
|
||||
lines := make([]string, 0, 10)
|
||||
for _, raw := range rawLines {
|
||||
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||
raw = strings.TrimRight(raw, " ")
|
||||
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(raw) > 48 {
|
||||
raw = raw[:45] + "..."
|
||||
}
|
||||
lines = append(lines, raw)
|
||||
if len(lines) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"(empty file)"}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
||||
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
||||
|
||||
face := basicfont.Face7x13
|
||||
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
||||
|
||||
codePane := image.Rect(22, 72, 338, 210)
|
||||
if mode == "CODE" {
|
||||
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
||||
} else {
|
||||
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
||||
}
|
||||
|
||||
y := 91
|
||||
for _, line := range lines {
|
||||
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
||||
y += 14
|
||||
if y > 202 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var target bytes.Buffer
|
||||
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
||||
return target.Bytes()
|
||||
}
|
||||
|
||||
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
||||
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||
d := font.Drawer{
|
||||
Dst: dst,
|
||||
Src: image.NewUniform(c),
|
||||
Face: face,
|
||||
Dot: fixed.P(x, y),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
||||
if len(text) <= maxChars {
|
||||
return []string{text}
|
||||
}
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return []string{text[:maxChars-3] + "..."}
|
||||
}
|
||||
lines := []string{}
|
||||
current := ""
|
||||
for _, word := range words {
|
||||
if current == "" {
|
||||
current = word
|
||||
continue
|
||||
}
|
||||
if len(current)+1+len(word) <= maxChars {
|
||||
current += " " + word
|
||||
continue
|
||||
}
|
||||
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||
current = word
|
||||
}
|
||||
if current != "" {
|
||||
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimThumbnailText(text string, maxChars int) string {
|
||||
if len(text) <= maxChars {
|
||||
return text
|
||||
}
|
||||
if maxChars <= 3 {
|
||||
return text[:maxChars]
|
||||
}
|
||||
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
||||
}
|
||||
|
||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||
bounds := src.Bounds()
|
||||
width := bounds.Dx()
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -46,6 +48,30 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
|
||||
data, err := createTextThumbnail(services.File{
|
||||
Name: "notes.md",
|
||||
ContentType: "text/markdown",
|
||||
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
|
||||
if err != nil {
|
||||
t.Fatalf("createTextThumbnail returned error: %v", err)
|
||||
}
|
||||
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||
}
|
||||
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
|
||||
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
|
||||
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
|
||||
t.Fatalf("Go source file should get a text thumbnail")
|
||||
}
|
||||
}
|
||||
|
||||
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
||||
t.Helper()
|
||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
@@ -397,7 +397,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
||||
objectKey := boxObjectKey(box.ID, storedName)
|
||||
contentType := incoming.ContentType()
|
||||
if contentType == "" {
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := file.Read(buffer)
|
||||
contentType = http.DetectContentType(buffer[:n])
|
||||
|
||||
@@ -23,10 +23,14 @@ type PageData struct {
|
||||
BaseURL string
|
||||
CanonicalURL string
|
||||
Robots string
|
||||
OGType string
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
ImageAlt string
|
||||
ImageType string
|
||||
MediaURL string
|
||||
MediaType string
|
||||
CurrentYear int
|
||||
CurrentUser any
|
||||
CSRFToken string
|
||||
|
||||
@@ -110,19 +110,18 @@
|
||||
}
|
||||
|
||||
function chooseDefaultMode(type, tabs) {
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (type.isImage) {
|
||||
return "image";
|
||||
}
|
||||
if (type.isVideo) {
|
||||
return "video";
|
||||
}
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
if (type.isAudio) {
|
||||
return "browser-audio";
|
||||
}
|
||||
|
||||
@@ -11,16 +11,30 @@
|
||||
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
||||
|
||||
<meta property="og:site_name" content="{{.AppName}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
|
||||
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||
<meta property="og:description" content="{{.Description}}">
|
||||
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
||||
{{if .ImageURL}}
|
||||
<meta property="og:image" content="{{.ImageURL}}">
|
||||
<meta property="og:image:secure_url" content="{{.ImageURL}}">
|
||||
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
|
||||
{{end}}
|
||||
{{if .MediaURL}}
|
||||
{{if eq .OGType "video.other"}}
|
||||
<meta property="og:video" content="{{.MediaURL}}">
|
||||
<meta property="og:video:secure_url" content="{{.MediaURL}}">
|
||||
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
|
||||
{{end}}
|
||||
{{if eq .OGType "music.song"}}
|
||||
<meta property="og:audio" content="{{.MediaURL}}">
|
||||
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
|
||||
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||
|
||||
Reference in New Issue
Block a user