@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,45 @@
|
|
|
1
|
+
export class InMemoryReactionRepository {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._reactions = [] // { reaction_id, msg_id, channel_id, user_id, emoji, ts }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
upsertReaction({ reactionId, msgId, channelId, userId, emoji, ts }) {
|
|
7
|
+
const exists = this._reactions.find(r => r.msg_id === msgId && r.user_id === userId && r.emoji === emoji)
|
|
8
|
+
if (!exists) {
|
|
9
|
+
this._reactions.push({ reaction_id: reactionId, msg_id: msgId, channel_id: channelId, user_id: userId, emoji, ts })
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
removeReaction({ msgId, userId, emoji }) {
|
|
14
|
+
this._reactions = this._reactions.filter(r => !(r.msg_id === msgId && r.user_id === userId && r.emoji === emoji))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
listReactionsForMsgs({ msgIds, requestingUserId }) {
|
|
18
|
+
const map = new Map()
|
|
19
|
+
if (!msgIds || msgIds.length === 0) return map
|
|
20
|
+
|
|
21
|
+
const msgIdSet = new Set(msgIds)
|
|
22
|
+
const relevant = this._reactions.filter(r => msgIdSet.has(r.msg_id))
|
|
23
|
+
|
|
24
|
+
// Group by msg_id + emoji, preserving insertion order
|
|
25
|
+
const grouped = new Map() // key: `${msgId}|${emoji}` → { msg_id, emoji, count, minTs, reacted }
|
|
26
|
+
for (const r of relevant) {
|
|
27
|
+
const key = `${r.msg_id}|${r.emoji}`
|
|
28
|
+
if (!grouped.has(key)) {
|
|
29
|
+
grouped.set(key, { msg_id: r.msg_id, emoji: r.emoji, count: 0, minTs: r.ts, reacted: false })
|
|
30
|
+
}
|
|
31
|
+
const entry = grouped.get(key)
|
|
32
|
+
entry.count++
|
|
33
|
+
if (r.ts < entry.minTs) entry.minTs = r.ts
|
|
34
|
+
if (r.user_id === requestingUserId) entry.reacted = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Sort by minTs and build the output map
|
|
38
|
+
const sorted = Array.from(grouped.values()).sort((a, b) => a.minTs - b.minTs)
|
|
39
|
+
for (const entry of sorted) {
|
|
40
|
+
if (!map.has(entry.msg_id)) map.set(entry.msg_id, [])
|
|
41
|
+
map.get(entry.msg_id).push({ emoji: entry.emoji, count: entry.count, reacted: entry.reacted })
|
|
42
|
+
}
|
|
43
|
+
return map
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class InMemorySearchRepository {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._index = [] // { msg_id, channel_id, seq, user_id, ts, text }
|
|
4
|
+
this._ftsEnabled = true
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
isFtsEnabled() {
|
|
8
|
+
return this._ftsEnabled
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
indexMessage({ msg_id, channel_id, seq, user_id, ts, text }) {
|
|
12
|
+
const existing = this._index.findIndex(r => r.msg_id === msg_id)
|
|
13
|
+
if (existing >= 0) this._index.splice(existing, 1)
|
|
14
|
+
this._index.push({ msg_id, channel_id, seq, user_id, ts, text })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
removeMessage({ msgId }) {
|
|
18
|
+
const i = this._index.findIndex(r => r.msg_id === msgId)
|
|
19
|
+
if (i >= 0) this._index.splice(i, 1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
searchFts({ channelId, query, limit }) {
|
|
23
|
+
const q = query.toLowerCase()
|
|
24
|
+
return this._index
|
|
25
|
+
.filter(r => r.channel_id === channelId && r.text.toLowerCase().includes(q))
|
|
26
|
+
.slice(0, limit)
|
|
27
|
+
.map(r => ({ ...r, snippet: r.text }))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
searchLike({ channelId, query, limit }) {
|
|
31
|
+
const q = query.toLowerCase()
|
|
32
|
+
return this._index
|
|
33
|
+
.filter(r => r.channel_id === channelId && r.text.toLowerCase().includes(q))
|
|
34
|
+
.slice(0, limit)
|
|
35
|
+
.map(r => ({ ...r, snippet: r.text }))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export class InMemorySignalingRepository {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._calls = new Map() // callId → { call_id, channel_id, created_by_user_id, topology, started_at, ended_at }
|
|
4
|
+
this._participants = [] // { call_id, user_id, peer_id, joined_at, left_at }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
findActiveByChannel({ channelId }) {
|
|
8
|
+
for (const call of this._calls.values()) {
|
|
9
|
+
if (call.channel_id === channelId && call.ended_at == null) return call
|
|
10
|
+
}
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
findActiveCalls() {
|
|
15
|
+
return [...this._calls.values()].filter(c => c.ended_at == null)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
insertCall({ callId, channelId, createdByUserId, topology, startedAt }) {
|
|
19
|
+
this._calls.set(callId, { call_id: callId, channel_id: channelId, created_by_user_id: createdByUserId, topology, started_at: startedAt, ended_at: null })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
endCall({ callId, endedAt }) {
|
|
23
|
+
const call = this._calls.get(callId)
|
|
24
|
+
if (call) call.ended_at = endedAt
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
insertParticipant({ callId, userId, peerId, joinedAt }) {
|
|
28
|
+
this._participants.push({ call_id: callId, user_id: userId, peer_id: peerId, joined_at: joinedAt, left_at: null })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
leaveParticipant({ callId, peerId, leftAt }) {
|
|
32
|
+
const p = this._participants.find(p => p.call_id === callId && p.peer_id === peerId && p.left_at == null)
|
|
33
|
+
if (p) p.left_at = leftAt
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class InMemoryUploadRepository {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._uploads = new Map()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
insert({ uploadId, uploaderUserId, channelId, originalName, storedName, mimeType, sizeBytes, now }) {
|
|
7
|
+
this._uploads.set(uploadId, {
|
|
8
|
+
upload_id: uploadId,
|
|
9
|
+
uploader_user_id: uploaderUserId,
|
|
10
|
+
channel_id: channelId,
|
|
11
|
+
original_name: originalName,
|
|
12
|
+
stored_name: storedName,
|
|
13
|
+
mime_type: mimeType,
|
|
14
|
+
size_bytes: sizeBytes,
|
|
15
|
+
created_at: now,
|
|
16
|
+
msg_id: null,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
findById({ uploadId }) {
|
|
21
|
+
return this._uploads.get(uploadId) ?? null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
linkToMessage({ uploadId, msgId }) {
|
|
25
|
+
const row = this._uploads.get(uploadId)
|
|
26
|
+
if (row) row.msg_id = msgId
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
findOrphansOlderThan({ thresholdTs }) {
|
|
30
|
+
return [...this._uploads.values()].filter(r => r.msg_id === null && r.created_at < thresholdTs)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
delete({ uploadId }) {
|
|
34
|
+
this._uploads.delete(uploadId)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class InMemoryUserSettingsRepository {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._store = new Map() // userId → { settings_json, updated_at }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
findByUserId({ userId }) {
|
|
7
|
+
return this._store.get(userId) ?? null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
upsert({ userId, settingsJson, updatedAt }) {
|
|
11
|
+
const existing = this._store.get(userId)
|
|
12
|
+
if (existing && existing.updated_at > updatedAt) return
|
|
13
|
+
this._store.set(userId, { settings_json: settingsJson, updated_at: updatedAt })
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { ServiceError } from '../util/errors'
|
|
4
|
+
const DEFAULT_UPLOAD_DIR = 'data/uploads'
|
|
5
|
+
|
|
6
|
+
export class LocalFileStore {
|
|
7
|
+
constructor({ uploadDir = process.env.UPLOAD_DIR ?? DEFAULT_UPLOAD_DIR } = {}) {
|
|
8
|
+
this.uploadDir = uploadDir
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
#dir(uploadId) {
|
|
12
|
+
return path.join(this.uploadDir, uploadId)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async write({ uploadId, storedName, stream: data }) {
|
|
16
|
+
const dir = this.#dir(uploadId)
|
|
17
|
+
await mkdir(dir, { recursive: true })
|
|
18
|
+
const filePath = path.join(dir, storedName)
|
|
19
|
+
await Bun.write(filePath, data)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async read({ uploadId, storedName }) {
|
|
23
|
+
const filePath = path.join(this.#dir(uploadId), storedName)
|
|
24
|
+
const file = Bun.file(filePath)
|
|
25
|
+
const exists = await file.exists()
|
|
26
|
+
if (!exists) {
|
|
27
|
+
throw new ServiceError('NOT_FOUND', `File not found: ${filePath}`)
|
|
28
|
+
}
|
|
29
|
+
return file.stream()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async delete({ uploadId, storedName }) {
|
|
33
|
+
const filePath = path.join(this.#dir(uploadId), storedName)
|
|
34
|
+
try {
|
|
35
|
+
await Bun.file(filePath).unlink()
|
|
36
|
+
} catch {
|
|
37
|
+
// already gone — ignore
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { runTransaction } from '../db/transaction.js'
|
|
2
|
+
|
|
3
|
+
export class SqliteAuthRepository {
|
|
4
|
+
constructor({ db }) {
|
|
5
|
+
this.db = db
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ── Invites ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
insertInvite({ inviteId, tokenHash, createdByUserId, now, expiresAt, maxUses, note, initialRolesJson }) {
|
|
11
|
+
this.db.prepare(
|
|
12
|
+
`INSERT INTO invites (invite_id, token_hash, created_by_user_id, created_at, expires_at, max_uses, uses, note, initial_roles_json)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
14
|
+
).run(inviteId, tokenHash, createdByUserId, now, expiresAt, maxUses, note, initialRolesJson)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
findInviteByTokenHash({ tokenHash }) {
|
|
18
|
+
return this.db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(tokenHash) ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
listInvites() {
|
|
22
|
+
return this.db.prepare(`
|
|
23
|
+
SELECT i.*, u.handle AS created_by_handle
|
|
24
|
+
FROM invites i
|
|
25
|
+
LEFT JOIN users u ON u.user_id = i.created_by_user_id
|
|
26
|
+
ORDER BY i.created_at DESC
|
|
27
|
+
`).all()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
deleteInvite({ inviteId }) {
|
|
31
|
+
this.db.prepare('DELETE FROM invites WHERE invite_id = ?').run(inviteId)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Atomically: increment invite uses + insert user + insert session */
|
|
35
|
+
registerUser({ inviteId, userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
|
|
36
|
+
runTransaction(this.db, () => {
|
|
37
|
+
this.db.prepare(
|
|
38
|
+
`INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
40
|
+
).run(userId, handle, displayName, rolesJson, passwordHash, now)
|
|
41
|
+
this.db.prepare(
|
|
42
|
+
`UPDATE invites SET uses = uses + 1, redeemed_by_user_id = ? WHERE invite_id = ?`
|
|
43
|
+
).run(userId, inviteId)
|
|
44
|
+
this.db.prepare(
|
|
45
|
+
`INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
47
|
+
).run(sessionId, userId, sessionTokenHash, now, sessionExpiresAt, now)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Atomically: insert user + insert session (bootstrap — no invite to redeem) */
|
|
52
|
+
registerBootstrapUser({ userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
|
|
53
|
+
runTransaction(this.db, () => {
|
|
54
|
+
this.db.prepare(
|
|
55
|
+
`INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
57
|
+
).run(userId, handle, displayName, rolesJson, passwordHash, now)
|
|
58
|
+
this.db.prepare(
|
|
59
|
+
`INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
61
|
+
).run(sessionId, userId, sessionTokenHash, now, sessionExpiresAt, now)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Users ──────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
findUserByHandle({ handle }) {
|
|
68
|
+
return this.db.prepare(
|
|
69
|
+
'SELECT user_id, handle, display_name, roles_json, password_hash FROM users WHERE handle = ?'
|
|
70
|
+
).get(handle) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
findUserById({ userId }) {
|
|
74
|
+
return this.db.prepare(
|
|
75
|
+
'SELECT user_id, handle, display_name, roles_json FROM users WHERE user_id = ?'
|
|
76
|
+
).get(userId) ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
listUsers() {
|
|
80
|
+
return this.db.prepare(
|
|
81
|
+
`SELECT user_id, handle, display_name, roles_json, created_at FROM users ORDER BY created_at ASC`
|
|
82
|
+
).all()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateUserRoles({ userId, rolesJson }) {
|
|
86
|
+
this.db.prepare('UPDATE users SET roles_json = ? WHERE user_id = ?').run(rolesJson, userId)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
updateUserPassword({ userId, passwordHash }) {
|
|
90
|
+
this.db.prepare('UPDATE users SET password_hash = ? WHERE user_id = ?').run(passwordHash, userId)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
updateUserDisplayName({ userId, displayName }) {
|
|
94
|
+
this.db.prepare('UPDATE users SET display_name = ? WHERE user_id = ?').run(displayName, userId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getUserCount() {
|
|
98
|
+
return this.db.prepare('SELECT COUNT(*) AS count FROM users').get()?.count ?? 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isHandleTaken({ handle }) {
|
|
102
|
+
return !!this.db.prepare('SELECT 1 FROM users WHERE handle = ?').get(handle)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Sessions ───────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
insertSession({ sessionId, userId, tokenHash, now, expiresAt }) {
|
|
108
|
+
this.db.prepare(
|
|
109
|
+
`INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
111
|
+
).run(sessionId, userId, tokenHash, now, expiresAt, now)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
findSessionWithUser({ tokenHash }) {
|
|
115
|
+
return this.db.prepare(
|
|
116
|
+
`SELECT s.session_id, s.user_id, s.expires_at, s.revoked_at, u.handle, u.display_name, u.roles_json
|
|
117
|
+
FROM sessions s JOIN users u ON u.user_id = s.user_id
|
|
118
|
+
WHERE s.token_hash = ?`
|
|
119
|
+
).get(tokenHash) ?? null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
touchSession({ sessionId, now }) {
|
|
123
|
+
this.db.prepare('UPDATE sessions SET last_seen_at = ? WHERE session_id = ?').run(now, sessionId)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
revokeSession({ sessionId, now }) {
|
|
127
|
+
this.db.prepare('UPDATE sessions SET revoked_at = ? WHERE session_id = ?').run(now, sessionId)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
revokeAllUserSessions({ userId, now }) {
|
|
131
|
+
this.db.prepare(
|
|
132
|
+
'UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL'
|
|
133
|
+
).run(now, userId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Bot tokens ─────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
insertBotUser({ userId, handle, displayName, now }) {
|
|
139
|
+
this.db.prepare(
|
|
140
|
+
`INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
|
|
141
|
+
VALUES (?, ?, ?, '["bot"]', NULL, ?)`
|
|
142
|
+
).run(userId, handle, displayName, now)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
insertBotToken({ tokenId, userId, tokenHash, label, now, expiresAt = null }) {
|
|
146
|
+
this.db.prepare(
|
|
147
|
+
`INSERT INTO bot_tokens (token_id, user_id, token_hash, label, created_at, expires_at)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
149
|
+
).run(tokenId, userId, tokenHash, label ?? null, now, expiresAt)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
listBotTokens({ userId }) {
|
|
153
|
+
return this.db.prepare(
|
|
154
|
+
`SELECT token_id, label, created_at, expires_at, last_used_at, revoked_at
|
|
155
|
+
FROM bot_tokens WHERE user_id = ? ORDER BY created_at DESC`
|
|
156
|
+
).all(userId)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
revokeBotToken({ tokenId, now }) {
|
|
160
|
+
this.db.prepare('UPDATE bot_tokens SET revoked_at = ? WHERE token_id = ?').run(now, tokenId)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
revokeAllBotTokens({ userId, now }) {
|
|
164
|
+
this.db.prepare(
|
|
165
|
+
'UPDATE bot_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL'
|
|
166
|
+
).run(now, userId)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
findBotTokenByHash({ tokenHash, now }) {
|
|
170
|
+
return this.db.prepare(
|
|
171
|
+
`SELECT bt.token_id, bt.user_id, bt.last_used_at,
|
|
172
|
+
u.handle, u.display_name, u.roles_json
|
|
173
|
+
FROM bot_tokens bt
|
|
174
|
+
JOIN users u ON u.user_id = bt.user_id
|
|
175
|
+
WHERE bt.token_hash = ?
|
|
176
|
+
AND bt.revoked_at IS NULL
|
|
177
|
+
AND (bt.expires_at IS NULL OR bt.expires_at > ?)`
|
|
178
|
+
).get(tokenHash, now) ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
touchBotToken({ tokenId, now }) {
|
|
182
|
+
this.db.prepare('UPDATE bot_tokens SET last_used_at = ? WHERE token_id = ?').run(now, tokenId)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { runTransaction } from '../db/transaction.js'
|
|
2
|
+
|
|
3
|
+
export class SqliteChannelRepository {
|
|
4
|
+
constructor({ db }) {
|
|
5
|
+
this.db = db
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
insertChannelWithOwner({ channelId, hubId, kind, name, topic, visibility, createdByUserId, now }) {
|
|
9
|
+
runTransaction(this.db, () => {
|
|
10
|
+
const nextOrder = (this.db.prepare(
|
|
11
|
+
`SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM channels WHERE hub_id = ? AND deleted_at IS NULL`
|
|
12
|
+
).get(hubId)?.next ?? 0)
|
|
13
|
+
this.db.prepare(
|
|
14
|
+
`INSERT INTO channels (channel_id, hub_id, kind, name, topic, visibility, sort_order, created_by_user_id, created_at)
|
|
15
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
16
|
+
).run(channelId, hubId, kind, name, topic, visibility, nextOrder, createdByUserId, now)
|
|
17
|
+
this.db.prepare(
|
|
18
|
+
`INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`
|
|
19
|
+
).run(channelId, createdByUserId, now)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
listInHub({ hubId }) {
|
|
24
|
+
return this.db.prepare(
|
|
25
|
+
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order
|
|
26
|
+
FROM channels c WHERE c.hub_id = ? AND c.deleted_at IS NULL
|
|
27
|
+
ORDER BY c.sort_order ASC, c.created_at ASC`
|
|
28
|
+
).all(hubId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
listAccessibleInHub({ hubId, userId, isGuest = false }) {
|
|
32
|
+
return this.db.prepare(
|
|
33
|
+
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order
|
|
34
|
+
FROM channels c WHERE c.hub_id = ? AND c.deleted_at IS NULL
|
|
35
|
+
AND (EXISTS (
|
|
36
|
+
SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.channel_id
|
|
37
|
+
AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL
|
|
38
|
+
) OR (? = 0 AND c.visibility = 'public'))
|
|
39
|
+
ORDER BY c.sort_order ASC, c.created_at ASC`
|
|
40
|
+
).all(hubId, userId, isGuest ? 1 : 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
listAll() {
|
|
44
|
+
return this.db.prepare(
|
|
45
|
+
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
|
|
46
|
+
FROM channels c JOIN hubs h ON c.hub_id = h.hub_id
|
|
47
|
+
WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
|
|
48
|
+
ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
|
|
49
|
+
).all()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
listAccessible({ userId, isGuest = false }) {
|
|
53
|
+
return this.db.prepare(
|
|
54
|
+
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
|
|
55
|
+
FROM channels c JOIN hubs h ON c.hub_id = h.hub_id
|
|
56
|
+
WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
|
|
57
|
+
AND (EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.channel_id AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL)
|
|
58
|
+
OR (? = 0 AND (
|
|
59
|
+
(h.visibility = 'public' AND c.visibility = 'public')
|
|
60
|
+
OR (c.visibility = 'public' AND EXISTS (SELECT 1 FROM hub_members hm WHERE hm.hub_id = h.hub_id AND hm.user_id = ? AND hm.left_at IS NULL))
|
|
61
|
+
)))
|
|
62
|
+
ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
|
|
63
|
+
).all(userId, isGuest ? 1 : 0, userId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
findById({ channelId }) {
|
|
67
|
+
return this.db.prepare('SELECT * FROM channels WHERE channel_id = ?').get(channelId) ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
findMembership({ channelId, userId }) {
|
|
71
|
+
return this.db.prepare('SELECT * FROM channel_members WHERE channel_id = ? AND user_id = ?').get(channelId, userId) ?? null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
findByHubAndName({ hubId, name }) {
|
|
75
|
+
return this.db.prepare('SELECT * FROM channels WHERE hub_id = ? AND name = ? AND deleted_at IS NULL').get(hubId, name) ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
findDmByName({ name }) {
|
|
79
|
+
return this.db.prepare(`SELECT * FROM channels WHERE kind = 'dm' AND name = ? AND deleted_at IS NULL`).get(name) ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
insertDmChannel({ channelId, name, userIdA, userIdB, now }) {
|
|
83
|
+
runTransaction(this.db, () => {
|
|
84
|
+
this.db.prepare(
|
|
85
|
+
`INSERT INTO channels (channel_id, hub_id, kind, name, topic, visibility, sort_order, created_by_user_id, created_at)
|
|
86
|
+
VALUES (?, NULL, 'dm', ?, NULL, 'private', 0, ?, ?)`
|
|
87
|
+
).run(channelId, name, userIdA, now)
|
|
88
|
+
this.db.prepare(
|
|
89
|
+
`INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'member', ?)`
|
|
90
|
+
).run(channelId, userIdA, now)
|
|
91
|
+
this.db.prepare(
|
|
92
|
+
`INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'member', ?)`
|
|
93
|
+
).run(channelId, userIdB, now)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
listDmsByUser({ userId }) {
|
|
98
|
+
return this.db.prepare(
|
|
99
|
+
`SELECT c.channel_id, c.name, c.kind,
|
|
100
|
+
other.user_id AS other_user_id
|
|
101
|
+
FROM channels c
|
|
102
|
+
JOIN channel_members cm ON cm.channel_id = c.channel_id AND cm.user_id = ? AND cm.left_at IS NULL
|
|
103
|
+
LEFT JOIN channel_members other ON other.channel_id = c.channel_id AND other.user_id != ? AND other.left_at IS NULL
|
|
104
|
+
WHERE c.kind = 'dm' AND c.deleted_at IS NULL
|
|
105
|
+
ORDER BY c.created_at DESC`
|
|
106
|
+
).all(userId, userId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
upsertMembership({ channelId, userId, role, now }) {
|
|
110
|
+
this.db.prepare(
|
|
111
|
+
`INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
|
112
|
+
ON CONFLICT(channel_id, user_id) DO UPDATE SET left_at = NULL, banned_at = NULL`
|
|
113
|
+
).run(channelId, userId, role, now)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setMemberLeft({ channelId, userId, now }) {
|
|
117
|
+
this.db.prepare('UPDATE channel_members SET left_at = ? WHERE channel_id = ? AND user_id = ?').run(now, channelId, userId)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
listActiveMembers({ channelId }) {
|
|
121
|
+
return this.db.prepare(
|
|
122
|
+
`SELECT user_id, role FROM channel_members WHERE channel_id = ? AND left_at IS NULL AND banned_at IS NULL`
|
|
123
|
+
).all(channelId)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
patchChannel({ channelId, name, topic, visibility }) {
|
|
127
|
+
const updates = []
|
|
128
|
+
const params = []
|
|
129
|
+
if (name !== undefined) { updates.push('name = ?'); params.push(name) }
|
|
130
|
+
if (topic !== undefined) { updates.push('topic = ?'); params.push(topic) }
|
|
131
|
+
if (visibility !== undefined) { updates.push('visibility = ?'); params.push(visibility) }
|
|
132
|
+
params.push(channelId)
|
|
133
|
+
this.db.prepare(`UPDATE channels SET ${updates.join(', ')} WHERE channel_id = ?`).run(...params)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
softDeleteChannel({ channelId, now }) {
|
|
137
|
+
this.db.prepare('UPDATE channels SET deleted_at = ? WHERE channel_id = ?').run(now, channelId)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
reorderChannels({ hubId, channelIds }) {
|
|
141
|
+
runTransaction(this.db, () => {
|
|
142
|
+
const stmt = this.db.prepare(
|
|
143
|
+
`UPDATE channels SET sort_order = ? WHERE channel_id = ? AND hub_id = ? AND deleted_at IS NULL`
|
|
144
|
+
)
|
|
145
|
+
channelIds.forEach((channelId, index) => stmt.run(index, channelId, hubId))
|
|
146
|
+
})
|
|
147
|
+
return this.listInHub({ hubId })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
|
|
3
|
+
export class SqliteDeliveryRepository {
|
|
4
|
+
constructor({ db }) {
|
|
5
|
+
this.db = db
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
findByChannelAndUser({ channelId, userId }) {
|
|
9
|
+
return this.db.prepare('SELECT * FROM deliveries WHERE channel_id = ? AND user_id = ?').get(channelId, userId) ?? null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
create({ channelId, userId, now }) {
|
|
13
|
+
const deliveryId = newId('del')
|
|
14
|
+
this.db.prepare(
|
|
15
|
+
`INSERT INTO deliveries (delivery_id, user_id, channel_id, after_seq, last_delivered_at, status) VALUES (?, ?, ?, 0, ?, 'active')`
|
|
16
|
+
).run(deliveryId, userId, channelId, now)
|
|
17
|
+
return this.db.prepare('SELECT * FROM deliveries WHERE delivery_id = ?').get(deliveryId)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
advance({ channelId, userId, afterSeq, now }) {
|
|
21
|
+
this.db.prepare(
|
|
22
|
+
`UPDATE deliveries
|
|
23
|
+
SET after_seq = ?, last_delivered_at = ?,
|
|
24
|
+
mention_seq = CASE WHEN mention_seq > 0 AND mention_seq <= ? THEN 0 ELSE mention_seq END
|
|
25
|
+
WHERE channel_id = ? AND user_id = ?`
|
|
26
|
+
).run(afterSeq, now, afterSeq, channelId, userId)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
advanceMention({ channelId, userId, mentionSeq, priority = 'normal' }) {
|
|
30
|
+
this.db.prepare(
|
|
31
|
+
`UPDATE deliveries SET mention_seq = ?, mention_priority = ?
|
|
32
|
+
WHERE channel_id = ? AND user_id = ? AND mention_seq < ?`
|
|
33
|
+
).run(mentionSeq, priority, channelId, userId, mentionSeq)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
buildDigestData({ userId }) {
|
|
37
|
+
return this.db.prepare(
|
|
38
|
+
`SELECT
|
|
39
|
+
d.channel_id, c.name, c.kind, d.after_seq, d.mention_seq, d.mention_priority,
|
|
40
|
+
COALESCE(
|
|
41
|
+
(SELECT MAX(seq) FROM messages WHERE channel_id = d.channel_id AND deleted_at IS NULL),
|
|
42
|
+
0
|
|
43
|
+
) AS max_seq,
|
|
44
|
+
(SELECT cm2.user_id FROM channel_members cm2
|
|
45
|
+
WHERE cm2.channel_id = c.channel_id AND cm2.user_id != d.user_id
|
|
46
|
+
AND cm2.left_at IS NULL AND c.kind = 'dm'
|
|
47
|
+
LIMIT 1) AS other_user_id
|
|
48
|
+
FROM deliveries d
|
|
49
|
+
JOIN channels c ON d.channel_id = c.channel_id AND c.deleted_at IS NULL
|
|
50
|
+
WHERE d.user_id = ?`
|
|
51
|
+
).all(userId)
|
|
52
|
+
}
|
|
53
|
+
}
|