@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.8",
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/schema": "0.16.0",
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/explainer": "0.7.13",
61
- "@commonpub/server": "2.54.0",
62
- "@commonpub/config": "0.13.0"
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",
@@ -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
- if (!content.value?.content) return undefined;
44
- const blocks = content.value.content as BlockTuple[];
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 [type, data] of blocks) {
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
+ });