@jacktea/pdf-viewer-server 0.1.3 → 0.1.5
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 +66 -0
- package/dist/server.js +1 -927
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @jacktea/pdf-viewer-server
|
|
2
|
+
|
|
3
|
+
Jacktea PDF Viewer 的实时协作后端服务,基于 Socket.io 实现。
|
|
4
|
+
|
|
5
|
+
## 简介
|
|
6
|
+
|
|
7
|
+
如果你希望在你的 PDF 预览器中接入多人实时协作功能(如:实时看到别人的高亮、批注、评论拉取与同步),可以使用此 Node.js 服务。
|
|
8
|
+
|
|
9
|
+
服务端主要负责多端状态同步和协同数据的分发,目前作为本地运行或自行部署的独立微服务使用。
|
|
10
|
+
|
|
11
|
+
## 特性
|
|
12
|
+
|
|
13
|
+
- ⚡ **实时光标与轨迹**:可以看到其他协作者的鼠标滚动和选择轨迹。
|
|
14
|
+
- 🔄 **秒级同步更新**:文档中的任何批注添加与修改均会基于房间隔离实时广播。
|
|
15
|
+
- 🔒 **权限拦截机制**:内置房间与用户身份概念的验证能力。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
你可以全局安装作为 CLI 服务启动:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @jacktea/pdf-viewer-server
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
或者在你的自有工程中配合脚本启动:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @jacktea/pdf-viewer-server
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 启动服务
|
|
32
|
+
|
|
33
|
+
### 1. 全局 CLI 方式 (开发调试)
|
|
34
|
+
|
|
35
|
+
全局安装后,在终端任意目录直接执行:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pdf-viewer-server
|
|
39
|
+
```
|
|
40
|
+
*(默认监听 `3000` 端口)*
|
|
41
|
+
|
|
42
|
+
### 2. 作为 Node 包在你的项目中启动
|
|
43
|
+
|
|
44
|
+
你可以通过 npm scripts 启动:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"scripts": {
|
|
49
|
+
"start:collab": "pdf-viewer-server"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. Server 配置环境变量
|
|
55
|
+
|
|
56
|
+
服务支持通过配置环境变量来控制行为,例如:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
PORT=8080 CORS_ORIGINS="https://app.example.com" pdf-viewer-server
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
主要参量:
|
|
63
|
+
- `PORT`: 启动端口 (默认 `3000`)
|
|
64
|
+
- `CORS_ORIGINS`: 允许跨域请求的域名白名单,逗号分隔
|
|
65
|
+
- `ALLOW_ALL_ORIGINS`: `true` (开启无限制 CORS,仅开发环境)
|
|
66
|
+
- `REQUIRE_AUTH`: `true` | `false` (是否必须校验认证 token)
|
package/dist/server.js
CHANGED
|
@@ -1,928 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const socket_io_1 = require("socket.io");
|
|
5
|
-
const http_1 = require("http");
|
|
6
|
-
// ============================================================================
|
|
7
|
-
// Configuration
|
|
8
|
-
// ============================================================================
|
|
9
|
-
/**
|
|
10
|
-
* Server configuration from environment variables
|
|
11
|
-
*/
|
|
12
|
-
const config = {
|
|
13
|
-
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
|
|
14
|
-
/**
|
|
15
|
-
* CORS allowed origins - comma-separated list or "*" for development only
|
|
16
|
-
* Example: "https://example.com,https://app.example.com"
|
|
17
|
-
*/
|
|
18
|
-
corsOrigins: process.env.CORS_ORIGINS || "http://localhost:5173",
|
|
19
|
-
/**
|
|
20
|
-
* Whether to allow any origin (development mode only)
|
|
21
|
-
* WARNING: Do not enable in production!
|
|
22
|
-
*/
|
|
23
|
-
allowAllOrigins: process.env.ALLOW_ALL_ORIGINS === "true",
|
|
24
|
-
/**
|
|
25
|
-
* Room cleanup timeout in milliseconds (default: 5 minutes)
|
|
26
|
-
*/
|
|
27
|
-
roomCleanupTimeout: parseInt(process.env.ROOM_CLEANUP_TIMEOUT || "300000", 10),
|
|
28
|
-
/**
|
|
29
|
-
* Whether websocket collaboration requires authentication token.
|
|
30
|
-
*/
|
|
31
|
-
requireAuth: process.env.REQUIRE_AUTH === "true",
|
|
32
|
-
/**
|
|
33
|
-
* Default role when authentication is not required and no token role is resolved.
|
|
34
|
-
* Allowed values: "user" | "viewer". Falls back to "user".
|
|
35
|
-
*/
|
|
36
|
-
defaultRole: getDefaultRole(process.env.DEFAULT_USER_ROLE),
|
|
37
|
-
/**
|
|
38
|
-
* Token-to-role mapping. Format:
|
|
39
|
-
* COLLAB_AUTH_TOKENS="tokenA:admin,tokenB:user,tokenC:viewer"
|
|
40
|
-
*/
|
|
41
|
-
authTokenRoles: parseAuthTokenRoles(process.env.COLLAB_AUTH_TOKENS || ""),
|
|
42
|
-
};
|
|
43
|
-
function parseUserRole(input) {
|
|
44
|
-
if (input === "admin" || input === "user" || input === "viewer") {
|
|
45
|
-
return input;
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
function getDefaultRole(input) {
|
|
50
|
-
const parsed = parseUserRole(input);
|
|
51
|
-
if (parsed === "viewer")
|
|
52
|
-
return "viewer";
|
|
53
|
-
return "user";
|
|
54
|
-
}
|
|
55
|
-
function parseAuthTokenRoles(raw) {
|
|
56
|
-
const entries = raw
|
|
57
|
-
.split(",")
|
|
58
|
-
.map((entry) => entry.trim())
|
|
59
|
-
.filter(Boolean);
|
|
60
|
-
const mapping = new Map();
|
|
61
|
-
for (const entry of entries) {
|
|
62
|
-
const separatorIndex = entry.indexOf(":");
|
|
63
|
-
if (separatorIndex <= 0)
|
|
64
|
-
continue;
|
|
65
|
-
const token = entry.slice(0, separatorIndex).trim();
|
|
66
|
-
const role = parseUserRole(entry.slice(separatorIndex + 1).trim());
|
|
67
|
-
if (!token || !role)
|
|
68
|
-
continue;
|
|
69
|
-
mapping.set(token, role);
|
|
70
|
-
}
|
|
71
|
-
return mapping;
|
|
72
|
-
}
|
|
73
|
-
function resolveRoleForConnection(token) {
|
|
74
|
-
const tokenRole = token ? config.authTokenRoles.get(token) : undefined;
|
|
75
|
-
if (config.requireAuth && !tokenRole) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
return tokenRole ?? config.defaultRole;
|
|
79
|
-
}
|
|
80
|
-
function readHandshakeTokenValue(value) {
|
|
81
|
-
if (typeof value === "string" && value.trim()) {
|
|
82
|
-
return value;
|
|
83
|
-
}
|
|
84
|
-
if (Array.isArray(value)) {
|
|
85
|
-
for (const entry of value) {
|
|
86
|
-
if (typeof entry === "string" && entry.trim()) {
|
|
87
|
-
return entry;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
function getHandshakeAuthToken(socket) {
|
|
94
|
-
const queryToken = readHandshakeTokenValue(socket.handshake.query.authToken);
|
|
95
|
-
if (queryToken)
|
|
96
|
-
return queryToken;
|
|
97
|
-
return readHandshakeTokenValue(socket.handshake.auth?.authToken);
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Parse CORS origins configuration
|
|
101
|
-
*/
|
|
102
|
-
function getCorsOrigins() {
|
|
103
|
-
if (config.allowAllOrigins) {
|
|
104
|
-
console.warn("[Security Warning] ALLOW_ALL_ORIGINS is enabled. Do not use in production!");
|
|
105
|
-
return "*";
|
|
106
|
-
}
|
|
107
|
-
const origins = config.corsOrigins.split(",").map(o => o.trim()).filter(Boolean);
|
|
108
|
-
return origins.length === 1 ? origins[0] : origins;
|
|
109
|
-
}
|
|
110
|
-
const httpServer = (0, http_1.createServer)();
|
|
111
|
-
const io = new socket_io_1.Server(httpServer, {
|
|
112
|
-
cors: {
|
|
113
|
-
origin: getCorsOrigins(),
|
|
114
|
-
methods: ["GET", "POST"],
|
|
115
|
-
credentials: true,
|
|
116
|
-
},
|
|
117
|
-
// Heartbeat configuration for connection health detection
|
|
118
|
-
pingInterval: 25000, // Send ping every 25 seconds
|
|
119
|
-
pingTimeout: 20000, // Wait 20 seconds for pong response
|
|
120
|
-
});
|
|
121
|
-
// ============================================================================
|
|
122
|
-
// State Management
|
|
123
|
-
// ============================================================================
|
|
124
|
-
// In-memory store (consider Redis for production)
|
|
125
|
-
const rooms = new Map();
|
|
126
|
-
// Room cleanup timers
|
|
127
|
-
const roomCleanupTimers = new Map();
|
|
128
|
-
/**
|
|
129
|
-
* Get or create a room
|
|
130
|
-
*/
|
|
131
|
-
function getOrCreateRoom(documentId) {
|
|
132
|
-
let room = rooms.get(documentId);
|
|
133
|
-
if (!room) {
|
|
134
|
-
room = {
|
|
135
|
-
annotations: [],
|
|
136
|
-
comments: [],
|
|
137
|
-
users: [],
|
|
138
|
-
mode: "normal",
|
|
139
|
-
createdAt: Date.now(),
|
|
140
|
-
lastActivityAt: Date.now(),
|
|
141
|
-
};
|
|
142
|
-
rooms.set(documentId, room);
|
|
143
|
-
}
|
|
144
|
-
return room;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Update room activity timestamp
|
|
148
|
-
*/
|
|
149
|
-
function touchRoom(documentId) {
|
|
150
|
-
const room = rooms.get(documentId);
|
|
151
|
-
if (room) {
|
|
152
|
-
room.lastActivityAt = Date.now();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Schedule room cleanup when empty
|
|
157
|
-
*/
|
|
158
|
-
function scheduleRoomCleanup(documentId) {
|
|
159
|
-
// Clear existing timer
|
|
160
|
-
const existingTimer = roomCleanupTimers.get(documentId);
|
|
161
|
-
if (existingTimer) {
|
|
162
|
-
clearTimeout(existingTimer);
|
|
163
|
-
}
|
|
164
|
-
// Schedule new cleanup
|
|
165
|
-
const timer = setTimeout(() => {
|
|
166
|
-
const room = rooms.get(documentId);
|
|
167
|
-
if (room && room.users.length === 0) {
|
|
168
|
-
rooms.delete(documentId);
|
|
169
|
-
roomCleanupTimers.delete(documentId);
|
|
170
|
-
console.log(`[${new Date().toISOString()}] Room ${documentId} cleaned up after inactivity`);
|
|
171
|
-
}
|
|
172
|
-
}, config.roomCleanupTimeout);
|
|
173
|
-
roomCleanupTimers.set(documentId, timer);
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Cancel scheduled room cleanup
|
|
177
|
-
*/
|
|
178
|
-
function cancelRoomCleanup(documentId) {
|
|
179
|
-
const timer = roomCleanupTimers.get(documentId);
|
|
180
|
-
if (timer) {
|
|
181
|
-
clearTimeout(timer);
|
|
182
|
-
roomCleanupTimers.delete(documentId);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// ============================================================================
|
|
186
|
-
// Permission Helpers
|
|
187
|
-
// ============================================================================
|
|
188
|
-
const DEFAULT_POLICY = {
|
|
189
|
-
rules: [
|
|
190
|
-
{ effect: "allow", actions: "*", roles: ["admin"] },
|
|
191
|
-
{
|
|
192
|
-
effect: "allow",
|
|
193
|
-
actions: ["annotation.view", "comment.view"],
|
|
194
|
-
roles: ["viewer"],
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
effect: "allow",
|
|
198
|
-
actions: [
|
|
199
|
-
"annotation.view",
|
|
200
|
-
"annotation.create",
|
|
201
|
-
"annotation.comment.create",
|
|
202
|
-
"comment.view",
|
|
203
|
-
],
|
|
204
|
-
roles: ["user"],
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
effect: "allow",
|
|
208
|
-
actions: ["annotation.update", "annotation.delete"],
|
|
209
|
-
roles: ["user"],
|
|
210
|
-
owner: true,
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
effect: "allow",
|
|
214
|
-
actions: [
|
|
215
|
-
"comment.update",
|
|
216
|
-
"comment.delete",
|
|
217
|
-
"comment.reply",
|
|
218
|
-
"thread.resolve",
|
|
219
|
-
"thread.reopen",
|
|
220
|
-
],
|
|
221
|
-
roles: ["user"],
|
|
222
|
-
owner: true,
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
|
-
};
|
|
226
|
-
function isViewAction(action) {
|
|
227
|
-
return action === "annotation.view" || action === "comment.view";
|
|
228
|
-
}
|
|
229
|
-
function actionMatches(rule, action) {
|
|
230
|
-
if (rule.actions === "*")
|
|
231
|
-
return true;
|
|
232
|
-
return rule.actions.includes(action);
|
|
233
|
-
}
|
|
234
|
-
function principalMatches(rule, userId, roles) {
|
|
235
|
-
const hasRoleConstraint = Array.isArray(rule.roles) && rule.roles.length > 0;
|
|
236
|
-
const hasUserConstraint = Array.isArray(rule.users) && rule.users.length > 0;
|
|
237
|
-
const roleMatched = !hasRoleConstraint || rule.roles.some((role) => roles.includes(role));
|
|
238
|
-
const userMatched = !hasUserConstraint || rule.users.includes(userId);
|
|
239
|
-
return roleMatched && userMatched;
|
|
240
|
-
}
|
|
241
|
-
function ownerMatches(rule, ownerId, userId) {
|
|
242
|
-
if (!rule.owner)
|
|
243
|
-
return true;
|
|
244
|
-
return Boolean(ownerId && ownerId === userId);
|
|
245
|
-
}
|
|
246
|
-
function evaluateRuleSet(rules, action, userId, roles, ownerId) {
|
|
247
|
-
if (!rules || rules.length === 0)
|
|
248
|
-
return undefined;
|
|
249
|
-
let hasAllow = false;
|
|
250
|
-
for (const rule of rules) {
|
|
251
|
-
if (!actionMatches(rule, action))
|
|
252
|
-
continue;
|
|
253
|
-
if (!principalMatches(rule, userId, roles))
|
|
254
|
-
continue;
|
|
255
|
-
if (!ownerMatches(rule, ownerId, userId))
|
|
256
|
-
continue;
|
|
257
|
-
if (rule.effect === "deny") {
|
|
258
|
-
return "deny";
|
|
259
|
-
}
|
|
260
|
-
hasAllow = true;
|
|
261
|
-
}
|
|
262
|
-
return hasAllow ? "allow" : undefined;
|
|
263
|
-
}
|
|
264
|
-
function evaluatePermission(options) {
|
|
265
|
-
const roles = [options.userRole ?? "user"];
|
|
266
|
-
if (options.previewMode && !isViewAction(options.action)) {
|
|
267
|
-
return { allowed: false, reason: "preview_mode" };
|
|
268
|
-
}
|
|
269
|
-
const resourceDecision = evaluateRuleSet(options.resourcePolicy?.rules, options.action, options.userId, roles, options.ownerId);
|
|
270
|
-
if (resourceDecision === "deny")
|
|
271
|
-
return { allowed: false, reason: "resource_explicit_deny" };
|
|
272
|
-
if (resourceDecision === "allow")
|
|
273
|
-
return { allowed: true };
|
|
274
|
-
const globalDecision = evaluateRuleSet(DEFAULT_POLICY.rules, options.action, options.userId, roles, options.ownerId);
|
|
275
|
-
if (globalDecision === "deny")
|
|
276
|
-
return { allowed: false, reason: "global_explicit_deny" };
|
|
277
|
-
if (globalDecision === "allow")
|
|
278
|
-
return { allowed: true };
|
|
279
|
-
return { allowed: false, reason: "no_matching_allow" };
|
|
280
|
-
}
|
|
281
|
-
function buildAnnotationCapabilities(annotation, userId, userRole, previewMode) {
|
|
282
|
-
return {
|
|
283
|
-
view: evaluatePermission({
|
|
284
|
-
action: "annotation.view",
|
|
285
|
-
userId,
|
|
286
|
-
userRole,
|
|
287
|
-
ownerId: annotation.metadata.authorId,
|
|
288
|
-
resourcePolicy: annotation.metadata.acl,
|
|
289
|
-
previewMode,
|
|
290
|
-
}).allowed,
|
|
291
|
-
update: evaluatePermission({
|
|
292
|
-
action: "annotation.update",
|
|
293
|
-
userId,
|
|
294
|
-
userRole,
|
|
295
|
-
ownerId: annotation.metadata.authorId,
|
|
296
|
-
resourcePolicy: annotation.metadata.acl,
|
|
297
|
-
previewMode,
|
|
298
|
-
}).allowed,
|
|
299
|
-
delete: evaluatePermission({
|
|
300
|
-
action: "annotation.delete",
|
|
301
|
-
userId,
|
|
302
|
-
userRole,
|
|
303
|
-
ownerId: annotation.metadata.authorId,
|
|
304
|
-
resourcePolicy: annotation.metadata.acl,
|
|
305
|
-
previewMode,
|
|
306
|
-
}).allowed,
|
|
307
|
-
commentCreate: evaluatePermission({
|
|
308
|
-
action: "annotation.comment.create",
|
|
309
|
-
userId,
|
|
310
|
-
userRole,
|
|
311
|
-
ownerId: annotation.metadata.authorId,
|
|
312
|
-
resourcePolicy: annotation.metadata.acl,
|
|
313
|
-
previewMode,
|
|
314
|
-
}).allowed,
|
|
315
|
-
resolveThread: evaluatePermission({
|
|
316
|
-
action: "thread.resolve",
|
|
317
|
-
userId,
|
|
318
|
-
userRole,
|
|
319
|
-
ownerId: annotation.metadata.authorId,
|
|
320
|
-
resourcePolicy: annotation.metadata.acl,
|
|
321
|
-
previewMode,
|
|
322
|
-
}).allowed,
|
|
323
|
-
reopenThread: evaluatePermission({
|
|
324
|
-
action: "thread.reopen",
|
|
325
|
-
userId,
|
|
326
|
-
userRole,
|
|
327
|
-
ownerId: annotation.metadata.authorId,
|
|
328
|
-
resourcePolicy: annotation.metadata.acl,
|
|
329
|
-
previewMode,
|
|
330
|
-
}).allowed,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
function buildCommentCapabilities(comment, annotation, userId, userRole, previewMode) {
|
|
334
|
-
const canReply = evaluatePermission({
|
|
335
|
-
action: "comment.reply",
|
|
336
|
-
userId,
|
|
337
|
-
userRole,
|
|
338
|
-
ownerId: comment.authorId,
|
|
339
|
-
resourcePolicy: comment.acl,
|
|
340
|
-
previewMode,
|
|
341
|
-
}).allowed;
|
|
342
|
-
return {
|
|
343
|
-
view: evaluatePermission({
|
|
344
|
-
action: "comment.view",
|
|
345
|
-
userId,
|
|
346
|
-
userRole,
|
|
347
|
-
ownerId: comment.authorId,
|
|
348
|
-
resourcePolicy: comment.acl,
|
|
349
|
-
previewMode,
|
|
350
|
-
}).allowed,
|
|
351
|
-
update: evaluatePermission({
|
|
352
|
-
action: "comment.update",
|
|
353
|
-
userId,
|
|
354
|
-
userRole,
|
|
355
|
-
ownerId: comment.authorId,
|
|
356
|
-
resourcePolicy: comment.acl,
|
|
357
|
-
previewMode,
|
|
358
|
-
}).allowed,
|
|
359
|
-
delete: evaluatePermission({
|
|
360
|
-
action: "comment.delete",
|
|
361
|
-
userId,
|
|
362
|
-
userRole,
|
|
363
|
-
ownerId: comment.authorId,
|
|
364
|
-
resourcePolicy: comment.acl,
|
|
365
|
-
previewMode,
|
|
366
|
-
}).allowed,
|
|
367
|
-
reply: canReply,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
function buildThreadCapabilities(annotation, thread, userId, userRole, previewMode) {
|
|
371
|
-
return {
|
|
372
|
-
resolve: evaluatePermission({
|
|
373
|
-
action: "thread.resolve",
|
|
374
|
-
userId,
|
|
375
|
-
userRole,
|
|
376
|
-
ownerId: annotation.metadata.authorId,
|
|
377
|
-
resourcePolicy: annotation.metadata.acl,
|
|
378
|
-
previewMode,
|
|
379
|
-
}).allowed,
|
|
380
|
-
reopen: evaluatePermission({
|
|
381
|
-
action: "thread.reopen",
|
|
382
|
-
userId,
|
|
383
|
-
userRole,
|
|
384
|
-
ownerId: annotation.metadata.authorId,
|
|
385
|
-
resourcePolicy: annotation.metadata.acl,
|
|
386
|
-
previewMode,
|
|
387
|
-
}).allowed,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
function sanitizeAnnotationPayload(input) {
|
|
391
|
-
const { capabilities: _capabilities, ...annotation } = input;
|
|
392
|
-
return {
|
|
393
|
-
...annotation,
|
|
394
|
-
metadata: {
|
|
395
|
-
...annotation.metadata,
|
|
396
|
-
},
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
function sanitizeCommentPayload(input) {
|
|
400
|
-
const { capabilities: _capabilities, ...comment } = input;
|
|
401
|
-
return {
|
|
402
|
-
...comment,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
function buildSyncPayload(room, userId, userRole) {
|
|
406
|
-
const visibleAnnotations = [];
|
|
407
|
-
for (const annotation of room.annotations) {
|
|
408
|
-
const capabilities = buildAnnotationCapabilities(annotation, userId, userRole, room.mode === "preview");
|
|
409
|
-
if (!capabilities.view) {
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
visibleAnnotations.push({
|
|
413
|
-
...annotation,
|
|
414
|
-
capabilities,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
const visibleAnnotationMap = new Map(visibleAnnotations.map((annotation) => [annotation.id, annotation]));
|
|
418
|
-
const visibleThreads = [];
|
|
419
|
-
for (const thread of room.comments) {
|
|
420
|
-
const annotation = visibleAnnotationMap.get(thread.targetAnnotationId);
|
|
421
|
-
if (!annotation)
|
|
422
|
-
continue;
|
|
423
|
-
const visibleComments = [];
|
|
424
|
-
for (const comment of thread.comments) {
|
|
425
|
-
const capabilities = buildCommentCapabilities(comment, annotation, userId, userRole, room.mode === "preview");
|
|
426
|
-
if (!capabilities.view) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
visibleComments.push({
|
|
430
|
-
...comment,
|
|
431
|
-
capabilities,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
visibleThreads.push({
|
|
435
|
-
...thread,
|
|
436
|
-
comments: visibleComments,
|
|
437
|
-
capabilities: buildThreadCapabilities(annotation, thread, userId, userRole, room.mode === "preview"),
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
return {
|
|
441
|
-
annotations: visibleAnnotations,
|
|
442
|
-
comments: visibleThreads,
|
|
443
|
-
activeUsers: room.users,
|
|
444
|
-
documentMode: room.mode,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
function canSetDocumentMode(userRole) {
|
|
448
|
-
return userRole === "admin";
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Validate annotation change payload
|
|
452
|
-
*/
|
|
453
|
-
function isValidAnnotationChange(change) {
|
|
454
|
-
if (!change || typeof change !== "object")
|
|
455
|
-
return false;
|
|
456
|
-
const c = change;
|
|
457
|
-
const validTypes = ["add", "update", "remove"];
|
|
458
|
-
return typeof c.type === "string" && validTypes.includes(c.type);
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* Validate comment change payload
|
|
462
|
-
*/
|
|
463
|
-
function isValidCommentChange(change) {
|
|
464
|
-
if (!change || typeof change !== "object")
|
|
465
|
-
return false;
|
|
466
|
-
const c = change;
|
|
467
|
-
const validTypes = ["add", "update", "remove"];
|
|
468
|
-
return typeof c.type === "string" && validTypes.includes(c.type);
|
|
469
|
-
}
|
|
470
|
-
function toTimestamp(value) {
|
|
471
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
472
|
-
return value;
|
|
473
|
-
}
|
|
474
|
-
if (typeof value === "string") {
|
|
475
|
-
const trimmed = value.trim();
|
|
476
|
-
if (!trimmed)
|
|
477
|
-
return undefined;
|
|
478
|
-
const asNumber = Number(trimmed);
|
|
479
|
-
if (Number.isFinite(asNumber)) {
|
|
480
|
-
return asNumber;
|
|
481
|
-
}
|
|
482
|
-
const parsed = Date.parse(trimmed);
|
|
483
|
-
if (!Number.isNaN(parsed)) {
|
|
484
|
-
return parsed;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
return undefined;
|
|
488
|
-
}
|
|
489
|
-
io.use((socket, next) => {
|
|
490
|
-
if (!config.requireAuth) {
|
|
491
|
-
next();
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
const authToken = getHandshakeAuthToken(socket);
|
|
495
|
-
const resolvedRole = resolveRoleForConnection(authToken);
|
|
496
|
-
if (!resolvedRole) {
|
|
497
|
-
console.warn(`[${new Date().toISOString()}] Unauthorized handshake: socket=${socket.id}, ip=${socket.handshake.address}`);
|
|
498
|
-
next(new Error("Unauthorized collaboration connection"));
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
next();
|
|
502
|
-
});
|
|
503
|
-
// ============================================================================
|
|
504
|
-
// Socket Event Handlers
|
|
505
|
-
// ============================================================================
|
|
506
|
-
io.on("connection", (socket) => {
|
|
507
|
-
console.log(`[${new Date().toISOString()}] New connection: ${socket.id}`);
|
|
508
|
-
// Error handling for this socket
|
|
509
|
-
socket.on("error", (error) => {
|
|
510
|
-
console.error(`[${new Date().toISOString()}] Socket error for ${socket.id}:`, error.message);
|
|
511
|
-
});
|
|
512
|
-
socket.on("join-document", ({ documentId, user, token }) => {
|
|
513
|
-
// Validate input
|
|
514
|
-
if (!documentId || typeof documentId !== "string") {
|
|
515
|
-
socket.emit("error", { message: "Invalid document ID" });
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
if (!user || typeof user.id !== "string") {
|
|
519
|
-
socket.emit("error", { message: "Invalid user information" });
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
const payloadToken = typeof token === "string" ? token : undefined;
|
|
523
|
-
const handshakeToken = getHandshakeAuthToken(socket);
|
|
524
|
-
const authToken = config.requireAuth ? handshakeToken : payloadToken || handshakeToken;
|
|
525
|
-
const resolvedRole = resolveRoleForConnection(authToken);
|
|
526
|
-
if (!resolvedRole) {
|
|
527
|
-
socket.emit("error", { message: "Unauthorized collaboration connection" });
|
|
528
|
-
console.warn(`[${new Date().toISOString()}] Unauthorized join attempt: socket=${socket.id}, document=${documentId}`);
|
|
529
|
-
socket.disconnect(true);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
socket.join(documentId);
|
|
533
|
-
// Cancel any pending cleanup for this room
|
|
534
|
-
cancelRoomCleanup(documentId);
|
|
535
|
-
const room = getOrCreateRoom(documentId);
|
|
536
|
-
const existingUser = room.users.find((u) => u.id === user.id);
|
|
537
|
-
if (!existingUser) {
|
|
538
|
-
room.users.push({
|
|
539
|
-
id: user.id,
|
|
540
|
-
name: user.name,
|
|
541
|
-
color: user.color,
|
|
542
|
-
avatar: user.avatar,
|
|
543
|
-
role: resolvedRole,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
existingUser.name = user.name ?? existingUser.name;
|
|
548
|
-
existingUser.color = user.color ?? existingUser.color;
|
|
549
|
-
existingUser.avatar = user.avatar ?? existingUser.avatar;
|
|
550
|
-
existingUser.role = resolvedRole;
|
|
551
|
-
}
|
|
552
|
-
// Associate socket with user/room for cleanup
|
|
553
|
-
socket.documentId = documentId;
|
|
554
|
-
socket.userId = user.id;
|
|
555
|
-
socket.userRole = resolvedRole;
|
|
556
|
-
// Count actual socket connections in this room
|
|
557
|
-
const socketsInRoom = io.sockets.adapter.rooms.get(documentId)?.size || 0;
|
|
558
|
-
console.log(`[${new Date().toISOString()}] User ${user.name} (${user.id}) joined document ${documentId} as ${resolvedRole}. Unique users: ${room.users.length}, Connections: ${socketsInRoom}`);
|
|
559
|
-
// Send initial sync
|
|
560
|
-
socket.emit("sync", buildSyncPayload(room, user.id, resolvedRole));
|
|
561
|
-
// Broadcast user joined
|
|
562
|
-
io.to(documentId).emit("users:update", room.users);
|
|
563
|
-
touchRoom(documentId);
|
|
564
|
-
});
|
|
565
|
-
socket.on("client:change", (payload) => {
|
|
566
|
-
const { documentId, userId, userRole } = socket;
|
|
567
|
-
if (!documentId || !userId)
|
|
568
|
-
return;
|
|
569
|
-
const room = rooms.get(documentId);
|
|
570
|
-
if (!room)
|
|
571
|
-
return;
|
|
572
|
-
// Validate payload structure
|
|
573
|
-
if (!payload || typeof payload !== "object" || !payload.type) {
|
|
574
|
-
console.warn(`[${new Date().toISOString()}] Invalid change payload from ${socket.id}`);
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
const previewMode = room.mode === "preview";
|
|
578
|
-
const deny = (action, reason) => {
|
|
579
|
-
const message = reason === "preview_mode"
|
|
580
|
-
? "PERMISSION_DENIED_PREVIEW_MODE"
|
|
581
|
-
: "PERMISSION_DENIED";
|
|
582
|
-
socket.emit("error", { message, action, reason });
|
|
583
|
-
};
|
|
584
|
-
const can = (action, options = {}) => {
|
|
585
|
-
const decision = evaluatePermission({
|
|
586
|
-
action,
|
|
587
|
-
userId,
|
|
588
|
-
userRole,
|
|
589
|
-
ownerId: options.ownerId,
|
|
590
|
-
resourcePolicy: options.resourcePolicy,
|
|
591
|
-
previewMode,
|
|
592
|
-
});
|
|
593
|
-
if (!decision.allowed) {
|
|
594
|
-
deny(action, decision.reason);
|
|
595
|
-
}
|
|
596
|
-
return decision.allowed;
|
|
597
|
-
};
|
|
598
|
-
let outboundPayload = null;
|
|
599
|
-
if (payload.type === "annotation") {
|
|
600
|
-
const { change } = payload;
|
|
601
|
-
if (!isValidAnnotationChange(change)) {
|
|
602
|
-
console.warn(`[${new Date().toISOString()}] Invalid annotation change from ${socket.id}`);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
// Apply change
|
|
606
|
-
if (change.type === "add" && change.annotation) {
|
|
607
|
-
const inputAnnotation = sanitizeAnnotationPayload(change.annotation);
|
|
608
|
-
if (!can("annotation.create")) {
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
// Ensure authorId is set to the current user
|
|
612
|
-
const annotationToAdd = {
|
|
613
|
-
...inputAnnotation,
|
|
614
|
-
metadata: {
|
|
615
|
-
...inputAnnotation.metadata,
|
|
616
|
-
authorId: userId,
|
|
617
|
-
createdAt: toTimestamp(inputAnnotation.metadata?.createdAt) ?? Date.now(),
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
room.annotations.push(annotationToAdd);
|
|
621
|
-
outboundPayload = {
|
|
622
|
-
type: "annotation",
|
|
623
|
-
change: {
|
|
624
|
-
type: "add",
|
|
625
|
-
annotation: annotationToAdd,
|
|
626
|
-
},
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
else if (change.type === "update" && change.annotation) {
|
|
630
|
-
const inputAnnotation = sanitizeAnnotationPayload(change.annotation);
|
|
631
|
-
const existingAnnotation = room.annotations.find((item) => item.id === inputAnnotation.id);
|
|
632
|
-
if (!existingAnnotation) {
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
const previousStatus = existingAnnotation.metadata.status === "resolved" ? "resolved" : "open";
|
|
636
|
-
const requestedStatus = inputAnnotation.metadata.status === "resolved"
|
|
637
|
-
? "resolved"
|
|
638
|
-
: inputAnnotation.metadata.status === "open"
|
|
639
|
-
? "open"
|
|
640
|
-
: previousStatus;
|
|
641
|
-
const statusChanged = requestedStatus !== previousStatus;
|
|
642
|
-
const stripStatusFields = (annotation) => {
|
|
643
|
-
const { status: _status, updatedAt: _updatedAt, ...metadata } = annotation.metadata;
|
|
644
|
-
return {
|
|
645
|
-
...annotation,
|
|
646
|
-
metadata,
|
|
647
|
-
};
|
|
648
|
-
};
|
|
649
|
-
const isStatusOnlyUpdate = JSON.stringify(stripStatusFields(existingAnnotation)) ===
|
|
650
|
-
JSON.stringify(stripStatusFields(inputAnnotation));
|
|
651
|
-
if (!isStatusOnlyUpdate &&
|
|
652
|
-
!can("annotation.update", {
|
|
653
|
-
ownerId: existingAnnotation.metadata.authorId,
|
|
654
|
-
resourcePolicy: existingAnnotation.metadata.acl,
|
|
655
|
-
})) {
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
if (statusChanged &&
|
|
659
|
-
!can(requestedStatus === "resolved" ? "thread.resolve" : "thread.reopen", {
|
|
660
|
-
ownerId: existingAnnotation.metadata.authorId,
|
|
661
|
-
resourcePolicy: existingAnnotation.metadata.acl,
|
|
662
|
-
})) {
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
const idx = room.annotations.findIndex(a => a.id === inputAnnotation.id);
|
|
666
|
-
if (idx !== -1) {
|
|
667
|
-
const updatedAnnotation = {
|
|
668
|
-
...inputAnnotation,
|
|
669
|
-
metadata: {
|
|
670
|
-
...inputAnnotation.metadata,
|
|
671
|
-
status: requestedStatus,
|
|
672
|
-
updatedAt: Date.now(),
|
|
673
|
-
}
|
|
674
|
-
};
|
|
675
|
-
room.annotations[idx] = updatedAnnotation;
|
|
676
|
-
outboundPayload = {
|
|
677
|
-
type: "annotation",
|
|
678
|
-
change: {
|
|
679
|
-
type: "update",
|
|
680
|
-
annotation: updatedAnnotation,
|
|
681
|
-
},
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
else if (change.type === "remove" && change.annotation) {
|
|
686
|
-
const existingAnnotation = room.annotations.find((item) => item.id === change.annotation?.id);
|
|
687
|
-
if (!existingAnnotation) {
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
if (!can("annotation.delete", {
|
|
691
|
-
ownerId: existingAnnotation.metadata.authorId,
|
|
692
|
-
resourcePolicy: existingAnnotation.metadata.acl,
|
|
693
|
-
})) {
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
room.annotations = room.annotations.filter(a => a.id !== change.annotation?.id);
|
|
697
|
-
outboundPayload = {
|
|
698
|
-
type: "annotation",
|
|
699
|
-
change: {
|
|
700
|
-
type: "remove",
|
|
701
|
-
annotation: existingAnnotation,
|
|
702
|
-
},
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
else if (payload.type === "comment") {
|
|
707
|
-
const { change } = payload;
|
|
708
|
-
if (!isValidCommentChange(change)) {
|
|
709
|
-
console.warn(`[${new Date().toISOString()}] Invalid comment change from ${socket.id}`);
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
if (change.type === "add" && change.comment) {
|
|
713
|
-
const inputComment = sanitizeCommentPayload(change.comment);
|
|
714
|
-
const targetAnnotation = room.annotations.find((item) => item.id === inputComment.targetAnnotationId);
|
|
715
|
-
if (!targetAnnotation) {
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
if (inputComment.parentId) {
|
|
719
|
-
const existingThread = room.comments.find((item) => item.targetAnnotationId === inputComment.targetAnnotationId);
|
|
720
|
-
const parentComment = existingThread?.comments.find((item) => item.id === inputComment.parentId);
|
|
721
|
-
if (!parentComment) {
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
if (!can("comment.reply", {
|
|
725
|
-
ownerId: parentComment.authorId,
|
|
726
|
-
resourcePolicy: parentComment.acl,
|
|
727
|
-
})) {
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
else if (!can("annotation.comment.create", {
|
|
732
|
-
ownerId: targetAnnotation.metadata.authorId,
|
|
733
|
-
resourcePolicy: targetAnnotation.metadata.acl,
|
|
734
|
-
})) {
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
// Find or create thread for this comment
|
|
738
|
-
let thread = room.comments.find(t => t.targetAnnotationId === inputComment.targetAnnotationId);
|
|
739
|
-
if (!thread) {
|
|
740
|
-
thread = {
|
|
741
|
-
id: inputComment.targetAnnotationId,
|
|
742
|
-
targetAnnotationId: inputComment.targetAnnotationId,
|
|
743
|
-
comments: [],
|
|
744
|
-
};
|
|
745
|
-
room.comments.push(thread);
|
|
746
|
-
}
|
|
747
|
-
// Ensure authorId is set to current user
|
|
748
|
-
const commentToAdd = {
|
|
749
|
-
...inputComment,
|
|
750
|
-
authorId: userId,
|
|
751
|
-
createdAt: toTimestamp(inputComment.createdAt) ?? Date.now(),
|
|
752
|
-
};
|
|
753
|
-
thread.comments.push(commentToAdd);
|
|
754
|
-
outboundPayload = {
|
|
755
|
-
type: "comment",
|
|
756
|
-
change: {
|
|
757
|
-
type: "add",
|
|
758
|
-
comment: commentToAdd,
|
|
759
|
-
},
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
else if (change.type === "update" && change.comment) {
|
|
763
|
-
const inputComment = sanitizeCommentPayload(change.comment);
|
|
764
|
-
// Find the thread and comment
|
|
765
|
-
const thread = room.comments.find(t => t.targetAnnotationId === inputComment.targetAnnotationId);
|
|
766
|
-
if (thread) {
|
|
767
|
-
const existingComment = thread.comments.find(c => c.id === inputComment.id);
|
|
768
|
-
if (existingComment) {
|
|
769
|
-
if (!can("comment.update", {
|
|
770
|
-
ownerId: existingComment.authorId,
|
|
771
|
-
resourcePolicy: existingComment.acl,
|
|
772
|
-
})) {
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
// Update the comment
|
|
776
|
-
const idx = thread.comments.findIndex(c => c.id === inputComment.id);
|
|
777
|
-
if (idx !== -1) {
|
|
778
|
-
const updatedComment = {
|
|
779
|
-
...inputComment,
|
|
780
|
-
updatedAt: Date.now(),
|
|
781
|
-
};
|
|
782
|
-
thread.comments[idx] = updatedComment;
|
|
783
|
-
outboundPayload = {
|
|
784
|
-
type: "comment",
|
|
785
|
-
change: {
|
|
786
|
-
type: "update",
|
|
787
|
-
comment: updatedComment,
|
|
788
|
-
},
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
else if (change.type === "remove" && change.comment) {
|
|
795
|
-
const inputComment = sanitizeCommentPayload(change.comment);
|
|
796
|
-
// Find the thread and comment
|
|
797
|
-
const thread = room.comments.find(t => t.targetAnnotationId === inputComment.targetAnnotationId);
|
|
798
|
-
if (thread) {
|
|
799
|
-
const existingComment = thread.comments.find(c => c.id === inputComment.id);
|
|
800
|
-
if (existingComment) {
|
|
801
|
-
if (!can("comment.delete", {
|
|
802
|
-
ownerId: existingComment.authorId,
|
|
803
|
-
resourcePolicy: existingComment.acl,
|
|
804
|
-
})) {
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
// Remove the comment
|
|
808
|
-
thread.comments = thread.comments.filter(c => c.id !== inputComment.id);
|
|
809
|
-
outboundPayload = {
|
|
810
|
-
type: "comment",
|
|
811
|
-
change: {
|
|
812
|
-
type: "remove",
|
|
813
|
-
comment: existingComment,
|
|
814
|
-
},
|
|
815
|
-
};
|
|
816
|
-
// If thread is empty, remove the thread as well
|
|
817
|
-
if (thread.comments.length === 0) {
|
|
818
|
-
room.comments = room.comments.filter(t => t.id !== thread.id);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// Broadcast to other clients
|
|
825
|
-
if (outboundPayload) {
|
|
826
|
-
socket.broadcast.to(documentId).emit("remote:change", outboundPayload);
|
|
827
|
-
}
|
|
828
|
-
const roomSockets = io.sockets.adapter.rooms.get(documentId);
|
|
829
|
-
if (roomSockets) {
|
|
830
|
-
for (const socketId of roomSockets) {
|
|
831
|
-
const roomSocket = io.sockets.sockets.get(socketId);
|
|
832
|
-
if (!roomSocket?.userId)
|
|
833
|
-
continue;
|
|
834
|
-
roomSocket.emit("sync", buildSyncPayload(room, roomSocket.userId, roomSocket.userRole));
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
touchRoom(documentId);
|
|
838
|
-
});
|
|
839
|
-
socket.on("document:set-mode", ({ mode }) => {
|
|
840
|
-
const { documentId, userRole } = socket;
|
|
841
|
-
if (!documentId)
|
|
842
|
-
return;
|
|
843
|
-
const room = rooms.get(documentId);
|
|
844
|
-
if (!room)
|
|
845
|
-
return;
|
|
846
|
-
if (mode !== "normal" && mode !== "preview") {
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
if (!canSetDocumentMode(userRole)) {
|
|
850
|
-
socket.emit("error", { message: "PERMISSION_DENIED", action: "document.setMode" });
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
if (room.mode === mode) {
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
room.mode = mode;
|
|
857
|
-
io.to(documentId).emit("document:mode:changed", mode);
|
|
858
|
-
const roomSockets = io.sockets.adapter.rooms.get(documentId);
|
|
859
|
-
if (!roomSockets)
|
|
860
|
-
return;
|
|
861
|
-
for (const socketId of roomSockets) {
|
|
862
|
-
const roomSocket = io.sockets.sockets.get(socketId);
|
|
863
|
-
if (!roomSocket?.userId)
|
|
864
|
-
continue;
|
|
865
|
-
roomSocket.emit("sync", buildSyncPayload(room, roomSocket.userId, roomSocket.userRole));
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
socket.on("client:interaction", (payload) => {
|
|
869
|
-
const { documentId, userId } = socket;
|
|
870
|
-
if (!documentId || !userId)
|
|
871
|
-
return;
|
|
872
|
-
if (!rooms.has(documentId))
|
|
873
|
-
return;
|
|
874
|
-
// Validate payload
|
|
875
|
-
if (!payload || typeof payload !== "object" || !payload.type) {
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
// Ensure userId matches the socket's authenticated user
|
|
879
|
-
const safePayload = {
|
|
880
|
-
...payload,
|
|
881
|
-
userId, // Override with authenticated user ID
|
|
882
|
-
};
|
|
883
|
-
// Interactions are ephemeral, just broadcast
|
|
884
|
-
socket.broadcast.to(documentId).emit("remote:interaction", safePayload);
|
|
885
|
-
});
|
|
886
|
-
socket.on("disconnect", () => {
|
|
887
|
-
const { documentId, userId } = socket;
|
|
888
|
-
if (documentId && rooms.has(documentId)) {
|
|
889
|
-
const room = rooms.get(documentId);
|
|
890
|
-
// Check if this user has any other active connections in this room
|
|
891
|
-
const roomSockets = io.sockets.adapter.rooms.get(documentId);
|
|
892
|
-
let userHasOtherConnections = false;
|
|
893
|
-
if (roomSockets) {
|
|
894
|
-
for (const socketId of roomSockets) {
|
|
895
|
-
if (socketId === socket.id)
|
|
896
|
-
continue;
|
|
897
|
-
const otherSocket = io.sockets.sockets.get(socketId);
|
|
898
|
-
if (otherSocket?.userId === userId) {
|
|
899
|
-
userHasOtherConnections = true;
|
|
900
|
-
break;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
// Only remove user from list if they have no other connections
|
|
905
|
-
if (!userHasOtherConnections) {
|
|
906
|
-
room.users = room.users.filter(u => u.id !== userId);
|
|
907
|
-
io.to(documentId).emit("users:update", room.users);
|
|
908
|
-
}
|
|
909
|
-
const remainingConnections = (roomSockets?.size || 1) - 1; // Subtract the disconnecting socket
|
|
910
|
-
console.log(`[${new Date().toISOString()}] User ${userId} disconnected from ${documentId}. Remaining connections: ${remainingConnections}, Unique users: ${room.users.length}`);
|
|
911
|
-
// Schedule cleanup if room is empty
|
|
912
|
-
if (room.users.length === 0) {
|
|
913
|
-
console.log(`[${new Date().toISOString()}] Room ${documentId} is now empty, scheduling cleanup`);
|
|
914
|
-
scheduleRoomCleanup(documentId);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
});
|
|
918
|
-
});
|
|
919
|
-
// ============================================================================
|
|
920
|
-
// Server Startup
|
|
921
|
-
// ============================================================================
|
|
922
|
-
httpServer.listen(config.port, () => {
|
|
923
|
-
console.log(`[${new Date().toISOString()}] Collaboration server running on port ${config.port}`);
|
|
924
|
-
console.log(`[${new Date().toISOString()}] CORS origins: ${getCorsOrigins()}`);
|
|
925
|
-
console.log(`[${new Date().toISOString()}] Heartbeat: pingInterval=25s, pingTimeout=20s`);
|
|
926
|
-
console.log(`[${new Date().toISOString()}] Room cleanup timeout: ${config.roomCleanupTimeout}ms`);
|
|
927
|
-
console.log(`[${new Date().toISOString()}] Auth mode: requireAuth=${config.requireAuth}, tokenRoles=${config.authTokenRoles.size}, defaultRole=${config.defaultRole}`);
|
|
928
|
-
});
|
|
2
|
+
"use strict";var _=require("socket.io"),E=require("http"),p={port:process.env.PORT?parseInt(process.env.PORT,10):3e3,corsOrigins:process.env.CORS_ORIGINS||"http://localhost:5173",allowAllOrigins:process.env.ALLOW_ALL_ORIGINS==="true",roomCleanupTimeout:parseInt(process.env.ROOM_CLEANUP_TIMEOUT||"300000",10),requireAuth:process.env.REQUIRE_AUTH==="true",defaultRole:j(process.env.DEFAULT_USER_ROLE),authTokenRoles:F(process.env.COLLAB_AUTH_TOKENS||"")};function N(e){if(e==="admin"||e==="user"||e==="viewer")return e}function j(e){return N(e)==="viewer"?"viewer":"user"}function F(e){let t=e.split(",").map(o=>o.trim()).filter(Boolean),n=new Map;for(let o of t){let s=o.indexOf(":");if(s<=0)continue;let i=o.slice(0,s).trim(),d=N(o.slice(s+1).trim());!i||!d||n.set(i,d)}return n}function M(e){let t=e?p.authTokenRoles.get(e):void 0;return p.requireAuth&&!t?null:t??p.defaultRole}function O(e){if(typeof e=="string"&&e.trim())return e;if(Array.isArray(e)){for(let t of e)if(typeof t=="string"&&t.trim())return t}}function x(e){let t=O(e.handshake.query.authToken);return t||O(e.handshake.auth?.authToken)}function L(){if(p.allowAllOrigins)return console.warn("[Security Warning] ALLOW_ALL_ORIGINS is enabled. Do not use in production!"),"*";let e=p.corsOrigins.split(",").map(t=>t.trim()).filter(Boolean);return e.length===1?e[0]:e}var q=(0,E.createServer)(),w=new _.Server(q,{cors:{origin:L(),methods:["GET","POST"],credentials:!0},pingInterval:25e3,pingTimeout:2e4}),I=new Map,C=new Map;function H(e){let t=I.get(e);return t||(t={annotations:[],comments:[],users:[],mode:"normal",createdAt:Date.now(),lastActivityAt:Date.now()},I.set(e,t)),t}function D(e){let t=I.get(e);t&&(t.lastActivityAt=Date.now())}function V(e){let t=C.get(e);t&&clearTimeout(t);let n=setTimeout(()=>{let o=I.get(e);o&&o.users.length===0&&(I.delete(e),C.delete(e),console.log(`[${new Date().toISOString()}] Room ${e} cleaned up after inactivity`))},p.roomCleanupTimeout);C.set(e,n)}function B(e){let t=C.get(e);t&&(clearTimeout(t),C.delete(e))}var G={rules:[{effect:"allow",actions:"*",roles:["admin"]},{effect:"allow",actions:["annotation.view","comment.view"],roles:["viewer"]},{effect:"allow",actions:["annotation.view","annotation.create","annotation.comment.create","comment.view"],roles:["user"]},{effect:"allow",actions:["annotation.update","annotation.delete"],roles:["user"],owner:!0},{effect:"allow",actions:["comment.update","comment.delete","comment.reply","thread.resolve","thread.reopen"],roles:["user"],owner:!0}]};function W(e){return e==="annotation.view"||e==="comment.view"}function J(e,t){return e.actions==="*"?!0:e.actions.includes(t)}function K(e,t,n){let o=Array.isArray(e.roles)&&e.roles.length>0,s=Array.isArray(e.users)&&e.users.length>0,i=!o||e.roles.some(l=>n.includes(l)),d=!s||e.users.includes(t);return i&&d}function Q(e,t,n){return e.owner?!!(t&&t===n):!0}function $(e,t,n,o,s){if(!e||e.length===0)return;let i=!1;for(let d of e)if(J(d,t)&&K(d,n,o)&&Q(d,s,n)){if(d.effect==="deny")return"deny";i=!0}return i?"allow":void 0}function h(e){let t=[e.userRole??"user"];if(e.previewMode&&!W(e.action))return{allowed:!1,reason:"preview_mode"};let n=$(e.resourcePolicy?.rules,e.action,e.userId,t,e.ownerId);if(n==="deny")return{allowed:!1,reason:"resource_explicit_deny"};if(n==="allow")return{allowed:!0};let o=$(G.rules,e.action,e.userId,t,e.ownerId);return o==="deny"?{allowed:!1,reason:"global_explicit_deny"}:o==="allow"?{allowed:!0}:{allowed:!1,reason:"no_matching_allow"}}function Y(e,t,n,o){return{view:h({action:"annotation.view",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed,update:h({action:"annotation.update",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed,delete:h({action:"annotation.delete",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed,commentCreate:h({action:"annotation.comment.create",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed,resolveThread:h({action:"thread.resolve",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed,reopenThread:h({action:"thread.reopen",userId:t,userRole:n,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:o}).allowed}}function X(e,t,n,o,s){let i=h({action:"comment.reply",userId:n,userRole:o,ownerId:e.authorId,resourcePolicy:e.acl,previewMode:s}).allowed;return{view:h({action:"comment.view",userId:n,userRole:o,ownerId:e.authorId,resourcePolicy:e.acl,previewMode:s}).allowed,update:h({action:"comment.update",userId:n,userRole:o,ownerId:e.authorId,resourcePolicy:e.acl,previewMode:s}).allowed,delete:h({action:"comment.delete",userId:n,userRole:o,ownerId:e.authorId,resourcePolicy:e.acl,previewMode:s}).allowed,reply:i}}function Z(e,t,n,o,s){return{resolve:h({action:"thread.resolve",userId:n,userRole:o,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:s}).allowed,reopen:h({action:"thread.reopen",userId:n,userRole:o,ownerId:e.metadata.authorId,resourcePolicy:e.metadata.acl,previewMode:s}).allowed}}function U(e){let{capabilities:t,...n}=e;return{...n,metadata:{...n.metadata}}}function R(e){let{capabilities:t,...n}=e;return{...n}}function P(e,t,n){let o=[];for(let d of e.annotations){let l=Y(d,t,n,e.mode==="preview");l.view&&o.push({...d,capabilities:l})}let s=new Map(o.map(d=>[d.id,d])),i=[];for(let d of e.comments){let l=s.get(d.targetAnnotationId);if(!l)continue;let f=[];for(let u of d.comments){let v=X(u,l,t,n,e.mode==="preview");v.view&&f.push({...u,capabilities:v})}i.push({...d,comments:f,capabilities:Z(l,d,t,n,e.mode==="preview")})}return{annotations:o,comments:i,activeUsers:e.users,documentMode:e.mode}}function ee(e){return e==="admin"}function te(e){if(!e||typeof e!="object")return!1;let t=e,n=["add","update","remove"];return typeof t.type=="string"&&n.includes(t.type)}function ne(e){if(!e||typeof e!="object")return!1;let t=e,n=["add","update","remove"];return typeof t.type=="string"&&n.includes(t.type)}function k(e){if(typeof e=="number"&&Number.isFinite(e))return e;if(typeof e=="string"){let t=e.trim();if(!t)return;let n=Number(t);if(Number.isFinite(n))return n;let o=Date.parse(t);if(!Number.isNaN(o))return o}}w.use((e,t)=>{if(!p.requireAuth){t();return}let n=x(e);if(!M(n)){console.warn(`[${new Date().toISOString()}] Unauthorized handshake: socket=${e.id}, ip=${e.handshake.address}`),t(new Error("Unauthorized collaboration connection"));return}t()});w.on("connection",e=>{console.log(`[${new Date().toISOString()}] New connection: ${e.id}`),e.on("error",t=>{console.error(`[${new Date().toISOString()}] Socket error for ${e.id}:`,t.message)}),e.on("join-document",({documentId:t,user:n,token:o})=>{if(!t||typeof t!="string"){e.emit("error",{message:"Invalid document ID"});return}if(!n||typeof n.id!="string"){e.emit("error",{message:"Invalid user information"});return}let s=typeof o=="string"?o:void 0,i=x(e),d=p.requireAuth?i:s||i,l=M(d);if(!l){e.emit("error",{message:"Unauthorized collaboration connection"}),console.warn(`[${new Date().toISOString()}] Unauthorized join attempt: socket=${e.id}, document=${t}`),e.disconnect(!0);return}e.join(t),B(t);let f=H(t),u=f.users.find(c=>c.id===n.id);u?(u.name=n.name??u.name,u.color=n.color??u.color,u.avatar=n.avatar??u.avatar,u.role=l):f.users.push({id:n.id,name:n.name,color:n.color,avatar:n.avatar,role:l}),e.documentId=t,e.userId=n.id,e.userRole=l;let v=w.sockets.adapter.rooms.get(t)?.size||0;console.log(`[${new Date().toISOString()}] User ${n.name} (${n.id}) joined document ${t} as ${l}. Unique users: ${f.users.length}, Connections: ${v}`),e.emit("sync",P(f,n.id,l)),w.to(t).emit("users:update",f.users),D(t)}),e.on("client:change",t=>{let{documentId:n,userId:o,userRole:s}=e;if(!n||!o)return;let i=I.get(n);if(!i)return;if(!t||typeof t!="object"||!t.type){console.warn(`[${new Date().toISOString()}] Invalid change payload from ${e.id}`);return}let d=i.mode==="preview",l=(c,a)=>{let r=a==="preview_mode"?"PERMISSION_DENIED_PREVIEW_MODE":"PERMISSION_DENIED";e.emit("error",{message:r,action:c,reason:a})},f=(c,a={})=>{let r=h({action:c,userId:o,userRole:s,ownerId:a.ownerId,resourcePolicy:a.resourcePolicy,previewMode:d});return r.allowed||l(c,r.reason),r.allowed},u=null;if(t.type==="annotation"){let{change:c}=t;if(!te(c)){console.warn(`[${new Date().toISOString()}] Invalid annotation change from ${e.id}`);return}if(c.type==="add"&&c.annotation){let a=U(c.annotation);if(!f("annotation.create"))return;let r={...a,metadata:{...a.metadata,authorId:o,createdAt:k(a.metadata?.createdAt)??Date.now()}};i.annotations.push(r),u={type:"annotation",change:{type:"add",annotation:r}}}else if(c.type==="update"&&c.annotation){let a=U(c.annotation),r=i.annotations.find(A=>A.id===a.id);if(!r)return;let m=r.metadata.status==="resolved"?"resolved":"open",g=a.metadata.status==="resolved"?"resolved":a.metadata.status==="open"?"open":m,y=g!==m,b=A=>{let{status:oe,updatedAt:ae,...z}=A.metadata;return{...A,metadata:z}};if(!(JSON.stringify(b(r))===JSON.stringify(b(a)))&&!f("annotation.update",{ownerId:r.metadata.authorId,resourcePolicy:r.metadata.acl})||y&&!f(g==="resolved"?"thread.resolve":"thread.reopen",{ownerId:r.metadata.authorId,resourcePolicy:r.metadata.acl}))return;let T=i.annotations.findIndex(A=>A.id===a.id);if(T!==-1){let A={...a,metadata:{...a.metadata,status:g,updatedAt:Date.now()}};i.annotations[T]=A,u={type:"annotation",change:{type:"update",annotation:A}}}}else if(c.type==="remove"&&c.annotation){let a=i.annotations.find(r=>r.id===c.annotation?.id);if(!a||!f("annotation.delete",{ownerId:a.metadata.authorId,resourcePolicy:a.metadata.acl}))return;i.annotations=i.annotations.filter(r=>r.id!==c.annotation?.id),u={type:"annotation",change:{type:"remove",annotation:a}}}}else if(t.type==="comment"){let{change:c}=t;if(!ne(c)){console.warn(`[${new Date().toISOString()}] Invalid comment change from ${e.id}`);return}if(c.type==="add"&&c.comment){let a=R(c.comment),r=i.annotations.find(y=>y.id===a.targetAnnotationId);if(!r)return;if(a.parentId){let b=i.comments.find(S=>S.targetAnnotationId===a.targetAnnotationId)?.comments.find(S=>S.id===a.parentId);if(!b||!f("comment.reply",{ownerId:b.authorId,resourcePolicy:b.acl}))return}else if(!f("annotation.comment.create",{ownerId:r.metadata.authorId,resourcePolicy:r.metadata.acl}))return;let m=i.comments.find(y=>y.targetAnnotationId===a.targetAnnotationId);m||(m={id:a.targetAnnotationId,targetAnnotationId:a.targetAnnotationId,comments:[]},i.comments.push(m));let g={...a,authorId:o,createdAt:k(a.createdAt)??Date.now()};m.comments.push(g),u={type:"comment",change:{type:"add",comment:g}}}else if(c.type==="update"&&c.comment){let a=R(c.comment),r=i.comments.find(m=>m.targetAnnotationId===a.targetAnnotationId);if(r){let m=r.comments.find(g=>g.id===a.id);if(m){if(!f("comment.update",{ownerId:m.authorId,resourcePolicy:m.acl}))return;let g=r.comments.findIndex(y=>y.id===a.id);if(g!==-1){let y={...a,updatedAt:Date.now()};r.comments[g]=y,u={type:"comment",change:{type:"update",comment:y}}}}}}else if(c.type==="remove"&&c.comment){let a=R(c.comment),r=i.comments.find(m=>m.targetAnnotationId===a.targetAnnotationId);if(r){let m=r.comments.find(g=>g.id===a.id);if(m){if(!f("comment.delete",{ownerId:m.authorId,resourcePolicy:m.acl}))return;r.comments=r.comments.filter(g=>g.id!==a.id),u={type:"comment",change:{type:"remove",comment:m}},r.comments.length===0&&(i.comments=i.comments.filter(g=>g.id!==r.id))}}}}u&&e.broadcast.to(n).emit("remote:change",u);let v=w.sockets.adapter.rooms.get(n);if(v)for(let c of v){let a=w.sockets.sockets.get(c);a?.userId&&a.emit("sync",P(i,a.userId,a.userRole))}D(n)}),e.on("document:set-mode",({mode:t})=>{let{documentId:n,userRole:o}=e;if(!n)return;let s=I.get(n);if(!s||t!=="normal"&&t!=="preview")return;if(!ee(o)){e.emit("error",{message:"PERMISSION_DENIED",action:"document.setMode"});return}if(s.mode===t)return;s.mode=t,w.to(n).emit("document:mode:changed",t);let i=w.sockets.adapter.rooms.get(n);if(i)for(let d of i){let l=w.sockets.sockets.get(d);l?.userId&&l.emit("sync",P(s,l.userId,l.userRole))}}),e.on("client:interaction",t=>{let{documentId:n,userId:o}=e;if(!n||!o||!I.has(n)||!t||typeof t!="object"||!t.type)return;let s={...t,userId:o};e.broadcast.to(n).emit("remote:interaction",s)}),e.on("disconnect",()=>{let{documentId:t,userId:n}=e;if(t&&I.has(t)){let o=I.get(t),s=w.sockets.adapter.rooms.get(t),i=!1;if(s)for(let l of s){if(l===e.id)continue;if(w.sockets.sockets.get(l)?.userId===n){i=!0;break}}i||(o.users=o.users.filter(l=>l.id!==n),w.to(t).emit("users:update",o.users));let d=(s?.size||1)-1;console.log(`[${new Date().toISOString()}] User ${n} disconnected from ${t}. Remaining connections: ${d}, Unique users: ${o.users.length}`),o.users.length===0&&(console.log(`[${new Date().toISOString()}] Room ${t} is now empty, scheduling cleanup`),V(t))}})});q.listen(p.port,()=>{console.log(`[${new Date().toISOString()}] Collaboration server running on port ${p.port}`),console.log(`[${new Date().toISOString()}] CORS origins: ${L()}`),console.log(`[${new Date().toISOString()}] Heartbeat: pingInterval=25s, pingTimeout=20s`),console.log(`[${new Date().toISOString()}] Room cleanup timeout: ${p.roomCleanupTimeout}ms`),console.log(`[${new Date().toISOString()}] Auth mode: requireAuth=${p.requireAuth}, tokenRoles=${p.authTokenRoles.size}, defaultRole=${p.defaultRole}`)});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacktea/pdf-viewer-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"bin": {
|
|
6
6
|
"pdf-viewer-server": "./dist/server.js"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
-
"build": "
|
|
22
|
+
"build": "tsup src/server.ts --format cjs --minify --clean",
|
|
23
23
|
"start": "node ./dist/server.js",
|
|
24
24
|
"dev": "nodemon --exec ts-node src/server.ts"
|
|
25
25
|
}
|