~gitpocket/gitpocket

feat(basin): squash and rebase merges, collaborators, disk quota, pgp verification, streamed raw files

commit
5b7fbd8a0f64110a74b33e15b31664c736a1ea22 browse tree
parent
5b5b41dc8de84e46237f6bbc90fd400b1a46a0a9
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-12 23:08 UTC

16 files changed, +609 −52

README.md modified

@@ -8,8 +8,11 @@ Small and lightweight git server for self hosting. Repositories are served over
88 - 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
99 - 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
1010 - 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`
1316 - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password
1417 - 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
1518
@@ -71,7 +74,7 @@ To send changes to something you do not own, fork it from the button beside the
7174
7275 ## Limits
7376
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.
7578
7679 ## Licence
7780

git.go modified

@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "io"
78 "os"
89 "os/exec"
910 "path"
@@ -28,10 +29,13 @@ type Commit struct {
2829 Parents []string
2930 Signature string
3031 Key string
32+ PGP string
3133 }
3234
3335 var signersFile string
3436
37+var gnupgHome string
38+
3539 type Entry struct {
3640 Mode string
3741 Type string
@@ -82,6 +86,9 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error)
8286 if signersFile != "" {
8387 cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile)
8488 }
89+ if gnupgHome != "" {
90+ cmd.Env = append(cmd.Env, "GNUPGHOME="+gnupgHome)
91+ }
8592 if stdin != nil {
8693 cmd.Stdin = bytes.NewReader(stdin)
8794 }
@@ -146,6 +153,107 @@ func (g Git) Mergeable(target, head string) bool {
146153 return err == nil
147154 }
148155
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+
149257 func (g Git) Merge(target, head, message, name, email string) (string, error) {
150258 targetHash, err := g.Resolve("refs/heads/" + target)
151259 if err != nil {
@@ -199,19 +307,19 @@ func (g Git) Resolve(ref string) (string, error) {
199307 return strings.TrimSpace(string(out)), nil
200308 }
201309
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"
203311
204312 func parseCommits(out []byte) []Commit {
205313 fields := strings.Split(string(out), "\x00")
206314 var commits []Commit
207- for len(fields) >= 9 {
315+ for len(fields) >= 10 {
208316 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])}
210318 if fields[4] != "" {
211319 c.Parents = strings.Split(fields[4], " ")
212320 }
213321 commits = append(commits, c)
214- fields = fields[9:]
322+ fields = fields[10:]
215323 if len(fields) > 0 {
216324 fields[0] = strings.TrimLeft(fields[0], "\n")
217325 }
@@ -371,6 +479,15 @@ func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit {
371479 return found
372480 }
373481
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+
374491 func (g Git) Blob(commit, filePath string) ([]byte, error) {
375492 out, err := g.run(nil, "cat-file", "blob", commit+":"+filePath)
376493 if err != nil {

highlight.go modified

@@ -21,10 +21,14 @@ var formatter = html.New(
2121
2222 var highlightStyle = styles.Get("pygments")
2323
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 {
2626 return "", false
2727 }
28+ return highlight(name, content)
29+}
30+
31+func highlight(name string, content []byte) (template.HTML, bool) {
2832 lexer := lexers.Match(filepath.Base(name))
2933 if lexer == nil {
3034 lexer = lexers.Analyse(string(content))

main.go modified

@@ -7,6 +7,7 @@ import (
77 "net/http"
88 "os"
99 "path/filepath"
10+ "strconv"
1011 "time"
1112 )
1213
@@ -19,6 +20,7 @@ func main() {
1920 keys user home type key sshd AuthorizedKeysCommand
2021 shell user forced command for ssh git access
2122 invite print a single use registration code
23+ quota [size] show or set the per user disk quota, 0 disables
2224 user name create a user, password read from stdin`)
2325 }
2426 flag.Parse()
@@ -38,6 +40,21 @@ func main() {
3840 os.Exit(2)
3941 }
4042 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+ }
4158 case "invite":
4259 store := mustStore(*root)
4360 code, err := store.CreateInvite()

pull.go modified

@@ -350,7 +350,7 @@ func (s *Server) pullSources(repo Repo, user *User) []sourceOption {
350350 if user != nil {
351351 forks, _ := s.store.Forks(repo.ID, user.ID)
352352 for _, f := range forks {
353- if f.Owner.ID == user.ID {
353+ if s.store.CanWrite(f, user) {
354354 add(f)
355355 }
356356 }
@@ -385,7 +385,7 @@ func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, strin
385385 if err != nil {
386386 return Repo{}, "", errors.New("No such source repository")
387387 }
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)) {
389389 return Repo{}, "", errors.New("Source must be this repository or your own fork of it")
390390 }
391391 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
459459 if !ok {
460460 return
461461 }
462- isOwner := user != nil && user.ID == repo.Owner.ID
462+ isOwner := s.store.CanWrite(repo, user)
463463 isAuthor := user != nil && user.ID == pull.Author.ID
464464 if r.Method == "POST" {
465465 if user == nil {
@@ -525,7 +525,16 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
525525 if body := strings.TrimSpace(r.FormValue("body")); body != "" {
526526 message += "\n\n" + body
527527 }
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+ }
529538 if err != nil {
530539 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
531540 return
@@ -600,6 +609,11 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
600609 if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" {
601610 data["Confirm"] = true
602611 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")
603617 }
604618 }
605619 } 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 {
6262 if err != nil {
6363 return fail("repository not found")
6464 }
65- isOwner := repo.Owner.ID == user.ID
66- if repo.Private && !isOwner {
65+ canWrite := store.CanWrite(repo, &user)
66+ if repo.Private && !canWrite {
6767 return fail("repository not found")
6868 }
69- if verb == "git-receive-pack" && !isOwner {
69+ if verb == "git-receive-pack" && !canWrite {
7070 return fail("you do not have write access to " + repo.Path())
7171 }
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+ }
7277 dir := filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git")
7378 git, err := exec.LookPath("git")
7479 if err != nil {

smart.go modified

@@ -54,7 +54,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
5454 return
5555 }
5656 u, ok := s.basicUser(r)
57- if !ok || u.ID != repo.Owner.ID {
57+ if !ok || !s.store.CanWrite(repo, &u) {
5858 if _, _, given := r.BasicAuth(); given {
5959 s.limiter.fail(ip)
6060 }
@@ -63,6 +63,12 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
6363 return
6464 }
6565 }
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+ }
6672 dir := s.repoDir(repo)
6773 w.Header().Set("Cache-Control", "no-cache")
6874 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)

static/style.css modified

@@ -251,3 +251,5 @@ mark{background:#fff3b0;padding:0}
251251 .reviewform{margin:0}
252252 .reviewform textarea{width:100%}
253253 .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{
6969 `alter table repos add column parent integer not null default 0`,
7070 }
7171
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+
7288 const (
7389 Public = 0
7490 Private = 1
@@ -173,6 +189,9 @@ func openStore(path string, readonly bool) (*Store, error) {
173189 if _, err := db.Exec(pullSchema); err != nil {
174190 return nil, err
175191 }
192+ if _, err := db.Exec(collabSchema); err != nil {
193+ return nil, err
194+ }
176195 }
177196 return &Store{db}, nil
178197 }
@@ -553,6 +572,152 @@ func (s *Store) SetAvatar(user int64, version int64) error {
553572 return err
554573 }
555574
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+
556721 func (s *Store) CreateInvite() (string, error) {
557722 code := random(12)
558723 _, err := s.db.Exec(`insert into invites(code) values (?)`, code)

templates/index.html modified

@@ -3,7 +3,8 @@
33 <div class="cards">
44 <section class="panel"><h3>Your repositories</h3>
55 {{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>
78 <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>
89 <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>
910 </div>

templates/keys.html modified

@@ -4,7 +4,14 @@
44 <form method="post" action="/settings/keys" class="stack">
55 <label>SSH public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label>
66 <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>
815 <div class="actions"><button class="btn primary">Add key ▸</button></div>
916 </form></div>
1017 {{end}}

templates/pull.html modified

@@ -21,8 +21,13 @@
2121 {{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}}
2222 {{if .Data.Owner}}{{if .Data.Confirm}}
2323 <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>
2427 <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>
2631 <div class="actions left"><button class="btn primary">Confirm merge ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}">Cancel</a></div>
2732 </form>
2833 {{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 @@
1414 <label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label>
1515 </div>
1616 </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>
1724 <h3>Delete repository</h3>
1825 <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack">
1926 <p>This permanently removes the repository and its history from the server.</p>

templates/settings.html modified

@@ -1,6 +1,7 @@
11 {{define "content"}}
22 {{$p := .Data.Profile}}
33 <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>
45 <form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform">
56 <div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div>
67 <div>

web.go modified

@@ -5,6 +5,7 @@ import (
55 "crypto/sha256"
66 "embed"
77 "encoding/hex"
8+ "errors"
89 "fmt"
910 "html/template"
1011 "io"
@@ -13,6 +14,7 @@ import (
1314 "mime"
1415 "net/http"
1516 "os"
17+ "os/exec"
1618 "path/filepath"
1719 "regexp"
1820 "slices"
@@ -95,6 +97,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
9597 s.policy = bluemonday.UGCPolicy()
9698 s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code")
9799 s.writeSigners()
100+ s.writeKeyring()
98101 sum := sha256.New()
99102 css, _ := staticFS.ReadFile("static/style.css")
100103 sum.Write(css)
@@ -171,6 +174,8 @@ func (s *Server) handler() http.Handler {
171174 mux.HandleFunc("POST /settings/sessions", s.endSessions)
172175 mux.HandleFunc("POST /settings/keys", s.addKey)
173176 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)
174179 mux.HandleFunc("POST /settings/tokens", s.addToken)
175180 mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken)
176181 mux.HandleFunc("POST /settings/password", s.password)
@@ -189,12 +194,13 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
189194 s.fail(w, r, 500, err.Error())
190195 return
191196 }
192- var repos []Repo
197+ var repos, shared []Repo
193198 if user != nil {
194199 repos, _ = s.store.ReposByOwner(user.ID, true)
200+ shared, _ = s.store.SharedRepos(user.ID)
195201 }
196202 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}})
198204 }
199205
200206 func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
@@ -308,15 +314,69 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
308314 }
309315
310316 func (s *Server) signer(c Commit) string {
311- if c.Signature != "G" || c.Key == "" {
317+ switch c.Signature {
318+ case "G", "U":
319+ default:
312320 return ""
313321 }
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+ }
316331 }
317332 return ""
318333 }
319334
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+
320380 func (s *Server) writeSigners() {
321381 keys, err := s.store.AllKeys()
322382 if err != nil {
@@ -392,8 +452,9 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
392452 if u == nil {
393453 return
394454 }
455+ used := usage(filepath.Join(s.root, "repos", u.Name))
395456 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)}})
397458 }
398459
399460 func (s *Server) security(w http.ResponseWriter, r *http.Request) {
@@ -410,7 +471,41 @@ func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) {
410471 return
411472 }
412473 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)
414509 }
415510
416511 func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) {
@@ -582,6 +677,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
582677 return
583678 }
584679 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+ }
585684 owner, err := s.store.UserByName(parts[0])
586685 if err != nil {
587686 s.fail(w, r, 404, "No such user")
@@ -620,7 +719,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
620719 s.fail(w, r, 404, "No such repository")
621720 return
622721 }
623- if repo.Private && (user == nil || user.ID != owner.ID) {
722+ if !s.store.CanRead(repo, user) {
624723 s.fail(w, r, 404, "No such repository")
625724 return
626725 }
@@ -633,6 +732,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
633732 s.updateRepo(w, r, repo)
634733 case rest == "delete" && r.Method == "POST":
635734 s.deleteRepo(w, r, repo)
735+ case rest == "collaborators" && r.Method == "POST":
736+ s.collaborators(w, r, repo)
636737 case rest == "refs" && r.Method == "GET":
637738 s.refs(w, r, repo)
638739 case strings.HasPrefix(rest, "tree/") && r.Method == "GET":
@@ -664,22 +765,41 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
664765 }
665766 }
666767
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+
667786 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
677797 }
678798
679799 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
680800 g := Git{Dir: s.repoDir(repo)}
681801 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}
683803 if repo.Parent != 0 {
684804 if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private {
685805 rp.Parent = &parent
@@ -732,9 +852,9 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
732852
733853 var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`)
734854
735-const maxRender = 1 << 20
855+const maxRender = 4 << 20
736856
737-const maxRaw = 64 << 20
857+const maxHighlight = 512 << 10
738858
739859 func (s *Server) renderMarkdown(src []byte) template.HTML {
740860 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
825945 data["Binary"] = true
826946 } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" {
827947 data["Markdown"] = s.renderMarkdown(content)
828- } else if h, ok := highlight(filePath, content); ok {
948+ } else if h, ok := highlightLimited(filePath, content); ok {
829949 data["Highlighted"] = h
830950 } else {
831951 data["Content"] = content
@@ -853,19 +973,15 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str
853973 s.fail(w, r, 404, "No such ref")
854974 return
855975 }
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" {
862977 s.fail(w, r, 404, "No such path")
863978 return
864979 }
865980 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
866981 w.Header().Set("X-Content-Type-Options", "nosniff")
982+ w.Header().Set("Content-Length", strconv.FormatInt(g.Size(commit, filePath), 10))
867983 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)
869985 }
870986
871987 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) {
9821098
9831099 func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) {
9841100 rp, _, _ := s.repoPage(r, repo, "")
985- if !rp.Owner {
1101+ if !rp.IsOwner {
9861102 s.fail(w, r, 403, "Not your repository")
9871103 return
9881104 }
9891105 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)
9911130 }
9921131
9931132 func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
9941133 rp, _, _ := s.repoPage(r, repo, "")
995- if !rp.Owner {
1134+ if !rp.IsOwner {
9961135 s.fail(w, r, 403, "Not your repository")
9971136 return
9981137 }
@@ -1007,7 +1146,7 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
10071146
10081147 func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
10091148 rp, _, _ := s.repoPage(r, repo, "")
1010- if !rp.Owner {
1149+ if !rp.IsOwner {
10111150 s.fail(w, r, 403, "Not your repository")
10121151 return
10131152 }