@avelonjs/neon 0.3.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/README.md +351 -0
- package/package.json +56 -0
- package/src/database/driver.ts +263 -0
- package/src/database/index.ts +8 -0
- package/src/identity/cookies.ts +54 -0
- package/src/identity/driver.ts +462 -0
- package/src/identity/errors.ts +206 -0
- package/src/identity/index.ts +15 -0
- package/src/identity/local-auth.ts +304 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +271 -0
- package/src/queue/index.ts +1 -0
- package/src/social/driver.ts +118 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +55 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +182 -0
- package/src/storage/index.ts +7 -0
- package/src/storage/local-s3.ts +128 -0
- package/src/tokens/driver.ts +220 -0
- package/src/tokens/index.ts +6 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Invalid, Unauthenticated, type SocialDriver, type SocialIdentity } from '@avelonjs/core'
|
|
2
|
+
import type { NeonSocialProfile } from './types'
|
|
3
|
+
|
|
4
|
+
/** Exact capability declaration for the Neon social driver. */
|
|
5
|
+
export const neonSocialCapabilities = {
|
|
6
|
+
providers: ['github', 'google'] as const,
|
|
7
|
+
} as const
|
|
8
|
+
|
|
9
|
+
type Provider = (typeof neonSocialCapabilities.providers)[number]
|
|
10
|
+
|
|
11
|
+
/** Construction options for {@link createNeonSocial}. */
|
|
12
|
+
export interface NeonSocialOptions {
|
|
13
|
+
/** Neon Auth / Better Auth base URL. */
|
|
14
|
+
authUrl?: string
|
|
15
|
+
/** Configured social connection name. */
|
|
16
|
+
instance?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isProvider(provider: string): provider is Provider {
|
|
20
|
+
return neonSocialCapabilities.providers.some((candidate) => candidate === provider)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function invalid(field: string, message: string): never {
|
|
24
|
+
throw new Invalid(message, { metadata: { fields: { [field]: [message] } } })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unauthenticated(message: string): never {
|
|
28
|
+
throw new Unauthenticated(message, { metadata: { guard: 'social' } })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Neon Auth social driver.
|
|
33
|
+
*
|
|
34
|
+
* `redirect()` builds a Better Auth social sign-in URL and records CSRF state on the driver
|
|
35
|
+
* instance. `callback()` verifies that state, then exchanges the authorization code over HTTP.
|
|
36
|
+
*/
|
|
37
|
+
export class NeonSocial implements SocialDriver<
|
|
38
|
+
typeof neonSocialCapabilities,
|
|
39
|
+
{ authUrl: string },
|
|
40
|
+
NeonSocialProfile
|
|
41
|
+
> {
|
|
42
|
+
readonly name = 'neon'
|
|
43
|
+
readonly instance: string
|
|
44
|
+
readonly capabilities = neonSocialCapabilities
|
|
45
|
+
|
|
46
|
+
readonly #authUrl: string
|
|
47
|
+
readonly #states = new Map<Provider, string>()
|
|
48
|
+
#nextState = 1
|
|
49
|
+
|
|
50
|
+
constructor(options: Required<NeonSocialOptions>) {
|
|
51
|
+
this.instance = options.instance
|
|
52
|
+
this.#authUrl = options.authUrl.replace(/\/$/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
raw(): { authUrl: string } {
|
|
56
|
+
return { authUrl: this.#authUrl }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async redirect(provider: string, callbackUrl: string, state?: string): Promise<string> {
|
|
60
|
+
if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
|
|
61
|
+
const expectedState = state ?? `assay-state-${this.#nextState++}`
|
|
62
|
+
this.#states.set(provider, expectedState)
|
|
63
|
+
const url = new URL(`${this.#authUrl}/sign-in/social`)
|
|
64
|
+
url.searchParams.set('provider', provider)
|
|
65
|
+
url.searchParams.set('callbackURL', callbackUrl)
|
|
66
|
+
url.searchParams.set('state', expectedState)
|
|
67
|
+
return url.toString()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async callback(
|
|
71
|
+
provider: string,
|
|
72
|
+
params: Readonly<Record<string, string>>,
|
|
73
|
+
callbackUrl: string,
|
|
74
|
+
): Promise<SocialIdentity<NeonSocialProfile>> {
|
|
75
|
+
if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
|
|
76
|
+
const expectedState = this.#states.get(provider)
|
|
77
|
+
if (!expectedState || !params.state || params.state !== expectedState) {
|
|
78
|
+
unauthenticated('Authorization state could not be verified.')
|
|
79
|
+
}
|
|
80
|
+
this.#states.delete(provider)
|
|
81
|
+
if (params.error) unauthenticated(`Authorization failed: ${params.error}.`)
|
|
82
|
+
if (!params.code) invalid('code', 'Authorization code is required.')
|
|
83
|
+
|
|
84
|
+
const response = await fetch(`${this.#authUrl}/callback`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
code: params.code,
|
|
89
|
+
provider,
|
|
90
|
+
callbackURL: callbackUrl,
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
const body = (await response.json()) as {
|
|
94
|
+
error?: string
|
|
95
|
+
user?: { id?: string; email?: string; name?: string }
|
|
96
|
+
}
|
|
97
|
+
const user = body.user
|
|
98
|
+
if (!response.ok || user === undefined || typeof user.id !== 'string') {
|
|
99
|
+
unauthenticated(body.error ?? 'Authorization code exchange failed.')
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
provider,
|
|
103
|
+
subject: user.id,
|
|
104
|
+
profile: {
|
|
105
|
+
displayName: user.name ?? user.email ?? user.id,
|
|
106
|
+
...(user.email === undefined ? {} : { email: user.email }),
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Creates a Neon social driver from options or environment defaults. */
|
|
113
|
+
export function createNeonSocial(options: NeonSocialOptions = {}): NeonSocial {
|
|
114
|
+
return new NeonSocial({
|
|
115
|
+
authUrl: options.authUrl ?? process.env.NEON_AUTH_URL ?? 'http://127.0.0.1:3000/api/auth',
|
|
116
|
+
instance: options.instance ?? 'default',
|
|
117
|
+
})
|
|
118
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Better Auth-shaped OAuth token endpoint for social conformance.
|
|
3
|
+
*
|
|
4
|
+
* Real deployments point {@link createNeonSocial} at Neon Auth. This server exists so the HTTP
|
|
5
|
+
* callback exchange can run without a hosted OAuth app.
|
|
6
|
+
*/
|
|
7
|
+
export class LocalSocialServer {
|
|
8
|
+
#server: ReturnType<typeof Bun.serve> | undefined
|
|
9
|
+
#url = ''
|
|
10
|
+
|
|
11
|
+
/** Base Auth URL, e.g. `http://127.0.0.1:3000/api/auth`. */
|
|
12
|
+
get url(): string {
|
|
13
|
+
return this.#url
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Starts the server on an ephemeral port. */
|
|
17
|
+
async start(): Promise<string> {
|
|
18
|
+
const self = this
|
|
19
|
+
this.#server = Bun.serve({
|
|
20
|
+
port: 0,
|
|
21
|
+
async fetch(request) {
|
|
22
|
+
return self.#handle(request)
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
this.#url = `http://127.0.0.1:${this.#server.port}/api/auth`
|
|
26
|
+
return this.#url
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stops the fixture listener. */
|
|
30
|
+
async stop(): Promise<void> {
|
|
31
|
+
this.#server?.stop(true)
|
|
32
|
+
this.#server = undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async #handle(request: Request): Promise<Response> {
|
|
36
|
+
const url = new URL(request.url)
|
|
37
|
+
const path = url.pathname.replace(/^\/api\/auth/, '')
|
|
38
|
+
|
|
39
|
+
if (request.method === 'POST' && path === '/callback') {
|
|
40
|
+
const body = (await request.json()) as { code?: string; provider?: string }
|
|
41
|
+
if (body.code !== 'valid-code') {
|
|
42
|
+
return Response.json({ error: 'invalid_grant' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
return Response.json({
|
|
45
|
+
user: {
|
|
46
|
+
id: `subject-${body.code}`,
|
|
47
|
+
email: 'actor@example.test',
|
|
48
|
+
name: 'Assay Actor',
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Response.json({ error: 'not_found' }, { status: 404 })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteObjectCommand,
|
|
3
|
+
GetObjectCommand,
|
|
4
|
+
HeadObjectCommand,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
S3Client,
|
|
7
|
+
} from '@aws-sdk/client-s3'
|
|
8
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
9
|
+
import {
|
|
10
|
+
NotFound,
|
|
11
|
+
type SignedUrlStorageSurface,
|
|
12
|
+
type StorageDriver,
|
|
13
|
+
type StorageObject,
|
|
14
|
+
} from '@avelonjs/core'
|
|
15
|
+
|
|
16
|
+
/** Exact capability declaration for the Neon S3 storage driver. */
|
|
17
|
+
export const neonStorageCapabilities = {
|
|
18
|
+
signedUrls: true,
|
|
19
|
+
transforms: [] as const,
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
/** Construction options for {@link createNeonStorage}. */
|
|
23
|
+
export interface NeonStorageOptions {
|
|
24
|
+
/** S3 bucket name. */
|
|
25
|
+
bucket?: string
|
|
26
|
+
/** Configured disk name. */
|
|
27
|
+
instance?: string
|
|
28
|
+
/** AWS region. Defaults to `AWS_REGION` or `us-east-1`. */
|
|
29
|
+
region?: string
|
|
30
|
+
/** Optional custom endpoint for R2, MinIO, or {@link LocalS3Server}. */
|
|
31
|
+
endpoint?: string
|
|
32
|
+
/** Access key. Defaults to `AWS_ACCESS_KEY_ID`. */
|
|
33
|
+
accessKeyId?: string
|
|
34
|
+
/** Secret key. Defaults to `AWS_SECRET_ACCESS_KEY`. */
|
|
35
|
+
secretAccessKey?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notFound(path: string): never {
|
|
39
|
+
throw new NotFound(`Storage object ${path} was not found.`, {
|
|
40
|
+
metadata: { resource: 'storage-object', identifier: path },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readContents(contents: Uint8Array | AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
45
|
+
if (contents instanceof Uint8Array) return contents.slice()
|
|
46
|
+
const chunks: Uint8Array[] = []
|
|
47
|
+
let size = 0
|
|
48
|
+
for await (const chunk of contents) {
|
|
49
|
+
const copy = chunk.slice()
|
|
50
|
+
chunks.push(copy)
|
|
51
|
+
size += copy.byteLength
|
|
52
|
+
}
|
|
53
|
+
const result = new Uint8Array(size)
|
|
54
|
+
let offset = 0
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
result.set(chunk, offset)
|
|
57
|
+
offset += chunk.byteLength
|
|
58
|
+
}
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isMissing(error: unknown): boolean {
|
|
63
|
+
if (typeof error !== 'object' || error === null) return false
|
|
64
|
+
const name = Reflect.get(error, 'name')
|
|
65
|
+
const code = Reflect.get(error, '$metadata')
|
|
66
|
+
const httpStatus =
|
|
67
|
+
typeof code === 'object' && code !== null ? Reflect.get(code, 'httpStatusCode') : undefined
|
|
68
|
+
return name === 'NotFound' || name === 'NoSuchKey' || httpStatus === 404
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* S3 storage driver used by the Neon stack.
|
|
73
|
+
*
|
|
74
|
+
* Neon has no object store. Bytes go to S3 (or an S3-compatible endpoint). Signed read URLs are
|
|
75
|
+
* SigV4 query URLs from the AWS SDK.
|
|
76
|
+
*/
|
|
77
|
+
export class NeonStorage
|
|
78
|
+
implements StorageDriver<typeof neonStorageCapabilities, S3Client>, SignedUrlStorageSurface
|
|
79
|
+
{
|
|
80
|
+
readonly name = 'neon'
|
|
81
|
+
readonly instance: string
|
|
82
|
+
readonly capabilities = neonStorageCapabilities
|
|
83
|
+
|
|
84
|
+
readonly #client: S3Client
|
|
85
|
+
readonly #bucket: string
|
|
86
|
+
|
|
87
|
+
constructor(options: {
|
|
88
|
+
bucket: string
|
|
89
|
+
instance: string
|
|
90
|
+
region: string
|
|
91
|
+
endpoint?: string
|
|
92
|
+
accessKeyId: string
|
|
93
|
+
secretAccessKey: string
|
|
94
|
+
}) {
|
|
95
|
+
this.instance = options.instance
|
|
96
|
+
this.#bucket = options.bucket
|
|
97
|
+
this.#client = new S3Client({
|
|
98
|
+
region: options.region,
|
|
99
|
+
// Vendor boundary: the AWS SDK client plus checksum flags that keep custom endpoints honest.
|
|
100
|
+
forcePathStyle: options.endpoint !== undefined,
|
|
101
|
+
...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
|
|
102
|
+
credentials: {
|
|
103
|
+
accessKeyId: options.accessKeyId,
|
|
104
|
+
secretAccessKey: options.secretAccessKey,
|
|
105
|
+
},
|
|
106
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
107
|
+
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
raw(): S3Client {
|
|
112
|
+
return this.#client
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async put(
|
|
116
|
+
path: string,
|
|
117
|
+
contents: Uint8Array | AsyncIterable<Uint8Array>,
|
|
118
|
+
options?: { readonly contentType?: string },
|
|
119
|
+
): Promise<StorageObject> {
|
|
120
|
+
const stored = await readContents(contents)
|
|
121
|
+
await this.#client.send(
|
|
122
|
+
new PutObjectCommand({
|
|
123
|
+
Bucket: this.#bucket,
|
|
124
|
+
Key: path,
|
|
125
|
+
Body: stored,
|
|
126
|
+
...(options?.contentType === undefined ? {} : { ContentType: options.contentType }),
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
path,
|
|
131
|
+
size: stored.byteLength,
|
|
132
|
+
...(options?.contentType === undefined ? {} : { contentType: options.contentType }),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async get(path: string): Promise<Uint8Array> {
|
|
137
|
+
try {
|
|
138
|
+
const response = await this.#client.send(
|
|
139
|
+
new GetObjectCommand({ Bucket: this.#bucket, Key: path }),
|
|
140
|
+
)
|
|
141
|
+
const body = response.Body
|
|
142
|
+
if (body === undefined) notFound(path)
|
|
143
|
+
return new Uint8Array(await body.transformToByteArray())
|
|
144
|
+
} catch (error: unknown) {
|
|
145
|
+
if (isMissing(error)) notFound(path)
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async delete(path: string): Promise<void> {
|
|
151
|
+
await this.#client.send(new DeleteObjectCommand({ Bucket: this.#bucket, Key: path }))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async exists(path: string): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
await this.#client.send(new HeadObjectCommand({ Bucket: this.#bucket, Key: path }))
|
|
157
|
+
return true
|
|
158
|
+
} catch (error: unknown) {
|
|
159
|
+
if (isMissing(error)) return false
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async signedUrl(path: string, expiresInSeconds: number): Promise<string> {
|
|
165
|
+
if (!(await this.exists(path))) notFound(path)
|
|
166
|
+
return getSignedUrl(this.#client, new GetObjectCommand({ Bucket: this.#bucket, Key: path }), {
|
|
167
|
+
expiresIn: expiresInSeconds,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Creates an S3 storage driver from options or environment defaults. */
|
|
173
|
+
export function createNeonStorage(options: NeonStorageOptions = {}): NeonStorage {
|
|
174
|
+
return new NeonStorage({
|
|
175
|
+
bucket: options.bucket ?? process.env.NEON_S3_BUCKET ?? process.env.AWS_S3_BUCKET ?? 'avelon',
|
|
176
|
+
instance: options.instance ?? 'default',
|
|
177
|
+
region: options.region ?? process.env.AWS_REGION ?? 'us-east-1',
|
|
178
|
+
endpoint: options.endpoint ?? process.env.AWS_ENDPOINT_URL_S3 ?? process.env.AWS_ENDPOINT_URL,
|
|
179
|
+
accessKeyId: options.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? 'test',
|
|
180
|
+
secretAccessKey: options.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY ?? 'test',
|
|
181
|
+
})
|
|
182
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
interface StoredObject {
|
|
2
|
+
bytes: Uint8Array
|
|
3
|
+
contentType?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function objectKey(bucket: string, key: string): string {
|
|
7
|
+
return `${bucket}/${key}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parsePath(pathname: string): { bucket: string; key: string } | undefined {
|
|
11
|
+
const parts = pathname.split('/').filter((part) => part.length > 0)
|
|
12
|
+
const bucket = parts[0]
|
|
13
|
+
if (bucket === undefined) return undefined
|
|
14
|
+
return { bucket, key: parts.slice(1).join('/') }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parses SigV4 query expiry. Returns epoch ms, or undefined when the request is not signed. */
|
|
18
|
+
export function signedUrlExpiryMs(url: URL): number | undefined {
|
|
19
|
+
const date = url.searchParams.get('X-Amz-Date')
|
|
20
|
+
const expires = Number(url.searchParams.get('X-Amz-Expires'))
|
|
21
|
+
if (date === null || date.length < 16 || !Number.isFinite(expires)) return undefined
|
|
22
|
+
const iso = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T${date.slice(9, 11)}:${date.slice(11, 13)}:${date.slice(13, 15)}Z`
|
|
23
|
+
const started = Date.parse(iso)
|
|
24
|
+
if (Number.isNaN(started)) return undefined
|
|
25
|
+
return started + expires * 1000
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal path-style S3 HTTP server for storage conformance.
|
|
30
|
+
*
|
|
31
|
+
* Real deployments point {@link createNeonStorage} at AWS S3, R2, or MinIO. This listener ignores
|
|
32
|
+
* SigV4 signatures and enforces `X-Amz-Date` / `X-Amz-Expires` so signed-URL expiry stays real.
|
|
33
|
+
*/
|
|
34
|
+
export class LocalS3Server {
|
|
35
|
+
readonly #objects = new Map<string, StoredObject>()
|
|
36
|
+
#server: ReturnType<typeof Bun.serve> | undefined
|
|
37
|
+
#url = ''
|
|
38
|
+
|
|
39
|
+
/** Path-style S3 endpoint, e.g. `http://127.0.0.1:9000`. */
|
|
40
|
+
get url(): string {
|
|
41
|
+
return this.#url
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Starts the server on an ephemeral port. */
|
|
45
|
+
async start(): Promise<string> {
|
|
46
|
+
const self = this
|
|
47
|
+
this.#server = Bun.serve({
|
|
48
|
+
port: 0,
|
|
49
|
+
async fetch(request) {
|
|
50
|
+
return self.#handle(request)
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
this.#url = `http://127.0.0.1:${this.#server.port}`
|
|
54
|
+
return this.#url
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Clears stored objects without restarting the listener. */
|
|
58
|
+
reset(): void {
|
|
59
|
+
this.#objects.clear()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stops the server and clears fixture state. */
|
|
63
|
+
async stop(): Promise<void> {
|
|
64
|
+
this.#server?.stop(true)
|
|
65
|
+
this.#server = undefined
|
|
66
|
+
this.reset()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #handle(request: Request): Promise<Response> {
|
|
70
|
+
const url = new URL(request.url)
|
|
71
|
+
const parsed = parsePath(url.pathname)
|
|
72
|
+
if (parsed === undefined || parsed.key.length === 0) {
|
|
73
|
+
return s3Error(404, 'NoSuchKey', 'The specified key does not exist.')
|
|
74
|
+
}
|
|
75
|
+
const id = objectKey(parsed.bucket, parsed.key)
|
|
76
|
+
|
|
77
|
+
if (request.method === 'PUT') {
|
|
78
|
+
const buffer = new Uint8Array(await request.arrayBuffer())
|
|
79
|
+
const contentType = request.headers.get('content-type') ?? undefined
|
|
80
|
+
this.#objects.set(id, {
|
|
81
|
+
bytes: buffer,
|
|
82
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
83
|
+
})
|
|
84
|
+
return new Response(null, { status: 200, headers: { ETag: `"${buffer.byteLength}"` } })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (request.method === 'DELETE') {
|
|
88
|
+
this.#objects.delete(id)
|
|
89
|
+
return new Response(null, { status: 204 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const expiry = signedUrlExpiryMs(url)
|
|
93
|
+
if (expiry !== undefined && expiry <= Date.now()) {
|
|
94
|
+
return new Response('expired', { status: 403 })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const stored = this.#objects.get(id)
|
|
98
|
+
if (stored === undefined) {
|
|
99
|
+
return s3Error(404, 'NoSuchKey', 'The specified key does not exist.')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const headers: Record<string, string> = {
|
|
103
|
+
'Content-Type': stored.contentType ?? 'application/octet-stream',
|
|
104
|
+
'Content-Length': String(stored.bytes.byteLength),
|
|
105
|
+
ETag: `"${stored.bytes.byteLength}"`,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (request.method === 'HEAD') {
|
|
109
|
+
return new Response(null, { status: 200, headers })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (request.method === 'GET') {
|
|
113
|
+
const copy = new ArrayBuffer(stored.bytes.byteLength)
|
|
114
|
+
new Uint8Array(copy).set(stored.bytes)
|
|
115
|
+
return new Response(copy, { status: 200, headers })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return s3Error(405, 'MethodNotAllowed', 'The specified method is not allowed.')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function s3Error(status: number, code: string, message: string): Response {
|
|
123
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message>${message}</Message></Error>`
|
|
124
|
+
return new Response(body, {
|
|
125
|
+
status,
|
|
126
|
+
headers: { 'Content-Type': 'application/xml' },
|
|
127
|
+
})
|
|
128
|
+
}
|