@commonpub/layer 0.18.2 → 0.19.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.
@@ -53,12 +53,15 @@ watch(() => props.open, (v) => {
53
53
  search.value = '';
54
54
  }
55
55
  });
56
+
57
+ const dialogRef = ref<HTMLElement | null>(null);
58
+ useFocusTrap(dialogRef, () => props.open, close);
56
59
  </script>
57
60
 
58
61
  <template>
59
62
  <Teleport to="body">
60
63
  <div v-if="open" class="cpub-picker-overlay" @click.self="close">
61
- <div class="cpub-picker-dialog" role="dialog" aria-label="Select content" @keydown.escape="close">
64
+ <div ref="dialogRef" class="cpub-picker-dialog" role="dialog" aria-label="Select content">
62
65
  <div class="cpub-picker-header">
63
66
  <h2 class="cpub-picker-title"><i class="fa-solid fa-link"></i> Link Existing Content</h2>
64
67
  <button class="cpub-picker-close" aria-label="Close" @click="close"><i class="fa-solid fa-xmark"></i></button>
@@ -77,16 +77,14 @@ function resetState(): void {
77
77
  confirmed.value = false;
78
78
  }
79
79
 
80
- function onKeydown(e: KeyboardEvent): void {
81
- if (e.key === 'Escape') handleClose();
82
- if (e.key === 'Enter' && !result.value && canSubmit.value && !loading.value) handleFetch();
83
- }
80
+ const dialogRef = ref<HTMLElement | null>(null);
81
+ useFocusTrap(dialogRef, () => props.show, handleClose);
84
82
  </script>
85
83
 
86
84
  <template>
87
85
  <Teleport to="body">
88
- <div v-if="show" class="cpub-import-overlay" @click.self="handleClose" @keydown="onKeydown">
89
- <div class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
86
+ <div v-if="show" class="cpub-import-overlay" @click.self="handleClose">
87
+ <div ref="dialogRef" class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
90
88
  <div class="cpub-import-header">
91
89
  <h2 id="import-url-title"><i class="fa-solid fa-file-import"></i> Import from URL</h2>
92
90
  <button class="cpub-import-close" aria-label="Close" @click="handleClose">
@@ -3,7 +3,7 @@
3
3
  * Modal showing publish validation errors.
4
4
  * Displayed when the user tries to publish content that's missing required fields.
5
5
  */
6
- defineProps<{
6
+ const props = defineProps<{
7
7
  errors: string[];
8
8
  show: boolean;
9
9
  }>();
@@ -11,12 +11,15 @@ defineProps<{
11
11
  const emit = defineEmits<{
12
12
  dismiss: [];
13
13
  }>();
14
+
15
+ const dialogRef = ref<HTMLElement | null>(null);
16
+ useFocusTrap(dialogRef, () => props.show, () => emit('dismiss'));
14
17
  </script>
15
18
 
16
19
  <template>
17
20
  <Teleport to="body">
18
21
  <div v-if="show" class="cpub-publish-errors-overlay" @click.self="emit('dismiss')">
19
- <div class="cpub-publish-errors-card" role="alertdialog" aria-labelledby="publish-errors-title">
22
+ <div ref="dialogRef" class="cpub-publish-errors-card" role="alertdialog" aria-labelledby="publish-errors-title">
20
23
  <h3 id="publish-errors-title" class="cpub-publish-errors-title">
21
24
  <i class="fa-solid fa-circle-exclamation" /> Not ready to publish
22
25
  </h3>
@@ -36,12 +36,15 @@ function submit(): void {
36
36
  }
37
37
 
38
38
  defineExpose({ show });
39
+
40
+ const dialogRef = ref<HTMLElement | null>(null);
41
+ useFocusTrap(dialogRef, () => open.value, close);
39
42
  </script>
40
43
 
41
44
  <template>
42
45
  <Teleport to="body">
43
46
  <div v-if="open" class="cpub-rfd-overlay" @click.self="close">
44
- <div class="cpub-rfd-dialog" role="dialog" aria-modal="true">
47
+ <div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true">
45
48
  <div class="cpub-rfd-header">
46
49
  <h3>Follow from your instance</h3>
47
50
  <button class="cpub-rfd-close" aria-label="Close" @click="close">
@@ -25,7 +25,12 @@ interface AuthResponse {
25
25
  session: ClientAuthSession | null;
26
26
  }
27
27
 
28
- /** Type-safe POST fetch that avoids Nuxt $fetch TS2589 deep instantiation */
28
+ // `$fetch<T>(url, options)` instantiates Nuxt's NitroFetchRequest generic
29
+ // with an excessively deep type graph, which fails TS2589 on this
30
+ // specific call shape. The cast below narrows `$fetch` to a concrete
31
+ // signature the compiler can check without recursing. Verified session
32
+ // 133: removing the cast immediately reintroduces the error. Cleanup is
33
+ // upstream — wait for Nuxt to simplify the $fetch type.
29
34
  async function authPost(url: string, body: Record<string, unknown>): Promise<AuthResponse | null> {
30
35
  return ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(url, {
31
36
  method: 'POST',
@@ -35,6 +40,12 @@ async function authPost(url: string, body: Record<string, unknown>): Promise<Aut
35
40
  });
36
41
  }
37
42
 
43
+ async function authGet(url: string): Promise<AuthResponse | null> {
44
+ return ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(url, {
45
+ credentials: 'include',
46
+ });
47
+ }
48
+
38
49
  export function useAuth() {
39
50
  const user = useState<ClientAuthUser | null>('auth-user', () => null);
40
51
  const session = useState<ClientAuthSession | null>('auth-session', () => null);
@@ -68,9 +79,7 @@ export function useAuth() {
68
79
  async function refreshSession(): Promise<void> {
69
80
  if (import.meta.server) return;
70
81
  try {
71
- const data = await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(
72
- '/api/me', { credentials: 'include' },
73
- );
82
+ const data = await authGet('/api/me');
74
83
  user.value = data?.user ?? null;
75
84
  session.value = data?.session ?? null;
76
85
  } catch {
@@ -0,0 +1,90 @@
1
+ import type { Ref } from 'vue';
2
+
3
+ /**
4
+ * Modal a11y helper: focus trap, initial focus, focus restoration, body
5
+ * scroll lock. Mirrors the behaviour of `<Dialog>` in @commonpub/ui without
6
+ * forcing a visual refactor of layer-side modals that have their own
7
+ * header/footer styling.
8
+ *
9
+ * Wire it from a modal's `<script setup>`:
10
+ *
11
+ * const dialogRef = ref<HTMLElement | null>(null);
12
+ * useFocusTrap(dialogRef, () => props.open, close);
13
+ *
14
+ * Where `() => props.open` is a getter that returns true while the modal
15
+ * is visible, and `close` is the function that dismisses it (called when
16
+ * the user presses Escape).
17
+ *
18
+ * The trap cycles Tab/Shift+Tab within `dialogRef`'s focusable
19
+ * descendants. On open, focus moves to the first focusable element. On
20
+ * close, focus restores to whatever element had focus when the modal
21
+ * opened. Body scroll is locked while open.
22
+ */
23
+ export function useFocusTrap(
24
+ dialogRef: Ref<HTMLElement | null>,
25
+ isOpen: () => boolean,
26
+ onEscape: () => void,
27
+ ): void {
28
+ let previousActive: HTMLElement | null = null;
29
+
30
+ function focusableElements(): HTMLElement[] {
31
+ if (!dialogRef.value) return [];
32
+ return Array.from(
33
+ dialogRef.value.querySelectorAll<HTMLElement>(
34
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
35
+ ),
36
+ );
37
+ }
38
+
39
+ function handleKeydown(event: KeyboardEvent): void {
40
+ if (!isOpen()) return;
41
+ if (event.key === 'Escape') {
42
+ event.preventDefault();
43
+ onEscape();
44
+ return;
45
+ }
46
+ if (event.key !== 'Tab') return;
47
+
48
+ const focusable = focusableElements();
49
+ if (focusable.length === 0) {
50
+ event.preventDefault();
51
+ return;
52
+ }
53
+ const first = focusable[0]!;
54
+ const last = focusable[focusable.length - 1]!;
55
+ const active = document.activeElement as HTMLElement | null;
56
+
57
+ if (event.shiftKey) {
58
+ if (active === first || !dialogRef.value?.contains(active)) {
59
+ event.preventDefault();
60
+ last.focus();
61
+ }
62
+ } else {
63
+ if (active === last || !dialogRef.value?.contains(active)) {
64
+ event.preventDefault();
65
+ first.focus();
66
+ }
67
+ }
68
+ }
69
+
70
+ watch(isOpen, async (open) => {
71
+ if (open) {
72
+ previousActive = document.activeElement as HTMLElement | null;
73
+ document.body.style.overflow = 'hidden';
74
+ document.addEventListener('keydown', handleKeydown);
75
+ await nextTick();
76
+ const first = focusableElements()[0];
77
+ first?.focus();
78
+ } else {
79
+ document.body.style.overflow = '';
80
+ document.removeEventListener('keydown', handleKeydown);
81
+ previousActive?.focus();
82
+ previousActive = null;
83
+ }
84
+ });
85
+
86
+ onUnmounted(() => {
87
+ document.body.style.overflow = '';
88
+ document.removeEventListener('keydown', handleKeydown);
89
+ });
90
+ }
@@ -83,6 +83,10 @@ const userUsername = computed(() => user.value?.username ?? '');
83
83
 
84
84
  <template>
85
85
  <div class="cpub-layout">
86
+ <!-- WCAG 2.4.1 — visually hidden until focused, lets keyboard users
87
+ skip past the global nav directly to page content. -->
88
+ <a href="#main-content" class="cpub-skip-link">Skip to content</a>
89
+
86
90
  <!-- ═══ TOP NAV ═══ -->
87
91
  <header class="cpub-topbar">
88
92
  <NuxtLink to="/" class="cpub-topbar-logo">
@@ -218,6 +222,28 @@ const userUsername = computed(() => user.value?.username ?? '');
218
222
  </template>
219
223
 
220
224
  <style scoped>
225
+ .cpub-skip-link {
226
+ position: absolute;
227
+ top: 0;
228
+ left: 0;
229
+ z-index: var(--z-modal, 9999);
230
+ padding: 8px 16px;
231
+ background: var(--accent);
232
+ color: var(--color-on-accent, #fff);
233
+ font-family: var(--font-mono);
234
+ font-size: var(--text-label);
235
+ text-transform: uppercase;
236
+ letter-spacing: var(--tracking-wider);
237
+ text-decoration: none;
238
+ transform: translateY(-100%);
239
+ transition: transform var(--transition-fast);
240
+ }
241
+ .cpub-skip-link:focus {
242
+ transform: translateY(0);
243
+ outline: 2px solid var(--text);
244
+ outline-offset: -2px;
245
+ }
246
+
221
247
  .cpub-layout { min-height: 100vh; display: flex; flex-direction: column; }
222
248
 
223
249
  /* ═══ TOPBAR ═══ */
package/nuxt.config.ts CHANGED
@@ -14,10 +14,16 @@ export default defineNuxtConfig({
14
14
  compatibilityDate: '2024-11-01',
15
15
  app: {
16
16
  head: {
17
+ htmlAttrs: { lang: 'en' },
17
18
  link: [
18
19
  {
19
20
  rel: 'stylesheet',
20
21
  href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
22
+ // SRI: protects against a compromised CDN serving altered CSS.
23
+ // If Font Awesome is upgraded, regenerate via:
24
+ // curl -sS https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css \
25
+ // | openssl dgst -sha384 -binary | openssl base64 -A | sed 's/^/sha384-/'
26
+ integrity: 'sha384-SZXxX4whJ79/gErwcOYf+zWLeJdY/qpuqC4cAa9rOGUstPomtqpuNWT9wdPEn2fk',
21
27
  crossorigin: 'anonymous',
22
28
  },
23
29
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -28,9 +28,6 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
- "@commonpub/explainer": "^0.7.12",
32
- "@commonpub/schema": "^0.14.4",
33
- "@commonpub/server": "^2.47.3",
34
31
  "@tiptap/core": "^2.11.0",
35
32
  "@tiptap/extension-bold": "^2.11.0",
36
33
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -54,12 +51,15 @@
54
51
  "vue-router": "^4.3.0",
55
52
  "zod": "^4.3.6",
56
53
  "@commonpub/auth": "0.5.1",
57
- "@commonpub/docs": "0.6.2",
58
54
  "@commonpub/config": "0.11.0",
55
+ "@commonpub/docs": "0.6.2",
56
+ "@commonpub/protocol": "0.9.9",
57
+ "@commonpub/schema": "0.15.0",
58
+ "@commonpub/server": "2.48.0",
59
+ "@commonpub/explainer": "0.7.12",
59
60
  "@commonpub/learning": "0.5.2",
60
- "@commonpub/editor": "0.7.9",
61
61
  "@commonpub/ui": "0.8.5",
62
- "@commonpub/protocol": "0.9.9"
62
+ "@commonpub/editor": "0.7.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -446,4 +446,19 @@ function fmtErrorRate(rate: number): string {
446
446
  .cpub-empty { padding: 40px; text-align: center; color: var(--text-dim); background: var(--surface); border: var(--border-width-default) solid var(--border); }
447
447
  .cpub-empty code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 6px; }
448
448
  .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
449
+
450
+ /* Mobile — admin api-keys table is the worst offender at 375px because
451
+ Postgres-style 6-column tables don't shrink. We let the table-wrapper
452
+ scroll horizontally and tighten everything else. The usage drawer's
453
+ stat grid drops to a single column. */
454
+ @media (max-width: 768px) {
455
+ .cpub-key-table { display: block; overflow-x: auto; white-space: nowrap; }
456
+ .cpub-key-table th,
457
+ .cpub-key-table td { padding: 8px 10px; font-size: 11px; }
458
+ .cpub-key-actions { flex-direction: column; gap: 4px; align-items: stretch; }
459
+ .cpub-key-actions .cpub-btn-link { padding: 4px 6px; }
460
+ .cpub-usage-grid { grid-template-columns: 1fr; gap: 12px; }
461
+ .cpub-usage-by-day ul { font-size: 10px; }
462
+ .cpub-key-usage-row td { padding: 12px; }
463
+ }
449
464
  </style>
@@ -526,4 +526,22 @@ async function refederate(): Promise<void> {
526
526
  margin-top: 10px; padding: 10px 14px; font-size: 0.8125rem; font-family: var(--font-mono);
527
527
  background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--text);
528
528
  }
529
+
530
+ /* Mobile — admin federation dashboard. The stats grid (4 cells × 100px min)
531
+ plus 24px gap claims the entire 375px viewport with nothing left for
532
+ labels. The form wraps each input + button onto its own line. The
533
+ activity-list rows shrink to legible. */
534
+ @media (max-width: 768px) {
535
+ .cpub-fed-stats { grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 16px; }
536
+ .cpub-fed-stat { padding: 12px 10px; }
537
+ .cpub-fed-stat-val { font-size: 1.25rem; }
538
+ .cpub-fed-tabs { overflow-x: auto; }
539
+ .cpub-fed-tabs button { padding: 8px 12px; white-space: nowrap; }
540
+ .cpub-fed-form { flex-direction: column; align-items: stretch; gap: 8px; }
541
+ .cpub-fed-form .cpub-fed-btn,
542
+ .cpub-fed-form .cpub-fed-input { width: 100%; }
543
+ .cpub-fed-actor { font-size: 11px; }
544
+ .cpub-fed-time { font-size: 9px; }
545
+ .cpub-admin-title { font-size: 1rem; }
546
+ }
529
547
  </style>
@@ -325,4 +325,38 @@ function stripHtml(html: string): string {
325
325
  font-weight: 600;
326
326
  margin-bottom: var(--space-4);
327
327
  }
328
+
329
+ /* Mobile — federated profile view. The header's avatar+info+follow-btn
330
+ row at gap:16px overflows 375px. Stats wrap, actions stack, DM form
331
+ actions stack. Avatar shrinks. */
332
+ @media (max-width: 768px) {
333
+ .cpub-remote-profile { padding: var(--space-4) var(--space-3); }
334
+ .cpub-remote-profile__header {
335
+ flex-direction: column;
336
+ align-items: flex-start;
337
+ gap: var(--space-3);
338
+ }
339
+ .cpub-remote-profile__avatar {
340
+ width: 56px;
341
+ height: 56px;
342
+ }
343
+ .cpub-remote-profile__name { font-size: 1.125rem; }
344
+ .cpub-remote-profile__actions {
345
+ flex-direction: column;
346
+ align-items: stretch;
347
+ width: 100%;
348
+ gap: var(--space-2);
349
+ }
350
+ .cpub-remote-profile__follow-btn,
351
+ .cpub-remote-profile__dm-send,
352
+ .cpub-remote-profile__dm-cancel { width: 100%; }
353
+ .cpub-remote-profile__stats {
354
+ flex-wrap: wrap;
355
+ gap: var(--space-2);
356
+ font-size: var(--font-size-sm);
357
+ }
358
+ .cpub-remote-profile__dm-actions {
359
+ flex-direction: column;
360
+ }
361
+ }
328
362
  </style>
@@ -357,4 +357,47 @@ const activeDifficultyFilter = ref('');
357
357
  .cpub-empty-icon { font-size: 32px; color: var(--text-faint); margin-bottom: 12px; }
358
358
  .cpub-empty-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
359
359
  .cpub-empty-sub { font-size: 12px; color: var(--text-dim); }
360
+
361
+ /* MOBILE (≤ 768px) — stack sidebar, collapse multi-column grids,
362
+ shrink outer padding so content gets the full viewport. */
363
+ @media (max-width: 768px) {
364
+ .cpub-learn-hero { padding: 24px 16px 18px; }
365
+ .cpub-hero-title { font-size: 22px; }
366
+ .cpub-hero-sub { font-size: 13px; margin-bottom: 18px; }
367
+ .cpub-hero-stats { gap: 16px; margin-top: 18px; padding-top: 16px; }
368
+
369
+ .cpub-shell { flex-direction: column; min-height: auto; }
370
+ .cpub-page { padding: 20px 16px; }
371
+ .cpub-sidebar {
372
+ width: 100%;
373
+ border-left: none;
374
+ border-top: var(--border-width-default) solid var(--border);
375
+ padding: 16px 0;
376
+ }
377
+
378
+ /* In-progress cards: already scroll horizontally on mobile via cpub-ip-row */
379
+
380
+ /* Path cards: stack vertically so description + aside don't crush */
381
+ .cpub-path-card { flex-direction: column; gap: 14px; padding: 16px; }
382
+ .cpub-path-aside {
383
+ flex-direction: row;
384
+ align-items: center;
385
+ justify-content: flex-start;
386
+ width: 100%;
387
+ }
388
+
389
+ /* My-path rows: put status/meta under title instead of side-by-side */
390
+ .cpub-my-path-row {
391
+ flex-direction: column;
392
+ align-items: flex-start;
393
+ gap: 6px;
394
+ padding: 12px;
395
+ }
396
+ .cpub-my-path-meta { gap: 10px; flex-wrap: wrap; }
397
+
398
+ /* Course + explainer grids (not currently rendered on this page but
399
+ safe to include in case the template adds them back) */
400
+ .cpub-course-grid { grid-template-columns: 1fr; }
401
+ .cpub-explainer-grid { grid-template-columns: 1fr; }
402
+ }
360
403
  </style>
@@ -227,4 +227,18 @@ const authorInitial = computed(() => {
227
227
  .cpub-link:hover {
228
228
  text-decoration: underline;
229
229
  }
230
+
231
+ /* MOBILE (≤ 768px) — let meta items wrap instead of overflowing, shrink
232
+ title + info padding so content gets the full viewport width. */
233
+ @media (max-width: 768px) {
234
+ .cpub-video-player { margin-bottom: 16px; }
235
+ .cpub-video-info { padding: 16px; }
236
+ .cpub-video-title { font-size: 18px; }
237
+ .cpub-video-meta {
238
+ flex-wrap: wrap;
239
+ gap: 10px;
240
+ row-gap: 6px;
241
+ }
242
+ .cpub-video-desc { font-size: 13px; margin-bottom: 16px; }
243
+ }
230
244
  </style>
@@ -324,4 +324,23 @@ function formatDate(dateStr: string): string {
324
324
  .cpub-empty-icon { font-size: 32px; color: var(--text-faint); margin-bottom: 12px; }
325
325
  .cpub-empty-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
326
326
  .cpub-empty-sub { font-size: 12px; color: var(--text-dim); }
327
+
328
+ /* MOBILE (≤ 768px) — collapse 2-track grid to single column, stack
329
+ sidebar under main content, shrink outer padding, and single-col
330
+ the thumbnail grid so cards get full viewport width. */
331
+ @media (max-width: 768px) {
332
+ .cpub-video-hero { padding: 24px 16px 18px; }
333
+ .cpub-hero-row { flex-wrap: wrap; gap: 10px; }
334
+ .cpub-hero-title { font-size: 22px; }
335
+ .cpub-hero-sub { font-size: 12px; margin-bottom: 14px; }
336
+
337
+ .cpub-filter-bar { padding: 0 16px; }
338
+
339
+ .cpub-page-wrap { padding: 20px 16px; }
340
+ .cpub-main-grid { grid-template-columns: 1fr; gap: 20px; }
341
+
342
+ .cpub-video-grid { grid-template-columns: 1fr; gap: 14px; }
343
+
344
+ .cpub-featured-title { font-size: 15px; }
345
+ }
327
346
  </style>
@@ -4,13 +4,15 @@ import { incrementViewCount } from '@commonpub/server';
4
4
  const recentViews = new Map<string, number>();
5
5
  const VIEW_COOLDOWN_MS = 5 * 60 * 1000;
6
6
 
7
- // Periodic cleanup every 2 minutes
7
+ // Periodic cleanup every 2 minutes. `.unref()` so this interval doesn't
8
+ // hold the event loop on shutdown — Nitro's graceful-exit path shouldn't
9
+ // have to wait on a view-dedup timer.
8
10
  setInterval(() => {
9
11
  const now = Date.now();
10
12
  for (const [key, ts] of recentViews) {
11
13
  if (now - ts > VIEW_COOLDOWN_MS) recentViews.delete(key);
12
14
  }
13
- }, 120_000);
15
+ }, 120_000).unref();
14
16
 
15
17
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
16
18
  const db = useDB();
@@ -3,13 +3,18 @@
3
3
  * Proxies and caches remote images for federated content.
4
4
  * Prevents slow cross-origin fetches on content cards.
5
5
  *
6
- * Security: only proxies images from known federation origins
7
- * (federated_content.origin_domain or remote_actors.instance_domain).
6
+ * Security: enforces HTTPS, blocks private/reserved hosts on the input
7
+ * URL AND on every redirect target (via safeFetchBinary), streams the
8
+ * response body with a hard size cap so a chunked-encoding upstream
9
+ * can't OOM us by withholding Content-Length.
8
10
  */
11
+ import { safeFetchBinary } from '@commonpub/server';
12
+
9
13
  export default defineEventHandler(async (event) => {
10
14
  const query = getQuery(event);
11
15
  const url = query.url as string | undefined;
12
- const width = Math.min(parseInt(String(query.w || '800'), 10), 1920);
16
+ // `w` query param is reserved for future image-resize work; not currently
17
+ // used for proxying — the upstream image is returned as-is.
13
18
 
14
19
  if (!url || typeof url !== 'string') {
15
20
  throw createError({ statusCode: 400, statusMessage: 'Missing url parameter' });
@@ -23,66 +28,24 @@ export default defineEventHandler(async (event) => {
23
28
  throw createError({ statusCode: 400, statusMessage: 'Invalid URL' });
24
29
  }
25
30
 
26
- // Only allow HTTPS image URLs
31
+ // Only allow HTTPS image URLs (defense-in-depth on top of safeFetchBinary's
32
+ // own private-URL check; safeFetchBinary allows http for content-import use,
33
+ // but image-proxy is HTTPS-only).
27
34
  if (parsed.protocol !== 'https:') {
28
35
  throw createError({ statusCode: 400, statusMessage: 'Only HTTPS URLs allowed' });
29
36
  }
30
37
 
31
- // Block localhost/private IPs (SSRF prevention)
32
- const hostname = parsed.hostname.toLowerCase();
33
- const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
34
- if (
35
- h === 'localhost' ||
36
- h === 'localhost.localdomain' ||
37
- h === 'metadata.google.internal' ||
38
- h.endsWith('.local') ||
39
- /^127\./.test(h) ||
40
- /^10\./.test(h) ||
41
- /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
42
- /^192\.168\./.test(h) ||
43
- /^169\.254\./.test(h) ||
44
- /^0\./.test(h) ||
45
- h === '::1' ||
46
- /^f[cd]/i.test(h) ||
47
- /^fe80/i.test(h)
48
- ) {
49
- throw createError({ statusCode: 403, statusMessage: 'Private addresses not allowed' });
50
- }
51
-
52
- // Fetch the remote image
53
- const controller = new AbortController();
54
- const timeout = setTimeout(() => controller.abort(), 15_000);
55
-
56
38
  try {
57
- const response = await fetch(url, {
58
- signal: controller.signal,
59
- headers: {
60
- Accept: 'image/*',
61
- 'User-Agent': 'CommonPub/1.0 (image-proxy)',
62
- },
63
- redirect: 'follow',
39
+ const { buffer, contentType } = await safeFetchBinary(url, {
40
+ accept: 'image/*',
41
+ userAgent: 'CommonPub/1.0 (image-proxy)',
42
+ timeoutMs: 15_000,
64
43
  });
65
44
 
66
- clearTimeout(timeout);
67
-
68
- if (!response.ok) {
69
- throw createError({ statusCode: 502, statusMessage: `Upstream returned ${response.status}` });
70
- }
71
-
72
- const contentType = response.headers.get('content-type') || '';
73
45
  if (!contentType.startsWith('image/')) {
74
46
  throw createError({ statusCode: 502, statusMessage: 'Not an image' });
75
47
  }
76
48
 
77
- // Limit to 10MB
78
- const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
79
- if (contentLength > 10 * 1024 * 1024) {
80
- throw createError({ statusCode: 502, statusMessage: 'Image too large' });
81
- }
82
-
83
- const buffer = Buffer.from(await response.arrayBuffer());
84
-
85
- // Set aggressive cache headers — federated images rarely change
86
49
  setResponseHeaders(event, {
87
50
  'Content-Type': contentType,
88
51
  'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400',
@@ -91,8 +54,16 @@ export default defineEventHandler(async (event) => {
91
54
 
92
55
  return buffer;
93
56
  } catch (err: unknown) {
94
- clearTimeout(timeout);
95
57
  if ((err as { statusCode?: number })?.statusCode) throw err;
58
+ const msg = err instanceof Error ? err.message : 'Failed to fetch image';
59
+ // Map known private-URL/redirect rejections to 403 so callers can distinguish
60
+ // them from upstream failures.
61
+ if (msg.includes('private or reserved') || msg.includes('Too many redirects')) {
62
+ throw createError({ statusCode: 403, statusMessage: msg });
63
+ }
64
+ if (msg === 'Response too large') {
65
+ throw createError({ statusCode: 502, statusMessage: 'Image too large' });
66
+ }
96
67
  throw createError({ statusCode: 502, statusMessage: 'Failed to fetch image' });
97
68
  }
98
69
  });
@@ -16,11 +16,37 @@ import { getUnreadCount, getUnreadMessageCount, subscribeSseEvents } from '@comm
16
16
  * the DB. The pub/sub payload is a nudge; we never trust it as the source
17
17
  * of truth.
18
18
  */
19
+
20
+ /**
21
+ * Per-user concurrent SSE connection cap. A normal browser will hold
22
+ * 1–2 connections per origin (HTTP/2 multiplexes); a script could
23
+ * trivially open hundreds, each holding a Redis subscriber + interval
24
+ * timers. Cap at 10 — generous for legitimate clients, hostile for
25
+ * abuse. The map lives at module scope and persists for the Nitro
26
+ * process lifetime.
27
+ *
28
+ * Multi-instance scale-out: each Nitro process has its own map, so
29
+ * the effective cap across N instances is `10 × N`. Acceptable; an
30
+ * attacker can't pin to one instance via L7 routing without a
31
+ * sticky-session config.
32
+ */
33
+ const MAX_CONNECTIONS_PER_USER = 10;
34
+ const userConnections = new Map<string, number>();
35
+
19
36
  export default defineEventHandler(async (event) => {
20
37
  const user = requireAuth(event);
21
38
  const userId = user.id;
22
39
  const db = useDB();
23
40
 
41
+ const current = userConnections.get(userId) ?? 0;
42
+ if (current >= MAX_CONNECTIONS_PER_USER) {
43
+ throw createError({
44
+ statusCode: 429,
45
+ statusMessage: 'Too many concurrent realtime connections',
46
+ });
47
+ }
48
+ userConnections.set(userId, current + 1);
49
+
24
50
  const encoder = new TextEncoder();
25
51
  const stream = new ReadableStream({
26
52
  async start(controller) {
@@ -39,6 +65,9 @@ export default defineEventHandler(async (event) => {
39
65
  unsubscribe = null;
40
66
  }
41
67
  try { controller.close(); } catch { /* already closed */ }
68
+ const next = (userConnections.get(userId) ?? 1) - 1;
69
+ if (next <= 0) userConnections.delete(userId);
70
+ else userConnections.set(userId, next);
42
71
  }
43
72
 
44
73
  async function sendCounts(): Promise<void> {
@@ -1,6 +1,36 @@
1
1
  // Security middleware — rate limiting + security headers + CSP
2
2
  import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
3
3
 
4
+ // Structured JSON sink for fail-open events. Emits one JSON line per event
5
+ // to stdout so Docker logs / Loki / Datadog / CloudWatch can parse without
6
+ // regex-scraping. Duplicated from packages/infra/structuredLogger.ts
7
+ // because layers/base doesn't depend on @commonpub/infra directly and the
8
+ // symbol isn't re-exported via @commonpub/server (which pins to the npm
9
+ // registry, not the workspace, in apps/reference). Keep this helper in
10
+ // sync with the one in infra if the event shape changes.
11
+ function jsonLog(component: string) {
12
+ return (message: string, meta?: Record<string, unknown>) => {
13
+ try {
14
+ const event: Record<string, unknown> = {
15
+ ts: new Date().toISOString(),
16
+ level: 'warn',
17
+ component,
18
+ message,
19
+ };
20
+ if (meta) {
21
+ for (const [k, v] of Object.entries(meta)) {
22
+ if (k === 'ts' || k === 'level' || k === 'component' || k === 'message') continue;
23
+ event[k] = v;
24
+ }
25
+ }
26
+ process.stdout.write(JSON.stringify(event) + '\n');
27
+ } catch {
28
+ // Circular meta; fall through to plain console so the event isn't lost.
29
+ console.warn(`[${component}] ${message}`, meta);
30
+ }
31
+ };
32
+ }
33
+
4
34
  // Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
5
35
  // in-process memory store. Unset env = byte-identical behavior to pre-0.6.
6
36
  // `onRedisError` is rate-limited: first event logs immediately, subsequent
@@ -8,7 +38,10 @@ import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, should
8
38
  // flood the log at real traffic.
9
39
  const store = createRateLimitStore({
10
40
  redisUrl: process.env.NUXT_REDIS_URL,
11
- onRedisError: createRedisFailOpenLogger({ scope: 'ratelimit:ip' }),
41
+ onRedisError: createRedisFailOpenLogger({
42
+ scope: 'ratelimit:ip',
43
+ sink: jsonLog('ratelimit-ip'),
44
+ }),
12
45
  });
13
46
  const isDev = process.env.NODE_ENV !== 'production';
14
47