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
This commit is contained in:
bunker-admin 2026-03-27 13:28:52 -06:00
parent 0fc9ea80bf
commit 8b9ab93856
37 changed files with 6273 additions and 61 deletions

View File

@ -32,6 +32,7 @@ import CodeEditorPage from '@/pages/CodeEditorPage';
import NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage';
import GiteaSetupPage from '@/pages/GiteaSetupPage';
import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage';
@ -45,6 +46,7 @@ import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import DocsCommentsPage from '@/pages/DocsCommentsPage';
import DocsMetadataPage from '@/pages/DocsMetadataPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage';
@ -153,6 +155,7 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
@ -313,6 +316,9 @@ export default function App() {
<Route index element={<ContactProfilePage />} />
</Route>
{/* Shared doc editor (no auth, token-based access) */}
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
@ -604,6 +610,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="docs/metadata"
element={
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsMetadataPage />
</ProtectedRoute>
}
/>
<Route
path="navigation"
element={
@ -644,6 +658,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="services/gitea/setup"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<GiteaSetupPage />
</ProtectedRoute>
}
/>
<Route
path="services/mailhog"
element={

View File

@ -231,6 +231,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
@ -338,6 +339,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/gitea/setup', icon: <SettingOutlined />, label: 'Gitea Setup' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},

View File

@ -0,0 +1,391 @@
import { useState, useEffect } from 'react';
import {
Modal,
Button,
Input,
Space,
Typography,
Popconfirm,
message,
theme,
Divider,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
CloseOutlined,
UserOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
type AuthorsMap = Record<string, AuthorEntry>;
interface AuthorsManagementModalProps {
open: boolean;
onClose: () => void;
authors: AuthorsMap;
onSaved: () => void;
}
interface AuthorFormState {
id: string;
name: string;
description: string;
avatar: string;
}
const emptyForm: AuthorFormState = { id: '', name: '', description: '', avatar: '' };
export function AuthorsManagementModal({
open,
onClose,
authors,
onSaved,
}: AuthorsManagementModalProps) {
const [messageApi, contextHolder] = message.useMessage();
const [localAuthors, setLocalAuthors] = useState<AuthorsMap>({});
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AuthorFormState>(emptyForm);
const [addingNew, setAddingNew] = useState(false);
const [newForm, setNewForm] = useState<AuthorFormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
// Sync from props when modal opens
useEffect(() => {
if (open) {
setLocalAuthors({ ...authors });
setEditingKey(null);
setAddingNew(false);
setNewForm(emptyForm);
setDirty(false);
}
}, [open, authors]);
const startEdit = (key: string) => {
const entry = localAuthors[key];
if (!entry) return;
setEditingKey(key);
setEditForm({
id: key,
name: entry.name,
description: entry.description ?? '',
avatar: entry.avatar ?? '',
});
};
const cancelEdit = () => {
setEditingKey(null);
setEditForm(emptyForm);
};
const saveEdit = () => {
if (!editForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
const updated = { ...localAuthors };
// If key changed, remove old and add new
if (editingKey && editingKey !== editForm.id && editForm.id.trim()) {
delete updated[editingKey];
}
const key = editForm.id.trim() || editingKey!;
updated[key] = buildEntry(editForm);
setLocalAuthors(updated);
setEditingKey(null);
setEditForm(emptyForm);
setDirty(true);
};
const deleteAuthor = (key: string) => {
const updated = { ...localAuthors };
delete updated[key];
setLocalAuthors(updated);
setDirty(true);
if (editingKey === key) cancelEdit();
};
const saveNewAuthor = () => {
if (!newForm.id.trim()) {
messageApi.warning('Author ID is required');
return;
}
if (!newForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
if (localAuthors[newForm.id.trim()]) {
messageApi.warning('An author with this ID already exists');
return;
}
const updated = { ...localAuthors };
updated[newForm.id.trim()] = buildEntry(newForm);
setLocalAuthors(updated);
setNewForm(emptyForm);
setAddingNew(false);
setDirty(true);
};
const handleSaveAll = async () => {
setSaving(true);
try {
await api.put('/docs/blog/authors', { authors: localAuthors });
messageApi.success('Authors saved');
setDirty(false);
onSaved();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to save authors';
messageApi.error(msg);
} finally {
setSaving(false);
}
};
const authorEntries = Object.entries(localAuthors);
return (
<Modal
title={
<span>
<UserOutlined style={{ marginRight: 8 }} />
Manage Authors
</span>
}
open={open}
onCancel={onClose}
footer={
<Space>
<Button onClick={onClose}>Close</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
</Space>
}
destroyOnHidden
width={560}
>
{contextHolder}
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{authorEntries.length === 0 && !addingNew && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No authors defined yet"
style={{ margin: '24px 0' }}
/>
)}
{authorEntries.map(([key, entry]) => (
<div key={key}>
{editingKey === key ? (
<AuthorEditForm
form={editForm}
onChange={setEditForm}
onSave={saveEdit}
onCancel={cancelEdit}
showId
/>
) : (
<AuthorRow
id={key}
entry={entry}
onEdit={() => startEdit(key)}
onDelete={() => deleteAuthor(key)}
/>
)}
<Divider style={{ margin: '8px 0' }} />
</div>
))}
{/* New author form */}
{addingNew ? (
<AuthorEditForm
form={newForm}
onChange={setNewForm}
onSave={saveNewAuthor}
onCancel={() => {
setAddingNew(false);
setNewForm(emptyForm);
}}
showId
isNew
/>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setAddingNew(true)}
block
style={{ marginTop: 8 }}
>
Add Author
</Button>
)}
</div>
</Modal>
);
}
function buildEntry(form: AuthorFormState): AuthorEntry {
const entry: AuthorEntry = { name: form.name.trim() };
if (form.description.trim()) entry.description = form.description.trim();
if (form.avatar.trim()) entry.avatar = form.avatar.trim();
return entry;
}
/** Read-only author row */
function AuthorRow({
id,
entry,
onEdit,
onDelete,
}: {
id: string;
entry: AuthorEntry;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
padding: '8px 4px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Typography.Text strong>{entry.name}</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace' }}
>
{id}
</Typography.Text>
</div>
{entry.description && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginTop: 2 }}
>
{entry.description}
</Typography.Text>
)}
{entry.avatar && (
<Typography.Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Avatar: {entry.avatar}
</Typography.Text>
)}
</div>
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} />
<Popconfirm
title="Delete this author?"
onConfirm={onDelete}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
);
}
/** Inline edit / create form for an author */
function AuthorEditForm({
form,
onChange,
onSave,
onCancel,
showId,
isNew,
}: {
form: AuthorFormState;
onChange: (f: AuthorFormState) => void;
onSave: () => void;
onCancel: () => void;
showId?: boolean;
isNew?: boolean;
}) {
const { token } = theme.useToken();
return (
<div
style={{
padding: '10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{showId && (
<Input
size="small"
placeholder="Author ID (e.g. jdoe)"
value={form.id}
onChange={(e) => onChange({ ...form, id: e.target.value })}
addonBefore="ID"
disabled={!isNew && !!form.id}
/>
)}
<Input
size="small"
placeholder="Display name"
value={form.name}
onChange={(e) => onChange({ ...form, name: e.target.value })}
addonBefore="Name"
autoFocus
/>
<Input
size="small"
placeholder="Short description (optional)"
value={form.description}
onChange={(e) => onChange({ ...form, description: e.target.value })}
addonBefore="Desc"
/>
<Input
size="small"
placeholder="Avatar URL (optional)"
value={form.avatar}
onChange={(e) => onChange({ ...form, avatar: e.target.value })}
addonBefore="Avatar"
/>
<Space size={8}>
<Button size="small" type="primary" icon={<SaveOutlined />} onClick={onSave}>
{isNew ? 'Add' : 'Update'}
</Button>
<Button size="small" icon={<CloseOutlined />} onClick={onCancel}>
Cancel
</Button>
</Space>
</div>
);
}

View File

@ -0,0 +1,265 @@
import { DatePicker, Select, Switch, Input, Typography, theme, Button, Tooltip } from 'antd';
import {
CalendarOutlined,
UserOutlined,
TagsOutlined,
FolderOutlined,
FileTextOutlined,
EyeInvisibleOutlined,
LinkOutlined,
TeamOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BlogFrontmatter } from '@/hooks/useBlogFrontmatter';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface BlogFrontmatterPanelProps {
frontmatter: BlogFrontmatter | null;
onUpdate: (field: string, value: unknown) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
loading: boolean;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onManageAuthors: () => void;
}
const PANEL_WIDTH = 250;
export function BlogFrontmatterPanel({
frontmatter,
onUpdate,
authors,
categories,
loading,
collapsed,
onCollapsedChange,
onManageAuthors,
}: BlogFrontmatterPanelProps) {
const { token } = theme.useToken();
if (!frontmatter) return null;
const authorOptions = Object.entries(authors).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = categories.map((cat) => ({
label: cat,
value: cat,
}));
if (collapsed) {
return (
<div
style={{
width: 32,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: 8,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Tooltip title="Show blog panel" placement="left">
<Button
type="text"
size="small"
icon={<LeftOutlined />}
onClick={() => onCollapsedChange(false)}
/>
</Tooltip>
</div>
);
}
return (
<div
style={{
width: PANEL_WIDTH,
flexShrink: 0,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Typography.Text strong style={{ fontSize: 13 }}>
Blog
</Typography.Text>
<Tooltip title="Collapse panel">
<Button
type="text"
size="small"
icon={<RightOutlined />}
onClick={() => onCollapsedChange(true)}
/>
</Tooltip>
</div>
{/* Fields */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{/* Date */}
<FieldGroup icon={<CalendarOutlined />} label="Date">
<DatePicker
value={frontmatter.date ? dayjs(frontmatter.date) : undefined}
onChange={(d) => onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)}
format="YYYY-MM-DD"
size="small"
style={{ width: '100%' }}
allowClear
/>
</FieldGroup>
{/* Authors */}
<FieldGroup
icon={<UserOutlined />}
label="Authors"
extra={
<Tooltip title="Manage authors">
<Button
type="link"
size="small"
icon={<TeamOutlined />}
onClick={onManageAuthors}
style={{ padding: 0, height: 'auto', fontSize: 11 }}
/>
</Tooltip>
}
>
<Select
mode="multiple"
size="small"
placeholder="Select authors"
value={frontmatter.authors ?? []}
onChange={(val) => onUpdate('authors', val)}
options={authorOptions}
loading={loading}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Categories */}
<FieldGroup icon={<FolderOutlined />} label="Categories">
<Select
mode="tags"
size="small"
placeholder="Add categories"
value={frontmatter.categories ?? []}
onChange={(val) => onUpdate('categories', val)}
options={categoryOptions}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Tags */}
<FieldGroup icon={<TagsOutlined />} label="Tags">
<Select
mode="tags"
size="small"
placeholder="Add tags"
value={frontmatter.tags ?? []}
onChange={(val) => onUpdate('tags', val)}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Draft */}
<FieldGroup icon={<EyeInvisibleOutlined />} label="Draft">
<Switch
size="small"
checked={frontmatter.draft ?? false}
onChange={(checked) => onUpdate('draft', checked || undefined)}
/>
</FieldGroup>
{/* Slug */}
<FieldGroup icon={<LinkOutlined />} label="Slug">
<Input
size="small"
placeholder="auto-generated"
value={frontmatter.slug ?? ''}
onChange={(e) => onUpdate('slug', e.target.value || undefined)}
allowClear
/>
</FieldGroup>
{/* Description */}
<FieldGroup icon={<FileTextOutlined />} label="Description">
<Input.TextArea
size="small"
rows={3}
placeholder="Post description / excerpt"
value={frontmatter.description ?? ''}
onChange={(e) => onUpdate('description', e.target.value || undefined)}
/>
</FieldGroup>
</div>
</div>
);
}
/** Small labeled field wrapper */
function FieldGroup({
icon,
label,
extra,
children,
}: {
icon: React.ReactNode;
label: string;
extra?: React.ReactNode;
children: React.ReactNode;
}) {
const { token } = theme.useToken();
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 4,
}}
>
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>{icon}</span>
<Typography.Text
style={{ fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.3 }}
>
{label}
</Typography.Text>
{extra && <span style={{ marginLeft: 'auto' }}>{extra}</span>}
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,322 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Spin,
Tag,
Space,
Typography,
Divider,
message,
Alert,
} from 'antd';
import {
LockOutlined,
TeamOutlined,
UserOutlined,
SaveOutlined,
UndoOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
const { Text, Title } = Typography;
interface DocAccessPolicyPanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface EffectivePolicy {
id: string | null;
documentPath: string;
isDirectory: boolean;
allowedEditors: string[];
isDefault: boolean;
}
const AVAILABLE_ROLES: { value: string; label: string }[] = [
{ value: 'role:SUPER_ADMIN', label: 'Super Admin' },
{ value: 'role:CONTENT_ADMIN', label: 'Content Admin' },
{ value: 'role:INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'role:MAP_ADMIN', label: 'Map Admin' },
{ value: 'role:BROADCAST_ADMIN', label: 'Broadcast Admin' },
{ value: 'role:MEDIA_ADMIN', label: 'Media Admin' },
{ value: 'role:PAYMENTS_ADMIN', label: 'Payments Admin' },
{ value: 'role:EVENTS_ADMIN', label: 'Events Admin' },
{ value: 'role:SOCIAL_ADMIN', label: 'Social Admin' },
];
function editorLabel(editor: string): string {
if (editor === 'all_content_editors') return 'All Content Editors';
if (editor.startsWith('role:')) {
const roleName = editor.substring(5);
return roleName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
if (editor.startsWith('user:')) return editor.substring(5);
return editor;
}
function editorColor(editor: string): string {
if (editor === 'all_content_editors') return 'green';
if (editor.startsWith('role:')) return 'blue';
if (editor.startsWith('user:')) return 'purple';
return 'default';
}
export function DocAccessPolicyPanel({ open, onClose, documentPath }: DocAccessPolicyPanelProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [policy, setPolicy] = useState<EffectivePolicy | null>(null);
const [allContentEditors, setAllContentEditors] = useState(true);
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [userEmails, setUserEmails] = useState<string[]>([]);
const [isDirectory, setIsDirectory] = useState(false);
const fetchPolicy = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<EffectivePolicy>('/docs-access/policy', {
params: { path: documentPath },
});
setPolicy(data);
// Parse allowedEditors into form state
const hasAllContentEditors = data.allowedEditors.includes('all_content_editors');
setAllContentEditors(hasAllContentEditors);
setIsDirectory(data.isDirectory);
const roles: string[] = [];
const users: string[] = [];
for (const editor of data.allowedEditors) {
if (editor === 'all_content_editors') continue;
if (editor.startsWith('role:')) roles.push(editor);
else if (editor.startsWith('user:')) users.push(editor.substring(5));
}
setSelectedRoles(roles);
setUserEmails(users);
} catch {
message.error('Failed to load access policy');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchPolicy();
}
}, [open, documentPath, fetchPolicy]);
const handleSave = async () => {
if (!documentPath) return;
setSaving(true);
try {
const allowedEditors: string[] = [];
if (allContentEditors) {
allowedEditors.push('all_content_editors');
}
allowedEditors.push(...selectedRoles);
allowedEditors.push(...userEmails.map((e) => `user:${e}`));
if (allowedEditors.length === 0) {
message.warning('At least one editor must be specified');
setSaving(false);
return;
}
await api.put('/docs-access/policy', {
documentPath,
isDirectory,
allowedEditors,
});
message.success('Access policy saved');
fetchPolicy();
} catch {
message.error('Failed to save access policy');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!documentPath) return;
setSaving(true);
try {
await api.delete('/docs-access/policy', {
params: { path: documentPath },
});
message.success('Policy reset to default');
fetchPolicy();
} catch {
message.error('Failed to reset policy');
} finally {
setSaving(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<LockOutlined />
<span>Access Policy</span>
</Space>
}
open={open}
onClose={onClose}
width={480}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
{policy?.isDefault && !policy.id && (
<Alert
message="Default policy"
description="No custom policy is set. All content editors can edit this file."
type="info"
showIcon
/>
)}
{policy && !policy.isDefault && (
<Alert
message="Custom policy active"
description={
policy.isDirectory
? `Directory policy applied from: ${policy.documentPath}`
: 'File-specific policy'
}
type="warning"
showIcon
/>
)}
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Current Editors
</Title>
<div>
{policy?.allowedEditors.map((editor) => (
<Tag
key={editor}
color={editorColor(editor)}
icon={
editor === 'all_content_editors' ? (
<TeamOutlined />
) : editor.startsWith('user:') ? (
<UserOutlined />
) : undefined
}
style={{ marginBottom: 4 }}
>
{editorLabel(editor)}
</Tag>
))}
</div>
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Edit Policy
</Title>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="All content editors (default)">
<Switch
checked={allContentEditors}
onChange={setAllContentEditors}
checkedChildren="On"
unCheckedChildren="Off"
/>
</Form.Item>
<Form.Item label="Additional roles">
<Select
mode="multiple"
value={selectedRoles}
onChange={setSelectedRoles}
options={AVAILABLE_ROLES}
placeholder="Select roles..."
allowClear
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Specific users (by email)">
<Select
mode="tags"
value={userEmails}
onChange={setUserEmails}
placeholder="Type email and press Enter..."
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Apply to directory">
<Switch
checked={isDirectory}
onChange={setIsDirectory}
checkedChildren="Directory"
unCheckedChildren="File only"
/>
{isDirectory && (
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
This policy will apply to all files within this directory and subdirectories.
</Text>
)}
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
Save Policy
</Button>
<Button
icon={<UndoOutlined />}
onClick={handleReset}
loading={saving}
disabled={policy?.isDefault ?? true}
>
Reset to Default
</Button>
</Space>
</Form.Item>
</Form>
</Space>
</>
)}
</Drawer>
);
}

View File

@ -0,0 +1,377 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Timeline,
Button,
Spin,
Space,
Typography,
Popconfirm,
message,
Empty,
Tag,
Divider,
theme,
} from 'antd';
import {
HistoryOutlined,
UserOutlined,
RollbackOutlined,
FileTextOutlined,
EyeOutlined,
CloseOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface DocHistoryDrawerProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
currentContent: string;
onRestore: (content: string) => void;
}
interface HistoryCommit {
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
committer: { name: string; email: string; date: string };
};
}
export function DocHistoryDrawer({
open,
onClose,
documentPath,
currentContent,
onRestore,
}: DocHistoryDrawerProps) {
const { token: themeToken } = theme.useToken();
const [loading, setLoading] = useState(false);
const [commits, setCommits] = useState<HistoryCommit[]>([]);
const [selectedSha, setSelectedSha] = useState<string | null>(null);
const [revisionContent, setRevisionContent] = useState<string | null>(null);
const [loadingRevision, setLoadingRevision] = useState(false);
const [restoring, setRestoring] = useState(false);
const fetchHistory = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ commits: HistoryCommit[] }>(
`/docs/history/${documentPath}`,
);
setCommits(data.commits);
} catch {
message.error('Failed to load version history');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchHistory();
setSelectedSha(null);
setRevisionContent(null);
}
}, [open, documentPath, fetchHistory]);
const handleSelectCommit = async (sha: string) => {
if (!documentPath) return;
if (selectedSha === sha) {
// Toggle off
setSelectedSha(null);
setRevisionContent(null);
return;
}
setSelectedSha(sha);
setLoadingRevision(true);
try {
const { data } = await api.get<{ sha: string; path: string; content: string }>(
`/docs/revision/${sha}/${documentPath}`,
);
setRevisionContent(data.content);
} catch {
message.error('Failed to load revision');
setRevisionContent(null);
} finally {
setLoadingRevision(false);
}
};
const handleRestore = async () => {
if (!documentPath || !selectedSha) return;
setRestoring(true);
try {
const { data } = await api.post<{ success: boolean; path: string }>(
`/docs/restore/${selectedSha}/${documentPath}`,
);
if (data.success && revisionContent) {
onRestore(revisionContent);
message.success('File restored to selected version');
setSelectedSha(null);
setRevisionContent(null);
fetchHistory();
} else {
message.error('Failed to restore revision');
}
} catch {
message.error('Failed to restore revision');
} finally {
setRestoring(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
// Simple line-by-line diff display
const renderDiffView = () => {
if (loadingRevision) {
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin />
</div>
);
}
if (revisionContent === null) return null;
const currentLines = currentContent.split('\n');
const historicalLines = revisionContent.split('\n');
return (
<div style={{ marginTop: 12 }}>
<Space style={{ marginBottom: 8 }}>
<Tag color="blue">Viewing revision {selectedSha?.substring(0, 7)}</Tag>
<Popconfirm
title="Restore this version?"
description="The current file content will be replaced with this revision."
onConfirm={handleRestore}
okText="Restore"
cancelText="Cancel"
>
<Button
type="primary"
size="small"
icon={<RollbackOutlined />}
loading={restoring}
>
Restore
</Button>
</Popconfirm>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => {
setSelectedSha(null);
setRevisionContent(null);
}}
>
Close
</Button>
</Space>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}
>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Historical ({selectedSha?.substring(0, 7)})
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{historicalLines.map((line, i) => {
const isDiff = i < currentLines.length && line !== currentLines[i];
const isAdded = i >= currentLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(82, 196, 26, 0.1)'
: isAdded
? 'rgba(82, 196, 26, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Current
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{currentLines.map((line, i) => {
const isDiff = i < historicalLines.length && line !== historicalLines[i];
const isAdded = i >= historicalLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(245, 34, 45, 0.1)'
: isAdded
? 'rgba(245, 34, 45, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
</div>
</div>
);
};
return (
<Drawer
title={
<Space>
<HistoryOutlined />
<span>Version History</span>
</Space>
}
open={open}
onClose={onClose}
width={selectedSha ? 800 : 420}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Empty description="No document selected" />
) : commits.length === 0 ? (
<Empty
image={<FileTextOutlined style={{ fontSize: 48, color: themeToken.colorTextDisabled }} />}
description="No version history available"
>
<Text type="secondary">
History is recorded when files are saved with Gitea integration enabled.
</Text>
</Empty>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div>
<Text type="secondary">File: </Text>
<Text strong>{fileName}</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{commits.length} revision{commits.length !== 1 ? 's' : ''}
</Text>
<Divider style={{ margin: '8px 0' }} />
<Timeline
items={commits.map((commit) => ({
color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary,
children: (
<div
key={commit.sha}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: themeToken.borderRadius,
background:
selectedSha === commit.sha
? themeToken.colorPrimaryBg
: undefined,
transition: 'background 0.2s',
}}
onClick={() => handleSelectCommit(commit.sha)}
>
<div style={{ marginBottom: 2 }}>
<Text strong style={{ fontSize: 13 }}>
{commit.commit.message}
</Text>
</div>
<Space size={8}>
<Text type="secondary" style={{ fontSize: 11 }}>
<UserOutlined style={{ marginRight: 4 }} />
{commit.commit.author.name}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(commit.commit.author.date).fromNow()}
</Text>
<Tag style={{ fontSize: 10 }}>{commit.sha.substring(0, 7)}</Tag>
</Space>
{selectedSha !== commit.sha && (
<div style={{ marginTop: 4 }}>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
handleSelectCommit(commit.sha);
}}
>
View
</Button>
</div>
)}
</div>
),
}))}
/>
{renderDiffView()}
</Space>
)}
</Drawer>
);
}

View File

@ -0,0 +1,390 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Input,
InputNumber,
Table,
Tag,
Space,
Typography,
Divider,
Tooltip,
message,
Alert,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ShareAltOutlined,
CopyOutlined,
StopOutlined,
LinkOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text, Paragraph } = Typography;
interface DocSharePanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface ShareLink {
id: string;
documentPath: string;
shareToken: string;
status: 'ACTIVE' | 'REVOKED' | 'EXPIRED';
canEdit: boolean;
expiresAt: string | null;
maxUses: number | null;
useCount: number;
guestName: string | null;
createdBy: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
}
const EXPIRY_OPTIONS = [
{ value: 1, label: '1 hour' },
{ value: 24, label: '24 hours' },
{ value: 168, label: '7 days' },
{ value: 720, label: '30 days' },
{ value: 0, label: 'No expiry' },
];
function statusTag(status: string) {
switch (status) {
case 'ACTIVE':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
Active
</Tag>
);
case 'REVOKED':
return (
<Tag icon={<CloseCircleOutlined />} color="error">
Revoked
</Tag>
);
case 'EXPIRED':
return (
<Tag icon={<ClockCircleOutlined />} color="default">
Expired
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
}
export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) {
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [links, setLinks] = useState<ShareLink[]>([]);
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
// Form state
const [canEdit, setCanEdit] = useState(true);
const [expiryHours, setExpiryHours] = useState<number>(168); // 7 days default
const [maxUses, setMaxUses] = useState<number | null>(null);
const [guestName, setGuestName] = useState('');
const fetchLinks = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ links: ShareLink[] }>('/docs-access/share/links', {
params: { path: documentPath },
});
setLinks(data.links);
} catch {
message.error('Failed to load share links');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchLinks();
setGeneratedUrl(null);
}
}, [open, documentPath, fetchLinks]);
const handleCreate = async () => {
if (!documentPath) return;
setCreating(true);
try {
const payload: Record<string, unknown> = {
documentPath,
canEdit,
};
if (expiryHours > 0) {
payload.expiresInHours = expiryHours;
}
if (maxUses && maxUses > 0) {
payload.maxUses = maxUses;
}
if (guestName.trim()) {
payload.guestName = guestName.trim();
}
const { data } = await api.post<{ id: string; shareToken: string; documentPath: string }>(
'/docs-access/share/create',
payload,
);
const url = `${window.location.origin}/docs/share/${data.shareToken}`;
setGeneratedUrl(url);
message.success('Share link created');
fetchLinks();
} catch {
message.error('Failed to create share link');
} finally {
setCreating(false);
}
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
message.success('Link copied to clipboard');
} catch {
message.error('Failed to copy link');
}
};
const handleRevoke = async (id: string) => {
try {
await api.patch(`/docs-access/share/${id}/revoke`);
message.success('Share link revoked');
fetchLinks();
} catch {
message.error('Failed to revoke share link');
}
};
const columns: ColumnsType<ShareLink> = [
{
title: 'Token',
dataIndex: 'shareToken',
key: 'token',
width: 100,
render: (token: string) => (
<Tooltip title={token}>
<Text code copyable={{ text: `${window.location.origin}/docs/share/${token}` }}>
{token.substring(0, 8)}...
</Text>
</Tooltip>
),
},
{
title: 'Guest',
dataIndex: 'guestName',
key: 'guest',
width: 100,
render: (name: string | null) => name || <Text type="secondary">--</Text>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'created',
width: 110,
render: (date: string) => (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
),
},
{
title: 'Expiry',
dataIndex: 'expiresAt',
key: 'expiry',
width: 110,
render: (date: string | null) =>
date ? (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
) : (
<Text type="secondary">Never</Text>
),
},
{
title: 'Uses',
key: 'uses',
width: 70,
render: (_: unknown, record: ShareLink) => (
<span>
{record.useCount}
{record.maxUses ? ` / ${record.maxUses}` : ''}
</span>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: string) => statusTag(status),
},
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, record: ShareLink) =>
record.status === 'ACTIVE' ? (
<Tooltip title="Revoke">
<Button
type="text"
danger
size="small"
icon={<StopOutlined />}
onClick={() => handleRevoke(record.id)}
/>
</Tooltip>
) : null,
},
];
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<ShareAltOutlined />
<span>Share Document</span>
</Space>
}
open={open}
onClose={onClose}
width={640}
destroyOnClose
>
{!documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
<Divider style={{ margin: '8px 0' }}>Create Share Link</Divider>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="Can edit">
<Switch
checked={canEdit}
onChange={setCanEdit}
checkedChildren="Edit"
unCheckedChildren="View only"
/>
</Form.Item>
<Form.Item label="Expiry">
<Select
value={expiryHours}
onChange={setExpiryHours}
options={EXPIRY_OPTIONS}
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Max uses (optional)">
<InputNumber
min={1}
max={1000}
value={maxUses}
onChange={(v) => setMaxUses(v)}
placeholder="Unlimited"
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Guest name (optional)">
<Input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder="Name shown to collaborators"
maxLength={200}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<LinkOutlined />}
onClick={handleCreate}
loading={creating}
>
Generate Link
</Button>
</Form.Item>
</Form>
{generatedUrl && (
<Alert
message="Share link created"
description={
<Space direction="vertical" style={{ width: '100%' }}>
<Paragraph
copyable={{
text: generatedUrl,
onCopy: () => message.success('Copied'),
}}
style={{ margin: 0, wordBreak: 'break-all' }}
>
{generatedUrl}
</Paragraph>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyUrl(generatedUrl)}
>
Copy Link
</Button>
</Space>
}
type="success"
showIcon
closable
onClose={() => setGeneratedUrl(null)}
/>
)}
<Divider style={{ margin: '8px 0' }}>Active Links</Divider>
<Table<ShareLink>
columns={columns}
dataSource={links}
rowKey="id"
loading={loading}
size="small"
pagination={false}
scroll={{ x: 600 }}
locale={{ emptyText: 'No share links for this document' }}
/>
</Space>
)}
</Drawer>
);
}

View File

@ -0,0 +1,165 @@
import { useState } from 'react';
import { Modal, Form, Input, DatePicker, Select, Switch, message, theme } from 'antd';
import { FileMarkdownOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface NewBlogPostModalProps {
open: boolean;
onClose: () => void;
onCreated: (path: string) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function NewBlogPostModal({
open,
onClose,
onCreated,
authors,
categories,
}: NewBlogPostModalProps) {
const { token } = theme.useToken();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const titleValue = Form.useWatch('title', form) as string | undefined;
const dateValue = Form.useWatch('date', form) as dayjs.Dayjs | undefined;
const slug = titleValue ? slugify(titleValue) : '';
const dateStr = dateValue ? dateValue.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
const previewFilename = slug ? `blog/posts/${dateStr}-${slug}.md` : '';
const authorOptions = Object.entries(authors).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = categories.map((cat) => ({
label: cat,
value: cat,
}));
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await api.post<{ path: string }>('/docs/blog/posts', {
title: values.title,
date: values.date ? values.date.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
authors: values.authors ?? [],
categories: values.categories ?? [],
draft: values.draft ?? true,
});
messageApi.success('Blog post created');
form.resetFields();
onCreated(res.data.path);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to create blog post';
messageApi.error(msg);
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<Modal
title={
<span>
<FileMarkdownOutlined style={{ marginRight: 8 }} />
New Blog Post
</span>
}
open={open}
onCancel={handleClose}
onOk={handleSubmit}
okText="Create"
confirmLoading={submitting}
destroyOnHidden
width={480}
>
{contextHolder}
<Form
form={form}
layout="vertical"
initialValues={{
date: dayjs(),
draft: true,
}}
style={{ marginTop: 16 }}
>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="My New Blog Post" autoFocus />
</Form.Item>
{previewFilename && (
<div
style={{
marginTop: -12,
marginBottom: 16,
padding: '6px 10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
fontSize: 12,
color: token.colorTextSecondary,
fontFamily: 'monospace',
}}
>
{previewFilename}
</div>
)}
<Form.Item name="date" label="Date">
<DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="authors" label="Author(s)">
<Select
mode="multiple"
placeholder="Select authors"
options={authorOptions}
allowClear
/>
</Form.Item>
<Form.Item name="categories" label="Categories">
<Select
mode="tags"
placeholder="Add categories"
options={categoryOptions}
allowClear
/>
</Form.Item>
<Form.Item name="draft" label="Draft" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
export interface AuthorSocial {
icon: string;
link: string;
name?: string;
}
export interface AuthorEntry {
name: string;
description?: string;
avatar?: string;
social?: AuthorSocial[];
}
export type AuthorsMap = Record<string, AuthorEntry>;
interface UseBlogAuthorsReturn {
authors: AuthorsMap;
loading: boolean;
refetch: () => Promise<void>;
}
export function useBlogAuthors(): UseBlogAuthorsReturn {
const [authors, setAuthors] = useState<AuthorsMap>({});
const [loading, setLoading] = useState(true);
const refetch = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AuthorsMap>('/docs/blog/authors');
setAuthors(res.data);
} catch {
// If no authors file exists yet, keep empty
setAuthors({});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refetch();
}, [refetch]);
return { authors, loading, refetch };
}

View File

@ -0,0 +1,31 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
interface UseBlogCategoriesReturn {
categories: string[];
loading: boolean;
refetch: () => Promise<void>;
}
export function useBlogCategories(): UseBlogCategoriesReturn {
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const refetch = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<string[]>('/docs/blog/categories');
setCategories(res.data);
} catch {
setCategories([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refetch();
}, [refetch]);
return { categories, loading, refetch };
}

View File

@ -0,0 +1,74 @@
import { useMemo, useCallback } from 'react';
import { parse, stringify } from 'yaml';
export interface BlogFrontmatter {
date?: string;
authors?: string[];
categories?: string[];
tags?: string[];
draft?: boolean;
slug?: string;
description?: string;
}
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
function parseFrontmatter(content: string): { frontmatter: BlogFrontmatter | null; body: string } {
const match = content.match(FRONTMATTER_RE);
if (!match) return { frontmatter: null, body: content };
try {
const parsed = parse(match[1] as string) as BlogFrontmatter | null;
return { frontmatter: parsed ?? {}, body: (match[2] as string) ?? '' };
} catch {
return { frontmatter: null, body: content };
}
}
function rebuildContent(frontmatter: BlogFrontmatter, body: string): string {
const yamlStr = stringify(frontmatter, { lineWidth: 0 }).trimEnd();
return `---\n${yamlStr}\n---\n${body}`;
}
interface UseBlogFrontmatterReturn {
isBlogPost: boolean;
frontmatter: BlogFrontmatter | null;
updateFrontmatter: (field: string, value: unknown) => string;
}
export function useBlogFrontmatter(
selectedFile: string | null,
fileContent: string,
): UseBlogFrontmatterReturn {
const isBlogPost = useMemo(
() =>
!!selectedFile &&
selectedFile.startsWith('blog/posts/') &&
selectedFile.endsWith('.md'),
[selectedFile],
);
const { frontmatter, body } = useMemo(() => {
if (!isBlogPost) return { frontmatter: null, body: fileContent };
return parseFrontmatter(fileContent);
}, [isBlogPost, fileContent]);
const updateFrontmatter = useCallback(
(field: string, value: unknown): string => {
const current = frontmatter ?? {};
const updated = { ...current, [field]: value };
// Remove empty optional fields to keep frontmatter clean
if (value === undefined || value === null || value === '') {
delete (updated as Record<string, unknown>)[field];
}
if (Array.isArray(value) && value.length === 0) {
delete (updated as Record<string, unknown>)[field];
}
return rebuildContent(updated, body);
},
[frontmatter, body],
);
return { isBlogPost, frontmatter, updateFrontmatter };
}

View File

@ -0,0 +1,120 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
import type { Collaborator, UseDocsCollaborationReturn } from '@/hooks/useDocsCollaboration';
/**
* A variant of useDocsCollaboration for share-link guests.
* Uses a collabToken (short-lived JWT) instead of the auth store token,
* and sets awareness with the provided guestIdentity.
*/
export function useDocShareCollaboration(
filePath: string | null,
enabled: boolean,
collabToken: string | null,
guestIdentity: { id: string; name: string; color: string } | null,
): UseDocsCollaborationReturn {
const [connected, setConnected] = useState(false);
const [synced, setSynced] = useState(false);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [collabState, setCollabState] = useState<{
yDoc: Y.Doc;
yText: Y.Text;
provider: HocuspocusProvider;
} | null>(null);
// Build WebSocket URL
const wsUrl = useMemo(() => {
if (!filePath || !enabled || !collabToken) return null;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/docs/collaborate`;
}, [filePath, enabled, collabToken]);
// Cleanup function
const cleanup = useCallback(() => {
setCollabState((prev) => {
if (prev) {
prev.provider.disconnect();
prev.provider.destroy();
prev.yDoc.destroy();
}
return null;
});
setConnected(false);
setSynced(false);
setCollaborators([]);
}, []);
useEffect(() => {
if (!wsUrl || !filePath || !enabled || !collabToken || !guestIdentity) {
cleanup();
return;
}
// Create new Y.Doc and provider
const doc = new Y.Doc();
const yText = doc.getText('content');
const provider = new HocuspocusProvider({
url: wsUrl,
name: filePath,
document: doc,
token: collabToken,
onConnect: () => setConnected(true),
onDisconnect: () => {
setConnected(false);
setSynced(false);
},
onSynced: () => setSynced(true),
});
// Set local awareness state with guest identity
provider.setAwarenessField('user', {
id: guestIdentity.id,
name: guestIdentity.name,
color: guestIdentity.color,
});
// Track collaborators via awareness changes
const handleAwarenessChange = () => {
const states = provider.awareness?.getStates();
if (!states) return;
const collab: Collaborator[] = [];
states.forEach((state, clientId) => {
const u = state.user as { id: string; name: string; color: string } | undefined;
if (u && u.id !== guestIdentity.id) {
collab.push({ id: u.id, name: u.name, color: u.color, clientId });
}
});
setCollaborators(collab);
};
provider.awareness?.on('change', handleAwarenessChange);
// Set state LAST so the component re-renders with everything ready
setCollabState({ yDoc: doc, yText, provider });
return () => {
provider.awareness?.off('change', handleAwarenessChange);
provider.disconnect();
provider.destroy();
doc.destroy();
setCollabState(null);
setConnected(false);
setSynced(false);
setCollaborators([]);
};
// We intentionally only recreate when these core values change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsUrl, filePath, enabled, collabToken, guestIdentity?.id]);
return {
yDoc: collabState?.yDoc ?? null,
yText: collabState?.yText ?? null,
provider: collabState?.provider ?? null,
connected,
synced,
collaborators,
active: enabled && !!collabState && synced && !!filePath,
};
}

View File

@ -0,0 +1,318 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Row, Col, Card, Statistic, Table, Tag, Select, Button, Space, Spin, Typography, theme } from 'antd';
import {
FileTextOutlined,
TagOutlined,
WarningOutlined,
ClockCircleOutlined,
LockOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useOutletContext, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
dayjs.extend(relativeTime);
interface PageMeta {
path: string;
title: string;
tags: string[];
description: string;
status: string;
lastModified: string | null;
wordCount: number;
hasAccessPolicy: boolean;
}
interface Warning {
type: string;
paths: string[];
}
interface MetadataResponse {
totalPages: number;
pages: PageMeta[];
warnings: Warning[];
}
type FilterMode = 'all' | 'no-tags' | 'no-description' | 'stale';
const TAG_COLORS = [
'blue', 'green', 'purple', 'cyan', 'magenta', 'orange', 'gold', 'lime', 'geekblue', 'volcano',
];
function tagColor(tag: string): string {
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0;
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? 'blue';
}
export default function DocsMetadataPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate();
const { token } = theme.useToken();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<MetadataResponse | null>(null);
const [filter, setFilter] = useState<FilterMode>('all');
const fetchMetadata = useCallback(async () => {
setLoading(true);
try {
const { data: res } = await api.get<MetadataResponse>('/docs/metadata');
setData(res);
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setPageHeader({ title: 'Documentation Metadata' });
fetchMetadata();
}, [setPageHeader, fetchMetadata]);
const staleDays = 30;
const stats = useMemo(() => {
if (!data) return { total: 0, noTags: 0, noDesc: 0, stale: 0, restricted: 0 };
const now = dayjs();
const noTags = data.pages.filter(p => !p.tags || p.tags.length === 0).length;
const noDesc = data.pages.filter(p => !p.description).length;
const stale = data.pages.filter(p => {
if (!p.lastModified) return true;
return now.diff(dayjs(p.lastModified), 'day') > staleDays;
}).length;
const restricted = data.pages.filter(p => p.hasAccessPolicy).length;
return { total: data.totalPages, noTags, noDesc, stale, restricted };
}, [data]);
const filteredPages = useMemo(() => {
if (!data) return [];
const now = dayjs();
switch (filter) {
case 'no-tags':
return data.pages.filter(p => !p.tags || p.tags.length === 0);
case 'no-description':
return data.pages.filter(p => !p.description);
case 'stale':
return data.pages.filter(p => {
if (!p.lastModified) return true;
return now.diff(dayjs(p.lastModified), 'day') > staleDays;
});
default:
return data.pages;
}
}, [data, filter]);
const columns: ColumnsType<PageMeta> = [
{
title: 'Path',
dataIndex: 'path',
key: 'path',
ellipsis: true,
sorter: (a, b) => a.path.localeCompare(b.path),
render: (path: string) => (
<Typography.Link
onClick={() => navigate('/app/docs', { state: { openFile: path } })}
style={{ fontFamily: 'monospace', fontSize: 12 }}
>
{path}
</Typography.Link>
),
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: (a, b) => (a.title || '').localeCompare(b.title || ''),
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
width: 240,
filters: (() => {
if (!data) return [];
const allTags = new Set<string>();
data.pages.forEach(p => (p.tags || []).forEach(t => allTags.add(t)));
return Array.from(allTags).sort().map(t => ({ text: t, value: t }));
})(),
onFilter: (value, record) => (record.tags || []).includes(value as string),
render: (tags: string[]) =>
tags && tags.length > 0 ? (
<Space size={[0, 4]} wrap>
{tags.map(t => (
<Tag key={t} color={tagColor(t)}>
{t}
</Tag>
))}
</Space>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
--
</Typography.Text>
),
},
{
title: 'Words',
dataIndex: 'wordCount',
key: 'wordCount',
width: 90,
align: 'right',
sorter: (a, b) => a.wordCount - b.wordCount,
render: (count: number) => count.toLocaleString(),
},
{
title: 'Last Modified',
dataIndex: 'lastModified',
key: 'lastModified',
width: 140,
sorter: (a, b) => {
const da = a.lastModified ? dayjs(a.lastModified).unix() : 0;
const db = b.lastModified ? dayjs(b.lastModified).unix() : 0;
return da - db;
},
render: (val: string | null) =>
val ? (
<Typography.Text title={dayjs(val).format('YYYY-MM-DD HH:mm')}>
{dayjs(val).fromNow()}
</Typography.Text>
) : (
<Typography.Text type="secondary">--</Typography.Text>
),
},
{
title: 'Access',
dataIndex: 'hasAccessPolicy',
key: 'hasAccessPolicy',
width: 70,
align: 'center',
filters: [
{ text: 'Restricted', value: true },
{ text: 'Public', value: false },
],
onFilter: (value, record) => record.hasAccessPolicy === value,
render: (restricted: boolean) =>
restricted ? (
<LockOutlined style={{ color: token.colorWarning }} title="Access policy applied" />
) : null,
},
];
if (loading && !data) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={8} md={4}>
<Card size="small">
<Statistic
title="Total Pages"
value={stats.total}
prefix={<FileTextOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card size="small">
<Statistic
title="No Tags"
value={stats.noTags}
prefix={<TagOutlined />}
valueStyle={stats.noTags > 0 ? { color: token.colorWarning } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card size="small">
<Statistic
title="No Description"
value={stats.noDesc}
prefix={<WarningOutlined />}
valueStyle={stats.noDesc > 0 ? { color: token.colorWarning } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card size="small">
<Statistic
title={`Stale (>${staleDays}d)`}
value={stats.stale}
prefix={<ClockCircleOutlined />}
valueStyle={stats.stale > 0 ? { color: token.colorError } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card size="small">
<Statistic
title="Restricted"
value={stats.restricted}
prefix={<LockOutlined />}
/>
</Card>
</Col>
</Row>
<Space style={{ marginBottom: 16 }}>
<Select<FilterMode>
value={filter}
onChange={setFilter}
style={{ width: 180 }}
options={[
{ label: 'Show All', value: 'all' },
{ label: 'Missing Tags', value: 'no-tags' },
{ label: 'Missing Description', value: 'no-description' },
{ label: `Stale (>${staleDays} days)`, value: 'stale' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={fetchMetadata} loading={loading}>
Refresh
</Button>
</Space>
<Table<PageMeta>
columns={columns}
dataSource={filteredPages}
rowKey="path"
loading={loading}
size="small"
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: ['25', '50', '100'] }}
scroll={{ x: 800 }}
/>
{data?.warnings && data.warnings.length > 0 && (
<Card
size="small"
title={<><WarningOutlined style={{ color: token.colorWarning }} /> Warnings</>}
style={{ marginTop: 24 }}
>
{data.warnings.map((w, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<Typography.Text strong>{w.type}:</Typography.Text>{' '}
<Typography.Text type="secondary">
{w.paths.length} page{w.paths.length !== 1 ? 's' : ''} {w.paths.slice(0, 5).join(', ')}
{w.paths.length > 5 ? ` (+${w.paths.length - 5} more)` : ''}
</Typography.Text>
</div>
))}
</Card>
)}
</div>
);
}

View File

@ -60,6 +60,10 @@ import {
DesktopOutlined,
CalendarOutlined,
ClearOutlined,
FormOutlined,
ShareAltOutlined,
LockOutlined,
HistoryOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -92,6 +96,18 @@ import { MonacoBinding } from 'y-monaco';
import type { SiteSettings } from '@/types/api';
import { registerWikiLinkCompletion } from '@/utils/wikiLinkCompletion';
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
// Blog authoring
import { useBlogFrontmatter } from '@/hooks/useBlogFrontmatter';
import { useBlogAuthors } from '@/hooks/useBlogAuthors';
import { useBlogCategories } from '@/hooks/useBlogCategories';
import { BlogFrontmatterPanel } from '@/components/docs/BlogFrontmatterPanel';
import { NewBlogPostModal } from '@/components/docs/NewBlogPostModal';
import { AuthorsManagementModal } from '@/components/docs/AuthorsManagementModal';
// Access policies & sharing
import { DocAccessPolicyPanel } from '@/components/docs/DocAccessPolicyPanel';
import { DocSharePanel } from '@/components/docs/DocSharePanel';
// Version history
import { DocHistoryDrawer } from '@/components/docs/DocHistoryDrawer';
type LayoutMode = 'split' | 'editor' | 'preview';
type PreviewMode = 'desktop' | 'mobile';
@ -651,6 +667,15 @@ export default function DocsPage() {
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
// New feature panels
const [newBlogPostOpen, setNewBlogPostOpen] = useState(false);
const [authorsModalOpen, setAuthorsModalOpen] = useState(false);
const [accessPolicyOpen, setAccessPolicyOpen] = useState(false);
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [blogPanelCollapsed, setBlogPanelCollapsed] = useState(
() => localStorage.getItem('docs-blog-panel-collapsed') === 'true',
);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -678,6 +703,11 @@ export default function DocsPage() {
collabEnabled,
);
// Blog hooks
const blogFrontmatter = useBlogFrontmatter(selectedFile, fileContent);
const blogAuthors = useBlogAuthors();
const blogCategories = useBlogCategories();
const [messageApi, contextHolder] = message.useMessage();
// Keep fileTreeRef in sync for Monaco autocomplete callback
@ -1900,6 +1930,9 @@ export default function DocsPage() {
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="New Blog Post" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => setNewBlogPostOpen(true)} aria-label="New blog post" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Upload File" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
@ -2076,6 +2109,16 @@ export default function DocsPage() {
)}
{dirty && !collab.active && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</span>}
{collab.active && <span style={{ color: token.colorSuccess, fontSize: 11 }}>Auto-saving</span>}
<span style={{ flex: 1 }} />
<Tooltip title="Version History" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={() => setHistoryDrawerOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Share" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ShareAltOutlined />} onClick={() => setSharePanelOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Access Policy" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<LockOutlined />} onClick={() => setAccessPolicyOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
</Tooltip>
</>
) : (
<span>Select a file from the tree</span>
@ -2156,8 +2199,31 @@ export default function DocsPage() {
</div>
)}
{/* Editor / Image Viewer */}
<div style={{ flex: 1, minHeight: 0 }}>
{/* Editor / Image Viewer + Blog Panel */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'row' }}>
{/* Blog Frontmatter Panel (right side when blog post selected) */}
{blogFrontmatter.isBlogPost && !isMobile && (
<BlogFrontmatterPanel
frontmatter={blogFrontmatter.frontmatter}
onUpdate={(field, value) => {
const newContent = blogFrontmatter.updateFrontmatter(field, value);
if (newContent !== null) {
setFileContent(newContent);
setDirty(true);
}
}}
authors={blogAuthors.authors}
categories={blogCategories.categories}
loading={blogAuthors.loading || blogCategories.loading}
collapsed={blogPanelCollapsed}
onCollapsedChange={(c) => {
setBlogPanelCollapsed(c);
localStorage.setItem('docs-blog-panel-collapsed', String(c));
}}
onManageAuthors={() => setAuthorsModalOpen(true)}
/>
)}
<div style={{ flex: 1, minHeight: 0 }}>
{fileLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
@ -2224,6 +2290,7 @@ export default function DocsPage() {
</div>
)}
</div>
</div>
</div>
)}
@ -2403,6 +2470,52 @@ export default function DocsPage() {
/>
</div>
)}
{/* Blog Authoring Modals */}
<NewBlogPostModal
open={newBlogPostOpen}
onClose={() => setNewBlogPostOpen(false)}
onCreated={(path) => {
setNewBlogPostOpen(false);
fetchTree(false, true);
loadFile(path);
}}
authors={blogAuthors.authors}
categories={blogCategories.categories}
/>
<AuthorsManagementModal
open={authorsModalOpen}
onClose={() => setAuthorsModalOpen(false)}
authors={blogAuthors.authors}
onSaved={() => blogAuthors.refetch()}
/>
{/* Access Policy & Sharing Drawers */}
<DocAccessPolicyPanel
open={accessPolicyOpen}
onClose={() => setAccessPolicyOpen(false)}
documentPath={selectedFile}
/>
<DocSharePanel
open={sharePanelOpen}
onClose={() => setSharePanelOpen(false)}
documentPath={selectedFile}
/>
{/* Version History Drawer */}
<DocHistoryDrawer
open={historyDrawerOpen}
onClose={() => setHistoryDrawerOpen(false)}
documentPath={selectedFile}
currentContent={fileContent}
onRestore={(content) => {
setFileContent(content);
setOriginalContent(content);
setDirty(false);
setHistoryDrawerOpen(false);
message.success('File restored to previous version');
}}
/>
</>
);
}

View File

@ -0,0 +1,510 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import {
Card, Button, Form, Input, Space, Typography, Spin, Alert,
Steps, App, Grid, Divider, Result, Tag, theme,
} from 'antd';
import {
CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined,
SettingOutlined, ApiOutlined, DatabaseOutlined, LockOutlined,
BranchesOutlined, ReloadOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
const { Text, Paragraph, Title } = Typography;
interface SetupStatus {
giteaOnline: boolean;
installComplete: boolean;
tokenConfigured: boolean;
reposCreated: boolean;
oauthConfigured: boolean;
setupComplete: boolean;
}
interface TestConnectionResult {
success: boolean;
giteaVersion?: string;
error?: string;
}
interface SetupStepResult {
step: string;
success: boolean;
error?: string;
data?: Record<string, unknown>;
}
interface SetupRunResult {
success: boolean;
steps: SetupStepResult[];
error?: string;
}
const STEP_LABELS: Record<string, string> = {
check_connection: 'Check Connection',
create_token: 'Create API Token',
create_comments_repo: 'Create Comments Repository',
create_history_repo: 'Create History Repository',
create_labels: 'Create Labels',
create_oauth_app: 'Create OAuth Application',
save_config: 'Save Configuration',
};
const STEP_ICONS: Record<string, React.ReactNode> = {
check_connection: <ApiOutlined />,
create_token: <LockOutlined />,
create_comments_repo: <DatabaseOutlined />,
create_history_repo: <DatabaseOutlined />,
create_labels: <SettingOutlined />,
create_oauth_app: <LockOutlined />,
save_config: <SettingOutlined />,
};
export default function GiteaSetupPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token: themeToken } = theme.useToken();
// State
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SetupStatus | null>(null);
const [currentStep, setCurrentStep] = useState(0);
// Step 1: Auth
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [testResult, setTestResult] = useState<TestConnectionResult | null>(null);
const [testing, setTesting] = useState(false);
// Step 2: Run
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<SetupRunResult | null>(null);
useEffect(() => {
setPageHeader({ title: 'Gitea Setup' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchStatus = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<SetupStatus>('/gitea/setup/status');
setStatus(res.data);
} catch {
message.error('Failed to load Gitea setup status');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await api.post<TestConnectionResult>('/gitea/setup/test-connection', {
username,
password,
});
setTestResult(res.data);
if (res.data.success) {
message.success('Connected to Gitea successfully');
}
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Connection test failed';
setTestResult({ success: false, error: errorMsg });
} finally {
setTesting(false);
}
};
const handleRunSetup = async () => {
setRunning(true);
setRunResult(null);
try {
const res = await api.post<SetupRunResult>('/gitea/setup/run', {
username,
password,
});
setRunResult(res.data);
if (res.data.success) {
message.success('Gitea setup completed successfully');
} else {
message.error('Setup completed with errors');
}
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Setup failed';
setRunResult({ success: false, steps: [], error: errorMsg });
message.error('Setup failed');
} finally {
setRunning(false);
}
};
const handleRerun = () => {
setCurrentStep(0);
setTestResult(null);
setRunResult(null);
setPassword('');
fetchStatus();
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
// Already complete — show success overview
if (status?.setupComplete && !runResult) {
return (
<Card>
<Result
status="success"
title="Gitea Setup Complete"
subTitle="All Gitea integrations have been configured successfully."
extra={[
<Button key="rerun" onClick={handleRerun} icon={<ReloadOutlined />}>
Re-run Setup
</Button>,
<Button
key="gitea"
type="primary"
icon={<BranchesOutlined />}
onClick={() => navigate('/app/services/gitea')}
>
Open Gitea
</Button>,
]}
/>
<Divider />
<Space direction="vertical" style={{ width: '100%' }} size="small">
<StatusRow label="Gitea Online" ok={status.giteaOnline} />
<StatusRow label="Initial Install Complete" ok={status.installComplete} />
<StatusRow label="API Token Configured" ok={status.tokenConfigured} />
<StatusRow label="Repositories Created" ok={status.reposCreated} />
<StatusRow label="OAuth Configured" ok={status.oauthConfigured} />
</Space>
</Card>
);
}
return (
<Card>
<Title level={4} style={{ marginTop: 0 }}>
<SettingOutlined style={{ marginRight: 8 }} />
Gitea Auto-Setup Wizard
</Title>
<Paragraph type="secondary">
This wizard configures Gitea for documentation comments, page history tracking, and OAuth integration.
</Paragraph>
<Steps
current={currentStep}
style={{ marginBottom: 32 }}
direction={isMobile ? 'vertical' : 'horizontal'}
items={[
{ title: 'Status Check', icon: <ApiOutlined /> },
{ title: 'Authenticate', icon: <LockOutlined /> },
{ title: 'Run Setup', icon: <SettingOutlined /> },
{ title: 'Complete', icon: <CheckCircleOutlined /> },
]}
/>
{/* Step 0: Status Check */}
{currentStep === 0 && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{!status?.giteaOnline && (
<Alert
type="warning"
showIcon
message="Gitea is not running"
description="Start the Gitea container before proceeding. Run: docker compose up -d gitea"
/>
)}
{status?.giteaOnline && !status.installComplete && (
<Alert
type="info"
showIcon
message="Complete Gitea Initial Setup"
description={
<span>
Gitea needs its initial setup completed first. Visit{' '}
<a
href="/app/services/gitea"
onClick={(e) => { e.preventDefault(); navigate('/app/services/gitea'); }}
>
the Gitea page
</a>{' '}
and complete the installation wizard to create your admin account.
</span>
}
/>
)}
{status?.giteaOnline && status.installComplete && (
<Alert
type="success"
showIcon
message="Gitea is ready for setup"
description="Gitea is running and the initial installation is complete. Proceed to authenticate."
/>
)}
<Space direction="vertical" style={{ width: '100%' }} size="small">
<StatusRow label="Gitea Online" ok={status?.giteaOnline ?? false} />
<StatusRow label="Initial Install Complete" ok={status?.installComplete ?? false} />
<StatusRow label="API Token Configured" ok={status?.tokenConfigured ?? false} />
<StatusRow label="Repositories Created" ok={status?.reposCreated ?? false} />
<StatusRow label="OAuth Configured" ok={status?.oauthConfigured ?? false} />
</Space>
<Divider />
<Space>
<Button onClick={fetchStatus} icon={<ReloadOutlined />}>
Refresh Status
</Button>
<Button
type="primary"
onClick={() => setCurrentStep(1)}
disabled={!status?.giteaOnline || !status?.installComplete}
>
Next
</Button>
</Space>
</Space>
)}
{/* Step 1: Authenticate */}
{currentStep === 1 && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="Gitea Admin Credentials"
description="Enter the admin username and password you created during Gitea's initial setup. These are used to create an API token for the integration."
/>
<Form layout="vertical" style={{ maxWidth: 400 }}>
<Form.Item label="Username">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
/>
</Form.Item>
<Form.Item label="Password">
<Input.Password
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter Gitea admin password"
onPressEnter={handleTestConnection}
/>
</Form.Item>
</Form>
<Space wrap>
<Button
onClick={handleTestConnection}
loading={testing}
icon={<ApiOutlined />}
disabled={!username || !password}
>
Test Connection
</Button>
</Space>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
showIcon
message={
testResult.success
? `Connected successfully — Gitea ${testResult.giteaVersion || ''}`
: 'Connection failed'
}
description={testResult.error || undefined}
/>
)}
<Divider />
<Space>
<Button onClick={() => setCurrentStep(0)}>Back</Button>
<Button
type="primary"
onClick={() => setCurrentStep(2)}
disabled={!testResult?.success}
>
Next
</Button>
</Space>
</Space>
)}
{/* Step 2: Run Setup */}
{currentStep === 2 && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="Ready to Configure"
description="This will create the API token, repositories, labels, and OAuth application in Gitea. Existing resources will be reused if found."
/>
{!runResult && (
<Button
type="primary"
size="large"
onClick={handleRunSetup}
loading={running}
icon={<SettingOutlined />}
>
{running ? 'Running Setup...' : 'Run Setup'}
</Button>
)}
{running && !runResult && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
<Paragraph type="secondary" style={{ marginTop: 12 }}>
Configuring Gitea resources...
</Paragraph>
</div>
)}
{runResult && (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{runResult.steps.map((step) => (
<div
key={step.step}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
borderRadius: themeToken.borderRadius,
background: step.success
? 'rgba(82, 196, 26, 0.06)'
: 'rgba(255, 77, 79, 0.06)',
}}
>
{step.success ? (
<CheckCircleOutlined style={{ color: themeToken.colorSuccess, fontSize: 18 }} />
) : (
<CloseCircleOutlined style={{ color: themeToken.colorError, fontSize: 18 }} />
)}
<span style={{ marginRight: 8 }}>{STEP_ICONS[step.step]}</span>
<Text style={{ flex: 1 }}>
{STEP_LABELS[step.step] || step.step}
</Text>
<Tag color={step.success ? 'success' : 'error'}>
{step.success ? 'OK' : 'Failed'}
</Tag>
</div>
))}
{runResult.error && (
<Alert
type="error"
showIcon
message="Setup Error"
description={runResult.error}
style={{ marginTop: 8 }}
/>
)}
{runResult.success && (
<Alert
type="success"
showIcon
message="All steps completed successfully"
style={{ marginTop: 8 }}
/>
)}
</Space>
)}
<Divider />
<Space>
<Button onClick={() => { setRunResult(null); setCurrentStep(1); }}>Back</Button>
{runResult && (
<Button
type="primary"
onClick={() => setCurrentStep(3)}
disabled={!runResult.success}
>
Next
</Button>
)}
{runResult && !runResult.success && (
<Button onClick={() => setRunResult(null)} icon={<ReloadOutlined />}>
Retry
</Button>
)}
</Space>
</Space>
)}
{/* Step 3: Complete */}
{currentStep === 3 && (
<Result
status="success"
title="Gitea Setup Complete"
subTitle="All Gitea integrations have been configured. Documentation comments, page history, and OAuth are ready to use."
extra={[
<Button
key="docs-comments"
onClick={() => navigate('/app/docs/comments')}
icon={<DatabaseOutlined />}
>
Documentation Comments
</Button>,
<Button
key="docs"
onClick={() => navigate('/app/docs')}
icon={<SettingOutlined />}
>
Documentation
</Button>,
<Button
key="gitea"
type="primary"
icon={<BranchesOutlined />}
onClick={() => navigate('/app/services/gitea')}
>
Open Gitea
</Button>,
]}
/>
)}
</Card>
);
}
/** Small helper component for status rows */
function StatusRow({ label, ok }: { label: string; ok: boolean }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{ok ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : (
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
)}
<Text>{label}</Text>
</div>
);
}

View File

@ -0,0 +1,293 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { ConfigProvider, Layout, Typography, Spin, Result, Space, theme, Tag } from 'antd';
import {
LockOutlined,
EditOutlined,
EyeOutlined,
LinkOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
import type { editor as monacoEditor } from 'monaco-editor';
import { MonacoBinding } from 'y-monaco';
import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
const { Header, Content } = Layout;
const { Title, Text } = Typography;
interface ShareValidation {
documentPath: string;
documentName: string;
canEdit: boolean;
collabToken: string;
guestIdentity: { id: string; name: string; color: string };
}
type PageState =
| { status: 'loading' }
| { status: 'ready'; data: ShareValidation }
| { status: 'error'; code: string; message: string };
export default function SharedDocEditorPage() {
const { shareToken } = useParams<{ shareToken: string }>();
const [pageState, setPageState] = useState<PageState>({ status: 'loading' });
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
const monacoBindingRef = useRef<MonacoBinding | null>(null);
const [editorReady, setEditorReady] = useState(false);
// Extract data for hooks (hooks must be called unconditionally)
const shareData = pageState.status === 'ready' ? pageState.data : null;
const collab = useDocShareCollaboration(
shareData?.documentPath ?? null,
!!shareData,
shareData?.collabToken ?? null,
shareData?.guestIdentity ?? null,
);
// Validate share token on mount
useEffect(() => {
if (!shareToken) {
setPageState({ status: 'error', code: 'INVALID', message: 'No share token provided' });
return;
}
let cancelled = false;
async function validate() {
try {
const { data } = await axios.get<ShareValidation>(
`/api/docs-access/share/public/${shareToken}`,
);
if (!cancelled) {
setPageState({ status: 'ready', data });
}
} catch (err: unknown) {
if (cancelled) return;
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } }; status?: number } })?.response;
const errorCode = resp?.data?.error?.code || 'UNKNOWN';
const errorMessage = resp?.data?.error?.message || 'Failed to validate share link';
if (resp?.status === 404) {
setPageState({ status: 'error', code: errorCode, message: 'This share link was not found.' });
} else if (resp?.status === 410) {
setPageState({
status: 'error',
code: errorCode,
message: errorCode === 'SHARE_LINK_REVOKED'
? 'This share link has been revoked by the document owner.'
: 'This share link has expired.',
});
} else if (resp?.status === 429) {
setPageState({ status: 'error', code: errorCode, message: 'This share link has reached its maximum number of uses.' });
} else {
setPageState({ status: 'error', code: errorCode, message: errorMessage });
}
}
}
validate();
return () => { cancelled = true; };
}, [shareToken]);
// Monaco editor mount handler
const handleEditorMount: OnMount = useCallback((editor) => {
monacoEditorRef.current = editor;
setEditorReady(true);
}, []);
// MonacoBinding effect: binds Y.Text to Monaco editor when both are ready
useEffect(() => {
if (monacoBindingRef.current) {
monacoBindingRef.current.destroy();
monacoBindingRef.current = null;
}
const ed = monacoEditorRef.current;
if (!collab.active || !collab.yText || !collab.provider || !ed || !editorReady) return;
const model = ed.getModel();
if (!model) return;
const binding = new MonacoBinding(
collab.yText,
model,
new Set([ed]),
collab.provider.awareness,
);
monacoBindingRef.current = binding;
return () => {
binding.destroy();
monacoBindingRef.current = null;
};
}, [collab.active, collab.yText, collab.provider, editorReady]);
// Determine file language for Monaco
const getLanguage = (path: string): string => {
if (path.endsWith('.md')) return 'markdown';
if (path.endsWith('.yml') || path.endsWith('.yaml')) return 'yaml';
if (path.endsWith('.json')) return 'json';
if (path.endsWith('.html')) return 'html';
if (path.endsWith('.css')) return 'css';
if (path.endsWith('.js') || path.endsWith('.ts')) return 'typescript';
if (path.endsWith('.py')) return 'python';
return 'plaintext';
};
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#3498db',
colorBgBase: '#0d1b2a',
colorBgContainer: '#1b2838',
},
}}
>
<Layout style={{ minHeight: '100vh', background: '#0d1b2a' }}>
{/* Header */}
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#1b2838',
borderBottom: '1px solid rgba(255,255,255,0.08)',
padding: '0 24px',
height: 56,
}}
>
<Space align="center">
<LinkOutlined style={{ fontSize: 18, color: '#3498db' }} />
<Title level={5} style={{ margin: 0, color: '#fff' }}>
Shared Document
</Title>
{shareData && (
<>
<Text type="secondary" style={{ fontSize: 13 }}>
{shareData.documentName}
</Text>
<Tag
icon={shareData.canEdit ? <EditOutlined /> : <EyeOutlined />}
color={shareData.canEdit ? 'blue' : 'default'}
>
{shareData.canEdit ? 'Edit' : 'View only'}
</Tag>
</>
)}
</Space>
<Space align="center">
{shareData && (
<CollaboratorAvatars
collaborators={collab.collaborators}
connected={collab.connected}
/>
)}
</Space>
</Header>
{/* Content */}
<Content style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{pageState.status === 'loading' && (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Space direction="vertical" align="center">
<Spin size="large" />
<Text type="secondary">Validating share link...</Text>
</Space>
</div>
)}
{pageState.status === 'error' && (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Result
status={
pageState.code === 'SHARE_LINK_REVOKED' || pageState.code === 'SHARE_LINK_EXPIRED'
? 'warning'
: pageState.code === 'SHARE_LINK_NOT_FOUND'
? '404'
: 'error'
}
icon={
pageState.code === 'SHARE_LINK_REVOKED' ? (
<LockOutlined style={{ color: '#faad14' }} />
) : undefined
}
title={
pageState.code === 'SHARE_LINK_EXPIRED'
? 'Link Expired'
: pageState.code === 'SHARE_LINK_REVOKED'
? 'Link Revoked'
: pageState.code === 'SHARE_LINK_NOT_FOUND'
? 'Not Found'
: 'Error'
}
subTitle={pageState.message}
/>
</div>
)}
{pageState.status === 'ready' && (
<div style={{ flex: 1, position: 'relative' }}>
{!collab.active && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
background: 'rgba(13,27,42,0.8)',
}}
>
<Space direction="vertical" align="center">
<Spin size="large" />
<Text type="secondary">Connecting to collaboration session...</Text>
</Space>
</div>
)}
<Editor
height="100%"
language={getLanguage(shareData?.documentPath ?? '')}
theme="vs-dark"
options={{
readOnly: !shareData?.canEdit,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
padding: { top: 12 },
}}
onMount={handleEditorMount}
/>
</div>
)}
</Content>
</Layout>
</ConfigProvider>
);
}

View File

@ -0,0 +1,76 @@
-- CreateEnum
CREATE TYPE "DocShareLinkStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
-- AlterEnum
ALTER TYPE "ContactActivityType" ADD VALUE IF NOT EXISTS 'POLL_VOTED';
-- CreateTable
CREATE TABLE "doc_access_policies" (
"id" TEXT NOT NULL,
"document_path" TEXT NOT NULL,
"is_directory" BOOLEAN NOT NULL DEFAULT false,
"allowed_editors" JSONB NOT NULL DEFAULT '[]',
"created_by_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "doc_access_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "doc_share_links" (
"id" TEXT NOT NULL,
"document_path" TEXT NOT NULL,
"share_token" TEXT NOT NULL,
"status" "DocShareLinkStatus" NOT NULL DEFAULT 'ACTIVE',
"can_edit" BOOLEAN NOT NULL DEFAULT true,
"expires_at" TIMESTAMP(3),
"max_uses" INTEGER,
"use_count" INTEGER NOT NULL DEFAULT 0,
"guest_name" TEXT,
"created_by_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "doc_share_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "doc_watches" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"file_path" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "doc_watches_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "doc_access_policies_document_path_key" ON "doc_access_policies"("document_path");
-- CreateIndex
CREATE INDEX "doc_access_policies_created_by_id_idx" ON "doc_access_policies"("created_by_id");
-- CreateIndex
CREATE UNIQUE INDEX "doc_share_links_share_token_key" ON "doc_share_links"("share_token");
-- CreateIndex
CREATE INDEX "doc_share_links_document_path_idx" ON "doc_share_links"("document_path");
-- CreateIndex
CREATE INDEX "doc_share_links_created_by_id_idx" ON "doc_share_links"("created_by_id");
-- CreateIndex
CREATE INDEX "doc_watches_file_path_idx" ON "doc_watches"("file_path");
-- CreateIndex
CREATE UNIQUE INDEX "doc_watches_user_id_file_path_key" ON "doc_watches"("user_id", "file_path");
-- AddForeignKey
ALTER TABLE "doc_access_policies" ADD CONSTRAINT "doc_access_policies_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "doc_share_links" ADD CONSTRAINT "doc_share_links_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "doc_watches" ADD CONSTRAINT "doc_watches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "gitea_setup_complete" BOOLEAN NOT NULL DEFAULT false;

View File

@ -210,6 +210,11 @@ model User {
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
// Docs access & sharing
docAccessPoliciesCreated DocAccessPolicy[] @relation("DocAccessPolicyCreator")
docShareLinksCreated DocShareLink[] @relation("DocShareLinkCreator")
docWatches DocWatch[] @relation("DocWatcher")
@@map("users")
}
@ -975,6 +980,7 @@ model SiteSettings {
giteaCommentsRepoName String @default("docs-comments")
giteaOauthClientId String @default("")
giteaOauthClientSecret String @default("") // Encrypted at rest
giteaSetupComplete Boolean @default(false) @map("gitea_setup_complete")
// Notification settings
notifyAdminShiftSignup Boolean @default(true)
@ -5169,6 +5175,60 @@ model DocCollabState {
@@map("doc_collab_state")
}
// --- Document Access Policies ---
enum DocShareLinkStatus {
ACTIVE
REVOKED
EXPIRED
}
model DocAccessPolicy {
id String @id @default(cuid())
documentPath String @unique @map("document_path") // e.g. "admin/index.md" or "guides/" (trailing slash = directory)
isDirectory Boolean @default(false) @map("is_directory")
allowedEditors Json @default("[]") @map("allowed_editors") // ["role:CONTENT_ADMIN", "user:clxyz", "all_content_editors"]
createdById String @map("created_by_id")
createdBy User @relation("DocAccessPolicyCreator", fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([createdById])
@@map("doc_access_policies")
}
model DocShareLink {
id String @id @default(cuid())
documentPath String @map("document_path")
shareToken String @unique @map("share_token")
status DocShareLinkStatus @default(ACTIVE)
canEdit Boolean @default(true) @map("can_edit")
expiresAt DateTime? @map("expires_at")
maxUses Int? @map("max_uses")
useCount Int @default(0) @map("use_count")
guestName String? @map("guest_name")
createdById String @map("created_by_id")
createdBy User @relation("DocShareLinkCreator", fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([documentPath])
@@index([createdById])
@@map("doc_share_links")
}
model DocWatch {
id String @id @default(cuid())
userId String @map("user_id")
filePath String @map("file_path")
user User @relation("DocWatcher", fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
@@unique([userId, filePath])
@@index([filePath])
@@map("doc_watches")
}
// ============================================================================
// PARTICIPANT NEEDS
// ============================================================================

View File

@ -191,6 +191,14 @@ const envSchema = z.object({
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
// Gitea Docs Version History
GITEA_DOCS_REPO: z.string().default('admin/changemaker.lite'),
GITEA_DOCS_PREFIX: z.string().default('mkdocs/docs'),
GITEA_DOCS_BRANCH: z.string().default('v2'),
// Gitea Auto-Setup (password used once to create API token, then cleared)
GITEA_ADMIN_PASSWORD: z.string().default(''),
// SMS Campaigns (Termux Android bridge)
ENABLE_SMS: z.string().default('false'),
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),

View File

@ -0,0 +1,51 @@
import { z } from 'zod';
// --- Author entry (for .authors.yml) ---
export const authorSocialLinkSchema = z.object({
icon: z.string().min(1).max(100),
link: z.string().url().max(500),
name: z.string().max(100).optional(),
});
export const authorEntrySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
avatar: z.string().max(500).optional(),
social: z.array(authorSocialLinkSchema).max(10).optional(),
});
export const authorsFileSchema = z.object({
authors: z.record(z.string().min(1).max(50), authorEntrySchema),
});
export type AuthorEntry = z.infer<typeof authorEntrySchema>;
export type AuthorsFile = z.infer<typeof authorsFileSchema>;
// --- Blog post frontmatter ---
export const blogFrontmatterSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format'),
authors: z.array(z.string().min(1)).min(1),
categories: z.array(z.string().min(1)).default([]),
tags: z.array(z.string().min(1)).optional(),
draft: z.boolean().optional(),
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
description: z.string().max(500).optional(),
});
export type BlogFrontmatter = z.infer<typeof blogFrontmatterSchema>;
// --- New blog post wizard ---
export const newBlogPostSchema = z.object({
title: z.string().min(1).max(200),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
authors: z.array(z.string()).min(1),
categories: z.array(z.string()).default([]),
draft: z.boolean().default(true),
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
description: z.string().max(500).optional(),
});
export type NewBlogPost = z.infer<typeof newBlogPostSchema>;

View File

@ -0,0 +1,198 @@
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { docsFilesService, FileNotFoundError } from './docs-files.service';
import { authorsFileSchema, type AuthorsFile, type AuthorEntry, type NewBlogPost } from './blog.schemas';
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const CATEGORIES_CACHE_KEY = 'DOCS_CACHE:blog:categories';
const CATEGORIES_CACHE_TTL = 300; // 5 minutes
export interface ParsedFrontmatter {
frontmatter: Record<string, unknown>;
body: string;
rawYaml: string;
}
/**
* Parse YAML frontmatter from markdown content.
* Returns null if no frontmatter block found.
*/
function parseFrontmatter(content: string): ParsedFrontmatter | null {
const match = content.match(FRONTMATTER_REGEX);
if (!match) return null;
const rawYaml = match[1];
const body = match[2];
try {
const frontmatter = yamlParse(rawYaml) as Record<string, unknown>;
return { frontmatter: frontmatter || {}, body, rawYaml };
} catch {
return null;
}
}
/**
* Serialize frontmatter object + body back to markdown.
* Uses YAML stringify with options to produce MkDocs Material-compatible output
* (no quoted dates, block arrays).
*/
function serializeFrontmatter(frontmatter: Record<string, unknown>, body: string): string {
// Order keys for consistent output
const ordered: Record<string, unknown> = {};
const keyOrder = ['date', 'authors', 'categories', 'tags', 'draft', 'slug', 'description'];
for (const key of keyOrder) {
if (frontmatter[key] !== undefined) ordered[key] = frontmatter[key];
}
// Include any extra keys not in the order
for (const key of Object.keys(frontmatter)) {
if (!(key in ordered)) ordered[key] = frontmatter[key];
}
const yaml = yamlStringify(ordered, {
lineWidth: 0,
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
}).trimEnd();
return `---\n${yaml}\n---\n${body}`;
}
/**
* Read and parse the blog/.authors.yml file.
*/
async function readAuthorsFile(): Promise<Record<string, AuthorEntry>> {
try {
const content = await docsFilesService.readFileContent('blog/.authors.yml');
const parsed = yamlParse(content) as { authors?: Record<string, AuthorEntry> };
return parsed?.authors || {};
} catch (err) {
if (err instanceof FileNotFoundError) {
// Create default empty authors file
await docsFilesService.writeFileContent('blog/.authors.yml', 'authors: {}\n');
return {};
}
throw err;
}
}
/**
* Write the blog/.authors.yml file.
* Validates structure with Zod before writing.
*/
async function writeAuthorsFile(authors: Record<string, AuthorEntry>): Promise<void> {
// Validate
authorsFileSchema.parse({ authors });
const yaml = yamlStringify({ authors }, {
lineWidth: 0,
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
});
await docsFilesService.writeFileContent('blog/.authors.yml', yaml);
}
/**
* Extract all unique categories from existing blog posts.
* Results are cached in Redis for 5 minutes.
*/
async function extractCategories(): Promise<string[]> {
// Try cache
try {
const cached = await redis.get(CATEGORIES_CACHE_KEY);
if (cached) return JSON.parse(cached) as string[];
} catch {
// Ignore cache errors
}
const tree = await docsFilesService.listTree();
const categories = new Set<string>();
// Find blog/posts directory
function findBlogPosts(nodes: typeof tree): typeof tree {
for (const node of nodes) {
if (node.path === 'blog/posts' && node.isDirectory && node.children) {
return node.children;
}
if (node.isDirectory && node.children) {
const result = findBlogPosts(node.children);
if (result.length > 0) return result;
}
}
return [];
}
const posts = findBlogPosts(tree);
// Read each post and extract categories
await Promise.all(
posts
.filter(n => !n.isDirectory && n.name.endsWith('.md'))
.map(async (node) => {
try {
const content = await docsFilesService.readFileContent(node.path);
const parsed = parseFrontmatter(content);
if (parsed?.frontmatter.categories && Array.isArray(parsed.frontmatter.categories)) {
for (const cat of parsed.frontmatter.categories) {
if (typeof cat === 'string') categories.add(cat);
}
}
} catch {
// Skip files that can't be read
}
}),
);
const sorted = Array.from(categories).sort();
// Cache result
try {
await redis.setex(CATEGORIES_CACHE_KEY, CATEGORIES_CACHE_TTL, JSON.stringify(sorted));
} catch {
// Ignore cache errors
}
return sorted;
}
/**
* Scaffold a new blog post with frontmatter.
*/
function scaffoldBlogPost(opts: NewBlogPost): { content: string; suggestedPath: string } {
const slug = opts.slug || slugify(opts.title);
const frontmatter: Record<string, unknown> = {
date: opts.date,
authors: opts.authors,
};
if (opts.categories.length > 0) frontmatter.categories = opts.categories;
if (opts.draft) frontmatter.draft = true;
if (opts.slug) frontmatter.slug = opts.slug;
if (opts.description) frontmatter.description = opts.description;
const body = `\n# ${opts.title}\n\nWrite your post content here.\n\n<!-- more -->\n\nContinue writing below the fold...\n`;
const content = serializeFrontmatter(frontmatter, body);
const suggestedPath = `blog/posts/${opts.date}-${slug}.md`;
return { content, suggestedPath };
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 80);
}
export const blogService = {
parseFrontmatter,
serializeFrontmatter,
readAuthorsFile,
writeAuthorsFile,
extractCategories,
scaffoldBlogPost,
};

View File

@ -0,0 +1,297 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { CONTENT_ROLES } from '../../utils/roles';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { docsAccessService, ShareLinkError } from './docs-access.service';
import { docsFilesService } from './docs-files.service';
import { upsertPolicySchema, createShareLinkSchema } from './docs-access.schemas';
const router = Router();
// --- Access Policy Routes (authenticated, CONTENT_ROLES) ---
// GET /api/docs-access/policy?path=... — get effective policy for a file
router.get(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
if (!path) {
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
return;
}
const policy = await docsAccessService.getEffectivePolicy(path);
res.json(policy);
} catch (err) {
logger.error('Failed to get doc access policy', err);
next(err);
}
},
);
// PUT /api/docs-access/policy — create or update policy for a path
router.put(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = upsertPolicySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid policy data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
const { documentPath, isDirectory, allowedEditors } = parsed.data;
const policy = await prisma.docAccessPolicy.upsert({
where: { documentPath },
create: {
documentPath,
isDirectory,
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
createdById: req.user!.id,
},
update: {
isDirectory,
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
},
});
res.json(policy);
} catch (err) {
logger.error('Failed to upsert doc access policy', err);
next(err);
}
},
);
// DELETE /api/docs-access/policy?path=... — remove policy (revert to default)
router.delete(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
if (!path) {
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
return;
}
await prisma.docAccessPolicy.deleteMany({
where: { documentPath: path },
});
res.json({ success: true });
} catch (err) {
logger.error('Failed to delete doc access policy', err);
next(err);
}
},
);
// GET /api/docs-access/policies — list all policies
router.get(
'/policies',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const policies = await prisma.docAccessPolicy.findMany({
orderBy: { documentPath: 'asc' },
include: { createdBy: { select: { id: true, name: true, email: true } } },
});
res.json({ policies });
} catch (err) {
logger.error('Failed to list doc access policies', err);
next(err);
}
},
);
// --- Share Link Routes ---
// POST /api/docs-access/share/create — generate share link (authenticated, CONTENT_ROLES)
router.post(
'/share/create',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = createShareLinkSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid share link data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
// Verify the file exists
try {
docsFilesService.safeResolve(parsed.data.documentPath);
} catch {
res.status(404).json({ error: { message: 'Document not found', code: 'NOT_FOUND' } });
return;
}
const link = await docsAccessService.generateShareLink(
req.user!.id,
parsed.data.documentPath,
{
canEdit: parsed.data.canEdit,
expiresInHours: parsed.data.expiresInHours,
maxUses: parsed.data.maxUses,
guestName: parsed.data.guestName,
},
);
res.status(201).json(link);
} catch (err) {
logger.error('Failed to create share link', err);
next(err);
}
},
);
// GET /api/docs-access/share/links?path=... — list share links for a document
router.get(
'/share/links',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
const where = path ? { documentPath: path } : {};
const links = await prisma.docShareLink.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { createdBy: { select: { id: true, name: true, email: true } } },
});
res.json({ links });
} catch (err) {
logger.error('Failed to list share links', err);
next(err);
}
},
);
// PATCH /api/docs-access/share/:id/revoke — revoke a share link
router.patch(
'/share/:id/revoke',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const link = await prisma.docShareLink.update({
where: { id },
data: { status: 'REVOKED' },
});
res.json(link);
} catch (err) {
logger.error('Failed to revoke share link', err);
next(err);
}
},
);
// DELETE /api/docs-access/share/:id — delete a share link record
router.delete(
'/share/:id',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await prisma.docShareLink.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
logger.error('Failed to delete share link', err);
next(err);
}
},
);
// --- Public Share Redemption (no auth required) ---
// GET /api/docs-access/share/public/:shareToken — validate token, return doc metadata + collab JWT
router.get(
'/share/public/:shareToken',
async (req: Request, res: Response, next: NextFunction) => {
try {
const shareToken = req.params.shareToken as string;
if (!shareToken || shareToken.length < 32) {
res.status(400).json({ error: { message: 'Invalid share token', code: 'VALIDATION_ERROR' } });
return;
}
const shareLink = await docsAccessService.validateShareToken(shareToken);
// Read document content to get the filename
let documentName = shareLink.documentPath.split('/').pop() || 'Document';
try {
// Check file still exists
docsFilesService.safeResolve(shareLink.documentPath);
} catch {
res.status(404).json({ error: { message: 'Shared document no longer exists', code: 'NOT_FOUND' } });
return;
}
// Generate a short-lived collab JWT for WebSocket access
const collabToken = docsAccessService.generateShareCollabToken({
shareToken,
documentPath: shareLink.documentPath,
canEdit: shareLink.canEdit,
guestName: shareLink.guestName,
});
// Deterministic color for guest
const colorPalette = [
'#E57373', '#81C784', '#64B5F6', '#FFB74D', '#BA68C8',
'#4DD0E1', '#FF8A65', '#AED581', '#9575CD', '#4DB6AC',
'#F06292', '#FFD54F', '#7986CB', '#A1887F', '#90A4AE',
];
const colorIndex = shareToken.charCodeAt(0) % colorPalette.length;
res.json({
documentPath: shareLink.documentPath,
documentName,
canEdit: shareLink.canEdit,
collabToken,
guestIdentity: {
id: `share:${shareToken.substring(0, 8)}`,
name: shareLink.guestName || 'Guest',
color: colorPalette[colorIndex],
},
});
} catch (err) {
if (err instanceof ShareLinkError) {
const statusMap: Record<string, number> = {
SHARE_LINK_NOT_FOUND: 404,
SHARE_LINK_REVOKED: 410,
SHARE_LINK_EXPIRED: 410,
SHARE_LINK_MAX_USES: 429,
};
res.status(statusMap[err.code] || 400).json({ error: { message: err.message, code: err.code } });
return;
}
logger.error('Failed to validate share link', err);
next(err);
}
},
);
export const docsAccessRouter = router;

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
// --- Access policy ---
export const upsertPolicySchema = z.object({
documentPath: z.string().min(1).max(500),
isDirectory: z.boolean().optional().default(false),
allowedEditors: z.array(z.string().max(200)).min(1),
});
export type UpsertPolicyInput = z.infer<typeof upsertPolicySchema>;
// --- Share link ---
export const createShareLinkSchema = z.object({
documentPath: z.string().min(1).max(500),
canEdit: z.boolean().optional().default(true),
expiresInHours: z.number().int().positive().max(720).optional(), // max 30 days
maxUses: z.number().int().positive().max(1000).optional(),
guestName: z.string().max(200).optional(),
});
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;

View File

@ -0,0 +1,286 @@
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,
};

View File

@ -10,8 +10,9 @@ import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES } from '../../utils/roles';
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
import { docsAccessService } from './docs-access.service';
// --- Metrics ---
import { Gauge } from 'prom-client';
@ -39,6 +40,15 @@ interface TokenPayload {
roles?: UserRole[];
}
// Share-link collab JWT payload (signed with JWT_INVITE_SECRET)
interface ShareTokenPayload {
type: 'doc_share';
shareToken: string;
documentPath: string;
canEdit: boolean;
guestName: string;
}
// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
@ -71,26 +81,6 @@ const docsExtension: Extension = {
throw new Error('Authentication required');
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
// Allow read-only for any authenticated non-TEMP user
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Validate document path (prevent path traversal)
try {
docsFilesService.safeResolve(documentName);
@ -98,45 +88,129 @@ const docsExtension: Extension = {
throw new Error('Invalid document path');
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
// Try standard JWT (access token) first
let payload: TokenPayload | null = null;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
// Standard JWT failed — try share-link JWT below
}
if (payload) {
// --- Standard authenticated user flow ---
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Per-file access policy check
if (hasWriteAccess) {
try {
const canEdit = await docsAccessService.canUserEdit(payload.id, roles as UserRole[], documentName);
if (!canEdit) {
data.connectionConfig.readOnly = true;
}
} catch {
// If policy check fails, default to read-only
data.connectionConfig.readOnly = true;
}
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
return;
}
// --- Share-link guest flow ---
let sharePayload: ShareTokenPayload;
try {
sharePayload = jwt.verify(token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] }) as ShareTokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
// Verify it's a doc_share token
if (sharePayload.type !== 'doc_share') {
throw new Error('Invalid token type');
}
// Verify the document matches
if (sharePayload.documentPath !== documentName) {
throw new Error('Token does not match this document');
}
// Re-validate share token against DB (could have been revoked since page load)
try {
await docsAccessService.validateShareToken(sharePayload.shareToken);
} catch {
throw new Error('Share link has been revoked or expired');
}
// Set read-only if share link doesn't grant edit
if (!sharePayload.canEdit) {
data.connectionConfig.readOnly = true;
}
// Rate limit for share guests (keyed on share token prefix)
const guestKey = `share:${sharePayload.shareToken.substring(0, 8)}`;
const guestCount = connectionsPerUser.get(guestKey) || 0;
if (guestCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
connectionsPerUser.set(guestKey, guestCount + 1);
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
// Only block if this is a NEW document (not joining existing)
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
// Set context for use in other hooks
// Set guest context
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
id: guestKey,
email: '',
name: sharePayload.guestName || 'Guest',
color: getUserColor(sharePayload.shareToken),
roles: [],
isShareGuest: true,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
logger.info(`Docs collab: guest "${sharePayload.guestName || 'Guest'}" connected to ${documentName} via share link`);
},
async onLoadDocument(data) {

View File

@ -317,6 +317,54 @@ async function searchFiles(
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
}
/**
* Search within file contents for a query string.
* Returns matching files with line numbers and context.
*/
async function searchContent(
query: string,
limit = 10,
): Promise<{ path: string; name: string; matches: { line: number; text: string; context: string }[] }[]> {
if (!query || query.length < 2) return [];
const tree = await listTree();
const q = query.toLowerCase();
const results: { path: string; name: string; matches: { line: number; text: string; context: string }[] }[] = [];
async function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (results.length >= limit) return;
if (node.isDirectory) {
if (node.children) await walk(node.children);
} else if (node.name.endsWith('.md') || node.name.endsWith('.txt') || node.name.endsWith('.yml') || node.name.endsWith('.yaml')) {
try {
const content = await readFileContent(node.path);
const lines = content.split('\n');
const matches: { line: number; text: string; context: string }[] = [];
for (let i = 0; i < lines.length && matches.length < 5; i++) {
if (lines[i].toLowerCase().includes(q)) {
const contextStart = Math.max(0, i - 1);
const contextEnd = Math.min(lines.length - 1, i + 1);
const context = lines.slice(contextStart, contextEnd + 1).join('\n');
matches.push({ line: i + 1, text: lines[i].trim(), context });
}
}
if (matches.length > 0) {
results.push({ path: node.path, name: node.name, matches });
}
} catch {
// Skip files that can't be read
}
}
}
}
await walk(tree);
return results;
}
export const docsFilesService = {
listTree,
readFileContent,
@ -329,4 +377,5 @@ export const docsFilesService = {
isEditableFile,
invalidateTreeCache,
searchFiles,
searchContent,
};

View File

@ -0,0 +1,267 @@
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
/**
* Docs version history via Gitea API.
* Auto-commits file saves and provides history/restore.
*
* Uses the main project repo (not the docs-comments repo).
* Files are stored at mkdocs/docs/{path} in the repo.
*/
const GITEA_TIMEOUT = 15000;
/**
* Encode a file path for Gitea API.
* Gitea expects each path segment to be individually encoded.
*/
function encodeRepoPath(filePath: string): string {
return filePath.split('/').map(encodeURIComponent).join('/');
}
interface GiteaCommit {
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
committer: { name: string; email: string; date: string };
};
}
interface GiteaFileContent {
content: string; // base64 encoded
sha: string;
name: string;
path: string;
}
function getBaseUrl(): string {
return env.GITEA_URL;
}
function getApiToken(): string {
return env.GITEA_API_TOKEN || '';
}
function getRepoPath(): string {
// The main project repo — matches repo_url in mkdocs.yml
return env.GITEA_DOCS_REPO || 'admin/changemaker.lite';
}
function getDocsPrefix(): string {
// Relative path within repo where docs live
return env.GITEA_DOCS_PREFIX || 'mkdocs/docs';
}
function getRepoBranch(): string {
return env.GITEA_DOCS_BRANCH || 'v2';
}
/**
* Make an authenticated Gitea API request.
*/
async function giteaRequest<T>(
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T> {
const token = getApiToken();
if (!token) throw new Error('Gitea API token not configured');
const url = `${getBaseUrl()}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GITEA_TIMEOUT);
const headers: Record<string, string> = {
Authorization: `token ${token}`,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
/**
* Get the current SHA of a file in the repo (needed for updates).
*/
async function getFileSha(repoFilePath: string): Promise<string | null> {
try {
const data = await giteaRequest<GiteaFileContent>(
'GET',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${getRepoBranch()}`,
);
return data.sha;
} catch {
return null;
}
}
/**
* Commit a file change to Gitea.
* Fire-and-forget errors are logged but don't propagate.
*/
async function commitFile(
docsRelativePath: string,
content: string,
authorName: string,
authorEmail: string,
): Promise<void> {
const token = getApiToken();
if (!token) return; // Silently skip if not configured
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
const existingSha = await getFileSha(repoFilePath);
const base64Content = Buffer.from(content, 'utf-8').toString('base64');
if (existingSha) {
// Update existing file
await giteaRequest(
'PUT',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Update ${docsRelativePath} by ${authorName}`,
content: base64Content,
sha: existingSha,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
} else {
// Create new file
await giteaRequest(
'POST',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Create ${docsRelativePath} by ${authorName}`,
content: base64Content,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
}
logger.debug(`Docs history: committed ${docsRelativePath} by ${authorName}`);
} catch (err) {
logger.warn(`Docs history: failed to commit ${docsRelativePath}:`, err instanceof Error ? err.message : err);
}
}
/**
* Get commit history for a documentation file.
*/
async function getFileHistory(docsRelativePath: string, limit = 20): Promise<GiteaCommit[]> {
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
return await giteaRequest<GiteaCommit[]>(
'GET',
`/repos/${getRepoPath()}/commits?sha=${getRepoBranch()}&path=${encodeURIComponent(repoFilePath)}&limit=${limit}`,
);
} catch (err) {
logger.warn(`Docs history: failed to get history for ${docsRelativePath}:`, err instanceof Error ? err.message : err);
return [];
}
}
/**
* Get file content at a specific commit.
*/
async function getFileAtCommit(docsRelativePath: string, commitSha: string): Promise<string | null> {
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
const data = await giteaRequest<GiteaFileContent>(
'GET',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${commitSha}`,
);
return Buffer.from(data.content, 'base64').toString('utf-8');
} catch (err) {
logger.warn(`Docs history: failed to get file at commit ${commitSha}:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Restore a file to a previous version by reading old content and creating a new commit.
*/
async function restoreRevision(
docsRelativePath: string,
commitSha: string,
authorName: string,
authorEmail: string,
): Promise<string | null> {
const oldContent = await getFileAtCommit(docsRelativePath, commitSha);
if (!oldContent) return null;
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
const currentSha = await getFileSha(repoFilePath);
if (!currentSha) return null;
try {
await giteaRequest(
'PUT',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Restore ${docsRelativePath} to revision ${commitSha.substring(0, 7)} by ${authorName}`,
content: Buffer.from(oldContent, 'utf-8').toString('base64'),
sha: currentSha,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
return oldContent;
} catch (err) {
logger.warn(`Docs history: failed to restore ${docsRelativePath}:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Check if the Gitea history feature is available (token configured and Gitea reachable).
*/
async function isAvailable(): Promise<boolean> {
const token = getApiToken();
if (!token) return false;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${getBaseUrl()}/api/v1/version`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
export const docsHistoryService = {
commitFile,
getFileHistory,
getFileAtCommit,
restoreRevision,
isAvailable,
};

View File

@ -0,0 +1,173 @@
import { stat } from 'fs/promises';
import { resolve as pathResolve } from 'path';
import { parse as yamlParse } from 'yaml';
import { env } from '../../config/env';
import { redis } from '../../config/redis';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { docsFilesService, type FileNode } from './docs-files.service';
const METADATA_CACHE_KEY = 'DOCS_CACHE:metadata';
const METADATA_CACHE_TTL = 300; // 5 minutes
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
export interface DocPageMetadata {
path: string;
title: string | null;
tags: string[];
description: string | null;
status: string | null;
lastModified: string | null;
wordCount: number;
hasAccessPolicy: boolean;
}
export interface DocMetadataWarning {
type: 'no-tags' | 'no-description' | 'orphaned' | 'stale';
paths: string[];
}
export interface DocMetadataResult {
totalPages: number;
pages: DocPageMetadata[];
warnings: DocMetadataWarning[];
}
/**
* Get comprehensive metadata for all documentation pages.
* Walks the file tree, parses frontmatter, cross-references with analytics and nav.
*/
async function getMetadata(): Promise<DocMetadataResult> {
// Try cache
try {
const cached = await redis.get(METADATA_CACHE_KEY);
if (cached) return JSON.parse(cached) as DocMetadataResult;
} catch {
// Ignore cache errors
}
const tree = await docsFilesService.listTree();
const docsRoot = pathResolve(env.MKDOCS_DOCS_PATH);
// Collect all .md files
const mdFiles: string[] = [];
function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (node.isDirectory && node.children) {
walk(node.children);
} else if (node.name.endsWith('.md')) {
mdFiles.push(node.path);
}
}
}
walk(tree);
// Get access policies in one query
const policies = await prisma.docAccessPolicy.findMany({
select: { documentPath: true },
});
const policyPaths = new Set(policies.map(p => p.documentPath));
// Parse each file
const pages: DocPageMetadata[] = await Promise.all(
mdFiles.map(async (filePath) => {
try {
const content = await docsFilesService.readFileContent(filePath);
const fullPath = pathResolve(docsRoot, filePath);
// Get file mod time
let lastModified: string | null = null;
try {
const fileStats = await stat(fullPath);
lastModified = fileStats.mtime.toISOString();
} catch {
// Ignore stat errors
}
// Parse frontmatter
let title: string | null = null;
let tags: string[] = [];
let description: string | null = null;
let status: string | null = null;
const match = content.match(FRONTMATTER_REGEX);
if (match) {
try {
const fm = yamlParse(match[1]) as Record<string, unknown>;
if (typeof fm?.title === 'string') title = fm.title;
if (Array.isArray(fm?.tags)) tags = fm.tags.filter((t): t is string => typeof t === 'string');
if (typeof fm?.description === 'string') description = fm.description;
if (typeof fm?.status === 'string') status = fm.status;
} catch {
// Invalid frontmatter
}
}
// Extract title from first # heading if not in frontmatter
if (!title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) title = headingMatch[1].trim();
}
// Word count (body only, rough estimate)
const bodyText = match ? match[2] : content;
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
return {
path: filePath,
title,
tags,
description,
status,
lastModified,
wordCount,
hasAccessPolicy: policyPaths.has(filePath),
};
} catch {
return {
path: filePath,
title: null,
tags: [],
description: null,
status: null,
lastModified: null,
wordCount: 0,
hasAccessPolicy: false,
};
}
}),
);
// Build warnings
const warnings: DocMetadataWarning[] = [];
const noTags = pages.filter(p => p.tags.length === 0 && !p.path.startsWith('blog/')).map(p => p.path);
if (noTags.length > 0) warnings.push({ type: 'no-tags', paths: noTags });
const noDescription = pages.filter(p => !p.description && !p.path.startsWith('blog/')).map(p => p.path);
if (noDescription.length > 0) warnings.push({ type: 'no-description', paths: noDescription });
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const stale = pages.filter(p => p.lastModified && p.lastModified < thirtyDaysAgo).map(p => p.path);
if (stale.length > 0) warnings.push({ type: 'stale', paths: stale });
const result: DocMetadataResult = {
totalPages: pages.length,
pages,
warnings,
};
// Cache result
try {
await redis.setex(METADATA_CACHE_KEY, METADATA_CACHE_TTL, JSON.stringify(result));
} catch {
// Ignore cache errors
}
return result;
}
export const docsMetadataService = {
getMetadata,
};

View File

@ -0,0 +1,404 @@
export interface DocTemplate {
id: string;
name: string;
description: string;
category: 'blog' | 'guide' | 'reference' | 'planning' | 'general';
icon: string; // Material icon name
filenamePattern: string; // e.g. "{{slug}}.md" or "blog/posts/{{date}}-{{slug}}.md"
content: string;
}
const today = () => new Date().toISOString().split('T')[0];
const BUILT_IN_TEMPLATES: DocTemplate[] = [
{
id: 'blog-post',
name: 'Blog Post',
description: 'A blog post with MkDocs Material frontmatter',
category: 'blog',
icon: 'article',
filenamePattern: 'blog/posts/{{date}}-{{slug}}.md',
content: `---
date: {{date}}
authors:
- admin
categories:
- General
draft: true
---
# {{title}}
Write your intro paragraph here. This appears on the blog index page.
<!-- more -->
## Main Content
Continue writing below the fold...
`,
},
{
id: 'how-to-guide',
name: 'How-To Guide',
description: 'Step-by-step guide for a specific task',
category: 'guide',
icon: 'menu_book',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- guide
---
# How to {{title}}
Brief description of what this guide covers and who it's for.
## Prerequisites
- Prerequisite 1
- Prerequisite 2
## Steps
### Step 1: Getting Started
Description of the first step.
### Step 2: Configuration
Description of the second step.
### Step 3: Verification
How to verify everything is working.
## Troubleshooting
Common issues and their solutions.
## Next Steps
What to do after completing this guide.
`,
},
{
id: 'api-reference',
name: 'API Reference',
description: 'API endpoint documentation',
category: 'reference',
icon: 'api',
filenamePattern: 'docs/api/{{slug}}.md',
content: `---
tags:
- api
- reference
---
# {{title}} API
## Overview
Brief description of this API group.
## Endpoints
### GET /api/endpoint
Description of what this endpoint does.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | string | Yes | Resource ID |
**Response:**
\`\`\`json
{
"id": "abc123",
"name": "Example"
}
\`\`\`
### POST /api/endpoint
Description of what this endpoint does.
**Request Body:**
\`\`\`json
{
"name": "Example"
}
\`\`\`
## Error Codes
| Code | Description |
|------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
`,
},
{
id: 'adr',
name: 'Architecture Decision Record',
description: 'Document an architecture decision',
category: 'planning',
icon: 'architecture',
filenamePattern: 'docs/architecture/adr-{{slug}}.md',
content: `---
tags:
- architecture
- decision
---
# ADR: {{title}}
**Date:** {{date}}
**Status:** Proposed
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?
### Positive
- Benefit 1
- Benefit 2
### Negative
- Drawback 1
- Drawback 2
### Neutral
- Side effect 1
`,
},
{
id: 'faq',
name: 'FAQ Page',
description: 'Frequently asked questions with collapsible answers',
category: 'general',
icon: 'quiz',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- faq
---
# {{title}}
Frequently asked questions about this topic.
??? question "Question 1?"
Answer to question 1. You can include **formatting**, links, and code blocks.
??? question "Question 2?"
Answer to question 2.
\`\`\`bash
# Example command
echo "hello world"
\`\`\`
??? question "Question 3?"
Answer to question 3.
`,
},
{
id: 'release-notes',
name: 'Release Notes',
description: 'Release notes for a version',
category: 'planning',
icon: 'new_releases',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- release
---
# Release Notes {{title}}
**Release Date:** {{date}}
## Highlights
Brief summary of the most important changes.
## New Features
- **Feature Name** Description of the new feature
## Improvements
- **Improvement** Description of the improvement
## Bug Fixes
- **Fix** Description of what was fixed
## Breaking Changes
!!! warning "Breaking Changes"
List any breaking changes that require user action.
## Upgrade Instructions
Steps needed to upgrade from the previous version.
`,
},
{
id: 'tutorial',
name: 'Tutorial',
description: 'In-depth tutorial with learning objectives',
category: 'guide',
icon: 'school',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- tutorial
---
# {{title}}
## What You'll Learn
By the end of this tutorial, you will be able to:
- Learning objective 1
- Learning objective 2
- Learning objective 3
## Prerequisites
!!! info "Before you begin"
- Prerequisite 1
- Prerequisite 2
## Part 1: Setup
Content for part 1...
## Part 2: Implementation
Content for part 2...
## Part 3: Testing
Content for part 3...
## Summary
Recap of what was covered and next steps.
## Further Reading
- [Related Guide 1](link)
- [Related Guide 2](link)
`,
},
{
id: 'meeting-notes',
name: 'Meeting Notes',
description: 'Meeting notes template with agenda and action items',
category: 'general',
icon: 'groups',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- meeting
---
# {{title}}
**Date:** {{date}}
**Attendees:**
## Agenda
1. Item 1
2. Item 2
3. Item 3
## Discussion Notes
### Topic 1
Notes...
### Topic 2
Notes...
## Action Items
- [ ] Action item 1 @assignee due date
- [ ] Action item 2 @assignee due date
## Next Meeting
Date and topics for next meeting.
`,
},
];
function getTemplates(): DocTemplate[] {
return BUILT_IN_TEMPLATES;
}
function applyTemplate(
templateId: string,
variables: { title: string; date?: string; slug?: string; author?: string },
): { content: string; suggestedPath: string } | null {
const template = BUILT_IN_TEMPLATES.find(t => t.id === templateId);
if (!template) return null;
const date = variables.date || today();
const slug = variables.slug || slugify(variables.title);
const author = variables.author || 'admin';
let content = template.content
.replace(/\{\{title\}\}/g, variables.title)
.replace(/\{\{date\}\}/g, date)
.replace(/\{\{slug\}\}/g, slug)
.replace(/\{\{author\}\}/g, author);
let suggestedPath = template.filenamePattern
.replace(/\{\{title\}\}/g, variables.title)
.replace(/\{\{date\}\}/g, date)
.replace(/\{\{slug\}\}/g, slug);
return { content, suggestedPath };
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 80);
}
export const docsTemplatesService = {
getTemplates,
applyTemplate,
};

View File

@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import { rm } from 'fs/promises';
import { extname, basename } from 'path';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { CONTENT_ROLES } from '../../utils/roles';
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
import { logger } from '../../utils/logger';
import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
@ -15,6 +16,12 @@ import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
import { docsResetService } from './docs-reset.service';
import { blogService } from './blog.service';
import { newBlogPostSchema, authorsFileSchema } from './blog.schemas';
import { docsAccessService } from './docs-access.service';
import { docsHistoryService } from './docs-history.service';
import { docsTemplatesService } from './docs-templates.service';
import { docsMetadataService } from './docs-metadata.service';
const router = Router();
router.use(authenticate);
@ -265,6 +272,27 @@ router.get(
},
);
// GET /api/docs/files/search-content — search within file contents
router.get(
'/files/search-content',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = String(req.query['q'] ?? '').trim();
if (!query || query.length < 2) {
res.json({ results: [] });
return;
}
const limit = Math.min(Math.max(Number(req.query['limit']) || 10, 1), 30);
const results = await docsFilesService.searchContent(query, limit);
res.json({ results });
} catch (err) {
logger.error('Failed to search docs content', err);
next(err);
}
},
);
// POST /api/docs/files/rename — rename/move file
router.post(
'/files/rename',
@ -277,7 +305,18 @@ router.post(
res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } });
return;
}
// Check access on both source and destination
const userRoles = getUserRoles(req.user!);
const canEditFrom = await docsAccessService.canUserEdit(req.user!.id, userRoles, from);
if (!canEditFrom) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
await docsFilesService.renameFile(from, to);
// Cascade rename for access policies and share links
docsAccessService.cascadeRename(from, to).catch(() => {});
// Invalidate old path's collaboration state
docsCollabService.invalidateDocument(from).catch(() => {});
res.json({ success: true });
@ -319,6 +358,15 @@ router.put(
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
// Per-file access check
const userRoles = getUserRoles(req.user!);
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
if (!canEdit) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
const { content } = req.body as { content?: string };
if (typeof content !== 'string') {
res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } });
@ -327,6 +375,9 @@ router.put(
await docsFilesService.writeFileContent(filePath, content);
// Invalidate collaboration state so next session starts fresh from disk
docsCollabService.invalidateDocument(filePath).catch(() => {});
// Fire-and-forget: commit to Gitea for version history
const userName = req.user!.email;
docsHistoryService.commitFile(filePath, content, userName, req.user!.email).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
handleFileError(err, res, next);
@ -367,7 +418,18 @@ router.delete(
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
// Per-file access check
const userRoles = getUserRoles(req.user!);
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
if (!canEdit) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
await docsFilesService.deleteFile(filePath);
// Cascade delete for access policies and share links
docsAccessService.cascadeDelete(filePath).catch(() => {});
// Invalidate collaboration state for deleted file
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true });
@ -410,4 +472,200 @@ function handleFileError(err: unknown, res: Response, next: NextFunction): void
next(err);
}
// --- Blog Endpoints ---
// GET /api/docs/blog/authors — read .authors.yml
router.get(
'/blog/authors',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const authors = await blogService.readAuthorsFile();
res.json({ authors });
} catch (err) {
logger.error('Failed to read blog authors', err);
next(err);
}
},
);
// PUT /api/docs/blog/authors — update .authors.yml
router.put(
'/blog/authors',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { authors } = req.body as { authors?: Record<string, unknown> };
if (!authors || typeof authors !== 'object') {
res.status(400).json({ error: { message: 'Authors object required', code: 'VALIDATION_ERROR' } });
return;
}
const parsed = authorsFileSchema.safeParse({ authors });
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid authors data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
await blogService.writeAuthorsFile(parsed.data.authors);
res.json({ success: true });
} catch (err) {
logger.error('Failed to update blog authors', err);
next(err);
}
},
);
// GET /api/docs/blog/categories — deduplicated categories from posts
router.get(
'/blog/categories',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const categories = await blogService.extractCategories();
res.json({ categories });
} catch (err) {
logger.error('Failed to extract blog categories', err);
next(err);
}
},
);
// POST /api/docs/blog/posts — create new blog post from wizard
router.post(
'/blog/posts',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = newBlogPostSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid blog post data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
const { content, suggestedPath } = blogService.scaffoldBlogPost(parsed.data);
await docsFilesService.createFile(suggestedPath, content);
// Fire-and-forget: commit to Gitea
const userName = req.user!.email;
docsHistoryService.commitFile(suggestedPath, content, userName, req.user!.email).catch(() => {});
res.status(201).json({ success: true, path: suggestedPath });
} catch (err) {
handleFileError(err, res, next);
}
},
);
// --- Version History Endpoints ---
// GET /api/docs/history/* — get commit history for a file
router.get(
'/history/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filePath = extractWildcardPath(req);
if (!filePath) {
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
const limit = Math.min(Math.max(Number(req.query['limit']) || 20, 1), 50);
const commits = await docsHistoryService.getFileHistory(filePath, limit);
res.json({ commits });
} catch (err) {
logger.error('Failed to get docs history', err);
next(err);
}
},
);
// GET /api/docs/revision/:sha/* — get file content at specific commit
router.get(
'/revision/:sha/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sha = req.params.sha as string;
// Extract path after /revision/:sha/
const params = req.params as Record<string, string | string[]>;
const wildcardParam = params[0] || params['0'];
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
if (!filePath || !sha) {
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
return;
}
const content = await docsHistoryService.getFileAtCommit(filePath, sha);
if (content === null) {
res.status(404).json({ error: { message: 'Revision not found', code: 'NOT_FOUND' } });
return;
}
res.json({ sha, path: filePath, content });
} catch (err) {
logger.error('Failed to get docs revision', err);
next(err);
}
},
);
// POST /api/docs/restore/:sha/* — restore file to a previous version
router.post(
'/restore/:sha/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sha = req.params.sha as string;
const params = req.params as Record<string, string | string[]>;
const wildcardParam = params[0] || params['0'];
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
if (!filePath || !sha) {
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
return;
}
const userName = req.user!.email;
const content = await docsHistoryService.restoreRevision(filePath, sha, userName, req.user!.email);
if (content === null) {
res.status(404).json({ error: { message: 'Could not restore revision', code: 'RESTORE_FAILED' } });
return;
}
// Also update the disk file
await docsFilesService.writeFileContent(filePath, content);
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
logger.error('Failed to restore docs revision', err);
next(err);
}
},
);
// --- Templates ---
// GET /api/docs/templates — list available templates
router.get(
'/templates',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response) => {
const templates = docsTemplatesService.getTemplates();
res.json({ templates });
},
);
// --- Metadata Dashboard ---
// GET /api/docs/metadata — full metadata for all docs pages
router.get(
'/metadata',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const metadata = await docsMetadataService.getMetadata();
res.json(metadata);
} catch (err) {
logger.error('Failed to get docs metadata', err);
next(err);
}
},
);
export const docsRouter = router;

View File

@ -0,0 +1,70 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { logger } from '../../utils/logger';
import { giteaSetupService } from './gitea-setup.service';
const router = Router();
router.use(authenticate);
router.use(requireNonTemp);
router.use(requireRole('SUPER_ADMIN'));
const credentialsSchema = z.object({
username: z.string().min(1).max(100),
password: z.string().min(1).max(200),
});
// GET /api/gitea/setup/status — check current setup state
router.get(
'/status',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const status = await giteaSetupService.checkStatus();
res.json(status);
} catch (err) {
logger.error('Failed to check Gitea setup status', err);
next(err);
}
},
);
// POST /api/gitea/setup/test-connection — test basic auth credentials
router.post(
'/test-connection',
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = credentialsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await giteaSetupService.testConnection(parsed.data.username, parsed.data.password);
res.json(result);
} catch (err) {
logger.error('Failed to test Gitea connection', err);
next(err);
}
},
);
// POST /api/gitea/setup/run — run full setup
router.post(
'/run',
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = credentialsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await giteaSetupService.runFullSetup(parsed.data.username, parsed.data.password);
res.json(result);
} catch (err) {
logger.error('Failed to run Gitea setup', err);
next(err);
}
},
);
export const giteaSetupRouter = router;

View File

@ -0,0 +1,447 @@
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { encrypt } from '../../utils/crypto';
import { giteaClient } from '../../services/gitea.client';
const SETUP_TIMEOUT = 15000;
interface StepResult {
step: string;
success: boolean;
error?: string;
data?: Record<string, unknown>;
}
interface SetupResult {
success: boolean;
steps: StepResult[];
error?: string;
}
/**
* Make a Gitea API request with Basic Auth (for bootstrapping before we have a token).
*/
async function giteaBasicRequest<T>(
method: string,
path: string,
username: string,
password: string,
body?: Record<string, unknown>,
): Promise<T> {
const url = `${env.GITEA_URL}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT);
const headers: Record<string, string> = {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
/**
* Make a Gitea API request with token auth.
*/
async function giteaTokenRequest<T>(
method: string,
path: string,
token: string,
body?: Record<string, unknown>,
): Promise<T> {
const url = `${env.GITEA_URL}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT);
const headers: Record<string, string> = {
Authorization: `token ${token}`,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
/**
* Check if Gitea is reachable and the install page has been completed.
*/
async function checkStatus(): Promise<{
giteaOnline: boolean;
installComplete: boolean;
tokenConfigured: boolean;
reposCreated: boolean;
oauthConfigured: boolean;
setupComplete: boolean;
}> {
let giteaOnline = false;
let installComplete = false;
let tokenConfigured = false;
let reposCreated = false;
let oauthConfigured = false;
// Check Gitea online
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, {
signal: controller.signal,
redirect: 'manual',
});
giteaOnline = res.ok;
installComplete = res.ok; // If API responds, install is complete
} finally {
clearTimeout(timeout);
}
} catch {
// Not reachable
}
// Check DB settings
try {
const settings = await prisma.siteSettings.findFirst({
select: {
giteaApiToken: true,
giteaOauthClientId: true,
giteaOauthClientSecret: true,
giteaSetupComplete: true,
},
});
if (settings) {
tokenConfigured = !!settings.giteaApiToken;
oauthConfigured = !!settings.giteaOauthClientId && !!settings.giteaOauthClientSecret;
if (settings.giteaSetupComplete) {
return {
giteaOnline,
installComplete,
tokenConfigured,
reposCreated: true, // Trust the flag
oauthConfigured,
setupComplete: true,
};
}
}
} catch {
// DB not available
}
// Check repos if we have a token
if (tokenConfigured && giteaOnline) {
try {
const config = await giteaClient.getConfig();
if (config.apiToken) {
await giteaTokenRequest('GET', `/repos/admin/docs-comments`, config.apiToken);
await giteaTokenRequest('GET', `/repos/admin/changemaker.lite`, config.apiToken);
reposCreated = true;
}
} catch {
// Repos don't exist
}
}
return {
giteaOnline,
installComplete,
tokenConfigured,
reposCreated,
oauthConfigured,
setupComplete: tokenConfigured && reposCreated,
};
}
/**
* Test connection with basic auth credentials.
*/
async function testConnection(username: string, password: string): Promise<{
success: boolean;
giteaVersion?: string;
error?: string;
}> {
try {
const data = await giteaBasicRequest<{ version: string }>(
'GET', '/version', username, password,
);
// Also verify admin access
await giteaBasicRequest<{ login: string; is_admin: boolean }>(
'GET', '/user', username, password,
);
return { success: true, giteaVersion: data.version };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('401')) {
return { success: false, error: 'Invalid username or password' };
}
return { success: false, error: msg };
}
}
/**
* Run the full Gitea setup: create token, repos, labels, OAuth app.
*/
async function runFullSetup(username: string, password: string): Promise<SetupResult> {
const steps: StepResult[] = [];
// Step 1: Check Gitea is reachable
try {
await giteaBasicRequest<{ version: string }>('GET', '/version', username, password);
steps.push({ step: 'check_connection', success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ step: 'check_connection', success: false, error: msg });
return { success: false, steps, error: 'Cannot connect to Gitea' };
}
// Step 2: Create API token
let apiToken = '';
try {
// First try to delete any existing token with the same name
try {
await giteaBasicRequest('DELETE', `/users/${username}/tokens/changemaker-auto`, username, password);
} catch {
// Token doesn't exist, that's fine
}
const tokenData = await giteaBasicRequest<{ sha1: string }>(
'POST',
`/users/${username}/tokens`,
username,
password,
{ name: 'changemaker-auto', scopes: ['all'] },
);
apiToken = tokenData.sha1;
steps.push({ step: 'create_token', success: true, data: { tokenName: 'changemaker-auto' } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ step: 'create_token', success: false, error: msg });
return { success: false, steps, error: 'Failed to create API token' };
}
// Step 3: Create docs-comments repo
try {
try {
await giteaTokenRequest('GET', `/repos/${username}/docs-comments`, apiToken);
steps.push({ step: 'create_comments_repo', success: true, data: { note: 'Already exists' } });
} catch {
await giteaTokenRequest('POST', '/user/repos', apiToken, {
name: 'docs-comments',
description: 'Documentation page comments — managed by Changemaker Lite',
private: false,
auto_init: true,
});
steps.push({ step: 'create_comments_repo', success: true });
}
} catch (err) {
steps.push({ step: 'create_comments_repo', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 4: Create changemaker.lite repo (for version history)
try {
try {
await giteaTokenRequest('GET', `/repos/${username}/changemaker.lite`, apiToken);
steps.push({ step: 'create_history_repo', success: true, data: { note: 'Already exists' } });
} catch {
await giteaTokenRequest('POST', '/user/repos', apiToken, {
name: 'changemaker.lite',
description: 'Documentation version history — managed by Changemaker Lite',
private: true,
auto_init: true,
default_branch: 'v2',
});
steps.push({ step: 'create_history_repo', success: true });
}
} catch (err) {
steps.push({ step: 'create_history_repo', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 5: Create labels on docs-comments repo
try {
const labels = [
{ name: 'docs-page', color: '#0075ca' },
{ name: 'anonymous', color: '#e4e669' },
{ name: 'moderated', color: '#0e8a16' },
];
for (const label of labels) {
try {
await giteaTokenRequest('POST', `/repos/${username}/docs-comments/labels`, apiToken, label);
} catch {
// Label may already exist
}
}
steps.push({ step: 'create_labels', success: true });
} catch (err) {
steps.push({ step: 'create_labels', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 6: Create OAuth2 app
let oauthClientId = '';
let oauthClientSecret = '';
try {
// Check if OAuth app already exists
const existingApps = await giteaTokenRequest<Array<{ id: number; name: string; client_id: string; client_secret: string }>>(
'GET', '/user/applications/oauth2', apiToken,
);
const existing = existingApps.find(a => a.name === 'changemaker-docs');
if (existing) {
oauthClientId = existing.client_id;
// Can't retrieve secret for existing app; leave blank if already set in DB
steps.push({ step: 'create_oauth_app', success: true, data: { note: 'Already exists', clientId: oauthClientId } });
} else {
const domain = env.DOMAIN || 'cmlite.org';
const oauthData = await giteaTokenRequest<{ client_id: string; client_secret: string }>(
'POST', '/user/applications/oauth2', apiToken,
{
name: 'changemaker-docs',
redirect_uris: [
`https://${domain}/comments/callback/`,
`https://docs.${domain}/comments/callback/`,
`http://localhost:${env.MKDOCS_PORT || 4003}/comments/callback/`,
],
},
);
oauthClientId = oauthData.client_id;
oauthClientSecret = oauthData.client_secret;
steps.push({ step: 'create_oauth_app', success: true, data: { clientId: oauthClientId } });
}
} catch (err) {
steps.push({ step: 'create_oauth_app', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 7: Save to database
try {
const updateData: Record<string, unknown> = {
enableDocsComments: true,
giteaApiToken: encrypt(apiToken),
giteaCommentsRepoOwner: username,
giteaCommentsRepoName: 'docs-comments',
giteaSetupComplete: true,
};
if (oauthClientId) updateData.giteaOauthClientId = oauthClientId;
if (oauthClientSecret) updateData.giteaOauthClientSecret = encrypt(oauthClientSecret);
await prisma.siteSettings.updateMany({ data: updateData });
// Clear config cache so the running system picks up new credentials
giteaClient.clearConfigCache();
steps.push({ step: 'save_config', success: true });
} catch (err) {
steps.push({ step: 'save_config', success: false, error: err instanceof Error ? err.message : String(err) });
return { success: false, steps, error: 'Failed to save configuration' };
}
const allStepsSucceeded = steps.every(s => s.success);
return { success: allStepsSucceeded, steps };
}
/**
* Auto-setup if GITEA_ADMIN_PASSWORD is set and setup hasn't been completed.
* Called on API startup. Fire-and-forget with retries.
*/
async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success: boolean; error?: string }> {
const password = env.GITEA_ADMIN_PASSWORD;
if (!password) return { alreadyComplete: true, success: true };
// Check if already complete
try {
const settings = await prisma.siteSettings.findFirst({
select: { giteaSetupComplete: true },
});
if (settings?.giteaSetupComplete) {
return { alreadyComplete: true, success: true };
}
} catch {
// DB might not be ready yet
}
// Wait for Gitea to be available (up to 3 retries, 15s apart)
let giteaReady = false;
for (let i = 0; i < 3; i++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, { signal: controller.signal });
if (res.ok) {
giteaReady = true;
break;
}
} finally {
clearTimeout(timeout);
}
} catch {
// Not ready yet
}
if (i < 2) {
logger.info(`Gitea auto-setup: waiting for Gitea to be ready (attempt ${i + 1}/3)...`);
await new Promise(r => setTimeout(r, 15000));
}
}
if (!giteaReady) {
return { alreadyComplete: false, success: false, error: 'Gitea not reachable after 3 attempts' };
}
// Run setup
logger.info('Gitea auto-setup: running initial setup...');
const result = await runFullSetup('admin', password);
if (result.success) {
logger.info('Gitea auto-setup: completed successfully');
for (const step of result.steps) {
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
}
} else {
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
for (const step of result.steps) {
if (!step.success) logger.warn(` ${step.step}: ${step.error}`);
}
}
return { alreadyComplete: false, success: result.success, error: result.error };
}
export const giteaSetupService = {
checkStatus,
testConnection,
runFullSetup,
autoSetupIfNeeded,
};

View File

@ -77,6 +77,7 @@ export const updateSiteSettingsSchema = z.object({
giteaCommentsRepoName: z.string().max(100).optional(),
giteaOauthClientId: z.string().max(500).optional(),
giteaOauthClientSecret: z.string().max(500).optional(),
giteaSetupComplete: z.boolean().optional(),
// User Provisioning
enableUserProvisioning: z.boolean().optional(),

View File

@ -38,6 +38,9 @@ import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
import { docsRouter } from './modules/docs/docs.routes';
import { docsAccessRouter } from './modules/docs/docs-access.routes';
import { giteaSetupRouter } from './modules/gitea-setup/gitea-setup.routes';
import { giteaSetupService } from './modules/gitea-setup/gitea-setup.service';
import { servicesRouter } from './modules/services/services.routes';
import { siteSettingsRouter } from './modules/settings/settings.routes';
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
@ -308,6 +311,8 @@ app.use('/api/pages', pagesPublicRouter); // Public landing pages
app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required)
app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required)
app.use('/api/docs', docsRouter); // Docs status + config (auth required)
app.use('/api/docs-access', docsAccessRouter); // Docs access policies + share links
app.use('/api/gitea/setup', giteaSetupRouter); // Gitea auto-setup (SUPER_ADMIN)
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
@ -421,6 +426,9 @@ async function start() {
reengagementService.scan().catch(() => {});
socialDigestService.scan().catch(() => {});
// Gitea auto-setup (if admin password is provided, auto-configure token + repos)
giteaSetupService.autoSetupIfNeeded().catch(() => {});
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
presenceService.markAllOffline().catch(() => {});
sseService.startHeartbeat();

View File

@ -641,12 +641,25 @@ configure_features() {
SMS_ENABLED="no"
fi
if prompt_yes_no "Enable Docs Comments (Gitea-backed page comments)?"; then
if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then
update_env_var "GITEA_COMMENTS_ENABLED" "true"
success "Docs Comments enabled"
success "Docs Comments & Version History enabled"
DOCS_COMMENTS_ENABLED="yes"
info "After Gitea is running, create a Personal Access Token and OAuth2 app,"
info "then set GITEA_API_TOKEN, GITEA_OAUTH_CLIENT_ID/SECRET in .env."
echo ""
info "Gitea auto-setup will create the API token, repos, and OAuth app automatically."
info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
echo ""
read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw
echo ""
if [[ -n "$gitea_admin_pw" ]]; then
update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw"
update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin"
success "Gitea admin password saved — auto-setup will run on next start"
else
info "No password provided. Run Gitea Setup from the admin GUI after first start."
fi
else
DOCS_COMMENTS_ENABLED="no"
fi

View File

@ -116,6 +116,13 @@ services:
- GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin}
- GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}
- GITEA_REGISTRY_PASS=${GITEA_REGISTRY_PASS:-}
# Gitea (docs comments, version history, auto-setup)
- GITEA_URL=${GITEA_URL:-http://gitea-changemaker:3000}
- GITEA_API_TOKEN=${GITEA_API_TOKEN:-}
- GITEA_ADMIN_PASSWORD=${GITEA_ADMIN_PASSWORD:-}
- GITEA_DOCS_REPO=${GITEA_DOCS_REPO:-admin/changemaker.lite}
- GITEA_DOCS_PREFIX=${GITEA_DOCS_PREFIX:-mkdocs/docs}
- GITEA_DOCS_BRANCH=${GITEA_DOCS_BRANCH:-v2}
volumes:
- ./api:/app
- /app/node_modules