From 7261ebaafa8379e90692e8aa868acf6cd8b87e0e Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 29 Jun 2026 15:30:20 -0400 Subject: [PATCH] fix: invite routes were double-nested under /invites causing 404->SPA fallback --- cmd/server/main.go | 575 +++++++++++++++++++++++---------------------- 1 file changed, 289 insertions(+), 286 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 70f7c5f..105f73c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,286 +1,289 @@ -package main - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - - _ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook" - "github.com/go-chi/chi/v5" - chimw "github.com/go-chi/chi/v5/middleware" - httpSwagger "github.com/swaggo/http-swagger" -) - -// @title Dumpster API -// @version 1.0 -// @description A chaotic, self-hosted Discord-like platform API -// @host localhost:8080 -// @BasePath /api/v1 -// @schemes http https -// @securityDefinitions.apikey SessionAuth -// @in cookie -// @name dumpster_session -func main() { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - - cfg := config.Load() - - database, err := db.New(cfg) - if err != nil { - logger.Error("failed to connect to database", "error", err) - os.Exit(1) - } - defer database.Close() - - if err := database.RunMigrations(); err != nil { - logger.Error("failed to run migrations", "error", err) - os.Exit(1) - } - - // Session store for middleware - sessionStore := auth.NewSessionStore(database.DB, cfg) - - // WebSocket origin allowlist - wsOrigins := []string{"https://" + cfg.Host} - if cfg.Host == "localhost" { - wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port) - } - gateway.SetAllowedOrigins(wsOrigins) - - // WebSocket hub - hub := gateway.NewHub(database.DB, logger) - go hub.Run() - - // Giphy client (nil if no API key) - giphyClient := giphy.NewClient(cfg.Giphy.APIKey) - - // Upload handler (nil if no MinIO config) - uploadHandler, err := upload.NewHandler(cfg) - if err != nil { - logger.Warn("upload handler not available", "error", err) - } - - // Voice client (LiveKit) - voiceClient := voice.NewClient(cfg.LiveKit.APIKey, cfg.LiveKit.Secret, cfg.LiveKit.URL) - if voiceClient == nil { - logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)") - } - - // Push notification handler (nil if no VAPID keys) - pushHandler := push.NewHandler(database.DB, cfg.WebPush.PublicKey, cfg.WebPush.PrivateKey, cfg.WebPush.Subject, logger) - if cfg.WebPush.PublicKey == "" { - logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)") - } - - // Auth handler - authHandler := auth.NewHandler(database.DB, cfg) - - // Permissions checker - permissionsChecker := permissions.NewChecker(database.DB) - memberHandler := server.NewMemberHandler(database.DB) - - r := chi.NewRouter() - r.Use(chimw.Logger) - r.Use(chimw.Recoverer) - r.Use(chimw.RequestID) - r.Use(middleware.SecurityHeaders) - - // Health check - r.Get("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) - }) - - // WebSocket endpoint - r.Get("/ws", func(w http.ResponseWriter, r *http.Request) { - gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName) - }) - - // API routes - r.Route("/api/v1", func(r chi.Router) { - // Auth (public: register, login, logout) with strict rate limiting - r.Route("/auth", func(r chi.Router) { - r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 - authHandler.RegisterPublicRoutes(r) - }) - - // Protected routes - r.Group(func(r chi.Router) { - r.Use(middleware.Session(sessionStore, cfg)) - r.Use(middleware.RequireAuth) - r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","))) - - // Auth (protected: me, update profile) - authHandler.RegisterProtectedRoutes(r) - - // Servers - r.Route("/servers", func(r chi.Router) { - serverHandler := server.NewHandler(database.DB) - r.Post("/", serverHandler.Create) - r.Get("/", serverHandler.List) - - r.Route("/{serverID}", func(r chi.Router) { - r.Get("/", serverHandler.Get) - r.Patch("/", serverHandler.Update) - r.Delete("/", serverHandler.Delete) - - // Server members - memberHandler.RegisterRoutes(r) - - // Channels (nested under servers) - r.Route("/channels", func(r chi.Router) { - channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) - }) - }) - }) - - // Roles and member-role assignment - roleHandler := server.NewRoleHandler(database.DB) - roleHandler.RegisterRoleRoutes(r) - - // Messages (under channels) - r.Route("/channels/{channelID}/messages", func(r chi.Router) { - message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r) - }) - - // Giphy search - if giphyClient != nil { - r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - if query == "" { - http.Error(w, `{"error":"missing query"}`, http.StatusBadRequest) - return - } - gifs, err := giphyClient.Search(query, 20) - if err != nil { - http.Error(w, `{"error":"search failed"}`, http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(gifs) - }) - r.Get("/gifs/trending", func(w http.ResponseWriter, r *http.Request) { - gifs, err := giphyClient.GetTrending(20) - if err != nil { - http.Error(w, `{"error":"trending failed"}`, http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(gifs) - }) - } - - // File upload - if uploadHandler != nil { - r.Post("/upload", uploadHandler.Upload) - } - - // Voice - if voiceClient != nil { - r.Route("/voice", func(r chi.Router) { - voice.NewHandler(database.DB, voiceClient, hub).RegisterRoutes(r) - }) - } - - // Reactions - r.Route("/messages/{messageID}/reactions", func(r chi.Router) { - reaction.NewHandler(database.DB, hub).RegisterRoutes(r) - }) - - // Bots - r.Route("/bots", func(r chi.Router) { - bot.NewHandler(database.DB).RegisterRoutes(r) - }) - - // Bot slash commands - r.Route("/bots/{botID}/commands", func(r chi.Router) { - bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r) - }) - - // Webhooks (protected: create/list/delete) - webhookHandler := webhook.NewHandler(database.DB, hub) - r.Route("/channels/{channelID}/webhooks", func(r chi.Router) { - webhookHandler.RegisterRoutes(r) - }) - r.Delete("/webhooks/{webhookID}", webhookHandler.Delete) - - // Invites (rate limited to prevent brute-force join) - r.Route("/invites", func(r chi.Router) { - r.Use(middleware.RateLimit(2, 5)) - invite.NewHandler(database.DB).RegisterRoutes(r) - }) - }) - }) - - // File serving (public, for viewing uploaded files) - if uploadHandler != nil { - r.Get("/files/*", uploadHandler.Serve) - } - - // Public webhook execution (no auth required) - r.Post("/webhooks/{webhookID}/{token}", func(w http.ResponseWriter, r *http.Request) { - webhook.NewHandler(database.DB, hub).Execute(w, r) - }) - - // 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" - if _, err := os.Stat(staticDir); err == nil { - fileServer := http.FileServer(http.Dir(staticDir)) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - // If the file exists, serve it; otherwise serve index.html (SPA fallback) - path := staticDir + r.URL.Path - if _, err := os.Stat(path); os.IsNotExist(err) { - http.ServeFile(w, r, staticDir+"/index.html") - return - } - fileServer.ServeHTTP(w, r) - }) - } - - addr := fmt.Sprintf(":%s", cfg.Port) - logger.Info("starting server", "addr", addr) - if err := http.ListenAndServe(addr, r); err != nil { - logger.Error("server error", "error", err) - os.Exit(1) - } -} +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + _ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook" + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + httpSwagger "github.com/swaggo/http-swagger" +) + +// @title Dumpster API +// @version 1.0 +// @description A chaotic, self-hosted Discord-like platform API +// @host localhost:8080 +// @BasePath /api/v1 +// @schemes http https +// @securityDefinitions.apikey SessionAuth +// @in cookie +// @name dumpster_session +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + cfg := config.Load() + + database, err := db.New(cfg) + if err != nil { + logger.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer database.Close() + + if err := database.RunMigrations(); err != nil { + logger.Error("failed to run migrations", "error", err) + os.Exit(1) + } + + // Session store for middleware + sessionStore := auth.NewSessionStore(database.DB, cfg) + + // WebSocket origin allowlist + wsOrigins := []string{"https://" + cfg.Host} + if cfg.Host == "localhost" { + wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port) + } + gateway.SetAllowedOrigins(wsOrigins) + + // WebSocket hub + hub := gateway.NewHub(database.DB, logger) + go hub.Run() + + // Giphy client (nil if no API key) + giphyClient := giphy.NewClient(cfg.Giphy.APIKey) + + // Upload handler (nil if no MinIO config) + uploadHandler, err := upload.NewHandler(cfg) + if err != nil { + logger.Warn("upload handler not available", "error", err) + } + + // Voice client (LiveKit) + voiceClient := voice.NewClient(cfg.LiveKit.APIKey, cfg.LiveKit.Secret, cfg.LiveKit.URL) + if voiceClient == nil { + logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)") + } + + // Push notification handler (nil if no VAPID keys) + pushHandler := push.NewHandler(database.DB, cfg.WebPush.PublicKey, cfg.WebPush.PrivateKey, cfg.WebPush.Subject, logger) + if cfg.WebPush.PublicKey == "" { + logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)") + } + + // Auth handler + authHandler := auth.NewHandler(database.DB, cfg) + + // Permissions checker + permissionsChecker := permissions.NewChecker(database.DB) + memberHandler := server.NewMemberHandler(database.DB) + + r := chi.NewRouter() + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + r.Use(chimw.RequestID) + r.Use(middleware.SecurityHeaders) + + // Health check + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + // WebSocket endpoint + r.Get("/ws", func(w http.ResponseWriter, r *http.Request) { + gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName) + }) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + // Auth (public: register, login, logout) with strict rate limiting + r.Route("/auth", func(r chi.Router) { + r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 + authHandler.RegisterPublicRoutes(r) + }) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(middleware.Session(sessionStore, cfg)) + r.Use(middleware.RequireAuth) + r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","))) + + // Auth (protected: me, update profile) + authHandler.RegisterProtectedRoutes(r) + + // Servers + r.Route("/servers", func(r chi.Router) { + serverHandler := server.NewHandler(database.DB) + r.Post("/", serverHandler.Create) + r.Get("/", serverHandler.List) + + r.Route("/{serverID}", func(r chi.Router) { + r.Get("/", serverHandler.Get) + r.Patch("/", serverHandler.Update) + r.Delete("/", serverHandler.Delete) + + // Server members + memberHandler.RegisterRoutes(r) + + // Channels (nested under servers) + r.Route("/channels", func(r chi.Router) { + channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) + }) + }) + }) + + // Roles and member-role assignment + roleHandler := server.NewRoleHandler(database.DB) + roleHandler.RegisterRoleRoutes(r) + + // Messages (under channels) + r.Route("/channels/{channelID}/messages", func(r chi.Router) { + message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r) + }) + + // Giphy search + if giphyClient != nil { + r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + http.Error(w, `{"error":"missing query"}`, http.StatusBadRequest) + return + } + gifs, err := giphyClient.Search(query, 20) + if err != nil { + http.Error(w, `{"error":"search failed"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gifs) + }) + r.Get("/gifs/trending", func(w http.ResponseWriter, r *http.Request) { + gifs, err := giphyClient.GetTrending(20) + if err != nil { + http.Error(w, `{"error":"trending failed"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gifs) + }) + } + + // File upload + if uploadHandler != nil { + r.Post("/upload", uploadHandler.Upload) + } + + // Voice + if voiceClient != nil { + r.Route("/voice", func(r chi.Router) { + voice.NewHandler(database.DB, voiceClient, hub).RegisterRoutes(r) + }) + } + + // Reactions + r.Route("/messages/{messageID}/reactions", func(r chi.Router) { + reaction.NewHandler(database.DB, hub).RegisterRoutes(r) + }) + + // Bots + r.Route("/bots", func(r chi.Router) { + bot.NewHandler(database.DB).RegisterRoutes(r) + }) + + // Bot slash commands + r.Route("/bots/{botID}/commands", func(r chi.Router) { + bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r) + }) + + // Webhooks (protected: create/list/delete) + webhookHandler := webhook.NewHandler(database.DB, hub) + r.Route("/channels/{channelID}/webhooks", func(r chi.Router) { + webhookHandler.RegisterRoutes(r) + }) + r.Delete("/webhooks/{webhookID}", webhookHandler.Delete) + + // Invites (rate limited to prevent brute-force join) + inviteHandler := invite.NewHandler(database.DB) + r.Post("/servers/{serverID}/invites", inviteHandler.Create) + r.Get("/invites/{code}", inviteHandler.Get) + r.Route("/invites/{code}/join", func(r chi.Router) { + r.Use(middleware.RateLimit(2, 5)) + r.Post("/", inviteHandler.Join) + }) + }) + }) + + // File serving (public, for viewing uploaded files) + if uploadHandler != nil { + r.Get("/files/*", uploadHandler.Serve) + } + + // Public webhook execution (no auth required) + r.Post("/webhooks/{webhookID}/{token}", func(w http.ResponseWriter, r *http.Request) { + webhook.NewHandler(database.DB, hub).Execute(w, r) + }) + + // 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" + if _, err := os.Stat(staticDir); err == nil { + fileServer := http.FileServer(http.Dir(staticDir)) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + // If the file exists, serve it; otherwise serve index.html (SPA fallback) + path := staticDir + r.URL.Path + if _, err := os.Stat(path); os.IsNotExist(err) { + http.ServeFile(w, r, staticDir+"/index.html") + return + } + fileServer.ServeHTTP(w, r) + }) + } + + addr := fmt.Sprintf(":%s", cfg.Port) + logger.Info("starting server", "addr", addr) + if err := http.ListenAndServe(addr, r); err != nil { + logger.Error("server error", "error", err) + os.Exit(1) + } +}