mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { adminPath } from '@/lib/admin/admin-nav';
|
||||
@@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useExtensionIcons } from './use-extension-icons';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ChangelogNotes } from '@/components/admin/changelog';
|
||||
|
||||
export interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -54,6 +55,10 @@ export interface ExtensionEntry {
|
||||
source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' };
|
||||
/** Versions kept on this server for a rollback, most recently replaced first. */
|
||||
previousVersions?: string[];
|
||||
/** The installed version's changelog, kept from a marketplace install. */
|
||||
changelog?: string;
|
||||
/** The changelogs of the kept versions that have one, by version. */
|
||||
previousChangelogs?: Record<string, string>;
|
||||
/** Loaded from the dev plugins directory rather than installed. */
|
||||
dev?: boolean;
|
||||
configSchema?: Record<string, ConfigField>;
|
||||
@@ -233,7 +238,8 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
if (!entry) return;
|
||||
const ok = await confirm({
|
||||
title: `Roll back to v${version}?`,
|
||||
message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.`,
|
||||
message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.`
|
||||
+ (entry.changelog ? ` What v${entry.version} changed, listed under Versions, is undone.` : ''),
|
||||
confirmText: 'Roll back',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
@@ -405,16 +411,16 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
)}
|
||||
|
||||
<SettingsSection id="versions" title="Versions" description={previous.length === 0 ? 'No earlier version is kept on this server.' : 'Earlier versions kept on this server can be put back without the directory.'}>
|
||||
<SettingItem label="Installed">
|
||||
<VersionRow label="Installed" changelog={entry.changelog} notesLabel={`What's new in v${entry.version}`}>
|
||||
<span className="font-mono text-sm text-foreground">v{entry.version}</span>
|
||||
</SettingItem>
|
||||
</VersionRow>
|
||||
{previous.map((version) => (
|
||||
<SettingItem key={version} label={`v${version}`} description="Kept on this server">
|
||||
<VersionRow key={version} label={`v${version}`} description="Kept on this server" changelog={entry.previousChangelogs?.[version]} notesLabel="Changelog">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" disabled={busy !== null} onClick={() => void rollback(version)}>
|
||||
{busy === `rollback:${version}` ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||||
Roll back
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</VersionRow>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -461,6 +467,31 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of the Versions section: like a SettingItem, with the version's changelog under its label. */
|
||||
function VersionRow({ label, description, changelog, notesLabel, children }: {
|
||||
label: string;
|
||||
description?: string;
|
||||
changelog?: string;
|
||||
notesLabel: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-border py-3 last:border-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1 sm:pe-4">
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
|
||||
{changelog && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">{notesLabel}</summary>
|
||||
<ChangelogNotes markdown={changelog} className="mt-2" />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: {
|
||||
field: ConfigField;
|
||||
value: string;
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { installVersion } from './marketplace';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ChangelogBadges, ChangelogEntries, ChangelogNotes, type ChangelogFlags } from '@/components/admin/changelog';
|
||||
import { versionsBetween } from '@/lib/admin/changelog-range';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
@@ -53,6 +55,7 @@ interface VersionRow {
|
||||
sha256: string;
|
||||
yanked: { at: string; reason: string | null } | null;
|
||||
changelog: string | null;
|
||||
notes: ChangelogFlags | null;
|
||||
downloads: number;
|
||||
signed: boolean;
|
||||
commit: string | null;
|
||||
@@ -309,6 +312,15 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
? compareVersions(preview.version, data.installedVersion)
|
||||
: 0;
|
||||
const installedRow = data.installedVersion ? ext.versions.find((v) => v.version === data.installedVersion) ?? null : null;
|
||||
// The notes for the previewed version: everything an update to it brings,
|
||||
// or everything going back to it undoes, or just its own.
|
||||
const comparing = !!preview && data.installed && !!data.installedVersion && !previewIsInstalled;
|
||||
const changeRows = !preview ? []
|
||||
: comparing ? versionsBetween(ext.versions, data.installedVersion!, preview.version, data.channel)
|
||||
: ext.versions.filter((v) => v.version === preview.version);
|
||||
const changesTitle = !comparing ? `Changelog of v${preview?.version}`
|
||||
: previewDirection > 0 ? `What's new since v${data.installedVersion}`
|
||||
: `What going back to v${preview!.version} undoes`;
|
||||
const suspended = ext.status === 'suspended';
|
||||
const historyVersions = new Set(data.history.map((h) => h.version));
|
||||
const canInstallPreview = !!preview && !bundle.error && previewFits && !suspended && bundle.signature?.status !== 'invalid';
|
||||
@@ -529,6 +541,12 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{changeRows.some((r) => r.changelog) && (
|
||||
<SettingsSection id="changes" title={changesTitle}>
|
||||
<ChangelogEntries entries={changeRows.map((r) => ({ version: r.version, publishedAt: r.publishedAt, changelog: r.changelog, notes: r.notes ?? undefined }))} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
id="versions"
|
||||
title="Versions"
|
||||
@@ -560,8 +578,14 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
<code className="font-mono text-xs text-foreground">v{v.version}</code>
|
||||
{v.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
{v.signed && <ShieldCheck className="h-3 w-3 text-muted-foreground" aria-label="signed" />}
|
||||
<ChangelogBadges flags={v.notes ?? undefined} />
|
||||
</div>
|
||||
{v.changelog && <p className="mt-0.5 line-clamp-2 max-w-xs whitespace-pre-wrap text-xs text-muted-foreground">{v.changelog}</p>}
|
||||
{v.changelog && (
|
||||
<details className="mt-1 max-w-md">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">Changelog</summary>
|
||||
<ChangelogNotes markdown={v.changelog} className="mt-1.5" />
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>{fmtDate(v.publishedAt)}</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigNote, inputClass } from '@/components/admin/config-items';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ChangelogBadges, ChangelogEntries, anyFlag, type ChangelogEntry, type ChangelogFlags } from '@/components/admin/changelog';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
@@ -43,7 +44,10 @@ interface InstalledEntry {
|
||||
|
||||
interface UpdatesResult {
|
||||
installed: InstalledEntry[];
|
||||
updates: Array<{ slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; changelog: string | null }>;
|
||||
updates: Array<{
|
||||
slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; publishedAt: string | null;
|
||||
changelog: string | null; notes?: ChangelogFlags; changelogs?: ChangelogEntry[];
|
||||
}>;
|
||||
yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>;
|
||||
deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>;
|
||||
suspended: string[];
|
||||
@@ -70,6 +74,16 @@ const PAGE = adminPageDef('marketplace');
|
||||
export const detailHref = (slug: string, version?: string | null) =>
|
||||
`${adminPath('marketplace', slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`;
|
||||
|
||||
/**
|
||||
* The notes an update brings: every version between the installed one and
|
||||
* the offered one, or, from a directory older than 2026-10-30, the offered
|
||||
* version's alone.
|
||||
*/
|
||||
function updateNotes(u: UpdatesResult['updates'][number]): ChangelogEntry[] {
|
||||
if (u.changelogs?.length) return u.changelogs;
|
||||
return [{ version: u.version, publishedAt: u.publishedAt ?? null, changelog: u.changelog, notes: u.notes }];
|
||||
}
|
||||
|
||||
/** The segmented Browse / Installed and All / Plugins / Themes switches. */
|
||||
const pillGroupClass = 'flex items-center gap-0.5 rounded-md bg-muted p-0.5 self-start';
|
||||
const pillClass = (active: boolean) => cn(
|
||||
@@ -437,28 +451,44 @@ function UpdatesSection({
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{updates.updates.map((u) => (
|
||||
<li key={u.slug} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<AdminLink href={detailHref(u.slug, u.version)} className="font-medium text-foreground hover:underline">{name(u.slug)}</AdminLink>
|
||||
<span className="text-muted-foreground">v{u.currentVersion} → v{u.version}</span>
|
||||
{u.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
{updates.updates.map((u) => {
|
||||
const entries = updateNotes(u);
|
||||
const withNotes = entries.filter((e) => e.changelog);
|
||||
return (
|
||||
<li key={u.slug} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<AdminLink href={detailHref(u.slug, u.version)} className="font-medium text-foreground hover:underline">{name(u.slug)}</AdminLink>
|
||||
<span className="text-muted-foreground">v{u.currentVersion} → v{u.version}</span>
|
||||
{u.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
<ChangelogBadges flags={{ security: anyFlag(entries, 'security'), breaking: anyFlag(entries, 'breaking') }} />
|
||||
</div>
|
||||
{withNotes.length > 0 ? (
|
||||
<details className="mt-1" data-testid={`update-notes-${u.slug}`}>
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
{entries.length > 1 ? `What's new in ${entries.length} versions` : "What's new"}
|
||||
</summary>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto pe-2">
|
||||
<ChangelogEntries entries={entries} />
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">No changelog for this update.</p>
|
||||
)}
|
||||
</div>
|
||||
{u.changelog && <p className="mt-0.5 line-clamp-2 whitespace-pre-wrap text-xs text-muted-foreground">{u.changelog}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5 self-start text-xs"
|
||||
onClick={() => onUpdate(u)}
|
||||
disabled={installing.has(u.slug)}
|
||||
>
|
||||
{installing.has(u.slug) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
|
||||
Update
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5 self-start text-xs"
|
||||
onClick={() => onUpdate(u)}
|
||||
disabled={installing.has(u.slug)}
|
||||
>
|
||||
{installing.has(u.slug) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
|
||||
Update
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ function versionRow(v: DirectoryVersion) {
|
||||
sha256: v.sha256,
|
||||
yanked: v.yanked,
|
||||
changelog: v.changelog ?? null,
|
||||
notes: v.notes ?? null,
|
||||
downloads: v.downloads ?? 0,
|
||||
signed: !!v.signature,
|
||||
commit: v.provenance?.source?.commit ?? null,
|
||||
|
||||
@@ -103,6 +103,17 @@ describe('POST /api/admin/marketplace', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the version's changelog with the install", async () => {
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), { changelog: '### Fixed\n- Dates in March' });
|
||||
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200);
|
||||
expect(registry.get('pretty-dates')).toMatchObject({ changelog: '### Fixed\n- Dates in March' });
|
||||
|
||||
// A version without notes doesn't keep the last one's.
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), { changelog: null });
|
||||
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200);
|
||||
expect(registry.get('pretty-dates')).not.toHaveProperty('changelog');
|
||||
});
|
||||
|
||||
it('answers 404 for a version the directory does not have', async () => {
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), {}, { missing: true });
|
||||
const res = await install({ slug: 'pretty-dates', version: '9.9.9', type: 'plugin' });
|
||||
|
||||
@@ -236,6 +236,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const source = { kind: 'marketplace' as const, slug, directoryUrl };
|
||||
// Kept with the install, and so with its rollback copy later.
|
||||
const notes = typeof meta.changelog === 'string' && meta.changelog.trim() ? { changelog: meta.changelog.slice(0, 20_000) } : {};
|
||||
const installExtras = {
|
||||
signature: signature.status,
|
||||
channel: meta.channel,
|
||||
@@ -282,6 +284,7 @@ export async function POST(request: NextRequest) {
|
||||
installedAt: existingTheme?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
source,
|
||||
...notes,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css, skin);
|
||||
@@ -357,6 +360,7 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.locales ? { locales: manifest.locales } : {}),
|
||||
...(manifest.credentials ? { credentials: manifest.credentials } : {}),
|
||||
source,
|
||||
...notes,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
installedExtensions,
|
||||
v2Url,
|
||||
type DirectoryAdvisory,
|
||||
type DirectoryChangelogEntry,
|
||||
type DirectoryVersion,
|
||||
} from '@/lib/admin/marketplace-directory';
|
||||
|
||||
export interface UpdatesAnswer {
|
||||
updates: Array<DirectoryVersion & { slug: string; currentVersion: string; changelog: string | null }>;
|
||||
/** `changelogs` is missing from directories older than 2026-10-30; `changelog` is then the offered version's alone. */
|
||||
updates: Array<DirectoryVersion & { slug: string; currentVersion: string; changelog: string | null; changelogs?: DirectoryChangelogEntry[] }>;
|
||||
yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>;
|
||||
deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>;
|
||||
suspended: string[];
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
|
||||
import { listHistory } from '@/lib/admin/extension-history';
|
||||
import { keptVersions, listHistory } from '@/lib/admin/extension-history';
|
||||
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
|
||||
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
@@ -33,14 +33,13 @@ export async function GET(request: NextRequest) {
|
||||
// Dev plugins win on id collision so admins see what users actually load.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const merged = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const, previousVersions: [] as string[] })),
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const, ...keptVersions([]) })),
|
||||
...(await Promise.all(registry.plugins
|
||||
.filter(p => !devIds.has(p.id))
|
||||
.map(async p => ({
|
||||
...p,
|
||||
dev: false as const,
|
||||
// Versions this server still has a copy of (lib/admin/extension-history.ts).
|
||||
previousVersions: (await listHistory('plugin', p.id).catch(() => [])).map(h => h.version),
|
||||
...keptVersions(await listHistory('plugin', p.id).catch(() => [])),
|
||||
})))),
|
||||
];
|
||||
return NextResponse.json(merged, {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
|
||||
import { listHistory } from '@/lib/admin/extension-history';
|
||||
import { keptVersions, listHistory } from '@/lib/admin/extension-history';
|
||||
|
||||
import { readThemePackage } from '@/lib/extension-package';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
@@ -27,8 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
const registry = await getThemeRegistry();
|
||||
const themes = await Promise.all(registry.themes.map(async t => ({
|
||||
...t,
|
||||
// Versions this server still has a copy of (lib/admin/extension-history.ts).
|
||||
previousVersions: (await listHistory('theme', t.id).catch(() => [])).map(h => h.version),
|
||||
...keptVersions(await listHistory('theme', t.id).catch(() => [])),
|
||||
})));
|
||||
return NextResponse.json(themes, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ChangelogEntries, ChangelogNotes } from '../changelog';
|
||||
import { parseChangelog } from '@/lib/admin/changelog-markdown';
|
||||
|
||||
describe('parseChangelog', () => {
|
||||
it('reads headings, lists, paragraphs, code, bold and links', () => {
|
||||
expect(parseChangelog('### Fixed\n- Keep the **header**\n intact. #31\n- Use `Intl`\n\nSee [the docs](https://example.org/a).\n\n```\n<b>x</b>\n```')).toEqual([
|
||||
{ t: 'heading', c: [{ t: 'text', v: 'Fixed' }] },
|
||||
{ t: 'list', ordered: false, items: [
|
||||
[{ t: 'text', v: 'Keep the ' }, { t: 'strong', c: [{ t: 'text', v: 'header' }] }, { t: 'text', v: ' intact. #31' }],
|
||||
[{ t: 'text', v: 'Use ' }, { t: 'code', v: 'Intl' }],
|
||||
] },
|
||||
{ t: 'para', c: [{ t: 'text', v: 'See ' }, { t: 'link', href: 'https://example.org/a', c: [{ t: 'text', v: 'the docs' }] }, { t: 'text', v: '.' }] },
|
||||
{ t: 'code', v: '<b>x</b>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps links to anything but http(s) as their text', () => {
|
||||
expect(parseChangelog('[click](javascript:alert`1`) [mail](mailto:a@b.c)')).toEqual([
|
||||
{ t: 'para', c: [{ t: 'text', v: 'click' }, { t: 'text', v: ' ' }, { t: 'text', v: 'mail' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits a numbered list from a bulleted one', () => {
|
||||
expect(parseChangelog('1. One\n2. Two\n- Three').map((b) => b.t === 'list' && b.ordered)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChangelogNotes', () => {
|
||||
it('shows HTML in the notes as text and never as elements', () => {
|
||||
const { container } = render(<ChangelogNotes markdown={'- <img src=x onerror="alert(1)"> and <script>alert(2)</script>\n\n<a href="javascript:alert(3)">x</a>'} />);
|
||||
expect(container.querySelector('img, script')).toBeNull();
|
||||
expect(container.querySelectorAll('a')).toHaveLength(0);
|
||||
expect(container.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it('opens links in a new tab without the opener', () => {
|
||||
render(<ChangelogNotes markdown="See [the docs](https://example.org/a)" />);
|
||||
const link = screen.getByRole('link', { name: 'the docs' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.org/a');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChangelogEntries', () => {
|
||||
it('lists each version with its flags, and says when one has no notes', () => {
|
||||
render(<ChangelogEntries entries={[
|
||||
{ version: '1.3.0', publishedAt: null, changelog: '### Security\n- Escape names', notes: { security: true, breaking: false } },
|
||||
{ version: '1.2.0', publishedAt: null, changelog: null },
|
||||
]} />);
|
||||
const items = screen.getAllByRole('listitem').filter((li) => li.parentElement?.tagName === 'OL');
|
||||
expect(items.map((li) => li.querySelector('span')?.textContent)).toEqual(['v1.3.0', 'v1.2.0']);
|
||||
expect(items[0]).toHaveTextContent('Security');
|
||||
expect(items[0]).toHaveTextContent('Escape names');
|
||||
expect(items[1]).toHaveTextContent('No notes for this version.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Fragment, useMemo, type ReactNode } from 'react';
|
||||
import { ShieldAlert, AlertTriangle } from '@/components/icons';
|
||||
import { parseChangelog, type Inline } from '@/lib/admin/changelog-markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from './badge';
|
||||
|
||||
export interface ChangelogFlags {
|
||||
security: boolean;
|
||||
breaking: boolean;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
publishedAt: string | null;
|
||||
changelog: string | null;
|
||||
notes?: ChangelogFlags;
|
||||
}
|
||||
|
||||
function inline(nodes: Inline[]): ReactNode {
|
||||
return nodes.map((n, i) => {
|
||||
if (n.t === 'text') return <Fragment key={i}>{n.v}</Fragment>;
|
||||
if (n.t === 'code') return <code key={i} className="rounded bg-muted px-1 py-px font-mono text-[0.9em]">{n.v}</code>;
|
||||
if (n.t === 'strong') return <strong key={i} className="font-semibold text-foreground">{inline(n.c)}</strong>;
|
||||
return <a key={i} href={n.href} target="_blank" rel="noopener noreferrer" className="text-primary underline-offset-2 hover:underline">{inline(n.c)}</a>;
|
||||
});
|
||||
}
|
||||
|
||||
/** An extension's changelog: a safe subset of Markdown, drawn as text. */
|
||||
export function ChangelogNotes({ markdown, className }: { markdown: string; className?: string }) {
|
||||
const blocks = useMemo(() => parseChangelog(markdown), [markdown]);
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-xs text-muted-foreground', className)}>
|
||||
{blocks.map((b, i) => {
|
||||
if (b.t === 'heading') return <p key={i} className="pt-1 font-semibold text-foreground first:pt-0">{inline(b.c)}</p>;
|
||||
if (b.t === 'para') return <p key={i}>{inline(b.c)}</p>;
|
||||
if (b.t === 'code') return <pre key={i} className="overflow-x-auto rounded bg-muted p-2 font-mono text-[0.9em]">{b.v}</pre>;
|
||||
const List = b.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<List key={i} className={cn('space-y-0.5 ps-4', b.ordered ? 'list-decimal' : 'list-disc')}>
|
||||
{b.items.map((item, j) => <li key={j}>{inline(item)}</li>)}
|
||||
</List>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Security" and "Breaking" next to an update, when its notes have those headings. */
|
||||
export function ChangelogBadges({ flags }: { flags: ChangelogFlags | undefined }) {
|
||||
if (!flags?.security && !flags?.breaking) return null;
|
||||
return (
|
||||
<>
|
||||
{flags.security && <Badge tone="danger" icon={<ShieldAlert className="h-3 w-3" />}>Security</Badge>}
|
||||
{flags.breaking && <Badge tone="warning" icon={<AlertTriangle className="h-3 w-3" />}>Breaking</Badge>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const anyFlag = (entries: ChangelogEntry[], key: keyof ChangelogFlags) => entries.some((e) => e.notes?.[key]);
|
||||
|
||||
/** The notes of several versions, newest first, each under its version. */
|
||||
export function ChangelogEntries({ entries }: { entries: ChangelogEntry[] }) {
|
||||
return (
|
||||
<ol className="space-y-3">
|
||||
{entries.map((e) => (
|
||||
<li key={e.version}>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-mono font-medium text-foreground">v{e.version}</span>
|
||||
{e.publishedAt && <span className="text-muted-foreground">{new Date(e.publishedAt).toLocaleDateString()}</span>}
|
||||
<ChangelogBadges flags={e.notes} />
|
||||
</div>
|
||||
{e.changelog ? <ChangelogNotes markdown={e.changelog} /> : <p className="text-xs italic text-muted-foreground">No notes for this version.</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { versionsBetween } from '../changelog-range';
|
||||
|
||||
const v = (version: string, yanked: unknown = null) => ({ version, channel: version.includes('-') ? 'beta' as const : 'stable' as const, yanked });
|
||||
const ALL = [v('1.4.0'), v('1.3.0'), v('1.3.0-beta.1'), v('1.2.0', { at: 'x' }), v('1.1.0'), v('1.0.0')];
|
||||
const versions = (installed: string, target: string, channel: 'stable' | 'beta' = 'stable') =>
|
||||
versionsBetween(ALL, installed, target, channel).map((r) => r.version);
|
||||
|
||||
describe('versionsBetween', () => {
|
||||
it('lists what an update brings, newest first, without withdrawn versions', () => {
|
||||
expect(versions('1.0.0', '1.3.0')).toEqual(['1.3.0', '1.1.0']);
|
||||
});
|
||||
|
||||
it('lists what going back undoes: the installed version down to just above the target', () => {
|
||||
expect(versions('1.4.0', '1.1.0')).toEqual(['1.4.0', '1.3.0']);
|
||||
});
|
||||
|
||||
it('keeps a withdrawn version that is installed', () => {
|
||||
expect(versions('1.2.0', '1.0.0')).toEqual(['1.2.0', '1.1.0']);
|
||||
});
|
||||
|
||||
it('shows betas on the beta channel or when going to a beta, ordered below their release', () => {
|
||||
expect(versions('1.1.0', '1.3.0', 'beta')).toEqual(['1.3.0', '1.3.0-beta.1']);
|
||||
expect(versions('1.1.0', '1.3.0-beta.1')).toEqual(['1.3.0-beta.1']);
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,20 @@ describe('plugin history', () => {
|
||||
expect(await registry.getPluginBundle('demo')).toBe('v1()');
|
||||
expect((await history.listHistory('plugin', 'demo')).map((e) => e.version)).toEqual(['1.1.0']);
|
||||
});
|
||||
|
||||
it("keeps a version's changelog with its copy and lists it for the admin", async () => {
|
||||
const registry = await import('../plugin-registry');
|
||||
const history = await import('../extension-history');
|
||||
await registry.savePlugin({ ...PLUGIN, changelog: '- First' }, 'v1()');
|
||||
await registry.savePlugin({ ...PLUGIN, version: '1.1.0' }, 'v2()');
|
||||
await registry.savePlugin({ ...PLUGIN, version: '1.2.0', changelog: '- Third' }, 'v3()');
|
||||
|
||||
const kept = await history.listHistory('plugin', 'demo');
|
||||
const { previousVersions, previousChangelogs } = history.keptVersions(kept);
|
||||
expect([...previousVersions].sort()).toEqual(['1.0.0', '1.1.0']);
|
||||
expect(previousChangelogs).toEqual({ '1.0.0': '- First' });
|
||||
expect((await history.readHistoryEntry('plugin', 'demo', '1.0.0')).entry.changelog).toBe('- First');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme history', () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The Markdown of an extension's changelog, as a small tree the admin UI
|
||||
* renders with React elements. Changelogs come from the extension directory
|
||||
* and are not covered by the bundle signature, so they never become HTML:
|
||||
* only headings, paragraphs, lists, code, bold and http(s) links are read,
|
||||
* and everything else stays text.
|
||||
*/
|
||||
|
||||
export type Inline =
|
||||
| { t: 'text'; v: string }
|
||||
| { t: 'code'; v: string }
|
||||
| { t: 'strong'; c: Inline[] }
|
||||
| { t: 'link'; href: string; c: Inline[] };
|
||||
|
||||
export type Block =
|
||||
| { t: 'heading'; c: Inline[] }
|
||||
| { t: 'para'; c: Inline[] }
|
||||
| { t: 'list'; ordered: boolean; items: Inline[][] }
|
||||
| { t: 'code'; v: string };
|
||||
|
||||
const MAX_LENGTH = 20_000;
|
||||
const INLINE = /`([^`\n]+)`|\*\*(.+?)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
|
||||
|
||||
export function parseInline(text: string): Inline[] {
|
||||
const out: Inline[] = [];
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(INLINE)) {
|
||||
if (m.index > last) out.push({ t: 'text', v: text.slice(last, m.index) });
|
||||
if (m[1] !== undefined) out.push({ t: 'code', v: m[1] });
|
||||
else if (m[2] !== undefined) out.push({ t: 'strong', c: parseInline(m[2]) });
|
||||
else if (/^https?:\/\//i.test(m[4])) out.push({ t: 'link', href: m[4], c: parseInline(m[3]) });
|
||||
else out.push({ t: 'text', v: m[3] });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push({ t: 'text', v: text.slice(last) });
|
||||
return out;
|
||||
}
|
||||
|
||||
const ITEM = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/;
|
||||
|
||||
export function parseChangelog(markdown: string): Block[] {
|
||||
const lines = markdown.slice(0, MAX_LENGTH).replace(/\r\n?/g, '\n').split('\n');
|
||||
const blocks: Block[] = [];
|
||||
let para: string[] = [];
|
||||
let list: { ordered: boolean; items: string[] } | null = null;
|
||||
const flush = () => {
|
||||
if (para.length) blocks.push({ t: 'para', c: parseInline(para.join(' ')) });
|
||||
if (list) blocks.push({ t: 'list', ordered: list.ordered, items: list.items.map(parseInline) });
|
||||
para = [];
|
||||
list = null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
flush();
|
||||
const fence = line.trim().slice(0, 3);
|
||||
const code: string[] = [];
|
||||
while (++i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i]);
|
||||
blocks.push({ t: 'code', v: code.join('\n') });
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) { flush(); continue; }
|
||||
const heading = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/);
|
||||
if (heading) { flush(); blocks.push({ t: 'heading', c: parseInline(heading[1]) }); continue; }
|
||||
const item = line.match(ITEM);
|
||||
if (item) {
|
||||
const ordered = /\d/.test(item[1]);
|
||||
if (para.length || (list && list.ordered !== ordered)) flush();
|
||||
list ??= { ordered, items: [] };
|
||||
list.items.push(item[2]);
|
||||
continue;
|
||||
}
|
||||
// An indented line goes on with the list item above it.
|
||||
if (list && /^\s+\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
|
||||
if (list) flush();
|
||||
para.push(line.trim());
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { compareVersions } from '@/lib/version-compare';
|
||||
|
||||
interface Row {
|
||||
version: string;
|
||||
channel: 'stable' | 'beta';
|
||||
yanked: unknown;
|
||||
}
|
||||
|
||||
/** compareVersions, with a release above its pre-releases (1.3.0-beta.1 < 1.3.0). */
|
||||
function compare(a: string, b: string): number {
|
||||
const d = compareVersions(a, b);
|
||||
if (d !== 0) return d;
|
||||
const pa = a.split('+')[0].split('-').slice(1).join('-');
|
||||
const pb = b.split('+')[0].split('-').slice(1).join('-');
|
||||
if (!pa || !pb) return pa ? -1 : pb ? 1 : 0;
|
||||
return pa.localeCompare(pb, 'en', { numeric: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The versions whose notes matter when going from `installed` to `target`,
|
||||
* newest first. Going up: the versions above the installed one up to the
|
||||
* target, what the update brings. Going down: the installed version down to
|
||||
* just above the target, what going back undoes. Withdrawn versions are left
|
||||
* out unless installed, and betas unless the target is one or the channel is
|
||||
* beta.
|
||||
*/
|
||||
export function versionsBetween<T extends Row>(versions: T[], installed: string, target: string, channel: 'stable' | 'beta'): T[] {
|
||||
const [low, high] = compare(target, installed) > 0 ? [installed, target] : [target, installed];
|
||||
const betas = channel === 'beta' || target.includes('-');
|
||||
return versions
|
||||
.filter((v) => (!v.yanked || v.version === installed) && (v.channel !== 'beta' || betas)
|
||||
&& compare(v.version, low) > 0 && compare(v.version, high) <= 0)
|
||||
.sort((a, b) => compare(b.version, a.version));
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export interface HistoryEntry {
|
||||
/** SHA-256 of the bundle (plugins) or css + skin (themes). */
|
||||
contentHash: string;
|
||||
source?: ServerPlugin['source'];
|
||||
/** The kept version's changelog, when it came from the marketplace with one. */
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
@@ -112,14 +114,26 @@ async function prune(kind: ExtensionKind, id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the admin lists say about the kept versions: the versions, most
|
||||
* recently replaced first, and the changelog of each that has one.
|
||||
*/
|
||||
export function keptVersions(history: HistoryEntry[]): { previousVersions: string[]; previousChangelogs: Record<string, string> } {
|
||||
return {
|
||||
previousVersions: history.map((h) => h.version),
|
||||
previousChangelogs: Object.fromEntries(history.filter((h) => h.changelog).map((h) => [h.version, h.changelog!])),
|
||||
};
|
||||
}
|
||||
|
||||
/** The versions kept for an extension, most recently replaced first. */
|
||||
export async function listHistory(kind: ExtensionKind, id: string): Promise<HistoryEntry[]> {
|
||||
if (!SAFE_ID_RE.test(id)) return [];
|
||||
return (await readAll(kind, id)).map(({ version, savedAt, contentHash, source }) => ({
|
||||
return (await readAll(kind, id)).map(({ version, savedAt, contentHash, source, entry }) => ({
|
||||
version,
|
||||
savedAt,
|
||||
contentHash,
|
||||
...(source ? { source } : {}),
|
||||
...(typeof entry.changelog === 'string' ? { changelog: entry.changelog } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -138,11 +138,21 @@ export interface DirectoryVersion {
|
||||
downloadUrl: string;
|
||||
/** Only on the full view (`/versions`, `/versions/{v}`). */
|
||||
changelog?: string | null;
|
||||
/** Whether the changelog has a Security or Breaking heading (directories since 2026-10-30). */
|
||||
notes?: { security: boolean; breaking: boolean };
|
||||
downloads?: number;
|
||||
signature?: { keyId: string } | null;
|
||||
provenance?: { source: { host: string; repo: string | null; commit: string | null } } | null;
|
||||
}
|
||||
|
||||
/** One version's notes in an update: every version between the installed one and the offered one has one. */
|
||||
export interface DirectoryChangelogEntry {
|
||||
version: string;
|
||||
publishedAt: string | null;
|
||||
changelog: string | null;
|
||||
notes: { security: boolean; breaking: boolean };
|
||||
}
|
||||
|
||||
export interface DirectoryAuthor {
|
||||
login: string;
|
||||
name: string;
|
||||
|
||||
@@ -106,6 +106,12 @@ export interface ServerPlugin {
|
||||
/** Credentials the plugin uses through the server proxy (no secrets here). */
|
||||
credentials?: Record<string, CredentialSpec>;
|
||||
source?: ExtensionSource;
|
||||
/**
|
||||
* The version's changelog (Markdown) from the directory, kept from a
|
||||
* marketplace install so the admin UI and the rollback history can show it
|
||||
* without the directory. Admin-only: /api/plugins doesn't send it.
|
||||
*/
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
export interface ServerTheme {
|
||||
@@ -120,6 +126,8 @@ export interface ServerTheme {
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
source?: ExtensionSource;
|
||||
/** The version's changelog from the directory; see ServerPlugin.changelog. */
|
||||
changelog?: string;
|
||||
/** True when a Theme API v2 skin.css is stored next to the CSS. */
|
||||
hasSkin?: boolean;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user