From 8b9ab938567233d4cd728d28894104fd5b80800c Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Fri, 27 Mar 2026 13:28:52 -0600 Subject: [PATCH] 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 --- admin/src/App.tsx | 22 + admin/src/components/AppLayout.tsx | 2 + .../docs/AuthorsManagementModal.tsx | 391 ++++++++++++++ .../components/docs/BlogFrontmatterPanel.tsx | 265 +++++++++ .../components/docs/DocAccessPolicyPanel.tsx | 322 +++++++++++ .../src/components/docs/DocHistoryDrawer.tsx | 377 +++++++++++++ admin/src/components/docs/DocSharePanel.tsx | 390 ++++++++++++++ .../src/components/docs/NewBlogPostModal.tsx | 165 ++++++ admin/src/hooks/useBlogAuthors.ts | 47 ++ admin/src/hooks/useBlogCategories.ts | 31 ++ admin/src/hooks/useBlogFrontmatter.ts | 74 +++ admin/src/hooks/useDocShareCollaboration.ts | 120 +++++ admin/src/pages/DocsMetadataPage.tsx | 318 +++++++++++ admin/src/pages/DocsPage.tsx | 117 +++- admin/src/pages/GiteaSetupPage.tsx | 510 ++++++++++++++++++ .../src/pages/public/SharedDocEditorPage.tsx | 293 ++++++++++ .../migration.sql | 76 +++ .../migration.sql | 2 + api/prisma/schema.prisma | 60 +++ api/src/config/env.ts | 8 + api/src/modules/docs/blog.schemas.ts | 51 ++ api/src/modules/docs/blog.service.ts | 198 +++++++ api/src/modules/docs/docs-access.routes.ts | 297 ++++++++++ api/src/modules/docs/docs-access.schemas.ts | 23 + api/src/modules/docs/docs-access.service.ts | 286 ++++++++++ api/src/modules/docs/docs-collab.service.ts | 182 +++++-- api/src/modules/docs/docs-files.service.ts | 49 ++ api/src/modules/docs/docs-history.service.ts | 267 +++++++++ api/src/modules/docs/docs-metadata.service.ts | 173 ++++++ .../modules/docs/docs-templates.service.ts | 404 ++++++++++++++ api/src/modules/docs/docs.routes.ts | 260 ++++++++- .../modules/gitea-setup/gitea-setup.routes.ts | 70 +++ .../gitea-setup/gitea-setup.service.ts | 447 +++++++++++++++ api/src/modules/settings/settings.schemas.ts | 1 + api/src/server.ts | 8 + config.sh | 21 +- docker-compose.yml | 7 + 37 files changed, 6273 insertions(+), 61 deletions(-) create mode 100644 admin/src/components/docs/AuthorsManagementModal.tsx create mode 100644 admin/src/components/docs/BlogFrontmatterPanel.tsx create mode 100644 admin/src/components/docs/DocAccessPolicyPanel.tsx create mode 100644 admin/src/components/docs/DocHistoryDrawer.tsx create mode 100644 admin/src/components/docs/DocSharePanel.tsx create mode 100644 admin/src/components/docs/NewBlogPostModal.tsx create mode 100644 admin/src/hooks/useBlogAuthors.ts create mode 100644 admin/src/hooks/useBlogCategories.ts create mode 100644 admin/src/hooks/useBlogFrontmatter.ts create mode 100644 admin/src/hooks/useDocShareCollaboration.ts create mode 100644 admin/src/pages/DocsMetadataPage.tsx create mode 100644 admin/src/pages/GiteaSetupPage.tsx create mode 100644 admin/src/pages/public/SharedDocEditorPage.tsx create mode 100644 api/prisma/migrations/20260327100000_add_docs_access_sharing_watch/migration.sql create mode 100644 api/prisma/migrations/20260327120000_add_gitea_setup_complete/migration.sql create mode 100644 api/src/modules/docs/blog.schemas.ts create mode 100644 api/src/modules/docs/blog.service.ts create mode 100644 api/src/modules/docs/docs-access.routes.ts create mode 100644 api/src/modules/docs/docs-access.schemas.ts create mode 100644 api/src/modules/docs/docs-access.service.ts create mode 100644 api/src/modules/docs/docs-history.service.ts create mode 100644 api/src/modules/docs/docs-metadata.service.ts create mode 100644 api/src/modules/docs/docs-templates.service.ts create mode 100644 api/src/modules/gitea-setup/gitea-setup.routes.ts create mode 100644 api/src/modules/gitea-setup/gitea-setup.service.ts diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 4e789157..7f729f36 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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() { } /> + {/* Shared doc editor (no auth, token-based access) */} + } /> + {/* Public Media Gallery (purple theme) — feature-gated */} }> } /> @@ -604,6 +610,14 @@ export default function App() { } /> + + + + } + /> } /> + + + + } + /> , label: 'Documentation' }); webChildren.push({ key: '/app/docs/analytics', icon: , label: 'Analytics' }); webChildren.push({ key: '/app/docs/comments', icon: , label: badges?.pendingComments ? Comments : 'Comments' }); + webChildren.push({ key: '/app/docs/metadata', icon: , label: 'Metadata' }); webChildren.push({ key: '/app/docs/settings', icon: , label: 'Docs Settings' }); webChildren.push({ key: '/app/code', icon: , 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: , label: 'Workflows' }, { key: '/app/services/gitea', icon: , label: 'Git' }, + { key: '/app/services/gitea/setup', icon: , label: 'Gitea Setup' }, { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, { key: '/app/services/miniqr', icon: , label: 'QR Codes' }, ]}, diff --git a/admin/src/components/docs/AuthorsManagementModal.tsx b/admin/src/components/docs/AuthorsManagementModal.tsx new file mode 100644 index 00000000..fa2944b9 --- /dev/null +++ b/admin/src/components/docs/AuthorsManagementModal.tsx @@ -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; + +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({}); + const [editingKey, setEditingKey] = useState(null); + const [editForm, setEditForm] = useState(emptyForm); + const [addingNew, setAddingNew] = useState(false); + const [newForm, setNewForm] = useState(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 ( + + + Manage Authors + + } + open={open} + onCancel={onClose} + footer={ + + + + + } + destroyOnHidden + width={560} + > + {contextHolder} + +
+ {authorEntries.length === 0 && !addingNew && ( + + )} + + {authorEntries.map(([key, entry]) => ( +
+ {editingKey === key ? ( + + ) : ( + startEdit(key)} + onDelete={() => deleteAuthor(key)} + /> + )} + +
+ ))} + + {/* New author form */} + {addingNew ? ( + { + setAddingNew(false); + setNewForm(emptyForm); + }} + showId + isNew + /> + ) : ( + + )} +
+
+ ); +} + +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 ( +
+
+
+ {entry.name} + + {id} + +
+ {entry.description && ( + + {entry.description} + + )} + {entry.avatar && ( + + Avatar: {entry.avatar} + + )} +
+ +
+ ); +} + +/** 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 ( +
+ {showId && ( + onChange({ ...form, id: e.target.value })} + addonBefore="ID" + disabled={!isNew && !!form.id} + /> + )} + onChange({ ...form, name: e.target.value })} + addonBefore="Name" + autoFocus + /> + onChange({ ...form, description: e.target.value })} + addonBefore="Desc" + /> + onChange({ ...form, avatar: e.target.value })} + addonBefore="Avatar" + /> + + + + +
+ ); +} diff --git a/admin/src/components/docs/BlogFrontmatterPanel.tsx b/admin/src/components/docs/BlogFrontmatterPanel.tsx new file mode 100644 index 00000000..ec857447 --- /dev/null +++ b/admin/src/components/docs/BlogFrontmatterPanel.tsx @@ -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; + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ + Blog + + +
+ + {/* Fields */} +
+ {/* Date */} + } label="Date"> + onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)} + format="YYYY-MM-DD" + size="small" + style={{ width: '100%' }} + allowClear + /> + + + {/* Authors */} + } + label="Authors" + extra={ + +
+
+ ); +} + +/** 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 ( +
+
+ {icon} + + {label} + + {extra && {extra}} +
+ {children} +
+ ); +} diff --git a/admin/src/components/docs/DocAccessPolicyPanel.tsx b/admin/src/components/docs/DocAccessPolicyPanel.tsx new file mode 100644 index 00000000..724f9003 --- /dev/null +++ b/admin/src/components/docs/DocAccessPolicyPanel.tsx @@ -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(null); + const [allContentEditors, setAllContentEditors] = useState(true); + const [selectedRoles, setSelectedRoles] = useState([]); + const [userEmails, setUserEmails] = useState([]); + const [isDirectory, setIsDirectory] = useState(false); + + const fetchPolicy = useCallback(async () => { + if (!documentPath) return; + setLoading(true); + try { + const { data } = await api.get('/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 ( + + + Access Policy + + } + open={open} + onClose={onClose} + width={480} + destroyOnClose + > + {loading ? ( +
+ +
+ ) : !documentPath ? ( + + ) : ( + <> + +
+ Document +
+ {fileName} +
+ + {documentPath} + +
+ + {policy?.isDefault && !policy.id && ( + + )} + + {policy && !policy.isDefault && ( + + )} + + + + + Current Editors + +
+ {policy?.allowedEditors.map((editor) => ( + + ) : editor.startsWith('user:') ? ( + + ) : undefined + } + style={{ marginBottom: 4 }} + > + {editorLabel(editor)} + + ))} +
+ + + + + Edit Policy + + +
+ + + + + + + + + + + {isDirectory && ( + + This policy will apply to all files within this directory and subdirectories. + + )} + + + + + + + + +
+
+ + )} +
+ ); +} diff --git a/admin/src/components/docs/DocHistoryDrawer.tsx b/admin/src/components/docs/DocHistoryDrawer.tsx new file mode 100644 index 00000000..95a7492b --- /dev/null +++ b/admin/src/components/docs/DocHistoryDrawer.tsx @@ -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([]); + const [selectedSha, setSelectedSha] = useState(null); + const [revisionContent, setRevisionContent] = useState(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 ( +
+ +
+ ); + } + if (revisionContent === null) return null; + + const currentLines = currentContent.split('\n'); + const historicalLines = revisionContent.split('\n'); + + return ( +
+ + Viewing revision {selectedSha?.substring(0, 7)} + + + + + + +
+
+ + Historical ({selectedSha?.substring(0, 7)}) + +
+              {historicalLines.map((line, i) => {
+                const isDiff = i < currentLines.length && line !== currentLines[i];
+                const isAdded = i >= currentLines.length;
+                return (
+                  
+ + {i + 1} + + {line} +
+ ); + })} +
+
+ +
+ + Current + +
+              {currentLines.map((line, i) => {
+                const isDiff = i < historicalLines.length && line !== historicalLines[i];
+                const isAdded = i >= historicalLines.length;
+                return (
+                  
+ + {i + 1} + + {line} +
+ ); + })} +
+
+
+
+ ); + }; + + return ( + + + Version History + + } + open={open} + onClose={onClose} + width={selectedSha ? 800 : 420} + destroyOnClose + > + {loading ? ( +
+ +
+ ) : !documentPath ? ( + + ) : commits.length === 0 ? ( + } + description="No version history available" + > + + History is recorded when files are saved with Gitea integration enabled. + + + ) : ( + +
+ File: + {fileName} +
+ + {commits.length} revision{commits.length !== 1 ? 's' : ''} + + + + + ({ + color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary, + children: ( +
handleSelectCommit(commit.sha)} + > +
+ + {commit.commit.message} + +
+ + + + {commit.commit.author.name} + + + {dayjs(commit.commit.author.date).fromNow()} + + {commit.sha.substring(0, 7)} + + {selectedSha !== commit.sha && ( +
+ +
+ )} +
+ ), + }))} + /> + + {renderDiffView()} +
+ )} +
+ ); +} diff --git a/admin/src/components/docs/DocSharePanel.tsx b/admin/src/components/docs/DocSharePanel.tsx new file mode 100644 index 00000000..28f65d3e --- /dev/null +++ b/admin/src/components/docs/DocSharePanel.tsx @@ -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 ( + } color="success"> + Active + + ); + case 'REVOKED': + return ( + } color="error"> + Revoked + + ); + case 'EXPIRED': + return ( + } color="default"> + Expired + + ); + default: + return {status}; + } +} + +export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) { + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [links, setLinks] = useState([]); + const [generatedUrl, setGeneratedUrl] = useState(null); + + // Form state + const [canEdit, setCanEdit] = useState(true); + const [expiryHours, setExpiryHours] = useState(168); // 7 days default + const [maxUses, setMaxUses] = useState(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 = { + 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 = [ + { + title: 'Token', + dataIndex: 'shareToken', + key: 'token', + width: 100, + render: (token: string) => ( + + + {token.substring(0, 8)}... + + + ), + }, + { + title: 'Guest', + dataIndex: 'guestName', + key: 'guest', + width: 100, + render: (name: string | null) => name || --, + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'created', + width: 110, + render: (date: string) => ( + + {dayjs(date).fromNow()} + + ), + }, + { + title: 'Expiry', + dataIndex: 'expiresAt', + key: 'expiry', + width: 110, + render: (date: string | null) => + date ? ( + + {dayjs(date).fromNow()} + + ) : ( + Never + ), + }, + { + title: 'Uses', + key: 'uses', + width: 70, + render: (_: unknown, record: ShareLink) => ( + + {record.useCount} + {record.maxUses ? ` / ${record.maxUses}` : ''} + + ), + }, + { + 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' ? ( + + + + + + {generatedUrl && ( + + message.success('Copied'), + }} + style={{ margin: 0, wordBreak: 'break-all' }} + > + {generatedUrl} + + + + } + type="success" + showIcon + closable + onClose={() => setGeneratedUrl(null)} + /> + )} + + Active Links + + + columns={columns} + dataSource={links} + rowKey="id" + loading={loading} + size="small" + pagination={false} + scroll={{ x: 600 }} + locale={{ emptyText: 'No share links for this document' }} + /> + + )} + + ); +} diff --git a/admin/src/components/docs/NewBlogPostModal.tsx b/admin/src/components/docs/NewBlogPostModal.tsx new file mode 100644 index 00000000..cb55aca0 --- /dev/null +++ b/admin/src/components/docs/NewBlogPostModal.tsx @@ -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; + 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 ( + + + New Blog Post + + } + open={open} + onCancel={handleClose} + onOk={handleSubmit} + okText="Create" + confirmLoading={submitting} + destroyOnHidden + width={480} + > + {contextHolder} +
+ + + + + {previewFilename && ( +
+ {previewFilename} +
+ )} + + + + + + + + + + + + +
+
+ ); +} diff --git a/admin/src/hooks/useBlogAuthors.ts b/admin/src/hooks/useBlogAuthors.ts new file mode 100644 index 00000000..2cfb980a --- /dev/null +++ b/admin/src/hooks/useBlogAuthors.ts @@ -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; + +interface UseBlogAuthorsReturn { + authors: AuthorsMap; + loading: boolean; + refetch: () => Promise; +} + +export function useBlogAuthors(): UseBlogAuthorsReturn { + const [authors, setAuthors] = useState({}); + const [loading, setLoading] = useState(true); + + const refetch = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/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 }; +} diff --git a/admin/src/hooks/useBlogCategories.ts b/admin/src/hooks/useBlogCategories.ts new file mode 100644 index 00000000..de103c9d --- /dev/null +++ b/admin/src/hooks/useBlogCategories.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api } from '@/lib/api'; + +interface UseBlogCategoriesReturn { + categories: string[]; + loading: boolean; + refetch: () => Promise; +} + +export function useBlogCategories(): UseBlogCategoriesReturn { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + const refetch = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/docs/blog/categories'); + setCategories(res.data); + } catch { + setCategories([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { categories, loading, refetch }; +} diff --git a/admin/src/hooks/useBlogFrontmatter.ts b/admin/src/hooks/useBlogFrontmatter.ts new file mode 100644 index 00000000..866c3f10 --- /dev/null +++ b/admin/src/hooks/useBlogFrontmatter.ts @@ -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)[field]; + } + if (Array.isArray(value) && value.length === 0) { + delete (updated as Record)[field]; + } + + return rebuildContent(updated, body); + }, + [frontmatter, body], + ); + + return { isBlogPost, frontmatter, updateFrontmatter }; +} diff --git a/admin/src/hooks/useDocShareCollaboration.ts b/admin/src/hooks/useDocShareCollaboration.ts new file mode 100644 index 00000000..64631cdb --- /dev/null +++ b/admin/src/hooks/useDocShareCollaboration.ts @@ -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([]); + + 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, + }; +} diff --git a/admin/src/pages/DocsMetadataPage.tsx b/admin/src/pages/DocsMetadataPage.tsx new file mode 100644 index 00000000..e16f4945 --- /dev/null +++ b/admin/src/pages/DocsMetadataPage.tsx @@ -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(); + const navigate = useNavigate(); + const { token } = theme.useToken(); + + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [filter, setFilter] = useState('all'); + + const fetchMetadata = useCallback(async () => { + setLoading(true); + try { + const { data: res } = await api.get('/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 = [ + { + title: 'Path', + dataIndex: 'path', + key: 'path', + ellipsis: true, + sorter: (a, b) => a.path.localeCompare(b.path), + render: (path: string) => ( + navigate('/app/docs', { state: { openFile: path } })} + style={{ fontFamily: 'monospace', fontSize: 12 }} + > + {path} + + ), + }, + { + 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(); + 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 ? ( + + {tags.map(t => ( + + {t} + + ))} + + ) : ( + + -- + + ), + }, + { + 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 ? ( + + {dayjs(val).fromNow()} + + ) : ( + -- + ), + }, + { + 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 ? ( + + ) : null, + }, + ]; + + if (loading && !data) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + } + /> + + + + + } + valueStyle={stats.noTags > 0 ? { color: token.colorWarning } : undefined} + /> + + + + + } + valueStyle={stats.noDesc > 0 ? { color: token.colorWarning } : undefined} + /> + + + + + ${staleDays}d)`} + value={stats.stale} + prefix={} + valueStyle={stats.stale > 0 ? { color: token.colorError } : undefined} + /> + + + + + } + /> + + + + + + + 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' }, + ]} + /> + + + + + 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 && ( + Warnings} + style={{ marginTop: 24 }} + > + {data.warnings.map((w, i) => ( +
+ {w.type}:{' '} + + {w.paths.length} page{w.paths.length !== 1 ? 's' : ''} — {w.paths.slice(0, 5).join(', ')} + {w.paths.length > 5 ? ` (+${w.paths.length - 5} more)` : ''} + +
+ ))} +
+ )} +
+ ); +} diff --git a/admin/src/pages/DocsPage.tsx b/admin/src/pages/DocsPage.tsx index 6ae2ade7..d7016b4e 100644 --- a/admin/src/pages/DocsPage.tsx +++ b/admin/src/pages/DocsPage.tsx @@ -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(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() { , + , + ]} + /> + + + + + + + + + + ); + } + + return ( + + + <SettingOutlined style={{ marginRight: 8 }} /> + Gitea Auto-Setup Wizard + + + This wizard configures Gitea for documentation comments, page history tracking, and OAuth integration. + + + }, + { title: 'Authenticate', icon: }, + { title: 'Run Setup', icon: }, + { title: 'Complete', icon: }, + ]} + /> + + {/* Step 0: Status Check */} + {currentStep === 0 && ( + + {!status?.giteaOnline && ( + + )} + + {status?.giteaOnline && !status.installComplete && ( + + Gitea needs its initial setup completed first. Visit{' '} + { e.preventDefault(); navigate('/app/services/gitea'); }} + > + the Gitea page + {' '} + and complete the installation wizard to create your admin account. + + } + /> + )} + + {status?.giteaOnline && status.installComplete && ( + + )} + + + + + + + + + + + + + + + + + )} + + {/* Step 1: Authenticate */} + {currentStep === 1 && ( + + + +
+ + setUsername(e.target.value)} + placeholder="admin" + /> + + + setPassword(e.target.value)} + placeholder="Enter Gitea admin password" + onPressEnter={handleTestConnection} + /> + +
+ + + + + + {testResult && ( + + )} + + + + + + + +
+ )} + + {/* Step 2: Run Setup */} + {currentStep === 2 && ( + + + + {!runResult && ( + + )} + + {running && !runResult && ( +
+ } /> + + Configuring Gitea resources... + +
+ )} + + {runResult && ( + + {runResult.steps.map((step) => ( +
+ {step.success ? ( + + ) : ( + + )} + {STEP_ICONS[step.step]} + + {STEP_LABELS[step.step] || step.step} + + + {step.success ? 'OK' : 'Failed'} + +
+ ))} + + {runResult.error && ( + + )} + + {runResult.success && ( + + )} +
+ )} + + + + + + {runResult && ( + + )} + {runResult && !runResult.success && ( + + )} + +
+ )} + + {/* Step 3: Complete */} + {currentStep === 3 && ( + navigate('/app/docs/comments')} + icon={} + > + Documentation Comments + , + , + , + ]} + /> + )} +
+ ); +} + +/** Small helper component for status rows */ +function StatusRow({ label, ok }: { label: string; ok: boolean }) { + return ( +
+ {ok ? ( + + ) : ( + + )} + {label} +
+ ); +} diff --git a/admin/src/pages/public/SharedDocEditorPage.tsx b/admin/src/pages/public/SharedDocEditorPage.tsx new file mode 100644 index 00000000..fffaf73d --- /dev/null +++ b/admin/src/pages/public/SharedDocEditorPage.tsx @@ -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({ status: 'loading' }); + + const monacoEditorRef = useRef(null); + const monacoBindingRef = useRef(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( + `/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 ( + + + {/* Header */} +
+ + + + Shared Document + + {shareData && ( + <> + + {shareData.documentName} + + : } + color={shareData.canEdit ? 'blue' : 'default'} + > + {shareData.canEdit ? 'Edit' : 'View only'} + + + )} + + + + {shareData && ( + + )} + +
+ + {/* Content */} + + {pageState.status === 'loading' && ( +
+ + + Validating share link... + +
+ )} + + {pageState.status === 'error' && ( +
+ + ) : 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} + /> +
+ )} + + {pageState.status === 'ready' && ( +
+ {!collab.active && ( +
+ + + Connecting to collaboration session... + +
+ )} + + +
+ )} +
+
+
+ ); +} diff --git a/api/prisma/migrations/20260327100000_add_docs_access_sharing_watch/migration.sql b/api/prisma/migrations/20260327100000_add_docs_access_sharing_watch/migration.sql new file mode 100644 index 00000000..53f86aa6 --- /dev/null +++ b/api/prisma/migrations/20260327100000_add_docs_access_sharing_watch/migration.sql @@ -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; diff --git a/api/prisma/migrations/20260327120000_add_gitea_setup_complete/migration.sql b/api/prisma/migrations/20260327120000_add_gitea_setup_complete/migration.sql new file mode 100644 index 00000000..c0733d6a --- /dev/null +++ b/api/prisma/migrations/20260327120000_add_gitea_setup_complete/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "site_settings" ADD COLUMN "gitea_setup_complete" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 05109491..bce2fc24 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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 // ============================================================================ diff --git a/api/src/config/env.ts b/api/src/config/env.ts index ac766180..4815fb23 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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'), diff --git a/api/src/modules/docs/blog.schemas.ts b/api/src/modules/docs/blog.schemas.ts new file mode 100644 index 00000000..fdfb8d42 --- /dev/null +++ b/api/src/modules/docs/blog.schemas.ts @@ -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; +export type AuthorsFile = z.infer; + +// --- 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; + +// --- 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; diff --git a/api/src/modules/docs/blog.service.ts b/api/src/modules/docs/blog.service.ts new file mode 100644 index 00000000..c5b14ef5 --- /dev/null +++ b/api/src/modules/docs/blog.service.ts @@ -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; + 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; + 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, body: string): string { + // Order keys for consistent output + const ordered: Record = {}; + 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> { + try { + const content = await docsFilesService.readFileContent('blog/.authors.yml'); + const parsed = yamlParse(content) as { authors?: Record }; + 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): Promise { + // 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 { + // 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(); + + // 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 = { + 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\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, +}; diff --git a/api/src/modules/docs/docs-access.routes.ts b/api/src/modules/docs/docs-access.routes.ts new file mode 100644 index 00000000..f0201b4e --- /dev/null +++ b/api/src/modules/docs/docs-access.routes.ts @@ -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 = { + 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; diff --git a/api/src/modules/docs/docs-access.schemas.ts b/api/src/modules/docs/docs-access.schemas.ts new file mode 100644 index 00000000..9da69be9 --- /dev/null +++ b/api/src/modules/docs/docs-access.schemas.ts @@ -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; + +// --- 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; diff --git a/api/src/modules/docs/docs-access.service.ts b/api/src/modules/docs/docs-access.service.ts new file mode 100644 index 00000000..006711c1 --- /dev/null +++ b/api/src/modules/docs/docs-access.service.ts @@ -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 { + // Normalize path: remove leading/trailing slashes + const normalized = documentPath.replace(/^\/+|\/+$/g, ''); + + // 1. Check for exact file match + const exactPolicy = await prisma.docAccessPolicy.findUnique({ + where: { documentPath: normalized }, + }); + if (exactPolicy) { + return { + id: exactPolicy.id, + documentPath: exactPolicy.documentPath, + isDirectory: exactPolicy.isDirectory, + allowedEditors: exactPolicy.allowedEditors as string[], + isDefault: false, + }; + } + + // 2. Walk up directory hierarchy looking for directory policies + const segments = normalized.split('/'); + for (let i = segments.length - 1; i >= 1; i--) { + const dirPath = segments.slice(0, i).join('/'); + const dirPolicy = await prisma.docAccessPolicy.findUnique({ + where: { documentPath: dirPath }, + }); + if (dirPolicy && dirPolicy.isDirectory) { + return { + id: dirPolicy.id, + documentPath: dirPolicy.documentPath, + isDirectory: true, + allowedEditors: dirPolicy.allowedEditors as string[], + isDefault: false, + }; + } + } + + // 3. No policy found — default to all content editors + return { + id: null, + documentPath: normalized, + isDirectory: false, + allowedEditors: ['all_content_editors'], + isDefault: true, + }; +} + +/** + * Check if a user can edit a specific document. + * SUPER_ADMIN always passes. + */ +async function canUserEdit( + userId: string, + userRoles: UserRole[], + documentPath: string, +): Promise { + // SUPER_ADMIN always passes + if (userRoles.includes(UserRole.SUPER_ADMIN)) return true; + + const policy = await getEffectivePolicy(documentPath); + + for (const editor of policy.allowedEditors) { + if (editor === 'all_content_editors') { + // Check if user has any CONTENT_ROLES + if (userRoles.includes(UserRole.CONTENT_ADMIN) || userRoles.includes(UserRole.SUPER_ADMIN)) { + return true; + } + } else if (editor.startsWith('user:')) { + if (editor === `user:${userId}`) return true; + } else if (editor.startsWith('role:')) { + const role = editor.substring(5) as UserRole; + if (userRoles.includes(role)) return true; + } + } + + return false; +} + +// --- Share Link Management --- + +interface ShareLinkOptions { + canEdit?: boolean; + expiresInHours?: number; + maxUses?: number; + guestName?: string; +} + +/** + * Generate a share link for a document. + */ +async function generateShareLink( + createdById: string, + documentPath: string, + options: ShareLinkOptions = {}, +): Promise<{ id: string; shareToken: string; documentPath: string }> { + // Validate the file exists + docsFilesService.safeResolve(documentPath); + + const shareToken = crypto.randomBytes(24).toString('hex'); + const expiresAt = options.expiresInHours + ? new Date(Date.now() + options.expiresInHours * 60 * 60 * 1000) + : null; + + const link = await prisma.docShareLink.create({ + data: { + documentPath: documentPath.replace(/^\/+|\/+$/g, ''), + shareToken, + canEdit: options.canEdit ?? true, + expiresAt, + maxUses: options.maxUses ?? null, + guestName: options.guestName ?? null, + createdById, + }, + }); + + return { id: link.id, shareToken: link.shareToken, documentPath: link.documentPath }; +} + +/** + * Validate a share token. Returns the share link or throws. + * Increments use count on successful validation. + */ +async function validateShareToken(shareToken: string): Promise<{ + id: string; + documentPath: string; + canEdit: boolean; + guestName: string | null; +}> { + const link = await prisma.docShareLink.findUnique({ + where: { shareToken }, + }); + + if (!link) throw new ShareLinkError('Share link not found', 'SHARE_LINK_NOT_FOUND'); + if (link.status !== 'ACTIVE') throw new ShareLinkError('Share link has been revoked', 'SHARE_LINK_REVOKED'); + if (link.expiresAt && link.expiresAt < new Date()) { + // Auto-expire + await prisma.docShareLink.update({ + where: { id: link.id }, + data: { status: 'EXPIRED' }, + }); + throw new ShareLinkError('Share link has expired', 'SHARE_LINK_EXPIRED'); + } + if (link.maxUses && link.useCount >= link.maxUses) { + throw new ShareLinkError('Share link has reached maximum uses', 'SHARE_LINK_MAX_USES'); + } + + // Increment use count + await prisma.docShareLink.update({ + where: { id: link.id }, + data: { useCount: { increment: 1 } }, + }); + + return { + id: link.id, + documentPath: link.documentPath, + canEdit: link.canEdit, + guestName: link.guestName, + }; +} + +/** + * Generate a short-lived JWT for a share-link guest to use with the collab WebSocket. + */ +function generateShareCollabToken(shareLink: { + shareToken: string; + documentPath: string; + canEdit: boolean; + guestName: string | null; +}): string { + return jwt.sign( + { + type: 'doc_share', + shareToken: shareLink.shareToken, + documentPath: shareLink.documentPath, + canEdit: shareLink.canEdit, + guestName: shareLink.guestName || 'Guest', + }, + env.JWT_INVITE_SECRET, + { expiresIn: '4h' }, + ); +} + +// --- Cascade Operations --- + +/** + * Update access policies and share links when a file is renamed. + */ +async function cascadeRename(oldPath: string, newPath: string): Promise { + const normalizedOld = oldPath.replace(/^\/+|\/+$/g, ''); + const normalizedNew = newPath.replace(/^\/+|\/+$/g, ''); + + try { + // Update exact match policy + await prisma.docAccessPolicy.updateMany({ + where: { documentPath: normalizedOld }, + data: { documentPath: normalizedNew }, + }); + + // Update share links + await prisma.docShareLink.updateMany({ + where: { documentPath: normalizedOld }, + data: { documentPath: normalizedNew }, + }); + + // Update doc watches + await prisma.docWatch.updateMany({ + where: { filePath: normalizedOld }, + data: { filePath: normalizedNew }, + }); + + logger.info(`Docs access: cascaded rename ${normalizedOld} → ${normalizedNew}`); + } catch (err) { + logger.warn('Failed to cascade rename for docs access:', err); + } +} + +/** + * Revoke share links and delete policy when a file is deleted. + */ +async function cascadeDelete(path: string): Promise { + const normalized = path.replace(/^\/+|\/+$/g, ''); + + try { + // Revoke active share links + await prisma.docShareLink.updateMany({ + where: { documentPath: normalized, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + + // Delete access policy + await prisma.docAccessPolicy.deleteMany({ + where: { documentPath: normalized }, + }); + + // Delete watches + await prisma.docWatch.deleteMany({ + where: { filePath: normalized }, + }); + + logger.info(`Docs access: cascaded delete for ${normalized}`); + } catch (err) { + logger.warn('Failed to cascade delete for docs access:', err); + } +} + +export class ShareLinkError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.name = 'ShareLinkError'; + this.code = code; + } +} + +export const docsAccessService = { + getEffectivePolicy, + canUserEdit, + generateShareLink, + validateShareToken, + generateShareCollabToken, + cascadeRename, + cascadeDelete, +}; diff --git a/api/src/modules/docs/docs-collab.service.ts b/api/src/modules/docs/docs-collab.service.ts index 081d62aa..0d61ca72 100644 --- a/api/src/modules/docs/docs-collab.service.ts +++ b/api/src/modules/docs/docs-collab.service.ts @@ -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) { diff --git a/api/src/modules/docs/docs-files.service.ts b/api/src/modules/docs/docs-files.service.ts index c244efbd..d3f65ba4 100644 --- a/api/src/modules/docs/docs-files.service.ts +++ b/api/src/modules/docs/docs-files.service.ts @@ -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, }; diff --git a/api/src/modules/docs/docs-history.service.ts b/api/src/modules/docs/docs-history.service.ts new file mode 100644 index 00000000..557ea62d --- /dev/null +++ b/api/src/modules/docs/docs-history.service.ts @@ -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( + method: string, + path: string, + body?: Record, +): Promise { + 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 = { + 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 { + try { + const data = await giteaRequest( + '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 { + 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 { + const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; + + try { + return await giteaRequest( + '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 { + const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; + + try { + const data = await giteaRequest( + '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 { + 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 { + 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, +}; diff --git a/api/src/modules/docs/docs-metadata.service.ts b/api/src/modules/docs/docs-metadata.service.ts new file mode 100644 index 00000000..5f6e3c48 --- /dev/null +++ b/api/src/modules/docs/docs-metadata.service.ts @@ -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 { + // 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; + 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, +}; diff --git a/api/src/modules/docs/docs-templates.service.ts b/api/src/modules/docs/docs-templates.service.ts new file mode 100644 index 00000000..18e48e53 --- /dev/null +++ b/api/src/modules/docs/docs-templates.service.ts @@ -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. + + + +## 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, +}; diff --git a/api/src/modules/docs/docs.routes.ts b/api/src/modules/docs/docs.routes.ts index ea2cbcf6..6c2cb2b9 100644 --- a/api/src/modules/docs/docs.routes.ts +++ b/api/src/modules/docs/docs.routes.ts @@ -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 }; + 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; + 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; + 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; diff --git a/api/src/modules/gitea-setup/gitea-setup.routes.ts b/api/src/modules/gitea-setup/gitea-setup.routes.ts new file mode 100644 index 00000000..f32aecef --- /dev/null +++ b/api/src/modules/gitea-setup/gitea-setup.routes.ts @@ -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; diff --git a/api/src/modules/gitea-setup/gitea-setup.service.ts b/api/src/modules/gitea-setup/gitea-setup.service.ts new file mode 100644 index 00000000..75a62fd7 --- /dev/null +++ b/api/src/modules/gitea-setup/gitea-setup.service.ts @@ -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; +} + +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( + method: string, + path: string, + username: string, + password: string, + body?: Record, +): Promise { + const url = `${env.GITEA_URL}/api/v1${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT); + + const headers: Record = { + 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( + method: string, + path: string, + token: string, + body?: Record, +): Promise { + const url = `${env.GITEA_URL}/api/v1${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT); + + const headers: Record = { + 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 { + 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>( + '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 = { + 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, +}; diff --git a/api/src/modules/settings/settings.schemas.ts b/api/src/modules/settings/settings.schemas.ts index 286c8f8a..666bf333 100644 --- a/api/src/modules/settings/settings.schemas.ts +++ b/api/src/modules/settings/settings.schemas.ts @@ -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(), diff --git a/api/src/server.ts b/api/src/server.ts index 811b46c5..1a8a7f5a 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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(); diff --git a/config.sh b/config.sh index f5062aeb..03bf68bf 100755 --- a/config.sh +++ b/config.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4bf8c0d0..f3f32f6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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