changemaker.lite/api/src/modules/docs/docs-access.service.ts
bunker-admin 8b9ab93856 Add docs CMS: blog authoring, access policies, sharing, version history, templates, metadata, search, Gitea auto-setup
7 documentation system features:
- Blog authoring: frontmatter panel, new post wizard, authors management
- Access policies: per-file/directory edit restrictions with role/user granularity
- Public sharing: share links with collaborative editing via dual JWT auth
- Version history: Gitea auto-commit on save, diff viewer, restore
- Document templates: 8 built-in templates (blog, guide, API ref, ADR, FAQ, etc.)
- Metadata dashboard: overview of all docs with warnings (no-tags, stale, etc.)
- Content search: in-file text search with line-level matches

Gitea auto-setup: one-click configuration of API token, repos, labels, OAuth app
- Backend service + startup hook (auto-configures if GITEA_ADMIN_PASSWORD set)
- Admin GUI wizard at /app/services/gitea/setup
- config.sh now prompts for Gitea admin password

Backend: 10 new files, 5 modified (3 models, 1 enum, 2 migrations, 30+ API endpoints)
Frontend: 13 new files, 3 modified (hooks, components, pages)

Bunker Admin
2026-03-27 13:28:52 -06:00

287 lines
7.9 KiB
TypeScript

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<EffectivePolicy> {
// 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<boolean> {
// 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<void> {
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<void> {
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,
};