domma-cms 0.114.0 → 0.115.0
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/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/lib/project-remove.js +1 -0
- package/admin/js/templates/project-detail.html +8 -0
- package/admin/js/templates/project-settings.html +5 -0
- package/admin/js/views/project-detail.js +2 -2
- package/admin/js/views/project-settings.js +1 -1
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/projects.js +28 -0
- package/server/routes/favicon.js +59 -0
- package/server/server.js +6 -0
- package/server/services/embedCsp.js +33 -0
- package/server/services/markdown.js +10 -4
- package/server/services/presetCollections.js +3 -1
- package/server/services/projects.js +199 -1
- package/server/templates/page.html +3 -2
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* DELETE /api/projects/:slug - delete (403 for core; 409 if any artefacts tagged)
|
|
10
10
|
* GET /api/projects/:slug/artefacts - grouped artefacts
|
|
11
11
|
* POST /api/projects/:slug/untag-all - escape hatch before delete (403 for core)
|
|
12
|
+
* POST /api/projects/:slug/remove-contents - delete the project AND everything filed under it;
|
|
13
|
+
* body {confirm: <slug>} (403 core/protected, 409 users/roles)
|
|
12
14
|
*
|
|
13
15
|
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
14
16
|
*/
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
getProject,
|
|
27
29
|
listProjectsForUser,
|
|
28
30
|
projectsBadge,
|
|
31
|
+
removeProjectContents,
|
|
29
32
|
untagAllForProject,
|
|
30
33
|
updateProject
|
|
31
34
|
} from '../../services/projects.js';
|
|
@@ -112,6 +115,31 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
112
115
|
return {success: true};
|
|
113
116
|
});
|
|
114
117
|
|
|
118
|
+
// Deletes pages and data, so it asks for more than projects:delete: deleting pages and
|
|
119
|
+
// collections too, and access to this project (a project-scoped user cannot reach others).
|
|
120
|
+
const canRemoveAll = {preHandler: [authenticate, requirePermission('projects', 'delete'),
|
|
121
|
+
requirePermission('pages', 'delete'), requirePermission('collections', 'delete')]};
|
|
122
|
+
fastify.post('/projects/:slug/remove-contents', canRemoveAll, async (request, reply) => {
|
|
123
|
+
const {slug} = request.params;
|
|
124
|
+
const visible = await listProjectsForUser(request.user);
|
|
125
|
+
if (!visible.some(p => p.slug === slug)) return reply.status(404).send({error: 'Project not found'});
|
|
126
|
+
// The slug typed back: this deletes pages and data, and cannot be undone.
|
|
127
|
+
if (request.body?.confirm !== slug) {
|
|
128
|
+
return reply.status(400).send({error: `Type the project's slug ("${slug}") to confirm.`});
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const host = String(request.headers['x-forwarded-host'] || request.headers.host || '').split(',')[0].trim();
|
|
132
|
+
const result = await removeProjectContents(slug, {siteHost: host});
|
|
133
|
+
await cache.invalidateTags(['projects', `project:${slug}`, 'site', 'nav']);
|
|
134
|
+
if (result.failed.length) {
|
|
135
|
+
return reply.status(500).send({error: `Some things could not be removed, so the project was kept: ${result.failed.join('; ')}`, ...result});
|
|
136
|
+
}
|
|
137
|
+
return {success: true, ...result};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return reply.status(err.status || 400).send({error: err.message});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
115
143
|
fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
|
|
116
144
|
if (!(await getProject(request.params.slug))) {
|
|
117
145
|
return reply.status(404).send({error: 'Project not found'});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /favicon.svg and /favicon.ico - every site has an icon in the browser tab.
|
|
3
|
+
*
|
|
4
|
+
* There was none: no <link rel="icon">, no route, so every page load asked for
|
|
5
|
+
* /favicon.ico and got a 404. Now site.json `favicon`, else the brand logo
|
|
6
|
+
* (brand.logo) - a redirect, so the logo in Media stays the one source -
|
|
7
|
+
* and otherwise a generated icon: the first letter of the site's name.
|
|
8
|
+
*
|
|
9
|
+
* Read per request, so a new logo or title shows without a restart.
|
|
10
|
+
*/
|
|
11
|
+
import {getConfig} from '../config.js';
|
|
12
|
+
|
|
13
|
+
/** A same-site path or an https URL - never a scheme that could run anything. */
|
|
14
|
+
// A backslash is refused anywhere: browsers read /\evil.com as //evil.com.
|
|
15
|
+
const SAFE_LOGO = /^(\/(?![/\\])[^\s"'<>\\]*|https:\/\/[^\s"'<>\\]+)$/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The generated icon: a rounded square with the site's initial.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} title - the site's name
|
|
21
|
+
* @returns {string} SVG
|
|
22
|
+
*/
|
|
23
|
+
export function letterIcon(title = '') {
|
|
24
|
+
const ch = (String(title).match(/[\p{L}\p{N}]/u) || ['D'])[0].toUpperCase();
|
|
25
|
+
const esc = ch.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
26
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
27
|
+
+ '<rect width="64" height="64" rx="14" fill="#4f46e5"/>'
|
|
28
|
+
+ `<text x="32" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="36" font-weight="700" fill="#ffffff">${esc}</text>`
|
|
29
|
+
+ '</svg>';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What to answer for the icon, from site.json.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} site
|
|
36
|
+
* @returns {{redirect: string}|{svg: string}}
|
|
37
|
+
*/
|
|
38
|
+
export function faviconFor(site = {}) {
|
|
39
|
+
// site.json `favicon` (a Settings key) first, then the brand logo.
|
|
40
|
+
for (const v of [site?.favicon, site?.brand?.logo]) {
|
|
41
|
+
const url = typeof v === 'string' ? v.trim() : '';
|
|
42
|
+
if (url && SAFE_LOGO.test(url)) return {redirect: url};
|
|
43
|
+
}
|
|
44
|
+
return {svg: letterIcon(site?.title || site?.brand?.text || '')};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function faviconRoutes(fastify) {
|
|
48
|
+
const answer = async (request, reply) => {
|
|
49
|
+
let site = {};
|
|
50
|
+
try { site = getConfig('site') || {}; } catch { /* no site config: the letter D */ }
|
|
51
|
+
const out = faviconFor(site);
|
|
52
|
+
reply.header('Cache-Control', 'public, max-age=3600');
|
|
53
|
+
if (out.redirect) return reply.redirect(out.redirect, 302);
|
|
54
|
+
return reply.type('image/svg+xml').send(out.svg);
|
|
55
|
+
};
|
|
56
|
+
fastify.get('/favicon.svg', answer);
|
|
57
|
+
// Browsers ask for /favicon.ico whatever the page says; an SVG body is accepted by every current browser.
|
|
58
|
+
fastify.get('/favicon.ico', answer);
|
|
59
|
+
}
|
package/server/server.js
CHANGED
|
@@ -31,6 +31,7 @@ import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
|
31
31
|
import * as cache from './services/cache/index.js';
|
|
32
32
|
import {parseTrustProxy} from './services/network.js';
|
|
33
33
|
import {rateLimitOptions} from './services/rate-limit.js';
|
|
34
|
+
import {frameSources} from './services/embedCsp.js';
|
|
34
35
|
|
|
35
36
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -122,6 +123,8 @@ await app.register(helmet, {
|
|
|
122
123
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
123
124
|
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
|
|
124
125
|
connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
|
|
126
|
+
// [embed] players and config/embeds.json hosts - without this, default-src blocked them all.
|
|
127
|
+
frameSrc: frameSources((() => { try { return getConfig('embeds')?.allowedHosts; } catch { return []; } })()),
|
|
125
128
|
upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
|
|
126
129
|
}
|
|
127
130
|
},
|
|
@@ -710,6 +713,9 @@ await app.register(docsPublicRoutes);
|
|
|
710
713
|
const { callPublicRoutes } = await import('./routes/call-public.js');
|
|
711
714
|
await app.register(callPublicRoutes);
|
|
712
715
|
|
|
716
|
+
// Before the pages: /favicon.* must never reach the page router.
|
|
717
|
+
const { faviconRoutes } = await import('./routes/favicon.js');
|
|
718
|
+
await app.register(faviconRoutes);
|
|
713
719
|
const { publicRoutes } = await import('./routes/public.js');
|
|
714
720
|
await app.register(publicRoutes);
|
|
715
721
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame-src the Content-Security-Policy needs for [embed].
|
|
3
|
+
*
|
|
4
|
+
* The CSP had no frame-src, so it fell back to default-src 'self' and the
|
|
5
|
+
* browser blocked every embedded player - YouTube and Vimeo included, which the
|
|
6
|
+
* [embed] shortcode documents as always allowed. The page rendered the iframe
|
|
7
|
+
* and the visitor saw a blocked frame.
|
|
8
|
+
*
|
|
9
|
+
* So: 'self' (the [demo] shortcode frames the site's own pages), the two players
|
|
10
|
+
* [embed] rewrites links to (youtube-nocookie.com, player.vimeo.com), and every
|
|
11
|
+
* host in config/embeds.json `allowedHosts` - the same list the renderer and the
|
|
12
|
+
* sanitiser already enforce (server.js passes getConfig('embeds'), as the
|
|
13
|
+
* renderer reads it). Read at boot: a change to embeds.json needs a restart to
|
|
14
|
+
* reach the header.
|
|
15
|
+
*/
|
|
16
|
+
/** The players [embed] always uses. */
|
|
17
|
+
// youtube.com too: the HTML sanitiser lets its iframes through, so the policy must agree.
|
|
18
|
+
export const ALWAYS_FRAMED = ['https://www.youtube-nocookie.com', 'https://www.youtube.com', 'https://youtube.com', 'https://player.vimeo.com'];
|
|
19
|
+
|
|
20
|
+
/** A bare hostname, or nothing: a host in embeds.json must never smuggle in a scheme, path or wildcard. */
|
|
21
|
+
const HOST_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string[]} allowedHosts - config/embeds.json allowedHosts
|
|
25
|
+
* @returns {string[]} frame-src sources
|
|
26
|
+
*/
|
|
27
|
+
export function frameSources(allowedHosts = []) {
|
|
28
|
+
const extra = (Array.isArray(allowedHosts) ? allowedHosts : [])
|
|
29
|
+
.map(h => String(h).trim().toLowerCase())
|
|
30
|
+
.filter(h => HOST_RE.test(h))
|
|
31
|
+
.map(h => `https://${h}`);
|
|
32
|
+
return [...new Set(["'self'", ...ALWAYS_FRAMED, ...extra])];
|
|
33
|
+
}
|
|
@@ -694,7 +694,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
694
694
|
* @param {string} opts.titleField - entry data field used as item title
|
|
695
695
|
* @param {string} [opts.dateField] - optional field surfaced as data-date
|
|
696
696
|
* @param {string} [opts.statusField]- optional field surfaced as data-status
|
|
697
|
-
* @param {string} [opts.iconField] - optional field surfaced as data-icon
|
|
697
|
+
* @param {string} [opts.iconField] - optional field surfaced as data-event-icon
|
|
698
698
|
* @param {string} [opts.bodyField] - optional Markdown body field
|
|
699
699
|
* @param {string} opts.layout - vertical | centred | horizontal
|
|
700
700
|
* @param {string} opts.theme - minimal | corporate | modern
|
|
@@ -712,7 +712,9 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
|
|
|
712
712
|
const title = escapeHtmlText(String(e.data?.[titleField] ?? e.id ?? '(untitled)'));
|
|
713
713
|
const date = dateField && e.data?.[dateField] != null ? ` data-date="${escapeAttr(String(e.data[dateField]))}"` : '';
|
|
714
714
|
const status = statusField && e.data?.[statusField] != null ? ` data-status="${escapeAttr(String(e.data[statusField]))}"` : '';
|
|
715
|
-
|
|
715
|
+
// data-event-icon, never data-icon: the icon scanner turns ANY [data-icon] element into an
|
|
716
|
+
// icon, replacing its children - which wiped each item's title and body before site.js read them.
|
|
717
|
+
const icon = iconField && e.data?.[iconField] != null ? ` data-event-icon="${escapeAttr(String(e.data[iconField]))}"` : '';
|
|
716
718
|
const bodyHtml = bodyField
|
|
717
719
|
? marked.parse(String(e.data?.[bodyField] ?? ''))
|
|
718
720
|
: '';
|
|
@@ -2581,6 +2583,9 @@ LAYOUT_RENDERERS['pricing'] = (attrs, body) => {
|
|
|
2581
2583
|
const period = escapeAttr(attrs.period || '');
|
|
2582
2584
|
const gradient = escapeAttr(attrs.gradient || 'indigo');
|
|
2583
2585
|
const cta = escapeAttr(attrs.footer || 'Get started');
|
|
2586
|
+
// Where the button goes - it always went to "#" before. Same handling as [button]'s href;
|
|
2587
|
+
// the sanitiser removes a javascript: URL.
|
|
2588
|
+
const href = escapeAttr(attrs.href || '#');
|
|
2584
2589
|
const features = parseSubTagList(body, 'feature');
|
|
2585
2590
|
const featHtml = features.map(f => `<div class="card-pricing-feature"><span class="card-pricing-check">\u2713</span>${escapeAttr(f.content)}</div>`).join('');
|
|
2586
2591
|
return `${cardRoot(attrs)}<div class="card-header card-gradient-${gradient}">
|
|
@@ -2588,7 +2593,7 @@ LAYOUT_RENDERERS['pricing'] = (attrs, body) => {
|
|
|
2588
2593
|
<div class="card-stat-value" style="color:#fff">${price}</div>
|
|
2589
2594
|
${period ? `<div style="opacity:.75;font-size:.8rem">${period}</div>` : ''}
|
|
2590
2595
|
</div><div class="card-pricing-features">${featHtml}</div>
|
|
2591
|
-
<a class="card-pricing-cta" href="
|
|
2596
|
+
<a class="card-pricing-cta" href="${href}">${cta}</a></div>\n`;
|
|
2592
2597
|
};
|
|
2593
2598
|
|
|
2594
2599
|
LAYOUT_RENDERERS['feature-comparison'] = (attrs, body) => {
|
|
@@ -3178,7 +3183,8 @@ function processTimelineBlocks(markdown) {
|
|
|
3178
3183
|
const title = escapeAttr(ia.title || '');
|
|
3179
3184
|
const date = ia.date ? ` data-date="${escapeAttr(ia.date)}"` : '';
|
|
3180
3185
|
const status = ia.status ? ` data-status="${escapeAttr(ia.status)}"` : '';
|
|
3181
|
-
|
|
3186
|
+
// data-event-icon, never data-icon - see renderCollectionTimeline: the icon scanner would empty the item.
|
|
3187
|
+
const icon = ia.icon ? ` data-event-icon="${escapeAttr(ia.icon)}"` : '';
|
|
3182
3188
|
const descHtml = marked.parse(processCardBlocks(processGridBlocks(processInlineShortcodes(restore(itemBody.trim())))));
|
|
3183
3189
|
return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${descHtml}</div></div>`;
|
|
3184
3190
|
}
|
|
@@ -52,7 +52,9 @@ const PRESETS = [
|
|
|
52
52
|
{name: 'rootUrl', label: 'Root URL', type: 'text'},
|
|
53
53
|
{name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
|
|
54
54
|
{name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
|
|
55
|
-
{name: 'protected', label: 'Protected', type: 'boolean', default: false}
|
|
55
|
+
{name: 'protected', label: 'Protected', type: 'boolean', default: false},
|
|
56
|
+
// Example content a starter bundle put on the site: the admin offers "Remove sample content".
|
|
57
|
+
{name: 'sample', label: 'Sample content', type: 'boolean', default: false}
|
|
56
58
|
],
|
|
57
59
|
api: {
|
|
58
60
|
create: {enabled: false, access: 'admin'},
|
|
@@ -510,7 +510,8 @@ export async function getArtefactsByProject() {
|
|
|
510
510
|
try {
|
|
511
511
|
const {listPages} = await import('./content.js');
|
|
512
512
|
for (const p of await listPages()) {
|
|
513
|
-
|
|
513
|
+
// urlPath: the page's own address (its file) - what removal deletes, too.
|
|
514
|
+
const url = p.urlPath || p.url || '/';
|
|
514
515
|
const explicit = (p.project === undefined) ? undefined : p.project;
|
|
515
516
|
const pageProject = await getProjectForPage(url, explicit);
|
|
516
517
|
out(pageProject).pages.push(p);
|
|
@@ -672,6 +673,203 @@ export async function untagAllForProject(projectSlug) {
|
|
|
672
673
|
return counts;
|
|
673
674
|
}
|
|
674
675
|
|
|
676
|
+
/**
|
|
677
|
+
* The path a menu link leads to on THIS site, or null when it leads elsewhere.
|
|
678
|
+
* Relative, root-relative and absolute same-site links all count; the query and
|
|
679
|
+
* hash are dropped and a trailing slash ignored, so /a, /a/, /a?x=1, /a#top and
|
|
680
|
+
* https://<this site>/a are one page.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} url
|
|
683
|
+
* @param {string} [siteHost] - this site's host, for absolute links
|
|
684
|
+
* @returns {string|null}
|
|
685
|
+
*/
|
|
686
|
+
export function linkPath(url, siteHost = '') {
|
|
687
|
+
if (typeof url !== 'string' || !url.trim()) return null;
|
|
688
|
+
let u;
|
|
689
|
+
try { u = new URL(url.trim(), 'http://this.site/'); } catch { return null; }
|
|
690
|
+
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url.trim()) || url.trim().startsWith('//');
|
|
691
|
+
if (absolute && !(siteHost && u.host.toLowerCase() === siteHost.toLowerCase())) return null;
|
|
692
|
+
if (!['http:', 'https:'].includes(u.protocol)) return null;
|
|
693
|
+
return u.pathname.replace(/\/+$/, '') || '/';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Menu items with the links to removed pages taken out, PURE. An item whose
|
|
698
|
+
* link leads to a removed page is dropped; its sub-items that still lead
|
|
699
|
+
* somewhere take its place, so removing a section never takes an unrelated
|
|
700
|
+
* page out of the nav.
|
|
701
|
+
*
|
|
702
|
+
* @param {object[]} items
|
|
703
|
+
* @param {Set<string>} goneUrls - normalised paths (see linkPath)
|
|
704
|
+
* @param {string} [siteHost]
|
|
705
|
+
* @returns {{items: object[], removed: number}}
|
|
706
|
+
*/
|
|
707
|
+
export function pruneMenuItems(items, goneUrls, siteHost = '') {
|
|
708
|
+
let removed = 0;
|
|
709
|
+
const walk = (list) => {
|
|
710
|
+
const out = [];
|
|
711
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
712
|
+
const kids = Array.isArray(item?.items) ? walk(item.items) : null;
|
|
713
|
+
const path = linkPath(item?.url, siteHost);
|
|
714
|
+
if (path && goneUrls.has(path)) {
|
|
715
|
+
removed++;
|
|
716
|
+
if (kids?.length) out.push(...kids);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
out.push(kids ? {...item, items: kids} : item);
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
};
|
|
723
|
+
return {items: walk(items), removed};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* What removing a project would refuse on, and why - checked BEFORE anything is
|
|
728
|
+
* deleted. People are never deleted with content; a menu in a site slot, or a
|
|
729
|
+
* built-in collection, is never deleted by a project removal either.
|
|
730
|
+
*/
|
|
731
|
+
async function removalBlockers(slug, grouped) {
|
|
732
|
+
const blockers = [];
|
|
733
|
+
if (grouped.users.length || grouped.roles.length) {
|
|
734
|
+
blockers.push(`${grouped.users.length} user(s) and ${grouped.roles.length} role(s) are filed under this project - move them to another project first; people are never deleted along with content`);
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const {getLocations} = await import('./menus.js');
|
|
738
|
+
const map = await getLocations();
|
|
739
|
+
for (const m of grouped.menus) {
|
|
740
|
+
const slot = Object.entries(map).find(([, s]) => s === m.slug)?.[0];
|
|
741
|
+
if (slot) blockers.push(`the menu "${m.slug}" is the site's ${slot} menu - map another menu to that slot, or move this one out of the project, first`);
|
|
742
|
+
}
|
|
743
|
+
} catch { /* no locations */ }
|
|
744
|
+
try {
|
|
745
|
+
const {PRESET_COLLECTION_SLUGS} = await import('./presetCollections.js');
|
|
746
|
+
const builtIn = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
|
|
747
|
+
for (const c of grouped.collections) {
|
|
748
|
+
if (builtIn.has(c.slug)) blockers.push(`the built-in collection "${c.slug}" is filed under this project - built-in collections are never deleted; move it out first`);
|
|
749
|
+
}
|
|
750
|
+
} catch { /* no presets */ }
|
|
751
|
+
return blockers;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Delete a project AND everything filed under it - "Remove sample content".
|
|
756
|
+
*
|
|
757
|
+
* Deletes its pages (with their versions and share links), collections (with
|
|
758
|
+
* their entries), forms, blocks, views, components, actions, menus, API
|
|
759
|
+
* endpoints and API tokens; takes the links to its pages out of every other
|
|
760
|
+
* menu; drops the project from menu and context-menu bindings, and deletes
|
|
761
|
+
* context menus that were bound only to its pages or to it; then deletes the
|
|
762
|
+
* project record.
|
|
763
|
+
*
|
|
764
|
+
* Refused - with nothing deleted - for core and protected projects, while any
|
|
765
|
+
* user or role is filed under it, while one of its menus fills a site slot,
|
|
766
|
+
* and while a built-in collection is filed under it. If anything fails part
|
|
767
|
+
* way, the project record is KEPT (so what is left can still be found under
|
|
768
|
+
* it) and the failures are reported.
|
|
769
|
+
*
|
|
770
|
+
* @param {string} slug
|
|
771
|
+
* @param {{siteHost?: string}} [opts] - this site's host, so absolute menu links to it are pruned too
|
|
772
|
+
* @returns {Promise<{removed: Record<string, number>, links: number, contextMenus: number, failed: string[], projectDeleted: boolean}>}
|
|
773
|
+
* @throws {Error} with `.status` 403 (core/protected), 404 (unknown) or 409 (blocked)
|
|
774
|
+
*/
|
|
775
|
+
export async function removeProjectContents(slug, {siteHost = ''} = {}) {
|
|
776
|
+
const fail = (status, message) => Object.assign(new Error(message), {status});
|
|
777
|
+
if (slug === CORE_PROJECT_SLUG) throw fail(403, 'The core project is the site itself - it cannot be removed.');
|
|
778
|
+
const project = await getProject(slug);
|
|
779
|
+
if (!project) throw fail(404, 'Project not found');
|
|
780
|
+
if (project.protected === true) throw fail(403, 'This project is protected and cannot be removed.');
|
|
781
|
+
|
|
782
|
+
const grouped = await getArtefactsForProject(slug);
|
|
783
|
+
const blockers = await removalBlockers(slug, grouped);
|
|
784
|
+
if (blockers.length) throw fail(409, `Nothing was removed: ${blockers.join('; ')}.`);
|
|
785
|
+
|
|
786
|
+
const removed = {pages: 0, collections: 0, forms: 0, blocks: 0, views: 0, components: 0, actions: 0, menus: 0, apis: 0, tokens: 0};
|
|
787
|
+
const failed = [];
|
|
788
|
+
const goneUrls = new Set();
|
|
789
|
+
const tryEach = async (list, fn, key, label) => {
|
|
790
|
+
for (const item of list) {
|
|
791
|
+
try {
|
|
792
|
+
const ok = await fn(item);
|
|
793
|
+
if (ok === false) throw new Error('not found');
|
|
794
|
+
removed[key]++;
|
|
795
|
+
} catch (err) {
|
|
796
|
+
failed.push(`${key} ${label(item)}: ${err.message}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const {deletePage} = await import('./content.js');
|
|
802
|
+
const {deletePreviewLinksForPage} = await import('./previewLinks.js');
|
|
803
|
+
// urlPath is where the page's file is - the same address membership resolves it by.
|
|
804
|
+
await tryEach(grouped.pages, async (p) => {
|
|
805
|
+
const url = p.urlPath || p.url;
|
|
806
|
+
await deletePage(url);
|
|
807
|
+
goneUrls.add(linkPath(url) || url);
|
|
808
|
+
try { await deletePreviewLinksForPage(url); } catch { /* none */ }
|
|
809
|
+
}, 'pages', (p) => p.urlPath || p.url);
|
|
810
|
+
const {deleteCollection} = await import('./collections.js');
|
|
811
|
+
await tryEach(grouped.collections, (c) => deleteCollection(c.slug), 'collections', (c) => c.slug);
|
|
812
|
+
const {deleteForm} = await import('./forms.js');
|
|
813
|
+
await tryEach(grouped.forms, (f) => deleteForm(f.slug), 'forms', (f) => f.slug);
|
|
814
|
+
const {deleteBlock} = await import('./blocks.js');
|
|
815
|
+
await tryEach(grouped.blocks, (b) => deleteBlock(b.name), 'blocks', (b) => b.name);
|
|
816
|
+
const {deleteView} = await import('./views.js');
|
|
817
|
+
await tryEach(grouped.views, (v) => deleteView(v.slug), 'views', (v) => v.slug);
|
|
818
|
+
const {deleteComponent} = await import('./components.js');
|
|
819
|
+
await tryEach(grouped.components, (c) => deleteComponent(c.name), 'components', (c) => c.name);
|
|
820
|
+
const {deleteAction} = await import('./actions.js');
|
|
821
|
+
await tryEach(grouped.actions, (a) => deleteAction(a.slug), 'actions', (a) => a.slug);
|
|
822
|
+
const menus = await import('./menus.js');
|
|
823
|
+
await tryEach(grouped.menus, (m) => menus.deleteMenu(m.slug), 'menus', (m) => m.slug);
|
|
824
|
+
// Through the endpoints service: it drops them from the live /api/x registry at once.
|
|
825
|
+
const {deleteEndpoint} = await import('./apiEndpoints.js');
|
|
826
|
+
await tryEach(grouped.apis, (e) => deleteEndpoint(e.id), 'apis', (e) => e.path || e.id);
|
|
827
|
+
// A token for this project would be a live key to whatever takes the slug next.
|
|
828
|
+
try {
|
|
829
|
+
const {API_TOKENS_COLLECTION_SLUG, revokeToken} = await import('./apiTokens.js');
|
|
830
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
831
|
+
await tryEach(entries.filter(e => e.data?.project === slug), (e) => revokeToken(e.id), 'tokens', (e) => e.data?.name || e.id);
|
|
832
|
+
} catch (err) { failed.push(`tokens: ${err.message}`); }
|
|
833
|
+
|
|
834
|
+
// Links to the removed pages, and bindings to the project, in the menus that remain.
|
|
835
|
+
let links = 0;
|
|
836
|
+
for (const m of await menus.listMenus().catch(() => [])) {
|
|
837
|
+
try {
|
|
838
|
+
const full = await menus.getMenu(m.slug);
|
|
839
|
+
if (!full) continue;
|
|
840
|
+
const {items, removed: n} = goneUrls.size ? pruneMenuItems(full.items, goneUrls, siteHost) : {items: full.items, removed: 0};
|
|
841
|
+
const bound = Array.isArray(full.binding?.projects) && full.binding.projects.includes(slug);
|
|
842
|
+
if (!n && !bound) continue;
|
|
843
|
+
const next = {...full, items};
|
|
844
|
+
if (bound) next.binding = {...full.binding, projects: full.binding.projects.filter(p => p !== slug)};
|
|
845
|
+
await menus.updateMenu(m.slug, next);
|
|
846
|
+
links += n;
|
|
847
|
+
} catch (err) { failed.push(`menu links in ${m.slug}: ${err.message}`); }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Context menus: bound only to removed pages and/or this project -> deleted; otherwise the project unbound.
|
|
851
|
+
let contextMenus = 0;
|
|
852
|
+
try {
|
|
853
|
+
const ctx = await import('./contextMenus.js');
|
|
854
|
+
for (const m of await ctx.getAllContextMenus()) {
|
|
855
|
+
const pages = Array.isArray(m?.binding?.pages) ? m.binding.pages : [];
|
|
856
|
+
const projects = Array.isArray(m?.binding?.projects) ? m.binding.projects : [];
|
|
857
|
+
const livePages = pages.filter(p => !goneUrls.has(linkPath(p) || p));
|
|
858
|
+
const liveProjects = projects.filter(p => p !== slug);
|
|
859
|
+
if (livePages.length === pages.length && liveProjects.length === projects.length) continue;
|
|
860
|
+
try {
|
|
861
|
+
if (!livePages.length && !liveProjects.length) { await ctx.deleteContextMenu(m.slug); contextMenus++; }
|
|
862
|
+
else await ctx.updateContextMenu(m.slug, {...m, binding: {...m.binding, pages: livePages, projects: liveProjects}});
|
|
863
|
+
} catch (err) { failed.push(`context menu ${m.slug}: ${err.message}`); }
|
|
864
|
+
}
|
|
865
|
+
} catch { /* no context menus */ }
|
|
866
|
+
|
|
867
|
+
// Anything left behind keeps the project, so it can still be found and dealt with.
|
|
868
|
+
let projectDeleted = false;
|
|
869
|
+
if (!failed.length) projectDeleted = await deleteProject(slug);
|
|
870
|
+
return {removed, links, contextMenus, failed, projectDeleted};
|
|
871
|
+
}
|
|
872
|
+
|
|
675
873
|
/**
|
|
676
874
|
* The sidebar badge on Manage projects (its countUrl, GET /api/projects/_count):
|
|
677
875
|
* how many projects this user sees - a count to glance at, not a warning -
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>{{seoTitle}}</title>
|
|
7
7
|
<meta name="description" content="{{seoDescription}}">
|
|
8
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
8
9
|
{{seoTags}}
|
|
9
10
|
|
|
10
11
|
<!-- Set on preview renders (editor preview, draft preview, share link).
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
<link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
|
|
25
26
|
|
|
26
27
|
<!-- Site CSS -->
|
|
27
|
-
<link rel="stylesheet" href="/public/css/site.css?v=
|
|
28
|
+
<link rel="stylesheet" href="/public/css/site.css?v=20261004-pricing-cta">
|
|
28
29
|
<link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
|
|
29
30
|
<!-- Menu item hover / current-page effects. Loaded after site.css so a
|
|
30
31
|
menu's own highlight wins over the blanket link styling there. -->
|
|
@@ -136,7 +137,7 @@
|
|
|
136
137
|
</script>
|
|
137
138
|
|
|
138
139
|
<!-- Site initialisation -->
|
|
139
|
-
<script src="/public/js/site.js?v=
|
|
140
|
+
<script src="/public/js/site.js?v=20261004-countdown" type="module"></script>
|
|
140
141
|
<script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
|
|
141
142
|
{{ctxMenusModule}}
|
|
142
143
|
|