@acms-subir/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,149 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import Modal from '../../components/Modal.astro';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { hashPassword, verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
6
+ import { slugify, authorSlug } from '../../lib/schema';
7
+
8
+ const repo = await getRepo(Astro.locals);
9
+
10
+ // Only owners manage users. The env-password session is always owner.
11
+ const sessionUserId = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
12
+ const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
13
+ const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
14
+
15
+ let error = '';
16
+ let failedOp = '';
17
+ if (Astro.request.method === 'POST' && isOwner) {
18
+ const form = await Astro.request.formData();
19
+ const op = String(form.get('op'));
20
+ failedOp = op;
21
+ try {
22
+ if (op === 'create') {
23
+ const password = String(form.get('password') ?? '');
24
+ if (password.length < 8) throw new Error('Password must be at least 8 characters.');
25
+ await repo.createUser({
26
+ name: String(form.get('name') || '').trim() || 'Unnamed',
27
+ email: String(form.get('email') || '').trim(),
28
+ role: form.get('role') === 'owner' ? 'owner' : 'editor',
29
+ passHash: await hashPassword(password),
30
+ });
31
+ }
32
+ if (op === 'update') {
33
+ const id = String(form.get('id'));
34
+ const password = String(form.get('password') ?? '');
35
+ await repo.updateUser(id, {
36
+ name: String(form.get('name') ?? '').trim() || undefined,
37
+ role: form.get('role') === 'owner' ? 'owner' : 'editor',
38
+ bio: String(form.get('bio') ?? ''),
39
+ avatar: String(form.get('avatar') ?? '').trim(),
40
+ slug: String(form.get('slug') ?? '').trim() ? slugify(String(form.get('slug'))) : '',
41
+ ...(password ? { passHash: await hashPassword(password) } : {}),
42
+ });
43
+ }
44
+ if (op === 'delete') await repo.deleteUser(String(form.get('id')));
45
+ if (!error) return Astro.redirect('/admin/users');
46
+ } catch (e) {
47
+ error = e instanceof Error ? e.message : 'Something went wrong (duplicate email?).';
48
+ }
49
+ }
50
+
51
+ const users = await repo.listUsers();
52
+ ---
53
+
54
+ <Admin title="Users" section="users">
55
+ <div class="page-head">
56
+ <h1>Users</h1>
57
+ {isOwner && <button class="btn primary" type="button" data-modal="invite-user">Invite user</button>}
58
+ </div>
59
+
60
+ {!isOwner && (
61
+ <div class="flash error">Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.</div>
62
+ )}
63
+ {error && <div class="flash error">{error}</div>}
64
+
65
+ {isOwner && (
66
+ <Modal id="invite-user" title="Invite user" open={!!error && failedOp === 'create'}>
67
+ <form method="post">
68
+ <input type="hidden" name="op" value="create" />
69
+ <div class="field-row">
70
+ <label class="field">
71
+ <span>Name</span>
72
+ <input type="text" name="name" placeholder="Name" required autofocus />
73
+ </label>
74
+ <label class="field">
75
+ <span>Role</span>
76
+ <select name="role">
77
+ <option value="editor">Editor</option>
78
+ <option value="owner">Owner</option>
79
+ </select>
80
+ </label>
81
+ </div>
82
+ <label class="field">
83
+ <span>Email</span>
84
+ <input type="email" name="email" placeholder="email@example.com" required />
85
+ </label>
86
+ <label class="field">
87
+ <span>Password</span>
88
+ <input type="password" name="password" placeholder="Min 8 characters" required minlength="8" />
89
+ </label>
90
+ <div class="modal-foot">
91
+ <button class="btn ghost" type="button" data-close>Cancel</button>
92
+ <button class="btn primary" type="submit">Add user</button>
93
+ </div>
94
+ <p class="hint" style="margin:12px 0 0">They sign in at <code>/admin/login</code> with email + password. Editors manage content; owners also manage users.</p>
95
+ </form>
96
+ </Modal>
97
+ )}
98
+
99
+ <div class="card">
100
+ <table class="list">
101
+ <thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
102
+ <tbody>
103
+ {users.map((u) => (
104
+ <tr>
105
+ <td>
106
+ {isOwner ? (
107
+ <form method="post" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
108
+ <input type="hidden" name="op" value="update" />
109
+ <input type="hidden" name="id" value={u.id} />
110
+ <input type="text" name="name" value={u.name} style="width:150px" />
111
+ <span class="t-sub" style="min-width:180px">{u.email}</span>
112
+ <select name="role" style="width:100px">
113
+ <option value="editor" selected={u.role === 'editor'}>Editor</option>
114
+ <option value="owner" selected={u.role === 'owner'}>Owner</option>
115
+ </select>
116
+ <input type="password" name="password" placeholder="New password (optional)" style="width:180px" />
117
+ <div style="display:flex;gap:8px;flex-wrap:wrap;width:100%;align-items:center">
118
+ <span class="t-sub">/author/</span>
119
+ <input type="text" name="slug" value={u.slug} placeholder={authorSlug(u)} style="width:140px" />
120
+ <input type="url" name="avatar" value={u.avatar} placeholder="Avatar URL (media library or https://…)" style="flex:1;min-width:220px" />
121
+ </div>
122
+ <textarea name="bio" rows="2" placeholder="Short bio — shown on the author card under posts and on the author page" style="width:100%">{u.bio}</textarea>
123
+ <button class="btn sm" type="submit">Save</button>
124
+ <a class="btn sm ghost" href={`/author/${authorSlug(u)}`} target="_blank" rel="noopener">View profile ↗</a>
125
+ </form>
126
+ ) : (
127
+ <>
128
+ <span class="t-title">{u.name}</span>
129
+ <div class="t-sub">{u.email}</div>
130
+ </>
131
+ )}
132
+ </td>
133
+ <td><span class={`pill ${u.role === 'owner' ? 'home' : 'published'}`}>{u.role}</span></td>
134
+ <td style="text-align:right;width:80px">
135
+ {isOwner && (
136
+ <form class="inline-form" method="post" onsubmit="return confirm('Delete this user? Their posts stay, attributed to no one.')">
137
+ <input type="hidden" name="op" value="delete" />
138
+ <input type="hidden" name="id" value={u.id} />
139
+ <button class="btn sm ghost danger" type="submit">Delete</button>
140
+ </form>
141
+ )}
142
+ </td>
143
+ </tr>
144
+ ))}
145
+ {users.length === 0 && <tr><td colspan="3" class="hint">No user accounts yet — the owner password from <code>.env</code> is the only login. Add accounts above to give posts real author bylines.</td></tr>}
146
+ </tbody>
147
+ </table>
148
+ </div>
149
+ </Admin>
@@ -0,0 +1,63 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { getMediaStore } from '../../lib/media/store';
5
+ import { zipStream, type ZipEntry } from '../../lib/backup/zip';
6
+
7
+ // Full-site backup as one ZIP download: content.json with every table,
8
+ // fetched through the repo so sqlite and D1 both export the same shape,
9
+ // plus the media binaries (originals + variants) under media/<key>.
10
+ // Password hashes never leave the repo layer, so user accounts export
11
+ // without them — after a restore, sign in with ADMIN_PASSWORD and reset.
12
+ export const GET: APIRoute = async ({ cookies, locals }) => {
13
+ const sessionUserId = verifySessionToken(cookies.get(SESSION_COOKIE)?.value);
14
+ if (!sessionUserId) {
15
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
16
+ }
17
+ const repo = await getRepo(locals);
18
+ // a backup holds everything, including the user list — owners only
19
+ const sessionUser = sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
20
+ if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') {
21
+ return Response.json({ error: 'Only owners can download backups' }, { status: 403 });
22
+ }
23
+
24
+ const [settings, pages, posts, media, links, tags, navItems, users, redirects] = await Promise.all([
25
+ repo.getSettings(),
26
+ repo.listPages(),
27
+ repo.listPosts(),
28
+ repo.listMedia(),
29
+ repo.listLinks(),
30
+ repo.listTags(),
31
+ repo.listNavItems(),
32
+ repo.listUsers(),
33
+ repo.listRedirects(),
34
+ ]);
35
+
36
+ const now = Date.now();
37
+ const content = {
38
+ format: 'acms-backup',
39
+ version: 2,
40
+ exportedAt: new Date(now).toISOString(),
41
+ settings, pages, posts, media, links, tags, navItems, users, redirects,
42
+ };
43
+
44
+ const store = getMediaStore(locals);
45
+ async function* entries(): AsyncGenerator<ZipEntry> {
46
+ yield { name: 'content.json', data: new TextEncoder().encode(JSON.stringify(content, null, 2)) };
47
+ for (const m of media) {
48
+ for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
49
+ const obj = await store.get(key);
50
+ if (!obj) continue; // a missing binary shouldn't abort the archive
51
+ yield { name: `media/${key}`, data: new Uint8Array(await new Response(obj.body).arrayBuffer()) };
52
+ }
53
+ }
54
+ }
55
+
56
+ const stamp = new Date(now).toISOString().slice(0, 10);
57
+ return new Response(zipStream(entries(), now), {
58
+ headers: {
59
+ 'Content-Type': 'application/zip',
60
+ 'Content-Disposition': `attachment; filename="acms-backup-${stamp}.zip"`,
61
+ },
62
+ });
63
+ };
@@ -0,0 +1,106 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { z } from 'zod';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { renderMarkdown } from '../../lib/markdown';
6
+ import { slugify } from '../../lib/schema';
7
+
8
+ // External drafts API: a trusted outside app (e.g. a drafts editor) pushes
9
+ // markdown here and it lands as a draft post, ready to refine in the admin.
10
+ // Create-only by design — the key can never publish, edit, or delete, so a
11
+ // leaked key costs at most junk drafts. Key + allowed browser origins are
12
+ // managed in /admin/settings; an empty key keeps the endpoint off.
13
+
14
+ const MAX_BODY_BYTES = 1_000_000;
15
+
16
+ const DraftInput = z.object({
17
+ title: z.string().trim().min(1, 'title is required'),
18
+ content: z.string().default(''), // markdown
19
+ excerpt: z.string().default(''),
20
+ tags: z.array(z.string().trim().min(1)).default([]),
21
+ slug: z.string().default(''),
22
+ cover: z.string().default(''),
23
+ });
24
+
25
+ // Allowlist entries: exact origins, '<scheme>://<host>:*' (any port), or '*'.
26
+ function originAllowed(origin: string, list: string[]): boolean {
27
+ return list.some((entry) => {
28
+ if (entry === '*') return true;
29
+ if (entry.endsWith(':*')) return origin === entry.slice(0, -2) || origin.startsWith(entry.slice(0, -1));
30
+ return origin === entry;
31
+ });
32
+ }
33
+
34
+ function corsHeaders(origin: string): Record<string, string> {
35
+ return {
36
+ 'Access-Control-Allow-Origin': origin,
37
+ 'Access-Control-Allow-Methods': 'POST',
38
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
39
+ 'Access-Control-Max-Age': '86400',
40
+ Vary: 'Origin',
41
+ };
42
+ }
43
+
44
+ function keyEqual(a: string, b: string): boolean {
45
+ return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
46
+ }
47
+
48
+ export const OPTIONS: APIRoute = async ({ request, locals }) => {
49
+ const origin = request.headers.get('Origin');
50
+ if (!origin) return new Response(null, { status: 204 });
51
+ const settings = await (await getRepo(locals)).getSettings();
52
+ if (!originAllowed(origin, settings.draftsAllowedOrigins)) {
53
+ return new Response(null, { status: 403 });
54
+ }
55
+ return new Response(null, { status: 204, headers: corsHeaders(origin) });
56
+ };
57
+
58
+ export const POST: APIRoute = async ({ request, locals }) => {
59
+ const repo = await getRepo(locals);
60
+ const settings = await repo.getSettings();
61
+
62
+ // browser callers must be on the allowlist; non-browser clients (curl,
63
+ // scripts) send no Origin and are gated by the key alone
64
+ const origin = request.headers.get('Origin');
65
+ const cors = origin ? corsHeaders(origin) : undefined;
66
+ const respond = (body: unknown, status: number) => Response.json(body, { status, headers: cors });
67
+ if (origin && !originAllowed(origin, settings.draftsAllowedOrigins)) {
68
+ return Response.json({ error: 'Origin not allowed' }, { status: 403 });
69
+ }
70
+
71
+ if (!settings.draftsApiKey) {
72
+ return respond({ error: 'Drafts API is not enabled — generate a key in Settings.' }, 503);
73
+ }
74
+ const auth = request.headers.get('Authorization') ?? '';
75
+ const key = auth.startsWith('Bearer ') ? auth.slice(7) : '';
76
+ if (!key || !keyEqual(key, settings.draftsApiKey)) {
77
+ return respond({ error: 'Invalid API key' }, 401);
78
+ }
79
+
80
+ const raw = await request.text();
81
+ if (raw.length > MAX_BODY_BYTES) return respond({ error: 'Body too large' }, 413);
82
+ let input: z.infer<typeof DraftInput>;
83
+ try {
84
+ input = DraftInput.parse(JSON.parse(raw));
85
+ } catch (e) {
86
+ if (e instanceof z.ZodError) return respond({ error: 'Invalid payload', issues: e.issues }, 400);
87
+ return respond({ error: 'Body must be valid JSON' }, 400);
88
+ }
89
+
90
+ const base = slugify(input.slug || input.title);
91
+ let slug = base;
92
+ for (let n = 2; await repo.getPostBySlug(slug); n++) slug = `${base}-${n}`;
93
+
94
+ const post = await repo.createPost({ title: input.title, slug });
95
+ await repo.updatePost(post.id, {
96
+ body: input.content,
97
+ bodyHtml: input.content ? renderMarkdown(input.content) : '',
98
+ excerpt: input.excerpt,
99
+ tags: input.tags,
100
+ cover: input.cover,
101
+ });
102
+ if (input.tags.length) await repo.ensureTags(input.tags);
103
+
104
+ const editUrl = new URL(`/admin/posts/${post.id}`, request.url).toString();
105
+ return respond({ id: post.id, slug, status: 'draft', editUrl }, 201);
106
+ };
@@ -0,0 +1,89 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { getMediaStore } from '../../../lib/media/store';
5
+
6
+ // Multipart upload from the admin. For images the client has already done
7
+ // the heavy lifting (EXIF rotation, downscale to max 1080px, WebP re-encode,
8
+ // variant generation); this route just stores the optimized file + variants
9
+ // and records the media row. SVG/GIF arrive untouched. Documents (for the
10
+ // file/download card) are stored as-is. Video/audio is deliberately rejected
11
+ // — embeds cover video until a streaming upload + Range-serving pipeline
12
+ // exists.
13
+ //
14
+ // Fields: file (primary), width, height, alt?, variant_<w> (File, webp)…
15
+ const DOCUMENT_TYPES = new Set([
16
+ 'application/pdf',
17
+ 'application/zip',
18
+ 'application/x-zip-compressed',
19
+ 'application/json',
20
+ 'text/plain',
21
+ 'text/csv',
22
+ 'text/markdown',
23
+ 'application/msword',
24
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ 'application/vnd.ms-excel',
26
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
27
+ 'application/vnd.ms-powerpoint',
28
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
29
+ 'application/epub+zip',
30
+ ]);
31
+ const MAX_IMAGE = 30 * 1024 * 1024;
32
+ const MAX_DOCUMENT = 25 * 1024 * 1024;
33
+
34
+ export const POST: APIRoute = async ({ request, cookies, locals }) => {
35
+ if (!verifySessionToken(cookies.get(SESSION_COOKIE)?.value)) {
36
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
37
+ }
38
+
39
+ const form = await request.formData();
40
+ const file = form.get('file');
41
+ if (!(file instanceof File) || file.size === 0) {
42
+ return Response.json({ error: 'Missing file' }, { status: 400 });
43
+ }
44
+ const isImage = file.type.startsWith('image/');
45
+ const isDocument = DOCUMENT_TYPES.has(file.type);
46
+ if (!isImage && !isDocument) {
47
+ const hint = file.type.startsWith('video/') || file.type.startsWith('audio/')
48
+ ? 'Video and audio uploads aren’t supported yet — use an embed card instead.'
49
+ : 'Unsupported file type.';
50
+ return Response.json({ error: hint }, { status: 415 });
51
+ }
52
+ const cap = isImage ? MAX_IMAGE : MAX_DOCUMENT;
53
+ if (file.size > cap) {
54
+ return Response.json({ error: `File too large (max ${Math.round(cap / 1024 / 1024)} MB)` }, { status: 413 });
55
+ }
56
+
57
+ const id = crypto.randomUUID();
58
+ const ext = (file.name.split('.').pop() || 'bin').toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin';
59
+ const key = `${id}/original.${ext}`;
60
+ const store = getMediaStore(locals);
61
+ await store.put(key, await file.arrayBuffer(), file.type);
62
+
63
+ const variants: { w: number; key: string }[] = [];
64
+ for (const [field, value] of form.entries()) {
65
+ const m = field.match(/^variant_(\d+)$/);
66
+ if (m && value instanceof File && value.size > 0) {
67
+ const w = Number(m[1]);
68
+ const vkey = `${id}/w${w}.webp`;
69
+ await store.put(vkey, await value.arrayBuffer(), 'image/webp');
70
+ variants.push({ w, key: vkey });
71
+ }
72
+ }
73
+ variants.sort((a, b) => a.w - b.w);
74
+
75
+ const repo = await getRepo(locals);
76
+ const media = await repo.createMedia({
77
+ id,
78
+ key,
79
+ filename: file.name,
80
+ mime: file.type,
81
+ width: Number(form.get('width') ?? 0),
82
+ height: Number(form.get('height') ?? 0),
83
+ size: file.size,
84
+ alt: String(form.get('alt') ?? ''),
85
+ variants,
86
+ });
87
+
88
+ return Response.json({ media });
89
+ };
@@ -0,0 +1,102 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { getMediaStore } from '../../lib/media/store';
5
+ import { unzip } from '../../lib/backup/unzip';
6
+ import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings } from '../../lib/schema';
7
+ import { z } from 'zod';
8
+
9
+ // Restore a backup ZIP produced by /api/backup. Two modes:
10
+ // POST /api/restore?dry=1 — parse + validate, return a report, change nothing
11
+ // POST /api/restore — REPLACE all content with the backup's
12
+ // Owner-only, like the backup download. Restored user accounts have no
13
+ // passwords (backups never include hashes) — sign in with ADMIN_PASSWORD.
14
+
15
+ const BackupContent = z.object({
16
+ format: z.literal('acms-backup'),
17
+ version: z.number(),
18
+ settings: Settings.partial(),
19
+ pages: z.array(Page),
20
+ posts: z.array(Post),
21
+ media: z.array(Media),
22
+ links: z.array(Link),
23
+ tags: z.array(Tag),
24
+ navItems: z.array(NavItem),
25
+ users: z.array(User),
26
+ // added in backup version 2; older backups restore with none
27
+ redirects: z.array(Redirect).default([]),
28
+ });
29
+
30
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
31
+ const sessionUserId = verifySessionToken(cookies.get(SESSION_COOKIE)?.value);
32
+ if (!sessionUserId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
33
+ const repo = await getRepo(locals);
34
+ const sessionUser = sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
35
+ if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') {
36
+ return Response.json({ error: 'Only owners can restore backups' }, { status: 403 });
37
+ }
38
+
39
+ const form = await request.formData();
40
+ const file = form.get('file');
41
+ if (!(file instanceof File) || file.size === 0) {
42
+ return Response.json({ error: 'Missing backup file' }, { status: 400 });
43
+ }
44
+ if (file.size > 200 * 1024 * 1024) {
45
+ return Response.json({ error: 'Backup too large (max 200 MB)' }, { status: 413 });
46
+ }
47
+
48
+ let entries: Map<string, Uint8Array>;
49
+ try {
50
+ entries = unzip(await file.arrayBuffer());
51
+ } catch (e) {
52
+ return Response.json({ error: e instanceof Error ? e.message : 'Unreadable ZIP' }, { status: 400 });
53
+ }
54
+ const contentRaw = entries.get('content.json');
55
+ if (!contentRaw) return Response.json({ error: 'content.json missing — not an acms backup' }, { status: 400 });
56
+
57
+ const parsed = BackupContent.safeParse(JSON.parse(new TextDecoder().decode(contentRaw)));
58
+ if (!parsed.success) {
59
+ return Response.json({ error: 'content.json failed validation — is this from a newer acms?' }, { status: 400 });
60
+ }
61
+ const content = parsed.data;
62
+
63
+ // an incomplete archive must not restore media rows whose binary is gone —
64
+ // that would publish permanently-404ing /media/ URLs. Rows missing their
65
+ // primary binary are dropped; rows missing only variants lose the variants.
66
+ const mediaKeys = content.media.flatMap((m) => [m.key, ...m.variants.map((v) => v.key)]);
67
+ const missingBinaries = mediaKeys.filter((k) => !entries.has(`media/${k}`));
68
+ content.media = content.media
69
+ .filter((m) => entries.has(`media/${m.key}`))
70
+ .map((m) => ({ ...m, variants: m.variants.filter((v) => entries.has(`media/${v.key}`)) }));
71
+
72
+ if (url.searchParams.get('dry')) {
73
+ return Response.json({
74
+ report: {
75
+ exportedAt: (JSON.parse(new TextDecoder().decode(contentRaw)) as { exportedAt?: string }).exportedAt ?? null,
76
+ posts: content.posts.length,
77
+ pages: content.pages.length,
78
+ media: content.media.length,
79
+ links: content.links.length,
80
+ tags: content.tags.length,
81
+ navItems: content.navItems.length,
82
+ users: content.users.length,
83
+ missingBinaries: missingBinaries.length,
84
+ },
85
+ });
86
+ }
87
+
88
+ // media binaries first, so content never references a missing object
89
+ const store = getMediaStore(locals);
90
+ for (const m of content.media) {
91
+ for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
92
+ const data = entries.get(`media/${key}`);
93
+ if (!data) continue;
94
+ const buf = new ArrayBuffer(data.byteLength);
95
+ new Uint8Array(buf).set(data);
96
+ await store.put(key, buf, m.mime);
97
+ }
98
+ }
99
+ await repo.restoreAll(content);
100
+
101
+ return Response.json({ ok: true, restored: { posts: content.posts.length, pages: content.pages.length, media: content.media.length } });
102
+ };
@@ -0,0 +1,66 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { authorSlug } from '../../lib/schema';
5
+ import { formatDate } from '../../lib/dates';
6
+ import { CONTENT, collectionUrl } from '../../lib/content';
7
+
8
+ const PAGE_SIZE = 10;
9
+ const { slug } = Astro.params;
10
+ const repo = await getRepo(Astro.locals);
11
+ const [users, settings] = await Promise.all([repo.listUsers(), repo.getSettings()]);
12
+ // stored slug wins; slugified name is the fallback so profiles work untouched
13
+ const author = users.find((u) => authorSlug(u) === slug);
14
+ if (!author) return new Response(null, { status: 404, statusText: 'Not found' });
15
+
16
+ // includes co-authored posts (authorIds), not just primary authorship
17
+ const allPosts = (await repo.listPublishedPosts()).filter(
18
+ (p) => p.authorId === author.id || p.authorIds.includes(author.id)
19
+ );
20
+ const pageCount = Math.max(1, Math.ceil(allPosts.length / PAGE_SIZE));
21
+ const pageNum = Math.min(pageCount, Math.max(1, Number(Astro.url.searchParams.get('page')) || 1));
22
+ const posts = allPosts.slice((pageNum - 1) * PAGE_SIZE, pageNum * PAGE_SIZE);
23
+ const base = `/author/${authorSlug(author)}`;
24
+
25
+ const fmt = (ts: number | null) =>
26
+ formatDate(ts, settings, 'short');
27
+ const readMins = (p: (typeof posts)[number]) =>
28
+ Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
29
+ ---
30
+
31
+ <Site title={author.name} description={author.bio || `${CONTENT.posts.plural} by ${author.name}`}>
32
+ <header class="author-hero">
33
+ {author.avatar ? (
34
+ <img class="author-avatar" src={author.avatar} alt={author.name} />
35
+ ) : (
36
+ <span class="author-avatar author-initial" aria-hidden="true">{author.name.slice(0, 1)}</span>
37
+ )}
38
+ <div>
39
+ <p class="tag-kicker">Author · {allPosts.length} {(allPosts.length === 1 ? CONTENT.posts.singular : CONTENT.posts.plural).toLowerCase()}</p>
40
+ <h1>{author.name}</h1>
41
+ {author.bio && <p class="tag-desc">{author.bio}</p>}
42
+ </div>
43
+ </header>
44
+ {posts.length === 0 && <p>No published {CONTENT.posts.plural.toLowerCase()} yet.</p>}
45
+ <ul class="post-list">
46
+ {posts.map((p) => (
47
+ <li class="post-item">
48
+ {p.cover && (
49
+ <a class="post-thumb" href={collectionUrl(p.collection, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
50
+ )}
51
+ <div>
52
+ <a href={collectionUrl(p.collection, p.slug)}>{p.title}</a>
53
+ <div class="post-meta">{fmt(p.publishedAt)} · {readMins(p)} min read</div>
54
+ {p.excerpt && <p class="post-excerpt">{p.excerpt}</p>}
55
+ </div>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ {pageCount > 1 && (
60
+ <nav class="pager" aria-label="Pagination">
61
+ {pageNum > 1 ? <a href={pageNum === 2 ? base : `${base}?page=${pageNum - 1}`}>← Newer</a> : <span />}
62
+ <span class="pager-status">Page {pageNum} of {pageCount}</span>
63
+ {pageNum < pageCount ? <a href={`${base}?page=${pageNum + 1}`}>Older →</a> : <span />}
64
+ </nav>
65
+ )}
66
+ </Site>