Skip to content

Commit 14c9bd8

Browse files
authored
Merge pull request #2891 from SeedCompany/local-file-bucket-urls-compatibility-with-interface
2 parents 96a1fab + 04d4ca1 commit 14c9bd8

File tree

6 files changed

+114
-32
lines changed

6 files changed

+114
-32
lines changed

src/components/file/bucket/composite-bucket.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NotFoundException } from '~/common';
55
import {
66
FileBucket,
77
GetObjectOutput,
8+
InvalidSignedUrlException,
89
PutObjectInput,
910
SignedOp,
1011
} from './file-bucket';
@@ -41,6 +42,18 @@ export class CompositeBucket extends FileBucket {
4142
return await source.getSignedUrl(operation, input);
4243
}
4344

45+
async parseSignedUrl(url: URL) {
46+
const results = await Promise.allSettled(
47+
this.sources.map(async (source) => await source.parseSignedUrl(url)),
48+
);
49+
for (const result of results) {
50+
if (result.status === 'fulfilled') {
51+
return result.value;
52+
}
53+
}
54+
throw new InvalidSignedUrlException();
55+
}
56+
4457
async getObject(key: string): Promise<GetObjectOutput> {
4558
const [source] = await this.selectSource(key);
4659
return await source.getObject(key);

src/components/file/bucket/file-bucket.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ import {
55
} from '@aws-sdk/client-s3';
66
import { RequestPresigningArguments } from '@aws-sdk/types';
77
import { Type } from '@nestjs/common';
8+
import { MaybeAsync } from '@seedcompany/common';
89
import { Command } from '@smithy/smithy-client';
910
import { NodeJsRuntimeStreamingBlobPayloadInputTypes } from '@smithy/types/dist-types/streaming-payload/streaming-blob-payload-input-types';
1011
import { Readable } from 'stream';
11-
import { Except, Merge, SetNonNullable, SetRequired } from 'type-fest';
12-
import { DurationIn } from '~/common';
12+
import {
13+
Except,
14+
LiteralUnion,
15+
Merge,
16+
SetNonNullable,
17+
SetRequired,
18+
} from 'type-fest';
19+
import { DurationIn, InputException, InputExceptionArgs } from '~/common';
1320

1421
// Limit body to only `Readable` which is always the case for Nodejs execution.
1522
export type GetObjectOutput = Merge<AwsGetObjectOutput, { Body: Readable }>;
@@ -41,6 +48,10 @@ export abstract class FileBucket {
4148
operation: Type<Command<TCommandInput, any, any>>,
4249
input: SignedOp<TCommandInput>,
4350
): Promise<string>;
51+
abstract parseSignedUrl(url: URL): MaybeAsync<{
52+
Key: string;
53+
operation: LiteralUnion<'PutObject' | 'GetObject', string>;
54+
}>;
4455

4556
abstract getObject(key: string): Promise<GetObjectOutput>;
4657
abstract headObject(key: string): Promise<HeadObjectOutput>;
@@ -52,3 +63,9 @@ export abstract class FileBucket {
5263
await this.deleteObject(oldKey);
5364
}
5465
}
66+
67+
export class InvalidSignedUrlException extends InputException {
68+
constructor(...args: InputExceptionArgs) {
69+
super(...InputException.parseArgs('Invalid signed URL', args));
70+
}
71+
}

src/components/file/bucket/local-bucket.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import { DateTime, Duration } from 'luxon';
1010
import { URL } from 'node:url';
1111
import { Readable } from 'stream';
1212
import { assert } from 'ts-essentials';
13-
import { InputException } from '~/common';
1413
import {
1514
FileBucket,
1615
GetObjectOutput,
16+
InvalidSignedUrlException,
1717
PutObjectInput,
1818
SignedOp,
1919
} from './file-bucket';
@@ -87,7 +87,7 @@ export abstract class LocalBucket<
8787
input: SignedOp<TCommandInput>,
8888
) {
8989
const signed = JSON.stringify({
90-
operation: operation.constructor.name,
90+
operation: operation.name.replace(/Command$/, ''),
9191
...input,
9292
signing: {
9393
...input.signing,
@@ -108,27 +108,40 @@ export abstract class LocalBucket<
108108
operation: Type<Command<TCommandInput, any, any>>,
109109
url: string,
110110
): SignedOp<TCommandInput> & { Key: string } {
111-
let raw;
111+
let u: URL;
112112
try {
113-
raw = new URL(url).searchParams.get('signed');
113+
u = new URL(url);
114114
} catch (e) {
115-
raw = url;
115+
u = new URL('http://localhost');
116+
u.searchParams.set('signed', url);
116117
}
117-
assert(typeof raw === 'string');
118-
let parsed;
119118
try {
120-
parsed = JSON.parse(raw) as SignedOp<TCommandInput> & {
119+
const parsed = this.parseSignedUrl(u) as SignedOp<TCommandInput> & {
121120
operation: string;
122-
Key: string;
123121
};
124-
assert(parsed.operation === operation.constructor.name);
122+
assert(
123+
parsed.operation === operation.name ||
124+
`${parsed.operation}Command` === operation.name,
125+
);
126+
return parsed;
127+
} catch (e) {
128+
throw new InvalidSignedUrlException(e);
129+
}
130+
}
131+
132+
parseSignedUrl(url: URL) {
133+
const raw = url.searchParams.get('signed');
134+
let parsed;
135+
try {
136+
parsed = JSON.parse(raw || '') as SignedOp<{ operation: string }>;
137+
assert(typeof parsed.operation === 'string');
125138
assert(typeof parsed.Key === 'string');
126139
assert(typeof parsed.signing.expiresIn === 'number');
127140
} catch (e) {
128-
throw new InputException(e);
141+
throw new InvalidSignedUrlException(e);
129142
}
130143
if (DateTime.local() > DateTime.fromMillis(parsed.signing.expiresIn)) {
131-
throw new InputException('url expired');
144+
throw new InvalidSignedUrlException('URL expired');
132145
}
133146
return parsed;
134147
}

src/components/file/bucket/readonly-bucket.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export class ReadonlyBucket extends FileBucket {
1818
return await this.source.getSignedUrl(operation, input);
1919
}
2020

21+
async parseSignedUrl(url: URL) {
22+
return await this.source.parseSignedUrl(url);
23+
}
24+
2125
async getObject(key: string) {
2226
return await this.source.getObject(key);
2327
}

src/components/file/bucket/s3-bucket.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
33
import { Type } from '@nestjs/common';
44
import { bufferFromStream } from '@seedcompany/common';
55
import { Command } from '@smithy/smithy-client';
6+
import got from 'got';
67
import { Duration } from 'luxon';
78
import { join } from 'path/posix';
89
import { Readable } from 'stream';
910
import { NotFoundException } from '~/common';
10-
import { FileBucket, PutObjectInput, SignedOp } from './file-bucket';
11+
import {
12+
FileBucket,
13+
InvalidSignedUrlException,
14+
PutObjectInput,
15+
SignedOp,
16+
} from './file-bucket';
1117

1218
/**
1319
* A bucket that actually connects to S3.
@@ -37,6 +43,25 @@ export class S3Bucket extends FileBucket {
3743
});
3844
}
3945

46+
async parseSignedUrl(url: URL) {
47+
if (
48+
!url.hostname.startsWith(this.bucket + '.') ||
49+
!url.hostname.endsWith('.amazonaws.com')
50+
) {
51+
throw new InvalidSignedUrlException();
52+
}
53+
54+
try {
55+
await got.head(url);
56+
} catch (e) {
57+
throw new InvalidSignedUrlException(e);
58+
}
59+
60+
const Key = url.pathname.slice(1);
61+
const operation = url.searchParams.get('x-id')!;
62+
return { Key, operation };
63+
}
64+
4065
async getObject(key: string) {
4166
const file = await this.s3
4267
.getObject({

src/components/file/local-bucket.controller.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,55 +3,65 @@ import {
33
Get,
44
Headers,
55
Put,
6-
Query,
76
Request,
87
Response,
98
} from '@nestjs/common';
109
import { Request as IRequest, Response as IResponse } from 'express';
1110
import { DateTime } from 'luxon';
11+
import { URL } from 'node:url';
1212
import rawBody from 'raw-body';
13-
import { InputException, ServerException } from '../../common';
14-
import { FileBucket, LocalBucket } from './bucket';
13+
import { InputException } from '~/common';
14+
import { FileBucket, InvalidSignedUrlException } from './bucket';
1515

16+
/**
17+
* This fakes S3 web hosting for use with LocalBuckets.
18+
*/
1619
@Controller(LocalBucketController.path)
1720
export class LocalBucketController {
1821
static path = '/local-bucket';
1922

20-
private readonly bucket: LocalBucket | undefined;
21-
constructor(bucket: FileBucket) {
22-
this.bucket = bucket instanceof LocalBucket ? bucket : undefined;
23-
}
23+
constructor(private readonly bucket: FileBucket) {}
2424

2525
@Put()
2626
async upload(
2727
@Headers('content-type') contentType: string,
28-
@Query('signed') signed: string,
2928
@Request() req: IRequest,
3029
) {
31-
if (!this.bucket) {
32-
throw new ServerException('Cannot upload file here');
33-
}
3430
// Chokes on json files because they are parsed with body-parser.
3531
// Need to disable it for this path or create a workaround.
3632
const contents = await rawBody(req);
3733
if (!contents) {
3834
throw new InputException();
3935
}
4036

41-
await this.bucket.upload(signed, {
37+
const url = new URL(`https://localhost${req.url}`);
38+
const parsed = await this.bucket.parseSignedUrl(url);
39+
if (parsed.operation !== 'PutObject') {
40+
throw new InvalidSignedUrlException();
41+
}
42+
await this.bucket.putObject({
43+
Key: parsed.Key,
4244
Body: contents,
4345
ContentType: contentType,
4446
});
47+
4548
return { ok: true };
4649
}
4750

4851
@Get()
49-
async download(@Query('signed') signed: string, @Response() res: IResponse) {
50-
if (!this.bucket) {
51-
throw new ServerException('Cannot download file here');
52+
async download(@Request() req: IRequest, @Response() res: IResponse) {
53+
const url = new URL(`https://localhost${req.url}`);
54+
const { Key, operation, ...rest } = await this.bucket.parseSignedUrl(url);
55+
if (operation !== 'GetObject') {
56+
throw new InvalidSignedUrlException();
5257
}
53-
54-
const out = await this.bucket.download(signed);
58+
const signedParams = Object.fromEntries(
59+
Object.entries(rest).flatMap(([k, v]) =>
60+
v != null ? [[k.replace(/^Response/, ''), v]] : [],
61+
),
62+
);
63+
const bucketObject = await this.bucket.getObject(Key);
64+
const out = { ...bucketObject, ...signedParams };
5565

5666
const headers = {
5767
'Cache-Control': out.CacheControl,

0 commit comments

Comments
 (0)