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(` +
Please verify your email address by clicking the link below:
+ +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(` +Hi %s,
+We received a request to reset your password. Click the link below to set a new password:
+ +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- 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.