@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for upload validation.
|
|
3
|
+
* No I/O — trivially testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Magic byte signatures → MIME type
|
|
7
|
+
const MAGIC = [
|
|
8
|
+
// Images
|
|
9
|
+
{ bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg' },
|
|
10
|
+
{ bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mime: 'image/png' },
|
|
11
|
+
{ bytes: [0x00, 0x00, 0x01, 0x00], mime: 'image/x-icon' },
|
|
12
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif' },
|
|
13
|
+
{ bytes: [0x52, 0x49, 0x46, 0x46], mime: 'image/webp', offset: 0, extra: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } },
|
|
14
|
+
// PDF
|
|
15
|
+
{ bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf' },
|
|
16
|
+
// ZIP (also docx, xlsx, etc. — we only allow plain zip)
|
|
17
|
+
{ bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip' },
|
|
18
|
+
{ bytes: [0x50, 0x4b, 0x05, 0x06], mime: 'application/zip' },
|
|
19
|
+
// GZip
|
|
20
|
+
{ bytes: [0x1f, 0x8b], mime: 'application/gzip' },
|
|
21
|
+
// MP3
|
|
22
|
+
{ bytes: [0x49, 0x44, 0x33], mime: 'audio/mpeg' },
|
|
23
|
+
{ bytes: [0xff, 0xfb], mime: 'audio/mpeg' },
|
|
24
|
+
{ bytes: [0xff, 0xf3], mime: 'audio/mpeg' },
|
|
25
|
+
{ bytes: [0xff, 0xf2], mime: 'audio/mpeg' },
|
|
26
|
+
// OGG
|
|
27
|
+
{ bytes: [0x4f, 0x67, 0x67, 0x53], mime: 'audio/ogg' },
|
|
28
|
+
// WAV
|
|
29
|
+
{ bytes: [0x52, 0x49, 0x46, 0x46], mime: 'audio/wav', extra: { offset: 8, bytes: [0x57, 0x41, 0x56, 0x45] } },
|
|
30
|
+
// MP4
|
|
31
|
+
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, mime: 'video/mp4' },
|
|
32
|
+
// WebM
|
|
33
|
+
{ bytes: [0x1a, 0x45, 0xdf, 0xa3], mime: 'video/webm' },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// Plain-text extensions we serve as text/plain (no magic check needed)
|
|
37
|
+
const TEXT_EXTENSIONS = new Set([
|
|
38
|
+
'txt', 'md', 'markdown', 'json', 'csv', 'js', 'ts', 'jsx', 'tsx',
|
|
39
|
+
'html', 'css', 'yaml', 'yml', 'toml', 'sh', 'bash', 'py', 'rb',
|
|
40
|
+
'go', 'rs', 'java', 'c', 'cpp', 'h', 'xml', 'svg',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
// MIME types that browsers / OSes can execute — force Content-Disposition: attachment
|
|
44
|
+
const FORCED_DOWNLOAD_MIMES = new Set([
|
|
45
|
+
'application/javascript',
|
|
46
|
+
'application/x-sh',
|
|
47
|
+
'application/octet-stream',
|
|
48
|
+
'application/x-executable',
|
|
49
|
+
'application/x-msdownload',
|
|
50
|
+
'application/x-msdos-program',
|
|
51
|
+
'text/html',
|
|
52
|
+
'application/xhtml+xml',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect MIME type from the first bytes of a file buffer.
|
|
57
|
+
* Returns the MIME string on success, or throws an Error with code 'UNSUPPORTED_TYPE'.
|
|
58
|
+
*
|
|
59
|
+
* @param {Uint8Array|Buffer} buf First N bytes (at least 16 recommended)
|
|
60
|
+
* @param {string} filename Original filename (used for text fallback)
|
|
61
|
+
* @returns {string} Detected MIME type
|
|
62
|
+
*/
|
|
63
|
+
export function validateMimeType(buf, filename = '') {
|
|
64
|
+
// 1. Try text extension fallback first so .md, .json, etc. always work
|
|
65
|
+
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
66
|
+
if (TEXT_EXTENSIONS.has(ext)) {
|
|
67
|
+
// Quick sanity: must not start with binary-looking bytes
|
|
68
|
+
const isBinary = Array.from(buf.slice(0, 8)).some(b => b < 0x09 && b !== 0x00)
|
|
69
|
+
if (!isBinary) return 'text/plain'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Try SVG (XML text)
|
|
73
|
+
if (ext === 'svg') {
|
|
74
|
+
return 'image/svg+xml'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Magic byte scan
|
|
78
|
+
for (const sig of MAGIC) {
|
|
79
|
+
const start = sig.offset ?? 0
|
|
80
|
+
const slice = buf.slice(start, start + sig.bytes.length)
|
|
81
|
+
const match = sig.bytes.every((b, i) => slice[i] === b)
|
|
82
|
+
if (!match) continue
|
|
83
|
+
|
|
84
|
+
// Optional extra check (e.g., WEBP vs WAV both start with RIFF)
|
|
85
|
+
if (sig.extra) {
|
|
86
|
+
const extra = buf.slice(sig.extra.offset, sig.extra.offset + sig.extra.bytes.length)
|
|
87
|
+
if (!sig.extra.bytes.every((b, i) => extra[i] === b)) continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return sig.mime
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const err = new Error(`Unsupported file type: ${filename}`)
|
|
94
|
+
err.code = 'UNSUPPORTED_TYPE'
|
|
95
|
+
throw err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns true if the MIME type should be served with Content-Disposition: attachment
|
|
100
|
+
* to prevent execution in the browser.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} mime
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isForcedDownload(mime) {
|
|
106
|
+
return FORCED_DOWNLOAD_MIMES.has(mime)
|
|
107
|
+
}
|
package/src/db/initDb.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Pure DDL — safe to call before migrations. No writes.
|
|
2
|
+
export const createSchema = (db) => {
|
|
3
|
+
db.exec(`
|
|
4
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
5
|
+
user_id TEXT PRIMARY KEY,
|
|
6
|
+
handle TEXT NOT NULL UNIQUE,
|
|
7
|
+
display_name TEXT NOT NULL,
|
|
8
|
+
roles_json TEXT NOT NULL,
|
|
9
|
+
password_hash TEXT,
|
|
10
|
+
created_at INTEGER NOT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
14
|
+
session_id TEXT PRIMARY KEY,
|
|
15
|
+
user_id TEXT NOT NULL,
|
|
16
|
+
token_hash TEXT NOT NULL,
|
|
17
|
+
created_at INTEGER NOT NULL,
|
|
18
|
+
expires_at INTEGER NOT NULL,
|
|
19
|
+
revoked_at INTEGER,
|
|
20
|
+
last_seen_at INTEGER,
|
|
21
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS invites (
|
|
25
|
+
invite_id TEXT PRIMARY KEY,
|
|
26
|
+
token_hash TEXT NOT NULL,
|
|
27
|
+
created_by_user_id TEXT NOT NULL,
|
|
28
|
+
created_at INTEGER NOT NULL,
|
|
29
|
+
expires_at INTEGER NOT NULL,
|
|
30
|
+
max_uses INTEGER NOT NULL,
|
|
31
|
+
uses INTEGER NOT NULL,
|
|
32
|
+
redeemed_by_user_id TEXT,
|
|
33
|
+
note TEXT,
|
|
34
|
+
initial_roles_json TEXT,
|
|
35
|
+
FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS hubs (
|
|
39
|
+
hub_id TEXT PRIMARY KEY,
|
|
40
|
+
name TEXT NOT NULL,
|
|
41
|
+
description TEXT,
|
|
42
|
+
visibility TEXT NOT NULL,
|
|
43
|
+
created_by_user_id TEXT NOT NULL,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
deleted_at INTEGER,
|
|
46
|
+
FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS hub_members (
|
|
50
|
+
hub_id TEXT NOT NULL,
|
|
51
|
+
user_id TEXT NOT NULL,
|
|
52
|
+
joined_at INTEGER NOT NULL,
|
|
53
|
+
left_at INTEGER,
|
|
54
|
+
PRIMARY KEY (hub_id, user_id),
|
|
55
|
+
FOREIGN KEY(hub_id) REFERENCES hubs(hub_id),
|
|
56
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
60
|
+
channel_id TEXT PRIMARY KEY,
|
|
61
|
+
hub_id TEXT,
|
|
62
|
+
kind TEXT NOT NULL,
|
|
63
|
+
name TEXT NOT NULL,
|
|
64
|
+
topic TEXT,
|
|
65
|
+
visibility TEXT NOT NULL,
|
|
66
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
created_by_user_id TEXT NOT NULL,
|
|
68
|
+
created_at INTEGER NOT NULL,
|
|
69
|
+
deleted_at INTEGER,
|
|
70
|
+
FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS channel_members (
|
|
74
|
+
channel_id TEXT NOT NULL,
|
|
75
|
+
user_id TEXT NOT NULL,
|
|
76
|
+
role TEXT NOT NULL,
|
|
77
|
+
joined_at INTEGER NOT NULL,
|
|
78
|
+
left_at INTEGER,
|
|
79
|
+
banned_at INTEGER,
|
|
80
|
+
PRIMARY KEY (channel_id, user_id),
|
|
81
|
+
FOREIGN KEY(channel_id) REFERENCES channels(channel_id),
|
|
82
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
86
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
ts INTEGER NOT NULL,
|
|
88
|
+
actor_user_id TEXT NOT NULL,
|
|
89
|
+
scope_kind TEXT NOT NULL,
|
|
90
|
+
scope_id TEXT NOT NULL,
|
|
91
|
+
type TEXT NOT NULL,
|
|
92
|
+
body_json TEXT NOT NULL,
|
|
93
|
+
trace TEXT
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
97
|
+
msg_id TEXT PRIMARY KEY,
|
|
98
|
+
channel_id TEXT NOT NULL,
|
|
99
|
+
seq INTEGER NOT NULL,
|
|
100
|
+
user_id TEXT NOT NULL,
|
|
101
|
+
ts INTEGER NOT NULL,
|
|
102
|
+
text TEXT NOT NULL,
|
|
103
|
+
client_msg_id TEXT,
|
|
104
|
+
deleted_at INTEGER,
|
|
105
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
106
|
+
attachments_json TEXT,
|
|
107
|
+
edited_at INTEGER,
|
|
108
|
+
FOREIGN KEY(channel_id) REFERENCES channels(channel_id),
|
|
109
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS uploads (
|
|
113
|
+
upload_id TEXT PRIMARY KEY,
|
|
114
|
+
uploader_user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
115
|
+
channel_id TEXT NOT NULL REFERENCES channels(channel_id),
|
|
116
|
+
msg_id TEXT REFERENCES messages(msg_id),
|
|
117
|
+
original_name TEXT NOT NULL,
|
|
118
|
+
stored_name TEXT NOT NULL,
|
|
119
|
+
mime_type TEXT NOT NULL,
|
|
120
|
+
size_bytes INTEGER NOT NULL,
|
|
121
|
+
created_at INTEGER NOT NULL
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
125
|
+
delivery_id TEXT PRIMARY KEY,
|
|
126
|
+
user_id TEXT NOT NULL,
|
|
127
|
+
channel_id TEXT NOT NULL,
|
|
128
|
+
after_seq INTEGER NOT NULL,
|
|
129
|
+
mention_seq INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
mention_priority TEXT NOT NULL DEFAULT 'normal',
|
|
131
|
+
last_delivered_at INTEGER,
|
|
132
|
+
status TEXT NOT NULL,
|
|
133
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id),
|
|
134
|
+
FOREIGN KEY(channel_id) REFERENCES channels(channel_id)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE TABLE IF NOT EXISTS user_settings (
|
|
138
|
+
user_id TEXT NOT NULL PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
139
|
+
settings_json TEXT NOT NULL DEFAULT '{}',
|
|
140
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS calls (
|
|
144
|
+
call_id TEXT NOT NULL PRIMARY KEY,
|
|
145
|
+
channel_id TEXT NOT NULL REFERENCES channels(channel_id),
|
|
146
|
+
created_by_user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
147
|
+
topology TEXT NOT NULL DEFAULT 'mesh',
|
|
148
|
+
started_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
149
|
+
ended_at INTEGER
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
CREATE TABLE IF NOT EXISTS call_participants (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
call_id TEXT NOT NULL REFERENCES calls(call_id),
|
|
155
|
+
user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
156
|
+
peer_id TEXT NOT NULL,
|
|
157
|
+
joined_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
158
|
+
left_at INTEGER
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
CREATE INDEX IF NOT EXISTS idx_calls_channel_active
|
|
162
|
+
ON calls (channel_id) WHERE ended_at IS NULL;
|
|
163
|
+
`)
|
|
164
|
+
|
|
165
|
+
// Add sort_order to existing databases that pre-date this column
|
|
166
|
+
try { db.exec(`ALTER TABLE channels ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
|
|
167
|
+
try { db.exec(`ALTER TABLE hubs ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
|
|
168
|
+
// Add mention_seq to existing deliveries tables that pre-date this column
|
|
169
|
+
try { db.exec(`ALTER TABLE deliveries ADD COLUMN mention_seq INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
|
|
170
|
+
// Add mention_priority to existing deliveries tables
|
|
171
|
+
try { db.exec(`ALTER TABLE deliveries ADD COLUMN mention_priority TEXT NOT NULL DEFAULT 'normal'`) } catch { /* already exists */ }
|
|
172
|
+
// Add priority + attachments_json to existing messages tables
|
|
173
|
+
try { db.exec(`ALTER TABLE messages ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'`) } catch { /* already exists */ }
|
|
174
|
+
try { db.exec(`ALTER TABLE messages ADD COLUMN attachments_json TEXT`) } catch { /* already exists */ }
|
|
175
|
+
try { db.exec(`ALTER TABLE messages ADD COLUMN edited_at INTEGER`) } catch { /* already exists */ }
|
|
176
|
+
|
|
177
|
+
// Bot tokens — added after initial schema
|
|
178
|
+
db.exec(`
|
|
179
|
+
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
180
|
+
token_id TEXT PRIMARY KEY,
|
|
181
|
+
user_id TEXT NOT NULL,
|
|
182
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
183
|
+
label TEXT,
|
|
184
|
+
created_at INTEGER NOT NULL,
|
|
185
|
+
expires_at INTEGER,
|
|
186
|
+
last_used_at INTEGER,
|
|
187
|
+
revoked_at INTEGER,
|
|
188
|
+
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
189
|
+
);
|
|
190
|
+
`)
|
|
191
|
+
// Add expires_at to existing bot_tokens tables that predate this column
|
|
192
|
+
try { db.exec('ALTER TABLE bot_tokens ADD COLUMN expires_at INTEGER') } catch { /* already exists */ }
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
db.exec(`
|
|
196
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_messages USING fts5(
|
|
197
|
+
text,
|
|
198
|
+
channel_id UNINDEXED,
|
|
199
|
+
msg_id UNINDEXED,
|
|
200
|
+
seq UNINDEXED,
|
|
201
|
+
user_id UNINDEXED,
|
|
202
|
+
ts UNINDEXED
|
|
203
|
+
);
|
|
204
|
+
`)
|
|
205
|
+
} catch {
|
|
206
|
+
db.exec(`
|
|
207
|
+
CREATE TABLE IF NOT EXISTS fts_messages (
|
|
208
|
+
text TEXT NOT NULL,
|
|
209
|
+
channel_id TEXT NOT NULL,
|
|
210
|
+
msg_id TEXT NOT NULL,
|
|
211
|
+
seq INTEGER NOT NULL,
|
|
212
|
+
user_id TEXT NOT NULL,
|
|
213
|
+
ts INTEGER NOT NULL
|
|
214
|
+
);
|
|
215
|
+
`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const initDb = (db) => {
|
|
220
|
+
createSchema(db)
|
|
221
|
+
|
|
222
|
+
// Close any calls left open by a previous crash or unclean shutdown.
|
|
223
|
+
// Live call state is in-memory; on restart there are no active peers.
|
|
224
|
+
db.exec(`UPDATE calls SET ended_at = unixepoch() WHERE ended_at IS NULL`)
|
|
225
|
+
}
|
package/src/db/openDb.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { mkdirSync, existsSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const ensureDir = (filePath) => {
|
|
6
|
+
const dir = dirname(filePath)
|
|
7
|
+
if (dir && dir !== '.' && !existsSync(dir)) {
|
|
8
|
+
mkdirSync(dir, { recursive: true })
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const openDatabase = (filePath) => {
|
|
13
|
+
ensureDir(filePath)
|
|
14
|
+
const db = new Database(filePath)
|
|
15
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
16
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
17
|
+
return db
|
|
18
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
const MIGRATE_DIR = join(__dirname, '../../migrate')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Apply all pending migrations to the given database.
|
|
10
|
+
* Already-applied migrations are skipped. Safe to call on every boot.
|
|
11
|
+
*/
|
|
12
|
+
export async function runMigrations(db, { logger } = {}) {
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
15
|
+
filename TEXT PRIMARY KEY,
|
|
16
|
+
applied_at INTEGER NOT NULL
|
|
17
|
+
)
|
|
18
|
+
`)
|
|
19
|
+
|
|
20
|
+
const files = readdirSync(MIGRATE_DIR).filter(f => f.endsWith('.js')).sort()
|
|
21
|
+
const applied = new Set(
|
|
22
|
+
db.prepare('SELECT filename FROM _migrations').all().map(r => r.filename)
|
|
23
|
+
)
|
|
24
|
+
const pending = files.filter(f => !applied.has(f))
|
|
25
|
+
|
|
26
|
+
if (pending.length === 0) return
|
|
27
|
+
|
|
28
|
+
const insertApplied = db.prepare(
|
|
29
|
+
'INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
for (const file of pending) {
|
|
33
|
+
const { run } = await import(join(MIGRATE_DIR, file))
|
|
34
|
+
db.exec('PRAGMA foreign_keys = OFF')
|
|
35
|
+
try {
|
|
36
|
+
db.transaction(() => {
|
|
37
|
+
run(db)
|
|
38
|
+
insertApplied.run(file, Date.now())
|
|
39
|
+
})()
|
|
40
|
+
} finally {
|
|
41
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
42
|
+
}
|
|
43
|
+
logger?.info('migration.applied', { file })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IFileStore — port for file persistence.
|
|
3
|
+
*
|
|
4
|
+
* Implemented by:
|
|
5
|
+
* LocalFileStore writes to data/uploads/{uploadId}/{storedName}
|
|
6
|
+
* InMemoryFileStore stores buffers in a Map (for tests)
|
|
7
|
+
*
|
|
8
|
+
* @interface
|
|
9
|
+
*/
|
|
10
|
+
export class IFileStore {
|
|
11
|
+
/**
|
|
12
|
+
* Persist a file stream.
|
|
13
|
+
* @param {{ uploadId: string, storedName: string, stream: ReadableStream }} params
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
// eslint-disable-next-line no-unused-vars
|
|
17
|
+
async write({ uploadId, storedName, stream }) { throw new Error('Not implemented') }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Open a file for reading.
|
|
21
|
+
* @param {{ uploadId: string, storedName: string }} params
|
|
22
|
+
* @returns {Promise<ReadableStream>}
|
|
23
|
+
*/
|
|
24
|
+
// eslint-disable-next-line no-unused-vars
|
|
25
|
+
async read({ uploadId, storedName }) { throw new Error('Not implemented') }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Delete a stored file.
|
|
29
|
+
* @param {{ uploadId: string, storedName: string }} params
|
|
30
|
+
* @returns {Promise<void>}
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line no-unused-vars
|
|
33
|
+
async delete({ uploadId, storedName }) { throw new Error('Not implemented') }
|
|
34
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { randomToken, hashToken, hashPassword, verifyPassword } from '../util/crypto.js'
|
|
3
|
+
import { ServiceError } from '../util/errors.js'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
6
|
+
const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
export class AuthService {
|
|
9
|
+
constructor({ authRepo, nowFn = () => Date.now(), sessionTtlMs = DEFAULT_SESSION_TTL_MS, bootstrapToken = null }) {
|
|
10
|
+
this.authRepo = authRepo
|
|
11
|
+
this.nowFn = nowFn
|
|
12
|
+
this.sessionTtlMs = sessionTtlMs
|
|
13
|
+
this.bootstrapToken = bootstrapToken
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
createInvite({ createdByUserId, ttlMs = DEFAULT_TTL_MS, maxUses = 1, note = null, roles = ['user'] }) {
|
|
17
|
+
this.requireAdmin(createdByUserId)
|
|
18
|
+
if (!Array.isArray(roles) || roles.length === 0) throw new ServiceError('BAD_REQUEST', 'roles must be a non-empty array')
|
|
19
|
+
const inviteToken = randomToken()
|
|
20
|
+
const inviteId = newId('invite')
|
|
21
|
+
const now = this.nowFn()
|
|
22
|
+
const expiresAt = now + ttlMs
|
|
23
|
+
this.authRepo.insertInvite({ inviteId, tokenHash: hashToken(inviteToken), createdByUserId, now, expiresAt, maxUses, note, initialRolesJson: JSON.stringify(roles) })
|
|
24
|
+
return { inviteToken, inviteId, expiresAt, maxUses, roles }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
listInvites({ requestingUserId }) {
|
|
28
|
+
this.requireAdmin(requestingUserId)
|
|
29
|
+
return this.authRepo.listInvites()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
revokeInvite({ inviteId, requestingUserId }) {
|
|
33
|
+
this.requireAdmin(requestingUserId)
|
|
34
|
+
this.authRepo.deleteInvite({ inviteId })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async redeemInvite({ inviteToken, profile, password }) {
|
|
38
|
+
const invite = this.authRepo.findInviteByTokenHash({ tokenHash: hashToken(inviteToken) })
|
|
39
|
+
const now = this.nowFn()
|
|
40
|
+
if (!invite) {
|
|
41
|
+
const bootstrap = await this.tryBootstrap({ inviteToken, profile, now, password })
|
|
42
|
+
if (bootstrap) return bootstrap
|
|
43
|
+
throw new ServiceError('AUTH_FAILED', 'Invite token is invalid')
|
|
44
|
+
}
|
|
45
|
+
if (invite.expires_at <= now) throw new ServiceError('AUTH_FAILED', 'Invite token has expired')
|
|
46
|
+
if (invite.uses >= invite.max_uses) throw new ServiceError('AUTH_FAILED', 'Invite token has been used')
|
|
47
|
+
const handle = profile?.handle?.trim()
|
|
48
|
+
const displayName = profile?.display_name?.trim() || handle
|
|
49
|
+
if (!handle) throw new ServiceError('BAD_REQUEST', 'Handle is required')
|
|
50
|
+
if (!password) throw new ServiceError('BAD_REQUEST', 'Password is required')
|
|
51
|
+
if (this.authRepo.isHandleTaken({ handle })) throw new ServiceError('CONFLICT', 'Handle already taken')
|
|
52
|
+
const userId = newId('u')
|
|
53
|
+
const roles = invite.initial_roles_json ? JSON.parse(invite.initial_roles_json) : this.getDefaultRoles()
|
|
54
|
+
const passwordHash = await hashPassword(password)
|
|
55
|
+
const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
|
|
56
|
+
this.authRepo.registerUser({
|
|
57
|
+
inviteId: invite.invite_id,
|
|
58
|
+
userId, handle, displayName,
|
|
59
|
+
rolesJson: JSON.stringify(roles),
|
|
60
|
+
passwordHash, now,
|
|
61
|
+
sessionId, sessionTokenHash: hashToken(sessionToken), sessionExpiresAt: expiresAt
|
|
62
|
+
})
|
|
63
|
+
return {
|
|
64
|
+
sessionToken,
|
|
65
|
+
user: { user_id: userId, handle, display_name: displayName, roles }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async tryBootstrap({ inviteToken, profile, now, password }) {
|
|
70
|
+
if (!this.bootstrapToken || inviteToken !== this.bootstrapToken) return null
|
|
71
|
+
if (this.authRepo.getUserCount() > 0) return null
|
|
72
|
+
const handle = profile?.handle?.trim()
|
|
73
|
+
const displayName = profile?.display_name?.trim() || handle
|
|
74
|
+
if (!handle) throw new ServiceError('BAD_REQUEST', 'Handle is required')
|
|
75
|
+
if (!password) throw new ServiceError('BAD_REQUEST', 'Password is required')
|
|
76
|
+
if (this.authRepo.isHandleTaken({ handle })) throw new ServiceError('CONFLICT', 'Handle already taken')
|
|
77
|
+
const userId = newId('u')
|
|
78
|
+
const roles = ['admin']
|
|
79
|
+
const passwordHash = await hashPassword(password)
|
|
80
|
+
const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
|
|
81
|
+
this.authRepo.registerBootstrapUser({
|
|
82
|
+
userId, handle, displayName,
|
|
83
|
+
rolesJson: JSON.stringify(roles),
|
|
84
|
+
passwordHash, now,
|
|
85
|
+
sessionId, sessionTokenHash: hashToken(sessionToken), sessionExpiresAt: expiresAt
|
|
86
|
+
})
|
|
87
|
+
this.bootstrapToken = null
|
|
88
|
+
return {
|
|
89
|
+
sessionToken,
|
|
90
|
+
user: { user_id: userId, handle, display_name: displayName, roles }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async signInWithPassword({ handle, password }) {
|
|
95
|
+
if (!handle || !password) throw new ServiceError('BAD_REQUEST', 'Handle and password required')
|
|
96
|
+
const row = this.authRepo.findUserByHandle({ handle })
|
|
97
|
+
if (!row || !row.password_hash) throw new ServiceError('AUTH_FAILED', 'Invalid handle or password')
|
|
98
|
+
const isValid = await verifyPassword(password, row.password_hash)
|
|
99
|
+
if (!isValid) throw new ServiceError('AUTH_FAILED', 'Invalid handle or password')
|
|
100
|
+
const now = this.nowFn()
|
|
101
|
+
const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
|
|
102
|
+
this.authRepo.insertSession({ sessionId, userId: row.user_id, tokenHash: hashToken(sessionToken), now, expiresAt })
|
|
103
|
+
return {
|
|
104
|
+
sessionToken,
|
|
105
|
+
user: { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
createSession(userId) {
|
|
110
|
+
const now = this.nowFn()
|
|
111
|
+
const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
|
|
112
|
+
this.authRepo.insertSession({ sessionId, userId, tokenHash: hashToken(sessionToken), now, expiresAt })
|
|
113
|
+
return { sessionId, sessionToken, expiresAt }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
validateSession(sessionToken) {
|
|
117
|
+
if (!sessionToken) return null
|
|
118
|
+
const now = this.nowFn()
|
|
119
|
+
const row = this.authRepo.findSessionWithUser({ tokenHash: hashToken(sessionToken) })
|
|
120
|
+
if (!row || row.revoked_at || row.expires_at <= now) return null
|
|
121
|
+
const lastSeenAt = row.last_seen_at ?? null
|
|
122
|
+
this.authRepo.touchSession({ sessionId: row.session_id, now })
|
|
123
|
+
return {
|
|
124
|
+
session_id: row.session_id,
|
|
125
|
+
last_seen_at: lastSeenAt,
|
|
126
|
+
user: { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
revokeSession(sessionId) {
|
|
131
|
+
this.authRepo.revokeSession({ sessionId, now: this.nowFn() })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── User management (admin only) ───────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
listUsers({ requestingUserId }) {
|
|
137
|
+
this.requireAdmin(requestingUserId)
|
|
138
|
+
return this.authRepo.listUsers().map(row => ({
|
|
139
|
+
user_id: row.user_id,
|
|
140
|
+
handle: row.handle,
|
|
141
|
+
display_name: row.display_name,
|
|
142
|
+
roles: JSON.parse(row.roles_json),
|
|
143
|
+
created_at: row.created_at,
|
|
144
|
+
}))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setUserRoles({ targetUserId, roles, requestingUserId }) {
|
|
148
|
+
this.requireAdmin(requestingUserId)
|
|
149
|
+
if (!Array.isArray(roles)) throw new ServiceError('BAD_REQUEST', 'roles must be an array')
|
|
150
|
+
this.authRepo.updateUserRoles({ userId: targetUserId, rolesJson: JSON.stringify(roles) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async adminSetPassword({ targetUserId, newPassword, requestingUserId }) {
|
|
154
|
+
this.requireAdmin(requestingUserId)
|
|
155
|
+
if (!newPassword || newPassword.length < 8) throw new ServiceError('BAD_REQUEST', 'Password must be at least 8 characters')
|
|
156
|
+
const passwordHash = await hashPassword(newPassword)
|
|
157
|
+
this.authRepo.updateUserPassword({ userId: targetUserId, passwordHash })
|
|
158
|
+
this.authRepo.revokeAllUserSessions({ userId: targetUserId, now: this.nowFn() })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
adminUpdateDisplayName({ targetUserId, displayName, requestingUserId }) {
|
|
162
|
+
this.requireAdmin(requestingUserId)
|
|
163
|
+
const name = displayName?.trim()
|
|
164
|
+
if (!name) throw new ServiceError('BAD_REQUEST', 'Display name is required')
|
|
165
|
+
this.authRepo.updateUserDisplayName({ userId: targetUserId, displayName: name })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
requireAdmin(userId) {
|
|
169
|
+
const user = this.getUser(userId)
|
|
170
|
+
if (!user || !user.roles.includes('admin')) throw new ServiceError('FORBIDDEN', 'Admin role required')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getUser(userId) {
|
|
174
|
+
const row = this.authRepo.findUserById({ userId })
|
|
175
|
+
if (!row) return null
|
|
176
|
+
return { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
findInvite(inviteToken) {
|
|
180
|
+
return this.authRepo.findInviteByTokenHash({ tokenHash: hashToken(inviteToken) })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
listUsersBasic() {
|
|
184
|
+
return this.authRepo.listUsers().map(row => ({
|
|
185
|
+
user_id: row.user_id,
|
|
186
|
+
handle: row.handle,
|
|
187
|
+
display_name: row.display_name,
|
|
188
|
+
roles: JSON.parse(row.roles_json),
|
|
189
|
+
}))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getDefaultRoles() { return ['user'] }
|
|
193
|
+
|
|
194
|
+
getUserCount() {
|
|
195
|
+
return this.authRepo.getUserCount()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
isHandleTaken(handle) {
|
|
199
|
+
return this.authRepo.isHandleTaken({ handle })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_makeSessionParts(now) {
|
|
203
|
+
const sessionId = newId('s')
|
|
204
|
+
const sessionToken = randomToken(32)
|
|
205
|
+
const expiresAt = now + this.sessionTtlMs
|
|
206
|
+
return { sessionId, sessionToken, expiresAt }
|
|
207
|
+
}
|
|
208
|
+
}
|