diff --git a/packages/prime-core/package.json b/packages/prime-core/package.json index 5a401898d..2bb9ab14e 100644 --- a/packages/prime-core/package.json +++ b/packages/prime-core/package.json @@ -51,12 +51,14 @@ "@primecms/field-slice": "^0.3.4-beta.0", "@primecms/field-string": "^0.3.4-beta.0", "apollo-server-express": "2.4.0", + "aws-sdk": "^2.459.0", "class-validator": "0.9.1", "cors": "2.8.5", "dataloader": "1.4.0", "debug": "4.1.1", "dotenv": "6.2.0", "express": "4.16.4", + "file-type": "^11.0.0", "graphql": "^14.1.1", "graphql-iso-date": "3.6.1", "graphql-type-json": "^0.2.1", diff --git a/packages/prime-core/src/entities/Asset.ts b/packages/prime-core/src/entities/Asset.ts new file mode 100644 index 000000000..41a0336a9 --- /dev/null +++ b/packages/prime-core/src/entities/Asset.ts @@ -0,0 +1,62 @@ +import { User } from '@accounts/typeorm'; +import { GraphQLFloat } from 'graphql'; +import { Field, ID, ObjectType } from 'type-graphql'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +@ObjectType() +export class Asset { + @Field(type => ID) + @PrimaryGeneratedColumn('uuid') + public id: string; + + @CreateDateColumn() + @Field({ nullable: true }) + public createdAt: Date; + + @UpdateDateColumn() + @Field({ nullable: true }) + public updatedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + @Field({ nullable: true }) + public deletedAt: Date; + + @Column({ nullable: false }) + @Field({ nullable: false }) + public url: string; + + @Column({ nullable: false }) + @Field({ nullable: false }) + public mimeType: string; + + @Column({ nullable: false }) + @Field({ nullable: false }) + public fileName: string; + + @Column({ nullable: false }) + @Field({ nullable: false }) + public fileSize: number; + + @Column('float', { nullable: true }) + @Field(type => GraphQLFloat, { nullable: true }) + public width: number; + + @Column('float', { nullable: true }) + @Field(type => GraphQLFloat, { nullable: true }) + public height: number; + + @Column({ nullable: true }) + @Field({ nullable: true }) + public userId?: string; + + @ManyToOne(type => User, { nullable: true, onDelete: 'SET NULL' }) + public user: User; +} diff --git a/packages/prime-core/src/modules/external/interfaces/Upload.ts b/packages/prime-core/src/modules/external/interfaces/Upload.ts new file mode 100644 index 000000000..0d7566a32 --- /dev/null +++ b/packages/prime-core/src/modules/external/interfaces/Upload.ts @@ -0,0 +1,23 @@ +import { ReadStream } from 'fs'; + +/** + * File upload details, resolved from an `Upload` scalar promise. + * See: https://github.com/jaydenseric/graphql-upload + */ +export interface Upload { + /** File name */ + filename: string; + + /** File MIME type. Provided by the client and can’t be trusted. */ + mimetype: string; + + /** File stream transfer encoding. */ + encoding: string; + + /** + * createReadStream Returns a Node.js readable stream of the file contents, + * for processing and storing the file. Multiple calls create independent streams. + * Throws if called after all resolvers have resolved, or after an error has interrupted the request. + */ + createReadStream: () => ReadStream; +} diff --git a/packages/prime-core/src/modules/internal/index.ts b/packages/prime-core/src/modules/internal/index.ts index 4fad4c7ac..65a7dca30 100644 --- a/packages/prime-core/src/modules/internal/index.ts +++ b/packages/prime-core/src/modules/internal/index.ts @@ -7,6 +7,7 @@ import { Connection } from 'typeorm'; import { pubSub } from '../../utils/pubSub'; import { createAccounts } from '../accounts'; import { AccessTokenResolver } from './resolvers/AccessTokenResolver'; +import { AssetResolver } from './resolvers/AssetResolver'; import { DocumentResolver } from './resolvers/DocumentResolver'; import { PrimeResolver } from './resolvers/PrimeResolver'; import { ReleaseResolver } from './resolvers/ReleaseResolver'; @@ -29,6 +30,7 @@ export const createInternal = async (connection: Connection) => { const schema = await buildTypeDefsAndResolvers({ resolvers: [ AccessTokenResolver, + AssetResolver, DocumentResolver, PrimeResolver, ReleaseResolver, diff --git a/packages/prime-core/src/modules/internal/repositories/AssetRepository.ts b/packages/prime-core/src/modules/internal/repositories/AssetRepository.ts new file mode 100644 index 000000000..cd202b18e --- /dev/null +++ b/packages/prime-core/src/modules/internal/repositories/AssetRepository.ts @@ -0,0 +1,6 @@ +import { EntityRepository } from 'typeorm'; +import { Asset } from '../../../entities/Asset'; +import { DataLoaderRepository } from './DataLoaderRepository'; + +@EntityRepository(Asset) +export class AssetRepository extends DataLoaderRepository {} diff --git a/packages/prime-core/src/modules/internal/resolvers/AssetResolver.ts b/packages/prime-core/src/modules/internal/resolvers/AssetResolver.ts new file mode 100644 index 000000000..c9d166f78 --- /dev/null +++ b/packages/prime-core/src/modules/internal/resolvers/AssetResolver.ts @@ -0,0 +1,102 @@ +import { Context } from 'apollo-server-core'; +import { GraphQLResolveInfo } from 'graphql'; +import { + Arg, + Args, + Ctx, + FieldResolver, + ID, + Info, + Mutation, + Query, + registerEnumType, + Resolver, + Root, +} from 'type-graphql'; +import { getRepository } from 'typeorm'; +import { EntityConnection } from 'typeorm-cursor-connection'; +import { InjectRepository } from 'typeorm-typedi-extensions'; +import { Asset } from '../../../entities/Asset'; +import { AssetRepository } from '../repositories/AssetRepository'; +import { AssetInput } from '../types/AssetInput'; +import { ConnectionArgs, createConnectionType } from '../types/createConnectionType'; +import { User } from '../types/User'; +import { Authorized } from '../utils/Authorized'; + +const AssetConnection = createConnectionType(Asset); + +enum AssetOrder { + id_ASC, + id_DESC, + name_ASC, + name_DESC, +} + +registerEnumType(AssetOrder, { + name: 'AssetConnectionOrder', +}); + +@Resolver(of => Asset) +export class AssetResolver { + @InjectRepository(AssetRepository) + private readonly assetRepository: AssetRepository; + + @Authorized() + @Query(returns => Asset, { nullable: true, description: 'Get Asset by ID' }) + public Asset( + @Arg('id', type => ID) id: string, + @Ctx() context: Context, + @Info() info: GraphQLResolveInfo + ) { + return this.assetRepository.loadOne(id); + } + + @Authorized() + @Query(returns => AssetConnection, { description: 'Get many Assets' }) + public async allAssets( + @Args() args: ConnectionArgs, + @Arg('order', type => AssetOrder, { defaultValue: 0 }) orderBy: string + ) { + const [sort, order]: any = orderBy.split('_'); + const connection = await new EntityConnection(args, { + repository: this.assetRepository, + sortOptions: [{ sort, order }], + }); + return { + edges: await connection.edges, + totalCount: await this.assetRepository.count(), + }; + } + + @Authorized() + @Mutation(returns => Asset, { description: 'Create Asset' }) + public async createAsset( + @Arg('input') input: AssetInput, + @Ctx() context: Context + ): Promise { + // TODO: Add asset on host + const assetInput: Partial = {}; + const asset = this.assetRepository.create(assetInput); + await this.assetRepository.save(asset); + return asset; + } + + @Authorized() + @Mutation(returns => Boolean, { description: 'Remove Asset by ID' }) + public async removeAsset( + @Arg('id', type => ID) id: string, + @Ctx() context: Context + ): Promise { + // TODO: Remove asset from host + const entity = await this.assetRepository.findOneOrFail(id); + return Boolean(await this.assetRepository.remove(entity)); + } + + @FieldResolver(returns => User, { description: 'Get Asset User' }) + public user(@Root() asset: Asset): Promise { + return getRepository(User).findOneOrFail({ + cache: 1000, + where: asset.user, + }); + } +} diff --git a/packages/prime-core/src/modules/internal/types/AssetInput.ts b/packages/prime-core/src/modules/internal/types/AssetInput.ts new file mode 100644 index 000000000..c68eeb14a --- /dev/null +++ b/packages/prime-core/src/modules/internal/types/AssetInput.ts @@ -0,0 +1,9 @@ +import { GraphQLUpload } from 'graphql-upload'; +import { Field, InputType } from 'type-graphql'; +import { Upload } from '../../external/interfaces/Upload'; + +@InputType() +export class AssetInput { + @Field(type => GraphQLUpload, { nullable: false }) + public upload: Promise; +} diff --git a/packages/prime-core/src/utils/S3Uploader.ts b/packages/prime-core/src/utils/S3Uploader.ts new file mode 100644 index 000000000..77237a833 --- /dev/null +++ b/packages/prime-core/src/utils/S3Uploader.ts @@ -0,0 +1,55 @@ +import S3 from 'aws-sdk/clients/s3'; +import fileType from 'file-type'; +import { ReadStream } from 'fs'; +import { Service } from 'typedi'; + +@Service() +export class S3Uploader { + private bucket: string; + private s3: S3; + + constructor() { + if (!process.env.S3_BUCKET) { + throw Error('S3_BUCKET not set'); + } + if (!process.env.S3_REGION) { + throw Error('S3_REGION not set'); + } + if (!process.env.S3_ACCESS_KEY_ID) { + throw Error('S3_ACCESS_KEY_ID not set'); + } + if (!process.env.S3_SECRET_ACCESS_KEY) { + throw Error('S3_SECRET_ACCESS_KEY not set'); + } + + this.bucket = process.env.S3_BUCKET; + this.s3 = new S3({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }, + }); + } + + public async upload(key: string, readStream: ReadStream): Promise { + // Detect mime-type + const stream = await fileType.stream(readStream); + const { mime } = stream.fileType!; + + return this.s3 + .upload({ + Bucket: this.bucket, + ACL: 'public-read', + Body: readStream, + Key: key, + ContentType: mime, + }) + .promise() + .then(({ Location }) => Location); + } + + public async delete(key: string): Promise { + // TODO + } +} diff --git a/yarn.lock b/yarn.lock index d13bf24ea..b30369d20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3553,6 +3553,21 @@ autoprefixer@^9.1.5: postcss "^7.0.14" postcss-value-parser "^3.3.1" +aws-sdk@^2.459.0: + version "2.459.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.459.0.tgz#126ec97f06d1178e920bfb5c1e538854a83d001f" + integrity sha512-b1ziocvzv0NkV0oh5QRpGl28iYKfPqgcK52zvM02/sCmDH4YGuThC6ckFh/sKpXBYK7ToCg1St0LmtBK6siFbg== + dependencies: + buffer "4.9.1" + events "1.1.1" + ieee754 "1.1.8" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sign2@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" @@ -4290,7 +4305,7 @@ buffer-xor@^1.0.3: resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@^4.3.0: +buffer@4.9.1, buffer@^4.3.0: version "4.9.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg= @@ -6670,6 +6685,11 @@ eventlistener@0.0.1: resolved "https://registry.yarnpkg.com/eventlistener/-/eventlistener-0.0.1.tgz#ed2baabb852227af2bcf889152c72c63ca532eb8" integrity sha1-7Suqu4UiJ68rz4iRUscsY8pTLrg= +events@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + events@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" @@ -7017,6 +7037,11 @@ file-loader@2.0.0: loader-utils "^1.0.2" schema-utils "^1.0.0" +file-type@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-11.0.0.tgz#576ca9ec099925b0c4df7e75d3be52696b62fb52" + integrity sha512-ixd0mHkDO8KJ1S+ANTM+cZoZgL+TB0txLMm9KjTndfOjFYuRmrUcOtmSEm+e9s7wrynZOvvRD/8LwMQ6a24Irg== + file-uri-to-path@1: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -8487,6 +8512,11 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" +ieee754@1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" + integrity sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q= + ieee754@^1.1.4: version "1.1.12" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" @@ -10114,6 +10144,11 @@ jest@24.8.0: import-local "^2.0.0" jest-cli "^24.8.0" +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= + joi@^11.1.1: version "11.4.0" resolved "https://registry.yarnpkg.com/joi/-/joi-11.4.0.tgz#f674897537b625e9ac3d0b7e1604c828ad913ccb" @@ -15846,6 +15881,11 @@ sass-loader@7.1.0: pify "^3.0.0" semver "^5.5.0" +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -17435,7 +17475,12 @@ typeorm@0.2.13, typeorm@^0.2.11: yargonaut "^1.1.2" yargs "^12.0.5" -typescript@3.2.x, typescript@3.3.1, typescript@^3.2.2: +typescript@3.2.x: + version "3.2.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.2.4.tgz#c585cb952912263d915b462726ce244ba510ef3d" + integrity sha512-0RNDbSdEokBeEAkgNbxJ+BLwSManFy9TeXz8uW+48j/xhEXv1ePME60olyzw2XzUqUBNAYFeJadIqAgNqIACwg== + +typescript@3.3.1, typescript@^3.2.2: version "3.3.1" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.3.1.tgz#6de14e1db4b8a006ac535e482c8ba018c55f750b" integrity sha512-cTmIDFW7O0IHbn1DPYjkiebHxwtCMU+eTy30ZtJNBPF9j2O1ITu5XH2YnBeVRKWHqF+3JQwWJv0Q0aUgX8W7IA== @@ -17668,6 +17713,14 @@ url-template@^2.0.8: resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE= +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -18380,7 +18433,7 @@ xml-name-validator@^3.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== -xml2js@^0.4.17: +xml2js@0.4.19, xml2js@^0.4.17: version "0.4.19" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==