You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/modules/storage/s3/storage.ts

209 lines
7.8 KiB

import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { objectStorageModule, signingBaseUrl } from '../../../helpers/storageObjects.ts'
import type { ObjectStoreClient } from '../../../helpers/storageObjects.ts'
import type { StorageTarget } from '../../../models/storage.ts'
/** Live clients, keyed by target. See `clientFor`. */
const clients = new Map<string, { client: S3Client; fingerprint: string }>()
/** The settings a client is built from — a change to any of them needs a new one. */
function configFingerprint(target: StorageTarget): string {
const c = target.config
return JSON.stringify([c.endpoint, c.region, c.accessKeyId, c.secretAccessKey, c.forcePathStyle])
}
/**
* The S3 client for this target, built once and kept.
*
* Rebuilt when the configuration changes, so a rotated key takes effect on the next operation rather
* than at the next restart.
*
* **Credentials are optional.** Left empty, the SDK falls back to its own chain — an IAM role on the
* instance, the standard `AWS_*` environment variables, a shared credentials file — which is how a
* deployment avoids putting a long-lived secret in the database at all.
*/
function clientFor(target: StorageTarget): S3Client {
const fingerprint = configFingerprint(target)
const cached = clients.get(target.id)
if (cached && cached.fingerprint === fingerprint) {
return cached.client
}
const { endpoint, region, accessKeyId, secretAccessKey, forcePathStyle } = target.config
const client = new S3Client({
region: region || 'us-east-1',
...(endpoint ? { endpoint } : {}),
...(forcePathStyle ? { forcePathStyle: true } : {}),
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {})
})
clients.set(target.id, { client, fingerprint })
return client
}
/**
* The client and bucket a signature should be made against.
*
* SigV4 covers the `Host` header, so a URL signed for the bucket's own address and then rewritten
* onto a CDN domain carries a signature for the wrong host and is rejected. The domain has to be
* signed *for*, which means a client pointed at it rather than the ordinary client with its output
* edited afterwards.
*
* Which of the two forms that takes depends on what sits at the domain, and `forcePathStyle` is the
* target's existing answer to exactly that question for the store itself:
*
* - **off** — the domain *is* the bucket, which is what a Cloudflare R2 custom domain or a Spaces CDN
* endpoint is. The SDK spells this `bucketEndpoint`, and it means the `Bucket` *parameter* carries
* the URL: passing the bucket's name alongside it fails outright.
* - **on** — the bucket is the first path segment, which is what a reverse proxy onto MinIO looks
* like, and the endpoint and the bucket name are then both ordinary.
*
* Not cached: signing is per request, and an administrator changing the base URL must not have to
* wait for anything to expire before seeing it.
*/
function signingTargetFor(
target: StorageTarget,
baseUrl: string | null
): { client: S3Client; bucket: string } {
if (!baseUrl) {
return { client: clientFor(target), bucket: target.config.bucket }
}
const { region, accessKeyId, secretAccessKey, forcePathStyle } = target.config
const credentials =
accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}
if (forcePathStyle) {
return {
client: new S3Client({
region: region || 'us-east-1',
endpoint: baseUrl,
forcePathStyle: true,
...credentials
}),
bucket: target.config.bucket
}
}
return {
client: new S3Client({ region: region || 'us-east-1', bucketEndpoint: true, ...credentials }),
bucket: baseUrl
}
}
/** Whether the store is telling us the key simply is not there. */
function isNotFound(err: any): boolean {
return (
err?.name === 'NoSuchKey' || err?.name === 'NotFound' || err?.$metadata?.httpStatusCode === 404
)
}
const s3Client: ObjectStoreClient = {
async put(target, key, data, contentType) {
await clientFor(target).send(
new PutObjectCommand({
Bucket: target.config.bucket,
Key: key,
Body: data,
ContentType: contentType,
// -> Omitted rather than sent as Standard: a compatible store that does not implement
// storage classes will reject the header outright rather than ignore it
...(target.config.storageClass && target.config.storageClass !== 'STANDARD'
? { StorageClass: target.config.storageClass }
: {})
})
)
},
async get(target, key) {
try {
const resp = await clientFor(target).send(
new GetObjectCommand({ Bucket: target.config.bucket, Key: key })
)
const bytes = await resp.Body?.transformToByteArray()
return bytes ? Buffer.from(bytes) : null
} catch (err: any) {
if (isNotFound(err)) {
// -> This target does not have the file: enabled after the upload, or removed from outside
// the wiki. Not a fault — the caller asks the next target.
return null
}
throw err
}
},
async remove(target, key) {
try {
await clientFor(target).send(
new DeleteObjectCommand({ Bucket: target.config.bucket, Key: key })
)
} catch (err: any) {
// -> S3 itself answers a delete of a missing key with success; not every compatible store does
if (!isNotFound(err)) {
throw err
}
}
},
async copy(target, fromKey, toKey) {
try {
await clientFor(target).send(
new CopyObjectCommand({
Bucket: target.config.bucket,
// -> The source is bucket-qualified and URI-encoded, which is the one part of this API that
// does not take a plain key: a `#` or a `+` in a file name would otherwise be read as
// part of the URL rather than as part of the name
CopySource: encodeURI(`${target.config.bucket}/${fromKey}`),
Key: toKey
})
)
return true
} catch (err: any) {
if (isNotFound(err)) {
return false
}
throw err
}
},
async presign(target, { key, expiresInSeconds, contentType, downloadAs }) {
const { client, bucket } = signingTargetFor(target, signingBaseUrl(target))
/*
The response headers travel in the signature rather than being left to the object's own
metadata: the wiki knows what it thinks the file is and whether this request was a download,
and neither of those is necessarily what was stored — an object written by an older instance,
or one uploaded straight into the bucket, carries whatever it carries.
*/
return getSignedUrl(
client,
new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentType: contentType,
...(downloadAs
? {
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(downloadAs)}"`
}
: {})
}),
{ expiresIn: expiresInSeconds }
)
}
}
/**
* S3 object storage module
*
* Amazon S3 and everything that speaks its API. One module rather than the three that 2.x shipped —
* an AWS one, a DigitalOcean one and a custom one — because the difference between them is an
* endpoint and a region, and treating that as a preset meant a new module for every service that
* appeared. Empty endpoint is AWS; anything else is whichever store the URL points at.
*
* The keys are the same paths the disk target writes, under whatever `pathPrefix` this target starts
* at, so a bucket and a folder hold the wiki's content laid out identically. See
* `helpers/storageObjects.ts` for everything above the four calls below.
*/
export default objectStorageModule(s3Client)