@commonpub/layer 0.21.9 → 0.21.11
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.
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Translate common watch-page URLs into iframe-embeddable equivalents
|
|
5
|
+
// so authors who paste a YouTube/Vimeo URL into the generic Embed block
|
|
6
|
+
// don't end up with an iframe that the provider refuses to render
|
|
7
|
+
// (X-Frame-Options / CSP frame-ancestors). Mirrors BlockVideoView.
|
|
8
|
+
const embedUrl = computed(() => {
|
|
5
9
|
const raw = (props.content.url as string) || '';
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
if (!raw) return '';
|
|
11
|
+
const yt = raw.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{6,})/);
|
|
12
|
+
if (yt) return `https://www.youtube-nocookie.com/embed/${yt[1]}`;
|
|
13
|
+
const vimeo = raw.match(/vimeo\.com\/(\d+)/);
|
|
14
|
+
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
|
15
|
+
// Anything else: allow http(s) only (block javascript:, data:, etc.).
|
|
16
|
+
if (raw.startsWith('https://') || raw.startsWith('http://')) return raw;
|
|
8
17
|
return '';
|
|
9
18
|
});
|
|
10
19
|
</script>
|
|
11
20
|
|
|
12
21
|
<template>
|
|
13
|
-
<div v-if="
|
|
22
|
+
<div v-if="embedUrl" class="cpub-block-embed">
|
|
14
23
|
<div class="cpub-embed-label">
|
|
15
24
|
<i class="fa-solid fa-globe"></i> Embed
|
|
16
25
|
</div>
|
|
17
26
|
<div class="cpub-embed-wrap">
|
|
18
|
-
<iframe :src="
|
|
27
|
+
<iframe :src="embedUrl" class="cpub-embed-iframe" frameborder="0" loading="lazy" title="Embedded content" />
|
|
19
28
|
</div>
|
|
20
29
|
</div>
|
|
21
30
|
</template>
|
|
@@ -7,8 +7,8 @@ const embedUrl = computed(() => {
|
|
|
7
7
|
const u = url.value;
|
|
8
8
|
if (!u) return '';
|
|
9
9
|
|
|
10
|
-
// YouTube —
|
|
11
|
-
const ytMatch = u.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]
|
|
10
|
+
// YouTube — handle watch + youtu.be + /embed/ + /v/ + /shorts/.
|
|
11
|
+
const ytMatch = u.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{6,})/);
|
|
12
12
|
if (ytMatch) return `https://www.youtube-nocookie.com/embed/${ytMatch[1]}`;
|
|
13
13
|
|
|
14
14
|
// Vimeo — extract video ID and construct safe embed URL
|
|
@@ -33,6 +33,13 @@ const blockTypes: BlockTypeGroup[] = [
|
|
|
33
33
|
{ type: 'downloads', label: 'Downloads', icon: 'fa-download', description: 'File attachments' },
|
|
34
34
|
],
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Media',
|
|
38
|
+
blocks: [
|
|
39
|
+
{ type: 'video', label: 'Video', icon: 'fa-film', description: 'YouTube, Vimeo embed' },
|
|
40
|
+
{ type: 'embed', label: 'Embed', icon: 'fa-globe', description: 'External embed (translates YouTube/Vimeo URLs)' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
36
43
|
{
|
|
37
44
|
name: 'Rich',
|
|
38
45
|
blocks: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/config": "0.13.0",
|
|
54
|
-
"@commonpub/docs": "0.6.3",
|
|
55
|
-
"@commonpub/explainer": "0.7.13",
|
|
56
53
|
"@commonpub/auth": "0.6.0",
|
|
57
|
-
"@commonpub/
|
|
54
|
+
"@commonpub/docs": "0.6.3",
|
|
55
|
+
"@commonpub/protocol": "0.10.1",
|
|
58
56
|
"@commonpub/schema": "0.16.0",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
57
|
+
"@commonpub/learning": "0.5.2",
|
|
58
|
+
"@commonpub/ui": "0.8.5",
|
|
59
|
+
"@commonpub/explainer": "0.7.15",
|
|
60
|
+
"@commonpub/config": "0.13.0",
|
|
61
|
+
"@commonpub/server": "2.54.2",
|
|
62
|
+
"@commonpub/editor": "0.7.10"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/admin/settings.vue
CHANGED
|
@@ -52,6 +52,29 @@ async function addSetting(): Promise<void> {
|
|
|
52
52
|
newKey.value = '';
|
|
53
53
|
newValue.value = '';
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
// Maintenance: rewrite stored DO Spaces origin asset URLs → CDN edge.
|
|
57
|
+
const cdnBusy = ref(false);
|
|
58
|
+
const cdnResult = ref<string>('');
|
|
59
|
+
async function backfillCdn(dryRun: boolean): Promise<void> {
|
|
60
|
+
if (!dryRun && !confirm('Rewrite all stored Spaces asset URLs to the CDN host? This updates the database.')) return;
|
|
61
|
+
cdnBusy.value = true;
|
|
62
|
+
cdnResult.value = '';
|
|
63
|
+
try {
|
|
64
|
+
const r = await $fetch<{ dryRun: boolean; hosts: { origin: string; cdn: string }; wouldRewrite?: Record<string, number>; rewritten?: Record<string, number> }>(
|
|
65
|
+
`/api/admin/storage/backfill-cdn-urls${dryRun ? '?dryRun=1' : ''}`,
|
|
66
|
+
{ method: 'POST' },
|
|
67
|
+
);
|
|
68
|
+
const counts = r.wouldRewrite ?? r.rewritten ?? {};
|
|
69
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
70
|
+
const nonZero = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
71
|
+
cdnResult.value = `${r.dryRun ? 'Would rewrite' : 'Rewrote'} ${total} URL(s) → ${r.hosts.cdn}${nonZero ? ` (${nonZero})` : ''}.`;
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
cdnResult.value = `Failed: ${err instanceof Error ? err.message : 'see server logs'}`;
|
|
74
|
+
} finally {
|
|
75
|
+
cdnBusy.value = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
55
78
|
</script>
|
|
56
79
|
|
|
57
80
|
<template>
|
|
@@ -111,6 +134,25 @@ async function addSetting(): Promise<void> {
|
|
|
111
134
|
</div>
|
|
112
135
|
</template>
|
|
113
136
|
<p v-else class="admin-empty">No settings configured.</p>
|
|
137
|
+
|
|
138
|
+
<section class="cpub-maint">
|
|
139
|
+
<h2 class="cpub-maint-title">Maintenance</h2>
|
|
140
|
+
<div class="cpub-maint-row">
|
|
141
|
+
<div>
|
|
142
|
+
<strong>Spaces → CDN URL backfill</strong>
|
|
143
|
+
<p class="cpub-maint-desc">
|
|
144
|
+
Rewrite stored DigitalOcean Spaces origin asset URLs to the CDN
|
|
145
|
+
edge host. Only affects existing rows (new uploads already use
|
|
146
|
+
the configured host). Idempotent; preview first.
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="cpub-maint-actions">
|
|
150
|
+
<button class="cpub-btn cpub-btn-sm" :disabled="cdnBusy" @click="backfillCdn(true)">Preview</button>
|
|
151
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="cdnBusy" @click="backfillCdn(false)">Apply</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<p v-if="cdnResult" class="cpub-maint-result">{{ cdnResult }}</p>
|
|
155
|
+
</section>
|
|
114
156
|
</div>
|
|
115
157
|
</template>
|
|
116
158
|
|
|
@@ -172,8 +214,44 @@ async function addSetting(): Promise<void> {
|
|
|
172
214
|
padding: var(--space-8) 0;
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
.cpub-maint {
|
|
218
|
+
margin-top: var(--space-8);
|
|
219
|
+
border: var(--border-width-default) solid var(--border);
|
|
220
|
+
background: var(--surface);
|
|
221
|
+
padding: var(--space-4);
|
|
222
|
+
}
|
|
223
|
+
.cpub-maint-title {
|
|
224
|
+
font-size: var(--text-lg);
|
|
225
|
+
font-weight: var(--font-weight-bold);
|
|
226
|
+
margin-bottom: var(--space-4);
|
|
227
|
+
}
|
|
228
|
+
.cpub-maint-row {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: flex-start;
|
|
231
|
+
justify-content: space-between;
|
|
232
|
+
gap: var(--space-4);
|
|
233
|
+
}
|
|
234
|
+
.cpub-maint-desc {
|
|
235
|
+
color: var(--text-dim);
|
|
236
|
+
font-size: var(--text-sm);
|
|
237
|
+
margin-top: var(--space-1);
|
|
238
|
+
max-width: 52ch;
|
|
239
|
+
}
|
|
240
|
+
.cpub-maint-actions {
|
|
241
|
+
display: flex;
|
|
242
|
+
gap: var(--space-2);
|
|
243
|
+
flex-shrink: 0;
|
|
244
|
+
}
|
|
245
|
+
.cpub-maint-result {
|
|
246
|
+
margin-top: var(--space-3);
|
|
247
|
+
font-family: var(--font-mono);
|
|
248
|
+
font-size: var(--text-sm);
|
|
249
|
+
color: var(--text);
|
|
250
|
+
}
|
|
251
|
+
|
|
175
252
|
@media (max-width: 768px) {
|
|
176
253
|
.settings-row { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
|
177
254
|
.settings-add { flex-direction: column; }
|
|
255
|
+
.cpub-maint-row { flex-direction: column; }
|
|
178
256
|
}
|
|
179
257
|
</style>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin maintenance: rewrite stored DigitalOcean Spaces ORIGIN asset
|
|
3
|
+
* URLs to the CDN edge host.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/admin/storage/backfill-cdn-urls (apply)
|
|
6
|
+
* POST /api/admin/storage/backfill-cdn-urls?dryRun=1 (counts only)
|
|
7
|
+
*
|
|
8
|
+
* WHY: object public URLs are frozen into the DB at upload time
|
|
9
|
+
* (files.public_url, content_items.cover_image_url,
|
|
10
|
+
* learning_paths.cover_image_url). Enabling S3_CDN only affects NEW
|
|
11
|
+
* uploads — existing rows keep the origin host until rewritten. This
|
|
12
|
+
* is the admin-UI equivalent of a one-off backfill, idempotent
|
|
13
|
+
* (re-running once rewritten is a no-op).
|
|
14
|
+
*
|
|
15
|
+
* Requires admin. Derives the origin→CDN host pair from the instance's
|
|
16
|
+
* own S3 env, so it can only ever rewrite THIS instance's Spaces host.
|
|
17
|
+
*/
|
|
18
|
+
import { contentItems, contests, files, hubs, learningPaths, products, users } from '@commonpub/schema';
|
|
19
|
+
import { sql } from 'drizzle-orm';
|
|
20
|
+
|
|
21
|
+
function spacesHosts(): { origin: string; cdn: string } | null {
|
|
22
|
+
const bucket = process.env.S3_BUCKET;
|
|
23
|
+
const endpoint = process.env.S3_ENDPOINT ?? '';
|
|
24
|
+
const region = process.env.S3_REGION;
|
|
25
|
+
if (!bucket) return null;
|
|
26
|
+
const m = endpoint.match(/^https?:\/\/([a-z0-9-]+)\.digitaloceanspaces\.com\/?$/i);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
const reg = region || m[1]!;
|
|
29
|
+
return {
|
|
30
|
+
origin: `${bucket}.${reg}.digitaloceanspaces.com`,
|
|
31
|
+
cdn: `${bucket}.${reg}.cdn.digitaloceanspaces.com`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default defineEventHandler(async (event) => {
|
|
36
|
+
requireAuth(event);
|
|
37
|
+
requireAdmin(event);
|
|
38
|
+
|
|
39
|
+
const hosts = spacesHosts();
|
|
40
|
+
if (!hosts) {
|
|
41
|
+
throw createError({
|
|
42
|
+
statusCode: 400,
|
|
43
|
+
statusMessage:
|
|
44
|
+
'This instance is not configured for DigitalOcean Spaces (need S3_BUCKET + a *.digitaloceanspaces.com S3_ENDPOINT).',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Guard against a no-op host pair.
|
|
48
|
+
if (hosts.origin === hosts.cdn) {
|
|
49
|
+
throw createError({ statusCode: 400, statusMessage: 'Origin and CDN host are identical.' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dryRun = getQuery(event).dryRun === '1';
|
|
53
|
+
const db = useDB();
|
|
54
|
+
const like = `%${hosts.origin}%`;
|
|
55
|
+
|
|
56
|
+
// Every LOCAL column that the upload pipeline writes a Spaces public
|
|
57
|
+
// URL into (avatar/banner/icon/cover/banner/contest-banner/product-
|
|
58
|
+
// image + files + learning cover). Federation tables hold REMOTE
|
|
59
|
+
// URLs and are deliberately excluded so we never rewrite another host.
|
|
60
|
+
const n = async <T>(q: Promise<{ n: number }[]>): Promise<number> => (await q)[0]?.n ?? 0;
|
|
61
|
+
|
|
62
|
+
const counts = {
|
|
63
|
+
'files.publicUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(files).where(sql`${files.publicUrl} LIKE ${like}`)),
|
|
64
|
+
'contentItems.coverImageUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(contentItems).where(sql`${contentItems.coverImageUrl} LIKE ${like}`)),
|
|
65
|
+
'contentItems.bannerUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(contentItems).where(sql`${contentItems.bannerUrl} LIKE ${like}`)),
|
|
66
|
+
'learningPaths.coverImageUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(learningPaths).where(sql`${learningPaths.coverImageUrl} LIKE ${like}`)),
|
|
67
|
+
'users.avatarUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(users).where(sql`${users.avatarUrl} LIKE ${like}`)),
|
|
68
|
+
'users.bannerUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(users).where(sql`${users.bannerUrl} LIKE ${like}`)),
|
|
69
|
+
'hubs.iconUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(hubs).where(sql`${hubs.iconUrl} LIKE ${like}`)),
|
|
70
|
+
'hubs.bannerUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(hubs).where(sql`${hubs.bannerUrl} LIKE ${like}`)),
|
|
71
|
+
'contests.bannerUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(contests).where(sql`${contests.bannerUrl} LIKE ${like}`)),
|
|
72
|
+
'products.imageUrl': await n(db.select({ n: sql<number>`count(*)::int` }).from(products).where(sql`${products.imageUrl} LIKE ${like}`)),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
return { success: true, dryRun: true, hosts, wouldRewrite: counts };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await db.update(files).set({ publicUrl: sql`replace(${files.publicUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${files.publicUrl} LIKE ${like}`);
|
|
80
|
+
await db.update(contentItems).set({ coverImageUrl: sql`replace(${contentItems.coverImageUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${contentItems.coverImageUrl} LIKE ${like}`);
|
|
81
|
+
await db.update(contentItems).set({ bannerUrl: sql`replace(${contentItems.bannerUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${contentItems.bannerUrl} LIKE ${like}`);
|
|
82
|
+
await db.update(learningPaths).set({ coverImageUrl: sql`replace(${learningPaths.coverImageUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${learningPaths.coverImageUrl} LIKE ${like}`);
|
|
83
|
+
await db.update(users).set({ avatarUrl: sql`replace(${users.avatarUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${users.avatarUrl} LIKE ${like}`);
|
|
84
|
+
await db.update(users).set({ bannerUrl: sql`replace(${users.bannerUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${users.bannerUrl} LIKE ${like}`);
|
|
85
|
+
await db.update(hubs).set({ iconUrl: sql`replace(${hubs.iconUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${hubs.iconUrl} LIKE ${like}`);
|
|
86
|
+
await db.update(hubs).set({ bannerUrl: sql`replace(${hubs.bannerUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${hubs.bannerUrl} LIKE ${like}`);
|
|
87
|
+
await db.update(contests).set({ bannerUrl: sql`replace(${contests.bannerUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${contests.bannerUrl} LIKE ${like}`);
|
|
88
|
+
await db.update(products).set({ imageUrl: sql`replace(${products.imageUrl}, ${hosts.origin}, ${hosts.cdn})` }).where(sql`${products.imageUrl} LIKE ${like}`);
|
|
89
|
+
|
|
90
|
+
return { success: true, dryRun: false, hosts, rewritten: counts };
|
|
91
|
+
});
|