Compare commits

...

3 commits

Author SHA1 Message Date
870c2357b7
chore: add to-do 2026-04-19 01:41:04 +02:00
55751db156
feat: auth base 2026-04-19 01:40:26 +02:00
434f5a82bb
refactor: bots 2026-04-19 00:44:48 +02:00
15 changed files with 373 additions and 86 deletions

View file

@ -1,3 +1,8 @@
# dbots # dbots
simple discord botlist simple discord botlist
## todo
* [ ] ratelimits
* [ ] complete auth (with paseto)

2
go.mod
View file

@ -3,7 +3,7 @@ module codeberg.org/nextgo/dbots
go 1.25.8 go 1.25.8
require ( 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 codeberg.org/ungo/gonsole v0.1.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httplog/v3 v3.3.0 github.com/go-chi/httplog/v3 v3.3.0

2
go.sum
View file

@ -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 h1:Xn8IiW5uYGajGqYPXU0kS8zXxqRs5E/MTfYjm0O1KrI=
codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs= 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 h1:QE/qpSyovejIXzIh29tzmrwgDWfaKUqNTCMZPJEDfvY=
codeberg.org/ungo/gonsole v0.1.0/go.mod h1:xPHtFAI4CeujxpwhkiVrRa6pMvWYtaNsC+jJIib5EgA= codeberg.org/ungo/gonsole v0.1.0/go.mod h1:xPHtFAI4CeujxpwhkiVrRa6pMvWYtaNsC+jJIib5EgA=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=

View file

@ -6,9 +6,25 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string `env:"DATABASE_URL,required"` Database DatabaseConfig `env:"DATABASE_"`
Port int `env:"PORT,default=8080"` Server ServerConfig `env:"SERVER_"`
DiscordToken string `env:"DISCORD_TOKEN"` 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 { func LoadConfig() Config {

163
internal/discord/client.go Normal file
View file

@ -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)
}

View file

@ -8,12 +8,15 @@ import (
) )
var ( var (
ErrBotNotFound = errors.New("bot not found") ErrBotNotFound = errors.New("Bot not found")
ErrBotAlreadyExists = errors.New("this bot has already been submitted") ErrBotAlreadyExists = errors.New("This bot has already been submitted")
ErrSearchFailed = errors.New("no bots found fitting this filter") 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")
ErrMainOwnerAsCoOwner = errors.New("you cannot set yourself as a co-owner") ErrBotNotExists = errors.New("Bot does not exist inside Discord")
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 { type ErrResponse struct {

View file

@ -13,29 +13,14 @@ type contextKey string
const userKey contextKey = "user" const userKey contextKey = "user"
var mainUserID = "774287684944134155"
// AuthMiddleware is a middleware to set the user as context value. // AuthMiddleware is a middleware to set the user as context value.
// this middleware does not prevents the user from accessing the route // this middleware does not prevents the user from accessing the route
// if not authorized. // if not authorized.
func AuthMiddleware(q *db.Queries) func(http.Handler) http.Handler { func AuthMiddleware(q *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := q.GetUser(r.Context(), mainUserID) // ctx := context.WithValue(r.Context(), userKey, user) // mocked
if err != nil { next.ServeHTTP(w, r)
user, _ = q.CreateUser(r.Context(), db.CreateUserParams{
ID: mainUserID,
Username: "elisiei",
})
}
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))
}) })
} }
} }

View file

@ -8,8 +8,10 @@ import (
"codeberg.org/nextgo/dbots/internal/config" "codeberg.org/nextgo/dbots/internal/config"
"codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/discord"
customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware" customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware"
"codeberg.org/nextgo/dbots/services/admin" "codeberg.org/nextgo/dbots/services/admin"
"codeberg.org/nextgo/dbots/services/auth"
"codeberg.org/nextgo/dbots/services/bot" "codeberg.org/nextgo/dbots/services/bot"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -41,17 +43,25 @@ func NewServer(queries *db.Queries, config config.Config) *Server {
} }
func (s *Server) Register() { 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,
)
authRouter := auth.NewRouter(s.queries, discordClient)
botRouter := bot.NewRouter(s.queries, discordClient)
adminRouter := admin.NewRouter(s.queries) adminRouter := admin.NewRouter(s.queries)
s.router.Mount("/auth", authRouter.Routes())
s.router.Mount("/bots", botRouter.Routes()) s.router.Mount("/bots", botRouter.Routes())
s.router.Mount("/admin", adminRouter.Routes()) s.router.Mount("/admin", adminRouter.Routes())
} }
func (s *Server) Start(port int) { func (s *Server) Start(addr string, port int) {
s.Register() s.Register()
slog.Info("server started", "port", port) slog.Info("server started", "addr", addr, "port", port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", port), s.router); err != nil { if err := http.ListenAndServe(fmt.Sprintf("%s:%d", addr, port), s.router); err != nil {
slog.Error("error starting server", "port", port, "err", err) slog.Error("error starting server", "port", port, "err", err)
os.Exit(1) os.Exit(1)
} }

View file

@ -22,7 +22,7 @@ func main() {
config := config.LoadConfig() config := config.LoadConfig()
ctx := context.Background() ctx := context.Background()
conn, err := pgxpool.New(ctx, config.DatabaseURL) conn, err := pgxpool.New(ctx, config.Database.PostgresURL)
if err != nil { if err != nil {
slog.Error("error connecting to postgres", "err", err) slog.Error("error connecting to postgres", "err", err)
return return
@ -31,5 +31,5 @@ func main() {
queries := db.New(conn) queries := db.New(conn)
server := server.NewServer(queries, config) server := server.NewServer(queries, config)
server.Start(config.Port) server.Start(config.Server.Address, config.Server.Port)
} }

View file

@ -26,6 +26,8 @@ func NewRouter(q *db.Queries) *Router {
} }
func (r *Router) Routes() http.Handler { func (r *Router) Routes() http.Handler {
r.router.Use(middleware.AuthGuardMiddleware) // todo: admin middleware
r.router.Route("/bots", func(router chi.Router) { r.router.Route("/bots", func(router chi.Router) {
router.Get("/", r.listBots) router.Get("/", r.listBots)
router.Route("/{botID}", func(b chi.Router) { router.Route("/{botID}", func(b chi.Router) {

65
services/auth/auth.go Normal file
View file

@ -0,0 +1,65 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/discord"
"github.com/jackc/pgx/v5"
)
// todo: api keysssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss
// or sessions????????????
type Service struct {
q *db.Queries
client *discord.Client
}
func NewService(q *db.Queries, client *discord.Client) *Service {
return &Service{q: q, client: client}
}
// GenerateState produces a random OAuth state parameter.
func GenerateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Callback handles the OAuth callback: exchanges the code, fetches the user,
// and upserts them in the database. Returns the local db.User.
func (s *Service) Callback(ctx context.Context, code string) (*db.User, error) {
token, err := s.client.ExchangeCode(ctx, code)
if err != nil {
return nil, err
}
dUser, err := s.client.GetCurrentUser(ctx, token.AccessToken)
if err != nil {
return nil, err
}
user, err := s.q.UpdateUser(ctx, db.UpdateUserParams{
ID: dUser.ID,
Username: &dUser.Username,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
user, err = s.q.CreateUser(ctx, db.CreateUserParams{
ID: dUser.ID,
Username: dUser.Username,
})
if err != nil {
return nil, err
}
}
return nil, err
}
return user, nil
}

68
services/auth/router.go Normal file
View file

@ -0,0 +1,68 @@
package auth
import (
"net/http"
"codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/discord"
"codeberg.org/nextgo/dbots/internal/errorutil"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Router struct {
auth *Service
router chi.Router
}
func NewRouter(q *db.Queries, client *discord.Client) *Router {
return &Router{
auth: NewService(q, client),
router: chi.NewRouter(),
}
}
func (r *Router) Routes() http.Handler {
r.router.Get("/login", r.login)
r.router.Get("/callback", r.callback)
r.router.Post("/logout", r.logout)
r.router.Get("/me", r.me)
return r.router
}
func (r *Router) me(w http.ResponseWriter, req *http.Request) {
}
func (r *Router) login(w http.ResponseWriter, req *http.Request) {
state, err := GenerateState()
if err != nil {
render.Render(w, req, errorutil.ErrInternal(err))
return
}
// todo: store state in a short-lived cookie or session before redirecting
http.Redirect(w, req, r.auth.client.AuthURL(state), http.StatusFound)
}
func (r *Router) callback(w http.ResponseWriter, req *http.Request) {
// todo: validate state matches what was stored
code := req.URL.Query().Get("code")
if code == "" {
render.Render(w, req, errorutil.ErrInvalidRequest(nil))
return
}
user, err := r.auth.Callback(req.Context(), code)
if err != nil {
render.Render(w, req, errorutil.ErrInternal(err))
return
}
// todo: create a session, set a cookie, then redirect to "/"
render.JSON(w, req, user)
}
func (r *Router) logout(w http.ResponseWriter, req *http.Request) {
// todo: delete session
render.NoContent(w, req)
}

View file

@ -2,16 +2,13 @@ package bot
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"strconv" "strconv"
"codeberg.org/nextgo/dbots/internal/config"
"codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/discord"
"codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/errorutil"
"codeberg.org/nextgo/dbots/internal/middleware" "codeberg.org/nextgo/dbots/internal/middleware"
"codeberg.org/nextgo/dbots/internal/paginate" "codeberg.org/nextgo/dbots/internal/paginate"
@ -42,52 +39,19 @@ type DiscordBot struct {
} }
type Service struct { type Service struct {
q *db.Queries q *db.Queries
c config.Config client *discord.Client
} }
func NewService(q *db.Queries, c config.Config) *Service { func NewService(q *db.Queries, client *discord.Client) *Service {
return &Service{q: q, c: c} return &Service{q: q, client: client}
}
// 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) { func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) {
user := middleware.GetUser(ctx) user := middleware.GetUser(ctx)
apiBot, err := s.GetDiscordStats(data.ID) application, err := s.client.GetApplication(ctx, data.ID)
if err != nil { 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 var count int32
@ -96,11 +60,11 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e
Overview: &data.Overview, Overview: &data.Overview,
IsSlash: data.IsSlash, IsSlash: data.IsSlash,
InstallContext: data.InstallContext, InstallContext: data.InstallContext,
ImportedFrom: nil, ImportedFrom: nil, // reserved for custom endpoint, should always be nil here ok??
Username: apiBot.Bot.Username, Username: application.Name,
Avatar: &apiBot.Bot.Avatar, Avatar: &application.Icon,
Description: &data.Description, Description: &data.Description,
GuildCount: &apiBot.Bot.ApproximateGuildCount, GuildCount: &count,
InstallCount: &count, InstallCount: &count,
Prefix: data.Prefix, Prefix: data.Prefix,
MainOwnerID: &user.ID, MainOwnerID: &user.ID,

View file

@ -24,6 +24,10 @@ func (c *CreateBotRequest) Bind(req *http.Request) error {
return errorutil.ErrInvalidID return errorutil.ErrInvalidID
} }
if len(c.Overview) < 15 || len(c.Overview) > 50 {
return errorutil.ErrInvalidOverview
}
// todo: proper checks idk // todo: proper checks idk
return nil return nil

View file

@ -4,8 +4,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"codeberg.org/nextgo/dbots/internal/config"
"codeberg.org/nextgo/dbots/internal/db" "codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/discord"
"codeberg.org/nextgo/dbots/internal/errorutil" "codeberg.org/nextgo/dbots/internal/errorutil"
"codeberg.org/nextgo/dbots/internal/middleware" "codeberg.org/nextgo/dbots/internal/middleware"
"codeberg.org/nextgo/dbots/internal/paginate" "codeberg.org/nextgo/dbots/internal/paginate"
@ -19,15 +19,15 @@ type Router struct {
router chi.Router router chi.Router
} }
func NewRouter(q *db.Queries, c config.Config) *Router { func NewRouter(q *db.Queries, client *discord.Client) *Router {
return &Router{ return &Router{
bots: NewService(q, c), bots: NewService(q, client),
router: chi.NewRouter(), router: chi.NewRouter(),
} }
} }
func (r *Router) Routes() http.Handler { 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.With(middleware.AuthGuardMiddleware).Post("/", r.submitBot)
r.router.Route("/{botID}", func(router chi.Router) { r.router.Route("/{botID}", func(router chi.Router) {
router.Use(middleware.BotContext(r.bots.q)) 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) 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() ctx := req.Context()
query := req.URL.Query().Get("q") query := req.URL.Query().Get("q")
p := paginate.ParseParams(req) p := paginate.ParseParams(req)