Commit 85a6c5a74e
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +7
| @@ -82,6 +82,13 @@ func newRoot() *cobra.Command { | |||
| 82 | pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), | 82 | pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), |
| 83 | pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}), | 83 | pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}), |
| 84 | ), | 84 | ), |
| 85 | group("repo", "any repository, for moderation (audited)", | ||
| 86 | pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}), | ||
| 87 | pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), | ||
| 88 | pass("unarchive", "unarchive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "unarchive"}}), | ||
| 89 | pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}), | ||
| 90 | pass("delete", "delete a repository: <owner/name> --yes", passOpts{server: []string{"admin", "repo", "delete"}}), | ||
| 91 | ), | ||
| 85 | ), | 92 | ), |
| 86 | manCmd(root), | 93 | manCmd(root), |
| 87 | ) | 94 | ) |
cmd/gitbayd/maint.go +4 −20
| @@ -3,16 +3,15 @@ package main | |||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io/fs" | ||
| 7 | "os" | 6 | "os" |
| 8 | "os/exec" | 7 | "os/exec" |
| 9 | "path/filepath" | ||
| 10 | "text/tabwriter" | 8 | "text/tabwriter" |
| 11 | 9 | ||
| 12 | "github.com/spf13/cobra" | 10 | "github.com/spf13/cobra" |
| 13 | 11 | ||
| 14 | "gitbay.org/gitbay/internal/config" | 12 | "gitbay.org/gitbay/internal/config" |
| 15 | "gitbay.org/gitbay/internal/control" | 13 | "gitbay.org/gitbay/internal/control" |
| 14 | "gitbay.org/gitbay/internal/gitutil" | ||
| 16 | "gitbay.org/gitbay/internal/store" | 15 | "gitbay.org/gitbay/internal/store" |
| 17 | ) | 16 | ) |
| 18 | 17 | ||
| @@ -47,7 +46,7 @@ func gcCmd() *cobra.Command { | |||
| 47 | var before, after int64 | 46 | var before, after int64 |
| 48 | for _, r := range repos { | 47 | for _, r := range repos { |
| 49 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) | 48 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) |
| 50 | b := duDir(dir) | 49 | b := gitutil.DirSize(dir) |
| 51 | gcArgs := []string{"-C", dir, "gc", "--quiet"} | 50 | gcArgs := []string{"-C", dir, "gc", "--quiet"} |
| 52 | if aggressive { | 51 | if aggressive { |
| 53 | gcArgs = append(gcArgs, "--aggressive") | 52 | gcArgs = append(gcArgs, "--aggressive") |
| @@ -56,7 +55,7 @@ func gcCmd() *cobra.Command { | |||
| 56 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) | 55 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) |
| 57 | continue | 56 | continue |
| 58 | } | 57 | } |
| 59 | a := duDir(dir) | 58 | a := gitutil.DirSize(dir) |
| 60 | before, after = before+b, after+a | 59 | before, after = before+b, after+a |
| 61 | fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a)) | 60 | fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a)) |
| 62 | } | 61 | } |
| @@ -100,7 +99,7 @@ func statsCmd() *cobra.Command { | |||
| 100 | var disks []repoDisk | 99 | var disks []repoDisk |
| 101 | var totalDisk int64 | 100 | var totalDisk int64 |
| 102 | for _, r := range repos { | 101 | for _, r := range repos { |
| 103 | b := duDir(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)) | 102 | b := gitutil.DirSize(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)) |
| 104 | disks = append(disks, repoDisk{r.Path(), b}) | 103 | disks = append(disks, repoDisk{r.Path(), b}) |
| 105 | totalDisk += b | 104 | totalDisk += b |
| 106 | } | 105 | } |
| @@ -130,21 +129,6 @@ func statsCmd() *cobra.Command { | |||
| 130 | return cmd | 129 | return cmd |
| 131 | } | 130 | } |
| 132 | 131 | ||
| 133 | // duDir sums file sizes under dir; errors count as zero. | ||
| 134 | func duDir(dir string) int64 { | ||
| 135 | var total int64 | ||
| 136 | filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { | ||
| 137 | if err != nil || d.IsDir() { | ||
| 138 | return nil | ||
| 139 | } | ||
| 140 | if fi, err := d.Info(); err == nil { | ||
| 141 | total += fi.Size() | ||
| 142 | } | ||
| 143 | return nil | ||
| 144 | }) | ||
| 145 | return total | ||
| 146 | } | ||
| 147 | |||
| 148 | func human(b int64) string { | 132 | func human(b int64) string { |
| 149 | switch { | 133 | switch { |
| 150 | case b >= 1<<30: | 134 | case b >= 1<<30: |
e2e/adminusers_test.go +110
| @@ -2,6 +2,8 @@ package e2e | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 5 | "strings" | 7 | "strings" |
| 6 | "testing" | 8 | "testing" |
| 7 | ) | 9 | ) |
| @@ -221,3 +223,111 @@ func TestAdminPromoteDemote(t *testing.T) { | |||
| 221 | t.Fatalf("host promote not audited:\n%s", out) | 223 | t.Fatalf("host promote not audited:\n%s", out) |
| 222 | } | 224 | } |
| 223 | } | 225 | } |
| 226 | |||
| 227 | func TestAdminRepoModeration(t *testing.T) { | ||
| 228 | inst := startInstance(t) | ||
| 229 | rootKey := inst.newKey(t, "root") | ||
| 230 | aliceKey := inst.newKey(t, "alice") | ||
| 231 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin") | ||
| 232 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 233 | for _, args := range [][]string{{"repo", "create", "alice/app"}, {"repo", "create", "alice/secret", "--private"}} { | ||
| 234 | if _, _, code := inst.ssh(t, aliceKey, "", args...); code != 0 { | ||
| 235 | t.Fatalf("%v failed", args) | ||
| 236 | } | ||
| 237 | } | ||
| 238 | // A push, so last_push has something to report. | ||
| 239 | work := t.TempDir() | ||
| 240 | env := inst.gitEnv(aliceKey) | ||
| 241 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 242 | dir := filepath.Join(work, "w") | ||
| 243 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | ||
| 244 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 245 | mustGit(t, dir, env, "add", ".") | ||
| 246 | mustGit(t, dir, env, "commit", "-q", "-m", "a") | ||
| 247 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 248 | |||
| 249 | // Instance admin carries no read right: the private repo still 404s. | ||
| 250 | if _, _, code := inst.ssh(t, rootKey, "", "repo", "show", "alice/secret"); code != 3 { | ||
| 251 | t.Fatalf("admin read a private repo: exit %d", code) | ||
| 252 | } | ||
| 253 | if _, _, code := inst.ssh(t, aliceKey, "", "admin", "repo", "list"); code != 4 { | ||
| 254 | t.Fatal("non-admin listed repos") | ||
| 255 | } | ||
| 256 | |||
| 257 | type row struct { | ||
| 258 | Path string `json:"path"` | ||
| 259 | Visibility string `json:"visibility"` | ||
| 260 | Archived bool `json:"archived"` | ||
| 261 | LastPush string `json:"last_push"` | ||
| 262 | Bytes int64 `json:"bytes"` | ||
| 263 | } | ||
| 264 | list := func(args ...string) []row { | ||
| 265 | t.Helper() | ||
| 266 | out, errOut, code := inst.ssh(t, rootKey, "", append([]string{"admin", "repo", "list", "--json"}, args...)...) | ||
| 267 | if code != 0 { | ||
| 268 | t.Fatalf("admin repo list %v: exit %d %s", args, code, errOut) | ||
| 269 | } | ||
| 270 | var env struct { | ||
| 271 | Data []row `json:"data"` | ||
| 272 | } | ||
| 273 | if err := json.Unmarshal([]byte(out), &env); err != nil { | ||
| 274 | t.Fatalf("list: %v\n%s", err, out) | ||
| 275 | } | ||
| 276 | return env.Data | ||
| 277 | } | ||
| 278 | rows := list() | ||
| 279 | if len(rows) != 2 || rows[0].Path != "alice/app" || rows[1].Path != "alice/secret" || rows[1].Visibility != "private" { | ||
| 280 | t.Fatalf("list: %+v", rows) | ||
| 281 | } | ||
| 282 | if rows[0].LastPush == "" || rows[1].LastPush != "" || rows[0].Bytes == 0 { | ||
| 283 | t.Fatalf("push and size facts: %+v", rows) | ||
| 284 | } | ||
| 285 | if rows := list("--visibility", "private"); len(rows) != 1 || rows[0].Path != "alice/secret" { | ||
| 286 | t.Fatalf("--visibility: %+v", rows) | ||
| 287 | } | ||
| 288 | if rows := list("--owner", "root"); len(rows) != 0 { | ||
| 289 | t.Fatalf("--owner root: %+v", rows) | ||
| 290 | } | ||
| 291 | |||
| 292 | // Archive, then visibility: the private repo becomes readable to | ||
| 293 | // everyone once public, admin included. | ||
| 294 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "archive", "alice/app"); code != 0 { | ||
| 295 | t.Fatal("admin archive failed") | ||
| 296 | } | ||
| 297 | if rows := list(); !rows[0].Archived { | ||
| 298 | t.Fatalf("not archived: %+v", rows) | ||
| 299 | } | ||
| 300 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "unarchive", "alice/app"); code != 0 { | ||
| 301 | t.Fatal("admin unarchive failed") | ||
| 302 | } | ||
| 303 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "visibility", "alice/secret", "public"); code != 0 { | ||
| 304 | t.Fatal("admin visibility failed") | ||
| 305 | } | ||
| 306 | if _, _, code := inst.ssh(t, rootKey, "", "repo", "show", "alice/secret"); code != 0 { | ||
| 307 | t.Fatal("repo still hidden after going public") | ||
| 308 | } | ||
| 309 | |||
| 310 | // Delete wants the typed confirmation and then removes it from the | ||
| 311 | // owner's view too. | ||
| 312 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "alice/app"); code != 2 { | ||
| 313 | t.Fatal("delete without --yes accepted") | ||
| 314 | } | ||
| 315 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "alice/app", "--yes"); code != 0 { | ||
| 316 | t.Fatal("admin delete failed") | ||
| 317 | } | ||
| 318 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "list"); strings.Contains(out, "alice/app") { | ||
| 319 | t.Fatalf("deleted repo still listed:\n%s", out) | ||
| 320 | } | ||
| 321 | if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "nobody/none", "--yes"); code != 3 { | ||
| 322 | t.Fatal("unknown repo should be not found") | ||
| 323 | } | ||
| 324 | |||
| 325 | // Every override is in the audit log under its own action, on top of | ||
| 326 | // the generic cmd row. | ||
| 327 | out, _, _ := inst.ssh(t, rootKey, "", "audit", "--json") | ||
| 328 | for _, want := range []string{"admin repo.archive", "admin repo.unarchive", "admin repo.visibility", "admin repo.delete"} { | ||
| 329 | if !strings.Contains(out, want) { | ||
| 330 | t.Fatalf("audit lacks %q:\n%s", want, out) | ||
| 331 | } | ||
| 332 | } | ||
| 333 | } | ||
internal/control/admin.go +159
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "strings" | 7 | "strings" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
| 10 | "gitbay.org/gitbay/internal/gitutil" | ||
| 10 | "gitbay.org/gitbay/internal/protocol" | 11 | "gitbay.org/gitbay/internal/protocol" |
| 11 | "gitbay.org/gitbay/internal/store" | 12 | "gitbay.org/gitbay/internal/store" |
| 12 | ) | 13 | ) |
| @@ -28,6 +29,26 @@ func init() { | |||
| 28 | Summary: "remove instance admin from an account (never the last one)", | 29 | Summary: "remove instance admin from an account (never the last one)", |
| 29 | Usage: "admin user demote <username>", | 30 | Usage: "admin user demote <username>", |
| 30 | SSHOnly: true, Run: runAdminUserDemote}) | 31 | SSHOnly: true, Run: runAdminUserDemote}) |
| 32 | register(Command{Path: []string{"admin", "repo", "list"}, | ||
| 33 | Summary: "list every repository with size and last push (instance admins)", | ||
| 34 | Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]", | ||
| 35 | ReadOnly: true, SSHOnly: true, Run: runAdminRepoList}) | ||
| 36 | register(Command{Path: []string{"admin", "repo", "archive"}, | ||
| 37 | Summary: "archive any repository (instance admins; audited)", | ||
| 38 | Usage: "admin repo archive <owner/name>", | ||
| 39 | SSHOnly: true, Run: runAdminRepoArchive}) | ||
| 40 | register(Command{Path: []string{"admin", "repo", "unarchive"}, | ||
| 41 | Summary: "unarchive any repository (instance admins; audited)", | ||
| 42 | Usage: "admin repo unarchive <owner/name>", | ||
| 43 | SSHOnly: true, Run: runAdminRepoUnarchive}) | ||
| 44 | register(Command{Path: []string{"admin", "repo", "visibility"}, | ||
| 45 | Summary: "set any repository's visibility (instance admins; audited)", | ||
| 46 | Usage: "admin repo visibility <owner/name> public|private", | ||
| 47 | SSHOnly: true, Run: runAdminRepoVisibility}) | ||
| 48 | register(Command{Path: []string{"admin", "repo", "delete"}, | ||
| 49 | Summary: "delete any repository (instance admins; audited)", | ||
| 50 | Usage: "admin repo delete <owner/name> --yes", | ||
| 51 | SSHOnly: true, Run: runAdminRepoDelete}) | ||
| 31 | } | 52 | } |
| 32 | 53 | ||
| 33 | // requireInstanceAdmin gates the admin noun. -1 means proceed. | 54 | // requireInstanceAdmin gates the admin noun. -1 means proceed. |
| @@ -289,3 +310,141 @@ func setAdmin(c *Ctx, args []string, admin bool) int { | |||
| 289 | fmt.Fprintf(w, "%sd %s\n", verb, u.Username) | 310 | fmt.Fprintf(w, "%sd %s\n", verb, u.Username) |
| 290 | }) | 311 | }) |
| 291 | } | 312 | } |
| 313 | |||
| 314 | // adminRepo loads a repository for an admin override. Instance admin | ||
| 315 | // carries no implicit read right, so policy is not consulted; the only | ||
| 316 | // refusal is a path that does not exist. Every caller audits what it does. | ||
| 317 | func adminRepo(c *Ctx, path string) (store.Repo, int) { | ||
| 318 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 319 | return store.Repo{}, code | ||
| 320 | } | ||
| 321 | repo, err := c.Store.RepoByPath(path) | ||
| 322 | if errors.Is(err, store.ErrNotFound) { | ||
| 323 | return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) | ||
| 324 | } else if err != nil { | ||
| 325 | return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err) | ||
| 326 | } | ||
| 327 | return repo, -1 | ||
| 328 | } | ||
| 329 | |||
| 330 | func runAdminRepoList(c *Ctx, args []string) int { | ||
| 331 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 332 | return code | ||
| 333 | } | ||
| 334 | args, p, code := parsePageFlags(c, args, "admin-repo", false) | ||
| 335 | if code >= 0 { | ||
| 336 | return code | ||
| 337 | } | ||
| 338 | var owner, visibility string | ||
| 339 | for i := 0; i < len(args); i++ { | ||
| 340 | switch args[i] { | ||
| 341 | case "--owner": | ||
| 342 | if i+1 >= len(args) { | ||
| 343 | return c.fail(protocol.ExitUsage, "--owner requires a value") | ||
| 344 | } | ||
| 345 | owner = args[i+1] | ||
| 346 | i++ | ||
| 347 | case "--visibility": | ||
| 348 | if i+1 >= len(args) || (args[i+1] != "public" && args[i+1] != "private") { | ||
| 349 | return c.fail(protocol.ExitUsage, "--visibility requires public|private") | ||
| 350 | } | ||
| 351 | visibility = args[i+1] | ||
| 352 | i++ | ||
| 353 | default: | ||
| 354 | return c.fail(protocol.ExitUsage, "usage: admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]") | ||
| 355 | } | ||
| 356 | } | ||
| 357 | repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key) | ||
| 358 | if err != nil { | ||
| 359 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 360 | } | ||
| 361 | repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path }) | ||
| 362 | type out struct { | ||
| 363 | Path string `json:"path"` | ||
| 364 | Visibility string `json:"visibility"` | ||
| 365 | Archived bool `json:"archived,omitempty"` | ||
| 366 | CreatedAt string `json:"created_at"` | ||
| 367 | LastPush string `json:"last_push,omitempty"` | ||
| 368 | Bytes int64 `json:"bytes"` | ||
| 369 | } | ||
| 370 | var ds []out | ||
| 371 | for _, r := range repos { | ||
| 372 | size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) | ||
| 373 | ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) | ||
| 374 | } | ||
| 375 | return c.emitPage(p, ds, next, func(w io.Writer) { | ||
| 376 | for _, d := range ds { | ||
| 377 | mark := "" | ||
| 378 | if d.Archived { | ||
| 379 | mark = "\t[archived]" | ||
| 380 | } | ||
| 381 | fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark) | ||
| 382 | } | ||
| 383 | }) | ||
| 384 | } | ||
| 385 | |||
| 386 | func runAdminRepoArchive(c *Ctx, args []string) int { return adminArchive(c, args, true) } | ||
| 387 | func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) } | ||
| 388 | |||
| 389 | func adminArchive(c *Ctx, args []string, archived bool) int { | ||
| 390 | verb := "archive" | ||
| 391 | if !archived { | ||
| 392 | verb = "unarchive" | ||
| 393 | } | ||
| 394 | if len(args) != 1 { | ||
| 395 | return c.fail(protocol.ExitUsage, "usage: admin repo %s <owner/name>", verb) | ||
| 396 | } | ||
| 397 | repo, code := adminRepo(c, args[0]) | ||
| 398 | if code >= 0 { | ||
| 399 | return code | ||
| 400 | } | ||
| 401 | if code := archiveRepo(c, repo, archived); code != protocol.ExitOK { | ||
| 402 | return code | ||
| 403 | } | ||
| 404 | c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()}) | ||
| 405 | return protocol.ExitOK | ||
| 406 | } | ||
| 407 | |||
| 408 | func runAdminRepoVisibility(c *Ctx, args []string) int { | ||
| 409 | if len(args) != 2 || (args[1] != "public" && args[1] != "private") { | ||
| 410 | return c.fail(protocol.ExitUsage, "usage: admin repo visibility <owner/name> public|private") | ||
| 411 | } | ||
| 412 | repo, code := adminRepo(c, args[0]) | ||
| 413 | if code >= 0 { | ||
| 414 | return code | ||
| 415 | } | ||
| 416 | if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK { | ||
| 417 | return code | ||
| 418 | } | ||
| 419 | c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]}) | ||
| 420 | return protocol.ExitOK | ||
| 421 | } | ||
| 422 | |||
| 423 | func runAdminRepoDelete(c *Ctx, args []string) int { | ||
| 424 | var path string | ||
| 425 | var yes bool | ||
| 426 | for _, a := range args { | ||
| 427 | if a == "--yes" { | ||
| 428 | yes = true | ||
| 429 | } else if path == "" { | ||
| 430 | path = a | ||
| 431 | } else { | ||
| 432 | return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes") | ||
| 433 | } | ||
| 434 | } | ||
| 435 | if path == "" { | ||
| 436 | return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes") | ||
| 437 | } | ||
| 438 | repo, code := adminRepo(c, path) | ||
| 439 | if code >= 0 { | ||
| 440 | return code | ||
| 441 | } | ||
| 442 | if !yes { | ||
| 443 | return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes") | ||
| 444 | } | ||
| 445 | if code := deleteRepo(c, repo); code != protocol.ExitOK { | ||
| 446 | return code | ||
| 447 | } | ||
| 448 | c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()}) | ||
| 449 | return protocol.ExitOK | ||
| 450 | } | ||
internal/control/repo.go +30 −8
| @@ -421,6 +421,12 @@ func runRepoDelete(c *Ctx, args []string) int { | |||
| 421 | if !yes { | 421 | if !yes { |
| 422 | return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") | 422 | return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") |
| 423 | } | 423 | } |
| 424 | return deleteRepo(c, repo) | ||
| 425 | } | ||
| 426 | |||
| 427 | // deleteRepo removes a repository the caller has already been cleared to | ||
| 428 | // delete: the database row, then the directory and its wiki companion. | ||
| 429 | func deleteRepo(c *Ctx, repo store.Repo) int { | ||
| 424 | // Open MRs sourced from this repo keep working (targets own the | 430 | // Open MRs sourced from this repo keep working (targets own the |
| 425 | // objects) but must show that the source is gone. | 431 | // objects) but must show that the source is gone. |
| 426 | if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { | 432 | if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { |
| @@ -574,24 +580,30 @@ func runSetVisibility(c *Ctx, args []string) int { | |||
| 574 | if code >= 0 { | 580 | if code >= 0 { |
| 575 | return code | 581 | return code |
| 576 | } | 582 | } |
| 577 | if repo.Visibility == args[1] { | 583 | return setRepoVisibility(c, repo, args[1]) |
| 578 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | 584 | } |
| 579 | fmt.Fprintf(w, "%s is already %s\n", repo.Path(), args[1]) | 585 | |
| 586 | // setRepoVisibility applies a visibility change the caller has already | ||
| 587 | // been cleared to make. | ||
| 588 | func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int { | ||
| 589 | if repo.Visibility == visibility { | ||
| 590 | return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) { | ||
| 591 | fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility) | ||
| 580 | }) | 592 | }) |
| 581 | } | 593 | } |
| 582 | if err := c.Store.SetRepoVisibility(repo.ID, args[1]); err != nil { | 594 | if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil { |
| 583 | return c.fail(protocol.ExitFailure, "%v", err) | 595 | return c.fail(protocol.ExitFailure, "%v", err) |
| 584 | } | 596 | } |
| 585 | // Going private takes the repository off every anonymous surface, so | 597 | // Going private takes the repository off every anonymous surface, so |
| 586 | // git:// exposure cannot outlive the change. | 598 | // git:// exposure cannot outlive the change. |
| 587 | if args[1] == "private" && repo.Settings.GitDaemon { | 599 | if visibility == "private" && repo.Settings.GitDaemon { |
| 588 | s := repo.Settings | 600 | s := repo.Settings |
| 589 | s.GitDaemon = false | 601 | s.GitDaemon = false |
| 590 | c.Store.SetRepoSettings(repo.ID, s) | 602 | c.Store.SetRepoSettings(repo.ID, s) |
| 591 | } | 603 | } |
| 592 | c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": args[1]}) | 604 | c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility}) |
| 593 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | 605 | return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) { |
| 594 | fmt.Fprintf(w, "%s is now %s\n", repo.Path(), args[1]) | 606 | fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility) |
| 595 | }) | 607 | }) |
| 596 | } | 608 | } |
| 597 | 609 | ||
| @@ -633,6 +645,16 @@ func setArchived(c *Ctx, args []string, archived bool) int { | |||
| 633 | if code >= 0 { | 645 | if code >= 0 { |
| 634 | return code | 646 | return code |
| 635 | } | 647 | } |
| 648 | return archiveRepo(c, repo, archived) | ||
| 649 | } | ||
| 650 | |||
| 651 | // archiveRepo flips the archived flag on a repository the caller has | ||
| 652 | // already been cleared to manage. | ||
| 653 | func archiveRepo(c *Ctx, repo store.Repo, archived bool) int { | ||
| 654 | verb := "archive" | ||
| 655 | if !archived { | ||
| 656 | verb = "unarchive" | ||
| 657 | } | ||
| 636 | if repo.Settings.Archived == archived { | 658 | if repo.Settings.Archived == archived { |
| 637 | return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb) | 659 | return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb) |
| 638 | } | 660 | } |
internal/gitutil/gitutil.go +16
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | "context" | 6 | "context" |
| 7 | "fmt" | 7 | "fmt" |
| 8 | "io" | 8 | "io" |
| 9 | "io/fs" | ||
| 9 | "os" | 10 | "os" |
| 10 | "os/exec" | 11 | "os/exec" |
| 11 | "path/filepath" | 12 | "path/filepath" |
| @@ -210,3 +211,18 @@ func WriteDescription(dir, desc string) error { | |||
| 210 | } | 211 | } |
| 211 | return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644) | 212 | return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644) |
| 212 | } | 213 | } |
| 214 | |||
| 215 | // DirSize sums file sizes under dir; unreadable entries count as zero. | ||
| 216 | func DirSize(dir string) int64 { | ||
| 217 | var total int64 | ||
| 218 | filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { | ||
| 219 | if err != nil || d.IsDir() { | ||
| 220 | return nil | ||
| 221 | } | ||
| 222 | if fi, err := d.Info(); err == nil { | ||
| 223 | total += fi.Size() | ||
| 224 | } | ||
| 225 | return nil | ||
| 226 | }) | ||
| 227 | return total | ||
| 228 | } | ||
internal/store/adminusers.go +60
| @@ -2,6 +2,7 @@ package store | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "encoding/json" | ||
| 5 | "errors" | 6 | "errors" |
| 6 | "fmt" | 7 | "fmt" |
| 7 | "time" | 8 | "time" |
| @@ -131,3 +132,62 @@ func (s *Store) SetUserAdmin(userID int64, admin bool) error { | |||
| 131 | } | 132 | } |
| 132 | return tx.Commit() | 133 | return tx.Commit() |
| 133 | } | 134 | } |
| 135 | |||
| 136 | // AdminRepo is one repository as the instance admin lists it. LastPush is | ||
| 137 | // the newest push event, "" when nothing has been pushed. | ||
| 138 | type AdminRepo struct { | ||
| 139 | Path string // owner/name, the keyset cursor | ||
| 140 | OwnerName string | ||
| 141 | Name string | ||
| 142 | Visibility string | ||
| 143 | Archived bool | ||
| 144 | CreatedAt string | ||
| 145 | LastPush string | ||
| 146 | } | ||
| 147 | |||
| 148 | // ListReposAdmin lists repositories across every owner, by path. owner and | ||
| 149 | // visibility narrow the set when non-empty; after is the path keyset | ||
| 150 | // cursor; limit 0 means no cap. | ||
| 151 | func (s *Store) ListReposAdmin(owner, visibility string, limit int, after string) ([]AdminRepo, error) { | ||
| 152 | q := `SELECT COALESCE(u.username, o.name) || '/' || r.name, COALESCE(u.username, o.name), r.name, | ||
| 153 | r.visibility, r.settings_json, r.created_at, | ||
| 154 | COALESCE((SELECT MAX(created_at) FROM events WHERE repo_id = r.id AND kind = 'push'), '') | ||
| 155 | FROM repos r | ||
| 156 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 157 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | ||
| 158 | WHERE COALESCE(u.username, o.name) || '/' || r.name > ?` | ||
| 159 | args := []any{after} | ||
| 160 | if owner != "" { | ||
| 161 | q += " AND COALESCE(u.username, o.name) = ?" | ||
| 162 | args = append(args, owner) | ||
| 163 | } | ||
| 164 | if visibility != "" { | ||
| 165 | q += " AND r.visibility = ?" | ||
| 166 | args = append(args, visibility) | ||
| 167 | } | ||
| 168 | q += " ORDER BY 1" | ||
| 169 | if limit > 0 { | ||
| 170 | q += " LIMIT ?" | ||
| 171 | args = append(args, limit) | ||
| 172 | } | ||
| 173 | rows, err := s.DB.Query(q, args...) | ||
| 174 | if err != nil { | ||
| 175 | return nil, err | ||
| 176 | } | ||
| 177 | defer rows.Close() | ||
| 178 | var out []AdminRepo | ||
| 179 | for rows.Next() { | ||
| 180 | var r AdminRepo | ||
| 181 | var settingsJSON string | ||
| 182 | if err := rows.Scan(&r.Path, &r.OwnerName, &r.Name, &r.Visibility, &settingsJSON, &r.CreatedAt, &r.LastPush); err != nil { | ||
| 183 | return nil, err | ||
| 184 | } | ||
| 185 | var st RepoSettings | ||
| 186 | if err := json.Unmarshal([]byte(settingsJSON), &st); err != nil { | ||
| 187 | return nil, fmt.Errorf("repo %s settings: %w", r.Path, err) | ||
| 188 | } | ||
| 189 | r.Archived = st.Archived | ||
| 190 | out = append(out, r) | ||
| 191 | } | ||
| 192 | return out, rows.Err() | ||
| 193 | } | ||