@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.
- package/CHANGELOG.md +13 -0
- package/integration.mjs +37 -0
- package/package.json +2 -1
- package/src/components/editor/extensions/cards.tsx +3 -38
- package/src/components/editor/extensions/mediaCards.tsx +4 -0
- package/src/components/import/ImportExport.tsx +519 -0
- package/src/components/import/runner.ts +284 -0
- package/src/lib/embeds.ts +50 -0
- package/src/lib/import/apply.ts +430 -0
- package/src/lib/import/bundle.ts +167 -0
- package/src/lib/import/export.ts +54 -0
- package/src/lib/import/ghost.ts +247 -0
- package/src/lib/import/lexical.ts +290 -0
- package/src/lib/import/migration.ts +27 -0
- package/src/lib/import/zipread.ts +72 -0
- package/src/lib/repo/index.ts +12 -8
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +116 -2
- package/src/lib/repo/types.ts +17 -0
- package/src/pages/admin/import.astro +16 -0
- package/src/pages/admin/settings.astro +11 -0
- package/src/pages/api/export.ts +41 -0
- package/src/pages/api/import/asset.ts +32 -0
- package/src/pages/api/import/index.ts +47 -0
- package/src/pages/api/import/media.ts +31 -0
package/src/lib/repo/index.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import type { ContentRepository } from './types';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
} else {
|
|
17
|
-
const { SqliteRepository } = await import('./sqlite');
|
|
18
|
-
repo = new SqliteRepository();
|
|
21
|
+
return new D1Repository(d1 as import('./d1').D1Database);
|
|
19
22
|
}
|
|
20
|
-
|
|
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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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 & 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
|
+
};
|