~gitpocket/gitpocket

feat(basin): minimal git host with ssh and smart https

commit
d622d6a95212d49329a541b39c31d0429e05b9a7 browse tree
author
james <j@cpl0.zip>
signature
none
date
2026-11-09 09:56 UTC

26 files changed, +2147 −0

.gitignore added

@@ -0,0 +1 @@
1+forge

README.md added

@@ -0,0 +1,32 @@
1+forge is a small self hosted git server in Go modelled on the browsing side of sourcehut. It serves repositories over ssh and smart https, renders trees, blobs, logs, diffs, refs and READMEs as plain server side HTML with no JavaScript, and keeps users, keys, tokens and repository metadata in a single SQLite file.
2+
3+## Build
4+
5+```
6+CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o forge .
7+```
8+
9+The SQLite driver is the pure Go modernc port, so the binary is static and copies to the server as one file next to a system `git`.
10+
11+## Usage
12+
13+```
14+forge -root /srv/forge serve -listen 127.0.0.1:3200 -host git.example.org
15+forge -root /srv/forge invite
16+```
17+
18+The data directory holds `forge.db` and a `repos/<user>/<name>.git` tree of bare repositories. Registration needs a single use invite from the second command unless `serve` is given `-open`. Clone URLs take the sourcehut shape `https://host/~user/repo` and `git@host:~user/repo`, with the `.git` suffix optional.
19+
20+Every read of git data shells out to the plumbing with a 30 second timeout rather than parsing packfiles in process. Tree listings use `ls-tree -l`, history uses `log` with a NUL separated format, and commit pages parse the unified output of `diff-tree -p --root -M` into hunks for rendering. This keeps the server stateless and lets large repositories inherit git's own performance, at the cost of one process spawn per page section.
21+
22+## SSH
23+
24+Pushes over ssh reuse the host sshd. Point `AuthorizedKeysCommand` for the `git` user at `forge -root /srv/forge keys %u %h %t %k`, which looks the presented key up by SHA256 fingerprint and answers with a forced command of `forge shell <user>`. That forced command reads `SSH_ORIGINAL_COMMAND`, checks ownership and visibility against the database, and execs `git upload-pack` or `git receive-pack` directly, so nothing stays resident between connections. The lookup opens the database read only and runs as root, while the forced command runs as the `git` user who owns the repositories.
25+
26+## HTTPS
27+
28+Anonymous clients may fetch public repositories. Private repositories and all pushes require basic auth with either the account password or an access token created in settings. Token hashes are stored as SHA256, passwords as bcrypt. The web forms rely on the standard library cross origin protection, so a POST without a same origin `Sec-Fetch-Site` or `Origin` header from a browser is refused while non browser git clients pass.
29+
30+## Limitations
31+
32+There is no syntax highlighting, no search, no per directory last commit column and no collaborators beyond the owner. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet. Markdown READMEs are rendered with goldmark and sanitised with bluemonday, other README formats are shown preformatted.

git.go added

@@ -0,0 +1,338 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os/exec"
8+ "path"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+)
14+
15+type Git struct {
16+ Dir string
17+}
18+
19+type Commit struct {
20+ Hash string
21+ Author string
22+ Email string
23+ Time time.Time
24+ Subject string
25+ Body string
26+ Parents []string
27+}
28+
29+type Entry struct {
30+ Mode string
31+ Type string
32+ Hash string
33+ Size int64
34+ Name string
35+}
36+
37+type Ref struct {
38+ Name string
39+ Hash string
40+ Time time.Time
41+}
42+
43+type Line struct {
44+ Kind string
45+ Old int
46+ New int
47+ Text string
48+}
49+
50+type Hunk struct {
51+ Header string
52+ Lines []Line
53+}
54+
55+type File struct {
56+ Old string
57+ New string
58+ Status string
59+ Binary bool
60+ Hunks []Hunk
61+ Added int
62+ Removed int
63+}
64+
65+var ErrMissing = errors.New("missing")
66+
67+func (g Git) run(stdin []byte, args ...string) ([]byte, error) {
68+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
69+ defer cancel()
70+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...)
71+ cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}
72+ if stdin != nil {
73+ cmd.Stdin = bytes.NewReader(stdin)
74+ }
75+ var out, errb bytes.Buffer
76+ cmd.Stdout = &out
77+ cmd.Stderr = &errb
78+ if err := cmd.Run(); err != nil {
79+ msg := strings.TrimSpace(errb.String())
80+ if msg == "" {
81+ msg = err.Error()
82+ }
83+ return nil, errors.New(msg)
84+ }
85+ return out.Bytes(), nil
86+}
87+
88+func initRepo(dir string) error {
89+ _, err := Git{Dir: "/"}.run(nil, "init", "--bare", "--quiet", "-b", "main", dir)
90+ return err
91+}
92+
93+func (g Git) Empty() bool {
94+ _, err := g.run(nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
95+ return err != nil
96+}
97+
98+func (g Git) Head() string {
99+ out, err := g.run(nil, "symbolic-ref", "--short", "HEAD")
100+ if err != nil {
101+ return "main"
102+ }
103+ return strings.TrimSpace(string(out))
104+}
105+
106+func (g Git) Resolve(ref string) (string, error) {
107+ if strings.HasPrefix(ref, "-") {
108+ return "", ErrMissing
109+ }
110+ out, err := g.run(nil, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}")
111+ if err != nil {
112+ return "", ErrMissing
113+ }
114+ return strings.TrimSpace(string(out)), nil
115+}
116+
117+const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00"
118+
119+func parseCommits(out []byte) []Commit {
120+ fields := strings.Split(string(out), "\x00")
121+ var commits []Commit
122+ for len(fields) >= 7 {
123+ t, _ := strconv.ParseInt(fields[3], 10, 64)
124+ c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6])}
125+ if fields[4] != "" {
126+ c.Parents = strings.Split(fields[4], " ")
127+ }
128+ commits = append(commits, c)
129+ fields = fields[7:]
130+ if len(fields) > 0 {
131+ fields[0] = strings.TrimLeft(fields[0], "\n")
132+ }
133+ }
134+ return commits
135+}
136+
137+func (g Git) Log(commit string, n int, filePath string) ([]Commit, error) {
138+ args := []string{"log", "--format=" + logFormat, "-n", strconv.Itoa(n), commit}
139+ if filePath != "" {
140+ args = append(args, "--", filePath)
141+ }
142+ out, err := g.run(nil, args...)
143+ if err != nil {
144+ return nil, err
145+ }
146+ return parseCommits(out), nil
147+}
148+
149+func (g Git) Commit(hash string) (Commit, error) {
150+ out, err := g.run(nil, "log", "--format="+logFormat, "-n", "1", hash)
151+ if err != nil {
152+ return Commit{}, ErrMissing
153+ }
154+ commits := parseCommits(out)
155+ if len(commits) == 0 {
156+ return Commit{}, ErrMissing
157+ }
158+ return commits[0], nil
159+}
160+
161+func (g Git) Tree(commit, dir string) ([]Entry, error) {
162+ spec := commit + ":" + dir
163+ out, err := g.run(nil, "ls-tree", "-l", "-z", spec)
164+ if err != nil {
165+ return nil, ErrMissing
166+ }
167+ var entries []Entry
168+ for _, rec := range bytes.Split(out, []byte{0}) {
169+ if len(rec) == 0 {
170+ continue
171+ }
172+ meta, name, ok := strings.Cut(string(rec), "\t")
173+ if !ok {
174+ continue
175+ }
176+ f := strings.Fields(meta)
177+ if len(f) != 4 {
178+ continue
179+ }
180+ e := Entry{Mode: f[0], Type: f[1], Hash: f[2], Name: name}
181+ if f[3] != "-" {
182+ e.Size, _ = strconv.ParseInt(f[3], 10, 64)
183+ }
184+ entries = append(entries, e)
185+ }
186+ return entries, nil
187+}
188+
189+func (g Git) Blob(commit, filePath string) ([]byte, error) {
190+ out, err := g.run(nil, "cat-file", "blob", commit+":"+filePath)
191+ if err != nil {
192+ return nil, ErrMissing
193+ }
194+ return out, nil
195+}
196+
197+func (g Git) Kind(commit, filePath string) string {
198+ out, err := g.run(nil, "cat-file", "-t", commit+":"+filePath)
199+ if err != nil {
200+ return ""
201+ }
202+ return strings.TrimSpace(string(out))
203+}
204+
205+func (g Git) Refs(prefix string) ([]Ref, error) {
206+ out, err := g.run(nil, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname)%00%(creatordate:unix)", prefix)
207+ if err != nil {
208+ return nil, err
209+ }
210+ var refs []Ref
211+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
212+ f := strings.Split(line, "\x00")
213+ if len(f) != 3 {
214+ continue
215+ }
216+ t, _ := strconv.ParseInt(f[2], 10, 64)
217+ refs = append(refs, Ref{Name: f[0], Hash: f[1], Time: time.Unix(t, 0)})
218+ }
219+ return refs, nil
220+}
221+
222+func (g Git) Readme(commit string) (string, []byte) {
223+ entries, err := g.Tree(commit, "")
224+ if err != nil {
225+ return "", nil
226+ }
227+ for _, name := range []string{"README.md", "README", "readme.md", "README.txt", "readme"} {
228+ for _, e := range entries {
229+ if e.Name == name && e.Type == "blob" {
230+ data, err := g.Blob(commit, name)
231+ if err == nil {
232+ return name, data
233+ }
234+ }
235+ }
236+ }
237+ return "", nil
238+}
239+
240+func (g Git) Diff(hash string) ([]File, error) {
241+ out, err := g.run(nil, "diff-tree", "-p", "--root", "-M", "--no-color", "--find-renames", hash)
242+ if err != nil {
243+ return nil, err
244+ }
245+ return parseDiff(string(out)), nil
246+}
247+
248+func parseDiff(text string) []File {
249+ var files []File
250+ var cur *File
251+ var hunk *Hunk
252+ oldN, newN := 0, 0
253+ for _, line := range strings.Split(text, "\n") {
254+ switch {
255+ case strings.HasPrefix(line, "diff --git "):
256+ files = append(files, File{Status: "modified"})
257+ cur = &files[len(files)-1]
258+ hunk = nil
259+ rest := strings.TrimPrefix(line, "diff --git ")
260+ if a, b, ok := strings.Cut(rest, " b/"); ok {
261+ cur.Old = strings.TrimPrefix(a, "a/")
262+ cur.New = b
263+ }
264+ case cur == nil:
265+ continue
266+ case strings.HasPrefix(line, "new file mode"):
267+ cur.Status = "added"
268+ case strings.HasPrefix(line, "deleted file mode"):
269+ cur.Status = "deleted"
270+ case strings.HasPrefix(line, "rename from "):
271+ cur.Status = "renamed"
272+ cur.Old = strings.TrimPrefix(line, "rename from ")
273+ case strings.HasPrefix(line, "rename to "):
274+ cur.New = strings.TrimPrefix(line, "rename to ")
275+ case strings.HasPrefix(line, "Binary files"):
276+ cur.Binary = true
277+ case strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "similarity ") || strings.HasPrefix(line, "old mode") || strings.HasPrefix(line, "new mode"):
278+ continue
279+ case strings.HasPrefix(line, "@@"):
280+ cur.Hunks = append(cur.Hunks, Hunk{Header: line})
281+ hunk = &cur.Hunks[len(cur.Hunks)-1]
282+ oldN, newN = hunkStart(line)
283+ case hunk == nil:
284+ continue
285+ case strings.HasPrefix(line, "+"):
286+ hunk.Lines = append(hunk.Lines, Line{Kind: "add", New: newN, Text: line[1:]})
287+ newN++
288+ cur.Added++
289+ case strings.HasPrefix(line, "-"):
290+ hunk.Lines = append(hunk.Lines, Line{Kind: "del", Old: oldN, Text: line[1:]})
291+ oldN++
292+ cur.Removed++
293+ case strings.HasPrefix(line, " "):
294+ hunk.Lines = append(hunk.Lines, Line{Kind: "ctx", Old: oldN, New: newN, Text: line[1:]})
295+ oldN++
296+ newN++
297+ case strings.HasPrefix(line, "\\"):
298+ hunk.Lines = append(hunk.Lines, Line{Kind: "meta", Text: line})
299+ }
300+ }
301+ return files
302+}
303+
304+func hunkStart(header string) (int, int) {
305+ f := strings.Fields(header)
306+ if len(f) < 3 {
307+ return 0, 0
308+ }
309+ parse := func(s string) int {
310+ s = strings.TrimLeft(s, "-+")
311+ if i := strings.Index(s, ","); i >= 0 {
312+ s = s[:i]
313+ }
314+ n, _ := strconv.Atoi(s)
315+ return n
316+ }
317+ return parse(f[1]), parse(f[2])
318+}
319+
320+func isBinary(data []byte) bool {
321+ head := data
322+ if len(head) > 8000 {
323+ head = head[:8000]
324+ }
325+ return bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head)
326+}
327+
328+func cleanPath(p string) (string, bool) {
329+ p = strings.Trim(p, "/")
330+ if p == "" {
331+ return "", true
332+ }
333+ c := path.Clean(p)
334+ if c != p || strings.HasPrefix(c, "..") || strings.Contains(c, "/../") || strings.HasPrefix(c, "-") {
335+ return "", false
336+ }
337+ return c, true
338+}

go.mod added

@@ -0,0 +1,25 @@
1+module forge
2+
3+go 1.27.1
4+
5+require (
6+ github.com/microcosm-cc/bluemonday v1.0.27
7+ github.com/yuin/goldmark v1.8.7
8+ golang.org/x/crypto v0.59.0
9+ modernc.org/sqlite v1.62.0
10+)
11+
12+require (
13+ github.com/aymerick/douceur v0.2.0 // indirect
14+ github.com/dustin/go-humanize v1.1.0 // indirect
15+ github.com/google/uuid v1.6.0 // indirect
16+ github.com/gorilla/css v1.0.1 // indirect
17+ github.com/mattn/go-isatty v0.0.24 // indirect
18+ github.com/ncruces/go-strftime v1.1.0 // indirect
19+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
20+ golang.org/x/net v0.62.0 // indirect
21+ golang.org/x/sys v0.50.0 // indirect
22+ modernc.org/libc v1.80.1 // indirect
23+ modernc.org/mathutil v1.7.1 // indirect
24+ modernc.org/memory v1.12.1 // indirect
25+)

go.sum added

@@ -0,0 +1,64 @@
1+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
3+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
4+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
5+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
6+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
7+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
10+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
11+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
12+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
13+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
14+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
15+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
16+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
17+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
18+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
19+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
20+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
21+github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI=
22+github.com/yuin/goldmark v1.8.7/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
23+golang.org/x/crypto v0.59.0 h1:xS2jhqYbzNrpvkWP2YTDf4TJwBy3ykR03i8vFDxEc7c=
24+golang.org/x/crypto v0.59.0/go.mod h1:veDyUwATGs3c+vN3VfTbzViMGXeTBk3WyhBOkn+F/4U=
25+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
26+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
27+golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
28+golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
29+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
30+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
31+golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
32+golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
33+golang.org/x/term v0.48.0 h1:G6UU1eiSB8nl3DzeUGt7Mg+2SctI8tPS6Z5/mBt6cOk=
34+golang.org/x/term v0.48.0/go.mod h1:BRDLHCZr2SUjIeBHHpRyQeC9xzSAVbzY2rvKgafnHf4=
35+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
36+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
37+modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE=
38+modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
39+modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4=
40+modernc.org/ccgo/v4 v4.38.1/go.mod h1:kOpEkJRbFwBvjUWwn4j1j9B7iFHbl1+orhgTMCDkDBk=
41+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
42+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
43+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
44+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
45+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
46+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
47+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
48+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
49+modernc.org/libc v1.80.1 h1:ejn7HtTV3GknNKbklqWQ1nvUrfX8mTxs1D+xp/JQwy4=
50+modernc.org/libc v1.80.1/go.mod h1:pvo25Rs3OJEbDqhUV9jdEGevxeLJrJeDmNid/AcQV1g=
51+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
52+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
53+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
54+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
55+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
56+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
57+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
58+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
59+modernc.org/sqlite v1.62.0 h1:gMaCjSM1qpl+iOjiWhR1LYP3zqrYDI+5GYLGt6veP2g=
60+modernc.org/sqlite v1.62.0/go.mod h1:Jzjh1mt4s0HuQ0KvtB1LIuHiGRmU2D5pgj8dUhW7/SE=
61+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
62+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
63+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
64+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

main.go added

@@ -0,0 +1,89 @@
1+package main
2+
3+import (
4+ "flag"
5+ "fmt"
6+ "log"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "time"
11+)
12+
13+func main() {
14+ root := flag.String("root", "/srv/forge", "data directory holding forge.db and repos/")
15+ flag.Usage = func() {
16+ fmt.Fprintln(os.Stderr, `usage: forge [-root dir] command
17+
18+ serve [-listen addr] [-host name] [-open] run the web server
19+ keys user home type key sshd AuthorizedKeysCommand
20+ shell user forced command for ssh git access
21+ invite print a single use registration code
22+ user name create a user, password read from stdin`)
23+ }
24+ flag.Parse()
25+ args := flag.Args()
26+ if len(args) == 0 {
27+ flag.Usage()
28+ os.Exit(2)
29+ }
30+ switch args[0] {
31+ case "serve":
32+ serve(*root, args[1:])
33+ case "keys":
34+ binary, _ := os.Executable()
35+ os.Exit(keys(*root, binary, args[1:]))
36+ case "shell":
37+ if len(args) != 2 {
38+ os.Exit(2)
39+ }
40+ os.Exit(shell(*root, args[1]))
41+ case "invite":
42+ store := mustStore(*root)
43+ code, err := store.CreateInvite()
44+ if err != nil {
45+ log.Fatal(err)
46+ }
47+ fmt.Println(code)
48+ case "user":
49+ if len(args) != 2 {
50+ os.Exit(2)
51+ }
52+ store := mustStore(*root)
53+ var password string
54+ fmt.Fscanln(os.Stdin, &password)
55+ if _, err := store.CreateUser(args[1], password); err != nil {
56+ log.Fatal(err)
57+ }
58+ default:
59+ flag.Usage()
60+ os.Exit(2)
61+ }
62+}
63+
64+func mustStore(root string) *Store {
65+ if err := os.MkdirAll(filepath.Join(root, "repos"), 0o755); err != nil {
66+ log.Fatal(err)
67+ }
68+ store, err := openStore(filepath.Join(root, "forge.db"), false)
69+ if err != nil {
70+ log.Fatal(err)
71+ }
72+ return store
73+}
74+
75+func serve(root string, args []string) {
76+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
77+ listen := fs.String("listen", "127.0.0.1:3200", "address to listen on")
78+ host := fs.String("host", "localhost", "public hostname used in clone urls")
79+ open := fs.Bool("open", false, "allow registration without an invite")
80+ fs.Parse(args)
81+ store := mustStore(root)
82+ srv := &http.Server{
83+ Addr: *listen,
84+ Handler: newServer(store, root, *host, *open).handler(),
85+ ReadHeaderTimeout: 10 * time.Second,
86+ }
87+ log.Printf("forge listening on %s for %s", *listen, *host)
88+ log.Fatal(srv.ListenAndServe())
89+}

shell.go added

@@ -0,0 +1,83 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "syscall"
10+)
11+
12+func keys(root, binary string, args []string) int {
13+ if len(args) != 4 {
14+ return 1
15+ }
16+ store, err := openStore(filepath.Join(root, "forge.db"), true)
17+ if err != nil {
18+ return 1
19+ }
20+ user, err := store.UserByKey(args[2] + " " + args[3])
21+ if err != nil {
22+ return 1
23+ }
24+ fmt.Printf("command=\"%s -root %s shell %s\",restrict %s %s\n", binary, root, user.Name, args[2], args[3])
25+ return 0
26+}
27+
28+func shell(root, name string) int {
29+ original := os.Getenv("SSH_ORIGINAL_COMMAND")
30+ fail := func(msg string) int {
31+ fmt.Fprintln(os.Stderr, msg)
32+ return 1
33+ }
34+ if original == "" {
35+ return fail("hello " + name + ", this host only serves git")
36+ }
37+ verb, arg, ok := strings.Cut(original, " ")
38+ if !ok {
39+ return fail("unsupported command")
40+ }
41+ switch verb {
42+ case "git-upload-pack", "git-receive-pack", "git-upload-archive":
43+ default:
44+ return fail("unsupported command")
45+ }
46+ arg = strings.Trim(strings.TrimSpace(arg), "'\"")
47+ arg = strings.TrimPrefix(strings.TrimPrefix(arg, "/"), "~")
48+ arg = strings.TrimSuffix(arg, ".git")
49+ owner, repoName, ok := strings.Cut(arg, "/")
50+ if !ok || !validName(owner) || !validName(repoName) {
51+ return fail("repository path must be ~owner/repo")
52+ }
53+ store, err := openStore(filepath.Join(root, "forge.db"), true)
54+ if err != nil {
55+ return fail("database unavailable")
56+ }
57+ user, err := store.UserByName(name)
58+ if err != nil {
59+ return fail("unknown user")
60+ }
61+ repo, err := store.RepoByPath(owner, repoName)
62+ if err != nil {
63+ return fail("repository not found")
64+ }
65+ isOwner := repo.Owner.ID == user.ID
66+ if repo.Private && !isOwner {
67+ return fail("repository not found")
68+ }
69+ if verb == "git-receive-pack" && !isOwner {
70+ return fail("you do not have write access to " + repo.Path())
71+ }
72+ dir := filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git")
73+ git, err := exec.LookPath("git")
74+ if err != nil {
75+ return fail("git not installed")
76+ }
77+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME")}
78+ if p := os.Getenv("GIT_PROTOCOL"); p != "" {
79+ env = append(env, "GIT_PROTOCOL="+p)
80+ }
81+ err = syscall.Exec(git, []string{"git", strings.TrimPrefix(verb, "git-"), dir}, env)
82+ return fail(err.Error())
83+}

smart.go added

@@ -0,0 +1,113 @@
1+package main
2+
3+import (
4+ "compress/gzip"
5+ "context"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "os/exec"
10+ "strings"
11+ "time"
12+)
13+
14+func isSmart(r *http.Request, rest string) bool {
15+ switch rest {
16+ case "info/refs":
17+ return r.URL.Query().Get("service") != ""
18+ case "git-upload-pack", "git-receive-pack":
19+ return r.Method == "POST"
20+ }
21+ return false
22+}
23+
24+func (s *Server) basicUser(r *http.Request) (User, bool) {
25+ name, secret, ok := r.BasicAuth()
26+ if !ok {
27+ return User{}, false
28+ }
29+ if u, ok := s.store.UserByToken(name, secret); ok {
30+ return u, true
31+ }
32+ return s.store.CheckPassword(name, secret)
33+}
34+
35+func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, rest string) {
36+ repo, err := s.store.RepoByPath(owner.Name, name)
37+ if err != nil {
38+ http.Error(w, "repository not found", 404)
39+ return
40+ }
41+ service := r.URL.Query().Get("service")
42+ if rest != "info/refs" {
43+ service = rest
44+ }
45+ if service != "git-upload-pack" && service != "git-receive-pack" {
46+ http.Error(w, "unsupported service", 403)
47+ return
48+ }
49+ needAuth := service == "git-receive-pack" || repo.Private
50+ if needAuth {
51+ u, ok := s.basicUser(r)
52+ if !ok || u.ID != repo.Owner.ID {
53+ w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`)
54+ http.Error(w, "authentication required", 401)
55+ return
56+ }
57+ }
58+ dir := s.repoDir(repo)
59+ w.Header().Set("Cache-Control", "no-cache")
60+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
61+ defer cancel()
62+ if rest == "info/refs" {
63+ cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", dir)
64+ out, err := cmd.Output()
65+ if err != nil {
66+ http.Error(w, "git failed", 500)
67+ return
68+ }
69+ w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
70+ fmt.Fprintf(w, "%04x# service=%s\n0000", len(service)+15, service)
71+ w.Write(out)
72+ return
73+ }
74+ var body io.Reader = r.Body
75+ if r.Header.Get("Content-Encoding") == "gzip" {
76+ gz, err := gzip.NewReader(r.Body)
77+ if err != nil {
78+ http.Error(w, "bad gzip", 400)
79+ return
80+ }
81+ defer gz.Close()
82+ body = gz
83+ }
84+ cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir)
85+ cmd.Stdin = body
86+ cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent"}
87+ stdout, err := cmd.StdoutPipe()
88+ if err != nil {
89+ http.Error(w, "git failed", 500)
90+ return
91+ }
92+ if err := cmd.Start(); err != nil {
93+ http.Error(w, "git failed", 500)
94+ return
95+ }
96+ w.Header().Set("Content-Type", "application/x-"+service+"-result")
97+ w.WriteHeader(200)
98+ flusher, _ := w.(http.Flusher)
99+ buf := make([]byte, 32*1024)
100+ for {
101+ n, err := stdout.Read(buf)
102+ if n > 0 {
103+ w.Write(buf[:n])
104+ if flusher != nil {
105+ flusher.Flush()
106+ }
107+ }
108+ if err != nil {
109+ break
110+ }
111+ }
112+ cmd.Wait()
113+}

static/style.css added

@@ -0,0 +1,71 @@
1+:root{--bg:#1b1d1e;--fg:#e6e6e3;--muted:#8e9398;--line:#2d3133;--accent:#8fbc8f;--add:#9ccc65;--del:#ef7b6d;--addbg:#1f2b1f;--delbg:#2e1f1f;--code:#151718}
2+*{box-sizing:border-box}
3+html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--fg)}
4+body{margin:0}
5+a{color:var(--accent);text-decoration:none}
6+a:hover{text-decoration:underline}
7+code,pre,.hash,.tree,.blob,.hunk,.refs .hash,.mode{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:13px}
8+pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)}
9+header{border-bottom:1px solid var(--line)}
10+nav{max-width:1000px;margin:0 auto;padding:.5rem 1rem;display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap}
11+nav .site{font-weight:600;color:var(--fg)}
12+nav form{display:inline}
13+nav button{background:none;border:0;color:var(--accent);cursor:pointer;font:inherit;padding:0}
14+nav.repo{border-top:1px solid var(--line);padding-top:.4rem;padding-bottom:.4rem}
15+nav.repo .path{color:var(--fg);margin-right:.8rem}
16+main{max-width:1000px;margin:0 auto;padding:1rem}
17+h1{font-size:1.3rem;margin:.2rem 0 .8rem}
18+h2{font-size:1.05rem;margin:1.2rem 0 .5rem}
19+h3{font-size:.95rem;margin:1rem 0 .3rem}
20+.muted,.when,.who{color:var(--muted)}
21+.lede{margin:.3rem 0 1rem}
22+.tag{font-size:.75rem;border:1px solid var(--line);padding:0 .3rem;color:var(--muted)}
23+.error{color:var(--del);border:1px solid var(--del);padding:.4rem .6rem}
24+.notice{border:1px solid var(--accent);padding:.4rem .6rem}
25+ul.repos,ul.commits,ul.refs,ul.files{list-style:none;padding:0;margin:0}
26+ul.repos li{display:grid;grid-template-columns:auto 1fr auto;gap:.8rem;padding:.35rem 0;border-bottom:1px solid var(--line)}
27+ul.repos .desc{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
28+ul.commits li{display:grid;grid-template-columns:7em 1fr auto auto;gap:.8rem;padding:.25rem 0;border-bottom:1px solid var(--line)}
29+ul.commits .subject{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
30+ul.refs li{display:flex;justify-content:space-between;padding:.2rem 0;border-bottom:1px solid var(--line)}
31+.columns{display:grid;grid-template-columns:2fr 1fr;gap:2rem}
32+@media(max-width:700px){.columns{grid-template-columns:1fr}ul.commits li{grid-template-columns:7em 1fr}ul.commits .who,ul.commits .when{display:none}}
33+dl.clone{margin:0 0 1rem}
34+dl.clone dt{color:var(--muted)}
35+dl.clone dd{margin:0 0 .2rem}
36+dl.meta{display:grid;grid-template-columns:6em 1fr;gap:.1rem .5rem;margin:0 0 .8rem}
37+dl.meta dt{color:var(--muted)}
38+dl.meta dd{margin:0;overflow-wrap:anywhere}
39+.readme{border-top:1px solid var(--line);margin-top:1.5rem;padding-top:1rem}
40+.readme img{max-width:100%}
41+.readme table{border-collapse:collapse}
42+.readme td,.readme th{border:1px solid var(--line);padding:.2rem .5rem}
43+.readme code{background:var(--code);padding:0 .2rem}
44+.readme pre code{padding:0}
45+table{border-collapse:collapse}
46+table.tree td{padding:.15rem .8rem .15rem 0}
47+table.tree .mode{color:var(--muted)}
48+table.tree .size{color:var(--muted);text-align:right}
49+table.refs td,table.keys td{padding:.2rem 1rem .2rem 0;border-bottom:1px solid var(--line)}
50+table.blob,table.hunk{width:100%;background:var(--code);border:1px solid var(--line)}
51+table.blob td,table.hunk td{padding:0 .5rem;white-space:pre;vertical-align:top}
52+td.n{text-align:right;color:var(--muted);user-select:none;width:1%}
53+td.n a{color:var(--muted)}
54+td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
55+tr:target td{background:#2a2e31}
56+.hunk .head td{color:var(--muted);background:#202325}
57+.hunk tr.add td.l{background:var(--addbg);color:var(--add)}
58+.hunk tr.del td.l{background:var(--delbg);color:var(--del)}
59+.hunk tr.meta td{color:var(--muted)}
60+.add{color:var(--add)}
61+.del{color:var(--del)}
62+.diff{margin-bottom:1.5rem}
63+.crumbs{margin:.2rem 0 .8rem}
64+form.stack{display:flex;flex-direction:column;gap:.6rem;max-width:32rem;margin-bottom:1rem}
65+form.stack label{display:flex;flex-direction:column;gap:.2rem;color:var(--muted)}
66+form.stack label.check{flex-direction:row;align-items:center;gap:.5rem}
67+input,textarea{font:inherit;background:var(--code);color:var(--fg);border:1px solid var(--line);padding:.35rem .5rem}
68+textarea{font-family:ui-monospace,monospace;font-size:13px}
69+button{font:inherit;background:var(--accent);color:#111;border:0;padding:.35rem .9rem;cursor:pointer;align-self:flex-start}
70+button.danger{background:none;color:var(--del);border:1px solid var(--del);padding:.1rem .5rem}
71+pre.body{white-space:pre-wrap}

store.go added

@@ -0,0 +1,422 @@
1+package main
2+
3+import (
4+ "crypto/rand"
5+ "crypto/sha256"
6+ "database/sql"
7+ "encoding/hex"
8+ "errors"
9+ "fmt"
10+ "regexp"
11+ "strings"
12+ "time"
13+
14+ "golang.org/x/crypto/bcrypt"
15+ "golang.org/x/crypto/ssh"
16+ _ "modernc.org/sqlite"
17+)
18+
19+const schema = `
20+create table if not exists users (
21+ id integer primary key,
22+ name text not null unique,
23+ hash text not null,
24+ created integer not null
25+);
26+create table if not exists sessions (
27+ id text primary key,
28+ user integer not null references users(id) on delete cascade,
29+ created integer not null
30+);
31+create table if not exists keys (
32+ id integer primary key,
33+ user integer not null references users(id) on delete cascade,
34+ fingerprint text not null unique,
35+ public text not null,
36+ title text not null,
37+ created integer not null
38+);
39+create table if not exists tokens (
40+ id integer primary key,
41+ user integer not null references users(id) on delete cascade,
42+ hash text not null unique,
43+ title text not null,
44+ created integer not null
45+);
46+create table if not exists repos (
47+ id integer primary key,
48+ owner integer not null references users(id) on delete cascade,
49+ name text not null,
50+ description text not null default '',
51+ private integer not null default 0,
52+ created integer not null,
53+ unique(owner, name)
54+);
55+create table if not exists invites (
56+ code text primary key,
57+ used integer not null default 0
58+);
59+`
60+
61+var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,38}$`)
62+
63+var ErrNotFound = errors.New("not found")
64+
65+type User struct {
66+ ID int64
67+ Name string
68+ Created time.Time
69+}
70+
71+type Repo struct {
72+ ID int64
73+ Owner User
74+ Name string
75+ Description string
76+ Private bool
77+ Created time.Time
78+}
79+
80+type Key struct {
81+ ID int64
82+ Fingerprint string
83+ Public string
84+ Title string
85+ Created time.Time
86+}
87+
88+type Token struct {
89+ ID int64
90+ Title string
91+ Created time.Time
92+}
93+
94+type Store struct {
95+ db *sql.DB
96+}
97+
98+func openStore(path string, readonly bool) (*Store, error) {
99+ dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)&_pragma=foreign_keys(1)"
100+ if readonly {
101+ dsn += "&mode=ro"
102+ }
103+ db, err := sql.Open("sqlite", dsn)
104+ if err != nil {
105+ return nil, err
106+ }
107+ if !readonly {
108+ if _, err := db.Exec(schema); err != nil {
109+ return nil, err
110+ }
111+ }
112+ return &Store{db}, nil
113+}
114+
115+func random(n int) string {
116+ b := make([]byte, n)
117+ if _, err := rand.Read(b); err != nil {
118+ panic(err)
119+ }
120+ return hex.EncodeToString(b)
121+}
122+
123+func validName(name string) bool {
124+ return nameRe.MatchString(name) && !strings.HasSuffix(name, ".git")
125+}
126+
127+func (s *Store) CreateUser(name, password string) (User, error) {
128+ if !validName(name) {
129+ return User{}, errors.New("names are lowercase letters, digits, dot, dash or underscore, at most 39 characters")
130+ }
131+ if len(password) < 8 {
132+ return User{}, errors.New("password must be at least 8 characters")
133+ }
134+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
135+ if err != nil {
136+ return User{}, err
137+ }
138+ now := time.Now()
139+ res, err := s.db.Exec(`insert into users(name, hash, created) values (?, ?, ?)`, name, string(hash), now.Unix())
140+ if err != nil {
141+ if strings.Contains(err.Error(), "UNIQUE") {
142+ return User{}, errors.New("that name is taken")
143+ }
144+ return User{}, err
145+ }
146+ id, _ := res.LastInsertId()
147+ return User{ID: id, Name: name, Created: now}, nil
148+}
149+
150+func (s *Store) UserByName(name string) (User, error) {
151+ var u User
152+ var created int64
153+ err := s.db.QueryRow(`select id, name, created from users where name = ?`, name).Scan(&u.ID, &u.Name, &created)
154+ if err == sql.ErrNoRows {
155+ return u, ErrNotFound
156+ }
157+ u.Created = time.Unix(created, 0)
158+ return u, err
159+}
160+
161+func (s *Store) userByID(id int64) (User, error) {
162+ var u User
163+ var created int64
164+ err := s.db.QueryRow(`select id, name, created from users where id = ?`, id).Scan(&u.ID, &u.Name, &created)
165+ if err == sql.ErrNoRows {
166+ return u, ErrNotFound
167+ }
168+ u.Created = time.Unix(created, 0)
169+ return u, err
170+}
171+
172+func (s *Store) CheckPassword(name, password string) (User, bool) {
173+ var u User
174+ var hash string
175+ var created int64
176+ err := s.db.QueryRow(`select id, name, hash, created from users where name = ?`, name).Scan(&u.ID, &u.Name, &hash, &created)
177+ if err != nil {
178+ bcrypt.CompareHashAndPassword([]byte("$2a$10$000000000000000000000uGJ8z1gQ7yP1cqeI4qzj6WmLIl3JcP6W"), []byte(password))
179+ return u, false
180+ }
181+ u.Created = time.Unix(created, 0)
182+ return u, bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
183+}
184+
185+func (s *Store) SetPassword(user int64, password string) error {
186+ if len(password) < 8 {
187+ return errors.New("password must be at least 8 characters")
188+ }
189+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
190+ if err != nil {
191+ return err
192+ }
193+ _, err = s.db.Exec(`update users set hash = ? where id = ?`, string(hash), user)
194+ return err
195+}
196+
197+func (s *Store) CreateSession(user int64) (string, error) {
198+ id := random(32)
199+ _, err := s.db.Exec(`insert into sessions(id, user, created) values (?, ?, ?)`, id, user, time.Now().Unix())
200+ return id, err
201+}
202+
203+func (s *Store) SessionUser(id string) (User, error) {
204+ var user int64
205+ err := s.db.QueryRow(`select user from sessions where id = ?`, id).Scan(&user)
206+ if err != nil {
207+ return User{}, ErrNotFound
208+ }
209+ return s.userByID(user)
210+}
211+
212+func (s *Store) DeleteSession(id string) {
213+ s.db.Exec(`delete from sessions where id = ?`, id)
214+}
215+
216+func (s *Store) AddKey(user int64, title, public string) error {
217+ pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
218+ if err != nil {
219+ return errors.New("that is not a valid openssh public key")
220+ }
221+ if title == "" {
222+ title = comment
223+ }
224+ if title == "" {
225+ title = "key"
226+ }
227+ line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
228+ _, err = s.db.Exec(`insert into keys(user, fingerprint, public, title, created) values (?, ?, ?, ?, ?)`,
229+ user, ssh.FingerprintSHA256(pub), line, title, time.Now().Unix())
230+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
231+ return errors.New("that key is already registered")
232+ }
233+ return err
234+}
235+
236+func (s *Store) Keys(user int64) ([]Key, error) {
237+ rows, err := s.db.Query(`select id, fingerprint, public, title, created from keys where user = ? order by id`, user)
238+ if err != nil {
239+ return nil, err
240+ }
241+ defer rows.Close()
242+ var keys []Key
243+ for rows.Next() {
244+ var k Key
245+ var created int64
246+ if err := rows.Scan(&k.ID, &k.Fingerprint, &k.Public, &k.Title, &created); err != nil {
247+ return nil, err
248+ }
249+ k.Created = time.Unix(created, 0)
250+ keys = append(keys, k)
251+ }
252+ return keys, rows.Err()
253+}
254+
255+func (s *Store) DeleteKey(user, id int64) error {
256+ _, err := s.db.Exec(`delete from keys where user = ? and id = ?`, user, id)
257+ return err
258+}
259+
260+func (s *Store) UserByKey(public string) (User, error) {
261+ pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
262+ if err != nil {
263+ return User{}, err
264+ }
265+ var user int64
266+ err = s.db.QueryRow(`select user from keys where fingerprint = ?`, ssh.FingerprintSHA256(pub)).Scan(&user)
267+ if err != nil {
268+ return User{}, ErrNotFound
269+ }
270+ return s.userByID(user)
271+}
272+
273+func tokenHash(token string) string {
274+ sum := sha256.Sum256([]byte(token))
275+ return hex.EncodeToString(sum[:])
276+}
277+
278+func (s *Store) CreateToken(user int64, title string) (string, error) {
279+ if title == "" {
280+ title = "token"
281+ }
282+ token := random(24)
283+ _, err := s.db.Exec(`insert into tokens(user, hash, title, created) values (?, ?, ?, ?)`, user, tokenHash(token), title, time.Now().Unix())
284+ return token, err
285+}
286+
287+func (s *Store) Tokens(user int64) ([]Token, error) {
288+ rows, err := s.db.Query(`select id, title, created from tokens where user = ? order by id`, user)
289+ if err != nil {
290+ return nil, err
291+ }
292+ defer rows.Close()
293+ var tokens []Token
294+ for rows.Next() {
295+ var t Token
296+ var created int64
297+ if err := rows.Scan(&t.ID, &t.Title, &created); err != nil {
298+ return nil, err
299+ }
300+ t.Created = time.Unix(created, 0)
301+ tokens = append(tokens, t)
302+ }
303+ return tokens, rows.Err()
304+}
305+
306+func (s *Store) DeleteToken(user, id int64) error {
307+ _, err := s.db.Exec(`delete from tokens where user = ? and id = ?`, user, id)
308+ return err
309+}
310+
311+func (s *Store) UserByToken(name, token string) (User, bool) {
312+ var user int64
313+ err := s.db.QueryRow(`select t.user from tokens t join users u on u.id = t.user where u.name = ? and t.hash = ?`, name, tokenHash(token)).Scan(&user)
314+ if err != nil {
315+ return User{}, false
316+ }
317+ u, err := s.userByID(user)
318+ return u, err == nil
319+}
320+
321+func (s *Store) CreateRepo(owner User, name, description string, private bool) (Repo, error) {
322+ if !validName(name) {
323+ return Repo{}, errors.New("repository names are lowercase letters, digits, dot, dash or underscore")
324+ }
325+ now := time.Now()
326+ res, err := s.db.Exec(`insert into repos(owner, name, description, private, created) values (?, ?, ?, ?, ?)`,
327+ owner.ID, name, description, private, now.Unix())
328+ if err != nil {
329+ if strings.Contains(err.Error(), "UNIQUE") {
330+ return Repo{}, errors.New("you already have a repository with that name")
331+ }
332+ return Repo{}, err
333+ }
334+ id, _ := res.LastInsertId()
335+ return Repo{ID: id, Owner: owner, Name: name, Description: description, Private: private, Created: now}, nil
336+}
337+
338+func scanRepos(rows *sql.Rows) ([]Repo, error) {
339+ defer rows.Close()
340+ var repos []Repo
341+ for rows.Next() {
342+ var r Repo
343+ var private int
344+ var created, ucreated int64
345+ if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil {
346+ return nil, err
347+ }
348+ r.Private = private != 0
349+ r.Created = time.Unix(created, 0)
350+ r.Owner.Created = time.Unix(ucreated, 0)
351+ repos = append(repos, r)
352+ }
353+ return repos, rows.Err()
354+}
355+
356+const repoColumns = `r.id, r.name, r.description, r.private, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
357+
358+func (s *Store) RepoByPath(owner, name string) (Repo, error) {
359+ rows, err := s.db.Query(`select `+repoColumns+` where u.name = ? and r.name = ?`, owner, name)
360+ if err != nil {
361+ return Repo{}, err
362+ }
363+ repos, err := scanRepos(rows)
364+ if err != nil {
365+ return Repo{}, err
366+ }
367+ if len(repos) == 0 {
368+ return Repo{}, ErrNotFound
369+ }
370+ return repos[0], nil
371+}
372+
373+func (s *Store) ReposByOwner(owner int64, includePrivate bool) ([]Repo, error) {
374+ rows, err := s.db.Query(`select `+repoColumns+` where r.owner = ? and (r.private = 0 or ?) order by r.name`, owner, includePrivate)
375+ if err != nil {
376+ return nil, err
377+ }
378+ return scanRepos(rows)
379+}
380+
381+func (s *Store) RecentRepos(n int) ([]Repo, error) {
382+ rows, err := s.db.Query(`select `+repoColumns+` where r.private = 0 order by r.created desc limit ?`, n)
383+ if err != nil {
384+ return nil, err
385+ }
386+ return scanRepos(rows)
387+}
388+
389+func (s *Store) UpdateRepo(id int64, description string, private bool) error {
390+ _, err := s.db.Exec(`update repos set description = ?, private = ? where id = ?`, description, private, id)
391+ return err
392+}
393+
394+func (s *Store) DeleteRepo(id int64) error {
395+ _, err := s.db.Exec(`delete from repos where id = ?`, id)
396+ return err
397+}
398+
399+func (s *Store) CreateInvite() (string, error) {
400+ code := random(12)
401+ _, err := s.db.Exec(`insert into invites(code) values (?)`, code)
402+ return code, err
403+}
404+
405+func (s *Store) UseInvite(code string) bool {
406+ res, err := s.db.Exec(`update invites set used = 1 where code = ? and used = 0`, code)
407+ if err != nil {
408+ return false
409+ }
410+ n, _ := res.RowsAffected()
411+ return n == 1
412+}
413+
414+func (s *Store) Stats() (users, repos int) {
415+ s.db.QueryRow(`select count(*) from users`).Scan(&users)
416+ s.db.QueryRow(`select count(*) from repos where private = 0`).Scan(&repos)
417+ return
418+}
419+
420+func (r Repo) Path() string {
421+ return fmt.Sprintf("~%s/%s", r.Owner.Name, r.Name)
422+}

templates/blob.html added

@@ -0,0 +1,14 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{template "crumbs" .Data}}
4+<p class="muted">{{size .Data.Size}} · <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">raw</a> · <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Data.Path}}">history</a></p>
5+{{if .Data.Binary}}<p class="muted">binary file, see raw.</p>
6+{{else if .Data.Large}}<p class="muted">file too large to display, see raw.</p>
7+{{else}}
8+<table class="blob">{{range $i, $l := lines .Data.Content}}<tr><td class="n" id="L{{inc $i}}"><a href="#L{{inc $i}}">{{inc $i}}</a></td><td class="l">{{$l}}</td></tr>{{end}}</table>
9+{{end}}
10+{{end}}
11+{{define "crumbs"}}
12+{{$p := .Page}}
13+<p class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}} <span class="muted">at <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">{{$p.Ref}}</a></span></p>
14+{{end}}

templates/commit.html added

@@ -0,0 +1,26 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{$c := .Data.Commit}}
4+<h1>{{$c.Subject}}</h1>
5+<dl class="meta">
6+<dt>commit</dt><dd><code>{{$c.Hash}}</code> <a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/">tree</a></dd>
7+{{range $c.Parents}}<dt>parent</dt><dd><a href="/{{$p.Repo.Path}}/commit/{{.}}"><code>{{.}}</code></a></dd>{{end}}
8+<dt>author</dt><dd>{{$c.Author}} &lt;{{$c.Email}}&gt;</dd>
9+<dt>date</dt><dd>{{date $c.Time}} UTC</dd>
10+</dl>
11+{{if $c.Body}}<pre class="body">{{$c.Body}}</pre>{{end}}
12+<p class="muted">{{len .Data.Files}} files changed, <span class="add">+{{.Data.Added}}</span> <span class="del">-{{.Data.Removed}}</span></p>
13+<ul class="files">{{range .Data.Files}}<li><a href="#{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span> <span class="add">+{{.Added}}</span> <span class="del">-{{.Removed}}</span></li>{{end}}</ul>
14+{{range .Data.Files}}
15+<section class="diff" id="{{.New}}">
16+<h3><a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h3>
17+{{if .Binary}}<p class="muted">binary file</p>{{end}}
18+{{range .Hunks}}
19+<table class="hunk">
20+<tr class="head"><td colspan="3">{{.Header}}</td></tr>
21+{{range .Lines}}<tr class="{{.Kind}}"><td class="n">{{if .Old}}{{.Old}}{{end}}</td><td class="n">{{if .New}}{{.New}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr>{{end}}
22+</table>
23+{{end}}
24+</section>
25+{{end}}
26+{{end}}

templates/create.html added

@@ -0,0 +1,9 @@
1+{{define "content"}}
2+<h1>new repository</h1>
3+<form method="post" action="/create" class="stack">
4+<label>name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
5+<label>description <input name="description" maxlength="200"></label>
6+<label class="check"><input type="checkbox" name="private"> private</label>
7+<button>create</button>
8+</form>
9+{{end}}

templates/error.html added

@@ -0,0 +1,3 @@
1+{{define "content"}}
2+<p><a href="/">back</a></p>
3+{{end}}

templates/index.html added

@@ -0,0 +1,13 @@
1+{{define "content"}}
2+{{if .User}}
3+<h1>your repositories</h1>
4+{{template "repolist" .Data.Repos}}
5+{{else}}
6+<p class="lede">a small git host. {{.Data.Public}} public repositories from {{.Data.Users}} users.</p>
7+<h1>recent repositories</h1>
8+{{template "repolist" .Data.Repos}}
9+{{end}}
10+{{end}}
11+{{define "repolist"}}
12+{{if .}}<ul class="repos">{{range .}}<li><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{end}}{{if .Description}}<span class="desc">{{.Description}}</span>{{end}}<span class="when">{{ago .Created}}</span></li>{{end}}</ul>{{else}}<p class="muted">nothing here yet.</p>{{end}}
13+{{end}}

templates/layout.html added

@@ -0,0 +1,29 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<title>{{.Title}}</title>
7+<link rel="stylesheet" href="/static/style.css">
8+</head>
9+<body>
10+<header>
11+<nav>
12+<a class="site" href="/">{{.Host}}</a>
13+{{if .User}}<a href="/~{{.User.Name}}">~{{.User.Name}}</a><a href="/create">new repo</a><a href="/settings">settings</a><form method="post" action="/logout"><button>log out</button></form>{{else}}<a href="/login">log in</a><a href="/register">register</a>{{end}}
14+</nav>
15+{{with .Data}}{{with index . "Page"}}
16+<nav class="repo">
17+<span class="path"><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/<a href="/{{.Repo.Path}}">{{.Repo.Name}}</a>{{if .Repo.Private}} <span class="tag">private</span>{{end}}</span>
18+<a href="/{{.Repo.Path}}">summary</a>
19+{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/">tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}">log</a><a href="/{{.Repo.Path}}/refs">refs</a>{{end}}
20+{{if .Owner}}<a href="/{{.Repo.Path}}/settings">settings</a>{{end}}
21+</nav>
22+{{end}}{{end}}
23+</header>
24+<main>
25+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
26+{{template "content" .}}
27+</main>
28+</body>
29+</html>

templates/log.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h1>log <span class="muted">{{$p.Ref}}</span></h1>
4+{{if $p.Empty}}<p class="muted">no commits.</p>{{else}}
5+<ul class="commits long">{{range .Data.Commits}}<li><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a><span class="subject"><a href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></span><span class="who">{{.Author}}</span><span class="when" title="{{date .Time}}">{{ago .Time}}</span></li>{{end}}</ul>
6+{{with .Data.Next}}<p><a href="?from={{.}}">older →</a></p>{{end}}
7+{{end}}
8+{{end}}

templates/login.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+<h1>log in</h1>
3+<form method="post" action="/login" class="stack">
4+<label>name <input name="name" required autofocus autocomplete="username"></label>
5+<label>password <input name="password" type="password" required autocomplete="current-password"></label>
6+<button>log in</button>
7+</form>
8+{{end}}

templates/refs.html added

@@ -0,0 +1,7 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h2>branches</h2>
4+<table class="refs">{{range .Data.Branches}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>
5+<h2>tags</h2>
6+{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>{{else}}<p class="muted">no tags.</p>{{end}}
7+{{end}}

templates/register.html added

@@ -0,0 +1,9 @@
1+{{define "content"}}
2+<h1>register</h1>
3+<form method="post" action="/register" class="stack">
4+<label>name <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
5+<label>password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
6+{{if not .Data.Open}}<label>invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
7+<button>create account</button>
8+</form>
9+{{end}}

templates/repo.html added

@@ -0,0 +1,33 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{if $p.Repo.Description}}<p class="lede">{{$p.Repo.Description}}</p>{{end}}
4+<dl class="clone">
5+<dt>clone</dt>
6+<dd><code>git clone https://{{.Host}}/{{$p.Repo.Path}}</code></dd>
7+{{if $p.Owner}}<dd><code>git clone git@{{.Host}}:{{$p.Repo.Path}}</code></dd>{{end}}
8+</dl>
9+{{if $p.Empty}}
10+<p class="muted">this repository is empty. push something:</p>
11+<pre>git remote add origin git@{{.Host}}:{{$p.Repo.Path}}
12+git push -u origin {{$p.Ref}}</pre>
13+{{else}}
14+<div class="columns">
15+<section>
16+<h2><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">latest commits</a></h2>
17+{{template "commits" (index .Data "Commits")}}
18+</section>
19+<section>
20+<h2><a href="/{{$p.Repo.Path}}/refs">refs</a></h2>
21+<ul class="refs">
22+{{range $i, $b := .Data.Branches}}{{if lt $i 6}}<li><a href="/{{$p.Repo.Path}}/tree/{{$b.Name}}/">{{$b.Name}}</a><span class="when">{{ago $b.Time}}</span></li>{{end}}{{end}}
23+{{range $i, $t := .Data.Tags}}{{if lt $i 6}}<li><a href="/{{$p.Repo.Path}}/tree/{{$t.Name}}/">{{$t.Name}}</a><span class="when">{{ago $t.Time}}</span></li>{{end}}{{end}}
24+</ul>
25+</section>
26+</div>
27+{{with .Data.Readme}}<article class="readme">{{.}}</article>{{end}}
28+{{with .Data.ReadmeText}}<article class="readme"><pre>{{.}}</pre></article>{{end}}
29+{{end}}
30+{{end}}
31+{{define "commits"}}
32+<ul class="commits">{{$path := ""}}{{range .}}<li><a class="hash" href="commit/{{.Hash}}">{{short .Hash}}</a><span class="subject">{{.Subject}}</span><span class="who">{{.Author}}</span><span class="when">{{ago .Time}}</span></li>{{end}}</ul>
33+{{end}}

templates/reposettings.html added

@@ -0,0 +1,14 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h1>settings</h1>
4+<form method="post" action="/{{$p.Repo.Path}}/settings" class="stack">
5+<label>description <input name="description" value="{{$p.Repo.Description}}" maxlength="200"></label>
6+<label class="check"><input type="checkbox" name="private"{{if $p.Repo.Private}} checked{{end}}> private</label>
7+<button>save</button>
8+</form>
9+<h2>delete</h2>
10+<form method="post" action="/{{$p.Repo.Path}}/delete" class="stack">
11+<label>type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label>
12+<button class="danger">delete repository</button>
13+</form>
14+{{end}}

templates/settings.html added

@@ -0,0 +1,24 @@
1+{{define "content"}}
2+<h1>settings</h1>
3+<h2>ssh keys</h2>
4+{{if .Data.Keys}}<table class="keys">{{range .Data.Keys}}<tr><td>{{.Title}}</td><td><code>{{.Fingerprint}}</code></td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/keys/{{.ID}}/delete"><button class="danger">remove</button></form></td></tr>{{end}}</table>{{end}}
5+<form method="post" action="/settings/keys" class="stack">
6+<label>title <input name="title" placeholder="laptop"></label>
7+<label>public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label>
8+<button>add key</button>
9+</form>
10+<h2>access tokens</h2>
11+<p class="muted">use a token as the password when pushing over https.</p>
12+{{with .Data.NewToken}}<p class="notice">new token, shown once: <code>{{.}}</code></p>{{end}}
13+{{if .Data.Tokens}}<table class="keys">{{range .Data.Tokens}}<tr><td>{{.Title}}</td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/tokens/{{.ID}}/delete"><button class="danger">revoke</button></form></td></tr>{{end}}</table>{{end}}
14+<form method="post" action="/settings/tokens" class="stack">
15+<label>title <input name="title" placeholder="ci"></label>
16+<button>create token</button>
17+</form>
18+<h2>password</h2>
19+<form method="post" action="/settings/password" class="stack">
20+<label>current <input name="current" type="password" required autocomplete="current-password"></label>
21+<label>new <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
22+<button>change password</button>
23+</form>
24+{{end}}

templates/tree.html added

@@ -0,0 +1,17 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{template "crumbs" .Data}}
4+<table class="tree">
5+{{range .Data.Entries}}
6+<tr>
7+<td class="mode">{{.Mode}}</td>
8+<td class="name">{{if eq .Type "tree"}}<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{if $.Data.Path}}{{$.Data.Path}}/{{end}}{{.Name}}/">{{.Name}}/</a>{{else if eq .Type "blob"}}<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{if $.Data.Path}}{{$.Data.Path}}/{{end}}{{.Name}}">{{.Name}}</a>{{else}}{{.Name}} <span class="muted">({{.Type}})</span>{{end}}</td>
9+<td class="size">{{if eq .Type "blob"}}{{size .Size}}{{end}}</td>
10+</tr>
11+{{end}}
12+</table>
13+{{end}}
14+{{define "crumbs"}}
15+{{$p := .Page}}
16+<p class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}} <span class="muted">at <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">{{$p.Ref}}</a></span></p>
17+{{end}}

templates/user.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+<h1>~{{.Data.Owner.Name}}</h1>
3+<p class="muted">joined {{ago .Data.Owner.Created}}</p>
4+{{template "repolist" .Data.Repos}}
5+{{end}}
6+{{define "repolist"}}
7+{{if .}}<ul class="repos">{{range .}}<li><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{end}}{{if .Description}}<span class="desc">{{.Description}}</span>{{end}}<span class="when">{{ago .Created}}</span></li>{{end}}</ul>{{else}}<p class="muted">no repositories.</p>{{end}}
8+{{end}}

web.go added

@@ -0,0 +1,687 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "embed"
6+ "fmt"
7+ "html/template"
8+ "io/fs"
9+ "log"
10+ "net/http"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "github.com/microcosm-cc/bluemonday"
18+ "github.com/yuin/goldmark"
19+ "github.com/yuin/goldmark/extension"
20+)
21+
22+//go:embed templates/*.html
23+var templateFS embed.FS
24+
25+//go:embed static
26+var staticFS embed.FS
27+
28+type Server struct {
29+ store *Store
30+ root string
31+ host string
32+ open bool
33+ pages map[string]*template.Template
34+ markdown goldmark.Markdown
35+ policy *bluemonday.Policy
36+}
37+
38+type page struct {
39+ Title string
40+ User *User
41+ Host string
42+ Error string
43+ Data any
44+}
45+
46+func newServer(store *Store, root, host string, open bool) *Server {
47+ s := &Server{store: store, root: root, host: host, open: open, pages: map[string]*template.Template{}}
48+ funcs := template.FuncMap{
49+ "short": func(h string) string {
50+ if len(h) > 10 {
51+ return h[:10]
52+ }
53+ return h
54+ },
55+ "ago": ago,
56+ "size": humanSize,
57+ "date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") },
58+ "lines": func(b []byte) []string {
59+ s := strings.TrimSuffix(string(b), "\n")
60+ return strings.Split(s, "\n")
61+ },
62+ "inc": func(i int) int { return i + 1 },
63+ "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
64+ "string": func(b []byte) string { return string(b) },
65+ }
66+ names, _ := fs.Glob(templateFS, "templates/*.html")
67+ for _, name := range names {
68+ base := filepath.Base(name)
69+ if base == "layout.html" {
70+ continue
71+ }
72+ s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", name))
73+ }
74+ s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM))
75+ s.policy = bluemonday.UGCPolicy()
76+ return s
77+}
78+
79+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) {
80+ p.Host = s.host
81+ if p.User == nil {
82+ p.User = s.current(r)
83+ }
84+ t, ok := s.pages[name]
85+ if !ok {
86+ http.Error(w, "missing template "+name, 500)
87+ return
88+ }
89+ var buf bytes.Buffer
90+ if err := t.Execute(&buf, p); err != nil {
91+ log.Printf("render %s: %v", name, err)
92+ http.Error(w, "template error", 500)
93+ return
94+ }
95+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
96+ buf.WriteTo(w)
97+}
98+
99+func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
100+ w.WriteHeader(code)
101+ s.render(w, r, "error", page{Title: msg, Error: msg})
102+}
103+
104+func (s *Server) current(r *http.Request) *User {
105+ c, err := r.Cookie("session")
106+ if err != nil {
107+ return nil
108+ }
109+ u, err := s.store.SessionUser(c.Value)
110+ if err != nil {
111+ return nil
112+ }
113+ return &u
114+}
115+
116+func (s *Server) handler() http.Handler {
117+ mux := http.NewServeMux()
118+ static, _ := fs.Sub(staticFS, "static")
119+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
120+ mux.HandleFunc("GET /{$}", s.index)
121+ mux.HandleFunc("GET /login", s.loginForm)
122+ mux.HandleFunc("POST /login", s.login)
123+ mux.HandleFunc("POST /logout", s.logout)
124+ mux.HandleFunc("GET /register", s.registerForm)
125+ mux.HandleFunc("POST /register", s.register)
126+ mux.HandleFunc("GET /create", s.createForm)
127+ mux.HandleFunc("POST /create", s.create)
128+ mux.HandleFunc("GET /settings", s.settings)
129+ mux.HandleFunc("POST /settings/keys", s.addKey)
130+ mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey)
131+ mux.HandleFunc("POST /settings/tokens", s.addToken)
132+ mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken)
133+ mux.HandleFunc("POST /settings/password", s.password)
134+ mux.HandleFunc("/", s.route)
135+ cop := http.NewCrossOriginProtection()
136+ return cop.Handler(mux)
137+}
138+
139+func (s *Server) index(w http.ResponseWriter, r *http.Request) {
140+ user := s.current(r)
141+ var repos []Repo
142+ var err error
143+ if user != nil {
144+ repos, err = s.store.ReposByOwner(user.ID, true)
145+ } else {
146+ repos, err = s.store.RecentRepos(30)
147+ }
148+ if err != nil {
149+ s.fail(w, r, 500, err.Error())
150+ return
151+ }
152+ users, public := s.store.Stats()
153+ s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Users": users, "Public": public}})
154+}
155+
156+func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
157+ s.render(w, r, "login", page{Title: "log in"})
158+}
159+
160+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
161+ u, ok := s.store.CheckPassword(strings.TrimSpace(r.FormValue("name")), r.FormValue("password"))
162+ if !ok {
163+ w.WriteHeader(401)
164+ s.render(w, r, "login", page{Title: "log in", Error: "wrong name or password"})
165+ return
166+ }
167+ s.startSession(w, u)
168+ http.Redirect(w, r, "/", http.StatusSeeOther)
169+}
170+
171+func (s *Server) startSession(w http.ResponseWriter, u User) {
172+ id, err := s.store.CreateSession(u.ID)
173+ if err != nil {
174+ return
175+ }
176+ http.SetCookie(w, &http.Cookie{Name: "session", Value: id, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 90 * 86400})
177+}
178+
179+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
180+ if c, err := r.Cookie("session"); err == nil {
181+ s.store.DeleteSession(c.Value)
182+ }
183+ http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
184+ http.Redirect(w, r, "/", http.StatusSeeOther)
185+}
186+
187+func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) {
188+ s.render(w, r, "register", page{Title: "register", Data: map[string]any{"Open": s.open, "Invite": r.URL.Query().Get("invite")}})
189+}
190+
191+func (s *Server) register(w http.ResponseWriter, r *http.Request) {
192+ invite := strings.TrimSpace(r.FormValue("invite"))
193+ retry := func(msg string) {
194+ w.WriteHeader(400)
195+ s.render(w, r, "register", page{Title: "register", Error: msg, Data: map[string]any{"Open": s.open, "Invite": invite}})
196+ }
197+ if !s.open && !s.store.UseInvite(invite) {
198+ retry("that invite is invalid or already used")
199+ return
200+ }
201+ u, err := s.store.CreateUser(strings.TrimSpace(r.FormValue("name")), r.FormValue("password"))
202+ if err != nil {
203+ retry(err.Error())
204+ return
205+ }
206+ s.startSession(w, u)
207+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
208+}
209+
210+func (s *Server) require(w http.ResponseWriter, r *http.Request) *User {
211+ u := s.current(r)
212+ if u == nil {
213+ http.Redirect(w, r, "/login", http.StatusSeeOther)
214+ }
215+ return u
216+}
217+
218+func (s *Server) createForm(w http.ResponseWriter, r *http.Request) {
219+ if u := s.require(w, r); u != nil {
220+ s.render(w, r, "create", page{Title: "new repository", User: u})
221+ }
222+}
223+
224+func (s *Server) create(w http.ResponseWriter, r *http.Request) {
225+ u := s.require(w, r)
226+ if u == nil {
227+ return
228+ }
229+ name := strings.TrimSpace(r.FormValue("name"))
230+ repo, err := s.store.CreateRepo(*u, name, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on")
231+ if err != nil {
232+ w.WriteHeader(400)
233+ s.render(w, r, "create", page{Title: "new repository", User: u, Error: err.Error()})
234+ return
235+ }
236+ dir := s.repoDir(repo)
237+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err == nil {
238+ err = initRepo(dir)
239+ }
240+ if err != nil {
241+ s.store.DeleteRepo(repo.ID)
242+ s.fail(w, r, 500, err.Error())
243+ return
244+ }
245+ http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
246+}
247+
248+func (s *Server) repoDir(repo Repo) string {
249+ return filepath.Join(s.root, "repos", repo.Owner.Name, repo.Name+".git")
250+}
251+
252+func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
253+ u := s.require(w, r)
254+ if u == nil {
255+ return
256+ }
257+ keys, _ := s.store.Keys(u.ID)
258+ tokens, _ := s.store.Tokens(u.ID)
259+ s.render(w, r, "settings", page{Title: "settings", User: u, Error: r.URL.Query().Get("error"),
260+ Data: map[string]any{"Keys": keys, "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}})
261+}
262+
263+func (s *Server) addKey(w http.ResponseWriter, r *http.Request) {
264+ u := s.require(w, r)
265+ if u == nil {
266+ return
267+ }
268+ if err := s.store.AddKey(u.ID, strings.TrimSpace(r.FormValue("title")), r.FormValue("key")); err != nil {
269+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
270+ return
271+ }
272+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
273+}
274+
275+func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
276+ u := s.require(w, r)
277+ if u == nil {
278+ return
279+ }
280+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
281+ s.store.DeleteKey(u.ID, id)
282+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
283+}
284+
285+func (s *Server) addToken(w http.ResponseWriter, r *http.Request) {
286+ u := s.require(w, r)
287+ if u == nil {
288+ return
289+ }
290+ token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title")))
291+ if err != nil {
292+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
293+ return
294+ }
295+ http.Redirect(w, r, "/settings?token="+token, http.StatusSeeOther)
296+}
297+
298+func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
299+ u := s.require(w, r)
300+ if u == nil {
301+ return
302+ }
303+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
304+ s.store.DeleteToken(u.ID, id)
305+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
306+}
307+
308+func (s *Server) password(w http.ResponseWriter, r *http.Request) {
309+ u := s.require(w, r)
310+ if u == nil {
311+ return
312+ }
313+ if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok {
314+ http.Redirect(w, r, "/settings?error=current+password+is+wrong", http.StatusSeeOther)
315+ return
316+ }
317+ if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil {
318+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
319+ return
320+ }
321+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
322+}
323+
324+func (s *Server) route(w http.ResponseWriter, r *http.Request) {
325+ if !strings.HasPrefix(r.URL.Path, "/~") {
326+ s.fail(w, r, 404, "page not found")
327+ return
328+ }
329+ parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/~"), "/", 3)
330+ owner, err := s.store.UserByName(parts[0])
331+ if err != nil {
332+ s.fail(w, r, 404, "no such user")
333+ return
334+ }
335+ user := s.current(r)
336+ if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") {
337+ repos, err := s.store.ReposByOwner(owner.ID, user != nil && user.ID == owner.ID)
338+ if err != nil {
339+ s.fail(w, r, 500, err.Error())
340+ return
341+ }
342+ s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Data: map[string]any{"Owner": owner, "Repos": repos}})
343+ return
344+ }
345+ name := parts[1]
346+ rest := ""
347+ if len(parts) == 3 {
348+ rest = parts[2]
349+ }
350+ if strings.HasSuffix(name, ".git") || isSmart(r, rest) {
351+ s.smart(w, r, owner, strings.TrimSuffix(name, ".git"), rest)
352+ return
353+ }
354+ repo, err := s.store.RepoByPath(owner.Name, name)
355+ if err != nil {
356+ s.fail(w, r, 404, "no such repository")
357+ return
358+ }
359+ if repo.Private && (user == nil || user.ID != owner.ID) {
360+ s.fail(w, r, 404, "no such repository")
361+ return
362+ }
363+ switch {
364+ case rest == "" && r.Method == "GET":
365+ s.summary(w, r, repo)
366+ case rest == "settings" && r.Method == "GET":
367+ s.repoSettings(w, r, repo)
368+ case rest == "settings" && r.Method == "POST":
369+ s.updateRepo(w, r, repo)
370+ case rest == "delete" && r.Method == "POST":
371+ s.deleteRepo(w, r, repo)
372+ case rest == "refs" && r.Method == "GET":
373+ s.refs(w, r, repo)
374+ case strings.HasPrefix(rest, "tree/") && r.Method == "GET":
375+ s.tree(w, r, repo, strings.TrimPrefix(rest, "tree/"))
376+ case strings.HasPrefix(rest, "blob/") && r.Method == "GET":
377+ s.tree(w, r, repo, strings.TrimPrefix(rest, "blob/"))
378+ case strings.HasPrefix(rest, "raw/") && r.Method == "GET":
379+ s.raw(w, r, repo, strings.TrimPrefix(rest, "raw/"))
380+ case strings.HasPrefix(rest, "log/") && r.Method == "GET":
381+ s.log(w, r, repo, strings.TrimPrefix(rest, "log/"))
382+ case rest == "log" && r.Method == "GET":
383+ s.log(w, r, repo, "")
384+ case strings.HasPrefix(rest, "commit/") && r.Method == "GET":
385+ s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/"))
386+ default:
387+ s.fail(w, r, 404, "page not found")
388+ }
389+}
390+
391+type repoPage struct {
392+ Repo Repo
393+ Ref string
394+ Commit string
395+ Owner bool
396+ Empty bool
397+}
398+
399+func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
400+ g := Git{Dir: s.repoDir(repo)}
401+ user := s.current(r)
402+ rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID}
403+ if g.Empty() {
404+ rp.Empty = true
405+ rp.Ref = g.Head()
406+ return rp, g, nil
407+ }
408+ if ref == "" {
409+ ref = g.Head()
410+ }
411+ commit, err := g.Resolve(ref)
412+ if err != nil {
413+ return rp, g, err
414+ }
415+ rp.Ref = ref
416+ rp.Commit = commit
417+ return rp, g, nil
418+}
419+
420+func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
421+ rp, g, err := s.repoPage(r, repo, "")
422+ if err != nil {
423+ s.fail(w, r, 404, "no such ref")
424+ return
425+ }
426+ data := map[string]any{"Page": rp}
427+ if !rp.Empty {
428+ commits, _ := g.Log(rp.Commit, 8, "")
429+ data["Commits"] = commits
430+ branches, _ := g.Refs("refs/heads")
431+ tags, _ := g.Refs("refs/tags")
432+ data["Branches"] = branches
433+ data["Tags"] = tags
434+ name, content := g.Readme(rp.Commit)
435+ if name != "" {
436+ data["ReadmeName"] = name
437+ if strings.HasSuffix(strings.ToLower(name), ".md") {
438+ data["Readme"] = s.renderMarkdown(content)
439+ } else {
440+ data["ReadmeText"] = string(content)
441+ }
442+ }
443+ }
444+ s.render(w, r, "repo", page{Title: repo.Path(), Data: data})
445+}
446+
447+func (s *Server) renderMarkdown(src []byte) template.HTML {
448+ var buf bytes.Buffer
449+ if err := s.markdown.Convert(src, &buf); err != nil {
450+ return template.HTML("<pre>" + template.HTMLEscapeString(string(src)) + "</pre>")
451+ }
452+ return template.HTML(s.policy.SanitizeBytes(buf.Bytes()))
453+}
454+
455+func (s *Server) splitRef(g Git, spec string) (string, string, bool) {
456+ spec = strings.Trim(spec, "/")
457+ if spec == "" {
458+ return "", "", false
459+ }
460+ parts := strings.Split(spec, "/")
461+ for i := len(parts); i >= 1; i-- {
462+ ref := strings.Join(parts[:i], "/")
463+ if _, err := g.Resolve(ref); err == nil {
464+ return ref, strings.Join(parts[i:], "/"), true
465+ }
466+ }
467+ return "", "", false
468+}
469+
470+func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
471+ g := Git{Dir: s.repoDir(repo)}
472+ ref, filePath, ok := s.splitRef(g, spec)
473+ if !ok {
474+ s.fail(w, r, 404, "no such ref")
475+ return
476+ }
477+ filePath, ok = cleanPath(filePath)
478+ if !ok {
479+ s.fail(w, r, 404, "no such path")
480+ return
481+ }
482+ rp, g, err := s.repoPage(r, repo, ref)
483+ if err != nil {
484+ s.fail(w, r, 404, "no such ref")
485+ return
486+ }
487+ crumbs := strings.Split(filePath, "/")
488+ if filePath == "" {
489+ crumbs = nil
490+ }
491+ data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs}
492+ switch g.Kind(rp.Commit, filePath) {
493+ case "tree", "commit":
494+ entries, err := g.Tree(rp.Commit, filePath)
495+ if err != nil {
496+ s.fail(w, r, 404, "no such path")
497+ return
498+ }
499+ data["Entries"] = entries
500+ s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data})
501+ case "blob":
502+ content, err := g.Blob(rp.Commit, filePath)
503+ if err != nil {
504+ s.fail(w, r, 404, "no such path")
505+ return
506+ }
507+ data["Size"] = int64(len(content))
508+ if isBinary(content) {
509+ data["Binary"] = true
510+ } else if len(content) > 1<<20 {
511+ data["Large"] = true
512+ } else {
513+ data["Content"] = content
514+ }
515+ s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Data: data})
516+ default:
517+ s.fail(w, r, 404, "no such path")
518+ }
519+}
520+
521+func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
522+ g := Git{Dir: s.repoDir(repo)}
523+ ref, filePath, ok := s.splitRef(g, spec)
524+ if !ok {
525+ s.fail(w, r, 404, "no such ref")
526+ return
527+ }
528+ filePath, ok = cleanPath(filePath)
529+ if !ok || filePath == "" {
530+ s.fail(w, r, 404, "no such path")
531+ return
532+ }
533+ commit, err := g.Resolve(ref)
534+ if err != nil {
535+ s.fail(w, r, 404, "no such ref")
536+ return
537+ }
538+ content, err := g.Blob(commit, filePath)
539+ if err != nil {
540+ s.fail(w, r, 404, "no such path")
541+ return
542+ }
543+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
544+ w.Header().Set("X-Content-Type-Options", "nosniff")
545+ w.Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(filePath)+"\"")
546+ w.Write(content)
547+}
548+
549+func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref string) {
550+ ref = strings.Trim(ref, "/")
551+ rp, g, err := s.repoPage(r, repo, ref)
552+ if err != nil {
553+ s.fail(w, r, 404, "no such ref")
554+ return
555+ }
556+ data := map[string]any{"Page": rp}
557+ if !rp.Empty {
558+ start := rp.Commit
559+ if from := r.URL.Query().Get("from"); from != "" {
560+ if start, err = g.Resolve(from); err != nil {
561+ s.fail(w, r, 404, "no such commit")
562+ return
563+ }
564+ }
565+ const per = 50
566+ commits, err := g.Log(start, per+1, "")
567+ if err != nil {
568+ s.fail(w, r, 500, err.Error())
569+ return
570+ }
571+ if len(commits) > per {
572+ data["Next"] = commits[per].Hash
573+ commits = commits[:per]
574+ }
575+ data["Commits"] = commits
576+ }
577+ s.render(w, r, "log", page{Title: repo.Path() + " log", Data: data})
578+}
579+
580+func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash string) {
581+ rp, g, err := s.repoPage(r, repo, hash)
582+ if err != nil {
583+ s.fail(w, r, 404, "no such commit")
584+ return
585+ }
586+ c, err := g.Commit(rp.Commit)
587+ if err != nil {
588+ s.fail(w, r, 404, "no such commit")
589+ return
590+ }
591+ files, err := g.Diff(rp.Commit)
592+ if err != nil {
593+ s.fail(w, r, 500, err.Error())
594+ return
595+ }
596+ added, removed := 0, 0
597+ for _, f := range files {
598+ added += f.Added
599+ removed += f.Removed
600+ }
601+ s.render(w, r, "commit", page{Title: c.Subject, Data: map[string]any{"Page": rp, "Commit": c, "Files": files, "Added": added, "Removed": removed}})
602+}
603+
604+func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) {
605+ rp, g, err := s.repoPage(r, repo, "")
606+ if err != nil {
607+ s.fail(w, r, 404, "no such ref")
608+ return
609+ }
610+ branches, _ := g.Refs("refs/heads")
611+ tags, _ := g.Refs("refs/tags")
612+ s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}})
613+}
614+
615+func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) {
616+ rp, _, _ := s.repoPage(r, repo, "")
617+ if !rp.Owner {
618+ s.fail(w, r, 403, "not your repository")
619+ return
620+ }
621+ s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Data: map[string]any{"Page": rp}})
622+}
623+
624+func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
625+ rp, _, _ := s.repoPage(r, repo, "")
626+ if !rp.Owner {
627+ s.fail(w, r, 403, "not your repository")
628+ return
629+ }
630+ if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on"); err != nil {
631+ s.fail(w, r, 500, err.Error())
632+ return
633+ }
634+ http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
635+}
636+
637+func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
638+ rp, _, _ := s.repoPage(r, repo, "")
639+ if !rp.Owner {
640+ s.fail(w, r, 403, "not your repository")
641+ return
642+ }
643+ if r.FormValue("confirm") != repo.Name {
644+ http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther)
645+ return
646+ }
647+ if err := s.store.DeleteRepo(repo.ID); err != nil {
648+ s.fail(w, r, 500, err.Error())
649+ return
650+ }
651+ os.RemoveAll(s.repoDir(repo))
652+ http.Redirect(w, r, "/~"+repo.Owner.Name, http.StatusSeeOther)
653+}
654+
655+func ago(t time.Time) string {
656+ d := time.Since(t)
657+ switch {
658+ case d < time.Minute:
659+ return "just now"
660+ case d < time.Hour:
661+ return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
662+ case d < 2*time.Hour:
663+ return "an hour ago"
664+ case d < 24*time.Hour:
665+ return fmt.Sprintf("%d hours ago", int(d.Hours()))
666+ case d < 48*time.Hour:
667+ return "yesterday"
668+ case d < 30*24*time.Hour:
669+ return fmt.Sprintf("%d days ago", int(d.Hours()/24))
670+ case d < 365*24*time.Hour:
671+ return fmt.Sprintf("%d months ago", int(d.Hours()/24/30))
672+ default:
673+ return fmt.Sprintf("%d years ago", int(d.Hours()/24/365))
674+ }
675+}
676+
677+func humanSize(n int64) string {
678+ switch {
679+ case n < 1024:
680+ return fmt.Sprintf("%d B", n)
681+ case n < 1<<20:
682+ return fmt.Sprintf("%.1f KiB", float64(n)/1024)
683+ default:
684+ return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
685+ }
686+}
687+