@commonpub/layer 0.21.1 → 0.21.3
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/blocks/BlockBuildStepView.vue +7 -1
- package/components/views/ProjectView.vue +4 -1
- package/layouts/default.vue +6 -0
- package/package.json +6 -6
- package/server/api/files/upload-from-url.post.ts +20 -46
- package/server/middleware/auth.ts +1 -0
- package/server/plugins/auto-admin.ts +33 -10
|
@@ -44,7 +44,13 @@ const hasChildren = computed(() => children.value.length > 0);
|
|
|
44
44
|
<span v-if="time" class="cpub-step-time"><i class="fa-regular fa-clock"></i> {{ time }}</span>
|
|
45
45
|
</div>
|
|
46
46
|
<div v-if="hasChildren" class="cpub-step-body">
|
|
47
|
-
|
|
47
|
+
<!-- Nuxt pathPrefix auto-imports components/blocks/BlockContentRenderer.vue
|
|
48
|
+
under the name BlocksBlockContentRenderer (the Blocks dir prefix only
|
|
49
|
+
de-dups when the filename starts with it). The bare tag would not
|
|
50
|
+
resolve and would silently blank nested step content. A static import
|
|
51
|
+
is not an option here: BlockContentRenderer and BlockBuildStepView are
|
|
52
|
+
mutually recursive (ESM cycle). Use the auto-import name. -->
|
|
53
|
+
<BlocksBlockContentRenderer :blocks="children" />
|
|
48
54
|
</div>
|
|
49
55
|
</div>
|
|
50
56
|
</template>
|
|
@@ -509,7 +509,10 @@ async function handleBuild(): Promise<void> {
|
|
|
509
509
|
<span v-if="step.time" class="cpub-build-step-time"><i class="fa-regular fa-clock"></i> {{ step.time }}</span>
|
|
510
510
|
</div>
|
|
511
511
|
<div v-if="step.children.length > 0" class="cpub-build-step-body">
|
|
512
|
-
|
|
512
|
+
<!-- Auto-import name (same as the body renderer above); the
|
|
513
|
+
bare BlockContentRenderer tag does not resolve under
|
|
514
|
+
Nuxt pathPrefix and silently blanks nested content. -->
|
|
515
|
+
<BlocksBlockContentRenderer :blocks="step.children" />
|
|
513
516
|
</div>
|
|
514
517
|
</div>
|
|
515
518
|
</div>
|
package/layouts/default.vue
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
// Explicit import: Nuxt's pathPrefix auto-import names this component
|
|
4
|
+
// `<NavMobileNavRenderer>` (the `nav/` dir prefix only de-duplicates when
|
|
5
|
+
// the filename starts with `Nav`, which `MobileNavRenderer` does not).
|
|
6
|
+
// Referencing `<MobileNavRenderer>` below would otherwise silently fail to
|
|
7
|
+
// resolve, leaving the mobile hamburger menu empty.
|
|
8
|
+
import MobileNavRenderer from '../components/nav/MobileNavRenderer.vue';
|
|
3
9
|
|
|
4
10
|
const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
|
|
5
11
|
const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -51,15 +51,15 @@
|
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
53
|
"@commonpub/auth": "0.6.0",
|
|
54
|
+
"@commonpub/docs": "0.6.3",
|
|
54
55
|
"@commonpub/learning": "0.5.2",
|
|
55
|
-
"@commonpub/docs": "0.6.2",
|
|
56
|
-
"@commonpub/config": "0.12.0",
|
|
57
56
|
"@commonpub/editor": "0.7.9",
|
|
58
|
-
"@commonpub/protocol": "0.9.9",
|
|
59
|
-
"@commonpub/schema": "0.16.0",
|
|
60
57
|
"@commonpub/explainer": "0.7.12",
|
|
58
|
+
"@commonpub/schema": "0.16.0",
|
|
61
59
|
"@commonpub/ui": "0.8.5",
|
|
62
|
-
"@commonpub/
|
|
60
|
+
"@commonpub/protocol": "0.9.10",
|
|
61
|
+
"@commonpub/server": "2.53.0",
|
|
62
|
+
"@commonpub/config": "0.12.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { createStorageFromEnv, generateStorageKey,
|
|
2
|
+
import { createStorageFromEnv, generateStorageKey, ALLOWED_IMAGE_TYPES, safeFetchBinary } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
const schema = z.object({
|
|
5
5
|
url: z.string().url(),
|
|
@@ -7,62 +7,36 @@ const schema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
export default defineEventHandler(async (event) => {
|
|
10
|
-
|
|
10
|
+
requireAuth(event);
|
|
11
11
|
const { url, purpose } = await parseBody(event, schema);
|
|
12
12
|
|
|
13
|
-
// SSRF
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
h === 'localhost.localdomain' ||
|
|
20
|
-
h === 'metadata.google.internal' ||
|
|
21
|
-
h.endsWith('.local') ||
|
|
22
|
-
/^127\./.test(h) ||
|
|
23
|
-
/^10\./.test(h) ||
|
|
24
|
-
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
25
|
-
/^192\.168\./.test(h) ||
|
|
26
|
-
/^169\.254\./.test(h) ||
|
|
27
|
-
/^0\./.test(h) ||
|
|
28
|
-
h === '::1' ||
|
|
29
|
-
/^f[cd]/i.test(h) ||
|
|
30
|
-
/^fe80/i.test(h)
|
|
31
|
-
) {
|
|
32
|
-
throw createError({ statusCode: 400, statusMessage: 'Cannot fetch from private/local addresses' });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Download the remote image
|
|
36
|
-
const controller = new AbortController();
|
|
37
|
-
const timeout = setTimeout(() => controller.abort(), 10_000); // 10s timeout
|
|
38
|
-
|
|
39
|
-
let response: Response;
|
|
13
|
+
// SSRF-safe fetch: blocks private/reserved/numeric hosts and non-HTTP(S)
|
|
14
|
+
// schemes, re-validates every redirect hop, and enforces a 10 MB streaming
|
|
15
|
+
// cap + deadline. Replaces a hand-rolled denylist that missed redirect
|
|
16
|
+
// re-validation, IPv4-mapped IPv6, and numeric-IP encodings.
|
|
17
|
+
let buffer: Buffer;
|
|
18
|
+
let contentType: string;
|
|
40
19
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
20
|
+
({ buffer, contentType } = await safeFetchBinary(url, {
|
|
21
|
+
accept: 'image/*',
|
|
22
|
+
userAgent: 'CommonPub/1.0 (image-upload)',
|
|
23
|
+
timeoutMs: 10_000,
|
|
24
|
+
}));
|
|
45
25
|
} catch (err) {
|
|
26
|
+
const msg = err instanceof Error ? err.message : '';
|
|
27
|
+
if (msg.includes('private or reserved')) {
|
|
28
|
+
throw createError({ statusCode: 400, statusMessage: 'Cannot fetch from private/local addresses' });
|
|
29
|
+
}
|
|
30
|
+
if (msg === 'Response too large') {
|
|
31
|
+
throw createError({ statusCode: 400, statusMessage: 'Image too large (max 10MB)' });
|
|
32
|
+
}
|
|
46
33
|
throw createError({ statusCode: 400, statusMessage: 'Failed to fetch remote image' });
|
|
47
|
-
} finally {
|
|
48
|
-
clearTimeout(timeout);
|
|
49
34
|
}
|
|
50
35
|
|
|
51
|
-
if (!response.ok) {
|
|
52
|
-
throw createError({ statusCode: 400, statusMessage: `Remote server returned ${response.status}` });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const contentType = response.headers.get('content-type') || '';
|
|
56
36
|
if (![...ALLOWED_IMAGE_TYPES].some((t: string) => contentType.startsWith(t))) {
|
|
57
37
|
throw createError({ statusCode: 400, statusMessage: `Unsupported image type: ${contentType}` });
|
|
58
38
|
}
|
|
59
39
|
|
|
60
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
61
|
-
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
62
|
-
if (buffer.length > maxSize) {
|
|
63
|
-
throw createError({ statusCode: 400, statusMessage: 'Image too large (max 10MB)' });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
40
|
// Upload to storage
|
|
67
41
|
const storage = createStorageFromEnv();
|
|
68
42
|
const ext = contentType.split('/')[1] || 'jpg';
|
|
@@ -113,6 +113,7 @@ export default defineEventHandler(async (event) => {
|
|
|
113
113
|
// Handle auth API routes — skip custom routes that Nitro handles directly
|
|
114
114
|
const isCustomAuthRoute = pathname.startsWith('/api/auth/federated/')
|
|
115
115
|
|| pathname.startsWith('/api/auth/oauth2/')
|
|
116
|
+
|| pathname.startsWith('/api/auth/mastodon/')
|
|
116
117
|
|| pathname === '/api/auth/sign-in-username'
|
|
117
118
|
|| pathname === '/api/auth/delete-user'
|
|
118
119
|
|| pathname === '/api/auth/export-data';
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Admin bootstrap plugin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* In production: promotes ADMIN_BOOTSTRAP_USER (env var) if no admin exists.
|
|
4
|
+
* Runs once when no admin exists yet:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - Development (NODE_ENV !== 'production'): promotes the first
|
|
7
|
+
* registered user to admin. Zero config.
|
|
8
|
+
* - Production, ADMIN_BOOTSTRAP_USER set: promotes that username.
|
|
9
|
+
* The canonical "set the admin directly" path.
|
|
10
|
+
* - Production, ADMIN_BOOTSTRAP_FIRST_USER truthy (1/true/yes):
|
|
11
|
+
* promotes the first registered user — the frictionless
|
|
12
|
+
* one-click-deploy path (deploy → register → you're admin).
|
|
13
|
+
* - Production, neither set: does nothing (safe default — a public
|
|
14
|
+
* instance shouldn't hand admin to a random first signup unless
|
|
15
|
+
* the operator explicitly opted in).
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: the whole block early-returns once any admin exists, so
|
|
18
|
+
* this is safe to leave enabled forever and ships harmlessly to
|
|
19
|
+
* instances that already have admins (no behavior change there).
|
|
10
20
|
*/
|
|
11
21
|
import { users } from '@commonpub/schema';
|
|
12
22
|
import { eq, asc, count } from 'drizzle-orm';
|
|
13
23
|
|
|
24
|
+
/** Truthy env check — accepts 1/true/yes (case-insensitive). */
|
|
25
|
+
function envTruthy(value: string | undefined): boolean {
|
|
26
|
+
return /^(1|true|yes)$/i.test(value ?? '');
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export default defineNitroPlugin((nitro) => {
|
|
15
30
|
// Run after a short delay so the DB pool is ready
|
|
16
31
|
setTimeout(async () => {
|
|
@@ -27,14 +42,21 @@ export default defineNitroPlugin((nitro) => {
|
|
|
27
42
|
|
|
28
43
|
// No admins exist — bootstrap one
|
|
29
44
|
|
|
30
|
-
// Production: promote specific user from env var
|
|
31
45
|
const bootstrapUsername = process.env.ADMIN_BOOTSTRAP_USER;
|
|
32
|
-
|
|
46
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
47
|
+
// First-user promotion: default in dev, opt-in in prod via
|
|
48
|
+
// ADMIN_BOOTSTRAP_FIRST_USER (the one-click-deploy path).
|
|
49
|
+
const allowFirstUser = !isProd || envTruthy(process.env.ADMIN_BOOTSTRAP_FIRST_USER);
|
|
50
|
+
|
|
51
|
+
// In production, do nothing unless the operator either named a
|
|
52
|
+
// user OR explicitly opted into first-user promotion. Preserves
|
|
53
|
+
// the original safe default for instances that set neither.
|
|
54
|
+
if (isProd && !bootstrapUsername && !allowFirstUser) return;
|
|
33
55
|
|
|
34
56
|
let targetUser: { id: string; username: string } | undefined;
|
|
35
57
|
|
|
36
58
|
if (bootstrapUsername) {
|
|
37
|
-
// Promote the specified user
|
|
59
|
+
// Promote the specified user (canonical "set admin directly").
|
|
38
60
|
const [found] = await db
|
|
39
61
|
.select({ id: users.id, username: users.username })
|
|
40
62
|
.from(users)
|
|
@@ -44,8 +66,9 @@ export default defineNitroPlugin((nitro) => {
|
|
|
44
66
|
if (!targetUser) {
|
|
45
67
|
console.warn(`[auto-admin] ADMIN_BOOTSTRAP_USER="${bootstrapUsername}" not found`);
|
|
46
68
|
}
|
|
47
|
-
} else {
|
|
48
|
-
//
|
|
69
|
+
} else if (allowFirstUser) {
|
|
70
|
+
// Promote the first registered user (dev always; prod only
|
|
71
|
+
// when ADMIN_BOOTSTRAP_FIRST_USER opted in).
|
|
49
72
|
const [firstUser] = await db
|
|
50
73
|
.select({ id: users.id, username: users.username })
|
|
51
74
|
.from(users)
|