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
+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) {