@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.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- 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
|
+
}
|
package/src/adminAuth.js
ADDED
|
@@ -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
|
+
}
|