diff --git a/Makefile b/Makefile index 38100e0..403a6c2 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ docs: # Build the web frontend build-web: - cd web && [ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && npm run build || (cd web && npm run build) + cd web && ([ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && npm run build || npm run build) # Run the server locally (dev mode) run: build-server diff --git a/cmd/server/main.go b/cmd/server/main.go index ec08c22..60c08e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,307 +1,307 @@ -package main - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - - _ "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/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/moderation" - "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, 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) - - r.Route("/{serverID}", func(r chi.Router) { - r.Get("/", serverHandler.Get) - r.Patch("/", serverHandler.Update) - r.Delete("/", serverHandler.Delete) - - // Server members - memberHandler.RegisterRoutes(r) - - // Moderation - r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick) - r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban) - r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans) - r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban) - r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute) - r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute) - - // Channels (nested under servers) - r.Route("/channels", func(r chi.Router) { - channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) - }) - }) - }) - - // 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) - }) - - // 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) - } -} +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + _ "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/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/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, 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) + + r.Route("/{serverID}", func(r chi.Router) { + r.Get("/", serverHandler.Get) + r.Patch("/", serverHandler.Update) + r.Delete("/", serverHandler.Delete) + + // Server members + memberHandler.RegisterRoutes(r) + + // Moderation + r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick) + r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban) + r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans) + r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban) + r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute) + r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute) + + // Channels (nested under servers) + r.Route("/channels", func(r chi.Router) { + channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) + }) + }) + }) + + // 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) + }) + + // 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) + } +} diff --git a/cmd/tui/api.go b/cmd/tui/api.go index e354339..a4e1dd5 100644 --- a/cmd/tui/api.go +++ b/cmd/tui/api.go @@ -13,8 +13,8 @@ import ( // APIClient wraps HTTP calls to the dumpster server. type APIClient struct { - BaseURL string - HTTP *http.Client + BaseURL string + HTTP *http.Client SessionToken string } diff --git a/cmd/tui/keys.go b/cmd/tui/keys.go index e43574a..b138ff3 100644 --- a/cmd/tui/keys.go +++ b/cmd/tui/keys.go @@ -3,18 +3,18 @@ package main import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Up key.Binding - Down key.Binding - Top key.Binding - Bottom key.Binding - Tab key.Binding - ShiftTab key.Binding - FocusInput key.Binding - Unfocus key.Binding - Quit key.Binding - Help key.Binding - Send key.Binding - MemberToggle key.Binding + Up key.Binding + Down key.Binding + Top key.Binding + Bottom key.Binding + Tab key.Binding + ShiftTab key.Binding + FocusInput key.Binding + Unfocus key.Binding + Quit key.Binding + Help key.Binding + Send key.Binding + MemberToggle key.Binding ChannelPicker key.Binding } diff --git a/cmd/tui/theme.go b/cmd/tui/theme.go index 74aedcd..f512e51 100644 --- a/cmd/tui/theme.go +++ b/cmd/tui/theme.go @@ -53,8 +53,8 @@ var ( BorderForeground(aqua) UnfocusedBorder = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(bgT) + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(bgT) // Server list ServerActive = lipgloss.NewStyle(). diff --git a/cmd/tui/voice.go b/cmd/tui/voice.go index 53d7619..a5e85fd 100644 --- a/cmd/tui/voice.go +++ b/cmd/tui/voice.go @@ -4,12 +4,12 @@ package main // VoiceState holds the current voice connection state. type VoiceState struct { - Connected bool - RoomName string - LiveKitURL string - Token string - Muted bool - Deafened bool + Connected bool + RoomName string + LiveKitURL string + Token string + Muted bool + Deafened bool Participants []VoiceParticipant } diff --git a/internal/dm/handlers.go b/internal/dm/handlers.go index fdf0250..87301fa 100644 --- a/internal/dm/handlers.go +++ b/internal/dm/handlers.go @@ -17,8 +17,8 @@ import ( // Handler handles direct-message conversations. type Handler struct { - db *sql.DB - hub *gateway.Hub + db *sql.DB + hub *gateway.Hub logger *slog.Logger } @@ -256,14 +256,14 @@ func (h *Handler) buildResponse(ctx context.Context, convID string) (conversatio } type messageResponse struct { - ID string `json:"id"` - ConversationID string `json:"conversation_id"` - AuthorID string `json:"author_id"` - AuthorName string `json:"author_username"` - DisplayName *string `json:"author_display_name"` - Content string `json:"content"` - EditedAt *string `json:"edited_at"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_username"` + DisplayName *string `json:"author_display_name"` + Content string `json:"content"` + EditedAt *string `json:"edited_at"` + CreatedAt string `json:"created_at"` } // ListMessages lists messages in a conversation. diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 266c20a..51f2eee 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -12,8 +12,8 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" - "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "github.com/go-chi/chi/v5" ) @@ -136,8 +136,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "slowmode", - "retry_after": retryAfter, + "error": "slowmode", + "retry_after": retryAfter, "retry_after_ms": retryAfter * 1000, }) return diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 504ccb4..99bf89e 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -1,105 +1,105 @@ -package middleware - -import ( - "net/http" - "strings" -) - -// CSRFProtect returns middleware that validates the Origin header on -// state-changing methods (POST, PUT, PATCH, DELETE). -// -// An origin is trusted if: -// 1. It's in the explicit additionalOrigins list, OR -// 2. Its hostname matches the configured host, OR -// 3. Its hostname matches the request's own Host header (same-origin check) -func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler { - originSet := make(map[string]struct{}) - for _, o := range additionalOrigins { - o = strings.TrimSpace(o) - if o != "" { - originSet[o] = struct{}{} - } - } - - hostname := host - if idx := strings.LastIndex(hostname, ":"); idx > 0 { - hostname = hostname[:idx] - } - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "POST", "PUT", "PATCH", "DELETE": - origin := r.Header.Get("Origin") - if origin == "" { - referer := r.Header.Get("Referer") - if referer != "" { - for o := range originSet { - if strings.HasPrefix(referer, o) { - origin = o - break - } - } - } - } - - if origin != "" { - // Extract request host for same-origin check - reqHost := r.Host - if idx := strings.LastIndex(reqHost, ":"); idx > 0 { - reqHost = reqHost[:idx] - } - - if !isOriginTrusted(origin, hostname, port, reqHost, originSet) { - http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) - return - } - } - } - next.ServeHTTP(w, r) - }) - } -} - -func isOriginTrusted(origin, host, port, reqHost string, originSet map[string]struct{}) bool { - if _, ok := originSet[origin]; ok { - return true - } - - withoutScheme := origin - if idx := strings.Index(withoutScheme, "://"); idx >= 0 { - withoutScheme = withoutScheme[idx+3:] - } - if idx := strings.Index(withoutScheme, "/"); idx >= 0 { - withoutScheme = withoutScheme[:idx] - } - - originHost := withoutScheme - originPort := "" - if idx := strings.LastIndex(originHost, ":"); idx >= 0 { - originPort = originHost[idx+1:] - originHost = originHost[:idx] - } - - originHost = strings.TrimPrefix(originHost, "[") - originHost = strings.TrimSuffix(originHost, "]") - - // Match configured host - if host != "" && originHost == host { - return true - } - - // Match request's own Host header (same-origin) - if reqHost != "" && originHost == reqHost { - return true - } - - // Localhost variants - if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { - if port == "" || originPort == "" || originPort == port { - return true - } - } - - return false -} +package middleware + +import ( + "net/http" + "strings" +) + +// CSRFProtect returns middleware that validates the Origin header on +// state-changing methods (POST, PUT, PATCH, DELETE). +// +// An origin is trusted if: +// 1. It's in the explicit additionalOrigins list, OR +// 2. Its hostname matches the configured host, OR +// 3. Its hostname matches the request's own Host header (same-origin check) +func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler { + originSet := make(map[string]struct{}) + for _, o := range additionalOrigins { + o = strings.TrimSpace(o) + if o != "" { + originSet[o] = struct{}{} + } + } + + hostname := host + if idx := strings.LastIndex(hostname, ":"); idx > 0 { + hostname = hostname[:idx] + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST", "PUT", "PATCH", "DELETE": + origin := r.Header.Get("Origin") + if origin == "" { + referer := r.Header.Get("Referer") + if referer != "" { + for o := range originSet { + if strings.HasPrefix(referer, o) { + origin = o + break + } + } + } + } + + if origin != "" { + // Extract request host for same-origin check + reqHost := r.Host + if idx := strings.LastIndex(reqHost, ":"); idx > 0 { + reqHost = reqHost[:idx] + } + + if !isOriginTrusted(origin, hostname, port, reqHost, originSet) { + http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) + return + } + } + } + next.ServeHTTP(w, r) + }) + } +} + +func isOriginTrusted(origin, host, port, reqHost string, originSet map[string]struct{}) bool { + if _, ok := originSet[origin]; ok { + return true + } + + withoutScheme := origin + if idx := strings.Index(withoutScheme, "://"); idx >= 0 { + withoutScheme = withoutScheme[idx+3:] + } + if idx := strings.Index(withoutScheme, "/"); idx >= 0 { + withoutScheme = withoutScheme[:idx] + } + + originHost := withoutScheme + originPort := "" + if idx := strings.LastIndex(originHost, ":"); idx >= 0 { + originPort = originHost[idx+1:] + originHost = originHost[:idx] + } + + originHost = strings.TrimPrefix(originHost, "[") + originHost = strings.TrimSuffix(originHost, "]") + + // Match configured host + if host != "" && originHost == host { + return true + } + + // Match request's own Host header (same-origin) + if reqHost != "" && originHost == reqHost { + return true + } + + // Localhost variants + if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { + if port == "" || originPort == "" || originPort == port { + return true + } + } + + return false +} diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go index df9b104..4f7be43 100644 --- a/internal/permissions/permissions.go +++ b/internal/permissions/permissions.go @@ -2,28 +2,28 @@ package permissions // Permission bitflags. const ( - VIEW_CHANNEL int64 = 1 << 0 - SEND_MESSAGES int64 = 1 << 1 - MANAGE_MESSAGES int64 = 1 << 2 - KICK_MEMBERS int64 = 1 << 3 - BAN_MEMBERS int64 = 1 << 4 - MANAGE_SERVER int64 = 1 << 5 - MANAGE_CHANNELS int64 = 1 << 6 - ADMINISTRATOR int64 = 1 << 7 - CONNECT_VOICE int64 = 1 << 8 - SPEAK_VOICE int64 = 1 << 9 - SHARE_SCREEN int64 = 1 << 10 - MUTE_MEMBERS int64 = 1 << 11 + VIEW_CHANNEL int64 = 1 << 0 + SEND_MESSAGES int64 = 1 << 1 + MANAGE_MESSAGES int64 = 1 << 2 + KICK_MEMBERS int64 = 1 << 3 + BAN_MEMBERS int64 = 1 << 4 + MANAGE_SERVER int64 = 1 << 5 + MANAGE_CHANNELS int64 = 1 << 6 + ADMINISTRATOR int64 = 1 << 7 + CONNECT_VOICE int64 = 1 << 8 + SPEAK_VOICE int64 = 1 << 9 + SHARE_SCREEN int64 = 1 << 10 + MUTE_MEMBERS int64 = 1 << 11 CREATE_INSTANT_INVITE int64 = 1 << 12 - CHANGE_NICKNAME int64 = 1 << 13 - MANAGE_NICKNAMES int64 = 1 << 14 - MANAGE_ROLES int64 = 1 << 15 - MANAGE_WEBHOOKS int64 = 1 << 16 - EMBED_LINKS int64 = 1 << 17 - ATTACH_FILES int64 = 1 << 18 - ADD_REACTIONS int64 = 1 << 19 - USE_EXTERNAL_EMOJIS int64 = 1 << 20 - MENTION_EVERYONE int64 = 1 << 21 + CHANGE_NICKNAME int64 = 1 << 13 + MANAGE_NICKNAMES int64 = 1 << 14 + MANAGE_ROLES int64 = 1 << 15 + MANAGE_WEBHOOKS int64 = 1 << 16 + EMBED_LINKS int64 = 1 << 17 + ATTACH_FILES int64 = 1 << 18 + ADD_REACTIONS int64 = 1 << 19 + USE_EXTERNAL_EMOJIS int64 = 1 << 20 + MENTION_EVERYONE int64 = 1 << 21 ) // DefaultEveryonePermissions is granted to the @everyone role when a server is created. diff --git a/internal/push/handlers.go b/internal/push/handlers.go index b7ae03d..afb01d0 100644 --- a/internal/push/handlers.go +++ b/internal/push/handlers.go @@ -183,8 +183,6 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin } } - - // SendChannelNotification sends a push notification to all server members (except the author) // when a new message is posted in a channel. func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) { @@ -254,6 +252,7 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author } } } + // GenerateVAPIDKeys generates a new VAPID key pair. func GenerateVAPIDKeys() (publicKey, privateKey string, err error) { privateKeyBytes := make([]byte, 32) diff --git a/internal/reaction/handlers.go b/internal/reaction/handlers.go index d9e73be..cc5ce59 100644 --- a/internal/reaction/handlers.go +++ b/internal/reaction/handlers.go @@ -148,9 +148,9 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventReactionAdd, Data: map[string]interface{}{ - "reaction": reaction, - "channel_id": channelID, - "message_id": messageID, + "reaction": reaction, + "channel_id": channelID, + "message_id": messageID, }, }) diff --git a/internal/voice/client.go b/internal/voice/client.go index 5c82918..6f8ece6 100644 --- a/internal/voice/client.go +++ b/internal/voice/client.go @@ -5,15 +5,15 @@ import ( "fmt" "time" - lksdk "github.com/livekit/server-sdk-go/v2" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" ) type Client struct { - apiKey string - apiSecret string - url string + apiKey string + apiSecret string + url string roomClient *lksdk.RoomServiceClient } @@ -62,8 +62,8 @@ func (c *Client) CreateRoom(name string) (*livekit.Room, error) { } room, err := c.roomClient.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ - Name: name, - EmptyTimeout: 300, // 5 minutes before auto-delete when empty + Name: name, + EmptyTimeout: 300, // 5 minutes before auto-delete when empty MaxParticipants: 20, }) if err != nil {