3 Commits

15 changed files with 1907 additions and 442 deletions

View File

@@ -46,26 +46,6 @@ type Database struct {
params *Params params *Params
} }
// GetDB returns the underlying sql.DB connection.
func (s *Database) GetDB() *sql.DB {
return s.db
}
// NewChannel creates a Channel model instance with the db reference injected.
func (s *Database) NewChannel(id int64, name, topic, modes string, createdAt, updatedAt time.Time) *models.Channel {
c := &models.Channel{
ID: id,
Name: name,
Topic: topic,
Modes: modes,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
c.SetDB(s)
return c
}
// New creates a new Database instance and registers lifecycle hooks. // New creates a new Database instance and registers lifecycle hooks.
func New(lc fx.Lifecycle, params Params) (*Database, error) { func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := new(Database) s := new(Database)
@@ -94,6 +74,455 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil return s, nil
} }
// NewTest creates a Database for testing, bypassing fx lifecycle.
// It connects to the given DSN and runs all migrations.
func NewTest(dsn string) (*Database, error) {
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Database{
db: d,
log: slog.Default(),
}
// Item 9: Enable foreign keys
if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil {
_ = d.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
ctx := context.Background()
err = s.runMigrations(ctx)
if err != nil {
_ = d.Close()
return nil, err
}
return s, nil
}
// GetDB returns the underlying sql.DB connection.
func (s *Database) GetDB() *sql.DB {
return s.db
}
// Hydrate injects the database reference into any model that
// embeds Base.
func (s *Database) Hydrate(m interface{ SetDB(d models.DB) }) {
m.SetDB(s)
}
// GetUserByID looks up a user by their ID.
func (s *Database) GetUserByID(
ctx context.Context,
id string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT id, nick, password_hash, created_at, updated_at, last_seen_at
FROM users WHERE id = ?`,
id,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// GetChannelByID looks up a channel by its ID.
func (s *Database) GetChannelByID(
ctx context.Context,
id string,
) (*models.Channel, error) {
c := &models.Channel{}
s.Hydrate(c)
err := s.db.QueryRowContext(ctx, `
SELECT id, name, topic, modes, created_at, updated_at
FROM channels WHERE id = ?`,
id,
).Scan(
&c.ID, &c.Name, &c.Topic, &c.Modes,
&c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
return c, nil
}
// GetUserByNick looks up a user by their nick.
func (s *Database) GetUserByNick(
ctx context.Context,
nick string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT id, nick, password_hash, created_at, updated_at, last_seen_at
FROM users WHERE nick = ?`,
nick,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// GetUserByToken looks up a user by their auth token.
func (s *Database) GetUserByToken(
ctx context.Context,
token string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT u.id, u.nick, u.password_hash,
u.created_at, u.updated_at, u.last_seen_at
FROM users u
JOIN auth_tokens t ON t.user_id = u.id
WHERE t.token = ?`,
token,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// DeleteAuthToken removes an auth token from the database.
func (s *Database) DeleteAuthToken(
ctx context.Context,
token string,
) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM auth_tokens WHERE token = ?`, token,
)
return err
}
// UpdateUserLastSeen updates the last_seen_at timestamp for a user.
func (s *Database) UpdateUserLastSeen(
ctx context.Context,
userID string,
) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
return err
}
// CreateUser inserts a new user into the database.
func (s *Database) CreateUser(
ctx context.Context,
id, nick, passwordHash string,
) (*models.User, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, nick, password_hash)
VALUES (?, ?, ?)`,
id, nick, passwordHash,
)
if err != nil {
return nil, err
}
u := &models.User{
ID: id, Nick: nick, PasswordHash: passwordHash,
CreatedAt: now, UpdatedAt: now,
}
s.Hydrate(u)
return u, nil
}
// CreateChannel inserts a new channel into the database.
func (s *Database) CreateChannel(
ctx context.Context,
id, name, topic, modes string,
) (*models.Channel, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO channels (id, name, topic, modes)
VALUES (?, ?, ?, ?)`,
id, name, topic, modes,
)
if err != nil {
return nil, err
}
c := &models.Channel{
ID: id, Name: name, Topic: topic, Modes: modes,
CreatedAt: now, UpdatedAt: now,
}
s.Hydrate(c)
return c, nil
}
// AddChannelMember adds a user to a channel with the given modes.
func (s *Database) AddChannelMember(
ctx context.Context,
channelID, userID, modes string,
) (*models.ChannelMember, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO channel_members
(channel_id, user_id, modes)
VALUES (?, ?, ?)`,
channelID, userID, modes,
)
if err != nil {
return nil, err
}
cm := &models.ChannelMember{
ChannelID: channelID,
UserID: userID,
Modes: modes,
JoinedAt: now,
}
s.Hydrate(cm)
return cm, nil
}
// CreateMessage inserts a new message into the database.
func (s *Database) CreateMessage(
ctx context.Context,
id, fromUserID, fromNick, target, msgType, body string,
) (*models.Message, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages
(id, from_user_id, from_nick, target, type, body)
VALUES (?, ?, ?, ?, ?, ?)`,
id, fromUserID, fromNick, target, msgType, body,
)
if err != nil {
return nil, err
}
m := &models.Message{
ID: id,
FromUserID: fromUserID,
FromNick: fromNick,
Target: target,
Type: msgType,
Body: body,
Timestamp: now,
CreatedAt: now,
}
s.Hydrate(m)
return m, nil
}
// QueueMessage adds a message to a user's delivery queue.
func (s *Database) QueueMessage(
ctx context.Context,
userID, messageID string,
) (*models.MessageQueueEntry, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO message_queue (user_id, message_id)
VALUES (?, ?)`,
userID, messageID,
)
if err != nil {
return nil, err
}
entryID, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get last insert id: %w", err)
}
mq := &models.MessageQueueEntry{
ID: entryID,
UserID: userID,
MessageID: messageID,
QueuedAt: now,
}
s.Hydrate(mq)
return mq, nil
}
// DequeueMessages returns up to limit pending messages for a user,
// ordered by queue time (oldest first).
func (s *Database) DequeueMessages(
ctx context.Context,
userID string,
limit int,
) ([]*models.MessageQueueEntry, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, message_id, queued_at
FROM message_queue
WHERE user_id = ?
ORDER BY queued_at ASC
LIMIT ?`,
userID, limit,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
entries := []*models.MessageQueueEntry{}
for rows.Next() {
e := &models.MessageQueueEntry{}
s.Hydrate(e)
err = rows.Scan(&e.ID, &e.UserID, &e.MessageID, &e.QueuedAt)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// AckMessages removes the given queue entry IDs, marking them as delivered.
func (s *Database) AckMessages(
ctx context.Context,
entryIDs []int64,
) error {
if len(entryIDs) == 0 {
return nil
}
placeholders := make([]string, len(entryIDs))
args := make([]interface{}, len(entryIDs))
for i, id := range entryIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(
"DELETE FROM message_queue WHERE id IN (%s)",
strings.Join(placeholders, ","),
)
_, err := s.db.ExecContext(ctx, query, args...)
return err
}
// CreateAuthToken inserts a new auth token for a user.
func (s *Database) CreateAuthToken(
ctx context.Context,
token, userID string,
) (*models.AuthToken, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO auth_tokens (token, user_id)
VALUES (?, ?)`,
token, userID,
)
if err != nil {
return nil, err
}
at := &models.AuthToken{Token: token, UserID: userID, CreatedAt: now}
s.Hydrate(at)
return at, nil
}
// CreateSession inserts a new session for a user.
func (s *Database) CreateSession(
ctx context.Context,
id, userID string,
) (*models.Session, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id)
VALUES (?, ?)`,
id, userID,
)
if err != nil {
return nil, err
}
sess := &models.Session{
ID: id, UserID: userID,
CreatedAt: now, LastActiveAt: now,
}
s.Hydrate(sess)
return sess, nil
}
// CreateServerLink inserts a new server link.
func (s *Database) CreateServerLink(
ctx context.Context,
id, name, url, sharedKeyHash string,
isActive bool,
) (*models.ServerLink, error) {
now := time.Now()
active := 0
if isActive {
active = 1
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO server_links
(id, name, url, shared_key_hash, is_active)
VALUES (?, ?, ?, ?, ?)`,
id, name, url, sharedKeyHash, active,
)
if err != nil {
return nil, err
}
sl := &models.ServerLink{
ID: id,
Name: name,
URL: url,
SharedKeyHash: sharedKeyHash,
IsActive: isActive,
CreatedAt: now,
}
s.Hydrate(sl)
return sl, nil
}
func (s *Database) connect(ctx context.Context) error { func (s *Database) connect(ctx context.Context) error {
dbURL := s.params.Config.DBURL dbURL := s.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
@@ -119,6 +548,11 @@ func (s *Database) connect(ctx context.Context) error {
s.db = d s.db = d
s.log.Info("database connected") s.log.Info("database connected")
// Item 9: Enable foreign keys on every connection
if _, err := s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return s.runMigrations(ctx) return s.runMigrations(ctx)
} }
@@ -149,13 +583,18 @@ func (s *Database) runMigrations(ctx context.Context) error {
return nil return nil
} }
func (s *Database) bootstrapMigrationsTable(ctx context.Context) error { func (s *Database) bootstrapMigrationsTable(
_, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( ctx context.Context,
) error {
_, err := s.db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("failed to create schema_migrations table: %w", err) return fmt.Errorf(
"create schema_migrations table: %w", err,
)
} }
return nil return nil
@@ -164,17 +603,20 @@ func (s *Database) bootstrapMigrationsTable(ctx context.Context) error {
func (s *Database) loadMigrations() ([]migration, error) { func (s *Database) loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(SchemaFiles, "schema") entries, err := fs.ReadDir(SchemaFiles, "schema")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read schema dir: %w", err) return nil, fmt.Errorf("read schema dir: %w", err)
} }
var migrations []migration var migrations []migration
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".sql") {
continue continue
} }
parts := strings.SplitN(entry.Name(), "_", minMigrationParts) parts := strings.SplitN(
entry.Name(), "_", minMigrationParts,
)
if len(parts) < minMigrationParts { if len(parts) < minMigrationParts {
continue continue
} }
@@ -184,9 +626,13 @@ func (s *Database) loadMigrations() ([]migration, error) {
continue continue
} }
content, err := SchemaFiles.ReadFile("schema/" + entry.Name()) content, err := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read migration %s: %w", entry.Name(), err) return nil, fmt.Errorf(
"read migration %s: %w", entry.Name(), err,
)
} }
migrations = append(migrations, migration{ migrations = append(migrations, migration{
@@ -203,29 +649,66 @@ func (s *Database) loadMigrations() ([]migration, error) {
return migrations, nil return migrations, nil
} }
func (s *Database) applyMigrations(ctx context.Context, migrations []migration) error { // Item 4: Wrap each migration in a transaction
func (s *Database) applyMigrations(
ctx context.Context,
migrations []migration,
) error {
for _, m := range migrations { for _, m := range migrations {
var exists int var exists int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists) err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
m.version,
).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf("failed to check migration %d: %w", m.version, err) return fmt.Errorf(
"check migration %d: %w", m.version, err,
)
} }
if exists > 0 { if exists > 0 {
continue continue
} }
s.log.Info("applying migration", "version", m.version, "name", m.name) s.log.Info(
"applying migration",
"version", m.version, "name", m.name,
)
_, err = s.db.ExecContext(ctx, m.sql) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to apply migration %d (%s): %w", m.version, m.name, err) return fmt.Errorf(
"begin tx for migration %d: %w", m.version, err,
)
} }
_, err = s.db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", m.version) _, err = tx.ExecContext(ctx, m.sql)
if err != nil { if err != nil {
return fmt.Errorf("failed to record migration %d: %w", m.version, err) _ = tx.Rollback()
return fmt.Errorf(
"apply migration %d (%s): %w",
m.version, m.name, err,
)
}
_, err = tx.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
m.version,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf(
"record migration %d: %w", m.version, err,
)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(
"commit migration %d: %w", m.version, err,
)
} }
} }

494
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,494 @@
package db_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/chat/internal/db"
)
const (
nickAlice = "alice"
nickBob = "bob"
nickCharlie = "charlie"
)
// setupTestDB creates a fresh database in a temp directory with
// all migrations applied.
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
dir := t.TempDir()
dsn := fmt.Sprintf(
"file:%s?_journal_mode=WAL",
filepath.Join(dir, "test.db"),
)
d, err := db.NewTest(dsn)
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() { _ = d.GetDB().Close() })
return d
}
func TestCreateUser(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, err := d.CreateUser(ctx, "u1", nickAlice, "hash1")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("got user %+v", u)
}
}
func TestCreateAuthToken(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, err := d.CreateUser(ctx, "u1", nickAlice, "h")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tok, err := d.CreateAuthToken(ctx, "tok1", "u1")
if err != nil {
t.Fatalf("CreateAuthToken: %v", err)
}
if tok.Token != "tok1" || tok.UserID != "u1" {
t.Errorf("unexpected token: %+v", tok)
}
u, err := tok.User(ctx)
if err != nil {
t.Fatalf("AuthToken.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("AuthToken.User got %+v", u)
}
}
func TestCreateChannel(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, err := d.CreateChannel(
ctx, "c1", "#general", "welcome", "+n",
)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if ch.ID != "c1" || ch.Name != "#general" {
t.Errorf("unexpected channel: %+v", ch)
}
}
func TestAddChannelMember(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, err := d.AddChannelMember(ctx, "c1", "u1", "+o")
if err != nil {
t.Fatalf("AddChannelMember: %v", err)
}
if cm.ChannelID != "c1" || cm.Modes != "+o" {
t.Errorf("unexpected member: %+v", cm)
}
}
func TestCreateMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
msg, err := d.CreateMessage(
ctx, "m1", "u1", nickAlice,
"#general", "message", "hello",
)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
if msg.ID != "m1" || msg.Body != "hello" {
t.Errorf("unexpected message: %+v", msg)
}
}
func TestQueueMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hi",
)
mq, err := d.QueueMessage(ctx, "u2", "m1")
if err != nil {
t.Fatalf("QueueMessage: %v", err)
}
if mq.UserID != "u2" || mq.MessageID != "m1" {
t.Errorf("unexpected queue entry: %+v", mq)
}
}
func TestCreateSession(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
sess, err := d.CreateSession(ctx, "s1", "u1")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if sess.ID != "s1" || sess.UserID != "u1" {
t.Errorf("unexpected session: %+v", sess)
}
u, err := sess.User(ctx)
if err != nil {
t.Fatalf("Session.User: %v", err)
}
if u.ID != "u1" {
t.Errorf("Session.User got %v, want u1", u.ID)
}
}
func TestCreateServerLink(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
sl, err := d.CreateServerLink(
ctx, "sl1", "peer1",
"https://peer.example.com", "keyhash", true,
)
if err != nil {
t.Fatalf("CreateServerLink: %v", err)
}
if sl.ID != "sl1" || !sl.IsActive {
t.Errorf("unexpected server link: %+v", sl)
}
}
func TestUserChannels(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#alpha", "", "")
_, _ = d.CreateChannel(ctx, "c2", "#beta", "", "")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "")
_, _ = d.AddChannelMember(ctx, "c2", "u1", "")
channels, err := u.Channels(ctx)
if err != nil {
t.Fatalf("User.Channels: %v", err)
}
if len(channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(channels))
}
if channels[0].Name != "#alpha" {
t.Errorf("first channel: got %s", channels[0].Name)
}
if channels[1].Name != "#beta" {
t.Errorf("second channel: got %s", channels[1].Name)
}
}
func TestUserChannelsEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
channels, err := u.Channels(ctx)
if err != nil {
t.Fatalf("User.Channels: %v", err)
}
if len(channels) != 0 {
t.Errorf("expected 0 channels, got %d", len(channels))
}
}
func TestUserQueuedMessages(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
for i := range 3 {
id := fmt.Sprintf("m%d", i)
_, _ = d.CreateMessage(
ctx, id, "u2", nickBob, "u1",
"message", fmt.Sprintf("msg%d", i),
)
time.Sleep(10 * time.Millisecond)
_, _ = d.QueueMessage(ctx, "u1", id)
}
msgs, err := u.QueuedMessages(ctx)
if err != nil {
t.Fatalf("User.QueuedMessages: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
for i, msg := range msgs {
want := fmt.Sprintf("msg%d", i)
if msg.Body != want {
t.Errorf("msg %d: got %q, want %q", i, msg.Body, want)
}
}
}
func TestUserQueuedMessagesEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
msgs, err := u.QueuedMessages(ctx)
if err != nil {
t.Fatalf("User.QueuedMessages: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestChannelMembers(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
_, _ = d.CreateUser(ctx, "u3", nickCharlie, "h")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "+o")
_, _ = d.AddChannelMember(ctx, "c1", "u2", "+v")
_, _ = d.AddChannelMember(ctx, "c1", "u3", "")
members, err := ch.Members(ctx)
if err != nil {
t.Fatalf("Channel.Members: %v", err)
}
if len(members) != 3 {
t.Fatalf("expected 3 members, got %d", len(members))
}
nicks := map[string]bool{}
for _, m := range members {
nicks[m.Nick] = true
}
for _, want := range []string{
nickAlice, nickBob, nickCharlie,
} {
if !nicks[want] {
t.Errorf("missing nick %q", want)
}
}
}
func TestChannelMembersEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#empty", "", "")
members, err := ch.Members(ctx)
if err != nil {
t.Fatalf("Channel.Members: %v", err)
}
if len(members) != 0 {
t.Errorf("expected 0, got %d", len(members))
}
}
func TestChannelRecentMessages(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
for i := range 5 {
id := fmt.Sprintf("m%d", i)
_, _ = d.CreateMessage(
ctx, id, "u1", nickAlice, "#general",
"message", fmt.Sprintf("msg%d", i),
)
time.Sleep(10 * time.Millisecond)
}
msgs, err := ch.RecentMessages(ctx, 3)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3, got %d", len(msgs))
}
if msgs[0].Body != "msg4" {
t.Errorf("first: got %q, want msg4", msgs[0].Body)
}
if msgs[2].Body != "msg2" {
t.Errorf("last: got %q, want msg2", msgs[2].Body)
}
}
func TestChannelRecentMessagesLargeLimit(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice,
"#general", "message", "only",
)
msgs, err := ch.RecentMessages(ctx, 100)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 1 {
t.Errorf("expected 1, got %d", len(msgs))
}
}
func TestChannelMemberUser(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "+o")
u, err := cm.User(ctx)
if err != nil {
t.Fatalf("ChannelMember.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("got %+v", u)
}
}
func TestChannelMemberChannel(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "topic", "+n")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "")
ch, err := cm.Channel(ctx)
if err != nil {
t.Fatalf("ChannelMember.Channel: %v", err)
}
if ch.ID != "c1" || ch.Topic != "topic" {
t.Errorf("got %+v", ch)
}
}
func TestDMMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
msg, err := d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hey",
)
if err != nil {
t.Fatalf("CreateMessage DM: %v", err)
}
if msg.Target != "u2" {
t.Errorf("target: got %q, want u2", msg.Target)
}
}

View File

@@ -2,88 +2,52 @@ package db
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"time" "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. // GetOrCreateChannel returns the channel id, creating it if needed.
func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) { func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (string, error) {
var id int64 var id string
err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id) err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id)
if err == nil { if err == nil {
return id, nil return id, nil
} }
now := time.Now() now := time.Now()
res, err := s.db.ExecContext(ctx, id = fmt.Sprintf("ch-%d", now.UnixNano())
"INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)",
name, now, now) _, err = s.db.ExecContext(ctx,
"INSERT INTO channels (id, name, topic, modes, created_at, updated_at) VALUES (?, ?, '', '', ?, ?)",
id, name, now, now)
if err != nil { if err != nil {
return 0, fmt.Errorf("create channel: %w", err) return "", fmt.Errorf("create channel: %w", err)
} }
id, _ = res.LastInsertId()
return id, nil return id, nil
} }
// JoinChannel adds a user to a channel. // JoinChannel adds a user to a channel.
func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error { func (s *Database) JoinChannel(ctx context.Context, channelID, userID string) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
"INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO channel_members (channel_id, user_id, modes, joined_at) VALUES (?, ?, '', ?)",
channelID, userID, time.Now()) channelID, userID, time.Now())
return err return err
} }
// PartChannel removes a user from a channel. // PartChannel removes a user from a channel.
func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error { func (s *Database) PartChannel(ctx context.Context, channelID, userID string) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
channelID, userID) channelID, userID)
return err return err
} }
// ListChannels returns all channels the user has joined. // ListChannels returns all channels the user has joined.
func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) { func (s *Database) ListChannels(ctx context.Context, userID string) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic FROM channels c `SELECT c.id, c.name, c.topic FROM channels c
INNER JOIN channel_members cm ON cm.channel_id = c.id INNER JOIN channel_members cm ON cm.channel_id = c.id
@@ -91,62 +55,66 @@ func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInf
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var channels []ChannelInfo defer func() { _ = rows.Close() }()
channels := []ChannelInfo{}
for rows.Next() { for rows.Next() {
var ch ChannelInfo var ch ChannelInfo
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil {
return nil, err return nil, err
} }
channels = append(channels, ch) channels = append(channels, ch)
} }
if channels == nil {
channels = []ChannelInfo{} return channels, rows.Err()
}
return channels, nil
} }
// ChannelInfo is a lightweight channel representation. // ChannelInfo is a lightweight channel representation.
type ChannelInfo struct { type ChannelInfo struct {
ID int64 `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
} }
// ChannelMembers returns all members of a channel. // ChannelMembers returns all members of a channel.
func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) { func (s *Database) ChannelMembers(ctx context.Context, channelID string) ([]MemberInfo, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT u.id, u.nick, u.last_seen FROM users u `SELECT u.id, u.nick, u.last_seen_at FROM users u
INNER JOIN channel_members cm ON cm.user_id = u.id INNER JOIN channel_members cm ON cm.user_id = u.id
WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) WHERE cm.channel_id = ? ORDER BY u.nick`, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var members []MemberInfo defer func() { _ = rows.Close() }()
members := []MemberInfo{}
for rows.Next() { for rows.Next() {
var m MemberInfo var m MemberInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil {
return nil, err return nil, err
} }
members = append(members, m) members = append(members, m)
} }
if members == nil {
members = []MemberInfo{} return members, rows.Err()
}
return members, nil
} }
// MemberInfo represents a channel member. // MemberInfo represents a channel member.
type MemberInfo struct { type MemberInfo struct {
ID int64 `json:"id"` ID string `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
LastSeen time.Time `json:"lastSeen"` LastSeen *time.Time `json:"lastSeen"`
} }
// MessageInfo represents a chat message. // MessageInfo represents a chat message.
type MessageInfo struct { type MessageInfo struct {
ID int64 `json:"id"` ID string `json:"id"`
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
Nick string `json:"nick"` Nick string `json:"nick"`
Content string `json:"content"` Content string `json:"content"`
@@ -155,234 +123,202 @@ type MessageInfo struct {
CreatedAt time.Time `json:"createdAt"` 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. // SendMessage inserts a channel message.
func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) { func (s *Database) SendMessage(ctx context.Context, channelID, userID, nick, content string) (string, error) {
res, err := s.db.ExecContext(ctx, now := time.Now()
"INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", id := fmt.Sprintf("msg-%d", now.UnixNano())
channelID, userID, content, time.Now())
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`,
id, now, userID, nick, channelID, content, now)
if err != nil { if err != nil {
return 0, err return "", err
} }
return res.LastInsertId()
return id, nil
} }
// SendDM inserts a direct message. // SendDM inserts a direct message.
func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) { func (s *Database) SendDM(ctx context.Context, fromID, fromNick, toID, content string) (string, error) {
res, err := s.db.ExecContext(ctx, now := time.Now()
"INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", id := fmt.Sprintf("msg-%d", now.UnixNano())
fromID, content, toID, time.Now())
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`,
id, now, fromID, fromNick, toID, content, now)
if err != nil { if err != nil {
return 0, err return "", err
} }
return res.LastInsertId()
return id, nil
} }
// GetDMs returns direct messages between two users after a given ID. // PollMessages returns all new messages for a user's joined channels, ordered by timestamp.
func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) { func (s *Database) PollMessages(ctx context.Context, userID string, afterTS string, 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 { if limit <= 0 {
limit = 100 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). rows, err := s.db.QueryContext(ctx,
func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) { `SELECT m.id, m.target, m.from_nick, m.body, m.created_at
if limit <= 0 { FROM messages m
limit = 50 WHERE m.created_at > COALESCE(NULLIF(?, ''), '1970-01-01')
} AND (
var query string m.target IN (SELECT cm.channel_id FROM channel_members cm WHERE cm.user_id = ?)
var args []any OR m.target = ?
if beforeID > 0 { OR m.from_user_id = ?
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at )
FROM messages m ORDER BY m.created_at ASC LIMIT ?`,
INNER JOIN users u ON u.id = m.user_id afterTS, userID, userID, userID, limit)
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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var msgs []MessageInfo defer func() { _ = rows.Close() }()
msgs := []MessageInfo{}
for rows.Next() { for rows.Next() {
var m MessageInfo var m MessageInfo
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
return nil, err return nil, err
} }
msgs = append(msgs, m) msgs = append(msgs, m)
} }
if msgs == nil {
msgs = []MessageInfo{} return msgs, rows.Err()
}
// 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). // GetMessagesBefore returns channel messages before a given timestamp (for history scrollback).
func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) { func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeTS string, limit int) ([]MessageInfo, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
var query string
var args []any var rows interface {
if beforeID > 0 { Next() bool
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at Scan(dest ...interface{}) error
FROM messages m Close() error
INNER JOIN users u ON u.id = m.user_id Err() error
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...)
var err error
if beforeTS != "" {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.target, m.from_nick, m.body, m.created_at
FROM messages m
WHERE m.target = ? AND m.created_at < ?
ORDER BY m.created_at DESC LIMIT ?`,
target, beforeTS, limit)
} else {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.target, m.from_nick, m.body, m.created_at
FROM messages m
WHERE m.target = ?
ORDER BY m.created_at DESC LIMIT ?`,
target, limit)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var msgs []MessageInfo defer func() { _ = rows.Close() }()
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)
}
// 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, rows.Err()
}
// GetDMsBefore returns DMs between two users before a given timestamp.
func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, beforeTS string, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var rows interface {
Next() bool
Scan(dest ...interface{}) error
Close() error
Err() error
}
var err error
if beforeTS != "" {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.from_nick, m.body, m.target, m.created_at
FROM messages m
WHERE m.created_at < ?
AND ((m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?))
ORDER BY m.created_at DESC LIMIT ?`,
beforeTS, userA, userB, userB, userA, limit)
} else {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.from_nick, m.body, m.target, m.created_at
FROM messages m
WHERE (m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?)
ORDER BY m.created_at DESC LIMIT ?`,
userA, userB, userB, userA, limit)
}
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
msgs := []MessageInfo{}
for rows.Next() { for rows.Next() {
var m MessageInfo var m MessageInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err return nil, err
} }
m.IsDM = true m.IsDM = true
msgs = append(msgs, m) msgs = append(msgs, m)
} }
if msgs == nil {
msgs = []MessageInfo{} // Reverse to ascending order.
}
// Reverse to ascending order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i] msgs[i], msgs[j] = msgs[j], msgs[i]
} }
return msgs, nil
return msgs, rows.Err()
} }
// ChangeNick updates a user's nickname. // ChangeNick updates a user's nickname.
func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error { func (s *Database) ChangeNick(ctx context.Context, userID string, newNick string) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
"UPDATE users SET nick = ? WHERE id = ?", newNick, userID) "UPDATE users SET nick = ? WHERE id = ?", newNick, userID)
return err return err
} }
// SetTopic sets the topic for a channel. // SetTopic sets the topic for a channel.
func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error { func (s *Database) SetTopic(ctx context.Context, channelName string, _ string, topic string) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
"UPDATE channels SET topic = ? WHERE name = ?", topic, channelName) "UPDATE channels SET topic = ? WHERE name = ?", topic, channelName)
return err return err
} }
@@ -398,17 +334,19 @@ func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var channels []ChannelInfo defer func() { _ = rows.Close() }()
channels := []ChannelInfo{}
for rows.Next() { for rows.Next() {
var ch ChannelInfo var ch ChannelInfo
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil {
return nil, err return nil, err
} }
channels = append(channels, ch) channels = append(channels, ch)
} }
if channels == nil {
channels = []ChannelInfo{} return channels, rows.Err()
}
return channels, nil
} }

View File

@@ -0,0 +1,89 @@
-- All schema changes go into this file until 1.0.0 is tagged.
-- There will not be migrations during the early development phase.
-- After 1.0.0, new changes get their own numbered migration files.
-- Users: accounts and authentication
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- UUID
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME
);
-- Auth tokens: one user can have multiple active tokens (multiple devices)
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY, -- random token string
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME, -- NULL = no expiry
last_used_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id);
-- Channels: chat rooms
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL UNIQUE, -- #general, etc.
topic TEXT NOT NULL DEFAULT '',
modes TEXT NOT NULL DEFAULT '', -- +i, +m, +s, +t, +n
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Channel members: who is in which channel, with per-user modes
CREATE TABLE IF NOT EXISTS channel_members (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
modes TEXT NOT NULL DEFAULT '', -- +o (operator), +v (voice)
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_members_user_id ON channel_members(user_id);
-- Messages: channel and DM history (rotated per MAX_HISTORY)
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, -- UUID
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
from_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_nick TEXT NOT NULL, -- denormalized for history
target TEXT NOT NULL, -- #channel name or user UUID for DMs
type TEXT NOT NULL DEFAULT 'message', -- message, action, notice, join, part, quit, topic, mode, nick, system
body TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}', -- JSON extensible metadata
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_target_ts ON messages(target, ts);
CREATE INDEX IF NOT EXISTS idx_messages_from_user ON messages(from_user_id);
-- Message queue: per-user pending delivery (unread messages)
CREATE TABLE IF NOT EXISTS message_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_message_queue_user_id ON message_queue(user_id, queued_at);
-- Sessions: server-held session state
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME -- idle timeout
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-- Server links: federation peer configuration
CREATE TABLE IF NOT EXISTS server_links (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL UNIQUE, -- human-readable peer name
url TEXT NOT NULL, -- base URL of peer server
shared_key_hash TEXT NOT NULL, -- hashed shared secret
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME
);

View File

@@ -1,8 +0,0 @@
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '',
modes TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,7 +1,9 @@
package handlers package handlers
import ( import (
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
@@ -12,77 +14,114 @@ import (
) )
// authUser extracts the user from the Authorization header (Bearer token). // authUser extracts the user from the Authorization header (Bearer token).
func (s *Handlers) authUser(r *http.Request) (int64, string, error) { func (s *Handlers) authUser(r *http.Request) (string, string, error) {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") { if !strings.HasPrefix(auth, "Bearer ") {
return 0, "", sql.ErrNoRows return "", "", sql.ErrNoRows
} }
token := strings.TrimPrefix(auth, "Bearer ") token := strings.TrimPrefix(auth, "Bearer ")
return s.params.Database.GetUserByToken(r.Context(), token)
u, err := s.params.Database.GetUserByToken(r.Context(), token)
if err != nil {
return "", "", err
}
return u.ID, u.Nick, nil
} }
func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) { func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (string, string, bool) {
uid, nick, err := s.authUser(r) uid, nick, err := s.authUser(r)
if err != nil { if err != nil {
s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized)
return 0, "", false return "", "", false
} }
return uid, nick, true return uid, nick, true
} }
func generateID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// HandleCreateSession creates a new user session and returns the auth token. // HandleCreateSession creates a new user session and returns the auth token.
func (s *Handlers) HandleCreateSession() http.HandlerFunc { func (s *Handlers) HandleCreateSession() http.HandlerFunc {
type request struct { type request struct {
Nick string `json:"nick"` Nick string `json:"nick"`
} }
type response struct { type response struct {
ID int64 `json:"id"` ID string `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Token string `json:"token"` Token string `json:"token"`
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req request var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest)
return return
} }
req.Nick = strings.TrimSpace(req.Nick) req.Nick = strings.TrimSpace(req.Nick)
if req.Nick == "" || len(req.Nick) > 32 { if req.Nick == "" || len(req.Nick) > 32 {
s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest)
return return
} }
id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick)
id := generateID()
u, err := s.params.Database.CreateUser(r.Context(), id, req.Nick, "")
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict)
return return
} }
s.log.Error("create user failed", "error", err) s.log.Error("create user failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated)
tokenStr := generateID()
_, err = s.params.Database.CreateAuthToken(r.Context(), tokenStr, u.ID)
if err != nil {
s.log.Error("create auth token failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, &response{ID: u.ID, Nick: req.Nick, Token: tokenStr}, http.StatusCreated)
} }
} }
// HandleState returns the current user's info and joined channels. // HandleState returns the current user's info and joined channels.
func (s *Handlers) HandleState() http.HandlerFunc { func (s *Handlers) HandleState() http.HandlerFunc {
type response struct { type response struct {
ID int64 `json:"id"` ID string `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []db.ChannelInfo `json:"channels"` Channels []db.ChannelInfo `json:"channels"`
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
uid, nick, ok := s.requireAuth(w, r) uid, nick, ok := s.requireAuth(w, r)
if !ok { if !ok {
return return
} }
channels, err := s.params.Database.ListChannels(r.Context(), uid) channels, err := s.params.Database.ListChannels(r.Context(), uid)
if err != nil { if err != nil {
s.log.Error("list channels failed", "error", err) s.log.Error("list channels failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK) s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK)
} }
} }
@@ -94,12 +133,15 @@ func (s *Handlers) HandleListAllChannels() http.HandlerFunc {
if !ok { if !ok {
return return
} }
channels, err := s.params.Database.ListAllChannels(r.Context()) channels, err := s.params.Database.ListAllChannels(r.Context())
if err != nil { if err != nil {
s.log.Error("list all channels failed", "error", err) s.log.Error("list all channels failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, channels, http.StatusOK) s.respondJSON(w, r, channels, http.StatusOK)
} }
} }
@@ -111,20 +153,26 @@ func (s *Handlers) HandleChannelMembers() http.HandlerFunc {
if !ok { if !ok {
return return
} }
name := "#" + chi.URLParam(r, "channel") name := "#" + chi.URLParam(r, "channel")
var chID int64
var chID string
err := s.params.Database.GetDB().QueryRowContext(r.Context(), err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", name).Scan(&chID) "SELECT id FROM channels WHERE name = ?", name).Scan(&chID)
if err != nil { if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return return
} }
members, err := s.params.Database.ChannelMembers(r.Context(), chID) members, err := s.params.Database.ChannelMembers(r.Context(), chID)
if err != nil { if err != nil {
s.log.Error("channel members failed", "error", err) s.log.Error("channel members failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, members, http.StatusOK) s.respondJSON(w, r, members, http.StatusOK)
} }
} }
@@ -137,14 +185,18 @@ func (s *Handlers) HandleGetMessages() http.HandlerFunc {
if !ok { if !ok {
return return
} }
afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
afterTS := r.URL.Query().Get("after")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit)
msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterTS, limit)
if err != nil { if err != nil {
s.log.Error("get messages failed", "error", err) s.log.Error("get messages failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, msgs, http.StatusOK) s.respondJSON(w, r, msgs, http.StatusOK)
} }
} }
@@ -158,16 +210,19 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc {
Params []string `json:"params,omitempty"` Params []string `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"` Body interface{} `json:"body,omitempty"`
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
uid, nick, ok := s.requireAuth(w, r) uid, nick, ok := s.requireAuth(w, r)
if !ok { if !ok {
return return
} }
var req request var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest)
return return
} }
req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) req.Command = strings.ToUpper(strings.TrimSpace(req.Command))
req.To = strings.TrimSpace(req.To) req.To = strings.TrimSpace(req.To)
@@ -176,11 +231,13 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc {
switch v := req.Body.(type) { switch v := req.Body.(type) {
case []interface{}: case []interface{}:
lines := make([]string, 0, len(v)) lines := make([]string, 0, len(v))
for _, item := range v { for _, item := range v {
if s, ok := item.(string); ok { if str, ok := item.(string); ok {
lines = append(lines, s) lines = append(lines, str)
} }
} }
return lines return lines
case []string: case []string:
return v return v
@@ -191,137 +248,19 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc {
switch req.Command { switch req.Command {
case "PRIVMSG", "NOTICE": case "PRIVMSG", "NOTICE":
if req.To == "" { s.handlePrivmsg(w, r, uid, nick, req.To, bodyLines())
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": case "JOIN":
if req.To == "" { s.handleJoin(w, r, uid, 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": case "PART":
if req.To == "" { s.handlePart(w, r, uid, 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": case "NICK":
lines := bodyLines() s.handleNick(w, r, uid, 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": case "TOPIC":
if req.To == "" { s.handleTopic(w, r, uid, req.To, bodyLines())
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": case "PING":
s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK)
@@ -333,6 +272,173 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc {
} }
} }
func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, nick, to string, lines []string) {
if to == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
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(to, "#") {
// Channel message.
var chID string
err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", 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, nick, 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.
targetUser, err := s.params.Database.GetUserByNick(r.Context(), 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, nick, targetUser.ID, 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)
}
}
func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to string) {
if to == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
channel := 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)
}
func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to string) {
if to == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
channel := to
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
var chID string
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)
}
func (s *Handlers) handleNick(w http.ResponseWriter, r *http.Request, uid string, lines []string) {
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)
}
func (s *Handlers) handleTopic(w http.ResponseWriter, r *http.Request, uid, to string, lines []string) {
if to == "" {
s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest)
return
}
if len(lines) == 0 {
s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest)
return
}
topic := strings.Join(lines, " ")
channel := 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)
}
// HandleGetHistory returns message history for a specific target (channel or DM). // HandleGetHistory returns message history for a specific target (channel or DM).
func (s *Handlers) HandleGetHistory() http.HandlerFunc { func (s *Handlers) HandleGetHistory() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -340,46 +446,56 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc {
if !ok { if !ok {
return return
} }
target := r.URL.Query().Get("target") target := r.URL.Query().Get("target")
if target == "" { if target == "" {
s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest) s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest)
return return
} }
beforeID, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
beforeTS := r.URL.Query().Get("before")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
// Channel history // Channel history — look up channel by name to get its ID for target matching.
var chID int64 var chID string
err := s.params.Database.GetDB().QueryRowContext(r.Context(), err := s.params.Database.GetDB().QueryRowContext(r.Context(),
"SELECT id FROM channels WHERE name = ?", target).Scan(&chID) "SELECT id FROM channels WHERE name = ?", target).Scan(&chID)
if err != nil { if err != nil {
s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound)
return return
} }
msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeID, limit)
msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeTS, limit)
if err != nil { if err != nil {
s.log.Error("get history failed", "error", err) s.log.Error("get history failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, msgs, http.StatusOK) s.respondJSON(w, r, msgs, http.StatusOK)
} else { } else {
// DM history // DM history.
targetID, err := s.params.Database.GetUserByNick(r.Context(), target) targetUser, err := s.params.Database.GetUserByNick(r.Context(), target)
if err != nil { if err != nil {
s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound)
return return
} }
msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetID, beforeID, limit)
msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetUser.ID, beforeTS, limit)
if err != nil { if err != nil {
s.log.Error("get dm history failed", "error", err) s.log.Error("get dm history failed", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError)
return return
} }
s.respondJSON(w, r, msgs, http.StatusOK) s.respondJSON(w, r, msgs, http.StatusOK)
} }
} }
@@ -391,6 +507,7 @@ func (s *Handlers) HandleServerInfo() http.HandlerFunc {
Name string `json:"name"` Name string `json:"name"`
MOTD string `json:"motd"` MOTD string `json:"motd"`
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, &response{ s.respondJSON(w, r, &response{
Name: s.params.Config.ServerName, Name: s.params.Config.ServerName,

View File

@@ -0,0 +1,27 @@
package models
import (
"context"
"fmt"
"time"
)
// AuthToken represents an authentication token for a user session.
type AuthToken struct {
Base
Token string `json:"-"`
UserID string `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// User returns the user who owns this token.
func (t *AuthToken) User(ctx context.Context) (*User, error) {
if ul := t.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, t.UserID)
}
return nil, fmt.Errorf("user lookup not available")
}

View File

@@ -1,6 +1,7 @@
package models package models
import ( import (
"context"
"time" "time"
) )
@@ -8,10 +9,88 @@ import (
type Channel struct { type Channel struct {
Base Base
ID int64 `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
Modes string `json:"modes"` Modes string `json:"modes"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// Members returns all users who are members of this channel.
func (c *Channel) Members(ctx context.Context) ([]*ChannelMember, error) {
rows, err := c.GetDB().QueryContext(ctx, `
SELECT cm.channel_id, cm.user_id, cm.modes, cm.joined_at,
u.nick
FROM channel_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.channel_id = ?
ORDER BY cm.joined_at`,
c.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
members := []*ChannelMember{}
for rows.Next() {
m := &ChannelMember{}
m.SetDB(c.db)
err = rows.Scan(
&m.ChannelID, &m.UserID, &m.Modes,
&m.JoinedAt, &m.Nick,
)
if err != nil {
return nil, err
}
members = append(members, m)
}
return members, rows.Err()
}
// RecentMessages returns the most recent messages in this channel.
func (c *Channel) RecentMessages(
ctx context.Context,
limit int,
) ([]*Message, error) {
rows, err := c.GetDB().QueryContext(ctx, `
SELECT id, ts, from_user_id, from_nick,
target, type, body, meta, created_at
FROM messages
WHERE target = ?
ORDER BY ts DESC
LIMIT ?`,
c.Name, limit,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
messages := []*Message{}
for rows.Next() {
msg := &Message{}
msg.SetDB(c.db)
err = rows.Scan(
&msg.ID, &msg.Timestamp, &msg.FromUserID,
&msg.FromNick, &msg.Target, &msg.Type,
&msg.Body, &msg.Meta, &msg.CreatedAt,
)
if err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, rows.Err()
}

View File

@@ -0,0 +1,36 @@
package models
import (
"context"
"fmt"
"time"
)
// ChannelMember represents a user's membership in a channel.
type ChannelMember struct {
Base
ChannelID string `json:"channelId"`
UserID string `json:"userId"`
Modes string `json:"modes"`
JoinedAt time.Time `json:"joinedAt"`
Nick string `json:"nick"` // denormalized from users table
}
// User returns the full User for this membership.
func (cm *ChannelMember) User(ctx context.Context) (*User, error) {
if ul := cm.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, cm.UserID)
}
return nil, fmt.Errorf("user lookup not available")
}
// Channel returns the full Channel for this membership.
func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) {
if cl := cm.GetChannelLookup(); cl != nil {
return cl.GetChannelByID(ctx, cm.ChannelID)
}
return nil, fmt.Errorf("channel lookup not available")
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
)
// Message represents a chat message (channel or DM).
type Message struct {
Base
ID string `json:"id"`
Timestamp time.Time `json:"ts"`
FromUserID string `json:"fromUserId"`
FromNick string `json:"from"`
Target string `json:"to"`
Type string `json:"type"`
Body string `json:"body"`
Meta string `json:"meta"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
)
// MessageQueueEntry represents a pending message delivery for a user.
type MessageQueueEntry struct {
Base
ID int64 `json:"id"`
UserID string `json:"userId"`
MessageID string `json:"messageId"`
QueuedAt time.Time `json:"queuedAt"`
}

View File

@@ -1,14 +1,29 @@
// Package models defines the data models used by the chat application. // Package models defines the data models used by the chat application.
// All model structs embed Base, which provides database access for
// relation-fetching methods directly on model instances.
package models package models
import "database/sql" import (
"context"
"database/sql"
)
// DB is the interface that models use to query relations. // DB is the interface that models use to query the database.
// This avoids a circular import with the db package. // This avoids a circular import with the db package.
type DB interface { type DB interface {
GetDB() *sql.DB GetDB() *sql.DB
} }
// UserLookup provides user lookup by ID without circular imports.
type UserLookup interface {
GetUserByID(ctx context.Context, id string) (*User, error)
}
// ChannelLookup provides channel lookup by ID without circular imports.
type ChannelLookup interface {
GetChannelByID(ctx context.Context, id string) (*Channel, error)
}
// Base is embedded in all model structs to provide database access. // Base is embedded in all model structs to provide database access.
type Base struct { type Base struct {
db DB db DB
@@ -18,3 +33,26 @@ type Base struct {
func (b *Base) SetDB(d DB) { func (b *Base) SetDB(d DB) {
b.db = d b.db = d
} }
// GetDB returns the database interface for use in model methods.
func (b *Base) GetDB() *sql.DB {
return b.db.GetDB()
}
// GetUserLookup returns the DB as a UserLookup if it implements the interface.
func (b *Base) GetUserLookup() UserLookup {
if ul, ok := b.db.(UserLookup); ok {
return ul
}
return nil
}
// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface.
func (b *Base) GetChannelLookup() ChannelLookup {
if cl, ok := b.db.(ChannelLookup); ok {
return cl
}
return nil
}

View File

@@ -0,0 +1,18 @@
package models
import (
"time"
)
// ServerLink represents a federation peer server configuration.
type ServerLink struct {
Base
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
SharedKeyHash string `json:"-"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"context"
"fmt"
"time"
)
// Session represents a server-held user session.
type Session struct {
Base
ID string `json:"id"`
UserID string `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
LastActiveAt time.Time `json:"lastActiveAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// User returns the user who owns this session.
func (s *Session) User(ctx context.Context) (*User, error) {
if ul := s.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, s.UserID)
}
return nil, fmt.Errorf("user lookup not available")
}

92
internal/models/user.go Normal file
View File

@@ -0,0 +1,92 @@
package models
import (
"context"
"time"
)
// User represents a registered user account.
type User struct {
Base
ID string `json:"id"`
Nick string `json:"nick"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
}
// Channels returns all channels the user is a member of.
func (u *User) Channels(ctx context.Context) ([]*Channel, error) {
rows, err := u.GetDB().QueryContext(ctx, `
SELECT c.id, c.name, c.topic, c.modes, c.created_at, c.updated_at
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id
WHERE cm.user_id = ?
ORDER BY c.name`,
u.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
channels := []*Channel{}
for rows.Next() {
c := &Channel{}
c.SetDB(u.db)
err = rows.Scan(
&c.ID, &c.Name, &c.Topic, &c.Modes,
&c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
channels = append(channels, c)
}
return channels, rows.Err()
}
// QueuedMessages returns undelivered messages for this user.
func (u *User) QueuedMessages(ctx context.Context) ([]*Message, error) {
rows, err := u.GetDB().QueryContext(ctx, `
SELECT m.id, m.ts, m.from_user_id, m.from_nick,
m.target, m.type, m.body, m.meta, m.created_at
FROM messages m
JOIN message_queue mq ON mq.message_id = m.id
WHERE mq.user_id = ?
ORDER BY mq.queued_at ASC`,
u.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
messages := []*Message{}
for rows.Next() {
msg := &Message{}
msg.SetDB(u.db)
err = rows.Scan(
&msg.ID, &msg.Timestamp, &msg.FromUserID,
&msg.FromNick, &msg.Target, &msg.Type,
&msg.Body, &msg.Meta, &msg.CreatedAt,
)
if err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, rows.Err()
}