Files
dumpsterChat/cmd/server/main.go
T
hobokenchicken 215f931311 fix: wire permission checks into message/channel handlers
- Add CheckChannelPermission to permissions.Checker (layers channel overrides on top of role permissions)
- Refactor requireChannelAccess to accept required permission bitmap
- Message Create checks SEND_MESSAGES; List/Search check VIEW_CHANNEL
- BulkDelete uses Checker.CheckPermission for MANAGE_MESSAGES
- Channel handler List filters out channels user cannot view
- Remove dead checkPermission method from message handler
- Fix frontend avatar upload: parse response URL, call updateProfile
2026-06-30 13:47:08 -04:00

357 lines
12 KiB
Go

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/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"
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)
})
// 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, 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, permissionsChecker).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)
}
}