// attach.go is the reading half of attachments (RFC 0016 §Reading): the
// card a file is drawn as, the routes that hand a reader more than a
// download, and the section every target carrying files renders.
//
// The section is one shape on four pages — ticket, wiki page, technote
// and forum post — because a file on a bug and a file on a wiki page
// are the same thing to the person looking for it. The post's copy is
// compact rather than different: same markup, smaller cards, no
// heading, because a post is already inside a heading.
package web
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/highlight"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/sniff"
)
// attachCard is one file as a card draws it. Everything here is decided
// server-side — the kind from the bytes, the preview from the kind —
// so the page a reader gets with no script is the page they get with
// one, minus the lightbox.
type attachCard struct {
Name string
Src string
Date string
User string
// Kind is sniff's verdict: image, text, media or other. It reaches
// the markup as data-kind, which is what the lightbox and the tests
// both key off.
Kind string
// Badge is the small label over the preview — the sniffed format,
// or "txt · 1,204 lines" for text. Empty for a file whose only
// label is its extension placeholder.
Badge string
// Ext is the uppercase extension a kind=other card draws instead of
// a preview it cannot make.
Ext string
// Size is the byte count in human words; Text is the highlighted
// first lines of a text file.
Size string
Text template.HTML
// Open is where the name and the preview point: the inline view for
// an image or a media file, the text viewer for text, and the
// download for everything else. Download is always the download.
Open string
Download string
}
// attachCtx is what the shared section template reads. Target rides
// along because the zip link needs it and a card does not.
type attachCtx struct {
Repo string
Target string
Cards []attachCard
Compact bool
}
// attachKindMissing is the card for an A card whose blob this store does
// not hold. Not one of sniff's kinds: nothing was sniffed.
const attachKindMissing = "missing"
// attachSource is the section, defined once and parsed into every page
// that can carry files.
const attachSource = `{{define "attachments"}}{{if .Cards}}
<section class="attach{{if .Compact}} attach-compact{{end}}" data-hook="attachments">
{{- if not .Compact}}
<div class="attach-h"><h3>Attachments <span class="n">{{len .Cards}}</span></h3>
{{- if gt (len .Cards) 1}}<div class="tools"><a class="attach-all" href="/r/{{.Repo}}/attachments/{{pathesc .Target}}.zip">Download all</a></div>{{end}}
</div>{{end}}
<div class="attach-grid">
{{- range .Cards}}
<article class="attach-card" data-hook="attachment" data-kind="{{.Kind}}">
{{- if eq .Kind "missing"}}<span class="attach-prev attach-missing"><span class="attach-ext" aria-hidden="true">{{.Ext}}</span><span class="attach-kind">not in this store</span></span>
{{- else}}<a class="attach-prev" href="{{.Open}}"{{if eq .Kind "image"}} data-attach-name="{{.Name}}" data-attach-sub="{{.Size}} · {{.User}}" data-attach-dl="{{.Download}}"{{end}}>
{{- if eq .Kind "image"}}<img src="{{.Open}}" loading="lazy" alt="{{.Name}}">
{{- else if eq .Kind "text"}}<div class="attach-code" aria-hidden="true">{{.Text}}</div>
{{- else if eq .Kind "media"}}<span class="attach-play" aria-hidden="true"></span>
{{- else}}<span class="attach-ext" aria-hidden="true">{{.Ext}}</span>{{end}}
{{- if .Badge}}<span class="attach-kind">{{.Badge}}</span>{{end}}</a>{{end}}
<div class="attach-info">
<span class="attach-name">{{if .Open}}<a href="{{.Open}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</span>
{{if .Download}}<a class="attach-dl" href="{{.Download}}" title="download" aria-label="download {{.Name}}"><svg class="ic" aria-hidden="true"><use href="#i-download"/></svg></a>{{end}}
<span class="attach-sub">{{if .Size}}{{.Size}} · {{end}}{{who $.Repo .User}} · {{ago .Date}}</span>
</div>
</article>{{end}}
</div>
</section>{{end}}{{end}}`
// attachCards turns the rows an object's op already carried into the
// cards a page draws, asking attachment.head what each file IS.
//
// One call per file. That is the cost of deciding from bytes rather
// than from a name, and it is the only honest way to do it: the N card
// is the attacher's claim and an extension is a label. A file whose
// head cannot be read still gets a card — a name and a download — so
// one unreadable blob never costs a reader the rest of the page.
func attachCards(ctx context.Context, r *ops.Registry, repo string, rows []attachmentRow) []attachCard {
if len(rows) == 0 {
return nil
}
cards := make([]attachCard, 0, len(rows))
for _, row := range rows {
c := attachCard{
Name: row.Name, Src: row.Src, Date: row.Date, User: row.User,
Kind: sniff.KindOther,
Ext: attachExt(row.Name),
Download: attachURL(repo, row.Src, ""),
}
c.Open = c.Download
out, err := invoke(ctx, r, "attachment.head", url.Values{
"repo": {repo}, "hash": {row.Src},
})
if errors.Is(err, ops.ErrNotFound) {
// The A card is here and its blob is not: a mirror whose
// upstream never served the bytes (six of the fossil
// project's ninety-two, on 2026-09-11). Say so on the card
// rather than offer a download that 404s.
c.Kind, c.Open, c.Download = attachKindMissing, "", ""
}
if err == nil {
var hd struct {
Size int `json:"size"`
Kind string `json:"kind"`
Mimetype string `json:"mimetype"`
Lines int `json:"lines"`
Text string `json:"text"`
Truncated bool `json:"truncated"`
}
remarshal(out, &hd)
c.Kind, c.Size = hd.Kind, byteSize(hd.Size)
switch hd.Kind {
case sniff.KindImage, sniff.KindMedia:
c.Badge = attachFormat(hd.Mimetype)
c.Open = attachURL(repo, row.Src, "view")
case sniff.KindText:
// A file too big for the prefix read was counted as far
// as the read went, so the badge says "1,024+ lines"
// rather than asserting a total nobody counted.
more := ""
if hd.Truncated {
more = "+"
}
c.Badge = "txt · " + thousands(hd.Lines) + more + " " +
map[bool]string{true: "line", false: "lines"}[hd.Lines == 1 && !hd.Truncated]
c.Open = attachURL(repo, row.Src, "text")
c.Text = attachPreview(row.Name, hd.Text)
}
}
cards = append(cards, c)
}
return cards
}
// attachHasImage reports whether any card on the page is an image, so
// the lightbox script is sent only where there is something to open.
func attachHasImage(groups ...[]attachCard) bool {
for _, g := range groups {
for _, c := range g {
if c.Kind == sniff.KindImage {
return true
}
}
}
return false
}
// attachInlineImage reports whether a rendered body already carries an
// attachment image — `` resolved to the inline
// view route. Such a page wants the lightbox even when it has no cards,
// which is the ordinary shape of a wiki page whose pictures are IN the
// text rather than listed under it.
func attachInlineImage(html string) bool {
for rest := html; ; {
i := strings.Index(rest, "/attachment/")
if i < 0 {
return false
}
rest = rest[i+len("/attachment/"):]
end := strings.IndexAny(rest, `"' >`)
if end < 0 {
return false
}
if strings.HasSuffix(rest[:end], "/view") {
return true
}
}
}
// attachURL addresses one attachment. The empty verb is the download.
func attachURL(repo, src, verb string) string {
u := "/r/" + url.PathEscape(repo) + "/attachment/" + url.PathEscape(src)
if verb != "" {
u += "/" + verb
}
return u
}
// attachFormat is the badge over an image or media preview: the sniffed
// type's subtype and nothing else, so a card says "png" where the file
// really is a PNG whatever it was named.
func attachFormat(mimetype string) string {
_, sub, ok := strings.Cut(mimetype, "/")
if !ok {
return ""
}
return sub
}
// attachExt is the placeholder a card with no preview draws: the
// extension, uppercase and short. A file with none says FILE rather
// than nothing, because an empty tile reads as a broken image.
func attachExt(name string) string {
ext := strings.TrimPrefix(path.Ext(name), ".")
if ext == "" {
return "FILE"
}
if len(ext) > 5 {
ext = ext[:5]
}
return strings.ToUpper(ext)
}
// attachPreview highlights the first lines of a text file for the card.
//
// Chroma emits its own <pre>, so the wrapper is a <div>: a <pre> inside
// a <pre> is not HTML, and the fade at the bottom needs the wrapper
// either way. The output is NOT passed through the sanitizer, unlike
// rendered markup — the sanitizer strips the class attributes the
// gutter and the token colours are drawn with, and these bytes are
// chroma's own escaping of content it never interprets.
func attachPreview(name, text string) template.HTML {
if text == "" {
return ""
}
if hl, ok := highlight.Code([]byte(text), "", name); ok {
return template.HTML(hl)
}
return template.HTML("<pre>" + template.HTMLEscapeString(text) + "</pre>")
}
// byteSize is a file's size in the words a person uses. Powers of two,
// because that is what every other forge shows and a reader comparing
// two numbers cares more that they agree than which base they are in.
func byteSize(n int) string {
switch {
case n < 1<<10:
return strconv.Itoa(n) + " B"
case n < 1<<20:
return strconv.FormatFloat(float64(n)/(1<<10), 'f', 0, 64) + " KB"
case n < 1<<30:
return strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64) + " MB"
}
return strconv.FormatFloat(float64(n)/(1<<30), 'f', 1, 64) + " GB"
}
// attachRoutes registers what a card needs beyond the download and the
// inline view: the text viewer and the whole-object zip.
func attachRoutes(mux *http.ServeMux, r *ops.Registry) {
// The text viewer. A card shows eight lines; this is the rest of
// them, numbered and highlighted, with the raw bytes one click
// away. Addressed by content hash, so the answer is immutable and
// says so — a removed attachment stops resolving at the op.
mux.HandleFunc("GET /r/{repo}/attachment/{hash}/text", func(w http.ResponseWriter, req *http.Request) {
repo, hash := req.PathValue("repo"), req.PathValue("hash")
raw, name, ok := attachBytes(r, w, req)
if !ok {
return
}
if !sniff.Text(raw) {
// Not text is not an error about the file — the download
// route still serves it — but this URL has no answer for
// it, and a 404 says so without inviting a retry.
fail(r, w, req, fmt.Errorf("not a text attachment: %w", ops.ErrNotFound))
return
}
body, ok := highlight.Numbered(raw, name)
data := pageData{
Title: name, Repo: repo, OwnH1: true,
AttachName: name,
AttachSize: byteSize(len(raw)),
AttachRawURL: attachURL(repo, hash, ""),
AttachTextHTML: template.HTML(body),
}
if !ok {
data.AttachTextHTML = template.HTML("<pre>" +
template.HTMLEscapeString(string(raw)) + "</pre>")
}
// No immutable caching here, unlike the routes that serve the
// bytes: this is a PAGE, and it carries the signed-in chrome
// every other page carries. render sets no-cache for exactly
// that reason. The content is still addressed by hash — the
// raw link beside it is the immutable one.
render(r, w, req, attachTextPage, data)
})
// Every live file on one object, zipped. Streamed through the op
// layer a file at a time — attachment.list for the names, then
// attachment.get for each — so the forge never holds an object's
// whole set in memory and never reaches past the ops for it.
mux.HandleFunc("GET /r/{repo}/attachments/{target...}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
target, ok := strings.CutSuffix(req.PathValue("target"), ".zip")
if !ok || target == "" {
fail(r, w, req, fmt.Errorf("no such archive: %w", ops.ErrNotFound))
return
}
out, err := invoke(req.Context(), r, "attachment.list", url.Values{
"repo": {repo}, "target": {target},
})
if err != nil {
fail(r, w, req, err)
return
}
var list struct {
Attachments []attachmentRow `json:"attachments"`
}
remarshal(out, &list)
if len(list.Attachments) == 0 {
fail(r, w, req, fmt.Errorf("no attachments on %q: %w", target, ops.ErrNotFound))
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
url.PathEscape(path.Base(target))+".zip")
zw := zip.NewWriter(w)
var skipped []string
for _, row := range list.Attachments {
// Ask how big it is before asking for it. attachment.get
// buffers, and a file past the op's cap would fail the
// whole archive at the point where nothing can be said
// about it any more — the response is already streaming.
if n, ok := attachSizeOf(req.Context(), r, repo, row.Src); ok && n > maxBundleFileBytes {
skipped = append(skipped, row.Name)
continue
}
got, err := invoke(req.Context(), r, "attachment.get", url.Values{
"repo": {repo}, "hash": {row.Src},
})
if err != nil {
// The response is already streaming, so there is no
// status left to change. Skipping the file leaves an
// archive missing one entry, which a reader can see;
// aborting would leave them a truncated zip, which
// they cannot.
continue
}
var ad struct {
Content string `json:"content"`
}
remarshal(got, &ad)
raw, err := base64.StdEncoding.DecodeString(ad.Content)
if err != nil {
continue
}
// The A card's name, cleaned to a single path element: an
// attachment named ../../etc/passwd must not become one.
entry, err := zw.Create(path.Base(strings.ReplaceAll(row.Name, `\`, "/")))
if err != nil {
break
}
if _, err := entry.Write(raw); err != nil {
break
}
}
// The archive says what is NOT in it. A silently short bundle is
// the worst outcome here: a reader unpacks it, finds four files
// where the page showed five, and has no way to learn why.
if len(skipped) > 0 {
_ = zw.SetComment("left out, larger than this forge will bundle: " +
strings.Join(skipped, ", "))
}
zw.Close()
})
}
// maxBundleFileBytes is the largest file the zip route will pull
// through attachment.get. It matches that op's own read cap: a file
// past it cannot be fetched at all, so asking is a failure rather than
// a slow success, and the bundle names it instead.
const maxBundleFileBytes = 32 << 20
// attachSizeOf asks the head op how big one file is. A file whose size
// cannot be learnt is attempted rather than skipped — the cheap answer
// is missing, not the file.
func attachSizeOf(ctx context.Context, r *ops.Registry, repo, src string) (int, bool) {
out, err := invoke(ctx, r, "attachment.head", url.Values{
"repo": {repo}, "hash": {src},
})
if err != nil {
return 0, false
}
var hd struct {
Size int `json:"size"`
}
remarshal(out, &hd)
return hd.Size, true
}
// attachBytes fetches one attachment's content through the op, which is
// where the A-card gate lives: a hash that is not a live attachment is
// not served by any route, whatever the route would have done with it.
func attachBytes(r *ops.Registry, w http.ResponseWriter, req *http.Request) (raw []byte, name string, ok bool) {
out, err := invoke(req.Context(), r, "attachment.get", url.Values{
"repo": {req.PathValue("repo")}, "hash": {req.PathValue("hash")},
})
if err != nil {
fail(r, w, req, err)
return nil, "", false
}
var ad struct {
Name string `json:"name"`
Content string `json:"content"`
}
remarshal(out, &ad)
raw, err = base64.StdEncoding.DecodeString(ad.Content)
if err != nil {
fail(r, w, req, err)
return nil, "", false
}
return raw, ad.Name, true
}
// serveInlineMedia answers the view route for audio and video, which
// need what an image does not: ranges, so a reader can seek without
// fetching the whole file first. http.ServeContent does the range
// arithmetic and sets Accept-Ranges; everything else about the route's
// posture is unchanged — nosniff, a CSP that permits nothing, and a
// type taken from the bytes rather than from anything a person wrote.
func serveInlineMedia(w http.ResponseWriter, req *http.Request, name, ctype string, raw []byte) {
w.Header().Set("Content-Type", ctype)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
// A zero time asks ServeContent not to write Last-Modified: the URL
// names the content by hash, so modification time says nothing the
// address does not already say better.
http.ServeContent(w, req, name, time.Time{}, bytes.NewReader(raw))
}
var attachTextPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h1 class="atx-h">{{.AttachName}}</h1>
<p class="w-by">{{.AttachSize}} · <a href="{{.AttachRawURL}}">raw</a></p>
<div class="atx" data-hook="attachment-text">{{.AttachTextHTML}}</div>
{{end}}`))