feat(api): serve a json twin of every page under /api with scoped tokens
29 files changed, +4423 −600
- forge/actions.go added +575 −0
- forge/admin.go modified +43 −11
- forge/api.go added +444 −0
- forge/api_test.go added +301 −0
- forge/edit.go modified +2 −18
- forge/extra.go modified +14 −54
- forge/follow.go modified +2 −4
- forge/gist.go modified +1 −1
- forge/gist_test.go modified +19 −7
- forge/gistweb.go modified +475 −120
- forge/inbox.go modified +13 −0
- forge/issues.go modified +11 −51
- forge/labels.go modified +4 −14
- forge/limit.go modified +1 −1
- forge/moderation.go modified +20 −21
- forge/people.go added +556 −0
- forge/pull.go modified +15 −187
- forge/repos.go added +969 −0
- forge/search.go modified +66 −11
- forge/smart.go modified +12 −7
- forge/store.go modified +52 −14
- forge/threads.go added +737 −0
- forge/web.go modified +46 −75
- web/static/style.css modified +7 −0
- web/templates/admin.tmpl modified +14 −0
- web/templates/api.tmpl added +12 −0
- web/templates/gistlog.tmpl modified +2 −1
- web/templates/search.tmpl modified +4 −0
- web/templates/tokens.tmpl modified +6 −3
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 { | ||
| 166 | 166 | } |
| 167 | 167 | |
| 168 | 168 | 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"}, | |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | 172 | type siteStats struct { |
| @@ -238,6 +238,8 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | ||
| 238 | 238 | state = "open" |
| 239 | 239 | } |
| 240 | 240 | data["Reports"], data["State"] = s.store.Reports(state), state |
| 241 | + case "gists": | |
| 242 | + data["Gists"] = s.gistLedger() | |
| 241 | 243 | case "storage": |
| 242 | 244 | quota := readQuota(s.root) |
| 243 | 245 | type owner struct { |
| @@ -269,6 +271,44 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | ||
| 269 | 271 | s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: data}) |
| 270 | 272 | } |
| 271 | 273 | |
| 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 | + | |
| 272 | 312 | func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { |
| 273 | 313 | section := r.FormValue("s") |
| 274 | 314 | back := "/admin?s=" + url.QueryEscape(section) |
| @@ -359,19 +399,11 @@ func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) { | ||
| 359 | 399 | return |
| 360 | 400 | } |
| 361 | 401 | 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) | |
| 370 | 403 | if err != nil { |
| 371 | - w.WriteHeader(400) | |
| 404 | + w.WriteHeader(problemCode(err)) | |
| 372 | 405 | s.render(w, r, "neworg", page{Title: "New organization", User: u, Error: err.Error(), Data: map[string]any{"Name": name}}) |
| 373 | 406 | return |
| 374 | 407 | } |
| 375 | - s.limiter.fail("org:" + u.Name) | |
| 376 | 408 | http.Redirect(w, r, "/~"+org.Name+"/settings", http.StatusSeeOther) |
| 377 | 409 | } |
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, | ||
| 88 | 88 | http.Redirect(w, r, "/login", http.StatusSeeOther) |
| 89 | 89 | return rp, g, false |
| 90 | 90 | } |
| 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()) | |
| 109 | 93 | return rp, g, false |
| 110 | 94 | } |
| 111 | 95 | rp.Tab = "tree" |
forge/extra.go modified
| @@ -12,7 +12,6 @@ import ( | ||
| 12 | 12 | "net/http" |
| 13 | 13 | "os" |
| 14 | 14 | "os/exec" |
| 15 | - "path/filepath" | |
| 16 | 15 | "slices" |
| 17 | 16 | "strconv" |
| 18 | 17 | "strings" |
| @@ -25,77 +24,38 @@ func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 25 | 24 | if u == nil { |
| 26 | 25 | return |
| 27 | 26 | } |
| 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))) | |
| 35 | 28 | } |
| 36 | 29 | |
| 37 | 30 | 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 { | |
| 40 | 33 | s.fail(w, r, 403, "Not your repository") |
| 41 | 34 | return |
| 42 | 35 | } |
| 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()) | |
| 59 | 39 | } |
| 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) | |
| 62 | 41 | } |
| 63 | 42 | |
| 64 | 43 | 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) | |
| 70 | 45 | 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()) | |
| 78 | 48 | return |
| 79 | 49 | } |
| 80 | 50 | 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")) | |
| 82 | 52 | return |
| 83 | 53 | } |
| 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())) | |
| 94 | 57 | return |
| 95 | 58 | } |
| 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())) | |
| 99 | 59 | http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther) |
| 100 | 60 | } |
| 101 | 61 | |
forge/follow.go modified
| @@ -50,12 +50,10 @@ func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) { | ||
| 50 | 50 | return |
| 51 | 51 | } |
| 52 | 52 | 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()) | |
| 56 | 55 | return |
| 57 | 56 | } |
| 58 | - s.store.Follow(u.ID, owner.ID, on) | |
| 59 | 57 | } |
| 60 | 58 | http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther) |
| 61 | 59 | } |
forge/gist.go modified
| @@ -124,7 +124,7 @@ func (s *Store) StarredGists(user int64) ([]Gist, error) { | ||
| 124 | 124 | |
| 125 | 125 | func (s *Store) GistCount(owner int64, withSecret bool) int { |
| 126 | 126 | 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) | |
| 128 | 128 | return n |
| 129 | 129 | } |
| 130 | 130 | |
forge/gist_test.go modified
| @@ -146,35 +146,47 @@ func TestGists(t *testing.T) { | ||
| 146 | 146 | t.Error("nested tree accepted") |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | - token, err := s.store.CreateToken(alice.ID, "cli") | |
| 149 | + token, err := s.store.CreateToken(alice.ID, "cli", scopeAll) | |
| 150 | 150 | if err != nil { |
| 151 | 151 | t.Fatal(err) |
| 152 | 152 | } |
| 153 | - body := `{"description":"api","public":true,"files":{"a.md":{"content":"# hi"}}}` | |
| 153 | + body := `{"description":"api","visibility":"public","files":[{"name":"a.md","content":"# hi"}]}` | |
| 154 | 154 | r := httptest.NewRequest("POST", "/api/gists", strings.NewReader(body)) |
| 155 | 155 | r.Header.Set("Content-Type", "application/json") |
| 156 | - r.SetBasicAuth("alice", token) | |
| 156 | + r.Header.Set("Authorization", "Bearer "+token) | |
| 157 | 157 | r.RemoteAddr = "192.0.2.1:1000" |
| 158 | 158 | w := httptest.NewRecorder() |
| 159 | 159 | h.ServeHTTP(w, r) |
| 160 | 160 | var made struct { |
| 161 | 161 | ID string |
| 162 | - Files map[string]struct{ Content string } | |
| 162 | + Files []struct{ Name, Content string } | |
| 163 | 163 | } |
| 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" { | |
| 165 | 165 | t.Fatalf("api create: %d %s", w.Code, w.Body.String()) |
| 166 | 166 | } |
| 167 | 167 | if w := get(h, "/api/gists/"+made.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"a.md"`) { |
| 168 | 168 | t.Errorf("api get: %d", w.Code) |
| 169 | 169 | } |
| 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")) | |
| 171 | 171 | r.RemoteAddr = "192.0.2.1:1000" |
| 172 | 172 | w = httptest.NewRecorder() |
| 173 | 173 | h.ServeHTTP(w, r) |
| 174 | 174 | if w.Code != 401 { |
| 175 | 175 | t.Errorf("anonymous api create: %d", w.Code) |
| 176 | 176 | } |
| 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 | + } | |
| 178 | 190 | if w := post(h, "/gist/"+id+"/delete", nil, bc); w.Code != 403 { |
| 179 | 191 | t.Errorf("non-owner deleted: %d", w.Code) |
| 180 | 192 | } |
forge/gistweb.go modified
| @@ -1,21 +1,19 @@ | ||
| 1 | 1 | package forge |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "encoding/json" | |
| 4 | + "encoding/base64" | |
| 5 | 5 | "errors" |
| 6 | - "fmt" | |
| 7 | 6 | "html/template" |
| 8 | 7 | "io" |
| 9 | 8 | "mime" |
| 10 | 9 | "net/http" |
| 11 | - "net/url" | |
| 12 | 10 | "os" |
| 13 | 11 | "path/filepath" |
| 14 | 12 | "regexp" |
| 15 | - "sort" | |
| 13 | + "slices" | |
| 16 | 14 | "strconv" |
| 17 | 15 | "strings" |
| 18 | - "time" | |
| 16 | + "unicode/utf8" | |
| 19 | 17 | ) |
| 20 | 18 | |
| 21 | 19 | var fullOID = regexp.MustCompile(`^[0-9a-f]{40}$`) |
| @@ -134,48 +132,27 @@ func (s *Server) gistRoute(w http.ResponseWriter, r *http.Request) { | ||
| 134 | 132 | s.store.StarGist(user.ID, gist.ID, !s.store.GistStarred(user.ID, gist.ID)) |
| 135 | 133 | http.Redirect(w, r, back, http.StatusSeeOther) |
| 136 | 134 | 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) | |
| 142 | 136 | if err != nil { |
| 143 | - s.fail(w, r, 400, err.Error()) | |
| 137 | + s.fail(w, r, problemCode(err), err.Error()) | |
| 144 | 138 | return |
| 145 | 139 | } |
| 146 | 140 | http.Redirect(w, r, "/gist/"+fork.ID, http.StatusSeeOther) |
| 147 | 141 | 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()) | |
| 159 | 144 | return |
| 160 | 145 | } |
| 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) | |
| 163 | 148 | return |
| 164 | 149 | } |
| 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"))) | |
| 166 | 153 | 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)) | |
| 179 | 156 | default: |
| 180 | 157 | s.fail(w, r, 404, "Page not found") |
| 181 | 158 | } |
| @@ -228,17 +205,40 @@ func (s *Server) gistPage(w http.ResponseWriter, r *http.Request, gist Gist, use | ||
| 228 | 205 | |
| 229 | 206 | func (s *Server) gistRevisions(w http.ResponseWriter, r *http.Request, gist Gist, user *User) { |
| 230 | 207 | 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)) | |
| 232 | 211 | type revision struct { |
| 233 | 212 | Commit Commit |
| 234 | 213 | Files []File |
| 235 | 214 | } |
| 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 | + } | |
| 236 | 224 | revs := make([]revision, 0, len(commits)) |
| 237 | 225 | for _, c := range commits { |
| 238 | 226 | files, _ := g.Diff(c.Hash) |
| 239 | 227 | revs = append(revs, revision{Commit: c, Files: files}) |
| 240 | 228 | } |
| 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 | |
| 242 | 242 | } |
| 243 | 243 | |
| 244 | 244 | 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 | ||
| 327 | 327 | http.Error(w, "too many failed attempts", 429) |
| 328 | 328 | return |
| 329 | 329 | } |
| 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 | + } | |
| 331 | 335 | if !ok || u.ID != gist.Owner.ID { |
| 332 | 336 | if _, _, given := r.BasicAuth(); given { |
| 333 | 337 | s.limiter.fail(ip) |
| @@ -434,6 +438,7 @@ func (s *Server) serveGistRaw(w http.ResponseWriter, r *http.Request) { | ||
| 434 | 438 | h.Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": file})) |
| 435 | 439 | if pinned { |
| 436 | 440 | h.Set("Cache-Control", "public, max-age=31536000, immutable") |
| 441 | + h.Set("Cloudflare-CDN-Cache-Control", "max-age=3600") | |
| 437 | 442 | } else { |
| 438 | 443 | h.Set("Cache-Control", "public, max-age=60") |
| 439 | 444 | } |
| @@ -493,123 +498,473 @@ func (s *Server) gistEmbed(w http.ResponseWriter, r *http.Request, gist Gist) { | ||
| 493 | 498 | }) |
| 494 | 499 | } |
| 495 | 500 | |
| 496 | -type apiGistFile struct { | |
| 497 | - Filename string `json:"filename"` | |
| 501 | +type gistFileDoc struct { | |
| 502 | + Name string `json:"name"` | |
| 498 | 503 | Size int64 `json:"size"` |
| 499 | - RawURL string `json:"raw_url"` | |
| 504 | + Raw string `json:"raw"` | |
| 505 | + Encoding string `json:"encoding,omitempty"` | |
| 500 | 506 | 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 { | |
| 522 | 530 | g := s.gistGit(gist) |
| 523 | 531 | 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 | + } | |
| 524 | 545 | entries, _ := g.gistTree(head) |
| 525 | 546 | 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 | + } | |
| 531 | 558 | } |
| 532 | - out.Files[e.Name] = f | |
| 559 | + out.Files = append(out.Files, f) | |
| 533 | 560 | } |
| 534 | 561 | return out |
| 535 | 562 | } |
| 536 | 563 | |
| 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 | |
| 543 | 572 | } |
| 544 | 573 | |
| 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") | |
| 551 | 577 | } |
| 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) | |
| 554 | 579 | } |
| 555 | 580 | |
| 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) | |
| 560 | 699 | return |
| 561 | 700 | } |
| 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() | |
| 563 | 706 | 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"}) | |
| 569 | 707 | return |
| 570 | 708 | } |
| 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 | + } | |
| 572 | 717 | if err != nil { |
| 573 | - writeJSON(w, 413, map[string]string{"message": "request too large"}) | |
| 718 | + c.err(err) | |
| 574 | 719 | return |
| 575 | 720 | } |
| 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 | |
| 579 | 726 | 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 { | |
| 582 | 729 | 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"` | |
| 585 | 733 | Content string `json:"content"` |
| 586 | 734 | } `json:"files"` |
| 587 | 735 | } |
| 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) { | |
| 590 | 737 | return |
| 591 | 738 | } |
| 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}) | |
| 595 | 742 | } |
| 596 | - sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) | |
| 597 | 743 | } 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 | + } | |
| 602 | 825 | } |
| 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) | |
| 604 | 905 | 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 { | |
| 606 | 963 | return |
| 607 | 964 | } |
| 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) | |
| 612 | 967 | return |
| 613 | 968 | } |
| 614 | - writeJSON(w, 201, s.apiView(gist)) | |
| 969 | + c.done() | |
| 615 | 970 | } |
forge/inbox.go modified
| @@ -134,6 +134,19 @@ func (s *Server) inbox(w http.ResponseWriter, r *http.Request) { | ||
| 134 | 134 | s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID, 100)}}) |
| 135 | 135 | } |
| 136 | 136 | |
| 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 | + | |
| 137 | 150 | func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) { |
| 138 | 151 | u := s.require(w, r) |
| 139 | 152 | if u == nil { |
forge/issues.go modified
| @@ -4,7 +4,6 @@ import ( | ||
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | 6 | "fmt" |
| 7 | - "html/template" | |
| 8 | 7 | "net/http" |
| 9 | 8 | "strconv" |
| 10 | 9 | "strings" |
| @@ -182,28 +181,15 @@ func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo) | ||
| 182 | 181 | http.Redirect(w, r, "/login", http.StatusSeeOther) |
| 183 | 182 | return |
| 184 | 183 | } |
| 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 { | |
| 199 | 186 | w.WriteHeader(400) |
| 200 | 187 | 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")}}) |
| 201 | 188 | return |
| 202 | 189 | } |
| 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 | |
| 207 | 193 | } |
| 208 | 194 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), http.StatusSeeOther) |
| 209 | 195 | } |
| @@ -224,55 +210,29 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s | ||
| 224 | 210 | } |
| 225 | 211 | user := rp.User |
| 226 | 212 | canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID) |
| 213 | + back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number) | |
| 227 | 214 | if user != nil && r.Method == "GET" { |
| 228 | 215 | s.store.ReadThread(user.ID, repo.ID, "issue", issue.Number) |
| 229 | 216 | } |
| 230 | - back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number) | |
| 231 | 217 | if r.Method == "POST" { |
| 232 | 218 | if user == nil { |
| 233 | 219 | http.Redirect(w, r, "/login", http.StatusSeeOther) |
| 234 | 220 | return |
| 235 | 221 | } |
| 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 | |
| 238 | 223 | switch action { |
| 239 | 224 | 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")) | |
| 258 | 226 | case "meta": |
| 259 | 227 | s.setMeta(w, r, repo, "issue", issue.ID, back) |
| 260 | 228 | 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") | |
| 271 | 231 | default: |
| 272 | 232 | s.fail(w, r, 404, "Page not found") |
| 273 | 233 | return |
| 274 | 234 | } |
| 275 | - http.Redirect(w, r, back, http.StatusSeeOther) | |
| 235 | + s.settle(w, r, back, err) | |
| 276 | 236 | return |
| 277 | 237 | } |
| 278 | 238 | 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 | ||
| 211 | 211 | } |
| 212 | 212 | |
| 213 | 213 | 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) { | |
| 216 | 216 | s.fail(w, r, 403, "Only collaborators can change labels and assignees") |
| 217 | 217 | return |
| 218 | 218 | } |
| 219 | 219 | r.ParseForm() |
| 220 | 220 | 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"])) | |
| 222 | 222 | } |
| 223 | 223 | 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"])) | |
| 235 | 225 | } |
| 236 | 226 | http.Redirect(w, r, back, http.StatusSeeOther) |
| 237 | 227 | } |
forge/limit.go modified
| @@ -131,7 +131,7 @@ func (b *buckets) allow(key string) bool { | ||
| 131 | 131 | func heavyRequest(r *http.Request) bool { |
| 132 | 132 | p := r.URL.Path |
| 133 | 133 | 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/") || | |
| 135 | 135 | strings.Contains(p, "/interdiff") || (strings.HasSuffix(p, "/search") && q.Get("q") != "") || |
| 136 | 136 | (strings.Contains(p, "/log") && q.Get("q") != "") || p == "/login" || p == "/register" |
| 137 | 137 | } |
forge/moderation.go modified
| @@ -313,12 +313,19 @@ func (s *Server) block(w http.ResponseWriter, r *http.Request) { | ||
| 313 | 313 | |
| 314 | 314 | func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, fail func(string)) bool { |
| 315 | 315 | 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 { | |
| 316 | 324 | rep, err := s.store.Report(id) |
| 317 | 325 | if err != nil { |
| 318 | - fail("No such report") | |
| 319 | - return false | |
| 326 | + return refuse(404, "No such report") | |
| 320 | 327 | } |
| 321 | - switch r.FormValue("do") { | |
| 328 | + switch do { | |
| 322 | 329 | case "dismiss": |
| 323 | 330 | s.store.CloseReports(rep.Kind, rep.Target, "dismissed", admin.ID) |
| 324 | 331 | case "resolve": |
| @@ -326,46 +333,38 @@ func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, f | ||
| 326 | 333 | case "ban": |
| 327 | 334 | name := s.reportAuthor(rep) |
| 328 | 335 | if name == "" { |
| 329 | - fail("Could not tell who wrote that") | |
| 330 | - return false | |
| 336 | + return refuse(409, "Could not tell who wrote that") | |
| 331 | 337 | } |
| 332 | 338 | if name == admin.Name { |
| 333 | - fail("You cannot ban yourself") | |
| 334 | - return false | |
| 339 | + return refuse(409, "You cannot ban yourself") | |
| 335 | 340 | } |
| 336 | 341 | if err := s.store.SetBanned(name, true); err != nil { |
| 337 | - fail("No such account") | |
| 338 | - return false | |
| 342 | + return refuse(404, "No such account") | |
| 339 | 343 | } |
| 340 | 344 | s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) |
| 341 | 345 | case "pages": |
| 342 | 346 | 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") | |
| 345 | 348 | } |
| 346 | 349 | s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) |
| 347 | 350 | 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") | |
| 351 | 353 | } |
| 352 | 354 | owner, name, _ := strings.Cut(rep.Target, "/") |
| 353 | 355 | repo, err := s.store.RepoByPath(owner, name) |
| 354 | 356 | if err != nil { |
| 355 | - fail("That repository is already gone") | |
| 356 | - return false | |
| 357 | + return refuse(404, "That repository is already gone") | |
| 357 | 358 | } |
| 358 | 359 | if err := s.store.DeleteRepo(repo.ID); err != nil { |
| 359 | - fail(err.Error()) | |
| 360 | - return false | |
| 360 | + return refuse(500, err.Error()) | |
| 361 | 361 | } |
| 362 | 362 | os.RemoveAll(s.repoDir(repo)) |
| 363 | 363 | s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) |
| 364 | 364 | default: |
| 365 | - fail("Unknown action") | |
| 366 | - return false | |
| 365 | + return refuse(400, "Unknown action") | |
| 367 | 366 | } |
| 368 | - return true | |
| 367 | + return nil | |
| 369 | 368 | } |
| 370 | 369 | |
| 371 | 370 | 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 ( | ||
| 5 | 5 | "errors" |
| 6 | 6 | "fmt" |
| 7 | 7 | "html/template" |
| 8 | - "log" | |
| 9 | 8 | "net/http" |
| 10 | - "os" | |
| 11 | - "path/filepath" | |
| 12 | 9 | "strconv" |
| 13 | 10 | "strings" |
| 14 | 11 | "time" |
| @@ -507,55 +504,11 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 507 | 504 | s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg, |
| 508 | 505 | 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")}}) |
| 509 | 506 | } |
| 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") | |
| 541 | 508 | if err != nil { |
| 542 | 509 | retry(err.Error()) |
| 543 | 510 | return |
| 544 | 511 | } |
| 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 | - } | |
| 559 | 512 | http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther) |
| 560 | 513 | } |
| 561 | 514 | |
| @@ -594,140 +547,37 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 594 | 547 | return |
| 595 | 548 | } |
| 596 | 549 | 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 | |
| 609 | 551 | switch action { |
| 610 | 552 | 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") | |
| 626 | 556 | case "review": |
| 627 | 557 | 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")) | |
| 632 | 559 | 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")) | |
| 634 | 560 | 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")) | |
| 646 | 562 | 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") | |
| 655 | 565 | case "meta": |
| 656 | 566 | s.setMeta(w, r, repo, "pull", pull.ID, back) |
| 657 | 567 | return |
| 658 | 568 | case "resolve", "unresolve", "delete": |
| 659 | 569 | 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) | |
| 673 | 571 | back += "#diff" |
| 674 | 572 | 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) | |
| 683 | 574 | 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")) | |
| 726 | 576 | default: |
| 727 | 577 | s.fail(w, r, 404, "Page not found") |
| 728 | 578 | return |
| 729 | 579 | } |
| 730 | - http.Redirect(w, r, back, http.StatusSeeOther) | |
| 580 | + s.settle(w, r, back, err) | |
| 731 | 581 | return |
| 732 | 582 | } |
| 733 | 583 | head, err := g.Resolve(pull.Ref()) |
| @@ -868,33 +718,11 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 868 | 718 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 869 | 719 | return |
| 870 | 720 | } |
| 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) | |
| 890 | 722 | 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()) | |
| 895 | 724 | return |
| 896 | 725 | } |
| 897 | - installHook(s.root, s.host, dir) | |
| 898 | 726 | http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) |
| 899 | 727 | } |
| 900 | 728 | |
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 { | ||
| 151 | 151 | return best |
| 152 | 152 | } |
| 153 | 153 | |
| 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 | + | |
| 154 | 172 | func (s *Server) find(w http.ResponseWriter, r *http.Request) { |
| 155 | 173 | query := strings.TrimSpace(r.URL.Query().Get("q")) |
| 156 | 174 | 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 { | |
| 157 | 219 | text, quals := parseQuery(query) |
| 158 | - lang := r.URL.Query().Get("lang") | |
| 159 | 220 | if l, ok := quals["language"]; ok { |
| 160 | 221 | lang = l |
| 161 | 222 | } |
| 162 | 223 | lang = canonicalLanguage(lang) |
| 163 | 224 | owner, tag := quals["user"], strings.ToLower(quals["tag"]) |
| 164 | - sort := r.URL.Query().Get("sort") | |
| 165 | 225 | q := strings.ToLower(text) |
| 166 | - if kind != "users" { | |
| 167 | - kind = "repos" | |
| 168 | - } | |
| 169 | 226 | |
| 170 | 227 | all, _ := s.store.PublicRepos("", "") |
| 171 | 228 | var repos []repoHit |
| @@ -262,13 +319,11 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) { | ||
| 262 | 319 | if len(users) > 100 { |
| 263 | 320 | users = users[:100] |
| 264 | 321 | } |
| 265 | - title := "Search" | |
| 266 | - if query != "" { | |
| 267 | - title = query + " · search" | |
| 322 | + var gists []gistHit | |
| 323 | + if lang == "" && tag == "" { | |
| 324 | + gists = s.searchGists(q, owner) | |
| 268 | 325 | } |
| 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} | |
| 272 | 327 | } |
| 273 | 328 | |
| 274 | 329 | 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 { | ||
| 22 | 22 | return false |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | -func (s *Server) basicUser(r *http.Request) (User, bool) { | |
| 25 | +func (s *Server) basicUser(r *http.Request) (User, string, bool) { | |
| 26 | 26 | name, secret, ok := r.BasicAuth() |
| 27 | 27 | if !ok { |
| 28 | - return User{}, false | |
| 28 | + return User{}, "", false | |
| 29 | 29 | } |
| 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 | |
| 32 | 32 | } |
| 33 | - return s.store.CheckPassword(name, secret) | |
| 33 | + u, ok := s.store.CheckPassword(name, secret) | |
| 34 | + return u, scopeAll, ok | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, rest string) { |
| 37 | 38 | repo, err := s.store.RepoByPath(owner.Name, name) |
| 38 | 39 | if err != nil { |
| 39 | 40 | var viewer *User |
| 40 | - if u, ok := s.basicUser(r); ok { | |
| 41 | + if u, _, ok := s.basicUser(r); ok { | |
| 41 | 42 | viewer = &u |
| 42 | 43 | } |
| 43 | 44 | 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, | ||
| 63 | 64 | http.Error(w, "too many failed attempts", 429) |
| 64 | 65 | return |
| 65 | 66 | } |
| 66 | - u, ok := s.basicUser(r) | |
| 67 | + u, scope, ok := s.basicUser(r) | |
| 67 | 68 | if !ok || !s.store.CanRead(repo, &u) { |
| 68 | 69 | if _, _, given := r.BasicAuth(); given { |
| 69 | 70 | s.limiter.fail(ip) |
| @@ -72,6 +73,10 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 72 | 73 | http.Error(w, "authentication required", 401) |
| 73 | 74 | return |
| 74 | 75 | } |
| 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 | + } | |
| 75 | 80 | pusher, writer = u, s.store.CanWrite(repo, &u) |
| 76 | 81 | } |
| 77 | 82 | if service == "git-receive-pack" && !hooksReady(s.root) { |
forge/store.go modified
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "fmt" |
| 10 | 10 | "net/mail" |
| 11 | 11 | "regexp" |
| 12 | + "slices" | |
| 12 | 13 | "strings" |
| 13 | 14 | "sync" |
| 14 | 15 | "time" |
| @@ -71,6 +72,8 @@ var migrations = []string{ | ||
| 71 | 72 | `alter table repos add column parent integer not null default 0`, |
| 72 | 73 | `alter table repos add column archived integer not null default 0`, |
| 73 | 74 | `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`, | |
| 74 | 77 | `alter table users add column verified integer not null default 0`, |
| 75 | 78 | `alter table users add column notify integer not null default 1`, |
| 76 | 79 | `alter table users add column banned integer not null default 0`, |
| @@ -257,9 +260,19 @@ type Key struct { | ||
| 257 | 260 | type Token struct { |
| 258 | 261 | ID int64 |
| 259 | 262 | Title string |
| 263 | + Scope string | |
| 260 | 264 | Created time.Time |
| 265 | + Used time.Time | |
| 261 | 266 | } |
| 262 | 267 | |
| 268 | +const ( | |
| 269 | + scopeAll = "all" | |
| 270 | + scopeRead = "read" | |
| 271 | + scopeGist = "gist" | |
| 272 | +) | |
| 273 | + | |
| 274 | +var tokenScopes = []string{scopeAll, scopeRead, scopeGist} | |
| 275 | + | |
| 263 | 276 | type Store struct { |
| 264 | 277 | db *sql.DB |
| 265 | 278 | settingsMu sync.Mutex |
| @@ -515,17 +528,23 @@ func tokenHash(token string) string { | ||
| 515 | 528 | return hex.EncodeToString(sum[:]) |
| 516 | 529 | } |
| 517 | 530 | |
| 518 | -func (s *Store) CreateToken(user int64, title string) (string, error) { | |
| 531 | +func (s *Store) CreateToken(user int64, title, scope string) (string, error) { | |
| 519 | 532 | if title == "" { |
| 520 | 533 | title = "token" |
| 521 | 534 | } |
| 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()) | |
| 524 | 543 | return token, err |
| 525 | 544 | } |
| 526 | 545 | |
| 527 | 546 | 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) | |
| 529 | 548 | if err != nil { |
| 530 | 549 | return nil, err |
| 531 | 550 | } |
| @@ -533,29 +552,48 @@ func (s *Store) Tokens(user int64) ([]Token, error) { | ||
| 533 | 552 | var tokens []Token |
| 534 | 553 | for rows.Next() { |
| 535 | 554 | 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 { | |
| 538 | 557 | return nil, err |
| 539 | 558 | } |
| 540 | 559 | t.Created = time.Unix(created, 0) |
| 560 | + if used > 0 { | |
| 561 | + t.Used = time.Unix(used, 0) | |
| 562 | + } | |
| 541 | 563 | tokens = append(tokens, t) |
| 542 | 564 | } |
| 543 | 565 | return tokens, rows.Err() |
| 544 | 566 | } |
| 545 | 567 | |
| 546 | 568 | 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 | + } | |
| 548 | 575 | return err |
| 549 | 576 | } |
| 550 | 577 | |
| 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) | |
| 556 | 586 | } |
| 557 | 587 | 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)) | |
| 559 | 597 | } |
| 560 | 598 | |
| 561 | 599 | func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) { |
| @@ -1137,7 +1175,7 @@ func validEmail(email string) bool { | ||
| 1137 | 1175 | const maxPins = 6 |
| 1138 | 1176 | |
| 1139 | 1177 | 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" | |
| 1141 | 1179 | } |
| 1142 | 1180 | |
| 1143 | 1181 | 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 { | ||
| 49 | 49 | heavy *buckets |
| 50 | 50 | uploads *limiter |
| 51 | 51 | posts *buckets |
| 52 | + writes *buckets | |
| 53 | + routes []apiRoute | |
| 52 | 54 | store *Store |
| 53 | 55 | root string |
| 54 | 56 | host string |
| @@ -84,17 +86,12 @@ type page struct { | ||
| 84 | 86 | } |
| 85 | 87 | |
| 86 | 88 | 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)} | |
| 88 | 90 | 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") }, | |
| 98 | 95 | "lines": func(b []byte) []string { |
| 99 | 96 | s := strings.TrimSuffix(string(b), "\n") |
| 100 | 97 | return strings.Split(s, "\n") |
| @@ -171,6 +168,7 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server { | ||
| 171 | 168 | } |
| 172 | 169 | } |
| 173 | 170 | s.version = hex.EncodeToString(sum.Sum(nil))[:12] |
| 171 | + s.routes = s.api() | |
| 174 | 172 | return s |
| 175 | 173 | } |
| 176 | 174 | |
| @@ -307,10 +305,12 @@ func (s *Server) handler() http.Handler { | ||
| 307 | 305 | mux.HandleFunc("GET /gists/new", s.newGistForm) |
| 308 | 306 | mux.HandleFunc("POST /gists", s.postGist) |
| 309 | 307 | 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) | |
| 312 | 310 | mux.HandleFunc("/", s.route) |
| 313 | 311 | cop := http.NewCrossOriginProtection() |
| 312 | + cop.AddInsecureBypassPattern("/api") | |
| 313 | + cop.AddInsecureBypassPattern("/api/") | |
| 314 | 314 | site := secure(s.throttle(cop.Handler(mux))) |
| 315 | 315 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 316 | 316 | if bareHost(r.Host) == s.rawHost() { |
| @@ -477,40 +477,17 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 477 | 477 | if u == nil { |
| 478 | 478 | return |
| 479 | 479 | } |
| 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) | |
| 494 | 480 | 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 { | |
| 497 | 483 | 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")}}) | |
| 499 | 485 | return |
| 500 | 486 | } |
| 501 | - dir := s.repoDir(repo) | |
| 502 | - err = os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 503 | - if err == nil { | |
| 504 | - err = initRepo(dir) | |
| 505 | - } | |
| 506 | 487 | 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()) | |
| 511 | 489 | return |
| 512 | 490 | } |
| 513 | - installHook(s.root, s.host, dir) | |
| 514 | 491 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 515 | 492 | } |
| 516 | 493 | |
| @@ -778,28 +755,29 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) { | ||
| 778 | 755 | if u == nil { |
| 779 | 756 | return |
| 780 | 757 | } |
| 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 | |
| 789 | 762 | } |
| 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) | |
| 792 | 769 | if len(v) > max { |
| 793 | 770 | v = v[:max] |
| 794 | 771 | } |
| 795 | 772 | return v |
| 796 | 773 | } |
| 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 | + } | |
| 801 | 779 | } |
| 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")} | |
| 803 | 781 | } |
| 804 | 782 | |
| 805 | 783 | func (s *Server) avatarPath(id int64) string { |
| @@ -891,7 +869,11 @@ func (s *Server) addToken(w http.ResponseWriter, r *http.Request) { | ||
| 891 | 869 | if u == nil { |
| 892 | 870 | return |
| 893 | 871 | } |
| 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) | |
| 895 | 877 | if err != nil { |
| 896 | 878 | http.Redirect(w, r, "/settings/tokens?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 897 | 879 | return |
| @@ -1637,20 +1619,19 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 1637 | 1619 | } |
| 1638 | 1620 | |
| 1639 | 1621 | 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()) | |
| 1643 | 1625 | return |
| 1644 | 1626 | } |
| 1645 | 1627 | if r.FormValue("confirm") != repo.Name { |
| 1646 | 1628 | http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther) |
| 1647 | 1629 | return |
| 1648 | 1630 | } |
| 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()) | |
| 1651 | 1633 | return |
| 1652 | 1634 | } |
| 1653 | - os.RemoveAll(s.repoDir(repo)) | |
| 1654 | 1635 | http.Redirect(w, r, "/~"+repo.Owner.Name, http.StatusSeeOther) |
| 1655 | 1636 | } |
| 1656 | 1637 | |
| @@ -1724,21 +1705,11 @@ func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, a | ||
| 1724 | 1705 | } |
| 1725 | 1706 | back := "/~" + org.Name + "/settings" |
| 1726 | 1707 | 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()) | |
| 1740 | 1711 | } |
| 1741 | - http.Redirect(w, r, back, http.StatusSeeOther) | |
| 1712 | + s.settle(w, r, back, err) | |
| 1742 | 1713 | return |
| 1743 | 1714 | } |
| 1744 | 1715 | 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 | ||
| 583 | 583 | .gistforks{list-style:none;padding:0;margin:0 0 1rem} |
| 584 | 584 | .revision{margin-bottom:1.2rem} |
| 585 | 585 | 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 @@ | ||
| 64 | 64 | <h3 class="rule">Usage</h3> |
| 65 | 65 | <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> |
| 66 | 66 | {{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}} | |
| 67 | 81 | {{if eq $d.Section "reports"}} |
| 68 | 82 | <h3 class="rule">Reports</h3> |
| 69 | 83 | <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&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 @@ | ||
| 2 | 2 | {{$d := .Data}} |
| 3 | 3 | {{$g := $d.Gist}} |
| 4 | 4 | <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> | |
| 6 | 6 | {{range $d.Revisions}}{{$c := .Commit}}<div class="revision"> |
| 7 | 7 | <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> |
| 8 | 8 | {{range .Files}}<section class="diff"> |
| @@ -13,4 +13,5 @@ | ||
| 13 | 13 | </table>{{end}} |
| 14 | 14 | </section>{{end}} |
| 15 | 15 | </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}} | |
| 16 | 17 | {{end}} |
web/templates/search.tmpl modified
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | <nav class="facetlist"> |
| 10 | 10 | <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> |
| 11 | 11 | <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> | |
| 12 | 13 | </nav> |
| 13 | 14 | </section> |
| 14 | 15 | {{if $d.Languages}}<section class="facetbox"> |
| @@ -22,6 +23,9 @@ | ||
| 22 | 23 | <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> |
| 23 | 24 | <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> |
| 24 | 25 | {{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}} | |
| 25 | 29 | {{else}} |
| 26 | 30 | <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> |
| 27 | 31 | {{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 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <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> | |
| 4 | 4 | {{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}} | |
| 6 | 6 | <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> | |
| 8 | 11 | <div class="actions left"><button class="btn primary">Generate new token ▸</button></div> |
| 9 | 12 | </form></div> |
| 10 | 13 | {{end}} |