Skip to content

Commit 96a1fab

Browse files
authored
Merge pull request #2888 from SeedCompany/dev-upload
2 parents 1875b0e + c800421 commit 96a1fab

File tree

15 files changed

+445
-60
lines changed

15 files changed

+445
-60
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@
6363
"extensionless": "^1.4.5",
6464
"fast-safe-stringify": "^2.1.1",
6565
"fastest-levenshtein": "^1.0.16",
66+
"file-type": "^18.5.0",
6667
"got": "^13.0.0",
6768
"graphql": "^16.8.0",
6869
"graphql-parse-resolve-info": "^4.13.0",
6970
"graphql-scalars": "^1.22.2",
71+
"graphql-upload": "^16.0.2",
7072
"human-format": "^1.2.0",
7173
"image-size": "^1.0.2",
7274
"ioredis": "^5.3.2",
@@ -77,6 +79,7 @@
7779
"lodash": "npm:lodash-es@^4.17.21",
7880
"lru-cache": "^7.18.3",
7981
"luxon": "^3.4.0",
82+
"mime": "beta",
8083
"nanoid": "^4.0.2",
8184
"neo4j-driver": "^5.12.0",
8285
"nestjs-console": "^9.0.0",
@@ -91,6 +94,7 @@
9194
"reflect-metadata": "^0.1.13",
9295
"rimraf": "^5.0.1",
9396
"rxjs": "^7.8.1",
97+
"sanitize-filename": "^1.6.3",
9498
"ts-essentials": "^9.3.2",
9599
"winston": "^3.10.0",
96100
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz",
@@ -107,6 +111,7 @@
107111
"@types/express": "^4.17.17",
108112
"@types/express-serve-static-core": "^4.17.35",
109113
"@types/ffprobe": "^1.1.5",
114+
"@types/graphql-upload": "^16.0.1",
110115
"@types/jest": "^29.5.3",
111116
"@types/jsonwebtoken": "^9.0.2",
112117
"@types/lodash": "^4.14.197",

src/common/scalars.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Type } from '@nestjs/common';
22
import { CustomScalar } from '@nestjs/graphql';
33
import { GraphQLScalarType } from 'graphql';
4+
import UploadScalar from 'graphql-upload/GraphQLUpload.mjs';
45
import { DateScalar, DateTimeScalar } from './luxon.graphql';
56
import { RichTextScalar } from './rich-text.scalar';
67
import { UrlScalar } from './url.field';
@@ -12,5 +13,6 @@ export const getRegisteredScalars = (): Scalar[] => [
1213
DateScalar,
1314
DateTimeScalar,
1415
RichTextScalar,
16+
UploadScalar,
1517
UrlScalar,
1618
];

src/components/engagement/events/engagement-updated.event.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,20 @@ export class EngagementUpdatedEvent {
1111
constructor(
1212
public updated: UnsecuredDto<Engagement>,
1313
readonly previous: UnsecuredDto<Engagement>,
14-
readonly updates: UpdateLanguageEngagement | UpdateInternshipEngagement,
14+
readonly input: UpdateLanguageEngagement | UpdateInternshipEngagement,
1515
readonly session: Session,
1616
) {}
1717

1818
isLanguageEngagement(): this is EngagementUpdatedEvent & {
19-
engagement: UnsecuredDto<LanguageEngagement>;
20-
updates: UpdateLanguageEngagement;
19+
updated: UnsecuredDto<LanguageEngagement>;
20+
input: UpdateLanguageEngagement;
2121
} {
2222
return this.updated.__typename === 'LanguageEngagement';
2323
}
2424

2525
isInternshipEngagement(): this is EngagementUpdatedEvent & {
26-
engagement: UnsecuredDto<InternshipEngagement>;
27-
updates: UpdateInternshipEngagement;
26+
updated: UnsecuredDto<InternshipEngagement>;
27+
input: UpdateInternshipEngagement;
2828
} {
2929
return this.updated.__typename === 'InternshipEngagement';
3030
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { HeadObjectOutput } from '@aws-sdk/client-s3';
22
import { Type } from '@nestjs/common';
33
import { Command } from '@smithy/smithy-client';
44
import { NotFoundException } from '~/common';
5-
import { FileBucket, GetObjectOutput, SignedOp } from './file-bucket';
5+
import {
6+
FileBucket,
7+
GetObjectOutput,
8+
PutObjectInput,
9+
SignedOp,
10+
} from './file-bucket';
611

712
/**
813
* A bucket that is composed of multiple other sources.
@@ -46,6 +51,12 @@ export class CompositeBucket extends FileBucket {
4651
return output;
4752
}
4853

54+
async putObject(input: PutObjectInput): Promise<void> {
55+
await this.doAndThrowAllErrors(
56+
this.writableSources.map((bucket) => bucket.putObject(input)),
57+
);
58+
}
59+
4960
async copyObject(oldKey: string, newKey: string): Promise<void> {
5061
const [existing] = await this.selectSources(oldKey, this.writableSources);
5162
await this.doAndThrowAllErrors(

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import {
22
GetObjectOutput as AwsGetObjectOutput,
3+
PutObjectCommandInput as AwsPutObjectCommandInput,
34
HeadObjectOutput,
45
} from '@aws-sdk/client-s3';
56
import { RequestPresigningArguments } from '@aws-sdk/types';
67
import { Type } from '@nestjs/common';
78
import { Command } from '@smithy/smithy-client';
9+
import { NodeJsRuntimeStreamingBlobPayloadInputTypes } from '@smithy/types/dist-types/streaming-payload/streaming-blob-payload-input-types';
810
import { Readable } from 'stream';
9-
import { Merge } from 'type-fest';
11+
import { Except, Merge, SetNonNullable, SetRequired } from 'type-fest';
1012
import { DurationIn } from '~/common';
1113

1214
// Limit body to only `Readable` which is always the case for Nodejs execution.
1315
export type GetObjectOutput = Merge<AwsGetObjectOutput, { Body: Readable }>;
1416

17+
export type PutObjectInput = Merge<
18+
SetNonNullable<
19+
SetRequired<Except<AwsPutObjectCommandInput, 'Bucket'>, 'ContentType'>,
20+
'Key'
21+
>,
22+
{
23+
Body: NodeJsRuntimeStreamingBlobPayloadInputTypes;
24+
}
25+
>;
26+
1527
export type SignedOp<T extends object> = Omit<T, 'Bucket'> & {
1628
Key: string;
1729
signing: Merge<RequestPresigningArguments, { expiresIn: DurationIn }>;
@@ -32,6 +44,7 @@ export abstract class FileBucket {
3244

3345
abstract getObject(key: string): Promise<GetObjectOutput>;
3446
abstract headObject(key: string): Promise<HeadObjectOutput>;
47+
abstract putObject(input: PutObjectInput): Promise<void>;
3548
abstract copyObject(oldKey: string, newKey: string): Promise<void>;
3649
abstract deleteObject(key: string): Promise<void>;
3750
async moveObject(oldKey: string, newKey: string) {

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ import {
33
PutObjectCommand as PutObject,
44
} from '@aws-sdk/client-s3';
55
import { Type } from '@nestjs/common';
6+
import { bufferFromStream } from '@seedcompany/common';
67
import { Command } from '@smithy/smithy-client';
78
import { pickBy } from 'lodash';
89
import { DateTime, Duration } from 'luxon';
910
import { URL } from 'node:url';
11+
import { Readable } from 'stream';
1012
import { assert } from 'ts-essentials';
1113
import { InputException } from '~/common';
12-
import { FileBucket, GetObjectOutput, SignedOp } from './file-bucket';
14+
import {
15+
FileBucket,
16+
GetObjectOutput,
17+
PutObjectInput,
18+
SignedOp,
19+
} from './file-bucket';
1320

1421
export interface LocalBucketOptions {
1522
baseUrl: URL;
@@ -62,6 +69,19 @@ export abstract class LocalBucket<
6269

6370
protected abstract saveFile(key: string, file: FakeAwsFile): Promise<void>;
6471

72+
async putObject(input: PutObjectInput) {
73+
const buffer =
74+
input.Body instanceof Readable
75+
? await bufferFromStream(input.Body)
76+
: Buffer.from(input.Body);
77+
await this.saveFile(input.Key, {
78+
LastModified: new Date(),
79+
...input,
80+
Body: buffer,
81+
ContentLength: buffer.byteLength,
82+
});
83+
}
84+
6585
async getSignedUrl<TCommandInput extends object>(
6686
operation: Type<Command<TCommandInput, any, any>>,
6787
input: SignedOp<TCommandInput>,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export class ReadonlyBucket extends FileBucket {
2626
return await this.source.headObject(key);
2727
}
2828

29+
async putObject(_input: unknown) {
30+
throw new Error('File bucket is readonly and cannot put objects');
31+
}
32+
2933
async copyObject(_oldKey: string, _newKey: string) {
3034
throw new Error('File bucket is readonly and cannot copy objects');
3135
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { NoSuchKey, S3 } from '@aws-sdk/client-s3';
22
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
33
import { Type } from '@nestjs/common';
4+
import { bufferFromStream } from '@seedcompany/common';
45
import { Command } from '@smithy/smithy-client';
56
import { Duration } from 'luxon';
67
import { join } from 'path/posix';
78
import { Readable } from 'stream';
89
import { NotFoundException } from '~/common';
9-
import { FileBucket, SignedOp } from './file-bucket';
10+
import { FileBucket, PutObjectInput, SignedOp } from './file-bucket';
1011

1112
/**
1213
* A bucket that actually connects to S3.
@@ -58,6 +59,22 @@ export class S3Bucket extends FileBucket {
5859
.catch(handleNotFound);
5960
}
6061

62+
async putObject(input: PutObjectInput) {
63+
// S3 needs to know the content length either from body or the header.
64+
// Since we streams don't have that, and we don't know from file, we need to
65+
// buffer it. This way we can know the length to send to S3.
66+
const fixedLengthBody =
67+
input.Body instanceof Readable
68+
? await bufferFromStream(input.Body)
69+
: input.Body;
70+
await this.s3.putObject({
71+
...input,
72+
Key: this.fullKey(input.Key),
73+
Bucket: this.bucket,
74+
Body: fixedLengthBody,
75+
});
76+
}
77+
6178
async copyObject(oldKey: string, newKey: string) {
6279
await this.s3
6380
.copyObject({

src/components/file/dto/upload.dto.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { Field, InputType, ObjectType } from '@nestjs/graphql';
2+
import { stripIndent } from 'common-tags';
3+
import UploadScalar from 'graphql-upload/GraphQLUpload.mjs';
4+
import type { FileUpload } from 'graphql-upload/Upload.mjs';
25
import { ID, IdField } from '~/common';
36
import { MediaUserMetadata } from '../media/media.dto';
47

@@ -8,22 +11,44 @@ export abstract class RequestUploadOutput {
811
readonly id: ID;
912

1013
@Field({
11-
description: 'A pre-signed url to upload the file to',
14+
description: stripIndent`
15+
A temporary url to upload the file to.
16+
It should be a an HTTP PUT request with the file as the body.
17+
The Content-Type header should be set to the mime type of the file.
18+
`,
1219
})
1320
readonly url: string;
1421
}
1522

1623
@InputType()
1724
export abstract class CreateDefinedFileVersionInput {
1825
@IdField({
19-
description: 'The ID returned from the `requestFileUpload` mutation',
26+
description: stripIndent`
27+
The ID returned from the \`requestFileUpload\` mutation.
28+
This _can_ be skipped if \`file\` is provided.
29+
`,
30+
nullable: true,
31+
})
32+
readonly uploadId?: ID;
33+
34+
@Field(() => UploadScalar, {
35+
description: stripIndent`
36+
A file directly uploaded.
37+
This is mainly here to allow usage with Apollo Studio/Sandbox.
38+
For production, prefer the \`url\` from the \`RequestUploadOutput\`.
39+
`,
40+
nullable: true,
2041
})
21-
readonly uploadId: ID;
42+
readonly file?: Promise<FileUpload>;
2243

2344
@Field({
24-
description: 'The file name',
45+
description: stripIndent`
46+
The file name. This is generally required.
47+
It's only optional if \`file\` is provided.
48+
`,
49+
nullable: true,
2550
})
26-
readonly name: string;
51+
readonly name?: string;
2752

2853
@Field({
2954
description:
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { File } from '../dto';
1+
import { File, FileVersion } from '../dto';
22

33
/**
44
* Emitted as the last step of the file upload process.
55
* Feel free to throw to abort mutation.
66
*/
77
export class AfterFileUploadEvent {
8-
constructor(readonly file: File) {}
8+
constructor(readonly file: File, readonly newVersion: FileVersion) {}
99
}

0 commit comments

Comments
 (0)