import crypto from 'crypto'; import jwt from 'jsonwebtoken'; import { UserRole } from '@prisma/client'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; import { docsFilesService } from './docs-files.service'; import type { CreateShareLinkInput } from './docs-access.schemas'; // --- Access Policy Resolution --- interface EffectivePolicy { id: string | null; documentPath: string; isDirectory: boolean; allowedEditors: string[]; isDefault: boolean; // true if no policy found (all content editors) } /** * Resolve the most specific applicable policy for a document path. * Walks from exact path up through parent directories. * Returns default "all content editors" if no policy found. */ async function getEffectivePolicy(documentPath: string): Promise { // Normalize path: remove leading/trailing slashes const normalized = documentPath.replace(/^\/+|\/+$/g, ''); // 1. Check for exact file match const exactPolicy = await prisma.docAccessPolicy.findUnique({ where: { documentPath: normalized }, }); if (exactPolicy) { return { id: exactPolicy.id, documentPath: exactPolicy.documentPath, isDirectory: exactPolicy.isDirectory, allowedEditors: exactPolicy.allowedEditors as string[], isDefault: false, }; } // 2. Walk up directory hierarchy looking for directory policies const segments = normalized.split('/'); for (let i = segments.length - 1; i >= 1; i--) { const dirPath = segments.slice(0, i).join('/'); const dirPolicy = await prisma.docAccessPolicy.findUnique({ where: { documentPath: dirPath }, }); if (dirPolicy && dirPolicy.isDirectory) { return { id: dirPolicy.id, documentPath: dirPolicy.documentPath, isDirectory: true, allowedEditors: dirPolicy.allowedEditors as string[], isDefault: false, }; } } // 3. No policy found — default to all content editors return { id: null, documentPath: normalized, isDirectory: false, allowedEditors: ['all_content_editors'], isDefault: true, }; } /** * Check if a user can edit a specific document. * SUPER_ADMIN always passes. */ async function canUserEdit( userId: string, userRoles: UserRole[], documentPath: string, ): Promise { // SUPER_ADMIN always passes if (userRoles.includes(UserRole.SUPER_ADMIN)) return true; const policy = await getEffectivePolicy(documentPath); for (const editor of policy.allowedEditors) { if (editor === 'all_content_editors') { // Check if user has any CONTENT_ROLES if (userRoles.includes(UserRole.CONTENT_ADMIN) || userRoles.includes(UserRole.SUPER_ADMIN)) { return true; } } else if (editor.startsWith('user:')) { if (editor === `user:${userId}`) return true; } else if (editor.startsWith('role:')) { const role = editor.substring(5) as UserRole; if (userRoles.includes(role)) return true; } } return false; } // --- Share Link Management --- interface ShareLinkOptions { canEdit?: boolean; expiresInHours?: number; maxUses?: number; guestName?: string; } /** * Generate a share link for a document. */ async function generateShareLink( createdById: string, documentPath: string, options: ShareLinkOptions = {}, ): Promise<{ id: string; shareToken: string; documentPath: string }> { // Validate the file exists docsFilesService.safeResolve(documentPath); const shareToken = crypto.randomBytes(24).toString('hex'); const expiresAt = options.expiresInHours ? new Date(Date.now() + options.expiresInHours * 60 * 60 * 1000) : null; const link = await prisma.docShareLink.create({ data: { documentPath: documentPath.replace(/^\/+|\/+$/g, ''), shareToken, canEdit: options.canEdit ?? true, expiresAt, maxUses: options.maxUses ?? null, guestName: options.guestName ?? null, createdById, }, }); return { id: link.id, shareToken: link.shareToken, documentPath: link.documentPath }; } /** * Validate a share token. Returns the share link or throws. * Increments use count on successful validation. */ async function validateShareToken(shareToken: string): Promise<{ id: string; documentPath: string; canEdit: boolean; guestName: string | null; }> { const link = await prisma.docShareLink.findUnique({ where: { shareToken }, }); if (!link) throw new ShareLinkError('Share link not found', 'SHARE_LINK_NOT_FOUND'); if (link.status !== 'ACTIVE') throw new ShareLinkError('Share link has been revoked', 'SHARE_LINK_REVOKED'); if (link.expiresAt && link.expiresAt < new Date()) { // Auto-expire await prisma.docShareLink.update({ where: { id: link.id }, data: { status: 'EXPIRED' }, }); throw new ShareLinkError('Share link has expired', 'SHARE_LINK_EXPIRED'); } if (link.maxUses && link.useCount >= link.maxUses) { throw new ShareLinkError('Share link has reached maximum uses', 'SHARE_LINK_MAX_USES'); } // Increment use count await prisma.docShareLink.update({ where: { id: link.id }, data: { useCount: { increment: 1 } }, }); return { id: link.id, documentPath: link.documentPath, canEdit: link.canEdit, guestName: link.guestName, }; } /** * Generate a short-lived JWT for a share-link guest to use with the collab WebSocket. */ function generateShareCollabToken(shareLink: { shareToken: string; documentPath: string; canEdit: boolean; guestName: string | null; }): string { return jwt.sign( { type: 'doc_share', shareToken: shareLink.shareToken, documentPath: shareLink.documentPath, canEdit: shareLink.canEdit, guestName: shareLink.guestName || 'Guest', }, env.JWT_INVITE_SECRET, { expiresIn: '4h' }, ); } // --- Cascade Operations --- /** * Update access policies and share links when a file is renamed. */ async function cascadeRename(oldPath: string, newPath: string): Promise { const normalizedOld = oldPath.replace(/^\/+|\/+$/g, ''); const normalizedNew = newPath.replace(/^\/+|\/+$/g, ''); try { // Update exact match policy await prisma.docAccessPolicy.updateMany({ where: { documentPath: normalizedOld }, data: { documentPath: normalizedNew }, }); // Update share links await prisma.docShareLink.updateMany({ where: { documentPath: normalizedOld }, data: { documentPath: normalizedNew }, }); // Update doc watches await prisma.docWatch.updateMany({ where: { filePath: normalizedOld }, data: { filePath: normalizedNew }, }); logger.info(`Docs access: cascaded rename ${normalizedOld} → ${normalizedNew}`); } catch (err) { logger.warn('Failed to cascade rename for docs access:', err); } } /** * Revoke share links and delete policy when a file is deleted. */ async function cascadeDelete(path: string): Promise { const normalized = path.replace(/^\/+|\/+$/g, ''); try { // Revoke active share links await prisma.docShareLink.updateMany({ where: { documentPath: normalized, status: 'ACTIVE' }, data: { status: 'REVOKED' }, }); // Delete access policy await prisma.docAccessPolicy.deleteMany({ where: { documentPath: normalized }, }); // Delete watches await prisma.docWatch.deleteMany({ where: { filePath: normalized }, }); logger.info(`Docs access: cascaded delete for ${normalized}`); } catch (err) { logger.warn('Failed to cascade delete for docs access:', err); } } export class ShareLinkError extends Error { code: string; constructor(message: string, code: string) { super(message); this.name = 'ShareLinkError'; this.code = code; } } export const docsAccessService = { getEffectivePolicy, canUserEdit, generateShareLink, validateShareToken, generateShareCollabToken, cascadeRename, cascadeDelete, };