Add link previews
This commit is contained in:
@@ -22,11 +22,6 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
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 {
|
||||
if s.cfg.PublicURL != "" {
|
||||
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
|
||||
}
|
||||
|
||||
// objectURL builds the download link handed back to an uploader.
|
||||
func (s *Server) objectURL(r *http.Request, id string) string {
|
||||
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 {
|
||||
page
|
||||
MaxSize string
|
||||
@@ -60,8 +58,6 @@ type indexPage struct {
|
||||
}
|
||||
|
||||
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))
|
||||
stale := false
|
||||
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
|
||||
// dead end.
|
||||
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{
|
||||
page: s.page(r, m.Filename, true),
|
||||
page: head,
|
||||
Meta: m,
|
||||
Size: config.FormatSize(m.Size),
|
||||
Expires: describeExpiry(m.Expires, s.now()),
|
||||
URL: s.objectURL(r, m.ID),
|
||||
InfoURL: infoURL,
|
||||
CanDelete: s.mayDelete(r, m, s.credential(r)),
|
||||
Error: errMsg,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,10 @@ type page struct {
|
||||
// The reading measure that suits the upload page is far too narrow for a
|
||||
// listing, which otherwise ends up behind a horizontal scrollbar.
|
||||
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
|
||||
|
||||
@@ -2346,3 +2346,130 @@ func TestFilesPageIsLaidOutForATable(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
Expires: expires,
|
||||
URL: url,
|
||||
InfoURL: s.absBase(r) + "i/" + m.ID,
|
||||
InfoURL: s.infoURL(r, m.ID),
|
||||
DeleteToken: secret,
|
||||
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),
|
||||
Expires: describeExpiry(m.Expires, s.now()),
|
||||
URL: url,
|
||||
InfoURL: s.absBase(r) + "i/" + m.ID,
|
||||
InfoURL: s.infoURL(r, m.ID),
|
||||
DeleteToken: secret,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<h1 class="filename">{{.Meta.Filename}}</h1>
|
||||
<dl>
|
||||
<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>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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">
|
||||
{{if .Favicon}}<link rel="icon" href="{{.Favicon}}">{{end}}
|
||||
</head>
|
||||
@@ -11,8 +24,6 @@
|
||||
<header>
|
||||
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
||||
<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>
|
||||
{{if .User}}
|
||||
<a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a>
|
||||
|
||||
Reference in New Issue
Block a user