Skip to content

Commit 6fe400f

Browse files
committed
Merge branch 'develop'
2 parents a66a6b1 + 2aa89a6 commit 6fe400f

28 files changed

+792
-135
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/authentication/authentication.service.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,16 @@ export class AuthenticationService {
117117
);
118118
}
119119

120-
impersonatee = impersonatee
121-
? {
122-
id: impersonatee?.id,
123-
roles: [
124-
...(impersonatee.roles ?? []),
125-
...(result.impersonateeRoles ?? []),
126-
],
127-
}
128-
: undefined;
120+
impersonatee =
121+
impersonatee && result.userId
122+
? {
123+
id: impersonatee?.id,
124+
roles: [
125+
...(impersonatee.roles ?? []),
126+
...(result.impersonateeRoles ?? []),
127+
],
128+
}
129+
: undefined;
129130

130131
const requesterSession: Session = {
131132
token,

src/components/authentication/login.resolver.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ResolveField,
77
Resolver,
88
} from '@nestjs/graphql';
9+
import { stripIndent } from 'common-tags';
910
import { AnonSession, GqlContextType, Session } from '../../common';
1011
import { Loader, LoaderOf } from '../../core';
1112
import { Powers as Power, Privileges } from '../authorization';
@@ -21,7 +22,10 @@ export class LoginResolver {
2122
) {}
2223

2324
@Mutation(() => LoginOutput, {
24-
description: 'Login a user',
25+
description: stripIndent`
26+
Login a user
27+
@sensitive-secrets
28+
`,
2529
})
2630
async login(
2731
@Args('input') input: LoginInput,
@@ -34,7 +38,10 @@ export class LoginResolver {
3438
}
3539

3640
@Mutation(() => LogoutOutput, {
37-
description: 'Logout a user',
41+
description: stripIndent`
42+
Logout a user
43+
@sensitive-secrets
44+
`,
3845
})
3946
async logout(
4047
@AnonSession() session: Session,

src/components/authentication/password.resolver.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Args, Mutation, Resolver } from '@nestjs/graphql';
2+
import { stripIndent } from 'common-tags';
23
import { AnonSession, LoggedInSession, Session } from '../../common';
34
import { AuthenticationService } from './authentication.service';
45
import {
@@ -15,7 +16,10 @@ export class PasswordResolver {
1516
constructor(private readonly authentication: AuthenticationService) {}
1617

1718
@Mutation(() => ChangePasswordOutput, {
18-
description: 'Change your password',
19+
description: stripIndent`
20+
Change your password
21+
@sensitive-secrets
22+
`,
1923
})
2024
async changePassword(
2125
@Args() { oldPassword, newPassword }: ChangePasswordArgs,
@@ -36,7 +40,10 @@ export class PasswordResolver {
3640
}
3741

3842
@Mutation(() => ResetPasswordOutput, {
39-
description: 'Reset Password',
43+
description: stripIndent`
44+
Reset Password
45+
@sensitive-secrets
46+
`,
4047
})
4148
async resetPassword(
4249
@Args('input') input: ResetPasswordInput,

src/components/authentication/register.resolver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ResolveField,
77
Resolver,
88
} from '@nestjs/graphql';
9+
import { stripIndent } from 'common-tags';
910
import { AnonSession, GqlContextType, Session } from '../../common';
1011
import { Loader, LoaderOf } from '../../core';
1112
import { Powers as Power, Privileges } from '../authorization';
@@ -21,7 +22,10 @@ export class RegisterResolver {
2122
) {}
2223

2324
@Mutation(() => RegisterOutput, {
24-
description: 'Register a new user',
25+
description: stripIndent`
26+
Register a new user
27+
@sensitive-secrets
28+
`,
2529
})
2630
async register(
2731
@Args('input') input: RegisterInput,

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: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ 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+
InvalidSignedUrlException,
9+
PutObjectInput,
10+
SignedOp,
11+
} from './file-bucket';
612

713
/**
814
* A bucket that is composed of multiple other sources.
@@ -36,6 +42,18 @@ export class CompositeBucket extends FileBucket {
3642
return await source.getSignedUrl(operation, input);
3743
}
3844

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+
3957
async getObject(key: string): Promise<GetObjectOutput> {
4058
const [source] = await this.selectSource(key);
4159
return await source.getObject(key);
@@ -46,6 +64,12 @@ export class CompositeBucket extends FileBucket {
4664
return output;
4765
}
4866

67+
async putObject(input: PutObjectInput): Promise<void> {
68+
await this.doAndThrowAllErrors(
69+
this.writableSources.map((bucket) => bucket.putObject(input)),
70+
);
71+
}
72+
4973
async copyObject(oldKey: string, newKey: string): Promise<void> {
5074
const [existing] = await this.selectSources(oldKey, this.writableSources);
5175
await this.doAndThrowAllErrors(

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
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';
8+
import { MaybeAsync } from '@seedcompany/common';
79
import { Command } from '@smithy/smithy-client';
10+
import { NodeJsRuntimeStreamingBlobPayloadInputTypes } from '@smithy/types/dist-types/streaming-payload/streaming-blob-payload-input-types';
811
import { Readable } from 'stream';
9-
import { Merge } from 'type-fest';
10-
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';
1120

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

24+
export type PutObjectInput = Merge<
25+
SetNonNullable<
26+
SetRequired<Except<AwsPutObjectCommandInput, 'Bucket'>, 'ContentType'>,
27+
'Key'
28+
>,
29+
{
30+
Body: NodeJsRuntimeStreamingBlobPayloadInputTypes;
31+
}
32+
>;
33+
1534
export type SignedOp<T extends object> = Omit<T, 'Bucket'> & {
1635
Key: string;
1736
signing: Merge<RequestPresigningArguments, { expiresIn: DurationIn }>;
@@ -29,13 +48,24 @@ export abstract class FileBucket {
2948
operation: Type<Command<TCommandInput, any, any>>,
3049
input: SignedOp<TCommandInput>,
3150
): Promise<string>;
51+
abstract parseSignedUrl(url: URL): MaybeAsync<{
52+
Key: string;
53+
operation: LiteralUnion<'PutObject' | 'GetObject', string>;
54+
}>;
3255

3356
abstract getObject(key: string): Promise<GetObjectOutput>;
3457
abstract headObject(key: string): Promise<HeadObjectOutput>;
58+
abstract putObject(input: PutObjectInput): Promise<void>;
3559
abstract copyObject(oldKey: string, newKey: string): Promise<void>;
3660
abstract deleteObject(key: string): Promise<void>;
3761
async moveObject(oldKey: string, newKey: string) {
3862
await this.copyObject(oldKey, newKey);
3963
await this.deleteObject(oldKey);
4064
}
4165
}
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: 46 additions & 13 deletions
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';
11-
import { InputException } from '~/common';
12-
import { FileBucket, GetObjectOutput, SignedOp } from './file-bucket';
13+
import {
14+
FileBucket,
15+
GetObjectOutput,
16+
InvalidSignedUrlException,
17+
PutObjectInput,
18+
SignedOp,
19+
} from './file-bucket';
1320

1421
export interface LocalBucketOptions {
1522
baseUrl: URL;
@@ -62,12 +69,25 @@ 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>,
6888
) {
6989
const signed = JSON.stringify({
70-
operation: operation.constructor.name,
90+
operation: operation.name.replace(/Command$/, ''),
7191
...input,
7292
signing: {
7393
...input.signing,
@@ -88,27 +108,40 @@ export abstract class LocalBucket<
88108
operation: Type<Command<TCommandInput, any, any>>,
89109
url: string,
90110
): SignedOp<TCommandInput> & { Key: string } {
91-
let raw;
111+
let u: URL;
92112
try {
93-
raw = new URL(url).searchParams.get('signed');
113+
u = new URL(url);
94114
} catch (e) {
95-
raw = url;
115+
u = new URL('http://localhost');
116+
u.searchParams.set('signed', url);
96117
}
97-
assert(typeof raw === 'string');
98-
let parsed;
99118
try {
100-
parsed = JSON.parse(raw) as SignedOp<TCommandInput> & {
119+
const parsed = this.parseSignedUrl(u) as SignedOp<TCommandInput> & {
101120
operation: string;
102-
Key: string;
103121
};
104-
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');
105138
assert(typeof parsed.Key === 'string');
106139
assert(typeof parsed.signing.expiresIn === 'number');
107140
} catch (e) {
108-
throw new InputException(e);
141+
throw new InvalidSignedUrlException(e);
109142
}
110143
if (DateTime.local() > DateTime.fromMillis(parsed.signing.expiresIn)) {
111-
throw new InputException('url expired');
144+
throw new InvalidSignedUrlException('URL expired');
112145
}
113146
return parsed;
114147
}

0 commit comments

Comments
 (0)