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 | 82 | pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), |
| 83 | 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 | 93 | manCmd(root), |
| 87 | 94 | ) |
cmd/gitbayd/maint.go +4 −20
| @@ -3,16 +3,15 @@ package main | ||
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | 5 | "fmt" |
| 6 | "io/fs" | |
| 7 | 6 | "os" |
| 8 | 7 | "os/exec" |
| 9 | "path/filepath" | |
| 10 | 8 | "text/tabwriter" |
| 11 | 9 | |
| 12 | 10 | "github.com/spf13/cobra" |
| 13 | 11 | |
| 14 | 12 | "gitbay.org/gitbay/internal/config" |
| 15 | 13 | "gitbay.org/gitbay/internal/control" |
| 14 | "gitbay.org/gitbay/internal/gitutil" | |
| 16 | 15 | "gitbay.org/gitbay/internal/store" |
| 17 | 16 | ) |
| 18 | 17 | |
| @@ -47,7 +46,7 @@ func gcCmd() *cobra.Command { | ||
| 47 | 46 | var before, after int64 |
| 48 | 47 | for _, r := range repos { |
| 49 | 48 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) |
| 50 | b := duDir(dir) | |
| 49 | b := gitutil.DirSize(dir) | |
| 51 | 50 | gcArgs := []string{"-C", dir, "gc", "--quiet"} |
| 52 | 51 | if aggressive { |
| 53 | 52 | gcArgs = append(gcArgs, "--aggressive") |
| @@ -56,7 +55,7 @@ func gcCmd() *cobra.Command { | ||
| 56 | 55 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) |
| 57 | 56 | continue |
| 58 | 57 | } |
| 59 | a := duDir(dir) | |
| 58 | a := gitutil.DirSize(dir) | |
| 60 | 59 | before, after = before+b, after+a |
| 61 | 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 | 99 | var disks []repoDisk |
| 101 | 100 | var totalDisk int64 |
| 102 | 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 | 103 | disks = append(disks, repoDisk{r.Path(), b}) |
| 105 | 104 | totalDisk += b |
| 106 | 105 | } |
| @@ -130,21 +129,6 @@ func statsCmd() *cobra.Command { | ||
| 130 | 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 | 132 | func human(b int64) string { |
| 149 | 133 | switch { |
| 150 | 134 | case b >= 1<<30: |
e2e/adminusers_test.go +110
| @@ -2,6 +2,8 @@ package e2e | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 5 | 7 | "strings" |
| 6 | 8 | "testing" |
| 7 | 9 | ) |
| @@ -221,3 +223,111 @@ func TestAdminPromoteDemote(t *testing.T) { | ||
| 221 | 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 | 7 | "strings" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | "gitbay.org/gitbay/internal/gitutil" | |
| 10 | 11 | "gitbay.org/gitbay/internal/protocol" |
| 11 | 12 | "gitbay.org/gitbay/internal/store" |
| 12 | 13 | ) |
| @@ -28,6 +29,26 @@ func init() { | ||
| 28 | 29 | Summary: "remove instance admin from an account (never the last one)", |
| 29 | 30 | Usage: "admin user demote <username>", |
| 30 | 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 | 54 | // requireInstanceAdmin gates the admin noun. -1 means proceed. |
| @@ -289,3 +310,141 @@ func setAdmin(c *Ctx, args []string, admin bool) int { | ||
| 289 | 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 | 421 | if !yes { |
| 422 | 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 | 430 | // Open MRs sourced from this repo keep working (targets own the |
| 425 | 431 | // objects) but must show that the source is gone. |
| 426 | 432 | if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { |
| @@ -574,24 +580,30 @@ func runSetVisibility(c *Ctx, args []string) int { | ||
| 574 | 580 | if code >= 0 { |
| 575 | 581 | return code |
| 576 | 582 | } |
| 577 | if repo.Visibility == args[1] { | |
| 578 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | |
| 579 | fmt.Fprintf(w, "%s is already %s\n", repo.Path(), args[1]) | |
| 583 | return setRepoVisibility(c, repo, args[1]) | |
| 584 | } | |
| 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 | 595 | return c.fail(protocol.ExitFailure, "%v", err) |
| 584 | 596 | } |
| 585 | 597 | // Going private takes the repository off every anonymous surface, so |
| 586 | 598 | // git:// exposure cannot outlive the change. |
| 587 | if args[1] == "private" && repo.Settings.GitDaemon { | |
| 599 | if visibility == "private" && repo.Settings.GitDaemon { | |
| 588 | 600 | s := repo.Settings |
| 589 | 601 | s.GitDaemon = false |
| 590 | 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]}) | |
| 593 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | |
| 594 | fmt.Fprintf(w, "%s is now %s\n", repo.Path(), args[1]) | |
| 604 | c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility}) | |
| 605 | return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) { | |
| 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 | 645 | if code >= 0 { |
| 634 | 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 | 658 | if repo.Settings.Archived == archived { |
| 637 | 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 | 6 | "context" |
| 7 | 7 | "fmt" |
| 8 | 8 | "io" |
| 9 | "io/fs" | |
| 9 | 10 | "os" |
| 10 | 11 | "os/exec" |
| 11 | 12 | "path/filepath" |
| @@ -210,3 +211,18 @@ func WriteDescription(dir, desc string) error { | ||
| 210 | 211 | } |
| 211 | 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 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | "encoding/json" | |
| 5 | 6 | "errors" |
| 6 | 7 | "fmt" |
| 7 | 8 | "time" |
| @@ -131,3 +132,62 @@ func (s *Store) SetUserAdmin(userID int64, admin bool) error { | ||
| 131 | 132 | } |
| 132 | 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 | } | |