diff --git a/cmd/server/main.go b/cmd/server/main.go index 35eacb6..6750577 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "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" @@ -98,8 +99,11 @@ func main() { 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) + authHandler := auth.NewHandler(database.DB, cfg, hub, mailer) // Permissions checker permissionsChecker := permissions.NewChecker(database.DB) @@ -225,6 +229,9 @@ func main() { notification.NewHandler(database.DB, logger).RegisterRoutes(r) }) + // Push notifications + pushHandler.RegisterRoutes(r) + // Read receipts rsHandler := readstate.NewHandler(database.DB, logger) r.Put("/channels/{channelID}/read", rsHandler.MarkRead) @@ -333,7 +340,7 @@ func main() { }) // Static file serving for production (SPA) - staticDir := "/srv/web" + 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) { diff --git a/cookie.txt b/cookie.txt new file mode 100644 index 0000000..c09df1e --- /dev/null +++ b/cookie.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / TRUE 1785435515 dumpster_session 720a3aad74029e6c8e0c4b93f8db0b48381afc5355e1c1345143a0f5e3d5ae34 diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 diff --git a/dumpster-server.old b/dumpster-server.old new file mode 100755 index 0000000..782479b Binary files /dev/null and b/dumpster-server.old differ diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go index d6bd3c0..def5e70 100644 --- a/internal/auth/cookie.go +++ b/internal/auth/cookie.go @@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration Path: "/", HttpOnly: true, Secure: secure, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, MaxAge: int(duration.Seconds()), }) } diff --git a/internal/auth/email_handlers.go b/internal/auth/email_handlers.go new file mode 100644 index 0000000..a88592c --- /dev/null +++ b/internal/auth/email_handlers.go @@ -0,0 +1,222 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" +) + +func generateToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + user, err := h.getProfile(r.Context(), userID) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + + token := generateToken() + _, err = h.db.ExecContext(r.Context(), ` + INSERT INTO user_tokens (user_id, token, type, expires_at) + VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours') + `, userID, token) + if err != nil { + http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) + return + } + + appURL := "http://" + h.cfg.Host + if h.cfg.Port != "80" && h.cfg.Port != "443" { + appURL += ":" + h.cfg.Port + } + + if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil { + http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var userID string + err := h.db.QueryRowContext(r.Context(), ` + DELETE FROM user_tokens + WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW() + RETURNING user_id + `, req.Token).Scan(&userID) + if err != nil { + http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) + return + } + + _, err = h.db.ExecContext(r.Context(), ` + UPDATE users SET email_verified = TRUE WHERE id = $1 + `, userID) + if err != nil { + http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var userID, username string + err := h.db.QueryRowContext(r.Context(), ` + SELECT id, username FROM users WHERE email = $1 + `, req.Email).Scan(&userID, &username) + if err != nil { + // Silent fail to prevent email enumeration + w.WriteHeader(http.StatusNoContent) + return + } + + token := generateToken() + _, err = h.db.ExecContext(r.Context(), ` + INSERT INTO user_tokens (user_id, token, type, expires_at) + VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour') + `, userID, token) + if err != nil { + http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) + return + } + + appURL := "http://" + h.cfg.Host + if h.cfg.Port != "80" && h.cfg.Port != "443" { + appURL += ":" + h.cfg.Port + } + + h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL) + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if len(req.NewPassword) < 8 { + http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest) + return + } + + var userID string + err := h.db.QueryRowContext(r.Context(), ` + DELETE FROM user_tokens + WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW() + RETURNING user_id + `, req.Token).Scan(&userID) + if err != nil { + http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) + return + } + + hash, err := HashPassword(req.NewPassword) + if err != nil { + http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError) + return + } + + _, err = h.db.ExecContext(r.Context(), ` + UPDATE users SET password_hash = $2 WHERE id = $1 + `, userID, hash) + if err != nil { + http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError) + return + } + + // Delete all sessions to force re-login + h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID) + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Subject string `json:"subject"` + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + // Check if user is an admin across the whole system. + // For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce. + // Or maybe there is a global admin flag? Let's check permissions. + // Actually, let's just make sure they have MANAGE_SERVER on a specific server or something. + // DumpsterChat doesn't have system-level admins out of the box, only Server-level. + // Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server? + // The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR. + var isAdmin bool + err := h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM member_roles mr + JOIN roles r ON mr.role_id = r.id + WHERE mr.user_id = $1 AND (r.permissions & $2) = $2 + ) + `, userID, permissions.ADMINISTRATOR).Scan(&isAdmin) + + if err != nil || !isAdmin { + http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden) + return + } + + rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`) + if err != nil { + http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var email string + if err := rows.Scan(&email); err == nil && email != "" { + // This is synchronous and could block, ideally use a queue, but this is fine for now + h.mailer.SendAnnouncement(email, req.Subject, req.Body) + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index ba53c2b..614b680 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -11,6 +11,7 @@ import ( "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" @@ -21,14 +22,16 @@ type Handler struct { cfg *config.Config sessions *SessionStore hub *gateway.Hub + mailer *email.Mailer } -func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler { +func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub, mailer *email.Mailer) *Handler { return &Handler{ db: db, cfg: cfg, sessions: NewSessionStore(db, cfg), hub: hub, + mailer: mailer, } } @@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) { r.Post("/register", h.Register) r.Post("/login/password", h.Login) r.Post("/logout", h.Logout) + r.Post("/verify-email", h.VerifyEmail) + r.Post("/request-password-reset", h.RequestPasswordReset) + r.Post("/reset-password", h.ResetPassword) } func (h *Handler) RegisterProtectedRoutes(r chi.Router) { + r.Post("/auth/request-verification", h.RequestVerification) + r.Post("/auth/admin/announce", h.AdminAnnounce) r.Get("/auth/me", h.Me) r.Patch("/auth/me", h.UpdateProfile) r.Put("/auth/me/password", h.ChangePassword) @@ -210,16 +218,16 @@ type loginRequest struct { } type updateProfileRequest struct { - AvatarURL string `json:"avatar_url"` - DisplayName string `json:"display_name"` - Bio string `json:"bio"` - AccentColor string `json:"accent_color"` - StatusText string `json:"status_text"` - Status string `json:"status"` - BannerURL string `json:"banner_url"` - Tagline string `json:"tagline"` - Pronouns string `json:"pronouns"` - SocialLinks []struct { + AvatarURL *string `json:"avatar_url"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + AccentColor *string `json:"accent_color"` + StatusText *string `json:"status_text"` + Status *string `json:"status"` + BannerURL *string `json:"banner_url"` + Tagline *string `json:"tagline"` + Pronouns *string `json:"pronouns"` + SocialLinks *[]struct { Platform string `json:"platform"` URL string `json:"url"` } `json:"social_links"` @@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { // @Failure 401 {object} map[string]string // @Router /auth/me [get] func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + cookie, _ := r.Cookie(h.cfg.Session.CookieName) + if cookie != nil { + fmt.Printf("Me Request: Cookie present! value=%s\n", cookie.Value) + } else { + fmt.Printf("Me Request: NO COOKIE!\n") + } userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { + fmt.Printf("Me Request: NO USER ID IN CONTEXT\n") http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } @@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } - if len(req.Bio) > 250 { + // Fetch existing user to merge fields + var existing struct { + DisplayName string + Bio string + AccentColor string + StatusText string + Status string + BannerURL string + Tagline string + Pronouns string + AvatarURL string + SocialLinks []struct { + Platform string `json:"platform"` + URL string `json:"url"` + } + } + var socialLinksBytes []byte + var bannerURL, tagline, pronouns, avatar sql.NullString + + err := h.db.QueryRowContext(r.Context(), ` + SELECT display_name, bio, accent_color, status_text, status, banner_url, tagline, pronouns, avatar, social_links + FROM users WHERE id = $1 + `, userID).Scan(&existing.DisplayName, &existing.Bio, &existing.AccentColor, &existing.StatusText, &existing.Status, &bannerURL, &tagline, &pronouns, &avatar, &socialLinksBytes) + + if err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + existing.BannerURL = bannerURL.String + existing.Tagline = tagline.String + existing.Pronouns = pronouns.String + existing.AvatarURL = avatar.String + if len(socialLinksBytes) > 0 { + json.Unmarshal(socialLinksBytes, &existing.SocialLinks) + } + + // Helper to merge strings + mergeStr := func(ptr *string, old string) string { + if ptr != nil { + return *ptr + } + return old + } + + newBio := mergeStr(req.Bio, existing.Bio) + newStatusText := mergeStr(req.StatusText, existing.StatusText) + newDisplayName := mergeStr(req.DisplayName, existing.DisplayName) + newTagline := mergeStr(req.Tagline, existing.Tagline) + newPronouns := mergeStr(req.Pronouns, existing.Pronouns) + newAccentColor := mergeStr(req.AccentColor, existing.AccentColor) + newStatus := mergeStr(req.Status, existing.Status) + newBannerURL := mergeStr(req.BannerURL, existing.BannerURL) + newAvatarURL := mergeStr(req.AvatarURL, existing.AvatarURL) + + if len(newBio) > 250 { http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest) return } - if len(req.StatusText) > 128 { + if len(newStatusText) > 128 { http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest) return } - if len(req.DisplayName) > 32 { + if len(newDisplayName) > 32 { http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest) return } - if len(req.Tagline) > 128 { + if len(newTagline) > 128 { http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest) return } - if len(req.Pronouns) > 40 { + if len(newPronouns) > 40 { http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest) return } - if req.AccentColor != "" && len(req.AccentColor) != 7 { + if newAccentColor != "" && len(newAccentColor) != 7 { http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest) return } - if req.Status != "" && !isValidStatus(req.Status) { + if newStatus != "" && !isValidStatus(newStatus) { http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest) return } - socialLinksJSON, _ := json.Marshal(req.SocialLinks) - _, err := h.db.ExecContext(r.Context(), ` + var socialLinksJSON []byte + if req.SocialLinks != nil { + socialLinksJSON, _ = json.Marshal(*req.SocialLinks) + } else { + socialLinksJSON, _ = json.Marshal(existing.SocialLinks) + } + + _, err = h.db.ExecContext(r.Context(), ` UPDATE users - SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status), - banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar) + SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6, + banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11 WHERE id = $1 - `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON, req.AvatarURL) + `, userID, newDisplayName, newBio, newAccentColor, newStatusText, newStatus, newBannerURL, newTagline, newPronouns, socialLinksJSON, newAvatarURL) if err != nil { http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) return } - if req.Status != "" && h.hub != nil { + if req.Status != nil && h.hub != nil { user, err := h.getProfile(r.Context(), userID) if err == nil { - h.hub.SetUserStatus(userID, req.Status) - h.hub.BroadcastPresence(userID, user.Username, req.Status) + h.hub.SetUserStatus(userID, *req.Status) + h.hub.BroadcastPresence(userID, user.Username, *req.Status) } } diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index fe89ef6..5600420 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -204,7 +204,16 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } - channels = append(channels, ch) + + // Check VIEW_CHANNEL permission + allowed, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if allowed { + channels = append(channels, ch) + } } if err := rows.Err(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) diff --git a/internal/config/config.go b/internal/config/config.go index 8e2894d..476217e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,14 @@ type Config struct { WebPush WebPushConfig Session SessionConfig Giphy GiphyConfig + SMTP SMTPConfig +} + +type SMTPConfig struct { + Host string + Port string + Username string + Password string } type DatabaseConfig struct { @@ -134,6 +142,12 @@ func Load() *Config { Giphy: GiphyConfig{ APIKey: getEnv("GIPHY_API_KEY", ""), }, + SMTP: SMTPConfig{ + Host: getEnv("SMTP_HOST", ""), + Port: getEnv("SMTP_PORT", ""), + Username: getEnv("SMTP_USERNAME", ""), + Password: getEnv("SMTP_PASSWORD", ""), + }, } } diff --git a/internal/db/db.go b/internal/db/db.go index 39a1cf9..4702d96 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -47,8 +47,19 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE; + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE TABLE IF NOT EXISTS user_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(32) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/internal/dm/handlers.go b/internal/dm/handlers.go index 87301fa..64e4b21 100644 --- a/internal/dm/handlers.go +++ b/internal/dm/handlers.go @@ -228,14 +228,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) { var resp conversationResponse err := h.db.QueryRowContext(ctx, ` - SELECT id, type, name, created_at::text FROM conversations WHERE id = $1 + SELECT id, type, COALESCE(name, ''), created_at::text FROM conversations WHERE id = $1 `, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt) if err != nil { return resp, err } memberRows, err := h.db.QueryContext(ctx, ` - SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '') + SELECT u.id, u.username, COALESCE(u.display_name, ''), COALESCE(u.avatar, '') FROM conversation_members cm JOIN users u ON u.id = cm.user_id WHERE cm.conversation_id = $1 diff --git a/internal/email/mailer.go b/internal/email/mailer.go new file mode 100644 index 0000000..5a0eec4 --- /dev/null +++ b/internal/email/mailer.go @@ -0,0 +1,74 @@ +package email + +import ( + "fmt" + "net/smtp" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" +) + +type Mailer struct { + cfg config.SMTPConfig +} + +func NewMailer(cfg config.SMTPConfig) *Mailer { + return &Mailer{cfg: cfg} +} + +func (m *Mailer) Send(to, subject, body string) error { + if m.cfg.Host == "" || m.cfg.Port == "" { + return fmt.Errorf("SMTP is not configured") + } + + auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + + msg := []byte("To: " + to + "\r\n" + + "Subject: " + subject + "\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n" + + "\r\n" + + body + "\r\n") + + addr := m.cfg.Host + ":" + m.cfg.Port + + // Some servers (like STARTTLS on 587) require standard Dial, then StartTLS + // We'll use smtp.SendMail which does STARTTLS automatically if the server supports it + err := smtp.SendMail(addr, auth, m.cfg.Username, []string{to}, msg) + if err != nil { + // If standard SendMail fails due to TLS issues, fallback to manual TLS/STARTTLS if needed + // But smtp.SendMail is usually sufficient for standard STARTTLS on port 587. + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (m *Mailer) SendVerificationEmail(to, username, token, appURL string) error { + subject := "Verify your dumpsterChat email" + link := fmt.Sprintf("%s/verify-email?token=%s", appURL, token) + body := fmt.Sprintf(` +

Welcome to dumpsterChat, %s!

+

Please verify your email address by clicking the link below:

+

%s

+

If you did not sign up for this account, you can safely ignore this email.

+ `, username, link, link) + + return m.Send(to, subject, body) +} + +func (m *Mailer) SendPasswordResetEmail(to, username, token, appURL string) error { + subject := "Reset your dumpsterChat password" + link := fmt.Sprintf("%s/reset-password?token=%s", appURL, token) + body := fmt.Sprintf(` +

Password Reset Request

+

Hi %s,

+

We received a request to reset your password. Click the link below to set a new password:

+

%s

+

If you did not request this, please ignore this email.

+ `, username, link, link) + + return m.Send(to, subject, body) +} + +func (m *Mailer) SendAnnouncement(to, subject, body string) error { + return m.Send(to, subject, body) +} diff --git a/internal/permissions/checker.go b/internal/permissions/checker.go index 7b42d44..8d390f1 100644 --- a/internal/permissions/checker.go +++ b/internal/permissions/checker.go @@ -96,13 +96,14 @@ func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, } // Layer channel overrides: apply deny first (subtract), then allow (add). - // Role-based overrides from all user roles, then user-specific override. + // Role-based overrides from all user roles, plus the @everyone role, then user-specific override. rows, err := c.db.QueryContext(ctx, ` SELECT co.allow_bitflags, co.deny_bitflags FROM channel_overrides co WHERE co.channel_id = $1 AND co.target_type = 'role' - AND co.target_id IN ( - SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3 + AND ( + co.target_id IN (SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3) + OR co.target_id = (SELECT id FROM roles WHERE server_id = $3 AND is_default = TRUE LIMIT 1) ) `, channelID, userID, serverID) if err != nil { diff --git a/internal/push/handlers.go b/internal/push/handlers.go index 16a5835..8dff7f9 100644 --- a/internal/push/handlers.go +++ b/internal/push/handlers.go @@ -8,6 +8,7 @@ import ( "encoding/json" "log/slog" "net/http" + "github.com/go-chi/chi/v5" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" webpush "github.com/SherClockHolmes/webpush-go" @@ -31,10 +32,10 @@ func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog. } } -func (h *Handler) RegisterRoutes(r *http.ServeMux) { - r.HandleFunc("POST /push/subscribe", h.Subscribe) - r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe) - r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey) +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/push/subscribe", h.Subscribe) + r.Post("/push/unsubscribe", h.Unsubscribe) + r.Get("/push/vapid-public-key", h.GetPublicKey) } // @Summary Get VAPID public key diff --git a/internal/server/roles.go b/internal/server/roles.go index 9aee049..7b3f47d 100644 --- a/internal/server/roles.go +++ b/internal/server/roles.go @@ -358,12 +358,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(roles) } -// SeedDefaultRole inserts the @everyone role for a newly created server. +// SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server. // Call this after creating a server (e.g. from the server creation handler). func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error { + // @everyone _, err := db.ExecContext(ctx, ` - INSERT INTO roles (server_id, name, permissions, position, is_default) - VALUES ($1, '@everyone', $2, 0, TRUE) + INSERT INTO roles (server_id, name, permissions, position, is_default, color) + VALUES ($1, '@everyone', $2, 0, TRUE, NULL) `, serverID, permissions.DefaultEveryonePermissions) + if err != nil { + return err + } + + // Admin + adminPerms := permissions.ADMINISTRATOR + _, err = db.ExecContext(ctx, ` + INSERT INTO roles (server_id, name, permissions, position, is_default, color) + VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75') + `, serverID, adminPerms) + if err != nil { + return err + } + + // Moderator + modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE + _, err = db.ExecContext(ctx, ` + INSERT INTO roles (server_id, name, permissions, position, is_default, color) + VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef') + `, serverID, modPerms) + return err } diff --git a/web/src/App.tsx b/web/src/App.tsx index e7f9201..7547073 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; import { LoginForm } from './components/LoginForm.tsx'; import { Layout } from './components/Layout.tsx'; import { ChatArea } from './components/ChatArea.tsx'; @@ -16,6 +17,17 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } function App() { + const fetchMe = useAuthStore((state) => state.fetchMe); + const [init, setInit] = useState(false); + + useEffect(() => { + fetchMe().finally(() => setInit(true)); + }, [fetchMe]); + + if (!init) { + return
Loading...
; + } + return ( diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index bb4fb4f..d70552f 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -82,7 +82,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp const result = await api.post('/servers/' + serverId + '/channels', body); const newChannel = { id: result.id, - serverId: result.server_id, + server_id: result.server_id, name: result.name, type: result.type, category: result.category || null, diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 98d9c9a..c9b3bca 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore, type UserStatus } from '../stores/auth.ts'; import { useWebSocketStore } from '../stores/ws.ts'; import { useServerStore } from '../stores/server.ts'; +import { useLayoutStore } from '../stores/layout.ts'; import { ServerBar } from './ServerBar.tsx'; import { ChannelList } from './ChannelList.tsx'; import { ConversationList } from './ConversationList.tsx'; @@ -29,7 +30,6 @@ export function Layout() { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isLoading = useAuthStore((state) => state.isLoading); const user = useAuthStore((state) => state.user); - const fetchMe = useAuthStore((state) => state.fetchMe); const logout = useAuthStore((state) => state.logout); const updateProfile = useAuthStore((state) => state.updateProfile); const wsConnect = useWebSocketStore((s) => s.connect); @@ -38,14 +38,14 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const [showStatusMenu, setShowStatusMenu] = useState(false); - const [dmMode, setDmMode] = useState(false); + const isDM = useLayoutStore((s) => s.isDM); + const setDM = useLayoutStore((s) => s.setDM); const [showServerSettings, setShowServerSettings] = useState(false); const activeServerId = useServerStore((s) => s.activeServerId); useEffect(() => { - fetchMe(); wsConnect(); return () => { wsDisconnect(); }; - }, [fetchMe, wsConnect, wsDisconnect]); + }, [wsConnect, wsDisconnect]); useEffect(() => { if (!isLoading && !isAuthenticated && location.pathname !== '/login') { navigate('/login', { replace: true }); @@ -120,9 +120,9 @@ export function Layout() {
- {dmMode ? : } + {isDM ? : }
@@ -155,7 +155,7 @@ export function Layout() {
- {!dmMode && } + {!isDM && } {showServerSettings && activeServerId && ( setShowServerSettings(false)} /> diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx index 590bc7d..93311da 100644 --- a/web/src/components/MemberList.tsx +++ b/web/src/components/MemberList.tsx @@ -6,6 +6,8 @@ import { usePresenceStore } from "../stores/presence.ts"; import type { UserStatus } from "../stores/auth.ts"; import { MemberContextMenu } from "./MemberContextMenu.tsx"; import { useConversationStore } from "../stores/conversation.ts"; +import { useLayoutStore } from "../stores/layout.ts"; +import { useNavigate } from "react-router-dom"; function statusIcon(status: UserStatus): string { switch (status) { @@ -53,7 +55,10 @@ function MemberRow({ member }: { member: Member }) { const currentUser = useAuthStore((s) => s.user); const userPerms = useServerStore((s) => s.userPermissions); const activeServerId = useServerStore((s) => s.activeServerId); + const setActiveServer = useServerStore((s) => s.setActiveServer); const createConversation = useConversationStore((s) => s.createConversation); + const setDM = useLayoutStore((s) => s.setDM); + const navigate = useNavigate(); const canKick = userPerms?.kick_members ?? false; const canBan = userPerms?.ban_members ?? false; @@ -79,9 +84,16 @@ function MemberRow({ member }: { member: Member }) { canKick={canKick} canBan={canBan} canMute={canMute} - onMessage={() => { - createConversation([member.id]); - setMenuOpen(false); + onMessage={async () => { + try { + const conv = await createConversation([member.id]); + setMenuOpen(false); + setActiveServer(null); + setDM(true); + navigate(`/dm/${conv.id}`); + } catch (err) { + console.error("Failed to start DM:", err); + } }} onKick={(_reason) => { fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" }) diff --git a/web/src/components/NewConversationModal.tsx b/web/src/components/NewConversationModal.tsx index 1df4063..1bb9cf3 100644 --- a/web/src/components/NewConversationModal.tsx +++ b/web/src/components/NewConversationModal.tsx @@ -15,7 +15,19 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) { const activeServerId = useServerStore((s) => s.activeServerId); const membersByServer = useMemberStore((s) => s.membersByServer); const currentUserId = useAuthStore((s) => s.user?.id); - const members = activeServerId ? membersByServer[activeServerId] || [] : []; + + const members = useMemo(() => { + const map = new Map(); + if (activeServerId && membersByServer[activeServerId]) { + membersByServer[activeServerId].forEach((m) => map.set(m.id, m)); + } else { + // Global search if no active server + Object.values(membersByServer).forEach((list) => { + list.forEach((m) => map.set(m.id, m)); + }); + } + return Array.from(map.values()); + }, [activeServerId, membersByServer]); const filtered = useMemo(() => { const q = query.toLowerCase(); diff --git a/web/src/components/UserSettings.tsx b/web/src/components/UserSettings.tsx index 0ba9e1a..c44be34 100644 --- a/web/src/components/UserSettings.tsx +++ b/web/src/components/UserSettings.tsx @@ -322,20 +322,40 @@ export function UserSettings() { -
- - - {isSubscribed ? '● subscribed' : '○ not subscribed'} - +
+
+ + + {Notification.permission === 'granted' ? '● allowed' : '○ not allowed'} + +
+
+ + + {isSubscribed ? '● subscribed' : '○ not subscribed'} + +

- Receive push notifications for @mentions and DMs when the app is closed. + Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.

)} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 5830549..73e9535 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -131,7 +131,7 @@ export const useAuthStore = create((set) => ({ fetchMe: async () => { set({ isLoading: true, error: null }); try { - const user = await api.get("/auth/me"); + const user = await api.get(`/auth/me?t=${Date.now()}`); set({ user, isAuthenticated: true, isLoading: false }); } catch (error) { set({ diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts index 3012a9a..1714e79 100644 --- a/web/src/stores/channel.ts +++ b/web/src/stores/channel.ts @@ -5,7 +5,7 @@ export type ChannelType = 'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'li export interface Channel { id: string; - serverId: string; + server_id: string; name: string; type: ChannelType; category: string | null; @@ -52,22 +52,22 @@ export const useChannelStore = create((set) => ({ addChannel: (channel) => set((state) => { - const list = state.channelsByServer[channel.serverId] || []; + const list = state.channelsByServer[channel.server_id] || []; return { channelsByServer: { ...state.channelsByServer, - [channel.serverId]: [...list, channel], + [channel.server_id]: [...list, channel], }, }; }), updateChannel: (channel) => set((state) => { - const list = state.channelsByServer[channel.serverId] || []; + const list = state.channelsByServer[channel.server_id] || []; return { channelsByServer: { ...state.channelsByServer, - [channel.serverId]: list.map((c) => (c.id === channel.id ? channel : c)), + [channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)), }, }; }), diff --git a/web/src/stores/typing.ts b/web/src/stores/typing.ts index d48602f..dfd322d 100644 --- a/web/src/stores/typing.ts +++ b/web/src/stores/typing.ts @@ -20,7 +20,7 @@ export const useTypingStore = create((set, get) => ({ sendTypingStart: (channelId) => { const ws = useWebSocketStore.getState(); if (ws.connected) { - ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } }); + ws.send({ type: 'TYPING_START', data: { channel_id: channelId } }); } }, diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts index 23e5ca0..3453022 100644 --- a/web/src/stores/voice.ts +++ b/web/src/stores/voice.ts @@ -11,7 +11,7 @@ import { } from 'livekit-client'; import { api } from '../lib/api.ts'; import { useWebSocketStore } from './ws.ts'; -import { useAuthStore } from './auth.ts'; + export interface VoiceParticipant { identity: string; @@ -201,7 +201,7 @@ export const useVoiceStore = create((set, get) => ({ const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_JOIN', - payload: { room_name: channelId }, + data: { room_name: channelId }, }); } catch (err) { set({ @@ -220,7 +220,7 @@ export const useVoiceStore = create((set, get) => ({ if (channelId) { wsSend({ type: 'VOICE_LEAVE', - payload: { room_name: channelId }, + data: { room_name: channelId }, }); } @@ -255,7 +255,7 @@ export const useVoiceStore = create((set, get) => ({ const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_MUTE', - payload: { room_name: get().currentRoom, muted: newMuted }, + data: { room_name: get().currentRoom, muted: newMuted }, }); }, @@ -285,7 +285,7 @@ export const useVoiceStore = create((set, get) => ({ const wsSend = useWebSocketStore.getState().send; wsSend({ type: 'VOICE_DEAFEN', - payload: { room_name: get().currentRoom, deafened: newDeafened }, + data: { room_name: get().currentRoom, deafened: newDeafened }, }); }, @@ -335,13 +335,11 @@ export const useVoiceStore = create((set, get) => ({ whisperTo: (targetUserId, targetUsername, message) => { const wsSend = useWebSocketStore.getState().send; - const me = useAuthStore.getState().user; wsSend({ type: 'VOICE_WHISPER', - payload: { + data: { target_user_id: targetUserId, target_username: targetUsername, - from_username: me?.username || 'unknown', message: message || '', }, }); diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index 19386de..1095f32 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -5,6 +5,7 @@ import { useServerStore } from './server.ts'; import { usePresenceStore } from './presence.ts'; import { useTypingStore } from './typing.ts'; import { useVoiceStore } from './voice.ts'; +import { useAuthStore } from './auth.ts'; import type { Message } from './message.ts'; import type { Channel } from './channel.ts'; import type { Server } from './server.ts'; @@ -14,7 +15,7 @@ type UnknownPayload = Record; export interface WsEvent { type: string; - payload: UnknownPayload; + data?: UnknownPayload; } interface WebSocketState { @@ -25,6 +26,17 @@ interface WebSocketState { send: (event: WsEvent) => void; } +let unreadCount = 0; + +if (typeof window !== 'undefined') { + window.addEventListener('focus', () => { + unreadCount = 0; + if (document.title.match(/^\(\d+\)\s/)) { + document.title = document.title.replace(/^\(\d+\)\s/, ''); + } + }); +} + function getWsHost(): string { const { protocol, host } = window.location; const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'; @@ -35,32 +47,13 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function extractMessage(payload: UnknownPayload): Message | null { - if (!isRecord(payload.message)) return null; - return payload.message as unknown as Message; -} - -function extractChannel(payload: UnknownPayload): Channel | null { - if (!isRecord(payload.channel)) return null; - return payload.channel as unknown as Channel; -} - -function extractServer(payload: UnknownPayload): Server | null { - if (!isRecord(payload.server)) return null; - return payload.server as unknown as Server; -} - -function extractIds(payload: UnknownPayload): { channel_id: string; message_id: string } | null { - if (typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') { +function extractIds(payload: UnknownPayload | undefined): { channel_id: string; message_id: string } | null { + if (!payload || typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') { return null; } return { channel_id: payload.channel_id, message_id: payload.message_id }; } -function extractChannelId(payload: UnknownPayload): string | null { - return typeof payload.channel_id === 'string' ? payload.channel_id : null; -} - export const useWebSocketStore = create((set, get) => ({ socket: null, connected: false, @@ -115,7 +108,7 @@ export const useWebSocketStore = create((set, get) => ({ } if (data.type === 'ping') { - get().send({ type: 'pong', payload: {} }); + get().send({ type: 'pong', data: {} }); return; } @@ -127,51 +120,75 @@ export const useWebSocketStore = create((set, get) => ({ const removeChannel = useChannelStore.getState().removeChannel; const updateServer = useServerStore.getState().updateServer; + const payload = data.data || {}; + switch (data.type) { - case 'message': { - const msg = extractMessage(data.payload); - if (msg) addMessage(msg); + case 'MESSAGE_CREATE': { + if (isRecord(payload)) { + const msg = payload as unknown as Message; + addMessage(msg); + + // Desktop notification + const currentUserId = useAuthStore.getState().user?.id; + if (msg.author_id !== currentUserId && !document.hasFocus()) { + // Update browser tab title + unreadCount++; + if (document.title.match(/^\(\d+\)\s/)) { + document.title = document.title.replace(/^\(\d+\)\s/, `(${unreadCount}) `); + } else { + document.title = `(${unreadCount}) ` + document.title; + } + + if (Notification.permission === 'granted') { + const title = msg.author_username ? `New message from ${msg.author_display_name || msg.author_username}` : 'New message'; + const notification = new Notification(title, { + body: msg.content, + icon: '/icons/icon-192.png', + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } + } + } break; } - case 'message_updated': { - const msg = extractMessage(data.payload); - if (msg) updateMessage(msg); + case 'MESSAGE_UPDATE': { + if (isRecord(payload)) updateMessage(payload as unknown as Message); break; } - case 'message_deleted': { - const ids = extractIds(data.payload); + case 'MESSAGE_DELETE': { + const ids = extractIds(payload); if (ids) removeMessage(ids.channel_id, ids.message_id); break; } - case 'channel_created': { - const channel = extractChannel(data.payload); - if (channel) addChannel(channel); + case 'CHANNEL_CREATE': { + if (isRecord(payload)) addChannel(payload as unknown as Channel); break; } - case 'channel_updated': { - const channel = extractChannel(data.payload); - if (channel) updateChannel(channel); + case 'CHANNEL_UPDATE': { + if (isRecord(payload)) updateChannel(payload as unknown as Channel); break; } - case 'channel_deleted': { - const channelId = extractChannelId(data.payload); + case 'CHANNEL_DELETE': { + const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : null; if (channelId) removeChannel(channelId); break; } - case 'server_updated': { - const server = extractServer(data.payload); - if (server) updateServer(server); + case 'SERVER_UPDATE': { + if (isRecord(payload)) updateServer(payload as unknown as Server); break; } case 'PRESENCE_UPDATE': { - const { user_id, username, status } = data.payload as Record; + const { user_id, username, status } = payload as Record; if (user_id && username && status) { usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus); } break; } case 'TYPING_START': { - const { channel_id, user_id, username } = data.payload as Record; + const { channel_id, user_id, username } = payload as Record; if (channel_id && user_id && username) { useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username }); } @@ -179,7 +196,7 @@ export const useWebSocketStore = create((set, get) => ({ } case 'VOICE_WHISPER': { // Forwarded from backend — only intended recipient gets this - const { from_user_id, from_username, message } = data.payload as Record; + const { from_user_id, from_username, message } = payload as Record; useVoiceStore.getState()._addWhisper({ fromUserId: from_user_id || '', fromUsername: from_username || 'unknown',