diff --git a/internal/server/pages.go b/internal/server/pages.go index b7e3289..8acfb5b 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -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, }) diff --git a/internal/server/preview.go b/internal/server/preview.go new file mode 100644 index 0000000..a2bf29b --- /dev/null +++ b/internal/server/preview.go @@ -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, + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8aa228c..62d36d5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a69bd6b..e2dedad 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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("= 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) + } + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go index b9956fc..da8d433 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -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, }) } diff --git a/web/templates/info.html b/web/templates/info.html index 0c0d8e8..41e1e6d 100644 --- a/web/templates/info.html +++ b/web/templates/info.html @@ -3,6 +3,7 @@