diff --git a/.gitignore b/.gitignore index b97556f..c427a42 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ go.work.sum # pg .pgdata/ .pgsocket/ +logfile + # Editor/IDE # .idea/ # .vscode/ diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b6566bd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,27 @@ +package config + +import ( + "codeberg.org/ungo/env" + "codeberg.org/ungo/env/dotenv" +) + +type Config struct { + DatabaseURL string `env:"DATABASE_URL,required"` + Port int `env:"PORT,default=8080"` + DiscordToken string `env:"DISCORD_TOKEN"` +} + +func LoadConfig() Config { + var cfg Config + if err := env.Load(&cfg); err != nil { + panic(err) + } + + return cfg +} + +func init() { + if err := dotenv.Load(); err != nil { + panic(err) + } +} diff --git a/internal/db/bots.sql.go b/internal/db/bots.sql.go index 6d7fac1..7ca8112 100644 --- a/internal/db/bots.sql.go +++ b/internal/db/bots.sql.go @@ -134,34 +134,6 @@ func (q *Queries) GetBot(ctx context.Context, id string) (*Bot, error) { return &i, err } -const getBotByUsername = `-- name: GetBotByUsername :one -SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots -WHERE username = $1 -` - -func (q *Queries) GetBotByUsername(ctx context.Context, username string) (*Bot, error) { - row := q.db.QueryRow(ctx, getBotByUsername, username) - var i Bot - err := row.Scan( - &i.ID, - &i.Username, - &i.Avatar, - &i.Overview, - &i.Description, - &i.IsSlash, - &i.InstallContext, - &i.GuildCount, - &i.InstallCount, - &i.ImportedFrom, - &i.Prefix, - &i.CreatedAt, - &i.UpdatedAt, - &i.Status, - &i.MainOwnerID, - ) - return &i, err -} - const listBots = `-- name: ListBots :many SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots ORDER BY id diff --git a/internal/db/custom.go b/internal/db/custom.go index 1ad46a0..6220d92 100644 --- a/internal/db/custom.go +++ b/internal/db/custom.go @@ -1,5 +1,10 @@ package db +import ( + "encoding/json" + "fmt" +) + type InstallContext string const ( @@ -41,3 +46,13 @@ func (s BotStatus) IsValid() bool { return false } } + +type CoOwnerSlice []User + +func (c *CoOwnerSlice) Scan(src any) error { + b, ok := src.([]byte) + if !ok { + return fmt.Errorf("expected []byte, got %T", src) + } + return json.Unmarshal(b, c) +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 4bb911e..30fbce6 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -19,7 +19,6 @@ type Querier interface { DeleteBot(ctx context.Context, id string) error DeleteUser(ctx context.Context, id string) error GetBot(ctx context.Context, id string) (*Bot, error) - GetBotByUsername(ctx context.Context, username string) (*Bot, error) GetBotCoOwner(ctx context.Context, arg GetBotCoOwnerParams) (*BotCoOwner, error) GetUser(ctx context.Context, id string) (*User, error) GetUserByUsername(ctx context.Context, username string) (*User, error) diff --git a/internal/db/sql/queries/bots.sql b/internal/db/sql/queries/bots.sql index 6e8b5b3..8cdc657 100644 --- a/internal/db/sql/queries/bots.sql +++ b/internal/db/sql/queries/bots.sql @@ -2,10 +2,6 @@ SELECT * FROM bots WHERE id = $1; --- name: GetBotByUsername :one -SELECT * FROM bots -WHERE username = $1; - -- name: CreateBot :one INSERT INTO bots ( id, diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go index 011df0c..44b38fc 100644 --- a/internal/errorutil/errorutil.go +++ b/internal/errorutil/errorutil.go @@ -13,6 +13,7 @@ var ( ErrSearchFailed = errors.New("no bots found fitting this filter") ErrInvalidID = errors.New("invalid discord id") ErrMainOwnerAsCoOwner = errors.New("you cannot set yourself as a co-owner") + ErrBotNotExists = errors.New("bot does not exist inside Discord") ) type ErrResponse struct { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ab81ea3..dd469b5 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -2,7 +2,6 @@ package middleware import ( "context" - "log/slog" "net/http" "codeberg.org/nextgo/dbots/internal/db" @@ -24,17 +23,17 @@ func AuthMiddleware(q *db.Queries) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, err := q.GetUser(r.Context(), mainUserID) if err != nil { - user, err = q.CreateUser(r.Context(), db.CreateUserParams{ + user, _ = q.CreateUser(r.Context(), db.CreateUserParams{ ID: mainUserID, Username: "elisiei", }) - if err != nil { - slog.Error("eeeehhh", "err", err) - next.ServeHTTP(w, r) - return - } } + q.CreateUser(r.Context(), db.CreateUserParams{ + ID: "740358234002686004", + Username: "ulysses_ck", + }) //test + ctx := context.WithValue(r.Context(), userKey, user) // mocked next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/server/server.go b/internal/server/server.go index 1a1724c..209975b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,10 +6,11 @@ import ( "net/http" "os" - "codeberg.org/nextgo/dbots/internal/admin" - "codeberg.org/nextgo/dbots/internal/bot" + "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware" + "codeberg.org/nextgo/dbots/services/admin" + "codeberg.org/nextgo/dbots/services/bot" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -19,25 +20,28 @@ import ( type Server struct { router *chi.Mux queries *db.Queries + config config.Config } -func NewServer(queries *db.Queries) *Server { +func NewServer(queries *db.Queries, config config.Config) *Server { router := chi.NewMux() router.Use(httplog.RequestLogger(slog.Default(), &httplog.Options{})) router.Use(middleware.Recoverer) router.Use(middleware.RequestID) router.Use(middleware.RealIP) - router.Use(customMiddlewares.AuthMiddleware(queries)) + router.Use(customMiddlewares.AuthMiddleware(queries)) // todo: use this middleware only when necessary + // i am using this globally cus it uses mocked data lol return &Server{ router: router, queries: queries, + config: config, } } func (s *Server) Register() { - botRouter := bot.NewRouter(s.queries) + botRouter := bot.NewRouter(s.queries, s.config) adminRouter := admin.NewRouter(s.queries) s.router.Mount("/bots", botRouter.Routes()) diff --git a/main.go b/main.go index 2c3f5cb..b071810 100644 --- a/main.go +++ b/main.go @@ -5,33 +5,21 @@ import ( "log/slog" "os" + "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/server" - "codeberg.org/ungo/env" - "codeberg.org/ungo/env/dotenv" "codeberg.org/ungo/gonsole" "github.com/jackc/pgx/v5/pgxpool" ) -type Config struct { - DatabaseURL string `env:"DATABASE_URL,required"` - Port int `env:"PORT,default=8080"` -} - -var config Config - func init() { gonsoleHandler := gonsole.New(os.Stdout, slog.LevelDebug) slogHandler := slog.New(gonsoleHandler) slog.SetDefault(slogHandler) - - dotenv.MustLoad() - if err := env.Load(&config); err != nil { - panic(err) - } } func main() { + config := config.LoadConfig() ctx := context.Background() conn, err := pgxpool.New(ctx, config.DatabaseURL) @@ -41,7 +29,7 @@ func main() { } queries := db.New(conn) - server := server.NewServer(queries) + server := server.NewServer(queries, config) server.Start(config.Port) } diff --git a/internal/admin/admin.go b/services/admin/admin.go similarity index 79% rename from internal/admin/admin.go rename to services/admin/admin.go index 634951d..4689dce 100644 --- a/internal/admin/admin.go +++ b/services/admin/admin.go @@ -20,6 +20,22 @@ func NewService(q *db.Queries) *Service { return &Service{q: q} } +func (s *Service) UpdateStatus(ctx context.Context, id string, status db.BotStatus) (*db.Bot, error) { + bot, err := s.q.UpdateBotStatus(ctx, db.UpdateBotStatusParams{ + ID: id, + Status: status, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errorutil.ErrNotFound.Err + } else { + return nil, err + } + } + + return bot, nil +} + func (s *Service) Get(ctx context.Context, id string) (*db.Bot, error) { bot, err := s.q.GetBot(ctx, id) if err != nil { diff --git a/services/admin/input.go b/services/admin/input.go new file mode 100644 index 0000000..8676caa --- /dev/null +++ b/services/admin/input.go @@ -0,0 +1,20 @@ +package admin + +import ( + "fmt" + "net/http" + + "codeberg.org/nextgo/dbots/internal/db" +) + +type UpdateBotStatusRequest struct { + NewStatus db.BotStatus `json:"new_status"` +} + +func (c *UpdateBotStatusRequest) Bind(req *http.Request) error { + if !c.NewStatus.IsValid() { + return fmt.Errorf("'%s' is not a valid bot status", c.NewStatus.String()) + } + + return nil +} diff --git a/internal/admin/router.go b/services/admin/router.go similarity index 65% rename from internal/admin/router.go rename to services/admin/router.go index 78af96a..c198b1c 100644 --- a/internal/admin/router.go +++ b/services/admin/router.go @@ -1,6 +1,7 @@ package admin import ( + "errors" "net/http" "codeberg.org/nextgo/dbots/internal/db" @@ -30,12 +31,36 @@ func (r *Router) Routes() http.Handler { router.Route("/{botID}", func(b chi.Router) { b.Use(middleware.BotContext(r.admin.q)) b.Get("/", r.getBot) + b.Post("/status", r.updateStatus) }) }) return r.router } +func (r *Router) updateStatus(w http.ResponseWriter, req *http.Request) { + var data UpdateBotStatusRequest + ctx := req.Context() + bot := middleware.GetBot(ctx) + + if err := render.Bind(req, &data); err != nil { + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + return + } + + updatedBot, err := r.admin.UpdateStatus(ctx, bot.ID, data.NewStatus) + if err != nil { + if errors.Is(err, errorutil.ErrNotFound.Err) { + render.Render(w, req, errorutil.ErrNotFound) + } else { + render.Render(w, req, errorutil.ErrInternal(err)) + } + return + } + + render.JSON(w, req, updatedBot) +} + func (r *Router) getBot(w http.ResponseWriter, req *http.Request) { ctx := req.Context() bot := middleware.GetBot(ctx) diff --git a/internal/bot/bot.go b/services/bot/bot.go similarity index 71% rename from internal/bot/bot.go rename to services/bot/bot.go index 2417bb9..974d7f2 100644 --- a/internal/bot/bot.go +++ b/services/bot/bot.go @@ -2,11 +2,15 @@ package bot import ( "context" + "encoding/json" "errors" "fmt" + "io" "log/slog" + "net/http" "strconv" + "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/middleware" @@ -15,16 +19,76 @@ import ( "github.com/jackc/pgx/v5/pgconn" ) -type Service struct { - q *db.Queries +type DiscordApiBot struct { + Application DiscordApplication `json:"application"` + Bot DiscordBot `json:"bot"` } -func NewService(q *db.Queries) *Service { - return &Service{q: q} +type DiscordApplication struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Description string `json:"description"` + IsVerified bool `json:"is_verified"` + BotPublic bool `json:"bot_public"` +} + +type DiscordBot struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Bot bool `json:"bot"` + ApproximateGuildCount int32 `json:"approximate_guild_count"` +} + +type Service struct { + q *db.Queries + c config.Config +} + +func NewService(q *db.Queries, c config.Config) *Service { + return &Service{q: q, c: c} +} + +// todo: finish +func (s *Service) GetDiscordStats(id string) (*DiscordApiBot, error) { + req, err := http.NewRequest( + http.MethodGet, + fmt.Sprintf("https://discord.com/api/v10/oauth2/authorize?client_id=%s&scope=bot", id), + nil, + ) // todo: since this method is a bit "illegal", i should move to rpc endpoint (which does not provide guild count) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", s.c.DiscordToken) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var bot DiscordApiBot + if err = json.Unmarshal(body, &bot); err != nil { + return nil, err + } + + return &bot, nil + } func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) { user := middleware.GetUser(ctx) + apiBot, err := s.GetDiscordStats(data.ID) + if err != nil { + return nil, errorutil.ErrBotNotExists + } var count int32 b, err := s.q.CreateBot(ctx, db.CreateBotParams{ @@ -33,10 +97,10 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e IsSlash: data.IsSlash, InstallContext: data.InstallContext, ImportedFrom: nil, - Username: data.Username, - Avatar: &data.Avatar, + Username: apiBot.Bot.Username, + Avatar: &apiBot.Bot.Avatar, Description: &data.Description, - GuildCount: &count, + GuildCount: &apiBot.Bot.ApproximateGuildCount, InstallCount: &count, Prefix: data.Prefix, MainOwnerID: &user.ID, @@ -54,29 +118,14 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e // 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 + // so if co-owners fail it's because the co-owner isn't registered in the botlist, + // however, i should handle this but i'm lazy as fuck. for now i will only output + // a silent error log instead of aborting the whole request. } 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 { @@ -141,18 +190,6 @@ func (s *Service) ListCoOwners(ctx context.Context, botID string) ([]*db.User, e 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) { bot, err := s.q.GetBot(ctx, id) if err != nil { @@ -193,3 +230,32 @@ func (s *Service) List( return paginate.NewPage(bots, int(total), p), 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 +} + +// 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 +} diff --git a/internal/bot/input.go b/services/bot/input.go similarity index 88% rename from internal/bot/input.go rename to services/bot/input.go index a7e43c5..38ee73d 100644 --- a/internal/bot/input.go +++ b/services/bot/input.go @@ -10,8 +10,6 @@ import ( type CreateBotRequest struct { ID string `json:"id"` - Username string `json:"username"` - Avatar string `json:"avatar"` Overview string `json:"overview"` Description string `json:"description"` IsSlash bool `json:"is_slash"` diff --git a/internal/bot/router.go b/services/bot/router.go similarity index 96% rename from internal/bot/router.go rename to services/bot/router.go index 5b1070c..89fa560 100644 --- a/internal/bot/router.go +++ b/services/bot/router.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" + "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/middleware" @@ -18,9 +19,9 @@ type Router struct { router chi.Router } -func NewRouter(q *db.Queries) *Router { +func NewRouter(q *db.Queries, c config.Config) *Router { return &Router{ - bots: NewService(q), + bots: NewService(q, c), router: chi.NewRouter(), } }