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
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# @emdash-cms/plugin-audit-log
## 0.1.2
### Patch Changes
- [#856](https://github.com/emdash-cms/emdash/pull/856) [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Republishes with `emdash` as a `peerDependency` instead of a runtime `dependency`. The previously published `0.1.1` release pinned `emdash` to an ancient `0.1.1` as a hard dependency, which made `npm install` of any template that included this plugin (e.g. the blog template) install two copies of `emdash` side-by-side, or fail outright with ERESOLVE on stricter npm configurations (#819). The package source already declared the dependency correctly; this version simply ships the corrected `package.json`.
- Updated dependencies [[`e2b3c6c`](https://github.com/emdash-cms/emdash/commit/e2b3c6cd930d5fa6fc607a0b26fd796f5b0f98b2), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`e0dc6fb`](https://github.com/emdash-cms/emdash/commit/e0dc6fb8adadc0e048f3f314d62bfa98d9bb48d4), [`c22fb3a`](https://github.com/emdash-cms/emdash/commit/c22fb3a10d445f12cca91620c9258d50695afa44), [`6a4e9b8`](https://github.com/emdash-cms/emdash/commit/6a4e9b8b0fa6064989224a42b14de435f487a76f), [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf), [`22a16ee`](https://github.com/emdash-cms/emdash/commit/22a16eed607a4e81391ecb6c45fe2e59aaca92fe), [`1e2b024`](https://github.com/emdash-cms/emdash/commit/1e2b02486ee0407e4f50b8342ba1a9e7d060e405), [`81662e9`](https://github.com/emdash-cms/emdash/commit/81662e98fcf1ad0ee880d4f1af96271c527d7423), [`2f22f57`](https://github.com/emdash-cms/emdash/commit/2f22f57abadf305cf6d3ce07ee78290178e032d1), [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86), [`a9c29ea`](https://github.com/emdash-cms/emdash/commit/a9c29ea584300f6cf67206bedcb1d39f05ea1c26), [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347), [`d5f7c48`](https://github.com/emdash-cms/emdash/commit/d5f7c481a507868f470361cfd715a5828640d45a), [`8ae227c`](https://github.com/emdash-cms/emdash/commit/8ae227cceade5c9852897c7b56f89e7422ee82a1), [`e2d5d16`](https://github.com/emdash-cms/emdash/commit/e2d5d160acea4444945b1ea79c80ca9ce138965b), [`0d98c62`](https://github.com/emdash-cms/emdash/commit/0d98c620a5f407648f3b7f3cbd30b642c74be607), [`64bf5b9`](https://github.com/emdash-cms/emdash/commit/64bf5b98125ca18ec26f7e0e65a71fcbe71fd44f), [`e81aa0f`](https://github.com/emdash-cms/emdash/commit/e81aa0f717be11bacdff30ed9bbc454824268555), [`0041d76`](https://github.com/emdash-cms/emdash/commit/0041d7699b32b77b4cd2ecd77b97340f0dd3abce), [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743), [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e), [`5b6f059`](https://github.com/emdash-cms/emdash/commit/5b6f059d06175ae0cb740d1ba32867d1ec6b2249), [`a86ff80`](https://github.com/emdash-cms/emdash/commit/a86ff80836fed175508ff06f744c7ad6b805627c), [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94), [`eb6dbd0`](https://github.com/emdash-cms/emdash/commit/eb6dbd056717fd076a8b5fa807d91516a00f5f2f)]:
- emdash@0.9.0
## 0.1.1
### Patch Changes
- [#363](https://github.com/emdash-cms/emdash/pull/363) [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes sandboxed plugin entries failing when package exports point to unbuilt TypeScript source. Adds build-time and bundle-time validation to catch misconfigured plugin exports early.
- Updated dependencies [[`422018a`](https://github.com/emdash-cms/emdash/commit/422018aeb227dffe3da7bfc772d86f9ce9c2bcd1), [`4221ba4`](https://github.com/emdash-cms/emdash/commit/4221ba48bc87ab9fa0b1bae144f6f2920beb4f5a), [`9269759`](https://github.com/emdash-cms/emdash/commit/9269759674bf254863f37d4cf1687fae56082063), [`d6cfc43`](https://github.com/emdash-cms/emdash/commit/d6cfc437f23e3e435a8862cab17d2c19363847d7), [`1bcfc50`](https://github.com/emdash-cms/emdash/commit/1bcfc502112d8756e34a720b8a170eb5486b425a), [`8c693b5`](https://github.com/emdash-cms/emdash/commit/8c693b582d7c5e29bd138161e81d9c8affb53689), [`5b3e33c`](https://github.com/emdash-cms/emdash/commit/5b3e33c26bc2eb30ab2a032960a5d57eb06f148a), [`9d10d27`](https://github.com/emdash-cms/emdash/commit/9d10d2791fe16be901d9d138e434bd79cf9335c4), [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569), [`f112ac4`](https://github.com/emdash-cms/emdash/commit/f112ac48194d1c2302e93756d54b116d3d207c22), [`e9a6f7a`](https://github.com/emdash-cms/emdash/commit/e9a6f7ac3ceeaf5c2d0a557e4cf6cab5f3d7d764), [`b297fdd`](https://github.com/emdash-cms/emdash/commit/b297fdd88dadcabeb93f47abea9f24f70b7d4b71), [`d211452`](https://github.com/emdash-cms/emdash/commit/d2114523a55021f65ee46e44e11157b06334819e), [`8e28cfc`](https://github.com/emdash-cms/emdash/commit/8e28cfc5d66f58f0fb91aa35c02afdd426bb6555), [`38af118`](https://github.com/emdash-cms/emdash/commit/38af118ad517fd9aa83064368543bf64bc32c08a)]:
- emdash@0.1.1
## 0.1.0
### Minor Changes
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
### Patch Changes
- Updated dependencies [[`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4)]:
- emdash@0.1.0
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2

View File

@@ -0,0 +1,45 @@
{
"name": "@emdash-cms/plugin-audit-log",
"version": "0.1.2",
"description": "Audit logging plugin for EmDash CMS - tracks content changes",
"type": "module",
"main": "dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.mts"
},
"./sandbox": "./dist/sandbox-entry.mjs"
},
"files": [
"dist"
],
"keywords": [
"emdash",
"cms",
"plugin",
"audit",
"logging",
"history"
],
"author": "Matt Kane",
"license": "MIT",
"peerDependencies": {
"emdash": "workspace:>=0.9.0"
},
"devDependencies": {
"tsdown": "catalog:",
"typescript": "catalog:"
},
"scripts": {
"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean",
"dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch",
"typecheck": "tsgo --noEmit"
},
"optionalDependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/audit-log"
}
}

View File

@@ -0,0 +1,53 @@
/**
* Audit Log Plugin for EmDash CMS
*
* Tracks all content and media changes for compliance and debugging.
*
* Features:
* - Logs create, update, delete operations
* - Tracks before/after state for updates
* - Records user information (when available)
* - Provides admin UI for viewing audit history
* - Configurable retention period (admin settings)
* - Uses plugin storage for persistent audit trail
*
* Demonstrates:
* - Plugin storage with indexes and queries
* - Admin-configurable settings schema
* - Lifecycle hooks (install, activate, deactivate, uninstall)
* - content:afterDelete hook
*/
import type { PluginDescriptor } from "emdash";
export interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete" | "media:upload" | "media:delete";
collection?: string;
resourceId: string;
resourceType: "content" | "media";
userId?: string;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
}
/**
* Create the audit log plugin descriptor
*/
export function auditLogPlugin(): PluginDescriptor {
return {
id: "audit-log",
version: "0.1.0",
format: "standard",
entrypoint: "@emdash-cms/plugin-audit-log/sandbox",
capabilities: ["read:content"],
storage: {
entries: { indexes: ["timestamp", "action", "resourceType", "collection"] },
},
adminPages: [{ path: "/history", label: "Audit History", icon: "history" }],
adminWidgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
};
}

View File

@@ -0,0 +1,373 @@
/**
* Sandbox Entry Point -- Audit Log
*
* Canonical plugin implementation using the standard format.
* Runs in both trusted (in-process) and sandboxed (isolate) modes.
*
* Note: The beforeSaveCache is module-scoped. In sandbox isolates that persist
* across hook invocations within a request, this works correctly. In isolates
* that don't persist, updates will be logged without "before" state (graceful
* degradation -- the entry is still recorded).
*/
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
interface ContentSaveEvent {
content: Record<string, unknown> & {
id?: string | number;
slug?: string;
status?: string;
data?: Record<string, unknown>;
};
collection: string;
isNew: boolean;
}
interface ContentDeleteEvent {
id: string;
collection: string;
}
interface MediaUploadEvent {
media: { id: string };
}
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete" | "media:upload" | "media:delete";
collection?: string;
resourceId: string;
resourceType: "content" | "media";
userId?: string;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
}
// ── Helpers ──
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isAuditEntry(value: unknown): value is AuditEntry {
return (
isRecord(value) &&
typeof value.timestamp === "string" &&
typeof value.action === "string" &&
typeof value.resourceId === "string" &&
typeof value.resourceType === "string"
);
}
// In-memory cache for content state before save/delete.
// Works within a single request lifecycle if the isolate persists.
const beforeSaveCache = new Map<string, unknown>();
// ── Plugin definition ──
export default definePlugin({
hooks: {
"plugin:install": async (_event: unknown, ctx: PluginContext) => {
ctx.log.info("Audit log plugin installed");
},
"plugin:activate": async (_event: unknown, ctx: PluginContext) => {
ctx.log.info("Audit log plugin activated");
},
"plugin:deactivate": async (_event: unknown, ctx: PluginContext) => {
ctx.log.info("Audit log plugin deactivated");
},
"plugin:uninstall": async (_event: unknown, ctx: PluginContext) => {
ctx.log.info("Audit log plugin uninstalled");
},
"content:beforeSave": {
handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
if (!event.isNew && event.content.id) {
const contentId =
typeof event.content.id === "string" ? event.content.id : String(event.content.id);
try {
if (ctx.content) {
const existing = await ctx.content.get(event.collection, contentId);
if (existing) {
beforeSaveCache.set(`${event.collection}:${contentId}`, existing);
}
}
} catch {
// Ignore -- best effort
}
}
return event.content;
},
},
"content:afterSave": {
handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
const contentId =
typeof event.content.id === "string" ? event.content.id : String(event.content.id ?? "");
const cacheKey = `${event.collection}:${contentId}`;
const before = beforeSaveCache.get(cacheKey);
beforeSaveCache.delete(cacheKey);
const beforeRecord = isRecord(before) ? before : undefined;
const afterRecord = isRecord(event.content.data) ? event.content.data : undefined;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: event.isNew ? "create" : "update",
collection: event.collection,
resourceId: contentId,
resourceType: "content",
changes:
beforeRecord || afterRecord ? { before: beforeRecord, after: afterRecord } : undefined,
metadata: { slug: event.content.slug, status: event.content.status },
};
try {
await ctx.storage.entries!.put(`${Date.now()}-${contentId}`, entry);
} catch (error) {
ctx.log.error("Failed to persist entry", error);
}
const icon = event.isNew ? "+" : "~";
ctx.log.info(`${icon} ${entry.action} content/${event.collection}/${contentId}`);
},
},
"content:beforeDelete": {
handler: async (event: ContentDeleteEvent, ctx: PluginContext) => {
if (ctx.content) {
try {
const existing = await ctx.content.get(event.collection, event.id);
if (existing) {
beforeSaveCache.set(`delete:${event.collection}:${event.id}`, existing);
}
} catch {
// Ignore
}
}
return true;
},
},
"content:afterDelete": {
handler: async (event: ContentDeleteEvent, ctx: PluginContext) => {
const cacheKey = `delete:${event.collection}:${event.id}`;
const beforeData = beforeSaveCache.get(cacheKey);
beforeSaveCache.delete(cacheKey);
const beforeRecord = isRecord(beforeData) ? beforeData : undefined;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection: event.collection,
resourceId: event.id,
resourceType: "content",
changes: beforeRecord ? { before: beforeRecord } : undefined,
};
try {
await ctx.storage.entries!.put(`${Date.now()}-${event.id}`, entry);
} catch (error) {
ctx.log.error("Failed to persist entry", error);
}
ctx.log.info(`- delete content/${event.collection}/${event.id}`);
},
},
"media:afterUpload": {
handler: async (event: MediaUploadEvent, ctx: PluginContext) => {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "media:upload",
resourceId: event.media.id,
resourceType: "media",
};
try {
await ctx.storage.entries!.put(`${Date.now()}-${event.media.id}`, entry);
} catch (error) {
ctx.log.error("Failed to persist entry", error);
}
ctx.log.info(`+ media:upload media/${event.media.id}`);
},
},
},
routes: {
// Block Kit admin handler -- returns plain block objects (no @emdash-cms/blocks import needed)
admin: {
handler: async (
routeCtx: { input: unknown; request: { url: string } },
ctx: PluginContext,
) => {
const interaction = routeCtx.input as {
type: string;
page?: string;
action_id?: string;
value?: string;
};
if (interaction.type === "page_load" && interaction.page === "/history") {
return buildHistoryBlocks(ctx);
}
if (interaction.type === "page_load" && interaction.page === "widget:recent-activity") {
return buildRecentBlocks(ctx);
}
if (interaction.type === "block_action" && interaction.action_id === "load-page") {
return buildHistoryBlocks(ctx, interaction.value);
}
return { blocks: [] };
},
},
recent: {
handler: async (
_routeCtx: { input: unknown; request: { url: string } },
ctx: PluginContext,
) => {
try {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 5,
});
return {
entries: result.items
.filter((item: { id: string; data: unknown }) => isAuditEntry(item.data))
.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
} catch (error) {
ctx.log.error("Failed to fetch recent entries", error);
return { entries: [] };
}
},
},
history: {
handler: async (
routeCtx: { input: unknown; request: { url: string } },
ctx: PluginContext,
) => {
try {
const url = new URL(routeCtx.request.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items
.filter((item: { id: string; data: unknown }) => isAuditEntry(item.data))
.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
} catch (error) {
ctx.log.error("Failed to fetch history", error);
return { entries: [], cursor: undefined, hasMore: false };
}
},
},
},
});
// ── Block Kit helpers (plain objects, no @emdash-cms/blocks import) ──
async function buildHistoryBlocks(ctx: PluginContext, cursor?: string) {
try {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 50,
cursor,
});
const entries = result.items
.filter((item: { id: string; data: unknown }) => isAuditEntry(item.data))
.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as AuditEntry),
}));
return {
blocks: [
{ type: "header", text: "Audit History" },
{ type: "context", text: "Track all content and media changes" },
{ type: "divider" },
{
type: "table",
blockId: "history-table",
columns: [
{ key: "action", label: "Action", format: "badge" },
{ key: "resource", label: "Resource", format: "code" },
{ key: "collection", label: "Collection", format: "text" },
{ key: "time", label: "Time", format: "relative_time" },
],
rows: entries.map((e) => ({
action: e.action,
resource: e.resourceId,
collection: e.collection ?? "-",
time: e.timestamp,
})),
pageActionId: "load-page",
nextCursor: result.cursor,
emptyText: "No audit entries yet",
},
{ type: "context", text: `Showing ${entries.length} entries` },
],
};
} catch (error) {
ctx.log.error("Failed to fetch history", error);
return { blocks: [{ type: "context", text: "Failed to load audit history" }] };
}
}
async function buildRecentBlocks(ctx: PluginContext) {
try {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 5,
});
const entries = result.items
.filter((item: { id: string; data: unknown }) => isAuditEntry(item.data))
.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as AuditEntry),
}));
if (entries.length === 0) {
return { blocks: [{ type: "context", text: "No recent activity" }] };
}
return {
blocks: [
{
type: "fields",
fields: entries.slice(0, 4).map((e) => ({
label: e.action,
value: `${e.collection ? `${e.collection}/` : ""}${e.resourceId}`,
})),
},
{ type: "context", text: `${entries.length} changes` },
],
};
} catch (error) {
ctx.log.error("Failed to fetch recent activity", error);
return { blocks: [{ type: "context", text: "Failed to load activity" }] };
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}