feat(basin): mail, password reset, totp, issues, stars, rename, transfer, archive, blame, compare, downloads, feeds, admin commands
36 files changed, +1502 −34
- README.md modified +8 −1
- account.go added +208 −0
- extra.go added +304 −0
- go.mod modified +1 −0
- go.sum modified +2 −0
- issues.go added +248 −0
- mail.go added +103 −0
- main.go modified +38 −1
- pull.go modified +25 −0
- shell.go modified +3 −0
- smart.go modified +4 −0
- static/style.css modified +12 −0
- store.go modified +202 −8
- templates/blame.html added +11 −0
- templates/blob.html modified +1 −1
- templates/compare.html added +26 −0
- templates/issue.html added +21 −0
- templates/issues.html added +8 −0
- templates/layout.html modified +2 −2
- templates/login.html modified +1 −1
- templates/message.html added +3 −0
- templates/newissue.html added +9 −0
- templates/recovery.html added +8 −0
- templates/refs.html modified +1 −1
- templates/repo.html modified +3 −0
- templates/reposettings.html modified +15 −0
- templates/reset.html added +13 −0
- templates/resetpassword.html added +9 −0
- templates/security.html modified +6 −0
- templates/settings.html modified +12 −0
- templates/totp.html added +10 −0
- templates/totpsetup.html added +11 −0
- templates/tree.html modified +1 −1
- templates/user.html modified +1 −0
- totp.go added +77 −0
- web.go modified +95 −18
README.md modified
| @@ -13,7 +13,14 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 13 | 13 | - Collaborators per repository who can push, read it when private and merge pull requests, while settings and deletion stay with the owner |
| 14 | 14 | - A per user disk quota set with `basin quota 2G` and enforced on push over both transports |
| 15 | 15 | - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp` |
| 16 | +- Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip | |
| 17 | +- Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title | |
| 18 | +- Blame, a compare page for any two refs, tar.gz and zip downloads of any ref from `git archive`, and an Atom feed of commits per branch | |
| 19 | +- Rename, transfer to another user and archive from the repository settings. An archived repository refuses pushes, pull requests and comments but stays readable | |
| 20 | +- An optional email address on each account, verified by a link, used for password resets and for notifications about pull requests, reviews and issues on your repositories. Both can be turned off and nothing works without the address, so the whole thing is opt in | |
| 21 | +- TOTP two factor authentication with a QR code for the authenticator app and eight single use recovery codes, which is what a password alone cannot give you on something that accepts pushes | |
| 16 | 22 | - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password |
| 23 | +- Relative links and images in a README resolve against the repository, so a `docs/diagram.png` reference renders rather than breaking | |
| 17 | 24 | - Registration is invite only unless you say otherwise, and there is a failure counter per address covering login and register and password changes and HTTPS auth |
| 18 | 25 | |
| 19 | 26 | ## Design |
| @@ -74,7 +81,7 @@ To send changes to something you do not own, fork it from the button beside the | ||
| 74 | 81 | |
| 75 | 82 | ## Limits |
| 76 | 83 | |
| 77 | -Raw files stream straight out of `cat-file` at any size, but the rendered view stops at 4 MiB and syntax highlighting at 512 KiB, beyond which a file is shown plain or offered as raw only. The quota is checked before a push starts rather than during it, so a single push can overshoot by its own size. There is no wiki, no issue tracker and no CI, and there are no plans for any of them. | |
| 84 | +Raw files stream straight out of `cat-file` at any size, but the rendered view stops at 4 MiB and syntax highlighting at 512 KiB, beyond which a file is shown plain or offered as raw only. The quota is checked before a push starts rather than during it, so a single push can overshoot by its own size. There is no wiki and no CI, and there are no plans for either. | |
| 78 | 85 | |
| 79 | 86 | ## Licence |
| 80 | 87 | |
account.go added
| @@ -0,0 +1,208 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/base64" | |
| 5 | + "fmt" | |
| 6 | + "html/template" | |
| 7 | + "net/http" | |
| 8 | + "net/url" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + qrcode "github.com/skip2/go-qrcode" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func (s *Server) setEmail(w http.ResponseWriter, r *http.Request) { | |
| 15 | + u := s.require(w, r) | |
| 16 | + if u == nil { | |
| 17 | + return | |
| 18 | + } | |
| 19 | + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) | |
| 20 | + if err := s.store.SetEmail(u.ID, email); err != nil { | |
| 21 | + http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 22 | + return | |
| 23 | + } | |
| 24 | + if email == "" { | |
| 25 | + http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 26 | + return | |
| 27 | + } | |
| 28 | + if s.mailer == nil { | |
| 29 | + http.Redirect(w, r, "/settings?error=Mail+is+not+configured+on+this+server", http.StatusSeeOther) | |
| 30 | + return | |
| 31 | + } | |
| 32 | + token, err := s.store.CreateMailToken(u.ID, "verify", email) | |
| 33 | + if err != nil { | |
| 34 | + http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 35 | + return | |
| 36 | + } | |
| 37 | + link := fmt.Sprintf("https://%s/verify/%s", s.host, token) | |
| 38 | + if err := s.mailer.Send(email, "Verify your email for "+s.host, fmt.Sprintf("Hello ~%s,\n\nOpen this link to confirm your email address:\n\n%s\n\nThe link is valid for 24 hours. If you did not ask for this, ignore it.\n", u.Name, link)); err != nil { | |
| 39 | + http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper("Could not send mail: "+err.Error()), http.StatusSeeOther) | |
| 40 | + return | |
| 41 | + } | |
| 42 | + http.Redirect(w, r, "/settings?sent=1", http.StatusSeeOther) | |
| 43 | +} | |
| 44 | + | |
| 45 | +func (s *Server) verifyEmail(w http.ResponseWriter, r *http.Request) { | |
| 46 | + user, email, ok := s.store.UseMailToken(r.PathValue("token"), "verify") | |
| 47 | + if !ok { | |
| 48 | + s.fail(w, r, 400, "That verification link is invalid or has expired") | |
| 49 | + return | |
| 50 | + } | |
| 51 | + s.store.MarkVerified(user, email) | |
| 52 | + s.render(w, r, "message", page{Title: "Email verified", Data: map[string]any{"Heading": "Email verified", "Text": "Thanks, " + email + " is now confirmed. Password resets and notifications will go there."}}) | |
| 53 | +} | |
| 54 | + | |
| 55 | +func (s *Server) setNotify(w http.ResponseWriter, r *http.Request) { | |
| 56 | + u := s.require(w, r) | |
| 57 | + if u == nil { | |
| 58 | + return | |
| 59 | + } | |
| 60 | + s.store.SetNotify(u.ID, r.FormValue("notify") == "on") | |
| 61 | + http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 62 | +} | |
| 63 | + | |
| 64 | +func (s *Server) resetForm(w http.ResponseWriter, r *http.Request) { | |
| 65 | + s.render(w, r, "reset", page{Title: "Reset password", Data: map[string]any{"Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}}) | |
| 66 | +} | |
| 67 | + | |
| 68 | +func (s *Server) resetRequest(w http.ResponseWriter, r *http.Request) { | |
| 69 | + ip := clientIP(r) | |
| 70 | + if s.limiter.blocked(ip) { | |
| 71 | + s.fail(w, r, 429, "Too many attempts, try again in a few minutes") | |
| 72 | + return | |
| 73 | + } | |
| 74 | + s.limiter.fail(ip) | |
| 75 | + who := strings.TrimSpace(r.FormValue("who")) | |
| 76 | + u, err := s.store.UserByName(who) | |
| 77 | + if err != nil { | |
| 78 | + u, err = s.store.UserByEmail(who) | |
| 79 | + } | |
| 80 | + if err == nil && s.mailer != nil { | |
| 81 | + if email := s.store.Account(u.ID).Email; email != "" && s.store.Account(u.ID).Verified { | |
| 82 | + token, _ := s.store.CreateMailToken(u.ID, "reset", email) | |
| 83 | + link := fmt.Sprintf("https://%s/reset/%s", s.host, token) | |
| 84 | + s.mailer.Send(email, "Password reset for "+s.host, fmt.Sprintf("Hello ~%s,\n\nSomeone asked to reset the password for your account. Open this link to choose a new one:\n\n%s\n\nThe link is valid for 24 hours. If that was not you, ignore this message and nothing changes.\n", u.Name, link)) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther) | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (s *Server) resetTokenForm(w http.ResponseWriter, r *http.Request) { | |
| 91 | + s.render(w, r, "resetpassword", page{Title: "Choose a new password", Data: map[string]any{"Token": r.PathValue("token")}}) | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (s *Server) resetPassword(w http.ResponseWriter, r *http.Request) { | |
| 95 | + user, _, ok := s.store.UseMailToken(r.PathValue("token"), "reset") | |
| 96 | + if !ok { | |
| 97 | + s.fail(w, r, 400, "That reset link is invalid or has expired") | |
| 98 | + return | |
| 99 | + } | |
| 100 | + if err := s.store.SetPassword(user, r.FormValue("password")); err != nil { | |
| 101 | + w.WriteHeader(400) | |
| 102 | + s.render(w, r, "resetpassword", page{Title: "Choose a new password", Error: err.Error(), Data: map[string]any{"Token": r.PathValue("token")}}) | |
| 103 | + return | |
| 104 | + } | |
| 105 | + s.store.DeleteSessions(user) | |
| 106 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 107 | +} | |
| 108 | + | |
| 109 | +func (s *Server) pendingUser(r *http.Request) (User, bool) { | |
| 110 | + c, err := r.Cookie("pending") | |
| 111 | + if err != nil { | |
| 112 | + return User{}, false | |
| 113 | + } | |
| 114 | + var user int64 | |
| 115 | + var created int64 | |
| 116 | + err = s.store.db.QueryRow(`select user, created from mailtokens where token = ? and kind = 'totp'`, c.Value).Scan(&user, &created) | |
| 117 | + if err != nil { | |
| 118 | + return User{}, false | |
| 119 | + } | |
| 120 | + u, err := s.store.userByID(user) | |
| 121 | + return u, err == nil | |
| 122 | +} | |
| 123 | + | |
| 124 | +func (s *Server) totpForm(w http.ResponseWriter, r *http.Request) { | |
| 125 | + if _, ok := s.pendingUser(r); !ok { | |
| 126 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 127 | + return | |
| 128 | + } | |
| 129 | + s.render(w, r, "totp", page{Title: "Two factor"}) | |
| 130 | +} | |
| 131 | + | |
| 132 | +func (s *Server) totpLogin(w http.ResponseWriter, r *http.Request) { | |
| 133 | + u, ok := s.pendingUser(r) | |
| 134 | + if !ok { | |
| 135 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 136 | + return | |
| 137 | + } | |
| 138 | + ip := clientIP(r) | |
| 139 | + if s.limiter.blocked(ip) { | |
| 140 | + s.fail(w, r, 429, "Too many attempts, try again in a few minutes") | |
| 141 | + return | |
| 142 | + } | |
| 143 | + a := s.store.Account(u.ID) | |
| 144 | + code := r.FormValue("code") | |
| 145 | + if !totpValid(a.TOTP, code) { | |
| 146 | + if left, used := useRecovery(a.Recovery, code); used { | |
| 147 | + s.store.SetTOTP(u.ID, a.TOTP, left) | |
| 148 | + } else { | |
| 149 | + s.limiter.fail(ip) | |
| 150 | + w.WriteHeader(401) | |
| 151 | + s.render(w, r, "totp", page{Title: "Two factor", Error: "Wrong code"}) | |
| 152 | + return | |
| 153 | + } | |
| 154 | + } | |
| 155 | + if c, err := r.Cookie("pending"); err == nil { | |
| 156 | + s.store.db.Exec(`delete from mailtokens where token = ?`, c.Value) | |
| 157 | + } | |
| 158 | + http.SetCookie(w, &http.Cookie{Name: "pending", Value: "", Path: "/", MaxAge: -1}) | |
| 159 | + s.startSession(w, u) | |
| 160 | + http.Redirect(w, r, "/", http.StatusSeeOther) | |
| 161 | +} | |
| 162 | + | |
| 163 | +func (s *Server) setupTOTP(w http.ResponseWriter, r *http.Request) { | |
| 164 | + u := s.require(w, r) | |
| 165 | + if u == nil { | |
| 166 | + return | |
| 167 | + } | |
| 168 | + if _, ok := s.store.CheckPassword(u.Name, r.FormValue("password")); !ok { | |
| 169 | + http.Redirect(w, r, "/settings/security?error=Wrong+password", http.StatusSeeOther) | |
| 170 | + return | |
| 171 | + } | |
| 172 | + secret := newSecret() | |
| 173 | + token, _ := s.store.CreateMailToken(u.ID, "totpsetup", secret) | |
| 174 | + uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", url.PathEscape(s.host), url.PathEscape(u.Name), secret, url.QueryEscape(s.host)) | |
| 175 | + png, _ := qrcode.Encode(uri, qrcode.Medium, 200) | |
| 176 | + s.render(w, r, "totpsetup", page{Title: "Enable two factor", User: u, Data: map[string]any{"Settings": "security", "Secret": secret, "Token": token, "QR": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)), "URI": uri}}) | |
| 177 | +} | |
| 178 | + | |
| 179 | +func (s *Server) enableTOTP(w http.ResponseWriter, r *http.Request) { | |
| 180 | + u := s.require(w, r) | |
| 181 | + if u == nil { | |
| 182 | + return | |
| 183 | + } | |
| 184 | + var secret string | |
| 185 | + var created int64 | |
| 186 | + err := s.store.db.QueryRow(`select email, created from mailtokens where token = ? and kind = 'totpsetup' and user = ?`, r.FormValue("token"), u.ID).Scan(&secret, &created) | |
| 187 | + if err != nil || !totpValid(secret, r.FormValue("code")) { | |
| 188 | + http.Redirect(w, r, "/settings/security?error=That+code+did+not+match%2C+start+again", http.StatusSeeOther) | |
| 189 | + return | |
| 190 | + } | |
| 191 | + s.store.db.Exec(`delete from mailtokens where token = ?`, r.FormValue("token")) | |
| 192 | + codes, hashes := newRecoveryCodes() | |
| 193 | + s.store.SetTOTP(u.ID, secret, hashes) | |
| 194 | + s.render(w, r, "recovery", page{Title: "Recovery codes", User: u, Data: map[string]any{"Settings": "security", "Codes": codes}}) | |
| 195 | +} | |
| 196 | + | |
| 197 | +func (s *Server) disableTOTP(w http.ResponseWriter, r *http.Request) { | |
| 198 | + u := s.require(w, r) | |
| 199 | + if u == nil { | |
| 200 | + return | |
| 201 | + } | |
| 202 | + if _, ok := s.store.CheckPassword(u.Name, r.FormValue("password")); !ok { | |
| 203 | + http.Redirect(w, r, "/settings/security?error=Wrong+password", http.StatusSeeOther) | |
| 204 | + return | |
| 205 | + } | |
| 206 | + s.store.SetTOTP(u.ID, "", "") | |
| 207 | + http.Redirect(w, r, "/settings/security", http.StatusSeeOther) | |
| 208 | +} | |
extra.go added
| @@ -0,0 +1,304 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/xml" | |
| 6 | + "fmt" | |
| 7 | + "html/template" | |
| 8 | + "net/http" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "path/filepath" | |
| 12 | + "strconv" | |
| 13 | + "strings" | |
| 14 | + "time" | |
| 15 | +) | |
| 16 | + | |
| 17 | +func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 18 | + u := s.require(w, r) | |
| 19 | + if u == nil { | |
| 20 | + return | |
| 21 | + } | |
| 22 | + s.store.Star(u.ID, repo.ID, !s.store.Starred(u.ID, repo.ID)) | |
| 23 | + http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 24 | +} | |
| 25 | + | |
| 26 | +func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 27 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 28 | + if !rp.IsOwner { | |
| 29 | + s.fail(w, r, 403, "Not your repository") | |
| 30 | + return | |
| 31 | + } | |
| 32 | + name := strings.TrimSpace(r.FormValue("name")) | |
| 33 | + back := "/" + repo.Path() + "/settings" | |
| 34 | + if name == repo.Name { | |
| 35 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 36 | + return | |
| 37 | + } | |
| 38 | + if err := s.store.RenameRepo(repo.ID, name); err != nil { | |
| 39 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 40 | + return | |
| 41 | + } | |
| 42 | + renamed := repo | |
| 43 | + renamed.Name = name | |
| 44 | + if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil { | |
| 45 | + s.store.RenameRepo(repo.ID, repo.Name) | |
| 46 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 47 | + return | |
| 48 | + } | |
| 49 | + http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther) | |
| 50 | +} | |
| 51 | + | |
| 52 | +func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 53 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 54 | + if !rp.IsOwner { | |
| 55 | + s.fail(w, r, 403, "Not your repository") | |
| 56 | + return | |
| 57 | + } | |
| 58 | + back := "/" + repo.Path() + "/settings" | |
| 59 | + target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(r.FormValue("owner"), "~"))) | |
| 60 | + if err != nil { | |
| 61 | + http.Redirect(w, r, back+"?error=No+such+user", http.StatusSeeOther) | |
| 62 | + return | |
| 63 | + } | |
| 64 | + if target.ID == repo.Owner.ID { | |
| 65 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 66 | + return | |
| 67 | + } | |
| 68 | + if r.FormValue("confirm") != repo.Name { | |
| 69 | + http.Redirect(w, r, back+"?error=Type+the+repository+name+to+confirm+the+transfer", http.StatusSeeOther) | |
| 70 | + return | |
| 71 | + } | |
| 72 | + if err := s.store.TransferRepo(repo.ID, target.ID); err != nil { | |
| 73 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 74 | + return | |
| 75 | + } | |
| 76 | + moved := repo | |
| 77 | + moved.Owner = target | |
| 78 | + os.MkdirAll(filepath.Dir(s.repoDir(moved)), 0o755) | |
| 79 | + if err := os.Rename(s.repoDir(repo), s.repoDir(moved)); err != nil { | |
| 80 | + s.store.TransferRepo(repo.ID, repo.Owner.ID) | |
| 81 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 82 | + return | |
| 83 | + } | |
| 84 | + s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()), | |
| 85 | + fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path())) | |
| 86 | + http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther) | |
| 87 | +} | |
| 88 | + | |
| 89 | +func (s *Server) archiveRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 90 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 91 | + if !rp.IsOwner { | |
| 92 | + s.fail(w, r, 403, "Not your repository") | |
| 93 | + return | |
| 94 | + } | |
| 95 | + s.store.SetArchived(repo.ID, !repo.Archived) | |
| 96 | + http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther) | |
| 97 | +} | |
| 98 | + | |
| 99 | +type BlameLine struct { | |
| 100 | + Hash string | |
| 101 | + Author string | |
| 102 | + Time time.Time | |
| 103 | + Line int | |
| 104 | + Text string | |
| 105 | + First bool | |
| 106 | + Subject string | |
| 107 | +} | |
| 108 | + | |
| 109 | +func (g Git) Blame(commit, filePath string) ([]BlameLine, error) { | |
| 110 | + out, err := g.run(nil, "blame", "--porcelain", commit, "--", filePath) | |
| 111 | + if err != nil { | |
| 112 | + return nil, err | |
| 113 | + } | |
| 114 | + meta := map[string]*BlameLine{} | |
| 115 | + var lines []BlameLine | |
| 116 | + var cur *BlameLine | |
| 117 | + for _, line := range strings.Split(string(out), "\n") { | |
| 118 | + switch { | |
| 119 | + case strings.HasPrefix(line, "\t"): | |
| 120 | + if cur != nil { | |
| 121 | + cur.Text = line[1:] | |
| 122 | + lines = append(lines, *cur) | |
| 123 | + cur = nil | |
| 124 | + } | |
| 125 | + case cur == nil: | |
| 126 | + f := strings.Fields(line) | |
| 127 | + if len(f) < 3 || len(f[0]) != 40 { | |
| 128 | + continue | |
| 129 | + } | |
| 130 | + n, _ := strconv.Atoi(f[2]) | |
| 131 | + base := meta[f[0]] | |
| 132 | + if base == nil { | |
| 133 | + base = &BlameLine{Hash: f[0]} | |
| 134 | + meta[f[0]] = base | |
| 135 | + } | |
| 136 | + cur = &BlameLine{Hash: f[0], Author: base.Author, Time: base.Time, Subject: base.Subject, Line: n} | |
| 137 | + case strings.HasPrefix(line, "author "): | |
| 138 | + meta[cur.Hash].Author = strings.TrimPrefix(line, "author ") | |
| 139 | + cur.Author = meta[cur.Hash].Author | |
| 140 | + case strings.HasPrefix(line, "author-time "): | |
| 141 | + t, _ := strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64) | |
| 142 | + meta[cur.Hash].Time = time.Unix(t, 0) | |
| 143 | + cur.Time = meta[cur.Hash].Time | |
| 144 | + case strings.HasPrefix(line, "summary "): | |
| 145 | + meta[cur.Hash].Subject = strings.TrimPrefix(line, "summary ") | |
| 146 | + cur.Subject = meta[cur.Hash].Subject | |
| 147 | + } | |
| 148 | + } | |
| 149 | + prev := "" | |
| 150 | + for i := range lines { | |
| 151 | + lines[i].First = lines[i].Hash != prev | |
| 152 | + prev = lines[i].Hash | |
| 153 | + } | |
| 154 | + return lines, nil | |
| 155 | +} | |
| 156 | + | |
| 157 | +func (s *Server) blame(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { | |
| 158 | + g := Git{Dir: s.repoDir(repo)} | |
| 159 | + ref, filePath, ok := s.splitRef(g, spec) | |
| 160 | + if !ok { | |
| 161 | + s.fail(w, r, 404, "No such ref") | |
| 162 | + return | |
| 163 | + } | |
| 164 | + filePath, ok = cleanPath(filePath) | |
| 165 | + if !ok || filePath == "" { | |
| 166 | + s.fail(w, r, 404, "No such path") | |
| 167 | + return | |
| 168 | + } | |
| 169 | + rp, g, err := s.repoPage(r, repo, ref) | |
| 170 | + if err != nil { | |
| 171 | + s.fail(w, r, 404, "No such ref") | |
| 172 | + return | |
| 173 | + } | |
| 174 | + rp.Tab = "tree" | |
| 175 | + if g.Size(rp.Commit, filePath) > maxRender { | |
| 176 | + s.fail(w, r, 413, "File too large to blame") | |
| 177 | + return | |
| 178 | + } | |
| 179 | + lines, err := g.Blame(rp.Commit, filePath) | |
| 180 | + if err != nil { | |
| 181 | + s.fail(w, r, 404, "No such path") | |
| 182 | + return | |
| 183 | + } | |
| 184 | + crumbs := strings.Split(filePath, "/") | |
| 185 | + data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Lines": lines, "File": true, "Blame": true} | |
| 186 | + if entry := g.Entry(rp.Commit, filePath); entry != nil { | |
| 187 | + data["Entry"] = entry | |
| 188 | + } | |
| 189 | + if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 { | |
| 190 | + data["LastCommit"] = last[0] | |
| 191 | + } | |
| 192 | + s.render(w, r, "blame", page{Title: repo.Path() + "/" + filePath + " blame", Data: data}) | |
| 193 | +} | |
| 194 | + | |
| 195 | +func (s *Server) compare(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { | |
| 196 | + rp, g, err := s.repoPage(r, repo, "") | |
| 197 | + if err != nil { | |
| 198 | + s.fail(w, r, 404, "No such ref") | |
| 199 | + return | |
| 200 | + } | |
| 201 | + rp.Tab = "log" | |
| 202 | + branches, _ := g.Refs("refs/heads") | |
| 203 | + data := map[string]any{"Page": rp, "Branches": branches} | |
| 204 | + base, head, ok := strings.Cut(spec, "...") | |
| 205 | + if !ok { | |
| 206 | + base, head = r.URL.Query().Get("base"), r.URL.Query().Get("head") | |
| 207 | + } | |
| 208 | + data["Base"], data["Head"] = base, head | |
| 209 | + if base != "" && head != "" { | |
| 210 | + bh, err1 := g.Resolve(base) | |
| 211 | + hh, err2 := g.Resolve(head) | |
| 212 | + if err1 != nil || err2 != nil { | |
| 213 | + s.fail(w, r, 404, "No such ref") | |
| 214 | + return | |
| 215 | + } | |
| 216 | + if mb, err := g.MergeBase(bh, hh); err == nil { | |
| 217 | + commits, _ := g.LogRange(mb, hh, 200) | |
| 218 | + files, _ := g.DiffRange(mb, hh) | |
| 219 | + added, removed := 0, 0 | |
| 220 | + for _, f := range files { | |
| 221 | + added += f.Added | |
| 222 | + removed += f.Removed | |
| 223 | + } | |
| 224 | + data["Commits"], data["Files"], data["Added"], data["Removed"], data["HeadHash"] = commits, files, added, removed, hh | |
| 225 | + } else { | |
| 226 | + data["Unrelated"] = true | |
| 227 | + } | |
| 228 | + } | |
| 229 | + s.render(w, r, "compare", page{Title: repo.Path() + " compare", Data: data}) | |
| 230 | +} | |
| 231 | + | |
| 232 | +func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { | |
| 233 | + g := Git{Dir: s.repoDir(repo)} | |
| 234 | + format := "" | |
| 235 | + switch { | |
| 236 | + case strings.HasSuffix(spec, ".tar.gz"): | |
| 237 | + format, spec = "tar.gz", strings.TrimSuffix(spec, ".tar.gz") | |
| 238 | + case strings.HasSuffix(spec, ".zip"): | |
| 239 | + format, spec = "zip", strings.TrimSuffix(spec, ".zip") | |
| 240 | + default: | |
| 241 | + s.fail(w, r, 404, "Use .tar.gz or .zip") | |
| 242 | + return | |
| 243 | + } | |
| 244 | + commit, err := g.Resolve(spec) | |
| 245 | + if err != nil { | |
| 246 | + s.fail(w, r, 404, "No such ref") | |
| 247 | + return | |
| 248 | + } | |
| 249 | + safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec) | |
| 250 | + name := repo.Name + "-" + safe | |
| 251 | + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) | |
| 252 | + defer cancel() | |
| 253 | + cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "archive", "--format="+format, "--prefix="+name+"/", commit) | |
| 254 | + cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} | |
| 255 | + w.Header().Set("Content-Type", "application/octet-stream") | |
| 256 | + w.Header().Set("Content-Disposition", `attachment; filename="`+name+"."+format+`"`) | |
| 257 | + cmd.Stdout = w | |
| 258 | + cmd.Run() | |
| 259 | +} | |
| 260 | + | |
| 261 | +type atomFeed struct { | |
| 262 | + XMLName xml.Name `xml:"feed"` | |
| 263 | + NS string `xml:"xmlns,attr"` | |
| 264 | + Title string `xml:"title"` | |
| 265 | + ID string `xml:"id"` | |
| 266 | + Updated string `xml:"updated"` | |
| 267 | + Link atomLink `xml:"link"` | |
| 268 | + Entries []atomEntry `xml:"entry"` | |
| 269 | +} | |
| 270 | + | |
| 271 | +type atomLink struct { | |
| 272 | + Href string `xml:"href,attr"` | |
| 273 | +} | |
| 274 | + | |
| 275 | +type atomEntry struct { | |
| 276 | + Title string `xml:"title"` | |
| 277 | + ID string `xml:"id"` | |
| 278 | + Updated string `xml:"updated"` | |
| 279 | + Author string `xml:"author>name"` | |
| 280 | + Link atomLink `xml:"link"` | |
| 281 | + Content string `xml:"content"` | |
| 282 | +} | |
| 283 | + | |
| 284 | +func (s *Server) feed(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 285 | + rp, g, err := s.repoPage(r, repo, r.URL.Query().Get("ref")) | |
| 286 | + if err != nil { | |
| 287 | + s.fail(w, r, 404, "No such ref") | |
| 288 | + return | |
| 289 | + } | |
| 290 | + base := "https://" + s.host + "/" + repo.Path() | |
| 291 | + feed := atomFeed{NS: "http://www.w3.org/2005/Atom", Title: repo.Path() + " commits on " + rp.Ref, ID: base + "/feed", Link: atomLink{Href: base}, Updated: time.Now().UTC().Format(time.RFC3339)} | |
| 292 | + if !rp.Empty { | |
| 293 | + commits, _ := g.Log(rp.Commit, 30, "") | |
| 294 | + for _, c := range commits { | |
| 295 | + feed.Entries = append(feed.Entries, atomEntry{Title: c.Subject, ID: base + "/commit/" + c.Hash, Updated: c.Time.UTC().Format(time.RFC3339), Author: c.Author, Link: atomLink{Href: base + "/commit/" + c.Hash}, Content: c.Body}) | |
| 296 | + } | |
| 297 | + if len(commits) > 0 { | |
| 298 | + feed.Updated = commits[0].Time.UTC().Format(time.RFC3339) | |
| 299 | + } | |
| 300 | + } | |
| 301 | + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") | |
| 302 | + w.Write([]byte(xml.Header)) | |
| 303 | + xml.NewEncoder(w).Encode(feed) | |
| 304 | +} | |
go.mod modified
| @@ -20,6 +20,7 @@ require ( | ||
| 20 | 20 | github.com/mattn/go-isatty v0.0.24 // indirect |
| 21 | 21 | github.com/ncruces/go-strftime v1.1.0 // indirect |
| 22 | 22 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 23 | + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect | |
| 23 | 24 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect |
| 24 | 25 | golang.org/x/image v0.48.0 // indirect |
| 25 | 26 | golang.org/x/net v0.62.0 // indirect |
go.sum modified
| @@ -31,6 +31,8 @@ github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2K | ||
| 31 | 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 32 | 32 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 33 | 33 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 34 | +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= | |
| 35 | +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= | |
| 34 | 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
| 35 | 37 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
| 36 | 38 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
issues.go added
| @@ -0,0 +1,248 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "html/template" | |
| 8 | + "net/http" | |
| 9 | + "strconv" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | +) | |
| 13 | + | |
| 14 | +type Issue struct { | |
| 15 | + ID int64 | |
| 16 | + Number int64 | |
| 17 | + Author User | |
| 18 | + Title string | |
| 19 | + Body string | |
| 20 | + State string | |
| 21 | + Created time.Time | |
| 22 | + Updated time.Time | |
| 23 | + Comments int | |
| 24 | +} | |
| 25 | + | |
| 26 | +func (s *Store) CreateIssue(repo Repo, author User, title, body string) (Issue, error) { | |
| 27 | + title = strings.TrimSpace(title) | |
| 28 | + if title == "" || len(title) > 200 { | |
| 29 | + return Issue{}, errors.New("Title is required and at most 200 characters") | |
| 30 | + } | |
| 31 | + if len(body) > 20000 { | |
| 32 | + return Issue{}, errors.New("Body is too long") | |
| 33 | + } | |
| 34 | + now := time.Now().Unix() | |
| 35 | + tx, err := s.db.Begin() | |
| 36 | + if err != nil { | |
| 37 | + return Issue{}, err | |
| 38 | + } | |
| 39 | + defer tx.Rollback() | |
| 40 | + var number int64 | |
| 41 | + tx.QueryRow(`select coalesce(max(number), 0) + 1 from issues where repo = ?`, repo.ID).Scan(&number) | |
| 42 | + res, err := tx.Exec(`insert into issues(repo, number, author, title, body, created, updated) values (?, ?, ?, ?, ?, ?, ?)`, repo.ID, number, author.ID, title, body, now, now) | |
| 43 | + if err != nil { | |
| 44 | + return Issue{}, err | |
| 45 | + } | |
| 46 | + if err := tx.Commit(); err != nil { | |
| 47 | + return Issue{}, err | |
| 48 | + } | |
| 49 | + id, _ := res.LastInsertId() | |
| 50 | + return Issue{ID: id, Number: number, Author: author, Title: title, Body: body, State: "open"}, nil | |
| 51 | +} | |
| 52 | + | |
| 53 | +const issueColumns = `i.id, i.number, i.title, i.body, i.state, i.created, i.updated, (select count(*) from issue_comments c where c.issue = i.id), u.id, u.name, u.created from issues i join users u on u.id = i.author` | |
| 54 | + | |
| 55 | +func scanIssues(rows *sql.Rows) ([]Issue, error) { | |
| 56 | + defer rows.Close() | |
| 57 | + var out []Issue | |
| 58 | + for rows.Next() { | |
| 59 | + var i Issue | |
| 60 | + var created, updated, uCreated int64 | |
| 61 | + if err := rows.Scan(&i.ID, &i.Number, &i.Title, &i.Body, &i.State, &created, &updated, &i.Comments, &i.Author.ID, &i.Author.Name, &uCreated); err != nil { | |
| 62 | + return nil, err | |
| 63 | + } | |
| 64 | + i.Created, i.Updated, i.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(uCreated, 0) | |
| 65 | + out = append(out, i) | |
| 66 | + } | |
| 67 | + return out, rows.Err() | |
| 68 | +} | |
| 69 | + | |
| 70 | +func (s *Store) Issues(repo int64, state string) ([]Issue, error) { | |
| 71 | + rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and (? = '' or i.state = ?) order by i.updated desc`, repo, state, state) | |
| 72 | + if err != nil { | |
| 73 | + return nil, err | |
| 74 | + } | |
| 75 | + return scanIssues(rows) | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (s *Store) Issue(repo, number int64) (Issue, error) { | |
| 79 | + rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and i.number = ?`, repo, number) | |
| 80 | + if err != nil { | |
| 81 | + return Issue{}, err | |
| 82 | + } | |
| 83 | + issues, err := scanIssues(rows) | |
| 84 | + if err != nil || len(issues) == 0 { | |
| 85 | + return Issue{}, ErrNotFound | |
| 86 | + } | |
| 87 | + return issues[0], nil | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (s *Store) OpenIssueCount(repo int64) int { | |
| 91 | + var n int | |
| 92 | + s.db.QueryRow(`select count(*) from issues where repo = ? and state = 'open'`, repo).Scan(&n) | |
| 93 | + return n | |
| 94 | +} | |
| 95 | + | |
| 96 | +func (s *Store) SetIssueState(id int64, state string) { | |
| 97 | + s.db.Exec(`update issues set state = ?, updated = ? where id = ?`, state, time.Now().Unix(), id) | |
| 98 | +} | |
| 99 | + | |
| 100 | +func (s *Store) AddIssueComment(issue int64, author User, body string) error { | |
| 101 | + body = strings.TrimSpace(body) | |
| 102 | + if body == "" || len(body) > 20000 { | |
| 103 | + return errors.New("Comment is empty or too long") | |
| 104 | + } | |
| 105 | + _, err := s.db.Exec(`insert into issue_comments(issue, author, body, created) values (?, ?, ?, ?)`, issue, author.ID, body, time.Now().Unix()) | |
| 106 | + s.db.Exec(`update issues set updated = ? where id = ?`, time.Now().Unix(), issue) | |
| 107 | + return err | |
| 108 | +} | |
| 109 | + | |
| 110 | +func (s *Store) IssueComments(issue int64) ([]Comment, error) { | |
| 111 | + rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from issue_comments c join users u on u.id = c.author where c.issue = ? order by c.id`, issue) | |
| 112 | + if err != nil { | |
| 113 | + return nil, err | |
| 114 | + } | |
| 115 | + defer rows.Close() | |
| 116 | + var out []Comment | |
| 117 | + for rows.Next() { | |
| 118 | + var c Comment | |
| 119 | + var created, uCreated int64 | |
| 120 | + if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil { | |
| 121 | + return nil, err | |
| 122 | + } | |
| 123 | + c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0) | |
| 124 | + out = append(out, c) | |
| 125 | + } | |
| 126 | + return out, rows.Err() | |
| 127 | +} | |
| 128 | + | |
| 129 | +func (s *Server) issues(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 130 | + rp, _, err := s.repoPage(r, repo, "") | |
| 131 | + if err != nil { | |
| 132 | + s.fail(w, r, 404, "No such ref") | |
| 133 | + return | |
| 134 | + } | |
| 135 | + rp.Tab = "issues" | |
| 136 | + state := r.URL.Query().Get("state") | |
| 137 | + if state == "" { | |
| 138 | + state = "open" | |
| 139 | + } | |
| 140 | + if state == "all" { | |
| 141 | + state = "" | |
| 142 | + } | |
| 143 | + issues, err := s.store.Issues(repo.ID, state) | |
| 144 | + if err != nil { | |
| 145 | + s.fail(w, r, 500, err.Error()) | |
| 146 | + return | |
| 147 | + } | |
| 148 | + s.render(w, r, "issues", page{Title: repo.Path() + " issues", Data: map[string]any{"Page": rp, "Issues": issues, "State": r.URL.Query().Get("state")}}) | |
| 149 | +} | |
| 150 | + | |
| 151 | +func (s *Server) newIssue(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 152 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 153 | + rp.Tab = "issues" | |
| 154 | + if rp.User == nil { | |
| 155 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 156 | + return | |
| 157 | + } | |
| 158 | + s.render(w, r, "newissue", page{Title: "New issue", Data: map[string]any{"Page": rp}}) | |
| 159 | +} | |
| 160 | + | |
| 161 | +func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 162 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 163 | + rp.Tab = "issues" | |
| 164 | + user := rp.User | |
| 165 | + if user == nil { | |
| 166 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 167 | + return | |
| 168 | + } | |
| 169 | + if repo.Archived { | |
| 170 | + s.fail(w, r, 403, "This repository is archived") | |
| 171 | + return | |
| 172 | + } | |
| 173 | + issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body"))) | |
| 174 | + if err != nil { | |
| 175 | + w.WriteHeader(400) | |
| 176 | + s.render(w, r, "newissue", page{Title: "New issue", Error: err.Error(), Data: map[string]any{"Page": rp, "IssueTitle": r.FormValue("title"), "Body": r.FormValue("body")}}) | |
| 177 | + return | |
| 178 | + } | |
| 179 | + if repo.Owner.ID != user.ID { | |
| 180 | + s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title), | |
| 181 | + fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", user.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number)) | |
| 182 | + } | |
| 183 | + http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), http.StatusSeeOther) | |
| 184 | +} | |
| 185 | + | |
| 186 | +func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest string) { | |
| 187 | + rp, _, _ := s.repoPage(r, repo, "") | |
| 188 | + rp.Tab = "issues" | |
| 189 | + numStr, action, _ := strings.Cut(rest, "/") | |
| 190 | + number, err := strconv.ParseInt(numStr, 10, 64) | |
| 191 | + if err != nil { | |
| 192 | + s.fail(w, r, 404, "No such issue") | |
| 193 | + return | |
| 194 | + } | |
| 195 | + issue, err := s.store.Issue(repo.ID, number) | |
| 196 | + if err != nil { | |
| 197 | + s.fail(w, r, 404, "No such issue") | |
| 198 | + return | |
| 199 | + } | |
| 200 | + user := rp.User | |
| 201 | + canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID) | |
| 202 | + back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number) | |
| 203 | + if r.Method == "POST" { | |
| 204 | + if user == nil { | |
| 205 | + http.Redirect(w, r, "/login", http.StatusSeeOther) | |
| 206 | + return | |
| 207 | + } | |
| 208 | + subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title) | |
| 209 | + link := fmt.Sprintf("https://%s%s", s.host, back) | |
| 210 | + switch action { | |
| 211 | + case "comment": | |
| 212 | + if repo.Archived { | |
| 213 | + s.fail(w, r, 403, "This repository is archived") | |
| 214 | + return | |
| 215 | + } | |
| 216 | + if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil { | |
| 217 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) | |
| 218 | + return | |
| 219 | + } | |
| 220 | + for _, id := range []int64{issue.Author.ID, repo.Owner.ID} { | |
| 221 | + if id != user.ID { | |
| 222 | + s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link)) | |
| 223 | + } | |
| 224 | + } | |
| 225 | + case "close": | |
| 226 | + if canManage { | |
| 227 | + s.store.SetIssueState(issue.ID, "closed") | |
| 228 | + } | |
| 229 | + case "reopen": | |
| 230 | + if canManage { | |
| 231 | + s.store.SetIssueState(issue.ID, "open") | |
| 232 | + } | |
| 233 | + default: | |
| 234 | + s.fail(w, r, 404, "Page not found") | |
| 235 | + return | |
| 236 | + } | |
| 237 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 238 | + return | |
| 239 | + } | |
| 240 | + comments, _ := s.store.IssueComments(issue.ID) | |
| 241 | + rendered := make([]map[string]any, len(comments)) | |
| 242 | + for i, c := range comments { | |
| 243 | + rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))} | |
| 244 | + } | |
| 245 | + body := s.renderMarkdown([]byte(issue.Body)) | |
| 246 | + s.render(w, r, "issue", page{Title: fmt.Sprintf("#%d %s", issue.Number, issue.Title), Error: r.URL.Query().Get("error"), Math: hasMath(body), | |
| 247 | + Data: map[string]any{"Page": rp, "Issue": issue, "Body": body, "Comments": rendered, "CanManage": canManage}}) | |
| 248 | +} | |
mail.go added
| @@ -0,0 +1,103 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/tls" | |
| 5 | + "encoding/json" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "log" | |
| 9 | + "net" | |
| 10 | + "net/smtp" | |
| 11 | + "os" | |
| 12 | + "path/filepath" | |
| 13 | + "strings" | |
| 14 | + "time" | |
| 15 | +) | |
| 16 | + | |
| 17 | +type Mailer struct { | |
| 18 | + Host string `json:"host"` | |
| 19 | + Port int `json:"port"` | |
| 20 | + User string `json:"user"` | |
| 21 | + Password string `json:"password"` | |
| 22 | + From string `json:"from"` | |
| 23 | + StartTLS bool `json:"starttls"` | |
| 24 | + Insecure bool `json:"insecure"` | |
| 25 | +} | |
| 26 | + | |
| 27 | +func loadMailer(root string) *Mailer { | |
| 28 | + b, err := os.ReadFile(filepath.Join(root, "smtp.json")) | |
| 29 | + if err != nil { | |
| 30 | + return nil | |
| 31 | + } | |
| 32 | + var m Mailer | |
| 33 | + if json.Unmarshal(b, &m) != nil || m.Host == "" || m.From == "" { | |
| 34 | + return nil | |
| 35 | + } | |
| 36 | + if m.Port == 0 { | |
| 37 | + m.Port = 465 | |
| 38 | + } | |
| 39 | + return &m | |
| 40 | +} | |
| 41 | + | |
| 42 | +func (m *Mailer) Send(to, subject, body string) error { | |
| 43 | + if m == nil { | |
| 44 | + return errors.New("mail is not configured") | |
| 45 | + } | |
| 46 | + addr := net.JoinHostPort(m.Host, fmt.Sprint(m.Port)) | |
| 47 | + tlsConfig := &tls.Config{ServerName: m.Host, InsecureSkipVerify: m.Insecure} | |
| 48 | + var client *smtp.Client | |
| 49 | + var err error | |
| 50 | + if m.StartTLS { | |
| 51 | + client, err = smtp.Dial(addr) | |
| 52 | + if err == nil { | |
| 53 | + err = client.StartTLS(tlsConfig) | |
| 54 | + } | |
| 55 | + } else if m.Insecure && m.Port != 465 { | |
| 56 | + client, err = smtp.Dial(addr) | |
| 57 | + } else { | |
| 58 | + var conn net.Conn | |
| 59 | + conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsConfig) | |
| 60 | + if err == nil { | |
| 61 | + client, err = smtp.NewClient(conn, m.Host) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + if err != nil { | |
| 65 | + return err | |
| 66 | + } | |
| 67 | + defer client.Close() | |
| 68 | + if m.User != "" { | |
| 69 | + if err := client.Auth(smtp.PlainAuth("", m.User, m.Password, m.Host)); err != nil { | |
| 70 | + return err | |
| 71 | + } | |
| 72 | + } | |
| 73 | + if err := client.Mail(m.From); err != nil { | |
| 74 | + return err | |
| 75 | + } | |
| 76 | + if err := client.Rcpt(to); err != nil { | |
| 77 | + return err | |
| 78 | + } | |
| 79 | + w, err := client.Data() | |
| 80 | + if err != nil { | |
| 81 | + return err | |
| 82 | + } | |
| 83 | + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", | |
| 84 | + m.From, to, strings.ReplaceAll(subject, "\n", " "), time.Now().Format(time.RFC1123Z), body) | |
| 85 | + if _, err := w.Write([]byte(msg)); err != nil { | |
| 86 | + return err | |
| 87 | + } | |
| 88 | + if err := w.Close(); err != nil { | |
| 89 | + return err | |
| 90 | + } | |
| 91 | + return client.Quit() | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (s *Server) notify(to, subject, body string) { | |
| 95 | + if to == "" || s.mailer == nil { | |
| 96 | + return | |
| 97 | + } | |
| 98 | + go func() { | |
| 99 | + if err := s.mailer.Send(to, subject, body); err != nil { | |
| 100 | + log.Printf("mail to %s: %v", to, err) | |
| 101 | + } | |
| 102 | + }() | |
| 103 | +} | |
main.go modified
| @@ -21,7 +21,10 @@ func main() { | ||
| 21 | 21 | shell user forced command for ssh git access |
| 22 | 22 | invite print a single use registration code |
| 23 | 23 | quota [size] show or set the per user disk quota, 0 disables |
| 24 | - user name create a user, password read from stdin`) | |
| 24 | + user name create a user, password read from stdin | |
| 25 | + passwd name set a user's password, read from stdin | |
| 26 | + ban name | unban name block or restore an account | |
| 27 | + delete name remove an account and its repositories`) | |
| 25 | 28 | } |
| 26 | 29 | flag.Parse() |
| 27 | 30 | args := flag.Args() |
| @@ -72,6 +75,40 @@ func main() { | ||
| 72 | 75 | if _, err := store.CreateUser(args[1], password); err != nil { |
| 73 | 76 | log.Fatal(err) |
| 74 | 77 | } |
| 78 | + case "passwd": | |
| 79 | + if len(args) != 2 { | |
| 80 | + os.Exit(2) | |
| 81 | + } | |
| 82 | + store := mustStore(*root) | |
| 83 | + u, err := store.UserByName(args[1]) | |
| 84 | + if err != nil { | |
| 85 | + log.Fatal("no such user") | |
| 86 | + } | |
| 87 | + var password string | |
| 88 | + fmt.Fscanln(os.Stdin, &password) | |
| 89 | + if err := store.SetPassword(u.ID, password); err != nil { | |
| 90 | + log.Fatal(err) | |
| 91 | + } | |
| 92 | + store.DeleteSessions(u.ID) | |
| 93 | + case "ban", "unban": | |
| 94 | + if len(args) != 2 { | |
| 95 | + os.Exit(2) | |
| 96 | + } | |
| 97 | + if err := mustStore(*root).SetBanned(args[1], args[0] == "ban"); err != nil { | |
| 98 | + log.Fatal(err) | |
| 99 | + } | |
| 100 | + case "delete": | |
| 101 | + if len(args) != 2 { | |
| 102 | + os.Exit(2) | |
| 103 | + } | |
| 104 | + store := mustStore(*root) | |
| 105 | + if _, err := store.UserByName(args[1]); err != nil { | |
| 106 | + log.Fatal("no such user") | |
| 107 | + } | |
| 108 | + if err := store.DeleteUser(args[1]); err != nil { | |
| 109 | + log.Fatal(err) | |
| 110 | + } | |
| 111 | + os.RemoveAll(filepath.Join(*root, "repos", args[1])) | |
| 75 | 112 | default: |
| 76 | 113 | flag.Usage() |
| 77 | 114 | os.Exit(2) |
pull.go modified
| @@ -409,6 +409,10 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 409 | 409 | s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg, |
| 410 | 410 | Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.FormValue("source"), "Target": r.FormValue("target"), "PullTitle": r.FormValue("title"), "Body": r.FormValue("body")}}) |
| 411 | 411 | } |
| 412 | + if repo.Archived { | |
| 413 | + retry("This repository is archived") | |
| 414 | + return | |
| 415 | + } | |
| 412 | 416 | src, branch, err := s.resolveSource(repo, user, r.FormValue("source")) |
| 413 | 417 | if err != nil { |
| 414 | 418 | retry(err.Error()) |
| @@ -432,6 +436,11 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 432 | 436 | retry("Could not fetch the source branch: " + err.Error()) |
| 433 | 437 | return |
| 434 | 438 | } |
| 439 | + url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number) | |
| 440 | + if repo.Owner.ID != user.ID { | |
| 441 | + s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title), | |
| 442 | + fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\n%s\n", user.Name, repo.Path(), pull.Body, url)) | |
| 443 | + } | |
| 435 | 444 | http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther) |
| 436 | 445 | } |
| 437 | 446 | |
| @@ -473,6 +482,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 473 | 482 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 474 | 483 | return |
| 475 | 484 | } |
| 485 | + s.notifyPull(pull, *user, "commented on", r.FormValue("body")) | |
| 476 | 486 | case "close": |
| 477 | 487 | if (isOwner || isAuthor) && pull.State == "open" { |
| 478 | 488 | s.store.SetPullState(pull.ID, "closed", "") |
| @@ -488,6 +498,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 488 | 498 | return |
| 489 | 499 | } |
| 490 | 500 | back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line") |
| 501 | + s.notifyPull(pull, *user, "reviewed "+r.FormValue("path")+" line "+r.FormValue("line")+" on", r.FormValue("body")) | |
| 491 | 502 | case "resolve", "unresolve", "delete": |
| 492 | 503 | id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64) |
| 493 | 504 | owner := s.store.ReviewAuthor(id) |
| @@ -540,6 +551,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 540 | 551 | return |
| 541 | 552 | } |
| 542 | 553 | s.store.SetPullState(pull.ID, "merged", hash) |
| 554 | + s.notifyPull(pull, *user, "merged", "") | |
| 543 | 555 | default: |
| 544 | 556 | s.fail(w, r, 404, "Page not found") |
| 545 | 557 | return |
| @@ -628,6 +640,19 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 628 | 640 | s.render(w, r, "pull", page{Title: fmt.Sprintf("#%d %s", pull.Number, pull.Title), Error: r.URL.Query().Get("error"), Math: hasMath(data["Body"]), Data: data}) |
| 629 | 641 | } |
| 630 | 642 | |
| 643 | +func (s *Server) notifyPull(pull Pull, actor User, verb, body string) { | |
| 644 | + url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, pull.Repo.Path(), pull.Number) | |
| 645 | + subject := fmt.Sprintf("[%s] pull request #%d: %s", pull.Repo.Path(), pull.Number, pull.Title) | |
| 646 | + text := fmt.Sprintf("~%s %s pull request #%d.\n\n%s\n\n%s\n", actor.Name, verb, pull.Number, body, url) | |
| 647 | + seen := map[int64]bool{actor.ID: true} | |
| 648 | + for _, id := range []int64{pull.Author.ID, pull.Repo.Owner.ID} { | |
| 649 | + if !seen[id] { | |
| 650 | + seen[id] = true | |
| 651 | + s.notify(s.store.VerifiedEmail(id), subject, text) | |
| 652 | + } | |
| 653 | + } | |
| 654 | +} | |
| 655 | + | |
| 631 | 656 | func mergeTitle(pull Pull) string { |
| 632 | 657 | return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch) |
| 633 | 658 | } |
shell.go modified
| @@ -69,6 +69,9 @@ func shell(root, name string) int { | ||
| 69 | 69 | if verb == "git-receive-pack" && !canWrite { |
| 70 | 70 | return fail("you do not have write access to " + repo.Path()) |
| 71 | 71 | } |
| 72 | + if verb == "git-receive-pack" && repo.Archived { | |
| 73 | + return fail("repository is archived and read only") | |
| 74 | + } | |
| 72 | 75 | if verb == "git-receive-pack" { |
| 73 | 76 | if over, used, quota := overQuota(root, repo.Owner.Name); over { |
| 74 | 77 | return fail(quotaMessage(used, quota)) |
smart.go modified
| @@ -63,6 +63,10 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 63 | 63 | return |
| 64 | 64 | } |
| 65 | 65 | } |
| 66 | + if service == "git-receive-pack" && repo.Archived { | |
| 67 | + http.Error(w, "repository is archived and read only", 403) | |
| 68 | + return | |
| 69 | + } | |
| 66 | 70 | if service == "git-receive-pack" { |
| 67 | 71 | if over, used, quota := overQuota(s.root, repo.Owner.Name); over { |
| 68 | 72 | http.Error(w, quotaMessage(used, quota), 403) |
static/style.css modified
| @@ -253,3 +253,15 @@ mark{background:#fff3b0;padding:0} | ||
| 253 | 253 | .comment.outdated{opacity:.8} |
| 254 | 254 | .mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem} |
| 255 | 255 | .mergeform label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} |
| 256 | +.btn.starred{background:#fff3cd;border-color:#ffe08a} | |
| 257 | +.btn.static{cursor:default} | |
| 258 | +.archived{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.3rem .6rem;margin:.4rem 0 0} | |
| 259 | +.compareform{display:flex;gap:.6rem;align-items:flex-end;margin-bottom:1rem;flex-wrap:wrap} | |
| 260 | +.compareform label{display:flex;flex-direction:column;font-size:.85rem;color:var(--muted)} | |
| 261 | +table.blame{width:100%} | |
| 262 | +table.blame td{padding:0 .5rem;white-space:pre;vertical-align:top} | |
| 263 | +table.blame td.who{width:1%;white-space:nowrap;color:var(--muted);font-family:system-ui,sans-serif;font-size:.8rem;border-right:1px solid var(--bar);padding-right:.8rem} | |
| 264 | +table.blame tr.first td{border-top:1px solid var(--line)} | |
| 265 | +table.blame td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%} | |
| 266 | +table.blame td.n{border-right:1px solid var(--bar)} | |
| 267 | +.actions a.muted{margin-right:auto;align-self:center} | |
store.go modified
| @@ -67,7 +67,49 @@ var migrations = []string{ | ||
| 67 | 67 | `alter table users add column display text not null default ''`, |
| 68 | 68 | `alter table repos add column tags text not null default ''`, |
| 69 | 69 | `alter table repos add column parent integer not null default 0`, |
| 70 | -} | |
| 70 | + `alter table repos add column archived integer not null default 0`, | |
| 71 | + `alter table users add column email text not null default ''`, | |
| 72 | + `alter table users add column verified integer not null default 0`, | |
| 73 | + `alter table users add column notify integer not null default 1`, | |
| 74 | + `alter table users add column banned integer not null default 0`, | |
| 75 | + `alter table users add column totp text not null default ''`, | |
| 76 | + `alter table users add column recovery text not null default ''`, | |
| 77 | +} | |
| 78 | + | |
| 79 | +const extraSchema = ` | |
| 80 | +create table if not exists mailtokens ( | |
| 81 | + token text primary key, | |
| 82 | + user integer not null references users(id) on delete cascade, | |
| 83 | + kind text not null, | |
| 84 | + email text not null, | |
| 85 | + created integer not null | |
| 86 | +); | |
| 87 | +create table if not exists stars ( | |
| 88 | + user integer not null references users(id) on delete cascade, | |
| 89 | + repo integer not null references repos(id) on delete cascade, | |
| 90 | + created integer not null, | |
| 91 | + primary key(user, repo) | |
| 92 | +); | |
| 93 | +create table if not exists issues ( | |
| 94 | + id integer primary key, | |
| 95 | + repo integer not null references repos(id) on delete cascade, | |
| 96 | + number integer not null, | |
| 97 | + author integer not null references users(id) on delete cascade, | |
| 98 | + title text not null, | |
| 99 | + body text not null default '', | |
| 100 | + state text not null default 'open', | |
| 101 | + created integer not null, | |
| 102 | + updated integer not null, | |
| 103 | + unique(repo, number) | |
| 104 | +); | |
| 105 | +create table if not exists issue_comments ( | |
| 106 | + id integer primary key, | |
| 107 | + issue integer not null references issues(id) on delete cascade, | |
| 108 | + author integer not null references users(id) on delete cascade, | |
| 109 | + body text not null, | |
| 110 | + created integer not null | |
| 111 | +); | |
| 112 | +` | |
| 71 | 113 | |
| 72 | 114 | const collabSchema = ` |
| 73 | 115 | create table if not exists collaborators ( |
| @@ -119,6 +161,7 @@ type Repo struct { | ||
| 119 | 161 | Unlisted bool |
| 120 | 162 | Tags []string |
| 121 | 163 | Parent int64 |
| 164 | + Archived bool | |
| 122 | 165 | Created time.Time |
| 123 | 166 | Updated time.Time |
| 124 | 167 | } |
| @@ -192,6 +235,9 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 192 | 235 | if _, err := db.Exec(collabSchema); err != nil { |
| 193 | 236 | return nil, err |
| 194 | 237 | } |
| 238 | + if _, err := db.Exec(extraSchema); err != nil { | |
| 239 | + return nil, err | |
| 240 | + } | |
| 195 | 241 | } |
| 196 | 242 | return &Store{db}, nil |
| 197 | 243 | } |
| @@ -267,7 +313,7 @@ func (s *Store) CheckPassword(name, password string) (User, bool) { | ||
| 267 | 313 | var u User |
| 268 | 314 | var hash string |
| 269 | 315 | var created int64 |
| 270 | - err := s.db.QueryRow(`select id, name, hash, created from users where name = ?`, name).Scan(&u.ID, &u.Name, &hash, &created) | |
| 316 | + err := s.db.QueryRow(`select id, name, hash, created from users where name = ? and banned = 0`, name).Scan(&u.ID, &u.Name, &hash, &created) | |
| 271 | 317 | if err != nil { |
| 272 | 318 | bcrypt.CompareHashAndPassword([]byte("$2a$10$000000000000000000000uGJ8z1gQ7yP1cqeI4qzj6WmLIl3JcP6W"), []byte(password)) |
| 273 | 319 | return u, false |
| @@ -296,7 +342,7 @@ func (s *Store) CreateSession(user int64) (string, error) { | ||
| 296 | 342 | |
| 297 | 343 | func (s *Store) SessionUser(id string) (User, error) { |
| 298 | 344 | var user int64 |
| 299 | - err := s.db.QueryRow(`select user from sessions where id = ? and created > ?`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user) | |
| 345 | + err := s.db.QueryRow(`select s.user from sessions s join users u on u.id = s.user where s.id = ? and s.created > ? and u.banned = 0`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user) | |
| 300 | 346 | if err != nil { |
| 301 | 347 | return User{}, ErrNotFound |
| 302 | 348 | } |
| @@ -387,7 +433,7 @@ func (s *Store) UserByKey(public string) (User, error) { | ||
| 387 | 433 | return User{}, err |
| 388 | 434 | } |
| 389 | 435 | var user int64 |
| 390 | - err = s.db.QueryRow(`select user from keys where fingerprint = ?`, ssh.FingerprintSHA256(pub)).Scan(&user) | |
| 436 | + err = s.db.QueryRow(`select k.user from keys k join users u on u.id = k.user where k.fingerprint = ? and u.banned = 0`, ssh.FingerprintSHA256(pub)).Scan(&user) | |
| 391 | 437 | if err != nil { |
| 392 | 438 | return User{}, ErrNotFound |
| 393 | 439 | } |
| @@ -434,7 +480,7 @@ func (s *Store) DeleteToken(user, id int64) error { | ||
| 434 | 480 | |
| 435 | 481 | func (s *Store) UserByToken(name, token string) (User, bool) { |
| 436 | 482 | var user int64 |
| 437 | - 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) | |
| 483 | + err := s.db.QueryRow(`select t.user from tokens t join users u on u.id = t.user where u.name = ? and t.hash = ? and u.banned = 0`, name, tokenHash(token)).Scan(&user) | |
| 438 | 484 | if err != nil { |
| 439 | 485 | return User{}, false |
| 440 | 486 | } |
| @@ -468,12 +514,13 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) { | ||
| 468 | 514 | var repos []Repo |
| 469 | 515 | for rows.Next() { |
| 470 | 516 | var r Repo |
| 471 | - var private int | |
| 517 | + var private, archived int | |
| 472 | 518 | var tags string |
| 473 | 519 | var created, ucreated int64 |
| 474 | - if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil { | |
| 520 | + if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &archived, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil { | |
| 475 | 521 | return nil, err |
| 476 | 522 | } |
| 523 | + r.Archived = archived != 0 | |
| 477 | 524 | r.Private = private == Private |
| 478 | 525 | r.Unlisted = private == Unlisted |
| 479 | 526 | r.Tags = splitTags(tags) |
| @@ -484,7 +531,7 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) { | ||
| 484 | 531 | return repos, rows.Err() |
| 485 | 532 | } |
| 486 | 533 | |
| 487 | -const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner` | |
| 534 | +const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.archived, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner` | |
| 488 | 535 | |
| 489 | 536 | func (s *Store) RepoByID(id int64) (Repo, error) { |
| 490 | 537 | rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id) |
| @@ -718,6 +765,153 @@ func (s *Store) PublicKeys(user int64) ([]string, []string) { | ||
| 718 | 765 | return ssh, pgp |
| 719 | 766 | } |
| 720 | 767 | |
| 768 | +type Account struct { | |
| 769 | + Email string | |
| 770 | + Verified bool | |
| 771 | + Notify bool | |
| 772 | + TOTP string | |
| 773 | + Recovery string | |
| 774 | +} | |
| 775 | + | |
| 776 | +func (s *Store) Account(user int64) Account { | |
| 777 | + var a Account | |
| 778 | + var verified, notify int | |
| 779 | + s.db.QueryRow(`select email, verified, notify, totp, recovery from users where id = ?`, user).Scan(&a.Email, &verified, ¬ify, &a.TOTP, &a.Recovery) | |
| 780 | + a.Verified, a.Notify = verified != 0, notify != 0 | |
| 781 | + return a | |
| 782 | +} | |
| 783 | + | |
| 784 | +func (s *Store) SetEmail(user int64, email string) error { | |
| 785 | + email = strings.TrimSpace(strings.ToLower(email)) | |
| 786 | + if email != "" && (!strings.Contains(email, "@") || len(email) > 200) { | |
| 787 | + return errors.New("That does not look like an email address") | |
| 788 | + } | |
| 789 | + _, err := s.db.Exec(`update users set email = ?, verified = 0 where id = ?`, email, user) | |
| 790 | + return err | |
| 791 | +} | |
| 792 | + | |
| 793 | +func (s *Store) SetNotify(user int64, on bool) { | |
| 794 | + s.db.Exec(`update users set notify = ? where id = ?`, on, user) | |
| 795 | +} | |
| 796 | + | |
| 797 | +func (s *Store) VerifiedEmail(user int64) string { | |
| 798 | + a := s.Account(user) | |
| 799 | + if a.Verified && a.Notify { | |
| 800 | + return a.Email | |
| 801 | + } | |
| 802 | + return "" | |
| 803 | +} | |
| 804 | + | |
| 805 | +func (s *Store) CreateMailToken(user int64, kind, email string) (string, error) { | |
| 806 | + token := random(24) | |
| 807 | + s.db.Exec(`delete from mailtokens where user = ? and kind = ?`, user, kind) | |
| 808 | + _, err := s.db.Exec(`insert into mailtokens(token, user, kind, email, created) values (?, ?, ?, ?, ?)`, token, user, kind, email, time.Now().Unix()) | |
| 809 | + return token, err | |
| 810 | +} | |
| 811 | + | |
| 812 | +func (s *Store) UseMailToken(token, kind string) (int64, string, bool) { | |
| 813 | + var user int64 | |
| 814 | + var email string | |
| 815 | + var created int64 | |
| 816 | + err := s.db.QueryRow(`select user, email, created from mailtokens where token = ? and kind = ?`, token, kind).Scan(&user, &email, &created) | |
| 817 | + if err != nil || time.Since(time.Unix(created, 0)) > 24*time.Hour { | |
| 818 | + return 0, "", false | |
| 819 | + } | |
| 820 | + s.db.Exec(`delete from mailtokens where token = ?`, token) | |
| 821 | + return user, email, true | |
| 822 | +} | |
| 823 | + | |
| 824 | +func (s *Store) MarkVerified(user int64, email string) { | |
| 825 | + s.db.Exec(`update users set email = ?, verified = 1 where id = ?`, email, user) | |
| 826 | +} | |
| 827 | + | |
| 828 | +func (s *Store) UserByEmail(email string) (User, error) { | |
| 829 | + var id int64 | |
| 830 | + if err := s.db.QueryRow(`select id from users where email = ? and verified = 1 and banned = 0`, strings.ToLower(strings.TrimSpace(email))).Scan(&id); err != nil { | |
| 831 | + return User{}, ErrNotFound | |
| 832 | + } | |
| 833 | + return s.userByID(id) | |
| 834 | +} | |
| 835 | + | |
| 836 | +func (s *Store) SetTOTP(user int64, secret, recovery string) { | |
| 837 | + s.db.Exec(`update users set totp = ?, recovery = ? where id = ?`, secret, recovery, user) | |
| 838 | +} | |
| 839 | + | |
| 840 | +func (s *Store) SetBanned(name string, banned bool) error { | |
| 841 | + res, err := s.db.Exec(`update users set banned = ? where name = ?`, banned, name) | |
| 842 | + if err != nil { | |
| 843 | + return err | |
| 844 | + } | |
| 845 | + if n, _ := res.RowsAffected(); n == 0 { | |
| 846 | + return ErrNotFound | |
| 847 | + } | |
| 848 | + s.db.Exec(`delete from sessions where user = (select id from users where name = ?)`, name) | |
| 849 | + return nil | |
| 850 | +} | |
| 851 | + | |
| 852 | +func (s *Store) DeleteUser(name string) error { | |
| 853 | + res, err := s.db.Exec(`delete from users where name = ?`, name) | |
| 854 | + if err != nil { | |
| 855 | + return err | |
| 856 | + } | |
| 857 | + if n, _ := res.RowsAffected(); n == 0 { | |
| 858 | + return ErrNotFound | |
| 859 | + } | |
| 860 | + return nil | |
| 861 | +} | |
| 862 | + | |
| 863 | +func (s *Store) RenameRepo(id int64, name string) error { | |
| 864 | + if !validName(name) || name == "avatar" { | |
| 865 | + return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") | |
| 866 | + } | |
| 867 | + _, err := s.db.Exec(`update repos set name = ? where id = ?`, name, id) | |
| 868 | + if err != nil && strings.Contains(err.Error(), "UNIQUE") { | |
| 869 | + return errors.New("A repository with that name already exists") | |
| 870 | + } | |
| 871 | + return err | |
| 872 | +} | |
| 873 | + | |
| 874 | +func (s *Store) TransferRepo(id int64, owner int64) error { | |
| 875 | + _, err := s.db.Exec(`update repos set owner = ? where id = ?`, owner, id) | |
| 876 | + if err != nil && strings.Contains(err.Error(), "UNIQUE") { | |
| 877 | + return errors.New("That user already has a repository with this name") | |
| 878 | + } | |
| 879 | + s.db.Exec(`delete from collaborators where repo = ? and user = ?`, id, owner) | |
| 880 | + return err | |
| 881 | +} | |
| 882 | + | |
| 883 | +func (s *Store) SetArchived(id int64, archived bool) { | |
| 884 | + s.db.Exec(`update repos set archived = ? where id = ?`, archived, id) | |
| 885 | +} | |
| 886 | + | |
| 887 | +func (s *Store) Star(user, repo int64, on bool) { | |
| 888 | + if on { | |
| 889 | + s.db.Exec(`insert or ignore into stars(user, repo, created) values (?, ?, ?)`, user, repo, time.Now().Unix()) | |
| 890 | + } else { | |
| 891 | + s.db.Exec(`delete from stars where user = ? and repo = ?`, user, repo) | |
| 892 | + } | |
| 893 | +} | |
| 894 | + | |
| 895 | +func (s *Store) Starred(user, repo int64) bool { | |
| 896 | + var n int | |
| 897 | + s.db.QueryRow(`select count(*) from stars where user = ? and repo = ?`, user, repo).Scan(&n) | |
| 898 | + return n > 0 | |
| 899 | +} | |
| 900 | + | |
| 901 | +func (s *Store) StarCount(repo int64) int { | |
| 902 | + var n int | |
| 903 | + s.db.QueryRow(`select count(*) from stars where repo = ?`, repo).Scan(&n) | |
| 904 | + return n | |
| 905 | +} | |
| 906 | + | |
| 907 | +func (s *Store) StarredRepos(user int64) ([]Repo, error) { | |
| 908 | + rows, err := s.db.Query(`select `+repoColumns+` join stars st on st.repo = r.id where st.user = ? and r.private = 0 order by st.created desc`, user) | |
| 909 | + if err != nil { | |
| 910 | + return nil, err | |
| 911 | + } | |
| 912 | + return scanRepos(rows) | |
| 913 | +} | |
| 914 | + | |
| 721 | 915 | func (s *Store) CreateInvite() (string, error) { |
| 722 | 916 | code := random(12) |
| 723 | 917 | _, err := s.db.Exec(`insert into invites(code) values (?)`, code) |
templates/blame.html added
| @@ -0,0 +1,11 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<div class="filebar"> | |
| 4 | +<span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Data.Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Data.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Data.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span> | |
| 5 | +{{with .Data.Entry}}<span class="mode">{{perm .Mode}}</span><span class="muted">{{size .Size}}</span>{{end}} | |
| 6 | +<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Data.Path}}">Log</a><a class="active" href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Data.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Commit}}/{{.Data.Path}}">Permalink</a> | |
| 7 | +<span class="spacer"></span> | |
| 8 | +{{with .Data.LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}} | |
| 9 | +</div> | |
| 10 | +<div class="code"><table class="blame">{{range .Data.Lines}}<tr{{if .First}} class="first"{{end}}><td class="who">{{if .First}}<a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{.Subject}}">{{short .Hash}}</a> {{.Author}} <span class="muted">{{ago .Time}}</span>{{end}}</td><td class="n" id="L{{.Line}}"><a href="#L{{.Line}}">{{.Line}}</a></td><td class="l">{{.Text}}</td></tr>{{end}}</table></div> | |
| 11 | +{{end}} | |
templates/blob.html modified
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | <div class="filebar"> |
| 15 | 15 | <span 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> |
| 16 | 16 | {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}} |
| 17 | -<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a> | |
| 17 | +<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a> | |
| 18 | 18 | <span class="spacer"></span> |
| 19 | 19 | {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}} |
| 20 | 20 | </div> |
templates/compare.html added
| @@ -0,0 +1,26 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<h3 class="plain">Compare</h3> | |
| 4 | +<form method="get" action="/{{$p.Repo.Path}}/compare" class="compareform"> | |
| 5 | +<label>base <input name="base" list="refs" value="{{.Data.Base}}" placeholder="main"></label> | |
| 6 | +<span>…</span> | |
| 7 | +<label>head <input name="head" list="refs" value="{{.Data.Head}}" placeholder="feature"></label> | |
| 8 | +<datalist id="refs">{{range .Data.Branches}}<option value="{{.Name}}">{{end}}</datalist> | |
| 9 | +<button class="btn">Compare ▸</button> | |
| 10 | +</form> | |
| 11 | +{{if .Data.Unrelated}}<p class="info">These refs have no common history.</p>{{end}} | |
| 12 | +{{if .Data.Commits}} | |
| 13 | +<h3>Commits <span class="muted">{{len .Data.Commits}}</span></h3> | |
| 14 | +<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 15 | +{{else if and .Data.Base .Data.Head (not .Data.Unrelated)}}<p class="info">{{.Data.Head}} has nothing that {{.Data.Base}} does not.</p>{{end}} | |
| 16 | +{{if .Data.Files}} | |
| 17 | +<h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3> | |
| 18 | +{{range .Data.Files}} | |
| 19 | +<section class="diff" id="{{.New}}"> | |
| 20 | +<h4><a href="/{{$p.Repo.Path}}/tree/{{$.Data.HeadHash}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h4> | |
| 21 | +{{if .Binary}}<p class="muted">Binary file.</p>{{end}} | |
| 22 | +{{range .Hunks}}<table class="hunk"><tr class="head"><td colspan="3">{{.Header}}</td></tr>{{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}}</table>{{end}} | |
| 23 | +</section> | |
| 24 | +{{end}} | |
| 25 | +{{end}} | |
| 26 | +{{end}} | |
templates/issue.html added
| @@ -0,0 +1,21 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +{{$i := .Data.Issue}} | |
| 4 | +<div class="pulltitle"> | |
| 5 | +<h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span></h3> | |
| 6 | +<p class="muted"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> opened this issue {{ago $i.Created}}{{if .Data.Comments}} · {{len .Data.Comments}} comments{{end}}</p> | |
| 7 | +</div> | |
| 8 | +<div class="columns pullbody"> | |
| 9 | +<div class="main"> | |
| 10 | +<div class="comment"><div class="who"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> <span class="muted">{{ago $i.Created}}</span></div><div class="readme">{{if $i.Body}}{{.Data.Body}}{{else}}<p class="muted">No description.</p>{{end}}</div></div> | |
| 11 | +{{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}} | |
| 12 | +{{if and .User (not $p.Repo.Archived)}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/comment" class="stack wide"> | |
| 13 | +<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label> | |
| 14 | +<div class="actions"><button class="btn">Comment ▸</button></div> | |
| 15 | +</form>{{else if not .User}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}} | |
| 16 | +</div> | |
| 17 | +<aside><div class="panel"> | |
| 18 | +{{if .Data.CanManage}}{{if eq $i.State "open"}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/close"><button class="btn block">Close issue ▸</button></form>{{else}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}}{{else}}<p class="muted small">Only the author and the repository owner can close this.</p>{{end}} | |
| 19 | +</div></aside> | |
| 20 | +</div> | |
| 21 | +{{end}} | |
templates/issues.html added
| @@ -0,0 +1,8 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<div class="pullhead"> | |
| 4 | +<nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav> | |
| 5 | +{{if and $p.User (not $p.Repo.Archived)}}<a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}} | |
| 6 | +</div> | |
| 7 | +{{if .Data.Issues}}<div class="events">{{range .Data.Issues}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/issues/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span></h4><p class="muted small">opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}} · updated {{ago .Updated}}</p></div>{{end}}</div>{{else}}<p class="info">No issues here.</p>{{end}} | |
| 8 | +{{end}} | |
templates/layout.html modified
| @@ -30,10 +30,10 @@ | ||
| 30 | 30 | </header> |
| 31 | 31 | {{with .Data}}{{with index . "Page"}} |
| 32 | 32 | <div class="repohead"> |
| 33 | -<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div> | |
| 33 | +<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}">{{if .Starred}}★ Starred{{else}}☆ Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static">☆ {{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div> | |
| 34 | 34 | <div class="tabbar"><nav class="tabs inner"> |
| 35 | 35 | <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a> |
| 36 | -{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}} | |
| 36 | +{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a> | |
| 37 | 37 | {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}} |
| 38 | 38 | </nav></div> |
| 39 | 39 | </div> |
templates/login.html modified
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <form method="post" action="/login" class="stack centred"> |
| 5 | 5 | <label>Username <input name="name" required autofocus autocomplete="username"></label> |
| 6 | 6 | <label>Password <input name="password" type="password" required autocomplete="current-password"></label> |
| 7 | -<div class="actions"><button class="btn primary">Log in ▸</button></div> | |
| 7 | +<div class="actions"><a class="muted small" href="/reset">Forgot your password?</a><button class="btn primary">Log in ▸</button></div> | |
| 8 | 8 | </form> |
| 9 | 9 | </div> |
| 10 | 10 | {{end}} |
templates/message.html added
| @@ -0,0 +1,3 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="panel narrow"><h3>{{.Data.Heading}}</h3><p>{{.Data.Text}}</p><p><a href="/">Back to the front page</a></p></div> | |
| 3 | +{{end}} | |
templates/newissue.html added
| @@ -0,0 +1,9 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}} | |
| 3 | +<h3>New issue</h3> | |
| 4 | +<form method="post" action="/{{$p.Repo.Path}}/issues/new" class="stack wide"> | |
| 5 | +<label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.IssueTitle}}"></label> | |
| 6 | +<label>Description <textarea name="body" rows="10" placeholder="Markdown supported">{{.Data.Body}}</textarea></label> | |
| 7 | +<div class="actions"><button class="btn primary">Open issue ▸</button></div> | |
| 8 | +</form> | |
| 9 | +{{end}} | |
templates/recovery.html added
| @@ -0,0 +1,8 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="panel"><h3>Two factor enabled</h3> | |
| 3 | +<p>These recovery codes each work once in place of an authenticator code. Store them somewhere safe, they will not be shown again.</p> | |
| 4 | +<pre>{{range .Data.Codes}}{{.}} | |
| 5 | +{{end}}</pre> | |
| 6 | +<a class="btn" href="/settings/security">Done ▸</a> | |
| 7 | +</div> | |
| 8 | +{{end}} | |
templates/refs.html modified
| @@ -3,5 +3,5 @@ | ||
| 3 | 3 | <div class="panel"><h3>Branches</h3> |
| 4 | 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></div> |
| 5 | 5 | <div class="panel"><h3>Tags</h3> |
| 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}}</div> | |
| 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><td><a href="/{{$p.Repo.Path}}/archive/{{.Name}}.tar.gz">tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{.Name}}.zip">zip</a></td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div> | |
| 7 | 7 | {{end}} |
templates/repo.html modified
| @@ -20,6 +20,9 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 20 | 20 | <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div> |
| 21 | 21 | {{if $p.Owner}}<strong>read/write</strong> |
| 22 | 22 | <div>{{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}</div>{{end}} |
| 23 | +<strong>download</strong> | |
| 24 | +<div><a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.tar.gz">{{$p.Ref}}.tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.zip">{{$p.Ref}}.zip</a></div> | |
| 25 | +<div><a href="/{{$p.Repo.Path}}/feed">atom feed</a> · <a href="/{{$p.Repo.Path}}/compare">compare</a></div> | |
| 23 | 26 | </div> |
| 24 | 27 | </div> |
| 25 | 28 | <div class="readme"> |
templates/reposettings.html modified
| @@ -14,6 +14,21 @@ | ||
| 14 | 14 | <label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label> |
| 15 | 15 | </div> |
| 16 | 16 | </form> |
| 17 | +<h3>Rename</h3> | |
| 18 | +<form method="post" action="/{{$p.Repo.Path}}/rename" class="stack"> | |
| 19 | +<label>New name <input name="name" value="{{$p.Repo.Name}}" required pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label> | |
| 20 | +<small>Old URLs stop working, forks and pull requests follow the repository.</small> | |
| 21 | +<div class="actions left"><button class="btn primary">Rename ▸</button></div> | |
| 22 | +</form> | |
| 23 | +<h3>Transfer</h3> | |
| 24 | +<form method="post" action="/{{$p.Repo.Path}}/transfer" class="stack"> | |
| 25 | +<label>New owner <input name="owner" placeholder="~someone" required></label> | |
| 26 | +<label>Type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label> | |
| 27 | +<div class="actions left"><button class="btn danger">Transfer repository ▸</button></div> | |
| 28 | +</form> | |
| 29 | +<h3>Archive</h3> | |
| 30 | +<p>{{if $p.Repo.Archived}}This repository is archived, nobody can push, open pull requests or comment.{{else}}Archiving makes the repository read only. Nobody can push, open pull requests or comment until it is unarchived.{{end}}</p> | |
| 31 | +<form method="post" action="/{{$p.Repo.Path}}/archive"><button class="btn">{{if $p.Repo.Archived}}Unarchive ▸{{else}}Archive ▸{{end}}</button></form> | |
| 17 | 32 | <h3>Collaborators</h3> |
| 18 | 33 | <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p> |
| 19 | 34 | {{if .Data.Collaborators}}<table class="keys">{{range .Data.Collaborators}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/{{$p.Repo.Path}}/collaborators"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}} |
templates/reset.html added
| @@ -0,0 +1,13 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="auth"> | |
| 3 | +<h3 class="rule">Reset your password <small>or <a href="/login">log in</a></small></h3> | |
| 4 | +{{if .Data.Sent}}<p class="notice">If that account has a verified email address, a reset link is on its way. It is valid for 24 hours.</p>{{end}} | |
| 5 | +{{if .Data.Mail}} | |
| 6 | +<p>Enter your username or verified email address and a link to choose a new password will be sent.</p> | |
| 7 | +<form method="post" action="/reset" class="stack centred"> | |
| 8 | +<label>Username or email <input name="who" required autofocus></label> | |
| 9 | +<div class="actions"><button class="btn primary">Send reset link ▸</button></div> | |
| 10 | +</form> | |
| 11 | +{{else}}<p>This server has no outgoing mail configured, so passwords are reset by the administrator with <code>basin passwd</code>.</p>{{end}} | |
| 12 | +</div> | |
| 13 | +{{end}} | |
templates/resetpassword.html added
| @@ -0,0 +1,9 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="auth"> | |
| 3 | +<h3 class="rule">Choose a new password</h3> | |
| 4 | +<form method="post" action="/reset/{{.Data.Token}}" class="stack centred"> | |
| 5 | +<label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password" autofocus></label> | |
| 6 | +<div class="actions"><button class="btn primary">Save password ▸</button></div> | |
| 7 | +</form> | |
| 8 | +</div> | |
| 9 | +{{end}} | |
templates/security.html modified
| @@ -5,6 +5,12 @@ | ||
| 5 | 5 | <label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label> |
| 6 | 6 | <div class="actions left"><button class="btn primary">Change password ▸</button></div> |
| 7 | 7 | </form></div> |
| 8 | +<div class="panel"><h3>Two factor authentication</h3> | |
| 9 | +{{if .Data.Account.TOTP}}<p><strong>Enabled</strong> on your account. A code from your authenticator app or a recovery code is required at every login.</p> | |
| 10 | +<form method="post" action="/settings/totp/disable" class="stack"><label>Password to disable <input name="password" type="password" required autocomplete="current-password"></label><div class="actions left"><button class="btn danger">Disable ▸</button></div></form> | |
| 11 | +{{else}}<p><strong>Disabled</strong> on your account. Enable this and you will be asked for a TOTP code each time you log in. Use of two factor is strongly recommended.</p> | |
| 12 | +<form method="post" action="/settings/totp" class="stack"><label>Password to continue <input name="password" type="password" required autocomplete="current-password"></label><div class="actions left"><button class="btn primary">Enable TOTP ▸</button></div></form>{{end}} | |
| 13 | +</div> | |
| 8 | 14 | <div class="panel"><h3>Sessions</h3> |
| 9 | 15 | <p>Sessions last 90 days. Logging out ends only the current one.</p> |
| 10 | 16 | <form method="post" action="/settings/sessions"><button class="btn">Log out everywhere ▸</button></form> |
templates/settings.html modified
| @@ -13,6 +13,18 @@ | ||
| 13 | 13 | </div> |
| 14 | 14 | </form> |
| 15 | 15 | <script src="/static/crop.js" defer></script> |
| 16 | +<h3 class="rule">Email</h3> | |
| 17 | +<form method="post" action="/settings/email" class="stack wide"> | |
| 18 | +<label>Email address{{if .Data.Account.Email}}{{if .Data.Account.Verified}} <span class="muted">(verified)</span>{{else}} <span class="muted">(not verified yet)</span>{{end}}{{end}} <input name="email" type="email" value="{{.Data.Account.Email}}" placeholder="you@example.org"></label> | |
| 19 | +<small>Used for password resets and notifications about your repositories and pull requests. Never shown publicly.{{if not .Data.Mail}} Mail is not configured on this server yet, so verification cannot be sent.{{end}}</small> | |
| 20 | +{{if .Data.Sent}}<p class="notice">Verification mail sent, open the link in it to confirm.</p>{{end}} | |
| 21 | +<div class="actions left"><button class="btn primary">{{if .Data.Account.Verified}}Change email ▸{{else}}Save and send verification ▸{{end}}</button></div> | |
| 22 | +</form> | |
| 23 | +<form method="post" action="/settings/notify" class="stack wide"> | |
| 24 | +<label class="check"><input type="checkbox" name="notify"{{if .Data.Account.Notify}} checked{{end}}> Email me about pull requests, reviews and issues on my repositories</label> | |
| 25 | +<div class="actions left"><button class="btn">Save ▸</button></div> | |
| 26 | +</form> | |
| 27 | +<h3 class="rule">Profile</h3> | |
| 16 | 28 | <form method="post" action="/settings/profile" class="stack wide"> |
| 17 | 29 | <div class="pair"> |
| 18 | 30 | <label>Username <span class="muted">(you can't edit this)</span><input value="{{.User.Name}}" disabled></label> |
templates/totp.html added
| @@ -0,0 +1,10 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="auth"> | |
| 3 | +<h3 class="rule">Two factor authentication</h3> | |
| 4 | +<p>Enter the six digit code from your authenticator app, or one of your recovery codes.</p> | |
| 5 | +<form method="post" action="/login/totp" class="stack centred"> | |
| 6 | +<label>Code <input name="code" required autofocus autocomplete="one-time-code" inputmode="numeric"></label> | |
| 7 | +<div class="actions"><button class="btn primary">Continue ▸</button></div> | |
| 8 | +</form> | |
| 9 | +</div> | |
| 10 | +{{end}} | |
templates/totpsetup.html added
| @@ -0,0 +1,11 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="panel"><h3>Enable two factor authentication</h3> | |
| 3 | +<div class="twocol"> | |
| 4 | +<div><img src="{{.Data.QR}}" alt="" width="200" height="200"><p class="muted small">Scan with any TOTP app, or enter the secret by hand: <code>{{.Data.Secret}}</code></p></div> | |
| 5 | +<form method="post" action="/settings/totp/enable" class="stack"> | |
| 6 | +<input type="hidden" name="token" value="{{.Data.Token}}"> | |
| 7 | +<label>Code from the app <input name="code" required autofocus inputmode="numeric" autocomplete="one-time-code"></label> | |
| 8 | +<div class="actions left"><button class="btn primary">Enable ▸</button><a class="btn" href="/settings/security">Cancel</a></div> | |
| 9 | +</form> | |
| 10 | +</div></div> | |
| 11 | +{{end}} | |
templates/tree.html modified
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | <div class="filebar"> |
| 19 | 19 | <span 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> |
| 20 | 20 | {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}} |
| 21 | -{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}} | |
| 21 | +{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}} | |
| 22 | 22 | <span class="spacer"></span> |
| 23 | 23 | <form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form> |
| 24 | 24 | {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}} |
templates/user.html modified
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | </aside> |
| 18 | 18 | <div class="main"> |
| 19 | 19 | {{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} |
| 20 | +{{with .Data.Starred}}<h3 class="plain">Starred</h3><div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a></h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{end}} | |
| 20 | 21 | </div> |
| 21 | 22 | </div> |
| 22 | 23 | {{end}} |
totp.go added
| @@ -0,0 +1,77 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/hmac" | |
| 5 | + "crypto/rand" | |
| 6 | + "crypto/sha1" | |
| 7 | + "crypto/sha256" | |
| 8 | + "encoding/base32" | |
| 9 | + "encoding/binary" | |
| 10 | + "encoding/hex" | |
| 11 | + "fmt" | |
| 12 | + "strings" | |
| 13 | + "time" | |
| 14 | +) | |
| 15 | + | |
| 16 | +func newSecret() string { | |
| 17 | + b := make([]byte, 20) | |
| 18 | + rand.Read(b) | |
| 19 | + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) | |
| 20 | +} | |
| 21 | + | |
| 22 | +func totpCode(secret string, t time.Time) string { | |
| 23 | + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) | |
| 24 | + if err != nil { | |
| 25 | + return "" | |
| 26 | + } | |
| 27 | + counter := make([]byte, 8) | |
| 28 | + binary.BigEndian.PutUint64(counter, uint64(t.Unix()/30)) | |
| 29 | + mac := hmac.New(sha1.New, key) | |
| 30 | + mac.Write(counter) | |
| 31 | + sum := mac.Sum(nil) | |
| 32 | + offset := sum[len(sum)-1] & 0x0f | |
| 33 | + code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff | |
| 34 | + return fmt.Sprintf("%06d", code%1000000) | |
| 35 | +} | |
| 36 | + | |
| 37 | +func totpValid(secret, code string) bool { | |
| 38 | + code = strings.ReplaceAll(strings.TrimSpace(code), " ", "") | |
| 39 | + now := time.Now() | |
| 40 | + for _, d := range []time.Duration{0, -30 * time.Second, 30 * time.Second} { | |
| 41 | + if hmac.Equal([]byte(totpCode(secret, now.Add(d))), []byte(code)) { | |
| 42 | + return true | |
| 43 | + } | |
| 44 | + } | |
| 45 | + return false | |
| 46 | +} | |
| 47 | + | |
| 48 | +func newRecoveryCodes() ([]string, string) { | |
| 49 | + var codes, hashes []string | |
| 50 | + for i := 0; i < 8; i++ { | |
| 51 | + b := make([]byte, 5) | |
| 52 | + rand.Read(b) | |
| 53 | + code := hex.EncodeToString(b) | |
| 54 | + codes = append(codes, code[:5]+"-"+code[5:]) | |
| 55 | + sum := sha256.Sum256([]byte(code)) | |
| 56 | + hashes = append(hashes, hex.EncodeToString(sum[:])) | |
| 57 | + } | |
| 58 | + return codes, strings.Join(hashes, ",") | |
| 59 | +} | |
| 60 | + | |
| 61 | +func useRecovery(stored, code string) (string, bool) { | |
| 62 | + code = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(code), "-", "")) | |
| 63 | + sum := sha256.Sum256([]byte(code)) | |
| 64 | + h := hex.EncodeToString(sum[:]) | |
| 65 | + var kept []string | |
| 66 | + found := false | |
| 67 | + for _, x := range strings.Split(stored, ",") { | |
| 68 | + if x == h && !found { | |
| 69 | + found = true | |
| 70 | + continue | |
| 71 | + } | |
| 72 | + if x != "" { | |
| 73 | + kept = append(kept, x) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + return strings.Join(kept, ","), found | |
| 77 | +} | |
web.go modified
| @@ -37,6 +37,7 @@ var templateFS embed.FS | ||
| 37 | 37 | var staticFS embed.FS |
| 38 | 38 | |
| 39 | 39 | type Server struct { |
| 40 | + mailer *Mailer | |
| 40 | 41 | version string |
| 41 | 42 | limiter *limiter |
| 42 | 43 | store *Store |
| @@ -62,7 +63,7 @@ type page struct { | ||
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 65 | func newServer(store *Store, root, host string, open bool, sshUser string) *Server { |
| 65 | - s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)} | |
| 66 | + s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)} | |
| 66 | 67 | funcs := template.FuncMap{ |
| 67 | 68 | "short": func(h string) string { |
| 68 | 69 | if len(h) > 10 { |
| @@ -163,6 +164,18 @@ func (s *Server) handler() http.Handler { | ||
| 163 | 164 | mux.HandleFunc("GET /login", s.loginForm) |
| 164 | 165 | mux.HandleFunc("POST /login", s.login) |
| 165 | 166 | mux.HandleFunc("POST /logout", s.logout) |
| 167 | + mux.HandleFunc("GET /login/totp", s.totpForm) | |
| 168 | + mux.HandleFunc("POST /login/totp", s.totpLogin) | |
| 169 | + mux.HandleFunc("GET /reset", s.resetForm) | |
| 170 | + mux.HandleFunc("POST /reset", s.resetRequest) | |
| 171 | + mux.HandleFunc("GET /reset/{token}", s.resetTokenForm) | |
| 172 | + mux.HandleFunc("POST /reset/{token}", s.resetPassword) | |
| 173 | + mux.HandleFunc("GET /verify/{token}", s.verifyEmail) | |
| 174 | + mux.HandleFunc("POST /settings/email", s.setEmail) | |
| 175 | + mux.HandleFunc("POST /settings/notify", s.setNotify) | |
| 176 | + mux.HandleFunc("POST /settings/totp", s.setupTOTP) | |
| 177 | + mux.HandleFunc("POST /settings/totp/enable", s.enableTOTP) | |
| 178 | + mux.HandleFunc("POST /settings/totp/disable", s.disableTOTP) | |
| 166 | 179 | mux.HandleFunc("GET /register", s.registerForm) |
| 167 | 180 | mux.HandleFunc("POST /register", s.register) |
| 168 | 181 | mux.HandleFunc("GET /create", s.createForm) |
| @@ -221,6 +234,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | ||
| 221 | 234 | s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"}) |
| 222 | 235 | return |
| 223 | 236 | } |
| 237 | + if s.store.Account(u.ID).TOTP != "" { | |
| 238 | + token, _ := s.store.CreateMailToken(u.ID, "totp", "") | |
| 239 | + http.SetCookie(w, &http.Cookie{Name: "pending", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600}) | |
| 240 | + http.Redirect(w, r, "/login/totp", http.StatusSeeOther) | |
| 241 | + return | |
| 242 | + } | |
| 224 | 243 | s.startSession(w, u) |
| 225 | 244 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 226 | 245 | } |
| @@ -454,7 +473,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { | ||
| 454 | 473 | } |
| 455 | 474 | used := usage(filepath.Join(s.root, "repos", u.Name)) |
| 456 | 475 | s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"), |
| 457 | - Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root)}}) | |
| 476 | + Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}}) | |
| 458 | 477 | } |
| 459 | 478 | |
| 460 | 479 | func (s *Server) security(w http.ResponseWriter, r *http.Request) { |
| @@ -462,7 +481,8 @@ func (s *Server) security(w http.ResponseWriter, r *http.Request) { | ||
| 462 | 481 | if u == nil { |
| 463 | 482 | return |
| 464 | 483 | } |
| 465 | - s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security"}}) | |
| 484 | + a := s.store.Account(u.ID) | |
| 485 | + s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security", "Account": a, "Mail": s.mailer != nil}}) | |
| 466 | 486 | } |
| 467 | 487 | |
| 468 | 488 | func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) { |
| @@ -698,7 +718,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 698 | 718 | if profile.Bio != "" { |
| 699 | 719 | bio = s.renderMarkdown([]byte(profile.Bio)) |
| 700 | 720 | } |
| 701 | - s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio}}) | |
| 721 | + starred, _ := s.store.StarredRepos(owner.ID) | |
| 722 | + s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio, "Starred": starred}}) | |
| 702 | 723 | return |
| 703 | 724 | } |
| 704 | 725 | name := parts[1] |
| @@ -734,6 +755,30 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 734 | 755 | s.deleteRepo(w, r, repo) |
| 735 | 756 | case rest == "collaborators" && r.Method == "POST": |
| 736 | 757 | s.collaborators(w, r, repo) |
| 758 | + case rest == "rename" && r.Method == "POST": | |
| 759 | + s.renameRepo(w, r, repo) | |
| 760 | + case rest == "transfer" && r.Method == "POST": | |
| 761 | + s.transferRepo(w, r, repo) | |
| 762 | + case rest == "archive" && r.Method == "POST": | |
| 763 | + s.archiveRepo(w, r, repo) | |
| 764 | + case rest == "star" && r.Method == "POST": | |
| 765 | + s.star(w, r, repo) | |
| 766 | + case strings.HasPrefix(rest, "blame/") && r.Method == "GET": | |
| 767 | + s.blame(w, r, repo, strings.TrimPrefix(rest, "blame/")) | |
| 768 | + case strings.HasPrefix(rest, "compare") && r.Method == "GET": | |
| 769 | + s.compare(w, r, repo, strings.TrimPrefix(strings.TrimPrefix(rest, "compare"), "/")) | |
| 770 | + case strings.HasPrefix(rest, "archive/") && r.Method == "GET": | |
| 771 | + s.download(w, r, repo, strings.TrimPrefix(rest, "archive/")) | |
| 772 | + case rest == "feed" && r.Method == "GET": | |
| 773 | + s.feed(w, r, repo) | |
| 774 | + case rest == "issues" && r.Method == "GET": | |
| 775 | + s.issues(w, r, repo) | |
| 776 | + case rest == "issues/new" && r.Method == "GET": | |
| 777 | + s.newIssue(w, r, repo) | |
| 778 | + case rest == "issues/new" && r.Method == "POST": | |
| 779 | + s.createIssue(w, r, repo) | |
| 780 | + case strings.HasPrefix(rest, "issues/"): | |
| 781 | + s.issue(w, r, repo, strings.TrimPrefix(rest, "issues/")) | |
| 737 | 782 | case rest == "refs" && r.Method == "GET": |
| 738 | 783 | s.refs(w, r, repo) |
| 739 | 784 | case strings.HasPrefix(rest, "tree/") && r.Method == "GET": |
| @@ -784,22 +829,28 @@ func (s *Server) publicKeys(w http.ResponseWriter, r *http.Request, spec string) | ||
| 784 | 829 | } |
| 785 | 830 | |
| 786 | 831 | type repoPage struct { |
| 787 | - Repo Repo | |
| 788 | - Ref string | |
| 789 | - Commit string | |
| 790 | - Owner bool | |
| 791 | - Empty bool | |
| 792 | - Tab string | |
| 793 | - Open int | |
| 794 | - Parent *Repo | |
| 795 | - User *User | |
| 796 | - IsOwner bool | |
| 832 | + Repo Repo | |
| 833 | + Ref string | |
| 834 | + Commit string | |
| 835 | + Owner bool | |
| 836 | + Empty bool | |
| 837 | + Tab string | |
| 838 | + Open int | |
| 839 | + OpenIssues int | |
| 840 | + Parent *Repo | |
| 841 | + User *User | |
| 842 | + IsOwner bool | |
| 843 | + Stars int | |
| 844 | + Starred bool | |
| 797 | 845 | } |
| 798 | 846 | |
| 799 | 847 | func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) { |
| 800 | 848 | g := Git{Dir: s.repoDir(repo)} |
| 801 | 849 | user := s.current(r) |
| 802 | - rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user} | |
| 850 | + rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: s.store.StarCount(repo.ID)} | |
| 851 | + if user != nil { | |
| 852 | + rp.Starred = s.store.Starred(user.ID, repo.ID) | |
| 853 | + } | |
| 803 | 854 | if repo.Parent != 0 { |
| 804 | 855 | if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { |
| 805 | 856 | rp.Parent = &parent |
| @@ -841,7 +892,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 841 | 892 | if name != "" { |
| 842 | 893 | data["ReadmeName"] = name |
| 843 | 894 | if strings.HasSuffix(strings.ToLower(name), ".md") { |
| 844 | - data["Readme"] = s.renderMarkdown(content) | |
| 895 | + data["Readme"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/") | |
| 845 | 896 | } else { |
| 846 | 897 | data["ReadmeText"] = string(content) |
| 847 | 898 | } |
| @@ -857,12 +908,34 @@ const maxRender = 4 << 20 | ||
| 857 | 908 | const maxHighlight = 512 << 10 |
| 858 | 909 | |
| 859 | 910 | func (s *Server) renderMarkdown(src []byte) template.HTML { |
| 911 | + return s.renderMarkdownAt(src, "", "") | |
| 912 | +} | |
| 913 | + | |
| 914 | +var relLink = regexp.MustCompile(`(src|href)="([^"]+)"`) | |
| 915 | + | |
| 916 | +func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML { | |
| 860 | 917 | src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) |
| 861 | 918 | var buf bytes.Buffer |
| 862 | 919 | if err := s.markdown.Convert(src, &buf); err != nil { |
| 863 | 920 | return template.HTML("<pre>" + template.HTMLEscapeString(string(src)) + "</pre>") |
| 864 | 921 | } |
| 865 | - return template.HTML(s.policy.SanitizeBytes(buf.Bytes())) | |
| 922 | + out := s.policy.SanitizeBytes(buf.Bytes()) | |
| 923 | + if rawBase != "" { | |
| 924 | + out = relLink.ReplaceAllFunc(out, func(m []byte) []byte { | |
| 925 | + parts := relLink.FindSubmatch(m) | |
| 926 | + attr, target := string(parts[1]), string(parts[2]) | |
| 927 | + if strings.Contains(target, "://") || strings.HasPrefix(target, "/") || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") { | |
| 928 | + return m | |
| 929 | + } | |
| 930 | + target = strings.TrimPrefix(target, "./") | |
| 931 | + base := treeBase | |
| 932 | + if attr == "src" { | |
| 933 | + base = rawBase | |
| 934 | + } | |
| 935 | + return []byte(attr + `="` + base + target + `"`) | |
| 936 | + }) | |
| 937 | + } | |
| 938 | + return template.HTML(out) | |
| 866 | 939 | } |
| 867 | 940 | |
| 868 | 941 | func hasMath(h any) bool { |
| @@ -944,7 +1017,11 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 944 | 1017 | } else if isBinary(content) { |
| 945 | 1018 | data["Binary"] = true |
| 946 | 1019 | } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" { |
| 947 | - data["Markdown"] = s.renderMarkdown(content) | |
| 1020 | + dir := "" | |
| 1021 | + if i := strings.LastIndex(filePath, "/"); i >= 0 { | |
| 1022 | + dir = filePath[:i+1] | |
| 1023 | + } | |
| 1024 | + data["Markdown"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/"+dir, "/"+repo.Path()+"/tree/"+rp.Ref+"/"+dir) | |
| 948 | 1025 | } else if h, ok := highlightLimited(filePath, content); ok { |
| 949 | 1026 | data["Highlighted"] = h |
| 950 | 1027 | } else { |