diff --git a/go.mod b/go.mod index 2b39b99..caedcfc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module codeberg.org/nextgo/dbots go 1.25.8 require ( - codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3 + codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3 codeberg.org/ungo/gonsole v0.1.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/httplog/v3 v3.3.0 diff --git a/go.sum b/go.sum index 985213c..df647ea 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3 h1:Xn8IiW5uYGajGqYPXU0kS8zXxqRs5E/MTfYjm0O1KrI= codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs= +codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3 h1:k0NM+1XP3ebvfTvZfiHcyEZc0Drci5oxjZjE7L/xDdE= +codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs= codeberg.org/ungo/gonsole v0.1.0 h1:QE/qpSyovejIXzIh29tzmrwgDWfaKUqNTCMZPJEDfvY= codeberg.org/ungo/gonsole v0.1.0/go.mod h1:xPHtFAI4CeujxpwhkiVrRa6pMvWYtaNsC+jJIib5EgA= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= diff --git a/internal/config/config.go b/internal/config/config.go index b6566bd..b3a617e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,9 +6,25 @@ import ( ) type Config struct { - DatabaseURL string `env:"DATABASE_URL,required"` - Port int `env:"PORT,default=8080"` - DiscordToken string `env:"DISCORD_TOKEN"` + Database DatabaseConfig `env:"DATABASE_"` + Server ServerConfig `env:"SERVER_"` + Discord DiscordConfig `env:"DISCORD_"` +} + +type DatabaseConfig struct { + RedisURL string `env:"REDIS_URL"` + PostgresURL string `env:"POSTGRES_URL,required"` +} + +type ServerConfig struct { + Port int `env:"PORT,default=8080"` + Address string `env:"ADDRESS,default=127.0.0.1"` +} + +type DiscordConfig struct { + ClientID string `env:"CLIENT_ID,required"` + ClientSecret string `env:"CLIENT_SECRET,required"` + RedirectURI string `env:"REDIRECT_URI,default=http://localhost:8080/auth/callback"` } func LoadConfig() Config { diff --git a/internal/discord/client.go b/internal/discord/client.go new file mode 100644 index 0000000..d307f2f --- /dev/null +++ b/internal/discord/client.go @@ -0,0 +1,163 @@ +package discord + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const baseURL = "https://discord.com/api/v10" + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator"` + Avatar *string `json:"avatar"` + Email *string `json:"email"` + Verified bool `json:"verified"` +} + +type ApplicationRPCResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Description string `json:"description"` + Summary string `json:"summary"` + IsMonetized bool `json:"is_monetized"` + IsVerified bool `json:"is_verified"` + IsDiscoverable bool `json:"is_discoverable"` + Hook bool `json:"hook"` + GuildID string `json:"guild_id"` + StorefrontAvailable bool `json:"storefront_available"` + BotPublic bool `json:"bot_public"` + BotRequireCodeGrant bool `json:"bot_require_code_grant"` + TermsOfServiceURL string `json:"terms_of_service_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + CustomInstallURL string `json:"custom_install_url"` + VerifyKey string `json:"verify_key"` + Flags int `json:"flags"` + Tags []string `json:"tags"` +} + +type Client struct { + clientID string + clientSecret string + redirectURI string + httpClient *http.Client +} + +func NewClient(clientID, clientSecret, redirectURI string) *Client { + return &Client{ + clientID: clientID, + clientSecret: clientSecret, + redirectURI: redirectURI, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// AuthURL returns the Discord OAuth2 authorization URL. +// state should be a random, unguessable string stored in the session. +func (c *Client) AuthURL(state string) string { + v := url.Values{} + v.Set("client_id", c.clientID) + v.Set("redirect_uri", c.redirectURI) + v.Set("response_type", "code") + v.Set("scope", "identify email") + v.Set("state", state) + return baseURL + "/oauth2/authorize?" + v.Encode() +} + +// ExchangeCode exchanges an authorization code for an access token. +func (c *Client) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) { + body := url.Values{} + body.Set("grant_type", "authorization_code") + body.Set("code", code) + body.Set("redirect_uri", c.redirectURI) + + return c.tokenRequest(ctx, body) +} + +// RefreshToken exchanges a refresh token for a new access token. +func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { + body := url.Values{} + body.Set("grant_type", "refresh_token") + body.Set("refresh_token", refreshToken) + + return c.tokenRequest(ctx, body) +} + +// GetCurrentUser fetches the authenticated user's profile. +func (c *Client) GetCurrentUser(ctx context.Context, accessToken string) (*User, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/users/@me", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + var user User + if err := c.do(req, &user); err != nil { + return nil, err + } + return &user, nil +} + +// GetApplication fetches public metadata for a bot application. +func (c *Client) GetApplication(ctx context.Context, id string) (*ApplicationRPCResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/applications/%s/rpc", baseURL, id), nil) + if err != nil { + return nil, err + } + + var app ApplicationRPCResponse + if err := c.do(req, &app); err != nil { + return nil, err + } + return &app, nil +} + +func (c *Client) tokenRequest(ctx context.Context, body url.Values) (*TokenResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + baseURL+"/oauth2/token", + strings.NewReader(body.Encode()), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.clientID, c.clientSecret) + + var t TokenResponse + if err := c.do(req, &t); err != nil { + return nil, err + } + return &t, nil +} + +func (c *Client) do(req *http.Request, out any) error { + res, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("discord API error %d: %s", res.StatusCode, body) + } + + return json.NewDecoder(res.Body).Decode(out) +} diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go index 44b38fc..1d2908f 100644 --- a/internal/errorutil/errorutil.go +++ b/internal/errorutil/errorutil.go @@ -8,12 +8,15 @@ 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") - ErrMainOwnerAsCoOwner = errors.New("you cannot set yourself as a co-owner") - ErrBotNotExists = errors.New("bot does not exist inside Discord") + ErrBotNotFound = errors.New("Bot not found") + ErrBotAlreadyExists = errors.New("This bot has already been submitted") + ErrSearchFailed = errors.New("No bots found fitting this filter") + ErrMainOwnerAsCoOwner = errors.New("You cannot set yourself as a co-owner") + ErrBotNotExists = errors.New("Bot does not exist inside Discord") + + // validation + ErrInvalidID = errors.New("Invalid Discord id") + ErrInvalidOverview = errors.New("Bot overview must be between 15 and 50 characters long") ) type ErrResponse struct { diff --git a/internal/server/server.go b/internal/server/server.go index 209975b..a93709e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/discord" customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware" "codeberg.org/nextgo/dbots/services/admin" "codeberg.org/nextgo/dbots/services/bot" @@ -41,7 +42,12 @@ func NewServer(queries *db.Queries, config config.Config) *Server { } func (s *Server) Register() { - botRouter := bot.NewRouter(s.queries, s.config) + discordClient := discord.NewClient( + s.config.Discord.ClientID, + s.config.Discord.ClientSecret, + s.config.Discord.RedirectURI, + ) + botRouter := bot.NewRouter(s.queries, discordClient) adminRouter := admin.NewRouter(s.queries) s.router.Mount("/bots", botRouter.Routes()) diff --git a/main.go b/main.go index b071810..c691bb7 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { config := config.LoadConfig() ctx := context.Background() - conn, err := pgxpool.New(ctx, config.DatabaseURL) + conn, err := pgxpool.New(ctx, config.Database.PostgresURL) if err != nil { slog.Error("error connecting to postgres", "err", err) return @@ -31,5 +31,5 @@ func main() { queries := db.New(conn) server := server.NewServer(queries, config) - server.Start(config.Port) + server.Start(config.Server.Port) } diff --git a/services/bot/bot.go b/services/bot/bot.go index 974d7f2..b158f6b 100644 --- a/services/bot/bot.go +++ b/services/bot/bot.go @@ -2,16 +2,13 @@ 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/discord" "codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/middleware" "codeberg.org/nextgo/dbots/internal/paginate" @@ -42,52 +39,19 @@ type DiscordBot struct { } type Service struct { - q *db.Queries - c config.Config + q *db.Queries + client *discord.Client } -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 NewService(q *db.Queries, client *discord.Client) *Service { + return &Service{q: q, client: client} } func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) { user := middleware.GetUser(ctx) - apiBot, err := s.GetDiscordStats(data.ID) + application, err := s.client.GetApplication(ctx, data.ID) if err != nil { - return nil, errorutil.ErrBotNotExists + return nil, errorutil.ErrBotNotExists // todo: some old bots have different client id (not the same as the user id) } var count int32 @@ -96,11 +60,11 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e Overview: &data.Overview, IsSlash: data.IsSlash, InstallContext: data.InstallContext, - ImportedFrom: nil, - Username: apiBot.Bot.Username, - Avatar: &apiBot.Bot.Avatar, + ImportedFrom: nil, // reserved for custom endpoint, should always be nil here ok?? + Username: application.Name, + Avatar: &application.Icon, Description: &data.Description, - GuildCount: &apiBot.Bot.ApproximateGuildCount, + GuildCount: &count, InstallCount: &count, Prefix: data.Prefix, MainOwnerID: &user.ID, diff --git a/services/bot/input.go b/services/bot/input.go index 38ee73d..92210cc 100644 --- a/services/bot/input.go +++ b/services/bot/input.go @@ -24,6 +24,10 @@ func (c *CreateBotRequest) Bind(req *http.Request) error { return errorutil.ErrInvalidID } + if len(c.Overview) < 15 || len(c.Overview) > 50 { + return errorutil.ErrInvalidOverview + } + // todo: proper checks idk return nil diff --git a/services/bot/router.go b/services/bot/router.go index 89fa560..b9b9d81 100644 --- a/services/bot/router.go +++ b/services/bot/router.go @@ -4,8 +4,8 @@ import ( "errors" "net/http" - "codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/discord" "codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/middleware" "codeberg.org/nextgo/dbots/internal/paginate" @@ -19,15 +19,15 @@ type Router struct { router chi.Router } -func NewRouter(q *db.Queries, c config.Config) *Router { +func NewRouter(q *db.Queries, client *discord.Client) *Router { return &Router{ - bots: NewService(q, c), + bots: NewService(q, client), router: chi.NewRouter(), } } func (r *Router) Routes() http.Handler { - r.router.Get("/", r.listBots) // todo: deprecate this + r.router.Get("/search", r.searchBots) r.router.With(middleware.AuthGuardMiddleware).Post("/", r.submitBot) r.router.Route("/{botID}", func(router chi.Router) { router.Use(middleware.BotContext(r.bots.q)) @@ -73,7 +73,7 @@ func (r *Router) getBot(w http.ResponseWriter, req *http.Request) { render.JSON(w, req, bot) } -func (r *Router) listBots(w http.ResponseWriter, req *http.Request) { +func (r *Router) searchBots(w http.ResponseWriter, req *http.Request) { ctx := req.Context() query := req.URL.Query().Get("q") p := paginate.ParseParams(req)