feat(admin): add registration modes, admin page, user created organisations and repo redirects
9 files changed, +338 −17
- admin.go added +239 −0
- main.go modified +25 −7
- quota.go modified +25 −1
- static/style.css modified +12 −2
- templates/admin.html added +22 −0
- templates/index.html modified +2 −2
- templates/layout.html modified +2 −2
- templates/neworg.html added +8 −0
- templates/register.html modified +3 −3
admin.go added
| @@ -0,0 +1,239 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "html/template" | |
| 5 | + "net/http" | |
| 6 | + "strconv" | |
| 7 | + "strings" | |
| 8 | + "time" | |
| 9 | +) | |
| 10 | + | |
| 11 | +const adminSchema = ` | |
| 12 | +create table if not exists settings ( | |
| 13 | + key text primary key, | |
| 14 | + value text not null | |
| 15 | +); | |
| 16 | +create table if not exists redirects ( | |
| 17 | + owner text not null, | |
| 18 | + name text not null, | |
| 19 | + repo integer not null references repos(id) on delete cascade, | |
| 20 | + primary key(owner, name) | |
| 21 | +); | |
| 22 | +` | |
| 23 | + | |
| 24 | +var registrationModes = []string{"open", "invite", "closed"} | |
| 25 | + | |
| 26 | +func (s *Store) Setting(key, fallback string) string { | |
| 27 | + var v string | |
| 28 | + if err := s.db.QueryRow(`select value from settings where key = ?`, key).Scan(&v); err != nil { | |
| 29 | + return fallback | |
| 30 | + } | |
| 31 | + return v | |
| 32 | +} | |
| 33 | + | |
| 34 | +func (s *Store) SetSetting(key, value string) error { | |
| 35 | + _, err := s.db.Exec(`insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value`, key, value) | |
| 36 | + return err | |
| 37 | +} | |
| 38 | + | |
| 39 | +func (s *Store) Registration() string { | |
| 40 | + return s.Setting("registration", "open") | |
| 41 | +} | |
| 42 | + | |
| 43 | +func validRegistration(mode string) bool { | |
| 44 | + for _, m := range registrationModes { | |
| 45 | + if m == mode { | |
| 46 | + return true | |
| 47 | + } | |
| 48 | + } | |
| 49 | + return false | |
| 50 | +} | |
| 51 | + | |
| 52 | +func (s *Store) IsAdmin(id int64) bool { | |
| 53 | + var admin bool | |
| 54 | + s.db.QueryRow(`select admin from users where id = ? and banned = 0`, id).Scan(&admin) | |
| 55 | + return admin | |
| 56 | +} | |
| 57 | + | |
| 58 | +func (s *Store) SetAdmin(name string, admin bool) error { | |
| 59 | + res, err := s.db.Exec(`update users set admin = ? where name = ? and org = 0`, admin, name) | |
| 60 | + if err != nil { | |
| 61 | + return err | |
| 62 | + } | |
| 63 | + if n, _ := res.RowsAffected(); n == 0 { | |
| 64 | + return ErrNotFound | |
| 65 | + } | |
| 66 | + return nil | |
| 67 | +} | |
| 68 | + | |
| 69 | +func (s *Store) AddRedirect(owner, name string, repo int64) { | |
| 70 | + s.db.Exec(`insert into redirects(owner, name, repo) values (?, ?, ?) on conflict(owner, name) do update set repo = excluded.repo`, owner, name, repo) | |
| 71 | +} | |
| 72 | + | |
| 73 | +func (s *Store) Redirect(owner, name string) (Repo, bool) { | |
| 74 | + var id int64 | |
| 75 | + if err := s.db.QueryRow(`select repo from redirects where owner = ? and name = ?`, owner, name).Scan(&id); err != nil { | |
| 76 | + return Repo{}, false | |
| 77 | + } | |
| 78 | + repo, err := s.RepoByID(id) | |
| 79 | + return repo, err == nil | |
| 80 | +} | |
| 81 | + | |
| 82 | +func (s *Store) Invites() []string { | |
| 83 | + rows, err := s.db.Query(`select code from invites where used = 0 order by rowid desc limit 50`) | |
| 84 | + if err != nil { | |
| 85 | + return nil | |
| 86 | + } | |
| 87 | + defer rows.Close() | |
| 88 | + var codes []string | |
| 89 | + for rows.Next() { | |
| 90 | + var c string | |
| 91 | + if rows.Scan(&c) == nil { | |
| 92 | + codes = append(codes, c) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + return codes | |
| 96 | +} | |
| 97 | + | |
| 98 | +type account struct { | |
| 99 | + User | |
| 100 | + Admin bool | |
| 101 | + Banned bool | |
| 102 | + Org bool | |
| 103 | + Repos int | |
| 104 | +} | |
| 105 | + | |
| 106 | +func (s *Store) Accounts() []account { | |
| 107 | + rows, err := s.db.Query(`select u.id, u.name, u.created, u.admin, u.banned, u.org, (select count(*) from repos r where r.owner = u.id) from users u order by u.created desc limit 1000`) | |
| 108 | + if err != nil { | |
| 109 | + return nil | |
| 110 | + } | |
| 111 | + defer rows.Close() | |
| 112 | + var out []account | |
| 113 | + for rows.Next() { | |
| 114 | + var a account | |
| 115 | + var created int64 | |
| 116 | + if rows.Scan(&a.ID, &a.Name, &created, &a.Admin, &a.Banned, &a.Org, &a.Repos) == nil { | |
| 117 | + a.Created = time.Unix(created, 0) | |
| 118 | + out = append(out, a) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + return out | |
| 122 | +} | |
| 123 | + | |
| 124 | +func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | |
| 125 | + u := s.require(w, r) | |
| 126 | + if u == nil { | |
| 127 | + return | |
| 128 | + } | |
| 129 | + if !s.store.IsAdmin(u.ID) { | |
| 130 | + s.fail(w, r, 404, "Not found") | |
| 131 | + return | |
| 132 | + } | |
| 133 | + if r.Method == "POST" { | |
| 134 | + s.adminAction(w, r, u) | |
| 135 | + return | |
| 136 | + } | |
| 137 | + featured, _ := s.store.FeaturedRepos() | |
| 138 | + ranks := map[int64]int{} | |
| 139 | + for i, f := range featured { | |
| 140 | + ranks[f.ID] = i + 1 | |
| 141 | + } | |
| 142 | + public, _ := s.store.PublicRepos("", "") | |
| 143 | + s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{ | |
| 144 | + "Registration": s.store.Registration(), "Modes": registrationModes, "Invites": s.store.Invites(), | |
| 145 | + "Accounts": s.store.Accounts(), "Ranks": ranks, "Public": public, "Quota": compactSize(readQuota(s.root)), | |
| 146 | + }}) | |
| 147 | +} | |
| 148 | + | |
| 149 | +func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { | |
| 150 | + back := "/admin" | |
| 151 | + fail := func(msg string) { | |
| 152 | + http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(msg), http.StatusSeeOther) | |
| 153 | + } | |
| 154 | + name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~") | |
| 155 | + switch r.FormValue("action") { | |
| 156 | + case "registration": | |
| 157 | + mode := r.FormValue("mode") | |
| 158 | + if !validRegistration(mode) { | |
| 159 | + fail("Unknown registration mode") | |
| 160 | + return | |
| 161 | + } | |
| 162 | + s.store.SetSetting("registration", mode) | |
| 163 | + case "invite": | |
| 164 | + if _, err := s.store.CreateInvite(); err != nil { | |
| 165 | + fail(err.Error()) | |
| 166 | + return | |
| 167 | + } | |
| 168 | + back += "#invites" | |
| 169 | + case "quota": | |
| 170 | + n, err := parseSize(strings.TrimSpace(r.FormValue("quota"))) | |
| 171 | + if err != nil { | |
| 172 | + fail(err.Error()) | |
| 173 | + return | |
| 174 | + } | |
| 175 | + if err := writeQuota(s.root, n); err != nil { | |
| 176 | + fail(err.Error()) | |
| 177 | + return | |
| 178 | + } | |
| 179 | + case "ban", "unban": | |
| 180 | + if name == u.Name { | |
| 181 | + fail("You cannot ban yourself") | |
| 182 | + return | |
| 183 | + } | |
| 184 | + if err := s.store.SetBanned(name, r.FormValue("action") == "ban"); err != nil { | |
| 185 | + fail("No such account") | |
| 186 | + return | |
| 187 | + } | |
| 188 | + back += "#accounts" | |
| 189 | + case "admin", "unadmin": | |
| 190 | + if name == u.Name { | |
| 191 | + fail("You cannot change your own admin rights") | |
| 192 | + return | |
| 193 | + } | |
| 194 | + if err := s.store.SetAdmin(name, r.FormValue("action") == "admin"); err != nil { | |
| 195 | + fail("No such user") | |
| 196 | + return | |
| 197 | + } | |
| 198 | + back += "#accounts" | |
| 199 | + case "feature": | |
| 200 | + owner, repo, ok := strings.Cut(name, "/") | |
| 201 | + rank, _ := strconv.Atoi(r.FormValue("rank")) | |
| 202 | + if !ok || rank < 0 || s.store.SetFeatured(owner, repo, rank) != nil { | |
| 203 | + fail("No such repository") | |
| 204 | + return | |
| 205 | + } | |
| 206 | + back += "#featured" | |
| 207 | + default: | |
| 208 | + fail("Unknown action") | |
| 209 | + return | |
| 210 | + } | |
| 211 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 212 | +} | |
| 213 | + | |
| 214 | +func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) { | |
| 215 | + u := s.require(w, r) | |
| 216 | + if u == nil { | |
| 217 | + return | |
| 218 | + } | |
| 219 | + if r.Method == "GET" { | |
| 220 | + s.render(w, r, "neworg", page{Title: "New organisation", User: u}) | |
| 221 | + return | |
| 222 | + } | |
| 223 | + name := strings.TrimSpace(r.FormValue("name")) | |
| 224 | + if s.limiter.blocked("org:" + u.Name) { | |
| 225 | + s.render(w, r, "neworg", page{Title: "New organisation", User: u, Error: "Too many organisations created recently, try again later", Data: map[string]any{"Name": name}}) | |
| 226 | + return | |
| 227 | + } | |
| 228 | + org, err := s.store.CreateOrg(name) | |
| 229 | + if err == nil { | |
| 230 | + err = s.store.SetMember(org.ID, u.ID, true) | |
| 231 | + } | |
| 232 | + if err != nil { | |
| 233 | + w.WriteHeader(400) | |
| 234 | + s.render(w, r, "neworg", page{Title: "New organisation", User: u, Error: err.Error(), Data: map[string]any{"Name": name}}) | |
| 235 | + return | |
| 236 | + } | |
| 237 | + s.limiter.fail("org:" + u.Name) | |
| 238 | + http.Redirect(w, r, "/~"+org.Name+"/settings", http.StatusSeeOther) | |
| 239 | +} | |
main.go modified
| @@ -7,7 +7,6 @@ import ( | ||
| 7 | 7 | "net/http" |
| 8 | 8 | "os" |
| 9 | 9 | "path/filepath" |
| 10 | - "strconv" | |
| 11 | 10 | "strings" |
| 12 | 11 | "time" |
| 13 | 12 | ) |
| @@ -17,10 +16,12 @@ func main() { | ||
| 17 | 16 | flag.Usage = func() { |
| 18 | 17 | fmt.Fprintln(os.Stderr, `usage: gitpocket [-root dir] command |
| 19 | 18 | |
| 20 | - serve [-listen addr] [-host name] [-name text] [-open] [-ssh-user name] run the web server | |
| 19 | + serve [-listen addr] [-host name] [-name text] [-ssh-user name] run the web server | |
| 21 | 20 | keys user home type key sshd AuthorizedKeysCommand |
| 22 | 21 | shell user forced command for ssh git access |
| 23 | 22 | invite print a single use registration code |
| 23 | + registration [open|invite|closed] show or set who may register | |
| 24 | + admin name | unadmin name grant or revoke site administration | |
| 24 | 25 | quota [size] show or set the per user disk quota, 0 disables |
| 25 | 26 | user name create a user, password read from stdin |
| 26 | 27 | passwd name set a user's password, read from stdin |
| @@ -29,7 +30,7 @@ func main() { | ||
| 29 | 30 | feature owner/repo... | -owner/repo set or clear the featured projects, in order |
| 30 | 31 | org name [+user|-user]... create an organisation and add or remove members |
| 31 | 32 | mail address send a test message through the configured mailer |
| 32 | - backup file write a consistent copy of the database`) | |
| 33 | + backup dir | file.db snapshot the database and bundle every changed repository into dir`) | |
| 33 | 34 | } |
| 34 | 35 | flag.Parse() |
| 35 | 36 | args := flag.Args() |
| @@ -59,7 +60,7 @@ func main() { | ||
| 59 | 60 | if err != nil { |
| 60 | 61 | log.Fatal(err) |
| 61 | 62 | } |
| 62 | - if err := os.WriteFile(filepath.Join(*root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644); err != nil { | |
| 63 | + if err := writeQuota(*root, n); err != nil { | |
| 63 | 64 | log.Fatal(err) |
| 64 | 65 | } |
| 65 | 66 | } |
| @@ -68,6 +69,24 @@ func main() { | ||
| 68 | 69 | } else { |
| 69 | 70 | fmt.Println(humanSize(q)) |
| 70 | 71 | } |
| 72 | + case "registration": | |
| 73 | + store := mustStore(*root) | |
| 74 | + if len(args) == 2 { | |
| 75 | + if !validRegistration(args[1]) { | |
| 76 | + log.Fatal("registration is open, invite or closed") | |
| 77 | + } | |
| 78 | + if err := store.SetSetting("registration", args[1]); err != nil { | |
| 79 | + log.Fatal(err) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + fmt.Println(store.Registration()) | |
| 83 | + case "admin", "unadmin": | |
| 84 | + if len(args) != 2 { | |
| 85 | + os.Exit(2) | |
| 86 | + } | |
| 87 | + if err := mustStore(*root).SetAdmin(args[1], args[0] == "admin"); err != nil { | |
| 88 | + log.Fatal("no such user") | |
| 89 | + } | |
| 71 | 90 | case "invite": |
| 72 | 91 | store := mustStore(*root) |
| 73 | 92 | code, err := store.CreateInvite() |
| @@ -104,7 +123,7 @@ func main() { | ||
| 104 | 123 | if len(args) != 2 { |
| 105 | 124 | os.Exit(2) |
| 106 | 125 | } |
| 107 | - if err := mustStore(*root).Backup(args[1]); err != nil { | |
| 126 | + if err := backup(*root, args[1], 14); err != nil { | |
| 108 | 127 | log.Fatal(err) |
| 109 | 128 | } |
| 110 | 129 | case "mail": |
| @@ -198,13 +217,12 @@ func serve(root string, args []string) { | ||
| 198 | 217 | listen := fs.String("listen", "127.0.0.1:3200", "address to listen on") |
| 199 | 218 | host := fs.String("host", "localhost", "public hostname used in clone urls") |
| 200 | 219 | name := fs.String("name", "gitpocket", "site name shown in the header") |
| 201 | - open := fs.Bool("open", false, "allow registration without an invite") | |
| 202 | 220 | sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls") |
| 203 | 221 | fs.Parse(args) |
| 204 | 222 | store := mustStore(root) |
| 205 | 223 | srv := &http.Server{ |
| 206 | 224 | Addr: *listen, |
| 207 | - Handler: newServer(store, root, *host, *name, *open, *sshUser).handler(), | |
| 225 | + Handler: newServer(store, root, *host, *name, *sshUser).handler(), | |
| 208 | 226 | ReadHeaderTimeout: 10 * time.Second, |
| 209 | 227 | IdleTimeout: 2 * time.Minute, |
| 210 | 228 | MaxHeaderBytes: 64 << 10, |
quota.go modified
| @@ -21,6 +21,7 @@ func readQuota(root string) int64 { | ||
| 21 | 21 | |
| 22 | 22 | func parseSize(s string) (int64, error) { |
| 23 | 23 | s = strings.ToUpper(strings.TrimSpace(s)) |
| 24 | + s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(s, "B"), "I")) | |
| 24 | 25 | mult := int64(1) |
| 25 | 26 | switch { |
| 26 | 27 | case strings.HasSuffix(s, "G"): |
| @@ -30,7 +31,7 @@ func parseSize(s string) (int64, error) { | ||
| 30 | 31 | case strings.HasSuffix(s, "K"): |
| 31 | 32 | mult, s = 1<<10, strings.TrimSuffix(s, "K") |
| 32 | 33 | } |
| 33 | - n, err := strconv.ParseInt(s, 10, 64) | |
| 34 | + n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) | |
| 34 | 35 | if err != nil || n < 0 { |
| 35 | 36 | return 0, errors.New("size must be a number with an optional K, M or G suffix") |
| 36 | 37 | } |
| @@ -62,3 +63,26 @@ func overQuota(root string, owner string) (bool, int64, int64) { | ||
| 62 | 63 | func quotaMessage(used, quota int64) string { |
| 63 | 64 | return fmt.Sprintf("disk quota exceeded, %s used of %s", humanSize(used), humanSize(quota)) |
| 64 | 65 | } |
| 66 | + | |
| 67 | +func writeQuota(root string, n int64) error { | |
| 68 | + return os.WriteFile(filepath.Join(root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644) | |
| 69 | +} | |
| 70 | + | |
| 71 | +func pushLimit(root, owner string) []string { | |
| 72 | + if over, used, quota := overQuota(root, owner); quota > 0 && !over { | |
| 73 | + return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(quota-used, 10)} | |
| 74 | + } | |
| 75 | + return nil | |
| 76 | +} | |
| 77 | + | |
| 78 | +func compactSize(n int64) string { | |
| 79 | + for _, u := range []struct { | |
| 80 | + suffix string | |
| 81 | + size int64 | |
| 82 | + }{{"G", 1 << 30}, {"M", 1 << 20}, {"K", 1 << 10}} { | |
| 83 | + if n >= u.size && n%u.size == 0 { | |
| 84 | + return strconv.FormatInt(n/u.size, 10) + u.suffix | |
| 85 | + } | |
| 86 | + } | |
| 87 | + return strconv.FormatInt(n, 10) | |
| 88 | +} | |
static/style.css modified
| @@ -187,8 +187,9 @@ input[type=file]{border:0;padding:0} | ||
| 187 | 187 | .twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem} |
| 188 | 188 | .twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem} |
| 189 | 189 | .twocol .legend{display:block;margin-bottom:.4rem} |
| 190 | -.twocol label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem} | |
| 191 | -.twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} | |
| 190 | +.twocol label.radio,.modes label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem} | |
| 191 | +.twocol label.radio small,.modes label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} | |
| 192 | +.modes input[type=radio]{width:auto;margin:0} | |
| 192 | 193 | h3.plain{border:0;font-size:1.25rem} |
| 193 | 194 | .columns.browse{grid-template-columns:minmax(0,2fr) 1fr} |
| 194 | 195 | .search input[name=search],.search input[name=q]{width:100%} |
| @@ -432,3 +433,12 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 432 | 433 | .orgactions{display:flex;gap:.5rem} |
| 433 | 434 | .columns.orgbody{grid-template-columns:minmax(0,1fr) 280px;gap:2rem} |
| 434 | 435 | @media(max-width:800px){.orghead{grid-template-columns:auto minmax(0,1fr)}.orgactions{grid-column:1/-1}.orgmark img,.orgmark span{width:4.5rem;height:4.5rem;font-size:2rem}.columns.orgbody{grid-template-columns:1fr}} |
| 436 | +.admintable{border-collapse:collapse;width:100%;max-width:52rem;margin-bottom:1rem;font-size:.93rem} | |
| 437 | +.admintable th,.admintable td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--line);vertical-align:middle} | |
| 438 | +.admintable th{color:var(--muted);font-weight:600} | |
| 439 | +.admintable input[type=number]{width:4.5rem} | |
| 440 | +.admintable .btn.small{margin:0 .3rem 0 0} | |
| 441 | +.admintable .acts{white-space:nowrap} | |
| 442 | +.codes{list-style:none;padding:0;margin:.6rem 0 1rem} | |
| 443 | +.codes li{margin:.2rem 0} | |
| 444 | +.dashhead span{display:flex;gap:.5rem} | |
templates/admin.html added
| @@ -0,0 +1,22 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$d := .Data}} | |
| 3 | +<h3 class="rule">Registration</h3> | |
| 4 | +<form method="post" action="/admin" class="stack wide modes"> | |
| 5 | +<input type="hidden" name="action" value="registration"> | |
| 6 | +<label class="radio"><input type="radio" name="mode" value="open"{{if eq $d.Registration "open"}} checked{{end}}> Open<small>Anyone can create an account, a few per address every few minutes</small></label> | |
| 7 | +<label class="radio"><input type="radio" name="mode" value="invite"{{if eq $d.Registration "invite"}} checked{{end}}> Invite only<small>Registration needs a single use code from the list below</small></label> | |
| 8 | +<label class="radio"><input type="radio" name="mode" value="closed"{{if eq $d.Registration "closed"}} checked{{end}}> Closed<small>Nobody can register; accounts are created with <code>gitpocket user</code></small></label> | |
| 9 | +<div class="actions left"><button class="btn primary">Save ▸</button></div> | |
| 10 | +</form> | |
| 11 | +<h3 class="rule" id="invites">Invites</h3> | |
| 12 | +<form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="invite"><button class="btn">Create invite ▸</button></form> | |
| 13 | +{{if $d.Invites}}<ul class="codes">{{range $d.Invites}}<li><code>https://{{$.Host}}/register?invite={{.}}</code></li>{{end}}</ul>{{else}}<p class="muted small">No unused invites.</p>{{end}} | |
| 14 | +<h3 class="rule">Disk quota</h3> | |
| 15 | +<form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="quota"><input name="quota" value="{{$d.Quota}}" aria-label="Quota per owner"> <button class="btn">Set ▸</button></form> | |
| 16 | +<p class="muted small">Per owner, for example 500M or 2G. Zero disables the quota. Pushes beyond it are refused before any data is written.</p> | |
| 17 | +<h3 class="rule" id="featured">Featured projects</h3> | |
| 18 | +<table class="admintable"><tbody>{{range $d.Public}}<tr><td><a href="/{{.Path}}">{{.Path}}</a></td><td><form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="feature"><input type="hidden" name="name" value="{{.Owner.Name}}/{{.Name}}"><input name="rank" type="number" min="0" max="99" value="{{index $d.Ranks .ID}}" aria-label="Position"> <button class="btn small">Set</button></form></td></tr>{{end}}</tbody></table> | |
| 19 | +<p class="muted small">Position 1 comes first on the front page and 0 removes a project. With none chosen the most recently pushed are shown.</p> | |
| 20 | +<h3 class="rule" id="accounts">Accounts</h3> | |
| 21 | +<table class="admintable"><thead><tr><th>Name</th><th>Joined</th><th>Repos</th><th></th></tr></thead><tbody>{{range $d.Accounts}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a>{{if .Org}} <span class="muted">org</span>{{end}}{{if .Admin}} <span class="muted">admin</span>{{end}}{{if .Banned}} <span class="muted">banned</span>{{end}}</td><td>{{.Created.Format "2006-01-02"}}</td><td>{{.Repos}}</td><td class="acts">{{if ne .Name $.User.Name}}<form method="post" action="/admin" class="inline"><input type="hidden" name="name" value="{{.Name}}">{{if .Banned}}<button class="btn small" name="action" value="unban">Unban</button>{{else}}<button class="btn small danger" name="action" value="ban">Ban</button>{{end}}{{if not .Org}}{{if .Admin}}<button class="btn small" name="action" value="unadmin">Revoke admin</button>{{else}}<button class="btn small" name="action" value="admin">Make admin</button>{{end}}{{end}}</form>{{end}}</td></tr>{{end}}</tbody></table> | |
| 22 | +{{end}} | |
templates/index.html modified
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{if .User}} |
| 3 | 3 | <div class="dash"> |
| 4 | -<section><div class="dashhead"><h3>Your repositories</h3><a class="btn primary" href="/create">New repository</a></div> | |
| 4 | +<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organisation</a> <a class="btn primary" href="/create">New repository</a></span></div> | |
| 5 | 5 | {{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p class="muted">You have no repositories yet.</p>{{end}} |
| 6 | 6 | {{if .Data.Shared}}<h3>Shared with you</h3>{{template "repolist" .Data.Shared}}{{end}}</section> |
| 7 | 7 | {{else}} |
| 8 | 8 | <div class="landing"> |
| 9 | -<p><a class="btn primary" href="/register">Register here ▸</a> or <a class="arrow" href="/login">Log in ▸</a> to get started, or check out some of our featured projects:</p> | |
| 9 | +{{if eq .Registration "closed"}}<p><a class="btn primary" href="/login">Log in ▸</a> or check out some of our featured projects:</p>{{else}}<p><a class="btn primary" href="/register">Register here ▸</a> or <a class="arrow" href="/login">Log in ▸</a> to get started, or check out some of our featured projects:</p>{{end}} | |
| 10 | 10 | <h3 class="featured">Featured projects</h3> |
| 11 | 11 | <div class="featuredgrid">{{range .Data.Featured}}<div class="feature"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<blockquote>{{.Description}}</blockquote>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a>{{end}}</p>{{end}}</div>{{end}}</div> |
| 12 | 12 | <div class="more"><a class="btn" href="/search">More projects ▸</a></div> |
templates/layout.html modified
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css"> |
| 13 | 13 | <script defer src="/static/katex/katex.min.js"></script> |
| 14 | 14 | <script defer src="/static/katex/auto-render.min.js"></script> |
| 15 | -<script>document.addEventListener("DOMContentLoaded",function(){renderMathInElement(document.body,{delimiters:[{left:"\\(",right:"\\)",display:false},{left:"\\[",right:"\\]",display:true}],throwOnError:false})})</script>{{end}} | |
| 15 | +<script defer src="/static/math.js"></script>{{end}} | |
| 16 | 16 | </head> |
| 17 | 17 | <body{{if .Fluid}} class="fluid"{{end}}> |
| 18 | 18 | <header> |
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | <a href="/search">explore</a> |
| 22 | 22 | {{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}} |
| 23 | 23 | <span class="spacer"></span> |
| 24 | -{{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a> — <a href="/register">Register</a></span>{{end}} | |
| 24 | +{{if .User}}<span>{{if .Admin}}<a href="/admin">admin</a> — {{end}}Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a>{{if ne .Registration "closed"}} — <a href="/register">Register</a>{{end}}</span>{{end}} | |
| 25 | 25 | </nav> |
| 26 | 26 | {{with .Data}}{{with index . "Settings"}} |
| 27 | 27 | <nav class="subtabs"> |
templates/neworg.html added
| @@ -0,0 +1,8 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<h3 class="rule">Create an organisation</h3> | |
| 3 | +<p class="muted">An organisation owns repositories on behalf of a group. You become its first member, and every member can push to its repositories, create new ones under it and edit its page.</p> | |
| 4 | +<form method="post" action="/orgs/new" class="stack wide"> | |
| 5 | +<label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}" value="{{with .Data}}{{.Name}}{{end}}"><small>Lowercase letters, digits, dot, dash or underscore. It shares the namespace with users.</small></label> | |
| 6 | +<div class="actions left"><button class="btn primary">Create organisation ▸</button></div> | |
| 7 | +</form> | |
| 8 | +{{end}} | |
templates/register.html modified
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="auth"> |
| 3 | 3 | <h3 class="rule">Register for {{.Host}} <small>or <a href="/login">log in</a></small></h3> |
| 4 | -<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. No email is needed and you are logged in straight away.</p> | |
| 4 | +{{if eq .Data.Mode "closed"}}<p class="info">Registration is closed on {{.Host}}. Ask an administrator for an account.</p>{{else}}<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. No email is needed and you are logged in straight away.</p> | |
| 5 | 5 | <form method="post" action="/register" class="stack centred"> |
| 6 | 6 | <label>Username <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}"><small>Lowercase letters, digits, dot, dash or underscore.</small></label> |
| 7 | 7 | <label>Password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label> |
| 8 | -{{if not .Data.Open}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}} | |
| 8 | +{{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}} | |
| 9 | 9 | <div class="actions"><button class="btn primary">Register ▸</button></div> |
| 10 | -</form> | |
| 10 | +</form>{{end}} | |
| 11 | 11 | <p class="privacy"><strong>Privacy notice</strong>: {{.Host}} stores your username, a hash of your password, the keys and profile details you choose to add, and the address of each failed login attempt for a few minutes. Nothing is collected for marketing or analytics and nothing is shared with third parties.</p> |
| 12 | 12 | </div> |
| 13 | 13 | {{end}} |