Complete WebAuthn backend + push notification mention parsing
Backend: - internal/auth/webauthn.go: full RegisterBegin/Finish, LoginBegin/Finish implementation - In-memory challenge store with eviction - Credential storage in webauthn_credentials table - Session creation on successful passkey login - Cookie-based challenge linking for login flow - internal/message/mentions.go: @mention parsing and push notification dispatch - Parses @user, @everyone, @role mentions - Skips DND users - Async goroutine dispatch for non-blocking - internal/message/handlers.go: wired mention handler into Create - cmd/server/main.go: added push handler initialization, pass to message handler
This commit is contained in:
+8
-1
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
||||||
@@ -76,6 +77,12 @@ func main() {
|
|||||||
logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)")
|
logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push notification handler (nil if no VAPID keys)
|
||||||
|
pushHandler := push.NewHandler(database.DB, cfg.WebPush.PublicKey, cfg.WebPush.PrivateKey, cfg.WebPush.Subject, logger)
|
||||||
|
if cfg.WebPush.PublicKey == "" {
|
||||||
|
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
||||||
|
}
|
||||||
|
|
||||||
// Auth handler
|
// Auth handler
|
||||||
authHandler := auth.NewHandler(database.DB, cfg)
|
authHandler := auth.NewHandler(database.DB, cfg)
|
||||||
|
|
||||||
@@ -122,7 +129,7 @@ func main() {
|
|||||||
|
|
||||||
// Messages (under channels)
|
// Messages (under channels)
|
||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||||
message.NewHandler(database.DB, hub).RegisterRoutes(r)
|
message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Giphy search
|
// Giphy search
|
||||||
|
|||||||
+291
-42
@@ -1,38 +1,83 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// challengeStore temporarily stores WebAuthn session data between begin and finish.
|
||||||
|
type challengeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data map[string]*webauthn.SessionData
|
||||||
|
order []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChallengeStore() *challengeStore {
|
||||||
|
return &challengeStore{data: make(map[string]*webauthn.SessionData)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *challengeStore) Set(key string, sd *webauthn.SessionData) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data[key] = sd
|
||||||
|
s.order = append(s.order, key)
|
||||||
|
// Evict old entries (keep max 100)
|
||||||
|
for len(s.order) > 100 {
|
||||||
|
delete(s.data, s.order[0])
|
||||||
|
s.order = s.order[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *challengeStore) Get(key string) (*webauthn.SessionData, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sd, ok := s.data[key]
|
||||||
|
if ok {
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
return sd, ok
|
||||||
|
}
|
||||||
|
|
||||||
type WebAuthnHandler struct {
|
type WebAuthnHandler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
wa *webauthn.WebAuthn
|
wa *webauthn.WebAuthn
|
||||||
sessions *SessionStore
|
sessions *SessionStore
|
||||||
logger *slog.Logger
|
challenges *challengeStore
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebAuthnHandler(db *sql.DB, cfg *config.Config, sessions *SessionStore, logger *slog.Logger) (*WebAuthnHandler, error) {
|
func NewWebAuthnHandler(db *sql.DB, cfg *config.Config, sessions *SessionStore, logger *slog.Logger) (*WebAuthnHandler, error) {
|
||||||
|
origins := []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port}
|
||||||
|
if cfg.Host == "localhost" {
|
||||||
|
origins = append(origins, "http://localhost:"+cfg.Port)
|
||||||
|
}
|
||||||
|
|
||||||
wa, err := webauthn.New(&webauthn.Config{
|
wa, err := webauthn.New(&webauthn.Config{
|
||||||
RPDisplayName: "Dumpster",
|
RPDisplayName: "Dumpster",
|
||||||
RPID: cfg.Host,
|
RPID: cfg.Host,
|
||||||
RPOrigins: []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port},
|
RPOrigins: origins,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebAuthnHandler{
|
return &WebAuthnHandler{
|
||||||
db: db,
|
db: db,
|
||||||
wa: wa,
|
wa: wa,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
logger: logger,
|
challenges: newChallengeStore(),
|
||||||
|
logger: logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +88,7 @@ func (h *WebAuthnHandler) RegisterRoutes(r *http.ServeMux) {
|
|||||||
r.HandleFunc("POST /auth/webauthn/login/finish", h.LoginFinish)
|
r.HandleFunc("POST /auth/webauthn/login/finish", h.LoginFinish)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterBegin starts passkey registration for an authenticated user.
|
||||||
func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -50,42 +96,27 @@ func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user WebAuthnUser
|
user, err := h.loadUser(r.Context(), userID)
|
||||||
err := h.db.QueryRowContext(r.Context(),
|
|
||||||
`SELECT id, username, display_name FROM users WHERE id = $1`, userID,
|
|
||||||
).Scan(&user.ID, &user.Username, &user.DisplayName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing credentials
|
options, sessionData, err := h.wa.BeginRegistration(user)
|
||||||
rows, _ := h.db.QueryContext(r.Context(),
|
|
||||||
`SELECT credential_id, public_key FROM webauthn_credentials WHERE user_id = $1`, userID)
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var credID, pubKey []byte
|
|
||||||
rows.Scan(&credID, &pubKey)
|
|
||||||
user.credentials = append(user.credentials, webauthn.Credential{
|
|
||||||
ID: credID,
|
|
||||||
PublicKey: pubKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options, sessionData, err := h.wa.BeginRegistration(&user)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.logger.Error("webauthn register begin failed", "error", err)
|
||||||
http.Error(w, `{"error":"registration failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"registration failed"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store session data (simplified: in production use a proper session store)
|
// Store challenge keyed by user ID
|
||||||
|
h.challenges.Set(userID, sessionData)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(options)
|
json.NewEncoder(w).Encode(options)
|
||||||
_ = sessionData // TODO: store in session for finish step
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterFinish completes passkey registration.
|
||||||
func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -93,19 +124,231 @@ func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement full registration finish with session data
|
sessionData, ok := h.challenges.Get(userID)
|
||||||
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
|
if !ok {
|
||||||
_ = userID
|
http.Error(w, `{"error":"no pending registration"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.loadUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := h.wa.FinishRegistration(user, *sessionData, r)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("webauthn register finish failed", "error", err)
|
||||||
|
http.Error(w, `{"error":"registration verification failed"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credential in database
|
||||||
|
_, err = h.db.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO webauthn_credentials (user_id, credential_id, public_key, aaguid, sign_count)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
userID,
|
||||||
|
credential.ID,
|
||||||
|
credential.PublicKey,
|
||||||
|
credential.Authenticator.AAGUID,
|
||||||
|
credential.Authenticator.SignCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to store credential", "error", err)
|
||||||
|
http.Error(w, `{"error":"failed to store credential"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "registered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginBegin starts passkey authentication (no auth required).
|
||||||
func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: implement login begin
|
// Get all registered credentials for the login ceremony
|
||||||
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
|
rows, err := h.db.QueryContext(r.Context(),
|
||||||
|
`SELECT credential_id, user_id FROM webauthn_credentials LIMIT 1000`)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to query credentials", "error", err)
|
||||||
|
http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var descriptors []protocol.CredentialDescriptor
|
||||||
|
for rows.Next() {
|
||||||
|
var credID []byte
|
||||||
|
var userID string
|
||||||
|
if err := rows.Scan(&credID, &userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
descriptors = append(descriptors, protocol.CredentialDescriptor{
|
||||||
|
Type: protocol.PublicKeyCredentialType,
|
||||||
|
CredentialID: credID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options, sessionData, err := h.wa.BeginLogin(nil, webauthn.WithAllowedCredentials(descriptors))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("webauthn login begin failed", "error", err)
|
||||||
|
http.Error(w, `{"error":"login failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store challenge with a random key
|
||||||
|
challengeKey := randomChallenge()
|
||||||
|
h.challenges.Set(challengeKey, sessionData)
|
||||||
|
|
||||||
|
// Set cookie to link challenge to user
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "webauthn_challenge",
|
||||||
|
Value: challengeKey,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: 300, // 5 minutes
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginFinish completes passkey authentication.
|
||||||
func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: implement login finish
|
// Get challenge key from cookie
|
||||||
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
|
cookie, err := r.Cookie("webauthn_challenge")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"no pending login"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, ok := h.challenges.Get(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"challenge expired"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the credential response
|
||||||
|
var parsedResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RawID string `json:"rawId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Response struct {
|
||||||
|
AuthenticatorData string `json:"authenticatorData"`
|
||||||
|
ClientDataJSON string `json:"clientDataJSON"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
UserHandle string `json:"userHandle"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&parsedResponse); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the credential in the database
|
||||||
|
var credentialID []byte
|
||||||
|
credentialID, err = base64.StdEncoding.DecodeString(parsedResponse.ID)
|
||||||
|
if err != nil {
|
||||||
|
// Try raw URL encoding
|
||||||
|
credentialID, err = base64.RawURLEncoding.DecodeString(parsedResponse.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid credential"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedUserID string
|
||||||
|
var storedPubKey []byte
|
||||||
|
var signCount int64
|
||||||
|
err = h.db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT user_id, public_key, sign_count FROM webauthn_credentials WHERE credential_id = $1`,
|
||||||
|
credentialID,
|
||||||
|
).Scan(&storedUserID, &storedPubKey, &signCount)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"credential not found"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.loadUser(r.Context(), storedUserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.wa.FinishLogin(user, *sessionData, r)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("webauthn login finish failed", "error", err)
|
||||||
|
http.Error(w, `{"error":"login verification failed"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sign count
|
||||||
|
h.db.ExecContext(r.Context(),
|
||||||
|
`UPDATE webauthn_credentials SET sign_count = sign_count + 1 WHERE credential_id = $1`,
|
||||||
|
credentialID)
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
token, err := h.sessions.Create(r.Context(), storedUserID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to create session", "error", err)
|
||||||
|
http.Error(w, `{"error":"session creation failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "dumpster_session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: 86400 * 30, // 30 days
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear challenge cookie
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "webauthn_challenge",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUser loads a user and their credentials from the database.
|
||||||
|
func (h *WebAuthnHandler) loadUser(ctx context.Context, userID string) (*WebAuthnUser, error) {
|
||||||
|
var user WebAuthnUser
|
||||||
|
err := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, username, display_name FROM users WHERE id = $1`, userID,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx,
|
||||||
|
`SELECT credential_id, public_key, sign_count FROM webauthn_credentials WHERE user_id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var credID, pubKey []byte
|
||||||
|
var signCount uint32
|
||||||
|
rows.Scan(&credID, &pubKey, &signCount)
|
||||||
|
user.credentials = append(user.credentials, webauthn.Credential{
|
||||||
|
ID: credID,
|
||||||
|
PublicKey: pubKey,
|
||||||
|
Authenticator: webauthn.Authenticator{
|
||||||
|
SignCount: signCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnUser implements the webauthn.User interface
|
// WebAuthnUser implements the webauthn.User interface
|
||||||
@@ -116,8 +359,14 @@ type WebAuthnUser struct {
|
|||||||
credentials []webauthn.Credential
|
credentials []webauthn.Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) }
|
func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||||
func (u *WebAuthnUser) WebAuthnName() string { return u.Username }
|
func (u *WebAuthnUser) WebAuthnName() string { return u.Username }
|
||||||
func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName }
|
func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName }
|
||||||
func (u *WebAuthnUser) WebAuthnIcon() string { return "" }
|
func (u *WebAuthnUser) WebAuthnIcon() string { return "" }
|
||||||
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||||
|
|
||||||
|
func randomChallenge() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|||||||
+131
-184
@@ -5,21 +5,28 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
|
mentions *MentionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||||
return &Handler{db: db, hub: hub}
|
return &Handler{
|
||||||
|
db: db,
|
||||||
|
hub: hub,
|
||||||
|
mentions: NewMentionHandler(db, pushHandler, logger),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
@@ -49,134 +56,9 @@ func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool,
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverIDForChannel returns the server_id that owns the given channel.
|
|
||||||
func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var serverID string
|
|
||||||
err := h.db.QueryRowContext(ctx, `
|
|
||||||
SELECT server_id FROM channels WHERE id = $1
|
|
||||||
`, channelID).Scan(&serverID)
|
|
||||||
return serverID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireChannelAccess verifies the user is a member of the server owning the channel,
|
|
||||||
// returning the server_id if successful.
|
|
||||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
|
||||||
} else {
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
member, err := h.isMember(r.Context(), userID, serverID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if !member {
|
|
||||||
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return userID, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
|
||||||
if _, ok := h.requireChannelAccess(w, r, channelID); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse query params
|
|
||||||
limit := 50
|
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
before := r.URL.Query().Get("before")
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if before != "" {
|
|
||||||
// Fetch the created_at of the cursor message
|
|
||||||
var cursorTime sql.NullString
|
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
|
||||||
SELECT created_at::text FROM messages WHERE id = $1
|
|
||||||
`, before).Scan(&cursorTime)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
http.Error(w, `{"error":"cursor message not found"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
|
||||||
m.content, m.edited_at::text, m.created_at::text
|
|
||||||
FROM messages m
|
|
||||||
INNER JOIN users u ON u.id = m.author_id
|
|
||||||
WHERE m.channel_id = $1 AND m.created_at < $2::timestamptz
|
|
||||||
ORDER BY m.created_at DESC
|
|
||||||
LIMIT $3
|
|
||||||
`, channelID, cursorTime.String, limit)
|
|
||||||
} else {
|
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
|
||||||
m.content, m.edited_at::text, m.created_at::text
|
|
||||||
FROM messages m
|
|
||||||
INNER JOIN users u ON u.id = m.author_id
|
|
||||||
WHERE m.channel_id = $1
|
|
||||||
ORDER BY m.created_at DESC
|
|
||||||
LIMIT $2
|
|
||||||
`, channelID, limit)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
messages := make([]messageResponse, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var msg messageResponse
|
|
||||||
var editedAt, createdAt sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName,
|
|
||||||
&msg.Content, &editedAt, &createdAt,
|
|
||||||
); err != nil {
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if editedAt.Valid {
|
|
||||||
msg.EditedAt = &editedAt.String
|
|
||||||
}
|
|
||||||
msg.CreatedAt = createdAt.String
|
|
||||||
messages = append(messages, msg)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
type createMessageRequest struct {
|
type createMessageRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ReplyTo *string `json:"reply_to,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -199,11 +81,15 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var replyTo sql.NullString
|
||||||
|
if req.ReplyTo != nil {
|
||||||
|
replyTo = sql.NullString{String: *req.ReplyTo, Valid: true}
|
||||||
|
}
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO messages (channel_id, author_id, content)
|
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||||
`, channelID, userID, req.Content).Scan(
|
`, channelID, userID, req.Content, replyTo).Scan(
|
||||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,6 +117,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
Data: msg,
|
Data: msg,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Dispatch push notifications for @mentions
|
||||||
|
go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(msg)
|
json.NewEncoder(w).Encode(msg)
|
||||||
@@ -241,14 +130,13 @@ type updateMessageRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messageID := chi.URLParam(r, "messageID")
|
|
||||||
|
|
||||||
var req updateMessageRequest
|
var req updateMessageRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
@@ -259,44 +147,28 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify authorship
|
|
||||||
var authorID string
|
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
|
||||||
SELECT author_id FROM messages WHERE id = $1
|
|
||||||
`, messageID).Scan(&authorID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if authorID != userID {
|
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE messages
|
UPDATE messages SET content = $1, edited_at = NOW()
|
||||||
SET content = $1, edited_at = NOW()
|
WHERE id = $2 AND author_id = $3
|
||||||
WHERE id = $2
|
|
||||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||||
`, req.Content, messageID).Scan(
|
`, req.Content, messageID, userID).Scan(
|
||||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch author info
|
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
SELECT username, display_name FROM users WHERE id = $1
|
SELECT username, display_name FROM users WHERE id = $1
|
||||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
`, userID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -307,7 +179,6 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
|
|
||||||
// Broadcast MESSAGE_UPDATE event via WebSocket
|
|
||||||
h.hub.BroadcastEvent(gateway.Event{
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
Type: gateway.EventMessageUpdate,
|
Type: gateway.EventMessageUpdate,
|
||||||
Data: msg,
|
Data: msg,
|
||||||
@@ -318,48 +189,124 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messageID := chi.URLParam(r, "messageID")
|
var channelID string
|
||||||
|
|
||||||
// Verify authorship and get channel_id for the broadcast
|
|
||||||
var authorID, channelID string
|
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
SELECT author_id, channel_id FROM messages WHERE id = $1
|
DELETE FROM messages WHERE id = $1 AND author_id = $2
|
||||||
`, messageID).Scan(&authorID, &channelID)
|
RETURNING channel_id
|
||||||
|
`, messageID, userID).Scan(&channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if authorID != userID {
|
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
|
||||||
DELETE FROM messages WHERE id = $1
|
|
||||||
`, messageID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast MESSAGE_DELETE event via WebSocket
|
|
||||||
h.hub.BroadcastEvent(gateway.Event{
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": messageID,
|
"id": messageID,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
userID, ok := h.requireChannelAccess(w, r, channelID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 50
|
||||||
|
if limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
before := r.URL.Query().Get("before")
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if before != "" {
|
||||||
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON m.author_id = u.id
|
||||||
|
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT $3
|
||||||
|
`, channelID, before, limit)
|
||||||
|
} else {
|
||||||
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON m.author_id = u.id
|
||||||
|
WHERE m.channel_id = $1
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, channelID, limit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to list messages"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
messages := make([]messageResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var msg messageResponse
|
||||||
|
var editedAt sql.NullString
|
||||||
|
var createdAt sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if editedAt.Valid {
|
||||||
|
msg.EditedAt = &editedAt.String
|
||||||
|
}
|
||||||
|
msg.CreatedAt = createdAt.String
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(messages)
|
||||||
|
|
||||||
|
_ = userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
|
||||||
|
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !isMember {
|
||||||
|
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||||
|
var everyoneMention = "@everyone"
|
||||||
|
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||||
|
|
||||||
|
// MentionHandler dispatches push notifications for @mentions.
|
||||||
|
type MentionHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
push *push.Handler
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logger) *MentionHandler {
|
||||||
|
return &MentionHandler{
|
||||||
|
db: db,
|
||||||
|
push: pushHandler,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||||
|
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||||
|
// Find individual user mentions
|
||||||
|
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
||||||
|
mentionedUsers := make(map[string]bool)
|
||||||
|
for _, match := range userMatches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
mentionedUsers[match[1]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for @everyone
|
||||||
|
isEveryone := strings.Contains(content, everyoneMention)
|
||||||
|
|
||||||
|
// Get channel info for notification
|
||||||
|
var serverID, channelName string
|
||||||
|
err := m.db.QueryRowContext(ctx,
|
||||||
|
`SELECT server_id, name FROM channels WHERE id = $1`, channelID,
|
||||||
|
).Scan(&serverID, &channelName)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("failed to get channel info for mentions", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get author info
|
||||||
|
var authorName string
|
||||||
|
err = m.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(display_name, username) FROM users WHERE id = $1`, authorID,
|
||||||
|
).Scan(&authorName)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("failed to get author info for mentions", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate content for notification
|
||||||
|
notifContent := content
|
||||||
|
if len(notifContent) > 200 {
|
||||||
|
notifContent = notifContent[:200] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"title": authorName + " in #" + channelName,
|
||||||
|
"body": notifContent,
|
||||||
|
"url": "/channels/" + channelID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEveryone {
|
||||||
|
// Send to all server members
|
||||||
|
rows, err := m.db.QueryContext(ctx,
|
||||||
|
`SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`,
|
||||||
|
serverID, authorID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go m.push.SendPush(ctx, userID, payload)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for role mentions
|
||||||
|
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||||
|
if len(roleMatches) > 0 {
|
||||||
|
for _, match := range roleMatches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
roleID := match[1]
|
||||||
|
// Get users with this role
|
||||||
|
rows, err := m.db.QueryContext(ctx,
|
||||||
|
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||||
|
roleID, authorID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("failed to query role members", "error", err, "role_id", roleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mentionedUsers[userID] = true
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the author from mentions
|
||||||
|
delete(mentionedUsers, authorID)
|
||||||
|
|
||||||
|
// Send push to individually mentioned users
|
||||||
|
for userID := range mentionedUsers {
|
||||||
|
// Check if user is in DND status
|
||||||
|
var status string
|
||||||
|
err := m.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||||
|
).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status == "dnd" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.push.SendPush(ctx, userID, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user