Files
emdash-patch-imageupload/packages/core/src/astro/integration/runtime.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

513 lines
15 KiB
TypeScript

/**
* Runtime utilities for EmDash
*
* This file contains functions that are used at runtime (in middleware, routes, etc.)
* and must work in all environments including Cloudflare Workers.
*
* DO NOT import Node.js-only modules here (fs, path, module, etc.)
*/
import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
import type { DatabaseDescriptor } from "../../db/adapters.js";
import type { MediaProviderDescriptor } from "../../media/types.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import type { StorageDescriptor } from "../storage/types.js";
export type { ResolvedPlugin };
export type { MediaProviderDescriptor };
/**
* Admin page definition (copied from plugins/types to avoid circular deps)
*/
export interface PluginAdminPage {
path: string;
label: string;
icon?: string;
}
/**
* Dashboard widget definition (copied from plugins/types to avoid circular deps)
*/
export interface PluginDashboardWidget {
id: string;
size?: "full" | "half" | "third";
title?: string;
}
/**
* Plugin descriptor - returned by plugin factory functions
*
* Contains all static metadata needed for manifest and admin UI,
* plus the entrypoint for runtime instantiation.
*
* @example
* ```ts
* export function myPlugin(options?: MyPluginOptions): PluginDescriptor {
* return {
* id: "my-plugin",
* version: "1.0.0",
* entrypoint: "@my-org/emdash-plugin-foo",
* options: options ?? {},
* adminEntry: "@my-org/emdash-plugin-foo/admin",
* adminPages: [{ path: "/settings", label: "Settings" }],
* };
* }
* ```
*/
/**
* Storage collection declaration for sandboxed plugins
*/
export interface StorageCollectionDeclaration {
indexes?: string[];
uniqueIndexes?: string[];
}
export interface PluginDescriptor<TOptions = Record<string, unknown>> {
/** Unique plugin identifier */
id: string;
/** Plugin version (semver) */
version: string;
/** Module specifier to import (e.g., "@emdash-cms/plugin-api-test") */
entrypoint: string;
/**
* Options to pass to createPlugin(). Native format only.
* Standard-format plugins configure themselves via KV settings
* and Block Kit admin pages -- not constructor options.
*/
options?: TOptions;
/**
* Plugin format. Determines how the entrypoint is loaded:
* - `"standard"` -- exports `definePlugin({ hooks, routes })` as default.
* Wrapped with `adaptSandboxEntry` for in-process execution. Can run in both
* `plugins: []` (in-process) and `sandboxed: []` (isolate).
* - `"native"` -- exports `createPlugin(options)` returning a `ResolvedPlugin`.
* Can only run in `plugins: []`. Cannot be sandboxed or published to marketplace.
*
* Defaults to `"native"` when unset.
*
*/
format?: "standard" | "native";
/** Admin UI module specifier (e.g., "@emdash-cms/plugin-audit-log/admin") */
adminEntry?: string;
/** Module specifier for site-side Astro rendering components (must export `blockComponents`) */
componentsEntry?: string;
/** Admin pages for navigation */
adminPages?: PluginAdminPage[];
/** Dashboard widgets */
adminWidgets?: PluginDashboardWidget[];
// === Sandbox-specific fields (for sandboxed plugins) ===
/**
* Capabilities the plugin requests.
* For standard-format plugins, capabilities are enforced in both trusted and
* sandboxed modes via the PluginContextFactory.
*/
capabilities?: string[];
/**
* Allowed hosts for network:fetch capability
* Supports wildcards like "*.example.com"
*/
allowedHosts?: string[];
/**
* Storage collections the plugin declares
* Sandboxed plugins can only access declared collections.
*/
storage?: Record<string, StorageCollectionDeclaration>;
}
/**
* Sandboxed plugin descriptor - same format as PluginDescriptor
*
* These run in isolated V8 isolates via Worker Loader on Cloudflare.
* The `entrypoint` is resolved to a file and bundled at build time.
*/
export type SandboxedPluginDescriptor<TOptions = Record<string, unknown>> =
PluginDescriptor<TOptions>;
export interface EmDashConfig {
/**
* Database configuration
*
* Use one of the adapter functions:
* - `sqlite({ url: "file:./data.db" })` - Local SQLite
* - `libsql({ url: "...", authToken: "..." })` - Turso/libSQL
* - `d1({ binding: "DB" })` - Cloudflare D1
*
* @example
* ```ts
* import { sqlite } from "emdash/db";
*
* emdash({
* database: sqlite({ url: "file:./data.db" }),
* })
* ```
*/
database?: DatabaseDescriptor;
/**
* Storage configuration (for media)
*/
storage?: StorageDescriptor;
/**
* Trusted plugins to load (run in main isolate)
*
* @example
* ```ts
* import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
* import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
*
* emdash({
* plugins: [
* auditLogPlugin(),
* webhookNotifierPlugin({ url: "https://example.com/webhook" }),
* ],
* })
* ```
*/
plugins?: PluginDescriptor[];
/**
* Sandboxed plugins to load (run in isolated V8 isolates)
*
* Only works on Cloudflare with Worker Loader enabled.
* Uses the same format as `plugins` - the difference is where they run.
*
* @example
* ```ts
* import { untrustedPlugin } from "some-third-party-plugin";
*
* emdash({
* plugins: [trustedPlugin()], // runs in host
* sandboxed: [untrustedPlugin()], // runs in isolate
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
sandboxed?: SandboxedPluginDescriptor[];
/**
* Module that exports the sandbox runner factory.
* Required if using sandboxed plugins.
*
* @example
* ```ts
* emdash({
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
sandboxRunner?: string;
/**
* Authentication configuration
*
* Use an auth adapter function from a platform package:
* - `access({ teamDomain: "..." })` from `@emdash-cms/cloudflare`
*
* When an external auth provider is configured, passkey auth is disabled.
*
* @example
* ```ts
* import { access } from "@emdash-cms/cloudflare";
*
* emdash({
* auth: access({
* teamDomain: "myteam.cloudflareaccess.com",
* audience: "abc123...",
* roleMapping: {
* "Admins": 50,
* "Editors": 30,
* },
* }),
* })
* ```
*/
auth?: AuthDescriptor;
/**
* Pluggable auth providers (login methods on the login page).
*
* Auth providers appear as options alongside passkey on the login page
* and setup wizard. Any provider can be used to create the initial
* admin account. Passkey is built-in; providers listed here are additive.
*
* @example
* ```ts
* import { atproto } from "@emdash-cms/auth-atproto";
*
* emdash({
* authProviders: [atproto()],
* })
* ```
*/
authProviders?: AuthProviderDescriptor[];
/**
* MCP (Model Context Protocol) server endpoint.
*
* Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp`
* that allows AI agents and tools to interact with the CMS using
* the standardized MCP protocol.
*
* Enabled by default. The endpoint requires bearer token auth, so
* it has no effect unless the user creates an API token and
* configures a client. Set to `false` to disable.
*
* @default true
*/
mcp?: boolean;
/**
* Plugin marketplace URL
*
* When set, enables the marketplace features: browse, install, update,
* and uninstall plugins from a remote marketplace.
*
* Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev.
* Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed).
*
* @example
* ```ts
* emdash({
* marketplace: "https://marketplace.emdashcms.com",
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
marketplace?: string;
/**
* Maximum allowed media file upload size in bytes.
*
* Applies to both direct multipart uploads and signed-URL uploads.
* When unset, defaults to 52_428_800 (50 MB).
*
* @example
* ```ts
* emdash({ maxUploadSize: 100 * 1024 * 1024 }) // 100 MB
* ```
*/
maxUploadSize?: number;
/**
* Public browser-facing origin for the site.
*
* Use when `Astro.url` / `request.url` do not match what users open — common with a
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
* while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
*
* Set to the full origin users type in the address bar (no path), e.g.
* `https://mysite.example.com`. When not set, falls back to environment variables
* `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
*
* Replaces `passkeyPublicOrigin` (which only fixed passkeys).
*/
siteUrl?: string;
/**
* Additional origins accepted by passkey verification.
*
* When the same EmDash deployment is reachable under several hostnames sharing
* a registrable parent (e.g. `https://example.com` plus
* `https://preview.example.com`), the canonical `siteUrl` defines the `rpId`
* and the entries here are the *additional* origins from which assertions
* are accepted. Each entry must be the same hostname as `siteUrl` or a
* subdomain of it — WebAuthn requires `rpId` to be a registrable suffix of
* every origin.
*
* Merged at runtime with the `EMDASH_ALLOWED_ORIGINS` env var (comma-separated).
* Validation:
* - Config-declared entries are shape-checked at Astro startup.
* - Subdomain relationship to `siteUrl` is checked at startup when
* `siteUrl` is also config-declared, otherwise at first passkey
* verification (since `siteUrl` may come from `EMDASH_SITE_URL`).
*
* Mismatches throw with a source-attributed message naming
* `config.allowedOrigins` or `EMDASH_ALLOWED_ORIGINS`.
*
* @example
* ```ts
* emdash({
* siteUrl: "https://example.com",
* allowedOrigins: ["https://preview.example.com"],
* })
* ```
*/
allowedOrigins?: string[];
/*
* Headers to trust for client IP resolution when running behind a reverse
* proxy. The first header in this list that is present on the request
* wins. Applies to rate limiting for auth endpoints and comment
* submission.
*
* Common values:
* - `x-real-ip` — nginx, Caddy, Traefik
* - `fly-client-ip` — Fly.io
* - `x-forwarded-for` — generic (first entry is used)
*
* Only set this when you **control the reverse proxy**. Untrusted
* clients can set any header they like; trusting headers from an open
* network is an IP-spoofing vulnerability that defeats rate limiting.
*
* On Cloudflare the `cf` object on the request is used automatically —
* you normally don't need to set this. Leave unset (or empty) to
* preserve the default: IP is resolved only when the request came
* through Cloudflare's edge.
*
* Falls back to `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated)
* when this option is not set, so operators can configure at deploy
* time without touching the Astro config.
*/
trustedProxyHeaders?: string[];
/**
* Enable playground mode for ephemeral "try EmDash" sites.
*
* When set, the integration injects a playground middleware (order: "pre")
* that runs BEFORE the normal EmDash middleware chain. It creates an
* isolated Durable Object database per session, runs migrations, applies
* the seed, creates an anonymous admin user, and sets the DB in ALS.
* By the time the runtime middleware runs, the database is fully ready.
*
* Setup and auth middleware are skipped (the playground handles both).
*
* Requires `@emdash-cms/cloudflare` as a dependency and a DO binding
* in wrangler.jsonc.
*
* @example
* ```ts
* emdash({
* database: playgroundDatabase({ binding: "PLAYGROUND_DB" }),
* playground: {
* middlewareEntrypoint: "@emdash-cms/cloudflare/db/playground-middleware",
* },
* })
* ```
*/
playground?: {
/** Module path for the playground middleware. */
middlewareEntrypoint: string;
};
/**
* Media providers for browsing and uploading media
*
* The local media provider (using storage adapter) is available by default.
* Additional providers can be added for external services like Unsplash,
* Cloudinary, Mux, Cloudflare Images, etc.
*
* @example
* ```ts
* import { cloudflareImages, cloudflareStream } from "@emdash-cms/cloudflare";
* import { unsplash } from "@emdash-cms/provider-unsplash";
*
* emdash({
* mediaProviders: [
* cloudflareImages({ accountId: "..." }),
* cloudflareStream({ accountId: "..." }),
* unsplash({ accessKey: "..." }),
* ],
* })
* ```
*/
mediaProviders?: MediaProviderDescriptor[];
/**
* Admin UI font configuration.
*
* By default, EmDash loads Noto Sans via the Astro Font API, covering
* Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek
* Extended, Devanagari, and Vietnamese. Fonts are downloaded from
* Google at build time and self-hosted, so there are no runtime CDN
* requests.
*
* To add support for additional writing systems (Arabic, CJK, etc.),
* pass script names. EmDash resolves the matching Noto Sans variant
* from Google Fonts and merges all script faces under a single
* font-family, so the browser downloads only the glyphs it needs
* via unicode-range.
*
* Set to `false` to disable font injection entirely and use system fonts.
*
* @example
* ```ts
* // Add Arabic and Japanese support
* emdash({
* fonts: {
* scripts: ["arabic", "japanese"],
* },
* })
* ```
*
* @example
* ```ts
* // Disable web fonts entirely (use system fonts)
* emdash({
* fonts: false,
* })
* ```
*/
fonts?:
| false
| {
/**
* Additional Noto Sans script families to include.
*
* Available scripts: arabic, armenian, bengali, chinese-simplified,
* chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi,
* georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer,
* korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu,
* thai, tibetan.
*/
scripts?: string[];
};
/**
* Admin UI branding (white-labeling).
*
* Overrides the default EmDash logo and name in the admin panel.
* Use this to white-label the CMS for agency or enterprise deployments.
* These settings are separate from the public site settings (title, logo,
* favicon) which remain available for SEO and front-end use.
*
* @example
* ```ts
* emdash({
* admin: {
* logo: "/images/agency-logo.webp",
* siteName: "AgencyX CMS",
* favicon: "/favicon.ico",
* },
* })
* ```
*/
admin?: {
/** URL or path to a custom logo image for the admin UI (login page, sidebar). */
logo?: string;
/** Custom name displayed in the admin sidebar and browser tab. */
siteName?: string;
/** URL or path to a custom favicon for the admin panel. */
favicon?: string;
};
}
/**
* Get stored config from global
* This is set by the virtual module at build time
*/
export function getStoredConfig(): EmDashConfig | null {
return globalThis.__emdashConfig || null;
}
/**
* Set stored config in global
* Called by the integration at config time
*/
export function setStoredConfig(config: EmDashConfig): void {
globalThis.__emdashConfig = config;
}
// Declare global type
declare global {
// eslint-disable-next-line no-var
var __emdashConfig: EmDashConfig | undefined;
}