diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..35942aa --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index cbaafd3..a5e12a1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,44 @@ # dbots -simple discord botlist +discord botlist. -## todo +## setup -* [ ] ratelimits -* [ ] complete auth (with paseto) +### 1. clone and install deps + +### 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 diff --git a/dbots b/dbots deleted file mode 100755 index 879cdf3..0000000 Binary files a/dbots and /dev/null differ diff --git a/internal/config/config.go b/internal/config/config.go index b08820c..3a7b105 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,10 +6,11 @@ import ( ) type Config struct { - Database DatabaseConfig `env:"DATABASE_"` - Server ServerConfig `env:"SERVER_"` - Discord DiscordConfig `env:"DISCORD_"` - Auth AuthConfig `env:"AUTH_"` + Database DatabaseConfig `env:"DATABASE_"` + Server ServerConfig `env:"SERVER_"` + Discord DiscordConfig `env:"DISCORD_"` + Auth AuthConfig `env:"AUTH_"` + Production bool `env:"PRODUCTION,default=true"` } type DatabaseConfig struct { diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go index 1d2908f..35ec51c 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") ErrMainOwnerAsCoOwner = errors.New("You cannot set yourself as a co-owner") ErrBotNotExists = errors.New("Bot does not exist inside Discord") + ErrBotPrivate = errors.New("You cannot submit private bots") // validation ErrInvalidID = errors.New("Invalid Discord id") diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 7ba177b..7abcd4b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -12,12 +12,11 @@ import ( type contextKey string -// UserContextKey is exported so service routers can read the user from context. var UserContextKey contextKey = "user" // 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. -// Does NOT block unauthenticated requests — use AuthGuardMiddleware for that. +// hasn't been revoked in the db, then sets the *db.User on the context. +// does not block unauthenticated requests func AuthMiddleware(q *db.Queries, pasetoKeyHex string) func(http.Handler) http.Handler { key, err := token.KeyFromHex(pasetoKeyHex) 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) if err != nil { - // Expired or tampered — clear the cookie and continue as anonymous. http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"}) next.ServeHTTP(w, r) return } - // Check the session hasn't been revoked server-side. if _, err := q.GetSession(r.Context(), claims.JTI); err != nil { http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"}) next.ServeHTTP(w, r) diff --git a/internal/server/server.go b/internal/server/server.go index 18c6052..bd7bb55 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,8 +32,7 @@ func NewServer(queries *db.Queries, config config.Config) *Server { router.Use(middleware.Recoverer) router.Use(middleware.RequestID) router.Use(middleware.RealIP) - router.Use(customMiddlewares.AuthMiddleware(queries, config.Auth.PasetoKey)) // todo: use this middleware only when necessary - // i am using this globally cus it uses mocked data lol + router.Use(customMiddlewares.AuthMiddleware(queries, config.Auth.PasetoKey)) return &Server{ router: router, @@ -49,7 +48,7 @@ func (s *Server) Register() { 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) adminRouter := admin.NewRouter(s.queries) diff --git a/services/auth/auth.go b/services/auth/auth.go index b0d79f5..507d757 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -35,7 +35,6 @@ func (s *Service) Callback(ctx context.Context, code string) (*db.User, *discord user, err := s.q.GetUser(ctx, dUser.ID) if err != nil { - // First login — create the user. user, err = s.q.CreateUser(ctx, db.CreateUserParams{ ID: dUser.ID, Username: dUser.Username, diff --git a/services/auth/router.go b/services/auth/router.go index 3afc38e..2e7df08 100644 --- a/services/auth/router.go +++ b/services/auth/router.go @@ -4,11 +4,13 @@ import ( "net/http" "time" + "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/token" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) @@ -16,18 +18,18 @@ import ( const cookieName = "session" type Router struct { - auth *Service - router chi.Router - pasetoKey string // hex-encoded AUTH_PASETO_KEY - queries *db.Queries + auth *Service + router chi.Router + config config.Config + 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{ - auth: NewService(q, client), - router: chi.NewRouter(), - pasetoKey: pasetoKey, - queries: q, + auth: NewService(q, client), + router: chi.NewRouter(), + config: config, + queries: q, } } @@ -87,8 +89,7 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) { return } - // Generate a session ID (jti) and persist it to the DB for server-side revocation. - jti, err := GenerateState() // reuses the same crypto/rand helper + jti, err := GenerateState() if err != nil { render.Render(w, req, errorutil.ErrInternal(err)) return @@ -104,7 +105,7 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) { return } - key, err := token.KeyFromHex(r.pasetoKey) + key, err := token.KeyFromHex(r.config.Auth.PasetoKey) if err != nil { render.Render(w, req, errorutil.ErrInternal(err)) return @@ -123,25 +124,22 @@ func (r *Router) callback(w http.ResponseWriter, req *http.Request) { SameSite: http.SameSiteStrictMode, MaxAge: int(token.TokenDuration.Seconds()), Path: "/", - // Secure: true, // enable in production (HTTPS) + Secure: r.config.Production, }) 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) { c, err := req.Cookie(cookieName) if err != nil { - // Already logged out. render.NoContent(w, req) return } - key, err := token.KeyFromHex(r.pasetoKey) + key, err := token.KeyFromHex(r.config.Auth.PasetoKey) if 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) } } diff --git a/services/bot/bot.go b/services/bot/bot.go index b158f6b..6d4fc45 100644 --- a/services/bot/bot.go +++ b/services/bot/bot.go @@ -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) } + if !application.BotPublic { + return nil, errorutil.ErrBotPrivate + } + var count int32 b, err := s.q.CreateBot(ctx, db.CreateBotParams{ ID: data.ID,