Commit 161f585ca2
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -407,6 +407,7 @@ client has no use for one (krz/gitbay#57). | ||
| 407 | 407 | | API token list, revoke | yes | yes | no | |
| 408 | 408 | | API token revoke with what it created | yes | no | no | |
| 409 | 409 | | account export bundle | yes | yes | n/a | |
| 410 | | delete your own account | yes | yes | no | | |
| 410 | 411 | | profile set | yes | yes | yes | |
| 411 | 412 | | write the profile about | yes | yes | yes | |
| 412 | 413 | | request a login link | n/a | yes | n/a | |
.gitbay/wiki/Terms.org +4 −3
| @@ -39,9 +39,10 @@ Reports go to the address on the [[Abuse][abuse page]]. | ||
| 39 | 39 | |
| 40 | 40 | =account export= writes your profile, repositories, issues and merge |
| 41 | 41 | requests as a bundle another gitbay instance can replay |
| 42 | (=gitbay migrate=), and every repository can be cloned. To have | |
| 43 | the account deleted, mail the abuse address from the account's primary | |
| 44 | email. | |
| 42 | (=gitbay migrate=), and every repository can be cloned. | |
| 43 | =gitbay auth delete= (or the bottom of =/settings=) deletes the account | |
| 44 | seven days after its mailed link is opened; see [[Users][the user | |
| 45 | guide]] for what goes and what stays. | |
| 45 | 46 | |
| 46 | 47 | If gitbay.org ever shuts down, every account with a verified address |
| 47 | 48 | gets notice and time to export first. |
.gitbay/wiki/Users.org +20
| @@ -366,6 +366,26 @@ in =$$= follows before a blank line. MathML typed as raw HTML is | ||
| 366 | 366 | stripped like any other markup the sanitizer does not allow. The CLI |
| 367 | 367 | shows the source. |
| 368 | 368 | |
| 369 | * Deleting your account | |
| 370 | ||
| 371 | #+begin_src sh | |
| 372 | gitbay auth delete --confirm <username> # mails a link to your primary address | |
| 373 | gitbay auth delete --cancel # withdraw it before the link is opened | |
| 374 | #+end_src | |
| 375 | ||
| 376 | The same is at the bottom of =/settings=. Nothing changes until the | |
| 377 | mailed link is opened (it works for 24 hours, and needs a verified | |
| 378 | address). Opening it disables the account at once and deletes it seven | |
| 379 | days later: its repositories, snippets, keys, addresses and tokens go. | |
| 380 | Issues, merge requests, comments and reviews it wrote on other people's | |
| 381 | repositories stay, under the name =ghost=. Signing in during the seven | |
| 382 | days — on the web, or any command over SSH with a full-scope key — | |
| 383 | cancels; git-, read-, runner-scoped keys and API tokens are refused and | |
| 384 | cannot cancel. The only admin of an organization is refused until | |
| 385 | another admin exists or the organization is deleted. Export first | |
| 386 | (=gitbay auth export=) to keep a copy. The username is free again after | |
| 387 | the purge. | |
| 388 | ||
| 369 | 389 | * Organizations |
| 370 | 390 | |
| 371 | 391 | Orgs share the owner namespace with users and own repositories at |
cmd/gitbay/main.go +1
| @@ -530,6 +530,7 @@ func authCmd() *cobra.Command { | ||
| 530 | 530 | ) |
| 531 | 531 | return group("auth", "whoami, SSH and PGP keys, email, API tokens", |
| 532 | 532 | pass("export", passOpts{server: []string{"account", "export"}}), |
| 533 | pass("delete", passOpts{server: []string{"account", "delete"}}), | |
| 533 | 534 | tokens, |
| 534 | 535 | pass("whoami", passOpts{server: []string{"whoami"}}), |
| 535 | 536 | group("keys", "manage SSH keys", |
cmd/gitbay/serverpath_test.go +1
| @@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | // help print the CLI's path (#267). A change to this list is deliberate. |
| 18 | 18 | func TestServerPathMismatches(t *testing.T) { |
| 19 | 19 | want := []string{ |
| 20 | "auth delete", | |
| 20 | 21 | "auth email add", "auth email list", "auth email primary", |
| 21 | 22 | "auth email remove", "auth email verify", |
| 22 | 23 | "auth export", |
cmd/gitbay/summaries_gen.go +1
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | package main |
| 4 | 4 | |
| 5 | 5 | var summaries = map[string]string{ |
| 6 | "account delete": "delete your account: mails a link, then purges seven days after it is opened", | |
| 6 | 7 | "account export": "write your account bundle (profile, repos, issues, MRs) as JSON", |
| 7 | 8 | "account import-bundle": "replay an account bundle (see gitbay migrate)", |
| 8 | 9 | "admin email verify": "mark an address verified by admin assertion", |
cmd/gitbayd/main.go +26
| @@ -242,6 +242,7 @@ func serveCmd() *cobra.Command { | ||
| 242 | 242 | if d := cfg.Registration.PendingExpiryDuration(); d > 0 { |
| 243 | 243 | go reapPending(whCtx, st, d) |
| 244 | 244 | } |
| 245 | go purgeDeletions(whCtx, st, cfg) | |
| 245 | 246 | go sweep(whCtx, st, cfg) |
| 246 | 247 | go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL, |
| 247 | 248 | RepoDir: func(owner, name string) string { |
| @@ -654,6 +655,31 @@ func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) { | ||
| 654 | 655 | } |
| 655 | 656 | } |
| 656 | 657 | |
| 658 | // purgeDeletions deletes accounts whose deletion grace has passed, | |
| 659 | // hourly and once at start, on the same tick override as reapPending. | |
| 660 | func purgeDeletions(ctx context.Context, st *store.Store, cfg config.Config) { | |
| 661 | tick := time.Hour | |
| 662 | if v := os.Getenv("GITBAY_REAP_TICK"); v != "" { | |
| 663 | if d, err := time.ParseDuration(v); err == nil { | |
| 664 | tick = d | |
| 665 | } | |
| 666 | } | |
| 667 | t := time.NewTicker(tick) | |
| 668 | defer t.Stop() | |
| 669 | for { | |
| 670 | if purged, err := control.PurgeDueAccounts(cfg, st, time.Now()); err != nil { | |
| 671 | slog.Error("purging deleted accounts", "err", err) | |
| 672 | } else if len(purged) > 0 { | |
| 673 | slog.Info("purged deleted accounts", "users", purged) | |
| 674 | } | |
| 675 | select { | |
| 676 | case <-ctx.Done(): | |
| 677 | return | |
| 678 | case <-t.C: | |
| 679 | } | |
| 680 | } | |
| 681 | } | |
| 682 | ||
| 657 | 683 | // pushPerQueue is how many pushes one principal may have waiting: half |
| 658 | 684 | // the queue, at least one. |
| 659 | 685 | func pushPerQueue(queue int) int { |
docs/specs/2026-10-02-account-delete-design.md added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | # Account deletion | |
| 2 | ||
| 3 | Closes #322. An account deletes itself. Today only `admin user delete` | |
| 4 | exists, and it refuses an account that anchors issues, merge requests, | |
| 5 | comments, reviews or an org with no other admin. | |
| 6 | ||
| 7 | ## Decision | |
| 8 | ||
| 9 | `account delete` is a write on every surface. It takes the typed | |
| 10 | username, mails a confirmation link to the primary verified address, | |
| 11 | and on confirmation marks the account for deletion seven days out. | |
| 12 | During those seven days the account is refused everywhere except the | |
| 13 | two ways a person signs in, either of which cancels. After seven days | |
| 14 | a reaper purges it: owned content goes, authored content on other | |
| 15 | owners' repositories moves to a `ghost` account. | |
| 16 | ||
| 17 | ## States | |
| 18 | ||
| 19 | | state | set by | SSH (full scope) | SSH (other scopes), API tokens | web session | web login link | | |
| 20 | |---|---|---|---|---|---| | |
| 21 | | requested | `account delete --confirm <name>` | normal | normal | normal | normal | | |
| 22 | | scheduled | the mailed link | cancels, then normal | refused | ended | cancels, then normal | | |
| 23 | | purged | the reaper | no account | no account | no account | no account | | |
| 24 | ||
| 25 | - *requested* is a row in `account_deletions` (user_id, token_hash, | |
| 26 | requested_at, expires_at; the link lives 24 hours). Nothing about the | |
| 27 | account changes. A second request replaces the first. | |
| 28 | - *scheduled* sets `users.delete_after` (now + 7 days) and ends every | |
| 29 | web session. A full-scope SSH session or a completed login link clears | |
| 30 | `delete_after`, is audited `account.delete.cancelled`, and prints or | |
| 31 | shows "deletion of your account was cancelled". Runner-, read- and | |
| 32 | deploy-scoped keys and API tokens are refused with "this account is | |
| 33 | scheduled for deletion on <date>; sign in to cancel" so automation | |
| 34 | cannot cancel by accident. | |
| 35 | - *purged*: the reaper, on the tick that already runs | |
| 36 | `ReapPendingUsers`, takes every account with `delete_after` in the | |
| 37 | past. | |
| 38 | ||
| 39 | ## Purge | |
| 40 | ||
| 41 | One function in `internal/control` (it needs the repository root), run | |
| 42 | from the reaper, in this order: | |
| 43 | ||
| 44 | 1. Re-check the org rule. If the account is now the only admin of an | |
| 45 | org, skip, leave `delete_after` set, audit | |
| 46 | `account.delete.blocked`, and show it in `admin user show`. An | |
| 47 | instance admin resolves it. | |
| 48 | 2. Delete every repository the account owns through `deleteRepo`, the | |
| 49 | same path as `repo delete` (open MRs sourced from them are marked | |
| 50 | source-gone; the directories go). | |
| 51 | 3. In one transaction: reassign `issues.author_id`, | |
| 52 | `merge_requests.author_id`, `issue_comments.author_id`, | |
| 53 | `mr_comments.author_id`, `mr_diff_comments.author_id` and | |
| 54 | `mr_reviews.reviewer_id` to the ghost; then `DeleteUser`, whose | |
| 55 | anchor check now passes. Everything else is already `CASCADE` (keys, | |
| 56 | emails, tokens, sessions, snippets, memberships, watches, reactions, | |
| 57 | assignments, inbox, review requests) or `SET NULL` (audit actor, | |
| 58 | event actor, signatures, statuses, merged/closed by, releases). | |
| 59 | Grants and the profile backfill row go in the same transaction, as | |
| 60 | `DeleteUser` already does. | |
| 61 | 4. Audit `account.delete.purged` with the username. | |
| 62 | ||
| 63 | The username is free again after the purge. User ids are never reused | |
| 64 | (#306). | |
| 65 | ||
| 66 | ## The ghost | |
| 67 | ||
| 68 | A real `users` row named `ghost`, created by the first purge that needs | |
| 69 | it and flagged `users.ghost = 1`: it has no keys, emails or sessions, | |
| 70 | cannot be signed in to, own anything, be granted access, or be deleted. | |
| 71 | `ghost` is added to `internal/policy/names.go` so nobody can register | |
| 72 | it. If an instance already has a real account named `ghost`, the purge | |
| 73 | refuses with a message telling the operator to rename it; gitbay.org | |
| 74 | has none. Its profile page reads "This account stands in for deleted | |
| 75 | users." Content shows `ghost` as its author, the way GitHub shows it. | |
| 76 | ||
| 77 | ## Surfaces | |
| 78 | ||
| 79 | - `account delete --confirm <username>`: refuses a mismatched name | |
| 80 | (exit 2), an account with no verified address (exit 4, "add and | |
| 81 | verify an address first"), and the only admin of an org (exit 4, | |
| 82 | naming the orgs). Prints where the mail went and suggests | |
| 83 | `account export`. | |
| 84 | - `account delete --cancel`: clears a request or a schedule (the | |
| 85 | scheduled case is reachable only from a full-scope key, which already | |
| 86 | cancels on connect; this exists for the requested state). | |
| 87 | - Web: a "Delete account" section at the bottom of `/settings` with | |
| 88 | the `confirmfield` partial, posting the same command. The mailed link | |
| 89 | opens `/settings/delete?token=<token>`, a page that names the purge date | |
| 90 | and has one button; the GET changes nothing. | |
| 91 | - The mail names the purge date, says how to cancel, and suggests | |
| 92 | `account export`. | |
| 93 | - `admin user show` reports `delete_after` and a blocked purge. | |
| 94 | ||
| 95 | ## Migration | |
| 96 | ||
| 97 | `account_deletions`; `users.delete_after TEXT`; `users.ghost INTEGER | |
| 98 | NOT NULL DEFAULT 0`. | |
| 99 | ||
| 100 | ## Docs | |
| 101 | ||
| 102 | Parity row; Users wiki page (a "Deleting your account" section); | |
| 103 | `/privacy` text; Terms wiki page's "Leaving" section, which today says | |
| 104 | to mail the operator. | |
| 105 | ||
| 106 | ## Not doing | |
| 107 | ||
| 108 | - Deleting authored content on other owners' repositories. Decided on | |
| 109 | #322: it moves to the ghost. | |
| 110 | - An admin-initiated version with the grace period. `admin user delete` | |
| 111 | stays as it is. | |
e2e/accountdelete_test.go added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/url" | |
| 6 | "os" | |
| 7 | "regexp" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | // Self-service deletion (#322): a request over SSH mails a link, the link | |
| 13 | // schedules the purge and disables the account, a narrower key is refused | |
| 14 | // and cannot cancel, and a full-scope key cancels by signing in. | |
| 15 | func TestAccountDeleteFlow(t *testing.T) { | |
| 16 | t.Parallel() | |
| 17 | smtp := startFakeSMTP(t) | |
| 18 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 19 | "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", | |
| 20 | smtp.addr)) | |
| 21 | key := inst.newKey(t, "erin") | |
| 22 | inst.admin(t, "admin", "user", "create", "erin", "--key", key+".pub", | |
| 23 | "--email", "erin@example.test", "--verified") | |
| 24 | gitKey := inst.newKey(t, "erin-git") | |
| 25 | pub, err := os.ReadFile(gitKey + ".pub") | |
| 26 | if err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | if _, errOut, code := inst.ssh(t, key, string(pub), "keys", "add", "--scope", "git"); code != 0 { | |
| 30 | t.Fatalf("keys add: %s", errOut) | |
| 31 | } | |
| 32 | ||
| 33 | if _, errOut, code := inst.ssh(t, key, "", "account", "delete", "--confirm", "erin"); code != 0 { | |
| 34 | t.Fatalf("account delete: exit %d %s", code, errOut) | |
| 35 | } | |
| 36 | mail := smtp.waitFor(t, "erin@example.test", "/settings/delete?token=") | |
| 37 | link := regexp.MustCompile(`/settings/delete\?token=[A-Za-z0-9_-]+`).FindString(mail) | |
| 38 | if link == "" { | |
| 39 | t.Fatalf("no link in mail:\n%s", mail) | |
| 40 | } | |
| 41 | ||
| 42 | browser := newBrowser(t) | |
| 43 | if status, body := browserGet(t, browser, inst.base()+link); status != 200 || !strings.Contains(body, "Delete erin") { | |
| 44 | t.Fatalf("GET link: %d", status) | |
| 45 | } | |
| 46 | if _, _, code := inst.ssh(t, key, "", "whoami"); code != 0 { | |
| 47 | t.Fatal("opening the page changed the account") | |
| 48 | } | |
| 49 | if status, body := browserPost(t, browser, inst.base()+link, url.Values{}); status != 200 || !strings.Contains(body, "Deletion scheduled") { | |
| 50 | t.Fatalf("POST link: %d\n%s", status, body) | |
| 51 | } | |
| 52 | ||
| 53 | if _, errOut, code := inst.ssh(t, gitKey, "", "whoami"); code != 4 || !strings.Contains(errOut, "scheduled for deletion") { | |
| 54 | t.Fatalf("git key while scheduled: exit %d %s", code, errOut) | |
| 55 | } | |
| 56 | if out := inst.admin(t, "admin", "user", "show", "erin", "--json"); !strings.Contains(out, `"delete_after"`) { | |
| 57 | t.Fatalf("show lacks the schedule:\n%s", out) | |
| 58 | } | |
| 59 | if _, errOut, code := inst.ssh(t, key, "", "whoami"); code != 0 || !strings.Contains(errOut, "deletion of your account was cancelled") { | |
| 60 | t.Fatalf("full key while scheduled: exit %d %s", code, errOut) | |
| 61 | } | |
| 62 | if out := inst.admin(t, "admin", "user", "show", "erin", "--json"); strings.Contains(out, `"delete_after"`) || !strings.Contains(out, `"state":"active"`) { | |
| 63 | t.Fatalf("not restored:\n%s", out) | |
| 64 | } | |
| 65 | if _, errOut, _ := inst.ssh(t, gitKey, "", "whoami"); strings.Contains(errOut, "scheduled for deletion") { | |
| 66 | t.Fatal("git key still told the account is scheduled after the cancel") | |
| 67 | } | |
| 68 | } | |
internal/control/accountdelete.go added +198
| @@ -0,0 +1,198 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "strings" | |
| 8 | "time" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/backuplock" | |
| 11 | "gitbay.org/gitbay/internal/config" | |
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | ) | |
| 15 | ||
| 16 | // DeletionGrace is how long a confirmed deletion waits before the purge. | |
| 17 | // Signing in during it cancels. | |
| 18 | const DeletionGrace = 7 * 24 * time.Hour | |
| 19 | ||
| 20 | // deletionLinkTTL is how long the mailed confirmation link works. | |
| 21 | const deletionLinkTTL = 24 * time.Hour | |
| 22 | ||
| 23 | func init() { | |
| 24 | register(Command{Path: []string{"account", "delete"}, | |
| 25 | NeedsRecentSignIn: true, | |
| 26 | Summary: "delete your account: mails a link, then purges seven days after it is opened", | |
| 27 | Usage: "account delete --confirm <username> | --cancel", | |
| 28 | Flags: []Flag{ | |
| 29 | {"--confirm", "<username>", "your username, typed out", ""}, | |
| 30 | {"--cancel", "", "withdraw a request that has not been confirmed", ""}, | |
| 31 | }, | |
| 32 | Examples: []string{"account delete --confirm alice"}, | |
| 33 | Run: runAccountDelete}) | |
| 34 | } | |
| 35 | ||
| 36 | func runAccountDelete(c *Ctx, args []string) int { | |
| 37 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--confirm"}, Bools: []string{"--cancel"}, | |
| 38 | Usage: "account delete --confirm <username> | --cancel"}) | |
| 39 | if err != nil { | |
| 40 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 41 | } | |
| 42 | if f.Has("--cancel") { | |
| 43 | had, err := c.Store.CancelAccountDeletion(c.User.ID) | |
| 44 | if err != nil { | |
| 45 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 46 | } | |
| 47 | if !had { | |
| 48 | return c.fail(protocol.ExitNotFound, "no deletion is pending for %s", c.User.Username) | |
| 49 | } | |
| 50 | c.Store.Audit(c.User.ID, "account.delete.cancelled", map[string]any{"user": c.User.Username}) | |
| 51 | return c.emit(map[string]any{"user": c.User.Username, "cancelled": true}, func(w io.Writer) { | |
| 52 | fmt.Fprintf(w, "deletion of %s cancelled\n", c.User.Username) | |
| 53 | }) | |
| 54 | } | |
| 55 | if f.Value("--confirm") != c.User.Username { | |
| 56 | return c.fail(protocol.ExitUsage, "type your username to confirm: account delete --confirm %s", c.User.Username) | |
| 57 | } | |
| 58 | if c.User.IsAdmin { | |
| 59 | if n, err := c.Store.OtherActiveAdmins(c.User.ID); err != nil { | |
| 60 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 61 | } else if n == 0 { | |
| 62 | return c.fail(protocol.ExitDenied, "you are the instance's only admin; promote another account first") | |
| 63 | } | |
| 64 | } | |
| 65 | orgs, err := c.Store.SoleAdminOrgs(c.User.ID) | |
| 66 | if err != nil { | |
| 67 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 68 | } | |
| 69 | if len(orgs) > 0 { | |
| 70 | return c.fail(protocol.ExitDenied, "you are the only admin of %s; add another admin or delete the organization first", | |
| 71 | strings.Join(orgs, ", ")) | |
| 72 | } | |
| 73 | address, err := c.Store.PreferredVerifiedEmail(c.User.ID) | |
| 74 | if err != nil || address == "" { | |
| 75 | return c.fail(protocol.ExitDenied, "deletion is confirmed by mail; add and verify an address first (email add)") | |
| 76 | } | |
| 77 | token, hash, err := store.NewToken() | |
| 78 | if err != nil { | |
| 79 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 80 | } | |
| 81 | if err := c.Store.RequestAccountDeletion(c.User.ID, hash, deletionLinkTTL); err != nil { | |
| 82 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 83 | } | |
| 84 | host := siteHost(c.Cfg) | |
| 85 | body := fmt.Sprintf( | |
| 86 | "Someone (hopefully you) asked to delete the account %s on %s.\n\n"+ | |
| 87 | "To go ahead, open this link within 24 hours:\n\n %s/settings/delete?token=%s\n\n"+ | |
| 88 | "Confirming disables the account at once. Seven days later it is deleted:\n"+ | |
| 89 | "its repositories, snippets, keys and addresses go, and what it wrote on\n"+ | |
| 90 | "other people's repositories stays under the name \"ghost\".\n\n"+ | |
| 91 | "Signing in during those seven days, on the web or over SSH with a\n"+ | |
| 92 | "full-scope key, cancels the deletion.\n\n"+ | |
| 93 | "To keep a copy first: ssh git@%s account export > bundle.json\n\n"+ | |
| 94 | "If this wasn't you, ignore this mail. Nothing has changed on the account.\n", | |
| 95 | c.User.Username, host, strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), token, host) | |
| 96 | if err := c.Store.EnqueueMail(address, "delete your account on "+host, body); err != nil { | |
| 97 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 98 | } | |
| 99 | c.Store.Audit(c.User.ID, "account.delete.requested", map[string]any{"user": c.User.Username}) | |
| 100 | return c.emit(map[string]any{"user": c.User.Username, "mailed": address}, func(w io.Writer) { | |
| 101 | fmt.Fprintf(w, "mailed a confirmation link to %s; it works for 24 hours\n", address) | |
| 102 | fmt.Fprintf(w, "nothing changes until it is opened. keep a copy first: account export > bundle.json\n") | |
| 103 | }) | |
| 104 | } | |
| 105 | ||
| 106 | // ScheduledRefusal is what a credential that cannot cancel a scheduled | |
| 107 | // deletion is told. | |
| 108 | func ScheduledRefusal(u store.User) string { | |
| 109 | if u.DeleteAfter == store.Purging { | |
| 110 | return "this account is being deleted" | |
| 111 | } | |
| 112 | return fmt.Sprintf("this account is scheduled for deletion at %s; sign in on the web or over SSH with a full-scope key to cancel", u.DeleteAfter) | |
| 113 | } | |
| 114 | ||
| 115 | // CancelScheduledDeletion is what signing in does to an account scheduled | |
| 116 | // for deletion: the schedule goes and the account is enabled. It reports | |
| 117 | // whether there was one. | |
| 118 | func CancelScheduledDeletion(st *store.Store, u *store.User, how string) bool { | |
| 119 | if u.DeleteAfter == "" { | |
| 120 | return false | |
| 121 | } | |
| 122 | if had, err := st.CancelAccountDeletion(u.ID); err != nil || !had { | |
| 123 | return false | |
| 124 | } | |
| 125 | st.Audit(u.ID, "account.delete.cancelled", map[string]any{"user": u.Username, "by": how}) | |
| 126 | u.Disabled, u.DeleteAfter = false, "" | |
| 127 | return true | |
| 128 | } | |
| 129 | ||
| 130 | // PurgeDueAccounts deletes every account whose grace period has passed. | |
| 131 | // An account that became the only admin of an org since it was scheduled | |
| 132 | // is skipped and audited; an instance admin resolves it. One account's | |
| 133 | // failure does not hold up the others; it is retried on the next tick. | |
| 134 | func PurgeDueAccounts(cfg config.Config, st *store.Store, now time.Time) ([]string, error) { | |
| 135 | due, err := st.DueDeletions(now) | |
| 136 | if err != nil || len(due) == 0 { | |
| 137 | return nil, err | |
| 138 | } | |
| 139 | var purged []string | |
| 140 | var errs []error | |
| 141 | for _, u := range due { | |
| 142 | if u.DeleteAfter != store.Purging { | |
| 143 | if orgs, err := st.SoleAdminOrgs(u.ID); err != nil { | |
| 144 | errs = append(errs, err) | |
| 145 | continue | |
| 146 | } else if len(orgs) > 0 { | |
| 147 | st.Audit(0, "account.delete.blocked", map[string]any{"user": u.Username, "orgs": orgs}) | |
| 148 | continue | |
| 149 | } | |
| 150 | } | |
| 151 | // The claim is what a cancel races: once it holds, signing in | |
| 152 | // no longer cancels, and before it the purge has touched nothing. | |
| 153 | if ok, err := st.ClaimDeletion(u.ID, now); err != nil { | |
| 154 | errs = append(errs, err) | |
| 155 | continue | |
| 156 | } else if !ok { | |
| 157 | continue | |
| 158 | } | |
| 159 | if err := purgeAccount(cfg, st, u); err != nil { | |
| 160 | errs = append(errs, fmt.Errorf("purging %s: %w", u.Username, err)) | |
| 161 | continue | |
| 162 | } | |
| 163 | st.Audit(0, "account.delete.purged", map[string]any{"user": u.Username}) | |
| 164 | purged = append(purged, u.Username) | |
| 165 | } | |
| 166 | return purged, errors.Join(errs...) | |
| 167 | } | |
| 168 | ||
| 169 | func purgeAccount(cfg config.Config, st *store.Store, u store.User) error { | |
| 170 | ghost, err := st.EnsureGhost() | |
| 171 | if err != nil { | |
| 172 | return err | |
| 173 | } | |
| 174 | repos, err := st.ListReposForOwner("user", u.ID) | |
| 175 | if err != nil { | |
| 176 | return err | |
| 177 | } | |
| 178 | if len(repos) > 0 { | |
| 179 | release, err := backuplock.TryShared(cfg.Server.Root) | |
| 180 | if err != nil { | |
| 181 | return err | |
| 182 | } | |
| 183 | for _, r := range repos { | |
| 184 | if err := removeRepo(st, cfg.Server.Root, r); err != nil { | |
| 185 | release() | |
| 186 | return err | |
| 187 | } | |
| 188 | } | |
| 189 | release() | |
| 190 | } | |
| 191 | if err := st.ReassignToGhost(u.ID, ghost); err != nil { | |
| 192 | return err | |
| 193 | } | |
| 194 | return st.DeleteUser(u.ID) | |
| 195 | } | |
| 196 | ||
| 197 | // errGhost refuses an admin action on the ghost account. | |
| 198 | var errGhost = errors.New("ghost stands in for deleted accounts and cannot be changed") | |
internal/control/accountdelete_test.go added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | "time" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/config" | |
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | ) | |
| 15 | ||
| 16 | // runAs dispatches argv as u and returns the exit code and stderr. | |
| 17 | func runAs(st *store.Store, u store.User, root string, argv ...string) (int, string) { | |
| 18 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} | |
| 19 | c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader("")} | |
| 20 | c.Cfg.Server.Root = root | |
| 21 | c.Cfg.Server.SiteURL = "https://forge.test/" | |
| 22 | c.Cfg.Limits.WriteRate = -1 | |
| 23 | return Dispatch(c, argv), errOut.String() | |
| 24 | } | |
| 25 | ||
| 26 | func deleteFixture(t *testing.T) (*store.Store, string, store.User, store.User) { | |
| 27 | t.Helper() | |
| 28 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 29 | if err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | t.Cleanup(func() { st.Close() }) | |
| 33 | if err := st.MigrateUp(); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | root := t.TempDir() | |
| 37 | user := func(name string) store.User { | |
| 38 | id, err := st.CreateUser(name, false) | |
| 39 | if err != nil { | |
| 40 | t.Fatal(err) | |
| 41 | } | |
| 42 | u, _ := st.UserByID(id) | |
| 43 | return u | |
| 44 | } | |
| 45 | return st, root, user("alice"), user("bob") | |
| 46 | } | |
| 47 | ||
| 48 | // account delete refuses a mistyped name, an account with no verified | |
| 49 | // address, and the only admin of an org; otherwise it mails a link and | |
| 50 | // changes nothing else. | |
| 51 | func TestAccountDeleteRequest(t *testing.T) { | |
| 52 | st, root, _, bob := deleteFixture(t) | |
| 53 | if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "verify an address") { | |
| 54 | t.Fatalf("no address: exit %d %q", code, errOut) | |
| 55 | } | |
| 56 | if err := st.AddEmail(bob.ID, "bob@example.test", "smtp", true); err != nil { | |
| 57 | t.Fatal(err) | |
| 58 | } | |
| 59 | if code, _ := runAs(st, bob, root, "account", "delete", "--confirm", "bobb"); code != protocol.ExitUsage { | |
| 60 | t.Fatalf("mistyped name: exit %d", code) | |
| 61 | } | |
| 62 | // The only instance admin is refused. | |
| 63 | soleAdmin := bob | |
| 64 | soleAdmin.IsAdmin = true | |
| 65 | st.DB.Exec("UPDATE users SET is_admin = 1 WHERE id = ?", bob.ID) | |
| 66 | if code, errOut := runAs(st, soleAdmin, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "only admin") { | |
| 67 | t.Fatalf("sole instance admin: exit %d %q", code, errOut) | |
| 68 | } | |
| 69 | st.DB.Exec("UPDATE users SET is_admin = 0 WHERE id = ?", bob.ID) | |
| 70 | if code, _ := runAs(st, bob, root, "org", "create", "acme"); code != 0 { | |
| 71 | t.Fatal("org create") | |
| 72 | } | |
| 73 | if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "only admin of acme") { | |
| 74 | t.Fatalf("sole org admin: exit %d %q", code, errOut) | |
| 75 | } | |
| 76 | if _, err := st.DB.Exec("DELETE FROM orgs WHERE name = 'acme'"); err != nil { | |
| 77 | t.Fatal(err) | |
| 78 | } | |
| 79 | if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != 0 { | |
| 80 | t.Fatalf("request: exit %d %q", code, errOut) | |
| 81 | } | |
| 82 | var body string | |
| 83 | if err := st.DB.QueryRow("SELECT body FROM notifications WHERE recipient = 'bob@example.test'").Scan(&body); err != nil || | |
| 84 | !strings.Contains(body, "https://forge.test/settings/delete?token=") { | |
| 85 | t.Fatalf("mail: %v %q", err, body) | |
| 86 | } | |
| 87 | if u, _ := st.UserByID(bob.ID); u.Disabled || u.DeleteAfter != "" { | |
| 88 | t.Fatal("a request alone changed the account") | |
| 89 | } | |
| 90 | if code, _ := runAs(st, bob, root, "account", "delete", "--cancel"); code != 0 { | |
| 91 | t.Fatal("cancel") | |
| 92 | } | |
| 93 | if code, _ := runAs(st, bob, root, "account", "delete", "--cancel"); code != protocol.ExitNotFound { | |
| 94 | t.Fatalf("second cancel: exit %d", code) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | // A confirmed deletion disables the account; the purge removes its | |
| 99 | // repositories and the account, and moves what it wrote elsewhere to the | |
| 100 | // ghost. Cancelling restores the account, and an admin decision clears | |
| 101 | // the schedule. | |
| 102 | func TestPurgeDueAccounts(t *testing.T) { | |
| 103 | st, root, alice, bob := deleteFixture(t) | |
| 104 | runAs(st, alice, root, "repo", "create", "alice/app") | |
| 105 | if code, errOut := runAs(st, bob, root, "repo", "create", "bob/own"); code != 0 { | |
| 106 | t.Fatalf("bob repo: %s", errOut) | |
| 107 | } | |
| 108 | app, _ := st.RepoByPath("alice/app") | |
| 109 | issue, err := st.CreateIssue(app.ID, bob.ID, "from bob", "", "md") | |
| 110 | if err != nil { | |
| 111 | t.Fatal(err) | |
| 112 | } | |
| 113 | ||
| 114 | schedule := func(u store.User, after time.Time) store.User { | |
| 115 | t.Helper() | |
| 116 | _, hash, _ := store.NewToken() | |
| 117 | if err := st.RequestAccountDeletion(u.ID, hash, time.Hour); err != nil { | |
| 118 | t.Fatal(err) | |
| 119 | } | |
| 120 | s, err := st.ConfirmAccountDeletion(hash, after) | |
| 121 | if err != nil { | |
| 122 | t.Fatal(err) | |
| 123 | } | |
| 124 | return s | |
| 125 | } | |
| 126 | ||
| 127 | // Cancelling (what signing in does) restores the account. | |
| 128 | s := schedule(bob, time.Now().Add(time.Hour)) | |
| 129 | if code, errOut := runAs(st, s, root, "whoami"); code != protocol.ExitDenied || !strings.Contains(errOut, "scheduled for deletion") { | |
| 130 | t.Fatalf("scheduled account: exit %d %q", code, errOut) | |
| 131 | } | |
| 132 | if !CancelScheduledDeletion(st, &s, "test") || s.Disabled { | |
| 133 | t.Fatal("cancel did not restore the account") | |
| 134 | } | |
| 135 | // A link opened after an admin suspended the account schedules | |
| 136 | // nothing. | |
| 137 | _, hash, _ := store.NewToken() | |
| 138 | st.RequestAccountDeletion(bob.ID, hash, time.Hour) | |
| 139 | st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob.ID) | |
| 140 | if _, err := st.ConfirmAccountDeletion(hash, time.Now()); err == nil { | |
| 141 | t.Fatal("a suspended account was scheduled") | |
| 142 | } | |
| 143 | st.DB.Exec("UPDATE users SET disabled = 0 WHERE id = ?", bob.ID) | |
| 144 | // Once the purge has claimed an account, signing in cannot cancel. | |
| 145 | s = schedule(bob, time.Now().Add(-time.Minute)) | |
| 146 | if ok, err := st.ClaimDeletion(bob.ID, time.Now()); !ok || err != nil { | |
| 147 | t.Fatalf("claim: %v %v", ok, err) | |
| 148 | } | |
| 149 | s, _ = st.UserByID(bob.ID) | |
| 150 | if CancelScheduledDeletion(st, &s, "test") { | |
| 151 | t.Fatal("cancelled a purge in progress") | |
| 152 | } | |
| 153 | st.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ?", bob.ID) | |
| 154 | // An admin disabling a scheduled account keeps it, disabled. | |
| 155 | schedule(bob, time.Now().Add(-time.Minute)) | |
| 156 | if err := st.SetUserDisabled(bob.ID, true); err != nil { | |
| 157 | t.Fatal(err) | |
| 158 | } | |
| 159 | if due, _ := st.DueDeletions(time.Now()); len(due) != 0 { | |
| 160 | t.Fatal("an admin's disable left the purge scheduled") | |
| 161 | } | |
| 162 | st.SetUserDisabled(bob.ID, false) | |
| 163 | ||
| 164 | schedule(bob, time.Now().Add(-time.Minute)) | |
| 165 | cfg := config.Default() | |
| 166 | cfg.Server.Root = root | |
| 167 | purged, err := PurgeDueAccounts(cfg, st, time.Now()) | |
| 168 | if err != nil || len(purged) != 1 || purged[0] != "bob" { | |
| 169 | t.Fatalf("purge: %v %v", purged, err) | |
| 170 | } | |
| 171 | if _, err := st.UserByID(bob.ID); err == nil { | |
| 172 | t.Fatal("bob still exists") | |
| 173 | } | |
| 174 | if _, err := os.Stat(RepoDir(root, "bob", "own")); !os.IsNotExist(err) { | |
| 175 | t.Fatalf("bob/own directory: %v", err) | |
| 176 | } | |
| 177 | ghost, err := st.UserByUsername("ghost") | |
| 178 | if err != nil || !ghost.Ghost || !ghost.Disabled { | |
| 179 | t.Fatalf("ghost: %+v %v", ghost, err) | |
| 180 | } | |
| 181 | var author int64 | |
| 182 | st.DB.QueryRow("SELECT author_id FROM issues WHERE id = ?", issue).Scan(&author) | |
| 183 | if author != ghost.ID { | |
| 184 | t.Fatalf("issue author %d, want ghost %d", author, ghost.ID) | |
| 185 | } | |
| 186 | admin := alice | |
| 187 | admin.IsAdmin = true | |
| 188 | if code, _ := runAs(st, admin, root, "admin", "user", "enable", "ghost"); code != protocol.ExitDenied { | |
| 189 | t.Fatalf("admin enable ghost: exit %d", code) | |
| 190 | } | |
| 191 | ||
| 192 | // The only admin of an org is skipped and stays scheduled. | |
| 193 | carolID, _ := st.CreateUser("carol", false) | |
| 194 | carol, _ := st.UserByID(carolID) | |
| 195 | runAs(st, carol, root, "org", "create", "solo") | |
| 196 | schedule(carol, time.Now().Add(-time.Minute)) | |
| 197 | if purged, err := PurgeDueAccounts(cfg, st, time.Now()); err != nil || len(purged) != 0 { | |
| 198 | t.Fatalf("sole admin purged: %v %v", purged, err) | |
| 199 | } | |
| 200 | if u, err := st.UserByID(carolID); err != nil || u.DeleteAfter == "" { | |
| 201 | t.Fatalf("carol: %+v %v", u, err) | |
| 202 | } | |
| 203 | } | |
internal/control/admin.go +3
| @@ -251,6 +251,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 251 | 251 | ByteLimit int64 `json:"byte_limit"` // 0 unlimited |
| 252 | 252 | APITokens []tokenOut `json:"api_tokens"` |
| 253 | 253 | WebSessions int64 `json:"web_sessions"` |
| 254 | DeleteAfter string `json:"delete_after,omitempty"` // a scheduled self-deletion | |
| 254 | 255 | } |
| 255 | 256 | d := out{adminUserOut: adminUserRow(row), |
| 256 | 257 | Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}} |
| @@ -298,6 +299,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 298 | 299 | if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil { |
| 299 | 300 | return c.fail(protocol.ExitFailure, "%v", err) |
| 300 | 301 | } |
| 302 | d.DeleteAfter = u.DeleteAfter | |
| 301 | 303 | |
| 302 | 304 | return c.emitView(d, func(w io.Writer) { |
| 303 | 305 | admin := "" |
| @@ -312,6 +314,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 312 | 314 | "last seen", c.when(d.LastSeen), |
| 313 | 315 | "repos", fmt.Sprintf("%d", d.Repos), |
| 314 | 316 | "web sessions", fmt.Sprintf("%d", d.WebSessions), |
| 317 | "deletes at", c.when(d.DeleteAfter), | |
| 315 | 318 | ) |
| 316 | 319 | if len(d.Keys) > 0 { |
| 317 | 320 | v.section("keys") |
internal/control/adminhost.go +3
| @@ -160,6 +160,9 @@ func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) { | ||
| 160 | 160 | } else if err != nil { |
| 161 | 161 | return u, c.fail(protocol.ExitFailure, "%v", err) |
| 162 | 162 | } |
| 163 | if u.Ghost { | |
| 164 | return u, c.fail(protocol.ExitDenied, "%v", errGhost) | |
| 165 | } | |
| 163 | 166 | return u, -1 |
| 164 | 167 | } |
| 165 | 168 | |
internal/control/control.go +3
| @@ -247,6 +247,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int { | ||
| 247 | 247 | // The SSH listener refuses a disabled account before it gets here; the |
| 248 | 248 | // API and the web reach Dispatch directly, so the check lives here too. |
| 249 | 249 | if c.User.Disabled { |
| 250 | if c.User.DeleteAfter != "" { | |
| 251 | return c.fail(protocol.ExitDenied, "%s", ScheduledRefusal(c.User)) | |
| 252 | } | |
| 250 | 253 | return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") |
| 251 | 254 | } |
| 252 | 255 | if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) { |
internal/control/loginlink.go +3 −1
| @@ -69,7 +69,9 @@ func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) err | ||
| 69 | 69 | // read paths — which is the whole of what suspension prevents, and more |
| 70 | 70 | // than pendingAllowed grants an unverified account. Returning nil rather |
| 71 | 71 | // than an error keeps the response identical to a miss. |
| 72 | if user.Disabled || user.Pending { | |
| 72 | // An account scheduled for deletion may still have a link: signing in | |
| 73 | // is how its owner cancels. | |
| 74 | if (user.Disabled && user.DeleteAfter == "") || user.Pending { | |
| 73 | 75 | return nil |
| 74 | 76 | } |
| 75 | 77 | |
internal/control/org.go +3
| @@ -274,6 +274,9 @@ func runOrgMembersAdd(c *Ctx, args []string) int { | ||
| 274 | 274 | if err != nil { |
| 275 | 275 | return c.fail(protocol.ExitFailure, "%v", err) |
| 276 | 276 | } |
| 277 | if target.Ghost { | |
| 278 | return c.fail(protocol.ExitDenied, "%v", errGhost) | |
| 279 | } | |
| 277 | 280 | if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil { |
| 278 | 281 | return c.failErr(err) |
| 279 | 282 | } |
internal/control/reauth_test.go +1 −1
| @@ -107,7 +107,7 @@ func TestNeedsRecentSignInSet(t *testing.T) { | ||
| 107 | 107 | } |
| 108 | 108 | slices.Sort(got) |
| 109 | 109 | want := []string{ |
| 110 | "admin email verify", | |
| 110 | "account delete", "admin email verify", | |
| 111 | 111 | "admin invite", |
| 112 | 112 | "admin repo visibility", |
| 113 | 113 | "admin user create", |
internal/control/repo.go +21 −9
| @@ -703,22 +703,31 @@ func deleteRepo(c *Ctx, repo store.Repo) int { | ||
| 703 | 703 | return lockCode |
| 704 | 704 | } |
| 705 | 705 | defer release() |
| 706 | // Open MRs sourced from this repo keep working (targets own the | |
| 707 | // objects) but must show that the source is gone. | |
| 708 | if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { | |
| 709 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 710 | } | |
| 711 | if err := c.Store.DeleteRepo(repo.ID); err != nil { | |
| 706 | if err := removeRepo(c.Store, c.Cfg.Server.Root, repo); err != nil { | |
| 712 | 707 | return c.fail(protocol.ExitFailure, "%v", err) |
| 713 | 708 | } |
| 714 | if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { | |
| 715 | return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) | |
| 716 | } | |
| 717 | 709 | return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { |
| 718 | 710 | fmt.Fprintf(w, "deleted %s\n", repo.Path()) |
| 719 | 711 | }) |
| 720 | 712 | } |
| 721 | 713 | |
| 714 | // removeRepo is deleteRepo's work without a request: the caller holds | |
| 715 | // off the backup. | |
| 716 | func removeRepo(st *store.Store, root string, repo store.Repo) error { | |
| 717 | // Open MRs sourced from this repo keep working (targets own the | |
| 718 | // objects) but must show that the source is gone. | |
| 719 | if err := st.MarkSourceGoneForRepo(repo.ID); err != nil { | |
| 720 | return err | |
| 721 | } | |
| 722 | if err := st.DeleteRepo(repo.ID); err != nil { | |
| 723 | return err | |
| 724 | } | |
| 725 | if err := os.RemoveAll(RepoDir(root, repo.OwnerName, repo.Name)); err != nil { | |
| 726 | return fmt.Errorf("database row removed but disk cleanup failed: %w", err) | |
| 727 | } | |
| 728 | return nil | |
| 729 | } | |
| 730 | ||
| 722 | 731 | // holdOffBackup keeps a full backup from starting while a repository |
| 723 | 732 | // directory moves or goes, and refuses while one runs: the backup's |
| 724 | 733 | // database snapshot names every repository its walk then archives |
| @@ -743,6 +752,9 @@ func runAccessGrant(c *Ctx, args []string) int { | ||
| 743 | 752 | if err != nil { |
| 744 | 753 | return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) |
| 745 | 754 | } |
| 755 | if target.Ghost { | |
| 756 | return c.fail(protocol.ExitDenied, "%v", errGhost) | |
| 757 | } | |
| 746 | 758 | if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil { |
| 747 | 759 | return c.fail(protocol.ExitFailure, "%v", err) |
| 748 | 760 | } |
internal/httpd/account.go +10
| @@ -243,6 +243,16 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 243 | 243 | return |
| 244 | 244 | } |
| 245 | 245 | back("", "key registered") |
| 246 | case "account-delete": | |
| 247 | if ok, msg := confirmed(r, u.Username); !ok { | |
| 248 | back(msg, "") | |
| 249 | return | |
| 250 | } | |
| 251 | if _, msg, ok := s.runControl(u, []string{"account", "delete", "--confirm", u.Username}); !ok { | |
| 252 | back(msg, "") | |
| 253 | return | |
| 254 | } | |
| 255 | back("", "a deletion link was mailed to your primary address; nothing changes until it is opened") | |
| 246 | 256 | case "key-remove": |
| 247 | 257 | want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")) |
| 248 | 258 | if ok, msg := confirmed(r, want); !ok { |
internal/httpd/accountdelete.go added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "time" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/control" | |
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // accountDeletePage is where the mailed deletion link lands. The token | |
| 12 | // is the authority, as a login link's is, so no session is needed; like | |
| 13 | // the login token it rides the query string. The GET changes nothing; the | |
| 14 | // button posts back to the same URL. | |
| 15 | type accountDeletePage struct { | |
| 16 | basePage | |
| 17 | User string | |
| 18 | Scheduled string | |
| 19 | } | |
| 20 | ||
| 21 | func (s *Server) accountDeleteForm(w http.ResponseWriter, r *http.Request) { | |
| 22 | w.Header().Set("Cache-Control", "no-store") | |
| 23 | page := accountDeletePage{basePage: s.base(r)} | |
| 24 | if u, err := s.st.AccountDeletionUser(store.HashToken(r.URL.Query().Get("token"))); err == nil { | |
| 25 | page.User = u.Username | |
| 26 | } | |
| 27 | s.render(w, "accountdelete.html", page) | |
| 28 | } | |
| 29 | ||
| 30 | func (s *Server) accountDeleteConfirm(w http.ResponseWriter, r *http.Request) { | |
| 31 | w.Header().Set("Cache-Control", "no-store") | |
| 32 | u, err := s.st.ConfirmAccountDeletion(store.HashToken(r.URL.Query().Get("token")), time.Now().Add(control.DeletionGrace)) | |
| 33 | if err != nil { | |
| 34 | s.render(w, "accountdelete.html", accountDeletePage{basePage: s.base(r)}) | |
| 35 | return | |
| 36 | } | |
| 37 | s.st.Audit(u.ID, "account.delete.scheduled", map[string]any{"user": u.Username, "after": u.DeleteAfter}) | |
| 38 | http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite)) | |
| 39 | s.render(w, "accountdelete.html", accountDeletePage{basePage: s.base(r), User: u.Username, Scheduled: u.DeleteAfter}) | |
| 40 | } | |
internal/httpd/accounts.go +7 −1
| @@ -140,7 +140,13 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | ||
| 140 | 140 | // and the session it would create renders every page the account can |
| 141 | 141 | // read. Checking here covers every mint path. The message is the one a |
| 142 | 142 | // bad token gets: a distinct one would confirm the account exists. |
| 143 | if u, err := s.st.UserByID(userID); err != nil || u.Disabled { | |
| 143 | // A login is how the owner of an account scheduled for deletion | |
| 144 | // cancels it. | |
| 145 | u, err := s.st.UserByID(userID) | |
| 146 | if err == nil { | |
| 147 | control.CancelScheduledDeletion(s.st, &u, "web") | |
| 148 | } | |
| 149 | if err != nil || u.Disabled { | |
| 144 | 150 | s.renderLogin(w, badLoginToken, false, "") |
| 145 | 151 | return |
| 146 | 152 | } |
internal/httpd/routes.go +3
| @@ -128,6 +128,9 @@ func (s *Server) Routes() []Route { | ||
| 128 | 128 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 129 | 129 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, |
| 130 | 130 | Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)}, |
| 131 | Route{Method: "GET", Pattern: "/settings/delete", Handler: s.accountDeleteForm}, | |
| 132 | Route{Method: "POST", Pattern: "/settings/delete", Mutating: true, | |
| 133 | Handler: s.checkOrigin(s.accountDeleteConfirm)}, | |
| 131 | 134 | Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)}, |
| 132 | 135 | Route{Method: "POST", Pattern: "/notifications", Mutating: true, |
| 133 | 136 | Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, |
internal/policy/names.go +1
| @@ -20,6 +20,7 @@ var reservedNames = map[string]bool{ | ||
| 20 | 20 | "favicon.svg": true, |
| 21 | 21 | "gitbay": true, // vanity go-import path on gitbay.org |
| 22 | 22 | "gitbay-bot": true, // authors dependency-update issues |
| 23 | "ghost": true, // authors what deleted accounts wrote (#322) | |
| 23 | 24 | "healthz": true, |
| 24 | 25 | "login": true, |
| 25 | 26 | "logout": true, |
internal/sshd/sshd.go +9
| @@ -467,7 +467,16 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | ||
| 467 | 467 | // receive-pack. A nil limiter is no limit. |
| 468 | 468 | func Exec(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string, |
| 469 | 469 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { |
| 470 | // A person signing in with a full-scope key cancels a scheduled | |
| 471 | // deletion; automation on narrower keys is refused and cannot. | |
| 472 | if user.Disabled && user.DeleteAfter != "" && key.Scope == "full" && control.CancelScheduledDeletion(st, &user, "ssh") { | |
| 473 | fmt.Fprintln(stderr, "deletion of your account was cancelled") | |
| 474 | } | |
| 470 | 475 | if user.Disabled { |
| 476 | if user.DeleteAfter != "" { | |
| 477 | fmt.Fprintln(stderr, control.ScheduledRefusal(user)) | |
| 478 | return protocol.ExitDenied | |
| 479 | } | |
| 471 | 480 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") |
| 472 | 481 | return protocol.ExitDenied |
| 473 | 482 | } |
internal/store/accountdelete.go added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "time" | |
| 8 | ) | |
| 9 | ||
| 10 | // ErrGhostNameTaken is returned when a real account holds the name the | |
| 11 | // ghost needs. | |
| 12 | var ErrGhostNameTaken = errors.New(`an account named "ghost" exists and is not the ghost; rename it before an account can be deleted`) | |
| 13 | ||
| 14 | // RequestAccountDeletion records a deletion request waiting on its mailed | |
| 15 | // link. A second request replaces the first. | |
| 16 | func (s *Store) RequestAccountDeletion(userID int64, tokenHash string, ttl time.Duration) error { | |
| 17 | _, err := s.DB.Exec(`INSERT INTO account_deletions (user_id, token_hash, expires_at) VALUES (?, ?, ?) | |
| 18 | ON CONFLICT (user_id) DO UPDATE SET token_hash = excluded.token_hash, | |
| 19 | created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), expires_at = excluded.expires_at`, | |
| 20 | userID, tokenHash, fmtTime(time.Now().Add(ttl))) | |
| 21 | return err | |
| 22 | } | |
| 23 | ||
| 24 | // AccountDeletionUser is the account an unexpired deletion link names. | |
| 25 | func (s *Store) AccountDeletionUser(tokenHash string) (User, error) { | |
| 26 | var userID int64 | |
| 27 | err := s.DB.QueryRow("SELECT user_id FROM account_deletions WHERE token_hash = ? AND expires_at > ?", | |
| 28 | tokenHash, fmtTime(time.Now())).Scan(&userID) | |
| 29 | if errors.Is(err, sql.ErrNoRows) { | |
| 30 | return User{}, ErrNotFound | |
| 31 | } | |
| 32 | if err != nil { | |
| 33 | return User{}, err | |
| 34 | } | |
| 35 | return s.UserByID(userID) | |
| 36 | } | |
| 37 | ||
| 38 | // ConfirmAccountDeletion consumes the link and schedules the purge at | |
| 39 | // after: the account is disabled, its web sessions and login links end, | |
| 40 | // and its open connections close. API tokens stay, refused while the | |
| 41 | // account is disabled, so a cancelled deletion leaves them working. | |
| 42 | func (s *Store) ConfirmAccountDeletion(tokenHash string, after time.Time) (User, error) { | |
| 43 | u, err := s.AccountDeletionUser(tokenHash) | |
| 44 | if err != nil { | |
| 45 | return User{}, err | |
| 46 | } | |
| 47 | // A suspension is an admin's decision; the link cannot turn it into | |
| 48 | // a schedule its owner could then cancel by signing in. | |
| 49 | if u.Disabled { | |
| 50 | return User{}, ErrNotFound | |
| 51 | } | |
| 52 | tx, err := s.DB.Begin() | |
| 53 | if err != nil { | |
| 54 | return User{}, err | |
| 55 | } | |
| 56 | defer tx.Rollback() | |
| 57 | if _, err := tx.Exec("DELETE FROM account_deletions WHERE user_id = ?", u.ID); err != nil { | |
| 58 | return User{}, err | |
| 59 | } | |
| 60 | if _, err := tx.Exec("UPDATE users SET disabled = 1, delete_after = ? WHERE id = ? AND disabled = 0", fmtTime(after), u.ID); err != nil { | |
| 61 | return User{}, err | |
| 62 | } | |
| 63 | for _, table := range []string{"web_sessions", "login_tokens"} { | |
| 64 | if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ?", u.ID); err != nil { | |
| 65 | return User{}, err | |
| 66 | } | |
| 67 | } | |
| 68 | if err := tx.Commit(); err != nil { | |
| 69 | return User{}, err | |
| 70 | } | |
| 71 | s.announce(Revoked{UserID: u.ID}) | |
| 72 | u.Disabled, u.DeleteAfter = true, fmtTime(after) | |
| 73 | return u, nil | |
| 74 | } | |
| 75 | ||
| 76 | // Purging marks users.delete_after while the purge runs. It sorts after | |
| 77 | // every timestamp, so nothing cancels it, and DueDeletions returns it | |
| 78 | // again until the purge completes. | |
| 79 | const Purging = "purging" | |
| 80 | ||
| 81 | // ClaimDeletion marks a due account as being purged. It reports false | |
| 82 | // when a cancel or an admin got there first. | |
| 83 | func (s *Store) ClaimDeletion(userID int64, now time.Time) (bool, error) { | |
| 84 | res, err := s.DB.Exec(`UPDATE users SET delete_after = ? WHERE id = ? AND disabled = 1 | |
| 85 | AND delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)`, Purging, userID, fmtTime(now), Purging) | |
| 86 | if err != nil { | |
| 87 | return false, err | |
| 88 | } | |
| 89 | n, _ := res.RowsAffected() | |
| 90 | return n == 1, nil | |
| 91 | } | |
| 92 | ||
| 93 | // CancelAccountDeletion drops a waiting request and a scheduled purge. | |
| 94 | // It reports whether either existed. | |
| 95 | func (s *Store) CancelAccountDeletion(userID int64) (bool, error) { | |
| 96 | res, err := s.DB.Exec("DELETE FROM account_deletions WHERE user_id = ?", userID) | |
| 97 | if err != nil { | |
| 98 | return false, err | |
| 99 | } | |
| 100 | requested, _ := res.RowsAffected() | |
| 101 | res, err = s.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ? AND delete_after IS NOT NULL AND delete_after != ?", userID, Purging) | |
| 102 | if err != nil { | |
| 103 | return false, err | |
| 104 | } | |
| 105 | scheduled, _ := res.RowsAffected() | |
| 106 | return requested+scheduled > 0, nil | |
| 107 | } | |
| 108 | ||
| 109 | // DueDeletions lists the accounts whose scheduled purge time has passed. | |
| 110 | func (s *Store) DueDeletions(now time.Time) ([]User, error) { | |
| 111 | rows, err := s.DB.Query("SELECT id FROM users WHERE delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)", fmtTime(now), Purging) | |
| 112 | if err != nil { | |
| 113 | return nil, err | |
| 114 | } | |
| 115 | ids, err := scanIDs(rows) | |
| 116 | if err != nil { | |
| 117 | return nil, err | |
| 118 | } | |
| 119 | var out []User | |
| 120 | for _, id := range ids { | |
| 121 | u, err := s.UserByID(id) | |
| 122 | if err != nil { | |
| 123 | return nil, err | |
| 124 | } | |
| 125 | out = append(out, u) | |
| 126 | } | |
| 127 | return out, nil | |
| 128 | } | |
| 129 | ||
| 130 | // SoleAdminOrgs names the organizations where the user is the only admin. | |
| 131 | func (s *Store) SoleAdminOrgs(userID int64) ([]string, error) { | |
| 132 | rows, err := s.DB.Query(`SELECT o.name FROM orgs o JOIN org_members m ON m.org_id = o.id | |
| 133 | WHERE m.user_id = ? AND m.role = 'admin' | |
| 134 | AND NOT EXISTS (SELECT 1 FROM org_members x | |
| 135 | WHERE x.org_id = o.id AND x.role = 'admin' AND x.user_id != m.user_id | |
| 136 | AND x.user_id NOT IN (SELECT id FROM users WHERE ghost = 1)) | |
| 137 | ORDER BY o.name`, userID) | |
| 138 | if err != nil { | |
| 139 | return nil, err | |
| 140 | } | |
| 141 | defer rows.Close() | |
| 142 | var names []string | |
| 143 | for rows.Next() { | |
| 144 | var n string | |
| 145 | if err := rows.Scan(&n); err != nil { | |
| 146 | return nil, err | |
| 147 | } | |
| 148 | names = append(names, n) | |
| 149 | } | |
| 150 | return names, rows.Err() | |
| 151 | } | |
| 152 | ||
| 153 | // OtherActiveAdmins counts instance admins other than the user who can | |
| 154 | // still act. | |
| 155 | func (s *Store) OtherActiveAdmins(userID int64) (int64, error) { | |
| 156 | var n int64 | |
| 157 | err := s.DB.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0 | |
| 158 | AND pending = 0 AND id != ?`, userID).Scan(&n) | |
| 159 | return n, err | |
| 160 | } | |
| 161 | ||
| 162 | // EnsureGhost returns the ghost account's id, creating it on first use. | |
| 163 | // It is disabled and holds no credentials, so nothing can act as it. | |
| 164 | func (s *Store) EnsureGhost() (int64, error) { | |
| 165 | var id int64 | |
| 166 | err := s.DB.QueryRow("SELECT id FROM users WHERE ghost = 1").Scan(&id) | |
| 167 | if err == nil { | |
| 168 | return id, nil | |
| 169 | } | |
| 170 | if !errors.Is(err, sql.ErrNoRows) { | |
| 171 | return 0, err | |
| 172 | } | |
| 173 | if _, err := s.UserByUsername("ghost"); err == nil { | |
| 174 | return 0, ErrGhostNameTaken | |
| 175 | } | |
| 176 | res, err := s.DB.Exec(`INSERT INTO users (username, is_admin, disabled, ghost, description) | |
| 177 | VALUES ('ghost', 0, 1, 1, 'This account stands in for deleted users.')`) | |
| 178 | if err != nil { | |
| 179 | return 0, err | |
| 180 | } | |
| 181 | return res.LastInsertId() | |
| 182 | } | |
| 183 | ||
| 184 | // ReassignToGhost moves what the user wrote on other owners' repositories | |
| 185 | // to the ghost: issues, merge requests, comments, diff comments and | |
| 186 | // reviews, the rows DeleteUser otherwise refuses over. Pending draft | |
| 187 | // comments are deleted and reviews made stale. | |
| 188 | func (s *Store) ReassignToGhost(userID, ghostID int64) error { | |
| 189 | tx, err := s.DB.Begin() | |
| 190 | if err != nil { | |
| 191 | return err | |
| 192 | } | |
| 193 | defer tx.Rollback() | |
| 194 | // Unsubmitted drafts go; reviews stay as text but no longer count | |
| 195 | // toward a merge gate. | |
| 196 | if _, err := tx.Exec("DELETE FROM mr_diff_comments WHERE author_id = ? AND pending = 1", userID); err != nil { | |
| 197 | return err | |
| 198 | } | |
| 199 | if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE reviewer_id = ?", userID); err != nil { | |
| 200 | return err | |
| 201 | } | |
| 202 | for _, col := range []struct{ table, column string }{ | |
| 203 | {"issues", "author_id"}, | |
| 204 | {"merge_requests", "author_id"}, | |
| 205 | {"issue_comments", "author_id"}, | |
| 206 | {"mr_comments", "author_id"}, | |
| 207 | {"mr_diff_comments", "author_id"}, | |
| 208 | {"mr_reviews", "reviewer_id"}, | |
| 209 | } { | |
| 210 | if _, err := tx.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", col.table, col.column, col.column), ghostID, userID); err != nil { | |
| 211 | return fmt.Errorf("reassigning %s: %w", col.table, err) | |
| 212 | } | |
| 213 | } | |
| 214 | return tx.Commit() | |
| 215 | } | |
internal/store/idreuse_test.go +3 −2
| @@ -149,8 +149,9 @@ func TestIDMigrationKeepsRowsAndForeignKeys(t *testing.T) { | ||
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | // The triggers still fire. |
| 152 | alice, err := s.UserByUsername("alice") | |
| 153 | if err != nil { | |
| 152 | // Read by column: the schema here is 0074's, older than User's loader. | |
| 153 | var alice User | |
| 154 | if err := s.DB.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&alice.ID); err != nil { | |
| 154 | 155 | t.Fatal(err) |
| 155 | 156 | } |
| 156 | 157 | if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", alice.ID); err == nil || !strings.Contains(err.Error(), "still owns repositories") { |
internal/store/migrations/0077_account_delete.down.sql added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN ghost; | |
| 2 | ALTER TABLE users DROP COLUMN delete_after; | |
| 3 | DROP TABLE account_deletions; | |
internal/store/migrations/0077_account_delete.up.sql added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | -- Self-service account deletion (#322). A request waits for its mailed | |
| 2 | -- link in account_deletions; a confirmed one sets users.delete_after and | |
| 3 | -- disables the account until the reaper purges it. users.ghost marks the | |
| 4 | -- one account that takes over what deleted accounts wrote. | |
| 5 | CREATE TABLE account_deletions ( | |
| 6 | user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 7 | token_hash TEXT NOT NULL UNIQUE, | |
| 8 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 9 | expires_at TEXT NOT NULL | |
| 10 | ); | |
| 11 | ALTER TABLE users ADD COLUMN delete_after TEXT; | |
| 12 | ALTER TABLE users ADD COLUMN ghost INTEGER NOT NULL DEFAULT 0; | |
internal/store/users.go +24 −8
| @@ -13,7 +13,11 @@ type User struct { | ||
| 13 | 13 | Username string |
| 14 | 14 | IsAdmin bool |
| 15 | 15 | Pending bool // self-registered, email not yet verified |
| 16 | Disabled bool // administratively suspended | |
| 16 | Disabled bool // administratively suspended, or scheduled for deletion | |
| 17 | // DeleteAfter is when a scheduled deletion purges the account, "" | |
| 18 | // when none is scheduled. A scheduled account is also Disabled. | |
| 19 | DeleteAfter string | |
| 20 | Ghost bool // stands in as the author of deleted accounts' content | |
| 17 | 21 | // SignedInAt is when the browser session this user came from was |
| 18 | 22 | // created by a login. Set by WebSessionUser only; zero elsewhere. |
| 19 | 23 | SignedInAt time.Time |
| @@ -137,15 +141,18 @@ func (s *Store) OwnerExists(name string) bool { | ||
| 137 | 141 | |
| 138 | 142 | func (s *Store) UserByUsername(name string) (User, error) { |
| 139 | 143 | var u User |
| 140 | var admin, pending, disabled int | |
| 141 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE username = ?", name). | |
| 142 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled) | |
| 144 | var admin, pending, disabled, ghost int | |
| 145 | var deleteAfter sql.NullString | |
| 146 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled, delete_after, ghost FROM users WHERE username = ?", name). | |
| 147 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled, &deleteAfter, &ghost) | |
| 143 | 148 | if errors.Is(err, sql.ErrNoRows) { |
| 144 | 149 | return u, ErrNotFound |
| 145 | 150 | } |
| 146 | 151 | u.IsAdmin = admin != 0 |
| 147 | 152 | u.Pending = pending != 0 |
| 148 | 153 | u.Disabled = disabled != 0 |
| 154 | u.DeleteAfter = deleteAfter.String | |
| 155 | u.Ghost = ghost != 0 | |
| 149 | 156 | return u, err |
| 150 | 157 | } |
| 151 | 158 | |
| @@ -203,10 +210,16 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) { | ||
| 203 | 210 | // are closed. |
| 204 | 211 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { |
| 205 | 212 | v := 0 |
| 213 | if _, err := s.DB.Exec("DELETE FROM account_deletions WHERE user_id = ?", userID); err != nil { | |
| 214 | return err | |
| 215 | } | |
| 206 | 216 | if disabled { |
| 207 | 217 | v = 1 |
| 208 | 218 | } |
| 209 | res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID) | |
| 219 | // An admin's decision replaces a scheduled or requested deletion | |
| 220 | // either way: a disabled account stays disabled and is not purged, | |
| 221 | // an enabled one is back in use. A purge already under way finishes. | |
| 222 | res, err := s.DB.Exec("UPDATE users SET disabled = ?, delete_after = CASE WHEN delete_after = ? THEN delete_after END WHERE id = ?", v, Purging, userID) | |
| 210 | 223 | if err != nil { |
| 211 | 224 | return err |
| 212 | 225 | } |
| @@ -321,15 +334,18 @@ func (s *Store) SetDiffLayout(userID int64, layout string) error { | ||
| 321 | 334 | |
| 322 | 335 | func (s *Store) UserByID(id int64) (User, error) { |
| 323 | 336 | var u User |
| 324 | var admin, pending, disabled int | |
| 325 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE id = ?", id). | |
| 326 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled) | |
| 337 | var admin, pending, disabled, ghost int | |
| 338 | var deleteAfter sql.NullString | |
| 339 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled, delete_after, ghost FROM users WHERE id = ?", id). | |
| 340 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled, &deleteAfter, &ghost) | |
| 327 | 341 | if errors.Is(err, sql.ErrNoRows) { |
| 328 | 342 | return u, ErrNotFound |
| 329 | 343 | } |
| 330 | 344 | u.IsAdmin = admin != 0 |
| 331 | 345 | u.Pending = pending != 0 |
| 332 | 346 | u.Disabled = disabled != 0 |
| 347 | u.DeleteAfter = deleteAfter.String | |
| 348 | u.Ghost = ghost != 0 | |
| 333 | 349 | return u, err |
| 334 | 350 | } |
| 335 | 351 | |
internal/web/templates/account.html +11
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | <li><a href="#export">Export</a></li> |
| 21 | 21 | <li><a href="#tokens">API tokens</a></li> |
| 22 | 22 | <li><a href="#cli">On the command line</a></li> |
| 23 | <li><a href="#delete">Delete account</a></li> | |
| 23 | 24 | </ul></div> |
| 24 | 25 | </details> |
| 25 | 26 | </nav> |
| @@ -251,6 +252,16 @@ gitbay admin ... # instance administration</pre> | ||
| 251 | 252 | grouping words dropped: <code>ssh git@{{.Host}} whoami</code>, |
| 252 | 253 | <code>ssh git@{{.Host}} web sessions list</code>.</p> |
| 253 | 254 | </section> |
| 255 | ||
| 256 | <section id="delete"><h2>Delete account</h2> | |
| 257 | <p class="meta">Deletes your repositories, snippets, keys and addresses. | |
| 258 | Issues, merge requests and comments you wrote on other people's | |
| 259 | repositories stay, under the name <code>ghost</code>. A link is mailed to | |
| 260 | your primary address; once it is opened the account is disabled, and it | |
| 261 | is deleted seven days later. Signing in during those days cancels. | |
| 262 | <a href="#export">Export</a> first to keep a copy.</p> | |
| 263 | <form method="post" action="/settings" class="setform"><input type="hidden" name="field" value="account-delete">{{template "confirmfield" .Viewer}} <button type="submit" class="danger">Mail the deletion link</button></form> | |
| 264 | </section> | |
| 254 | 265 | </div> |
| 255 | 266 | </div> |
| 256 | 267 | {{end}} |
internal/web/templates/accountdelete.html added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | {{define "width"}}bounded{{end}} | |
| 2 | {{define "title"}}delete account · {{.Site}}{{end}} | |
| 3 | {{define "content"}} | |
| 4 | {{if .Scheduled}} | |
| 5 | <h1>Deletion scheduled</h1> | |
| 6 | <p>{{.User}} is disabled and will be deleted at {{when .Scheduled}}.</p> | |
| 7 | <p class="meta">To cancel, <a href="/login">sign in</a> before then, or run any | |
| 8 | command over SSH with a full-scope key.</p> | |
| 9 | {{else if .User}} | |
| 10 | <h1>Delete {{.User}}</h1> | |
| 11 | <p>Confirming disables the account now and deletes it seven days later: | |
| 12 | its repositories, snippets, keys and addresses go, and what it wrote on | |
| 13 | other people's repositories stays under the name <code>ghost</code>. | |
| 14 | Signing in during those seven days cancels.</p> | |
| 15 | <form method="post"> | |
| 16 | <p class="btngroup"><button type="submit" class="danger">Delete {{.User}}</button> <a href="/">Cancel</a></p> | |
| 17 | </form> | |
| 18 | {{else}} | |
| 19 | <h1>Link not valid</h1> | |
| 20 | <p class="meta">This deletion link has expired or was already used. Nothing | |
| 21 | has changed. Request a new one from <a href="/settings#delete">settings</a>.</p> | |
| 22 | {{end}} | |
| 23 | {{end}} | |
internal/web/templates/privacy.html +5 −1
| @@ -19,7 +19,11 @@ repositories are visible only to accounts you grant; to everyone else | ||
| 19 | 19 | they are indistinguishable from nonexistent.</p> |
| 20 | 20 | <p>Your data is portable by design: <code>gitbay auth export</code> |
| 21 | 21 | downloads your account bundle, git data is yours by clone, and |
| 22 | <code>gitbay migrate</code> moves everything to another instance.</p> | |
| 22 | <code>gitbay migrate</code> moves everything to another instance. | |
| 23 | <code>gitbay auth delete</code>, or the bottom of the settings page, | |
| 24 | deletes the account seven days after the mailed link is opened; what it | |
| 25 | wrote on other people's repositories stays, attributed to | |
| 26 | <code>ghost</code>.</p> | |
| 23 | 27 | |
| 24 | 28 | <h2>The mobile client</h2> |
| 25 | 29 | <p>The iOS app is a client for an instance you name at sign-in. It has |
internal/web/web_test.go +1 −1
| @@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) { | ||
| 90 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func TestMainWidthClass(t *testing.T) { |
| 92 | 92 | wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "symbols.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true} |
| 93 | bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} | |
| 93 | bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true, "accountdelete.html": true} | |
| 94 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |
| 96 | 96 | src, err := TemplateSource(name) |