package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" _ "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) 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) }) // 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([]string{"https://" + cfg.Host, "http://localhost:" + cfg.Port})) // Auth (protected: me, update profile) authHandler.RegisterProtectedRoutes(r) // Servers r.Route("/servers", func(r chi.Router) { server.NewHandler(database.DB).RegisterRoutes(r) // Channels (nested under servers) r.Route("/{serverID}/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/delete) r.Route("/channels/{channelID}/webhooks", func(r chi.Router) { webhook.NewHandler(database.DB, hub).RegisterRoutes(r) }) // 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) } }