fix(build): make build-web works; gofmt all Go files

This commit is contained in:
2026-06-30 10:01:11 -04:00
parent 30e159cbdb
commit d3b7f39b9e
13 changed files with 479 additions and 480 deletions
+1 -1
View File
@@ -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
+307 -307
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
}
+12 -12
View File
@@ -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
}
+2 -2
View File
@@ -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().
+6 -6
View File
@@ -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
}
+10 -10
View File
@@ -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.
+3 -3
View File
@@ -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
+105 -105
View File
@@ -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
}
+21 -21
View File
@@ -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.
+1 -2
View File
@@ -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)
+3 -3
View File
@@ -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,
},
})
+6 -6
View File
@@ -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 {