graphile-settings 6.13.8 → 6.15.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/esm/presigned-url-resolver.d.ts +4 -3
- package/esm/presigned-url-resolver.js +4 -3
- package/esm/upload-resolver.d.ts +16 -7
- package/esm/upload-resolver.js +188 -64
- package/package.json +18 -17
- package/presigned-url-resolver.d.ts +4 -3
- package/presigned-url-resolver.js +4 -3
- package/upload-resolver.d.ts +16 -7
- package/upload-resolver.js +187 -63
|
@@ -19,9 +19,10 @@ import type { BucketNameResolver, EnsureBucketProvisioned, S3Config } from 'grap
|
|
|
19
19
|
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
|
|
20
20
|
* the result. Same CDN config as upload-resolver.ts.
|
|
21
21
|
*
|
|
22
|
-
* NOTE: The `bucket` field here is the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* NOTE: The `bucket` field here is only the connection's default and is never
|
|
23
|
+
* uploaded to. Every managed upload names its bucket explicitly, resolved from
|
|
24
|
+
* the tenant's logical bucket row via `resolveBucketName`; there is no
|
|
25
|
+
* environment-global upload bucket.
|
|
25
26
|
*/
|
|
26
27
|
export declare function getPresignedUrlS3Config(): S3Config;
|
|
27
28
|
/**
|
|
@@ -24,9 +24,10 @@ let s3Config = null;
|
|
|
24
24
|
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
|
|
25
25
|
* the result. Same CDN config as upload-resolver.ts.
|
|
26
26
|
*
|
|
27
|
-
* NOTE: The `bucket` field here is the
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* NOTE: The `bucket` field here is only the connection's default and is never
|
|
28
|
+
* uploaded to. Every managed upload names its bucket explicitly, resolved from
|
|
29
|
+
* the tenant's logical bucket row via `resolveBucketName`; there is no
|
|
30
|
+
* environment-global upload bucket.
|
|
30
31
|
*/
|
|
31
32
|
export function getPresignedUrlS3Config() {
|
|
32
33
|
if (s3Config)
|
package/esm/upload-resolver.d.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Upload resolver for the Constructive upload plugin.
|
|
2
|
+
* Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
4
|
+
* This is the streaming transport into the *managed* storage lane: bytes arrive
|
|
5
|
+
* on the mutation, and the file they carry gets the same treatment a presigned
|
|
6
|
+
* upload gets — a bucket resolved inside the tenant, a content-addressed key, a
|
|
7
|
+
* files row, and a projection document naming that row.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* It used to be a second storage model: stream to `BUCKET_NAME` under a random
|
|
10
|
+
* key, hand back a URL, record nothing. Objects written that way belonged to no
|
|
11
|
+
* database, could not be deduplicated, listed, or access-controlled, and storage
|
|
12
|
+
* GC could not see that a document still pointed at them. There is no
|
|
13
|
+
* environment bucket in this path any more; `cdn.*` supplies S3 credentials and
|
|
14
|
+
* an endpoint only.
|
|
9
15
|
*
|
|
10
|
-
*
|
|
16
|
+
* Compatibility: `image`/`upload` columns still receive `url` alongside the new
|
|
17
|
+
* `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
|
|
18
|
+
* working while they migrate to `id` + the files row's late-bound `downloadUrl`.
|
|
19
|
+
*
|
|
20
|
+
* ENV VARS (S3 connection only):
|
|
11
21
|
* BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
|
|
12
|
-
* BUCKET_NAME - bucket name (default: 'test-bucket')
|
|
13
22
|
* AWS_REGION - AWS region (default: 'us-east-1')
|
|
14
23
|
* AWS_ACCESS_KEY - access key (default: 'minioadmin')
|
|
15
24
|
* AWS_SECRET_KEY - secret key (default: 'minioadmin')
|
package/esm/upload-resolver.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Upload resolver for the Constructive upload plugin.
|
|
2
|
+
* Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
4
|
+
* This is the streaming transport into the *managed* storage lane: bytes arrive
|
|
5
|
+
* on the mutation, and the file they carry gets the same treatment a presigned
|
|
6
|
+
* upload gets — a bucket resolved inside the tenant, a content-addressed key, a
|
|
7
|
+
* files row, and a projection document naming that row.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* It used to be a second storage model: stream to `BUCKET_NAME` under a random
|
|
10
|
+
* key, hand back a URL, record nothing. Objects written that way belonged to no
|
|
11
|
+
* database, could not be deduplicated, listed, or access-controlled, and storage
|
|
12
|
+
* GC could not see that a document still pointed at them. There is no
|
|
13
|
+
* environment bucket in this path any more; `cdn.*` supplies S3 credentials and
|
|
14
|
+
* an endpoint only.
|
|
9
15
|
*
|
|
10
|
-
*
|
|
16
|
+
* Compatibility: `image`/`upload` columns still receive `url` alongside the new
|
|
17
|
+
* `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
|
|
18
|
+
* working while they migrate to `id` + the files row's late-bound `downloadUrl`.
|
|
19
|
+
*
|
|
20
|
+
* ENV VARS (S3 connection only):
|
|
11
21
|
* BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
|
|
12
|
-
* BUCKET_NAME - bucket name (default: 'test-bucket')
|
|
13
22
|
* AWS_REGION - AWS region (default: 'us-east-1')
|
|
14
23
|
* AWS_ACCESS_KEY - access key (default: 'minioadmin')
|
|
15
24
|
* AWS_SECRET_KEY - secret key (default: 'minioadmin')
|
|
@@ -17,100 +26,215 @@
|
|
|
17
26
|
*/
|
|
18
27
|
import { getEnvOptions } from '@constructive-io/graphql-env';
|
|
19
28
|
import Streamer from '@constructive-io/s3-streamer';
|
|
20
|
-
import uploadNames from '@constructive-io/upload-names';
|
|
21
29
|
import { Logger } from '@pgpmjs/logger';
|
|
22
|
-
import {
|
|
30
|
+
import { createHash, randomUUID } from 'crypto';
|
|
31
|
+
import { finalizeStagedUpload, resolveManagedUploadTarget, withRequestPgClient, } from 'graphile-presigned-url-plugin';
|
|
32
|
+
import { checkTypeAgreement } from 'mime-bytes';
|
|
33
|
+
import { Transform } from 'stream';
|
|
34
|
+
import { createBucketNameResolver, createEnsureBucketProvisioned, getPresignedUrlS3Config, } from './presigned-url-resolver';
|
|
23
35
|
const log = new Logger('upload-resolver');
|
|
24
36
|
const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
|
|
25
37
|
let streamer = null;
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The S3 streamer, built from the CDN connection settings.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately constructed with no `defaultBucket`: every upload names the
|
|
42
|
+
* bucket it resolved, and a default here would be an environment-owned bucket
|
|
43
|
+
* standing in for a tenant's.
|
|
44
|
+
*/
|
|
27
45
|
function getStreamer() {
|
|
28
46
|
if (streamer)
|
|
29
47
|
return streamer;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
bucketName = cdn.bucketName || 'test-bucket';
|
|
34
|
-
const awsRegion = cdn.awsRegion || 'us-east-1';
|
|
35
|
-
const awsAccessKey = cdn.awsAccessKey || 'minioadmin';
|
|
36
|
-
const awsSecretKey = cdn.awsSecretKey || 'minioadmin';
|
|
37
|
-
const endpoint = cdn.endpoint || 'http://localhost:9000';
|
|
38
|
-
if (process.env.NODE_ENV === 'production') {
|
|
39
|
-
if (!cdn.awsAccessKey || !cdn.awsSecretKey) {
|
|
40
|
-
log.warn('[upload-resolver] WARNING: Using default credentials in production.');
|
|
41
|
-
}
|
|
48
|
+
const { cdn = {} } = getEnvOptions();
|
|
49
|
+
if (process.env.NODE_ENV === 'production' && (!cdn.awsAccessKey || !cdn.awsSecretKey)) {
|
|
50
|
+
log.warn('[upload-resolver] WARNING: Using default credentials in production.');
|
|
42
51
|
}
|
|
43
|
-
|
|
52
|
+
const provider = cdn.provider || 'minio';
|
|
53
|
+
log.info(`[upload-resolver] Initializing: provider=${provider}`);
|
|
44
54
|
streamer = new Streamer({
|
|
45
|
-
defaultBucket: bucketName,
|
|
46
|
-
awsRegion,
|
|
47
|
-
awsSecretKey,
|
|
48
|
-
awsAccessKey,
|
|
49
|
-
endpoint,
|
|
50
55
|
provider,
|
|
56
|
+
awsRegion: cdn.awsRegion || 'us-east-1',
|
|
57
|
+
awsAccessKey: cdn.awsAccessKey || 'minioadmin',
|
|
58
|
+
awsSecretKey: cdn.awsSecretKey || 'minioadmin',
|
|
59
|
+
endpoint: cdn.endpoint || 'http://localhost:9000',
|
|
51
60
|
});
|
|
52
61
|
return streamer;
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
64
|
+
* The upload lane's view of the presigned plugin's options: the same S3
|
|
65
|
+
* connection, physical-name policy, and provisioning hook the presigned lane
|
|
66
|
+
* uses, so both transports resolve identical coordinates for a bucket.
|
|
67
|
+
*
|
|
68
|
+
* Built on first upload rather than at import time — `createBucketNameResolver`
|
|
69
|
+
* throws on a missing name prefix, and that must surface as a failed upload, not
|
|
70
|
+
* as a server that will not boot.
|
|
57
71
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
let managedOptions = null;
|
|
73
|
+
function getManagedOptions() {
|
|
74
|
+
if (!managedOptions) {
|
|
75
|
+
managedOptions = {
|
|
76
|
+
s3: getPresignedUrlS3Config,
|
|
77
|
+
resolveBucketName: createBucketNameResolver(),
|
|
78
|
+
ensureBucketProvisioned: createEnsureBucketProvisioned(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return managedOptions;
|
|
82
|
+
}
|
|
83
|
+
/** A staging key: transient, and never what the object ends up under. */
|
|
84
|
+
function stagingKey() {
|
|
85
|
+
return `.staging/${randomUUID()}`;
|
|
86
|
+
}
|
|
87
|
+
async function resolveDatabaseId(pgClient) {
|
|
88
|
+
const result = await pgClient.query({ text: `SELECT jwt_private.current_database_id() AS id` });
|
|
89
|
+
return result.rows[0]?.id ?? null;
|
|
61
90
|
}
|
|
62
91
|
/**
|
|
63
|
-
*
|
|
92
|
+
* Which default bucket an *unregistered* column resolves to.
|
|
64
93
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* MIME validation happens before persistence: content type is detected from
|
|
70
|
-
* stream bytes, validated against smart-tag/type rules, and only then uploaded.
|
|
94
|
+
* Columns written by the pre-managed resolver held a directly-embedded URL, so
|
|
95
|
+
* their readers assume a publicly addressable object; resolving them to the
|
|
96
|
+
* private default would break every page rendering one. A registered column
|
|
97
|
+
* states its own intent and this is not consulted.
|
|
71
98
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const { filename } = upload;
|
|
76
|
-
const key = generateKey(filename);
|
|
77
|
-
// MIME type validation from smart tags
|
|
78
|
-
const typ = type || tags?.type;
|
|
99
|
+
const LEGACY_DEFAULT_PUBLIC_ACCESS = true;
|
|
100
|
+
/** The mime allowlist for a column, from its smart tags or its type. */
|
|
101
|
+
function allowedMimeTypes(tags, typ) {
|
|
79
102
|
const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i;
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
if (tags?.mime) {
|
|
104
|
+
return String(tags.mime)
|
|
82
105
|
.trim()
|
|
83
106
|
.split(',')
|
|
84
107
|
.map((a) => a.trim())
|
|
85
|
-
.filter((m) => VALID_MIME.test(m))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
.filter((m) => VALID_MIME.test(m));
|
|
109
|
+
}
|
|
110
|
+
return typ === 'image' ? DEFAULT_IMAGE_MIME_TYPES : [];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Hash and measure bytes as they stream past, without buffering them.
|
|
114
|
+
*
|
|
115
|
+
* The final key is the content hash, which is only known once the last byte has
|
|
116
|
+
* gone by — so the object is staged first and promoted after. Nothing is held in
|
|
117
|
+
* memory: a 2GB upload streams through this the same as a 2KB one.
|
|
118
|
+
*/
|
|
119
|
+
function hashingPassThrough() {
|
|
120
|
+
const hash = createHash('sha256');
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
const stream = new Transform({
|
|
123
|
+
transform(chunk, _encoding, callback) {
|
|
124
|
+
hash.update(chunk);
|
|
125
|
+
bytes += chunk.length;
|
|
126
|
+
callback(null, chunk);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
return Object.assign(stream, {
|
|
130
|
+
digest: () => hash.digest('hex'),
|
|
131
|
+
bytes: () => bytes,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Stream an upload into managed storage and return the value the column stores.
|
|
136
|
+
*
|
|
137
|
+
* Shape by column type hint:
|
|
138
|
+
* * `image` / `upload` (jsonb domains) → the projection document, including a
|
|
139
|
+
* compatibility `url` for public buckets.
|
|
140
|
+
* * `attachment` (text domain) → the object's public URL. A text column cannot
|
|
141
|
+
* hold a projection, so the files row is still authoritative but the column
|
|
142
|
+
* itself carries no id; the row keeps the object alive. A private bucket
|
|
143
|
+
* raises rather than storing an expiring presigned URL in a column.
|
|
144
|
+
*/
|
|
145
|
+
async function uploadResolver(upload, _args, context, info) {
|
|
146
|
+
const { tags, type, field } = info.uploadPlugin;
|
|
147
|
+
const typ = type || tags?.type;
|
|
148
|
+
const withPgClient = context?.withPgClient;
|
|
149
|
+
const pgSettings = context?.pgSettings ?? null;
|
|
150
|
+
if (!withPgClient) {
|
|
151
|
+
throw new Error('UPLOAD_NO_PG_CLIENT: a managed upload resolves its bucket in the database, so the ' +
|
|
152
|
+
'GraphQL context must carry withPgClient');
|
|
153
|
+
}
|
|
154
|
+
if (!field) {
|
|
155
|
+
throw new Error('UPLOAD_FIELD_UNKNOWN: the upload plugin did not report which column is being written, ' +
|
|
156
|
+
'so the storage module and bucket backing it cannot be resolved');
|
|
157
|
+
}
|
|
158
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
159
|
+
if (!databaseId)
|
|
160
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
161
|
+
const target = await resolveManagedUploadTarget({
|
|
162
|
+
options: getManagedOptions(),
|
|
163
|
+
withPgClient,
|
|
164
|
+
pgSettings,
|
|
165
|
+
databaseId,
|
|
166
|
+
field: field,
|
|
167
|
+
defaultPublicAccess: LEGACY_DEFAULT_PUBLIC_ACCESS,
|
|
168
|
+
});
|
|
169
|
+
if (typ === 'attachment' && !target.bucket.is_public) {
|
|
170
|
+
throw new Error('ATTACHMENT_BUCKET_NOT_PUBLIC: an attachment column stores a plain URL, and the resolved ' +
|
|
171
|
+
`bucket "${target.bucket.key}" is private, whose only URLs expire. Use an upload column, ` +
|
|
172
|
+
'which stores the file id and resolves a fresh download URL on read.');
|
|
173
|
+
}
|
|
174
|
+
const s3 = getStreamer();
|
|
175
|
+
const { filename } = upload;
|
|
176
|
+
// Validate before persisting: content type comes from the leading bytes, not
|
|
177
|
+
// from the client's claim about them.
|
|
89
178
|
const detected = await s3.detectContentType({
|
|
90
179
|
readStream: upload.createReadStream(),
|
|
91
180
|
filename,
|
|
92
181
|
});
|
|
93
|
-
|
|
94
|
-
|
|
182
|
+
// The three claims must agree, and disagreement is rejection rather than
|
|
183
|
+
// relabelling: an `.jpg` carrying HTML is served to a browser as an image and
|
|
184
|
+
// executed as a script. Compared against `magic.type` — what the bytes say —
|
|
185
|
+
// not `contentType`, which the detector may have refined using the very
|
|
186
|
+
// extension under suspicion.
|
|
187
|
+
const agreement = checkTypeAgreement({
|
|
188
|
+
filename,
|
|
189
|
+
declaredMime: upload.mimetype,
|
|
190
|
+
detectedMime: detected.magic?.type,
|
|
191
|
+
});
|
|
192
|
+
if (!agreement.ok) {
|
|
193
|
+
detected.stream.destroy();
|
|
194
|
+
throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}. The upload was rejected rather than ` +
|
|
195
|
+
'stored under a type it is not.');
|
|
196
|
+
}
|
|
197
|
+
const allowed = allowedMimeTypes(tags, typ);
|
|
198
|
+
if (allowed.length && !allowed.includes(detected.contentType)) {
|
|
95
199
|
detected.stream.destroy();
|
|
96
200
|
throw new Error('UPLOAD_MIMETYPE');
|
|
97
201
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
202
|
+
const staged = stagingKey();
|
|
203
|
+
const hashing = hashingPassThrough();
|
|
204
|
+
const uploadResult = await s3.uploadWithContentType({
|
|
205
|
+
readStream: detected.stream.pipe(hashing),
|
|
206
|
+
contentType: detected.contentType,
|
|
101
207
|
magic: detected.magic,
|
|
102
|
-
key,
|
|
103
|
-
bucket:
|
|
208
|
+
key: staged,
|
|
209
|
+
bucket: target.physicalName,
|
|
210
|
+
});
|
|
211
|
+
// Owns the staged key from here: it either promotes it into a files row or
|
|
212
|
+
// removes it, so a failed upload leaves nothing behind in S3.
|
|
213
|
+
const { projection } = await finalizeStagedUpload({
|
|
214
|
+
target,
|
|
215
|
+
withPgClient,
|
|
216
|
+
pgSettings,
|
|
217
|
+
staged: {
|
|
218
|
+
stagingKey: staged,
|
|
219
|
+
contentHash: hashing.digest(),
|
|
220
|
+
contentType: uploadResult.contentType,
|
|
221
|
+
size: hashing.bytes(),
|
|
222
|
+
filename,
|
|
223
|
+
},
|
|
104
224
|
});
|
|
105
|
-
const url = result.upload.Location;
|
|
106
|
-
const { contentType } = result;
|
|
107
225
|
switch (typ) {
|
|
108
226
|
case 'image':
|
|
109
227
|
case 'upload':
|
|
110
|
-
|
|
228
|
+
// `filename` and `mime` were in the pre-managed shape and stay in it;
|
|
229
|
+
// `url` is populated for public buckets and deprecated in favour of `id`.
|
|
230
|
+
return { ...projection, filename, mime: uploadResult.contentType };
|
|
111
231
|
case 'attachment':
|
|
112
232
|
default:
|
|
113
|
-
|
|
233
|
+
if (!projection.url) {
|
|
234
|
+
throw new Error(`ATTACHMENT_NO_PUBLIC_URL: bucket "${target.bucket.key}" has no public URL prefix ` +
|
|
235
|
+
'configured, so there is no durable URL to store in a text column');
|
|
236
|
+
}
|
|
237
|
+
return projection.url;
|
|
114
238
|
}
|
|
115
239
|
}
|
|
116
240
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-settings",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.15.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "graphile settings",
|
|
6
6
|
"main": "index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@constructive-io/bucket-provisioner": "^0.24.0",
|
|
34
34
|
"@constructive-io/graphql-env": "^3.29.3",
|
|
35
35
|
"@constructive-io/graphql-types": "^3.28.3",
|
|
36
|
-
"@constructive-io/s3-streamer": "^2.
|
|
36
|
+
"@constructive-io/s3-streamer": "^2.40.1",
|
|
37
37
|
"@constructive-io/s3-utils": "^2.30.0",
|
|
38
38
|
"@constructive-io/upload-names": "^2.29.0",
|
|
39
39
|
"@dataplan/json": "1.0.1",
|
|
@@ -44,33 +44,34 @@
|
|
|
44
44
|
"@pgsql/quotes": "^18.2.4",
|
|
45
45
|
"cors": "^2.8.6",
|
|
46
46
|
"express": "^5.2.1",
|
|
47
|
-
"grafast": "1.1.
|
|
47
|
+
"grafast": "1.1.2",
|
|
48
48
|
"grafserv": "1.0.1",
|
|
49
49
|
"graphile-bucket-provisioner-plugin": "1.12.4",
|
|
50
50
|
"graphile-build": "5.1.1",
|
|
51
51
|
"graphile-build-pg": "5.1.3",
|
|
52
|
-
"graphile-bulk-mutations": "^1.11.
|
|
52
|
+
"graphile-bulk-mutations": "^1.11.8",
|
|
53
53
|
"graphile-config": "1.1.0",
|
|
54
|
-
"graphile-connection-filter": "^2.11.
|
|
55
|
-
"graphile-history": "^1.3.
|
|
56
|
-
"graphile-i18n": "^2.11.
|
|
57
|
-
"graphile-llm": "^1.11.
|
|
58
|
-
"graphile-ltree": "^2.11.
|
|
54
|
+
"graphile-connection-filter": "^2.11.8",
|
|
55
|
+
"graphile-history": "^1.3.8",
|
|
56
|
+
"graphile-i18n": "^2.11.8",
|
|
57
|
+
"graphile-llm": "^1.11.8",
|
|
58
|
+
"graphile-ltree": "^2.11.8",
|
|
59
59
|
"graphile-meta": "^1.7.1",
|
|
60
|
-
"graphile-pg-aggregates": "^2.11.
|
|
61
|
-
"graphile-postgis": "^3.11.
|
|
62
|
-
"graphile-presigned-url-plugin": "^1.
|
|
60
|
+
"graphile-pg-aggregates": "^2.11.8",
|
|
61
|
+
"graphile-postgis": "^3.11.8",
|
|
62
|
+
"graphile-presigned-url-plugin": "^1.13.0",
|
|
63
63
|
"graphile-realtime-subscriptions": "^1.10.5",
|
|
64
|
-
"graphile-search": "^2.11.
|
|
65
|
-
"graphile-upload-plugin": "^2.
|
|
64
|
+
"graphile-search": "^2.11.8",
|
|
65
|
+
"graphile-upload-plugin": "^2.25.0",
|
|
66
66
|
"graphile-utils": "5.0.3",
|
|
67
67
|
"graphql": "16.13.0",
|
|
68
68
|
"inflekt": "^0.8.1",
|
|
69
69
|
"lru-cache": "^11.2.7",
|
|
70
|
+
"mime-bytes": "^0.31.0",
|
|
70
71
|
"pg": "^8.21.0",
|
|
71
72
|
"pg-query-context": "^2.29.1",
|
|
72
73
|
"pg-sql2": "5.0.1",
|
|
73
|
-
"postgraphile": "5.1.
|
|
74
|
+
"postgraphile": "5.1.4",
|
|
74
75
|
"request-ip": "^3.3.0",
|
|
75
76
|
"tamedevil": "0.1.1"
|
|
76
77
|
},
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
"@types/express": "^5.0.6",
|
|
80
81
|
"@types/pg": "^8.20.4",
|
|
81
82
|
"@types/request-ip": "^0.0.41",
|
|
82
|
-
"graphile-test": "^5.11.
|
|
83
|
+
"graphile-test": "^5.11.8",
|
|
83
84
|
"makage": "^0.3.0",
|
|
84
85
|
"nodemon": "^3.1.14",
|
|
85
86
|
"ts-node": "^10.9.2"
|
|
@@ -91,5 +92,5 @@
|
|
|
91
92
|
"constructive",
|
|
92
93
|
"graphql"
|
|
93
94
|
],
|
|
94
|
-
"gitHead": "
|
|
95
|
+
"gitHead": "d0d796b7eb0d16c8c19405a49c0ca62ce3df117b"
|
|
95
96
|
}
|
|
@@ -19,9 +19,10 @@ import type { BucketNameResolver, EnsureBucketProvisioned, S3Config } from 'grap
|
|
|
19
19
|
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
|
|
20
20
|
* the result. Same CDN config as upload-resolver.ts.
|
|
21
21
|
*
|
|
22
|
-
* NOTE: The `bucket` field here is the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* NOTE: The `bucket` field here is only the connection's default and is never
|
|
23
|
+
* uploaded to. Every managed upload names its bucket explicitly, resolved from
|
|
24
|
+
* the tenant's logical bucket row via `resolveBucketName`; there is no
|
|
25
|
+
* environment-global upload bucket.
|
|
25
26
|
*/
|
|
26
27
|
export declare function getPresignedUrlS3Config(): S3Config;
|
|
27
28
|
/**
|
|
@@ -31,9 +31,10 @@ let s3Config = null;
|
|
|
31
31
|
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
|
|
32
32
|
* the result. Same CDN config as upload-resolver.ts.
|
|
33
33
|
*
|
|
34
|
-
* NOTE: The `bucket` field here is the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* NOTE: The `bucket` field here is only the connection's default and is never
|
|
35
|
+
* uploaded to. Every managed upload names its bucket explicitly, resolved from
|
|
36
|
+
* the tenant's logical bucket row via `resolveBucketName`; there is no
|
|
37
|
+
* environment-global upload bucket.
|
|
37
38
|
*/
|
|
38
39
|
function getPresignedUrlS3Config() {
|
|
39
40
|
if (s3Config)
|
package/upload-resolver.d.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Upload resolver for the Constructive upload plugin.
|
|
2
|
+
* Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
4
|
+
* This is the streaming transport into the *managed* storage lane: bytes arrive
|
|
5
|
+
* on the mutation, and the file they carry gets the same treatment a presigned
|
|
6
|
+
* upload gets — a bucket resolved inside the tenant, a content-addressed key, a
|
|
7
|
+
* files row, and a projection document naming that row.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* It used to be a second storage model: stream to `BUCKET_NAME` under a random
|
|
10
|
+
* key, hand back a URL, record nothing. Objects written that way belonged to no
|
|
11
|
+
* database, could not be deduplicated, listed, or access-controlled, and storage
|
|
12
|
+
* GC could not see that a document still pointed at them. There is no
|
|
13
|
+
* environment bucket in this path any more; `cdn.*` supplies S3 credentials and
|
|
14
|
+
* an endpoint only.
|
|
9
15
|
*
|
|
10
|
-
*
|
|
16
|
+
* Compatibility: `image`/`upload` columns still receive `url` alongside the new
|
|
17
|
+
* `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
|
|
18
|
+
* working while they migrate to `id` + the files row's late-bound `downloadUrl`.
|
|
19
|
+
*
|
|
20
|
+
* ENV VARS (S3 connection only):
|
|
11
21
|
* BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
|
|
12
|
-
* BUCKET_NAME - bucket name (default: 'test-bucket')
|
|
13
22
|
* AWS_REGION - AWS region (default: 'us-east-1')
|
|
14
23
|
* AWS_ACCESS_KEY - access key (default: 'minioadmin')
|
|
15
24
|
* AWS_SECRET_KEY - secret key (default: 'minioadmin')
|
package/upload-resolver.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Upload resolver for the Constructive upload plugin.
|
|
3
|
+
* Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* and
|
|
5
|
+
* This is the streaming transport into the *managed* storage lane: bytes arrive
|
|
6
|
+
* on the mutation, and the file they carry gets the same treatment a presigned
|
|
7
|
+
* upload gets — a bucket resolved inside the tenant, a content-addressed key, a
|
|
8
|
+
* files row, and a projection document naming that row.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* It used to be a second storage model: stream to `BUCKET_NAME` under a random
|
|
11
|
+
* key, hand back a URL, record nothing. Objects written that way belonged to no
|
|
12
|
+
* database, could not be deduplicated, listed, or access-controlled, and storage
|
|
13
|
+
* GC could not see that a document still pointed at them. There is no
|
|
14
|
+
* environment bucket in this path any more; `cdn.*` supplies S3 credentials and
|
|
15
|
+
* an endpoint only.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
17
|
+
* Compatibility: `image`/`upload` columns still receive `url` alongside the new
|
|
18
|
+
* `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
|
|
19
|
+
* working while they migrate to `id` + the files row's late-bound `downloadUrl`.
|
|
20
|
+
*
|
|
21
|
+
* ENV VARS (S3 connection only):
|
|
12
22
|
* BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
|
|
13
|
-
* BUCKET_NAME - bucket name (default: 'test-bucket')
|
|
14
23
|
* AWS_REGION - AWS region (default: 'us-east-1')
|
|
15
24
|
* AWS_ACCESS_KEY - access key (default: 'minioadmin')
|
|
16
25
|
* AWS_SECRET_KEY - secret key (default: 'minioadmin')
|
|
@@ -23,100 +32,215 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
23
32
|
exports.constructiveUploadFieldDefinitions = void 0;
|
|
24
33
|
const graphql_env_1 = require("@constructive-io/graphql-env");
|
|
25
34
|
const s3_streamer_1 = __importDefault(require("@constructive-io/s3-streamer"));
|
|
26
|
-
const upload_names_1 = __importDefault(require("@constructive-io/upload-names"));
|
|
27
35
|
const logger_1 = require("@pgpmjs/logger");
|
|
28
36
|
const crypto_1 = require("crypto");
|
|
37
|
+
const graphile_presigned_url_plugin_1 = require("graphile-presigned-url-plugin");
|
|
38
|
+
const mime_bytes_1 = require("mime-bytes");
|
|
39
|
+
const stream_1 = require("stream");
|
|
40
|
+
const presigned_url_resolver_1 = require("./presigned-url-resolver");
|
|
29
41
|
const log = new logger_1.Logger('upload-resolver');
|
|
30
42
|
const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
|
|
31
43
|
let streamer = null;
|
|
32
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The S3 streamer, built from the CDN connection settings.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately constructed with no `defaultBucket`: every upload names the
|
|
48
|
+
* bucket it resolved, and a default here would be an environment-owned bucket
|
|
49
|
+
* standing in for a tenant's.
|
|
50
|
+
*/
|
|
33
51
|
function getStreamer() {
|
|
34
52
|
if (streamer)
|
|
35
53
|
return streamer;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
bucketName = cdn.bucketName || 'test-bucket';
|
|
40
|
-
const awsRegion = cdn.awsRegion || 'us-east-1';
|
|
41
|
-
const awsAccessKey = cdn.awsAccessKey || 'minioadmin';
|
|
42
|
-
const awsSecretKey = cdn.awsSecretKey || 'minioadmin';
|
|
43
|
-
const endpoint = cdn.endpoint || 'http://localhost:9000';
|
|
44
|
-
if (process.env.NODE_ENV === 'production') {
|
|
45
|
-
if (!cdn.awsAccessKey || !cdn.awsSecretKey) {
|
|
46
|
-
log.warn('[upload-resolver] WARNING: Using default credentials in production.');
|
|
47
|
-
}
|
|
54
|
+
const { cdn = {} } = (0, graphql_env_1.getEnvOptions)();
|
|
55
|
+
if (process.env.NODE_ENV === 'production' && (!cdn.awsAccessKey || !cdn.awsSecretKey)) {
|
|
56
|
+
log.warn('[upload-resolver] WARNING: Using default credentials in production.');
|
|
48
57
|
}
|
|
49
|
-
|
|
58
|
+
const provider = cdn.provider || 'minio';
|
|
59
|
+
log.info(`[upload-resolver] Initializing: provider=${provider}`);
|
|
50
60
|
streamer = new s3_streamer_1.default({
|
|
51
|
-
defaultBucket: bucketName,
|
|
52
|
-
awsRegion,
|
|
53
|
-
awsSecretKey,
|
|
54
|
-
awsAccessKey,
|
|
55
|
-
endpoint,
|
|
56
61
|
provider,
|
|
62
|
+
awsRegion: cdn.awsRegion || 'us-east-1',
|
|
63
|
+
awsAccessKey: cdn.awsAccessKey || 'minioadmin',
|
|
64
|
+
awsSecretKey: cdn.awsSecretKey || 'minioadmin',
|
|
65
|
+
endpoint: cdn.endpoint || 'http://localhost:9000',
|
|
57
66
|
});
|
|
58
67
|
return streamer;
|
|
59
68
|
}
|
|
60
69
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
70
|
+
* The upload lane's view of the presigned plugin's options: the same S3
|
|
71
|
+
* connection, physical-name policy, and provisioning hook the presigned lane
|
|
72
|
+
* uses, so both transports resolve identical coordinates for a bucket.
|
|
73
|
+
*
|
|
74
|
+
* Built on first upload rather than at import time — `createBucketNameResolver`
|
|
75
|
+
* throws on a missing name prefix, and that must surface as a failed upload, not
|
|
76
|
+
* as a server that will not boot.
|
|
63
77
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
let managedOptions = null;
|
|
79
|
+
function getManagedOptions() {
|
|
80
|
+
if (!managedOptions) {
|
|
81
|
+
managedOptions = {
|
|
82
|
+
s3: presigned_url_resolver_1.getPresignedUrlS3Config,
|
|
83
|
+
resolveBucketName: (0, presigned_url_resolver_1.createBucketNameResolver)(),
|
|
84
|
+
ensureBucketProvisioned: (0, presigned_url_resolver_1.createEnsureBucketProvisioned)(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return managedOptions;
|
|
88
|
+
}
|
|
89
|
+
/** A staging key: transient, and never what the object ends up under. */
|
|
90
|
+
function stagingKey() {
|
|
91
|
+
return `.staging/${(0, crypto_1.randomUUID)()}`;
|
|
92
|
+
}
|
|
93
|
+
async function resolveDatabaseId(pgClient) {
|
|
94
|
+
const result = await pgClient.query({ text: `SELECT jwt_private.current_database_id() AS id` });
|
|
95
|
+
return result.rows[0]?.id ?? null;
|
|
67
96
|
}
|
|
68
97
|
/**
|
|
69
|
-
*
|
|
98
|
+
* Which default bucket an *unregistered* column resolves to.
|
|
70
99
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* MIME validation happens before persistence: content type is detected from
|
|
76
|
-
* stream bytes, validated against smart-tag/type rules, and only then uploaded.
|
|
100
|
+
* Columns written by the pre-managed resolver held a directly-embedded URL, so
|
|
101
|
+
* their readers assume a publicly addressable object; resolving them to the
|
|
102
|
+
* private default would break every page rendering one. A registered column
|
|
103
|
+
* states its own intent and this is not consulted.
|
|
77
104
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const { filename } = upload;
|
|
82
|
-
const key = generateKey(filename);
|
|
83
|
-
// MIME type validation from smart tags
|
|
84
|
-
const typ = type || tags?.type;
|
|
105
|
+
const LEGACY_DEFAULT_PUBLIC_ACCESS = true;
|
|
106
|
+
/** The mime allowlist for a column, from its smart tags or its type. */
|
|
107
|
+
function allowedMimeTypes(tags, typ) {
|
|
85
108
|
const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i;
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
if (tags?.mime) {
|
|
110
|
+
return String(tags.mime)
|
|
88
111
|
.trim()
|
|
89
112
|
.split(',')
|
|
90
113
|
.map((a) => a.trim())
|
|
91
|
-
.filter((m) => VALID_MIME.test(m))
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
114
|
+
.filter((m) => VALID_MIME.test(m));
|
|
115
|
+
}
|
|
116
|
+
return typ === 'image' ? DEFAULT_IMAGE_MIME_TYPES : [];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Hash and measure bytes as they stream past, without buffering them.
|
|
120
|
+
*
|
|
121
|
+
* The final key is the content hash, which is only known once the last byte has
|
|
122
|
+
* gone by — so the object is staged first and promoted after. Nothing is held in
|
|
123
|
+
* memory: a 2GB upload streams through this the same as a 2KB one.
|
|
124
|
+
*/
|
|
125
|
+
function hashingPassThrough() {
|
|
126
|
+
const hash = (0, crypto_1.createHash)('sha256');
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
const stream = new stream_1.Transform({
|
|
129
|
+
transform(chunk, _encoding, callback) {
|
|
130
|
+
hash.update(chunk);
|
|
131
|
+
bytes += chunk.length;
|
|
132
|
+
callback(null, chunk);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
return Object.assign(stream, {
|
|
136
|
+
digest: () => hash.digest('hex'),
|
|
137
|
+
bytes: () => bytes,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Stream an upload into managed storage and return the value the column stores.
|
|
142
|
+
*
|
|
143
|
+
* Shape by column type hint:
|
|
144
|
+
* * `image` / `upload` (jsonb domains) → the projection document, including a
|
|
145
|
+
* compatibility `url` for public buckets.
|
|
146
|
+
* * `attachment` (text domain) → the object's public URL. A text column cannot
|
|
147
|
+
* hold a projection, so the files row is still authoritative but the column
|
|
148
|
+
* itself carries no id; the row keeps the object alive. A private bucket
|
|
149
|
+
* raises rather than storing an expiring presigned URL in a column.
|
|
150
|
+
*/
|
|
151
|
+
async function uploadResolver(upload, _args, context, info) {
|
|
152
|
+
const { tags, type, field } = info.uploadPlugin;
|
|
153
|
+
const typ = type || tags?.type;
|
|
154
|
+
const withPgClient = context?.withPgClient;
|
|
155
|
+
const pgSettings = context?.pgSettings ?? null;
|
|
156
|
+
if (!withPgClient) {
|
|
157
|
+
throw new Error('UPLOAD_NO_PG_CLIENT: a managed upload resolves its bucket in the database, so the ' +
|
|
158
|
+
'GraphQL context must carry withPgClient');
|
|
159
|
+
}
|
|
160
|
+
if (!field) {
|
|
161
|
+
throw new Error('UPLOAD_FIELD_UNKNOWN: the upload plugin did not report which column is being written, ' +
|
|
162
|
+
'so the storage module and bucket backing it cannot be resolved');
|
|
163
|
+
}
|
|
164
|
+
const databaseId = await (0, graphile_presigned_url_plugin_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
165
|
+
if (!databaseId)
|
|
166
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
167
|
+
const target = await (0, graphile_presigned_url_plugin_1.resolveManagedUploadTarget)({
|
|
168
|
+
options: getManagedOptions(),
|
|
169
|
+
withPgClient,
|
|
170
|
+
pgSettings,
|
|
171
|
+
databaseId,
|
|
172
|
+
field: field,
|
|
173
|
+
defaultPublicAccess: LEGACY_DEFAULT_PUBLIC_ACCESS,
|
|
174
|
+
});
|
|
175
|
+
if (typ === 'attachment' && !target.bucket.is_public) {
|
|
176
|
+
throw new Error('ATTACHMENT_BUCKET_NOT_PUBLIC: an attachment column stores a plain URL, and the resolved ' +
|
|
177
|
+
`bucket "${target.bucket.key}" is private, whose only URLs expire. Use an upload column, ` +
|
|
178
|
+
'which stores the file id and resolves a fresh download URL on read.');
|
|
179
|
+
}
|
|
180
|
+
const s3 = getStreamer();
|
|
181
|
+
const { filename } = upload;
|
|
182
|
+
// Validate before persisting: content type comes from the leading bytes, not
|
|
183
|
+
// from the client's claim about them.
|
|
95
184
|
const detected = await s3.detectContentType({
|
|
96
185
|
readStream: upload.createReadStream(),
|
|
97
186
|
filename,
|
|
98
187
|
});
|
|
99
|
-
|
|
100
|
-
|
|
188
|
+
// The three claims must agree, and disagreement is rejection rather than
|
|
189
|
+
// relabelling: an `.jpg` carrying HTML is served to a browser as an image and
|
|
190
|
+
// executed as a script. Compared against `magic.type` — what the bytes say —
|
|
191
|
+
// not `contentType`, which the detector may have refined using the very
|
|
192
|
+
// extension under suspicion.
|
|
193
|
+
const agreement = (0, mime_bytes_1.checkTypeAgreement)({
|
|
194
|
+
filename,
|
|
195
|
+
declaredMime: upload.mimetype,
|
|
196
|
+
detectedMime: detected.magic?.type,
|
|
197
|
+
});
|
|
198
|
+
if (!agreement.ok) {
|
|
199
|
+
detected.stream.destroy();
|
|
200
|
+
throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}. The upload was rejected rather than ` +
|
|
201
|
+
'stored under a type it is not.');
|
|
202
|
+
}
|
|
203
|
+
const allowed = allowedMimeTypes(tags, typ);
|
|
204
|
+
if (allowed.length && !allowed.includes(detected.contentType)) {
|
|
101
205
|
detected.stream.destroy();
|
|
102
206
|
throw new Error('UPLOAD_MIMETYPE');
|
|
103
207
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
208
|
+
const staged = stagingKey();
|
|
209
|
+
const hashing = hashingPassThrough();
|
|
210
|
+
const uploadResult = await s3.uploadWithContentType({
|
|
211
|
+
readStream: detected.stream.pipe(hashing),
|
|
212
|
+
contentType: detected.contentType,
|
|
107
213
|
magic: detected.magic,
|
|
108
|
-
key,
|
|
109
|
-
bucket:
|
|
214
|
+
key: staged,
|
|
215
|
+
bucket: target.physicalName,
|
|
216
|
+
});
|
|
217
|
+
// Owns the staged key from here: it either promotes it into a files row or
|
|
218
|
+
// removes it, so a failed upload leaves nothing behind in S3.
|
|
219
|
+
const { projection } = await (0, graphile_presigned_url_plugin_1.finalizeStagedUpload)({
|
|
220
|
+
target,
|
|
221
|
+
withPgClient,
|
|
222
|
+
pgSettings,
|
|
223
|
+
staged: {
|
|
224
|
+
stagingKey: staged,
|
|
225
|
+
contentHash: hashing.digest(),
|
|
226
|
+
contentType: uploadResult.contentType,
|
|
227
|
+
size: hashing.bytes(),
|
|
228
|
+
filename,
|
|
229
|
+
},
|
|
110
230
|
});
|
|
111
|
-
const url = result.upload.Location;
|
|
112
|
-
const { contentType } = result;
|
|
113
231
|
switch (typ) {
|
|
114
232
|
case 'image':
|
|
115
233
|
case 'upload':
|
|
116
|
-
|
|
234
|
+
// `filename` and `mime` were in the pre-managed shape and stay in it;
|
|
235
|
+
// `url` is populated for public buckets and deprecated in favour of `id`.
|
|
236
|
+
return { ...projection, filename, mime: uploadResult.contentType };
|
|
117
237
|
case 'attachment':
|
|
118
238
|
default:
|
|
119
|
-
|
|
239
|
+
if (!projection.url) {
|
|
240
|
+
throw new Error(`ATTACHMENT_NO_PUBLIC_URL: bucket "${target.bucket.key}" has no public URL prefix ` +
|
|
241
|
+
'configured, so there is no durable URL to store in a text column');
|
|
242
|
+
}
|
|
243
|
+
return projection.url;
|
|
120
244
|
}
|
|
121
245
|
}
|
|
122
246
|
/**
|