package main import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "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/email" "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/servergroup" "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" "github.com/go-chi/cors" 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, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"} 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() // Built-in bot runner botRunner := bot.NewRunner(database.DB, hub, logger) botRunner.Register("steamfree", bot.SteamFreeBot) botRunner.Register("confess", bot.ConfessBot) botRunner.Register("leaderboard", bot.LeaderboardBot) go botRunner.StartAll() // 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)") } // Email mailer mailer := email.NewMailer(cfg.SMTP) // Auth handler authHandler := auth.NewHandler(database.DB, cfg, hub, mailer) // Permissions checker permissionsChecker := permissions.NewChecker(database.DB) memberHandler := server.NewMemberHandler(database.DB) r := chi.NewRouter() r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link", "X-Session-Token"}, AllowCredentials: true, MaxAge: 300, })) 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) }) // Bot WebSocket endpoint (auth via ?token= query param) r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) { gateway.ServeBotWS(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(cfg.Host, cfg.Port, append( strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","), "tauri://localhost", "http://tauri.localhost", "https://tauri.localhost", ))) // 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) }) // Server groups (sub-server sections) r.Route("/groups", func(r chi.Router) { servergroup.NewHandler(database.DB).RegisterRoutes(r) }) // Availability r.Get("/availability", authHandler.GetServerAvailability) }) }) // Direct messages dmHandler := dm.NewHandler(database.DB, hub, pushHandler, 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) { msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker) msgHandler.SetConfessHandler(botRunner) msgHandler.RegisterRoutes(r) }) // Polls pollHandler := message.NewPollHandler(database.DB, hub, permissionsChecker) r.Route("/polls", func(r chi.Router) { pollHandler.RegisterRoutes(r) }) // Feature requests frHandler := message.NewFeatureRequestHandler(database.DB) r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) { frHandler.RegisterRoutes(r) }) // Per-channel notification settings r.Route("/channels/{channelID}/notifications", func(r chi.Router) { notification.NewHandler(database.DB, logger).RegisterRoutes(r) }) // Calendar events calHandler := channel.NewHandler(database.DB, permissionsChecker) r.Route("/channels/{channelID}/events", func(r chi.Router) { r.Get("/", calHandler.ListEvents) r.Post("/", calHandler.CreateEvent) }) // Threads (forum posts) threadHandler := channel.NewHandler(database.DB, permissionsChecker) r.Route("/channels/{channelID}/threads", func(r chi.Router) { r.Get("/", threadHandler.ListThreads) r.Post("/", threadHandler.CreateThread) }) r.Patch("/threads/{threadID}", threadHandler.UpdateThread) // Forum tags r.Get("/channels/{channelID}/forum-tags", threadHandler.ListForumTags) r.Post("/channels/{channelID}/forum-tags", threadHandler.CreateForumTag) r.Delete("/forum-tags/{tagID}", threadHandler.DeleteForumTag) // Push notifications pushHandler.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) }) r.Get("/gifs/proxy", func(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.Query().Get("url") if targetURL == "" { http.Error(w, `{"error":"missing url"}`, http.StatusBadRequest) return } // Verify it's a Giphy domain to prevent general proxy abuse parsed, err := url.Parse(targetURL) if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".giphy.com") { http.Error(w, `{"error":"invalid proxy target"}`, http.StatusBadRequest) return } resp, err := http.Get(targetURL) if err != nil { http.Error(w, `{"error":"failed to fetch image"}`, http.StatusInternalServerError) return } defer resp.Body.Close() // Stream response w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) w.Header().Set("Content-Length", resp.Header.Get("Content-Length")) w.Header().Set("Cache-Control", "public, max-age=604800") // Cache for 7 days w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) }) } // 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 + slash commands r.Route("/bots", func(r chi.Router) { bot.NewHandler(database.DB, botRunner).RegisterRoutes(r) 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) // Legacy redirect: old uploads returned /{bucket}/objectName r.Get("/dumpster-files/*", func(w http.ResponseWriter, r *http.Request) { objectName := strings.TrimPrefix(r.URL.Path, "/dumpster-files/") http.Redirect(w, r, "/files/"+objectName, http.StatusMovedPermanently) }) } // 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"), )) }) // Privacy policy page (served before SPA catch-all) r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write([]byte(privacyPage)) }) // Static file serving for production (SPA) staticDir := "web/dist" 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) } } const privacyPage = ` Privacy Policy — dumpsterChat

Privacy Policy

dumpsterChat — Last updated: July 17, 2026

Overview

dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is self-hosted, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.

Data We Collect

When you use dumpsterChat, the following data is stored on the server:

How We Use Your Data

Your data is used solely to operate the chat platform:

No Third-Party Analytics

dumpsterChat does not include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.

Third-Party Integrations

If enabled by the server owner, optional integrations may be used:

These integrations are optional and controlled entirely by the server owner.

Data Retention

Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.

Your Rights

Depending on your jurisdiction, you may have the right to:

To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.

Account Deletion Requests

To request deletion of your account and all associated data:

We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.

Security

We take reasonable measures to protect your data:

Children's Privacy

dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.

Changes to This Policy

We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.

Contact

If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.

This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.
`