@acms-subir/core 0.3.1 → 0.3.3-next.20260929113209

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.
@@ -1,23 +1,27 @@
1
1
  import type { ContentRepository } from './types';
2
2
 
3
- let repo: ContentRepository | null = null;
3
+ // The promise (not the instance) is cached: concurrent first requests would
4
+ // otherwise each create a repository and race to run pending migrations.
5
+ let repo: Promise<ContentRepository> | null = null;
4
6
 
5
7
  // Adapter priority: Cloudflare D1 (when the Worker binding exists —
6
8
  // production on Workers and local wrangler/astro dev via platformProxy)
7
9
  // → libSQL file (plain Node self-hosting).
8
10
  // Pass Astro.locals (or the endpoint's `locals`) so the D1 binding is
9
11
  // reachable; the chosen adapter is cached for the isolate's lifetime.
10
- export async function getRepo(locals?: unknown): Promise<ContentRepository> {
11
- if (repo) return repo;
12
+ export function getRepo(locals?: unknown): Promise<ContentRepository> {
13
+ repo ??= create(locals).catch((e) => { repo = null; throw e; });
14
+ return repo;
15
+ }
16
+
17
+ async function create(locals?: unknown): Promise<ContentRepository> {
12
18
  const d1 = (locals as { runtime?: { env?: { DB?: unknown } } } | undefined)?.runtime?.env?.DB;
13
19
  if (d1) {
14
20
  const { D1Repository } = await import('./d1');
15
- repo = new D1Repository(d1 as import('./d1').D1Database);
16
- } else {
17
- const { SqliteRepository } = await import('./sqlite');
18
- repo = new SqliteRepository();
21
+ return new D1Repository(d1 as import('./d1').D1Database);
19
22
  }
20
- return repo;
23
+ const { SqliteRepository } = await import('./sqlite');
24
+ return new SqliteRepository();
21
25
  }
22
26
 
23
27
  export type { ContentRepository };
@@ -1,4 +1,5 @@
1
1
  import { LIVE_MIGRATION } from '../live/migration';
2
+ import { IMPORT_MIGRATION } from '../import/migration';
2
3
  import { EMAIL_MIGRATION } from '../email/migration';
3
4
  import { WEBHOOK_MIGRATION } from '../webhook-sql';
4
5
  // Versioned schema migrations shared by the two SQLite-dialect adapters
@@ -499,6 +500,7 @@ export const MIGRATIONS: Migration[] = [
499
500
  ] },
500
501
  WEBHOOK_MIGRATION,
501
502
  LIVE_MIGRATION,
503
+ IMPORT_MIGRATION,
502
504
  ];
503
505
 
504
506
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -2,7 +2,7 @@ import { LiveStore } from '../live/store';
2
2
  import { LiveConfig } from '../live/types';
3
3
  import { ApiError } from '../api';
4
4
  import { EmailStore, bookingJobs } from '../email/store';
5
- import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
5
+ import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
6
6
  import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
7
7
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
8
8
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
@@ -68,6 +68,20 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
68
68
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
69
69
  });
70
70
  }
71
+ function rowToImportRun(r: Row): ImportRun {
72
+ let report: Record<string, unknown> = {};
73
+ try { report = JSON.parse(String(r.report ?? '{}')); } catch { /* keep empty */ }
74
+ return {
75
+ id: String(r.id), source: String(r.source), label: String(r.label ?? ''), status: String(r.status) as ImportRun['status'],
76
+ report, createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
77
+ };
78
+ }
79
+ function rowToImportRecord(r: Row): ImportRecord {
80
+ return {
81
+ runId: String(r.run_id), kind: String(r.kind) as ImportRecordKind, sourceId: String(r.source_id), acmsId: String(r.acms_id),
82
+ action: String(r.action) as ImportRecord['action'], stamp: Number(r.stamp ?? 0),
83
+ };
84
+ }
71
85
  function rowToSnippet(r: Row): Snippet {
72
86
  return Snippet.parse({
73
87
  id: r.id, name: r.name, contentJson: r.content_json ?? '[]', createdAt: Number(r.created_at),
@@ -1577,7 +1591,7 @@ export class SqlRepository implements ContentRepository {
1577
1591
  // Suppression and re-enabling share the restore transaction. A failed
1578
1592
  // restore cannot leave notifications disabled or emit phantom deletes.
1579
1593
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1580
- ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests'].map((t) => ({
1594
+ ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
1581
1595
  sql: `DELETE FROM ${t}`,
1582
1596
  })),
1583
1597
  ...(content.calendars ?? []).map((c) => ({
@@ -1656,6 +1670,106 @@ export class SqlRepository implements ContentRepository {
1656
1670
  await this.reindexSearch();
1657
1671
  }
1658
1672
 
1673
+ // ---- imports ----
1674
+ async createImportRun({ id, source, label }: Pick<ImportRun, 'id' | 'source' | 'label'>) {
1675
+ await this.ready;
1676
+ const now = Date.now();
1677
+ await this.driver.run('INSERT INTO import_runs (id, source, label, status, report, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, source, label, 'running', '{}', now, now]);
1678
+ }
1679
+ async updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>) {
1680
+ await this.ready;
1681
+ const sets = ['updated_at = ?'];
1682
+ const args: unknown[] = [Date.now()];
1683
+ if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
1684
+ if (data.report !== undefined) { sets.push('report = ?'); args.push(JSON.stringify(data.report)); }
1685
+ await this.driver.run(`UPDATE import_runs SET ${sets.join(', ')} WHERE id = ?`, [...args, id]);
1686
+ }
1687
+ async listImportRuns() {
1688
+ await this.ready;
1689
+ return (await this.driver.all('SELECT * FROM import_runs ORDER BY created_at DESC')).map(rowToImportRun);
1690
+ }
1691
+ async getImportRun(id: string) {
1692
+ await this.ready;
1693
+ const r = await this.first('SELECT * FROM import_runs WHERE id = ?', [id]);
1694
+ return r ? rowToImportRun(r) : null;
1695
+ }
1696
+ async addImportRecords(records: ImportRecord[]) {
1697
+ await this.ready;
1698
+ const now = Date.now();
1699
+ await this.driver.batch(records.map((r) => ({
1700
+ sql: 'INSERT INTO import_records (run_id, kind, source_id, acms_id, action, stamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, kind, source_id) DO UPDATE SET acms_id = excluded.acms_id, stamp = excluded.stamp',
1701
+ args: [r.runId, r.kind, r.sourceId, r.acmsId, r.action, r.stamp, now],
1702
+ })));
1703
+ }
1704
+ async listImportRecords(runId: string) {
1705
+ await this.ready;
1706
+ return (await this.driver.all('SELECT * FROM import_records WHERE run_id = ? ORDER BY created_at ASC', [runId])).map(rowToImportRecord);
1707
+ }
1708
+ async findImportRecords(kind: ImportRecordKind, sourceIds: string[]) {
1709
+ await this.ready;
1710
+ if (!sourceIds.length) return [];
1711
+ const out: ImportRecord[] = [];
1712
+ // chunked to stay under SQLite's bound-parameter limit
1713
+ for (let i = 0; i < sourceIds.length; i += 200) {
1714
+ const chunk = sourceIds.slice(i, i + 200);
1715
+ const rows = await this.driver.all(
1716
+ `SELECT r.* FROM import_records r JOIN import_runs u ON u.id = r.run_id
1717
+ WHERE u.status != 'undone' AND r.kind = ? AND r.source_id IN (${chunk.map(() => '?').join(', ')})
1718
+ ORDER BY r.created_at ASC`,
1719
+ [kind, ...chunk]
1720
+ );
1721
+ out.push(...rows.map(rowToImportRecord));
1722
+ }
1723
+ // later runs win
1724
+ return [...new Map(out.map((r) => [r.sourceId, r])).values()];
1725
+ }
1726
+ async writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }) {
1727
+ await this.ready;
1728
+ const posts = data.posts ?? [], pages = data.pages ?? [];
1729
+ const stmts: SqlStatement[] = [{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' }];
1730
+ for (const p of posts) {
1731
+ stmts.push({
1732
+ sql: `INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, draft, published_at, scheduled_at, created_at, updated_at)
1733
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1734
+ ON CONFLICT(id) DO UPDATE SET collection = excluded.collection, slug = excluded.slug, title = excluded.title, body = excluded.body,
1735
+ body_json = excluded.body_json, body_html = excluded.body_html, excerpt = excluded.excerpt, tags = excluded.tags, cover = excluded.cover,
1736
+ cover_alt = excluded.cover_alt, seo_title = excluded.seo_title, seo_desc = excluded.seo_desc, status = excluded.status,
1737
+ featured = excluded.featured, author_id = excluded.author_id, template = excluded.template, draft = NULL,
1738
+ published_at = excluded.published_at, scheduled_at = excluded.scheduled_at, updated_at = excluded.updated_at`,
1739
+ args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? null, p.template, null, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
1740
+ });
1741
+ stmts.push({ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [p.id] });
1742
+ p.authorIds.forEach((aid, i) => stmts.push({ sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)', args: [p.id, aid, i] }));
1743
+ }
1744
+ for (const p of pages) {
1745
+ stmts.push({
1746
+ sql: `INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, draft, created_at, updated_at)
1747
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1748
+ ON CONFLICT(id) DO UPDATE SET slug = excluded.slug, title = excluded.title, status = excluded.status, locale = excluded.locale,
1749
+ blocks = excluded.blocks, body_json = excluded.body_json, body_html = excluded.body_html, seo_title = excluded.seo_title,
1750
+ seo_desc = excluded.seo_desc, template = excluded.template, draft = NULL, updated_at = excluded.updated_at`,
1751
+ args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, null, p.createdAt, p.updatedAt],
1752
+ });
1753
+ }
1754
+ for (const id of data.deletePosts ?? []) {
1755
+ stmts.push(
1756
+ { sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
1757
+ { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
1758
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] },
1759
+ );
1760
+ }
1761
+ for (const id of data.deletePages ?? []) {
1762
+ stmts.push(
1763
+ { sql: 'DELETE FROM pages WHERE id = ?', args: [id] },
1764
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] },
1765
+ );
1766
+ }
1767
+ stmts.push({ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' });
1768
+ await this.driver.batch(stmts);
1769
+ for (const p of posts) await this.syncPostIndex(p.id);
1770
+ for (const p of pages) await this.syncPageIndex(p.id);
1771
+ }
1772
+
1659
1773
  // ---- redirects ----
1660
1774
  async listRedirects() {
1661
1775
  await this.ready;
@@ -1,6 +1,9 @@
1
1
  import type { LiveStore } from '../live/store';
2
2
  import type { BookingRequest } from '../backup/content';
3
3
  import type { EmailStore } from '../email/store';
4
+ export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
5
+ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
6
+ export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
4
7
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
5
8
 
6
9
  import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
@@ -260,6 +263,20 @@ export interface ContentRepository {
260
263
  bookingRequests?: BookingRequest[];
261
264
  }): Promise<void>;
262
265
 
266
+ // imports (lib/import/apply.ts drives these). writeImported is the
267
+ // id-preserving bulk write for imported posts/pages: original timestamps
268
+ // are kept and publication webhooks stay silent (a migration of old posts
269
+ // is not news), in one transaction with the search index refreshed after.
270
+ createImportRun(run: Pick<ImportRun, 'id' | 'source' | 'label'>): Promise<void>;
271
+ updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>): Promise<void>;
272
+ listImportRuns(): Promise<ImportRun[]>;
273
+ getImportRun(id: string): Promise<ImportRun | null>;
274
+ addImportRecords(records: ImportRecord[]): Promise<void>;
275
+ listImportRecords(runId: string): Promise<ImportRecord[]>;
276
+ // latest record per source id across runs that weren't undone
277
+ findImportRecords(kind: ImportRecordKind, sourceIds: string[]): Promise<ImportRecord[]>;
278
+ writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }): Promise<void>;
279
+
263
280
  // redirects — exact-path forwards, consulted when a request would 404.
264
281
  // createRedirect upserts on fromPath and prunes self/loop entries.
265
282
  listRedirects(): Promise<Redirect[]>;
@@ -0,0 +1,16 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import ImportExport from '../../components/import/ImportExport';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
6
+
7
+ // owner-only: an import can create accounts and change site settings
8
+ const repo = await getRepo(Astro.locals);
9
+ const sessionUserId = await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals);
10
+ const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
11
+ if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') return Astro.redirect('/admin/settings');
12
+ const tab = Astro.url.searchParams.get('tab');
13
+ ---
14
+ <Admin title="Import & export" section="settings" wide>
15
+ <ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} />
16
+ </Admin>
@@ -229,6 +229,7 @@ const groups = [
229
229
  { id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' },
230
230
  ] }] : []),
231
231
  { label: 'Advanced', items: [
232
+ ...(isOwner ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
232
233
  { id: 'backup', label: 'Backup', terms: 'backup restore export zip' },
233
234
  { id: 'backend', label: 'Backend', terms: 'backend database d1 sqlite' },
234
235
  ] },
@@ -571,6 +572,16 @@ const groups = [
571
572
 
572
573
  <div class="set-group-title">Advanced</div>
573
574
 
575
+ {isOwner && (
576
+ <section class="scard" id="import">
577
+ <header class="scard-head">
578
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
579
+ <div class="grow"><h2>Import &amp; export</h2><p>Import posts, pages, authors and images from Ghost or another acms site, or export this site's content. Imports merge with what's here and can be undone.</p></div>
580
+ <div class="scard-actions always"><a class="btn" href="/admin/import?tab=export">Export</a><a class="btn" href="/admin/import">Import</a></div>
581
+ </header>
582
+ </section>
583
+ )}
584
+
574
585
  <section class="scard" id="backup">
575
586
  <header class="scard-head">
576
587
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
@@ -0,0 +1,41 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { getMediaStore } from '../../lib/media/store';
4
+ import { apiError, requireSession } from '../../lib/api';
5
+ import { exportBundle } from '../../lib/import/export';
6
+ import { zipStream, type ZipEntry } from '../../lib/backup/zip';
7
+
8
+ // Portable content export (acms-export v1): bundle.json + media/<key>.
9
+ // Import it into any acms site from Admin → Import & export; unlike a backup
10
+ // it merges instead of replacing and carries no bookings or credentials.
11
+ export const GET: APIRoute = async ({ request, cookies, locals }) => {
12
+ try {
13
+ const repo = await getRepo(locals);
14
+ await requireSession(request, cookies, repo, true);
15
+ const now = Date.now();
16
+ const bundle = await exportBundle(repo, now);
17
+ const store = getMediaStore(locals);
18
+ const missing: string[] = [];
19
+ async function* entries(): AsyncGenerator<ZipEntry> {
20
+ // media first so a truncated download fails loudly on bundle.json
21
+ for (const m of bundle.media) {
22
+ for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
23
+ const obj = await store.get(key);
24
+ if (!obj) { missing.push(key); continue; }
25
+ yield { name: `media/${key}`, data: new Uint8Array(await new Response(obj.body).arrayBuffer()) };
26
+ }
27
+ }
28
+ const present = bundle.media.filter((m) => ![m.key, ...m.variants.map((v) => v.key)].some((k) => missing.includes(k)));
29
+ yield { name: 'bundle.json', data: new TextEncoder().encode(JSON.stringify({ ...bundle, media: present }, null, 1)) };
30
+ }
31
+ const stamp = new Date(now).toISOString().slice(0, 10);
32
+ return new Response(zipStream(entries(), now), {
33
+ headers: {
34
+ 'Content-Type': 'application/zip',
35
+ 'Cache-Control': 'no-store',
36
+ 'X-Content-Type-Options': 'nosniff',
37
+ 'Content-Disposition': `attachment; filename="acms-export-${stamp}.zip"`,
38
+ },
39
+ });
40
+ } catch (e) { return apiError(e); }
41
+ };
@@ -0,0 +1,32 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { ApiError, apiError, readJson, readResponseBytes, requireSession } from '../../../lib/api';
5
+ import { fetchPublicUrl } from '../../../lib/net';
6
+
7
+ // Download one file from the site being imported. The old site won't send
8
+ // CORS headers, so the browser can't fetch it directly; it asks here, then
9
+ // resizes and uploads the bytes like any other media. Owner-only, public
10
+ // hosts only (every redirect hop re-checked), capped, and never HTML — this
11
+ // must not turn into a general-purpose proxy.
12
+ const MAX_BYTES = 30 * 1024 * 1024;
13
+ const Body = z.object({ url: z.string().url().max(2000) });
14
+
15
+ export const POST: APIRoute = async ({ request, cookies, locals }) => {
16
+ try {
17
+ const repo = await getRepo(locals);
18
+ await requireSession(request, cookies, repo, true);
19
+ const { url } = Body.parse(await readJson(request, 10_000));
20
+ const got = await fetchPublicUrl(url, { accept: '*/*', userAgent: 'acms-importer/1.0', timeoutMs: 30_000 });
21
+ if (!got) throw new ApiError(502, 'The old site could not be reached for this file.');
22
+ const { res } = got;
23
+ if (!res.ok) throw new ApiError(502, `The old site answered ${res.status} for this file.`);
24
+ const type = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0].trim().toLowerCase();
25
+ if (type === 'text/html' || type.startsWith('video/') || type.startsWith('audio/')) {
26
+ throw new ApiError(415, type === 'text/html' ? 'The old site returned a web page instead of the file.' : 'Video and audio files aren’t re-hosted yet.');
27
+ }
28
+ const data = await readResponseBytes(res, MAX_BYTES);
29
+ // readResponseBytes allocates a fresh, exactly-sized buffer
30
+ return new Response(data as Uint8Array<ArrayBuffer>, { headers: { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
31
+ } catch (e) { return apiError(e); }
32
+ };
@@ -0,0 +1,47 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { getMediaStore } from '../../../lib/media/store';
5
+ import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
6
+ import { ImportBundle } from '../../../lib/import/bundle';
7
+ import { FinishInput, ImportOptions, ItemsInput, StartInput, finishImport, importItems, planImport, startImport, undoImport } from '../../../lib/import/apply';
8
+
9
+ // Import pipeline steps (see lib/import/apply.ts). Owner-only: an import can
10
+ // create accounts, rewrite navigation and change site settings.
11
+ const Body = z.discriminatedUnion('op', [
12
+ z.object({ op: z.literal('plan'), bundle: ImportBundle, options: ImportOptions }),
13
+ z.object({ op: z.literal('start') }).merge(StartInput),
14
+ z.object({ op: z.literal('items') }).merge(ItemsInput),
15
+ z.object({ op: z.literal('finish') }).merge(FinishInput),
16
+ z.object({ op: z.literal('undo'), runId: z.string().uuid(), removeMedia: z.boolean().default(false) }),
17
+ ]);
18
+
19
+ export const GET: APIRoute = async ({ request, cookies, locals }) => {
20
+ try {
21
+ const repo = await getRepo(locals);
22
+ await requireSession(request, cookies, repo, true);
23
+ return json({ runs: await repo.listImportRuns() });
24
+ } catch (e) { return apiError(e); }
25
+ };
26
+
27
+ export const POST: APIRoute = async ({ request, cookies, locals }) => {
28
+ try {
29
+ const repo = await getRepo(locals);
30
+ await requireSession(request, cookies, repo, true);
31
+ // item batches carry full post bodies
32
+ const body = Body.parse(await readJson(request, 12 * 1024 * 1024));
33
+ switch (body.op) {
34
+ case 'plan': return json(await planImport(repo, body.bundle, body.options));
35
+ case 'start': return json(await startImport(repo, body));
36
+ case 'items': return json(await importItems(repo, body));
37
+ case 'finish': return json(await finishImport(repo, body));
38
+ case 'undo': {
39
+ const store = getMediaStore(locals);
40
+ return json(await undoImport(repo, body.runId, { removeMedia: body.removeMedia, deleteMediaObject: (key) => store.delete(key) }));
41
+ }
42
+ }
43
+ } catch (e) {
44
+ if (e instanceof Error && !(e instanceof ApiError) && !(e instanceof z.ZodError)) return apiError(new ApiError(400, e.message));
45
+ return apiError(e);
46
+ }
47
+ };
@@ -0,0 +1,31 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { getMediaStore } from '../../../lib/media/store';
4
+ import { ApiError, apiError, requireSession } from '../../../lib/api';
5
+ import { Media } from '../../../lib/schema';
6
+
7
+ // Verbatim media from an acms export: the row keeps its id and storage keys,
8
+ // so every /media URL in the imported content resolves unchanged. Fields:
9
+ // `media` (JSON row) plus one file part per key, named by that key.
10
+ export const POST: APIRoute = async ({ request, cookies, locals }) => {
11
+ try {
12
+ const repo = await getRepo(locals);
13
+ await requireSession(request, cookies, repo, true);
14
+ const form = await request.formData();
15
+ const media = Media.parse(JSON.parse(String(form.get('media') ?? 'null')));
16
+ const keys = [media.key, ...media.variants.map((v) => v.key)];
17
+ for (const key of keys) {
18
+ if (!key.startsWith(`${media.id}/`) || key.includes('..') || key.includes('\\') || /[\x00-\x1f]/.test(key)) throw new ApiError(400, 'Unsafe media key.');
19
+ }
20
+ if (await repo.getMedia(media.id)) return Response.json({ media, existed: true });
21
+ const store = getMediaStore(locals);
22
+ for (const key of keys) {
23
+ const file = form.get(key);
24
+ if (!(file instanceof File) || !file.size) throw new ApiError(400, `Missing file for ${key}.`);
25
+ if (file.size > 30 * 1024 * 1024) throw new ApiError(413, 'File too large (max 30 MB).');
26
+ await store.put(key, await file.arrayBuffer(), key === media.key ? media.mime : 'image/webp');
27
+ }
28
+ const { createdAt: _createdAt, ...row } = media;
29
+ return Response.json({ media: await repo.createMedia(row), existed: false });
30
+ } catch (e) { return apiError(e); }
31
+ };