Add embedded web chat client (closes #7) (#8)

This commit was merged in pull request #8.
This commit is contained in:
2026-02-11 03:02:41 +01:00
parent 95ccc1b2cd
commit df2217a38b
55 changed files with 5182 additions and 123 deletions

View File

@@ -27,7 +27,7 @@ type Config struct {
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
@@ -80,7 +80,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),

414
internal/db/queries.go Normal file
View File

@@ -0,0 +1,414 @@
package db
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
func generateToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// CreateUser registers a new user with the given nick and returns the user with token.
func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) {
token := generateToken()
now := time.Now()
res, err := s.db.ExecContext(ctx,
"INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)",
nick, token, now, now)
if err != nil {
return 0, "", fmt.Errorf("create user: %w", err)
}
id, _ := res.LastInsertId()
return id, token, nil
}
// GetUserByToken returns user id and nick for a given auth token.
func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) {
var id int64
var nick string
err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick)
if err != nil {
return 0, "", err
}
// Update last_seen
_, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id)
return id, nick, nil
}
// GetUserByNick returns user id for a given nick.
func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id)
return id, err
}
// GetOrCreateChannel returns the channel id, creating it if needed.
func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id)
if err == nil {
return id, nil
}
now := time.Now()
res, err := s.db.ExecContext(ctx,
"INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)",
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
id, _ = res.LastInsertId()
return id, nil
}
// JoinChannel adds a user to a channel.
func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error {
_, err := s.db.ExecContext(ctx,
"INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)",
channelID, userID, time.Now())
return err
}
// PartChannel removes a user from a channel.
func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
channelID, userID)
return err
}
// ListChannels returns all channels the user has joined.
func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic FROM channels c
INNER JOIN channel_members cm ON cm.channel_id = c.id
WHERE cm.user_id = ? ORDER BY c.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var channels []ChannelInfo
for rows.Next() {
var ch ChannelInfo
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil {
return nil, err
}
channels = append(channels, ch)
}
if channels == nil {
channels = []ChannelInfo{}
}
return channels, nil
}
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
}
// ChannelMembers returns all members of a channel.
func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT u.id, u.nick, u.last_seen FROM users u
INNER JOIN channel_members cm ON cm.user_id = u.id
WHERE cm.channel_id = ? ORDER BY u.nick`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var members []MemberInfo
for rows.Next() {
var m MemberInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil {
return nil, err
}
members = append(members, m)
}
if members == nil {
members = []MemberInfo{}
}
return members, nil
}
// MemberInfo represents a channel member.
type MemberInfo struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
LastSeen time.Time `json:"lastSeen"`
}
// MessageInfo represents a chat message.
type MessageInfo struct {
ID int64 `json:"id"`
Channel string `json:"channel,omitempty"`
Nick string `json:"nick"`
Content string `json:"content"`
IsDM bool `json:"isDm,omitempty"`
DMTarget string `json:"dmTarget,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// GetMessages returns messages for a channel, optionally after a given ID.
func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, c.name, u.nick, m.content, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ?
ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
return msgs, nil
}
// SendMessage inserts a channel message.
func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) {
res, err := s.db.ExecContext(ctx,
"INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)",
channelID, userID, content, time.Now())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// SendDM inserts a direct message.
func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) {
res, err := s.db.ExecContext(ctx,
"INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)",
fromID, content, toID, time.Now())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// GetDMs returns direct messages between two users after a given ID.
func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1 AND m.id > ?
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = true
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
return msgs, nil
}
// PollMessages returns all new messages (channel + DM) for a user after a given ID.
func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
LEFT JOIN channels c ON c.id = m.channel_id
LEFT JOIN users t ON t.id = m.dm_target_id
WHERE m.id > ? AND (
(m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?))
OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?))
)
ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
var isDM int
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = isDM == 1
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
return msgs, nil
}
// GetMessagesBefore returns channel messages before a given ID (for history scrollback).
func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var query string
var args []any
if beforeID > 0 {
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ?
ORDER BY m.id DESC LIMIT ?`
args = []any{channelID, beforeID, limit}
} else {
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id = ? AND m.is_dm = 0
ORDER BY m.id DESC LIMIT ?`
args = []any{channelID, limit}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
// Reverse to ascending order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// GetDMsBefore returns DMs between two users before a given ID (for history scrollback).
func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var query string
var args []any
if beforeID > 0 {
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1 AND m.id < ?
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id DESC LIMIT ?`
args = []any{beforeID, userA, userB, userB, userA, limit}
} else {
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id DESC LIMIT ?`
args = []any{userA, userB, userB, userA, limit}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = true
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
// Reverse to ascending order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// ChangeNick updates a user's nickname.
func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error {
_, err := s.db.ExecContext(ctx,
"UPDATE users SET nick = ? WHERE id = ?", newNick, userID)
return err
}
// SetTopic sets the topic for a channel.
func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error {
_, err := s.db.ExecContext(ctx,
"UPDATE channels SET topic = ? WHERE name = ?", topic, channelName)
return err
}
// GetServerName returns the server name (unused, config provides this).
func (s *Database) GetServerName() string {
return ""
}
// ListAllChannels returns all channels.
func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
"SELECT id, name, topic FROM channels ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var channels []ChannelInfo
for rows.Next() {
var ch ChannelInfo
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil {
return nil, err
}
channels = append(channels, ch)
}
if channels == nil {
channels = []ChannelInfo{}
}
return channels, nil
}

View File

@@ -0,0 +1,31 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_dm INTEGER NOT NULL DEFAULT 0,
dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);

400
internal/handlers/api.go Normal file
View File

@@ -0,0 +1,400 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
"git.eeqj.de/sneak/chat/internal/db"
"github.com/go-chi/chi"
)
// authUser extracts the user from the Authorization header (Bearer token).
func (s *Handlers) authUser(r *http.Request) (int64, string, error) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, "", sql.ErrNoRows
}
token := strings.TrimPrefix(auth, "Bearer ")
return s.params.Database.GetUserByToken(r.Context(), token)
}
func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) {
uid, nick, err := s.authUser(r)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized)
return 0, "", false
}
return uid, nick, true
}
// HandleCreateSession creates a new user session and returns the auth token.
func (s *Handlers) HandleCreateSession() http.HandlerFunc {
type request struct {
Nick string `json:"nick"`
}
type response struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
}
return func(w http.ResponseWriter, r *http.Request) {
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest)
return
}
req.Nick = strings.TrimSpace(req.Nick)
if req.Nick == "" || len(req.Nick) > 32 {
s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest)
return
}
id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict)
return
}
s.log.Error("create user failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated)
}
}
// HandleState returns the current user's info and joined channels.
func (s *Handlers) HandleState() http.HandlerFunc {
type response struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []db.ChannelInfo `json:"channels"`
}
return func(w http.ResponseWriter, r *http.Request) {
uid, nick, ok := s.requireAuth(w, r)
if !ok {
return
}
channels, err := s.params.Database.ListChannels(r.Context(), uid)
if err != nil {
s.log.Error("list channels failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK)
}
}
// HandleListAllChannels returns all channels on the server.
func (s *Handlers) HandleListAllChannels() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
if !ok {
return
}
channels, err := s.params.Database.ListAllChannels(r.Context())
if err != nil {
s.log.Error("list all channels failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, channels, http.StatusOK)
}
}
// HandleChannelMembers returns members of a channel.
func (s *Handlers) HandleChannelMembers() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
if !ok {
return
}
name := "#" + chi.URLParam(r, "channel")
var chID int64
err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", name).Scan(&chID)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return
}
members, err := s.params.Database.ChannelMembers(r.Context(), chID)
if err != nil {
s.log.Error("channel members failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, members, http.StatusOK)
}
}
// HandleGetMessages returns all new messages (channel + DM) for the user via long-polling.
// This is the single unified message stream — replaces separate channel/DM/poll endpoints.
func (s *Handlers) HandleGetMessages() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, _, ok := s.requireAuth(w, r)
if !ok {
return
}
afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit)
if err != nil {
s.log.Error("get messages failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, msgs, http.StatusOK)
}
}
// HandleSendCommand handles all C2S commands via POST /messages.
// The "command" field dispatches to the appropriate logic.
func (s *Handlers) HandleSendCommand() http.HandlerFunc {
type request struct {
Command string `json:"command"`
To string `json:"to"`
Params []string `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"`
}
return func(w http.ResponseWriter, r *http.Request) {
uid, nick, ok := s.requireAuth(w, r)
if !ok {
return
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest)
return
}
req.Command = strings.ToUpper(strings.TrimSpace(req.Command))
req.To = strings.TrimSpace(req.To)
// Helper to extract body as string lines.
bodyLines := func() []string {
switch v := req.Body.(type) {
case []interface{}:
lines := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
lines = append(lines, s)
}
}
return lines
case []string:
return v
default:
return nil
}
}
switch req.Command {
case "PRIVMSG", "NOTICE":
if req.To == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
lines := bodyLines()
if len(lines) == 0 {
s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest)
return
}
content := strings.Join(lines, "\n")
if strings.HasPrefix(req.To, "#") {
// Channel message
var chID int64
err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return
}
msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, content)
if err != nil {
s.log.Error("send message failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated)
} else {
// DM
targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound)
return
}
msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, content)
if err != nil {
s.log.Error("send dm failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated)
}
case "JOIN":
if req.To == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
channel := req.To
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := s.params.Database.GetOrCreateChannel(r.Context(), channel)
if err != nil {
s.log.Error("get/create channel failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil {
s.log.Error("join channel failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]string{"status": "joined", "channel": channel}, http.StatusOK)
case "PART":
if req.To == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
channel := req.To
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
var chID int64
err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", channel).Scan(&chID)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return
}
if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil {
s.log.Error("part channel failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]string{"status": "parted", "channel": channel}, http.StatusOK)
case "NICK":
lines := bodyLines()
if len(lines) == 0 {
s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest)
return
}
newNick := strings.TrimSpace(lines[0])
if newNick == "" || len(newNick) > 32 {
s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest)
return
}
if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict)
return
}
s.log.Error("change nick failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]string{"status": "ok", "nick": newNick}, http.StatusOK)
case "TOPIC":
if req.To == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
lines := bodyLines()
if len(lines) == 0 {
s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest)
return
}
topic := strings.Join(lines, " ")
channel := req.To
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil {
s.log.Error("set topic failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, map[string]string{"status": "ok", "topic": topic}, http.StatusOK)
case "PING":
s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK)
default:
_ = nick // suppress unused warning
s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest)
}
}
}
// HandleGetHistory returns message history for a specific target (channel or DM).
func (s *Handlers) HandleGetHistory() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, _, ok := s.requireAuth(w, r)
if !ok {
return
}
target := r.URL.Query().Get("target")
if target == "" {
s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest)
return
}
beforeID, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
if strings.HasPrefix(target, "#") {
// Channel history
var chID int64
err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", target).Scan(&chID)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return
}
msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeID, limit)
if err != nil {
s.log.Error("get history failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, msgs, http.StatusOK)
} else {
// DM history
targetID, err := s.params.Database.GetUserByNick(r.Context(), target)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound)
return
}
msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetID, beforeID, limit)
if err != nil {
s.log.Error("get dm history failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, msgs, http.StatusOK)
}
}
}
// HandleServerInfo returns server metadata (MOTD, name).
func (s *Handlers) HandleServerInfo() http.HandlerFunc {
type response struct {
Name string `json:"name"`
MOTD string `json:"motd"`
}
return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, &response{
Name: s.params.Config.ServerName,
MOTD: s.params.Config.MOTD,
}, http.StatusOK)
}
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/healthcheck"
@@ -20,6 +21,7 @@ type Params struct {
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
}

View File

@@ -1,9 +1,12 @@
package server
import (
"io/fs"
"net/http"
"time"
"git.eeqj.de/sneak/chat/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
@@ -45,4 +48,40 @@ func (s *Server) SetupRoutes() {
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
})
}
// API v1
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/server", s.h.HandleServerInfo())
r.Post("/session", s.h.HandleCreateSession())
// Unified state and message endpoints
r.Get("/state", s.h.HandleState())
r.Get("/messages", s.h.HandleGetMessages())
r.Post("/messages", s.h.HandleSendCommand())
r.Get("/history", s.h.HandleGetHistory())
// Channels
r.Get("/channels", s.h.HandleListAllChannels())
r.Get("/channels/{channel}/members", s.h.HandleChannelMembers())
})
// Serve embedded SPA
distFS, err := fs.Sub(web.Dist, "dist")
if err != nil {
s.log.Error("failed to get web dist filesystem", "error", err)
} else {
fileServer := http.FileServer(http.FS(distFS))
s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file; if not found, serve index.html for SPA routing
f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:])
if err != nil || len(f) == 0 {
indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexHTML)
return
}
fileServer.ServeHTTP(w, r)
})
}
}