// attach.go is attachments on the page (RFC 0016 §Reading, §Writing):
// the card a file is drawn as, the routes that hand a reader more than
// a download, the section every target carrying files renders, and the
// controls a holder of the letters sees in it — the form that is the
// upload's floor, the remove on each card, and vouch and reject on a
// held one.
//
// 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"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/attach"
"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
// Hash is the A card, which is what a remove, a vouch and a reject
// name; Target is what it is attached to.
Hash string
Target 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
// External says the bytes are in the repository's bucket (a stub):
// the download is a redirect and the size is the stub's word.
// Held says nobody trusted has vouched for the upload yet, so the
// card is a link with a name and never an image. Reason is the
// words a missing card shows.
External bool
Held bool
Reason string
}
// attachWrite is what the section shows a viewer who may change it,
// decided by the page from the viewer's letters and the binding.
type attachWrite struct {
// Family is the object's kind, which the fragment route needs back
// to pick the same letters.
Family string
// CanAttach: holds the family's upload letter and a bucket is
// bound. The form is drawn, and a remove on every card.
CanAttach bool
// Unbound: an administrator on a repository with no bucket. One
// line pointing at the settings section; anyone else sees nothing.
Unbound bool
// CanMod holds the family's moderation letter: vouch and reject on
// a held card.
CanMod bool
// Limit is the binding's per-file cap in words, for the form.
Limit string
// Back is the page to return to after a form posts.
Back string
// Error is the last refusal, carried back on the redirect so the
// form without script says why in the same line the script does.
Error 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
Write *attachWrite
}
// attachKindMissing is the card for an A card whose blob this store does
// not hold, or a stub with no bucket to point at. Not one of sniff's
// kinds: nothing was sniffed.
const attachKindMissing = "missing"
// attachShows reports whether the section draws at all: when it has
// cards, or when the viewer may add one.
func (c attachCtx) Shows() bool {
return len(c.Cards) > 0 || (c.Write != nil && (c.Write.CanAttach || c.Write.Unbound))
}
// attachSource is the section, defined once and parsed into every page
// that can carry files.
const attachSource = `{{define "attachments"}}{{if .Shows}}
<section class="attach{{if .Compact}} attach-compact{{end}}" data-hook="attachments"{{if not .Compact}} id="attachments"{{end}} data-repo="{{.Repo}}" data-target="{{.Target}}"{{with .Write}} data-family="{{.Family}}"{{end}}>
{{- if not .Compact}}
<div class="attach-h"><h3>Attachments{{if .Cards}} <span class="n">{{len .Cards}}</span>{{end}}</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}}
{{- if .Cards}}<div class="attach-grid">
{{- range .Cards}}{{$c := .}}
<article class="attach-card" data-hook="attachment" data-kind="{{.Kind}}"{{if .Held}} data-held="true"{{end}}{{if .External}} data-external="true"{{end}}>
{{- if eq .Kind "missing"}}<span class="attach-prev attach-missing"><span class="attach-ext" aria-hidden="true">{{.Ext}}</span><span class="attach-kind">{{.Reason}}</span></span>
{{- else if .Held}}<a class="attach-prev attach-held" href="{{.Download}}"><span class="attach-ext" aria-hidden="true">{{.Ext}}</span><span class="attach-kind">held for review</span></a>
{{- 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"}}{{if .Text}}<div class="attach-code" aria-hidden="true">{{.Text}}</div>{{else}}<span class="attach-ext" aria-hidden="true">{{.Ext}}</span>{{end}}
{{- 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>
{{- with $.Write}}{{if or (and .CanMod $c.Held) .CanAttach}}<span class="attach-act">
{{- if and .CanMod $c.Held}}<form method="post" action="/r/{{$.Repo}}/attach/vouch" class="attach-inline-form"><input type="hidden" name="artifact" value="{{$c.Hash}}"><input type="hidden" name="back" value="{{.Back}}"><button type="submit" data-hook="attachment-vouch">Vouch</button></form>
<form method="post" action="/r/{{$.Repo}}/attach/reject" class="attach-inline-form"><input type="hidden" name="artifact" value="{{$c.Hash}}"><input type="hidden" name="back" value="{{.Back}}"><button type="submit" data-hook="attachment-reject" data-confirm="{{$c.Name}} is removed from {{$.Target}}; the uploader is not told why.">Reject</button></form>{{end}}
{{- if .CanAttach}}<form method="post" action="/r/{{$.Repo}}/attach/remove" class="attach-inline-form"><input type="hidden" name="target" value="{{$.Target}}"><input type="hidden" name="name" value="{{$c.Name}}"><input type="hidden" name="back" value="{{.Back}}"><button type="submit" data-hook="attachment-remove" data-confirm="{{$c.Name}} is removed from this {{if $c.External}}page; the bytes stay in the bucket unless the repository deletes them on removal{{else}}page; the bytes stay in the repository's history{{end}}.">Remove</button></form>{{end}}</span>{{end}}{{end}}
</div>
</article>{{end}}
</div>{{end}}
{{- with .Write}}{{if .CanAttach}}
<form method="post" enctype="multipart/form-data" action="/r/{{$.Repo}}/attach/upload/{{pathesc $.Target}}?back={{.Back}}" class="attach-form" data-hook="attach-form" data-limit="{{.Limit}}">
<label class="attach-pick">Attach a file <input type="file" name="file" required></label>
<button type="submit" class="attach-go">Upload</button>
<progress class="attach-progress" max="100" value="0" hidden></progress>
<span class="attach-error" data-hook="attach-error" role="alert"{{if not .Error}} hidden{{end}}>{{.Error}}</span>
<small class="attach-limit">up to {{.Limit}}</small>
</form>
{{- else if .Unbound}}<p class="attach-unbound" data-hook="attach-unbound">No bucket is bound for uploads yet — <a href="/r/{{$.Repo}}/settings#attachments">bind one in settings</a>.</p>{{end}}{{end}}
</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, Hash: row.Hash, Date: row.Date, User: row.User,
Kind: sniff.KindOther,
Ext: attachExt(row.Name),
Download: attachURL(repo, row.Src, ""),
External: row.External, Held: row.Held,
}
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, c.Reason = attachKindMissing, "", "", "not in this store"
}
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"`
External bool `json:"external"`
Held bool `json:"held"`
Reason string `json:"reason"`
}
remarshal(out, &hd)
c.Kind, c.Size = hd.Kind, byteSize(hd.Size)
if hd.External {
// A stub (RFC 0016): the bytes are in the bucket, so the
// download is the redirect and the inline view is the
// redirect too, both minted by the link op on the way.
// No text preview — the forge does not read the bytes —
// and nothing inline until a person vouched for it.
c.External, c.Held, c.Reason = true, hd.Held, hd.Reason
c.Download = attachURL(repo, row.Src, "get")
c.Open = c.Download
if hd.Kind == attachKindMissing {
c.Open, c.Download = "", ""
} else if !c.Held && (hd.Kind == sniff.KindImage || hd.Kind == sniff.KindMedia) && attach.Inline(hd.Mimetype) {
c.Badge = attachFormat(hd.Mimetype)
c.Open = attachURL(repo, row.Src, "view")
} else if hd.Kind == sniff.KindImage || hd.Kind == sniff.KindMedia {
c.Badge = attachFormat(hd.Mimetype)
}
cards = append(cards, c)
continue
}
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 && !c.Held {
return true
}
}
}
return false
}
// attachWriteFor decides what the section offers this viewer on this
// object: the form when they hold the family's upload letter and a
// bucket is bound, the pointer to settings when they administer a
// repository with none, vouch and reject when they hold the moderation
// letter. family is the object's kind — ticket, wiki, technote or
// forum — which picks the letters (RFC 0008: b/B to attach; q, l, 5 to
// review). nil when the viewer may do nothing, which is the shape the
// section has always had.
func attachWriteFor(r *ops.Registry, caps Caps, verify Verify, sess Sessions, repo, family string, req *http.Request) *attachWrite {
upload, review := "b", "l"
switch family {
case "forum":
upload, review = "B", "5"
case "ticket":
review = "q"
}
back := req.URL.Path
if q := req.URL.Query(); q.Get("rev") != "" {
back += "?rev=" + url.QueryEscape(q.Get("rev"))
}
w := &attachWrite{
Family: family,
CanMod: mayCap(caps, verify, sess, repo, review+"a", ops.Admin, req),
Back: back + "#attachments",
Error: req.URL.Query().Get("attach"),
}
if !mayCap(caps, verify, sess, repo, upload, ops.Token, req) {
if !w.CanMod {
return nil
}
return w
}
out, err := invoke(req.Context(), r, "bucket.get", url.Values{"repo": {repo}})
if err != nil {
// No bucket store on this forge, or not an administrator: the
// op answers the binding to admins only, and a holder of the
// upload letter who is not one asks the cheaper question.
return attachWriteUnprivileged(r, w, caps, verify, sess, repo, req)
}
var v struct {
Bound bool `json:"bound"`
Limit int64 `json:"limit"`
}
remarshal(out, &v)
if !v.Bound {
w.Unbound = mayCap(caps, verify, sess, repo, "a", ops.Admin, req)
return w
}
w.CanAttach, w.Limit = true, byteSize(int(v.Limit))
return w
}
// attachWriteUnprivileged asks whether uploads work here without the
// administrator's op: attachment.begin refuses an unbound bucket with
// its own words, and a probe with an impossible size is refused on the
// binding before it is refused on the size. Cheaper than a second op
// surface, and it needs no letters beyond the uploader's own.
func attachWriteUnprivileged(r *ops.Registry, w *attachWrite, caps Caps, verify Verify, sess Sessions, repo string, req *http.Request) *attachWrite {
out, err := invoke(req.Context(), r, "bucket.limit", url.Values{"repo": {repo}})
if err != nil {
return w
}
var v struct {
Bound bool `json:"bound"`
Limit int64 `json:"limit"`
}
remarshal(out, &v)
if v.Bound {
w.CanAttach, w.Limit = true, byteSize(int(v.Limit))
}
return w
}
// 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"
}
// UploadFormFunc is the no-script upload (RFC 0016 §Writing) as the
// composition root wires it: a multipart request carried to the bucket
// on the caller's behalf. The web layer owns the session and the
// redirect; the api layer owns the bytes. nil means the form path
// answers unavailable.
type UploadFormFunc func(ctx context.Context, repo, target string, req *http.Request) (any, error)
// attachBack is the page a form returns to: one of this forge's own
// repository pages and nothing else, so a posted `back` cannot send a
// reader off-site.
func attachBack(repo, back string) string {
if strings.HasPrefix(back, "/r/"+url.PathEscape(repo)+"/") && !strings.Contains(back, "//") && !strings.ContainsAny(back, "\r\n") {
return back
}
return "/r/" + url.PathEscape(repo)
}
// attachRedirect sends a form's poster back where they came from, with
// the refusal in the query when there was one, or answers JSON to a
// caller who asked for it — the script posts the same forms.
func attachRedirect(w http.ResponseWriter, req *http.Request, repo string, out any, err error) {
if strings.Contains(req.Header.Get("Accept"), "application/json") {
if err != nil {
writeAttachJSON(w, statusFor(err), map[string]string{"error": err.Error()})
return
}
writeAttachJSON(w, http.StatusOK, out)
return
}
back := attachBack(repo, req.FormValue("back"))
if err != nil {
u, _ := url.Parse(back)
q := u.Query()
q.Set("attach", refusalWords(err))
u.RawQuery = q.Encode()
back = u.String()
}
http.Redirect(w, req, back, http.StatusSeeOther)
}
// refusalWords is an op's refusal without the sentinel suffix the
// router adds for machines, since this one is read by a person.
func refusalWords(err error) string {
msg := err.Error()
for _, suffix := range []string{": invalid request", ": forbidden", ": not found", ": temporarily unavailable"} {
msg = strings.TrimSuffix(msg, suffix)
}
return msg
}
func writeAttachJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// statusFor is the status an op's refusal maps to, for the JSON
// answers here; fail() does the same for pages.
func statusFor(err error) int {
switch {
case errors.Is(err, ops.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ops.ErrInvalid):
return http.StatusBadRequest
case errors.Is(err, ops.ErrForbidden):
return http.StatusForbidden
case errors.Is(err, ops.ErrUnavailable):
return http.StatusServiceUnavailable
}
return http.StatusInternalServerError
}
// attachLink asks the link op where a file's bytes are. ok is false
// for a real blob, which quarry's own routes serve.
func attachLink(r *ops.Registry, req *http.Request, repo, hash string) (download, inline string, external bool, err error) {
out, err := invoke(req.Context(), r, "attachment.link", url.Values{"repo": {repo}, "hash": {hash}})
if err != nil {
return "", "", false, err
}
var v struct {
External bool `json:"external"`
Download string `json:"download"`
Inline string `json:"inline"`
}
remarshal(out, &v)
return v.Download, v.Inline, v.External, nil
}
// attachRoutes registers what a card needs beyond the download and the
// inline view: the text viewer, the whole-object zip, the redirect to
// a stub's bytes, the section fragment, and the writes — the form that
// is the upload's floor, begin and commit for the script, and remove,
// vouch and reject for the cards.
func attachRoutes(mux *http.ServeMux, r *ops.Registry, caps Caps, verify Verify, sessions Sessions, upload UploadFormFunc) {
// A stub's bytes (RFC 0016 §Reading): a redirect to the public base
// or to a presigned GET good for an hour. Never cached — the URL it
// answers expires, and the answer depends on who asked.
mux.HandleFunc("GET /r/{repo}/attachment/{hash}/get", func(w http.ResponseWriter, req *http.Request) {
repo, hash := req.PathValue("repo"), req.PathValue("hash")
download, _, external, err := attachLink(r, req, repo, hash)
if err != nil {
fail(r, w, req, err)
return
}
if !external {
http.Redirect(w, req, attachURL(repo, hash, ""), http.StatusFound)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, req, download, http.StatusFound)
})
// The section alone, for the script to patch in after an upload or
// a removal: the same template the page rendered, so the two never
// drift. family picks the letters the way the page did.
mux.HandleFunc("GET /r/{repo}/attach.frag", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
target := req.URL.Query().Get("target")
if target == "" {
fail(r, w, req, fmt.Errorf("target is needed: %w", ops.ErrInvalid))
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)
ctx := attachCtx{Repo: repo, Target: target,
Cards: attachCards(req.Context(), r, repo, list.Attachments),
Compact: req.URL.Query().Get("compact") == "1",
Write: attachWriteFor(r, caps, verify, sessions, repo, req.URL.Query().Get("family"), req)}
if ctx.Write != nil && req.URL.Query().Get("back") != "" {
ctx.Write.Back = attachBack(repo, req.URL.Query().Get("back"))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := attachFragPage.ExecuteTemplate(w, "attachments", ctx); err != nil {
log.Printf("attach.frag: %v", err)
}
})
// The form floor: a multipart POST quarry carries to the bucket.
mux.HandleFunc("POST /r/{repo}/attach/upload/{target...}", func(w http.ResponseWriter, req *http.Request) {
repo, target := req.PathValue("repo"), req.PathValue("target")
req, ok := gateCap(caps, verify, sessions, repo, "bB", ops.Token, w, req)
if !ok {
return
}
if upload == nil {
fail(r, w, req, fmt.Errorf("this forge takes no uploads: %w", ops.ErrUnavailable))
return
}
out, err := upload(req.Context(), repo, target, req)
// The multipart body was consumed by the upload; the return
// address rides in the query the form was given.
if req.Form == nil {
req.Form = url.Values{}
}
if back := req.URL.Query().Get("back"); back != "" {
req.Form.Set("back", back)
}
attachRedirect(w, req, repo, out, err)
})
// The scripted path's two halves, under the session cookie: the
// api router speaks Basic, and a page speaks cookies.
for _, name := range []string{"begin", "commit"} {
op := "attachment." + name
mux.HandleFunc("POST /r/{repo}/attach/"+name, func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateCap(caps, verify, sessions, repo, "bB", ops.Token, w, req)
if !ok {
return
}
if err := req.ParseForm(); err != nil {
writeAttachJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
form := req.PostForm
form.Set("repo", repo)
out, err := invoke(req.Context(), r, op, form)
if err != nil {
writeAttachJSON(w, statusFor(err), map[string]string{"error": refusalWords(err)})
return
}
writeAttachJSON(w, http.StatusOK, out)
})
}
for _, name := range []string{"remove", "vouch", "reject"} {
op, letters := "attachment."+name, "ql5a"
if name == "remove" {
letters = "bB"
}
mux.HandleFunc("POST /r/{repo}/attach/"+name, func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateCap(caps, verify, sessions, repo, letters, ops.Token, w, req)
if !ok {
return
}
if err := req.ParseForm(); err != nil {
fail(r, w, req, err)
return
}
form := present(req.PostForm, "target", "name", "artifact")
form.Set("repo", repo)
out, err := invoke(req.Context(), r, op, form)
attachRedirect(w, req, repo, out, err)
})
}
// 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 _, _, external, lerr := attachLink(r, req, repo, hash); lerr == nil && external {
// A stub's text is in the bucket, not here; the download
// is the redirect.
http.Redirect(w, req, attachURL(repo, hash, "get"), http.StatusFound)
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
}
// Uploads live in the bucket; the zip names them and leaves
// them out rather than packing five-line stubs as files.
var external []string
kept := list.Attachments[:0]
for _, row := range list.Attachments {
if row.External {
external = append(external, row.Name)
continue
}
kept = append(kept, row)
}
list.Attachments = kept
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.
var notes []string
if len(skipped) > 0 {
notes = append(notes, "left out, larger than this forge will bundle: "+strings.Join(skipped, ", "))
}
if len(external) > 0 {
notes = append(notes, "left out, stored in the repository's bucket rather than here: "+strings.Join(external, ", "))
}
if len(notes) > 0 {
_ = zw.SetComment(strings.Join(notes, "\n"))
}
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))
}
// attachFragPage renders the section alone, for attach.frag.
var attachFragPage = template.Must(template.Must(base.Clone()).Parse(attachSource))
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}}`))