fix: invite routes were double-nested under /invites causing 404->SPA fallback

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