diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 639c9d4d..e6829de7 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -54,6 +54,10 @@ type Entry { creator: ID! dateCreated: DateTime! meta: JSON! + signedUrl: String! + + """Get the number of milliseconds the signed URL is valid for.""" + signedUrlExpiration: Float! } type UploadSession { diff --git a/packages/server/src/config/configuration.ts b/packages/server/src/config/configuration.ts index d4607a58..f404aa0e 100644 --- a/packages/server/src/config/configuration.ts +++ b/packages/server/src/config/configuration.ts @@ -15,5 +15,8 @@ export default () => ({ }, dataset: { prefix: process.env.GCP_STORAGE_DATASET_PREFIX || 'datasets' + }, + entry: { + signedURLExpiration: process.env.GCP_STORAGE_ENTRY_SIGNED_URL_EXPIRATION || (15 * 60 * 1000) // 15 minutes } }); diff --git a/packages/server/src/entry/models/entry.model.ts b/packages/server/src/entry/models/entry.model.ts index efe28454..766cd92c 100644 --- a/packages/server/src/entry/models/entry.model.ts +++ b/packages/server/src/entry/models/entry.model.ts @@ -48,6 +48,9 @@ export class Entry { @Field(() => JSON) meta: any; + @Prop({ required: false }) + signedURLExpiration: Date; + // TODO: Add creator field } diff --git a/packages/server/src/entry/resolvers/entry.resolver.ts b/packages/server/src/entry/resolvers/entry.resolver.ts index f3f36d12..0f59274c 100644 --- a/packages/server/src/entry/resolvers/entry.resolver.ts +++ b/packages/server/src/entry/resolvers/entry.resolver.ts @@ -1,4 +1,4 @@ -import { Args, ID, Mutation, Resolver, Query } from '@nestjs/graphql'; +import { Args, ID, Mutation, Resolver, Query, ResolveField, Parent } from '@nestjs/graphql'; import { Dataset } from '../../dataset/dataset.model'; import { Entry } from '../models/entry.model'; import { EntryCreate } from '../dtos/create.dto'; @@ -16,7 +16,19 @@ export class EntryResolver { } @Query(() => [Entry]) - async entryForDataset(@Args('dataset', { type: () => ID }) dataset: Dataset): Promise { + async entryForDataset(@Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset): Promise { return this.entryService.findForDataset(dataset); } + + @ResolveField(() => String) + async signedUrl(@Parent() entry: Entry): Promise { + return this.entryService.getSignedUrl(entry); + } + + // NOTE: With the current implementation, this is only really helpful + // if the request to `signedUrl` is made. + @ResolveField(() => Number, { description: 'Get the number of milliseconds the signed URL is valid for.' }) + async signedUrlExpiration(@Parent() entry: Entry): Promise { + return this.entryService.getSignedUrlExpiration(entry); + } } diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index b8d2566a..17ffef9f 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -1,13 +1,22 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Entry } from '../models/entry.model'; import { Model } from 'mongoose'; import { EntryCreate } from '../dtos/create.dto'; import { Dataset } from '../../dataset/dataset.model'; +import { GCP_STORAGE_PROVIDER } from '../../gcp/providers/storage.provider'; +import { Bucket, Storage } from '@google-cloud/storage'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class EntryService { - constructor(@InjectModel(Entry.name) private readonly entryMode: Model) {} + private readonly bucketName = this.configService.getOrThrow('gcp.storage.bucket'); + private readonly bucket: Bucket = this.storage.bucket(this.bucketName); + private readonly expiration = this.configService.getOrThrow('entry.signedURLExpiration'); + + constructor(@InjectModel(Entry.name) private readonly entryMode: Model, + @Inject(GCP_STORAGE_PROVIDER) private readonly storage: Storage, + private readonly configService: ConfigService) {} async find(entryID: string): Promise { return this.entryMode.findOne({ _id: entryID }); @@ -23,7 +32,7 @@ export class EntryService { } async findForDataset(dataset: Dataset): Promise { - return this.entryMode.find({ dataset: dataset._id }); + return this.entryMode.find({ dataset: dataset._id.toString() }); } async exists(entryID: string, dataset: Dataset): Promise { @@ -34,4 +43,22 @@ export class EntryService { async setBucketLocation(entry: Entry, bucketLocation: string): Promise { await this.entryMode.updateOne({ _id: entry._id }, { bucketLocation }); } + + async getSignedUrl(entry: Entry): Promise { + const file = this.bucket.file(entry.bucketLocation); + const [url] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + this.expiration + }); + return url; + } + + /** + * Get how long the signed URL is valid for in milliseconds. + * + * In the future, this could be configurable per entry. + */ + async getSignedUrlExpiration(_entry: Entry): Promise { + return this.expiration; + } }