56af584ede
Moved thread and forum-tag routes from nested /servers/{sid}/channels/ to
top-level /channels/{id}/... to match frontend API calls. Forum posts were
getting SPA HTML fallback instead of JSON.
Added orange notification dots to DM and server buttons in ServerBar.
432 lines
15 KiB
Go
432 lines
15 KiB
Go
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"
|
|
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)")
|
|
}
|
|
|
|
// 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(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, 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) {
|
|
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).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
|
|
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)
|
|
|
|
// 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"),
|
|
))
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
}
|