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
287 lines
7.9 KiB
TypeScript
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,
|
|
};
|