Add link previews

This commit is contained in:
2026-09-18 20:56:41 +02:00
parent 601053c51d
commit f152f84139
7 changed files with 188 additions and 13 deletions
+12 -9
View File
@@ -22,11 +22,6 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
w.Write(append(b, '\n')) w.Write(append(b, '\n'))
} }
// absBase is the absolute URL of this service, for links and examples.
//
// It prefers --public-url. Falling back to the request's Host is display-only:
// the header is caller-controlled, so it is never used for anything a third
// party would see.
func (s *Server) absBase(r *http.Request) string { func (s *Server) absBase(r *http.Request) string {
if s.cfg.PublicURL != "" { if s.cfg.PublicURL != "" {
return s.cfg.PublicURL + s.cfg.BasePath return s.cfg.PublicURL + s.cfg.BasePath
@@ -38,11 +33,14 @@ func (s *Server) absBase(r *http.Request) string {
return scheme + "://" + r.Host + s.cfg.BasePath return scheme + "://" + r.Host + s.cfg.BasePath
} }
// objectURL builds the download link handed back to an uploader.
func (s *Server) objectURL(r *http.Request, id string) string { func (s *Server) objectURL(r *http.Request, id string) string {
return s.absBase(r) + "d/" + id return s.absBase(r) + "d/" + id
} }
func (s *Server) infoURL(r *http.Request, id string) string {
return s.absBase(r) + "i/" + id
}
type indexPage struct { type indexPage struct {
page page
MaxSize string MaxSize string
@@ -60,8 +58,6 @@ type indexPage struct {
} }
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script.
lim, err := s.limitsFor(r, s.cookieCredential(r)) lim, err := s.limitsFor(r, s.cookieCredential(r))
stale := false stale := false
switch { switch {
@@ -124,12 +120,19 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
// delete attempt, so a mistyped token lands back on the form rather than on a // delete attempt, so a mistyped token lands back on the form rather than on a
// dead end. // dead end.
func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Meta, status int, errMsg string) { func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Meta, status int, errMsg string) {
infoURL := s.infoURL(r, m.ID)
head := s.page(r, m.Filename, true)
// This is the page people paste at each other, so it is the one that has
// to unfurl into something readable.
head.Preview = objectPreview(m, infoURL)
s.render(w, status, "info.html", objectPage{ s.render(w, status, "info.html", objectPage{
page: s.page(r, m.Filename, true), page: head,
Meta: m, Meta: m,
Size: config.FormatSize(m.Size), Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()), Expires: describeExpiry(m.Expires, s.now()),
URL: s.objectURL(r, m.ID), URL: s.objectURL(r, m.ID),
InfoURL: infoURL,
CanDelete: s.mayDelete(r, m, s.credential(r)), CanDelete: s.mayDelete(r, m, s.credential(r)),
Error: errMsg, Error: errMsg,
}) })
+29
View File
@@ -0,0 +1,29 @@
package server
import (
"strings"
"uncensored-send/internal/config"
"uncensored-send/internal/store"
)
type preview struct {
Description string
Author string
URL string
}
func objectPreview(m *store.Meta, url string) *preview {
facts := []string{config.FormatBytes(m.Size)}
if m.Owner != "" {
facts = append(facts, "uploaded by "+m.Owner)
}
if m.Expires != nil {
facts = append(facts, "expires "+absolute(m.Expires))
}
return &preview{
Description: strings.Join(facts, ", ") + ". Open the page to download it.",
Author: m.Owner,
URL: url,
}
}
+4
View File
@@ -245,6 +245,10 @@ type page struct {
// The reading measure that suits the upload page is far too narrow for a // The reading measure that suits the upload page is far too narrow for a
// listing, which otherwise ends up behind a horizontal scrollbar. // listing, which otherwise ends up behind a horizontal scrollbar.
Wide bool Wide bool
// Preview is the link card for this page, nil when there is nothing
// sensible to tell somebody who has only been handed the URL.
Preview *preview
} }
// page builds the common fields, resolving the session so the header can show // page builds the common fields, resolving the session so the header can show
+127
View File
@@ -2346,3 +2346,130 @@ func TestFilesPageIsLaidOutForATable(t *testing.T) {
t.Error("the listing still relies on a horizontal scroll container") t.Error("the listing still relies on a horizontal scroll container")
} }
} }
// --- link previews -------------------------------------------------------
// metaContent pulls a meta tag's content out of a rendered page, keyed by
// either the property or the name attribute, so a test reads what an unfurler
// would rather than matching a whole tag it does not care about.
func metaContent(page, key string) string {
for _, attr := range []string{"property", "name"} {
marker := fmt.Sprintf("<meta %s=%q content=", attr, key)
i := strings.Index(page, marker)
if i < 0 {
continue
}
rest := page[i+len(marker):]
if !strings.HasPrefix(rest, `"`) {
continue
}
if end := strings.Index(rest[1:], `"`); end >= 0 {
return rest[1 : 1+end]
}
}
return ""
}
// A /i/ link is the thing people paste at each other, so it has to unfurl into
// something that says what is on the other end of it.
func TestInfoPageUnfurls(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.PublicURL = "https://drop.example" })
res := decode[uploadResult](t, h.uploadReader(t,
strings.NewReader(strings.Repeat("x", 4096)),
map[string]string{
"Content-Disposition": `attachment; filename="MyGame.zip"`,
"Authorization": "Bearer " + h.token,
}))
info := h.get(t, "/i/"+res.ID, "")
raw, _ := io.ReadAll(info.Body)
info.Body.Close()
page := string(raw)
if got, want := metaContent(page, "og:title"), "MyGame.zip - Uncensored Send"; got != want {
t.Errorf("og:title = %q, want %q", got, want)
}
if got, want := metaContent(page, "og:url"), "https://drop.example/i/"+res.ID; got != want {
t.Errorf("og:url = %q, want %q", got, want)
}
if got, want := metaContent(page, "author"), "friend"; got != want {
t.Errorf("author = %q, want %q", got, want)
}
if metaContent(page, "twitter:card") != "summary" {
t.Error("no twitter:card, so clients holding to that vocabulary draw nothing")
}
desc := metaContent(page, "og:description")
for _, want := range []string{"4 KiB", "uploaded by friend", "expires 2026-09-15 10:00 UTC"} {
if !strings.Contains(desc, want) {
t.Errorf("og:description = %q, missing %q", desc, want)
}
}
if !strings.Contains(desc, "Open the page") {
t.Errorf("og:description = %q, says nothing about what to do with the link", desc)
}
// The deadline is cached by the chat platform and read back days later, so
// it has to be an absolute time rather than a countdown that goes stale.
if strings.Contains(desc, "in 3d") {
t.Errorf("og:description = %q, carries a relative deadline", desc)
}
if metaContent(page, "description") != desc {
t.Error("the plain description tag disagrees with the Open Graph one")
}
}
// An anonymous upload has no uploader to name, and the card must not claim one.
func TestAnonymousUploadHasNoAuthorInItsCard(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
info := h.get(t, "/i/"+res.ID, "")
raw, _ := io.ReadAll(info.Body)
info.Body.Close()
page := string(raw)
if got := metaContent(page, "author"); got != "" {
t.Errorf("author = %q, want none", got)
}
if desc := metaContent(page, "og:description"); strings.Contains(desc, "uploaded by") {
t.Errorf("og:description = %q, names an uploader there is none of", desc)
}
if !strings.Contains(page, "anonymous") {
t.Error("the page itself does not say the upload was anonymous")
}
}
// A filename is caller-supplied and ends up inside an attribute; the escaping
// has to hold there as well as in the body.
func TestPreviewEscapesTheFilename(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
"Content-Disposition": `attachment; filename="\" onload=\"alert(1)"`}))
info := h.get(t, "/i/"+res.ID, "")
raw, _ := io.ReadAll(info.Body)
info.Body.Close()
page := string(raw)
if strings.Contains(page, `onload="alert(1)"`) {
t.Fatalf("a filename broke out of the meta attribute:\n%s", page)
}
if got, want := metaContent(page, "og:title"), res.Filename; !strings.Contains(got, "onload") && got != want {
// The value is escaped, so this only checks the tag is there at all.
t.Errorf("og:title = %q", got)
}
}
// Pages whose content depends on who is asking say nothing to an unfurler,
// which presents no token and would otherwise be shown a stranger's view.
func TestOnlyTheInfoPageCarriesACard(t *testing.T) {
h := newHarness(t, nil)
for _, path := range []string{"/", "/login", "/files"} {
resp := h.get(t, path, "")
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if got := metaContent(string(raw), "og:title"); got != "" {
t.Errorf("%s carries og:title = %q", path, got)
}
}
}
+2 -2
View File
@@ -403,7 +403,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor
ID: m.ID, Filename: m.Filename, Size: m.Size, SHA256: m.SHA256, ID: m.ID, Filename: m.Filename, Size: m.Size, SHA256: m.SHA256,
Expires: expires, Expires: expires,
URL: url, URL: url,
InfoURL: s.absBase(r) + "i/" + m.ID, InfoURL: s.infoURL(r, m.ID),
DeleteToken: secret, DeleteToken: secret,
DeleteURL: s.absBase(r) + "d/" + m.ID + "/delete", DeleteURL: s.absBase(r) + "d/" + m.ID + "/delete",
}) })
@@ -419,7 +419,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor
Size: config.FormatSize(m.Size), Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()), Expires: describeExpiry(m.Expires, s.now()),
URL: url, URL: url,
InfoURL: s.absBase(r) + "i/" + m.ID, InfoURL: s.infoURL(r, m.ID),
DeleteToken: secret, DeleteToken: secret,
}) })
} }
+1
View File
@@ -3,6 +3,7 @@
<h1 class="filename">{{.Meta.Filename}}</h1> <h1 class="filename">{{.Meta.Filename}}</h1>
<dl> <dl>
<dt>Size</dt><dd>{{.Size}}</dd> <dt>Size</dt><dd>{{.Size}}</dd>
<dt>Uploaded by</dt><dd>{{if .Meta.Owner}}{{.Meta.Owner}}{{else}}<em>anonymous</em>{{end}}</dd>
<dt>Expires</dt><dd>{{.Expires}}</dd> <dt>Expires</dt><dd>{{.Expires}}</dd>
<dt>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd> <dt>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd>
</dl> </dl>
+13 -2
View File
@@ -4,6 +4,19 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - Uncensored Send</title> <title>{{.Title}} - Uncensored Send</title>
{{- with .Preview}}
<meta property="og:type" content="website">
<meta property="og:title" content="{{$.Title}} - Uncensored Send">
<meta property="og:description" content="{{.Description}}">
{{- if .URL}}
<meta property="og:url" content="{{.URL}}">
{{- end}}
{{- if .Author}}
<meta name="author" content="{{.Author}}">
{{- end}}
<meta name="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
{{- end}}
<link rel="stylesheet" href="{{.Base}}static/style.css"> <link rel="stylesheet" href="{{.Base}}static/style.css">
{{if .Favicon}}<link rel="icon" href="{{.Favicon}}">{{end}} {{if .Favicon}}<link rel="icon" href="{{.Favicon}}">{{end}}
</head> </head>
@@ -11,8 +24,6 @@
<header> <header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a> <a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
<nav> <nav>
{{- /* Uploading is what the site is for, so it is a named destination and
not only a click on the wordmark. */}}
<a href="{{.Base}}">Upload</a> <a href="{{.Base}}">Upload</a>
{{if .User}} {{if .User}}
<a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a> <a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a>