@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,55 @@
|
|
|
1
|
+
export class PresenceService {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.userToConnections = new Map()
|
|
4
|
+
this.connectionToChannels = new Map()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
addConnection(connectionId, userId) {
|
|
8
|
+
if (!this.userToConnections.has(userId)) this.userToConnections.set(userId, new Set())
|
|
9
|
+
this.userToConnections.get(userId).add(connectionId)
|
|
10
|
+
this.connectionToChannels.set(connectionId, new Set())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
removeConnection(connectionId, userId) {
|
|
14
|
+
if (userId && this.userToConnections.has(userId)) {
|
|
15
|
+
const set = this.userToConnections.get(userId)
|
|
16
|
+
set.delete(connectionId)
|
|
17
|
+
if (set.size === 0) this.userToConnections.delete(userId)
|
|
18
|
+
}
|
|
19
|
+
this.connectionToChannels.delete(connectionId)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
joinChannel(connectionId, channelId) {
|
|
23
|
+
this.connectionToChannels.get(connectionId)?.add(channelId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
leaveChannel(connectionId, channelId) {
|
|
27
|
+
this.connectionToChannels.get(connectionId)?.delete(channelId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
listOnlineUsers() {
|
|
31
|
+
return Array.from(this.userToConnections.entries()).map(([userId, connections]) => ({
|
|
32
|
+
user_id: userId,
|
|
33
|
+
online: connections.size > 0
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return online users who are subscribed to at least one of the given channels. */
|
|
38
|
+
listOnlineUsersInChannels(channelIds) {
|
|
39
|
+
const channelSet = new Set(channelIds)
|
|
40
|
+
const result = []
|
|
41
|
+
for (const [userId, connections] of this.userToConnections) {
|
|
42
|
+
if (connections.size === 0) continue
|
|
43
|
+
let visible = false
|
|
44
|
+
for (const connectionId of connections) {
|
|
45
|
+
const channels = this.connectionToChannels.get(connectionId) ?? new Set()
|
|
46
|
+
for (const channelId of channels) {
|
|
47
|
+
if (channelSet.has(channelId)) { visible = true; break }
|
|
48
|
+
}
|
|
49
|
+
if (visible) break
|
|
50
|
+
}
|
|
51
|
+
if (visible) result.push({ user_id: userId, online: true })
|
|
52
|
+
}
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { ServiceError } from '../util/errors.js'
|
|
3
|
+
import { validateEmoji } from '../core/reactions.js'
|
|
4
|
+
|
|
5
|
+
const MAX_DISTINCT_EMOJI = 20
|
|
6
|
+
|
|
7
|
+
export class ReactionService {
|
|
8
|
+
constructor({ reactionRepo, channelService, nowFn = () => Date.now() }) {
|
|
9
|
+
this.reactionRepo = reactionRepo
|
|
10
|
+
this.channelService = channelService
|
|
11
|
+
this.nowFn = nowFn
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
addReaction({ msgId, channelId, userId, emoji }) {
|
|
15
|
+
validateEmoji(emoji)
|
|
16
|
+
if (!this.channelService.isMember(channelId, userId)) {
|
|
17
|
+
throw new ServiceError('FORBIDDEN', 'Not a member')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Enforce cap of 20 distinct emoji per message
|
|
21
|
+
const current = this.reactionRepo.listReactionsForMsgs({ msgIds: [msgId], requestingUserId: userId })
|
|
22
|
+
const existing = current.get(msgId) ?? []
|
|
23
|
+
const alreadyHasEmoji = existing.some(r => r.emoji === emoji)
|
|
24
|
+
if (!alreadyHasEmoji && existing.length >= MAX_DISTINCT_EMOJI) {
|
|
25
|
+
throw new ServiceError('BAD_REQUEST', 'Reaction limit reached')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const reactionId = newId('rx')
|
|
29
|
+
const ts = this.nowFn()
|
|
30
|
+
this.reactionRepo.upsertReaction({ reactionId, msgId, channelId, userId, emoji, ts })
|
|
31
|
+
return this.#summaryFor(msgId, userId)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
removeReaction({ msgId, channelId, userId, emoji }) {
|
|
35
|
+
validateEmoji(emoji)
|
|
36
|
+
if (!this.channelService.isMember(channelId, userId)) {
|
|
37
|
+
throw new ServiceError('FORBIDDEN', 'Not a member')
|
|
38
|
+
}
|
|
39
|
+
this.reactionRepo.removeReaction({ msgId, userId, emoji })
|
|
40
|
+
return this.#summaryFor(msgId, userId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Enriches a messages array with reactions — called by MessageService after list queries. */
|
|
44
|
+
enrichWithReactions({ messages, requestingUserId }) {
|
|
45
|
+
if (messages.length === 0) return messages
|
|
46
|
+
const map = this.reactionRepo.listReactionsForMsgs({
|
|
47
|
+
msgIds: messages.map(m => m.msg_id),
|
|
48
|
+
requestingUserId,
|
|
49
|
+
})
|
|
50
|
+
return messages.map(m => ({ ...m, reactions: map.get(m.msg_id) ?? [] }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#summaryFor(msgId, requestingUserId) {
|
|
54
|
+
const map = this.reactionRepo.listReactionsForMsgs({ msgIds: [msgId], requestingUserId })
|
|
55
|
+
return map.get(msgId) ?? []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class SearchService {
|
|
2
|
+
constructor({ searchRepo }) {
|
|
3
|
+
this.searchRepo = searchRepo
|
|
4
|
+
this.useFts = searchRepo.isFtsEnabled()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
indexMessage({ msg_id, channel_id, seq, user_id, ts, text }) {
|
|
8
|
+
this.searchRepo.indexMessage({ msg_id, channel_id, seq, user_id, ts, text })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
removeMessage({ msgId }) {
|
|
12
|
+
this.searchRepo.removeMessage({ msgId })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
searchMessages({ channelId, query, limit = 50 }) {
|
|
16
|
+
if (this.useFts) {
|
|
17
|
+
return this.searchRepo.searchFts({ channelId, query, limit })
|
|
18
|
+
}
|
|
19
|
+
return this.searchRepo.searchLike({ channelId, query, limit })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { newId } from '../util/ids.js'
|
|
3
|
+
import { ServiceError } from '../util/errors.js'
|
|
4
|
+
|
|
5
|
+
export class SignalingService {
|
|
6
|
+
constructor({ signalingRepo, nowFn = () => Date.now() } = {}) {
|
|
7
|
+
this.nowFn = nowFn
|
|
8
|
+
this.signalingRepo = signalingRepo ?? null
|
|
9
|
+
this.calls = new Map() // callId → { call_id, room_id, created_by_user_id, topology, peers: Map }
|
|
10
|
+
this.emitter = new EventEmitter()
|
|
11
|
+
|
|
12
|
+
if (this.signalingRepo) {
|
|
13
|
+
this.#loadActiveCallsFromDb()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// On startup: restore active calls into in-memory map so signaling can
|
|
18
|
+
// continue for calls that were in progress when the server last stopped.
|
|
19
|
+
// Peers reconnect via normal WS hello → channel.join → rtc.call_create flow.
|
|
20
|
+
#loadActiveCallsFromDb() {
|
|
21
|
+
const rows = this.signalingRepo.findActiveCalls()
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
this.calls.set(row.call_id, {
|
|
24
|
+
call_id: row.call_id,
|
|
25
|
+
room_id: row.channel_id,
|
|
26
|
+
created_by_user_id: row.created_by_user_id,
|
|
27
|
+
topology: row.topology,
|
|
28
|
+
peers: new Map() // peers reconnect fresh; previous peer_ids are gone
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onEvent(handler) {
|
|
34
|
+
this.emitter.on('event', handler)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
createCall({ roomId, createdByUserId, topology = 'mesh' }) {
|
|
38
|
+
// Check in-memory first (fast path)
|
|
39
|
+
for (const call of this.calls.values()) {
|
|
40
|
+
if (call.room_id === roomId) {
|
|
41
|
+
return { call_id: call.call_id, room_id: call.room_id, topology: call.topology }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check DB (covers calls restored from a previous server run that may not
|
|
46
|
+
// have been re-hydrated into memory yet)
|
|
47
|
+
if (this.signalingRepo) {
|
|
48
|
+
const existing = this.signalingRepo.findActiveByChannel({ channelId: roomId })
|
|
49
|
+
if (existing) {
|
|
50
|
+
if (!this.calls.has(existing.call_id)) {
|
|
51
|
+
this.calls.set(existing.call_id, {
|
|
52
|
+
call_id: existing.call_id,
|
|
53
|
+
room_id: existing.channel_id,
|
|
54
|
+
created_by_user_id: existing.created_by_user_id,
|
|
55
|
+
topology: existing.topology,
|
|
56
|
+
peers: new Map()
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return { call_id: existing.call_id, room_id: existing.channel_id, topology: existing.topology }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const callId = newId('call')
|
|
64
|
+
const now = Math.floor(this.nowFn() / 1000)
|
|
65
|
+
this.calls.set(callId, { call_id: callId, room_id: roomId, created_by_user_id: createdByUserId, topology, peers: new Map() })
|
|
66
|
+
this.signalingRepo?.insertCall({ callId, channelId: roomId, createdByUserId, topology, startedAt: now })
|
|
67
|
+
return { call_id: callId, room_id: roomId, topology }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
joinCall({ callId, userId, displayName }) {
|
|
71
|
+
const call = this.calls.get(callId)
|
|
72
|
+
if (!call) throw new ServiceError('NOT_FOUND', 'Call not found')
|
|
73
|
+
const peerId = newId('peer')
|
|
74
|
+
const now = Math.floor(this.nowFn() / 1000)
|
|
75
|
+
call.peers.set(peerId, { peer_id: peerId, user_id: userId, display_name: displayName ?? null, joined_at: now })
|
|
76
|
+
this.signalingRepo?.insertParticipant({ callId, userId, peerId, joinedAt: now })
|
|
77
|
+
const peers = Array.from(call.peers.values()).map(p => ({ peer_id: p.peer_id, user_id: p.user_id, display_name: p.display_name }))
|
|
78
|
+
return { peerId, peers }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
leaveCall({ callId, peerId }) {
|
|
82
|
+
const call = this.calls.get(callId)
|
|
83
|
+
if (!call) return { removed: false, peers: [], ended: false, room_id: null }
|
|
84
|
+
const now = Math.floor(this.nowFn() / 1000)
|
|
85
|
+
this.signalingRepo?.leaveParticipant({ callId, peerId, leftAt: now })
|
|
86
|
+
const removed = call.peers.delete(peerId)
|
|
87
|
+
const peers = Array.from(call.peers.values()).map(p => ({ peer_id: p.peer_id, user_id: p.user_id }))
|
|
88
|
+
const roomId = call.room_id
|
|
89
|
+
const ended = call.peers.size === 0
|
|
90
|
+
if (ended) {
|
|
91
|
+
this.calls.delete(callId)
|
|
92
|
+
this.signalingRepo?.endCall({ callId, endedAt: now })
|
|
93
|
+
}
|
|
94
|
+
return { removed, peers, ended, room_id: roomId }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
endCall({ callId }) {
|
|
98
|
+
const call = this.calls.get(callId)
|
|
99
|
+
if (!call) return null
|
|
100
|
+
const now = Math.floor(this.nowFn() / 1000)
|
|
101
|
+
this.calls.delete(callId)
|
|
102
|
+
this.signalingRepo?.endCall({ callId, endedAt: now })
|
|
103
|
+
return { call_id: call.call_id, room_id: call.room_id, peers: Array.from(call.peers.values()).map(p => ({ peer_id: p.peer_id, user_id: p.user_id })) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Join a call, handling two state machine cases so the transport handler
|
|
108
|
+
* does not need to make these decisions:
|
|
109
|
+
*
|
|
110
|
+
* - already_in_call: peer is already in this exact call — idempotent, no change
|
|
111
|
+
* - switched: peer was in a different call — leave it first, then join
|
|
112
|
+
* - joined: peer had no prior call — plain join
|
|
113
|
+
*
|
|
114
|
+
* @returns {{ status, peerId, peers, previousLeft? }}
|
|
115
|
+
* previousLeft is only present when status === 'switched':
|
|
116
|
+
* { call_id, room_id, peerId, removed, ended }
|
|
117
|
+
*/
|
|
118
|
+
joinOrSwitch({ callId, userId, displayName, currentPeerId, currentCallId }) {
|
|
119
|
+
// Already in this exact call — return current state, no side effects
|
|
120
|
+
if (currentPeerId && currentCallId === callId) {
|
|
121
|
+
const call = this.calls.get(callId)
|
|
122
|
+
if (call?.peers.has(currentPeerId)) {
|
|
123
|
+
const peers = Array.from(call.peers.values()).map(p => ({ peer_id: p.peer_id, user_id: p.user_id }))
|
|
124
|
+
return { status: 'already_in_call', peerId: currentPeerId, peers }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// In a different call — leave it first
|
|
129
|
+
let previousLeft
|
|
130
|
+
if (currentPeerId && currentCallId && currentCallId !== callId) {
|
|
131
|
+
const leaveResult = this.leaveCall({ callId: currentCallId, peerId: currentPeerId })
|
|
132
|
+
previousLeft = {
|
|
133
|
+
call_id: currentCallId,
|
|
134
|
+
room_id: leaveResult.room_id,
|
|
135
|
+
peerId: currentPeerId,
|
|
136
|
+
removed: leaveResult.removed,
|
|
137
|
+
ended: leaveResult.ended,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = this.joinCall({ callId, userId, displayName })
|
|
142
|
+
return {
|
|
143
|
+
status: previousLeft ? 'switched' : 'joined',
|
|
144
|
+
peerId: result.peerId,
|
|
145
|
+
peers: result.peers,
|
|
146
|
+
...(previousLeft && { previousLeft }),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getCall(callId) { return this.calls.get(callId) }
|
|
151
|
+
|
|
152
|
+
getActiveCallForChannel(channelId) {
|
|
153
|
+
for (const call of this.calls.values()) {
|
|
154
|
+
if (call.room_id === channelId) return call
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
routeOffer({ callId, fromPeerId, toPeerId, sdp }) {
|
|
160
|
+
this.#route({ callId, fromPeerId, toPeerId, payload: { sdp }, type: 'rtc.offer_event' })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
routeAnswer({ callId, fromPeerId, toPeerId, sdp }) {
|
|
164
|
+
this.#route({ callId, fromPeerId, toPeerId, payload: { sdp }, type: 'rtc.answer_event' })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
routeIce({ callId, fromPeerId, toPeerId, candidate }) {
|
|
168
|
+
this.#route({ callId, fromPeerId, toPeerId, payload: { candidate }, type: 'rtc.ice_event' })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#route({ callId, fromPeerId, toPeerId, payload, type }) {
|
|
172
|
+
const call = this.calls.get(callId)
|
|
173
|
+
if (!call) throw new ServiceError('NOT_FOUND', 'Call not found')
|
|
174
|
+
if (!call.peers.has(fromPeerId) || !call.peers.has(toPeerId)) throw new ServiceError('BAD_REQUEST', 'Peer not in call')
|
|
175
|
+
this.emitter.emit('event', { t: type, body: { call_id: callId, from_peer_id: fromPeerId, to_peer_id: toPeerId, ...payload } })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { randomToken } from '../util/crypto.js'
|
|
3
|
+
import { validateMimeType, isForcedDownload } from '../core/uploads.js'
|
|
4
|
+
import { ServiceError } from '../util/errors.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_BYTES = Number(process.env.MAX_UPLOAD_BYTES ?? 26_214_400) // 25 MB
|
|
7
|
+
|
|
8
|
+
export class UploadService {
|
|
9
|
+
constructor({ uploadRepo, fileStore, channelService, nowFn = () => Date.now(), maxBytes = DEFAULT_MAX_BYTES }) {
|
|
10
|
+
this.uploadRepo = uploadRepo
|
|
11
|
+
this.fileStore = fileStore
|
|
12
|
+
this.channelService = channelService
|
|
13
|
+
this.nowFn = nowFn
|
|
14
|
+
this.maxBytes = maxBytes
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate, store, and record an uploaded file.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ userId, channelId, userRoles, filename, stream, sizeBytes, magicBuf }} params
|
|
21
|
+
* magicBuf — first ≥16 bytes of the file (already read for MIME detection)
|
|
22
|
+
* stream — ReadableStream of the full file (including the bytes in magicBuf)
|
|
23
|
+
* @returns {{ upload_id, url, original_name, mime_type, size_bytes }}
|
|
24
|
+
*/
|
|
25
|
+
async upload({ userId, channelId, userRoles, filename, stream, sizeBytes, magicBuf }) {
|
|
26
|
+
if (!this.channelService.isMember(channelId, userId)) {
|
|
27
|
+
throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (sizeBytes > this.maxBytes) {
|
|
31
|
+
throw new ServiceError('BAD_REQUEST', `File exceeds maximum size of ${this.maxBytes} bytes`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mimeType = validateMimeType(magicBuf, filename) // throws UNSUPPORTED_TYPE with code
|
|
35
|
+
|
|
36
|
+
const uploadId = newId('up')
|
|
37
|
+
const storedName = randomToken(24) // opaque — never derived from filename
|
|
38
|
+
const now = this.nowFn()
|
|
39
|
+
|
|
40
|
+
await this.fileStore.write({ uploadId, storedName, stream })
|
|
41
|
+
|
|
42
|
+
this.uploadRepo.insert({
|
|
43
|
+
uploadId,
|
|
44
|
+
uploaderUserId: userId,
|
|
45
|
+
channelId,
|
|
46
|
+
originalName: filename,
|
|
47
|
+
storedName,
|
|
48
|
+
mimeType,
|
|
49
|
+
sizeBytes,
|
|
50
|
+
now,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const url = `/uploads/${uploadId}/${encodeURIComponent(filename)}`
|
|
54
|
+
return { upload_id: uploadId, url, original_name: filename, mime_type: mimeType, size_bytes: sizeBytes }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Link a list of upload_ids to a sent message.
|
|
59
|
+
* Validates that each upload belongs to the channel and was uploaded by this user.
|
|
60
|
+
*/
|
|
61
|
+
linkToMessage({ uploadIds, msgId, userId, channelId }) {
|
|
62
|
+
for (const uploadId of uploadIds) {
|
|
63
|
+
const row = this.uploadRepo.findById({ uploadId })
|
|
64
|
+
if (!row) throw new ServiceError('NOT_FOUND', `Upload not found: ${uploadId}`)
|
|
65
|
+
if (row.uploader_user_id !== userId) throw new ServiceError('FORBIDDEN', 'Upload belongs to another user')
|
|
66
|
+
if (row.channel_id !== channelId) throw new ServiceError('FORBIDDEN', 'Upload is not in this channel')
|
|
67
|
+
this.uploadRepo.linkToMessage({ uploadId, msgId })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getUpload({ uploadId }) {
|
|
72
|
+
return this.uploadRepo.findById({ uploadId })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate access and return a stream plus metadata for serving a file.
|
|
77
|
+
*/
|
|
78
|
+
async streamFile({ uploadId, requestingUserId, userRoles }) {
|
|
79
|
+
const row = this.uploadRepo.findById({ uploadId })
|
|
80
|
+
if (!row) throw new ServiceError('NOT_FOUND', 'File not found')
|
|
81
|
+
|
|
82
|
+
if (!this.channelService.canAccessChannel(row.channel_id, requestingUserId, userRoles)) {
|
|
83
|
+
throw new ServiceError('FORBIDDEN', 'Access denied')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const stream = await this.fileStore.read({ uploadId, storedName: row.stored_name })
|
|
87
|
+
const contentDisposition = isForcedDownload(row.mime_type)
|
|
88
|
+
? `attachment; filename="${encodeURIComponent(row.original_name)}"`
|
|
89
|
+
: `inline; filename="${encodeURIComponent(row.original_name)}"`
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
stream,
|
|
93
|
+
mimeType: row.mime_type,
|
|
94
|
+
originalName: row.original_name,
|
|
95
|
+
contentDisposition,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Delete orphan uploads (msg_id IS NULL) older than olderThanMs milliseconds.
|
|
101
|
+
*/
|
|
102
|
+
async deleteOrphans({ olderThanMs }) {
|
|
103
|
+
const threshold = this.nowFn() - olderThanMs
|
|
104
|
+
const orphans = this.uploadRepo.findOrphansOlderThan({ thresholdTs: threshold })
|
|
105
|
+
for (const row of orphans) {
|
|
106
|
+
await this.fileStore.delete({ uploadId: row.upload_id, storedName: row.stored_name })
|
|
107
|
+
this.uploadRepo.delete({ uploadId: row.upload_id })
|
|
108
|
+
}
|
|
109
|
+
return orphans.length
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const ALLOWED_KEYS = new Set(['last_channel_id', 'mobile_chat_open'])
|
|
2
|
+
|
|
3
|
+
export class UserSettingsService {
|
|
4
|
+
constructor({ userSettingsRepo }) {
|
|
5
|
+
this.userSettingsRepo = userSettingsRepo
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Returns { settings, updated_at }
|
|
9
|
+
getSettings(userId) {
|
|
10
|
+
const row = this.userSettingsRepo.findByUserId({ userId })
|
|
11
|
+
if (!row) return { settings: {}, updated_at: 0 }
|
|
12
|
+
try {
|
|
13
|
+
return { settings: JSON.parse(row.settings_json), updated_at: row.updated_at }
|
|
14
|
+
} catch {
|
|
15
|
+
return { settings: {}, updated_at: 0 }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Merges patch into stored settings. Only allow-listed keys are persisted.
|
|
20
|
+
// updatedAt is a Unix timestamp (seconds); last-write-wins enforced by the repo.
|
|
21
|
+
putSettings(userId, patch, updatedAt) {
|
|
22
|
+
const existing = this.getSettings(userId)
|
|
23
|
+
const merged = { ...existing.settings }
|
|
24
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
25
|
+
if (ALLOWED_KEYS.has(k)) merged[k] = v
|
|
26
|
+
}
|
|
27
|
+
this.userSettingsRepo.upsert({ userId, settingsJson: JSON.stringify(merged), updatedAt })
|
|
28
|
+
return this.getSettings(userId)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebPushService — sends Web Push notifications without any third-party library.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* • VAPID authentication (RFC 8292) — ES256 JWT signed with the application
|
|
6
|
+
* server's ECDSA P-256 private key.
|
|
7
|
+
* • Payload encryption (RFC 8291 / RFC 8188 aes128gcm content-encoding) —
|
|
8
|
+
* ECDH key agreement, HKDF key derivation, AES-128-GCM encryption, all via
|
|
9
|
+
* the standard Web Crypto API (crypto.subtle), available in Bun natively.
|
|
10
|
+
*
|
|
11
|
+
* Required env vars (set via `bun scripts/generate-vapid.js`):
|
|
12
|
+
* VAPID_PUBLIC_KEY base64url-encoded raw P-256 public key (65 bytes)
|
|
13
|
+
* VAPID_PRIVATE_KEY base64url-encoded P-256 private scalar d (32 bytes)
|
|
14
|
+
* VAPID_SUBJECT contact URI, e.g. mailto:admin@example.com
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function b64url_encode(bytes) {
|
|
20
|
+
let bin = ''
|
|
21
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
|
|
22
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function b64url_decode(str) {
|
|
26
|
+
const padded = str + '==='.slice((str.length + 3) % 4)
|
|
27
|
+
return Uint8Array.from(atob(padded.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function concat(arrays) {
|
|
31
|
+
const total = arrays.reduce((n, a) => n + a.length, 0)
|
|
32
|
+
const out = new Uint8Array(total)
|
|
33
|
+
let off = 0
|
|
34
|
+
for (const a of arrays) { out.set(a, off); off += a.length }
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Service ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export class WebPushService {
|
|
41
|
+
#publicKey // base64url string — raw uncompressed P-256 (65 bytes)
|
|
42
|
+
#privateKey // base64url string — P-256 scalar d (32 bytes)
|
|
43
|
+
#subject // mailto: or https: contact URI
|
|
44
|
+
#pushRepo
|
|
45
|
+
|
|
46
|
+
constructor({ vapidPublicKey, vapidPrivateKey, vapidSubject, pushRepo }) {
|
|
47
|
+
this.#publicKey = vapidPublicKey ?? null
|
|
48
|
+
this.#privateKey = vapidPrivateKey ?? null
|
|
49
|
+
this.#subject = vapidSubject ?? null
|
|
50
|
+
this.#pushRepo = pushRepo
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isConfigured() {
|
|
54
|
+
return !!(this.#publicKey && this.#privateKey && this.#subject)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Send a push notification to every registered subscription for userId. */
|
|
58
|
+
async sendToUser({ userId, title, body, url, channelId }) {
|
|
59
|
+
if (!this.isConfigured()) return
|
|
60
|
+
const subs = this.#pushRepo.getSubscriptionsForUser(userId)
|
|
61
|
+
if (subs.length === 0) return
|
|
62
|
+
await Promise.allSettled(
|
|
63
|
+
subs.map(sub => this.#sendOne(sub, { title, body, url, channel_id: channelId }))
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Private ─────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async #sendOne(sub, payload) {
|
|
70
|
+
let encrypted
|
|
71
|
+
try {
|
|
72
|
+
encrypted = await this.#encrypt(sub, JSON.stringify(payload))
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Encryption failure is a bug — re-throw so the caller can log it
|
|
75
|
+
throw new Error(`WebPush encrypt failed: ${err?.message}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const audience = new URL(sub.endpoint).origin
|
|
79
|
+
const jwt = await this.#buildJwt(audience)
|
|
80
|
+
|
|
81
|
+
const res = await fetch(sub.endpoint, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `vapid t=${jwt},k=${this.#publicKey}`,
|
|
85
|
+
'Content-Type': 'application/octet-stream',
|
|
86
|
+
'Content-Encoding': 'aes128gcm',
|
|
87
|
+
TTL: '86400',
|
|
88
|
+
},
|
|
89
|
+
body: encrypted,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (res.status === 404 || res.status === 410) {
|
|
93
|
+
// Subscription has expired or was removed — clean up
|
|
94
|
+
this.#pushRepo.removeSubscription(sub.endpoint)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build an ES256 VAPID JWT for the given push service origin.
|
|
100
|
+
*
|
|
101
|
+
* JWT header: { typ: "JWT", alg: "ES256" }
|
|
102
|
+
* JWT claims: { aud, exp, sub }
|
|
103
|
+
* Signature: ECDSA P-256 / SHA-256 (IEEE P1363 format — raw r||s, 64 bytes)
|
|
104
|
+
*/
|
|
105
|
+
async #buildJwt(audience) {
|
|
106
|
+
const enc = new TextEncoder()
|
|
107
|
+
|
|
108
|
+
// Reconstruct JWK from raw public key bytes + private scalar d
|
|
109
|
+
const pub = b64url_decode(this.#publicKey) // 65-byte uncompressed: 0x04 || x(32) || y(32)
|
|
110
|
+
const jwk = {
|
|
111
|
+
kty: 'EC', crv: 'P-256',
|
|
112
|
+
x: b64url_encode(pub.slice(1, 33)),
|
|
113
|
+
y: b64url_encode(pub.slice(33, 65)),
|
|
114
|
+
d: this.#privateKey,
|
|
115
|
+
ext: true,
|
|
116
|
+
}
|
|
117
|
+
const sigKey = await crypto.subtle.importKey(
|
|
118
|
+
'jwk', jwk,
|
|
119
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
120
|
+
false, ['sign']
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const header = b64url_encode(enc.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
|
|
124
|
+
const claims = b64url_encode(enc.encode(JSON.stringify({
|
|
125
|
+
aud: audience,
|
|
126
|
+
exp: Math.floor(Date.now() / 1000) + 43_200, // 12 h
|
|
127
|
+
sub: this.#subject,
|
|
128
|
+
})))
|
|
129
|
+
|
|
130
|
+
const sigInput = enc.encode(`${header}.${claims}`)
|
|
131
|
+
const sigRaw = new Uint8Array(
|
|
132
|
+
await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, sigKey, sigInput)
|
|
133
|
+
) // 64 bytes: r(32) || s(32) in IEEE P1363 format — correct for JWT ES256
|
|
134
|
+
|
|
135
|
+
return `${header}.${claims}.${b64url_encode(sigRaw)}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Encrypt a plaintext string for delivery to a push subscription.
|
|
140
|
+
*
|
|
141
|
+
* Follows RFC 8291 (Message Encryption for Web Push) which layers on top of
|
|
142
|
+
* RFC 8188 (Encrypted Content-Encoding for HTTP, aes128gcm variant).
|
|
143
|
+
*
|
|
144
|
+
* Key derivation chain:
|
|
145
|
+
* ecdh_secret = ECDH(as_private_ephemeral, ua_public)
|
|
146
|
+
* IKM = HKDF(salt=auth, IKM=ecdh_secret, info="WebPush: info\0"||ua_pub||as_pub, 32)
|
|
147
|
+
* CEK = HKDF(salt=random_salt, IKM=IKM, info="Content-Encoding: aes128gcm\0", 16)
|
|
148
|
+
* NONCE = HKDF(salt=random_salt, IKM=IKM, info="Content-Encoding: nonce\0", 12)
|
|
149
|
+
*
|
|
150
|
+
* Ciphertext format (RFC 8188):
|
|
151
|
+
* random_salt(16) || rs(4 BE) || keyid_len(1) || as_pub(65) || AES-128-GCM(padded_plaintext)
|
|
152
|
+
*/
|
|
153
|
+
async #encrypt(sub, plaintext) {
|
|
154
|
+
const enc = new TextEncoder()
|
|
155
|
+
|
|
156
|
+
// ── Subscription keys ────────────────────────────────────────────────────
|
|
157
|
+
const ua_public = b64url_decode(sub.p256dh) // 65-byte uncompressed P-256 point
|
|
158
|
+
const auth_secret = b64url_decode(sub.auth) // 16 bytes
|
|
159
|
+
|
|
160
|
+
// ── Ephemeral application-server key pair ────────────────────────────────
|
|
161
|
+
const as_kp = await crypto.subtle.generateKey(
|
|
162
|
+
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
|
|
163
|
+
)
|
|
164
|
+
const as_public_raw = new Uint8Array(await crypto.subtle.exportKey('raw', as_kp.publicKey)) // 65 bytes
|
|
165
|
+
|
|
166
|
+
// ── ECDH shared secret ───────────────────────────────────────────────────
|
|
167
|
+
const ua_key = await crypto.subtle.importKey(
|
|
168
|
+
'raw', ua_public, { name: 'ECDH', namedCurve: 'P-256' }, false, []
|
|
169
|
+
)
|
|
170
|
+
const ecdh_secret = new Uint8Array(
|
|
171
|
+
await crypto.subtle.deriveBits({ name: 'ECDH', public: ua_key }, as_kp.privateKey, 256)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// ── RFC 8291: derive IKM ─────────────────────────────────────────────────
|
|
175
|
+
// key_info = "WebPush: info\0" || ua_public || as_public
|
|
176
|
+
const key_info = concat([enc.encode('WebPush: info\0'), ua_public, as_public_raw])
|
|
177
|
+
const ecdh_key = await crypto.subtle.importKey('raw', ecdh_secret, 'HKDF', false, ['deriveBits'])
|
|
178
|
+
const ikm = new Uint8Array(await crypto.subtle.deriveBits(
|
|
179
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: auth_secret, info: key_info },
|
|
180
|
+
ecdh_key, 256
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
// ── RFC 8188 aes128gcm: derive CEK and NONCE ─────────────────────────────
|
|
184
|
+
const random_salt = crypto.getRandomValues(new Uint8Array(16))
|
|
185
|
+
const ikm_key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits'])
|
|
186
|
+
const [cek_bits, nonce_bits] = await Promise.all([
|
|
187
|
+
crypto.subtle.deriveBits(
|
|
188
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: random_salt, info: enc.encode('Content-Encoding: aes128gcm\0') },
|
|
189
|
+
ikm_key, 128
|
|
190
|
+
),
|
|
191
|
+
crypto.subtle.deriveBits(
|
|
192
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: random_salt, info: enc.encode('Content-Encoding: nonce\0') },
|
|
193
|
+
ikm_key, 96
|
|
194
|
+
),
|
|
195
|
+
])
|
|
196
|
+
const cek = new Uint8Array(cek_bits)
|
|
197
|
+
const nonce = new Uint8Array(nonce_bits)
|
|
198
|
+
|
|
199
|
+
// ── Encrypt with AES-128-GCM ─────────────────────────────────────────────
|
|
200
|
+
// Pad with delimiter byte 0x02 (marks the last record in aes128gcm)
|
|
201
|
+
const padded = concat([enc.encode(plaintext), new Uint8Array([2])])
|
|
202
|
+
const aes_key = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt'])
|
|
203
|
+
const ciphertext = new Uint8Array(
|
|
204
|
+
await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aes_key, padded)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// ── RFC 8188 content-encoding header ─────────────────────────────────────
|
|
208
|
+
// salt(16) || rs(4 big-endian) || keyid_len(1) || keyid(65)
|
|
209
|
+
const hdr = new Uint8Array(16 + 4 + 1 + as_public_raw.length)
|
|
210
|
+
hdr.set(random_salt, 0)
|
|
211
|
+
new DataView(hdr.buffer).setUint32(16, 4096, false) // record size = 4096
|
|
212
|
+
hdr[20] = as_public_raw.length // keyid length = 65
|
|
213
|
+
hdr.set(as_public_raw, 21)
|
|
214
|
+
|
|
215
|
+
return concat([hdr, ciphertext])
|
|
216
|
+
}
|
|
217
|
+
}
|