@jacktea/pdf-viewer-server 0.1.3

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.
Files changed (2) hide show
  1. package/dist/server.js +928 -0
  2. package/package.json +26 -0
package/dist/server.js ADDED
@@ -0,0 +1,928 @@
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
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@jacktea/pdf-viewer-server",
3
+ "version": "0.1.3",
4
+ "private": false,
5
+ "bin": {
6
+ "pdf-viewer-server": "./dist/server.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "dependencies": {
12
+ "socket.io": "^4.7.5",
13
+ "ts-node": "^10.9.2"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^20.12.7",
17
+ "typescript": "^5.4.5",
18
+ "nodemon": "^3.1.0"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "build": "tsc -p tsconfig.json",
23
+ "start": "node ./dist/server.js",
24
+ "dev": "nodemon --exec ts-node src/server.ts"
25
+ }
26
+ }