Skip to content

Commit

Permalink
Merge pull request #2706 (long lived File url)
Browse files Browse the repository at this point in the history
  • Loading branch information
CarsonF committed Dec 20, 2022
2 parents 2bd1f8c + 4b5d120 commit ac67d80
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 12 deletions.
35 changes: 32 additions & 3 deletions src/common/session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
createParamDecorator,
ExecutionContext,
PipeTransform,
Type,
} from '@nestjs/common';
import { CONTROLLER_WATERMARK } from '@nestjs/common/constants';
import { Context } from '@nestjs/graphql';
import { uniq } from 'lodash';
import { DateTime } from 'luxon';
Expand Down Expand Up @@ -41,10 +48,32 @@ const sessionFromContext = (context: GqlContextType) => {
return context.session;
};

export const AnonSession = () => Context({ transform: sessionFromContext });

export const LoggedInSession = () =>
Context({ transform: sessionFromContext }, { transform: loggedInSession });
AnonSession({ transform: loggedInSession });

export const AnonSession =
(...pipes: Array<Type<PipeTransform> | PipeTransform>): ParameterDecorator =>
(...args) => {
Context({ transform: sessionFromContext }, ...pipes)(...args);
process.nextTick(() => {
// Only set this metadata if it's a controller method.
// Waiting for the next tick as class decorators execute after methods.
if (Reflect.getMetadata(CONTROLLER_WATERMARK, args[0].constructor)) {
HttpSession(...pipes)(...args);
SessionWatermark(...args);
}
});
};

// Using Nest's custom decorator so that we can pass pipes.
const HttpSession = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().session;
}
);

const SessionWatermark: ParameterDecorator = (target, key) =>
Reflect.defineMetadata('SESSION_WATERMARK', true, target.constructor, key);

export const addScope = (session: Session, scope?: ScopedRole[]) => ({
...session,
Expand Down
35 changes: 28 additions & 7 deletions src/components/authentication/session.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,40 @@ export class SessionInterceptor implements NestInterceptor {
) {}

async intercept(executionContext: ExecutionContext, next: CallHandler) {
if (executionContext.getType<GqlRequestType>() !== 'graphql') {
return next.handle();
const type = executionContext.getType<GqlRequestType>();
if (type === 'graphql') {
await this.handleGql(executionContext);
} else if (type === 'http') {
await this.handleHttp(executionContext);
}

return next.handle();
}

private async handleHttp(executionContext: ExecutionContext) {
const enabled = Reflect.getMetadata(
'SESSION_WATERMARK',
executionContext.getClass(),
executionContext.getHandler().name
);
if (!enabled) {
return;
}
const request = executionContext.switchToHttp().getRequest();
request.session = await this.hydrateSession({ request });
}

private async handleGql(executionContext: ExecutionContext) {
const gqlExecutionContext = GqlExecutionContext.create(executionContext);
const ctx = gqlExecutionContext.getContext<GqlContextType>();
const info = gqlExecutionContext.getInfo<GraphQLResolveInfo>();

if (!ctx.session && info.fieldName !== 'session') {
ctx.session = await this.hydrateSession(ctx);
}

return next.handle();
}

async hydrateSession(context: GqlContextType) {
async hydrateSession(context: Pick<GqlContextType, 'request'>) {
const token = this.getTokenFromContext(context);
if (!token) {
throw new UnauthenticatedException();
Expand All @@ -57,7 +76,7 @@ export class SessionInterceptor implements NestInterceptor {
return await this.auth.resumeSession(token, impersonatee);
}

getTokenFromContext(context: GqlContextType): string | null {
getTokenFromContext(context: Pick<GqlContextType, 'request'>): string | null {
return (
this.getTokenFromAuthHeader(context.request) ??
this.getTokenFromCookie(context.request)
Expand All @@ -81,7 +100,9 @@ export class SessionInterceptor implements NestInterceptor {
return req?.cookies?.[this.config.sessionCookie.name] || null;
}

getImpersonateeFromContext(context: GqlContextType): Session['impersonatee'] {
getImpersonateeFromContext(
context: Pick<GqlContextType, 'request'>
): Session['impersonatee'] {
const user = context.request?.headers?.['x-cord-impersonate-user'] as
| ID
| undefined;
Expand Down
37 changes: 37 additions & 0 deletions src/components/file/file-url.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
Controller,
forwardRef,
Get,
Inject,
Param,
Query,
Response,
} from '@nestjs/common';
import { Response as IResponse } from 'express';
import { ID, LoggedInSession, Session } from '~/common';
import { FileService } from './file.service';

@Controller(FileUrlController.path)
export class FileUrlController {
static path = '/file';

constructor(
@Inject(forwardRef(() => FileService))
private readonly files: FileService
) {}

@Get(':fileId/:fileName')
async download(
@Param('fileId') fileId: ID,
@Query('proxy') proxy: string | undefined,
@LoggedInSession() session: Session,
@Response() res: IResponse
) {
const node = await this.files.getFileNode(fileId, session);

// TODO authorization using session

const url = await this.files.getDownloadUrl(node);
res.redirect(url);
}
}
17 changes: 17 additions & 0 deletions src/components/file/file-version.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { stripIndent } from 'common-tags';
import { URL } from 'url';
import { FileVersion } from './dto';
import { FileService } from './file.service';
Expand All @@ -7,8 +8,24 @@ import { FileService } from './file.service';
export class FileVersionResolver {
constructor(protected readonly service: FileService) {}

@ResolveField(() => URL, {
description: stripIndent`
A url to the file version.
This url could require authentication.
`,
})
async url(@Parent() node: FileVersion) {
return await this.service.getUrl(node);
}

@ResolveField(() => URL, {
description: 'A direct url to download the file version',
deprecationReason: stripIndent`
Use \`url\` instead.
Note while this url is anonymous, the new field, \`url\` is not.
`,
})
downloadUrl(@Parent() node: FileVersion): Promise<string> {
return this.service.getDownloadUrl(node);
Expand Down
3 changes: 2 additions & 1 deletion src/components/file/file.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthorizationModule } from '../authorization/authorization.module';
import { DirectoryResolver } from './directory.resolver';
import { FileNodeLoader } from './file-node.loader';
import { FileNodeResolver } from './file-node.resolver';
import { FileUrlController } from './file-url.controller';
import { FileVersionResolver } from './file-version.resolver';
import { FileRepository } from './file.repository';
import { FileResolver } from './file.resolver';
Expand All @@ -24,7 +25,7 @@ import { LocalBucketController } from './local-bucket.controller';
FileService,
...Object.values(handlers),
],
controllers: [LocalBucketController],
controllers: [FileUrlController, LocalBucketController],
exports: [FileService],
})
export class FileModule {}
16 changes: 16 additions & 0 deletions src/components/file/file.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,24 @@ export class FileResolver {
return await this.service.listChildren(node, input, session);
}

@ResolveField(() => URL, {
description: stripIndent`
A url to the file.
This url could require authentication.
`,
})
async url(@Parent() node: File) {
return await this.service.getUrl(node);
}

@ResolveField(() => URL, {
description: 'A direct url to download the file',
deprecationReason: stripIndent`
Use \`url\` instead.
Note while this url is anonymous, the new field, \`url\` is not.
`,
})
downloadUrl(@Parent() node: File): Promise<string> {
return this.service.getDownloadUrl(node);
Expand Down
15 changes: 14 additions & 1 deletion src/components/file/file.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Connection } from 'cypher-query-builder';
import { intersection } from 'lodash';
import { withAddedPath } from '~/common/url.util';
import {
bufferFromStream,
DuplicateException,
Expand All @@ -12,7 +13,7 @@ import {
Session,
UnauthorizedException,
} from '../../common';
import { ILogger, Logger } from '../../core';
import { ConfigService, ILogger, Logger } from '../../core';
import { FileBucket } from './bucket';
import {
CreateDefinedFileVersionInput,
Expand All @@ -33,6 +34,7 @@ import {
RenameFileInput,
RequestUploadOutput,
} from './dto';
import { FileUrlController as FileUrl } from './file-url.controller';
import { FileRepository } from './file.repository';

@Injectable()
Expand All @@ -41,6 +43,7 @@ export class FileService {
private readonly bucket: FileBucket,
private readonly repo: FileRepository,
private readonly db: Connection,
private readonly config: ConfigService,
@Logger('file:service') private readonly logger: ILogger
) {}

Expand Down Expand Up @@ -118,6 +121,16 @@ export class FileService {
return await bufferFromStream(data);
}

async getUrl(node: FileNode) {
const url = withAddedPath(
this.config.hostUrl,
FileUrl.path,
isFile(node) ? node.latestVersionId : node.id,
encodeURIComponent(node.name)
);
return url.toString();
}

async getDownloadUrl(node: FileNode): Promise<string> {
if (isDirectory(node)) {
throw new InputException('Directories cannot be downloaded yet');
Expand Down

0 comments on commit ac67d80

Please sign in to comment.