@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.
- package/components/ContentPicker.vue +4 -1
- package/components/ImportUrlModal.vue +4 -6
- package/components/PublishErrorsModal.vue +5 -2
- package/components/RemoteFollowDialog.vue +4 -1
- package/composables/useAuth.ts +13 -4
- package/composables/useFocusTrap.ts +90 -0
- package/layouts/default.vue +26 -0
- package/nuxt.config.ts +6 -0
- package/package.json +7 -7
- package/pages/admin/api-keys.vue +15 -0
- package/pages/admin/federation.vue +18 -0
- package/pages/federation/users/[handle].vue +34 -0
- package/pages/learn/index.vue +43 -0
- package/pages/videos/[id].vue +14 -0
- package/pages/videos/index.vue +19 -0
- package/server/api/content/[id]/view.post.ts +4 -2
- package/server/api/image-proxy.get.ts +24 -53
- package/server/api/realtime/stream.get.ts +29 -0
- package/server/middleware/security.ts +34 -1
|
@@ -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"
|
|
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
|
-
|
|
81
|
-
|
|
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"
|
|
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">
|
package/composables/useAuth.ts
CHANGED
|
@@ -25,7 +25,12 @@ interface AuthResponse {
|
|
|
25
25
|
session: ClientAuthSession | null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
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 (
|
|
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
|
+
}
|
package/layouts/default.vue
CHANGED
|
@@ -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.
|
|
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/
|
|
62
|
+
"@commonpub/editor": "0.7.9"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/admin/api-keys.vue
CHANGED
|
@@ -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>
|
package/pages/learn/index.vue
CHANGED
|
@@ -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>
|
package/pages/videos/[id].vue
CHANGED
|
@@ -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>
|
package/pages/videos/index.vue
CHANGED
|
@@ -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:
|
|
7
|
-
* (
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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({
|
|
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
|
|