vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // Release pages. A release is an annotated tag, so this file reads refs/tags and |
| 4 | // nothing else: the notes are the tag message and the tarball is generated from |
| 5 | // the tag's tree when it is asked for. There is no release table to keep in sync |
| 6 | // with the repository, and no stored tarball that can disagree with the tag. |
| 7 | |
| 8 | import ( |
| 9 | "bytes" |
| 10 | "fmt" |
| 11 | "html/template" |
| 12 | "mime" |
| 13 | "net/http" |
| 14 | "path" |
| 15 | "strings" |
| 16 | "time" |
| 17 | |
| 18 | "rodent.software/vole/core/git" |
| 19 | "rodent.software/vole/forge/repo" |
| 20 | "rodent.software/vole/metadata" |
| 21 | ) |
| 22 | |
| 23 | // releaseArtifacts returns the files attached to a tag. The index answers from |
| 24 | // the metadata branch it was built from; a server without an index, or one whose |
| 25 | // refresh failed, reads the pointers directly. |
| 26 | func (w *Web) releaseArtifacts(r *http.Request, m *repo.Meta, tag string) []metadata.Artifact { |
| 27 | if w.Index != nil { |
| 28 | if err := w.ensureIndex(memoOf(r), m); err == nil { |
| 29 | if list, err := w.Index.Artifacts(m.ID, tag); err == nil { |
| 30 | return list |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | list, _ := metadata.ListReleaseArtifacts(m.Git(), tag) |
| 35 | return list |
| 36 | } |
| 37 | |
| 38 | // archiveConc bounds concurrent archives. Plain tar streams at ~1 GiB/s while gzip |
| 39 | // is single-threaded per stream, and eight concurrent gzipped archives of a 100 MiB |
| 40 | // tree cost 13 s of CPU. Both spawn git and stream for as long as the client reads, |
| 41 | // so both take a slot: plain tar is cheaper, not free. Queueing a request is better |
| 42 | // than making every request late. |
| 43 | const archiveConc = 4 |
| 44 | |
| 45 | // maxReleaseCommits bounds the "since the previous release" list. A release a |
| 46 | // thousand commits wide is a report, not a page. |
| 47 | const maxReleaseCommits = 100 |
| 48 | |
| 49 | func (w *Web) Releases(rw http.ResponseWriter, r *http.Request) { |
| 50 | m, ok := w.open(rw, r) |
| 51 | if !ok { |
| 52 | return |
| 53 | } |
| 54 | tags, err := m.Git().Tags() |
| 55 | if err != nil { |
| 56 | w.serverError(rw, r, "cannot list tags: "+err.Error()) |
| 57 | return |
| 58 | } |
| 59 | light := 0 |
| 60 | for _, t := range tags { |
| 61 | if !t.Annotated { |
| 62 | light++ |
| 63 | } |
| 64 | } |
| 65 | w.render(rw, "releases", struct { |
| 66 | Page |
| 67 | Name string |
| 68 | Tags []git.Tag |
| 69 | Lightweight int |
| 70 | CanManage bool |
| 71 | }{w.page(r, m.Name+" · releases"), m.Name, tags, light, w.canManageReleases(r, m)}, 0) |
| 72 | } |
| 73 | |
| 74 | func (w *Web) ReleaseView(rw http.ResponseWriter, r *http.Request) { |
| 75 | m, ok := w.open(rw, r) |
| 76 | if !ok { |
| 77 | return |
| 78 | } |
| 79 | g := m.Git() |
| 80 | t, err := g.Tag(r.PathValue("tag")) |
| 81 | if err != nil { |
| 82 | w.notFound(rw, r) |
| 83 | return |
| 84 | } |
| 85 | // The previous release is the nearest one behind this commit, not the next |
| 86 | // row in a list whose order depends on tie-breaking timestamps. |
| 87 | prevName := g.PreviousTag(t.Commit) |
| 88 | from := "" |
| 89 | if prevName != "" { |
| 90 | if prev, err := g.Tag(prevName); err == nil { |
| 91 | from = prev.Commit |
| 92 | } else { |
| 93 | prevName = "" |
| 94 | } |
| 95 | } |
| 96 | commits, _ := g.CommitsBetween(from, t.Commit, maxReleaseCommits) |
| 97 | count, _ := g.CountBetween(from, t.Commit) |
| 98 | |
| 99 | // The artifact list is the pointer directory on the metadata branch, not a |
| 100 | // stored manifest: the same pointers the sweep marks and the hook validates. |
| 101 | // The index holds the same set, read from the branch it was built from. |
| 102 | artifacts := w.releaseArtifacts(r, m, t.Name) |
| 103 | var artifactBytes int64 |
| 104 | for _, a := range artifacts { |
| 105 | artifactBytes += a.Size |
| 106 | } |
| 107 | |
| 108 | // git archive copies LFS pointer files verbatim, so a tarball of an LFS |
| 109 | // repository holds shopping lists where the reader expects bytes. Saying so is |
| 110 | // cheaper than resolving pointers, and a silent pointer is the kind of surprise |
| 111 | // that costs an afternoon. |
| 112 | lfsPointers := usesLFS(g, t.Commit) |
| 113 | |
| 114 | // Only an annotated tag has notes of its own; a lightweight tag's message is |
| 115 | // the commit's subject, which the reader can already see. |
| 116 | var notes template.HTML |
| 117 | if t.Annotated && strings.TrimSpace(t.Message) != "" { |
| 118 | notes = w.renderMarkdown(m.Name, t.Message) |
| 119 | } |
| 120 | |
| 121 | w.render(rw, "release", struct { |
| 122 | Page |
| 123 | Name string |
| 124 | Tag git.Tag |
| 125 | Notes template.HTML |
| 126 | Prev string |
| 127 | Count int |
| 128 | Commits []git.Commit |
| 129 | Capped bool |
| 130 | CappedAt int |
| 131 | LFSPointers bool |
| 132 | Artifacts []metadata.Artifact |
| 133 | ArtifactBytes int64 |
| 134 | CanEdit bool |
| 135 | }{ |
| 136 | Page: w.page(r, m.Name+" · "+t.Name), |
| 137 | Name: m.Name, |
| 138 | Tag: t, |
| 139 | Notes: notes, |
| 140 | Prev: prevName, |
| 141 | Count: count, |
| 142 | Commits: commits, |
| 143 | Capped: count > len(commits), |
| 144 | CappedAt: maxReleaseCommits, |
| 145 | LFSPointers: lfsPointers, |
| 146 | Artifacts: artifacts, |
| 147 | ArtifactBytes: artifactBytes, |
| 148 | CanEdit: w.canManageReleases(r, m) && t.Annotated && len(artifacts) == 0, |
| 149 | }, 0) |
| 150 | } |
| 151 | |
| 152 | // ReleaseArtifact serves one attached file out of the object store. The read |
| 153 | // rule is the clone rule, so a private repository's artifacts are 404 like |
| 154 | // everything else it has, and the bytes come from the same store LFS uses — an |
| 155 | // artifact is not copied anywhere to be downloadable. |
| 156 | func (w *Web) ReleaseArtifact(rw http.ResponseWriter, r *http.Request) { |
| 157 | m, ok := w.open(rw, r) |
| 158 | if !ok { |
| 159 | return |
| 160 | } |
| 161 | g := m.Git() |
| 162 | t, err := g.Tag(r.PathValue("tag")) |
| 163 | if err != nil { |
| 164 | w.notFound(rw, r) |
| 165 | return |
| 166 | } |
| 167 | name := r.PathValue("artifact") |
| 168 | artifacts := w.releaseArtifacts(r, m, t.Name) |
| 169 | var art *metadata.Artifact |
| 170 | for i := range artifacts { |
| 171 | if artifacts[i].Name == name { |
| 172 | art = &artifacts[i] |
| 173 | break |
| 174 | } |
| 175 | } |
| 176 | if art == nil || w.LFS == nil { |
| 177 | w.notFound(rw, r) |
| 178 | return |
| 179 | } |
| 180 | f, size, err := w.LFS.Open(m.Name, art.OID) |
| 181 | if err != nil { |
| 182 | // A pointer whose object is gone is a store that lost bytes, not a |
| 183 | // client mistake; it still cannot be served. |
| 184 | w.Logger("artifact %s/%s/%s: %v", m.Name, t.Name, name, err) |
| 185 | w.notFound(rw, r) |
| 186 | return |
| 187 | } |
| 188 | defer f.Close() |
| 189 | |
| 190 | // The object id is the content, so it is the validator: the same bytes can |
| 191 | // never arrive under a different ETag, and a re-uploaded name cannot serve |
| 192 | // stale content. |
| 193 | rw.Header().Set("ETag", `"`+art.OID+`"`) |
| 194 | rw.Header().Set("Content-Type", artifactType(name)) |
| 195 | rw.Header().Set("X-Content-Type-Options", "nosniff") |
| 196 | rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", |
| 197 | releaseSafe(name))) |
| 198 | rw.Header().Set("Cache-Control", w.downloadCacheControl(m)) |
| 199 | // ServeContent handles Range and the conditional request against the ETag |
| 200 | // above, which is what makes a 100 MiB artifact resumable. |
| 201 | http.ServeContent(rw, r, releaseSafe(name), time.Time{}, f) |
| 202 | _ = size |
| 203 | } |
| 204 | |
| 205 | // artifactType names the type from the extension and refuses to guess HTML: an |
| 206 | // artifact is downloaded, never rendered on this origin, so a wrong type costs |
| 207 | // nothing while a right one saves the reader a step. |
| 208 | func artifactType(name string) string { |
| 209 | switch strings.ToLower(path.Ext(name)) { |
| 210 | case ".html", ".htm", ".svg", ".xml": |
| 211 | return "application/octet-stream" |
| 212 | } |
| 213 | if t := mime.TypeByExtension(path.Ext(name)); t != "" { |
| 214 | return t |
| 215 | } |
| 216 | return "application/octet-stream" |
| 217 | } |
| 218 | |
| 219 | func (w *Web) ReleaseTar(rw http.ResponseWriter, r *http.Request) { |
| 220 | w.releaseArchive(rw, r, "tar") |
| 221 | } |
| 222 | |
| 223 | func (w *Web) ReleaseTarGz(rw http.ResponseWriter, r *http.Request) { |
| 224 | w.releaseArchive(rw, r, "tar.gz") |
| 225 | } |
| 226 | |
| 227 | func (w *Web) releaseArchive(rw http.ResponseWriter, r *http.Request, format string) { |
| 228 | m, ok := w.open(rw, r) |
| 229 | if !ok { |
| 230 | return |
| 231 | } |
| 232 | g := m.Git() |
| 233 | t, err := g.Tag(r.PathValue("tag")) |
| 234 | if err != nil { |
| 235 | w.notFound(rw, r) |
| 236 | return |
| 237 | } |
| 238 | |
| 239 | // Both formats spawn git and stream for as long as the client reads, so both take |
| 240 | // a slot from the same pool. Plain tar is cheaper than gzip but not free, and an |
| 241 | // unbounded number of either is the thing the bound exists for. |
| 242 | if w.sem != nil { |
| 243 | w.sem <- struct{}{} |
| 244 | defer func() { <-w.sem }() |
| 245 | } |
| 246 | |
| 247 | contentType, ext := "application/x-tar", ".tar" |
| 248 | if format == "tar.gz" { |
| 249 | contentType, ext = "application/gzip", ".tar.gz" |
| 250 | } |
| 251 | // The bytes are a pure function of the tag object and the format, so the tag |
| 252 | // object id is the validator: a re-tagged name changes the ETag, and nothing |
| 253 | // else can change the bytes. |
| 254 | etag := `"` + releaseSafe(t.OID) + "-" + strings.ReplaceAll(format, ".", "") + `"` |
| 255 | rw.Header().Set("ETag", etag) |
| 256 | rw.Header().Set("Cache-Control", w.downloadCacheControl(m)) |
| 257 | rw.Header().Set("Content-Type", contentType) |
| 258 | // Always an attachment: an archive is not a document, and serving one that a |
| 259 | // browser might render would put other people's bytes on this origin. |
| 260 | rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", |
| 261 | releaseSafe(m.Name)+"-"+releaseSafe(t.Name)+ext)) |
| 262 | rw.Header().Set("X-Content-Type-Options", "nosniff") |
| 263 | if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) { |
| 264 | rw.WriteHeader(http.StatusNotModified) |
| 265 | return |
| 266 | } |
| 267 | if r.Method == http.MethodHead { |
| 268 | return |
| 269 | } |
| 270 | |
| 271 | prefix := releaseSafe(m.Name) + "-" + releaseSafe(t.Name) + "/" |
| 272 | if err := g.Archive(rw, t.Commit, format, prefix); err != nil { |
| 273 | // Headers are already on the wire, so the reader gets a cut archive; the |
| 274 | // log is the only place left to say so. |
| 275 | w.Logger("archive %s %s: %v", m.Name, t.Name, err) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // usesLFS reports whether the tree tracks anything through Git LFS. It reads the |
| 280 | // root .gitattributes, which is where `git lfs track` writes its patterns; a |
| 281 | // pattern declared in a subdirectory is not detected, and the notice is worded so |
| 282 | // that the omission cannot make it false. Only the head of the file is read: a |
| 283 | // pattern lives on one line, and the file's length is not evidence of anything. |
| 284 | func usesLFS(g *git.Repo, commit string) bool { |
| 285 | const maxGitattributesBytes = 64 << 10 |
| 286 | data, _, err := g.BlobPrefixAt(commit, ".gitattributes", maxGitattributesBytes) |
| 287 | if err != nil { |
| 288 | return false |
| 289 | } |
| 290 | return bytes.Contains(data, []byte("filter=lfs")) |
| 291 | } |
| 292 | |
| 293 | // releaseSafe reduces a repository or tag name to characters that are safe in a |
| 294 | // filename and in a path prefix. A tag may contain "/" and spaces; a download |
| 295 | // filename may not. |
| 296 | func releaseSafe(s string) string { |
| 297 | var b strings.Builder |
| 298 | for _, c := range s { |
| 299 | switch { |
| 300 | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', |
| 301 | c == '.', c == '-', c == '_', c == '+': |
| 302 | b.WriteRune(c) |
| 303 | default: |
| 304 | b.WriteByte('-') |
| 305 | } |
| 306 | } |
| 307 | out := strings.Trim(b.String(), "-") |
| 308 | if out == "" || out == "." || out == ".." { |
| 309 | return "release" |
| 310 | } |
| 311 | return out |
| 312 | } |
| 313 |