package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "strings" _ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/audit" "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/dm" "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/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/notification" "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/readstate" "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, hub) // 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) modHandler := moderation.NewHandler(database.DB, hub) auditLogger := audit.NewLogger(database.DB) auditHandler := audit.NewHandler(database.DB) r.Route("/{serverID}", func(r chi.Router) { r.Get("/", serverHandler.Get) r.Patch("/", serverHandler.Update) r.Delete("/", serverHandler.Delete) // Audit log r.With(middleware.RequirePermission(permissionsChecker, permissions.MANAGE_SERVER)).Get("/audit-log", auditHandler.List) // Server members memberHandler.RegisterRoutes(r) // Moderation r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", func(w http.ResponseWriter, r *http.Request) { modHandler.Kick(w, r) serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") userID, _ := middleware.UserIDFromContext(r.Context()) _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionKick, "user", targetID, "", nil) }) r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", func(w http.ResponseWriter, r *http.Request) { modHandler.Ban(w, r) }) r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans) r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", func(w http.ResponseWriter, r *http.Request) { modHandler.Unban(w, r) serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") userID, _ := middleware.UserIDFromContext(r.Context()) _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnban, "user", targetID, "", nil) }) r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", func(w http.ResponseWriter, r *http.Request) { modHandler.Mute(w, r) }) r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", func(w http.ResponseWriter, r *http.Request) { modHandler.Unmute(w, r) serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") userID, _ := middleware.UserIDFromContext(r.Context()) _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnmute, "user", targetID, "", nil) }) // Channels (nested under servers) r.Route("/channels", func(r chi.Router) { channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) }) // Availability r.Get("/availability", authHandler.GetServerAvailability) }) }) // Direct messages dmHandler := dm.NewHandler(database.DB, hub, logger) r.Route("/conversations", func(r chi.Router) { dmHandler.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) }) // Per-channel notification settings r.Route("/channels/{channelID}/notifications", func(r chi.Router) { notification.NewHandler(database.DB, logger).RegisterRoutes(r) }) // Read receipts rsHandler := readstate.NewHandler(database.DB, logger) r.Put("/channels/{channelID}/read", rsHandler.MarkRead) r.Get("/users/me/read-states", rsHandler.GetAll) // 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) } }