@devchitchat/chat 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,99 @@
1
+ import { runTransaction } from '../db/transaction.js'
2
+
3
+ export class SqliteHubRepository {
4
+ constructor({ db }) {
5
+ this.db = db
6
+ }
7
+
8
+ insertHubWithOwner({ hubId, name, description, visibility, createdByUserId, now }) {
9
+ runTransaction(this.db, () => {
10
+ this.db.prepare(
11
+ `INSERT INTO hubs (hub_id, name, description, visibility, created_by_user_id, created_at)
12
+ VALUES (?, ?, ?, ?, ?, ?)`
13
+ ).run(hubId, name, description, visibility, createdByUserId, now)
14
+ this.db.prepare(
15
+ `INSERT INTO hub_members (hub_id, user_id, joined_at) VALUES (?, ?, ?)`
16
+ ).run(hubId, createdByUserId, now)
17
+ })
18
+ }
19
+
20
+ listAllHubs() {
21
+ return this.db.prepare(
22
+ `SELECT h.hub_id, h.name, h.description, h.visibility,
23
+ (SELECT COUNT(*) FROM channels c WHERE c.hub_id = h.hub_id AND c.deleted_at IS NULL) AS channel_count
24
+ FROM hubs h WHERE h.deleted_at IS NULL ORDER BY h.sort_order ASC, h.name ASC`
25
+ ).all()
26
+ }
27
+
28
+ listAccessibleHubs({ userId }) {
29
+ return this.db.prepare(
30
+ `SELECT h.hub_id, h.name, h.description, h.visibility,
31
+ (SELECT COUNT(*) FROM channels c WHERE c.hub_id = h.hub_id AND c.deleted_at IS NULL) AS channel_count
32
+ FROM hubs h WHERE h.deleted_at IS NULL
33
+ AND (h.visibility = 'public' OR EXISTS (
34
+ SELECT 1 FROM hub_members hm WHERE hm.hub_id = h.hub_id AND hm.user_id = ? AND hm.left_at IS NULL
35
+ ))
36
+ ORDER BY h.sort_order ASC, h.name ASC`
37
+ ).all(userId)
38
+ }
39
+
40
+ reorderHubs({ hubIds }) {
41
+ runTransaction(this.db, () => {
42
+ const stmt = this.db.prepare('UPDATE hubs SET sort_order = ? WHERE hub_id = ?')
43
+ hubIds.forEach((id, i) => stmt.run(i, id))
44
+ })
45
+ }
46
+
47
+ findById({ hubId }) {
48
+ return this.db.prepare('SELECT * FROM hubs WHERE hub_id = ?').get(hubId) ?? null
49
+ }
50
+
51
+ findByName({ name }) {
52
+ return this.db.prepare('SELECT * FROM hubs WHERE name = ? AND deleted_at IS NULL').get(name) ?? null
53
+ }
54
+
55
+ findMembership({ hubId, userId }) {
56
+ return this.db.prepare('SELECT * FROM hub_members WHERE hub_id = ? AND user_id = ?').get(hubId, userId) ?? null
57
+ }
58
+
59
+ upsertMembership({ hubId, userId, now }) {
60
+ this.db.prepare(
61
+ `INSERT INTO hub_members (hub_id, user_id, joined_at) VALUES (?, ?, ?)
62
+ ON CONFLICT(hub_id, user_id) DO UPDATE SET left_at = NULL`
63
+ ).run(hubId, userId, now)
64
+ }
65
+
66
+ setMemberLeft({ hubId, userId, now }) {
67
+ this.db.prepare('UPDATE hub_members SET left_at = ? WHERE hub_id = ? AND user_id = ?').run(now, hubId, userId)
68
+ }
69
+
70
+ listMembers({ hubId }) {
71
+ return this.db.prepare(
72
+ `SELECT u.user_id, u.handle, u.display_name, hm.joined_at
73
+ FROM hub_members hm JOIN users u ON hm.user_id = u.user_id
74
+ WHERE hm.hub_id = ? AND hm.left_at IS NULL
75
+ ORDER BY hm.joined_at ASC`
76
+ ).all(hubId)
77
+ }
78
+
79
+ patchHub({ hubId, name, description, visibility }) {
80
+ const updates = []
81
+ const params = []
82
+ if (name !== undefined) { updates.push('name = ?'); params.push(name) }
83
+ if (description !== undefined) { updates.push('description = ?'); params.push(description) }
84
+ if (visibility !== undefined) { updates.push('visibility = ?'); params.push(visibility) }
85
+ params.push(hubId)
86
+ this.db.prepare(`UPDATE hubs SET ${updates.join(', ')} WHERE hub_id = ?`).run(...params)
87
+ }
88
+
89
+ listActiveChannelIds({ hubId }) {
90
+ return this.db.prepare('SELECT channel_id FROM channels WHERE hub_id = ? AND deleted_at IS NULL').all(hubId).map(r => r.channel_id)
91
+ }
92
+
93
+ softDeleteHub({ hubId, now }) {
94
+ runTransaction(this.db, () => {
95
+ this.db.prepare('UPDATE hubs SET deleted_at = ? WHERE hub_id = ?').run(now, hubId)
96
+ this.db.prepare('UPDATE channels SET deleted_at = ? WHERE hub_id = ? AND deleted_at IS NULL').run(now, hubId)
97
+ })
98
+ }
99
+ }
@@ -0,0 +1,90 @@
1
+ import { runTransaction } from '../db/transaction.js'
2
+
3
+ const MSG_COLS = `m.msg_id, m.seq, m.user_id, u.display_name AS user_display_name, m.ts, m.text, m.edited_at, m.attachments_json`
4
+
5
+ export class SqliteMessageRepository {
6
+ constructor({ db }) {
7
+ this.db = db
8
+ }
9
+
10
+ /**
11
+ * Atomically allocates the next seq, inserts the message and an audit event.
12
+ * Returns { seq }.
13
+ */
14
+ insertMessage({ msgId, channelId, userId, now, text, clientMsgId, priority = 'normal', attachmentsJson = null }) {
15
+ return runTransaction(this.db, () => {
16
+ const row = this.db.prepare('SELECT MAX(seq) AS max_seq FROM messages WHERE channel_id = ?').get(channelId)
17
+ const seq = (row?.max_seq || 0) + 1
18
+
19
+ this.db.prepare(
20
+ `INSERT INTO messages (msg_id, channel_id, seq, user_id, ts, text, client_msg_id, priority, attachments_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
21
+ ).run(msgId, channelId, seq, userId, now, text, clientMsgId, priority, attachmentsJson)
22
+
23
+ this.db.prepare(
24
+ `INSERT INTO events (ts, actor_user_id, scope_kind, scope_id, type, body_json)
25
+ VALUES (?, ?, 'channel', ?, 'msg.send', ?)`
26
+ ).run(now, userId, channelId, JSON.stringify({ msg_id: msgId, seq }))
27
+
28
+ return { seq }
29
+ })
30
+ }
31
+
32
+ listMessages({ channelId, afterSeq, limit }) {
33
+ const rows = this.db.prepare(
34
+ `SELECT ${MSG_COLS}
35
+ FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
36
+ WHERE m.channel_id = ? AND m.seq > ? AND m.deleted_at IS NULL ORDER BY m.seq ASC LIMIT ?`
37
+ ).all(channelId, afterSeq, limit)
38
+ return rows.map(r => ({
39
+ ...r,
40
+ attachments: r.attachments_json ? JSON.parse(r.attachments_json) : [],
41
+ attachments_json: undefined,
42
+ }))
43
+ }
44
+
45
+ listLatestMessages({ channelId, limit }) {
46
+ const rows = this.db.prepare(
47
+ `SELECT ${MSG_COLS}
48
+ FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
49
+ WHERE m.channel_id = ? AND m.deleted_at IS NULL
50
+ ORDER BY m.seq DESC LIMIT ?`
51
+ ).all(channelId, limit)
52
+ return rows.reverse().map(r => ({
53
+ ...r,
54
+ attachments: r.attachments_json ? JSON.parse(r.attachments_json) : [],
55
+ attachments_json: undefined,
56
+ }))
57
+ }
58
+
59
+ getById(msgId) {
60
+ return this.db.prepare(
61
+ `SELECT msg_id, channel_id, seq, user_id, ts, text, deleted_at FROM messages WHERE msg_id = ?`
62
+ ).get(msgId) ?? null
63
+ }
64
+
65
+ updateMessage({ msgId, text, editedAt }) {
66
+ this.db.prepare(
67
+ `UPDATE messages SET text = ?, edited_at = ? WHERE msg_id = ?`
68
+ ).run(text, editedAt, msgId)
69
+ }
70
+
71
+ deleteMessage({ msgId, deletedAt }) {
72
+ this.db.prepare(
73
+ `UPDATE messages SET deleted_at = ? WHERE msg_id = ?`
74
+ ).run(deletedAt, msgId)
75
+ }
76
+
77
+ listMessagesBefore({ channelId, beforeSeq, limit }) {
78
+ const rows = this.db.prepare(
79
+ `SELECT ${MSG_COLS}
80
+ FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
81
+ WHERE m.channel_id = ? AND m.seq < ? AND m.deleted_at IS NULL
82
+ ORDER BY m.seq DESC LIMIT ?`
83
+ ).all(channelId, beforeSeq, limit)
84
+ return rows.reverse().map(r => ({
85
+ ...r,
86
+ attachments: r.attachments_json ? JSON.parse(r.attachments_json) : [],
87
+ attachments_json: undefined,
88
+ }))
89
+ }
90
+ }
@@ -0,0 +1,39 @@
1
+ import { newId } from '../util/ids.js'
2
+
3
+ export class SqlitePushRepository {
4
+ constructor({ db }) {
5
+ this.db = db
6
+ }
7
+
8
+ /** Upsert a push subscription for a user. */
9
+ upsertSubscription({ userId, endpoint, p256dh, auth }) {
10
+ const existing = this.db.prepare('SELECT sub_id FROM push_subscriptions WHERE endpoint = ?').get(endpoint)
11
+ const now = Date.now()
12
+ if (existing) {
13
+ this.db.prepare(
14
+ 'UPDATE push_subscriptions SET user_id = ?, p256dh = ?, auth = ?, last_used_at = ? WHERE endpoint = ?'
15
+ ).run(userId, p256dh, auth, now, endpoint)
16
+ } else {
17
+ this.db.prepare(
18
+ 'INSERT INTO push_subscriptions (sub_id, user_id, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)'
19
+ ).run(newId('ps'), userId, endpoint, p256dh, auth, now)
20
+ }
21
+ }
22
+
23
+ /** Return all subscriptions for a user. */
24
+ getSubscriptionsForUser(userId) {
25
+ return this.db.prepare(
26
+ 'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?'
27
+ ).all(userId)
28
+ }
29
+
30
+ /** Remove a subscription by endpoint (called when push returns 410/404). */
31
+ removeSubscription(endpoint) {
32
+ this.db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint)
33
+ }
34
+
35
+ /** Remove all subscriptions for a user (e.g. on sign-out). */
36
+ removeAllForUser(userId) {
37
+ this.db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId)
38
+ }
39
+ }
@@ -0,0 +1,50 @@
1
+ export class SqliteReactionRepository {
2
+ constructor({ db }) {
3
+ this.db = db
4
+ }
5
+
6
+ /** Returns void. UNIQUE constraint is the idempotency guard. */
7
+ upsertReaction({ reactionId, msgId, channelId, userId, emoji, ts }) {
8
+ this.db.prepare(
9
+ `INSERT INTO message_reactions (reaction_id, msg_id, channel_id, user_id, emoji, ts)
10
+ VALUES (?, ?, ?, ?, ?, ?)
11
+ ON CONFLICT (msg_id, user_id, emoji) DO NOTHING`
12
+ ).run(reactionId, msgId, channelId, userId, emoji, ts)
13
+ }
14
+
15
+ /** Returns void. No-op if the row doesn't exist. */
16
+ removeReaction({ msgId, userId, emoji }) {
17
+ this.db.prepare(
18
+ `DELETE FROM message_reactions WHERE msg_id = ? AND user_id = ? AND emoji = ?`
19
+ ).run(msgId, userId, emoji)
20
+ }
21
+
22
+ /**
23
+ * Returns Map<msgId, [{emoji, count, reacted}]>
24
+ * requestingUserId used to compute the `reacted` flag.
25
+ */
26
+ listReactionsForMsgs({ msgIds, requestingUserId }) {
27
+ if (!msgIds || msgIds.length === 0) return new Map()
28
+
29
+ const placeholders = msgIds.map(() => '?').join(', ')
30
+ const rows = this.db.prepare(
31
+ `SELECT msg_id, emoji, COUNT(*) AS count,
32
+ MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted
33
+ FROM message_reactions
34
+ WHERE msg_id IN (${placeholders})
35
+ GROUP BY msg_id, emoji
36
+ ORDER BY MIN(ts) ASC`
37
+ ).all(requestingUserId, ...msgIds)
38
+
39
+ const map = new Map()
40
+ for (const row of rows) {
41
+ if (!map.has(row.msg_id)) map.set(row.msg_id, [])
42
+ map.get(row.msg_id).push({
43
+ emoji: row.emoji,
44
+ count: row.count,
45
+ reacted: row.reacted === 1,
46
+ })
47
+ }
48
+ return map
49
+ }
50
+ }
@@ -0,0 +1,42 @@
1
+ export class SqliteSearchRepository {
2
+ constructor({ db }) {
3
+ this.db = db
4
+ }
5
+
6
+ isFtsEnabled() {
7
+ try {
8
+ const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'fts_messages'").get()
9
+ return !!row?.sql && row.sql.toUpperCase().includes('VIRTUAL TABLE')
10
+ } catch {
11
+ return false
12
+ }
13
+ }
14
+
15
+ indexMessage({ msg_id, channel_id, seq, user_id, ts, text }) {
16
+ this.db.prepare(
17
+ `INSERT INTO fts_messages (text, channel_id, msg_id, seq, user_id, ts) VALUES (?, ?, ?, ?, ?, ?)`
18
+ ).run(text, channel_id, msg_id, seq, user_id, ts)
19
+ }
20
+
21
+ removeMessage({ msgId }) {
22
+ this.db.prepare(`DELETE FROM fts_messages WHERE msg_id = ?`).run(msgId)
23
+ }
24
+
25
+ searchFts({ channelId, query, limit }) {
26
+ return this.db.prepare(
27
+ `SELECT m.channel_id, m.msg_id, m.seq, m.user_id, m.ts,
28
+ snippet(fts_messages, 0, '<mark>', '</mark>', '…', 10) AS snippet
29
+ FROM fts_messages
30
+ JOIN messages m ON m.msg_id = fts_messages.msg_id
31
+ WHERE fts_messages MATCH ? AND m.channel_id = ?
32
+ ORDER BY bm25(fts_messages) LIMIT ?`
33
+ ).all(query, channelId, limit)
34
+ }
35
+
36
+ searchLike({ channelId, query, limit }) {
37
+ return this.db.prepare(
38
+ `SELECT channel_id, msg_id, seq, user_id, ts, text AS snippet
39
+ FROM fts_messages WHERE channel_id = ? AND text LIKE ? LIMIT ?`
40
+ ).all(channelId, `%${query}%`, limit)
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ export class SqliteSignalingRepository {
2
+ constructor({ db }) {
3
+ this._findActiveByChannel = db.prepare(`
4
+ SELECT call_id, channel_id, created_by_user_id, topology, started_at
5
+ FROM calls WHERE channel_id = ? AND ended_at IS NULL LIMIT 1
6
+ `)
7
+ this._findActiveCalls = db.prepare(`
8
+ SELECT call_id, channel_id, created_by_user_id, topology, started_at
9
+ FROM calls WHERE ended_at IS NULL
10
+ `)
11
+ this._insertCall = db.prepare(`
12
+ INSERT INTO calls (call_id, channel_id, created_by_user_id, topology, started_at)
13
+ VALUES (?, ?, ?, ?, ?)
14
+ `)
15
+ this._endCall = db.prepare(`
16
+ UPDATE calls SET ended_at = ? WHERE call_id = ?
17
+ `)
18
+ this._insertParticipant = db.prepare(`
19
+ INSERT INTO call_participants (call_id, user_id, peer_id, joined_at)
20
+ VALUES (?, ?, ?, ?)
21
+ `)
22
+ this._leaveParticipant = db.prepare(`
23
+ UPDATE call_participants SET left_at = ? WHERE call_id = ? AND peer_id = ?
24
+ `)
25
+ }
26
+
27
+ findActiveByChannel({ channelId }) {
28
+ return this._findActiveByChannel.get(channelId) ?? null
29
+ }
30
+
31
+ findActiveCalls() {
32
+ return this._findActiveCalls.all()
33
+ }
34
+
35
+ insertCall({ callId, channelId, createdByUserId, topology, startedAt }) {
36
+ this._insertCall.run(callId, channelId, createdByUserId, topology, startedAt)
37
+ }
38
+
39
+ endCall({ callId, endedAt }) {
40
+ this._endCall.run(endedAt, callId)
41
+ }
42
+
43
+ insertParticipant({ callId, userId, peerId, joinedAt }) {
44
+ this._insertParticipant.run(callId, userId, peerId, joinedAt)
45
+ }
46
+
47
+ leaveParticipant({ callId, peerId, leftAt }) {
48
+ this._leaveParticipant.run(leftAt, callId, peerId)
49
+ }
50
+ }
@@ -0,0 +1,34 @@
1
+ export class SqliteUploadRepository {
2
+ constructor({ db }) {
3
+ this.db = db
4
+ }
5
+
6
+ insert({ uploadId, uploaderUserId, channelId, originalName, storedName, mimeType, sizeBytes, now }) {
7
+ this.db.prepare(
8
+ `INSERT INTO uploads (upload_id, uploader_user_id, channel_id, original_name, stored_name, mime_type, size_bytes, created_at)
9
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
10
+ ).run(uploadId, uploaderUserId, channelId, originalName, storedName, mimeType, sizeBytes, now)
11
+ }
12
+
13
+ findById({ uploadId }) {
14
+ return this.db.prepare(
15
+ `SELECT * FROM uploads WHERE upload_id = ?`
16
+ ).get(uploadId) ?? null
17
+ }
18
+
19
+ linkToMessage({ uploadId, msgId }) {
20
+ this.db.prepare(
21
+ `UPDATE uploads SET msg_id = ? WHERE upload_id = ?`
22
+ ).run(msgId, uploadId)
23
+ }
24
+
25
+ findOrphansOlderThan({ thresholdTs }) {
26
+ return this.db.prepare(
27
+ `SELECT * FROM uploads WHERE msg_id IS NULL AND created_at < ?`
28
+ ).all(thresholdTs)
29
+ }
30
+
31
+ delete({ uploadId }) {
32
+ this.db.prepare(`DELETE FROM uploads WHERE upload_id = ?`).run(uploadId)
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ export class SqliteUserSettingsRepository {
2
+ constructor({ db }) {
3
+ this.db = db
4
+ }
5
+
6
+ findByUserId({ userId }) {
7
+ return this.db.prepare(
8
+ `SELECT settings_json, updated_at FROM user_settings WHERE user_id = ?`
9
+ ).get(userId) ?? null
10
+ }
11
+
12
+ // Upsert only when the incoming updated_at is >= the stored value (last-write-wins)
13
+ upsert({ userId, settingsJson, updatedAt }) {
14
+ this.db.prepare(`
15
+ INSERT INTO user_settings (user_id, settings_json, updated_at)
16
+ VALUES (?, ?, ?)
17
+ ON CONFLICT (user_id) DO UPDATE
18
+ SET settings_json = excluded.settings_json,
19
+ updated_at = excluded.updated_at
20
+ WHERE excluded.updated_at >= user_settings.updated_at
21
+ `).run(userId, settingsJson, updatedAt)
22
+ }
23
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * adminAuth — shared guard for admin HTTP pages.
3
+ *
4
+ * Usage in a page handler:
5
+ * import { requireAdminSession } from '../../src/adminAuth.js'
6
+ * const session = requireAdminSession(req)
7
+ * if (session instanceof Response) return session // redirect to login
8
+ */
9
+ import { sessionFromRequest, logger } from './context.js'
10
+
11
+ /**
12
+ * Returns the validated admin session, or a redirect Response if the user
13
+ * is not authenticated or lacks the admin role.
14
+ */
15
+ export function requireAdminSession(req) {
16
+ const session = sessionFromRequest(req)
17
+ if (!session) {
18
+ return Response.redirect(new URL('/login', req.url), 302)
19
+ }
20
+ if (!session.user.roles.includes('admin')) {
21
+ logger?.warn('admin.access_denied', { userId: session.user.user_id, url: req.url })
22
+ return new Response('Forbidden', { status: 403 })
23
+ }
24
+ return session
25
+ }
package/src/config.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * config.js — shared server-side configuration.
3
+ *
4
+ * BASE_PATH: the URL subpath the app is mounted at (e.g. "/chat").
5
+ * Defaults to "" (root). Trailing slash is stripped automatically.
6
+ *
7
+ * p(path) — path helper: prepends BASE_PATH to any absolute path string.
8
+ * Use it everywhere a literal server-side path is constructed.
9
+ */
10
+ export const BASE_PATH = (process.env.BASE_PATH ?? '').replace(/\/$/, '')
11
+ export const p = (path) => `${BASE_PATH}${path}`
package/src/context.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Service context singleton.
3
+ *
4
+ * index97 imports page handlers dynamically. ES modules are singletons,
5
+ * so any handler that imports from this file gets the same live references
6
+ * that were wired up in index.js at startup.
7
+ *
8
+ * Usage in a page handler:
9
+ * import { auth, channelService, messageService } from '../../src/context.js'
10
+ */
11
+
12
+ export let auth = null
13
+ export let hubService = null
14
+ export let channelService = null
15
+ export let messageService = null
16
+ export let deliveryService = null
17
+ export let searchService = null
18
+ export let presenceService = null
19
+ export let signalingService = null
20
+ export let userSettingsService = null
21
+ export let botService = null
22
+ export let uploadService = null
23
+ export let reactionService = null
24
+ export let logger = null
25
+
26
+ export function init(services) {
27
+ auth = services.auth
28
+ hubService = services.hubService
29
+ channelService = services.channelService
30
+ messageService = services.messageService
31
+ deliveryService = services.deliveryService
32
+ searchService = services.searchService
33
+ presenceService = services.presenceService
34
+ signalingService = services.signalingService
35
+ userSettingsService = services.userSettingsService
36
+ botService = services.botService
37
+ uploadService = services.uploadService
38
+ reactionService = services.reactionService
39
+ logger = services.logger
40
+ }
41
+
42
+ /**
43
+ * Parse session token from a request's cookie header.
44
+ * Returns { session, user } or null if unauthenticated.
45
+ */
46
+ export function sessionFromRequest(req) {
47
+ const cookie = req.headers.get('cookie') || ''
48
+ const match = cookie.match(/(?:^|;\s*)session=([^;]+)/)
49
+ if (!match) return null
50
+ return auth.validateSession(decodeURIComponent(match[1]))
51
+ }
52
+
53
+ /**
54
+ * Authenticate a bot via an Authorization: Bearer <token> header.
55
+ * Returns { user_id, handle, displayName, roles } or null if not authenticated.
56
+ */
57
+ export async function botUserFromRequest(req) {
58
+ const authHeader = req.headers.get('authorization') ?? ''
59
+ const match = authHeader.match(/^Bearer\s+(.+)$/i)
60
+ if (!match) return null
61
+ try {
62
+ const bot = await botService.authenticateToken(match[1])
63
+ if (!bot) return null
64
+ // Normalize to the same shape as session.user so callers use user_id consistently.
65
+ return { user_id: bot.userId, handle: bot.handle, displayName: bot.displayName, roles: bot.roles }
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Build a Set-Cookie header string for a session token.
73
+ */
74
+ export function sessionCookie(token, { maxAgeSec = 30 * 24 * 60 * 60, clear = false } = {}) {
75
+ if (clear) return 'session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
76
+ return `session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}`
77
+ }
package/src/core/dm.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * buildDmChannelName — deterministic canonical key for a DM channel.
3
+ *
4
+ * Sorting the two user IDs alphabetically ensures the same name regardless
5
+ * of who initiates: dm:u_abc:u_xyz == dm:u_xyz:u_abc.
6
+ */
7
+ export function buildDmChannelName(userIdA, userIdB) {
8
+ const [a, b] = [userIdA, userIdB].sort()
9
+ return `dm:${a}:${b}`
10
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * parseMentions — extract @handle mentions from a message and resolve them to users.
3
+ *
4
+ * @param {string} text — raw message text
5
+ * @param {Array<{ user_id: string, handle: string }>} members — channel members to match against
6
+ * @returns {Array<{ user_id: string, handle: string }>} — deduplicated matched members
7
+ */
8
+ export function parseMentions(text, members = []) {
9
+ if (!text || members.length === 0) return []
10
+ const byHandle = new Map(
11
+ members
12
+ .filter(m => m.handle)
13
+ .map(m => [m.handle.toLowerCase(), m])
14
+ )
15
+ const seen = new Set()
16
+ const results = []
17
+ const pattern = /@([a-zA-Z0-9_.-]+)/g
18
+ let match
19
+ while ((match = pattern.exec(text)) !== null) {
20
+ const handle = match[1].toLowerCase()
21
+ if (byHandle.has(handle) && !seen.has(handle)) {
22
+ seen.add(handle)
23
+ results.push(byHandle.get(handle))
24
+ }
25
+ }
26
+ return results
27
+ }
@@ -0,0 +1,21 @@
1
+ import { ServiceError } from '../util/errors.js'
2
+
3
+ export function validateEditPermission(requestingUserId, authorUserId) {
4
+ if (requestingUserId !== authorUserId)
5
+ throw new ServiceError('FORBIDDEN', 'Only the author can edit this message')
6
+ }
7
+
8
+ export function validateEditText(text) {
9
+ const trimmed = (text ?? '').trim()
10
+ if (!trimmed) throw new ServiceError('BAD_REQUEST', 'Message text cannot be empty')
11
+ return trimmed
12
+ }
13
+
14
+ export function assertMessageEditable(deletedAt) {
15
+ if (deletedAt != null) throw new ServiceError('BAD_REQUEST', 'Cannot edit a deleted message')
16
+ }
17
+
18
+ export function validateDeletePermission(requestingUserId, authorUserId) {
19
+ if (requestingUserId !== authorUserId)
20
+ throw new ServiceError('FORBIDDEN', 'Only the author can delete this message')
21
+ }
@@ -0,0 +1,6 @@
1
+ import { ServiceError } from '../util/errors.js'
2
+
3
+ export function validateEmoji(emoji) {
4
+ if (typeof emoji !== 'string') throw new ServiceError('BAD_REQUEST', 'emoji must be a string')
5
+ if (!emoji || [...emoji].length > 4) throw new ServiceError('BAD_REQUEST', 'Invalid emoji')
6
+ }
@@ -0,0 +1,5 @@
1
+ export const ROLES = ['admin', 'user', 'guest', 'bot']
2
+
3
+ export const isAdmin = (roles = []) => roles.includes('admin')
4
+ export const isGuest = (roles = []) => roles.includes('guest')
5
+ export const isBot = (roles = []) => roles.includes('bot')