Skip to content
quarry Artifact [48247e4ff8]
Login

Artifact [48247e4ff8]

Artifact 48247e4ff87cac2f0dccca8d56f7fa094196561e6fe8289439fb4ed4625df4db:


// 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 — `![shot](attach:crash.png)` 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}}`))