~gitpocket/gitpocket

8

feat(api): serve a json twin of every page under /api with scoped tokens

commit
0064912a0e7081eeb6a2461ffdc4ce720469a2f1 browse tree
parent
d2d81efd76ee70d09c0fafa721f6ca10b9618ac0
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 13:56 UTC

29 files changed, +4423 −600

forge/actions.go added

@@ -0,0 +1,575 @@
1+package forge
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log"
7+ "net/http"
8+ "net/url"
9+ "os"
10+ "path"
11+ "path/filepath"
12+ "slices"
13+ "strings"
14+)
15+
16+type problem struct {
17+ code int
18+ msg string
19+}
20+
21+func (p *problem) Error() string { return p.msg }
22+
23+func refuse(code int, msg string) error { return &problem{code, msg} }
24+
25+func short(h string) string {
26+ if len(h) > 10 {
27+ return h[:10]
28+ }
29+ return h
30+}
31+
32+func problemCode(err error) int {
33+ var p *problem
34+ if errors.As(err, &p) {
35+ return p.code
36+ }
37+ return 400
38+}
39+
40+func (s *Server) settle(w http.ResponseWriter, r *http.Request, back string, err error) {
41+ switch {
42+ case err == nil:
43+ http.Redirect(w, r, back, http.StatusSeeOther)
44+ case problemCode(err) == 400 || problemCode(err) == 409:
45+ target, frag, _ := strings.Cut(back, "#")
46+ if frag != "" {
47+ frag = "#" + frag
48+ }
49+ http.Redirect(w, r, target+"?error="+url.QueryEscape(err.Error())+frag, http.StatusSeeOther)
50+ default:
51+ s.fail(w, r, problemCode(err), err.Error())
52+ }
53+}
54+
55+func (s *Server) materialise(repo Repo, build func(dir string) error) error {
56+ dir := s.repoDir(repo)
57+ err := os.MkdirAll(filepath.Dir(dir), 0o755)
58+ if err == nil {
59+ err = build(dir)
60+ }
61+ if err != nil {
62+ log.Printf("create %s: %v", repo.Path(), err)
63+ os.RemoveAll(dir)
64+ s.store.DeleteRepo(repo.ID)
65+ return refuse(500, "Could not create the repository")
66+ }
67+ installHook(s.root, s.host, dir)
68+ return nil
69+}
70+
71+func (s *Server) createRepo(u User, ownerName, name, description string, vis int, tags string) (Repo, error) {
72+ owner := u
73+ if ownerName != "" && ownerName != u.Name {
74+ org, err := s.store.UserByName(ownerName)
75+ if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
76+ return Repo{}, refuse(403, "You are not a member of that organization")
77+ }
78+ owner = org
79+ }
80+ if s.uploads.blocked("create:" + u.Name) {
81+ return Repo{}, refuse(429, "Too many new repositories, try again later")
82+ }
83+ s.uploads.fail("create:" + u.Name)
84+ repo, err := s.store.CreateRepo(owner, strings.TrimSpace(name), strings.TrimSpace(description), vis, tags)
85+ if err != nil {
86+ return Repo{}, err
87+ }
88+ return repo, s.materialise(repo, initRepo)
89+}
90+
91+func (s *Server) forkRepo(u User, repo Repo) (Repo, error) {
92+ if u.ID == repo.Owner.ID {
93+ return Repo{}, refuse(409, "You cannot fork your own repository")
94+ }
95+ if s.shunned(repo, &u) {
96+ return Repo{}, refuse(403, "You cannot fork this repository")
97+ }
98+ if s.uploads.blocked("create:" + u.Name) {
99+ return Repo{}, refuse(429, "Too many new repositories, try again later")
100+ }
101+ s.uploads.fail("create:" + u.Name)
102+ fork, err := s.store.ForkRepo(u, repo)
103+ if err != nil {
104+ return Repo{}, err
105+ }
106+ return fork, s.materialise(fork, func(dir string) error { return cloneRepo(s.repoDir(repo), dir) })
107+}
108+
109+func (s *Server) ownRepo(u *User, repo Repo) error {
110+ if !s.store.Owns(repo, u) {
111+ return refuse(403, "Not your repository")
112+ }
113+ return nil
114+}
115+
116+func (s *Server) renameRepoTo(u User, repo Repo, name string) (Repo, error) {
117+ if err := s.ownRepo(&u, repo); err != nil {
118+ return repo, err
119+ }
120+ if name == repo.Name {
121+ return repo, nil
122+ }
123+ if err := s.store.RenameRepo(repo.ID, name); err != nil {
124+ return repo, err
125+ }
126+ renamed := repo
127+ renamed.Name = name
128+ if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil {
129+ s.store.RenameRepo(repo.ID, repo.Name)
130+ return repo, refuse(500, err.Error())
131+ }
132+ s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
133+ return renamed, nil
134+}
135+
136+func (s *Server) transferRepoTo(u User, repo Repo, owner string) (Repo, error) {
137+ if err := s.ownRepo(&u, repo); err != nil {
138+ return repo, err
139+ }
140+ target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(owner, "~")))
141+ if err != nil {
142+ return repo, refuse(404, "No such user")
143+ }
144+ if target.ID == repo.Owner.ID {
145+ return repo, nil
146+ }
147+ if err := s.store.TransferRepo(repo.ID, target.ID); err != nil {
148+ return repo, err
149+ }
150+ moved := repo
151+ moved.Owner = target
152+ os.MkdirAll(filepath.Dir(s.repoDir(moved)), 0o755)
153+ if err := os.Rename(s.repoDir(repo), s.repoDir(moved)); err != nil {
154+ s.store.TransferRepo(repo.ID, repo.Owner.ID)
155+ return repo, refuse(500, err.Error())
156+ }
157+ s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
158+ s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()),
159+ fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path()))
160+ return moved, nil
161+}
162+
163+func (s *Server) removeRepo(u User, repo Repo) error {
164+ if err := s.ownRepo(&u, repo); err != nil {
165+ return err
166+ }
167+ if err := s.store.DeleteRepo(repo.ID); err != nil {
168+ return refuse(500, err.Error())
169+ }
170+ os.RemoveAll(s.repoDir(repo))
171+ return nil
172+}
173+
174+func (s *Server) starRepo(u User, repo Repo, on bool) error {
175+ if on && s.shunned(repo, &u) {
176+ return refuse(403, "You cannot star this repository")
177+ }
178+ s.store.Star(u.ID, repo.ID, on)
179+ return nil
180+}
181+
182+func (s *Server) followUser(u User, owner User, on bool) error {
183+ if u.ID == owner.ID {
184+ return refuse(409, "You cannot follow yourself")
185+ }
186+ if on && s.store.Blocked(owner.ID, u.ID) {
187+ return refuse(403, "You cannot follow this account")
188+ }
189+ s.store.Follow(u.ID, owner.ID, on)
190+ return nil
191+}
192+
193+func (s *Server) setMember(u User, org User, name string, on bool) error {
194+ if !s.store.IsMember(org.ID, u.ID) {
195+ return refuse(403, "You are not a member of this organization")
196+ }
197+ target, err := s.store.UserByName(strings.TrimPrefix(strings.TrimSpace(name), "~"))
198+ if err != nil || s.store.IsOrg(target.ID) {
199+ return refuse(404, "No such user")
200+ }
201+ if !on && len(s.store.Members(org.ID)) == 1 && s.store.IsMember(org.ID, target.ID) {
202+ return refuse(409, "An organization needs at least one member")
203+ }
204+ return s.store.SetMember(org.ID, target.ID, on)
205+}
206+
207+func (s *Server) createOrg(u User, name string) (User, error) {
208+ if s.limiter.blocked("org:" + u.Name) {
209+ return User{}, refuse(429, "Too many organizations created recently, try again later")
210+ }
211+ org, err := s.store.CreateOrg(strings.TrimSpace(name))
212+ if err == nil {
213+ err = s.store.SetMember(org.ID, u.ID, true)
214+ }
215+ if err != nil {
216+ return User{}, err
217+ }
218+ s.limiter.fail("org:" + u.Name)
219+ return org, nil
220+}
221+
222+func (s *Server) openIssue(u User, repo Repo, title, body string) (Issue, error) {
223+ if repo.Archived {
224+ return Issue{}, refuse(403, "This repository is archived")
225+ }
226+ if s.shunned(repo, &u) {
227+ return Issue{}, refuse(403, "You cannot open issues here")
228+ }
229+ if !s.store.ThreadBudget(u.ID) {
230+ return Issue{}, refuse(429, "Too many new issues and pull requests, try again later")
231+ }
232+ issue, err := s.store.CreateIssue(repo, u, title, strings.TrimSpace(body))
233+ if err != nil {
234+ return Issue{}, err
235+ }
236+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, "opened")
237+ if repo.Owner.ID != u.ID {
238+ s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title),
239+ fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", u.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number))
240+ }
241+ return issue, nil
242+}
243+
244+func (s *Server) commentIssue(u User, repo Repo, issue Issue, body string) error {
245+ if repo.Archived {
246+ return refuse(403, "This repository is archived")
247+ }
248+ if s.shunned(repo, &u) {
249+ return refuse(403, "You cannot comment here")
250+ }
251+ if err := s.store.AddIssueComment(issue.ID, u, body); err != nil {
252+ return err
253+ }
254+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, "commented on")
255+ subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title)
256+ link := fmt.Sprintf("https://%s/%s/issues/%d", s.host, repo.Path(), issue.Number)
257+ for _, id := range []int64{issue.Author.ID, repo.Owner.ID} {
258+ if id != u.ID {
259+ s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", u.Name, issue.Number, body, link))
260+ }
261+ }
262+ return nil
263+}
264+
265+func (s *Server) setIssueState(u User, repo Repo, issue Issue, open bool) error {
266+ if !s.store.CanWrite(repo, &u) && u.ID != issue.Author.ID {
267+ return refuse(403, "Only collaborators and the author can close or reopen this issue")
268+ }
269+ state, verb := "closed", "closed"
270+ if open {
271+ state, verb = "open", "reopened"
272+ }
273+ if issue.State == state {
274+ return nil
275+ }
276+ s.store.SetIssueState(issue.ID, state)
277+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, verb)
278+ return nil
279+}
280+
281+func (s *Server) setLabels(u User, repo Repo, kind string, item int64, ids []int64) error {
282+ if !s.store.CanWrite(repo, &u) {
283+ return refuse(403, "Only collaborators can change labels and assignees")
284+ }
285+ s.store.SetItemLabels(kind, item, repo.ID, ids)
286+ return nil
287+}
288+
289+func (s *Server) setAssignees(u User, repo Repo, kind string, item int64, ids []int64) error {
290+ if !s.store.CanWrite(repo, &u) {
291+ return refuse(403, "Only collaborators can change labels and assignees")
292+ }
293+ allowed := map[int64]bool{}
294+ for _, a := range s.store.Assignable(repo) {
295+ allowed[a.ID] = true
296+ }
297+ var keep []int64
298+ for _, id := range ids {
299+ if allowed[id] {
300+ keep = append(keep, id)
301+ }
302+ }
303+ s.store.SetAssignees(kind, item, keep)
304+ return nil
305+}
306+
307+func (s *Server) openPull(u User, repo Repo, source, target, title, body string, draft bool) (Pull, error) {
308+ g := Git{Dir: s.repoDir(repo)}
309+ if repo.Archived {
310+ return Pull{}, refuse(403, "This repository is archived")
311+ }
312+ if s.store.IsPullMirror(repo.ID) {
313+ return Pull{}, refuse(403, "This repository is a mirror and does not take pull requests")
314+ }
315+ if s.shunned(repo, &u) {
316+ return Pull{}, refuse(403, "You cannot open pull requests here")
317+ }
318+ if !s.store.ThreadBudget(u.ID) {
319+ return Pull{}, refuse(429, "Too many new issues and pull requests, try again later")
320+ }
321+ src, branch, err := s.resolveSource(repo, &u, source)
322+ if err != nil {
323+ return Pull{}, err
324+ }
325+ if _, err := g.Resolve("refs/heads/" + target); err != nil || !validBranch(target) {
326+ return Pull{}, refuse(400, "No such target branch")
327+ }
328+ if src.ID == repo.ID && branch == target {
329+ return Pull{}, refuse(400, "Source and target are the same branch")
330+ }
331+ pull, err := s.store.CreatePull(repo, u, title, strings.TrimSpace(body), src, branch, target)
332+ if err != nil {
333+ return Pull{}, err
334+ }
335+ if draft {
336+ s.store.SetDraft(pull.ID, true)
337+ pull.Draft = true
338+ }
339+ if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil {
340+ return Pull{}, refuse(500, "Could not fetch the source branch: "+err.Error())
341+ }
342+ g.RecordRevision(pull)
343+ s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), u, repo, "pull", pull.Number, pull.Title, "opened")
344+ if repo.Owner.ID != u.ID {
345+ s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title),
346+ fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\nhttps://%s/%s/pulls/%d\n", u.Name, repo.Path(), pull.Body, s.host, repo.Path(), pull.Number))
347+ }
348+ return pull, nil
349+}
350+
351+func (s *Server) pullGuard(u User, pull Pull, closing bool) error {
352+ if pull.Repo.Archived {
353+ return refuse(403, "This repository is archived")
354+ }
355+ if !closing && s.shunned(pull.Repo, &u) {
356+ return refuse(403, "You cannot take part here")
357+ }
358+ return nil
359+}
360+
361+func (s *Server) pullManager(u User, pull Pull) bool {
362+ return s.store.CanWrite(pull.Repo, &u) || u.ID == pull.Author.ID
363+}
364+
365+func (s *Server) commentPull(u User, pull Pull, body string) error {
366+ if err := s.pullGuard(u, pull, false); err != nil {
367+ return err
368+ }
369+ if err := s.store.AddComment(pull.ID, u, body); err != nil {
370+ return err
371+ }
372+ s.notifyPull(pull, u, "commented on", body)
373+ return nil
374+}
375+
376+func (s *Server) setPullState(u User, pull Pull, open bool) error {
377+ if err := s.pullGuard(u, pull, true); err != nil {
378+ return err
379+ }
380+ if !s.pullManager(u, pull) {
381+ return refuse(403, "Only collaborators and the author can close or reopen this pull request")
382+ }
383+ from, to, verb := "open", "closed", "closed"
384+ if open {
385+ from, to, verb = "closed", "open", "reopened"
386+ }
387+ if pull.State != from {
388+ return refuse(409, "This pull request is "+pull.State)
389+ }
390+ s.store.SetPullState(pull.ID, to, "")
391+ s.store.Notify(s.store.Watchers(pull.Repo, "pull", pull.ID, pull.Author.ID), u, pull.Repo, "pull", pull.Number, pull.Title, verb)
392+ return nil
393+}
394+
395+func (s *Server) setPullDraft(u User, pull Pull, draft bool) error {
396+ if err := s.pullGuard(u, pull, false); err != nil {
397+ return err
398+ }
399+ if !s.pullManager(u, pull) {
400+ return refuse(403, "Only collaborators and the author can change the draft state")
401+ }
402+ s.store.SetDraft(pull.ID, draft)
403+ return nil
404+}
405+
406+func (s *Server) notePull(u User, pull Pull, filePath, side string, line int, body string) error {
407+ if err := s.pullGuard(u, pull, false); err != nil {
408+ return err
409+ }
410+ if err := s.store.AddReview(pull.ID, u, filePath, side, line, body); err != nil {
411+ return err
412+ }
413+ s.notifyPull(pull, u, fmt.Sprintf("reviewed %s line %d on", filePath, line), body)
414+ return nil
415+}
416+
417+func (s *Server) touchNote(u User, pull Pull, id int64, action string) error {
418+ if err := s.pullGuard(u, pull, false); err != nil {
419+ return err
420+ }
421+ notes, _ := s.store.Reviews(pull.ID)
422+ i := slices.IndexFunc(notes, func(r Review) bool { return r.ID == id })
423+ if i < 0 {
424+ return refuse(404, "No such comment on this pull request")
425+ }
426+ author := notes[i].Author.ID
427+ if author != u.ID && !s.store.CanWrite(pull.Repo, &u) {
428+ return refuse(403, "Not your comment")
429+ }
430+ switch action {
431+ case "resolve":
432+ s.store.ResolveReview(id, true)
433+ case "unresolve":
434+ s.store.ResolveReview(id, false)
435+ case "delete":
436+ s.store.DeleteReview(id)
437+ }
438+ return nil
439+}
440+
441+func (s *Server) judgePull(u User, pull Pull, state, body string) error {
442+ if err := s.pullGuard(u, pull, false); err != nil {
443+ return err
444+ }
445+ if u.ID == pull.Author.ID && state != "comment" {
446+ return refuse(403, "You cannot approve your own pull request")
447+ }
448+ head, _ := Git{Dir: s.repoDir(pull.Repo)}.Resolve(pull.Ref())
449+ if err := s.store.AddPRReview(pull.ID, u, state, body, head); err != nil {
450+ return err
451+ }
452+ verb := map[string]string{"approved": "approved", "changes": "requested changes on", "comment": "reviewed"}[state]
453+ s.notifyPull(pull, u, verb, body)
454+ return nil
455+}
456+
457+func (s *Server) refreshPull(u User, pull Pull) error {
458+ if err := s.pullGuard(u, pull, false); err != nil {
459+ return err
460+ }
461+ if !s.pullManager(u, pull) {
462+ return refuse(403, "Only collaborators and the author can update this pull request")
463+ }
464+ if pull.State != "open" || pull.Agit != "" {
465+ return refuse(409, "Only open pull requests from a branch can be updated")
466+ }
467+ g := Git{Dir: s.repoDir(pull.Repo)}
468+ if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil {
469+ return err
470+ }
471+ g.RecordRevision(pull)
472+ s.store.TouchPull(pull.ID)
473+ return nil
474+}
475+
476+func (s *Server) mergePull(u User, pull Pull, strategy, title, body string) (string, error) {
477+ if err := s.pullGuard(u, pull, false); err != nil {
478+ return "", err
479+ }
480+ repo := pull.Repo
481+ if s.store.IsPullMirror(repo.ID) {
482+ return "", refuse(403, "This repository is a mirror and read only")
483+ }
484+ if !s.store.CanWrite(repo, &u) || pull.State != "open" {
485+ return "", refuse(403, "Only the repository owner can merge")
486+ }
487+ if pull.Draft {
488+ return "", refuse(409, "Draft pull requests cannot be merged")
489+ }
490+ g := Git{Dir: s.repoDir(repo)}
491+ if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok && prot.Approvals > 0 {
492+ head, _ := g.Resolve(pull.Ref())
493+ approvals, changes := latestVerdicts(s.store.PRReviews(pull.ID), head)
494+ if approvals < prot.Approvals || changes > 0 {
495+ return "", refuse(409, fmt.Sprintf("%s needs %d approving reviews of the current head and no outstanding change requests", pull.TargetBranch, prot.Approvals))
496+ }
497+ }
498+ title = strings.TrimSpace(title)
499+ if title == "" {
500+ title = mergeTitle(pull)
501+ }
502+ message := title
503+ if body = strings.TrimSpace(body); body != "" {
504+ message += "\n\n" + body
505+ }
506+ var hash string
507+ var err error
508+ switch strategy {
509+ case "squash":
510+ hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, u.Name, u.Name+"@"+s.host)
511+ case "rebase":
512+ hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), u.Name, u.Name+"@"+s.host)
513+ case "ff":
514+ hash, err = g.FastForward(pull.TargetBranch, pull.Ref(), pull.baseRef())
515+ case "", "merge":
516+ hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, u.Name, u.Name+"@"+s.host)
517+ default:
518+ return "", refuse(400, "Strategy must be merge, squash, rebase or ff")
519+ }
520+ if err != nil {
521+ return "", refuse(409, err.Error())
522+ }
523+ s.store.SetPullState(pull.ID, "merged", hash)
524+ s.notifyPull(pull, u, "merged", "")
525+ return hash, nil
526+}
527+
528+func (s *Server) editable(u *User, repo Repo, branch string) (Git, string, error) {
529+ g := Git{Dir: s.repoDir(repo)}
530+ if !s.store.CanWrite(repo, u) {
531+ return g, "", refuse(403, "You cannot write to this repository")
532+ }
533+ if repo.Archived {
534+ return g, "", refuse(403, "This repository is archived")
535+ }
536+ if s.store.IsPullMirror(repo.ID) {
537+ return g, "", refuse(403, "This repository is a mirror and read only")
538+ }
539+ head, err := g.Resolve("refs/heads/" + branch)
540+ if err != nil || !validBranch(branch) {
541+ return g, "", refuse(404, "Edits need a branch, not a tag or commit")
542+ }
543+ if prot, ok := s.store.Protection(repo.ID, branch); ok && prot.RequirePR {
544+ return g, "", refuse(403, branch+" only accepts changes through pull requests")
545+ }
546+ return g, head, nil
547+}
548+
549+func (s *Server) writeFile(u User, repo Repo, branch, filePath string, content []byte, remove bool, message, parent string) (string, error) {
550+ g, head, err := s.editable(&u, repo, branch)
551+ if err != nil {
552+ return "", err
553+ }
554+ if parent != "" && parent != head {
555+ return "", refuse(409, branch+" has moved on since "+short(parent))
556+ }
557+ kind := g.Kind(head, filePath)
558+ if remove && kind != "blob" {
559+ return "", refuse(404, "No such file")
560+ }
561+ if kind == "tree" {
562+ return "", refuse(409, filePath+" is a directory")
563+ }
564+ if strings.TrimSpace(message) == "" {
565+ verb := "Update "
566+ switch {
567+ case remove:
568+ verb = "Delete "
569+ case kind == "":
570+ verb = "Create "
571+ }
572+ message = verb + path.Base(filePath)
573+ }
574+ return g.Commit1(branch, head, filePath, content, remove, strings.TrimSpace(message), u.Name, u.Name+"@"+s.host)
575+}

forge/admin.go modified

@@ -166,7 +166,7 @@ func (s *Store) Accounts() []account {
166166 }
167167
168168 var adminSections = []struct{ Key, Title string }{
169- {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, {"reports", "reports"},
169+ {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, {"gists", "gists"}, {"reports", "reports"},
170170 }
171171
172172 type siteStats struct {
@@ -238,6 +238,8 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
238238 state = "open"
239239 }
240240 data["Reports"], data["State"] = s.store.Reports(state), state
241+ case "gists":
242+ data["Gists"] = s.gistLedger()
241243 case "storage":
242244 quota := readQuota(s.root)
243245 type owner struct {
@@ -269,6 +271,44 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
269271 s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: data})
270272 }
271273
274+type gistRow struct {
275+ Gist
276+ Size int64
277+ Files int
278+ Reports int
279+}
280+
281+func (s *Server) gistLedger() []gistRow {
282+ gists, _ := s.store.Gists(0, true, 5000)
283+ open := map[string]int{}
284+ for _, rep := range s.store.Reports("open") {
285+ if rep.Kind == "gist" {
286+ open[rep.Target]++
287+ }
288+ }
289+ rows := make([]gistRow, 0, len(gists))
290+ for _, g := range gists {
291+ entries, _ := s.gistGit(g).gistTree(s.gistHead(g))
292+ rows = append(rows, gistRow{Gist: g, Size: usage(s.gistGit(g).Dir), Files: len(entries), Reports: open[g.Owner.Name+"/"+g.ID]})
293+ }
294+ slices.SortStableFunc(rows, func(a, b gistRow) int {
295+ if a.Reports != b.Reports {
296+ return b.Reports - a.Reports
297+ }
298+ return int(b.Size - a.Size)
299+ })
300+ return rows
301+}
302+
303+func (s *Server) apiAdminGists(c *call) {
304+ type row struct {
305+ gistDoc
306+ Bytes int64 `json:"bytes_on_disk"`
307+ Reports int `json:"open_reports"`
308+ }
309+ paged(c, mapped(s.gistLedger(), func(g gistRow) row { return row{s.gistDoc(g.Gist, false), g.Size, g.Reports} }))
310+}
311+
272312 func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
273313 section := r.FormValue("s")
274314 back := "/admin?s=" + url.QueryEscape(section)
@@ -359,19 +399,11 @@ func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) {
359399 return
360400 }
361401 name := strings.TrimSpace(r.FormValue("name"))
362- if s.limiter.blocked("org:" + u.Name) {
363- s.render(w, r, "neworg", page{Title: "New organization", User: u, Error: "Too many organizations created recently, try again later", Data: map[string]any{"Name": name}})
364- return
365- }
366- org, err := s.store.CreateOrg(name)
367- if err == nil {
368- err = s.store.SetMember(org.ID, u.ID, true)
369- }
402+ org, err := s.createOrg(*u, name)
370403 if err != nil {
371- w.WriteHeader(400)
404+ w.WriteHeader(problemCode(err))
372405 s.render(w, r, "neworg", page{Title: "New organization", User: u, Error: err.Error(), Data: map[string]any{"Name": name}})
373406 return
374407 }
375- s.limiter.fail("org:" + u.Name)
376408 http.Redirect(w, r, "/~"+org.Name+"/settings", http.StatusSeeOther)
377409 }

forge/api.go added

@@ -0,0 +1,444 @@
1+package forge
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "io"
7+ "log"
8+ "net/http"
9+ "net/url"
10+ "strconv"
11+ "strings"
12+ "time"
13+)
14+
15+const (
16+ needNone = "none"
17+ needUser = "user"
18+ needWrite = "write"
19+ needGist = "gist"
20+ needAdmin = "admin"
21+ needPassword = "password"
22+)
23+
24+type apiRoute struct {
25+ Method string `json:"method"`
26+ Path string `json:"path"`
27+ Needs string `json:"needs"`
28+ About string `json:"about"`
29+ handle func(c *call)
30+ pattern []string
31+}
32+
33+type call struct {
34+ s *Server
35+ w http.ResponseWriter
36+ r *http.Request
37+ user *User
38+ scope string
39+ via string
40+ args map[string]string
41+}
42+
43+func (s *Server) api() []apiRoute {
44+ routes := []apiRoute{
45+ {"GET", "/", needNone, "this index: every route, the scope it needs and what it does", nil, nil},
46+ }
47+ routes = append(routes, s.peopleRoutes()...)
48+ routes = append(routes, s.repoRoutes()...)
49+ routes = append(routes, s.threadRoutes()...)
50+ routes = append(routes, s.gistRoutes()...)
51+ for i := range routes {
52+ routes[i].pattern = strings.Split(strings.Trim(routes[i].Path, "/"), "/")
53+ }
54+ routes[0].handle = func(c *call) { c.s.apiIndex(c, routes) }
55+ return routes
56+}
57+
58+func (s *Server) apiIndex(c *call, routes []apiRoute) {
59+ if strings.Contains(c.r.Header.Get("Accept"), "text/html") {
60+ groups := map[string][]apiRoute{}
61+ var order []string
62+ for _, rt := range routes[1:] {
63+ key := apiGroup(rt.Path)
64+ if _, ok := groups[key]; !ok {
65+ order = append(order, key)
66+ }
67+ groups[key] = append(groups[key], rt)
68+ }
69+ type group struct {
70+ Name string
71+ Routes []apiRoute
72+ }
73+ var out []group
74+ for _, k := range order {
75+ out = append(out, group{k, groups[k]})
76+ }
77+ s.render(c.w, c.r, "api", page{Title: "API", User: s.current(c.r), Data: map[string]any{"Groups": out, "Base": "https://" + s.host + "/api"}})
78+ return
79+ }
80+ c.reply(200, map[string]any{
81+ "site": s.name,
82+ "base": "https://" + s.host + "/api",
83+ "auth": "send a token as `Authorization: Bearer gp_…` or as the password of HTTP basic auth, tokens are made under /settings/tokens or with POST /api/me/tokens",
84+ "scopes": map[string]string{scopeAll: "everything the account may do", scopeRead: "reads only", scopeGist: "reads, plus writes to your own gists"},
85+ "needs": map[string]string{needNone: "anyone, private data appears when authenticated", needUser: "any authenticated caller", needWrite: "a token scoped all, a password or a session",
86+ needGist: "a token scoped all or gist", needAdmin: "a site administrator with a token scoped all", needPassword: "a password or a session, never a token"},
87+ "paging": "lists answer {items, total, next}, follow next or pass ?from= and ?limit= (at most 100)",
88+ "routes": routes,
89+ })
90+}
91+
92+func apiGroup(p string) string {
93+ parts := strings.Split(strings.Trim(p, "/"), "/")
94+ switch {
95+ case parts[0] == "~:owner" && len(parts) > 2 && (parts[2] == "issues" || parts[2] == "pulls"):
96+ return parts[2]
97+ case parts[0] == "~:owner" && len(parts) > 1 && parts[1] == ":repo":
98+ return "repositories"
99+ case parts[0] == "~:owner":
100+ return "accounts"
101+ }
102+ return parts[0]
103+}
104+
105+func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
106+ h := w.Header()
107+ h.Set("Access-Control-Allow-Origin", "*")
108+ h.Set("Access-Control-Expose-Headers", "Link")
109+ h.Set("Cache-Control", "no-store")
110+ if r.Method == "OPTIONS" {
111+ h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
112+ h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
113+ h.Set("Access-Control-Max-Age", "86400")
114+ w.WriteHeader(204)
115+ return
116+ }
117+ c := &call{s: s, w: w, r: r, args: map[string]string{}}
118+ rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
119+ var segs []string
120+ if rest != "" {
121+ segs = strings.Split(rest, "/")
122+ }
123+ method := r.Method
124+ if method == "HEAD" {
125+ method = "GET"
126+ }
127+ var allowed []string
128+ for _, rt := range s.routes {
129+ args, ok := matchRoute(rt.pattern, segs)
130+ if !ok {
131+ continue
132+ }
133+ if rt.Method != method {
134+ allowed = append(allowed, rt.Method)
135+ continue
136+ }
137+ c.args = args
138+ if !c.authenticate() || !c.permit(rt.Needs) {
139+ return
140+ }
141+ rt.handle(c)
142+ return
143+ }
144+ if len(allowed) > 0 {
145+ h.Set("Allow", strings.Join(allowed, ", "))
146+ c.fail(405, "Use "+strings.Join(allowed, " or ")+" here")
147+ return
148+ }
149+ c.fail(404, "No such route, see /api for the list")
150+}
151+
152+func matchRoute(pattern, segs []string) (map[string]string, bool) {
153+ if len(pattern) == 1 && pattern[0] == "" {
154+ return nil, len(segs) == 0
155+ }
156+ args := map[string]string{}
157+ for i, p := range pattern {
158+ if strings.HasPrefix(p, "*") {
159+ args[p[1:]] = strings.Join(segs[min(i, len(segs)):], "/")
160+ return args, true
161+ }
162+ if i >= len(segs) {
163+ return nil, false
164+ }
165+ prefix, name, wild := strings.Cut(p, ":")
166+ if !wild {
167+ if segs[i] != p {
168+ return nil, false
169+ }
170+ continue
171+ }
172+ if !strings.HasPrefix(segs[i], prefix) || len(segs[i]) == len(prefix) {
173+ return nil, false
174+ }
175+ v, err := url.PathUnescape(segs[i][len(prefix):])
176+ if err != nil {
177+ return nil, false
178+ }
179+ args[name] = v
180+ }
181+ return args, len(segs) == len(pattern)
182+}
183+
184+func (c *call) sameOrigin() bool {
185+ switch c.r.Header.Get("Sec-Fetch-Site") {
186+ case "same-origin":
187+ return true
188+ case "":
189+ o := c.r.Header.Get("Origin")
190+ return o == "" || o == "https://"+c.s.host
191+ }
192+ return false
193+}
194+
195+func (c *call) authenticate() bool {
196+ ip := clientIP(c.r)
197+ auth := c.r.Header.Get("Authorization")
198+ if auth == "" {
199+ if c.sameOrigin() {
200+ if u := c.s.current(c.r); u != nil {
201+ c.user, c.scope, c.via = u, scopeAll, "session"
202+ }
203+ }
204+ return true
205+ }
206+ if c.s.limiter.blocked(ip) {
207+ c.fail(429, "Too many failed attempts, try again later")
208+ return false
209+ }
210+ kind, value, _ := strings.Cut(auth, " ")
211+ switch strings.ToLower(kind) {
212+ case "bearer", "token":
213+ if u, scope, ok := c.s.store.UserByBearer(strings.TrimSpace(value)); ok {
214+ c.user, c.scope, c.via = &u, scope, "token"
215+ return true
216+ }
217+ case "basic":
218+ name, secret, _ := c.r.BasicAuth()
219+ if u, scope, ok := c.s.store.UserByToken(name, secret); ok {
220+ c.user, c.scope, c.via = &u, scope, "token"
221+ return true
222+ }
223+ if u, ok := c.s.store.CheckPassword(name, secret); ok {
224+ if c.s.store.Account(u.ID).TOTP != "" {
225+ c.fail(401, "This account uses two-factor authentication, use a token instead of the password")
226+ return false
227+ }
228+ c.user, c.scope, c.via = &u, scopeAll, "password"
229+ return true
230+ }
231+ }
232+ c.s.limiter.fail(ip)
233+ c.w.Header().Set("WWW-Authenticate", `Bearer realm="`+c.s.host+`"`)
234+ c.fail(401, "Those credentials are not valid")
235+ return false
236+}
237+
238+func (c *call) permit(need string) bool {
239+ if need == needNone {
240+ return true
241+ }
242+ if c.user == nil {
243+ c.w.Header().Set("WWW-Authenticate", `Bearer realm="`+c.s.host+`"`)
244+ c.fail(401, "Authenticate with a token first")
245+ return false
246+ }
247+ switch need {
248+ case needWrite, needAdmin:
249+ if c.scope != scopeAll {
250+ c.fail(403, "This token is scoped to "+c.scope+" and cannot do that")
251+ return false
252+ }
253+ case needGist:
254+ if c.scope != scopeAll && c.scope != scopeGist {
255+ c.fail(403, "This token is scoped to "+c.scope+" and cannot do that")
256+ return false
257+ }
258+ case needPassword:
259+ if c.via == "token" {
260+ c.fail(403, "Tokens cannot manage tokens, use your password or the settings page")
261+ return false
262+ }
263+ }
264+ if need == needAdmin && !c.s.store.IsAdmin(c.user.ID) {
265+ c.fail(403, "Only administrators can do that")
266+ return false
267+ }
268+ if need != needUser && !c.s.writes.allow(strconv.FormatInt(c.user.ID, 10)) {
269+ c.w.Header().Set("Retry-After", "10")
270+ c.fail(429, "Too many changes, slow down")
271+ return false
272+ }
273+ return true
274+}
275+
276+func (c *call) arg(name string) string { return c.args[name] }
277+
278+func (c *call) query(name string) string { return c.r.URL.Query().Get(name) }
279+
280+func (c *call) reply(code int, v any) {
281+ c.w.Header().Set("Content-Type", "application/json; charset=utf-8")
282+ c.w.WriteHeader(code)
283+ enc := json.NewEncoder(c.w)
284+ enc.SetIndent("", " ")
285+ enc.SetEscapeHTML(false)
286+ enc.Encode(v)
287+}
288+
289+func (c *call) done() { c.w.WriteHeader(204) }
290+
291+func (c *call) fail(code int, msg string) {
292+ c.reply(code, map[string]any{"status": code, "error": msg})
293+}
294+
295+func (c *call) err(err error) {
296+ if errors.Is(err, ErrNotFound) {
297+ c.fail(404, "Not found")
298+ return
299+ }
300+ code := problemCode(err)
301+ msg := err.Error()
302+ if code >= 500 {
303+ c.s.logFailure(c.r, msg)
304+ msg = "Something went wrong"
305+ }
306+ c.fail(code, msg)
307+}
308+
309+func (s *Server) logFailure(r *http.Request, msg string) {
310+ log.Printf("%s %s: %s", r.Method, r.URL.Path, msg)
311+}
312+
313+func (c *call) decode(v any) bool {
314+ body := http.MaxBytesReader(c.w, c.r.Body, 8<<20)
315+ dec := json.NewDecoder(body)
316+ dec.DisallowUnknownFields()
317+ if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
318+ var tooBig *http.MaxBytesError
319+ if errors.As(err, &tooBig) {
320+ c.fail(413, "The body is larger than 8 MiB")
321+ return false
322+ }
323+ c.fail(400, "The body is not the JSON this route expects: "+err.Error())
324+ return false
325+ }
326+ return true
327+}
328+
329+type listing struct {
330+ Items any `json:"items"`
331+ Total int `json:"total"`
332+ Next *string `json:"next"`
333+}
334+
335+func paged[T any](c *call, items []T) {
336+ limit, err := strconv.Atoi(c.query("limit"))
337+ if err != nil || limit <= 0 {
338+ limit = 30
339+ }
340+ limit = min(limit, 100)
341+ from, _ := strconv.Atoi(c.query("from"))
342+ from = max(from, 0)
343+ total := len(items)
344+ page := items[min(from, total):min(from+limit, total)]
345+ if page == nil {
346+ page = []T{}
347+ }
348+ out := listing{Items: page, Total: total}
349+ if from+limit < total {
350+ q := c.r.URL.Query()
351+ q.Set("from", strconv.Itoa(from+limit))
352+ q.Set("limit", strconv.Itoa(limit))
353+ next := "https://" + c.s.host + c.r.URL.Path + "?" + q.Encode()
354+ out.Next = &next
355+ c.w.Header().Set("Link", "<"+next+`>; rel="next"`)
356+ }
357+ c.reply(200, out)
358+}
359+
360+func mapped[T, U any](items []T, f func(T) U) []U {
361+ out := make([]U, 0, len(items))
362+ for _, it := range items {
363+ out = append(out, f(it))
364+ }
365+ return out
366+}
367+
368+func (c *call) account() (User, bool) {
369+ name := c.arg("owner")
370+ u, err := c.s.store.UserByName(name)
371+ if err != nil {
372+ if renamed, ok := c.s.store.Renamed(name); ok {
373+ c.fail(404, "~"+name+" is now ~"+renamed.Name)
374+ return User{}, false
375+ }
376+ c.fail(404, "No such account")
377+ return User{}, false
378+ }
379+ return u, true
380+}
381+
382+func (c *call) repo() (Repo, bool) {
383+ owner, ok := c.account()
384+ if !ok {
385+ return Repo{}, false
386+ }
387+ repo, err := c.s.store.RepoByPath(owner.Name, c.arg("repo"))
388+ if err != nil {
389+ if moved, ok := c.s.store.Redirect(owner.Name, c.arg("repo")); ok && c.s.store.CanRead(moved, c.user) {
390+ c.fail(404, owner.Name+"/"+c.arg("repo")+" moved to "+moved.Path())
391+ return Repo{}, false
392+ }
393+ c.fail(404, "No such repository")
394+ return Repo{}, false
395+ }
396+ if !c.s.store.CanRead(repo, c.user) {
397+ c.fail(404, "No such repository")
398+ return Repo{}, false
399+ }
400+ return repo, true
401+}
402+
403+func (c *call) number() (int64, bool) {
404+ n, err := strconv.ParseInt(c.arg("number"), 10, 64)
405+ if err != nil || n <= 0 {
406+ c.fail(404, "Not found")
407+ return 0, false
408+ }
409+ return n, true
410+}
411+
412+func (c *call) id(name string) (int64, bool) {
413+ n, err := strconv.ParseInt(c.arg(name), 10, 64)
414+ if err != nil || n <= 0 {
415+ c.fail(404, "Not found")
416+ return 0, false
417+ }
418+ return n, true
419+}
420+
421+func iso(t time.Time) *string {
422+ if t.IsZero() {
423+ return nil
424+ }
425+ v := t.UTC().Format(time.RFC3339)
426+ return &v
427+}
428+
429+func handle(name string) string { return "~" + name }
430+
431+func handles(users []User) []string {
432+ out := []string{}
433+ for _, u := range users {
434+ out = append(out, handle(u.Name))
435+ }
436+ return out
437+}
438+
439+func nonNil[T any](v []T) []T {
440+ if v == nil {
441+ return []T{}
442+ }
443+ return v
444+}

forge/api_test.go added

@@ -0,0 +1,301 @@
1+package forge
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "os/exec"
10+ "strings"
11+ "testing"
12+)
13+
14+type apiClient struct {
15+ t *testing.T
16+ h http.Handler
17+}
18+
19+func (a apiClient) do(method, path, auth, body string) *httptest.ResponseRecorder {
20+ a.t.Helper()
21+ r := httptest.NewRequest(method, path, strings.NewReader(body))
22+ r.RemoteAddr = "192.0.2.7:1000"
23+ if body != "" && strings.HasPrefix(strings.TrimSpace(body), "{") {
24+ r.Header.Set("Content-Type", "application/json")
25+ }
26+ switch {
27+ case strings.HasPrefix(auth, "basic:"):
28+ name, pass, _ := strings.Cut(strings.TrimPrefix(auth, "basic:"), ":")
29+ r.SetBasicAuth(name, pass)
30+ case strings.HasPrefix(auth, "cookie:"):
31+ r.Header.Set("Cookie", sessionCookie+"="+strings.TrimPrefix(auth, "cookie:"))
32+ case auth != "":
33+ r.Header.Set("Authorization", "Bearer "+auth)
34+ }
35+ w := httptest.NewRecorder()
36+ a.h.ServeHTTP(w, r)
37+ return w
38+}
39+
40+func (a apiClient) want(code int, method, path, auth, body string) map[string]any {
41+ a.t.Helper()
42+ w := a.do(method, path, auth, body)
43+ if w.Code != code {
44+ a.t.Fatalf("%s %s: want %d, got %d %s", method, path, code, w.Code, w.Body.String())
45+ }
46+ out := map[string]any{}
47+ if w.Body.Len() > 0 && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
48+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
49+ a.t.Fatalf("%s %s: %v", method, path, err)
50+ }
51+ }
52+ return out
53+}
54+
55+func items(v map[string]any) []any {
56+ list, _ := v["items"].([]any)
57+ return list
58+}
59+
60+func TestAPI(t *testing.T) {
61+ if _, err := exec.LookPath("git"); err != nil {
62+ t.Skip("git not installed")
63+ }
64+ s, h := testServer(t)
65+ a := apiClient{t, h}
66+ alice, _ := s.store.CreateUser("alice", "correct horse")
67+ bob, _ := s.store.CreateUser("bob", "battery staple")
68+ age(t, s, alice)
69+ age(t, s, bob)
70+ at, _ := s.store.CreateToken(alice.ID, "all", scopeAll)
71+ bt, _ := s.store.CreateToken(bob.ID, "all", scopeAll)
72+ rt, _ := s.store.CreateToken(alice.ID, "reader", scopeRead)
73+ gt, _ := s.store.CreateToken(alice.ID, "gists", scopeGist)
74+
75+ index := a.want(200, "GET", "/api", "", "")
76+ if routes, _ := index["routes"].([]any); len(routes) < 80 {
77+ t.Errorf("index lists %d routes", len(routes))
78+ }
79+ r := httptest.NewRequest("GET", "/api", nil)
80+ r.Header.Set("Accept", "text/html")
81+ w := httptest.NewRecorder()
82+ h.ServeHTTP(w, r)
83+ if w.Code != 200 || !strings.Contains(w.Body.String(), "/~:owner/:repo/pulls/:number/merge") {
84+ t.Errorf("html index: %d", w.Code)
85+ }
86+ a.want(401, "GET", "/api/me", "", "")
87+ a.want(401, "GET", "/api/me", "gp_nonsense", "")
88+ me := a.want(200, "GET", "/api/me", at, "")
89+ if me["handle"] != "~alice" || me["scope"] != "all" || me["via"] != "token" {
90+ t.Errorf("me: %v", me)
91+ }
92+ if v := a.want(200, "GET", "/api/me", "basic:alice:"+at, ""); v["via"] != "token" {
93+ t.Errorf("basic token: %v", v)
94+ }
95+ a.want(404, "GET", "/api/nowhere", at, "")
96+ if w := a.do("PUT", "/api/me", at, "{}"); w.Code != 405 || !strings.Contains(w.Header().Get("Allow"), "PATCH") {
97+ t.Errorf("405: %d %v", w.Code, w.Header())
98+ }
99+ if w := a.do("OPTIONS", "/api/me", "", ""); w.Code != 204 || w.Header().Get("Access-Control-Allow-Origin") != "*" {
100+ t.Errorf("preflight: %d", w.Code)
101+ }
102+
103+ a.want(403, "POST", "/api/repos", rt, `{"name":"nope"}`)
104+ a.want(403, "POST", "/api/repos", gt, `{"name":"nope"}`)
105+ a.want(403, "POST", "/api/me/tokens", at, `{"title":"x"}`)
106+ tok := a.want(201, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"title":"cli","scope":"read"}`)
107+ if !strings.HasPrefix(tok["token"].(string), "gp_") || tok["scope"] != "read" {
108+ t.Errorf("token: %v", tok)
109+ }
110+ a.want(400, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"scope":"root"}`)
111+ a.want(400, "POST", "/api/repos", at, `{"name":"x","bogus":1}`)
112+
113+ login := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, "")
114+ cookie := session(login)
115+ if v := a.want(200, "GET", "/api/me", "cookie:"+cookie, ""); v["via"] != "session" {
116+ t.Errorf("session: %v", v)
117+ }
118+ r = httptest.NewRequest("GET", "/api/me", nil)
119+ r.Header.Set("Cookie", sessionCookie+"="+cookie)
120+ r.Header.Set("Sec-Fetch-Site", "cross-site")
121+ w = httptest.NewRecorder()
122+ h.ServeHTTP(w, r)
123+ if w.Code != 401 {
124+ t.Errorf("cross-site cookie accepted: %d", w.Code)
125+ }
126+
127+ repo := a.want(201, "POST", "/api/repos", at, `{"name":"proj","description":"a project","tags":["ocaml"]}`)
128+ if repo["path"] != "~alice/proj" || repo["visibility"] != "public" {
129+ t.Fatalf("repo: %v", repo)
130+ }
131+ a.want(404, "GET", "/api/~alice/proj/tree/main", at, "")
132+ g := Git{Dir: s.repoDir(Repo{Owner: alice, Name: "proj"})}
133+ base := commitFile(t, g, "main", "", "README.md", "# proj\n\nhello\n")
134+ a.want(200, "PATCH", "/api/~alice/proj", at, `{"description":"renamed soon","visibility":"unlisted"}`)
135+ a.want(403, "PATCH", "/api/~alice/proj", bt, `{"description":"mine"}`)
136+ put := a.want(201, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 1\n","message":"add lib"}`)
137+ if put["subject"] != "add lib" {
138+ t.Errorf("put: %v", put)
139+ }
140+ a.want(409, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 2\n","parent":"`+base+`"}`)
141+ bin := base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 255})
142+ a.want(201, "PUT", "/api/~alice/proj/tree/main/blob.bin", at, `{"content":"`+bin+`","encoding":"base64"}`)
143+ a.want(403, "PUT", "/api/~alice/proj/tree/main/x", bt, `{"content":"x"}`)
144+ dir := a.want(200, "GET", "/api/~alice/proj/tree/main", "", "")
145+ if entries, _ := dir["entries"].([]any); len(entries) != 3 {
146+ t.Errorf("tree: %v", dir)
147+ }
148+ file := a.want(200, "GET", "/api/~alice/proj/tree/main/src/lib.ml", "", "")
149+ if file["content"] != "let x = 1\n" || file["encoding"] != "utf-8" {
150+ t.Errorf("file: %v", file)
151+ }
152+ if f := a.want(200, "GET", "/api/~alice/proj/tree/main/blob.bin", "", ""); f["encoding"] != "base64" || f["content"] != bin {
153+ t.Errorf("binary: %v", f)
154+ }
155+ if w := a.do("GET", "/api/~alice/proj/raw/main/src/lib.ml", "", ""); w.Body.String() != "let x = 1\n" {
156+ t.Errorf("raw: %q", w.Body.String())
157+ }
158+ log := a.want(200, "GET", "/api/~alice/proj/log/main?limit=2", "", "")
159+ if len(items(log)) != 2 || log["next"] == nil {
160+ t.Errorf("log: %v", log)
161+ }
162+ commit := a.want(200, "GET", "/api/~alice/proj/commit/"+base+"?patch=1", "", "")
163+ if files, _ := commit["files"].([]any); len(files) != 1 {
164+ t.Errorf("commit: %v", commit)
165+ }
166+ a.want(200, "GET", "/api/~alice/proj/blame/main/README.md", "", "")
167+ if rd := a.want(200, "GET", "/api/~alice/proj/readme", "", ""); !strings.Contains(rd["html"].(string), "<h1") {
168+ t.Errorf("readme: %v", rd)
169+ }
170+ if gr := a.want(200, "GET", "/api/~alice/proj/grep?q=hello", "", ""); len(gr["matches"].([]any)) != 1 {
171+ t.Errorf("grep: %v", gr)
172+ }
173+ if w := a.do("GET", "/api/~alice/proj/archive/main.tar.gz", "", ""); w.Code != 200 || w.Body.Len() < 100 {
174+ t.Errorf("archive: %d", w.Code)
175+ }
176+ a.want(200, "DELETE", "/api/~alice/proj/tree/main/blob.bin?message=drop", at, "")
177+ refs := a.want(200, "GET", "/api/~alice/proj/refs", "", "")
178+ if len(items(refs)) != 1 {
179+ t.Errorf("refs: %v", refs)
180+ }
181+
182+ a.want(201, "POST", "/api/~alice/proj/labels", at, `{"name":"bug","colour":"#ff0000"}`)
183+ issue := a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"it breaks","body":"badly"}`)
184+ if issue["author"] != "~bob" || issue["state"] != "open" {
185+ t.Errorf("issue: %v", issue)
186+ }
187+ a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"second"}`)
188+ a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"third"}`)
189+ page := a.want(200, "GET", "/api/~alice/proj/issues?limit=2", "", "")
190+ if page["total"].(float64) != 3 || len(items(page)) != 2 || page["next"] == nil {
191+ t.Errorf("paging: %v", page)
192+ }
193+ a.want(403, "PATCH", "/api/~alice/proj/issues/1", bt, `{"labels":["bug"]}`)
194+ if v := a.want(200, "PATCH", "/api/~alice/proj/issues/1", at, `{"labels":["bug"],"assignees":["~bob"]}`); len(v["labels"].([]any)) != 1 {
195+ t.Errorf("labels: %v", v)
196+ }
197+ if v := a.want(200, "GET", "/api/~alice/proj/issues?label=bug", "", ""); len(items(v)) != 1 {
198+ t.Errorf("label filter: %v", v)
199+ }
200+ a.want(201, "POST", "/api/~alice/proj/issues/1/comments", at, `{"body":"looking"}`)
201+ a.want(200, "PATCH", "/api/~alice/proj/issues/1", bt, `{"state":"closed"}`)
202+ if v := a.want(200, "GET", "/api/~alice/proj/issues/1", "", ""); v["state"] != "closed" || v["comments"].(float64) != 1 {
203+ t.Errorf("closed: %v", v)
204+ }
205+ if n := a.want(200, "GET", "/api/me/notifications", bt, ""); len(items(n)) == 0 {
206+ t.Error("bob has no notifications")
207+ }
208+
209+ head, _ := g.Resolve("refs/heads/main")
210+ commitFile(t, g, "feature", head, "feature.txt", "new\n")
211+ a.want(400, "POST", "/api/~alice/proj/pulls", bt, `{"title":"x","head":"nope"}`)
212+ pull := a.want(201, "POST", "/api/~alice/proj/pulls", at, `{"title":"add feature","head":"feature","base":"main"}`)
213+ if pull["mergeable"] != true || pull["head"].(map[string]any)["branch"] != "feature" {
214+ t.Fatalf("pull: %v", pull)
215+ }
216+ a.want(204, "PUT", "/api/~alice/proj/protections/main", at, `{"approvals":1}`)
217+ a.want(409, "POST", "/api/~alice/proj/pulls/1/merge", at, `{"strategy":"squash"}`)
218+ a.want(403, "POST", "/api/~alice/proj/pulls/1/reviews", at, `{"verdict":"approved"}`)
219+ a.want(201, "POST", "/api/~alice/proj/pulls/1/reviews", bt, `{"verdict":"approved","body":"lgtm"}`)
220+ note := a.want(201, "POST", "/api/~alice/proj/pulls/1/notes", bt, `{"path":"feature.txt","side":"n","line":1,"body":"nit"}`)
221+ a.want(204, "PATCH", "/api/~alice/proj/pulls/1/notes/"+jsonID(note), at, `{"resolved":true}`)
222+ if v := a.want(200, "GET", "/api/~alice/proj/pulls/1/files?patch=1", "", ""); len(items(v)) != 1 {
223+ t.Errorf("files: %v", v)
224+ }
225+ if v := a.want(200, "GET", "/api/~alice/proj/pulls/1/commits", "", ""); len(items(v)) != 1 {
226+ t.Errorf("commits: %v", v)
227+ }
228+ a.want(403, "POST", "/api/~alice/proj/pulls/1/merge", bt, `{}`)
229+ merged := a.want(200, "POST", "/api/~alice/proj/pulls/1/merge", at, `{"strategy":"squash"}`)
230+ if merged["state"] != "merged" || merged["merged_as"] == nil {
231+ t.Errorf("merged: %v", merged)
232+ }
233+ if v := a.want(200, "GET", "/api/~alice/proj/pulls?state=merged", "", ""); len(items(v)) != 1 {
234+ t.Errorf("merged list: %v", v)
235+ }
236+
237+ fork := a.want(201, "POST", "/api/~alice/proj/forks", bt, "")
238+ if fork["fork_of"] != "~alice/proj" {
239+ t.Errorf("fork: %v", fork)
240+ }
241+ a.want(409, "POST", "/api/~alice/proj/forks", at, "")
242+ a.want(204, "PUT", "/api/~alice/proj/star", bt, "")
243+ if v := a.want(200, "GET", "/api/~alice/proj", "", ""); v["stars"].(float64) != 1 {
244+ t.Errorf("stars: %v", v)
245+ }
246+ a.want(204, "PUT", "/api/~alice/follow", bt, "")
247+ if v := a.want(200, "GET", "/api/~alice/followers", "", ""); len(items(v)) != 1 {
248+ t.Errorf("followers: %v", v)
249+ }
250+ a.want(204, "PUT", "/api/~alice/proj/collaborators/~bob", at, "")
251+ if v := a.want(200, "GET", "/api/me/repos", bt, ""); v["total"].(float64) != 2 {
252+ t.Errorf("bob's repos: %v", v)
253+ }
254+ a.want(204, "DELETE", "/api/~alice/proj/collaborators/~bob", at, "")
255+ a.want(200, "PATCH", "/api/~alice/proj", at, `{"name":"renamed","archived":true}`)
256+ a.want(403, "POST", "/api/~alice/renamed/issues", bt, `{"title":"archived"}`)
257+ a.want(404, "GET", "/api/~alice/proj", "", "")
258+ a.want(400, "DELETE", "/api/~alice/renamed", at, "")
259+ a.want(204, "DELETE", "/api/~bob/proj?confirm=proj", bt, "")
260+
261+ a.want(403, "GET", "/api/admin/stats", at, "")
262+ s.store.SetAdmin("alice", true)
263+ if v := a.want(200, "GET", "/api/admin/stats", at, ""); v["users"].(float64) != 2 {
264+ t.Errorf("stats: %v", v)
265+ }
266+ a.want(403, "GET", "/api/admin/stats", rt, "")
267+
268+ gist := a.want(201, "POST", "/api/gists", gt, `{"description":"notes","visibility":"public","files":[{"name":"a.txt","content":"one"},{"name":"b.txt","content":"two"}]}`)
269+ id := gist["id"].(string)
270+ a.want(403, "PATCH", "/api/gists/"+id, bt, `{"description":"mine"}`)
271+ a.want(409, "PATCH", "/api/gists/"+id, gt, `{"head":"`+strings.Repeat("0", 40)+`","files":[{"name":"a.txt","delete":true}]}`)
272+ edited := a.want(200, "PATCH", "/api/gists/"+id, gt, `{"files":[{"name":"a.txt","delete":true},{"name":"b.txt","rename":"c.txt"},{"name":"d.txt","content":"four"}]}`)
273+ var names []string
274+ for _, f := range edited["files"].([]any) {
275+ names = append(names, f.(map[string]any)["name"].(string))
276+ }
277+ if strings.Join(names, ",") != "c.txt,d.txt" {
278+ t.Errorf("gist files: %v", names)
279+ }
280+ if v := a.want(200, "GET", "/api/gists/"+id+"/revisions", "", ""); v["total"].(float64) != 2 {
281+ t.Errorf("revisions: %v", v)
282+ }
283+ a.want(204, "PUT", "/api/gists/"+id+"/star", bt, "")
284+ a.want(201, "POST", "/api/gists/"+id+"/comments", bt, `{"body":"neat"}`)
285+ a.want(201, "POST", "/api/gists/"+id+"/forks", bt, "")
286+ if v := a.want(200, "GET", "/api/search?kind=gists&q=notes", "", ""); len(items(v)) != 2 {
287+ t.Errorf("gist search: %v", v)
288+ }
289+ if v := a.want(200, "GET", "/api/admin/gists", at, ""); v["total"].(float64) != 2 {
290+ t.Errorf("admin gists: %v", v)
291+ }
292+ if w := a.do("POST", "/api/gists?name=raw.txt&visibility=secret", gt, "plain body"); w.Code != 201 || !strings.HasPrefix(w.Body.String(), "https://example.org/gist/") {
293+ t.Errorf("raw gist: %d %s", w.Code, w.Body.String())
294+ }
295+ a.want(204, "DELETE", "/api/gists/"+id, gt, "")
296+}
297+
298+func jsonID(v map[string]any) string {
299+ b, _ := json.Marshal(v["id"])
300+ return string(b)
301+}

forge/edit.go modified

@@ -88,24 +88,8 @@ func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo,
8888 http.Redirect(w, r, "/login", http.StatusSeeOther)
8989 return rp, g, false
9090 }
91- if !rp.Owner {
92- s.fail(w, r, 403, "You cannot write to this repository")
93- return rp, g, false
94- }
95- if repo.Archived {
96- s.fail(w, r, 403, "This repository is archived")
97- return rp, g, false
98- }
99- if rp.Mirror != "" {
100- s.fail(w, r, 403, "This repository is a mirror and read only")
101- return rp, g, false
102- }
103- if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) {
104- s.fail(w, r, 404, "Edits need a branch, not a tag or commit")
105- return rp, g, false
106- }
107- if prot, ok := s.store.Protection(repo.ID, branch); ok && prot.RequirePR {
108- s.fail(w, r, 403, branch+" only accepts changes through pull requests")
91+ if _, _, err := s.editable(rp.User, repo, branch); err != nil {
92+ s.fail(w, r, problemCode(err), err.Error())
10993 return rp, g, false
11094 }
11195 rp.Tab = "tree"

forge/extra.go modified

@@ -12,7 +12,6 @@ import (
1212 "net/http"
1313 "os"
1414 "os/exec"
15- "path/filepath"
1615 "slices"
1716 "strconv"
1817 "strings"
@@ -25,77 +24,38 @@ func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) {
2524 if u == nil {
2625 return
2726 }
28- on := !s.store.Starred(u.ID, repo.ID)
29- if on && s.shunned(repo, u) {
30- s.fail(w, r, 403, "You cannot star this repository")
31- return
32- }
33- s.store.Star(u.ID, repo.ID, on)
34- http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
27+ s.settle(w, r, "/"+repo.Path(), s.starRepo(*u, repo, !s.store.Starred(u.ID, repo.ID)))
3528 }
3629
3730 func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
38- rp, _, _ := s.repoPage(r, repo, "")
39- if !rp.IsOwner {
31+ u := s.current(r)
32+ if u == nil {
4033 s.fail(w, r, 403, "Not your repository")
4134 return
4235 }
43- name := strings.TrimSpace(r.FormValue("name"))
44- back := "/" + repo.Path() + "/settings"
45- if name == repo.Name {
46- http.Redirect(w, r, back, http.StatusSeeOther)
47- return
48- }
49- if err := s.store.RenameRepo(repo.ID, name); err != nil {
50- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
51- return
52- }
53- renamed := repo
54- renamed.Name = name
55- if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil {
56- s.store.RenameRepo(repo.ID, repo.Name)
57- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
58- return
36+ renamed, err := s.renameRepoTo(*u, repo, strings.TrimSpace(r.FormValue("name")))
37+ if err != nil && problemCode(err) != 403 {
38+ err = refuse(400, err.Error())
5939 }
60- s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
61- http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther)
40+ s.settle(w, r, "/"+renamed.Path()+"/settings", err)
6241 }
6342
6443 func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
65- rp, _, _ := s.repoPage(r, repo, "")
66- if !rp.IsOwner {
67- s.fail(w, r, 403, "Not your repository")
68- return
69- }
44+ u := s.current(r)
7045 back := "/" + repo.Path() + "/settings"
71- target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(r.FormValue("owner"), "~")))
72- if err != nil {
73- http.Redirect(w, r, back+"?error=No+such+user", http.StatusSeeOther)
74- return
75- }
76- if target.ID == repo.Owner.ID {
77- http.Redirect(w, r, back, http.StatusSeeOther)
46+ if err := s.ownRepo(u, repo); err != nil {
47+ s.fail(w, r, 403, err.Error())
7848 return
7949 }
8050 if r.FormValue("confirm") != repo.Name {
81- http.Redirect(w, r, back+"?error=Type+the+repository+name+to+confirm+the+transfer", http.StatusSeeOther)
51+ s.settle(w, r, back, refuse(400, "Type the repository name to confirm the transfer"))
8252 return
8353 }
84- if err := s.store.TransferRepo(repo.ID, target.ID); err != nil {
85- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
86- return
87- }
88- moved := repo
89- moved.Owner = target
90- os.MkdirAll(filepath.Dir(s.repoDir(moved)), 0o755)
91- if err := os.Rename(s.repoDir(repo), s.repoDir(moved)); err != nil {
92- s.store.TransferRepo(repo.ID, repo.Owner.ID)
93- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
54+ moved, err := s.transferRepoTo(*u, repo, r.FormValue("owner"))
55+ if err != nil {
56+ s.settle(w, r, back, refuse(400, err.Error()))
9457 return
9558 }
96- s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
97- s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()),
98- fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path()))
9959 http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther)
10060 }
10161

forge/follow.go modified

@@ -50,12 +50,10 @@ func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) {
5050 return
5151 }
5252 if u.ID != owner.ID && !s.store.IsOrg(u.ID) {
53- on := !s.store.Follows(u.ID, owner.ID)
54- if on && s.store.Blocked(owner.ID, u.ID) {
55- s.fail(w, r, 403, "You cannot follow this account")
53+ if err := s.followUser(*u, owner, !s.store.Follows(u.ID, owner.ID)); err != nil {
54+ s.fail(w, r, problemCode(err), err.Error())
5655 return
5756 }
58- s.store.Follow(u.ID, owner.ID, on)
5957 }
6058 http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther)
6159 }

forge/gist.go modified

@@ -124,7 +124,7 @@ func (s *Store) StarredGists(user int64) ([]Gist, error) {
124124
125125 func (s *Store) GistCount(owner int64, withSecret bool) int {
126126 var n int
127- s.db.QueryRow(`select count(*) from gists where owner = ? and (secret = 0 or ?)`, owner, withSecret).Scan(&n)
127+ s.db.QueryRow(`select count(*) from gists where (? = 0 or owner = ?) and (secret = 0 or ?)`, owner, owner, withSecret).Scan(&n)
128128 return n
129129 }
130130

forge/gist_test.go modified

@@ -146,35 +146,47 @@ func TestGists(t *testing.T) {
146146 t.Error("nested tree accepted")
147147 }
148148
149- token, err := s.store.CreateToken(alice.ID, "cli")
149+ token, err := s.store.CreateToken(alice.ID, "cli", scopeAll)
150150 if err != nil {
151151 t.Fatal(err)
152152 }
153- body := `{"description":"api","public":true,"files":{"a.md":{"content":"# hi"}}}`
153+ body := `{"description":"api","visibility":"public","files":[{"name":"a.md","content":"# hi"}]}`
154154 r := httptest.NewRequest("POST", "/api/gists", strings.NewReader(body))
155155 r.Header.Set("Content-Type", "application/json")
156- r.SetBasicAuth("alice", token)
156+ r.Header.Set("Authorization", "Bearer "+token)
157157 r.RemoteAddr = "192.0.2.1:1000"
158158 w := httptest.NewRecorder()
159159 h.ServeHTTP(w, r)
160160 var made struct {
161161 ID string
162- Files map[string]struct{ Content string }
162+ Files []struct{ Name, Content string }
163163 }
164- if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &made) != nil || made.Files["a.md"].Content != "# hi" {
164+ if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &made) != nil || len(made.Files) != 1 || made.Files[0].Content != "# hi" {
165165 t.Fatalf("api create: %d %s", w.Code, w.Body.String())
166166 }
167167 if w := get(h, "/api/gists/"+made.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"a.md"`) {
168168 t.Errorf("api get: %d", w.Code)
169169 }
170- r = httptest.NewRequest("POST", "/api/gists?file=x.txt", strings.NewReader("raw upload"))
170+ r = httptest.NewRequest("POST", "/api/gists?name=x.txt", strings.NewReader("raw upload"))
171171 r.RemoteAddr = "192.0.2.1:1000"
172172 w = httptest.NewRecorder()
173173 h.ServeHTTP(w, r)
174174 if w.Code != 401 {
175175 t.Errorf("anonymous api create: %d", w.Code)
176176 }
177-
177+ if w := get(h, "/gist/"+id+"/revisions?from=1", bc); w.Code != 200 || !strings.Contains(w.Body.String(), "Newer") {
178+ t.Errorf("revision paging: %d", w.Code)
179+ }
180+ if w := get(h, "/admin?s=gists", ac); w.Code != 403 && w.Code != 404 {
181+ t.Errorf("admin gists open to a non-admin: %d", w.Code)
182+ }
183+ s.store.SetAdmin("alice", true)
184+ if w := get(h, "/admin?s=gists", ac); w.Code != 200 || !strings.Contains(w.Body.String(), "/gist/"+id) {
185+ t.Errorf("admin gists: %d", w.Code)
186+ }
187+ if w := get(h, "/search?type=gists&q=hello", bc); !strings.Contains(w.Body.String(), "/gist/"+id) {
188+ t.Error("gist search misses hello.go")
189+ }
178190 if w := post(h, "/gist/"+id+"/delete", nil, bc); w.Code != 403 {
179191 t.Errorf("non-owner deleted: %d", w.Code)
180192 }

forge/gistweb.go modified

@@ -1,21 +1,19 @@
11 package forge
22
33 import (
4- "encoding/json"
4+ "encoding/base64"
55 "errors"
6- "fmt"
76 "html/template"
87 "io"
98 "mime"
109 "net/http"
11- "net/url"
1210 "os"
1311 "path/filepath"
1412 "regexp"
15- "sort"
13+ "slices"
1614 "strconv"
1715 "strings"
18- "time"
16+ "unicode/utf8"
1917 )
2018
2119 var fullOID = regexp.MustCompile(`^[0-9a-f]{40}$`)
@@ -134,48 +132,27 @@ func (s *Server) gistRoute(w http.ResponseWriter, r *http.Request) {
134132 s.store.StarGist(user.ID, gist.ID, !s.store.GistStarred(user.ID, gist.ID))
135133 http.Redirect(w, r, back, http.StatusSeeOther)
136134 case sub == "fork":
137- if user.ID == gist.Owner.ID {
138- s.fail(w, r, 400, "You already own this gist")
139- return
140- }
141- fork, err := s.forkGist(*user, gist)
135+ fork, err := s.forkGistFor(*user, gist)
142136 if err != nil {
143- s.fail(w, r, 400, err.Error())
137+ s.fail(w, r, problemCode(err), err.Error())
144138 return
145139 }
146140 http.Redirect(w, r, "/gist/"+fork.ID, http.StatusSeeOther)
147141 case sub == "delete":
148- if user.ID != gist.Owner.ID && !s.store.IsAdmin(user.ID) {
149- s.fail(w, r, 403, "Only the owner can delete this gist")
150- return
151- }
152- s.store.DeleteGist(gist.ID)
153- os.RemoveAll(s.gistGit(gist).Dir)
154- s.store.CloseReports("gist", gist.Owner.Name+"/"+gist.ID, "resolved", user.ID)
155- http.Redirect(w, r, "/~"+gist.Owner.Name+"/gists", http.StatusSeeOther)
156- case sub == "comment":
157- if s.store.Blocked(gist.Owner.ID, user.ID) {
158- s.fail(w, r, 403, "You cannot comment here")
142+ if err := s.removeGist(*user, gist); err != nil {
143+ s.fail(w, r, problemCode(err), err.Error())
159144 return
160145 }
161- if err := s.store.AddGistComment(gist.ID, user.ID, r.FormValue("body")); err != nil {
162- http.Redirect(w, r, back+"?error="+url.QueryEscape(err.Error())+"#comments", http.StatusSeeOther)
146+ if user.ID != gist.Owner.ID {
147+ http.Redirect(w, r, "/admin?s=gists", http.StatusSeeOther)
163148 return
164149 }
165- http.Redirect(w, r, back+"#comments", http.StatusSeeOther)
150+ http.Redirect(w, r, "/~"+gist.Owner.Name+"/gists", http.StatusSeeOther)
151+ case sub == "comment":
152+ s.settle(w, r, back+"#comments", s.commentGist(*user, gist, r.FormValue("body")))
166153 case strings.HasPrefix(sub, "comment/") && strings.HasSuffix(sub, "/delete"):
167- cid, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(sub, "comment/"), "/delete"), 10, 64)
168- author, ok := s.store.GistCommentAuthor(gist.ID, cid)
169- if err != nil || !ok {
170- s.fail(w, r, 404, "No such comment")
171- return
172- }
173- if author != user.ID && gist.Owner.ID != user.ID && !s.store.IsAdmin(user.ID) {
174- s.fail(w, r, 403, "You cannot delete that comment")
175- return
176- }
177- s.store.DeleteGistComment(cid)
178- http.Redirect(w, r, back+"#comments", http.StatusSeeOther)
154+ cid, _ := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(sub, "comment/"), "/delete"), 10, 64)
155+ s.settle(w, r, back+"#comments", s.dropGistComment(*user, gist, cid))
179156 default:
180157 s.fail(w, r, 404, "Page not found")
181158 }
@@ -228,17 +205,40 @@ func (s *Server) gistPage(w http.ResponseWriter, r *http.Request, gist Gist, use
228205
229206 func (s *Server) gistRevisions(w http.ResponseWriter, r *http.Request, gist Gist, user *User) {
230207 g := s.gistGit(gist)
231- commits, _ := g.Log(s.gistHead(gist), 30, "")
208+ from, _ := strconv.Atoi(r.URL.Query().Get("from"))
209+ from = max(from, 0)
210+ commits, _ := g.Log(s.gistHead(gist), gistRevisionPage+1, "", "--skip="+strconv.Itoa(from))
232211 type revision struct {
233212 Commit Commit
234213 Files []File
235214 }
215+ data := map[string]any{"Gist": gist, "Total": s.gistRevisionCount(gist)}
216+ if len(commits) > gistRevisionPage {
217+ commits = commits[:gistRevisionPage]
218+ data["Next"] = from + gistRevisionPage
219+ }
220+ if from > 0 {
221+ data["Prev"] = max(0, from-gistRevisionPage)
222+ data["Paged"] = true
223+ }
236224 revs := make([]revision, 0, len(commits))
237225 for _, c := range commits {
238226 files, _ := g.Diff(c.Hash)
239227 revs = append(revs, revision{Commit: c, Files: files})
240228 }
241- s.render(w, r, "gistlog", page{Title: "Revisions of gist " + gist.ID[:8], User: user, Data: map[string]any{"Gist": gist, "Revisions": revs}})
229+ data["Revisions"] = revs
230+ s.render(w, r, "gistlog", page{Title: "Revisions of gist " + gist.ID[:8], User: user, Data: data})
231+}
232+
233+const gistRevisionPage = 30
234+
235+func (s *Server) gistRevisionCount(gist Gist) int {
236+ out, err := s.gistGit(gist).run(nil, "rev-list", "--count", "refs/heads/main")
237+ if err != nil {
238+ return 0
239+ }
240+ n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
241+ return n
242242 }
243243
244244 func (s *Server) editGist(w http.ResponseWriter, r *http.Request, gist Gist, user *User) {
@@ -327,7 +327,11 @@ func (s *Server) gistSmart(w http.ResponseWriter, r *http.Request, gist Gist, re
327327 http.Error(w, "too many failed attempts", 429)
328328 return
329329 }
330- u, ok := s.basicUser(r)
330+ u, scope, ok := s.basicUser(r)
331+ if ok && scope == scopeRead {
332+ http.Error(w, "this token is read only", 403)
333+ return
334+ }
331335 if !ok || u.ID != gist.Owner.ID {
332336 if _, _, given := r.BasicAuth(); given {
333337 s.limiter.fail(ip)
@@ -434,6 +438,7 @@ func (s *Server) serveGistRaw(w http.ResponseWriter, r *http.Request) {
434438 h.Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": file}))
435439 if pinned {
436440 h.Set("Cache-Control", "public, max-age=31536000, immutable")
441+ h.Set("Cloudflare-CDN-Cache-Control", "max-age=3600")
437442 } else {
438443 h.Set("Cache-Control", "public, max-age=60")
439444 }
@@ -493,123 +498,473 @@ func (s *Server) gistEmbed(w http.ResponseWriter, r *http.Request, gist Gist) {
493498 })
494499 }
495500
496-type apiGistFile struct {
497- Filename string `json:"filename"`
501+type gistFileDoc struct {
502+ Name string `json:"name"`
498503 Size int64 `json:"size"`
499- RawURL string `json:"raw_url"`
504+ Raw string `json:"raw"`
505+ Encoding string `json:"encoding,omitempty"`
500506 Content string `json:"content,omitempty"`
501- Truncated bool `json:"truncated"`
502-}
503-
504-type apiGist struct {
505- ID string `json:"id"`
506- Description string `json:"description"`
507- Public bool `json:"public"`
508- Owner string `json:"owner"`
509- HTMLURL string `json:"html_url"`
510- GitPullURL string `json:"git_pull_url"`
511- EmbedURL string `json:"embed_url"`
512- Created string `json:"created_at"`
513- Updated string `json:"updated_at"`
514- Files map[string]apiGistFile `json:"files"`
515-}
516-
517-func (s *Server) apiView(gist Gist) apiGist {
518- out := apiGist{ID: gist.ID, Description: gist.Description, Public: !gist.Secret, Owner: gist.Owner.Name,
519- HTMLURL: "https://" + s.host + "/gist/" + gist.ID, GitPullURL: "https://" + s.host + "/gist/" + gist.ID + ".git",
520- EmbedURL: "https://" + s.rawHost() + "/embed/" + gist.ID,
521- Created: gist.Created.UTC().Format(time.RFC3339), Updated: gist.Updated.UTC().Format(time.RFC3339), Files: map[string]apiGistFile{}}
507+ Truncated bool `json:"truncated,omitempty"`
508+}
509+
510+type gistDoc struct {
511+ ID string `json:"id"`
512+ Description string `json:"description"`
513+ Visibility string `json:"visibility"`
514+ Owner string `json:"owner"`
515+ Head string `json:"head"`
516+ Files []gistFileDoc `json:"files"`
517+ Stars int `json:"stars"`
518+ Forks int `json:"forks"`
519+ Comments int `json:"comments"`
520+ ForkOf *string `json:"fork_of"`
521+ Created *string `json:"created"`
522+ Updated *string `json:"updated"`
523+ Clone cloneSet `json:"clone"`
524+ Embed string `json:"embed"`
525+ URL string `json:"url"`
526+ API string `json:"api"`
527+}
528+
529+func (s *Server) gistDoc(gist Gist, content bool) gistDoc {
522530 g := s.gistGit(gist)
523531 head := s.gistHead(gist)
532+ vis := "public"
533+ if gist.Secret {
534+ vis = "secret"
535+ }
536+ out := gistDoc{ID: gist.ID, Description: gist.Description, Visibility: vis, Owner: handle(gist.Owner.Name), Head: head, Files: []gistFileDoc{},
537+ Stars: gist.Stars, Forks: gist.Forks, Comments: gist.Comments, Created: iso(gist.Created), Updated: iso(gist.Updated),
538+ Clone: cloneSet{HTTPS: "https://" + s.host + "/gist/" + gist.ID + ".git", SSH: s.sshUser + "@" + s.sshHost + ":gist/" + gist.ID + ".git"},
539+ Embed: "https://" + s.rawHost() + "/embed/" + gist.ID, URL: "https://" + s.host + "/gist/" + gist.ID, API: "https://" + s.host + "/api/gists/" + gist.ID}
540+ if gist.Parent != "" {
541+ if p, err := s.store.Gist(gist.Parent); err == nil && !p.Secret {
542+ out.ForkOf = &p.ID
543+ }
544+ }
524545 entries, _ := g.gistTree(head)
525546 for _, e := range entries {
526- f := apiGistFile{Filename: e.Name, Size: e.Size, RawURL: s.rawURL(gist, head, e.Name)}
527- if e.Size > gistWebFile {
528- f.Truncated = true
529- } else if data, err := g.run(nil, "cat-file", "blob", e.OID); err == nil && !isBinary(data) {
530- f.Content = string(data)
547+ f := gistFileDoc{Name: e.Name, Size: e.Size, Raw: s.rawURL(gist, head, e.Name)}
548+ if content {
549+ if e.Size > gistWebFile {
550+ f.Truncated = true
551+ } else if data, err := g.run(nil, "cat-file", "blob", e.OID); err == nil {
552+ if isBinary(data) || !utf8.Valid(data) {
553+ f.Encoding, f.Content = "base64", base64.StdEncoding.EncodeToString(data)
554+ } else {
555+ f.Encoding, f.Content = "utf-8", string(data)
556+ }
557+ }
531558 }
532- out.Files[e.Name] = f
559+ out.Files = append(out.Files, f)
533560 }
534561 return out
535562 }
536563
537-func writeJSON(w http.ResponseWriter, code int, v any) {
538- w.Header().Set("Content-Type", "application/json")
539- w.WriteHeader(code)
540- enc := json.NewEncoder(w)
541- enc.SetIndent("", " ")
542- enc.Encode(v)
564+func (s *Server) removeGist(u User, gist Gist) error {
565+ if u.ID != gist.Owner.ID && !s.store.IsAdmin(u.ID) {
566+ return refuse(403, "Only the owner can delete this gist")
567+ }
568+ s.store.DeleteGist(gist.ID)
569+ os.RemoveAll(s.gistGit(gist).Dir)
570+ s.store.CloseReports("gist", gist.Owner.Name+"/"+gist.ID, "resolved", u.ID)
571+ return nil
543572 }
544573
545-func (s *Server) apiGist(w http.ResponseWriter, r *http.Request) {
546- id := r.PathValue("id")
547- gist, err := s.store.Gist(id)
548- if !gistID.MatchString(id) || err != nil {
549- writeJSON(w, 404, map[string]string{"message": "gist not found"})
550- return
574+func (s *Server) commentGist(u User, gist Gist, body string) error {
575+ if s.store.Blocked(gist.Owner.ID, u.ID) {
576+ return refuse(403, "You cannot comment here")
551577 }
552- w.Header().Set("Access-Control-Allow-Origin", "*")
553- writeJSON(w, 200, s.apiView(gist))
578+ return s.store.AddGistComment(gist.ID, u.ID, body)
554579 }
555580
556-func (s *Server) apiCreateGist(w http.ResponseWriter, r *http.Request) {
557- ip := clientIP(r)
558- if s.limiter.blocked(ip) {
559- writeJSON(w, 429, map[string]string{"message": "too many failed attempts"})
581+func (s *Server) dropGistComment(u User, gist Gist, cid int64) error {
582+ author, ok := s.store.GistCommentAuthor(gist.ID, cid)
583+ if !ok {
584+ return refuse(404, "No such comment")
585+ }
586+ if author != u.ID && gist.Owner.ID != u.ID && !s.store.IsAdmin(u.ID) {
587+ return refuse(403, "You cannot delete that comment")
588+ }
589+ s.store.DeleteGistComment(cid)
590+ return nil
591+}
592+
593+func (s *Server) forkGistFor(u User, gist Gist) (Gist, error) {
594+ if u.ID == gist.Owner.ID {
595+ return Gist{}, refuse(409, "You already own this gist")
596+ }
597+ return s.forkGist(u, gist)
598+}
599+
600+type gistChange struct {
601+ Name string `json:"name"`
602+ Content *string `json:"content"`
603+ Rename string `json:"rename"`
604+ Delete bool `json:"delete"`
605+}
606+
607+func (s *Server) changeGist(u User, gist Gist, head string, changes []gistChange) error {
608+ g := s.gistGit(gist)
609+ current := s.gistHead(gist)
610+ if head == "" {
611+ head = current
612+ }
613+ if head != current {
614+ return refuse(409, "The gist moved on to "+short(current)+", fetch it again")
615+ }
616+ keep, err := g.gistTree(head)
617+ if err != nil {
618+ return refuse(409, "The gist has no files yet")
619+ }
620+ var files []gistFile
621+ for _, ch := range changes {
622+ i := slices.IndexFunc(keep, func(e gistEntry) bool { return e.Name == ch.Name })
623+ if i < 0 {
624+ if ch.Delete || ch.Content == nil {
625+ return refuse(404, "No file called "+ch.Name)
626+ }
627+ files = append(files, gistFile{Name: ch.Name, Content: *ch.Content})
628+ continue
629+ }
630+ e := keep[i]
631+ keep = slices.Delete(keep, i, i+1)
632+ if ch.Delete {
633+ continue
634+ }
635+ name := ch.Name
636+ if ch.Rename != "" {
637+ name = ch.Rename
638+ }
639+ if ch.Content == nil {
640+ if err := checkGistName(name); err != nil {
641+ return err
642+ }
643+ e.Name = name
644+ keep = append(keep, e)
645+ continue
646+ }
647+ files = append(files, gistFile{Orig: ch.Name, Name: name, Content: *ch.Content})
648+ }
649+ _, err = s.writeGist(g, head, files, keep, u, "Edit gist")
650+ if errors.Is(err, errNoChange) {
651+ return nil
652+ }
653+ return err
654+}
655+
656+func (s *Server) gistRoutes() []apiRoute {
657+ return []apiRoute{
658+ {"GET", "/gists", needNone, "public gists, newest first", s.apiGists, nil},
659+ {"POST", "/gists", needGist, "create from {description, visibility, files: [{name, content}]}, or send the raw file with ?name=&description=&visibility= and get its address back as text", s.apiCreateGist, nil},
660+ {"GET", "/gists/:id", needNone, "a gist with every file's content, secret gists only need their id", s.apiGist, nil},
661+ {"PATCH", "/gists/:id", needGist, "change description or visibility, and files with [{name, content, rename, delete}], head guards against lost updates", s.apiPatchGist, nil},
662+ {"DELETE", "/gists/:id", needGist, "delete the gist", s.apiDeleteGist, nil},
663+ {"PUT", "/gists/:id/files/*name", needGist, "write one file from the raw request body, creating it when missing", s.apiPutGistFile, nil},
664+ {"DELETE", "/gists/:id/files/*name", needGist, "remove one file", s.apiPutGistFile, nil},
665+ {"GET", "/gists/:id/revisions", needNone, "revisions, newest first, ?patch=1 adds the hunks", s.apiGistRevisions, nil},
666+ {"PUT", "/gists/:id/star", needGist, "star", s.apiStarGist, nil},
667+ {"DELETE", "/gists/:id/star", needGist, "unstar", s.apiStarGist, nil},
668+ {"GET", "/gists/:id/forks", needNone, "forks", s.apiGistForks, nil},
669+ {"POST", "/gists/:id/forks", needGist, "fork into your account", s.apiForkGist, nil},
670+ {"GET", "/gists/:id/comments", needNone, "comments, oldest first", s.apiGistComments, nil},
671+ {"POST", "/gists/:id/comments", needGist, "comment with {body}", s.apiCommentGist, nil},
672+ {"DELETE", "/gists/:id/comments/:cid", needGist, "delete a comment, yours or one on your gist", s.apiDropGistComment, nil},
673+ {"GET", "/~:owner/gists", needNone, "an account's public gists, its secret ones too when it is you, ?starred=1 for the gists it starred", s.apiAccountGists, nil},
674+ }
675+}
676+
677+func (c *call) gist() (Gist, bool) {
678+ id := c.arg("id")
679+ if !gistID.MatchString(id) {
680+ c.fail(404, "No such gist")
681+ return Gist{}, false
682+ }
683+ gist, err := c.s.store.Gist(id)
684+ if err != nil {
685+ c.fail(404, "No such gist")
686+ return Gist{}, false
687+ }
688+ return gist, true
689+}
690+
691+func (s *Server) gistDocs(gists []Gist) []gistDoc {
692+ return mapped(gists, func(g Gist) gistDoc { return s.gistDoc(g, false) })
693+}
694+
695+func (s *Server) apiGists(c *call) {
696+ gists, err := s.store.Gists(0, false, 1000)
697+ if err != nil {
698+ c.err(err)
560699 return
561700 }
562- u, ok := s.basicUser(r)
701+ paged(c, s.gistDocs(gists))
702+}
703+
704+func (s *Server) apiAccountGists(c *call) {
705+ owner, ok := c.account()
563706 if !ok {
564- if _, _, given := r.BasicAuth(); given {
565- s.limiter.fail(ip)
566- }
567- w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`)
568- writeJSON(w, 401, map[string]string{"message": "authenticate with your name and an access token"})
569707 return
570708 }
571- body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
709+ var gists []Gist
710+ var err error
711+ if c.query("starred") == "1" {
712+ gists, err = s.store.StarredGists(owner.ID)
713+ gists = slices.DeleteFunc(gists, func(g Gist) bool { return g.Secret && (c.user == nil || c.user.ID != g.Owner.ID) })
714+ } else {
715+ gists, err = s.store.Gists(owner.ID, c.user != nil && c.user.ID == owner.ID, 1000)
716+ }
572717 if err != nil {
573- writeJSON(w, 413, map[string]string{"message": "request too large"})
718+ c.err(err)
574719 return
575720 }
576- isJSON := strings.HasPrefix(r.Header.Get("Content-Type"), "application/json")
577- var description string
578- var public bool
721+ paged(c, s.gistDocs(gists))
722+}
723+
724+func (s *Server) apiCreateGist(c *call) {
725+ var description, vis string
579726 var files []gistFile
580- if isJSON {
581- var req struct {
727+ if strings.HasPrefix(c.r.Header.Get("Content-Type"), "application/json") {
728+ var in struct {
582729 Description string `json:"description"`
583- Public bool `json:"public"`
584- Files map[string]struct {
730+ Visibility string `json:"visibility"`
731+ Files []struct {
732+ Name string `json:"name"`
585733 Content string `json:"content"`
586734 } `json:"files"`
587735 }
588- if err := json.Unmarshal(body, &req); err != nil {
589- writeJSON(w, 400, map[string]string{"message": "invalid JSON: " + err.Error()})
736+ if !c.decode(&in) {
590737 return
591738 }
592- description, public = req.Description, req.Public
593- for name, f := range req.Files {
594- files = append(files, gistFile{Name: name, Content: f.Content})
739+ description, vis = in.Description, in.Visibility
740+ for _, f := range in.Files {
741+ files = append(files, gistFile{Name: f.Name, Content: f.Content})
595742 }
596- sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
597743 } else {
598- q := r.URL.Query()
599- description = q.Get("description")
600- public = q.Get("public") == "1" || q.Get("public") == "true"
601- files = []gistFile{{Name: q.Get("file"), Content: string(body)}}
744+ body, err := io.ReadAll(http.MaxBytesReader(c.w, c.r.Body, gistWebFile+1))
745+ if err != nil {
746+ c.fail(413, "The file is larger than "+humanSize(gistWebFile)+", push it with git instead")
747+ return
748+ }
749+ name := c.query("name")
750+ if name == "" {
751+ name = c.query("file")
752+ }
753+ description, vis = c.query("description"), c.query("visibility")
754+ files = []gistFile{{Name: name, Content: string(body)}}
755+ }
756+ if vis != "" && vis != "public" && vis != "secret" {
757+ c.fail(400, "Visibility must be public or secret")
758+ return
759+ }
760+ gist, err := s.createGist(*c.user, description, vis != "public", files)
761+ if err != nil {
762+ c.err(err)
763+ return
764+ }
765+ doc := s.gistDoc(gist, true)
766+ c.w.Header().Set("Location", doc.API)
767+ if !strings.HasPrefix(c.r.Header.Get("Content-Type"), "application/json") && !strings.Contains(c.r.Header.Get("Accept"), "application/json") {
768+ c.w.Header().Set("Content-Type", "text/plain; charset=utf-8")
769+ c.w.WriteHeader(201)
770+ io.WriteString(c.w, doc.URL+"\n")
771+ return
772+ }
773+ c.reply(201, doc)
774+}
775+
776+func (s *Server) apiGist(c *call) {
777+ if gist, ok := c.gist(); ok {
778+ c.reply(200, s.gistDoc(gist, true))
779+ }
780+}
781+
782+func (s *Server) apiPatchGist(c *call) {
783+ gist, ok := c.gist()
784+ if !ok {
785+ return
786+ }
787+ if c.user.ID != gist.Owner.ID {
788+ c.fail(403, "Only the owner can edit this gist")
789+ return
790+ }
791+ var in struct {
792+ Description *string `json:"description"`
793+ Visibility *string `json:"visibility"`
794+ Head string `json:"head"`
795+ Files []gistChange `json:"files"`
796+ }
797+ if !c.decode(&in) {
798+ return
799+ }
800+ description, secret := gist.Description, gist.Secret
801+ if in.Description != nil {
802+ d, err := cleanDescription(*in.Description)
803+ if err != nil {
804+ c.err(err)
805+ return
806+ }
807+ description = d
808+ }
809+ if in.Visibility != nil {
810+ if *in.Visibility != "public" && *in.Visibility != "secret" {
811+ c.fail(400, "Visibility must be public or secret")
812+ return
813+ }
814+ secret = *in.Visibility == "secret"
815+ }
816+ if len(in.Files) > 0 {
817+ if over, used, quota := overQuota(s.root, gist.Owner.Name); over {
818+ c.fail(403, quotaMessage(used, quota))
819+ return
820+ }
821+ if err := s.changeGist(*c.user, gist, in.Head, in.Files); err != nil {
822+ c.err(err)
823+ return
824+ }
602825 }
603- gist, err := s.createGist(u, description, !public, files)
826+ s.store.UpdateGist(gist.ID, description, secret)
827+ gist, _ = s.store.Gist(gist.ID)
828+ c.reply(200, s.gistDoc(gist, true))
829+}
830+
831+func (s *Server) apiPutGistFile(c *call) {
832+ gist, ok := c.gist()
833+ if !ok {
834+ return
835+ }
836+ if c.user.ID != gist.Owner.ID {
837+ c.fail(403, "Only the owner can edit this gist")
838+ return
839+ }
840+ change := gistChange{Name: c.arg("name"), Delete: c.r.Method == "DELETE"}
841+ if !change.Delete {
842+ if over, used, quota := overQuota(s.root, gist.Owner.Name); over {
843+ c.fail(403, quotaMessage(used, quota))
844+ return
845+ }
846+ body, err := io.ReadAll(http.MaxBytesReader(c.w, c.r.Body, gistWebFile+1))
847+ if err != nil {
848+ c.fail(413, "The file is larger than "+humanSize(gistWebFile)+", push it with git instead")
849+ return
850+ }
851+ content := string(body)
852+ change.Content = &content
853+ }
854+ if err := s.changeGist(*c.user, gist, c.r.Header.Get("If-Match"), []gistChange{change}); err != nil {
855+ c.err(err)
856+ return
857+ }
858+ gist, _ = s.store.Gist(gist.ID)
859+ c.reply(200, s.gistDoc(gist, false))
860+}
861+
862+func (s *Server) apiDeleteGist(c *call) {
863+ gist, ok := c.gist()
864+ if !ok {
865+ return
866+ }
867+ if err := s.removeGist(*c.user, gist); err != nil {
868+ c.err(err)
869+ return
870+ }
871+ c.done()
872+}
873+
874+func (s *Server) apiGistRevisions(c *call) {
875+ gist, ok := c.gist()
876+ if !ok {
877+ return
878+ }
879+ g := s.gistGit(gist)
880+ commits, _ := g.Log(s.gistHead(gist), 1000, "")
881+ patch := c.query("patch") == "1"
882+ views := mapped(commits, func(cm Commit) commitView {
883+ v := s.commitView(cm)
884+ if files, err := g.Diff(cm.Hash); err == nil {
885+ v.Files = nonNil(fileViews(files, patch))
886+ }
887+ return v
888+ })
889+ paged(c, views)
890+}
891+
892+func (s *Server) apiStarGist(c *call) {
893+ if gist, ok := c.gist(); ok {
894+ s.store.StarGist(c.user.ID, gist.ID, c.r.Method == "PUT")
895+ c.done()
896+ }
897+}
898+
899+func (s *Server) apiGistForks(c *call) {
900+ gist, ok := c.gist()
901+ if !ok {
902+ return
903+ }
904+ forks, err := s.store.GistForks(gist.ID)
604905 if err != nil {
605- writeJSON(w, 422, map[string]string{"message": err.Error()})
906+ c.err(err)
907+ return
908+ }
909+ paged(c, s.gistDocs(forks))
910+}
911+
912+func (s *Server) apiForkGist(c *call) {
913+ gist, ok := c.gist()
914+ if !ok {
915+ return
916+ }
917+ fork, err := s.forkGistFor(*c.user, gist)
918+ if err != nil {
919+ c.err(err)
920+ return
921+ }
922+ c.reply(201, s.gistDoc(fork, false))
923+}
924+
925+func gistCommentViews(comments []GistComment) []commentView {
926+ return mapped(comments, func(gc GistComment) commentView {
927+ return commentView{gc.ID, handle(gc.Author.Name), gc.Body, iso(gc.Created)}
928+ })
929+}
930+
931+func (s *Server) apiGistComments(c *call) {
932+ if gist, ok := c.gist(); ok {
933+ paged(c, gistCommentViews(s.store.GistComments(gist.ID)))
934+ }
935+}
936+
937+func (s *Server) apiCommentGist(c *call) {
938+ gist, ok := c.gist()
939+ if !ok {
940+ return
941+ }
942+ var in struct {
943+ Body string `json:"body"`
944+ }
945+ if !c.decode(&in) {
946+ return
947+ }
948+ if err := s.commentGist(*c.user, gist, in.Body); err != nil {
949+ c.err(err)
950+ return
951+ }
952+ comments := s.store.GistComments(gist.ID)
953+ c.reply(201, gistCommentViews(comments[len(comments)-1:])[0])
954+}
955+
956+func (s *Server) apiDropGistComment(c *call) {
957+ gist, ok := c.gist()
958+ if !ok {
959+ return
960+ }
961+ cid, ok := c.id("cid")
962+ if !ok {
606963 return
607964 }
608- if !isJSON {
609- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
610- w.WriteHeader(201)
611- fmt.Fprintln(w, "https://"+s.host+"/gist/"+gist.ID)
965+ if err := s.dropGistComment(*c.user, gist, cid); err != nil {
966+ c.err(err)
612967 return
613968 }
614- writeJSON(w, 201, s.apiView(gist))
969+ c.done()
615970 }

forge/inbox.go modified

@@ -134,6 +134,19 @@ func (s *Server) inbox(w http.ResponseWriter, r *http.Request) {
134134 s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID, 100)}})
135135 }
136136
137+func (s *Store) ReadNotification(user, id int64) bool {
138+ res, err := s.db.Exec(`update notifications set unread = 0 where user = ? and id = ?`, user, id)
139+ if err != nil {
140+ return false
141+ }
142+ n, _ := res.RowsAffected()
143+ return n > 0
144+}
145+
146+func (s *Store) ReadAll(user int64) {
147+ s.db.Exec(`update notifications set unread = 0 where user = ? and unread = 1`, user)
148+}
149+
137150 func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) {
138151 u := s.require(w, r)
139152 if u == nil {

forge/issues.go modified

@@ -4,7 +4,6 @@ import (
44 "database/sql"
55 "errors"
66 "fmt"
7- "html/template"
87 "net/http"
98 "strconv"
109 "strings"
@@ -182,28 +181,15 @@ func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo)
182181 http.Redirect(w, r, "/login", http.StatusSeeOther)
183182 return
184183 }
185- if repo.Archived {
186- s.fail(w, r, 403, "This repository is archived")
187- return
188- }
189- if s.shunned(repo, user) {
190- s.fail(w, r, 403, "You cannot open issues here")
191- return
192- }
193- if !s.store.ThreadBudget(user.ID) {
194- s.fail(w, r, 429, "Too many new issues and pull requests, try again later")
195- return
196- }
197- issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")))
198- if err != nil {
184+ issue, err := s.openIssue(*user, repo, r.FormValue("title"), r.FormValue("body"))
185+ if err != nil && problemCode(err) == 400 {
199186 w.WriteHeader(400)
200187 s.render(w, r, "newissue", page{Title: "New issue", Error: err.Error(), Data: map[string]any{"Page": rp, "IssueTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
201188 return
202189 }
203- s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "opened")
204- if repo.Owner.ID != user.ID {
205- s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title),
206- fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", user.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number))
190+ if err != nil {
191+ s.fail(w, r, problemCode(err), err.Error())
192+ return
207193 }
208194 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), http.StatusSeeOther)
209195 }
@@ -224,55 +210,29 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
224210 }
225211 user := rp.User
226212 canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID)
213+ back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number)
227214 if user != nil && r.Method == "GET" {
228215 s.store.ReadThread(user.ID, repo.ID, "issue", issue.Number)
229216 }
230- back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number)
231217 if r.Method == "POST" {
232218 if user == nil {
233219 http.Redirect(w, r, "/login", http.StatusSeeOther)
234220 return
235221 }
236- subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title)
237- link := fmt.Sprintf("https://%s%s", s.host, back)
222+ var err error
238223 switch action {
239224 case "comment":
240- if repo.Archived {
241- s.fail(w, r, 403, "This repository is archived")
242- return
243- }
244- if s.shunned(repo, user) {
245- s.fail(w, r, 403, "You cannot comment here")
246- return
247- }
248- if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil {
249- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
250- return
251- }
252- s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "commented on")
253- for _, id := range []int64{issue.Author.ID, repo.Owner.ID} {
254- if id != user.ID {
255- s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link))
256- }
257- }
225+ err = s.commentIssue(*user, repo, issue, r.FormValue("body"))
258226 case "meta":
259227 s.setMeta(w, r, repo, "issue", issue.ID, back)
260228 return
261- case "close":
262- if canManage {
263- s.store.SetIssueState(issue.ID, "closed")
264- s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "closed")
265- }
266- case "reopen":
267- if canManage {
268- s.store.SetIssueState(issue.ID, "open")
269- s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "reopened")
270- }
229+ case "close", "reopen":
230+ err = s.setIssueState(*user, repo, issue, action == "reopen")
271231 default:
272232 s.fail(w, r, 404, "Page not found")
273233 return
274234 }
275- http.Redirect(w, r, back, http.StatusSeeOther)
235+ s.settle(w, r, back, err)
276236 return
277237 }
278238 comments, _ := s.store.IssueComments(issue.ID)

forge/labels.go modified

@@ -211,27 +211,17 @@ func (s *Server) manageProtection(w http.ResponseWriter, r *http.Request, repo R
211211 }
212212
213213 func (s *Server) setMeta(w http.ResponseWriter, r *http.Request, repo Repo, kind string, item int64, back string) {
214- rp, _, _ := s.repoPage(r, repo, "")
215- if !rp.Owner {
214+ u := s.current(r)
215+ if u == nil || !s.store.CanWrite(repo, u) {
216216 s.fail(w, r, 403, "Only collaborators can change labels and assignees")
217217 return
218218 }
219219 r.ParseForm()
220220 if _, ok := r.Form["labels_form"]; ok {
221- s.store.SetItemLabels(kind, item, repo.ID, parseIDs(r.Form["label"]))
221+ s.setLabels(*u, repo, kind, item, parseIDs(r.Form["label"]))
222222 }
223223 if _, ok := r.Form["assignees_form"]; ok {
224- allowed := map[int64]bool{}
225- for _, u := range s.store.Assignable(repo) {
226- allowed[u.ID] = true
227- }
228- var ids []int64
229- for _, id := range parseIDs(r.Form["assignee"]) {
230- if allowed[id] {
231- ids = append(ids, id)
232- }
233- }
234- s.store.SetAssignees(kind, item, ids)
224+ s.setAssignees(*u, repo, kind, item, parseIDs(r.Form["assignee"]))
235225 }
236226 http.Redirect(w, r, back, http.StatusSeeOther)
237227 }

forge/limit.go modified

@@ -131,7 +131,7 @@ func (b *buckets) allow(key string) bool {
131131 func heavyRequest(r *http.Request) bool {
132132 p := r.URL.Path
133133 q := r.URL.Query()
134- return p == "/search" || strings.Contains(p, "/archive/") || strings.Contains(p, "/blame/") ||
134+ return p == "/search" || p == "/api/search" || strings.HasSuffix(p, "/grep") || strings.Contains(p, "/archive/") || strings.Contains(p, "/blame/") ||
135135 strings.Contains(p, "/interdiff") || (strings.HasSuffix(p, "/search") && q.Get("q") != "") ||
136136 (strings.Contains(p, "/log") && q.Get("q") != "") || p == "/login" || p == "/register"
137137 }

forge/moderation.go modified

@@ -313,12 +313,19 @@ func (s *Server) block(w http.ResponseWriter, r *http.Request) {
313313
314314 func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, fail func(string)) bool {
315315 id, _ := strconv.ParseInt(r.FormValue("report"), 10, 64)
316+ if err := s.moderateReport(*admin, id, r.FormValue("do"), r.FormValue("confirm")); err != nil {
317+ fail(err.Error())
318+ return false
319+ }
320+ return true
321+}
322+
323+func (s *Server) moderateReport(admin User, id int64, do, confirm string) error {
316324 rep, err := s.store.Report(id)
317325 if err != nil {
318- fail("No such report")
319- return false
326+ return refuse(404, "No such report")
320327 }
321- switch r.FormValue("do") {
328+ switch do {
322329 case "dismiss":
323330 s.store.CloseReports(rep.Kind, rep.Target, "dismissed", admin.ID)
324331 case "resolve":
@@ -326,46 +333,38 @@ func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, f
326333 case "ban":
327334 name := s.reportAuthor(rep)
328335 if name == "" {
329- fail("Could not tell who wrote that")
330- return false
336+ return refuse(409, "Could not tell who wrote that")
331337 }
332338 if name == admin.Name {
333- fail("You cannot ban yourself")
334- return false
339+ return refuse(409, "You cannot ban yourself")
335340 }
336341 if err := s.store.SetBanned(name, true); err != nil {
337- fail("No such account")
338- return false
342+ return refuse(404, "No such account")
339343 }
340344 s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
341345 case "pages":
342346 if err := s.store.SetPages(rep.Owner(), false); err != nil {
343- fail("No such account")
344- return false
347+ return refuse(404, "No such account")
345348 }
346349 s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
347350 case "delete":
348- if rep.Kind != "repo" || r.FormValue("confirm") != rep.Target {
349- fail("Type the repository path to confirm deletion")
350- return false
351+ if rep.Kind != "repo" || confirm != rep.Target {
352+ return refuse(400, "Type the repository path to confirm deletion")
351353 }
352354 owner, name, _ := strings.Cut(rep.Target, "/")
353355 repo, err := s.store.RepoByPath(owner, name)
354356 if err != nil {
355- fail("That repository is already gone")
356- return false
357+ return refuse(404, "That repository is already gone")
357358 }
358359 if err := s.store.DeleteRepo(repo.ID); err != nil {
359- fail(err.Error())
360- return false
360+ return refuse(500, err.Error())
361361 }
362362 os.RemoveAll(s.repoDir(repo))
363363 s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
364364 default:
365- fail("Unknown action")
366- return false
365+ return refuse(400, "Unknown action")
367366 }
368- return true
367+ return nil
369368 }
370369
371370 func (s *Server) reportAuthor(rep Report) string {

forge/people.go added

@@ -0,0 +1,556 @@
1+package forge
2+
3+import (
4+ "slices"
5+ "strconv"
6+ "strings"
7+)
8+
9+type accountBrief struct {
10+ Handle string `json:"handle"`
11+ Kind string `json:"kind"`
12+ Display string `json:"display"`
13+ Avatar string `json:"avatar"`
14+ URL string `json:"url"`
15+ API string `json:"api"`
16+}
17+
18+type accountFull struct {
19+ accountBrief
20+ Bio string `json:"bio"`
21+ Location string `json:"location"`
22+ Workplace string `json:"workplace"`
23+ Websites []string `json:"websites"`
24+ Joined *string `json:"joined"`
25+ Repos int `json:"repos"`
26+ Gists int `json:"gists"`
27+ Stars int `json:"stars"`
28+ Followers int `json:"followers"`
29+ Following int `json:"following"`
30+ Members []string `json:"members,omitempty"`
31+ Orgs []string `json:"orgs,omitempty"`
32+ Followed bool `json:"followed"`
33+}
34+
35+type selfView struct {
36+ accountFull
37+ Email string `json:"email"`
38+ Verified bool `json:"verified"`
39+ Admin bool `json:"admin"`
40+ TwoStep bool `json:"two_factor"`
41+ Scope string `json:"scope"`
42+ Via string `json:"via"`
43+}
44+
45+type keyView struct {
46+ ID int64 `json:"id"`
47+ Title string `json:"title"`
48+ Fingerprint string `json:"fingerprint"`
49+ Key string `json:"key"`
50+ Added *string `json:"added"`
51+}
52+
53+type tokenView struct {
54+ ID int64 `json:"id"`
55+ Title string `json:"title"`
56+ Scope string `json:"scope"`
57+ Created *string `json:"created"`
58+ Used *string `json:"used"`
59+ Token string `json:"token,omitempty"`
60+}
61+
62+type noticeView struct {
63+ ID int64 `json:"id"`
64+ Repo string `json:"repo"`
65+ Kind string `json:"kind"`
66+ Number int64 `json:"number"`
67+ Title string `json:"title"`
68+ Actor string `json:"actor"`
69+ Verb string `json:"verb"`
70+ At *string `json:"at"`
71+ Unread bool `json:"unread"`
72+ URL string `json:"url"`
73+ API string `json:"api"`
74+}
75+
76+func (s *Server) brief(u User) accountBrief {
77+ kind := "user"
78+ if s.store.IsOrg(u.ID) {
79+ kind = "org"
80+ }
81+ p := s.store.Profile(u.ID)
82+ return accountBrief{Handle: handle(u.Name), Kind: kind, Display: p.Display,
83+ Avatar: "https://" + s.host + "/~" + u.Name + "/avatar?v=" + strconv.FormatInt(p.Avatar, 10),
84+ URL: "https://" + s.host + "/~" + u.Name, API: "https://" + s.host + "/api/~" + u.Name}
85+}
86+
87+func (s *Server) full(u User, viewer *User) accountFull {
88+ b := s.brief(u)
89+ p := s.store.Profile(u.ID)
90+ member := viewer != nil && (viewer.ID == u.ID || s.store.IsMember(u.ID, viewer.ID))
91+ repos, _ := s.store.ReposByOwner(u.ID, member)
92+ followers, following := s.store.FollowCounts(u.ID)
93+ out := accountFull{accountBrief: b, Bio: p.Bio, Location: p.Location, Workplace: p.Workplace, Websites: []string{},
94+ Joined: iso(u.Created), Repos: len(repos), Stars: s.store.StarredCount(u.ID), Followers: followers, Following: following,
95+ Followed: viewer != nil && s.store.Follows(viewer.ID, u.ID)}
96+ if p.Website != "" {
97+ out.Websites = strings.Split(p.Website, "\n")
98+ }
99+ if b.Kind == "org" {
100+ out.Members = handles(s.store.Members(u.ID))
101+ } else {
102+ out.Gists = s.store.GistCount(u.ID, viewer != nil && viewer.ID == u.ID)
103+ out.Orgs = handles(s.store.Orgs(u.ID))
104+ }
105+ return out
106+}
107+
108+func (s *Server) notice(n Notification) noticeView {
109+ return noticeView{ID: n.ID, Repo: n.Repo.Path(), Kind: n.Kind, Number: n.Number, Title: n.Title, Actor: handle(n.Actor), Verb: n.Verb,
110+ At: iso(n.Created), Unread: n.Unread, URL: "https://" + s.host + n.Link(), API: "https://" + s.host + "/api" + n.Link()}
111+}
112+
113+func (s *Server) peopleRoutes() []apiRoute {
114+ return []apiRoute{
115+ {"GET", "/me", needUser, "the authenticated account, with email, admin flag and the scope of the credential", s.apiMe, nil},
116+ {"PATCH", "/me", needWrite, "change display, bio, location, workplace or websites", s.apiPatchMe, nil},
117+ {"GET", "/me/repos", needUser, "every repository you can push to: your own, your organizations' and those you collaborate on", s.apiMyRepos, nil},
118+ {"GET", "/me/orgs", needUser, "organizations you belong to", s.apiMyOrgs, nil},
119+ {"GET", "/me/keys", needUser, "your SSH keys", s.apiKeys, nil},
120+ {"POST", "/me/keys", needWrite, "add an SSH key from {title, key}", s.apiAddKey, nil},
121+ {"DELETE", "/me/keys/:id", needWrite, "remove an SSH key", s.apiDeleteKey, nil},
122+ {"GET", "/me/tokens", needUser, "your tokens, never their secrets", s.apiTokens, nil},
123+ {"POST", "/me/tokens", needPassword, "mint a token from {title, scope}, the secret appears once in the reply", s.apiAddToken, nil},
124+ {"DELETE", "/me/tokens/:id", needWrite, "revoke a token", s.apiDeleteToken, nil},
125+ {"GET", "/me/notifications", needUser, "your inbox, ?unread=1 for unread only", s.apiNotifications, nil},
126+ {"PUT", "/me/notifications/:id", needWrite, "mark one notification read", s.apiReadNotification, nil},
127+ {"DELETE", "/me/notifications", needWrite, "mark every notification read", s.apiReadAll, nil},
128+ {"GET", "/me/blocks", needUser, "accounts you have blocked", s.apiBlocks, nil},
129+ {"PUT", "/me/blocks/~:name", needWrite, "block an account from your repositories", s.apiBlock, nil},
130+ {"DELETE", "/me/blocks/~:name", needWrite, "unblock an account", s.apiBlock, nil},
131+ {"POST", "/orgs", needWrite, "create an organization from {name} with you as its first member", s.apiCreateOrg, nil},
132+ {"GET", "/search", needNone, "search with the site syntax, ?q= with lang:, user: and tag: qualifiers, ?kind=repos|people|gists", s.apiSearch, nil},
133+ {"GET", "/~:owner", needNone, "an account or organization", s.apiAccount, nil},
134+ {"GET", "/~:owner/repos", needNone, "repositories owned by the account that you can see", s.apiAccountRepos, nil},
135+ {"GET", "/~:owner/stars", needNone, "repositories the account starred", s.apiAccountStars, nil},
136+ {"GET", "/~:owner/followers", needNone, "followers", s.apiFollowers, nil},
137+ {"GET", "/~:owner/following", needNone, "accounts this one follows", s.apiFollowing, nil},
138+ {"PUT", "/~:owner/follow", needWrite, "follow the account", s.apiFollow, nil},
139+ {"DELETE", "/~:owner/follow", needWrite, "stop following the account", s.apiFollow, nil},
140+ {"GET", "/~:owner/keys", needNone, "public SSH keys and PGP fingerprints, the same keys commit seals are checked against", s.apiPublicKeys, nil},
141+ {"GET", "/~:owner/members", needNone, "members of an organization", s.apiMembers, nil},
142+ {"PUT", "/~:owner/members/~:name", needWrite, "add a member, members only", s.apiMember, nil},
143+ {"DELETE", "/~:owner/members/~:name", needWrite, "remove a member, members only", s.apiMember, nil},
144+ {"GET", "/admin/stats", needAdmin, "account and repository counts", s.apiStats, nil},
145+ {"GET", "/admin/accounts", needAdmin, "every account with its flags", s.apiAccounts, nil},
146+ {"PUT", "/admin/accounts/~:name/ban", needAdmin, "ban an account", s.apiBan, nil},
147+ {"DELETE", "/admin/accounts/~:name/ban", needAdmin, "lift a ban", s.apiBan, nil},
148+ {"GET", "/admin/reports", needAdmin, "reports, ?state=open|resolved", s.apiReports, nil},
149+ {"POST", "/admin/reports/:id", needAdmin, "act on a report with {do: dismiss|resolve|ban|pages|delete, confirm}", s.apiModerate, nil},
150+ {"POST", "/admin/invites", needAdmin, "mint a single use registration code", s.apiInvite, nil},
151+ {"GET", "/admin/gists", needAdmin, "every gist, secret ones included, with size and open reports", s.apiAdminGists, nil},
152+ }
153+}
154+
155+func (s *Server) apiMe(c *call) {
156+ a := s.store.Account(c.user.ID)
157+ c.reply(200, selfView{accountFull: s.full(*c.user, c.user), Email: a.Email, Verified: a.Verified, Admin: s.store.IsAdmin(c.user.ID),
158+ TwoStep: a.TOTP != "", Scope: c.scope, Via: c.via})
159+}
160+
161+func (s *Server) apiPatchMe(c *call) {
162+ var in struct {
163+ Display *string `json:"display"`
164+ Bio *string `json:"bio"`
165+ Location *string `json:"location"`
166+ Workplace *string `json:"workplace"`
167+ Websites *[]string `json:"websites"`
168+ }
169+ if !c.decode(&in) {
170+ return
171+ }
172+ p := s.store.Profile(c.user.ID)
173+ pick := func(v *string, old string) string {
174+ if v != nil {
175+ return *v
176+ }
177+ return old
178+ }
179+ sites := strings.Split(p.Website, "\n")
180+ if in.Websites != nil {
181+ sites = *in.Websites
182+ }
183+ next := cleanProfile(pick(in.Display, p.Display), pick(in.Bio, p.Bio), pick(in.Location, p.Location), pick(in.Workplace, p.Workplace), sites)
184+ next.Avatar = p.Avatar
185+ if err := s.store.UpdateProfile(c.user.ID, next); err != nil {
186+ c.err(err)
187+ return
188+ }
189+ s.apiMe(c)
190+}
191+
192+func (s *Server) repoViews(repos []Repo) []repoView {
193+ return mapped(repos, s.repoView)
194+}
195+
196+func (s *Server) apiMyRepos(c *call) {
197+ own, _ := s.store.ReposByOwner(c.user.ID, true)
198+ for _, o := range s.store.Orgs(c.user.ID) {
199+ r, _ := s.store.ReposByOwner(o.ID, true)
200+ own = append(own, r...)
201+ }
202+ shared, _ := s.store.SharedRepos(c.user.ID)
203+ paged(c, s.repoViews(append(own, shared...)))
204+}
205+
206+func (s *Server) apiMyOrgs(c *call) {
207+ paged(c, mapped(s.store.Orgs(c.user.ID), s.brief))
208+}
209+
210+func (s *Server) apiKeys(c *call) {
211+ keys, err := s.store.Keys(c.user.ID)
212+ if err != nil {
213+ c.err(err)
214+ return
215+ }
216+ paged(c, mapped(keys, func(k Key) keyView {
217+ return keyView{ID: k.ID, Title: k.Title, Fingerprint: k.Fingerprint, Key: k.Public, Added: iso(k.Created)}
218+ }))
219+}
220+
221+func (s *Server) apiAddKey(c *call) {
222+ var in struct {
223+ Title string `json:"title"`
224+ Key string `json:"key"`
225+ }
226+ if !c.decode(&in) {
227+ return
228+ }
229+ if err := s.store.AddKey(c.user.ID, strings.TrimSpace(in.Title), strings.TrimSpace(in.Key)); err != nil {
230+ c.err(err)
231+ return
232+ }
233+ s.writeSigners()
234+ keys, _ := s.store.Keys(c.user.ID)
235+ k := keys[len(keys)-1]
236+ c.reply(201, keyView{ID: k.ID, Title: k.Title, Fingerprint: k.Fingerprint, Key: k.Public, Added: iso(k.Created)})
237+}
238+
239+func (s *Server) apiDeleteKey(c *call) {
240+ id, ok := c.id("id")
241+ if !ok {
242+ return
243+ }
244+ keys, _ := s.store.Keys(c.user.ID)
245+ if !slices.ContainsFunc(keys, func(k Key) bool { return k.ID == id }) {
246+ c.fail(404, "No such key")
247+ return
248+ }
249+ if err := s.store.DeleteKey(c.user.ID, id); err != nil {
250+ c.err(err)
251+ return
252+ }
253+ s.writeSigners()
254+ c.done()
255+}
256+
257+func (s *Server) apiTokens(c *call) {
258+ tokens, err := s.store.Tokens(c.user.ID)
259+ if err != nil {
260+ c.err(err)
261+ return
262+ }
263+ paged(c, mapped(tokens, func(t Token) tokenView {
264+ return tokenView{ID: t.ID, Title: t.Title, Scope: t.Scope, Created: iso(t.Created), Used: iso(t.Used)}
265+ }))
266+}
267+
268+func (s *Server) apiAddToken(c *call) {
269+ var in struct {
270+ Title string `json:"title"`
271+ Scope string `json:"scope"`
272+ }
273+ if !c.decode(&in) {
274+ return
275+ }
276+ if in.Scope == "" {
277+ in.Scope = scopeAll
278+ }
279+ secret, err := s.store.CreateToken(c.user.ID, strings.TrimSpace(in.Title), in.Scope)
280+ if err != nil {
281+ c.err(err)
282+ return
283+ }
284+ tokens, _ := s.store.Tokens(c.user.ID)
285+ t := tokens[len(tokens)-1]
286+ c.reply(201, tokenView{ID: t.ID, Title: t.Title, Scope: t.Scope, Created: iso(t.Created), Token: secret})
287+}
288+
289+func (s *Server) apiDeleteToken(c *call) {
290+ id, ok := c.id("id")
291+ if !ok {
292+ return
293+ }
294+ if err := s.store.DeleteToken(c.user.ID, id); err != nil {
295+ c.err(err)
296+ return
297+ }
298+ c.done()
299+}
300+
301+func (s *Server) apiNotifications(c *call) {
302+ all := s.store.Notifications(c.user.ID, 500)
303+ if c.query("unread") == "1" || c.query("unread") == "true" {
304+ all = slices.DeleteFunc(all, func(n Notification) bool { return !n.Unread })
305+ }
306+ paged(c, mapped(all, s.notice))
307+}
308+
309+func (s *Server) apiReadNotification(c *call) {
310+ id, ok := c.id("id")
311+ if !ok {
312+ return
313+ }
314+ if !s.store.ReadNotification(c.user.ID, id) {
315+ c.fail(404, "No such notification")
316+ return
317+ }
318+ c.done()
319+}
320+
321+func (s *Server) apiReadAll(c *call) {
322+ s.store.ReadAll(c.user.ID)
323+ c.done()
324+}
325+
326+func (s *Server) apiBlocks(c *call) {
327+ paged(c, mapped(s.store.BlockedUsers(c.user.ID), s.brief))
328+}
329+
330+func (s *Server) apiBlock(c *call) {
331+ target, err := s.store.UserByName(c.arg("name"))
332+ if err != nil || target.ID == c.user.ID || s.store.IsOrg(target.ID) {
333+ c.fail(400, "You cannot block that account")
334+ return
335+ }
336+ if err := s.store.SetBlock(c.user.ID, target.ID, c.r.Method == "PUT"); err != nil {
337+ c.err(err)
338+ return
339+ }
340+ c.done()
341+}
342+
343+func (s *Server) apiCreateOrg(c *call) {
344+ var in struct {
345+ Name string `json:"name"`
346+ }
347+ if !c.decode(&in) {
348+ return
349+ }
350+ org, err := s.createOrg(*c.user, in.Name)
351+ if err != nil {
352+ c.err(err)
353+ return
354+ }
355+ c.reply(201, s.full(org, c.user))
356+}
357+
358+func (s *Server) apiSearch(c *call) {
359+ res := s.runSearch(strings.TrimSpace(c.query("q")), c.query("lang"), c.query("sort"))
360+ switch c.query("kind") {
361+ case "people", "users":
362+ paged(c, mapped(res.Users, func(h userHit) accountBrief { return s.brief(h.User) }))
363+ case "gists":
364+ paged(c, mapped(res.Gists, func(h gistHit) gistDoc { return s.gistDoc(h.Gist, false) }))
365+ default:
366+ paged(c, mapped(res.Repos, func(h repoHit) repoView {
367+ v := s.repoView(h.Repo)
368+ if h.Language != nil {
369+ v.Language = h.Language.Name
370+ }
371+ return v
372+ }))
373+ }
374+}
375+
376+func (s *Server) apiAccount(c *call) {
377+ u, ok := c.account()
378+ if !ok {
379+ return
380+ }
381+ c.reply(200, s.full(u, c.user))
382+}
383+
384+func (s *Server) apiAccountRepos(c *call) {
385+ u, ok := c.account()
386+ if !ok {
387+ return
388+ }
389+ member := c.user != nil && (c.user.ID == u.ID || s.store.IsMember(u.ID, c.user.ID))
390+ repos, err := s.store.ReposByOwner(u.ID, member)
391+ if err != nil {
392+ c.err(err)
393+ return
394+ }
395+ paged(c, s.repoViews(s.byUpdate(repos)))
396+}
397+
398+func (s *Server) apiAccountStars(c *call) {
399+ u, ok := c.account()
400+ if !ok {
401+ return
402+ }
403+ repos, err := s.store.StarredRepos(u.ID)
404+ if err != nil {
405+ c.err(err)
406+ return
407+ }
408+ repos = slices.DeleteFunc(repos, func(r Repo) bool { return !s.store.CanRead(r, c.user) })
409+ paged(c, s.repoViews(repos))
410+}
411+
412+func (s *Server) apiFollowers(c *call) {
413+ if u, ok := c.account(); ok {
414+ paged(c, mapped(s.store.Followers(u.ID), s.brief))
415+ }
416+}
417+
418+func (s *Server) apiFollowing(c *call) {
419+ if u, ok := c.account(); ok {
420+ paged(c, mapped(s.store.Following(u.ID), s.brief))
421+ }
422+}
423+
424+func (s *Server) apiFollow(c *call) {
425+ u, ok := c.account()
426+ if !ok {
427+ return
428+ }
429+ if s.store.IsOrg(c.user.ID) {
430+ c.fail(403, "Organizations cannot follow")
431+ return
432+ }
433+ if err := s.followUser(*c.user, u, c.r.Method == "PUT"); err != nil {
434+ c.err(err)
435+ return
436+ }
437+ c.done()
438+}
439+
440+func (s *Server) apiPublicKeys(c *call) {
441+ u, ok := c.account()
442+ if !ok {
443+ return
444+ }
445+ ssh, pgp := s.store.PublicKeys(u.ID)
446+ c.reply(200, map[string][]string{"ssh": nonNil(ssh), "pgp": nonNil(pgp)})
447+}
448+
449+func (s *Server) apiMembers(c *call) {
450+ u, ok := c.account()
451+ if !ok {
452+ return
453+ }
454+ if !s.store.IsOrg(u.ID) {
455+ c.fail(404, "~"+u.Name+" is not an organization")
456+ return
457+ }
458+ paged(c, mapped(s.store.Members(u.ID), s.brief))
459+}
460+
461+func (s *Server) apiMember(c *call) {
462+ org, ok := c.account()
463+ if !ok {
464+ return
465+ }
466+ if !s.store.IsOrg(org.ID) {
467+ c.fail(404, "~"+org.Name+" is not an organization")
468+ return
469+ }
470+ if err := s.setMember(*c.user, org, c.arg("name"), c.r.Method == "PUT"); err != nil {
471+ c.err(err)
472+ return
473+ }
474+ c.done()
475+}
476+
477+func (s *Server) apiStats(c *call) {
478+ st := s.store.Stats()
479+ c.reply(200, map[string]int{"users": st.Users, "orgs": st.Orgs, "admins": st.Admins, "banned": st.Banned, "repos": st.Repos,
480+ "private": st.Private, "unlisted": st.Unlisted, "gists": s.store.GistCount(0, true), "open_reports": s.store.OpenReports()})
481+}
482+
483+func (s *Server) apiAccounts(c *call) {
484+ type row struct {
485+ accountBrief
486+ Joined *string `json:"joined"`
487+ Admin bool `json:"admin"`
488+ Banned bool `json:"banned"`
489+ Pages bool `json:"pages"`
490+ Repos int `json:"repos"`
491+ }
492+ paged(c, mapped(s.store.Accounts(), func(a account) row {
493+ return row{accountBrief: s.brief(a.User), Joined: iso(a.Created), Admin: a.Admin, Banned: a.Banned, Pages: a.Pages, Repos: a.Repos}
494+ }))
495+}
496+
497+func (s *Server) apiBan(c *call) {
498+ name := c.arg("name")
499+ if name == c.user.Name {
500+ c.fail(409, "You cannot ban yourself")
501+ return
502+ }
503+ if err := s.store.SetBanned(name, c.r.Method == "PUT"); err != nil {
504+ c.fail(404, "No such account")
505+ return
506+ }
507+ c.done()
508+}
509+
510+func (s *Server) apiReports(c *call) {
511+ state := c.query("state")
512+ if state == "" {
513+ state = "open"
514+ }
515+ type row struct {
516+ ID int64 `json:"id"`
517+ Reporter string `json:"reporter"`
518+ Kind string `json:"kind"`
519+ Target string `json:"target"`
520+ Reason string `json:"reason"`
521+ State string `json:"state"`
522+ At *string `json:"at"`
523+ URL string `json:"url"`
524+ }
525+ paged(c, mapped(s.store.Reports(state), func(r Report) row {
526+ return row{ID: r.ID, Reporter: handle(r.Reporter.Name), Kind: r.Kind, Target: r.Target, Reason: r.Reason, State: r.State, At: iso(r.Created), URL: "https://" + s.host + r.Link()}
527+ }))
528+}
529+
530+func (s *Server) apiModerate(c *call) {
531+ id, ok := c.id("id")
532+ if !ok {
533+ return
534+ }
535+ var in struct {
536+ Do string `json:"do"`
537+ Confirm string `json:"confirm"`
538+ }
539+ if !c.decode(&in) {
540+ return
541+ }
542+ if err := s.moderateReport(*c.user, id, in.Do, in.Confirm); err != nil {
543+ c.err(err)
544+ return
545+ }
546+ c.done()
547+}
548+
549+func (s *Server) apiInvite(c *call) {
550+ code, err := s.store.CreateInvite()
551+ if err != nil {
552+ c.err(err)
553+ return
554+ }
555+ c.reply(201, map[string]string{"code": code, "register": "https://" + s.host + "/register?invite=" + code})
556+}

forge/pull.go modified

@@ -5,10 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "html/template"
8- "log"
98 "net/http"
10- "os"
11- "path/filepath"
129 "strconv"
1310 "strings"
1411 "time"
@@ -507,55 +504,11 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
507504 s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg,
508505 Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.FormValue("source"), "Target": r.FormValue("target"), "PullTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
509506 }
510- if repo.Archived {
511- retry("This repository is archived")
512- return
513- }
514- if rp.Mirror != "" {
515- retry("This repository is a mirror and does not take pull requests")
516- return
517- }
518- if s.shunned(repo, user) {
519- retry("You cannot open pull requests here")
520- return
521- }
522- if !s.store.ThreadBudget(user.ID) {
523- retry("Too many new issues and pull requests, try again later")
524- return
525- }
526- src, branch, err := s.resolveSource(repo, user, r.FormValue("source"))
527- if err != nil {
528- retry(err.Error())
529- return
530- }
531- target := r.FormValue("target")
532- if _, err := g.Resolve("refs/heads/" + target); err != nil || !validBranch(target) {
533- retry("No such target branch")
534- return
535- }
536- if src.ID == repo.ID && branch == target {
537- retry("Source and target are the same branch")
538- return
539- }
540- pull, err := s.store.CreatePull(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")), src, branch, target)
507+ pull, err := s.openPull(*user, repo, r.FormValue("source"), r.FormValue("target"), r.FormValue("title"), r.FormValue("body"), r.FormValue("draft") == "on")
541508 if err != nil {
542509 retry(err.Error())
543510 return
544511 }
545- if r.FormValue("draft") == "on" {
546- s.store.SetDraft(pull.ID, true)
547- }
548- if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil {
549- retry("Could not fetch the source branch: " + err.Error())
550- return
551- }
552- g.RecordRevision(pull)
553- url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number)
554- s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "opened")
555- if repo.Owner.ID != user.ID {
556- s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title),
557- fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\n%s\n", user.Name, repo.Path(), pull.Body, url))
558- }
559512 http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther)
560513 }
561514
@@ -594,140 +547,37 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
594547 return
595548 }
596549 back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number)
597- if repo.Archived {
598- s.fail(w, r, 403, "This repository is archived")
599- return
600- }
601- if action != "close" && s.shunned(repo, user) {
602- s.fail(w, r, 403, "You cannot take part here")
603- return
604- }
605- if action == "merge" && rp.Mirror != "" {
606- s.fail(w, r, 403, "This repository is a mirror and read only")
607- return
608- }
550+ var err error
609551 switch action {
610552 case "comment":
611- if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil {
612- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
613- return
614- }
615- s.notifyPull(pull, *user, "commented on", r.FormValue("body"))
616- case "close":
617- if (isOwner || isAuthor) && pull.State == "open" {
618- s.store.SetPullState(pull.ID, "closed", "")
619- s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "closed")
620- }
621- case "reopen":
622- if (isOwner || isAuthor) && pull.State == "closed" {
623- s.store.SetPullState(pull.ID, "open", "")
624- s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "reopened")
625- }
553+ err = s.commentPull(*user, pull, r.FormValue("body"))
554+ case "close", "reopen":
555+ err = s.setPullState(*user, pull, action == "reopen")
626556 case "review":
627557 line, _ := strconv.Atoi(r.FormValue("line"))
628- if err := s.store.AddReview(pull.ID, *user, r.FormValue("path"), r.FormValue("side"), line, r.FormValue("body")); err != nil {
629- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
630- return
631- }
558+ err = s.notePull(*user, pull, r.FormValue("path"), r.FormValue("side"), line, r.FormValue("body"))
632559 back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line")
633- s.notifyPull(pull, *user, "reviewed "+r.FormValue("path")+" line "+r.FormValue("line")+" on", r.FormValue("body"))
634560 case "verdict":
635- if user.ID == pull.Author.ID && r.FormValue("state") != "comment" {
636- http.Redirect(w, r, back+"?error=You+cannot+approve+your+own+pull+request", http.StatusSeeOther)
637- return
638- }
639- head, _ := g.Resolve(pull.Ref())
640- if err := s.store.AddPRReview(pull.ID, *user, r.FormValue("state"), r.FormValue("body"), head); err != nil {
641- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
642- return
643- }
644- verb := map[string]string{"approved": "approved", "changes": "requested changes on", "comment": "reviewed"}[r.FormValue("state")]
645- s.notifyPull(pull, *user, verb, r.FormValue("body"))
561+ err = s.judgePull(*user, pull, r.FormValue("state"), r.FormValue("body"))
646562 back += "#reviews"
647- case "ready":
648- if isOwner || isAuthor {
649- s.store.SetDraft(pull.ID, false)
650- }
651- case "draft":
652- if isOwner || isAuthor {
653- s.store.SetDraft(pull.ID, true)
654- }
563+ case "ready", "draft":
564+ err = s.setPullDraft(*user, pull, action == "draft")
655565 case "meta":
656566 s.setMeta(w, r, repo, "pull", pull.ID, back)
657567 return
658568 case "resolve", "unresolve", "delete":
659569 id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64)
660- owner := s.store.ReviewAuthor(id)
661- if owner == 0 || (owner != user.ID && !isOwner) {
662- s.fail(w, r, 403, "Not your comment")
663- return
664- }
665- switch action {
666- case "resolve":
667- s.store.ResolveReview(id, true)
668- case "unresolve":
669- s.store.ResolveReview(id, false)
670- case "delete":
671- s.store.DeleteReview(id)
672- }
570+ err = s.touchNote(*user, pull, id, action)
673571 back += "#diff"
674572 case "update":
675- if (isOwner || isAuthor) && pull.State == "open" && pull.Agit == "" {
676- if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil {
677- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
678- return
679- }
680- g.RecordRevision(pull)
681- s.store.TouchPull(pull.ID)
682- }
573+ err = s.refreshPull(*user, pull)
683574 case "merge":
684- if !isOwner || pull.State != "open" {
685- s.fail(w, r, 403, "Only the repository owner can merge")
686- return
687- }
688- if pull.Draft {
689- http.Redirect(w, r, back+"?error=Draft+pull+requests+cannot+be+merged", http.StatusSeeOther)
690- return
691- }
692- if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok && prot.Approvals > 0 {
693- head, _ := g.Resolve(pull.Ref())
694- approvals, changes := latestVerdicts(s.store.PRReviews(pull.ID), head)
695- if approvals < prot.Approvals || changes > 0 {
696- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(fmt.Sprintf("%s needs %d approving reviews of the current head and no outstanding change requests", pull.TargetBranch, prot.Approvals)), http.StatusSeeOther)
697- return
698- }
699- }
700- title := strings.TrimSpace(r.FormValue("title"))
701- if title == "" {
702- title = mergeTitle(pull)
703- }
704- message := title
705- if body := strings.TrimSpace(r.FormValue("body")); body != "" {
706- message += "\n\n" + body
707- }
708- var hash string
709- var err error
710- switch r.FormValue("strategy") {
711- case "squash":
712- hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, user.Name, user.Name+"@"+s.host)
713- case "rebase":
714- hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), user.Name, user.Name+"@"+s.host)
715- case "ff":
716- hash, err = g.FastForward(pull.TargetBranch, pull.Ref(), pull.baseRef())
717- default:
718- hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, user.Name, user.Name+"@"+s.host)
719- }
720- if err != nil {
721- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
722- return
723- }
724- s.store.SetPullState(pull.ID, "merged", hash)
725- s.notifyPull(pull, *user, "merged", "")
575+ _, err = s.mergePull(*user, pull, r.FormValue("strategy"), r.FormValue("title"), r.FormValue("body"))
726576 default:
727577 s.fail(w, r, 404, "Page not found")
728578 return
729579 }
730- http.Redirect(w, r, back, http.StatusSeeOther)
580+ s.settle(w, r, back, err)
731581 return
732582 }
733583 head, err := g.Resolve(pull.Ref())
@@ -868,33 +718,11 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
868718 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
869719 return
870720 }
871- if s.shunned(repo, user) {
872- s.fail(w, r, 403, "You cannot fork this repository")
873- return
874- }
875- if s.uploads.blocked("create:" + user.Name) {
876- s.fail(w, r, 429, "Too many new repositories, try again later")
877- return
878- }
879- s.uploads.fail("create:" + user.Name)
880- fork, err := s.store.ForkRepo(*user, repo)
881- if err != nil {
882- s.fail(w, r, 400, err.Error())
883- return
884- }
885- dir := s.repoDir(fork)
886- err = os.MkdirAll(filepath.Dir(dir), 0o755)
887- if err == nil {
888- err = cloneRepo(s.repoDir(repo), dir)
889- }
721+ fork, err := s.forkRepo(*user, repo)
890722 if err != nil {
891- log.Printf("create %s: %v", fork.Path(), err)
892- os.RemoveAll(dir)
893- s.store.DeleteRepo(fork.ID)
894- s.fail(w, r, 500, "Could not create the repository")
723+ s.fail(w, r, problemCode(err), err.Error())
895724 return
896725 }
897- installHook(s.root, s.host, dir)
898726 http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther)
899727 }
900728

forge/repos.go added

@@ -0,0 +1,969 @@
1+package forge
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "mime"
7+ "path"
8+ "slices"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+)
14+
15+type repoView struct {
16+ Path string `json:"path"`
17+ Owner string `json:"owner"`
18+ Name string `json:"name"`
19+ Description string `json:"description"`
20+ Visibility string `json:"visibility"`
21+ Tags []string `json:"tags"`
22+ Archived bool `json:"archived"`
23+ ForkOf *string `json:"fork_of"`
24+ Mirror *string `json:"mirror_of"`
25+ Stars int `json:"stars"`
26+ Forks int `json:"forks"`
27+ Branch string `json:"default_branch"`
28+ Language string `json:"language,omitempty"`
29+ Created *string `json:"created"`
30+ Pushed *string `json:"pushed"`
31+ Clone cloneSet `json:"clone"`
32+ URL string `json:"url"`
33+ API string `json:"api"`
34+}
35+
36+type cloneSet struct {
37+ HTTPS string `json:"https"`
38+ SSH string `json:"ssh"`
39+}
40+
41+type refView struct {
42+ Name string `json:"name"`
43+ Kind string `json:"kind"`
44+ SHA string `json:"sha"`
45+ At *string `json:"at"`
46+ Default bool `json:"default"`
47+}
48+
49+type personView struct {
50+ Name string `json:"name"`
51+ Email string `json:"email"`
52+ Account *string `json:"account"`
53+}
54+
55+type commitView struct {
56+ SHA string `json:"sha"`
57+ Subject string `json:"subject"`
58+ Body string `json:"body"`
59+ Author personView `json:"author"`
60+ At *string `json:"at"`
61+ Parents []string `json:"parents"`
62+ Seal *string `json:"sealed_by"`
63+ Files []fileView `json:"files,omitempty"`
64+}
65+
66+type fileView struct {
67+ Path string `json:"path"`
68+ From string `json:"from,omitempty"`
69+ Status string `json:"status"`
70+ Binary bool `json:"binary"`
71+ Added int `json:"added"`
72+ Removed int `json:"removed"`
73+ Hunks []hunkView `json:"hunks,omitempty"`
74+}
75+
76+type hunkView struct {
77+ Header string `json:"header"`
78+ Lines []string `json:"lines"`
79+}
80+
81+type entryView struct {
82+ Name string `json:"name"`
83+ Path string `json:"path"`
84+ Kind string `json:"kind"`
85+ Mode string `json:"mode"`
86+ Size int64 `json:"size"`
87+ SHA string `json:"sha"`
88+}
89+
90+type labelView struct {
91+ ID int64 `json:"id"`
92+ Name string `json:"name"`
93+ Colour string `json:"colour"`
94+}
95+
96+func visibilityName(r Repo) string {
97+ switch {
98+ case r.Private:
99+ return "private"
100+ case r.Unlisted:
101+ return "unlisted"
102+ }
103+ return "public"
104+}
105+
106+func (s *Server) repoView(r Repo) repoView {
107+ v := repoView{Path: r.Path(), Owner: handle(r.Owner.Name), Name: r.Name, Description: r.Description, Visibility: visibilityName(r),
108+ Tags: nonNil(r.Tags), Archived: r.Archived, Stars: r.Stars, Forks: r.Forks, Branch: Git{Dir: s.repoDir(r)}.Head(),
109+ Created: iso(r.Created), Pushed: iso(repoUpdated(s.repoDir(r))),
110+ Clone: cloneSet{HTTPS: "https://" + s.host + "/" + r.Path(), SSH: s.sshUser + "@" + s.sshHost + ":" + r.Path()},
111+ URL: "https://" + s.host + "/" + r.Path(), API: "https://" + s.host + "/api/" + r.Path()}
112+ if r.Parent != 0 {
113+ if p, err := s.store.RepoByID(r.Parent); err == nil && !p.Private {
114+ path := p.Path()
115+ v.ForkOf = &path
116+ }
117+ }
118+ if m, ok := s.store.PullMirror(r.ID); ok {
119+ src := m.Source()
120+ v.Mirror = &src
121+ }
122+ return v
123+}
124+
125+func (s *Server) commitView(c Commit) commitView {
126+ v := commitView{SHA: c.Hash, Subject: c.Subject, Body: c.Body, Author: personView{Name: c.Author, Email: c.Email}, At: iso(c.Time), Parents: nonNil(c.Parents)}
127+ if a := s.account(c); a != "" {
128+ h := handle(a)
129+ v.Author.Account = &h
130+ }
131+ if signer := s.signer(c); signer != "" {
132+ h := handle(signer)
133+ v.Seal = &h
134+ }
135+ return v
136+}
137+
138+func fileViews(files []File, patch bool) []fileView {
139+ return mapped(files, func(f File) fileView {
140+ v := fileView{Path: f.New, Status: f.Status, Binary: f.Binary, Added: f.Added, Removed: f.Removed}
141+ if f.Old != f.New {
142+ v.From = f.Old
143+ }
144+ if patch {
145+ for _, h := range f.Hunks {
146+ hv := hunkView{Header: h.Header, Lines: []string{}}
147+ for _, l := range h.Lines {
148+ mark := " "
149+ switch l.Kind {
150+ case "add":
151+ mark = "+"
152+ case "del":
153+ mark = "-"
154+ case "ctx":
155+ default:
156+ mark = "\\"
157+ }
158+ hv.Lines = append(hv.Lines, mark+l.Text)
159+ }
160+ v.Hunks = append(v.Hunks, hv)
161+ }
162+ }
163+ return v
164+ })
165+}
166+
167+func entryKind(e Entry) string {
168+ switch {
169+ case e.Type == "tree":
170+ return "dir"
171+ case e.Type == "commit":
172+ return "submodule"
173+ case strings.HasPrefix(e.Mode, "120"):
174+ return "link"
175+ }
176+ return "file"
177+}
178+
179+func (s *Server) repoRoutes() []apiRoute {
180+ return []apiRoute{
181+ {"GET", "/repos", needNone, "public repositories, newest first, ?q= and ?tag= narrow the list", s.apiRepos, nil},
182+ {"POST", "/repos", needWrite, "create a repository from {name, owner, description, visibility, tags}", s.apiCreateRepo, nil},
183+ {"GET", "/~:owner/:repo", needNone, "a repository", s.apiRepo, nil},
184+ {"PATCH", "/~:owner/:repo", needWrite, "change description, visibility, tags, pinned or archived, rename with name and transfer with owner", s.apiPatchRepo, nil},
185+ {"DELETE", "/~:owner/:repo", needWrite, "delete the repository, ?confirm= must repeat its name", s.apiDeleteRepo, nil},
186+ {"GET", "/~:owner/:repo/forks", needNone, "forks you can see", s.apiForks, nil},
187+ {"POST", "/~:owner/:repo/forks", needWrite, "fork into your account", s.apiFork, nil},
188+ {"PUT", "/~:owner/:repo/star", needWrite, "star", s.apiStar, nil},
189+ {"DELETE", "/~:owner/:repo/star", needWrite, "unstar", s.apiStar, nil},
190+ {"GET", "/~:owner/:repo/collaborators", needUser, "collaborators, visible to anyone who can push", s.apiCollaborators, nil},
191+ {"PUT", "/~:owner/:repo/collaborators/~:name", needWrite, "grant push access", s.apiCollaborator, nil},
192+ {"DELETE", "/~:owner/:repo/collaborators/~:name", needWrite, "revoke push access", s.apiCollaborator, nil},
193+ {"GET", "/~:owner/:repo/labels", needNone, "labels", s.apiLabels, nil},
194+ {"POST", "/~:owner/:repo/labels", needWrite, "add a label from {name, colour}", s.apiAddLabel, nil},
195+ {"DELETE", "/~:owner/:repo/labels/:id", needWrite, "delete a label", s.apiDeleteLabel, nil},
196+ {"GET", "/~:owner/:repo/protections", needNone, "protected branches", s.apiProtections, nil},
197+ {"PUT", "/~:owner/:repo/protections/*branch", needWrite, "protect a branch with {require_pull, approvals}", s.apiProtect, nil},
198+ {"DELETE", "/~:owner/:repo/protections/*branch", needWrite, "lift a branch protection", s.apiUnprotect, nil},
199+ {"GET", "/~:owner/:repo/refs", needNone, "branches and tags, ?kind=branch|tag", s.apiRefs, nil},
200+ {"GET", "/~:owner/:repo/log/*ref", needNone, "history from a ref or the default branch, ?path= follows one file", s.apiLog, nil},
201+ {"GET", "/~:owner/:repo/commit/:sha", needNone, "a commit and the files it touched, ?patch=1 adds the hunks", s.apiCommit, nil},
202+ {"GET", "/~:owner/:repo/compare/*spec", needNone, "commits and files between base...head, ?patch=1 adds the hunks", s.apiCompare, nil},
203+ {"GET", "/~:owner/:repo/tree/*spec", needNone, "a directory listing or a file at ref/path, files carry their content up to 1 MiB", s.apiTree, nil},
204+ {"PUT", "/~:owner/:repo/tree/*spec", needWrite, "commit a file to branch/path from {content, encoding, message, parent}", s.apiPutFile, nil},
205+ {"DELETE", "/~:owner/:repo/tree/*spec", needWrite, "commit the removal of branch/path, ?message= and ?parent= are optional", s.apiDeleteFile, nil},
206+ {"GET", "/~:owner/:repo/raw/*spec", needNone, "the bytes of a file at ref/path", s.apiRaw, nil},
207+ {"GET", "/~:owner/:repo/blame/*spec", needNone, "the last commit to touch each line of a file", s.apiBlame, nil},
208+ {"GET", "/~:owner/:repo/readme", needNone, "the README of a ref, raw and rendered, ?ref= picks the ref", s.apiReadme, nil},
209+ {"GET", "/~:owner/:repo/languages", needNone, "languages by bytes on the default branch", s.apiLanguages, nil},
210+ {"GET", "/~:owner/:repo/grep", needNone, "fixed string search through a ref, ?q= and ?ref=", s.apiGrep, nil},
211+ {"GET", "/~:owner/:repo/archive/*spec", needNone, "a tar.gz or zip of a ref, as ref.tar.gz or ref.zip", s.apiArchive, nil},
212+ }
213+}
214+
215+func (s *Server) apiRepos(c *call) {
216+ repos, err := s.store.PublicRepos(strings.TrimSpace(c.query("q")), strings.ToLower(c.query("tag")))
217+ if err != nil {
218+ c.err(err)
219+ return
220+ }
221+ repos = slices.DeleteFunc(repos, func(r Repo) bool { return r.Unlisted })
222+ paged(c, s.repoViews(repos))
223+}
224+
225+func (s *Server) apiCreateRepo(c *call) {
226+ var in struct {
227+ Name string `json:"name"`
228+ Owner string `json:"owner"`
229+ Description string `json:"description"`
230+ Visibility string `json:"visibility"`
231+ Tags []string `json:"tags"`
232+ }
233+ if !c.decode(&in) {
234+ return
235+ }
236+ if in.Visibility != "" && in.Visibility != "public" && in.Visibility != "unlisted" && in.Visibility != "private" {
237+ c.fail(400, "Visibility must be public, unlisted or private")
238+ return
239+ }
240+ repo, err := s.createRepo(*c.user, strings.TrimPrefix(in.Owner, "~"), in.Name, in.Description, visibility(in.Visibility), strings.Join(in.Tags, ","))
241+ if err != nil {
242+ c.err(err)
243+ return
244+ }
245+ c.w.Header().Set("Location", "https://"+s.host+"/api/"+repo.Path())
246+ c.reply(201, s.repoView(repo))
247+}
248+
249+func (s *Server) apiRepo(c *call) {
250+ if repo, ok := c.repo(); ok {
251+ c.reply(200, s.repoView(repo))
252+ }
253+}
254+
255+func (s *Server) apiPatchRepo(c *call) {
256+ repo, ok := c.repo()
257+ if !ok {
258+ return
259+ }
260+ if err := s.ownRepo(c.user, repo); err != nil {
261+ c.err(err)
262+ return
263+ }
264+ var in struct {
265+ Description *string `json:"description"`
266+ Visibility *string `json:"visibility"`
267+ Tags *[]string `json:"tags"`
268+ Pinned *bool `json:"pinned"`
269+ Archived *bool `json:"archived"`
270+ Name *string `json:"name"`
271+ Owner *string `json:"owner"`
272+ }
273+ if !c.decode(&in) {
274+ return
275+ }
276+ if in.Description != nil || in.Visibility != nil || in.Tags != nil {
277+ desc, vis, tags := repo.Description, visibility(visibilityName(repo)), strings.Join(repo.Tags, ",")
278+ if in.Description != nil {
279+ desc = strings.TrimSpace(*in.Description)
280+ }
281+ if in.Visibility != nil {
282+ if *in.Visibility != "public" && *in.Visibility != "unlisted" && *in.Visibility != "private" {
283+ c.fail(400, "Visibility must be public, unlisted or private")
284+ return
285+ }
286+ vis = visibility(*in.Visibility)
287+ }
288+ if in.Tags != nil {
289+ tags = strings.Join(*in.Tags, ",")
290+ }
291+ if err := s.store.UpdateRepo(repo.ID, desc, vis, tags); err != nil {
292+ c.err(err)
293+ return
294+ }
295+ }
296+ if in.Pinned != nil && *in.Pinned != (s.store.Pins(repo.Owner.ID)[repo.ID] > 0) {
297+ if err := s.store.Pin(repo.Owner.ID, repo.ID, *in.Pinned); err != nil {
298+ c.err(err)
299+ return
300+ }
301+ }
302+ if in.Archived != nil {
303+ s.store.SetArchived(repo.ID, *in.Archived)
304+ }
305+ var err error
306+ if in.Name != nil {
307+ if repo, err = s.renameRepoTo(*c.user, repo, strings.TrimSpace(*in.Name)); err != nil {
308+ c.err(err)
309+ return
310+ }
311+ }
312+ if in.Owner != nil {
313+ if repo, err = s.transferRepoTo(*c.user, repo, *in.Owner); err != nil {
314+ c.err(err)
315+ return
316+ }
317+ }
318+ fresh, err := s.store.RepoByID(repo.ID)
319+ if err != nil {
320+ c.err(err)
321+ return
322+ }
323+ c.reply(200, s.repoView(fresh))
324+}
325+
326+func (s *Server) apiDeleteRepo(c *call) {
327+ repo, ok := c.repo()
328+ if !ok {
329+ return
330+ }
331+ if c.query("confirm") != repo.Name {
332+ c.fail(400, "Repeat the repository name as ?confirm="+repo.Name+" to delete it")
333+ return
334+ }
335+ if err := s.removeRepo(*c.user, repo); err != nil {
336+ c.err(err)
337+ return
338+ }
339+ c.done()
340+}
341+
342+func (s *Server) apiForks(c *call) {
343+ repo, ok := c.repo()
344+ if !ok {
345+ return
346+ }
347+ var viewer int64
348+ if c.user != nil {
349+ viewer = c.user.ID
350+ }
351+ forks, err := s.store.Forks(repo.ID, viewer)
352+ if err != nil {
353+ c.err(err)
354+ return
355+ }
356+ paged(c, s.repoViews(forks))
357+}
358+
359+func (s *Server) apiFork(c *call) {
360+ repo, ok := c.repo()
361+ if !ok {
362+ return
363+ }
364+ fork, err := s.forkRepo(*c.user, repo)
365+ if err != nil {
366+ c.err(err)
367+ return
368+ }
369+ c.reply(201, s.repoView(fork))
370+}
371+
372+func (s *Server) apiStar(c *call) {
373+ repo, ok := c.repo()
374+ if !ok {
375+ return
376+ }
377+ if err := s.starRepo(*c.user, repo, c.r.Method == "PUT"); err != nil {
378+ c.err(err)
379+ return
380+ }
381+ c.done()
382+}
383+
384+func (s *Server) apiCollaborators(c *call) {
385+ repo, ok := c.repo()
386+ if !ok {
387+ return
388+ }
389+ if !s.store.CanWrite(repo, c.user) {
390+ c.fail(403, "Only people who can push see the collaborators")
391+ return
392+ }
393+ users, err := s.store.Collaborators(repo.ID)
394+ if err != nil {
395+ c.err(err)
396+ return
397+ }
398+ paged(c, mapped(users, s.brief))
399+}
400+
401+func (s *Server) apiCollaborator(c *call) {
402+ repo, ok := c.repo()
403+ if !ok {
404+ return
405+ }
406+ if err := s.ownRepo(c.user, repo); err != nil {
407+ c.err(err)
408+ return
409+ }
410+ target, err := s.store.UserByName(c.arg("name"))
411+ if err != nil {
412+ c.fail(404, "No such account")
413+ return
414+ }
415+ if c.r.Method == "DELETE" {
416+ s.store.RemoveCollaborator(repo.ID, target.ID)
417+ c.done()
418+ return
419+ }
420+ if target.ID == repo.Owner.ID {
421+ c.fail(409, "The owner already has access")
422+ return
423+ }
424+ if err := s.store.AddCollaborator(repo.ID, target.Name); err != nil {
425+ c.err(err)
426+ return
427+ }
428+ c.done()
429+}
430+
431+func labelViews(labels []Label) []labelView {
432+ return mapped(labels, func(l Label) labelView { return labelView{l.ID, l.Name, l.Colour} })
433+}
434+
435+func (s *Server) apiLabels(c *call) {
436+ repo, ok := c.repo()
437+ if !ok {
438+ return
439+ }
440+ labels, err := s.store.Labels(repo.ID)
441+ if err != nil {
442+ c.err(err)
443+ return
444+ }
445+ paged(c, labelViews(labels))
446+}
447+
448+func (s *Server) apiAddLabel(c *call) {
449+ repo, ok := c.repo()
450+ if !ok {
451+ return
452+ }
453+ if err := s.ownRepo(c.user, repo); err != nil {
454+ c.err(err)
455+ return
456+ }
457+ var in struct {
458+ Name string `json:"name"`
459+ Colour string `json:"colour"`
460+ }
461+ if !c.decode(&in) {
462+ return
463+ }
464+ if err := s.store.AddLabel(repo.ID, in.Name, in.Colour); err != nil {
465+ c.err(err)
466+ return
467+ }
468+ labels, _ := s.store.Labels(repo.ID)
469+ for _, l := range labels {
470+ if l.Name == strings.TrimSpace(in.Name) {
471+ c.reply(201, labelView{l.ID, l.Name, l.Colour})
472+ return
473+ }
474+ }
475+ c.reply(201, labelViews(labels))
476+}
477+
478+func (s *Server) apiDeleteLabel(c *call) {
479+ repo, ok := c.repo()
480+ if !ok {
481+ return
482+ }
483+ if err := s.ownRepo(c.user, repo); err != nil {
484+ c.err(err)
485+ return
486+ }
487+ id, ok := c.id("id")
488+ if !ok {
489+ return
490+ }
491+ s.store.DeleteLabel(repo.ID, id)
492+ c.done()
493+}
494+
495+func (s *Server) apiProtections(c *call) {
496+ repo, ok := c.repo()
497+ if !ok {
498+ return
499+ }
500+ type row struct {
501+ Branch string `json:"branch"`
502+ RequirePull bool `json:"require_pull"`
503+ Approvals int `json:"approvals"`
504+ }
505+ paged(c, mapped(s.store.Protections(repo.ID), func(p Protection) row { return row{p.Branch, p.RequirePR, p.Approvals} }))
506+}
507+
508+func (s *Server) apiProtect(c *call) {
509+ repo, ok := c.repo()
510+ if !ok {
511+ return
512+ }
513+ if err := s.ownRepo(c.user, repo); err != nil {
514+ c.err(err)
515+ return
516+ }
517+ var in struct {
518+ RequirePull bool `json:"require_pull"`
519+ Approvals int `json:"approvals"`
520+ }
521+ if !c.decode(&in) {
522+ return
523+ }
524+ if err := s.store.SetProtection(repo.ID, c.arg("branch"), in.RequirePull, in.Approvals); err != nil {
525+ c.err(err)
526+ return
527+ }
528+ c.done()
529+}
530+
531+func (s *Server) apiUnprotect(c *call) {
532+ repo, ok := c.repo()
533+ if !ok {
534+ return
535+ }
536+ if err := s.ownRepo(c.user, repo); err != nil {
537+ c.err(err)
538+ return
539+ }
540+ s.store.DeleteProtection(repo.ID, c.arg("branch"))
541+ c.done()
542+}
543+
544+func (s *Server) apiRefs(c *call) {
545+ repo, ok := c.repo()
546+ if !ok {
547+ return
548+ }
549+ g := Git{Dir: s.repoDir(repo)}
550+ prefixes := []string{"refs/heads", "refs/tags"}
551+ switch c.query("kind") {
552+ case "branch":
553+ prefixes = prefixes[:1]
554+ case "tag":
555+ prefixes = prefixes[1:]
556+ }
557+ refs, err := g.Refs(prefixes...)
558+ if err != nil {
559+ c.err(err)
560+ return
561+ }
562+ head := g.Head()
563+ paged(c, mapped(refs, func(r Ref) refView {
564+ kind := "branch"
565+ if r.Tag {
566+ kind = "tag"
567+ }
568+ return refView{Name: r.Name, Kind: kind, SHA: r.Hash, At: iso(r.Time), Default: !r.Tag && r.Name == head}
569+ }))
570+}
571+
572+func (c *call) commitAt(g Git, ref string) (string, bool) {
573+ if ref == "" {
574+ ref = g.Head()
575+ }
576+ sha, err := g.Resolve(ref)
577+ if err != nil {
578+ if g.Empty() {
579+ c.fail(404, "The repository is empty")
580+ } else {
581+ c.fail(404, "No such ref")
582+ }
583+ return "", false
584+ }
585+ return sha, true
586+}
587+
588+func (c *call) refPath(g Git) (string, string, bool) {
589+ spec := c.arg("spec")
590+ if spec == "" {
591+ c.fail(404, "Name a ref, then a path")
592+ return "", "", false
593+ }
594+ ref, filePath, ok := c.s.splitRef(g, spec)
595+ if !ok {
596+ c.fail(404, "No such ref")
597+ return "", "", false
598+ }
599+ filePath, ok = cleanPath(filePath)
600+ if !ok {
601+ c.fail(404, "No such path")
602+ return "", "", false
603+ }
604+ return ref, filePath, true
605+}
606+
607+func (s *Server) apiLog(c *call) {
608+ repo, ok := c.repo()
609+ if !ok {
610+ return
611+ }
612+ g := Git{Dir: s.repoDir(repo)}
613+ sha, ok := c.commitAt(g, strings.Trim(c.arg("ref"), "/"))
614+ if !ok {
615+ return
616+ }
617+ filePath, ok := cleanPath(c.query("path"))
618+ if !ok {
619+ c.fail(404, "No such path")
620+ return
621+ }
622+ limit, err := strconv.Atoi(c.query("limit"))
623+ if err != nil || limit <= 0 {
624+ limit = 30
625+ }
626+ limit = min(limit, 100)
627+ from, _ := strconv.Atoi(c.query("from"))
628+ from = max(from, 0)
629+ commits, err := g.Log(sha, limit+1, filePath, "--skip="+strconv.Itoa(from))
630+ if err != nil {
631+ c.err(err)
632+ return
633+ }
634+ out := struct {
635+ Items []commitView `json:"items"`
636+ Next *string `json:"next"`
637+ }{Items: mapped(commits[:min(limit, len(commits))], s.commitView)}
638+ if len(commits) > limit {
639+ q := c.r.URL.Query()
640+ q.Set("from", strconv.Itoa(from+limit))
641+ q.Set("limit", strconv.Itoa(limit))
642+ next := "https://" + s.host + c.r.URL.Path + "?" + q.Encode()
643+ out.Next = &next
644+ c.w.Header().Set("Link", "<"+next+`>; rel="next"`)
645+ }
646+ c.reply(200, out)
647+}
648+
649+func (s *Server) apiCommit(c *call) {
650+ repo, ok := c.repo()
651+ if !ok {
652+ return
653+ }
654+ g := Git{Dir: s.repoDir(repo)}
655+ sha, ok := c.commitAt(g, c.arg("sha"))
656+ if !ok {
657+ return
658+ }
659+ commit, err := g.Commit(sha)
660+ if err != nil {
661+ c.fail(404, "No such commit")
662+ return
663+ }
664+ files, err := g.Diff(sha)
665+ if err != nil {
666+ c.err(err)
667+ return
668+ }
669+ v := s.commitView(commit)
670+ v.Files = nonNil(fileViews(files, c.query("patch") == "1"))
671+ c.reply(200, v)
672+}
673+
674+func (s *Server) apiCompare(c *call) {
675+ repo, ok := c.repo()
676+ if !ok {
677+ return
678+ }
679+ g := Git{Dir: s.repoDir(repo)}
680+ base, head, found := strings.Cut(c.arg("spec"), "...")
681+ if !found {
682+ c.fail(400, "Compare takes base...head")
683+ return
684+ }
685+ from, ok := c.commitAt(g, base)
686+ if !ok {
687+ return
688+ }
689+ to, ok := c.commitAt(g, head)
690+ if !ok {
691+ return
692+ }
693+ mb, err := g.MergeBase(from, to)
694+ if err != nil {
695+ c.fail(409, "The two refs share no history")
696+ return
697+ }
698+ commits, _ := g.LogRange(mb, to, 250)
699+ files, _ := g.DiffRange(mb, to)
700+ c.reply(200, map[string]any{"base": from, "head": to, "merge_base": mb, "commits": mapped(commits, s.commitView), "files": fileViews(files, c.query("patch") == "1")})
701+}
702+
703+func (s *Server) apiTree(c *call) {
704+ repo, ok := c.repo()
705+ if !ok {
706+ return
707+ }
708+ g := Git{Dir: s.repoDir(repo)}
709+ ref, filePath, ok := c.refPath(g)
710+ if !ok {
711+ return
712+ }
713+ sha, ok := c.commitAt(g, ref)
714+ if !ok {
715+ return
716+ }
717+ switch g.Kind(sha, filePath) {
718+ case "tree", "":
719+ if filePath != "" && g.Kind(sha, filePath) == "" {
720+ c.fail(404, "No such path")
721+ return
722+ }
723+ entries, err := g.Tree(sha, filePath)
724+ if err != nil {
725+ c.fail(404, "No such path")
726+ return
727+ }
728+ c.reply(200, map[string]any{"ref": ref, "commit": sha, "path": filePath, "kind": "dir", "entries": mapped(entries, func(e Entry) entryView {
729+ return entryView{Name: e.Name, Path: path.Join(filePath, e.Name), Kind: entryKind(e), Mode: e.Mode, Size: e.Size, SHA: e.Hash}
730+ })})
731+ default:
732+ e := g.Entry(sha, filePath)
733+ if e == nil {
734+ c.fail(404, "No such path")
735+ return
736+ }
737+ out := map[string]any{"ref": ref, "commit": sha, "path": filePath, "kind": entryKind(*e), "mode": e.Mode, "size": e.Size, "sha": e.Hash,
738+ "raw": "https://" + s.host + "/api/" + repo.Path() + "/raw/" + ref + "/" + escapePath(filePath)}
739+ if e.Size <= 1<<20 && entryKind(*e) != "submodule" {
740+ data, err := g.Blob(sha, filePath)
741+ if err == nil {
742+ if utf8.Valid(data) && !isBinary(data) {
743+ out["encoding"], out["content"] = "utf-8", string(data)
744+ } else {
745+ out["encoding"], out["content"] = "base64", base64.StdEncoding.EncodeToString(data)
746+ }
747+ }
748+ }
749+ c.reply(200, out)
750+ }
751+}
752+
753+func (s *Server) apiPutFile(c *call) {
754+ repo, ok := c.repo()
755+ if !ok {
756+ return
757+ }
758+ g := Git{Dir: s.repoDir(repo)}
759+ branch, filePath, ok := c.refPath(g)
760+ if !ok {
761+ return
762+ }
763+ if filePath == "" {
764+ c.fail(400, "Name a file after the branch")
765+ return
766+ }
767+ var in struct {
768+ Content string `json:"content"`
769+ Encoding string `json:"encoding"`
770+ Message string `json:"message"`
771+ Parent string `json:"parent"`
772+ }
773+ if !c.decode(&in) {
774+ return
775+ }
776+ data := []byte(in.Content)
777+ switch in.Encoding {
778+ case "", "utf-8":
779+ case "base64":
780+ var err error
781+ if data, err = base64.StdEncoding.DecodeString(in.Content); err != nil {
782+ c.fail(400, "Content is not valid base64")
783+ return
784+ }
785+ default:
786+ c.fail(400, "Encoding must be utf-8 or base64")
787+ return
788+ }
789+ sha, err := s.writeFile(*c.user, repo, branch, filePath, data, false, in.Message, in.Parent)
790+ if err != nil {
791+ c.err(err)
792+ return
793+ }
794+ commit, _ := g.Commit(sha)
795+ c.reply(201, s.commitView(commit))
796+}
797+
798+func (s *Server) apiDeleteFile(c *call) {
799+ repo, ok := c.repo()
800+ if !ok {
801+ return
802+ }
803+ g := Git{Dir: s.repoDir(repo)}
804+ branch, filePath, ok := c.refPath(g)
805+ if !ok {
806+ return
807+ }
808+ sha, err := s.writeFile(*c.user, repo, branch, filePath, nil, true, c.query("message"), c.query("parent"))
809+ if err != nil {
810+ c.err(err)
811+ return
812+ }
813+ commit, _ := g.Commit(sha)
814+ c.reply(200, s.commitView(commit))
815+}
816+
817+func (s *Server) apiRaw(c *call) {
818+ repo, ok := c.repo()
819+ if !ok {
820+ return
821+ }
822+ g := Git{Dir: s.repoDir(repo)}
823+ ref, filePath, ok := c.refPath(g)
824+ if !ok {
825+ return
826+ }
827+ sha, ok := c.commitAt(g, ref)
828+ if !ok {
829+ return
830+ }
831+ if g.Kind(sha, filePath) != "blob" {
832+ c.fail(404, "No such file")
833+ return
834+ }
835+ h := c.w.Header()
836+ h.Set("Content-Type", "application/octet-stream")
837+ h.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(filePath)}))
838+ h.Set("Content-Length", strconv.FormatInt(g.Size(sha, filePath), 10))
839+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
840+ g.Stream(c.w, sha, filePath)
841+}
842+
843+func (s *Server) apiBlame(c *call) {
844+ repo, ok := c.repo()
845+ if !ok {
846+ return
847+ }
848+ g := Git{Dir: s.repoDir(repo)}
849+ ref, filePath, ok := c.refPath(g)
850+ if !ok {
851+ return
852+ }
853+ sha, ok := c.commitAt(g, ref)
854+ if !ok {
855+ return
856+ }
857+ if g.Kind(sha, filePath) != "blob" {
858+ c.fail(404, "No such file")
859+ return
860+ }
861+ if g.Size(sha, filePath) > maxRender {
862+ c.fail(413, "File too large to blame")
863+ return
864+ }
865+ lines, err := g.Blame(sha, filePath)
866+ if err != nil {
867+ c.err(err)
868+ return
869+ }
870+ type row struct {
871+ Line int `json:"line"`
872+ SHA string `json:"sha"`
873+ Author string `json:"author"`
874+ At *string `json:"at"`
875+ Subject string `json:"subject"`
876+ Text string `json:"text"`
877+ }
878+ c.reply(200, map[string]any{"commit": sha, "path": filePath, "lines": mapped(lines, func(l BlameLine) row {
879+ return row{l.Line, l.Hash, l.Author, iso(l.Time), l.Subject, l.Text}
880+ })})
881+}
882+
883+func (s *Server) apiReadme(c *call) {
884+ repo, ok := c.repo()
885+ if !ok {
886+ return
887+ }
888+ g := Git{Dir: s.repoDir(repo)}
889+ sha, ok := c.commitAt(g, c.query("ref"))
890+ if !ok {
891+ return
892+ }
893+ name, data := g.Readme(sha)
894+ if name == "" {
895+ c.fail(404, "No README at this ref")
896+ return
897+ }
898+ c.reply(200, map[string]string{"path": name, "commit": sha, "text": string(data), "html": string(s.renderMarkdown(data))})
899+}
900+
901+func (s *Server) apiLanguages(c *call) {
902+ repo, ok := c.repo()
903+ if !ok {
904+ return
905+ }
906+ type row struct {
907+ Name string `json:"name"`
908+ Colour string `json:"colour"`
909+ Bytes int64 `json:"bytes"`
910+ Percent float64 `json:"percent"`
911+ }
912+ c.reply(200, mapped(s.repoLanguages(repo), func(l language) row { return row{l.Name, l.Colour, l.Bytes, l.Percent} }))
913+}
914+
915+func (s *Server) apiGrep(c *call) {
916+ repo, ok := c.repo()
917+ if !ok {
918+ return
919+ }
920+ q := strings.TrimSpace(c.query("q"))
921+ if q == "" {
922+ c.fail(400, "Pass ?q=")
923+ return
924+ }
925+ g := Git{Dir: s.repoDir(repo)}
926+ sha, ok := c.commitAt(g, c.query("ref"))
927+ if !ok {
928+ return
929+ }
930+ matches, more, err := g.Grep(sha, q, 500)
931+ if err != nil {
932+ c.err(err)
933+ return
934+ }
935+ type row struct {
936+ Path string `json:"path"`
937+ Line int `json:"line"`
938+ Text string `json:"text"`
939+ }
940+ c.reply(200, map[string]any{"commit": sha, "truncated": more, "matches": mapped(matches, func(m Match) row { return row{m.Path, m.Line, m.Text} })})
941+}
942+
943+func (s *Server) apiArchive(c *call) {
944+ repo, ok := c.repo()
945+ if !ok {
946+ return
947+ }
948+ spec, format := c.arg("spec"), ""
949+ switch {
950+ case strings.HasSuffix(spec, ".tar.gz"):
951+ format, spec = "tar.gz", strings.TrimSuffix(spec, ".tar.gz")
952+ case strings.HasSuffix(spec, ".zip"):
953+ format, spec = "zip", strings.TrimSuffix(spec, ".zip")
954+ default:
955+ c.fail(404, "Ask for ref.tar.gz or ref.zip")
956+ return
957+ }
958+ g := Git{Dir: s.repoDir(repo)}
959+ sha, ok := c.commitAt(g, spec)
960+ if !ok {
961+ return
962+ }
963+ name := repo.Name + "-" + short(sha)
964+ ctx, cancel := context.WithTimeout(c.r.Context(), 10*time.Minute)
965+ defer cancel()
966+ c.w.Header().Set("Content-Type", "application/octet-stream")
967+ c.w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + "." + format}))
968+ writeArchive(ctx, c.w, g.Dir, format, name, sha)
969+}

forge/search.go modified

@@ -151,21 +151,78 @@ func matchScore(q string, fields ...string) int {
151151 return best
152152 }
153153
154+type searchResult struct {
155+ Query string
156+ Base string
157+ Box string
158+ Lang string
159+ Sort string
160+ Repos []repoHit
161+ Users []userHit
162+ Gists []gistHit
163+ Languages []langCount
164+}
165+
166+type gistHit struct {
167+ Gist
168+ Files []string
169+ score int
170+}
171+
154172 func (s *Server) find(w http.ResponseWriter, r *http.Request) {
155173 query := strings.TrimSpace(r.URL.Query().Get("q"))
156174 kind := r.URL.Query().Get("type")
175+ if kind != "users" && kind != "gists" {
176+ kind = "repos"
177+ }
178+ res := s.runSearch(query, r.URL.Query().Get("lang"), r.URL.Query().Get("sort"))
179+ title := "Search"
180+ if query != "" {
181+ title = query + " · search"
182+ }
183+ s.render(w, r, "search", page{Title: title, User: s.current(r), Data: map[string]any{
184+ "Query": res.Box, "Base": res.Base, "Type": kind, "Lang": res.Lang, "Sort": res.Sort, "Repos": res.Repos, "Users": res.Users, "Gists": res.Gists, "Languages": res.Languages, "Facets": true,
185+ }})
186+}
187+
188+func (s *Server) searchGists(q, owner string) []gistHit {
189+ gists, _ := s.store.Gists(0, false, 300)
190+ var hits []gistHit
191+ for _, g := range gists {
192+ if owner != "" && !strings.EqualFold(owner, g.Owner.Name) {
193+ continue
194+ }
195+ entries, _ := s.gistGit(g).gistTree(s.gistHead(g))
196+ var names []string
197+ for _, e := range entries {
198+ names = append(names, e.Name)
199+ }
200+ score := matchScore(q, strings.Join(names, " "), g.Description, g.Owner.Name)
201+ if score == 0 {
202+ continue
203+ }
204+ hits = append(hits, gistHit{Gist: g, Files: names, score: score})
205+ }
206+ slices.SortStableFunc(hits, func(a, b gistHit) int {
207+ if a.score != b.score {
208+ return b.score - a.score
209+ }
210+ return b.Updated.Compare(a.Updated)
211+ })
212+ if len(hits) > 100 {
213+ hits = hits[:100]
214+ }
215+ return hits
216+}
217+
218+func (s *Server) runSearch(query, lang, sort string) searchResult {
157219 text, quals := parseQuery(query)
158- lang := r.URL.Query().Get("lang")
159220 if l, ok := quals["language"]; ok {
160221 lang = l
161222 }
162223 lang = canonicalLanguage(lang)
163224 owner, tag := quals["user"], strings.ToLower(quals["tag"])
164- sort := r.URL.Query().Get("sort")
165225 q := strings.ToLower(text)
166- if kind != "users" {
167- kind = "repos"
168- }
169226
170227 all, _ := s.store.PublicRepos("", "")
171228 var repos []repoHit
@@ -262,13 +319,11 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) {
262319 if len(users) > 100 {
263320 users = users[:100]
264321 }
265- title := "Search"
266- if query != "" {
267- title = query + " · search"
322+ var gists []gistHit
323+ if lang == "" && tag == "" {
324+ gists = s.searchGists(q, owner)
268325 }
269- s.render(w, r, "search", page{Title: title, User: s.current(r), Data: map[string]any{
270- "Query": box, "Base": base, "Type": kind, "Lang": lang, "Sort": sort, "Repos": repos, "Users": users, "Languages": facets, "Facets": true,
271- }})
326+ return searchResult{Query: query, Base: base, Box: box, Lang: lang, Sort: sort, Repos: repos, Users: users, Gists: gists, Languages: facets}
272327 }
273328
274329 var qualifiers = map[string]string{"language": "language", "lang": "language", "user": "user", "owner": "user", "tag": "tag", "topic": "tag"}

forge/smart.go modified

@@ -22,22 +22,23 @@ func isSmart(r *http.Request, rest string) bool {
2222 return false
2323 }
2424
25-func (s *Server) basicUser(r *http.Request) (User, bool) {
25+func (s *Server) basicUser(r *http.Request) (User, string, bool) {
2626 name, secret, ok := r.BasicAuth()
2727 if !ok {
28- return User{}, false
28+ return User{}, "", false
2929 }
30- if u, ok := s.store.UserByToken(name, secret); ok {
31- return u, true
30+ if u, scope, ok := s.store.UserByToken(name, secret); ok {
31+ return u, scope, true
3232 }
33- return s.store.CheckPassword(name, secret)
33+ u, ok := s.store.CheckPassword(name, secret)
34+ return u, scopeAll, ok
3435 }
3536
3637 func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, rest string) {
3738 repo, err := s.store.RepoByPath(owner.Name, name)
3839 if err != nil {
3940 var viewer *User
40- if u, ok := s.basicUser(r); ok {
41+ if u, _, ok := s.basicUser(r); ok {
4142 viewer = &u
4243 }
4344 segment, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/~"+owner.Name+"/"), "/")
@@ -63,7 +64,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
6364 http.Error(w, "too many failed attempts", 429)
6465 return
6566 }
66- u, ok := s.basicUser(r)
67+ u, scope, ok := s.basicUser(r)
6768 if !ok || !s.store.CanRead(repo, &u) {
6869 if _, _, given := r.BasicAuth(); given {
6970 s.limiter.fail(ip)
@@ -72,6 +73,10 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
7273 http.Error(w, "authentication required", 401)
7374 return
7475 }
76+ if service == "git-receive-pack" && scope != scopeAll {
77+ http.Error(w, "this token is scoped to "+scope+" and cannot push to repositories", 403)
78+ return
79+ }
7580 pusher, writer = u, s.store.CanWrite(repo, &u)
7681 }
7782 if service == "git-receive-pack" && !hooksReady(s.root) {

forge/store.go modified

@@ -9,6 +9,7 @@ import (
99 "fmt"
1010 "net/mail"
1111 "regexp"
12+ "slices"
1213 "strings"
1314 "sync"
1415 "time"
@@ -71,6 +72,8 @@ var migrations = []string{
7172 `alter table repos add column parent integer not null default 0`,
7273 `alter table repos add column archived integer not null default 0`,
7374 `alter table users add column email text not null default ''`,
75+ `alter table tokens add column scope text not null default 'all'`,
76+ `alter table tokens add column used integer not null default 0`,
7477 `alter table users add column verified integer not null default 0`,
7578 `alter table users add column notify integer not null default 1`,
7679 `alter table users add column banned integer not null default 0`,
@@ -257,9 +260,19 @@ type Key struct {
257260 type Token struct {
258261 ID int64
259262 Title string
263+ Scope string
260264 Created time.Time
265+ Used time.Time
261266 }
262267
268+const (
269+ scopeAll = "all"
270+ scopeRead = "read"
271+ scopeGist = "gist"
272+)
273+
274+var tokenScopes = []string{scopeAll, scopeRead, scopeGist}
275+
263276 type Store struct {
264277 db *sql.DB
265278 settingsMu sync.Mutex
@@ -515,17 +528,23 @@ func tokenHash(token string) string {
515528 return hex.EncodeToString(sum[:])
516529 }
517530
518-func (s *Store) CreateToken(user int64, title string) (string, error) {
531+func (s *Store) CreateToken(user int64, title, scope string) (string, error) {
519532 if title == "" {
520533 title = "token"
521534 }
522- token := random(24)
523- _, err := s.db.Exec(`insert into tokens(user, hash, title, created) values (?, ?, ?, ?)`, user, tokenHash(token), title, time.Now().Unix())
535+ if len(title) > 100 {
536+ return "", errors.New("Token titles are at most 100 characters")
537+ }
538+ if !slices.Contains(tokenScopes, scope) {
539+ return "", errors.New("Scope must be all, read or gist")
540+ }
541+ token := "gp_" + random(24)
542+ _, err := s.db.Exec(`insert into tokens(user, hash, title, scope, created) values (?, ?, ?, ?, ?)`, user, tokenHash(token), title, scope, time.Now().Unix())
524543 return token, err
525544 }
526545
527546 func (s *Store) Tokens(user int64) ([]Token, error) {
528- rows, err := s.db.Query(`select id, title, created from tokens where user = ? order by id`, user)
547+ rows, err := s.db.Query(`select id, title, scope, created, used from tokens where user = ? order by id`, user)
529548 if err != nil {
530549 return nil, err
531550 }
@@ -533,29 +552,48 @@ func (s *Store) Tokens(user int64) ([]Token, error) {
533552 var tokens []Token
534553 for rows.Next() {
535554 var t Token
536- var created int64
537- if err := rows.Scan(&t.ID, &t.Title, &created); err != nil {
555+ var created, used int64
556+ if err := rows.Scan(&t.ID, &t.Title, &t.Scope, &created, &used); err != nil {
538557 return nil, err
539558 }
540559 t.Created = time.Unix(created, 0)
560+ if used > 0 {
561+ t.Used = time.Unix(used, 0)
562+ }
541563 tokens = append(tokens, t)
542564 }
543565 return tokens, rows.Err()
544566 }
545567
546568 func (s *Store) DeleteToken(user, id int64) error {
547- _, err := s.db.Exec(`delete from tokens where user = ? and id = ?`, user, id)
569+ res, err := s.db.Exec(`delete from tokens where user = ? and id = ?`, user, id)
570+ if err == nil {
571+ if n, _ := res.RowsAffected(); n == 0 {
572+ return ErrNotFound
573+ }
574+ }
548575 return err
549576 }
550577
551-func (s *Store) UserByToken(name, token string) (User, bool) {
552- var user int64
553- err := s.db.QueryRow(`select t.user from tokens t join users u on u.id = t.user where u.name = ? and t.hash = ? and u.banned = 0`, name, tokenHash(token)).Scan(&user)
554- if err != nil {
555- return User{}, false
578+func (s *Store) tokenUser(query string, args ...any) (User, string, bool) {
579+ var id, user, used int64
580+ var scope string
581+ if err := s.db.QueryRow(`select t.id, t.user, t.scope, t.used from tokens t join users u on u.id = t.user where u.banned = 0 and `+query, args...).Scan(&id, &user, &scope, &used); err != nil {
582+ return User{}, "", false
583+ }
584+ if now := time.Now().Unix(); now-used > 60 {
585+ s.db.Exec(`update tokens set used = ? where id = ?`, now, id)
556586 }
557587 u, err := s.userByID(user)
558- return u, err == nil
588+ return u, scope, err == nil
589+}
590+
591+func (s *Store) UserByToken(name, token string) (User, string, bool) {
592+ return s.tokenUser(`u.name = ? and t.hash = ?`, name, tokenHash(token))
593+}
594+
595+func (s *Store) UserByBearer(token string) (User, string, bool) {
596+ return s.tokenUser(`t.hash = ?`, tokenHash(token))
559597 }
560598
561599 func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) {
@@ -1137,7 +1175,7 @@ func validEmail(email string) bool {
11371175 const maxPins = 6
11381176
11391177 func reservedRepo(name string) bool {
1140- return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim" || name == "gists"
1178+ return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim" || name == "gists" || name == "keys" || name == "members"
11411179 }
11421180
11431181 func (s *Store) Pins(owner int64) map[int64]int {

forge/threads.go added

@@ -0,0 +1,737 @@
1+package forge
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+)
8+
9+type issueView struct {
10+ Number int64 `json:"number"`
11+ Title string `json:"title"`
12+ Body string `json:"body"`
13+ State string `json:"state"`
14+ Author string `json:"author"`
15+ Labels []string `json:"labels"`
16+ Assignees []string `json:"assignees"`
17+ Comments int `json:"comments"`
18+ Opened *string `json:"opened"`
19+ Updated *string `json:"updated"`
20+ URL string `json:"url"`
21+ API string `json:"api"`
22+}
23+
24+type branchSide struct {
25+ Repo string `json:"repo"`
26+ Branch string `json:"branch"`
27+ SHA string `json:"sha,omitempty"`
28+}
29+
30+type pullView struct {
31+ issueView
32+ Draft bool `json:"draft"`
33+ Head branchSide `json:"head"`
34+ Base branchSide `json:"base"`
35+ Topic string `json:"topic,omitempty"`
36+ Merged *string `json:"merged_as"`
37+ Mergeable *bool `json:"mergeable,omitempty"`
38+ Verdicts *verdictSum `json:"verdicts,omitempty"`
39+}
40+
41+type verdictSum struct {
42+ Approvals int `json:"approvals"`
43+ Changes int `json:"changes_requested"`
44+ Needed int `json:"approvals_needed"`
45+}
46+
47+type commentView struct {
48+ ID int64 `json:"id"`
49+ Author string `json:"author"`
50+ Body string `json:"body"`
51+ At *string `json:"at"`
52+}
53+
54+type reviewView struct {
55+ ID int64 `json:"id"`
56+ Author string `json:"author"`
57+ Verdict string `json:"verdict"`
58+ Body string `json:"body"`
59+ Head string `json:"head"`
60+ At *string `json:"at"`
61+}
62+
63+type noteView struct {
64+ ID int64 `json:"id"`
65+ Author string `json:"author"`
66+ Path string `json:"path"`
67+ Side string `json:"side"`
68+ Line int `json:"line"`
69+ Body string `json:"body"`
70+ Resolved bool `json:"resolved"`
71+ At *string `json:"at"`
72+}
73+
74+func labelNames(labels []Label) []string {
75+ return mapped(labels, func(l Label) string { return l.Name })
76+}
77+
78+func (s *Server) issueView(repo Repo, i Issue) issueView {
79+ link := fmt.Sprintf("%s/issues/%d", repo.Path(), i.Number)
80+ return issueView{Number: i.Number, Title: i.Title, Body: i.Body, State: i.State, Author: handle(i.Author.Name),
81+ Labels: labelNames(s.store.ItemLabels("issue", i.ID)), Assignees: handles(s.store.Assignees("issue", i.ID)), Comments: i.Comments,
82+ Opened: iso(i.Created), Updated: iso(i.Updated), URL: "https://" + s.host + "/" + link, API: "https://" + s.host + "/api/" + link}
83+}
84+
85+func (s *Server) pullView(p Pull, deep bool) pullView {
86+ link := fmt.Sprintf("%s/pulls/%d", p.Repo.Path(), p.Number)
87+ v := pullView{issueView: issueView{Number: p.Number, Title: p.Title, Body: p.Body, State: p.State, Author: handle(p.Author.Name),
88+ Labels: labelNames(s.store.ItemLabels("pull", p.ID)), Assignees: handles(s.store.Assignees("pull", p.ID)), Comments: p.Comments,
89+ Opened: iso(p.Created), Updated: iso(p.Updated), URL: "https://" + s.host + "/" + link, API: "https://" + s.host + "/api/" + link},
90+ Draft: p.Draft, Topic: p.Agit, Base: branchSide{Repo: p.Repo.Path(), Branch: p.TargetBranch}}
91+ if p.Agit == "" {
92+ v.Head = branchSide{Repo: p.SourceRepo.Path(), Branch: p.SourceBranch}
93+ } else {
94+ v.Head = branchSide{Repo: p.Repo.Path(), Branch: p.Source()}
95+ }
96+ if p.Merged != "" {
97+ v.Merged = &p.Merged
98+ }
99+ g := Git{Dir: s.repoDir(p.Repo)}
100+ head, err := g.Resolve(p.Ref())
101+ if err == nil {
102+ v.Head.SHA = head
103+ }
104+ if base, err := g.Resolve("refs/heads/" + p.TargetBranch); err == nil {
105+ v.Base.SHA = base
106+ }
107+ if !deep || err != nil {
108+ return v
109+ }
110+ approvals, changes := latestVerdicts(s.store.PRReviews(p.ID), head)
111+ sum := verdictSum{Approvals: approvals, Changes: changes}
112+ if prot, ok := s.store.Protection(p.Repo.ID, p.TargetBranch); ok {
113+ sum.Needed = prot.Approvals
114+ }
115+ v.Verdicts = &sum
116+ if p.State == "open" {
117+ ok := !p.Draft && g.Mergeable(p.TargetBranch, head) && v.Base.SHA != head && (sum.Needed == 0 || (approvals >= sum.Needed && changes == 0))
118+ v.Mergeable = &ok
119+ }
120+ return v
121+}
122+
123+func commentViews(comments []Comment) []commentView {
124+ return mapped(comments, func(c Comment) commentView { return commentView{c.ID, handle(c.Author.Name), c.Body, iso(c.Created)} })
125+}
126+
127+func (s *Server) threadRoutes() []apiRoute {
128+ return []apiRoute{
129+ {"GET", "/~:owner/:repo/issues", needNone, "issues, ?state=open|closed|all and ?label=name", s.apiIssues, nil},
130+ {"POST", "/~:owner/:repo/issues", needWrite, "open an issue from {title, body}, collaborators may add labels and assignees", s.apiOpenIssue, nil},
131+ {"GET", "/~:owner/:repo/issues/:number", needNone, "an issue", s.apiIssue, nil},
132+ {"PATCH", "/~:owner/:repo/issues/:number", needWrite, "change state, labels or assignees", s.apiPatchIssue, nil},
133+ {"GET", "/~:owner/:repo/issues/:number/comments", needNone, "comments, oldest first", s.apiIssueComments, nil},
134+ {"POST", "/~:owner/:repo/issues/:number/comments", needWrite, "comment with {body}", s.apiCommentIssue, nil},
135+ {"GET", "/~:owner/:repo/pulls", needNone, "pull requests, ?state=open|closed|merged|all and ?label=name", s.apiPulls, nil},
136+ {"POST", "/~:owner/:repo/pulls", needWrite, "open a pull request from {title, body, head, base, draft}, head is a branch or ~you/fork:branch", s.apiOpenPull, nil},
137+ {"GET", "/~:owner/:repo/pulls/:number", needNone, "a pull request with its head, mergeability and verdicts", s.apiPull, nil},
138+ {"PATCH", "/~:owner/:repo/pulls/:number", needWrite, "change state, draft, labels or assignees", s.apiPatchPull, nil},
139+ {"GET", "/~:owner/:repo/pulls/:number/commits", needNone, "commits the pull request adds", s.apiPullCommits, nil},
140+ {"GET", "/~:owner/:repo/pulls/:number/files", needNone, "files the pull request changes, ?patch=1 adds the hunks", s.apiPullFiles, nil},
141+ {"GET", "/~:owner/:repo/pulls/:number/revisions", needNone, "every head the pull request has had, oldest first", s.apiPullRevisions, nil},
142+ {"GET", "/~:owner/:repo/pulls/:number/comments", needNone, "conversation comments", s.apiPullComments, nil},
143+ {"POST", "/~:owner/:repo/pulls/:number/comments", needWrite, "comment with {body}", s.apiCommentPull, nil},
144+ {"GET", "/~:owner/:repo/pulls/:number/reviews", needNone, "verdicts, each tied to the head it judged", s.apiReviews, nil},
145+ {"POST", "/~:owner/:repo/pulls/:number/reviews", needWrite, "give a verdict with {verdict: approved|changes|comment, body}", s.apiReview, nil},
146+ {"GET", "/~:owner/:repo/pulls/:number/notes", needNone, "line notes on the diff", s.apiNotes, nil},
147+ {"POST", "/~:owner/:repo/pulls/:number/notes", needWrite, "note a line with {path, side: n|o, line, body}", s.apiAddNote, nil},
148+ {"PATCH", "/~:owner/:repo/pulls/:number/notes/:id", needWrite, "resolve or reopen a note with {resolved}", s.apiPatchNote, nil},
149+ {"DELETE", "/~:owner/:repo/pulls/:number/notes/:id", needWrite, "delete a note", s.apiDeleteNote, nil},
150+ {"POST", "/~:owner/:repo/pulls/:number/refresh", needWrite, "fetch the source branch again", s.apiRefreshPull, nil},
151+ {"POST", "/~:owner/:repo/pulls/:number/merge", needWrite, "merge with {strategy: merge|squash|rebase|ff, title, body}", s.apiMerge, nil},
152+ }
153+}
154+
155+func (c *call) issue() (Repo, Issue, bool) {
156+ repo, ok := c.repo()
157+ if !ok {
158+ return repo, Issue{}, false
159+ }
160+ n, ok := c.number()
161+ if !ok {
162+ return repo, Issue{}, false
163+ }
164+ issue, err := c.s.store.Issue(repo.ID, n)
165+ if err != nil {
166+ c.fail(404, "No such issue")
167+ return repo, Issue{}, false
168+ }
169+ return repo, issue, true
170+}
171+
172+func (c *call) pull() (Pull, bool) {
173+ repo, ok := c.repo()
174+ if !ok {
175+ return Pull{}, false
176+ }
177+ n, ok := c.number()
178+ if !ok {
179+ return Pull{}, false
180+ }
181+ pull, err := c.s.store.Pull(repo, n)
182+ if err != nil {
183+ c.fail(404, "No such pull request")
184+ return Pull{}, false
185+ }
186+ return pull, true
187+}
188+
189+func (s *Server) labelIDs(repo Repo, names []string) ([]int64, error) {
190+ labels, _ := s.store.Labels(repo.ID)
191+ var ids []int64
192+ for _, n := range names {
193+ i := slices.IndexFunc(labels, func(l Label) bool { return strings.EqualFold(l.Name, n) })
194+ if i < 0 {
195+ return nil, refuse(400, "No label called "+n)
196+ }
197+ ids = append(ids, labels[i].ID)
198+ }
199+ return ids, nil
200+}
201+
202+func (s *Server) userIDs(names []string) ([]int64, error) {
203+ var ids []int64
204+ for _, n := range names {
205+ u, err := s.store.UserByName(strings.TrimPrefix(n, "~"))
206+ if err != nil {
207+ return nil, refuse(400, "No account called "+n)
208+ }
209+ ids = append(ids, u.ID)
210+ }
211+ return ids, nil
212+}
213+
214+type threadPatch struct {
215+ State *string `json:"state"`
216+ Draft *bool `json:"draft"`
217+ Labels *[]string `json:"labels"`
218+ Assignees *[]string `json:"assignees"`
219+}
220+
221+func (s *Server) applyMeta(u User, repo Repo, kind string, item int64, in threadPatch) error {
222+ if in.Labels != nil {
223+ ids, err := s.labelIDs(repo, *in.Labels)
224+ if err != nil {
225+ return err
226+ }
227+ if err := s.setLabels(u, repo, kind, item, ids); err != nil {
228+ return err
229+ }
230+ }
231+ if in.Assignees != nil {
232+ ids, err := s.userIDs(*in.Assignees)
233+ if err != nil {
234+ return err
235+ }
236+ if err := s.setAssignees(u, repo, kind, item, ids); err != nil {
237+ return err
238+ }
239+ }
240+ return nil
241+}
242+
243+func listState(c *call, fallback string) (string, bool) {
244+ state := c.query("state")
245+ switch state {
246+ case "":
247+ return fallback, true
248+ case "all":
249+ return "", true
250+ case "open", "closed", "merged":
251+ return state, true
252+ }
253+ c.fail(400, "State must be open, closed, merged or all")
254+ return "", false
255+}
256+
257+func (s *Server) labelFilter(c *call, repo Repo, kind string) (map[int64]bool, bool) {
258+ name := c.query("label")
259+ if name == "" {
260+ return nil, true
261+ }
262+ ids, err := s.labelIDs(repo, []string{name})
263+ if err != nil {
264+ c.err(err)
265+ return nil, false
266+ }
267+ return s.store.ItemsWithLabel(kind, ids[0]), true
268+}
269+
270+func (s *Server) apiIssues(c *call) {
271+ repo, ok := c.repo()
272+ if !ok {
273+ return
274+ }
275+ state, ok := listState(c, "open")
276+ if !ok {
277+ return
278+ }
279+ issues, err := s.store.Issues(repo.ID, state)
280+ if err != nil {
281+ c.err(err)
282+ return
283+ }
284+ keep, ok := s.labelFilter(c, repo, "issue")
285+ if !ok {
286+ return
287+ }
288+ if keep != nil {
289+ issues = slices.DeleteFunc(issues, func(i Issue) bool { return !keep[i.ID] })
290+ }
291+ paged(c, mapped(issues, func(i Issue) issueView { return s.issueView(repo, i) }))
292+}
293+
294+func (s *Server) apiOpenIssue(c *call) {
295+ repo, ok := c.repo()
296+ if !ok {
297+ return
298+ }
299+ var in struct {
300+ Title string `json:"title"`
301+ Body string `json:"body"`
302+ Labels []string `json:"labels"`
303+ Assignees []string `json:"assignees"`
304+ }
305+ if !c.decode(&in) {
306+ return
307+ }
308+ issue, err := s.openIssue(*c.user, repo, in.Title, in.Body)
309+ if err != nil {
310+ c.err(err)
311+ return
312+ }
313+ if (in.Labels != nil || in.Assignees != nil) && s.store.CanWrite(repo, c.user) {
314+ s.applyMeta(*c.user, repo, "issue", issue.ID, threadPatch{Labels: &in.Labels, Assignees: &in.Assignees})
315+ }
316+ issue, _ = s.store.Issue(repo.ID, issue.Number)
317+ v := s.issueView(repo, issue)
318+ c.w.Header().Set("Location", v.API)
319+ c.reply(201, v)
320+}
321+
322+func (s *Server) apiIssue(c *call) {
323+ if repo, issue, ok := c.issue(); ok {
324+ c.reply(200, s.issueView(repo, issue))
325+ }
326+}
327+
328+func (s *Server) apiPatchIssue(c *call) {
329+ repo, issue, ok := c.issue()
330+ if !ok {
331+ return
332+ }
333+ var in threadPatch
334+ if !c.decode(&in) {
335+ return
336+ }
337+ if in.Draft != nil {
338+ c.fail(400, "Issues have no draft state")
339+ return
340+ }
341+ if in.State != nil {
342+ if *in.State != "open" && *in.State != "closed" {
343+ c.fail(400, "State must be open or closed")
344+ return
345+ }
346+ if err := s.setIssueState(*c.user, repo, issue, *in.State == "open"); err != nil {
347+ c.err(err)
348+ return
349+ }
350+ }
351+ if err := s.applyMeta(*c.user, repo, "issue", issue.ID, in); err != nil {
352+ c.err(err)
353+ return
354+ }
355+ issue, _ = s.store.Issue(repo.ID, issue.Number)
356+ c.reply(200, s.issueView(repo, issue))
357+}
358+
359+func (s *Server) apiIssueComments(c *call) {
360+ _, issue, ok := c.issue()
361+ if !ok {
362+ return
363+ }
364+ comments, err := s.store.IssueComments(issue.ID)
365+ if err != nil {
366+ c.err(err)
367+ return
368+ }
369+ paged(c, commentViews(comments))
370+}
371+
372+func (s *Server) apiCommentIssue(c *call) {
373+ repo, issue, ok := c.issue()
374+ if !ok {
375+ return
376+ }
377+ var in struct {
378+ Body string `json:"body"`
379+ }
380+ if !c.decode(&in) {
381+ return
382+ }
383+ if err := s.commentIssue(*c.user, repo, issue, in.Body); err != nil {
384+ c.err(err)
385+ return
386+ }
387+ comments, _ := s.store.IssueComments(issue.ID)
388+ c.reply(201, commentViews(comments[len(comments)-1:])[0])
389+}
390+
391+func (s *Server) apiPulls(c *call) {
392+ repo, ok := c.repo()
393+ if !ok {
394+ return
395+ }
396+ state, ok := listState(c, "open")
397+ if !ok {
398+ return
399+ }
400+ pulls, err := s.store.Pulls(repo, state)
401+ if err != nil {
402+ c.err(err)
403+ return
404+ }
405+ keep, ok := s.labelFilter(c, repo, "pull")
406+ if !ok {
407+ return
408+ }
409+ if keep != nil {
410+ pulls = slices.DeleteFunc(pulls, func(p Pull) bool { return !keep[p.ID] })
411+ }
412+ paged(c, mapped(pulls, func(p Pull) pullView { return s.pullView(p, false) }))
413+}
414+
415+func (s *Server) apiOpenPull(c *call) {
416+ repo, ok := c.repo()
417+ if !ok {
418+ return
419+ }
420+ var in struct {
421+ Title string `json:"title"`
422+ Body string `json:"body"`
423+ Head string `json:"head"`
424+ Base string `json:"base"`
425+ Draft bool `json:"draft"`
426+ }
427+ if !c.decode(&in) {
428+ return
429+ }
430+ if in.Base == "" {
431+ in.Base = Git{Dir: s.repoDir(repo)}.Head()
432+ }
433+ source := in.Head
434+ if !strings.Contains(source, ":") {
435+ source = repo.Path() + ":" + source
436+ }
437+ pull, err := s.openPull(*c.user, repo, source, in.Base, in.Title, in.Body, in.Draft)
438+ if err != nil {
439+ c.err(err)
440+ return
441+ }
442+ pull, _ = s.store.Pull(repo, pull.Number)
443+ v := s.pullView(pull, true)
444+ c.w.Header().Set("Location", v.API)
445+ c.reply(201, v)
446+}
447+
448+func (s *Server) apiPull(c *call) {
449+ if pull, ok := c.pull(); ok {
450+ c.reply(200, s.pullView(pull, true))
451+ }
452+}
453+
454+func (s *Server) apiPatchPull(c *call) {
455+ pull, ok := c.pull()
456+ if !ok {
457+ return
458+ }
459+ var in threadPatch
460+ if !c.decode(&in) {
461+ return
462+ }
463+ if in.State != nil && *in.State != pull.State {
464+ if *in.State != "open" && *in.State != "closed" {
465+ c.fail(400, "State must be open or closed, merge through /merge")
466+ return
467+ }
468+ if err := s.setPullState(*c.user, pull, *in.State == "open"); err != nil {
469+ c.err(err)
470+ return
471+ }
472+ }
473+ if in.Draft != nil {
474+ if err := s.setPullDraft(*c.user, pull, *in.Draft); err != nil {
475+ c.err(err)
476+ return
477+ }
478+ }
479+ if err := s.applyMeta(*c.user, pull.Repo, "pull", pull.ID, in); err != nil {
480+ c.err(err)
481+ return
482+ }
483+ pull, _ = s.store.Pull(pull.Repo, pull.Number)
484+ c.reply(200, s.pullView(pull, true))
485+}
486+
487+func (s *Server) pullRange(c *call, pull Pull) (Git, string, string, bool) {
488+ g := Git{Dir: s.repoDir(pull.Repo)}
489+ head, err := g.Resolve(pull.Ref())
490+ if err != nil {
491+ c.fail(410, "The head of this pull request is gone")
492+ return g, "", "", false
493+ }
494+ base := pull.TargetBranch
495+ if pull.State == "merged" && pull.Merged != "" {
496+ base = g.mergedBase(pull)
497+ }
498+ mb, err := g.MergeBase(base, head)
499+ if err != nil {
500+ c.fail(409, "The branches share no history")
501+ return g, "", "", false
502+ }
503+ return g, mb, head, true
504+}
505+
506+func (s *Server) apiPullCommits(c *call) {
507+ pull, ok := c.pull()
508+ if !ok {
509+ return
510+ }
511+ g, mb, head, ok := s.pullRange(c, pull)
512+ if !ok {
513+ return
514+ }
515+ commits, err := g.LogRange(mb, head, 250)
516+ if err != nil {
517+ c.err(err)
518+ return
519+ }
520+ paged(c, mapped(commits, s.commitView))
521+}
522+
523+func (s *Server) apiPullFiles(c *call) {
524+ pull, ok := c.pull()
525+ if !ok {
526+ return
527+ }
528+ g, mb, head, ok := s.pullRange(c, pull)
529+ if !ok {
530+ return
531+ }
532+ files, err := g.DiffRange(mb, head)
533+ if err != nil {
534+ c.err(err)
535+ return
536+ }
537+ paged(c, fileViews(files, c.query("patch") == "1"))
538+}
539+
540+func (s *Server) apiPullRevisions(c *call) {
541+ pull, ok := c.pull()
542+ if !ok {
543+ return
544+ }
545+ revs := Git{Dir: s.repoDir(pull.Repo)}.RecordRevision(pull)
546+ type row struct {
547+ Revision int `json:"revision"`
548+ SHA string `json:"sha"`
549+ }
550+ out := []row{}
551+ for i, h := range revs {
552+ out = append(out, row{i + 1, h})
553+ }
554+ paged(c, out)
555+}
556+
557+func (s *Server) apiPullComments(c *call) {
558+ pull, ok := c.pull()
559+ if !ok {
560+ return
561+ }
562+ comments, err := s.store.Comments(pull.ID)
563+ if err != nil {
564+ c.err(err)
565+ return
566+ }
567+ paged(c, commentViews(comments))
568+}
569+
570+func (s *Server) apiCommentPull(c *call) {
571+ pull, ok := c.pull()
572+ if !ok {
573+ return
574+ }
575+ var in struct {
576+ Body string `json:"body"`
577+ }
578+ if !c.decode(&in) {
579+ return
580+ }
581+ if err := s.commentPull(*c.user, pull, in.Body); err != nil {
582+ c.err(err)
583+ return
584+ }
585+ comments, _ := s.store.Comments(pull.ID)
586+ c.reply(201, commentViews(comments[len(comments)-1:])[0])
587+}
588+
589+func reviewViews(reviews []PRReview) []reviewView {
590+ return mapped(reviews, func(r PRReview) reviewView {
591+ return reviewView{r.ID, handle(r.Author.Name), r.State, r.Body, r.Head, iso(r.Created)}
592+ })
593+}
594+
595+func (s *Server) apiReviews(c *call) {
596+ if pull, ok := c.pull(); ok {
597+ paged(c, reviewViews(s.store.PRReviews(pull.ID)))
598+ }
599+}
600+
601+func (s *Server) apiReview(c *call) {
602+ pull, ok := c.pull()
603+ if !ok {
604+ return
605+ }
606+ var in struct {
607+ Verdict string `json:"verdict"`
608+ Body string `json:"body"`
609+ }
610+ if !c.decode(&in) {
611+ return
612+ }
613+ if err := s.judgePull(*c.user, pull, in.Verdict, in.Body); err != nil {
614+ c.err(err)
615+ return
616+ }
617+ reviews := s.store.PRReviews(pull.ID)
618+ c.reply(201, reviewViews(reviews[len(reviews)-1:])[0])
619+}
620+
621+func noteViews(reviews []Review) []noteView {
622+ return mapped(reviews, func(r Review) noteView {
623+ return noteView{r.ID, handle(r.Author.Name), r.Path, r.Side, r.Line, r.Body, r.Resolved, iso(r.Created)}
624+ })
625+}
626+
627+func (s *Server) apiNotes(c *call) {
628+ pull, ok := c.pull()
629+ if !ok {
630+ return
631+ }
632+ notes, err := s.store.Reviews(pull.ID)
633+ if err != nil {
634+ c.err(err)
635+ return
636+ }
637+ paged(c, noteViews(notes))
638+}
639+
640+func (s *Server) apiAddNote(c *call) {
641+ pull, ok := c.pull()
642+ if !ok {
643+ return
644+ }
645+ var in struct {
646+ Path string `json:"path"`
647+ Side string `json:"side"`
648+ Line int `json:"line"`
649+ Body string `json:"body"`
650+ }
651+ if !c.decode(&in) {
652+ return
653+ }
654+ if err := s.notePull(*c.user, pull, in.Path, in.Side, in.Line, in.Body); err != nil {
655+ c.err(err)
656+ return
657+ }
658+ notes, _ := s.store.Reviews(pull.ID)
659+ newest := slices.MaxFunc(notes, func(a, b Review) int { return int(a.ID - b.ID) })
660+ c.reply(201, noteViews([]Review{newest})[0])
661+}
662+
663+func (s *Server) apiPatchNote(c *call) {
664+ pull, ok := c.pull()
665+ if !ok {
666+ return
667+ }
668+ id, ok := c.id("id")
669+ if !ok {
670+ return
671+ }
672+ var in struct {
673+ Resolved bool `json:"resolved"`
674+ }
675+ if !c.decode(&in) {
676+ return
677+ }
678+ action := "unresolve"
679+ if in.Resolved {
680+ action = "resolve"
681+ }
682+ if err := s.touchNote(*c.user, pull, id, action); err != nil {
683+ c.err(err)
684+ return
685+ }
686+ c.done()
687+}
688+
689+func (s *Server) apiDeleteNote(c *call) {
690+ pull, ok := c.pull()
691+ if !ok {
692+ return
693+ }
694+ id, ok := c.id("id")
695+ if !ok {
696+ return
697+ }
698+ if err := s.touchNote(*c.user, pull, id, "delete"); err != nil {
699+ c.err(err)
700+ return
701+ }
702+ c.done()
703+}
704+
705+func (s *Server) apiRefreshPull(c *call) {
706+ pull, ok := c.pull()
707+ if !ok {
708+ return
709+ }
710+ if err := s.refreshPull(*c.user, pull); err != nil {
711+ c.err(err)
712+ return
713+ }
714+ pull, _ = s.store.Pull(pull.Repo, pull.Number)
715+ c.reply(200, s.pullView(pull, true))
716+}
717+
718+func (s *Server) apiMerge(c *call) {
719+ pull, ok := c.pull()
720+ if !ok {
721+ return
722+ }
723+ var in struct {
724+ Strategy string `json:"strategy"`
725+ Title string `json:"title"`
726+ Body string `json:"body"`
727+ }
728+ if !c.decode(&in) {
729+ return
730+ }
731+ if _, err := s.mergePull(*c.user, pull, in.Strategy, in.Title, in.Body); err != nil {
732+ c.err(err)
733+ return
734+ }
735+ pull, _ = s.store.Pull(pull.Repo, pull.Number)
736+ c.reply(200, s.pullView(pull, true))
737+}

forge/web.go modified

@@ -49,6 +49,8 @@ type Server struct {
4949 heavy *buckets
5050 uploads *limiter
5151 posts *buckets
52+ writes *buckets
53+ routes []apiRoute
5254 store *Store
5355 root string
5456 host string
@@ -84,17 +86,12 @@ type page struct {
8486 }
8587
8688 func newServer(store *Store, root, host, name string, sshUser string) *Server {
87- s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, sshHost: host, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour), posts: newBuckets(0.2, 60)}
89+ s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, sshHost: host, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour), posts: newBuckets(0.2, 60), writes: newBuckets(1, 120)}
8890 funcs := template.FuncMap{
89- "short": func(h string) string {
90- if len(h) > 10 {
91- return h[:10]
92- }
93- return h
94- },
95- "ago": ago,
96- "size": humanSize,
97- "date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") },
91+ "short": short,
92+ "ago": ago,
93+ "size": humanSize,
94+ "date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") },
9895 "lines": func(b []byte) []string {
9996 s := strings.TrimSuffix(string(b), "\n")
10097 return strings.Split(s, "\n")
@@ -171,6 +168,7 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server {
171168 }
172169 }
173170 s.version = hex.EncodeToString(sum.Sum(nil))[:12]
171+ s.routes = s.api()
174172 return s
175173 }
176174
@@ -307,10 +305,12 @@ func (s *Server) handler() http.Handler {
307305 mux.HandleFunc("GET /gists/new", s.newGistForm)
308306 mux.HandleFunc("POST /gists", s.postGist)
309307 mux.HandleFunc("/gist/", s.gistRoute)
310- mux.HandleFunc("POST /api/gists", s.apiCreateGist)
311- mux.HandleFunc("GET /api/gists/{id}", s.apiGist)
308+ mux.HandleFunc("/api", s.serveAPI)
309+ mux.HandleFunc("/api/", s.serveAPI)
312310 mux.HandleFunc("/", s.route)
313311 cop := http.NewCrossOriginProtection()
312+ cop.AddInsecureBypassPattern("/api")
313+ cop.AddInsecureBypassPattern("/api/")
314314 site := secure(s.throttle(cop.Handler(mux)))
315315 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
316316 if bareHost(r.Host) == s.rawHost() {
@@ -477,40 +477,17 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
477477 if u == nil {
478478 return
479479 }
480- owner := *u
481- if o := r.FormValue("owner"); o != "" && o != u.Name {
482- org, err := s.store.UserByName(o)
483- if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
484- s.fail(w, r, 403, "You are not a member of that organization")
485- return
486- }
487- owner = org
488- }
489- if s.uploads.blocked("create:" + u.Name) {
490- s.fail(w, r, 429, "Too many new repositories, try again later")
491- return
492- }
493- s.uploads.fail("create:" + u.Name)
494480 name := strings.TrimSpace(r.FormValue("name"))
495- repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags"))
496- if err != nil {
481+ repo, err := s.createRepo(*u, r.FormValue("owner"), name, r.FormValue("description"), visibility(r.FormValue("visibility")), r.FormValue("tags"))
482+ if err != nil && problemCode(err) == 400 {
497483 w.WriteHeader(400)
498- s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error(), Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Suggest": suggestName(), "Owner": owner.Name, "Name": name, "Description": r.FormValue("description"), "Tags": r.FormValue("tags")}})
484+ s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error(), Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Suggest": suggestName(), "Owner": r.FormValue("owner"), "Name": name, "Description": r.FormValue("description"), "Tags": r.FormValue("tags")}})
499485 return
500486 }
501- dir := s.repoDir(repo)
502- err = os.MkdirAll(filepath.Dir(dir), 0o755)
503- if err == nil {
504- err = initRepo(dir)
505- }
506487 if err != nil {
507- log.Printf("create %s: %v", repo.Path(), err)
508- os.RemoveAll(dir)
509- s.store.DeleteRepo(repo.ID)
510- s.fail(w, r, 500, "Could not create the repository")
488+ s.fail(w, r, problemCode(err), err.Error())
511489 return
512490 }
513- installHook(s.root, s.host, dir)
514491 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
515492 }
516493
@@ -778,28 +755,29 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
778755 if u == nil {
779756 return
780757 }
781- links := func(vals []string) string {
782- var out []string
783- for _, v := range vals {
784- if v = strings.TrimSpace(v); v != "" && len(v) <= 200 && !strings.ContainsAny(v, "\r\n") {
785- out = append(out, v)
786- }
787- }
788- return strings.Join(out, "\n")
758+ p := cleanProfile(r.FormValue("display"), r.FormValue("bio"), r.FormValue("location"), r.FormValue("workplace"), r.Form["website"])
759+ if err := s.store.UpdateProfile(u.ID, p); err != nil {
760+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
761+ return
789762 }
790- field := func(name string, max int) string {
791- v := strings.TrimSpace(r.FormValue(name))
763+ http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
764+}
765+
766+func cleanProfile(display, bio, location, workplace string, websites []string) Profile {
767+ field := func(v string, max int) string {
768+ v = strings.TrimSpace(v)
792769 if len(v) > max {
793770 v = v[:max]
794771 }
795772 return v
796773 }
797- p := Profile{Display: field("display", 100), Bio: field("bio", 4000), Location: field("location", 100), Workplace: field("workplace", 100), Website: links(r.Form["website"])}
798- if err := s.store.UpdateProfile(u.ID, p); err != nil {
799- http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
800- return
774+ var links []string
775+ for _, v := range websites {
776+ if v = strings.TrimSpace(v); v != "" && len(v) <= 200 && !strings.ContainsAny(v, "\r\n") {
777+ links = append(links, v)
778+ }
801779 }
802- http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
780+ return Profile{Display: field(display, 100), Bio: field(bio, 4000), Location: field(location, 100), Workplace: field(workplace, 100), Website: strings.Join(links, "\n")}
803781 }
804782
805783 func (s *Server) avatarPath(id int64) string {
@@ -891,7 +869,11 @@ func (s *Server) addToken(w http.ResponseWriter, r *http.Request) {
891869 if u == nil {
892870 return
893871 }
894- token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title")))
872+ scope := r.FormValue("scope")
873+ if scope == "" {
874+ scope = scopeAll
875+ }
876+ token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title")), scope)
895877 if err != nil {
896878 http.Redirect(w, r, "/settings/tokens?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
897879 return
@@ -1637,20 +1619,19 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
16371619 }
16381620
16391621 func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
1640- rp, _, _ := s.repoPage(r, repo, "")
1641- if !rp.IsOwner {
1642- s.fail(w, r, 403, "Not your repository")
1622+ u := s.current(r)
1623+ if err := s.ownRepo(u, repo); err != nil {
1624+ s.fail(w, r, 403, err.Error())
16431625 return
16441626 }
16451627 if r.FormValue("confirm") != repo.Name {
16461628 http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther)
16471629 return
16481630 }
1649- if err := s.store.DeleteRepo(repo.ID); err != nil {
1650- s.fail(w, r, 500, err.Error())
1631+ if err := s.removeRepo(*u, repo); err != nil {
1632+ s.fail(w, r, problemCode(err), err.Error())
16511633 return
16521634 }
1653- os.RemoveAll(s.repoDir(repo))
16541635 http.Redirect(w, r, "/~"+repo.Owner.Name, http.StatusSeeOther)
16551636 }
16561637
@@ -1724,21 +1705,11 @@ func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, a
17241705 }
17251706 back := "/~" + org.Name + "/settings"
17261707 if r.Method == "POST" && action == "members" {
1727- name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")
1728- target, err := s.store.UserByName(name)
1729- switch {
1730- case err != nil || s.store.IsOrg(target.ID):
1731- back += "?error=No+such+user"
1732- case r.FormValue("remove") != "":
1733- if len(s.store.Members(org.ID)) == 1 {
1734- back += "?error=An+organization+needs+at+least+one+member"
1735- } else {
1736- s.store.SetMember(org.ID, target.ID, false)
1737- }
1738- default:
1739- s.store.SetMember(org.ID, target.ID, true)
1708+ err := s.setMember(*u, org, r.FormValue("name"), r.FormValue("remove") == "")
1709+ if err != nil {
1710+ err = refuse(400, err.Error())
17401711 }
1741- http.Redirect(w, r, back, http.StatusSeeOther)
1712+ s.settle(w, r, back, err)
17421713 return
17431714 }
17441715 if r.Method != "GET" || action != "settings" {

web/static/style.css modified

@@ -583,3 +583,10 @@ nav.gisttabs,nav.gistfilter{border-bottom:1px solid var(--bar);margin-bottom:.8r
583583 .gistforks{list-style:none;padding:0;margin:0 0 1rem}
584584 .revision{margin-bottom:1.2rem}
585585 h4.rule{border-bottom:1px solid var(--bar);padding-bottom:.2rem;margin:1.2rem 0 .6rem}
586+nav.pager{display:flex;gap:.5rem;margin:1rem 0}
587+.apiroutes{width:100%}
588+.apiroutes td{vertical-align:top;padding:.3rem 1rem .3rem 0;border-bottom:1px solid var(--line)}
589+.apiroutes td.verb{width:4.5rem;font-family:"DejaVu Sans Mono",Menlo,Consolas,monospace;font-size:12px;font-weight:600;white-space:nowrap}
590+.apiroutes td.route{white-space:nowrap;width:22rem}
591+.apiroutes td.needs{white-space:nowrap;width:5.5rem}
592+.apiwrap{overflow-x:auto}

web/templates/admin.tmpl modified

@@ -64,6 +64,20 @@
6464 <h3 class="rule">Usage</h3>
6565 <table class="keys">{{range $d.Owners}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td>{{size .Used}}</td><td class="muted">{{if $d.Quota}}{{.Pct}}% of {{$d.QuotaText}}{{end}}</td></tr>{{else}}<tr><td class="muted">No repositories yet.</td></tr>{{end}}</table>
6666 {{end}}
67+{{if eq $d.Section "gists"}}
68+<h3 class="rule">Gists</h3>
69+<p class="muted">Every gist, secret ones included, reported ones first and then the largest. Size counts the repository on disk.</p>
70+<table class="keys">{{range $d.Gists}}<tr>
71+<td><a href="/gist/{{.ID}}">{{if .Description}}{{.Description}}{{else}}{{short .ID}}{{end}}</a></td>
72+<td><a href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a></td>
73+<td class="muted">{{if .Secret}}secret{{else}}public{{end}}</td>
74+<td class="muted">{{.Files}} file{{if ne .Files 1}}s{{end}}</td>
75+<td class="muted">{{size .Size}}</td>
76+<td>{{if .Reports}}<a href="/admin?s=reports"><span class="tag">{{.Reports}} report{{if ne .Reports 1}}s{{end}}</span></a>{{end}}</td>
77+<td class="when">updated {{ago .Updated}}</td>
78+<td><form method="post" action="/gist/{{.ID}}/delete" class="inline"><button class="btn small danger">Delete</button></form></td>
79+</tr>{{else}}<tr><td class="muted">No gists yet.</td></tr>{{end}}</table>
80+{{end}}
6781 {{if eq $d.Section "reports"}}
6882 <h3 class="rule">Reports</h3>
6983 <nav class="tabs small"><a href="/admin?s=reports"{{if eq $d.State "open"}} class="active"{{end}}>open</a><a href="/admin?s=reports&state=resolved"{{if eq $d.State "resolved"}} class="active"{{end}}>resolved</a><a href="/admin?s=reports&state=dismissed"{{if eq $d.State "dismissed"}} class="active"{{end}}>dismissed</a></nav>

web/templates/api.tmpl added

@@ -0,0 +1,12 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+<h3>API</h3>
4+<p>Every page on this site has a JSON twin under <code>{{$d.Base}}</code>, so <code>/~you/project/issues/4</code> is read from <code>/api/~you/project/issues/4</code>. Send a token from <a href="/settings/tokens">settings</a> as <code>Authorization: Bearer gp_…</code> or as the password of HTTP basic auth. Lists answer <code>{items, total, next}</code> and take <code>?from=</code> and <code>?limit=</code>, errors answer <code>{status, error}</code>, and fetching this address without asking for HTML returns the same table as JSON.</p>
5+<pre>curl -H "Authorization: Bearer $TOKEN" {{$d.Base}}/me
6+curl -H "Authorization: Bearer $TOKEN" -X POST -d '{"title":"crash on empty input"}' {{$d.Base}}/~you/project/issues
7+curl -H "Authorization: Bearer $TOKEN" --data-binary @notes.txt '{{$d.Base}}/gists?name=notes.txt&amp;visibility=public'</pre>
8+<p class="muted">Needs: <code>none</code> is open to anyone, <code>user</code> takes any credential, <code>write</code> a token scoped <code>all</code>, <code>gist</code> a token scoped <code>all</code> or <code>gist</code>, <code>password</code> a password or a session and <code>admin</code> a site administrator.</p>
9+{{range $d.Groups}}<h4 class="rule">{{.Name}}</h4>
10+<div class="apiwrap"><table class="apiroutes">{{range .Routes}}<tr><td class="verb">{{.Method}}</td><td class="route"><code>{{.Path}}</code></td><td class="needs"><span class="tag">{{.Needs}}</span></td><td>{{.About}}</td></tr>{{end}}</table></div>
11+{{end}}
12+{{end}}

web/templates/gistlog.tmpl modified

@@ -2,7 +2,7 @@
22 {{$d := .Data}}
33 {{$g := $d.Gist}}
44 <div class="gisthead"><div><h2><a href="/~{{$g.Owner.Name}}">~{{$g.Owner.Name}}</a> / <a href="/gist/{{$g.ID}}">gist {{short $g.ID}}</a></h2></div></div>
5-<nav class="tabs small gisttabs"><a href="/gist/{{$g.ID}}">code</a><a class="active" href="/gist/{{$g.ID}}/revisions">revisions <span class="count">{{len $d.Revisions}}</span></a></nav>
5+<nav class="tabs small gisttabs"><a href="/gist/{{$g.ID}}">code</a><a class="active" href="/gist/{{$g.ID}}/revisions">revisions <span class="count">{{$d.Total}}</span></a></nav>
66 {{range $d.Revisions}}{{$c := .Commit}}<div class="revision">
77 <p><a class="hash" href="/gist/{{$g.ID}}?rev={{$c.Hash}}">{{short $c.Hash}}</a> {{template "seal" $c}} {{template "who" $c}} <span class="muted">{{$c.Subject}} · {{ago $c.Time}}</span></p>
88 {{range .Files}}<section class="diff">
@@ -13,4 +13,5 @@
1313 </table>{{end}}
1414 </section>{{end}}
1515 </div>{{end}}
16+{{if or $d.Next $d.Paged}}<nav class="pager">{{if $d.Paged}}<a class="btn" href="/gist/{{$g.ID}}/revisions{{if $d.Prev}}?from={{$d.Prev}}{{end}}">◂ Newer</a>{{end}}{{with $d.Next}}<a class="btn" href="/gist/{{$g.ID}}/revisions?from={{.}}">Older ▸</a>{{end}}</nav>{{end}}
1617 {{end}}

web/templates/search.tmpl modified

@@ -9,6 +9,7 @@
99 <nav class="facetlist">
1010 <a href="?q={{$d.Query}}&type=repos"{{if eq $d.Type "repos"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2.5h8.5a1 1 0 0 1 1 1v9.5H4.5A1.5 1.5 0 0 1 3 11.5z"/><path d="M3 11.5A1.5 1.5 0 0 1 4.5 10h8"/></svg><span class="fname">Repositories</span><span class="count">{{len $d.Repos}}</span></a>
1111 <a href="?q={{$d.Query}}&type=users"{{if eq $d.Type "users"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="2.7"/><path d="M2.8 13.5c.6-2.6 2.7-4 5.2-4s4.6 1.4 5.2 4"/></svg><span class="fname">Users</span><span class="count">{{len $d.Users}}</span></a>
12+<a href="?q={{$d.Query}}&type=gists"{{if eq $d.Type "gists"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.5h5l3 3v8H4z"/><path d="M9 2.5v3h3M6 9h4M6 11h3"/></svg><span class="fname">Gists</span><span class="count">{{len $d.Gists}}</span></a>
1213 </nav>
1314 </section>
1415 {{if $d.Languages}}<section class="facetbox">
@@ -22,6 +23,9 @@
2223 <div class="resulthead"><h3 class="plain">{{len $d.Repos}} repositor{{if eq (len $d.Repos) 1}}y{{else}}ies{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}</h3>
2324 <nav class="sorts"><a href="?q={{$d.Query}}&type=repos{{with $d.Lang}}&lang={{.}}{{end}}"{{if not $d.Sort}} class="active"{{end}}>Best match</a><a href="?q={{$d.Query}}&type=repos&sort=updated{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "updated"}} class="active"{{end}}>Recently updated</a><a href="?q={{$d.Query}}&type=repos&sort=stars{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "stars"}} class="active"{{end}}>Most stars</a></nav></div>
2425 {{range $d.Repos}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{with .Description}}<p>{{.}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a>{{end}}</p>{{end}}<p class="meta">{{with .Language}}<span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span>{{end}}{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No repositories match. Try a shorter query or clear the language filter.</p>{{end}}
26+{{else if eq $d.Type "gists"}}
27+<div class="resulthead"><h3 class="plain">{{len $d.Gists}} gist{{if ne (len $d.Gists) 1}}s{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}</h3></div>
28+{{range $d.Gists}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/gist/{{.ID}}">{{if .Description}}{{.Description}}{{else}}{{index .Files 0}}{{end}}</a></h4><p class="tags">{{range .Files}}<code>{{.}}</code> {{end}}</p><p class="meta">{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No public gists match.</p>{{end}}
2529 {{else}}
2630 <div class="resulthead"><h3 class="plain">{{len $d.Users}} user{{if ne (len $d.Users) 1}}s{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} writing {{.}}{{end}}</h3></div>
2731 {{range $d.Users}}<div class="hit user{{if .Org}} org{{end}}"><a href="/~{{.Name}}" class="hitavatar"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>{{if .Org}}Since{{else}}Joined{{end}} {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}}

web/templates/tokens.tmpl modified

@@ -1,10 +1,13 @@
11 {{define "content"}}
22 <div class="panel"><h3>Personal access tokens</h3>
3-<p>Use a token as the password when pushing over HTTPS, for example from CI. Tokens are shown once and stored hashed.</p>
3+<p>Use a token as the password when pushing over HTTPS, or send it as <code>Authorization: Bearer</code> to the <a href="/api">API</a>. Tokens are shown once and stored hashed.</p>
44 {{with .Data.NewToken}}<p class="notice">New token, shown once: <code>{{.}}</code></p>{{end}}
5-{{if .Data.Tokens}}<table class="keys">{{range .Data.Tokens}}<tr><td>{{.Title}}</td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/tokens/{{.ID}}/delete"><button class="btn danger">Revoke</button></form></td></tr>{{end}}</table>{{else}}<p>You have not created any personal access tokens.</p>{{end}}
5+{{if .Data.Tokens}}<table class="keys">{{range .Data.Tokens}}<tr><td>{{.Title}}</td><td><span class="tag">{{.Scope}}</span></td><td class="when">created {{ago .Created}}</td><td class="when">{{if .Used.IsZero}}never used{{else}}used {{ago .Used}}{{end}}</td><td><form method="post" action="/settings/tokens/{{.ID}}/delete"><button class="btn danger">Revoke</button></form></td></tr>{{end}}</table>{{else}}<p>You have not created any personal access tokens.</p>{{end}}
66 <form method="post" action="/settings/tokens" class="stack">
7-<label>Title <input name="title" placeholder="ci"></label>
7+<label>Title <input name="title" placeholder="ci" maxlength="100"></label>
8+<label class="radio"><input type="radio" name="scope" value="all" checked> All<small>Push, pull and every API call your account may make</small></label>
9+<label class="radio"><input type="radio" name="scope" value="read"> Read<small>Clone, fetch and read the API, nothing that changes state</small></label>
10+<label class="radio"><input type="radio" name="scope" value="gist"> Gist<small>Read everything and create, edit or push to your gists only</small></label>
811 <div class="actions left"><button class="btn primary">Generate new token ▸</button></div>
912 </form></div>
1013 {{end}}