sync: phase 1 backend + frontend from server

This commit is contained in:
2026-06-30 09:26:03 -04:00
parent d4cdd89544
commit 30e159cbdb
31 changed files with 4758 additions and 114 deletions
+28 -25
View File
@@ -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)
+85
View File
@@ -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();
`
+426
View File
@@ -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
}
+149
View File
@@ -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
}
+40
View File
@@ -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
View File
@@ -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) {
+207
View File
@@ -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
}
+24 -13
View File
@@ -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 {