Files
dumpsterChat/internal/bot/handlers.go
T
hobokenchicken 88756a72fa security: LOW findings + fix .gitignore overmatch
- Swagger UI restricted to localhost only
- Message content length limit (4000 chars max) on create + update
- Bot/server/webhook ownership checks return 404 instead of 403
  (prevents resource ID enumeration)
- Fix .gitignore: /server instead of server to stop ignoring
  internal/server/ directory
2026-06-29 09:41:44 -04:00

550 lines
14 KiB
Go

package bot
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles bot CRUD and server-assignment routes.
type Handler struct {
db *sql.DB
}
// NewHandler creates a new bot Handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
// RegisterRoutes registers authenticated bot routes under the given router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{botID}", h.Get)
r.Patch("/{botID}", h.Update)
r.Delete("/{botID}", h.Delete)
r.Post("/{botID}/servers", h.AddToServer)
r.Delete("/{botID}/servers/{serverID}", h.RemoveFromServer)
r.Post("/{botID}/regenerate-token", h.RegenerateToken)
}
// ---- response / request types ----
type botResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar *string `json:"avatar"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
}
// botWithToken is returned only on create / regenerate-token.
type botWithToken struct {
botResponse
Token string `json:"token"`
}
type createBotRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
type updateBotRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Avatar *string `json:"avatar"`
}
type addToServerRequest struct {
ServerID string `json:"server_id"`
}
// ---- helpers ----
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
http.Error(w, `{"error":"`+msg+`"}`, status)
}
// ---- handlers ----
// @Summary Create a bot
// @Description Create a new bot
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param body body createBotRequest true "Bot data"
// @Success 201 {object} botWithToken
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /bots [post]
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
var req createBotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
token := GenerateToken()
tokenHash := HashToken(token)
var bot botWithToken
var avatar sql.NullString
var createdAt sql.NullString
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO bots (name, description, owner_id, token)
VALUES ($1, $2, $3, $4)
RETURNING id, name, avatar, description, owner_id, created_at::text
`, req.Name, req.Description, userID, tokenHash).Scan(
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to create bot")
return
}
if avatar.Valid {
bot.Avatar = &avatar.String
}
bot.CreatedAt = createdAt.String
bot.Token = token
writeJSON(w, http.StatusCreated, bot)
}
// @Summary List bots
// @Description List all bots owned by the current user
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Success 200 {array} botResponse
// @Failure 401 {object} map[string]string
// @Router /bots [get]
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, name, avatar, description, owner_id, created_at::text
FROM bots WHERE owner_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
defer rows.Close()
bots := make([]botResponse, 0)
for rows.Next() {
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
bots = append(bots, b)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
writeJSON(w, http.StatusOK, bots)
}
// @Summary Get a bot
// @Description Get a bot by ID (owner only)
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 200 {object} botResponse
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [get]
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
err := h.db.QueryRowContext(r.Context(), `
SELECT id, name, avatar, description, owner_id, created_at::text
FROM bots WHERE id = $1
`, botID).Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if b.OwnerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
writeJSON(w, http.StatusOK, b)
}
// @Summary Update a bot
// @Description Update a bot's name, description, or avatar (owner only)
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param body body updateBotRequest true "Bot update data"
// @Success 200 {object} botResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [patch]
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
// Verify ownership
var ownerID string
err := h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM bots WHERE id = $1
`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
var req updateBotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == nil && req.Description == nil && req.Avatar == nil {
writeErr(w, http.StatusBadRequest, "nothing to update")
return
}
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE bots
SET name = COALESCE($1, name),
description = COALESCE($2, description),
avatar = COALESCE($3, avatar)
WHERE id = $4
RETURNING id, name, avatar, description, owner_id, created_at::text
`, req.Name, req.Description, req.Avatar, botID).Scan(
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to update bot")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
writeJSON(w, http.StatusOK, b)
}
// @Summary Delete a bot
// @Description Delete a bot (owner only)
// @Tags bots
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 204
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [delete]
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
var ownerID string
err := h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM bots WHERE id = $1
`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
_, err = h.db.ExecContext(r.Context(), `DELETE FROM bots WHERE id = $1`, botID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to delete bot")
return
}
w.WriteHeader(http.StatusNoContent)
}
// @Summary Add bot to server
// @Description Add a bot to a server
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param body body addToServerRequest true "Server data"
// @Success 201 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/servers [post]
func (h *Handler) AddToServer(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
// Verify bot ownership
var ownerID string
err := h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM bots WHERE id = $1
`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
var req addToServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.ServerID == "" {
writeErr(w, http.StatusBadRequest, "server_id is required")
return
}
// Verify user is a member of the target server
var exists bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
`, userID, req.ServerID).Scan(&exists)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if !exists {
writeErr(w, http.StatusForbidden, "not a member of this server")
return
}
_, err = h.db.ExecContext(r.Context(), `
INSERT INTO bot_servers (bot_id, server_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (bot_id, server_id) DO NOTHING
`, botID, req.ServerID, userID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to add bot to server")
return
}
writeJSON(w, http.StatusCreated, map[string]string{
"bot_id": botID,
"server_id": req.ServerID,
})
}
// @Summary Remove bot from server
// @Description Remove a bot from a server
// @Tags bots
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param serverID path string true "Server ID"
// @Success 204
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/servers/{serverID} [delete]
func (h *Handler) RemoveFromServer(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
serverID := chi.URLParam(r, "serverID")
// Verify bot ownership
var ownerID string
err := h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM bots WHERE id = $1
`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
result, err := h.db.ExecContext(r.Context(), `
DELETE FROM bot_servers WHERE bot_id = $1 AND server_id = $2
`, botID, serverID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to remove bot from server")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeErr(w, http.StatusNotFound, "bot not in this server")
return
}
w.WriteHeader(http.StatusNoContent)
}
// @Summary Regenerate bot token
// @Description Regenerate a bot's API token (owner only)
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 200 {object} botWithToken
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/regenerate-token [post]
func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
// Verify ownership
var ownerID string
err := h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM bots WHERE id = $1
`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
token := GenerateToken()
tokenHash := HashToken(token)
var b botWithToken
var avatar sql.NullString
var createdAt sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE bots SET token = $1
WHERE id = $2
RETURNING id, name, avatar, description, owner_id, created_at::text
`, tokenHash, botID).Scan(
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to regenerate token")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
b.Token = token
writeJSON(w, http.StatusOK, b)
}