Commit 85a6c5a74e

85a6c5a74e2b873e8a30b06a7e87781a6f524cad

parent: a779ff0efe

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 01:05 UTC

admin: audited repository overrides for moderation

policy has no IsAdmin clause, so an instance admin could not archive,
hide, or delete a spam repository, and no host-local command did it
either. That stays true for reads: a private repository still answers
not-found to an admin.

admin repo list pages every repository by path with --owner and
--visibility filters, carrying size and the newest push event. admin
repo archive|unarchive, visibility, and delete resolve the path without
consulting policy and write an admin repo.<action> audit row naming the
repository, on top of the dispatcher's cmd row.

The archive, visibility, and delete bodies move out of their user-facing
runners into archiveRepo, setRepoVisibility, and deleteRepo so both
paths run the same code after their own access check. duDir moves to
gitutil.DirSize for the same reason.

Closes #71

Layout: unified · split

cmd/gitbay/main.go +7
@@ -82,6 +82,13 @@ func newRoot() *cobra.Command {
8282 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}),
8383 pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}),
8484 ),
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 ),
8592 ),
8693 manCmd(root),
8794 )
cmd/gitbayd/maint.go +4 −20
@@ -3,16 +3,15 @@ package main
33import (
44 "encoding/json"
55 "fmt"
6 "io/fs"
76 "os"
87 "os/exec"
9 "path/filepath"
108 "text/tabwriter"
119
1210 "github.com/spf13/cobra"
1311
1412 "gitbay.org/gitbay/internal/config"
1513 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
1615 "gitbay.org/gitbay/internal/store"
1716)
1817
@@ -47,7 +46,7 @@ func gcCmd() *cobra.Command {
4746 var before, after int64
4847 for _, r := range repos {
4948 dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)
50 b := duDir(dir)
49 b := gitutil.DirSize(dir)
5150 gcArgs := []string{"-C", dir, "gc", "--quiet"}
5251 if aggressive {
5352 gcArgs = append(gcArgs, "--aggressive")
@@ -56,7 +55,7 @@ func gcCmd() *cobra.Command {
5655 fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out)
5756 continue
5857 }
59 a := duDir(dir)
58 a := gitutil.DirSize(dir)
6059 before, after = before+b, after+a
6160 fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a))
6261 }
@@ -100,7 +99,7 @@ func statsCmd() *cobra.Command {
10099 var disks []repoDisk
101100 var totalDisk int64
102101 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))
104103 disks = append(disks, repoDisk{r.Path(), b})
105104 totalDisk += b
106105 }
@@ -130,21 +129,6 @@ func statsCmd() *cobra.Command {
130129 return cmd
131130}
132131
133// duDir sums file sizes under dir; errors count as zero.
134func 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
148132func human(b int64) string {
149133 switch {
150134 case b >= 1<<30:
e2e/adminusers_test.go +110
@@ -2,6 +2,8 @@ package e2e
22
33import (
44 "encoding/json"
5 "os"
6 "path/filepath"
57 "strings"
68 "testing"
79)
@@ -221,3 +223,111 @@ func TestAdminPromoteDemote(t *testing.T) {
221223 t.Fatalf("host promote not audited:\n%s", out)
222224 }
223225}
226
227func 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 (
77 "strings"
88 "time"
99
10 "gitbay.org/gitbay/internal/gitutil"
1011 "gitbay.org/gitbay/internal/protocol"
1112 "gitbay.org/gitbay/internal/store"
1213)
@@ -28,6 +29,26 @@ func init() {
2829 Summary: "remove instance admin from an account (never the last one)",
2930 Usage: "admin user demote <username>",
3031 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})
3152}
3253
3354// requireInstanceAdmin gates the admin noun. -1 means proceed.
@@ -289,3 +310,141 @@ func setAdmin(c *Ctx, args []string, admin bool) int {
289310 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
290311 })
291312}
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.
317func 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
330func 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
386func runAdminRepoArchive(c *Ctx, args []string) int { return adminArchive(c, args, true) }
387func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
388
389func 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
408func 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
423func 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 {
421421 if !yes {
422422 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
423423 }
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.
429func deleteRepo(c *Ctx, repo store.Repo) int {
424430 // Open MRs sourced from this repo keep working (targets own the
425431 // objects) but must show that the source is gone.
426432 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
@@ -574,24 +580,30 @@ func runSetVisibility(c *Ctx, args []string) int {
574580 if code >= 0 {
575581 return code
576582 }
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.
588func 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)
580592 })
581593 }
582 if err := c.Store.SetRepoVisibility(repo.ID, args[1]); err != nil {
594 if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
583595 return c.fail(protocol.ExitFailure, "%v", err)
584596 }
585597 // Going private takes the repository off every anonymous surface, so
586598 // git:// exposure cannot outlive the change.
587 if args[1] == "private" && repo.Settings.GitDaemon {
599 if visibility == "private" && repo.Settings.GitDaemon {
588600 s := repo.Settings
589601 s.GitDaemon = false
590602 c.Store.SetRepoSettings(repo.ID, s)
591603 }
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)
595607 })
596608}
597609
@@ -633,6 +645,16 @@ func setArchived(c *Ctx, args []string, archived bool) int {
633645 if code >= 0 {
634646 return code
635647 }
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.
653func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
654 verb := "archive"
655 if !archived {
656 verb = "unarchive"
657 }
636658 if repo.Settings.Archived == archived {
637659 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
638660 }
internal/gitutil/gitutil.go +16
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "io"
9 "io/fs"
910 "os"
1011 "os/exec"
1112 "path/filepath"
@@ -210,3 +211,18 @@ func WriteDescription(dir, desc string) error {
210211 }
211212 return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
212213}
214
215// DirSize sums file sizes under dir; unreadable entries count as zero.
216func 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
22
33import (
44 "database/sql"
5 "encoding/json"
56 "errors"
67 "fmt"
78 "time"
@@ -131,3 +132,62 @@ func (s *Store) SetUserAdmin(userID int64, admin bool) error {
131132 }
132133 return tx.Commit()
133134}
135
136// AdminRepo is one repository as the instance admin lists it. LastPush is
137// the newest push event, "" when nothing has been pushed.
138type 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.
151func (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}