@crombolo/server 0.0.0-stage → 0.10.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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Where uploaded files live.
3
+ *
4
+ * Umbraco abstracts this as `IFileSystem` with a `MediaFileManager` on top, so a
5
+ * site can move media to blob storage without touching anything that uses it.
6
+ * This is the same seam, narrowed to what a media library actually needs: get,
7
+ * put, delete, and list a prefix. Nothing above this interface knows whether the
8
+ * bytes are on a disk, in a bucket or in a container.
9
+ *
10
+ * The key space is the layout the file system store has always used, so an
11
+ * existing media folder keeps working unchanged:
12
+ *
13
+ * `ab12cd34/photo.jpg` a placed file, served at `/media/ab12cd34/photo.jpg`
14
+ * `.temp/<uuid>/photo.jpg` an upload not yet placed, with `meta.json` beside it
15
+ * `.cache/ab/<hash>.webp` a processed image variant
16
+ *
17
+ * A site picks a store in `crombolo.config.ts` — `fileSystemMediaStore`,
18
+ * `s3MediaStore` or `azureMediaStore` — or leaves it to the environment, which
19
+ * is what a container deployment wants.
20
+ */
21
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
22
+ import { dirname, join, resolve, sep } from 'node:path'
23
+
24
+ export interface StoredFile {
25
+ key: string
26
+ size: number
27
+ /**
28
+ * Changes whenever the contents change. Variant cache keys are built from it,
29
+ * so a replaced original invalidates its crops without anyone tracking that.
30
+ */
31
+ etag: string
32
+ contentType: string
33
+ bytes(): Promise<Uint8Array>
34
+ /** A path on this machine, when the store has one; lets the server stream it. */
35
+ localPath?: string
36
+ }
37
+
38
+ export interface MediaStore {
39
+ /** Named in the boot banner and in logs: `media/`, `s3://bucket`, … */
40
+ readonly description: string
41
+ get(key: string): Promise<StoredFile | undefined>
42
+ put(key: string, body: Uint8Array | File, options?: { contentType?: string }): Promise<void>
43
+ delete(key: string): Promise<void>
44
+ /** Every key under a prefix, which is how temporary uploads are swept. */
45
+ list(prefix: string): Promise<string[]>
46
+ /**
47
+ * A URL the browser should fetch instead of coming through this server — a CDN
48
+ * origin, or a presigned link. Undefined means this server serves the bytes,
49
+ * which is what the file system store always does.
50
+ */
51
+ publicUrl?(key: string): string | undefined
52
+ }
53
+
54
+ /** Content types by extension, for stores that do not infer one. */
55
+ const CONTENT_TYPES: Record<string, string> = {
56
+ avif: 'image/avif',
57
+ css: 'text/css',
58
+ gif: 'image/gif',
59
+ ico: 'image/x-icon',
60
+ jpeg: 'image/jpeg',
61
+ jpg: 'image/jpeg',
62
+ js: 'text/javascript',
63
+ json: 'application/json',
64
+ mp3: 'audio/mpeg',
65
+ mp4: 'video/mp4',
66
+ pdf: 'application/pdf',
67
+ png: 'image/png',
68
+ svg: 'image/svg+xml',
69
+ txt: 'text/plain',
70
+ webm: 'video/webm',
71
+ webp: 'image/webp',
72
+ woff2: 'font/woff2',
73
+ zip: 'application/zip',
74
+ }
75
+
76
+ export function contentTypeFor(key: string): string {
77
+ const extension = key.split('.').pop()?.toLowerCase() ?? ''
78
+ return CONTENT_TYPES[extension] ?? 'application/octet-stream'
79
+ }
80
+
81
+ /**
82
+ * A key is `<segment>/<segment>`: no `..`, no leading dot except the two the
83
+ * layout above reserves, and no backslashes. Checked in one place so every store
84
+ * is safe, not only the one that touches a file system.
85
+ */
86
+ export function isSafeMediaKey(key: string): boolean {
87
+ if (key === '' || key.includes('\\') || key.includes('//')) return false
88
+ const parts = key.split('/')
89
+ return parts.every(
90
+ (part, index) =>
91
+ part !== '' &&
92
+ part !== '.' &&
93
+ part !== '..' &&
94
+ (!part.startsWith('.') || (index === 0 && (part === '.temp' || part === '.cache'))),
95
+ )
96
+ }
97
+
98
+ // ------------------------------------------------------------- file system
99
+
100
+ export interface FileSystemMediaStoreOptions {
101
+ /**
102
+ * A URL prefix that serves the media root directly — a CDN in front of the
103
+ * same disk, or nginx. Without one the server streams every file itself.
104
+ */
105
+ publicUrlPrefix?: string
106
+ }
107
+
108
+ /**
109
+ * The default: files under a directory, which is where Umbraco puts them too.
110
+ * Deleting the last file in a folder deletes the folder, so a media root does not
111
+ * silt up with empty directories.
112
+ */
113
+ export function fileSystemMediaStore(
114
+ root: string,
115
+ options: FileSystemMediaStoreOptions = {},
116
+ ): MediaStore {
117
+ const base = resolve(root)
118
+ const prefix = options.publicUrlPrefix?.replace(/\/$/, '')
119
+
120
+ const pathOf = (key: string): string | undefined => {
121
+ if (!isSafeMediaKey(key)) return undefined
122
+ const path = resolve(base, key)
123
+ return path === base || path.startsWith(base + sep) ? path : undefined
124
+ }
125
+
126
+ return {
127
+ description: base,
128
+
129
+ async get(key) {
130
+ const path = pathOf(key)
131
+ if (!path || !existsSync(path)) return undefined
132
+ const stat = statSync(path)
133
+ if (!stat.isFile()) return undefined
134
+ const file = Bun.file(path)
135
+ return {
136
+ key,
137
+ size: stat.size,
138
+ // Size and mtime are what a web server would use, and they are cheap.
139
+ etag: `${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}`,
140
+ contentType: file.type || contentTypeFor(key),
141
+ bytes: () => file.bytes(),
142
+ localPath: path,
143
+ }
144
+ },
145
+
146
+ async put(key, body) {
147
+ const path = pathOf(key)
148
+ if (!path) throw new Error(`"${key}" is not a usable media key.`)
149
+ mkdirSync(dirname(path), { recursive: true })
150
+ await Bun.write(path, body)
151
+ },
152
+
153
+ async delete(key) {
154
+ const path = pathOf(key)
155
+ if (!path || !existsSync(path)) return
156
+ rmSync(path, { recursive: true, force: true })
157
+ const folder = dirname(path)
158
+ if (folder !== base && existsSync(folder) && readdirSync(folder).length === 0)
159
+ rmSync(folder, { recursive: true, force: true })
160
+ },
161
+
162
+ async list(keyPrefix) {
163
+ // A prefix names a folder as often as a key, so `.temp/<id>/` and
164
+ // `.temp/<id>` mean the same thing here.
165
+ const trimmed = keyPrefix.replace(/\/+$/, '')
166
+ const start = trimmed === '' ? base : pathOf(trimmed)
167
+ if (!start || !existsSync(start)) return []
168
+ const found: string[] = []
169
+ const walk = (dir: string) => {
170
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
171
+ const path = join(dir, entry.name)
172
+ if (entry.isDirectory()) walk(path)
173
+ else
174
+ found.push(
175
+ path
176
+ .slice(base.length + 1)
177
+ .split(sep)
178
+ .join('/'),
179
+ )
180
+ }
181
+ }
182
+ if (statSync(start).isDirectory()) walk(start)
183
+ else found.push(trimmed)
184
+ return found
185
+ },
186
+
187
+ publicUrl: prefix ? (key) => `${prefix}/${key}` : undefined,
188
+ }
189
+ }
190
+
191
+ // -------------------------------------------------------------- from the environment
192
+
193
+ export type MediaStoreKind = 'filesystem' | 's3' | 'azure'
194
+
195
+ /**
196
+ * The store the environment asks for. `CROMBOLO_MEDIA_STORE` picks the kind;
197
+ * each kind reads its own variables, and an unset one falls back to the
198
+ * credentials its SDK would find anyway (an instance role, for S3).
199
+ *
200
+ * CROMBOLO_MEDIA_STORE=s3
201
+ * CROMBOLO_MEDIA_S3_BUCKET=my-site-media
202
+ * CROMBOLO_MEDIA_S3_REGION=ap-southeast-2
203
+ *
204
+ * CROMBOLO_MEDIA_STORE=azure
205
+ * CROMBOLO_MEDIA_AZURE_ACCOUNT=mysite
206
+ * CROMBOLO_MEDIA_AZURE_CONTAINER=media
207
+ * CROMBOLO_MEDIA_AZURE_KEY=… (or …_SAS=?sv=…)
208
+ *
209
+ * Anything unrecognised is the file system, so a mistyped value degrades to the
210
+ * default rather than losing the media library.
211
+ */
212
+ export async function mediaStoreFromEnvironment(
213
+ mediaDir: string,
214
+ env: Record<string, string | undefined> = Bun.env,
215
+ ): Promise<MediaStore> {
216
+ const kind = (env.CROMBOLO_MEDIA_STORE ?? 'filesystem').toLowerCase()
217
+ if (kind === 's3') {
218
+ const { s3MediaStore } = await import('./media-store-s3.ts')
219
+ return s3MediaStore({
220
+ bucket: env.CROMBOLO_MEDIA_S3_BUCKET ?? '',
221
+ region: env.CROMBOLO_MEDIA_S3_REGION,
222
+ endpoint: env.CROMBOLO_MEDIA_S3_ENDPOINT,
223
+ accessKeyId: env.CROMBOLO_MEDIA_S3_ACCESS_KEY_ID,
224
+ secretAccessKey: env.CROMBOLO_MEDIA_S3_SECRET_ACCESS_KEY,
225
+ sessionToken: env.CROMBOLO_MEDIA_S3_SESSION_TOKEN,
226
+ prefix: env.CROMBOLO_MEDIA_S3_PREFIX,
227
+ publicUrlPrefix: env.CROMBOLO_MEDIA_S3_PUBLIC_URL,
228
+ presignSeconds: env.CROMBOLO_MEDIA_S3_PRESIGN_SECONDS
229
+ ? Number(env.CROMBOLO_MEDIA_S3_PRESIGN_SECONDS)
230
+ : undefined,
231
+ })
232
+ }
233
+ if (kind === 'azure') {
234
+ const { azureMediaStore } = await import('./media-store-azure.ts')
235
+ return azureMediaStore({
236
+ account: env.CROMBOLO_MEDIA_AZURE_ACCOUNT ?? '',
237
+ container: env.CROMBOLO_MEDIA_AZURE_CONTAINER ?? 'media',
238
+ accountKey: env.CROMBOLO_MEDIA_AZURE_KEY,
239
+ sasToken: env.CROMBOLO_MEDIA_AZURE_SAS,
240
+ endpoint: env.CROMBOLO_MEDIA_AZURE_ENDPOINT,
241
+ prefix: env.CROMBOLO_MEDIA_AZURE_PREFIX,
242
+ publicUrlPrefix: env.CROMBOLO_MEDIA_AZURE_PUBLIC_URL,
243
+ })
244
+ }
245
+ return fileSystemMediaStore(mediaDir, {
246
+ publicUrlPrefix: env.CROMBOLO_MEDIA_PUBLIC_URL,
247
+ })
248
+ }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Member authentication on the front end, and the public-access check that
3
+ * depends on it.
4
+ *
5
+ * Umbraco has no fixed URLs for this: a site writes a surface controller and
6
+ * posts to it. There is no surface-controller equivalent here, so these four
7
+ * endpoints are ours, under the fixed `/umbraco` namespace rather than the
8
+ * movable backoffice path, because they belong to the framework and not to the
9
+ * editor.
10
+ *
11
+ * POST /umbraco/members/login username, password, rememberMe?, returnUrl?
12
+ * POST /umbraco/members/logout
13
+ * POST /umbraco/members/register name, email, username, password, returnUrl?
14
+ * GET /umbraco/members/current
15
+ *
16
+ * A form post is answered with a redirect, a JSON post with JSON, so the same
17
+ * endpoint serves a plain `<form>` in a TSX template and a fetch from the page.
18
+ *
19
+ * Registration is refused unless the site turns it on: an endpoint that creates
20
+ * accounts is a decision a site makes, not one a framework makes for it.
21
+ */
22
+ import {
23
+ expireCookie,
24
+ hashPassword,
25
+ memberCookieName,
26
+ passwordConfigJson,
27
+ readCookie,
28
+ readMemberTicket,
29
+ serializeCookie,
30
+ signMemberTicket,
31
+ verifyPassword,
32
+ } from '@crombolo/auth'
33
+ import {
34
+ DEFAULT_PASSWORD_CONFIGURATION,
35
+ isValidPassword,
36
+ localReturnUrl,
37
+ ObjectTypes,
38
+ } from '@crombolo/core'
39
+ import {
40
+ ContentTypeRepository,
41
+ type Db,
42
+ ensureKeyValue,
43
+ MemberRepository,
44
+ type NodeSchemaState,
45
+ PublicAccessRepository,
46
+ } from '@crombolo/data'
47
+ import type { AccessDecision, PublishedContent, RequestMember } from '@crombolo/render'
48
+ import type { CromboloConfig } from './config.ts'
49
+
50
+ /** The key member tickets are signed with, generated once and shared by every node. */
51
+ const SIGNING_KEY = 'crombolo/memberCookieKey'
52
+
53
+ export const MEMBER_ROUTE_PREFIX = '/umbraco/members'
54
+
55
+ export interface MemberAuthOptions {
56
+ /** The schema state this node runs at; a registration is refused without it. */
57
+ nodeState?: NodeSchemaState
58
+ nodeId?: string
59
+ }
60
+
61
+ export interface MemberAuth {
62
+ /** The member this request carries, or undefined. */
63
+ resolve(request: Request): Promise<RequestMember | undefined>
64
+ /**
65
+ * Whether a member may see a page, and what to render instead when not. The
66
+ * answer is Umbraco's: the login page for a visitor who is not signed in, the
67
+ * error page for one who is signed in but not allowed.
68
+ */
69
+ decide(content: PublishedContent, member: RequestMember | undefined): Promise<AccessDecision>
70
+ /** Handles the member endpoints, or undefined when the path is not one. */
71
+ handle(request: Request, pathname: string): Promise<Response | undefined>
72
+ }
73
+
74
+ const isFormPost = (request: Request) =>
75
+ (request.headers.get('content-type') ?? '').includes('form')
76
+
77
+ async function readInput(request: Request): Promise<Record<string, string>> {
78
+ if (isFormPost(request)) {
79
+ const form = await request.formData().catch(() => undefined)
80
+ if (!form) return {}
81
+ const out: Record<string, string> = {}
82
+ for (const [key, value] of form.entries()) if (typeof value === 'string') out[key] = value
83
+ return out
84
+ }
85
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
86
+ const out: Record<string, string> = {}
87
+ for (const [key, value] of Object.entries(body ?? {}))
88
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
89
+ out[key] = String(value)
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * A `returnUrl` must be a path on this site. An absolute URL would let a login
95
+ * form bounce a visitor somewhere else, which is the open-redirect this closes.
96
+ *
97
+ * `localReturnUrl` rather than a prefix test: a browser reads `/\evil.com` as
98
+ * protocol-relative, so the leading single slash it appears to have is not a
99
+ * guarantee of anything.
100
+ */
101
+ const safeReturnUrl = (value: string | undefined): string | undefined => localReturnUrl(value)
102
+
103
+ /**
104
+ * Whether a POST came from this site, rather than a form on someone else's.
105
+ *
106
+ * These three endpoints take a plain form post and act on the member cookie, and
107
+ * `SameSite=Lax` does not cover a cross-site POST's effect here: an attacker's
108
+ * form can sign a visitor into an account the attacker controls, or out of their
109
+ * own. Umbraco's surface controllers carry an antiforgery token for this; with no
110
+ * token to hand, the fetch metadata a browser sends is what distinguishes them.
111
+ *
112
+ * A request with neither header is allowed: that is a non-browser client, which
113
+ * carries no ambient cookies to abuse.
114
+ */
115
+ function fromThisSite(request: Request): boolean {
116
+ const site = request.headers.get('sec-fetch-site')
117
+ if (site) return site === 'same-origin' || site === 'none'
118
+ const origin = request.headers.get('origin')
119
+ if (!origin) return true
120
+ try {
121
+ return origin === new URL(request.url).origin
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ export function createMemberAuth(
128
+ db: Db,
129
+ config: CromboloConfig,
130
+ options: MemberAuthOptions = {},
131
+ ): MemberAuth {
132
+ // A registration is a write, so it is refused unless the repository is told
133
+ // which schema state this node runs at.
134
+ const members = new MemberRepository(db, { nodeState: options.nodeState, nodeId: options.nodeId })
135
+ const access = new PublicAccessRepository(db)
136
+ const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
137
+ const cookie = memberCookieName({
138
+ siteName: config.siteName,
139
+ securePrefix: config.secureCookies,
140
+ })
141
+ // Read once and held: every front-end request resolves a member, and that must
142
+ // not cost a query for the key each time.
143
+ let signingKey: Promise<string> | undefined
144
+ const secret = () => {
145
+ signingKey ??= ensureKeyValue(db, SIGNING_KEY, () => crypto.randomUUID())
146
+ return signingKey
147
+ }
148
+
149
+ const asRequestMember = (member: {
150
+ key: string
151
+ name: string
152
+ username: string
153
+ email: string
154
+ roles: string[]
155
+ }): RequestMember => ({
156
+ key: member.key,
157
+ name: member.name,
158
+ username: member.username,
159
+ email: member.email,
160
+ roles: member.roles,
161
+ })
162
+
163
+ const issue = async (key: string, stamp: string | null, persist: boolean) => {
164
+ const expires = Math.floor(Date.now() / 1000) + config.memberSessionMinutes * 60
165
+ const ticket = await signMemberTicket(await secret(), { id: key, stamp: stamp ?? '', expires })
166
+ return serializeCookie({
167
+ name: cookie,
168
+ value: ticket,
169
+ secure: config.secureCookies,
170
+ // Without "remember me" the cookie lasts the browser session, though the
171
+ // ticket inside it still expires.
172
+ maxAgeSeconds: persist ? config.memberSessionMinutes * 60 : undefined,
173
+ })
174
+ }
175
+
176
+ const resolve: MemberAuth['resolve'] = async (request) => {
177
+ const ticket = await readMemberTicket(await secret(), readCookie(request, cookie))
178
+ if (!ticket) return undefined
179
+ const member = await members.byKey(ticket.id)
180
+ if (!member) return undefined
181
+ // The stamp is what makes revocation immediate: a password change or a
182
+ // lockout rotates it, and every ticket issued before it is now worthless.
183
+ if ((member.securityStamp ?? '') !== ticket.stamp) return undefined
184
+ if (!member.isApproved || member.isLockedOut) return undefined
185
+ return asRequestMember(member)
186
+ }
187
+
188
+ const decide: MemberAuth['decide'] = async (content, member) => {
189
+ const entry = await access.entryFor(content.key)
190
+ if (!entry) return { status: 'allowed' }
191
+ const here = content.key.toLowerCase()
192
+ // The login and error pages must render even when they sit inside the
193
+ // branch they serve, or the substitution would never terminate.
194
+ if (here === entry.loginNodeKey || here === entry.errorNodeKey) return { status: 'allowed' }
195
+ if (!member) return { status: 'substitute', contentKey: entry.loginNodeKey }
196
+ const allowed =
197
+ entry.memberUserNames.some((name) => name.toLowerCase() === member.username.toLowerCase()) ||
198
+ entry.memberGroupNames.some((name) =>
199
+ member.roles.some((role) => role.toLowerCase() === name.toLowerCase()),
200
+ )
201
+ return allowed
202
+ ? { status: 'allowed' }
203
+ : { status: 'substitute', contentKey: entry.errorNodeKey }
204
+ }
205
+
206
+ const answer = (
207
+ request: Request,
208
+ result: { ok: true; body?: unknown } | { ok: false; status: number; error: string },
209
+ options: { returnUrl?: string; cookies?: string[] } = {},
210
+ ): Response => {
211
+ const headers = new Headers()
212
+ for (const value of options.cookies ?? []) headers.append('set-cookie', value)
213
+ if (isFormPost(request)) {
214
+ headers.set('location', options.returnUrl ?? '/')
215
+ // 303 so the browser follows with GET rather than re-posting.
216
+ return new Response(null, { status: 303, headers })
217
+ }
218
+ headers.set('content-type', 'application/json')
219
+ return new Response(
220
+ JSON.stringify(result.ok ? (result.body ?? { ok: true }) : { error: result.error }),
221
+ { status: result.ok ? 200 : result.status, headers },
222
+ )
223
+ }
224
+
225
+ const login = async (request: Request): Promise<Response> => {
226
+ const input = await readInput(request)
227
+ const returnUrl = safeReturnUrl(input.returnUrl)
228
+ const username = (input.username ?? '').trim()
229
+ const member = username ? await members.byUsername(username) : undefined
230
+ // A failed form post lands back where it came from, with a flag the login
231
+ // template can read; a failed fetch gets a 401.
232
+ const back = returnUrl ?? '/'
233
+ const failed = () =>
234
+ answer(
235
+ request,
236
+ { ok: false, status: 401, error: 'The username or password is not correct.' },
237
+ { returnUrl: `${back}${back.includes('?') ? '&' : '?'}memberLoginFailed=1` },
238
+ )
239
+ if (!member) {
240
+ // Spend the same time as a real verification would, so a missing username
241
+ // is not distinguishable by how quickly it fails.
242
+ await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
243
+ return failed()
244
+ }
245
+ // Checked before the password, so the answer never depends on whether the
246
+ // password was right; the same throwaway verify as above keeps the time taken
247
+ // from saying what the response does not.
248
+ if (member.isLockedOut || !member.isApproved) {
249
+ await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
250
+ return failed()
251
+ }
252
+ if (!(await verifyPassword(input.password ?? '', member.passwordHash))) {
253
+ await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
254
+ return failed()
255
+ }
256
+ await members.recordLogin(member.key)
257
+ return answer(
258
+ request,
259
+ { ok: true, body: { member: asRequestMember(member) } },
260
+ {
261
+ returnUrl: returnUrl ?? '/',
262
+ cookies: [await issue(member.key, member.securityStamp, input.rememberMe === 'true')],
263
+ },
264
+ )
265
+ }
266
+
267
+ const logout = async (request: Request): Promise<Response> => {
268
+ const input = await readInput(request)
269
+ return answer(
270
+ request,
271
+ { ok: true },
272
+ {
273
+ returnUrl: safeReturnUrl(input.returnUrl) ?? '/',
274
+ cookies: [expireCookie(cookie, config.secureCookies)],
275
+ },
276
+ )
277
+ }
278
+
279
+ const register = async (request: Request): Promise<Response> => {
280
+ if (!config.allowMemberRegistration)
281
+ return answer(request, {
282
+ ok: false,
283
+ status: 404,
284
+ error: 'Member registration is not enabled on this site.',
285
+ })
286
+ const input = await readInput(request)
287
+ const email = (input.email ?? '').trim()
288
+ const username = (input.username ?? '').trim() || email
289
+ const name = (input.name ?? '').trim() || username
290
+ const password = input.password ?? ''
291
+ if (!email || !username || !password)
292
+ return answer(request, {
293
+ ok: false,
294
+ status: 400,
295
+ error: 'A name, e-mail address and password are required.',
296
+ })
297
+ // The configured type, and only that: `memberRegistrationType` is the site's
298
+ // statement of what a visitor may create, so honouring a type named in the
299
+ // request would let them register as any member type the site defines.
300
+ const alias = config.memberRegistrationType
301
+ const type = await memberTypes.byAlias(alias)
302
+ if (!type)
303
+ return answer(request, {
304
+ ok: false,
305
+ status: 400,
306
+ error: `The member type "${alias}" does not exist.`,
307
+ })
308
+ if ((await members.taken('username', username)) || (await members.taken('email', email)))
309
+ return answer(request, {
310
+ ok: false,
311
+ status: 409,
312
+ error: 'That username or e-mail address is already registered.',
313
+ })
314
+ // Members were held to no policy at all while backoffice users were; after
315
+ // uniqueness, as ASP.NET Identity orders its own validators.
316
+ if (!isValidPassword(password))
317
+ return answer(request, {
318
+ ok: false,
319
+ status: 400,
320
+ error: `A password must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
321
+ })
322
+ const groupKeys: string[] = []
323
+ for (const groupName of config.memberRegistrationGroups) {
324
+ const rows = await db.query<{ unique_id: string }>(
325
+ 'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
326
+ [groupName, ObjectTypes.MemberGroup],
327
+ )
328
+ if (rows[0]) groupKeys.push(String(rows[0].unique_id))
329
+ }
330
+ const created = await members.create(
331
+ {
332
+ key: crypto.randomUUID(),
333
+ contentTypeKey: type.key,
334
+ componentKey: null,
335
+ parentKey: null,
336
+ values: [],
337
+ variants: [{ culture: null, segment: null, name }],
338
+ },
339
+ {
340
+ email,
341
+ username,
342
+ isApproved: true,
343
+ isLockedOut: false,
344
+ passwordHash: await hashPassword(password),
345
+ passwordConfig: passwordConfigJson(),
346
+ groupKeys,
347
+ },
348
+ )
349
+ return answer(
350
+ request,
351
+ { ok: true, body: { member: asRequestMember(created) } },
352
+ {
353
+ returnUrl: safeReturnUrl(input.returnUrl) ?? '/',
354
+ cookies: [await issue(created.key, created.securityStamp, false)],
355
+ },
356
+ )
357
+ }
358
+
359
+ return {
360
+ resolve,
361
+ decide,
362
+ async handle(request, pathname) {
363
+ if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
364
+ const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
365
+ if (action === 'current') {
366
+ if (request.method !== 'GET') return new Response(null, { status: 405 })
367
+ const member = await resolve(request)
368
+ return member ? Response.json({ member }) : Response.json({ member: null }, { status: 401 })
369
+ }
370
+ if (request.method !== 'POST') return new Response(null, { status: 405 })
371
+ if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
372
+ switch (action) {
373
+ case 'login':
374
+ return login(request)
375
+ case 'logout':
376
+ return logout(request)
377
+ case 'register':
378
+ return register(request)
379
+ default:
380
+ return undefined
381
+ }
382
+ },
383
+ }
384
+ }