Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 9.9 KiB
1package 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
8import (
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.
26func (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.
43const 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.
47const maxReleaseCommits = 100
48
49func (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
74func (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.
156func (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.
208func 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
219func (w *Web) ReleaseTar(rw http.ResponseWriter, r *http.Request) {
220 w.releaseArchive(rw, r, "tar")
221}
222
223func (w *Web) ReleaseTarGz(rw http.ResponseWriter, r *http.Request) {
224 w.releaseArchive(rw, r, "tar.gz")
225}
226
227func (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.
284func 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.
296func 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