sync: phase 1 backend + frontend from server
This commit is contained in:
@@ -37,13 +37,14 @@ type createChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position int `json:"position"`
|
||||
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// isMember checks whether the given user is a member of the given server.
|
||||
@@ -124,11 +125,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var ch channelResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO channels (server_id, name, type, category, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, server_id, name, type, category, position, created_at
|
||||
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
`, serverID, req.Name, channelType, category, position).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
|
||||
@@ -175,7 +176,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT id, server_id, name, type, category, position, created_at
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
FROM channels
|
||||
WHERE server_id = $1
|
||||
ORDER BY position, name
|
||||
@@ -189,7 +190,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
channels := make([]channelResponse, 0)
|
||||
for rows.Next() {
|
||||
var ch channelResponse
|
||||
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -226,9 +227,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var ch channelResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, server_id, name, type, category, position, created_at
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
FROM channels WHERE id = $1
|
||||
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt)
|
||||
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
@@ -253,10 +254,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
Position *int `json:"position"`
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
Position *int `json:"position"`
|
||||
SlowmodeSeconds *int `json:"slowmode_seconds"`
|
||||
}
|
||||
|
||||
// @Summary Update a channel
|
||||
@@ -287,7 +289,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil {
|
||||
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil {
|
||||
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -330,11 +332,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
SET name = COALESCE($1, name),
|
||||
type = COALESCE($2, type),
|
||||
category = COALESCE($3, category),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5
|
||||
RETURNING id, server_id, name, type, category, position, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, channelID).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
|
||||
position = COALESCE($4, position),
|
||||
slowmode_seconds = COALESCE($5, slowmode_seconds)
|
||||
WHERE id = $6
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
|
||||
|
||||
@@ -214,4 +214,89 @@ CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||
|
||||
-- Direct message conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(16) NOT NULL DEFAULT 'dm',
|
||||
name VARCHAR(100),
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content VARCHAR(4000) NOT NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC);
|
||||
|
||||
-- Moderation
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
banned_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (server_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_mutes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
muted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (server_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_mutes_server ON server_mutes(server_id);
|
||||
|
||||
-- Link embeds
|
||||
CREATE TABLE IF NOT EXISTS embeds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
site_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
|
||||
|
||||
-- Channel slowmode
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Message full-text search
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||
|
||||
CREATE OR REPLACE FUNCTION messages_search_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector := to_tsvector('english', COALESCE(NEW.content, ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
|
||||
CREATE TRIGGER messages_search_trigger
|
||||
BEFORE INSERT OR UPDATE ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION messages_search_update();
|
||||
`
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package dm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles direct-message conversations.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new DM handler.
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler {
|
||||
return &Handler{db: db, hub: hub, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers conversation routes.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/", h.List)
|
||||
r.Route("/{conversationID}", func(r chi.Router) {
|
||||
r.Get("/", h.Get)
|
||||
r.Get("/messages", h.ListMessages)
|
||||
r.Post("/messages", h.SendMessage)
|
||||
})
|
||||
}
|
||||
|
||||
type createConversationRequest struct {
|
||||
UserIDs []string `json:"user_ids"`
|
||||
}
|
||||
|
||||
type conversationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Members []member `json:"members"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type member struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// Create creates a new DM or group DM.
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req createConversationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.UserIDs) == 0 {
|
||||
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build unique member set including creator.
|
||||
memberSet := map[string]struct{}{userID: {}}
|
||||
for _, uid := range req.UserIDs {
|
||||
if uid != "" {
|
||||
memberSet[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(memberSet) == 1 {
|
||||
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
memberIDs := make([]string, 0, len(memberSet))
|
||||
for uid := range memberSet {
|
||||
memberIDs = append(memberIDs, uid)
|
||||
}
|
||||
sort.Strings(memberIDs)
|
||||
|
||||
// For a 1:1 DM, check if it already exists.
|
||||
if len(memberIDs) == 2 {
|
||||
var existingID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
WHERE c.type = 'dm'
|
||||
AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversation_id = c.id) = 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM conversation_members cm2
|
||||
WHERE cm2.conversation_id = c.id AND cm2.user_id NOT IN ($1, $2)
|
||||
)
|
||||
LIMIT 1
|
||||
`, memberIDs[0], memberIDs[1]).Scan(&existingID)
|
||||
if err == nil {
|
||||
h.getByID(w, r, existingID)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
convType := "dm"
|
||||
if len(memberIDs) > 2 {
|
||||
convType = "group_dm"
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var convID string
|
||||
var createdAt string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO conversations (type, created_by)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, created_at::text
|
||||
`, convType, userID).Scan(&convID, &createdAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create conversation"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, uid := range memberIDs {
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1, $2)
|
||||
`, convID, uid)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to add member"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.buildResponse(r.Context(), convID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// Get returns a single conversation.
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
resp, err := h.buildResponse(r.Context(), convID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (h *Handler) getByID(w http.ResponseWriter, r *http.Request, convID string) {
|
||||
resp, err := h.buildResponse(r.Context(), convID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// List returns the current user's conversations.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
JOIN conversation_members cm ON cm.conversation_id = c.id
|
||||
WHERE cm.user_id = $1
|
||||
ORDER BY c.created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []conversationResponse
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := h.buildResponse(r.Context(), id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
conversations = append(conversations, resp)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(conversations)
|
||||
}
|
||||
|
||||
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
|
||||
`, 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, '')
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = $1
|
||||
`, convID)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
defer memberRows.Close()
|
||||
|
||||
for memberRows.Next() {
|
||||
var m member
|
||||
if err := memberRows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar); err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Members = append(resp.Members, m)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListMessages lists messages in a conversation.
|
||||
func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
if !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
|
||||
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.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2)
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT $3
|
||||
`, convID, before, limit)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT $2
|
||||
`, convID, 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 sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var displayName sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.AuthorName, &displayName, &msg.Content, &editedAt, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
if displayName.Valid {
|
||||
msg.DisplayName = &displayName.String
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
// SendMessage sends a message to a conversation.
|
||||
func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
if !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Content == "" || len(req.Content) > 4000 {
|
||||
http.Error(w, `{"error":"invalid content"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var displayName sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO conversation_messages (conversation_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, conversation_id, author_id, content, edited_at::text, created_at::text
|
||||
`, convID, userID, req.Content).Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.QueryRowContext(r.Context(), `SELECT username, display_name FROM users WHERE id = $1`, userID).Scan(&msg.AuthorName, &displayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
if displayName.Valid {
|
||||
msg.DisplayName = &displayName.String
|
||||
}
|
||||
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) isMember(ctx context.Context, convID, userID string) bool {
|
||||
var exists bool
|
||||
err := h.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2)`, convID, userID).Scan(&exists)
|
||||
return err == nil && exists
|
||||
}
|
||||
|
||||
// MemberIDs returns all user IDs in a conversation.
|
||||
func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error) {
|
||||
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var urlRegex = regexp.MustCompile(`https?://[^\s<>"{}|\^\[\]]+`)
|
||||
|
||||
// Embed represents a stored link preview.
|
||||
type Embed struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// ExtractURLs returns all HTTP/HTTPS URLs found in text.
|
||||
func ExtractURLs(text string) []string {
|
||||
matches := urlRegex.FindAllString(text, -1)
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, u := range matches {
|
||||
if _, ok := seen[u]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
out = append(out, u)
|
||||
}
|
||||
if len(out) > 5 {
|
||||
out = out[:5]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchEmbed retrieves OpenGraph meta tags for a URL.
|
||||
func FetchEmbed(url string) *Embed {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DumpsterBot/1.0)")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
html := string(body)
|
||||
return &Embed{
|
||||
URL: url,
|
||||
Title: ogTag(html, "og:title"),
|
||||
Description: ogTag(html, "og:description"),
|
||||
ImageURL: ogTag(html, "og:image"),
|
||||
SiteName: ogTag(html, "og:site_name"),
|
||||
}
|
||||
}
|
||||
|
||||
func ogTag(html, property string) string {
|
||||
// Try property
|
||||
re := regexp.MustCompile(`<meta[^>]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
|
||||
m := re.FindStringSubmatch(html)
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
// Try name
|
||||
re = regexp.MustCompile(`<meta[^>]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
|
||||
m = re.FindStringSubmatch(html)
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
// Fallback for title
|
||||
if property == "og:title" {
|
||||
re = regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
|
||||
m = re.FindStringSubmatch(html)
|
||||
if len(m) > 1 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FetchAndStore extracts URLs from content, fetches embeds, and stores them.
|
||||
func FetchAndStore(db *sql.DB, messageID string, content string) {
|
||||
urls := ExtractURLs(content)
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
for _, url := range urls {
|
||||
emb := FetchEmbed(url)
|
||||
if emb == nil || (emb.Title == "" && emb.Description == "" && emb.ImageURL == "") {
|
||||
continue
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO embeds (message_id, url, title, description, image_url, site_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, messageID, emb.URL, emb.Title, emb.Description, emb.ImageURL, emb.SiteName)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to store embed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadForMessages returns embeds grouped by message_id.
|
||||
func LoadForMessages(ctx context.Context, db *sql.DB, messageIDs []string) (map[string][]Embed, error) {
|
||||
result := make(map[string][]Embed)
|
||||
if len(messageIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
placeholders := make([]string, len(messageIDs))
|
||||
args := make([]interface{}, len(messageIDs))
|
||||
for i, id := range messageIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
query := "SELECT message_id, id, url, title, description, image_url, site_name FROM embeds WHERE message_id IN (" + strings.Join(placeholders, ",") + ")"
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e Embed
|
||||
var mid string
|
||||
if err := rows.Scan(&mid, &e.ID, &e.URL, &e.Title, &e.Description, &e.ImageURL, &e.SiteName); err != nil {
|
||||
continue
|
||||
}
|
||||
result[mid] = append(result[mid], e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -281,6 +281,46 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastToConversation sends an event to all clients who are members of a
|
||||
// given direct-message conversation.
|
||||
func (h *Hub) BroadcastToConversation(convID string, event Event) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to load conversation members", "conversation_id", convID, "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err == nil {
|
||||
members[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for client := range h.clients {
|
||||
if _, ok := members[client.UserID]; ok {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
go func(c *Client) { h.unregister <- c }(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServerIDForChannel looks up the server_id that owns the given channel.
|
||||
// Used by handlers to route events to the correct server scope.
|
||||
func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||
|
||||
+198
-17
@@ -8,8 +8,11 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -42,19 +45,31 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/search", h.Search)
|
||||
r.Patch("/{messageID}", h.Update)
|
||||
r.Delete("/{messageID}", h.Delete)
|
||||
}
|
||||
|
||||
type embedResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo *string `json:"reply_to,omitempty"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Embeds []embedResponse `json:"embeds"`
|
||||
}
|
||||
|
||||
// isMember checks whether the given user is a member of the given server.
|
||||
@@ -105,6 +120,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check server mute.
|
||||
muted, err := moderation.IsMuted(r.Context(), h.db, serverID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if muted {
|
||||
http.Error(w, `{"error":"you are muted in this server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check slowmode.
|
||||
if retryAfter, active := h.checkSlowmode(r.Context(), channelID, userID); active {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "slowmode",
|
||||
"retry_after": retryAfter,
|
||||
"retry_after_ms": retryAfter * 1000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
@@ -112,12 +150,12 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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, reply_to)
|
||||
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, reply_to::text, edited_at::text, created_at::text
|
||||
`, channelID, userID, req.Content, replyTo).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
@@ -133,6 +171,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
@@ -154,14 +195,43 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if channelName == "" {
|
||||
channelName = channelID
|
||||
}
|
||||
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
|
||||
if h.pushHandler != nil {
|
||||
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch and store link embeds asynchronously.
|
||||
go embed.FetchAndStore(h.db, msg.ID, req.Content)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) checkSlowmode(ctx context.Context, channelID, userID string) (int, bool) {
|
||||
var slowmode int
|
||||
err := h.db.QueryRowContext(ctx, `SELECT slowmode_seconds FROM channels WHERE id = $1`, channelID).Scan(&slowmode)
|
||||
if err != nil || slowmode <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var elapsed int
|
||||
err = h.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() - created_at))::int, 0)
|
||||
FROM messages
|
||||
WHERE channel_id = $1 AND author_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, channelID, userID).Scan(&elapsed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if elapsed < slowmode {
|
||||
return slowmode - elapsed, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
type updateMessageRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -205,12 +275,13 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET content = $1, edited_at = NOW()
|
||||
WHERE id = $2 AND author_id = $3
|
||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
|
||||
`, req.Content, messageID, userID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -229,11 +300,17 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Re-fetch embeds after edit.
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
|
||||
// Look up serverID for scoped broadcast
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
||||
if serverID != "" {
|
||||
@@ -243,6 +320,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
go embed.FetchAndStore(h.db, msg.ID, req.Content)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
@@ -327,7 +406,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, 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)
|
||||
@@ -336,7 +415,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
`, 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
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, 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
|
||||
@@ -355,10 +434,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
var replyTo sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
@@ -366,12 +449,110 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
|
||||
_ = userID
|
||||
}
|
||||
|
||||
// attachEmbeds loads embeds for the given messages and attaches them.
|
||||
func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
ids := make([]string, len(messages))
|
||||
for i, m := range messages {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
embedMap, err := embed.LoadForMessages(ctx, h.db, ids)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
for i := range messages {
|
||||
if embs, ok := embedMap[messages[i].ID]; ok {
|
||||
out := make([]embedResponse, len(embs))
|
||||
for j, e := range embs {
|
||||
out[j] = embedResponse{
|
||||
ID: e.ID,
|
||||
URL: e.URL,
|
||||
Title: e.Title,
|
||||
Description: e.Description,
|
||||
ImageURL: e.ImageURL,
|
||||
SiteName: e.SiteName,
|
||||
}
|
||||
}
|
||||
messages[i].Embeds = out
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// Search searches messages in a channel using PostgreSQL full-text search.
|
||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelAccess(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
http.Error(w, `{"error":"missing q parameter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 25
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC, m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, q, limit)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to search 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
|
||||
var replyTo sql.NullString
|
||||
var rank float64
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
|
||||
// Returns (userID, serverID, ok).
|
||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles server moderation actions.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
}
|
||||
|
||||
// NewHandler creates a new moderation handler.
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||
return &Handler{db: db, hub: hub}
|
||||
}
|
||||
|
||||
type banRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type muteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
Duration string `json:"duration"` // e.g. "15m", "1h", "1d", empty = permanent
|
||||
}
|
||||
|
||||
// banResponse is the JSON shape returned when banning a user.
|
||||
type banResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
UserID string `json:"user_id"`
|
||||
BannedBy string `json:"banned_by"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ban bans a user from a server.
|
||||
func (h *Handler) Ban(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req banRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove member if present.
|
||||
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, req.UserID)
|
||||
|
||||
var ban banResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO bans (server_id, user_id, banned_by, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, server_id, user_id, banned_by, reason, created_at::text
|
||||
`, serverID, req.UserID, userID, req.Reason).Scan(&ban.ID, &ban.ServerID, &ban.UserID, &ban.BannedBy, &ban.Reason, &ban.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to ban user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMemberRemove,
|
||||
Data: map[string]string{"server_id": serverID, "user_id": req.UserID},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ban)
|
||||
}
|
||||
|
||||
// Unban removes a ban.
|
||||
func (h *Handler) Unban(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
_, err := h.db.ExecContext(r.Context(), `DELETE FROM bans WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to unban user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListBans lists banned users.
|
||||
func (h *Handler) ListBans(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT b.id, b.server_id, b.user_id, b.banned_by, b.reason, b.created_at::text, u.username
|
||||
FROM bans b
|
||||
JOIN users u ON u.id = b.user_id
|
||||
WHERE b.server_id = $1
|
||||
ORDER BY b.created_at DESC
|
||||
`, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
banResponse
|
||||
Username string `json:"username"`
|
||||
}
|
||||
var bans []item
|
||||
for rows.Next() {
|
||||
var b item
|
||||
if err := rows.Scan(&b.ID, &b.ServerID, &b.UserID, &b.BannedBy, &b.Reason, &b.CreatedAt, &b.Username); err != nil {
|
||||
continue
|
||||
}
|
||||
bans = append(bans, b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(bans)
|
||||
}
|
||||
|
||||
// Kick removes a member from a server.
|
||||
func (h *Handler) Kick(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
_, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to kick user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMemberRemove,
|
||||
Data: map[string]string{"server_id": serverID, "user_id": targetID},
|
||||
})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Mute creates a server mute.
|
||||
func (h *Handler) Mute(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req muteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var expiresAt sql.NullTime
|
||||
if req.Duration != "" {
|
||||
d, err := time.ParseDuration(req.Duration)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid duration"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expiresAt = sql.NullTime{Time: time.Now().Add(d), Valid: true}
|
||||
}
|
||||
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO server_mutes (server_id, user_id, muted_by, reason, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (server_id, user_id) DO UPDATE SET muted_by = EXCLUDED.muted_by, reason = EXCLUDED.reason, expires_at = EXCLUDED.expires_at, created_at = NOW()
|
||||
`, serverID, req.UserID, userID, req.Reason, expiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to mute user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Unmute removes a server mute.
|
||||
func (h *Handler) Unmute(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
_, err := h.db.ExecContext(r.Context(), `DELETE FROM server_mutes WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to unmute user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// IsMuted checks whether a user is currently muted in a server.
|
||||
func IsMuted(ctx context.Context, db *sql.DB, serverID, userID string) (bool, error) {
|
||||
var exists bool
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM server_mutes WHERE server_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW()))
|
||||
`, serverID, userID).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return exists, err
|
||||
}
|
||||
@@ -2,22 +2,33 @@ package permissions
|
||||
|
||||
// Permission bitflags.
|
||||
const (
|
||||
VIEW_CHANNEL int64 = 1 << 0
|
||||
SEND_MESSAGES int64 = 1 << 1
|
||||
MANAGE_MESSAGES int64 = 1 << 2
|
||||
KICK_MEMBERS int64 = 1 << 3
|
||||
BAN_MEMBERS int64 = 1 << 4
|
||||
MANAGE_SERVER int64 = 1 << 5
|
||||
MANAGE_CHANNELS int64 = 1 << 6
|
||||
ADMINISTRATOR int64 = 1 << 7
|
||||
CONNECT_VOICE int64 = 1 << 8
|
||||
SPEAK_VOICE int64 = 1 << 9
|
||||
SHARE_SCREEN int64 = 1 << 10
|
||||
VIEW_CHANNEL int64 = 1 << 0
|
||||
SEND_MESSAGES int64 = 1 << 1
|
||||
MANAGE_MESSAGES int64 = 1 << 2
|
||||
KICK_MEMBERS int64 = 1 << 3
|
||||
BAN_MEMBERS int64 = 1 << 4
|
||||
MANAGE_SERVER int64 = 1 << 5
|
||||
MANAGE_CHANNELS int64 = 1 << 6
|
||||
ADMINISTRATOR int64 = 1 << 7
|
||||
CONNECT_VOICE int64 = 1 << 8
|
||||
SPEAK_VOICE int64 = 1 << 9
|
||||
SHARE_SCREEN int64 = 1 << 10
|
||||
MUTE_MEMBERS int64 = 1 << 11
|
||||
CREATE_INSTANT_INVITE int64 = 1 << 12
|
||||
CHANGE_NICKNAME int64 = 1 << 13
|
||||
MANAGE_NICKNAMES int64 = 1 << 14
|
||||
MANAGE_ROLES int64 = 1 << 15
|
||||
MANAGE_WEBHOOKS int64 = 1 << 16
|
||||
EMBED_LINKS int64 = 1 << 17
|
||||
ATTACH_FILES int64 = 1 << 18
|
||||
ADD_REACTIONS int64 = 1 << 19
|
||||
USE_EXTERNAL_EMOJIS int64 = 1 << 20
|
||||
MENTION_EVERYONE int64 = 1 << 21
|
||||
)
|
||||
|
||||
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
|
||||
// Bits 0,1,8,9,10 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN = 1539.
|
||||
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN
|
||||
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
|
||||
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
|
||||
|
||||
// Has reports whether the permission set contains the given permission bits.
|
||||
func Has(permissions int64, perm int64) bool {
|
||||
|
||||
Reference in New Issue
Block a user