diff --git a/src/aws/s3.ts b/src/aws/s3.ts index f7438af..aee71a1 100644 --- a/src/aws/s3.ts +++ b/src/aws/s3.ts @@ -3,6 +3,7 @@ import { CopyObjectCommand, DeleteObjectCommand, PutObjectCommand, + ListObjectsV2Command, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export const EXPIRES_DEFAULT_SECS = 300; @@ -46,3 +47,52 @@ export const moveS3File = async ( }); return s3.send(deleteCmd); }; + +export const listS3Files = async ( + bucket: string, + prefix: string, +): Promise => { + const s3Client = new S3Client({ region: 'eu-west-1' }); + + const command = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix, + MaxKeys: 1000, + }); + + try { + let isTruncated = true; + const files = []; + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + files.push(...Contents); + isTruncated = IsTruncated; + command.input.ContinuationToken = NextContinuationToken; + } + + return files; + } catch (err) { + console.error(err); + } + + return []; +}; + +export const deleteFiles = async ( + bucket: string, + key: string, +): Promise => { + try { + const s3Client = new S3Client({ region: 'eu-west-1' }); + const deleteCmd = new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + }); + return s3Client.send(deleteCmd); + } catch (err) { + console.error(err); + } + return null; +}; diff --git a/src/datasources/product-api.ts b/src/datasources/product-api.ts index bfb040f..fa2cc74 100644 --- a/src/datasources/product-api.ts +++ b/src/datasources/product-api.ts @@ -3,6 +3,7 @@ import { BaseSQLDataSource } from './BaseSQLDataSource'; import { camelcase } from 'stringcase'; import slug from 'slug'; import * as sql from './sql'; +import * as CONFIG from '../config'; import { Product, ProductGroup, @@ -35,6 +36,8 @@ import { PrintProductDefaultsAPI } from './printproduct-defaults-api'; import { GraphQLError } from 'graphql'; import { MINUTE } from './utils'; import { ScopeAccess, Scopes } from '../cognito/access-control'; +import { Interior } from '../types/interior-types'; +import { deleteFiles, listS3Files } from '../aws/s3'; export class ProductAPI extends BaseSQLDataSource { textsApi: TextsAPI; @@ -1005,6 +1008,52 @@ export class ProductAPI extends BaseSQLDataSource { return Promise.all(insertPromises); } + /** + * @function cleanupInteriors + * @description Removes all not used interiors from a product + */ + async cleanupInteriors( + productId: number, + currentInteriors: Interior[], + ): Promise<{ active: string[]; deleted: string[] }> { + ScopeAccess.validate(this.user).all([ + Scopes.PRODUCTS_WRITE, + Scopes.INTERIORS_WRITE, + ]); + + // Get all interior files for product on s3 + const files = await listS3Files( + CONFIG.imageBucket, + `interiors/${productId}`, + ); + + const shouldKeep = []; + const shouldRemove = []; + + // Convert to match key format from s3 + const currentUris = currentInteriors.map((item) => item.uri.substring(1)); + + // Go through all files on s3 and organize as delete or keep + for (const file of files) { + if (currentUris.includes(file.Key)) { + shouldKeep.push(file.Key); + } else { + shouldRemove.push(file.Key); + } + } + + // Setup all delete actions + const deletePromises = shouldRemove.map((file) => { + return deleteFiles(CONFIG.imageBucket, file); + }); + + // Run all delete in parallell + await Promise.all(deletePromises); + + // Return what we did + return { active: shouldKeep, deleted: shouldRemove }; + } + /** * @function setProportionsWarning * @description Sets propotion warning-values to a product diff --git a/src/resolvers/products-resolver.ts b/src/resolvers/products-resolver.ts index 260afef..e61f090 100644 --- a/src/resolvers/products-resolver.ts +++ b/src/resolvers/products-resolver.ts @@ -232,6 +232,28 @@ export const productMutationTypeDefs = { return (dataSources.productApi).getProduct(id); }, + /** + * @function cleanupInteriors + * @description Will remove all unused interior images for s3 + */ + async cleanupInteriors(_, { productId }, { dataSources }) { + const product = await (dataSources.productApi).getProduct( + productId, + ); + const interiors = []; + for (const printProduct of product.printProducts) { + const i = await (( + dataSources.interiorApi + )).getPrintProductInteriors(printProduct.id); + interiors.push(...i); + } + const response = await (( + dataSources.productApi + )).cleanupInteriors(productId, interiors); + + return response; + }, + /** * @function productInfo * @description Mutate base information about a product diff --git a/src/schema.graphql b/src/schema.graphql index 4a3dd43..8c31c6a 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -462,6 +462,11 @@ type IdNumberResult { id: Int! } +type CleanupResponse { + active: [String] + deleted: [String] +} + type Mutation { """ Product @@ -524,6 +529,7 @@ type Mutation { productGroup(productId: Int!, groupIds: [Int]): Product productGroupInteriors(productId: Int!, groupId: Int!, uris: [String]): Product generateInteriors(productId: Int!, roomIds: [String]!): StringResult + cleanupInteriors(productId: Int!): CleanupResponse addOwnInteriorToPrintProduct( printId: Int! uploadedS3Key: String!