@commonpub/layer 0.21.8 → 0.21.10
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.10",
|
|
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/auth": "0.6.0",
|
|
54
53
|
"@commonpub/docs": "0.6.3",
|
|
54
|
+
"@commonpub/explainer": "0.7.14",
|
|
55
|
+
"@commonpub/editor": "0.7.10",
|
|
55
56
|
"@commonpub/learning": "0.5.2",
|
|
56
|
-
"@commonpub/
|
|
57
|
-
"@commonpub/editor": "0.7.9",
|
|
58
|
-
"@commonpub/protocol": "0.10.0",
|
|
57
|
+
"@commonpub/server": "2.54.1",
|
|
59
58
|
"@commonpub/ui": "0.8.5",
|
|
60
|
-
"@commonpub/
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
59
|
+
"@commonpub/config": "0.13.0",
|
|
60
|
+
"@commonpub/auth": "0.6.0",
|
|
61
|
+
"@commonpub/schema": "0.16.0",
|
|
62
|
+
"@commonpub/protocol": "0.10.1"
|
|
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>
|
|
@@ -40,10 +40,15 @@ const isOwner = computed(() => user.value?.id === content.value?.author?.id);
|
|
|
40
40
|
|
|
41
41
|
// Estimate reading time (~200 words per minute)
|
|
42
42
|
const readTime = computed(() => {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Explainer document-format content is an object ({hero,sections,…}),
|
|
44
|
+
// not a BlockTuple[] — only blog/project store an array. Guard the
|
|
45
|
+
// for…of: iterating the object threw "not iterable" and 500'd SSR for
|
|
46
|
+
// every document-format explainer.
|
|
47
|
+
const raw = content.value?.content;
|
|
48
|
+
if (!Array.isArray(raw)) return undefined;
|
|
49
|
+
const blocks = raw as BlockTuple[];
|
|
45
50
|
let words = 0;
|
|
46
|
-
for (const [
|
|
51
|
+
for (const [, data] of blocks) {
|
|
47
52
|
// Extract text from any string field in the block data
|
|
48
53
|
for (const val of Object.values(data)) {
|
|
49
54
|
if (typeof val === 'string' && val.trim()) {
|
|
@@ -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
|
+
});
|