@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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
|
+
}
|