@meith/web 0.29.0 → 0.29.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/app/api/v1/[...path]/route.ts +19 -12
- package/package.json +48 -48
- package/src/server/api/body.ts +79 -0
- package/src/server/same-origin.ts +29 -6
- package/src/server/upgrade-notice.ts +1 -1
|
@@ -16,6 +16,7 @@ import type { Actor } from '@meith/authorization'
|
|
|
16
16
|
import { isAppError, metrics, statusForError, toPublicError, withSpan } from '@meith/core'
|
|
17
17
|
import { currentRequestId } from '@meith/core/logger'
|
|
18
18
|
|
|
19
|
+
import { readJsonBody } from '@/server/api/body'
|
|
19
20
|
import { JSON_MEDIA_TYPE } from '@/server/api/http'
|
|
20
21
|
import { handlerFor } from '@/server/api/registry'
|
|
21
22
|
import {
|
|
@@ -161,13 +162,30 @@ async function respondTo(
|
|
|
161
162
|
)
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
let body: Record<string, unknown> | null = null
|
|
166
|
+
if (matched.route.request !== undefined) {
|
|
167
|
+
const outcome = await readJsonBody(request)
|
|
168
|
+
if (outcome.kind === 'too-large') {
|
|
169
|
+
return fail(
|
|
170
|
+
413,
|
|
171
|
+
'payload_too_large',
|
|
172
|
+
'The request body is larger than the API allows.',
|
|
173
|
+
headers,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
if (outcome.kind === 'invalid') {
|
|
177
|
+
return fail(400, 'invalid_body', 'The request body is not valid JSON.', headers)
|
|
178
|
+
}
|
|
179
|
+
body = outcome.body
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
try {
|
|
165
183
|
const result = await handler({
|
|
166
184
|
actor: caller.actor,
|
|
167
185
|
token: caller.authenticated?.token ?? null,
|
|
168
186
|
params: matched.params,
|
|
169
187
|
url,
|
|
170
|
-
body
|
|
188
|
+
body,
|
|
171
189
|
})
|
|
172
190
|
|
|
173
191
|
return json(result.body, result.status, headers)
|
|
@@ -180,17 +198,6 @@ async function respondTo(
|
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
|
|
183
|
-
async function readJsonBody(request: NextRequest): Promise<Record<string, unknown> | null> {
|
|
184
|
-
try {
|
|
185
|
-
const parsed: unknown = await request.json()
|
|
186
|
-
return typeof parsed === 'object' && parsed !== null
|
|
187
|
-
? (parsed as Record<string, unknown>)
|
|
188
|
-
: null
|
|
189
|
-
} catch {
|
|
190
|
-
return null
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
201
|
export async function GET(request: NextRequest): Promise<Response> {
|
|
195
202
|
return handle(request, 'GET')
|
|
196
203
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,53 +43,53 @@
|
|
|
43
43
|
"react-dom": "19.2.8",
|
|
44
44
|
"tailwindcss": "^4.3.3",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"@meith/accounts": "0.29.
|
|
47
|
-
"@meith/admin": "0.29.
|
|
48
|
-
"@meith/antispam": "0.29.
|
|
49
|
-
"@meith/api": "0.29.
|
|
50
|
-
"@meith/attachments": "0.29.
|
|
51
|
-
"@meith/authorization": "0.29.
|
|
52
|
-
"@meith/avatars": "0.29.
|
|
53
|
-
"@meith/core": "0.29.
|
|
54
|
-
"@meith/db": "0.29.
|
|
55
|
-
"@meith/demo": "0.29.
|
|
56
|
-
"@meith/drafts": "0.29.
|
|
57
|
-
"@meith/drivers": "0.29.
|
|
58
|
-
"@meith/events": "0.29.
|
|
59
|
-
"@meith/forums": "0.29.
|
|
60
|
-
"@meith/groups": "0.29.
|
|
61
|
-
"@meith/i18n": "0.29.
|
|
62
|
-
"@meith/import": "0.29.
|
|
63
|
-
"@meith/install": "0.29.
|
|
64
|
-
"@meith/mail": "0.29.
|
|
65
|
-
"@meith/markdown": "0.29.
|
|
66
|
-
"@meith/marketplace": "0.29.
|
|
67
|
-
"@meith/messages": "0.29.
|
|
68
|
-
"@meith/moderation": "0.29.
|
|
69
|
-
"@meith/notifications": "0.29.
|
|
70
|
-
"@meith/plugin-calendar": "0.29.
|
|
71
|
-
"@meith/plugin-dues": "0.29.
|
|
72
|
-
"@meith/plugin-kit": "0.29.
|
|
73
|
-
"@meith/polls": "0.29.
|
|
74
|
-
"@meith/posts": "0.29.
|
|
75
|
-
"@meith/profile-fields": "0.29.
|
|
76
|
-
"@meith/relations": "0.29.
|
|
77
|
-
"@meith/reputation": "0.29.
|
|
78
|
-
"@meith/runtime": "0.29.
|
|
79
|
-
"@meith/search": "0.29.
|
|
80
|
-
"@meith/settings": "0.29.
|
|
81
|
-
"@meith/signatures": "0.29.
|
|
82
|
-
"@meith/subscriptions": "0.29.
|
|
83
|
-
"@meith/tasks": "0.29.
|
|
84
|
-
"@meith/theme-clubhouse": "0.29.
|
|
85
|
-
"@meith/theme-default": "0.29.
|
|
86
|
-
"@meith/theme-kit": "0.29.
|
|
87
|
-
"@meith/theme-midnight": "0.29.
|
|
88
|
-
"@meith/theme-phasebook": "0.29.
|
|
89
|
-
"@meith/theme-raidframe": "0.29.
|
|
90
|
-
"@meith/threads": "0.29.
|
|
91
|
-
"@meith/ui": "0.29.
|
|
92
|
-
"@meith/upgrade": "0.29.
|
|
46
|
+
"@meith/accounts": "0.29.1",
|
|
47
|
+
"@meith/admin": "0.29.1",
|
|
48
|
+
"@meith/antispam": "0.29.1",
|
|
49
|
+
"@meith/api": "0.29.1",
|
|
50
|
+
"@meith/attachments": "0.29.1",
|
|
51
|
+
"@meith/authorization": "0.29.1",
|
|
52
|
+
"@meith/avatars": "0.29.1",
|
|
53
|
+
"@meith/core": "0.29.1",
|
|
54
|
+
"@meith/db": "0.29.1",
|
|
55
|
+
"@meith/demo": "0.29.1",
|
|
56
|
+
"@meith/drafts": "0.29.1",
|
|
57
|
+
"@meith/drivers": "0.29.1",
|
|
58
|
+
"@meith/events": "0.29.1",
|
|
59
|
+
"@meith/forums": "0.29.1",
|
|
60
|
+
"@meith/groups": "0.29.1",
|
|
61
|
+
"@meith/i18n": "0.29.1",
|
|
62
|
+
"@meith/import": "0.29.1",
|
|
63
|
+
"@meith/install": "0.29.1",
|
|
64
|
+
"@meith/mail": "0.29.1",
|
|
65
|
+
"@meith/markdown": "0.29.1",
|
|
66
|
+
"@meith/marketplace": "0.29.1",
|
|
67
|
+
"@meith/messages": "0.29.1",
|
|
68
|
+
"@meith/moderation": "0.29.1",
|
|
69
|
+
"@meith/notifications": "0.29.1",
|
|
70
|
+
"@meith/plugin-calendar": "0.29.1",
|
|
71
|
+
"@meith/plugin-dues": "0.29.1",
|
|
72
|
+
"@meith/plugin-kit": "0.29.1",
|
|
73
|
+
"@meith/polls": "0.29.1",
|
|
74
|
+
"@meith/posts": "0.29.1",
|
|
75
|
+
"@meith/profile-fields": "0.29.1",
|
|
76
|
+
"@meith/relations": "0.29.1",
|
|
77
|
+
"@meith/reputation": "0.29.1",
|
|
78
|
+
"@meith/runtime": "0.29.1",
|
|
79
|
+
"@meith/search": "0.29.1",
|
|
80
|
+
"@meith/settings": "0.29.1",
|
|
81
|
+
"@meith/signatures": "0.29.1",
|
|
82
|
+
"@meith/subscriptions": "0.29.1",
|
|
83
|
+
"@meith/tasks": "0.29.1",
|
|
84
|
+
"@meith/theme-clubhouse": "0.29.1",
|
|
85
|
+
"@meith/theme-default": "0.29.1",
|
|
86
|
+
"@meith/theme-kit": "0.29.1",
|
|
87
|
+
"@meith/theme-midnight": "0.29.1",
|
|
88
|
+
"@meith/theme-phasebook": "0.29.1",
|
|
89
|
+
"@meith/theme-raidframe": "0.29.1",
|
|
90
|
+
"@meith/threads": "0.29.1",
|
|
91
|
+
"@meith/ui": "0.29.1",
|
|
92
|
+
"@meith/upgrade": "0.29.1"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
export const MAX_API_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
export type JsonBodyOutcome =
|
|
6
|
+
| { readonly kind: 'ok'; readonly body: Record<string, unknown> | null }
|
|
7
|
+
| { readonly kind: 'too-large' }
|
|
8
|
+
| { readonly kind: 'invalid' }
|
|
9
|
+
|
|
10
|
+
async function readBounded(
|
|
11
|
+
request: Request,
|
|
12
|
+
limit: number,
|
|
13
|
+
): Promise<Uint8Array | 'too-large' | 'unreadable'> {
|
|
14
|
+
const stream = request.body
|
|
15
|
+
if (stream === null) {
|
|
16
|
+
try {
|
|
17
|
+
const whole = new Uint8Array(await request.arrayBuffer())
|
|
18
|
+
return whole.byteLength > limit ? 'too-large' : whole
|
|
19
|
+
} catch {
|
|
20
|
+
return 'unreadable'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reader = stream.getReader()
|
|
25
|
+
const chunks: Uint8Array[] = []
|
|
26
|
+
let total = 0
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
let step = await reader.read()
|
|
30
|
+
while (!step.done) {
|
|
31
|
+
const chunk = step.value
|
|
32
|
+
if (chunk !== undefined) {
|
|
33
|
+
total += chunk.byteLength
|
|
34
|
+
if (total > limit) {
|
|
35
|
+
await reader.cancel().catch(() => {})
|
|
36
|
+
return 'too-large'
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk)
|
|
39
|
+
}
|
|
40
|
+
step = await reader.read()
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return 'unreadable'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body = new Uint8Array(total)
|
|
47
|
+
let offset = 0
|
|
48
|
+
for (const chunk of chunks) {
|
|
49
|
+
body.set(chunk, offset)
|
|
50
|
+
offset += chunk.byteLength
|
|
51
|
+
}
|
|
52
|
+
return body
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readJsonBody(
|
|
56
|
+
request: Request,
|
|
57
|
+
limit: number = MAX_API_BODY_BYTES,
|
|
58
|
+
): Promise<JsonBodyOutcome> {
|
|
59
|
+
const declared = Number(request.headers.get('content-length') ?? '')
|
|
60
|
+
if (Number.isFinite(declared) && declared > limit) return { kind: 'too-large' }
|
|
61
|
+
|
|
62
|
+
const bytes = await readBounded(request, limit)
|
|
63
|
+
if (bytes === 'too-large') return { kind: 'too-large' }
|
|
64
|
+
if (bytes === 'unreadable') return { kind: 'invalid' }
|
|
65
|
+
if (bytes.byteLength === 0) return { kind: 'ok', body: null }
|
|
66
|
+
|
|
67
|
+
let parsed: unknown
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes))
|
|
70
|
+
} catch {
|
|
71
|
+
return { kind: 'invalid' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
kind: 'ok',
|
|
76
|
+
body:
|
|
77
|
+
typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { env } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
function canonicalOrigin(): URL | null {
|
|
6
|
+
const configured = env.APP_URL
|
|
7
|
+
if (configured === undefined || configured === '') return null
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
return new URL(configured)
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requestHost(request: Request): string | null {
|
|
17
|
+
const forwarded = env.TRUSTED_PROXY_HOPS > 0 ? request.headers.get('x-forwarded-host') : null
|
|
18
|
+
const host = forwarded ?? request.headers.get('host')
|
|
5
19
|
if (host === null) return null
|
|
6
20
|
|
|
7
21
|
const trimmed = host.trim().toLowerCase()
|
|
@@ -12,14 +26,23 @@ export function isSameOrigin(request: Request): boolean {
|
|
|
12
26
|
const origin = request.headers.get('origin')?.trim()
|
|
13
27
|
|
|
14
28
|
if (origin !== undefined && origin !== '' && origin !== 'null') {
|
|
15
|
-
|
|
16
|
-
if (host === null) return false
|
|
17
|
-
|
|
29
|
+
let presented: URL
|
|
18
30
|
try {
|
|
19
|
-
|
|
31
|
+
presented = new URL(origin)
|
|
20
32
|
} catch {
|
|
21
33
|
return false
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
const canonical = canonicalOrigin()
|
|
37
|
+
if (canonical !== null) {
|
|
38
|
+
return (
|
|
39
|
+
presented.host.toLowerCase() === canonical.host.toLowerCase() &&
|
|
40
|
+
presented.protocol === canonical.protocol
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const host = requestHost(request)
|
|
45
|
+
return host !== null && presented.host.toLowerCase() === host
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const site = request.headers.get('sec-fetch-site')
|
|
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
16
16
|
|
|
17
17
|
import { activeDefinitions } from './plugin-host'
|
|
18
18
|
|
|
19
|
-
export const CODE_VERSION = '0.29.
|
|
19
|
+
export const CODE_VERSION = '0.29.1'
|
|
20
20
|
|
|
21
21
|
export interface UpgradeApplied {
|
|
22
22
|
readonly plugins: readonly string[]
|