feat(basin): squash and rebase merges, collaborators, disk quota, pgp verification, streamed raw files
16 files changed, +609 −52
- README.md modified +6 −3
- git.go modified +121 −4
- highlight.go modified +6 −2
- main.go modified +17 −0
- pull.go modified +18 −4
- quota.go added +64 −0
- shell.go modified +8 −3
- smart.go modified +7 −1
- static/style.css modified +2 −0
- store.go modified +165 −0
- templates/index.html modified +2 −1
- templates/keys.html modified +8 −1
- templates/pull.html modified +6 −1
- templates/reposettings.html modified +7 −0
- templates/settings.html modified +1 −0
- web.go modified +171 −32
README.md modified
| @@ -8,8 +8,11 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 8 | 8 | - Tree and blob and log and commit and refs views, plus a case insensitive search across file contents at any ref backed by `git grep`. Chroma does the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser |
| 9 | 9 | - The last commit shown against every tree entry comes out of one `git log --name-only` walk per directory instead of a separate process per file |
| 10 | 10 | - Profiles have a display name and a Markdown bio along with location and workplace and website, plus an avatar that you crop in the browser and the server then resamples down to a 256px PNG |
| 11 | -- Forks and pull requests from either a branch or a fork. Discussion comments and inline review comments on any line of the diff are Markdown, review threads can be resolved and go to an outdated section when the line leaves the diff, merges take an editable message, and a request can be closed or reopened or brought up to date from its source at any point | |
| 12 | -- SSH signature verification. Sign with `gpg.format ssh`, register the same key in settings, and your commits start showing `Verified ~you` | |
| 11 | +- Forks and pull requests from either a branch or a fork. Discussion comments and inline review comments on any line of the diff are Markdown, review threads can be resolved and go to an outdated section when the line leaves the diff, and a request can be closed or reopened or brought up to date from its source at any point | |
| 12 | +- Three merge strategies, a merge commit with an editable message, a squash authored by the request author, or a rebase that replays each commit onto the target. All three are built from `merge-tree` and `commit-tree` without a working tree and fail cleanly on conflict | |
| 13 | +- Collaborators per repository who can push, read it when private and merge pull requests, while settings and deletion stay with the owner | |
| 14 | +- A per user disk quota set with `basin quota 2G` and enforced on push over both transports | |
| 15 | +- Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp` | |
| 13 | 16 | - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password |
| 14 | 17 | - Registration is invite only unless you say otherwise, and there is a failure counter per address covering login and register and password changes and HTTPS auth |
| 15 | 18 | |
| @@ -71,7 +74,7 @@ To send changes to something you do not own, fork it from the button beside the | ||
| 71 | 74 | |
| 72 | 75 | ## Limits |
| 73 | 76 | |
| 74 | -There are no squash or rebase merges, no collaborators beyond the single owner of a repo, and no disk quota of any kind. Blobs over 1 MiB are not rendered and anything over 64 MiB is not served raw either, while highlighting gives up past 256 KiB. Signature checking is SSH only for now, so commits signed with GPG show up as unverified. | |
| 77 | +Raw files stream straight out of `cat-file` at any size, but the rendered view stops at 4 MiB and syntax highlighting at 512 KiB, beyond which a file is shown plain or offered as raw only. The quota is checked before a push starts rather than during it, so a single push can overshoot by its own size. There is no wiki, no issue tracker and no CI, and there are no plans for any of them. | |
| 75 | 78 | |
| 76 | 79 | ## Licence |
| 77 | 80 | |
git.go modified
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | 6 | "errors" |
| 7 | + "io" | |
| 7 | 8 | "os" |
| 8 | 9 | "os/exec" |
| 9 | 10 | "path" |
| @@ -28,10 +29,13 @@ type Commit struct { | ||
| 28 | 29 | Parents []string |
| 29 | 30 | Signature string |
| 30 | 31 | Key string |
| 32 | + PGP string | |
| 31 | 33 | } |
| 32 | 34 | |
| 33 | 35 | var signersFile string |
| 34 | 36 | |
| 37 | +var gnupgHome string | |
| 38 | + | |
| 35 | 39 | type Entry struct { |
| 36 | 40 | Mode string |
| 37 | 41 | Type string |
| @@ -82,6 +86,9 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) | ||
| 82 | 86 | if signersFile != "" { |
| 83 | 87 | cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile) |
| 84 | 88 | } |
| 89 | + if gnupgHome != "" { | |
| 90 | + cmd.Env = append(cmd.Env, "GNUPGHOME="+gnupgHome) | |
| 91 | + } | |
| 85 | 92 | if stdin != nil { |
| 86 | 93 | cmd.Stdin = bytes.NewReader(stdin) |
| 87 | 94 | } |
| @@ -146,6 +153,107 @@ func (g Git) Mergeable(target, head string) bool { | ||
| 146 | 153 | return err == nil |
| 147 | 154 | } |
| 148 | 155 | |
| 156 | +func (g Git) mergeTree(base, ours, theirs string) (string, error) { | |
| 157 | + args := []string{"merge-tree", "--write-tree"} | |
| 158 | + if base != "" { | |
| 159 | + args = append(args, "--merge-base", base) | |
| 160 | + } | |
| 161 | + out, err := g.run(nil, append(args, ours, theirs)...) | |
| 162 | + if err != nil { | |
| 163 | + return "", errors.New("merge conflicts, resolve them locally and update the pull request") | |
| 164 | + } | |
| 165 | + tree, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") | |
| 166 | + return tree, nil | |
| 167 | +} | |
| 168 | + | |
| 169 | +func ident(name, email string, when time.Time) []string { | |
| 170 | + stamp := strconv.FormatInt(when.Unix(), 10) + " +0000" | |
| 171 | + return []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_AUTHOR_DATE=" + stamp, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email, "GIT_COMMITTER_DATE=" + stamp} | |
| 172 | +} | |
| 173 | + | |
| 174 | +func (g Git) Squash(target, head, message, authorName, authorEmail, name, email string) (string, error) { | |
| 175 | + targetHash, err := g.Resolve("refs/heads/" + target) | |
| 176 | + if err != nil { | |
| 177 | + return "", errors.New("target branch is missing") | |
| 178 | + } | |
| 179 | + headHash, err := g.Resolve(head) | |
| 180 | + if err != nil { | |
| 181 | + return "", errors.New("pull request head is missing") | |
| 182 | + } | |
| 183 | + if base, err := g.MergeBase(targetHash, headHash); err == nil && base == headHash { | |
| 184 | + return "", errors.New("already merged") | |
| 185 | + } | |
| 186 | + tree, err := g.mergeTree("", targetHash, headHash) | |
| 187 | + if err != nil { | |
| 188 | + return "", err | |
| 189 | + } | |
| 190 | + now := time.Now() | |
| 191 | + env := ident(authorName, authorEmail, now) | |
| 192 | + env[3], env[4] = "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email | |
| 193 | + commit, err := g.runEnv(env, []byte(message), "commit-tree", tree, "-p", targetHash) | |
| 194 | + if err != nil { | |
| 195 | + return "", err | |
| 196 | + } | |
| 197 | + hash := strings.TrimSpace(string(commit)) | |
| 198 | + if _, err := g.run(nil, "update-ref", "refs/heads/"+target, hash, targetHash); err != nil { | |
| 199 | + return "", errors.New("target branch moved, try again") | |
| 200 | + } | |
| 201 | + return hash, nil | |
| 202 | +} | |
| 203 | + | |
| 204 | +func (g Git) Rebase(target, head, name, email string) (string, error) { | |
| 205 | + targetHash, err := g.Resolve("refs/heads/" + target) | |
| 206 | + if err != nil { | |
| 207 | + return "", errors.New("target branch is missing") | |
| 208 | + } | |
| 209 | + headHash, err := g.Resolve(head) | |
| 210 | + if err != nil { | |
| 211 | + return "", errors.New("pull request head is missing") | |
| 212 | + } | |
| 213 | + base, err := g.MergeBase(targetHash, headHash) | |
| 214 | + if err != nil { | |
| 215 | + return "", errors.New("no common history") | |
| 216 | + } | |
| 217 | + if base == headHash { | |
| 218 | + return "", errors.New("already merged") | |
| 219 | + } | |
| 220 | + out, err := g.run(nil, "rev-list", "--reverse", "--no-merges", base+".."+headHash) | |
| 221 | + if err != nil { | |
| 222 | + return "", err | |
| 223 | + } | |
| 224 | + tip := targetHash | |
| 225 | + for _, c := range strings.Fields(string(out)) { | |
| 226 | + info, err := g.run(nil, "log", "-n", "1", "--format=%P%x00%an%x00%ae%x00%at%x00%B", c) | |
| 227 | + if err != nil { | |
| 228 | + return "", err | |
| 229 | + } | |
| 230 | + f := strings.SplitN(string(info), "\x00", 5) | |
| 231 | + if len(f) != 5 { | |
| 232 | + return "", errors.New("could not read commit " + c) | |
| 233 | + } | |
| 234 | + parent := strings.Fields(f[0]) | |
| 235 | + if len(parent) == 0 { | |
| 236 | + return "", errors.New("cannot rebase a root commit") | |
| 237 | + } | |
| 238 | + tree, err := g.mergeTree(parent[0], tip, c) | |
| 239 | + if err != nil { | |
| 240 | + return "", err | |
| 241 | + } | |
| 242 | + t, _ := strconv.ParseInt(f[3], 10, 64) | |
| 243 | + env := ident(f[1], f[2], time.Unix(t, 0)) | |
| 244 | + env[3], env[4], env[5] = "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, "GIT_COMMITTER_DATE="+strconv.FormatInt(time.Now().Unix(), 10)+" +0000" | |
| 245 | + commit, err := g.runEnv(env, []byte(strings.TrimRight(f[4], "\n")+"\n"), "commit-tree", tree, "-p", tip) | |
| 246 | + if err != nil { | |
| 247 | + return "", err | |
| 248 | + } | |
| 249 | + tip = strings.TrimSpace(string(commit)) | |
| 250 | + } | |
| 251 | + if _, err := g.run(nil, "update-ref", "refs/heads/"+target, tip, targetHash); err != nil { | |
| 252 | + return "", errors.New("target branch moved, try again") | |
| 253 | + } | |
| 254 | + return tip, nil | |
| 255 | +} | |
| 256 | + | |
| 149 | 257 | func (g Git) Merge(target, head, message, name, email string) (string, error) { |
| 150 | 258 | targetHash, err := g.Resolve("refs/heads/" + target) |
| 151 | 259 | if err != nil { |
| @@ -199,19 +307,19 @@ func (g Git) Resolve(ref string) (string, error) { | ||
| 199 | 307 | return strings.TrimSpace(string(out)), nil |
| 200 | 308 | } |
| 201 | 309 | |
| 202 | -const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00%G?%x00%GK%x00" | |
| 310 | +const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00%G?%x00%GK%x00%GP%x00" | |
| 203 | 311 | |
| 204 | 312 | func parseCommits(out []byte) []Commit { |
| 205 | 313 | fields := strings.Split(string(out), "\x00") |
| 206 | 314 | var commits []Commit |
| 207 | - for len(fields) >= 9 { | |
| 315 | + for len(fields) >= 10 { | |
| 208 | 316 | t, _ := strconv.ParseInt(fields[3], 10, 64) |
| 209 | - c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6]), Signature: fields[7], Key: fields[8]} | |
| 317 | + c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6]), Signature: fields[7], Key: fields[8], PGP: strings.ToUpper(fields[9])} | |
| 210 | 318 | if fields[4] != "" { |
| 211 | 319 | c.Parents = strings.Split(fields[4], " ") |
| 212 | 320 | } |
| 213 | 321 | commits = append(commits, c) |
| 214 | - fields = fields[9:] | |
| 322 | + fields = fields[10:] | |
| 215 | 323 | if len(fields) > 0 { |
| 216 | 324 | fields[0] = strings.TrimLeft(fields[0], "\n") |
| 217 | 325 | } |
| @@ -371,6 +479,15 @@ func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit { | ||
| 371 | 479 | return found |
| 372 | 480 | } |
| 373 | 481 | |
| 482 | +func (g Git) Stream(w io.Writer, commit, filePath string) error { | |
| 483 | + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | |
| 484 | + defer cancel() | |
| 485 | + cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "cat-file", "blob", commit+":"+filePath) | |
| 486 | + cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} | |
| 487 | + cmd.Stdout = w | |
| 488 | + return cmd.Run() | |
| 489 | +} | |
| 490 | + | |
| 374 | 491 | func (g Git) Blob(commit, filePath string) ([]byte, error) { |
| 375 | 492 | out, err := g.run(nil, "cat-file", "blob", commit+":"+filePath) |
| 376 | 493 | if err != nil { |
highlight.go modified
| @@ -21,10 +21,14 @@ var formatter = html.New( | ||
| 21 | 21 | |
| 22 | 22 | var highlightStyle = styles.Get("pygments") |
| 23 | 23 | |
| 24 | -func highlight(name string, content []byte) (template.HTML, bool) { | |
| 25 | - if len(content) > 256<<10 { | |
| 24 | +func highlightLimited(name string, content []byte) (template.HTML, bool) { | |
| 25 | + if len(content) > maxHighlight { | |
| 26 | 26 | return "", false |
| 27 | 27 | } |
| 28 | + return highlight(name, content) | |
| 29 | +} | |
| 30 | + | |
| 31 | +func highlight(name string, content []byte) (template.HTML, bool) { | |
| 28 | 32 | lexer := lexers.Match(filepath.Base(name)) |
| 29 | 33 | if lexer == nil { |
| 30 | 34 | lexer = lexers.Analyse(string(content)) |
main.go modified
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "net/http" |
| 8 | 8 | "os" |
| 9 | 9 | "path/filepath" |
| 10 | + "strconv" | |
| 10 | 11 | "time" |
| 11 | 12 | ) |
| 12 | 13 | |
| @@ -19,6 +20,7 @@ func main() { | ||
| 19 | 20 | keys user home type key sshd AuthorizedKeysCommand |
| 20 | 21 | shell user forced command for ssh git access |
| 21 | 22 | invite print a single use registration code |
| 23 | + quota [size] show or set the per user disk quota, 0 disables | |
| 22 | 24 | user name create a user, password read from stdin`) |
| 23 | 25 | } |
| 24 | 26 | flag.Parse() |
| @@ -38,6 +40,21 @@ func main() { | ||
| 38 | 40 | os.Exit(2) |
| 39 | 41 | } |
| 40 | 42 | os.Exit(shell(*root, args[1])) |
| 43 | + case "quota": | |
| 44 | + if len(args) == 2 { | |
| 45 | + n, err := parseSize(args[1]) | |
| 46 | + if err != nil { | |
| 47 | + log.Fatal(err) | |
| 48 | + } | |
| 49 | + if err := os.WriteFile(filepath.Join(*root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644); err != nil { | |
| 50 | + log.Fatal(err) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + if q := readQuota(*root); q == 0 { | |
| 54 | + fmt.Println("no quota") | |
| 55 | + } else { | |
| 56 | + fmt.Println(humanSize(q)) | |
| 57 | + } | |
| 41 | 58 | case "invite": |
| 42 | 59 | store := mustStore(*root) |
| 43 | 60 | code, err := store.CreateInvite() |
pull.go modified
| @@ -350,7 +350,7 @@ func (s *Server) pullSources(repo Repo, user *User) []sourceOption { | ||
| 350 | 350 | if user != nil { |
| 351 | 351 | forks, _ := s.store.Forks(repo.ID, user.ID) |
| 352 | 352 | for _, f := range forks { |
| 353 | - if f.Owner.ID == user.ID { | |
| 353 | + if s.store.CanWrite(f, user) { | |
| 354 | 354 | add(f) |
| 355 | 355 | } |
| 356 | 356 | } |
| @@ -385,7 +385,7 @@ func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, strin | ||
| 385 | 385 | if err != nil { |
| 386 | 386 | return Repo{}, "", errors.New("No such source repository") |
| 387 | 387 | } |
| 388 | - if src.ID != repo.ID && (src.Parent != repo.ID || user == nil || src.Owner.ID != user.ID) { | |
| 388 | + if src.ID != repo.ID && (src.Parent != repo.ID || !s.store.CanWrite(src, user)) { | |
| 389 | 389 | return Repo{}, "", errors.New("Source must be this repository or your own fork of it") |
| 390 | 390 | } |
| 391 | 391 | if _, err := (Git{Dir: s.repoDir(src)}).Resolve("refs/heads/" + branch); err != nil { |
| @@ -459,7 +459,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 459 | 459 | if !ok { |
| 460 | 460 | return |
| 461 | 461 | } |
| 462 | - isOwner := user != nil && user.ID == repo.Owner.ID | |
| 462 | + isOwner := s.store.CanWrite(repo, user) | |
| 463 | 463 | isAuthor := user != nil && user.ID == pull.Author.ID |
| 464 | 464 | if r.Method == "POST" { |
| 465 | 465 | if user == nil { |
| @@ -525,7 +525,16 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 525 | 525 | if body := strings.TrimSpace(r.FormValue("body")); body != "" { |
| 526 | 526 | message += "\n\n" + body |
| 527 | 527 | } |
| 528 | - hash, err := g.Merge(pull.TargetBranch, pull.Ref(), message, user.Name, user.Name+"@"+s.host) | |
| 528 | + var hash string | |
| 529 | + var err error | |
| 530 | + switch r.FormValue("strategy") { | |
| 531 | + case "squash": | |
| 532 | + hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, user.Name, user.Name+"@"+s.host) | |
| 533 | + case "rebase": | |
| 534 | + hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), user.Name, user.Name+"@"+s.host) | |
| 535 | + default: | |
| 536 | + hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, user.Name, user.Name+"@"+s.host) | |
| 537 | + } | |
| 529 | 538 | if err != nil { |
| 530 | 539 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 531 | 540 | return |
| @@ -600,6 +609,11 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 600 | 609 | if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" { |
| 601 | 610 | data["Confirm"] = true |
| 602 | 611 | data["MergeTitle"] = mergeTitle(pull) |
| 612 | + var subjects []string | |
| 613 | + for _, c := range data["Commits"].([]Commit) { | |
| 614 | + subjects = append(subjects, "* "+c.Subject) | |
| 615 | + } | |
| 616 | + data["SquashBody"] = strings.Join(subjects, "\n") | |
| 603 | 617 | } |
| 604 | 618 | } |
| 605 | 619 | } else { |
quota.go added
| @@ -0,0 +1,64 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "io/fs" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "strconv" | |
| 10 | + "strings" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func readQuota(root string) int64 { | |
| 14 | + b, err := os.ReadFile(filepath.Join(root, "quota")) | |
| 15 | + if err != nil { | |
| 16 | + return 0 | |
| 17 | + } | |
| 18 | + n, _ := parseSize(strings.TrimSpace(string(b))) | |
| 19 | + return n | |
| 20 | +} | |
| 21 | + | |
| 22 | +func parseSize(s string) (int64, error) { | |
| 23 | + s = strings.ToUpper(strings.TrimSpace(s)) | |
| 24 | + mult := int64(1) | |
| 25 | + switch { | |
| 26 | + case strings.HasSuffix(s, "G"): | |
| 27 | + mult, s = 1<<30, strings.TrimSuffix(s, "G") | |
| 28 | + case strings.HasSuffix(s, "M"): | |
| 29 | + mult, s = 1<<20, strings.TrimSuffix(s, "M") | |
| 30 | + case strings.HasSuffix(s, "K"): | |
| 31 | + mult, s = 1<<10, strings.TrimSuffix(s, "K") | |
| 32 | + } | |
| 33 | + n, err := strconv.ParseInt(s, 10, 64) | |
| 34 | + if err != nil || n < 0 { | |
| 35 | + return 0, errors.New("size must be a number with an optional K, M or G suffix") | |
| 36 | + } | |
| 37 | + return n * mult, nil | |
| 38 | +} | |
| 39 | + | |
| 40 | +func usage(dir string) int64 { | |
| 41 | + var total int64 | |
| 42 | + filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { | |
| 43 | + if err == nil && !d.IsDir() { | |
| 44 | + if info, err := d.Info(); err == nil { | |
| 45 | + total += info.Size() | |
| 46 | + } | |
| 47 | + } | |
| 48 | + return nil | |
| 49 | + }) | |
| 50 | + return total | |
| 51 | +} | |
| 52 | + | |
| 53 | +func overQuota(root string, owner string) (bool, int64, int64) { | |
| 54 | + quota := readQuota(root) | |
| 55 | + if quota == 0 { | |
| 56 | + return false, 0, 0 | |
| 57 | + } | |
| 58 | + used := usage(filepath.Join(root, "repos", owner)) | |
| 59 | + return used >= quota, used, quota | |
| 60 | +} | |
| 61 | + | |
| 62 | +func quotaMessage(used, quota int64) string { | |
| 63 | + return fmt.Sprintf("disk quota exceeded, %s used of %s", humanSize(used), humanSize(quota)) | |
| 64 | +} | |
shell.go modified
| @@ -62,13 +62,18 @@ func shell(root, name string) int { | ||
| 62 | 62 | if err != nil { |
| 63 | 63 | return fail("repository not found") |
| 64 | 64 | } |
| 65 | - isOwner := repo.Owner.ID == user.ID | |
| 66 | - if repo.Private && !isOwner { | |
| 65 | + canWrite := store.CanWrite(repo, &user) | |
| 66 | + if repo.Private && !canWrite { | |
| 67 | 67 | return fail("repository not found") |
| 68 | 68 | } |
| 69 | - if verb == "git-receive-pack" && !isOwner { | |
| 69 | + if verb == "git-receive-pack" && !canWrite { | |
| 70 | 70 | return fail("you do not have write access to " + repo.Path()) |
| 71 | 71 | } |
| 72 | + if verb == "git-receive-pack" { | |
| 73 | + if over, used, quota := overQuota(root, repo.Owner.Name); over { | |
| 74 | + return fail(quotaMessage(used, quota)) | |
| 75 | + } | |
| 76 | + } | |
| 72 | 77 | dir := filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git") |
| 73 | 78 | git, err := exec.LookPath("git") |
| 74 | 79 | if err != nil { |
smart.go modified
| @@ -54,7 +54,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 54 | 54 | return |
| 55 | 55 | } |
| 56 | 56 | u, ok := s.basicUser(r) |
| 57 | - if !ok || u.ID != repo.Owner.ID { | |
| 57 | + if !ok || !s.store.CanWrite(repo, &u) { | |
| 58 | 58 | if _, _, given := r.BasicAuth(); given { |
| 59 | 59 | s.limiter.fail(ip) |
| 60 | 60 | } |
| @@ -63,6 +63,12 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 63 | 63 | return |
| 64 | 64 | } |
| 65 | 65 | } |
| 66 | + if service == "git-receive-pack" { | |
| 67 | + if over, used, quota := overQuota(s.root, repo.Owner.Name); over { | |
| 68 | + http.Error(w, quotaMessage(used, quota), 403) | |
| 69 | + return | |
| 70 | + } | |
| 71 | + } | |
| 66 | 72 | dir := s.repoDir(repo) |
| 67 | 73 | w.Header().Set("Cache-Control", "no-cache") |
| 68 | 74 | ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) |
static/style.css modified
| @@ -251,3 +251,5 @@ mark{background:#fff3b0;padding:0} | ||
| 251 | 251 | .reviewform{margin:0} |
| 252 | 252 | .reviewform textarea{width:100%} |
| 253 | 253 | .comment.outdated{opacity:.8} |
| 254 | +.mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem} | |
| 255 | +.mergeform label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} | |
store.go modified
| @@ -69,6 +69,22 @@ var migrations = []string{ | ||
| 69 | 69 | `alter table repos add column parent integer not null default 0`, |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | +const collabSchema = ` | |
| 73 | +create table if not exists collaborators ( | |
| 74 | + repo integer not null references repos(id) on delete cascade, | |
| 75 | + user integer not null references users(id) on delete cascade, | |
| 76 | + created integer not null, | |
| 77 | + primary key(repo, user) | |
| 78 | +); | |
| 79 | +create table if not exists pgp ( | |
| 80 | + id integer primary key, | |
| 81 | + user integer not null references users(id) on delete cascade, | |
| 82 | + fingerprint text not null unique, | |
| 83 | + armored text not null, | |
| 84 | + created integer not null | |
| 85 | +); | |
| 86 | +` | |
| 87 | + | |
| 72 | 88 | const ( |
| 73 | 89 | Public = 0 |
| 74 | 90 | Private = 1 |
| @@ -173,6 +189,9 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 173 | 189 | if _, err := db.Exec(pullSchema); err != nil { |
| 174 | 190 | return nil, err |
| 175 | 191 | } |
| 192 | + if _, err := db.Exec(collabSchema); err != nil { | |
| 193 | + return nil, err | |
| 194 | + } | |
| 176 | 195 | } |
| 177 | 196 | return &Store{db}, nil |
| 178 | 197 | } |
| @@ -553,6 +572,152 @@ func (s *Store) SetAvatar(user int64, version int64) error { | ||
| 553 | 572 | return err |
| 554 | 573 | } |
| 555 | 574 | |
| 575 | +func (s *Store) AddCollaborator(repo int64, name string) error { | |
| 576 | + u, err := s.UserByName(name) | |
| 577 | + if err != nil { | |
| 578 | + return errors.New("No such user") | |
| 579 | + } | |
| 580 | + _, err = s.db.Exec(`insert or ignore into collaborators(repo, user, created) values (?, ?, ?)`, repo, u.ID, time.Now().Unix()) | |
| 581 | + return err | |
| 582 | +} | |
| 583 | + | |
| 584 | +func (s *Store) RemoveCollaborator(repo, user int64) { | |
| 585 | + s.db.Exec(`delete from collaborators where repo = ? and user = ?`, repo, user) | |
| 586 | +} | |
| 587 | + | |
| 588 | +func (s *Store) Collaborators(repo int64) ([]User, error) { | |
| 589 | + rows, err := s.db.Query(`select u.id, u.name, u.created from collaborators c join users u on u.id = c.user where c.repo = ? order by u.name`, repo) | |
| 590 | + if err != nil { | |
| 591 | + return nil, err | |
| 592 | + } | |
| 593 | + defer rows.Close() | |
| 594 | + var out []User | |
| 595 | + for rows.Next() { | |
| 596 | + var u User | |
| 597 | + var created int64 | |
| 598 | + if err := rows.Scan(&u.ID, &u.Name, &created); err != nil { | |
| 599 | + return nil, err | |
| 600 | + } | |
| 601 | + u.Created = time.Unix(created, 0) | |
| 602 | + out = append(out, u) | |
| 603 | + } | |
| 604 | + return out, rows.Err() | |
| 605 | +} | |
| 606 | + | |
| 607 | +func (s *Store) IsCollaborator(repo, user int64) bool { | |
| 608 | + var n int | |
| 609 | + s.db.QueryRow(`select count(*) from collaborators where repo = ? and user = ?`, repo, user).Scan(&n) | |
| 610 | + return n > 0 | |
| 611 | +} | |
| 612 | + | |
| 613 | +func (s *Store) CanWrite(repo Repo, user *User) bool { | |
| 614 | + if user == nil { | |
| 615 | + return false | |
| 616 | + } | |
| 617 | + return user.ID == repo.Owner.ID || s.IsCollaborator(repo.ID, user.ID) | |
| 618 | +} | |
| 619 | + | |
| 620 | +func (s *Store) CanRead(repo Repo, user *User) bool { | |
| 621 | + if !repo.Private { | |
| 622 | + return true | |
| 623 | + } | |
| 624 | + return s.CanWrite(repo, user) | |
| 625 | +} | |
| 626 | + | |
| 627 | +func (s *Store) SharedRepos(user int64) ([]Repo, error) { | |
| 628 | + rows, err := s.db.Query(`select `+repoColumns+` join collaborators c on c.repo = r.id where c.user = ? order by u.name, r.name`, user) | |
| 629 | + if err != nil { | |
| 630 | + return nil, err | |
| 631 | + } | |
| 632 | + return scanRepos(rows) | |
| 633 | +} | |
| 634 | + | |
| 635 | +type PGPKey struct { | |
| 636 | + ID int64 | |
| 637 | + Fingerprint string | |
| 638 | + Created time.Time | |
| 639 | +} | |
| 640 | + | |
| 641 | +func (s *Store) AddPGP(user int64, fingerprint, armored string) error { | |
| 642 | + _, err := s.db.Exec(`insert into pgp(user, fingerprint, armored, created) values (?, ?, ?, ?)`, user, fingerprint, armored, time.Now().Unix()) | |
| 643 | + if err != nil && strings.Contains(err.Error(), "UNIQUE") { | |
| 644 | + return errors.New("That key is already registered") | |
| 645 | + } | |
| 646 | + return err | |
| 647 | +} | |
| 648 | + | |
| 649 | +func (s *Store) PGPKeys(user int64) ([]PGPKey, error) { | |
| 650 | + rows, err := s.db.Query(`select id, fingerprint, created from pgp where user = ? order by id`, user) | |
| 651 | + if err != nil { | |
| 652 | + return nil, err | |
| 653 | + } | |
| 654 | + defer rows.Close() | |
| 655 | + var out []PGPKey | |
| 656 | + for rows.Next() { | |
| 657 | + var k PGPKey | |
| 658 | + var created int64 | |
| 659 | + if err := rows.Scan(&k.ID, &k.Fingerprint, &created); err != nil { | |
| 660 | + return nil, err | |
| 661 | + } | |
| 662 | + k.Created = time.Unix(created, 0) | |
| 663 | + out = append(out, k) | |
| 664 | + } | |
| 665 | + return out, rows.Err() | |
| 666 | +} | |
| 667 | + | |
| 668 | +func (s *Store) DeletePGP(user, id int64) { | |
| 669 | + s.db.Exec(`delete from pgp where user = ? and id = ?`, user, id) | |
| 670 | +} | |
| 671 | + | |
| 672 | +func (s *Store) AllPGP() ([]string, error) { | |
| 673 | + rows, err := s.db.Query(`select armored from pgp order by id`) | |
| 674 | + if err != nil { | |
| 675 | + return nil, err | |
| 676 | + } | |
| 677 | + defer rows.Close() | |
| 678 | + var out []string | |
| 679 | + for rows.Next() { | |
| 680 | + var a string | |
| 681 | + if err := rows.Scan(&a); err != nil { | |
| 682 | + return nil, err | |
| 683 | + } | |
| 684 | + out = append(out, a) | |
| 685 | + } | |
| 686 | + return out, rows.Err() | |
| 687 | +} | |
| 688 | + | |
| 689 | +func (s *Store) UserByPGP(fingerprint string) (User, bool) { | |
| 690 | + var user int64 | |
| 691 | + if err := s.db.QueryRow(`select user from pgp where fingerprint = ?`, strings.ToUpper(fingerprint)).Scan(&user); err != nil { | |
| 692 | + return User{}, false | |
| 693 | + } | |
| 694 | + u, err := s.userByID(user) | |
| 695 | + return u, err == nil | |
| 696 | +} | |
| 697 | + | |
| 698 | +func (s *Store) PublicKeys(user int64) ([]string, []string) { | |
| 699 | + var ssh, pgp []string | |
| 700 | + rows, err := s.db.Query(`select public from keys where user = ? order by id`, user) | |
| 701 | + if err == nil { | |
| 702 | + for rows.Next() { | |
| 703 | + var k string | |
| 704 | + rows.Scan(&k) | |
| 705 | + ssh = append(ssh, k) | |
| 706 | + } | |
| 707 | + rows.Close() | |
| 708 | + } | |
| 709 | + rows, err = s.db.Query(`select armored from pgp where user = ? order by id`, user) | |
| 710 | + if err == nil { | |
| 711 | + for rows.Next() { | |
| 712 | + var k string | |
| 713 | + rows.Scan(&k) | |
| 714 | + pgp = append(pgp, k) | |
| 715 | + } | |
| 716 | + rows.Close() | |
| 717 | + } | |
| 718 | + return ssh, pgp | |
| 719 | +} | |
| 720 | + | |
| 556 | 721 | func (s *Store) CreateInvite() (string, error) { |
| 557 | 722 | code := random(12) |
| 558 | 723 | _, err := s.db.Exec(`insert into invites(code) values (?)`, code) |
templates/index.html modified
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | <div class="cards"> |
| 4 | 4 | <section class="panel"><h3>Your repositories</h3> |
| 5 | 5 | {{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p>You have no repositories yet.</p>{{end}} |
| 6 | -<a class="btn primary" href="/create">Create a repository ▸</a></section> | |
| 6 | +<a class="btn primary" href="/create">Create a repository ▸</a> | |
| 7 | +{{if .Data.Shared}}<h3>Shared with you</h3>{{template "repolist" .Data.Shared}}{{end}}</section> | |
| 7 | 8 | <section class="panel"><h3>Your profile</h3><p>Set a bio in Markdown, a location, a workplace and an avatar. Your page is <a href="/~{{.User.Name}}">~{{.User.Name}}</a>.</p><a class="btn" href="/settings">Edit your profile ▸</a></section> |
| 8 | 9 | <section class="panel"><h3>Keys and tokens</h3><p>Register SSH public keys for pushing and create access tokens for HTTPS pushes or scripts.</p><a class="btn" href="/settings/keys">Manage keys ▸</a></section> |
| 9 | 10 | </div> |
templates/keys.html modified
| @@ -4,7 +4,14 @@ | ||
| 4 | 4 | <form method="post" action="/settings/keys" class="stack"> |
| 5 | 5 | <label>SSH public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label> |
| 6 | 6 | <label>Title <input name="title" placeholder="Defaults to the key comment"></label> |
| 7 | -<small>Push with <code>{{.SSHUser}}@{{.Host}}:~{{.User.Name}}/repo</code> once the key is registered.</small> | |
| 7 | +<small>Push with <code>{{.SSHUser}}@{{.Host}}:~{{.User.Name}}/repo</code> once the key is registered. Your keys are public at <a href="/~{{.User.Name}}.keys">~{{.User.Name}}.keys</a>.</small> | |
| 8 | +<div class="actions"><button class="btn primary">Add key ▸</button></div> | |
| 9 | +</form></div> | |
| 10 | +<div class="panel"><h3>PGP keys</h3> | |
| 11 | +{{if .Data.PGP}}<table class="keys">{{range .Data.PGP}}<tr><td><code>{{.Fingerprint}}</code></td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/pgp/{{.ID}}/delete"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}} | |
| 12 | +<form method="post" action="/settings/pgp" class="stack"> | |
| 13 | +<label>PGP public key <textarea name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea></label> | |
| 14 | +<small>Export yours with <code>gpg --armor --export-options export-minimal --export you@example.org</code>. Commits signed by this key show as verified. Public at <a href="/~{{.User.Name}}.pgp">~{{.User.Name}}.pgp</a>.</small> | |
| 8 | 15 | <div class="actions"><button class="btn primary">Add key ▸</button></div> |
| 9 | 16 | </form></div> |
| 10 | 17 | {{end}} |
templates/pull.html modified
| @@ -21,8 +21,13 @@ | ||
| 21 | 21 | {{else if .Data.Mergeable}}<p>This branch can be merged automatically.</p>{{else}}<p>This branch has conflicts or nothing new to merge. Resolve locally, push, then update.</p>{{end}} |
| 22 | 22 | {{if .Data.Owner}}{{if .Data.Confirm}} |
| 23 | 23 | <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge" class="stack mergeform"> |
| 24 | +<label class="radio"><input type="radio" name="strategy" value="merge" checked> Merge commit<small>Both parents kept</small></label> | |
| 25 | +<label class="radio"><input type="radio" name="strategy" value="squash"> Squash<small>One commit, authored by ~{{$pr.Author.Name}}</small></label> | |
| 26 | +<label class="radio"><input type="radio" name="strategy" value="rebase"> Rebase<small>Commits replayed onto {{$pr.TargetBranch}}, message below ignored</small></label> | |
| 24 | 27 | <label>Commit message <input name="title" value="{{.Data.MergeTitle}}" maxlength="200" required></label> |
| 25 | -<label>Extended description <textarea name="body" rows="4">{{$pr.Title}}</textarea></label> | |
| 28 | +<label>Extended description <textarea name="body" rows="4">{{$pr.Title}}{{with .Data.SquashBody}} | |
| 29 | + | |
| 30 | +{{.}}{{end}}</textarea></label> | |
| 26 | 31 | <div class="actions left"><button class="btn primary">Confirm merge ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}">Cancel</a></div> |
| 27 | 32 | </form> |
| 28 | 33 | {{else}}{{if .Data.Mergeable}}<a class="btn primary block" href="?merge=1">Merge pull request ▸</a>{{else}}<button class="btn primary block" disabled>Merge pull request ▸</button>{{end}}{{end}}{{end}} |
templates/reposettings.html modified
| @@ -14,6 +14,13 @@ | ||
| 14 | 14 | <label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label> |
| 15 | 15 | </div> |
| 16 | 16 | </form> |
| 17 | +<h3>Collaborators</h3> | |
| 18 | +<p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p> | |
| 19 | +{{if .Data.Collaborators}}<table class="keys">{{range .Data.Collaborators}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/{{$p.Repo.Path}}/collaborators"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}} | |
| 20 | +<form method="post" action="/{{$p.Repo.Path}}/collaborators" class="stack"> | |
| 21 | +<label>Username <input name="name" placeholder="~someone" required></label> | |
| 22 | +<div class="actions left"><button class="btn primary">Add collaborator ▸</button></div> | |
| 23 | +</form> | |
| 17 | 24 | <h3>Delete repository</h3> |
| 18 | 25 | <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack"> |
| 19 | 26 | <p>This permanently removes the repository and its history from the server.</p> |
templates/settings.html modified
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Profile}} |
| 3 | 3 | <h3 class="rule">Edit your profile</h3> |
| 4 | +<p class="muted small">Disk usage {{size .Data.Used}}{{if .Data.Quota}} of {{size .Data.Quota}}{{end}}.</p> | |
| 4 | 5 | <form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform"> |
| 5 | 6 | <div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div> |
| 6 | 7 | <div> |
web.go modified
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "crypto/sha256" |
| 6 | 6 | "embed" |
| 7 | 7 | "encoding/hex" |
| 8 | + "errors" | |
| 8 | 9 | "fmt" |
| 9 | 10 | "html/template" |
| 10 | 11 | "io" |
| @@ -13,6 +14,7 @@ import ( | ||
| 13 | 14 | "mime" |
| 14 | 15 | "net/http" |
| 15 | 16 | "os" |
| 17 | + "os/exec" | |
| 16 | 18 | "path/filepath" |
| 17 | 19 | "regexp" |
| 18 | 20 | "slices" |
| @@ -95,6 +97,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 95 | 97 | s.policy = bluemonday.UGCPolicy() |
| 96 | 98 | s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code") |
| 97 | 99 | s.writeSigners() |
| 100 | + s.writeKeyring() | |
| 98 | 101 | sum := sha256.New() |
| 99 | 102 | css, _ := staticFS.ReadFile("static/style.css") |
| 100 | 103 | sum.Write(css) |
| @@ -171,6 +174,8 @@ func (s *Server) handler() http.Handler { | ||
| 171 | 174 | mux.HandleFunc("POST /settings/sessions", s.endSessions) |
| 172 | 175 | mux.HandleFunc("POST /settings/keys", s.addKey) |
| 173 | 176 | mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey) |
| 177 | + mux.HandleFunc("POST /settings/pgp", s.addPGP) | |
| 178 | + mux.HandleFunc("POST /settings/pgp/{id}/delete", s.deletePGP) | |
| 174 | 179 | mux.HandleFunc("POST /settings/tokens", s.addToken) |
| 175 | 180 | mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken) |
| 176 | 181 | mux.HandleFunc("POST /settings/password", s.password) |
| @@ -189,12 +194,13 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 189 | 194 | s.fail(w, r, 500, err.Error()) |
| 190 | 195 | return |
| 191 | 196 | } |
| 192 | - var repos []Repo | |
| 197 | + var repos, shared []Repo | |
| 193 | 198 | if user != nil { |
| 194 | 199 | repos, _ = s.store.ReposByOwner(user.ID, true) |
| 200 | + shared, _ = s.store.SharedRepos(user.ID) | |
| 195 | 201 | } |
| 196 | 202 | users, public := s.store.Stats() |
| 197 | - s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Recent": recent, "Users": users, "Public": public}}) | |
| 203 | + s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent, "Users": users, "Public": public}}) | |
| 198 | 204 | } |
| 199 | 205 | |
| 200 | 206 | func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { |
| @@ -308,15 +314,69 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 308 | 314 | } |
| 309 | 315 | |
| 310 | 316 | func (s *Server) signer(c Commit) string { |
| 311 | - if c.Signature != "G" || c.Key == "" { | |
| 317 | + switch c.Signature { | |
| 318 | + case "G", "U": | |
| 319 | + default: | |
| 312 | 320 | return "" |
| 313 | 321 | } |
| 314 | - if u, ok := s.store.UserByFingerprint(c.Key); ok { | |
| 315 | - return u.Name | |
| 322 | + if c.Key != "" { | |
| 323 | + if u, ok := s.store.UserByFingerprint(c.Key); ok { | |
| 324 | + return u.Name | |
| 325 | + } | |
| 326 | + } | |
| 327 | + if c.PGP != "" { | |
| 328 | + if u, ok := s.store.UserByPGP(c.PGP); ok { | |
| 329 | + return u.Name | |
| 330 | + } | |
| 316 | 331 | } |
| 317 | 332 | return "" |
| 318 | 333 | } |
| 319 | 334 | |
| 335 | +func (s *Server) writeKeyring() { | |
| 336 | + home := filepath.Join(s.root, "gnupg") | |
| 337 | + if err := os.MkdirAll(home, 0o700); err != nil { | |
| 338 | + return | |
| 339 | + } | |
| 340 | + keys, err := s.store.AllPGP() | |
| 341 | + if err != nil { | |
| 342 | + return | |
| 343 | + } | |
| 344 | + os.Remove(filepath.Join(home, "pubring.kbx")) | |
| 345 | + os.Remove(filepath.Join(home, "pubring.kbx~")) | |
| 346 | + for _, k := range keys { | |
| 347 | + cmd := exec.Command("gpg", "--batch", "--quiet", "--import") | |
| 348 | + cmd.Env = []string{"GNUPGHOME=" + home, "PATH=" + os.Getenv("PATH")} | |
| 349 | + cmd.Stdin = strings.NewReader(k) | |
| 350 | + cmd.Run() | |
| 351 | + } | |
| 352 | + exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run() | |
| 353 | + gnupgHome = home | |
| 354 | +} | |
| 355 | + | |
| 356 | +func pgpFingerprint(armored string) (string, error) { | |
| 357 | + cmd := exec.Command("gpg", "--batch", "--with-colons", "--import-options", "show-only", "--import") | |
| 358 | + dir, err := os.MkdirTemp("", "basin-gpg") | |
| 359 | + if err != nil { | |
| 360 | + return "", err | |
| 361 | + } | |
| 362 | + defer os.RemoveAll(dir) | |
| 363 | + cmd.Env = []string{"GNUPGHOME=" + dir, "PATH=" + os.Getenv("PATH")} | |
| 364 | + cmd.Stdin = strings.NewReader(armored) | |
| 365 | + out, err := cmd.Output() | |
| 366 | + if err != nil { | |
| 367 | + return "", errors.New("That is not a valid armored PGP public key") | |
| 368 | + } | |
| 369 | + for _, line := range strings.Split(string(out), "\n") { | |
| 370 | + if strings.HasPrefix(line, "fpr:") { | |
| 371 | + f := strings.Split(line, ":") | |
| 372 | + if len(f) > 9 && f[9] != "" { | |
| 373 | + return strings.ToUpper(f[9]), nil | |
| 374 | + } | |
| 375 | + } | |
| 376 | + } | |
| 377 | + return "", errors.New("That is not a valid armored PGP public key") | |
| 378 | +} | |
| 379 | + | |
| 320 | 380 | func (s *Server) writeSigners() { |
| 321 | 381 | keys, err := s.store.AllKeys() |
| 322 | 382 | if err != nil { |
| @@ -392,8 +452,9 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { | ||
| 392 | 452 | if u == nil { |
| 393 | 453 | return |
| 394 | 454 | } |
| 455 | + used := usage(filepath.Join(s.root, "repos", u.Name)) | |
| 395 | 456 | s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"), |
| 396 | - Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID)}}) | |
| 457 | + Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root)}}) | |
| 397 | 458 | } |
| 398 | 459 | |
| 399 | 460 | func (s *Server) security(w http.ResponseWriter, r *http.Request) { |
| @@ -410,7 +471,41 @@ func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) { | ||
| 410 | 471 | return |
| 411 | 472 | } |
| 412 | 473 | keys, _ := s.store.Keys(u.ID) |
| 413 | - s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys}}) | |
| 474 | + pgp, _ := s.store.PGPKeys(u.ID) | |
| 475 | + s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys, "PGP": pgp}}) | |
| 476 | +} | |
| 477 | + | |
| 478 | +func (s *Server) addPGP(w http.ResponseWriter, r *http.Request) { | |
| 479 | + u := s.require(w, r) | |
| 480 | + if u == nil { | |
| 481 | + return | |
| 482 | + } | |
| 483 | + armored := strings.TrimSpace(r.FormValue("key")) | |
| 484 | + if len(armored) > 64<<10 { | |
| 485 | + http.Redirect(w, r, "/settings/keys?error=Key+too+large", http.StatusSeeOther) | |
| 486 | + return | |
| 487 | + } | |
| 488 | + fp, err := pgpFingerprint(armored) | |
| 489 | + if err == nil { | |
| 490 | + err = s.store.AddPGP(u.ID, fp, armored) | |
| 491 | + } | |
| 492 | + if err != nil { | |
| 493 | + http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 494 | + return | |
| 495 | + } | |
| 496 | + s.writeKeyring() | |
| 497 | + http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) | |
| 498 | +} | |
| 499 | + | |
| 500 | +func (s *Server) deletePGP(w http.ResponseWriter, r *http.Request) { | |
| 501 | + u := s.require(w, r) | |
| 502 | + if u == nil { | |
| 503 | + return | |
| 504 | + } | |
| 505 | + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) | |
| 506 | + s.store.DeletePGP(u.ID, id) | |
| 507 | + s.writeKeyring() | |
| 508 | + http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) | |
| 414 | 509 | } |
| 415 | 510 | |
| 416 | 511 | func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) { |
| @@ -582,6 +677,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 582 | 677 | return |
| 583 | 678 | } |
| 584 | 679 | parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/~"), "/", 3) |
| 680 | + if len(parts) == 1 && (strings.HasSuffix(parts[0], ".keys") || strings.HasSuffix(parts[0], ".pgp")) { | |
| 681 | + s.publicKeys(w, r, parts[0]) | |
| 682 | + return | |
| 683 | + } | |
| 585 | 684 | owner, err := s.store.UserByName(parts[0]) |
| 586 | 685 | if err != nil { |
| 587 | 686 | s.fail(w, r, 404, "No such user") |
| @@ -620,7 +719,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 620 | 719 | s.fail(w, r, 404, "No such repository") |
| 621 | 720 | return |
| 622 | 721 | } |
| 623 | - if repo.Private && (user == nil || user.ID != owner.ID) { | |
| 722 | + if !s.store.CanRead(repo, user) { | |
| 624 | 723 | s.fail(w, r, 404, "No such repository") |
| 625 | 724 | return |
| 626 | 725 | } |
| @@ -633,6 +732,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 633 | 732 | s.updateRepo(w, r, repo) |
| 634 | 733 | case rest == "delete" && r.Method == "POST": |
| 635 | 734 | s.deleteRepo(w, r, repo) |
| 735 | + case rest == "collaborators" && r.Method == "POST": | |
| 736 | + s.collaborators(w, r, repo) | |
| 636 | 737 | case rest == "refs" && r.Method == "GET": |
| 637 | 738 | s.refs(w, r, repo) |
| 638 | 739 | case strings.HasPrefix(rest, "tree/") && r.Method == "GET": |
| @@ -664,22 +765,41 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 664 | 765 | } |
| 665 | 766 | } |
| 666 | 767 | |
| 768 | +func (s *Server) publicKeys(w http.ResponseWriter, r *http.Request, spec string) { | |
| 769 | + name, kind, _ := strings.Cut(spec, ".") | |
| 770 | + u, err := s.store.UserByName(name) | |
| 771 | + if err != nil { | |
| 772 | + http.NotFound(w, r) | |
| 773 | + return | |
| 774 | + } | |
| 775 | + ssh, pgp := s.store.PublicKeys(u.ID) | |
| 776 | + w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 777 | + list := ssh | |
| 778 | + if kind == "pgp" { | |
| 779 | + list = pgp | |
| 780 | + } | |
| 781 | + for _, k := range list { | |
| 782 | + fmt.Fprintln(w, k) | |
| 783 | + } | |
| 784 | +} | |
| 785 | + | |
| 667 | 786 | type repoPage struct { |
| 668 | - Repo Repo | |
| 669 | - Ref string | |
| 670 | - Commit string | |
| 671 | - Owner bool | |
| 672 | - Empty bool | |
| 673 | - Tab string | |
| 674 | - Open int | |
| 675 | - Parent *Repo | |
| 676 | - User *User | |
| 787 | + Repo Repo | |
| 788 | + Ref string | |
| 789 | + Commit string | |
| 790 | + Owner bool | |
| 791 | + Empty bool | |
| 792 | + Tab string | |
| 793 | + Open int | |
| 794 | + Parent *Repo | |
| 795 | + User *User | |
| 796 | + IsOwner bool | |
| 677 | 797 | } |
| 678 | 798 | |
| 679 | 799 | func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) { |
| 680 | 800 | g := Git{Dir: s.repoDir(repo)} |
| 681 | 801 | user := s.current(r) |
| 682 | - rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user} | |
| 802 | + rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user} | |
| 683 | 803 | if repo.Parent != 0 { |
| 684 | 804 | if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { |
| 685 | 805 | rp.Parent = &parent |
| @@ -732,9 +852,9 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 732 | 852 | |
| 733 | 853 | var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`) |
| 734 | 854 | |
| 735 | -const maxRender = 1 << 20 | |
| 855 | +const maxRender = 4 << 20 | |
| 736 | 856 | |
| 737 | -const maxRaw = 64 << 20 | |
| 857 | +const maxHighlight = 512 << 10 | |
| 738 | 858 | |
| 739 | 859 | func (s *Server) renderMarkdown(src []byte) template.HTML { |
| 740 | 860 | src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) |
| @@ -825,7 +945,7 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 825 | 945 | data["Binary"] = true |
| 826 | 946 | } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" { |
| 827 | 947 | data["Markdown"] = s.renderMarkdown(content) |
| 828 | - } else if h, ok := highlight(filePath, content); ok { | |
| 948 | + } else if h, ok := highlightLimited(filePath, content); ok { | |
| 829 | 949 | data["Highlighted"] = h |
| 830 | 950 | } else { |
| 831 | 951 | data["Content"] = content |
| @@ -853,19 +973,15 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str | ||
| 853 | 973 | s.fail(w, r, 404, "No such ref") |
| 854 | 974 | return |
| 855 | 975 | } |
| 856 | - if g.Size(commit, filePath) > maxRaw { | |
| 857 | - s.fail(w, r, 413, "File too large to serve") | |
| 858 | - return | |
| 859 | - } | |
| 860 | - content, err := g.Blob(commit, filePath) | |
| 861 | - if err != nil { | |
| 976 | + if g.Kind(commit, filePath) != "blob" { | |
| 862 | 977 | s.fail(w, r, 404, "No such path") |
| 863 | 978 | return |
| 864 | 979 | } |
| 865 | 980 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 866 | 981 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 982 | + w.Header().Set("Content-Length", strconv.FormatInt(g.Size(commit, filePath), 10)) | |
| 867 | 983 | w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) |
| 868 | - w.Write(content) | |
| 984 | + g.Stream(w, commit, filePath) | |
| 869 | 985 | } |
| 870 | 986 | |
| 871 | 987 | func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref string) { |
| @@ -982,17 +1098,40 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 982 | 1098 | |
| 983 | 1099 | func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) { |
| 984 | 1100 | rp, _, _ := s.repoPage(r, repo, "") |
| 985 | - if !rp.Owner { | |
| 1101 | + if !rp.IsOwner { | |
| 986 | 1102 | s.fail(w, r, 403, "Not your repository") |
| 987 | 1103 | return |
| 988 | 1104 | } |
| 989 | 1105 | rp.Tab = "settings" |
| 990 | - s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Data: map[string]any{"Page": rp}}) | |
| 1106 | + collabs, _ := s.store.Collaborators(repo.ID) | |
| 1107 | + s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs}}) | |
| 1108 | +} | |
| 1109 | + | |
| 1110 | +func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 1111 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 1112 | + if !rp.IsOwner { | |
| 1113 | + s.fail(w, r, 403, "Not your repository") | |
| 1114 | + return | |
| 1115 | + } | |
| 1116 | + back := "/" + repo.Path() + "/settings" | |
| 1117 | + if id, err := strconv.ParseInt(r.FormValue("remove"), 10, 64); err == nil { | |
| 1118 | + s.store.RemoveCollaborator(repo.ID, id) | |
| 1119 | + } else if name := strings.TrimSpace(strings.TrimPrefix(r.FormValue("name"), "~")); name != "" { | |
| 1120 | + if name == repo.Owner.Name { | |
| 1121 | + http.Redirect(w, r, back+"?error=That+is+you", http.StatusSeeOther) | |
| 1122 | + return | |
| 1123 | + } | |
| 1124 | + if err := s.store.AddCollaborator(repo.ID, name); err != nil { | |
| 1125 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 1126 | + return | |
| 1127 | + } | |
| 1128 | + } | |
| 1129 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 991 | 1130 | } |
| 992 | 1131 | |
| 993 | 1132 | func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { |
| 994 | 1133 | rp, _, _ := s.repoPage(r, repo, "") |
| 995 | - if !rp.Owner { | |
| 1134 | + if !rp.IsOwner { | |
| 996 | 1135 | s.fail(w, r, 403, "Not your repository") |
| 997 | 1136 | return |
| 998 | 1137 | } |
| @@ -1007,7 +1146,7 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 1007 | 1146 | |
| 1008 | 1147 | func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) { |
| 1009 | 1148 | rp, _, _ := s.repoPage(r, repo, "") |
| 1010 | - if !rp.Owner { | |
| 1149 | + if !rp.IsOwner { | |
| 1011 | 1150 | s.fail(w, r, 403, "Not your repository") |
| 1012 | 1151 | return |
| 1013 | 1152 | } |