feat(basin): minimal git host with ssh and smart https
26 files changed, +2147 −0
- .gitignore added +1 −0
- README.md added +32 −0
- git.go added +338 −0
- go.mod added +25 −0
- go.sum added +64 −0
- main.go added +89 −0
- shell.go added +83 −0
- smart.go added +113 −0
- static/style.css added +71 −0
- store.go added +422 −0
- templates/blob.html added +14 −0
- templates/commit.html added +26 −0
- templates/create.html added +9 −0
- templates/error.html added +3 −0
- templates/index.html added +13 −0
- templates/layout.html added +29 −0
- templates/log.html added +8 −0
- templates/login.html added +8 −0
- templates/refs.html added +7 −0
- templates/register.html added +9 −0
- templates/repo.html added +33 −0
- templates/reposettings.html added +14 −0
- templates/settings.html added +24 −0
- templates/tree.html added +17 −0
- templates/user.html added +8 −0
- web.go added +687 −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}} <{{$c.Email}}></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 | + | |