feat(moderation): add reports, account blocks and hourly thread limits
19 files changed, +605 −12
- README.md modified +1 −0
- forge/admin.go modified +17 −2
- forge/extra.go modified +6 −1
- forge/feature_test.go added +91 −0
- forge/flow_test.go modified +1 −1
- forge/follow.go modified +6 −1
- forge/issues.go modified +12 −0
- forge/moderation.go added +393 −0
- forge/pull.go modified +25 −0
- forge/store.go modified +2 −1
- forge/web.go modified +10 −1
- web/static/style.css modified +3 −0
- web/templates/admin.tmpl modified +17 −1
- web/templates/issue.tmpl modified +1 −1
- web/templates/layout.tmpl modified +1 −1
- web/templates/pull.tmpl modified +1 −1
- web/templates/report.tmpl added +13 −0
- web/templates/settings.tmpl modified +3 −0
- web/templates/user.tmpl modified +2 −1
README.md modified
| @@ -22,6 +22,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 22 | 22 | - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push |
| 23 | 23 | - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository |
| 24 | 24 | - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account |
| 25 | +- Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories | |
| 25 | 26 | - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests |
| 26 | 27 | - Site search over repositories and people with language facets |
| 27 | 28 | - Light and dark themes, light by default, with an option to follow the device |
forge/admin.go modified
| @@ -165,7 +165,7 @@ func (s *Store) Accounts() []account { | ||
| 165 | 165 | } |
| 166 | 166 | |
| 167 | 167 | var adminSections = []struct{ Key, Title string }{ |
| 168 | - {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, | |
| 168 | + {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, {"reports", "reports"}, | |
| 169 | 169 | } |
| 170 | 170 | |
| 171 | 171 | type siteStats struct { |
| @@ -200,7 +200,7 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | ||
| 200 | 200 | if !known { |
| 201 | 201 | section = "overview" |
| 202 | 202 | } |
| 203 | - data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != ""} | |
| 203 | + data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != "", "OpenReports": s.store.OpenReports()} | |
| 204 | 204 | switch section { |
| 205 | 205 | case "overview": |
| 206 | 206 | accounts := s.store.Accounts() |
| @@ -231,6 +231,12 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | ||
| 231 | 231 | } |
| 232 | 232 | public, _ := s.store.PublicRepos("", "") |
| 233 | 233 | data["Ranks"], data["Public"] = ranks, public |
| 234 | + case "reports": | |
| 235 | + state := r.URL.Query().Get("state") | |
| 236 | + if state != "resolved" && state != "dismissed" { | |
| 237 | + state = "open" | |
| 238 | + } | |
| 239 | + data["Reports"], data["State"] = s.store.Reports(state), state | |
| 234 | 240 | case "storage": |
| 235 | 241 | quota := readQuota(s.root) |
| 236 | 242 | type owner struct { |
| @@ -312,6 +318,15 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { | ||
| 312 | 318 | fail("No such user") |
| 313 | 319 | return |
| 314 | 320 | } |
| 321 | + case "moderate": | |
| 322 | + if !s.moderate(w, r, u, fail) { | |
| 323 | + return | |
| 324 | + } | |
| 325 | + case "pages", "nopages": | |
| 326 | + if err := s.store.SetPages(name, r.FormValue("action") == "pages"); err != nil { | |
| 327 | + fail("No such account") | |
| 328 | + return | |
| 329 | + } | |
| 315 | 330 | case "feature": |
| 316 | 331 | owner, repo, ok := strings.Cut(name, "/") |
| 317 | 332 | rank, _ := strconv.Atoi(r.FormValue("rank")) |
forge/extra.go modified
| @@ -25,7 +25,12 @@ func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 25 | 25 | if u == nil { |
| 26 | 26 | return |
| 27 | 27 | } |
| 28 | - s.store.Star(u.ID, repo.ID, !s.store.Starred(u.ID, repo.ID)) | |
| 28 | + on := !s.store.Starred(u.ID, repo.ID) | |
| 29 | + if on && s.shunned(repo, u) { | |
| 30 | + s.fail(w, r, 403, "You cannot star this repository") | |
| 31 | + return | |
| 32 | + } | |
| 33 | + s.store.Star(u.ID, repo.ID, on) | |
| 29 | 34 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 30 | 35 | } |
| 31 | 36 | |
forge/feature_test.go added
| @@ -0,0 +1,91 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "net/url" | |
| 6 | + "strconv" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | +) | |
| 10 | + | |
| 11 | +func TestModeration(t *testing.T) { | |
| 12 | + s, h := testServer(t) | |
| 13 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 14 | + bob, _ := s.store.CreateUser("bob", "correct horse") | |
| 15 | + boss, _ := s.store.CreateUser("boss", "correct horse") | |
| 16 | + s.store.SetAdmin("boss", true) | |
| 17 | + s.store.CreateRepo(alice, "proj", "", Public, "") | |
| 18 | + s.store.CreateRepo(alice, "hidden", "", Private, "") | |
| 19 | + a, _ := s.store.CreateSession(alice.ID) | |
| 20 | + b, _ := s.store.CreateSession(bob.ID) | |
| 21 | + admin, _ := s.store.CreateSession(boss.ID) | |
| 22 | + | |
| 23 | + if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"spam"}, "body": {"x"}}, b); w.Code != http.StatusSeeOther { | |
| 24 | + t.Fatalf("issue: %d", w.Code) | |
| 25 | + } | |
| 26 | + if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 200 { | |
| 27 | + t.Fatalf("report: %d %s", w.Code, w.Body.String()) | |
| 28 | + } | |
| 29 | + if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 400 { | |
| 30 | + t.Error("duplicate report accepted") | |
| 31 | + } | |
| 32 | + if w := post(h, "/report", url.Values{"kind": {"repo"}, "target": {"alice/hidden"}, "reason": {"x"}}, b); w.Code != 404 { | |
| 33 | + t.Error("report reveals a private repository") | |
| 34 | + } | |
| 35 | + if w := get(h, "/admin?s=reports", admin); !strings.Contains(w.Body.String(), "alice/proj#1") { | |
| 36 | + t.Error("report missing from admin") | |
| 37 | + } | |
| 38 | + if w := get(h, "/admin?s=reports", b); w.Code == 200 { | |
| 39 | + t.Error("non-admin sees reports") | |
| 40 | + } | |
| 41 | + rep := s.store.Reports("open")[0] | |
| 42 | + if w := post(h, "/admin", url.Values{"s": {"reports"}, "action": {"moderate"}, "report": {itoa(rep.ID)}, "do": {"ban"}}, admin); strings.Contains(w.Header().Get("Location"), "error") { | |
| 43 | + t.Fatalf("ban: %s", w.Header().Get("Location")) | |
| 44 | + } | |
| 45 | + if !s.store.IsBanned(bob.ID) || s.store.IsBanned(alice.ID) { | |
| 46 | + t.Error("ban hit the wrong account") | |
| 47 | + } | |
| 48 | + s.store.SetBanned("bob", false) | |
| 49 | + b, _ = s.store.CreateSession(bob.ID) | |
| 50 | + if len(s.store.Reports("open")) != 0 { | |
| 51 | + t.Error("report still open") | |
| 52 | + } | |
| 53 | + | |
| 54 | + if w := post(h, "/block", url.Values{"name": {"bob"}}, a); w.Code != http.StatusSeeOther { | |
| 55 | + t.Fatalf("block: %d", w.Code) | |
| 56 | + } | |
| 57 | + if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"again"}}, b); w.Code != 403 { | |
| 58 | + t.Errorf("blocked user opened an issue: %d", w.Code) | |
| 59 | + } | |
| 60 | + if w := post(h, "/~alice/proj/issues/1/comment", url.Values{"body": {"hi"}}, b); w.Code != 403 { | |
| 61 | + t.Errorf("blocked user commented: %d", w.Code) | |
| 62 | + } | |
| 63 | + if w := post(h, "/~alice/proj/star", nil, b); w.Code != 403 { | |
| 64 | + t.Errorf("blocked user starred: %d", w.Code) | |
| 65 | + } | |
| 66 | + if w := post(h, "/~alice/follow", nil, b); w.Code != 403 { | |
| 67 | + t.Errorf("blocked user followed: %d", w.Code) | |
| 68 | + } | |
| 69 | + if w := post(h, "/~alice/proj/fork", nil, b); w.Code != 403 { | |
| 70 | + t.Errorf("blocked user forked: %d", w.Code) | |
| 71 | + } | |
| 72 | + if !strings.Contains(get(h, "/settings", a).Body.String(), "~bob") { | |
| 73 | + t.Error("blocked list missing from settings") | |
| 74 | + } | |
| 75 | + post(h, "/block", url.Values{"name": {"bob"}, "action": {"unblock"}}, a) | |
| 76 | + if s.store.Blocked(alice.ID, bob.ID) { | |
| 77 | + t.Error("unblock failed") | |
| 78 | + } | |
| 79 | + if w := post(h, "/block", url.Values{"name": {"bob"}}, b); w.Code != 400 { | |
| 80 | + t.Error("blocked yourself") | |
| 81 | + } | |
| 82 | + | |
| 83 | + for i := 0; i < threadsPerHour; i++ { | |
| 84 | + s.store.CreateIssue(Repo{ID: 1}, bob, "t", "") | |
| 85 | + } | |
| 86 | + if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"flood"}}, b); w.Code != 429 { | |
| 87 | + t.Errorf("issue flood not limited: %d", w.Code) | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +func itoa(n int64) string { return strconv.FormatInt(n, 10) } | |
forge/flow_test.go modified
| @@ -251,7 +251,7 @@ func TestAdminSections(t *testing.T) { | ||
| 251 | 251 | admin, _ := s.store.CreateUser("root", "correct horse") |
| 252 | 252 | s.store.SetAdmin("root", true) |
| 253 | 253 | id, _ := s.store.CreateSession(admin.ID) |
| 254 | - for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "bogus"} { | |
| 254 | + for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "reports", "bogus"} { | |
| 255 | 255 | if w := get(h, "/admin?s="+sec, id); w.Code != 200 { |
| 256 | 256 | t.Errorf("section %s: %d", sec, w.Code) |
| 257 | 257 | } |
forge/follow.go modified
| @@ -50,7 +50,12 @@ func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) { | ||
| 50 | 50 | return |
| 51 | 51 | } |
| 52 | 52 | if u.ID != owner.ID && !s.store.IsOrg(u.ID) { |
| 53 | - s.store.Follow(u.ID, owner.ID, !s.store.Follows(u.ID, owner.ID)) | |
| 53 | + on := !s.store.Follows(u.ID, owner.ID) | |
| 54 | + if on && s.store.Blocked(owner.ID, u.ID) { | |
| 55 | + s.fail(w, r, 403, "You cannot follow this account") | |
| 56 | + return | |
| 57 | + } | |
| 58 | + s.store.Follow(u.ID, owner.ID, on) | |
| 54 | 59 | } |
| 55 | 60 | http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther) |
| 56 | 61 | } |
forge/issues.go modified
| @@ -186,6 +186,14 @@ func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo) | ||
| 186 | 186 | s.fail(w, r, 403, "This repository is archived") |
| 187 | 187 | return |
| 188 | 188 | } |
| 189 | + if s.shunned(repo, user) { | |
| 190 | + s.fail(w, r, 403, "You cannot open issues here") | |
| 191 | + return | |
| 192 | + } | |
| 193 | + if !s.store.ThreadBudget(user.ID) { | |
| 194 | + s.fail(w, r, 429, "Too many new issues and pull requests, try again later") | |
| 195 | + return | |
| 196 | + } | |
| 189 | 197 | issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body"))) |
| 190 | 198 | if err != nil { |
| 191 | 199 | w.WriteHeader(400) |
| @@ -233,6 +241,10 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s | ||
| 233 | 241 | s.fail(w, r, 403, "This repository is archived") |
| 234 | 242 | return |
| 235 | 243 | } |
| 244 | + if s.shunned(repo, user) { | |
| 245 | + s.fail(w, r, 403, "You cannot comment here") | |
| 246 | + return | |
| 247 | + } | |
| 236 | 248 | if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil { |
| 237 | 249 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 238 | 250 | return |
forge/moderation.go added
| @@ -0,0 +1,393 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "net/url" | |
| 8 | + "os" | |
| 9 | + "strconv" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const moderationSchema = ` | |
| 15 | +create table if not exists blocks ( | |
| 16 | + user integer not null references users(id) on delete cascade, | |
| 17 | + blocked integer not null references users(id) on delete cascade, | |
| 18 | + created integer not null, | |
| 19 | + primary key (user, blocked) | |
| 20 | +); | |
| 21 | +create table if not exists reports ( | |
| 22 | + id integer primary key, | |
| 23 | + reporter integer not null references users(id) on delete cascade, | |
| 24 | + kind text not null, | |
| 25 | + target text not null, | |
| 26 | + reason text not null, | |
| 27 | + state text not null default 'open', | |
| 28 | + handler integer not null default 0, | |
| 29 | + created integer not null | |
| 30 | +); | |
| 31 | +create index if not exists reports_state on reports(state, created); | |
| 32 | +create table if not exists guestpushes ( | |
| 33 | + user integer not null references users(id) on delete cascade, | |
| 34 | + created integer not null | |
| 35 | +); | |
| 36 | +create index if not exists guestpushes_user on guestpushes(user, created); | |
| 37 | +` | |
| 38 | + | |
| 39 | +const ( | |
| 40 | + guestPushes = 30 | |
| 41 | + threadsPerHour = 20 | |
| 42 | + reportsPerHour = 5 | |
| 43 | + maxReason = 2000 | |
| 44 | +) | |
| 45 | + | |
| 46 | +var reportKinds = map[string]string{"user": "account", "repo": "repository", "issue": "issue", "pull": "pull request"} | |
| 47 | + | |
| 48 | +type Report struct { | |
| 49 | + ID int64 | |
| 50 | + Reporter User | |
| 51 | + Kind string | |
| 52 | + Target string | |
| 53 | + Reason string | |
| 54 | + State string | |
| 55 | + Created time.Time | |
| 56 | +} | |
| 57 | + | |
| 58 | +func (r Report) Link() string { | |
| 59 | + switch r.Kind { | |
| 60 | + case "user": | |
| 61 | + return "/~" + r.Target | |
| 62 | + case "repo": | |
| 63 | + return "/~" + r.Target | |
| 64 | + case "issue", "pull": | |
| 65 | + repo, num, _ := strings.Cut(r.Target, "#") | |
| 66 | + section := map[string]string{"issue": "issues", "pull": "pulls"}[r.Kind] | |
| 67 | + return "/~" + repo + "/" + section + "/" + num | |
| 68 | + } | |
| 69 | + return "/" | |
| 70 | +} | |
| 71 | + | |
| 72 | +func (r Report) Owner() string { | |
| 73 | + name, _, _ := strings.Cut(r.Target, "/") | |
| 74 | + name, _, _ = strings.Cut(name, "#") | |
| 75 | + return name | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (s *Store) IsBanned(id int64) bool { | |
| 79 | + var banned bool | |
| 80 | + s.db.QueryRow(`select banned from users where id = ?`, id).Scan(&banned) | |
| 81 | + return banned | |
| 82 | +} | |
| 83 | + | |
| 84 | +func (s *Store) PagesAllowed(id int64) bool { | |
| 85 | + var on bool | |
| 86 | + s.db.QueryRow(`select pages from users where id = ?`, id).Scan(&on) | |
| 87 | + return on | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (s *Store) SetPages(name string, on bool) error { | |
| 91 | + res, err := s.db.Exec(`update users set pages = ? where name = ?`, on, name) | |
| 92 | + if err != nil { | |
| 93 | + return err | |
| 94 | + } | |
| 95 | + if n, _ := res.RowsAffected(); n == 0 { | |
| 96 | + return ErrNotFound | |
| 97 | + } | |
| 98 | + return nil | |
| 99 | +} | |
| 100 | + | |
| 101 | +func (s *Store) Blocked(owner, actor int64) bool { | |
| 102 | + var n int | |
| 103 | + s.db.QueryRow(`select count(*) from blocks where user = ? and blocked = ?`, owner, actor).Scan(&n) | |
| 104 | + return n > 0 | |
| 105 | +} | |
| 106 | + | |
| 107 | +func (s *Store) SetBlock(user, blocked int64, on bool) error { | |
| 108 | + if !on { | |
| 109 | + _, err := s.db.Exec(`delete from blocks where user = ? and blocked = ?`, user, blocked) | |
| 110 | + return err | |
| 111 | + } | |
| 112 | + tx, err := s.db.Begin() | |
| 113 | + if err != nil { | |
| 114 | + return err | |
| 115 | + } | |
| 116 | + defer tx.Rollback() | |
| 117 | + if _, err := tx.Exec(`insert or ignore into blocks(user, blocked, created) values (?, ?, ?)`, user, blocked, time.Now().Unix()); err != nil { | |
| 118 | + return err | |
| 119 | + } | |
| 120 | + if _, err := tx.Exec(`delete from follows where (follower = ?1 and followee = ?2) or (follower = ?2 and followee = ?1)`, blocked, user); err != nil { | |
| 121 | + return err | |
| 122 | + } | |
| 123 | + if _, err := tx.Exec(`delete from stars where user = ? and repo in (select id from repos where owner = ?)`, blocked, user); err != nil { | |
| 124 | + return err | |
| 125 | + } | |
| 126 | + return tx.Commit() | |
| 127 | +} | |
| 128 | + | |
| 129 | +func (s *Store) BlockedUsers(user int64) []User { | |
| 130 | + rows, err := s.db.Query(`select u.id, u.name, u.created from blocks b join users u on u.id = b.blocked where b.user = ? order by u.name`, user) | |
| 131 | + if err != nil { | |
| 132 | + return nil | |
| 133 | + } | |
| 134 | + defer rows.Close() | |
| 135 | + var out []User | |
| 136 | + for rows.Next() { | |
| 137 | + var u User | |
| 138 | + var created int64 | |
| 139 | + if rows.Scan(&u.ID, &u.Name, &created) == nil { | |
| 140 | + u.Created = time.Unix(created, 0) | |
| 141 | + out = append(out, u) | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return out | |
| 145 | +} | |
| 146 | + | |
| 147 | +func (s *Store) ThreadBudget(user int64) bool { | |
| 148 | + since := time.Now().Add(-time.Hour).Unix() | |
| 149 | + var n int | |
| 150 | + s.db.QueryRow(`select (select count(*) from issues where author = ?1 and created > ?2) + (select count(*) from pulls where author = ?1 and created > ?2)`, user, since).Scan(&n) | |
| 151 | + return n < threadsPerHour | |
| 152 | +} | |
| 153 | + | |
| 154 | +func (s *Store) GuestPushBudget(user int64) bool { | |
| 155 | + var n int | |
| 156 | + s.db.QueryRow(`select count(*) from guestpushes where user = ? and created > ?`, user, time.Now().Add(-time.Hour).Unix()).Scan(&n) | |
| 157 | + return n < guestPushes | |
| 158 | +} | |
| 159 | + | |
| 160 | +func (s *Store) GuestPushed(user int64) { | |
| 161 | + now := time.Now() | |
| 162 | + s.db.Exec(`insert into guestpushes(user, created) values (?, ?)`, user, now.Unix()) | |
| 163 | + s.db.Exec(`delete from guestpushes where created < ?`, now.Add(-time.Hour).Unix()) | |
| 164 | +} | |
| 165 | + | |
| 166 | +func (s *Store) AddReport(reporter int64, kind, target, reason string) error { | |
| 167 | + if _, ok := reportKinds[kind]; !ok { | |
| 168 | + return errors.New("Unknown report type") | |
| 169 | + } | |
| 170 | + reason = strings.TrimSpace(reason) | |
| 171 | + if reason == "" { | |
| 172 | + return errors.New("Say what is wrong") | |
| 173 | + } | |
| 174 | + if len(reason) > maxReason { | |
| 175 | + return fmt.Errorf("Keep the report under %d characters", maxReason) | |
| 176 | + } | |
| 177 | + var recent, dup int | |
| 178 | + s.db.QueryRow(`select count(*) from reports where reporter = ? and created > ?`, reporter, time.Now().Add(-time.Hour).Unix()).Scan(&recent) | |
| 179 | + if recent >= reportsPerHour { | |
| 180 | + return errors.New("Too many reports, try again later") | |
| 181 | + } | |
| 182 | + s.db.QueryRow(`select count(*) from reports where reporter = ? and kind = ? and target = ? and state = 'open'`, reporter, kind, target).Scan(&dup) | |
| 183 | + if dup > 0 { | |
| 184 | + return errors.New("You already reported this, an admin will look at it") | |
| 185 | + } | |
| 186 | + _, err := s.db.Exec(`insert into reports(reporter, kind, target, reason, created) values (?, ?, ?, ?, ?)`, reporter, kind, target, reason, time.Now().Unix()) | |
| 187 | + return err | |
| 188 | +} | |
| 189 | + | |
| 190 | +func (s *Store) Reports(state string) []Report { | |
| 191 | + rows, err := s.db.Query(`select r.id, r.kind, r.target, r.reason, r.state, r.created, u.id, u.name from reports r join users u on u.id = r.reporter where r.state = ? order by r.created desc limit 200`, state) | |
| 192 | + if err != nil { | |
| 193 | + return nil | |
| 194 | + } | |
| 195 | + defer rows.Close() | |
| 196 | + var out []Report | |
| 197 | + for rows.Next() { | |
| 198 | + var rep Report | |
| 199 | + var created int64 | |
| 200 | + if rows.Scan(&rep.ID, &rep.Kind, &rep.Target, &rep.Reason, &rep.State, &created, &rep.Reporter.ID, &rep.Reporter.Name) == nil { | |
| 201 | + rep.Created = time.Unix(created, 0) | |
| 202 | + out = append(out, rep) | |
| 203 | + } | |
| 204 | + } | |
| 205 | + return out | |
| 206 | +} | |
| 207 | + | |
| 208 | +func (s *Store) Report(id int64) (Report, error) { | |
| 209 | + var rep Report | |
| 210 | + var created int64 | |
| 211 | + err := s.db.QueryRow(`select id, kind, target, reason, state, created from reports where id = ?`, id).Scan(&rep.ID, &rep.Kind, &rep.Target, &rep.Reason, &rep.State, &created) | |
| 212 | + rep.Created = time.Unix(created, 0) | |
| 213 | + return rep, err | |
| 214 | +} | |
| 215 | + | |
| 216 | +func (s *Store) CloseReports(kind, target, state string, handler int64) { | |
| 217 | + s.db.Exec(`update reports set state = ?, handler = ? where kind = ? and target = ? and state = 'open'`, state, handler, kind, target) | |
| 218 | +} | |
| 219 | + | |
| 220 | +func (s *Store) OpenReports() int { | |
| 221 | + var n int | |
| 222 | + s.db.QueryRow(`select count(*) from reports where state = 'open'`).Scan(&n) | |
| 223 | + return n | |
| 224 | +} | |
| 225 | + | |
| 226 | +func (s *Server) shunned(repo Repo, u *User) bool { | |
| 227 | + return u != nil && s.store.Blocked(repo.Owner.ID, u.ID) | |
| 228 | +} | |
| 229 | + | |
| 230 | +func (s *Server) reportTarget(kind, target string, viewer *User) (string, bool) { | |
| 231 | + switch kind { | |
| 232 | + case "user": | |
| 233 | + u, err := s.store.UserByName(target) | |
| 234 | + return u.Name, err == nil | |
| 235 | + case "repo", "issue", "pull": | |
| 236 | + path, num, hasNum := strings.Cut(target, "#") | |
| 237 | + owner, name, ok := strings.Cut(path, "/") | |
| 238 | + if !ok || hasNum != (kind != "repo") { | |
| 239 | + return "", false | |
| 240 | + } | |
| 241 | + repo, err := s.store.RepoByPath(owner, name) | |
| 242 | + if err != nil || (repo.Private && !s.store.CanWrite(repo, viewer)) { | |
| 243 | + return "", false | |
| 244 | + } | |
| 245 | + bare := repo.Owner.Name + "/" + repo.Name | |
| 246 | + if kind == "repo" { | |
| 247 | + return bare, true | |
| 248 | + } | |
| 249 | + n, err := strconv.ParseInt(num, 10, 64) | |
| 250 | + if err != nil { | |
| 251 | + return "", false | |
| 252 | + } | |
| 253 | + if kind == "issue" { | |
| 254 | + _, err = s.store.Issue(repo.ID, n) | |
| 255 | + } else { | |
| 256 | + _, err = s.store.Pull(repo, n) | |
| 257 | + } | |
| 258 | + return fmt.Sprintf("%s#%d", bare, n), err == nil | |
| 259 | + } | |
| 260 | + return "", false | |
| 261 | +} | |
| 262 | + | |
| 263 | +func (s *Server) report(w http.ResponseWriter, r *http.Request) { | |
| 264 | + u := s.require(w, r) | |
| 265 | + if u == nil { | |
| 266 | + return | |
| 267 | + } | |
| 268 | + kind := r.FormValue("kind") | |
| 269 | + target, ok := s.reportTarget(kind, strings.TrimPrefix(strings.TrimSpace(r.FormValue("target")), "~"), u) | |
| 270 | + if !ok { | |
| 271 | + s.fail(w, r, 404, "Nothing to report there") | |
| 272 | + return | |
| 273 | + } | |
| 274 | + data := map[string]any{"Kind": kind, "Noun": reportKinds[kind], "Target": target, "Reason": r.FormValue("reason")} | |
| 275 | + if r.Method == "POST" { | |
| 276 | + if err := s.store.AddReport(u.ID, kind, target, r.FormValue("reason")); err != nil { | |
| 277 | + w.WriteHeader(400) | |
| 278 | + s.render(w, r, "report", page{Title: "Report", User: u, Error: err.Error(), Data: data}) | |
| 279 | + return | |
| 280 | + } | |
| 281 | + data["Sent"] = true | |
| 282 | + } | |
| 283 | + s.render(w, r, "report", page{Title: "Report", User: u, Data: data}) | |
| 284 | +} | |
| 285 | + | |
| 286 | +func (s *Server) block(w http.ResponseWriter, r *http.Request) { | |
| 287 | + u := s.require(w, r) | |
| 288 | + if u == nil { | |
| 289 | + return | |
| 290 | + } | |
| 291 | + target, err := s.store.UserByName(strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")) | |
| 292 | + if err != nil || target.ID == u.ID || s.store.IsOrg(target.ID) { | |
| 293 | + s.fail(w, r, 400, "You cannot block that account") | |
| 294 | + return | |
| 295 | + } | |
| 296 | + on := r.FormValue("action") != "unblock" | |
| 297 | + if err := s.store.SetBlock(u.ID, target.ID, on); err != nil { | |
| 298 | + s.fail(w, r, 500, err.Error()) | |
| 299 | + return | |
| 300 | + } | |
| 301 | + back := "/~" + target.Name | |
| 302 | + if r.FormValue("back") == "settings" { | |
| 303 | + back = "/settings#blocked" | |
| 304 | + } | |
| 305 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 306 | +} | |
| 307 | + | |
| 308 | +func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, fail func(string)) bool { | |
| 309 | + id, _ := strconv.ParseInt(r.FormValue("report"), 10, 64) | |
| 310 | + rep, err := s.store.Report(id) | |
| 311 | + if err != nil { | |
| 312 | + fail("No such report") | |
| 313 | + return false | |
| 314 | + } | |
| 315 | + switch r.FormValue("do") { | |
| 316 | + case "dismiss": | |
| 317 | + s.store.CloseReports(rep.Kind, rep.Target, "dismissed", admin.ID) | |
| 318 | + case "resolve": | |
| 319 | + s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) | |
| 320 | + case "ban": | |
| 321 | + name := s.reportAuthor(rep) | |
| 322 | + if name == "" { | |
| 323 | + fail("Could not tell who wrote that") | |
| 324 | + return false | |
| 325 | + } | |
| 326 | + if name == admin.Name { | |
| 327 | + fail("You cannot ban yourself") | |
| 328 | + return false | |
| 329 | + } | |
| 330 | + if err := s.store.SetBanned(name, true); err != nil { | |
| 331 | + fail("No such account") | |
| 332 | + return false | |
| 333 | + } | |
| 334 | + s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) | |
| 335 | + case "pages": | |
| 336 | + if err := s.store.SetPages(rep.Owner(), false); err != nil { | |
| 337 | + fail("No such account") | |
| 338 | + return false | |
| 339 | + } | |
| 340 | + s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) | |
| 341 | + case "delete": | |
| 342 | + if rep.Kind != "repo" || r.FormValue("confirm") != rep.Target { | |
| 343 | + fail("Type the repository path to confirm deletion") | |
| 344 | + return false | |
| 345 | + } | |
| 346 | + owner, name, _ := strings.Cut(rep.Target, "/") | |
| 347 | + repo, err := s.store.RepoByPath(owner, name) | |
| 348 | + if err != nil { | |
| 349 | + fail("That repository is already gone") | |
| 350 | + return false | |
| 351 | + } | |
| 352 | + if err := s.store.DeleteRepo(repo.ID); err != nil { | |
| 353 | + fail(err.Error()) | |
| 354 | + return false | |
| 355 | + } | |
| 356 | + os.RemoveAll(s.repoDir(repo)) | |
| 357 | + s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID) | |
| 358 | + default: | |
| 359 | + fail("Unknown action") | |
| 360 | + return false | |
| 361 | + } | |
| 362 | + return true | |
| 363 | +} | |
| 364 | + | |
| 365 | +func (s *Server) reportAuthor(rep Report) string { | |
| 366 | + if rep.Kind == "user" || rep.Kind == "repo" { | |
| 367 | + return rep.Owner() | |
| 368 | + } | |
| 369 | + path, num, _ := strings.Cut(rep.Target, "#") | |
| 370 | + owner, name, _ := strings.Cut(path, "/") | |
| 371 | + repo, err := s.store.RepoByPath(owner, name) | |
| 372 | + if err != nil { | |
| 373 | + return "" | |
| 374 | + } | |
| 375 | + n, err := strconv.ParseInt(num, 10, 64) | |
| 376 | + if err != nil { | |
| 377 | + return "" | |
| 378 | + } | |
| 379 | + if rep.Kind == "issue" { | |
| 380 | + if issue, err := s.store.Issue(repo.ID, n); err == nil { | |
| 381 | + return issue.Author.Name | |
| 382 | + } | |
| 383 | + return "" | |
| 384 | + } | |
| 385 | + if pull, err := s.store.Pull(repo, n); err == nil { | |
| 386 | + return pull.Author.Name | |
| 387 | + } | |
| 388 | + return "" | |
| 389 | +} | |
| 390 | + | |
| 391 | +func reportURL(kind, target string) string { | |
| 392 | + return "/report?kind=" + kind + "&target=" + url.QueryEscape(target) | |
| 393 | +} | |
forge/pull.go modified
| @@ -500,6 +500,14 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 500 | 500 | retry("This repository is archived") |
| 501 | 501 | return |
| 502 | 502 | } |
| 503 | + if s.shunned(repo, user) { | |
| 504 | + retry("You cannot open pull requests here") | |
| 505 | + return | |
| 506 | + } | |
| 507 | + if !s.store.ThreadBudget(user.ID) { | |
| 508 | + retry("Too many new issues and pull requests, try again later") | |
| 509 | + return | |
| 510 | + } | |
| 503 | 511 | src, branch, err := s.resolveSource(repo, user, r.FormValue("source")) |
| 504 | 512 | if err != nil { |
| 505 | 513 | retry(err.Error()) |
| @@ -571,6 +579,14 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 571 | 579 | return |
| 572 | 580 | } |
| 573 | 581 | back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number) |
| 582 | + if repo.Archived { | |
| 583 | + s.fail(w, r, 403, "This repository is archived") | |
| 584 | + return | |
| 585 | + } | |
| 586 | + if action != "close" && s.shunned(repo, user) { | |
| 587 | + s.fail(w, r, 403, "You cannot take part here") | |
| 588 | + return | |
| 589 | + } | |
| 574 | 590 | switch action { |
| 575 | 591 | case "comment": |
| 576 | 592 | if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil { |
| @@ -830,6 +846,15 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 830 | 846 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 831 | 847 | return |
| 832 | 848 | } |
| 849 | + if s.shunned(repo, user) { | |
| 850 | + s.fail(w, r, 403, "You cannot fork this repository") | |
| 851 | + return | |
| 852 | + } | |
| 853 | + if s.uploads.blocked("create:" + user.Name) { | |
| 854 | + s.fail(w, r, 429, "Too many new repositories, try again later") | |
| 855 | + return | |
| 856 | + } | |
| 857 | + s.uploads.fail("create:" + user.Name) | |
| 833 | 858 | fork, err := s.store.ForkRepo(*user, repo) |
| 834 | 859 | if err != nil { |
| 835 | 860 | s.fail(w, r, 400, err.Error()) |
forge/store.go modified
| @@ -82,6 +82,7 @@ var migrations = []string{ | ||
| 82 | 82 | `alter table users add column admin integer not null default 0`, |
| 83 | 83 | `alter table repos add column pinned integer not null default 0`, |
| 84 | 84 | `alter table users add column theme text not null default ''`, |
| 85 | + `alter table users add column pages integer not null default 1`, | |
| 85 | 86 | } |
| 86 | 87 | |
| 87 | 88 | const extraSchema = ` |
| @@ -277,7 +278,7 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 277 | 278 | if _, err := db.Exec(schema); err != nil { |
| 278 | 279 | return nil, err |
| 279 | 280 | } |
| 280 | - for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema} { | |
| 281 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema} { | |
| 281 | 282 | if _, err := db.Exec(ddl); err != nil { |
| 282 | 283 | return nil, err |
| 283 | 284 | } |
forge/web.go modified
| @@ -295,6 +295,9 @@ func (s *Server) handler() http.Handler { | ||
| 295 | 295 | mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar) |
| 296 | 296 | mux.HandleFunc("/admin", s.admin) |
| 297 | 297 | mux.HandleFunc("/orgs/new", s.newOrg) |
| 298 | + mux.HandleFunc("GET /report", s.report) | |
| 299 | + mux.HandleFunc("POST /report", s.report) | |
| 300 | + mux.HandleFunc("POST /block", s.block) | |
| 298 | 301 | mux.HandleFunc("/", s.route) |
| 299 | 302 | cop := http.NewCrossOriginProtection() |
| 300 | 303 | return secure(s.throttle(cop.Handler(mux))) |
| @@ -450,6 +453,11 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 450 | 453 | } |
| 451 | 454 | owner = org |
| 452 | 455 | } |
| 456 | + if s.uploads.blocked("create:" + u.Name) { | |
| 457 | + s.fail(w, r, 429, "Too many new repositories, try again later") | |
| 458 | + return | |
| 459 | + } | |
| 460 | + s.uploads.fail("create:" + u.Name) | |
| 453 | 461 | name := strings.TrimSpace(r.FormValue("name")) |
| 454 | 462 | repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")) |
| 455 | 463 | if err != nil { |
| @@ -613,7 +621,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { | ||
| 613 | 621 | } |
| 614 | 622 | used := usage(filepath.Join(s.root, "repos", u.Name)) |
| 615 | 623 | s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"), |
| 616 | - 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")}}) | |
| 624 | + 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"), "Blocked": s.store.BlockedUsers(u.ID)}}) | |
| 617 | 625 | } |
| 618 | 626 | |
| 619 | 627 | func (s *Server) security(w http.ResponseWriter, r *http.Request) { |
| @@ -941,6 +949,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 941 | 949 | data["Orgs"] = orgs |
| 942 | 950 | data["Followers"], data["Following"] = s.store.FollowCounts(owner.ID) |
| 943 | 951 | data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID) |
| 952 | + data["Blocking"] = user != nil && s.store.Blocked(user.ID, owner.ID) | |
| 944 | 953 | intro := s.profileReadme(owner) |
| 945 | 954 | data["Intro"] = intro |
| 946 | 955 | if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name { |
web/static/style.css modified
| @@ -556,3 +556,6 @@ fieldset.links{border:0;padding:0;margin:0 0 .8rem;display:flex;flex-direction:c | ||
| 556 | 556 | fieldset.links legend{padding:0;margin-bottom:.25rem} |
| 557 | 557 | @media(max-width:700px){.inboxpanel{position:fixed;top:3.2rem;left:1rem;right:1rem;width:auto;max-width:none}} |
| 558 | 558 | @media(max-width:800px){.btn{white-space:nowrap}.dashhead{flex-wrap:wrap;row-gap:.4rem}.dashhead h3{white-space:nowrap}.columns.pullbody,.columns.browse,.dash:has(>section+section){grid-template-columns:minmax(0,1fr)}nav.tabs a{padding:.25rem .6rem}table.tree .last,table.tree .mode{display:none}table.tree td.name{white-space:normal;overflow-wrap:anywhere}main a,.crumbs{overflow-wrap:anywhere;min-width:0}.userhead{flex-wrap:wrap;gap:.2rem 1rem}.userhead nav.tabs.small{margin-bottom:0;max-width:100%}.titlerow{flex-wrap:wrap;gap:.3rem .8rem}.titlerow .btn{white-space:nowrap}.columns.user,.columns.orgbody{grid-template-columns:minmax(0,1fr)}.heatwrap{overflow-x:auto;direction:rtl;padding-bottom:2.4rem;margin-bottom:-2.4rem}.heatwrap>.heat{direction:ltr;min-width:620px}.heat .c:hover::after,.heat .c:focus::after{bottom:auto;top:calc(100% + 6px)}} |
| 559 | +.modlinks{margin:.6em 0;text-align:center} | |
| 560 | +button.link{background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:underline} | |
| 561 | +pre.reason{white-space:pre-wrap;word-break:break-word} | |
web/templates/admin.tmpl modified
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$d := .Data}} |
| 3 | -<nav class="tabs small admintabs">{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active"{{end}}>{{.Title}}</a>{{end}}</nav> | |
| 3 | +<nav class="tabs small admintabs">{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active"{{end}}>{{.Title}}{{if and (eq .Key "reports") $d.OpenReports}} <span class="count">{{$d.OpenReports}}</span>{{end}}</a>{{end}}</nav> | |
| 4 | 4 | {{if $d.Saved}}<p class="notice">Saved.</p>{{end}} |
| 5 | 5 | |
| 6 | 6 | {{if eq $d.Section "overview"}} |
| @@ -64,4 +64,20 @@ | ||
| 64 | 64 | <h3 class="rule">Usage</h3> |
| 65 | 65 | <table class="keys">{{range $d.Owners}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td>{{size .Used}}</td><td class="muted">{{if $d.Quota}}{{.Pct}}% of {{$d.QuotaText}}{{end}}</td></tr>{{else}}<tr><td class="muted">No repositories yet.</td></tr>{{end}}</table> |
| 66 | 66 | {{end}} |
| 67 | +{{if eq $d.Section "reports"}} | |
| 68 | +<h3 class="rule">Reports</h3> | |
| 69 | +<nav class="tabs small"><a href="/admin?s=reports"{{if eq $d.State "open"}} class="active"{{end}}>open</a><a href="/admin?s=reports&state=resolved"{{if eq $d.State "resolved"}} class="active"{{end}}>resolved</a><a href="/admin?s=reports&state=dismissed"{{if eq $d.State "dismissed"}} class="active"{{end}}>dismissed</a></nav> | |
| 70 | +{{range $d.Reports}}<div class="event"> | |
| 71 | +<h4>{{.Kind}} <a href="{{.Link}}">{{.Target}}</a></h4> | |
| 72 | +<p class="muted small">reported by <a href="/~{{.Reporter.Name}}">~{{.Reporter.Name}}</a> {{ago .Created}}</p> | |
| 73 | +<pre class="reason">{{.Reason}}</pre> | |
| 74 | +{{if eq .State "open"}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="reports"><input type="hidden" name="action" value="moderate"><input type="hidden" name="report" value="{{.ID}}"> | |
| 75 | +<button class="btn small" name="do" value="dismiss">Dismiss</button> | |
| 76 | +<button class="btn small" name="do" value="resolve">Mark resolved</button> | |
| 77 | +<button class="btn small danger" name="do" value="ban">Ban author</button> | |
| 78 | +<button class="btn small danger" name="do" value="pages">Disable pages for ~{{.Owner}}</button> | |
| 79 | +</form> | |
| 80 | +{{if eq .Kind "repo"}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="reports"><input type="hidden" name="action" value="moderate"><input type="hidden" name="report" value="{{.ID}}"><input name="confirm" placeholder="{{.Target}}" aria-label="Type {{.Target}} to confirm"><button class="btn small danger" name="do" value="delete">Delete repository</button></form>{{end}}{{end}} | |
| 81 | +</div>{{else}}<p class="info">Nothing here.</p>{{end}} | |
| 82 | +{{end}} | |
| 67 | 83 | {{end}} |
web/templates/issue.tmpl modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$i := .Data.Issue}} |
| 4 | 4 | <div class="pulltitle"> |
| 5 | 5 | <h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</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> | |
| 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}}{{if .User}} · <a class="muted" href="/report?kind=issue&target={{$p.Repo.Path}}%23{{$i.Number}}">report</a>{{end}}</p> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| 9 | 9 | <div class="main"> |
web/templates/layout.tmpl modified
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | </header> |
| 38 | 38 | {{with .Data}}{{with index . "Page"}} |
| 39 | 39 | <div class="repohead"> |
| 40 | -<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}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{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> | |
| 40 | +<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}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{if and .User (not .IsOwner)}}<a class="muted small reportlink" href="/report?kind=repo&target={{.Repo.Path}}">report</a>{{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> | |
| 41 | 41 | <div class="tabbar"><nav class="tabs inner"> |
| 42 | 42 | <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a> |
| 43 | 43 | {{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}}>branches</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> |
web/templates/pull.tmpl modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$pr := .Data.Pull}} |
| 4 | 4 | <div class="pulltitle"> |
| 5 | 5 | <h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3> |
| 6 | -<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p> | |
| 6 | +<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| 9 | 9 | <div class="main"> |
web/templates/report.tmpl added
| @@ -0,0 +1,13 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$d := .Data}} | |
| 3 | +<div class="panel narrow"> | |
| 4 | +<h3>Report {{$d.Noun}}</h3> | |
| 5 | +{{if $d.Sent}}<p>Thanks, the report about <code>{{$d.Target}}</code> went to the admins.</p><p><a href="/">Back to the front page</a></p>{{else}} | |
| 6 | +<form method="post" action="/report" class="stack"> | |
| 7 | +<input type="hidden" name="kind" value="{{$d.Kind}}"><input type="hidden" name="target" value="{{$d.Target}}"> | |
| 8 | +<p class="muted">Reporting <code>{{$d.Target}}</code>. Only admins see this.</p> | |
| 9 | +<label>What is wrong <textarea name="reason" rows="6" maxlength="2000" required autofocus>{{$d.Reason}}</textarea></label> | |
| 10 | +<div class="actions"><button class="btn primary">Send report ▸</button></div> | |
| 11 | +</form>{{end}} | |
| 12 | +</div> | |
| 13 | +{{end}} | |
web/templates/settings.tmpl modified
| @@ -46,4 +46,7 @@ | ||
| 46 | 46 | <label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{$p.Bio}}</textarea></label> |
| 47 | 47 | <div class="actions"><button class="btn primary">Save changes ▸</button></div> |
| 48 | 48 | </form> |
| 49 | +<h3 class="rule" id="blocked">Blocked accounts</h3> | |
| 50 | +<p class="muted small">Blocked accounts cannot open issues or pull requests, comment, star or fork your repositories, or follow you.</p> | |
| 51 | +{{if .Data.Blocked}}<table class="keys">{{range .Data.Blocked}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/block" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="back" value="settings"><button class="btn small" name="action" value="unblock">Unblock</button></form></td></tr>{{end}}</table>{{else}}<p class="muted">Nobody.</p>{{end}} | |
| 49 | 52 | {{end}} |
web/templates/user.tmpl modified
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | <img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt=""> |
| 11 | 11 | <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div> |
| 12 | 12 | {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}} |
| 13 | -{{if and .User (not $own)}}<form method="post" action="/~{{.Data.Owner.Name}}/follow" class="followform"><button class="btn block{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form>{{end}} | |
| 13 | +{{if and .User (not $own)}}<form method="post" action="/~{{.Data.Owner.Name}}/follow" class="followform"><button class="btn block{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form> | |
| 14 | +<div class="small muted modlinks"><form method="post" action="/block" class="inline"><input type="hidden" name="name" value="{{.Data.Owner.Name}}">{{if .Data.Blocking}}<button class="link" name="action" value="unblock">unblock</button>{{else}}<button class="link" name="action" value="block">block</button>{{end}}</form> · <a href="/report?kind=user&target={{.Data.Owner.Name}}">report</a></div>{{end}} | |
| 14 | 15 | <ul class="facts"> |
| 15 | 16 | <li class="followcount"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5.5" r="2.4"/><path d="M1.8 13.5c.5-2.4 2.2-3.7 4.2-3.7s3.7 1.3 4.2 3.7"/><path d="M10.4 3.3a2.4 2.4 0 0 1 0 4.4M12 9.9c1.2.5 2 1.7 2.2 3.6"/></svg><a href="/~{{.Data.Owner.Name}}/followers">{{.Data.Followers}} follower{{if ne .Data.Followers 1}}s{{end}}</a><span class="sep">·</span><a href="/~{{.Data.Owner.Name}}/following">{{.Data.Following}} following</a></li> |
| 16 | 17 | {{if $p.Location}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 15s5-4.6 5-8.5A5 5 0 0 0 3 6.5C3 10.4 8 15 8 15z"/><circle cx="8" cy="6.5" r="1.8"/></svg>{{$p.Location}}</li>{{end}} |