feat: add SMTP email support and fix profile/permissions bugs

This commit is contained in:
root
2026-06-30 19:29:08 +00:00
parent 4ded582dc4
commit f4437c5e1d
26 changed files with 643 additions and 129 deletions
+9 -2
View File
@@ -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) {
+5
View File
@@ -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
Regular → Executable
View File
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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()),
})
}
+222
View File
@@ -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)
}
+103 -26
View File
@@ -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)
}
}
+9
View File
@@ -204,8 +204,17 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
// 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)
return
+14
View File
@@ -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", ""),
},
}
}
+11
View File
@@ -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,
+2 -2
View File
@@ -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
+74
View File
@@ -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(`
<h1>Welcome to dumpsterChat, %s!</h1>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="%s">%s</a></p>
<p>If you did not sign up for this account, you can safely ignore this email.</p>
`, 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(`
<h1>Password Reset Request</h1>
<p>Hi %s,</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p><a href="%s">%s</a></p>
<p>If you did not request this, please ignore this email.</p>
`, username, link, link)
return m.Send(to, subject, body)
}
func (m *Mailer) SendAnnouncement(to, subject, body string) error {
return m.Send(to, subject, body)
}
+4 -3
View File
@@ -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 {
+5 -4
View File
@@ -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
+25 -3
View File
@@ -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
}
+12
View File
@@ -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 <div className="h-screen w-screen bg-gb-bg flex items-center justify-center text-gb-fg-s font-mono text-xs">Loading...</div>;
}
return (
<BrowserRouter>
<Routes>
+1 -1
View File
@@ -82,7 +82,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
const result = await api.post<ChannelApiResponse>('/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,
+10 -10
View File
@@ -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() {
<div className="flex-1 flex min-h-0">
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
<button
onClick={() => setDmMode(false)}
onClick={() => setDM(false)}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
!dmMode
!isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
@@ -132,11 +132,11 @@ export function Layout() {
</button>
<button
onClick={() => {
setDmMode(true);
setDM(true);
setActiveServer(null);
}}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
dmMode
isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
@@ -146,7 +146,7 @@ export function Layout() {
</button>
</div>
<ServerBar />
{dmMode ? <ConversationList /> : <ChannelList />}
{isDM ? <ConversationList /> : <ChannelList />}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<VoicePanel />
@@ -155,7 +155,7 @@ export function Layout() {
</div>
</div>
</div>
{!dmMode && <MemberList />}
{!isDM && <MemberList />}
</div>
{showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
+14 -2
View File
@@ -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]);
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" })
+13 -1
View File
@@ -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<string, any>();
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();
+21 -1
View File
@@ -322,6 +322,25 @@ export function UserSettings() {
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
NOTIFICATIONS:
</label>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button
type="button"
onClick={async () => {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
// Force a re-render to update the permission status
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
}
}}
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
@@ -334,8 +353,9 @@ export function UserSettings() {
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
</div>
</div>
<p className="text-gb-fg-f text-xs font-mono mt-2">
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.
</p>
</div>
)}
+1 -1
View File
@@ -131,7 +131,7 @@ export const useAuthStore = create<AuthState>((set) => ({
fetchMe: async () => {
set({ isLoading: true, error: null });
try {
const user = await api.get<User>("/auth/me");
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
+5 -5
View File
@@ -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<ChannelState>((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)),
},
};
}),
+1 -1
View File
@@ -20,7 +20,7 @@ export const useTypingStore = create<TypingState>((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 } });
}
},
+6 -8
View File
@@ -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<VoiceState>((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<VoiceState>((set, get) => ({
if (channelId) {
wsSend({
type: 'VOICE_LEAVE',
payload: { room_name: channelId },
data: { room_name: channelId },
});
}
@@ -255,7 +255,7 @@ export const useVoiceStore = create<VoiceState>((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<VoiceState>((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<VoiceState>((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 || '',
},
});
+62 -45
View File
@@ -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<string, unknown>;
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<string, unknown> {
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<WebSocketState>((set, get) => ({
socket: null,
connected: false,
@@ -115,7 +108,7 @@ export const useWebSocketStore = create<WebSocketState>((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<WebSocketState>((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<string, string>;
const { user_id, username, status } = payload as Record<string, string>;
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<string, string>;
const { channel_id, user_id, username } = payload as Record<string, string>;
if (channel_id && user_id && username) {
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
}
@@ -179,7 +196,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
case 'VOICE_WHISPER': {
// Forwarded from backend — only intended recipient gets this
const { from_user_id, from_username, message } = data.payload as Record<string, string>;
const { from_user_id, from_username, message } = payload as Record<string, string>;
useVoiceStore.getState()._addWhisper({
fromUserId: from_user_id || '',
fromUsername: from_username || 'unknown',