releases.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "errors"
6 "io"
7 "log"
8 "mime/multipart"
9 "net/http"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strconv"
14 "strings"
15
16 "github.com/go-chi/chi/v5"
17
18 "hearthforge/internal/db"
19 "hearthforge/internal/gitcmd"
20 "hearthforge/internal/markdown"
21 "hearthforge/internal/util"
22 "hearthforge/internal/web/views"
23)
24
25const releasesPerPage = 20
26
27// sourceFormats are the archives Archive writes, in display order.
28var sourceFormats = []struct{ format, ext string }{
29 {"zip", ".zip"},
30 {"tar.gz", ".tar.gz"},
31 {"tar.zst", ".tar.zst"},
32}
33
34// unsafeAssetChar matches everything a stored asset name may not contain.
35var unsafeAssetChar = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
36
37// sanitizeFilename reduces an uploaded name to a plain file name.
38func sanitizeFilename(name string) string {
39 safe := unsafeAssetChar.ReplaceAllString(filepath.Base(name), "_")
40 if safe == "" || strings.Trim(safe, ".") == "" {
41 return "_"
42 }
43 return safe
44}
45
46func (s *Server) releaseRoutes(r chi.Router) {
47 r.Get("/{repo}/releases", s.releaseList)
48 r.Get("/{repo}/releases/{id}", s.releaseDetail)
49 r.Get("/{repo}/releases/{id}/assets/{filename}", s.releaseAssetDownload)
50 r.Get("/{repo}/releases/{id}/source/{filename}", s.releaseSourceDownload)
51
52 r.Group(func(r chi.Router) {
53 r.Use(s.requireAdmin)
54 r.Get("/{repo}/releases/new", s.newRelease)
55 r.Post("/{repo}/releases", s.createRelease)
56 r.Post("/{repo}/releases/{id}/delete", s.deleteRelease)
57 })
58}
59
60// releaseDir is the on-disk home of one release.
61func (s *Server) releaseDir(id int64) string {
62 return filepath.Join(s.Cfg.ReleasesDir(), strconv.FormatInt(id, 10))
63}
64
65// releaseParam loads the release named in the URL inside the given repository.
66func (s *Server) releaseParam(w http.ResponseWriter, r *http.Request, repoID int64) (*db.Release, bool) {
67 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
68 if err != nil {
69 http.Error(w, "Not found", http.StatusNotFound)
70 return nil, false
71 }
72 release, err := s.DB.ReleaseInRepo(r.Context(), repoID, id)
73 if err != nil {
74 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
75 return nil, false
76 }
77 if release == nil {
78 http.Error(w, "Not found", http.StatusNotFound)
79 return nil, false
80 }
81 return release, true
82}
83
84func (s *Server) releaseList(w http.ResponseWriter, r *http.Request) {
85 repo, ok := s.visibleRepo(w, r)
86 if !ok {
87 return
88 }
89 total, err := s.DB.CountReleases(r.Context(), repo.ID)
90 if err != nil {
91 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
92 return
93 }
94 page := util.Paginate(util.ParsePage(r.URL.Query().Get("page")), total, releasesPerPage)
95 releases, err := s.DB.ListReleases(r.Context(), repo.ID, releasesPerPage, page.Offset)
96 if err != nil {
97 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
98 return
99 }
100 ids := make([]int64, len(releases))
101 for i, rel := range releases {
102 ids[i] = rel.ID
103 }
104 counts, err := s.DB.AssetCounts(r.Context(), ids)
105 if err != nil {
106 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
107 return
108 }
109 items := make([]views.ReleaseListItem, len(releases))
110 for i, rel := range releases {
111 item := views.ReleaseListItem{Release: rel, AssetCount: counts[rel.ID]}
112 if rel.Notes != nil && *rel.Notes != "" {
113 item.NotesPlain = markdown.ToPlaintext(*rel.Notes)
114 item.NotesHTML = s.MD.Render(*rel.Notes, "", nil)
115 }
116 items[i] = item
117 }
118 views.Render(w, http.StatusOK, views.ReleaseList(s.Cfg, User(r), repo, items, views.PageInfo{
119 Page: page.Page,
120 TotalPages: page.TotalPages,
121 URLTemplate: "/" + repo.Name + "/releases?page={page}",
122 }))
123}
124
125func (s *Server) newRelease(w http.ResponseWriter, r *http.Request) {
126 repo, ok := s.visibleRepo(w, r)
127 if !ok {
128 return
129 }
130 views.Render(w, http.StatusOK, views.NewRelease(s.Cfg, User(r), repo, "", nil))
131}
132
133func (s *Server) createRelease(w http.ResponseWriter, r *http.Request) {
134 if s.limited(w, r, uploadLimiter, false) {
135 return
136 }
137 repo, ok := s.visibleRepo(w, r)
138 if !ok {
139 return
140 }
141 if err := parseUploadForm(r, s.Cfg.MaxUploadBytes); err != nil {
142 http.Error(w, "Bad Request", http.StatusBadRequest)
143 return
144 }
145 if r.MultipartForm != nil {
146 defer r.MultipartForm.RemoveAll()
147 }
148
149 tagCreated := false
150 name := strings.TrimSpace(r.FormValue("name"))
151 notes := strings.TrimSpace(r.FormValue("notes"))
152 createTag := r.FormValue("create_tag") == "on"
153 tagName := ""
154 revision := ""
155 if createTag {
156 tagName = strings.TrimSpace(r.FormValue("tag_name"))
157 revision = strings.TrimSpace(r.FormValue("revision"))
158 }
159 if tooLong(w, r.FormValue("name"), s.Cfg.MaxTitleBytes) ||
160 tooLong(w, r.FormValue("tag_name"), s.Cfg.MaxTitleBytes) ||
161 tooLong(w, r.FormValue("notes"), s.Cfg.MaxTextBodyBytes) {
162 return
163 }
164
165 values := &views.ReleaseFormValues{
166 CreateTag: createTag,
167 TagName: r.FormValue("tag_name"),
168 Revision: r.FormValue("revision"),
169 Name: r.FormValue("name"),
170 Notes: r.FormValue("notes"),
171 IncludeSourceCode: r.FormValue("include_source_code") == "on",
172 }
173 fail := func(msg string) {
174 views.Render(w, http.StatusOK, views.NewRelease(s.Cfg, User(r), repo, msg, values))
175 }
176
177 if name == "" {
178 fail("Release title is required")
179 return
180 }
181 if createTag {
182 switch {
183 case tagName == "":
184 fail("Tag name is required when creating a git tag")
185 return
186 case !validTagName.MatchString(tagName):
187 fail("Tag name may only contain letters, digits, dots, hyphens, underscores, and plus signs")
188 return
189 case revision == "":
190 fail("Revision is required when creating a git tag")
191 return
192 }
193 // The release row carries UNIQUE(repo_id, tag_name). Check it before
194 // creating the tag, so a rejected insert cannot leave a stray tag.
195 exists, err := s.DB.ReleaseExistsForTag(r.Context(), repo.ID, tagName)
196 if err != nil {
197 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
198 return
199 }
200 if exists {
201 views.Render(w, http.StatusUnprocessableEntity,
202 views.NewRelease(s.Cfg, User(r), repo, "A release for this tag already exists", values))
203 return
204 }
205 message := name
206 if notes != "" {
207 message = name + "\n\n" + notes
208 }
209 tagger := gitcmd.Ident{Name: s.Cfg.CommitterName, Email: s.Cfg.CommitterEmail}
210 err = s.Git.CreateTag(r.Context(), repo.Name, tagName, revision, message, tagger)
211 switch {
212 case errors.Is(err, gitcmd.ErrExists):
213 fail(`Git tag "` + tagName + `" already exists in this repository`)
214 return
215 case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef):
216 fail(`"` + revision + `" is not a valid revision in this repository`)
217 return
218 case err != nil:
219 fail("Failed to create git tag")
220 return
221 }
222 tagCreated = true
223 }
224
225 includeSource := values.IncludeSourceCode && createTag && tagName != ""
226 var tagPtr, notesPtr *string
227 if createTag {
228 tagPtr = &tagName
229 }
230 if notes != "" {
231 notesPtr = &notes
232 }
233
234 var files []*multipart.FileHeader
235 if r.MultipartForm != nil {
236 files = r.MultipartForm.File["files"]
237 }
238 // One stored file must map to one asset row. Two uploads that sanitize to
239 // the same name would overwrite each other and still insert two rows.
240 if duplicateAssetName(files) {
241 views.Render(w, http.StatusUnprocessableEntity,
242 views.NewRelease(s.Cfg, User(r), repo, "Duplicate asset name", values))
243 return
244 }
245
246 now := db.NowISO()
247 // The row goes in first so the release id names the directory. Archive
248 // generation and upload copying then run outside any transaction: the
249 // database has a single connection and must not be held for file work.
250 id, err := s.DB.CreateRelease(r.Context(), repo.ID, tagPtr, name, notesPtr, includeSource, now)
251 if err != nil {
252 if isUniqueViolation(err) {
253 views.Render(w, http.StatusUnprocessableEntity,
254 views.NewRelease(s.Cfg, User(r), repo, "A release for this tag already exists", values))
255 return
256 }
257 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
258 return
259 }
260 // A half-written release is removed row and directory together, which is
261 // also what SyncReleases does for a directory left behind by a crash.
262 rollback := func() {
263 // The tag this request created belongs to the release. Leaving it
264 // behind would block a retry with the same tag name.
265 if tagCreated {
266 if derr := s.Git.DeleteTag(context.WithoutCancel(r.Context()), repo.Name, tagName); derr != nil {
267 log.Printf("delete tag %q after failed release write: %v", tagName, derr)
268 }
269 }
270 os.RemoveAll(s.releaseDir(id))
271 if derr := s.DB.DeleteRelease(context.WithoutCancel(r.Context()), id); derr != nil {
272 log.Printf("delete release %d after failed write: %v", id, derr)
273 }
274 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
275 }
276
277 dir := s.releaseDir(id)
278 if includeSource {
279 sourceDir := filepath.Join(dir, "source")
280 if err := os.MkdirAll(sourceDir, 0o755); err != nil {
281 rollback()
282 return
283 }
284 if err := s.Git.Archive(r.Context(), repo.Name, tagName, repo.Name, sourceDir); err != nil {
285 rollback()
286 return
287 }
288 }
289 assets, err := writeReleaseAssets(filepath.Join(dir, "assets"), files)
290 if err != nil {
291 rollback()
292 return
293 }
294 if err := s.DB.AddReleaseAssets(r.Context(), id, assets, now); err != nil {
295 rollback()
296 return
297 }
298 redirectTo(w, r, "/"+repo.Name+"/releases/"+strconv.FormatInt(id, 10))
299}
300
301// duplicateAssetName reports whether two uploads sanitize to the same name.
302func duplicateAssetName(files []*multipart.FileHeader) bool {
303 seen := make(map[string]bool, len(files))
304 for _, fh := range files {
305 if fh.Size == 0 {
306 continue
307 }
308 name := sanitizeFilename(fh.Filename)
309 if seen[name] {
310 return true
311 }
312 seen[name] = true
313 }
314 return false
315}
316
317// writeReleaseAssets stores the uploaded files and returns their asset rows.
318// Empty uploads are skipped, like the browser's empty file input.
319func writeReleaseAssets(assetsDir string, files []*multipart.FileHeader) ([]db.NewAsset, error) {
320 var assets []db.NewAsset
321 for _, fh := range files {
322 if fh.Size == 0 {
323 continue
324 }
325 if len(assets) == 0 {
326 if err := os.MkdirAll(assetsDir, 0o755); err != nil {
327 return nil, err
328 }
329 }
330 filename := sanitizeFilename(fh.Filename)
331 size, err := writeUpload(filepath.Join(assetsDir, filename), fh)
332 if err != nil {
333 return nil, err
334 }
335 assets = append(assets, db.NewAsset{Filename: filename, Size: size})
336 }
337 return assets, nil
338}
339
340func writeUpload(path string, fh *multipart.FileHeader) (int64, error) {
341 src, err := fh.Open()
342 if err != nil {
343 return 0, err
344 }
345 defer src.Close()
346 dst, err := os.Create(path)
347 if err != nil {
348 return 0, err
349 }
350 defer dst.Close()
351 return io.Copy(dst, src)
352}
353
354func (s *Server) releaseDetail(w http.ResponseWriter, r *http.Request) {
355 repo, ok := s.visibleRepo(w, r)
356 if !ok {
357 return
358 }
359 release, ok := s.releaseParam(w, r, repo.ID)
360 if !ok {
361 return
362 }
363 assets, err := s.DB.ListReleaseAssets(r.Context(), release.ID)
364 if err != nil {
365 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
366 return
367 }
368 notesHTML := ""
369 if release.Notes != nil && *release.Notes != "" {
370 notesHTML = s.MD.Render(*release.Notes, "", nil)
371 }
372
373 var archives []views.SourceArchive
374 if release.IncludeSourceCode && release.TagName != nil {
375 base := repo.Name + "-" + *release.TagName
376 sourceDir := filepath.Join(s.releaseDir(release.ID), "source")
377 for _, f := range sourceFormats {
378 info, err := os.Stat(filepath.Join(sourceDir, base+f.ext))
379 if err != nil {
380 continue
381 }
382 archives = append(archives, views.SourceArchive{
383 Format: f.format,
384 Filename: base + f.ext,
385 Size: info.Size(),
386 })
387 }
388 }
389 views.Render(w, http.StatusOK,
390 views.ReleaseDetail(s.Cfg, User(r), repo, release, notesHTML, assets, archives))
391}
392
393func (s *Server) releaseAssetDownload(w http.ResponseWriter, r *http.Request) {
394 repo, ok := s.visibleRepo(w, r)
395 if !ok {
396 return
397 }
398 release, ok := s.releaseParam(w, r, repo.ID)
399 if !ok {
400 return
401 }
402 filename := filepath.Base(chi.URLParam(r, "filename"))
403 asset, err := s.DB.ReleaseAssetByFilename(r.Context(), release.ID, filename)
404 if err != nil {
405 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
406 return
407 }
408 if asset == nil {
409 http.Error(w, "Not found", http.StatusNotFound)
410 return
411 }
412 serveReleaseFile(w, filepath.Join(s.releaseDir(release.ID), "assets", filename), filename)
413}
414
415func (s *Server) releaseSourceDownload(w http.ResponseWriter, r *http.Request) {
416 repo, ok := s.visibleRepo(w, r)
417 if !ok {
418 return
419 }
420 release, ok := s.releaseParam(w, r, repo.ID)
421 if !ok {
422 return
423 }
424 if !release.IncludeSourceCode {
425 http.Error(w, "Not found", http.StatusNotFound)
426 return
427 }
428 filename := filepath.Base(chi.URLParam(r, "filename"))
429 serveReleaseFile(w, filepath.Join(s.releaseDir(release.ID), "source", filename), filename)
430}
431
432// serveReleaseFile sends one file as an attachment. The caller must reduce the
433// name to a plain file name, so the path cannot escape the release directory.
434func serveReleaseFile(w http.ResponseWriter, path, filename string) {
435 f, err := os.Open(path)
436 if err != nil {
437 http.Error(w, "Not found", http.StatusNotFound)
438 return
439 }
440 defer f.Close()
441 info, err := f.Stat()
442 if err != nil || info.IsDir() {
443 http.Error(w, "Not found", http.StatusNotFound)
444 return
445 }
446 w.Header().Set("Content-Disposition", util.ContentDisposition("attachment", filename))
447 w.Header().Set("Content-Type", "application/octet-stream")
448 w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
449 io.Copy(w, f)
450}
451
452func (s *Server) deleteRelease(w http.ResponseWriter, r *http.Request) {
453 repo, ok := s.visibleRepo(w, r)
454 if !ok {
455 return
456 }
457 release, ok := s.releaseParam(w, r, repo.ID)
458 if !ok {
459 return
460 }
461 // The files go first, so a crash in between leaves a visible but broken
462 // release instead of a row pointing at missing files.
463 if err := os.RemoveAll(s.releaseDir(release.ID)); err != nil {
464 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
465 return
466 }
467 if err := s.DB.DeleteRelease(r.Context(), release.ID); err != nil {
468 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
469 return
470 }
471 redirectTo(w, r, "/"+repo.Name+"/releases")
472}
473
474// SyncReleases reconciles the release directories with the database at startup.
475// An orphaned directory is left behind when the server dies after writing the
476// files but before the transaction commits. A missing directory is left behind
477// when it dies after the removal but before the row is deleted.
478func (s *Server) SyncReleases(ctx context.Context) error {
479 ids, err := s.DB.AllReleaseIDs(ctx)
480 if err != nil {
481 return err
482 }
483 known := make(map[string]bool, len(ids))
484 for _, id := range ids {
485 known[strconv.FormatInt(id, 10)] = true
486 }
487
488 // The directory does not exist on first run, so a read error is not fatal.
489 entries, _ := os.ReadDir(s.Cfg.ReleasesDir())
490 for _, e := range entries {
491 if !e.IsDir() || known[e.Name()] {
492 continue
493 }
494 if _, err := strconv.ParseInt(e.Name(), 10, 64); err != nil {
495 continue
496 }
497 if err := os.RemoveAll(filepath.Join(s.Cfg.ReleasesDir(), e.Name())); err != nil {
498 return err
499 }
500 log.Printf("Removed orphaned release directory: %s", e.Name())
501 }
502
503 // Only releases with source archives or assets have a directory.
504 withDir, err := s.DB.ReleaseIDsWithDirectory(ctx)
505 if err != nil {
506 return err
507 }
508 var stale []int64
509 for _, id := range withDir {
510 if _, err := os.Stat(s.releaseDir(id)); err != nil {
511 stale = append(stale, id)
512 }
513 }
514 if len(stale) == 0 {
515 return nil
516 }
517 if err := s.DB.DeleteReleases(ctx, stale); err != nil {
518 return err
519 }
520 log.Printf("Removed %d stale release record(s)", len(stale))
521 return nil
522}
523