refactor: bot submit

This commit is contained in:
Elisiei Yehorov 2026-04-19 15:46:49 +02:00
parent 611619f180
commit 74149e5d6d
Signed by: elisiei
GPG key ID: BA1D158DCE3DF089
10 changed files with 85 additions and 34 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# database
DATABASE_POSTGRES_URL=postgresql://elisiei@localhost:5432/postgres
# discord
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# auth
AUTH_PASETO_KEY=
# goose
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgresql://elisiei@localhost:5432/postgres
GOOSE_MIGRATION_DIR=./internal/db/sql/migrations
PRODUCTION=false

View file

@ -1,8 +1,44 @@
# dbots # dbots
simple discord botlist discord botlist.
## todo ## setup
* [ ] ratelimits ### 1. clone and install deps
* [ ] complete auth (with paseto)
### 2. create a `.env` file
```sh
cp .env.example .env
```
then fill it in (see [environment variables](#environment-variables) below).
### 3. run migrations
```sh
goose -dir internal/db/sql/migrations postgres "$DATABASE_POSTGRES_URL" up
```
### 4. start the server
```sh
go run .
```
## environment variables
| variable | required | default | description |
|----------|----------|---------|-------------|
| `DATABASE_POSTGRES_URL` | yes | — | postgres connection string, e.g. `postgres://user:pass@localhost:5432/dbots` |
| `DATABASE_REDIS_URL` | no | — | redis connection string (not used yet) |
| `DISCORD_CLIENT_ID` | yes | — | your discord application's client id |
| `DISCORD_CLIENT_SECRET` | yes | — | your discord application's client secret |
| `DISCORD_REDIRECT_URI` | no | `http://localhost:8080/auth/callback` | must match what's set in your discord app's oauth2 redirect urls |
| `AUTH_PASETO_KEY` | yes | — | 32-byte key as 64 hex chars. generate with `openssl rand -hex 32` |
| `SERVER_PORT` | no | `8080` | port to listen on |
| `SERVER_ADDRESS` | no | `127.0.0.1` | address to bind to |
# license
cc0 1.0

BIN
dbots

Binary file not shown.

View file

@ -10,6 +10,7 @@ type Config struct {
Server ServerConfig `env:"SERVER_"` Server ServerConfig `env:"SERVER_"`
Discord DiscordConfig `env:"DISCORD_"` Discord DiscordConfig `env:"DISCORD_"`
Auth AuthConfig `env:"AUTH_"` Auth AuthConfig `env:"AUTH_"`
Production bool `env:"PRODUCTION,default=true"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {

View file

@ -13,6 +13,7 @@ var (
ErrSearchFailed = errors.New("No bots found fitting this filter") ErrSearchFailed = errors.New("No bots found fitting this filter")
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")
ErrBotPrivate = errors.New("You cannot submit private bots")
// validation // validation
ErrInvalidID = errors.New("Invalid Discord id") ErrInvalidID = errors.New("Invalid Discord id")

View file

@ -12,12 +12,11 @@ import (
type contextKey string type contextKey string
// UserContextKey is exported so service routers can read the user from context.
var UserContextKey contextKey = "user" var UserContextKey contextKey = "user"
// AuthMiddleware reads the PASETO session cookie, verifies it, checks it // AuthMiddleware reads the PASETO session cookie, verifies it, checks it
// hasn't been revoked in the DB, then sets the *db.User on the context. // hasn't been revoked in the db, then sets the *db.User on the context.
// Does NOT block unauthenticated requests — use AuthGuardMiddleware for that. // does not block unauthenticated requests
func AuthMiddleware(q *db.Queries, pasetoKeyHex string) func(http.Handler) http.Handler { func AuthMiddleware(q *db.Queries, pasetoKeyHex string) func(http.Handler) http.Handler {
key, err := token.KeyFromHex(pasetoKeyHex) key, err := token.KeyFromHex(pasetoKeyHex)
if err != nil { if err != nil {
@ -34,13 +33,11 @@ func AuthMiddleware(q *db.Queries, pasetoKeyHex string) func(http.Handler) http.
claims, err := token.VerifyToken(key, c.Value) claims, err := token.VerifyToken(key, c.Value)
if err != nil { if err != nil {
// Expired or tampered — clear the cookie and continue as anonymous.
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"}) http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"})
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
// Check the session hasn't been revoked server-side.
if _, err := q.GetSession(r.Context(), claims.JTI); err != nil { if _, err := q.GetSession(r.Context(), claims.JTI); err != nil {
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"}) http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"})
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View file

@ -32,8 +32,7 @@ func NewServer(queries *db.Queries, config config.Config) *Server {
router.Use(middleware.Recoverer) router.Use(middleware.Recoverer)
router.Use(middleware.RequestID) router.Use(middleware.RequestID)
router.Use(middleware.RealIP) router.Use(middleware.RealIP)
router.Use(customMiddlewares.AuthMiddleware(queries, config.Auth.PasetoKey)) // todo: use this middleware only when necessary router.Use(customMiddlewares.AuthMiddleware(queries, config.Auth.PasetoKey))
// i am using this globally cus it uses mocked data lol
return &Server{ return &Server{
router: router, router: router,
@ -49,7 +48,7 @@ func (s *Server) Register() {
s.config.Discord.RedirectURI, s.config.Discord.RedirectURI,
) )
authRouter := auth.NewRouter(s.queries, discordClient, s.config.Auth.PasetoKey) authRouter := auth.NewRouter(s.queries, discordClient, s.config)
botRouter := bot.NewRouter(s.queries, discordClient) botRouter := bot.NewRouter(s.queries, discordClient)
adminRouter := admin.NewRouter(s.queries) adminRouter := admin.NewRouter(s.queries)

View file

@ -35,7 +35,6 @@ func (s *Service) Callback(ctx context.Context, code string) (*db.User, *discord
user, err := s.q.GetUser(ctx, dUser.ID) user, err := s.q.GetUser(ctx, dUser.ID)
if err != nil { if err != nil {
// First login — create the user.
user, err = s.q.CreateUser(ctx, db.CreateUserParams{ user, err = s.q.CreateUser(ctx, db.CreateUserParams{
ID: dUser.ID, ID: dUser.ID,
Username: dUser.Username, Username: dUser.Username,

View file

@ -4,11 +4,13 @@ import (
"net/http" "net/http"
"time" "time"
"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/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/token" "codeberg.org/nextgo/dbots/internal/token"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
@ -18,15 +20,15 @@ const cookieName = "session"
type Router struct { type Router struct {
auth *Service auth *Service
router chi.Router router chi.Router
pasetoKey string // hex-encoded AUTH_PASETO_KEY config config.Config
queries *db.Queries queries *db.Queries
} }
func NewRouter(q *db.Queries, client *discord.Client, pasetoKey string) *Router { func NewRouter(q *db.Queries, client *discord.Client, config config.Config) *Router {
return &Router{ return &Router{
auth: NewService(q, client), auth: NewService(q, client),
router: chi.NewRouter(), router: chi.NewRouter(),
pasetoKey: pasetoKey, config: config,
queries: q, queries: q,
} }
} }
@ -87,8 +89,7 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) {
return return
} }
// Generate a session ID (jti) and persist it to the DB for server-side revocation. jti, err := GenerateState()
jti, err := GenerateState() // reuses the same crypto/rand helper
if err != nil { if err != nil {
render.Render(w, req, errorutil.ErrInternal(err)) render.Render(w, req, errorutil.ErrInternal(err))
return return
@ -104,7 +105,7 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) {
return return
} }
key, err := token.KeyFromHex(r.pasetoKey) key, err := token.KeyFromHex(r.config.Auth.PasetoKey)
if err != nil { if err != nil {
render.Render(w, req, errorutil.ErrInternal(err)) render.Render(w, req, errorutil.ErrInternal(err))
return return
@ -123,25 +124,22 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) {
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
MaxAge: int(token.TokenDuration.Seconds()), MaxAge: int(token.TokenDuration.Seconds()),
Path: "/", Path: "/",
// Secure: true, // enable in production (HTTPS) Secure: r.config.Production,
}) })
http.Redirect(w, req, "/", http.StatusFound) http.Redirect(w, req, "/", http.StatusFound)
} }
// POST /auth/logout — revoke the session server-side and clear the cookie.
func (r *Router) logout(w http.ResponseWriter, req *http.Request) { func (r *Router) logout(w http.ResponseWriter, req *http.Request) {
c, err := req.Cookie(cookieName) c, err := req.Cookie(cookieName)
if err != nil { if err != nil {
// Already logged out.
render.NoContent(w, req) render.NoContent(w, req)
return return
} }
key, err := token.KeyFromHex(r.pasetoKey) key, err := token.KeyFromHex(r.config.Auth.PasetoKey)
if err == nil { if err == nil {
if claims, err := token.VerifyToken(key, c.Value); err == nil { if claims, err := token.VerifyToken(key, c.Value); err == nil {
// Best-effort: ignore DB errors, the cookie will be cleared anyway.
_ = r.queries.RevokeSession(req.Context(), claims.JTI) _ = r.queries.RevokeSession(req.Context(), claims.JTI)
} }
} }

View file

@ -54,6 +54,10 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e
return nil, errorutil.ErrBotNotExists // todo: some old bots have different client id (not the same as the user id) return nil, errorutil.ErrBotNotExists // todo: some old bots have different client id (not the same as the user id)
} }
if !application.BotPublic {
return nil, errorutil.ErrBotPrivate
}
var count int32 var count int32
b, err := s.q.CreateBot(ctx, db.CreateBotParams{ b, err := s.q.CreateBot(ctx, db.CreateBotParams{
ID: data.ID, ID: data.ID,