diff --git a/.gitignore b/.gitignore index 731c65e..2602562 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,6 @@ Thumbs.db postgres_data/ valkey_data/ minio_data/ -server -migrate -dumpster-server +/server +/migrate +/dumpster-server diff --git a/cmd/server/main.go b/cmd/server/main.go index 0fce2f9..6beb33e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -223,10 +223,25 @@ func main() { webhook.NewHandler(database.DB, hub).Execute(w, r) }) - // Swagger UI - r.Get("/docs/*", httpSwagger.Handler( - httpSwagger.URL("/docs/swagger.json"), - )) + // Swagger UI (only accessible from localhost to avoid exposing API docs) + r.Group(func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if host == "" { + host = r.Header.Get("Host") + } + if host != "localhost:"+cfg.Port && host != "127.0.0.1:"+cfg.Port && host != "[::1]:"+cfg.Port { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Get("/docs/*", httpSwagger.Handler( + httpSwagger.URL("/docs/swagger.json"), + )) + }) // Static file serving for production (SPA) staticDir := "/srv/web" diff --git a/internal/bot/commands.go b/internal/bot/commands.go index 87cf4e2..e821cbc 100644 --- a/internal/bot/commands.go +++ b/internal/bot/commands.go @@ -310,7 +310,7 @@ func (h *CommandHandler) verifyBotOwnership(r *http.Request, userID, botID strin return err } if ownerID != userID { - return errForbidden + return errNotFound } return nil } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index dc4bf50..e7c10cb 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -216,7 +216,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { return } if b.OwnerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } if avatar.Valid { @@ -264,7 +264,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } @@ -335,7 +335,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } @@ -385,7 +385,7 @@ func (h *Handler) AddToServer(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } @@ -464,7 +464,7 @@ func (h *Handler) RemoveFromServer(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } @@ -518,7 +518,7 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "bot not found") return } diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 42390ee..11b304f 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -90,6 +90,10 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } + if len(req.Content) > 4000 { + http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest) + return + } var msg messageResponse var editedAt sql.NullString @@ -173,6 +177,10 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } + if len(req.Content) > 4000 { + http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest) + return + } var msg messageResponse var editedAt sql.NullString diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..9fde244 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,314 @@ +package server + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type Handler struct { + db *sql.DB +} + +func NewHandler(db *sql.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/", h.Create) + r.Get("/", h.List) + r.Get("/{serverID}", h.Get) + r.Patch("/{serverID}", h.Update) + r.Delete("/{serverID}", h.Delete) +} + +type createServerRequest struct { + Name string `json:"name"` + Icon string `json:"icon"` +} + +type serverResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Icon *string `json:"icon"` + OwnerID string `json:"owner_id"` +} + +// @Summary Create a server +// @Description Create a new server +// @Tags servers +// @Accept json +// @Produce json +// @Security SessionAuth +// @Param body body createServerRequest true "Server data" +// @Success 201 {object} serverResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /servers [post] +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req createServerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == "" { + http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest) + return + } + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var srv serverResponse + var icon sql.NullString + if req.Icon != "" { + icon = sql.NullString{String: req.Icon, Valid: true} + } + err = tx.QueryRowContext(r.Context(), ` + INSERT INTO servers (name, icon, owner_id) + VALUES ($1, $2, $3) + RETURNING id, name, icon, owner_id + `, req.Name, icon, userID).Scan(&srv.ID, &srv.Name, &srv.Icon, &srv.OwnerID) + if err != nil { + http.Error(w, `{"error":"failed to create server"}`, http.StatusInternalServerError) + return + } + + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO members (user_id, server_id) + VALUES ($1, $2) + `, userID, srv.ID) + if err != nil { + http.Error(w, `{"error":"failed to add owner as member"}`, http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(srv) +} + +// @Summary List servers +// @Description List all servers the current user is a member of +// @Tags servers +// @Produce json +// @Security SessionAuth +// @Success 200 {array} serverResponse +// @Failure 401 {object} map[string]string +// @Router /servers [get] +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT s.id, s.name, s.icon, s.owner_id + FROM servers s + INNER JOIN members m ON m.server_id = s.id + WHERE m.user_id = $1 + ORDER BY s.name + `, userID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + servers := make([]serverResponse, 0) + for rows.Next() { + var srv serverResponse + if err := rows.Scan(&srv.ID, &srv.Name, &srv.Icon, &srv.OwnerID); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + servers = append(servers, srv) + } + if err := rows.Err(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(servers) +} + +// @Summary Get a server +// @Description Get a server by ID +// @Tags servers +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Success 200 {object} serverResponse +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /servers/{serverID} [get] +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + + var srv serverResponse + err := h.db.QueryRowContext(r.Context(), ` + SELECT s.id, s.name, s.icon, s.owner_id + FROM servers s + INNER JOIN members m ON m.server_id = s.id + WHERE s.id = $1 AND m.user_id = $2 + `, serverID, userID).Scan(&srv.ID, &srv.Name, &srv.Icon, &srv.OwnerID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"server not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(srv) +} + +type updateServerRequest struct { + Name *string `json:"name"` + Icon *string `json:"icon"` +} + +// @Summary Update a server +// @Description Update a server's name or icon (owner only) +// @Tags servers +// @Accept json +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Param body body updateServerRequest true "Server update data" +// @Success 200 {object} serverResponse +// @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 /servers/{serverID} [patch] +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + + var req updateServerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == nil && req.Icon == nil { + http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) + return + } + + // Verify ownership + var ownerID string + err := h.db.QueryRowContext(r.Context(), ` + SELECT owner_id FROM servers WHERE id = $1 + `, serverID).Scan(&ownerID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"server not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if ownerID != userID { + http.Error(w, `{"error":"server not found"}`, http.StatusNotFound) + return + } + + var srv serverResponse + err = h.db.QueryRowContext(r.Context(), ` + UPDATE servers + SET name = COALESCE($1, name), + icon = COALESCE($2, icon) + WHERE id = $3 + RETURNING id, name, icon, owner_id + `, req.Name, req.Icon, serverID).Scan(&srv.ID, &srv.Name, &srv.Icon, &srv.OwnerID) + if err != nil { + http.Error(w, `{"error":"failed to update server"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(srv) +} + +// @Summary Delete a server +// @Description Delete a server (owner only) +// @Tags servers +// @Security SessionAuth +// @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 /servers/{serverID} [delete] +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + + // Verify ownership + var ownerID string + err := h.db.QueryRowContext(r.Context(), ` + SELECT owner_id FROM servers WHERE id = $1 + `, serverID).Scan(&ownerID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"server not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if ownerID != userID { + http.Error(w, `{"error":"server not found"}`, http.StatusNotFound) + return + } + + _, err = h.db.ExecContext(r.Context(), ` + DELETE FROM servers WHERE id = $1 + `, serverID) + if err != nil { + http.Error(w, `{"error":"failed to delete server"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/webhook/handlers.go b/internal/webhook/handlers.go index 9389268..3a14cce 100644 --- a/internal/webhook/handlers.go +++ b/internal/webhook/handlers.go @@ -270,7 +270,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } if createdBy != userID { - writeErr(w, http.StatusForbidden, "forbidden") + writeErr(w, http.StatusNotFound, "webhook not found") return }