Skip to content

Commit 337e753

Browse files
committed
Expose logic for buckets to parse signed urls
1 parent c800421 commit 337e753

File tree

5 files changed

+82
-13
lines changed

5 files changed

+82
-13
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: 20 additions & 10 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';
@@ -108,27 +108,37 @@ 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
};
124122
assert(parsed.operation === operation.constructor.name);
123+
return parsed;
124+
} catch (e) {
125+
throw new InvalidSignedUrlException(e);
126+
}
127+
}
128+
129+
parseSignedUrl(url: URL) {
130+
const raw = url.searchParams.get('signed');
131+
let parsed;
132+
try {
133+
parsed = JSON.parse(raw || '') as SignedOp<{ operation: string }>;
134+
assert(typeof parsed.operation === 'string');
125135
assert(typeof parsed.Key === 'string');
126136
assert(typeof parsed.signing.expiresIn === 'number');
127137
} catch (e) {
128-
throw new InputException(e);
138+
throw new InvalidSignedUrlException(e);
129139
}
130140
if (DateTime.local() > DateTime.fromMillis(parsed.signing.expiresIn)) {
131-
throw new InputException('url expired');
141+
throw new InvalidSignedUrlException('URL expired');
132142
}
133143
return parsed;
134144
}

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({

0 commit comments

Comments
 (0)