@acms-subir/core 0.3.2 → 0.3.3
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 +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +60 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +522 -0
- package/src/components/import/runner.ts +285 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +459 -0
- package/src/lib/import/bundle.ts +171 -0
- package/src/lib/import/export.ts +55 -0
- package/src/lib/import/ghost.ts +248 -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/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +13 -0
- package/src/lib/repo/sql.ts +156 -9
- package/src/lib/repo/types.ts +26 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/import.astro +16 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/settings.astro +11 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -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/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { crc32 } from '../backup/zip';
|
|
2
|
+
|
|
3
|
+
// Lazy ZIP reader for import sources: acms exports (stored) and archives made
|
|
4
|
+
// by Finder, Windows or `zip` (deflated). Only the central directory is read
|
|
5
|
+
// up front; entries inflate on demand through the platform's
|
|
6
|
+
// DecompressionStream('deflate-raw'), available in browsers, Workers and
|
|
7
|
+
// Node, so a large Ghost content folder never inflates all at once.
|
|
8
|
+
|
|
9
|
+
export interface ZipIndex {
|
|
10
|
+
names: string[];
|
|
11
|
+
has(name: string): boolean;
|
|
12
|
+
read(name: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openZip(buf: ArrayBuffer): ZipIndex {
|
|
16
|
+
const bytes = new Uint8Array(buf);
|
|
17
|
+
const view = new DataView(buf);
|
|
18
|
+
let eocd = -1;
|
|
19
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65536); i--) {
|
|
20
|
+
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
|
21
|
+
}
|
|
22
|
+
if (eocd < 0) throw new Error('Not a ZIP file.');
|
|
23
|
+
const count = view.getUint16(eocd + 10, true);
|
|
24
|
+
const dirSize = view.getUint32(eocd + 12, true);
|
|
25
|
+
let off = view.getUint32(eocd + 16, true);
|
|
26
|
+
if (count === 0xffff || off === 0xffffffff) throw new Error('ZIP64 archives are not supported. Split the folder into smaller ZIPs.');
|
|
27
|
+
if (off + dirSize > eocd) throw new Error('Corrupt ZIP directory.');
|
|
28
|
+
|
|
29
|
+
const entries = new Map<string, { method: number; crc: number; size: number; compSize: number; header: number }>();
|
|
30
|
+
for (let n = 0; n < count; n++) {
|
|
31
|
+
if (off + 46 > eocd || view.getUint32(off, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
|
|
32
|
+
const flags = view.getUint16(off + 8, true);
|
|
33
|
+
const method = view.getUint16(off + 10, true);
|
|
34
|
+
const nameLen = view.getUint16(off + 28, true);
|
|
35
|
+
const extraLen = view.getUint16(off + 30, true);
|
|
36
|
+
const commentLen = view.getUint16(off + 32, true);
|
|
37
|
+
const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
|
|
38
|
+
if (!(flags & 1) && !name.endsWith('/') && !name.split('/').includes('..') && !name.startsWith('/')) {
|
|
39
|
+
entries.set(name, {
|
|
40
|
+
method,
|
|
41
|
+
crc: view.getUint32(off + 16, true),
|
|
42
|
+
compSize: view.getUint32(off + 20, true),
|
|
43
|
+
size: view.getUint32(off + 24, true),
|
|
44
|
+
header: view.getUint32(off + 42, true),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
off += 46 + nameLen + extraLen + commentLen;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
names: [...entries.keys()],
|
|
52
|
+
has: (name) => entries.has(name),
|
|
53
|
+
async read(name) {
|
|
54
|
+
const e = entries.get(name);
|
|
55
|
+
if (!e) throw new Error(`${name} is not in the ZIP.`);
|
|
56
|
+
if (view.getUint32(e.header, true) !== 0x04034b50) throw new Error('Corrupt ZIP entry.');
|
|
57
|
+
const start = e.header + 30 + view.getUint16(e.header + 26, true) + view.getUint16(e.header + 28, true);
|
|
58
|
+
const raw = bytes.subarray(start, start + e.compSize);
|
|
59
|
+
let data: Uint8Array<ArrayBuffer>;
|
|
60
|
+
if (e.method === 0) data = raw;
|
|
61
|
+
else if (e.method === 8) data = await inflate(raw);
|
|
62
|
+
else throw new Error(`${name} uses an unsupported compression method.`);
|
|
63
|
+
if (data.length !== e.size || crc32(data) !== e.crc) throw new Error(`${name} is damaged (checksum mismatch).`);
|
|
64
|
+
return data;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function inflate(raw: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
|
|
70
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
71
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
72
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { Media } from '../schema';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { getMediaStore } from './store';
|
|
5
|
+
|
|
6
|
+
// Multipart upload shared by the admin (/api/media/upload) and editor apps
|
|
7
|
+
// (/api/v1/admin/media). For images the client has already done the heavy
|
|
8
|
+
// lifting (EXIF rotation, downscale, WebP re-encode, variant generation);
|
|
9
|
+
// this stores the optimized file + variants and records the media row.
|
|
10
|
+
// SVG/GIF arrive untouched. Documents (for the file/download card) are
|
|
11
|
+
// stored as-is. Video/audio is deliberately rejected — embeds cover video
|
|
12
|
+
// until a streaming upload + Range-serving pipeline exists.
|
|
13
|
+
//
|
|
14
|
+
// Fields: file (primary), width, height, alt?, variant_<w> (File, webp)…
|
|
15
|
+
export 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
|
+
export const MAX_IMAGE = 30 * 1024 * 1024;
|
|
32
|
+
export const MAX_DOCUMENT = 25 * 1024 * 1024;
|
|
33
|
+
/** a whole multipart body: the image plus its resized variants */
|
|
34
|
+
export const MAX_UPLOAD_BODY = MAX_IMAGE * 2;
|
|
35
|
+
|
|
36
|
+
export async function storeUpload(form: FormData, locals: unknown, repo: ContentRepository): Promise<Media> {
|
|
37
|
+
const file = form.get('file');
|
|
38
|
+
if (!(file instanceof File) || file.size === 0) throw new ApiError(400, 'Missing file');
|
|
39
|
+
const isImage = file.type.startsWith('image/');
|
|
40
|
+
const isDocument = DOCUMENT_TYPES.has(file.type);
|
|
41
|
+
if (!isImage && !isDocument) {
|
|
42
|
+
throw new ApiError(415, file.type.startsWith('video/') || file.type.startsWith('audio/')
|
|
43
|
+
? 'Video and audio uploads aren’t supported yet — use an embed card instead.'
|
|
44
|
+
: 'Unsupported file type.');
|
|
45
|
+
}
|
|
46
|
+
const cap = isImage ? MAX_IMAGE : MAX_DOCUMENT;
|
|
47
|
+
if (file.size > cap) throw new ApiError(413, `File too large (max ${Math.round(cap / 1024 / 1024)} MB)`);
|
|
48
|
+
|
|
49
|
+
const variantFields = [...form.entries()].filter(([field, value]) => /^variant_\d+$/.test(field) && value instanceof File && value.size > 0);
|
|
50
|
+
if (variantFields.length > 8) throw new ApiError(400, 'Too many image sizes (max 8).');
|
|
51
|
+
for (const [field, value] of variantFields) {
|
|
52
|
+
const w = Number(field.slice(8));
|
|
53
|
+
if (!Number.isInteger(w) || w < 16 || w > 8192) throw new ApiError(400, 'Image sizes must be 16–8192 pixels wide.');
|
|
54
|
+
if ((value as File).size > MAX_IMAGE) throw new ApiError(413, `File too large (max ${Math.round(MAX_IMAGE / 1024 / 1024)} MB)`);
|
|
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 variantFields) {
|
|
65
|
+
const w = Number(field.slice(8));
|
|
66
|
+
const vkey = `${id}/w${w}.webp`;
|
|
67
|
+
await store.put(vkey, await (value as File).arrayBuffer(), 'image/webp');
|
|
68
|
+
variants.push({ w, key: vkey });
|
|
69
|
+
}
|
|
70
|
+
variants.sort((a, b) => a.w - b.w);
|
|
71
|
+
|
|
72
|
+
return repo.createMedia({
|
|
73
|
+
id,
|
|
74
|
+
key,
|
|
75
|
+
filename: file.name,
|
|
76
|
+
mime: file.type,
|
|
77
|
+
width: Number(form.get('width') ?? 0),
|
|
78
|
+
height: Number(form.get('height') ?? 0),
|
|
79
|
+
size: file.size,
|
|
80
|
+
alt: String(form.get('alt') ?? ''),
|
|
81
|
+
variants,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { userConfig } from 'virtual:acms-config';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { failure, publicResponse } from './http';
|
|
5
|
+
|
|
6
|
+
export function association(platform: 'ios' | 'android', mobile = userConfig.mobile) {
|
|
7
|
+
if (platform === 'ios' && mobile?.ios) {
|
|
8
|
+
const { teamId, bundleId, paths } = mobile.ios;
|
|
9
|
+
return { applinks: { apps: [], details: [{ appID: `${teamId}.${bundleId}`, paths }] } };
|
|
10
|
+
}
|
|
11
|
+
if (platform === 'android' && mobile?.android) {
|
|
12
|
+
return [{ relation: ['delegate_permission/common.handle_all_urls'], target: {
|
|
13
|
+
namespace: 'android_app', package_name: mobile.android.package, sha256_cert_fingerprints: mobile.android.sha256CertFingerprints,
|
|
14
|
+
} }];
|
|
15
|
+
}
|
|
16
|
+
throw new ApiError(404, 'App association not configured.');
|
|
17
|
+
}
|
|
18
|
+
export function associationRoute(platform: 'ios' | 'android'): APIRoute {
|
|
19
|
+
return async ({ request }) => {
|
|
20
|
+
let res: Response;
|
|
21
|
+
try { res = await publicResponse(request, association(platform), true); }
|
|
22
|
+
catch (error) { res = failure(error); }
|
|
23
|
+
return request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -5,10 +5,12 @@ import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calen
|
|
|
5
5
|
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
6
6
|
import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
|
|
7
7
|
import { availabilityContext } from '../booking/service';
|
|
8
|
-
import { addDaysYmd, describeLink, linkLengths, slotsForDay, ymd } from '../booking/availability';
|
|
8
|
+
import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
|
|
9
9
|
import { siteOrigin } from '../seo';
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
11
|
import { pageQuery, isoDay, queryParams } from './http';
|
|
12
|
+
import { localizeDoc } from '../translations';
|
|
13
|
+
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
12
14
|
|
|
13
15
|
export function requireCalendar(bookings = false) {
|
|
14
16
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -19,31 +21,36 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
19
21
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
20
22
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
21
23
|
}
|
|
24
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDay.optional(), to: isoDay.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
|
|
22
25
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
23
26
|
requireCalendar();
|
|
24
|
-
const q =
|
|
25
|
-
const
|
|
26
|
-
const to = q.to ? Date.parse(q.to) : from + 30 * 86400000;
|
|
27
|
-
if (to <= from || to - from > 90 * 86400000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
|
|
27
|
+
const q = eventsQuery.parse(queryParams(url));
|
|
28
|
+
const locale = mobileLocale(q.locale);
|
|
28
29
|
const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
|
|
30
|
+
// days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
|
|
31
|
+
const tz = settings.timezone || 'UTC';
|
|
32
|
+
const from = localMidnight(q.from ?? ymd(Date.now(), tz), tz);
|
|
33
|
+
const to = q.to ? localMidnight(q.to, tz) : localMidnight(addDaysYmd(q.from ?? ymd(Date.now(), tz), 30), tz);
|
|
34
|
+
if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
|
|
29
35
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
30
36
|
const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
|
|
31
37
|
if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
|
|
32
38
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
|
|
33
|
-
const visible = events.filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
39
|
+
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
34
40
|
&& (!q.q || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q.q.toLowerCase())));
|
|
35
41
|
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
36
42
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|
|
37
43
|
const start = (q.page - 1) * q.limit;
|
|
38
44
|
const origin = siteOrigin(settings, url);
|
|
39
45
|
return {
|
|
40
|
-
timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
46
|
+
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
41
47
|
occurrences: occurrences.slice(start, start + q.limit).map(o => {
|
|
42
48
|
const event = eventToJson(o.event, origin, publicCalendars, settings.timezone);
|
|
49
|
+
event.url = localizedUrl(event.url, origin, locale);
|
|
43
50
|
// Only configured public event fields; no undeclared imported metadata.
|
|
44
51
|
event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(o.event.fields, f.key)).map(f => [f.key, o.event.fields[f.key]]));
|
|
45
52
|
event.image = safeUrl(o.event.cover, origin);
|
|
46
|
-
event.cta = { ...event.cta, href: safeUrl(event.cta.href, origin, true) };
|
|
53
|
+
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
47
54
|
return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, event };
|
|
48
55
|
}),
|
|
49
56
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
@@ -65,11 +72,12 @@ export async function listResources(repo: ContentRepository, url: URL) {
|
|
|
65
72
|
})),
|
|
66
73
|
})), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
|
|
67
74
|
}
|
|
75
|
+
export const availabilityQuery = z.object({ resource: z.string().min(1).max(200), link: z.string().min(1).max(200), from: isoDay.optional(),
|
|
76
|
+
days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
|
|
77
|
+
}).strict();
|
|
68
78
|
export async function getAvailability(repo: ContentRepository, url: URL) {
|
|
69
79
|
requireCalendar(true);
|
|
70
|
-
const q =
|
|
71
|
-
days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
|
|
72
|
-
}).strict().parse(queryParams(url));
|
|
80
|
+
const q = availabilityQuery.parse(queryParams(url));
|
|
73
81
|
const resource = await repo.getResourceBySlug(q.resource);
|
|
74
82
|
const link = resource ? await repo.getBookingLinkBySlug(resource.id, q.link) : null;
|
|
75
83
|
if (!resource || !link || !link.active) throw new ApiError(404, 'Resource or booking link not found.');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const MobileConfig = z.object({
|
|
4
|
+
ios: z.object({
|
|
5
|
+
teamId: z.string().regex(/^[A-Z0-9]{10}$/, 'Use a 10-character Apple team ID.'),
|
|
6
|
+
bundleId: z.string().regex(/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/, 'Use a bundle ID such as com.example.app.'),
|
|
7
|
+
paths: z.array(z.string().min(1).max(2000).refine(p => /^(?:NOT )?(?:\*|\/[^\r\n]*)$/.test(p), 'Use an absolute path or *, optionally prefixed with NOT.')).max(100).default(['*']),
|
|
8
|
+
}).strict().optional(),
|
|
9
|
+
android: z.object({
|
|
10
|
+
package: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/, 'Use an Android package such as com.example.app.'),
|
|
11
|
+
sha256CertFingerprints: z.array(z.string().regex(/^(?:[A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}$/, 'Use a colon-separated SHA-256 certificate fingerprint.').transform(s => s.toUpperCase())).min(1),
|
|
12
|
+
}).strict().optional(),
|
|
13
|
+
// Push notifications to the site's own Expo apps. Off unless configured:
|
|
14
|
+
// true, or { enabled, retentionDays } (devices not seen for that many days are removed).
|
|
15
|
+
push: z.union([z.boolean(), z.object({
|
|
16
|
+
enabled: z.boolean().default(true),
|
|
17
|
+
retentionDays: z.number().int().min(30).max(730).default(270),
|
|
18
|
+
}).strict()]).optional(),
|
|
19
|
+
// Editor apps (staff sign in through the browser, OAuth 2.0 + PKCE). Off
|
|
20
|
+
// unless redirect URIs are listed: only these exact URIs receive codes.
|
|
21
|
+
editor: z.object({
|
|
22
|
+
name: z.string().trim().min(1).max(80).optional(),
|
|
23
|
+
redirectUris: z.array(z.string().max(500).superRefine((uri, ctx) => {
|
|
24
|
+
const problem = redirectUriProblem(uri);
|
|
25
|
+
if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem });
|
|
26
|
+
})).min(1).max(20),
|
|
27
|
+
accessTokenMinutes: z.number().int().min(5).max(24 * 60).default(60),
|
|
28
|
+
refreshTokenDays: z.number().int().min(1).max(365).default(90),
|
|
29
|
+
}).strict().optional(),
|
|
30
|
+
}).strict();
|
|
31
|
+
|
|
32
|
+
const BLOCKED_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'about', 'blob', 'filesystem', 'ftp', 'ws', 'wss', 'mailto', 'tel', 'sms', 'intent', 'chrome', 'view-source']);
|
|
33
|
+
/** why a redirect URI can't be allowed, or '' (custom scheme, https, or http loopback per RFC 8252) */
|
|
34
|
+
export function redirectUriProblem(uri) {
|
|
35
|
+
let url;
|
|
36
|
+
try { url = new URL(uri); } catch { return 'Use an absolute URI such as com.example.staff://auth or https://example.com/app/auth.'; }
|
|
37
|
+
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
|
38
|
+
if (url.hash || uri.includes('#')) return 'Redirect URIs cannot contain a fragment.';
|
|
39
|
+
if (url.username || url.password) return 'Redirect URIs cannot contain credentials.';
|
|
40
|
+
if (BLOCKED_SCHEMES.has(scheme)) return `The ${scheme}: scheme cannot receive sign-in codes.`;
|
|
41
|
+
if (scheme === 'http') {
|
|
42
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
43
|
+
if (!['127.0.0.1', '::1', 'localhost'].includes(host)) return 'http:// is only allowed for loopback addresses (127.0.0.1, [::1], localhost); use https:// or an app scheme.';
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
if (scheme === 'https') return url.hostname ? '' : 'https:// redirect URIs need a host.';
|
|
47
|
+
if (!/^[a-z][a-z0-9+.-]*$/.test(scheme)) return 'Invalid URI scheme.';
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveMobile(value) {
|
|
52
|
+
const result = MobileConfig.safeParse(value === undefined ? {} : value);
|
|
53
|
+
if (!result.success) throw new Error(`acms: ${result.error.issues.map(i => `mobile.${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
54
|
+
const { push, editor, ...rest } = result.data;
|
|
55
|
+
const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270 };
|
|
56
|
+
return { ...rest, push: { enabled: options.enabled, retentionDays: options.retentionDays },
|
|
57
|
+
editor: editor ? { enabled: true, name: editor.name ?? '', redirectUris: [...new Set(editor.redirectUris)], accessTokenMinutes: editor.accessTokenMinutes, refreshTokenDays: editor.refreshTokenDays }
|
|
58
|
+
: { enabled: false, name: '', redirectUris: [], accessTokenMinutes: 60, refreshTokenDays: 90 } };
|
|
59
|
+
}
|
|
@@ -10,10 +10,12 @@ import { renderMarkdown } from '../markdown';
|
|
|
10
10
|
import { blocksToHtml } from '../blocksToHtml';
|
|
11
11
|
import { ApiError } from '../api';
|
|
12
12
|
import { pageQuery, queryParams } from './http';
|
|
13
|
+
import { localizeDoc } from '../translations';
|
|
14
|
+
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
13
15
|
|
|
14
16
|
export const contentQuery = z.object({
|
|
15
17
|
...pageQuery, kind: z.enum(['post', 'page']).optional(),
|
|
16
|
-
collection: z.string().min(1).max(80).optional(), locale:
|
|
18
|
+
collection: z.string().min(1).max(80).optional(), locale: localeQuery,
|
|
17
19
|
}).strict();
|
|
18
20
|
export function safeUrl(value: string, origin: string, link = false): string {
|
|
19
21
|
if (!value) return '';
|
|
@@ -22,7 +24,7 @@ export function safeUrl(value: string, origin: string, link = false): string {
|
|
|
22
24
|
return (['http:', 'https:', ...(link ? ['mailto:', 'tel:'] : [])].includes(url.protocol) && !url.username && !url.password) ? url.href : '';
|
|
23
25
|
} catch { return ''; }
|
|
24
26
|
}
|
|
25
|
-
export function mobileHtml(html: string, origin: string) {
|
|
27
|
+
export function mobileHtml(html: string, origin: string, locale: string | null = null) {
|
|
26
28
|
return sanitizeHtml(html, {
|
|
27
29
|
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
|
|
28
30
|
allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
|
|
@@ -30,20 +32,21 @@ export function mobileHtml(html: string, origin: string) {
|
|
|
30
32
|
allowedSchemesByTag: { img: ['https', 'http'] },
|
|
31
33
|
allowProtocolRelative: false,
|
|
32
34
|
transformTags: {
|
|
33
|
-
a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: safeUrl(attrs.href ?? '', origin, true) } }),
|
|
35
|
+
a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: localizedUrl(safeUrl(attrs.href ?? '', origin, true), origin, locale) } }),
|
|
34
36
|
img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: safeUrl(attrs.src ?? '', origin) } }),
|
|
35
37
|
},
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
40
|
const iso = (n: number | null) => n === null ? null : new Date(n).toISOString();
|
|
39
|
-
function
|
|
41
|
+
export function contentDto(raw: Page | Post, kind: 'page' | 'post', origin: string, detail: boolean, locale: string | null) {
|
|
42
|
+
const doc = localizeDoc(raw, locale ?? undefined);
|
|
40
43
|
const post = kind === 'post' ? doc as Post : null;
|
|
41
44
|
const page = kind === 'page' ? doc as Page : null;
|
|
42
45
|
const result = {
|
|
43
46
|
id: doc.id, kind, slug: doc.slug, title: doc.title,
|
|
44
47
|
collection: post?.collection ?? null,
|
|
45
|
-
locale
|
|
46
|
-
url: safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin),
|
|
48
|
+
locale,
|
|
49
|
+
url: localizedUrl(safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin), origin, locale),
|
|
47
50
|
excerpt: post?.excerpt ?? page!.seoDesc,
|
|
48
51
|
tags: post ? publicTags(post.tags) : [],
|
|
49
52
|
image: post ? safeUrl(post.cover, origin) : '', imageAlt: post?.coverAlt ?? '',
|
|
@@ -53,7 +56,7 @@ function dto(doc: Page | Post, kind: 'page' | 'post', origin: string, detail: bo
|
|
|
53
56
|
if (!detail) return result;
|
|
54
57
|
// An intentionally empty canonical editor body must not revive legacy text.
|
|
55
58
|
const html = doc.bodyJson ? doc.bodyHtml : doc.bodyHtml || (post ? renderMarkdown(post.body) : blocksToHtml(page!.blocks));
|
|
56
|
-
return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url), dynamicWidgets: 'website-only' as const } };
|
|
59
|
+
return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url, locale), dynamicWidgets: 'website-only' as const } };
|
|
57
60
|
}
|
|
58
61
|
function visible(doc: Page | Post, kind: 'page' | 'post') {
|
|
59
62
|
return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
|
|
@@ -62,17 +65,19 @@ export async function listContent(repo: ContentRepository, url: URL) {
|
|
|
62
65
|
const q = contentQuery.parse(queryParams(url));
|
|
63
66
|
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
64
67
|
if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
|
|
65
|
-
|
|
68
|
+
const locale = mobileLocale(q.locale);
|
|
66
69
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
67
|
-
const rows = await repo.listPublicContent({ ...q, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
|
|
70
|
+
const rows = await repo.listPublicContent({ ...q, locale: undefined, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
|
|
68
71
|
const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
|
|
69
72
|
const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
|
|
70
|
-
return doc && visible(doc, r.kind) ?
|
|
73
|
+
return doc && visible(doc, r.kind) ? contentDto(doc, r.kind, origin, false, locale) : null;
|
|
71
74
|
}));
|
|
72
|
-
return { items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
|
|
75
|
+
return { locale, items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
|
|
73
76
|
}
|
|
77
|
+
export const contentDetailQuery = z.object({ kind: z.enum(['post', 'page']).optional(), locale: localeQuery }).strict();
|
|
74
78
|
export async function getContent(repo: ContentRepository, url: URL, id: string) {
|
|
75
|
-
const q =
|
|
79
|
+
const q = contentDetailQuery.parse(queryParams(url));
|
|
80
|
+
const locale = mobileLocale(q.locale);
|
|
76
81
|
// IDs are UUIDs, but restored databases may contain arbitrary legacy IDs.
|
|
77
82
|
if (!id || id.length > 200) throw new ApiError(404, 'Content not found.');
|
|
78
83
|
const post = q.kind === 'page' ? null : await repo.getPost(id);
|
|
@@ -81,5 +86,5 @@ export async function getContent(repo: ContentRepository, url: URL, id: string)
|
|
|
81
86
|
const kind = post ? 'post' : 'page';
|
|
82
87
|
const doc = post ?? page;
|
|
83
88
|
if (!doc || !visible(doc, kind)) throw new ApiError(404, 'Content not found.');
|
|
84
|
-
return
|
|
89
|
+
return contentDto(doc, kind, siteOrigin(await repo.getSettings(), url), true, locale);
|
|
85
90
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { getTheme } from '../theme';
|
|
5
|
+
import { wallNow } from '../sections';
|
|
6
|
+
import { I18N } from '../i18n';
|
|
7
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
8
|
+
import { queryParams } from './http';
|
|
9
|
+
import { publishedPage, visibleSection } from './pages';
|
|
10
|
+
|
|
11
|
+
// One published section as its own chrome-less document, for apps that have
|
|
12
|
+
// no native component for it (GET /embed/section/<page|home>/<sectionId>).
|
|
13
|
+
// The same visitor rules as the website and /api/v1/pages apply: a hidden,
|
|
14
|
+
// off-schedule, invalid or unknown section is a 404, never a preview.
|
|
15
|
+
|
|
16
|
+
export const embedQuery = z.object({ locale: localeQuery, scheme: z.enum(['light', 'dark']).optional() }).strict();
|
|
17
|
+
|
|
18
|
+
export interface EmbedSection {
|
|
19
|
+
/** The page to hand to ThemeSections (the language's own home when it has one). */
|
|
20
|
+
page: NonNullable<Awaited<ReturnType<ContentRepository['getPage']>>>;
|
|
21
|
+
/** Settings whose layout for that page holds only the requested section. */
|
|
22
|
+
settings: Awaited<ReturnType<ContentRepository['getSettings']>>;
|
|
23
|
+
sectionId: string;
|
|
24
|
+
type: string;
|
|
25
|
+
/** Language to render in (the site default when none was asked for). */
|
|
26
|
+
locale: string;
|
|
27
|
+
scheme?: 'light' | 'dark';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function embedSection(repo: ContentRepository, url: URL, path: string, now = Date.now()): Promise<EmbedSection> {
|
|
31
|
+
const q = embedQuery.safeParse(queryParams(url));
|
|
32
|
+
if (!q.success) throw new ApiError(400, 'Invalid request parameters.');
|
|
33
|
+
const locale = mobileLocale(q.data.locale);
|
|
34
|
+
const parts = path.split('/').filter(Boolean);
|
|
35
|
+
const sectionId = parts.pop() ?? '';
|
|
36
|
+
const slug = parts.join('/');
|
|
37
|
+
if (!slug || !sectionId || path.length > 600) throw new ApiError(404, 'Section not found.');
|
|
38
|
+
const settings = await repo.getSettings();
|
|
39
|
+
const page = await publishedPage(repo, settings, slug, locale).catch(() => { throw new ApiError(404, 'Section not found.'); });
|
|
40
|
+
const layout = settings.sectionPages[page.id];
|
|
41
|
+
const stored = layout?.mode === 'sections' ? layout.sections.find((s) => s.id === sectionId) : undefined;
|
|
42
|
+
const section = stored && visibleSection(getTheme(settings.activeTheme), stored, wallNow(settings.timezone || 'UTC', now));
|
|
43
|
+
if (!layout || !stored || !section) throw new ApiError(404, 'Section not found.');
|
|
44
|
+
return {
|
|
45
|
+
page, sectionId, type: section.type, scheme: q.data.scheme,
|
|
46
|
+
locale: locale ?? I18N.defaultLocale ?? '',
|
|
47
|
+
settings: { ...settings, sectionPages: { ...settings.sectionPages, [page.id]: { ...layout, mode: 'sections', sections: [stored] } } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reports the section's height and hands link taps to the app instead of
|
|
53
|
+
* navigating: React Native WebView (window.ReactNativeWebView) or, on the
|
|
54
|
+
* web, the parent window of the iframe. Messages carry only public data.
|
|
55
|
+
*/
|
|
56
|
+
export function embedBridgeScript(sectionId: string): string {
|
|
57
|
+
return `(function () {
|
|
58
|
+
var id = ${JSON.stringify(sectionId).replace(/</g, '\\u003c')};
|
|
59
|
+
var post = function (msg) {
|
|
60
|
+
msg.acmsSection = id;
|
|
61
|
+
if (window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
|
62
|
+
else if (window.parent !== window) window.parent.postMessage(msg, '*');
|
|
63
|
+
};
|
|
64
|
+
var root = document.getElementById('acms-embed');
|
|
65
|
+
var last = 0;
|
|
66
|
+
var send = function () {
|
|
67
|
+
var h = Math.ceil(root.getBoundingClientRect().height);
|
|
68
|
+
if (h !== last) { last = h; post({ type: 'height', height: h }); }
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener('click', function (e) {
|
|
71
|
+
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
|
72
|
+
if (!a) return;
|
|
73
|
+
var raw = a.getAttribute('href') || '';
|
|
74
|
+
if (!raw || raw.charAt(0) === '#') return;
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
post({ type: 'navigate', href: a.href });
|
|
77
|
+
}, true);
|
|
78
|
+
if (window.ResizeObserver) new ResizeObserver(send).observe(root);
|
|
79
|
+
window.addEventListener('load', send);
|
|
80
|
+
document.addEventListener('toggle', send, true);
|
|
81
|
+
send();
|
|
82
|
+
})();`;
|
|
83
|
+
}
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -6,8 +6,9 @@ import type { ContentRepository } from '../repo/types';
|
|
|
6
6
|
|
|
7
7
|
const headers = {
|
|
8
8
|
'Access-Control-Allow-Origin': '*',
|
|
9
|
-
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
|
|
10
|
-
|
|
9
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
10
|
+
// Authorization carries editor-app bearer tokens; no route reads cookies
|
|
11
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match',
|
|
11
12
|
'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
|
|
12
13
|
'X-Content-Type-Options': 'nosniff',
|
|
13
14
|
'Cache-Control': 'no-store',
|
|
@@ -18,8 +19,8 @@ export const response = (data: unknown, status = 200, extra: HeadersInit = {}) =
|
|
|
18
19
|
new Headers(extra).forEach((value, key) => h.set(key, value));
|
|
19
20
|
return Response.json({ version: 1, data }, { status, headers: h });
|
|
20
21
|
};
|
|
21
|
-
export async function publicResponse(request: Request, data: unknown) {
|
|
22
|
-
const res = response(data);
|
|
22
|
+
export async function publicResponse(request: Request, data: unknown, raw = false) {
|
|
23
|
+
const res = raw ? Response.json(data, { headers }) : response(data);
|
|
23
24
|
const etag = `"${await sha256Hex(await res.clone().text())}"`;
|
|
24
25
|
res.headers.set('ETag', etag);
|
|
25
26
|
// Revalidate even fresh-looking content: unpublishing must take effect.
|
|
@@ -29,12 +30,13 @@ export async function publicResponse(request: Request, data: unknown) {
|
|
|
29
30
|
}
|
|
30
31
|
export function failure(error: unknown) {
|
|
31
32
|
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
32
|
-
const codes: Record<number, string> = { 400: 'invalid_request', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited' };
|
|
33
|
+
const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
|
|
33
34
|
return Response.json({ version: 1, error: {
|
|
34
35
|
code: codes[status] ?? 'internal_error',
|
|
35
36
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
36
37
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
37
|
-
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {})
|
|
38
|
+
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}),
|
|
39
|
+
...(status === 401 ? { 'WWW-Authenticate': 'Bearer realm="acms", error="invalid_token"' } : {}) } });
|
|
38
40
|
}
|
|
39
41
|
export async function limitRequest(repo: ContentRepository, address: string, write = false) {
|
|
40
42
|
const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import { I18N, localePath } from '../i18n';
|
|
4
|
+
|
|
5
|
+
export const localeQuery = z.string().min(1).max(35).optional();
|
|
6
|
+
export function mobileLocale(value?: string) {
|
|
7
|
+
if (value && !I18N.locales.includes(value)) throw new ApiError(400, 'Unknown locale.');
|
|
8
|
+
return value ?? (I18N.defaultLocale || null);
|
|
9
|
+
}
|
|
10
|
+
export function localizedUrl(value: string, origin: string, locale: string | null) {
|
|
11
|
+
if (!value || !locale) return value;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value, origin);
|
|
14
|
+
if (url.origin !== new URL(origin).origin || /^\/(?:_|(?:media|api|admin|go)(?:\/|$))|\.[a-z0-9]+$/i.test(url.pathname)) return value;
|
|
15
|
+
url.pathname = localePath(url.pathname, locale);
|
|
16
|
+
return url.href;
|
|
17
|
+
} catch { return value; }
|
|
18
|
+
}
|