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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/releases.go

Line attribution on main · 312 line(s)

c56911e2
Keenan Nemetz
17d ago
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 (
4581690d
Keenan Nemetz
13d ago
9 "bytes"
c56911e2
Keenan Nemetz
17d ago
10 "fmt"
11 "html/template"
85cc89fe
Keenan Nemetz
17d ago
12 "mime"
c56911e2
Keenan Nemetz
17d ago
13 "net/http"
85cc89fe
Keenan Nemetz
17d ago
14 "path"
c56911e2
Keenan Nemetz
17d ago
15 "strings"
85cc89fe
Keenan Nemetz
17d ago
16 "time"
c56911e2
Keenan Nemetz
17d ago
17
d37d301f
Keenan Nemetz
20h ago
18 "rodent.software/vole/core/git"
19 "rodent.software/vole/forge/repo"
20 "rodent.software/vole/metadata"
c56911e2
Keenan Nemetz
17d ago
21 )
22
b739915c
Keenan Nemetz
10d ago
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
701232cf
Keenan Nemetz
13d ago
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.
c56911e2
Keenan Nemetz
17d ago
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 {
e84a66f5
Keenan Nemetz
15d ago
56 w.serverError(rw, r, "cannot list tags: "+err.Error())
c56911e2
Keenan Nemetz
17d ago
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
c5902451
Keenan Nemetz
17d ago
68 Tags []git.Tag
c56911e2
Keenan Nemetz
17d ago
69 Lightweight int
ec343013
Keenan Nemetz
15d ago
70 CanManage bool
71 }{w.page(r, m.Name+" · releases"), m.Name, tags, light, w.canManageReleases(r, m)}, 0)
c56911e2
Keenan Nemetz
17d ago
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 {
e84a66f5
Keenan Nemetz
15d ago
82 w.notFound(rw, r)
c56911e2
Keenan Nemetz
17d ago
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
85cc89fe
Keenan Nemetz
17d ago
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.
b739915c
Keenan Nemetz
10d ago
101 // The index holds the same set, read from the branch it was built from.
102 artifacts := w.releaseArtifacts(r, m, t.Name)
85cc89fe
Keenan Nemetz
17d ago
103 var artifactBytes int64
104 for _, a := range artifacts {
105 artifactBytes += a.Size
106 }
107
520470e1
Keenan Nemetz
16d ago
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.
c56911e2
Keenan Nemetz
17d ago
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
85cc89fe
Keenan Nemetz
17d ago
123 Name string
c5902451
Keenan Nemetz
17d ago
124 Tag git.Tag
85cc89fe
Keenan Nemetz
17d ago
125 Notes template.HTML
126 Prev string
127 Count int
c5902451
Keenan Nemetz
17d ago
128 Commits []git.Commit
85cc89fe
Keenan Nemetz
17d ago
129 Capped bool
130 CappedAt int
131 LFSPointers bool
c5902451
Keenan Nemetz
17d ago
132 Artifacts []metadata.Artifact
85cc89fe
Keenan Nemetz
17d ago
133 ArtifactBytes int64
ec343013
Keenan Nemetz
15d ago
134 CanEdit bool
c56911e2
Keenan Nemetz
17d ago
135 }{
6c84d387
Keenan Nemetz
16d ago
136 Page: w.page(r, m.Name+" · "+t.Name),
85cc89fe
Keenan Nemetz
17d ago
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,
ec343013
Keenan Nemetz
15d ago
148 CanEdit: w.canManageReleases(r, m) && t.Annotated && len(artifacts) == 0,
c56911e2
Keenan Nemetz
17d ago
149 }, 0)
150 }
151
85cc89fe
Keenan Nemetz
17d ago
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 {
e84a66f5
Keenan Nemetz
15d ago
164 w.notFound(rw, r)
85cc89fe
Keenan Nemetz
17d ago
165 return
166 }
167 name := r.PathValue("artifact")
b739915c
Keenan Nemetz
10d ago
168 artifacts := w.releaseArtifacts(r, m, t.Name)
c5902451
Keenan Nemetz
17d ago
169 var art *metadata.Artifact
85cc89fe
Keenan Nemetz
17d ago
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 {
e84a66f5
Keenan Nemetz
15d ago
177 w.notFound(rw, r)
85cc89fe
Keenan Nemetz
17d ago
178 return
179 }
65b88b3b
Keenan Nemetz
17d ago
180 f, size, err := w.LFS.Open(m.Name, art.OID)
85cc89fe
Keenan Nemetz
17d ago
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)
e84a66f5
Keenan Nemetz
15d ago
185 w.notFound(rw, r)
85cc89fe
Keenan Nemetz
17d ago
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)))
25343521
Keenan Nemetz
3d ago
198 rw.Header().Set("Cache-Control", w.downloadCacheControl(m))
85cc89fe
Keenan Nemetz
17d ago
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
c56911e2
Keenan Nemetz
17d ago
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 {
e84a66f5
Keenan Nemetz
15d ago
235 w.notFound(rw, r)
c56911e2
Keenan Nemetz
17d ago
236 return
237 }
238
701232cf
Keenan Nemetz
13d ago
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
c56911e2
Keenan Nemetz
17d ago
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)
25343521
Keenan Nemetz
3d ago
256 rw.Header().Set("Cache-Control", w.downloadCacheControl(m))
c56911e2
Keenan Nemetz
17d ago
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
4581690d
Keenan Nemetz
13d ago
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.
c5902451
Keenan Nemetz
17d ago
284 func usesLFS(g *git.Repo, commit string) bool {
4581690d
Keenan Nemetz
13d ago
285 const maxGitattributesBytes = 64 << 10
286 data, _, err := g.BlobPrefixAt(commit, ".gitattributes", maxGitattributesBytes)
c56911e2
Keenan Nemetz
17d ago
287 if err != nil {
288 return false
289 }
4581690d
Keenan Nemetz
13d ago
290 return bytes.Contains(data, []byte("filter=lfs"))
c56911e2
Keenan Nemetz
17d ago
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 }