From 9df1d0de563c0d1ba366b7acf8f7ab83448d5489 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Fri, 17 Apr 2026 22:37:20 +0200 Subject: [PATCH] feat(bots): co-owners handling --- internal/bot/bot.go | 112 ++++++++++++++++++++++++++++++-- internal/bot/input.go | 2 + internal/bot/router.go | 71 ++++++++++++++++++-- internal/errorutil/errorutil.go | 6 ++ 4 files changed, 177 insertions(+), 14 deletions(-) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index add1b40..893e708 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -3,7 +3,9 @@ package bot import ( "context" "errors" + "fmt" "log/slog" + "strconv" "codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/errorutil" @@ -23,7 +25,7 @@ func NewService(q *db.Queries) *Service { func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) { user := middleware.GetUser(ctx) - var count int32 = 0 + var count int32 b, err := s.q.CreateBot(ctx, db.CreateBotParams{ ID: data.ID, Overview: &data.Overview, @@ -41,18 +43,115 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e if err != nil { slog.Error("error submitting bot", "err", err, "bot_id", data.ID) var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - switch pgErr.Code { - case "23505": - return nil, errorutil.ErrBotAlreadyExists - } + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, errorutil.ErrBotAlreadyExists } return nil, err } + if err := s.addCoOwners(ctx, b.ID, user.ID, data.CoOwners); err != nil { + // Bot was created but co-owners failed. Log and surface the error. + // ideally this whole operation runs in a transaction but who cares lol. + slog.Error("error adding co-owners after submit", "err", err, "bot_id", b.ID) + return nil, err + } + return b, nil } +// addCoOwners validates and inserts each co-owner for a bot. +// mainOwnerID is provided to reject attempts to set the main owner as a co-owner. +func (s *Service) addCoOwners(ctx context.Context, botID, mainOwnerID string, coOwnerIDs []string) error { + for _, id := range coOwnerIDs { + if err := validateCoOwner(id, mainOwnerID); err != nil { + return err + } + if err := s.q.AddBotCoOwner(ctx, db.AddBotCoOwnerParams{ + BotID: botID, + UserID: id, + }); err != nil { + return fmt.Errorf("adding co-owner %s: %w", id, err) + } + } + return nil +} + +// AddCoOwner adds a single co-owner to a bot. +// Only the main owner of the bot may call this. +func (s *Service) AddCoOwner(ctx context.Context, botID, coOwnerID string) error { + user := middleware.GetUser(ctx) + + bot, err := s.q.GetBot(ctx, botID) + if err != nil { + return errorutil.ErrNotFound.Err + } + + if bot.MainOwnerID == nil || *bot.MainOwnerID != user.ID { + return errorutil.ErrForbidden.Err + } + + if err := validateCoOwner(coOwnerID, user.ID); err != nil { + return err + } + + if err := s.q.AddBotCoOwner(ctx, db.AddBotCoOwnerParams{ + BotID: botID, + UserID: coOwnerID, + }); err != nil { + slog.Error("error adding co-owner", "err", err, "bot_id", botID, "user_id", coOwnerID) + return err + } + + return nil +} + +// RemoveCoOwner removes a co-owner from a bot. +// Only the main owner of the bot may call this. +func (s *Service) RemoveCoOwner(ctx context.Context, botID, coOwnerID string) error { + user := middleware.GetUser(ctx) + + bot, err := s.q.GetBot(ctx, botID) + if err != nil { + return errorutil.ErrNotFound.Err + } + + if bot.MainOwnerID == nil || *bot.MainOwnerID != user.ID { + return errorutil.ErrForbidden.Err + } + + if err := s.q.RemoveBotCoOwner(ctx, db.RemoveBotCoOwnerParams{ + BotID: botID, + UserID: coOwnerID, + }); err != nil { + slog.Error("error removing co-owner", "err", err, "bot_id", botID, "user_id", coOwnerID) + return err + } + + return nil +} + +// ListCoOwners returns the users who are co-owners of a bot. +func (s *Service) ListCoOwners(ctx context.Context, botID string) ([]*db.User, error) { + owners, err := s.q.ListCoOwnersByBot(ctx, botID) + if err != nil { + slog.Error("error listing co-owners", "err", err, "bot_id", botID) + return nil, err + } + return owners, nil +} + +// validateCoOwner checks that the given ID is a valid Discord snowflake +// and is not the same as the main owner. +func validateCoOwner(id, mainOwnerID string) error { + if _, err := strconv.ParseUint(id, 10, 64); err != nil { + return errorutil.ErrInvalidID + } + if id == mainOwnerID { + return errorutil.ErrMainOwnerAsCoOwner + } + return nil +} + func (s *Service) Get(ctx context.Context, id string) (*db.Bot, error) { return s.q.GetBot(ctx, id) } @@ -75,7 +174,6 @@ func (s *Service) List( Status: db.BotStatusApproved, Limit: p.Limit, Offset: p.Offset, - // todo: query }) if err != nil { slog.Error("error listing bots", "query", query, "err", err) diff --git a/internal/bot/input.go b/internal/bot/input.go index 2a82d14..a7e43c5 100644 --- a/internal/bot/input.go +++ b/internal/bot/input.go @@ -26,5 +26,7 @@ func (c *CreateBotRequest) Bind(req *http.Request) error { return errorutil.ErrInvalidID } + // todo: proper checks idk + return nil } diff --git a/internal/bot/router.go b/internal/bot/router.go index 50a0651..f7a56d8 100644 --- a/internal/bot/router.go +++ b/internal/bot/router.go @@ -31,28 +31,32 @@ func NewRouter(q *db.Queries) *Router { } func (r *Router) Routes() http.Handler { - r.router. - With(middleware.AuthGuardMiddleware). - Post("/", r.submitBot) - r.router.Get("/", r.listBots) + r.router.Get("/", r.listBots) // todo: deprecate this + r.router.With(middleware.AuthGuardMiddleware).Post("/", r.submitBot) r.router.Route("/{botID}", func(router chi.Router) { router.Use(r.BotContext) router.With(r.BotCache).Get("/", r.getBot) + router.Route("/co-owners", func(c chi.Router) { + c.Use(middleware.AuthGuardMiddleware) + c.Get("/", r.listCoOwners) + c.Post("/{userID}", r.addCoOwner) + c.Delete("/{userID}", r.removeCoOwner) + }) }) return r.router } func (r *Router) submitBot(w http.ResponseWriter, req *http.Request) { - data := &CreateBotRequest{} + var data CreateBotRequest - if err := render.Bind(req, data); err != nil { + if err := render.Bind(req, &data); err != nil { render.Render(w, req, errorutil.ErrInvalidRequest(err)) return } ctx := req.Context() - bot, err := r.bots.Submit(ctx, *data) + bot, err := r.bots.Submit(ctx, data) if errors.Is(err, errorutil.ErrBotAlreadyExists) { render.Render(w, req, errorutil.ErrInvalidRequest(err)) } else { @@ -89,6 +93,59 @@ func (r *Router) listBots(w http.ResponseWriter, req *http.Request) { render.JSON(w, req, page) } +func (r *Router) listCoOwners(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + bot := ctx.Value(botKey).(*db.Bot) + + owners, err := r.bots.ListCoOwners(ctx, bot.ID) + if err != nil { + render.Render(w, req, errorutil.ErrInternal(err)) + return + } + + render.JSON(w, req, owners) +} + +func (r *Router) addCoOwner(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + bot := ctx.Value(botKey).(*db.Bot) + userID := chi.URLParam(req, "userID") + + if err := r.bots.AddCoOwner(ctx, bot.ID, userID); err != nil { + switch { + case errors.Is(err, errorutil.ErrNotFound.Err): + render.Render(w, req, errorutil.ErrNotFound) + case errors.Is(err, errorutil.ErrForbidden.Err): + render.Render(w, req, errorutil.ErrForbidden) + default: + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + } + return + } + + render.NoContent(w, req) +} + +func (r *Router) removeCoOwner(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + bot := ctx.Value(botKey).(*db.Bot) + userID := chi.URLParam(req, "userID") + + if err := r.bots.RemoveCoOwner(ctx, bot.ID, userID); err != nil { + switch { + case errors.Is(err, errorutil.ErrNotFound.Err): + render.Render(w, req, errorutil.ErrNotFound) + case errors.Is(err, errorutil.ErrForbidden.Err): + render.Render(w, req, errorutil.ErrForbidden) + default: + render.Render(w, req, errorutil.ErrInternal(err)) + } + return + } + + render.NoContent(w, req) +} + func (r *Router) BotContext(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ctx := req.Context() diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go index 3bc34a6..011df0c 100644 --- a/internal/errorutil/errorutil.go +++ b/internal/errorutil/errorutil.go @@ -8,6 +8,7 @@ import ( ) var ( + ErrBotNotFound = errors.New("bot not found") ErrBotAlreadyExists = errors.New("this bot has already been submitted") ErrSearchFailed = errors.New("no bots found fitting this filter") ErrInvalidID = errors.New("invalid discord id") @@ -54,6 +55,11 @@ var ErrUnauthorized = &ErrResponse{ StatusText: "unauthorized", } +var ErrForbidden = &ErrResponse{ + HTTPStatusCode: 403, + StatusText: "forbidden", +} + func errString(err error) string { if err == nil { return ""