@commonpub/layer 0.18.3 → 0.19.1
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/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/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
|
@@ -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">
|
|
@@ -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-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g',
|
|
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.1",
|
|
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",
|
|
@@ -53,13 +50,16 @@
|
|
|
53
50
|
"vue": "^3.4.0",
|
|
54
51
|
"vue-router": "^4.3.0",
|
|
55
52
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/auth": "0.5.1",
|
|
57
|
-
"@commonpub/config": "0.11.0",
|
|
58
53
|
"@commonpub/docs": "0.6.2",
|
|
54
|
+
"@commonpub/config": "0.11.0",
|
|
59
55
|
"@commonpub/editor": "0.7.9",
|
|
56
|
+
"@commonpub/explainer": "0.7.12",
|
|
60
57
|
"@commonpub/learning": "0.5.2",
|
|
58
|
+
"@commonpub/schema": "0.15.0",
|
|
59
|
+
"@commonpub/server": "2.48.0",
|
|
60
|
+
"@commonpub/protocol": "0.9.9",
|
|
61
61
|
"@commonpub/ui": "0.8.5",
|
|
62
|
-
"@commonpub/
|
|
62
|
+
"@commonpub/auth": "0.5.1"
|
|
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>
|
|
@@ -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> {
|