diff --git a/lib/configs/amplify/build-setting.ts b/lib/configs/amplify/build-setting.ts index 459e77eba..032e0044d 100644 --- a/lib/configs/amplify/build-setting.ts +++ b/lib/configs/amplify/build-setting.ts @@ -14,6 +14,7 @@ const preBuildForFeeds = { commands: [ 'eval $(ssh-agent -s)', 'ssh-add <(echo "$DEPLOY_KEY" | base64 --decode)', + 'echo "github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=" >> ~/.ssh/known_hosts', 'git submodule init', 'git submodule update --remote', 'yum -y install make nasm autoconf automake libtool dpkg pkgconfig libpng libpng-dev g++', diff --git a/lib/configs/common/registry.ts b/lib/configs/common/registry.ts index 936047c89..f79d1727d 100644 --- a/lib/configs/common/registry.ts +++ b/lib/configs/common/registry.ts @@ -12,6 +12,7 @@ export const enum DataEndpoint { COURSE, THREAD, COMMENT, + ADS, //! New ADS value } export enum OperationEndpoint { diff --git a/lib/constructs/business/api-endpoint.ts b/lib/constructs/business/api-endpoint.ts index 1af9f7bce..12323a27a 100644 --- a/lib/constructs/business/api-endpoint.ts +++ b/lib/constructs/business/api-endpoint.ts @@ -15,7 +15,12 @@ import { API_DOMAIN } from '../../configs/route53/domain'; import { GraphqlApiService } from './graphql-api-service'; import { AbstractHttpApiService } from './http-api-service'; import { RestApiService } from './rest-api-service'; -import { GraphqlApiServiceId, graphqlApiServiceMap, RestApiServiceId, restApiServiceMap } from './service'; +import { + GraphqlApiServiceId, + graphqlApiServiceMap, + RestApiServiceId, + restApiServiceMap, +} from './service'; export interface ApiEndpointProps { zone: route53.IHostedZone; @@ -23,9 +28,18 @@ export interface ApiEndpointProps { } export abstract class AbstractApiEndpoint extends Construct { - abstract readonly apiEndpoint: apigw.RestApi | apigw.LambdaRestApi | apigw.SpecRestApi | apigw2.HttpApi | appsync.GraphqlApi; + abstract readonly apiEndpoint: + | apigw.RestApi + | apigw.LambdaRestApi + | apigw.SpecRestApi + | apigw2.HttpApi + | appsync.GraphqlApi; - protected constructor(scope: Construct, id: string, props?: ApiEndpointProps) { + protected constructor( + scope: Construct, + id: string, + props?: ApiEndpointProps, + ) { super(scope, id); } } @@ -44,7 +58,8 @@ export abstract class AbstractRestApiEndpoint extends AbstractApiEndpoint { } public getDomain(): string { - const domainName: apigw.DomainName | undefined = this.apiEndpoint.domainName; + const domainName: apigw.DomainName | undefined = + this.apiEndpoint.domainName; if (typeof domainName === 'undefined') { throw RangeError('Domain not configured for this API endpoint.'); @@ -52,8 +67,12 @@ export abstract class AbstractRestApiEndpoint extends AbstractApiEndpoint { return domainName.domainName; } - public addService(name: RestApiServiceId, dataSource?: string, auth = false): this { - this.apiServices[name] = new restApiServiceMap[name](this, `${ name }-api`, { + public addService( + name: RestApiServiceId, + dataSource?: string, + auth = false, + ): this { + this.apiServices[name] = new restApiServiceMap[name](this, `${name}-api`, { dataSource: dataSource, authorizer: auth ? this.authorizer : undefined, validator: this.reqValidator, @@ -61,7 +80,7 @@ export abstract class AbstractRestApiEndpoint extends AbstractApiEndpoint { return this; } - public abstract deploy(): void + public abstract deploy(): void; } export abstract class AbstractGraphqlEndpoint extends AbstractApiEndpoint { @@ -75,16 +94,30 @@ export abstract class AbstractGraphqlEndpoint extends AbstractApiEndpoint { super(scope, id, props); } - public addService(name: GraphqlApiServiceId, dataSource: string, auth = 'apiKey'): this { - this.apiServices[name] = new graphqlApiServiceMap[name](this, `${ name }-api`, { - dataSource: dynamodb.Table.fromTableName(this, `${ name }-table`, dataSource), - auth: this.authMode[auth], - }); + public addService( + name: GraphqlApiServiceId, + dataSource: string, + auth = 'apiKey', + ): this { + this.apiServices[name] = new graphqlApiServiceMap[name]( + this, + `${name}-api`, + { + dataSource: dynamodb.Table.fromTableName( + this, + `${name}-table`, + dataSource, + ), + auth: this.authMode[auth], + }, + ); return this; } public getDomain(): string { - const domain = this.apiEndpoint.graphqlUrl.match(/https:\/\/(.*)\/graphql/g); + const domain = this.apiEndpoint.graphqlUrl.match( + /https:\/\/(.*)\/graphql/g, + ); if (domain === null) { return ''; } @@ -127,7 +160,12 @@ export class WasedaTimeRestApiEndpoint extends AbstractRestApiEndpoint { description: 'The main API endpoint for WasedaTime Web App.', endpointTypes: [apigw.EndpointType.REGIONAL], deploy: false, - binaryMediaTypes: ['application/pdf', 'image/png'], + binaryMediaTypes: [ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/gif', + ], }); this.apiEndpoint.addGatewayResponse('4xx-resp', { type: apigw.ResponseType.DEFAULT_4XX, @@ -171,7 +209,9 @@ export class WasedaTimeRestApiEndpoint extends AbstractRestApiEndpoint { }); new route53.ARecord(this, 'alias-record', { zone: props.zone, - target: route53.RecordTarget.fromAlias(new route53_targets.ApiGatewayDomain(this.domain)), + target: route53.RecordTarget.fromAlias( + new route53_targets.ApiGatewayDomain(this.domain), + ), recordName: API_DOMAIN, }); } @@ -186,7 +226,10 @@ export class WasedaTimeRestApiEndpoint extends AbstractRestApiEndpoint { api: this.apiEndpoint, retainDeployments: false, }); - const hash = Buffer.from(flatted.stringify(this.apiServices), 'binary').toString('base64'); + const hash = Buffer.from( + flatted.stringify(this.apiServices), + 'binary', + ).toString('base64'); if (STAGE === 'dev') { devDeployment.addToLogicalId(hash); } else if (STAGE === 'prod') { @@ -232,7 +275,6 @@ export class WasedaTimeGraphqlEndpoint extends AbstractGraphqlEndpoint { readonly apiServices: { [name: string]: GraphqlApiService } = {}; constructor(scope: Construct, id: string, props: ApiEndpointProps) { - super(scope, id, props); const apiKeyAuth: appsync.AuthorizationMode = { diff --git a/lib/constructs/business/rest-api-service.ts b/lib/constructs/business/rest-api-service.ts index b12f1760c..5269d4379 100644 --- a/lib/constructs/business/rest-api-service.ts +++ b/lib/constructs/business/rest-api-service.ts @@ -717,6 +717,7 @@ export class ForumThreadsApiService extends RestApiService { const boardResource = root.addResource('{board_id}'); const threadResource = boardResource.addResource('{thread_id}'); const userResource = root.addResource('user'); + const testResource = root.addResource('test'); const optionsForumHome = root.addCorsPreflight({ allowOrigins: allowOrigins, @@ -766,6 +767,18 @@ export class ForumThreadsApiService extends RestApiService { ], }); + const optionsTestThreads = testResource.addCorsPreflight({ + allowOrigins: allowOrigins, + allowHeaders: allowHeaders, + allowMethods: [ + apigw2.HttpMethod.GET, + apigw2.HttpMethod.POST, + apigw2.HttpMethod.PATCH, + apigw2.HttpMethod.DELETE, + apigw2.HttpMethod.OPTIONS, + ], + }); + const getRespModel = scope.apiEndpoint.addModel('threads-get-resp-model', { schema: forumThreadGetRespSchema, contentType: 'application/json', @@ -791,6 +804,7 @@ export class ForumThreadsApiService extends RestApiService { { envVars: { TABLE_NAME: props.dataSource!, + BUCKET_NAME: 'wasedatime-thread-img', }, }, ); @@ -819,6 +833,14 @@ export class ForumThreadsApiService extends RestApiService { forumThreadsFunctions.deleteFunction, { proxy: true }, ); + const testPostIntegration = new apigw.LambdaIntegration( + forumThreadsFunctions.testPostFunction, + { proxy: true }, + ); + const testGetIntegration = new apigw.LambdaIntegration( + forumThreadsFunctions.testGetFunction, + { proxy: true }, + ); const getAllForumThreads = root.addMethod( apigw2.HttpMethod.GET, @@ -918,13 +940,44 @@ export class ForumThreadsApiService extends RestApiService { requestValidator: props.validator, }, ); + const testPostForumThreads = testResource.addMethod( + apigw2.HttpMethod.POST, + testPostIntegration, + { + operationName: 'testPostThread', + methodResponses: [ + { + statusCode: '200', + responseParameters: lambdaRespParams, + }, + ], + authorizer: props.authorizer, + requestValidator: props.validator, + }, + ); + + const testGetForumThreads = testResource.addMethod( + apigw2.HttpMethod.GET, + testGetIntegration, + { + operationName: 'testGetThread', + methodResponses: [ + { + statusCode: '200', + responseParameters: lambdaRespParams, + }, + ], + authorizer: props.authorizer, + requestValidator: props.validator, + }, + ); this.resourceMapping = { '/forum': { [apigw2.HttpMethod.GET]: getAllForumThreads, [apigw2.HttpMethod.OPTIONS]: optionsForumHome, }, - '/forum/{uid}': { + '/forum/user': { [apigw2.HttpMethod.GET]: getUserForumThreads, [apigw2.HttpMethod.OPTIONS]: optionsUserThreads, }, @@ -938,6 +991,11 @@ export class ForumThreadsApiService extends RestApiService { [apigw2.HttpMethod.PATCH]: patchForumThreads, [apigw2.HttpMethod.DELETE]: deleteForumThreads, }, + '/forum/test': { + [apigw2.HttpMethod.POST]: testPostForumThreads, + [apigw2.HttpMethod.GET]: testGetForumThreads, + [apigw2.HttpMethod.OPTIONS]: optionsTestThreads, + }, }; } } diff --git a/lib/constructs/common/lambda-functions.ts b/lib/constructs/common/lambda-functions.ts index a51645a03..85ca97d75 100644 --- a/lib/constructs/common/lambda-functions.ts +++ b/lib/constructs/common/lambda-functions.ts @@ -166,7 +166,7 @@ export class SyllabusScraper extends Construct { logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 4096, runtime: lambda.Runtime.PYTHON_3_9, - timeout: Duration.seconds(210), + timeout: Duration.seconds(300), environment: props.envVars, role: s3AccessRole, }); @@ -491,19 +491,21 @@ export class ForumThreadFunctions extends Construct { readonly postFunction: lambda.Function; readonly patchFunction: lambda.Function; readonly deleteFunction: lambda.Function; + readonly testPostFunction: lambda.Function; + readonly testGetFunction: lambda.Function; constructor(scope: Construct, id: string, props: FunctionsProps) { super(scope, id); - const dynamoDBReadRole: iam.LazyRole = new iam.LazyRole( + const DBReadRole: iam.LazyRole = new iam.LazyRole( this, - 'dynamo-read-role', + 'dynamo-s3-read-role', { assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), description: - 'Allow lambda function to perform crud operation on dynamodb', + 'Allow lambda function to perform read operation on dynamodb and s3', path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, - roleName: 'dynamodb-lambda-read-thread', + roleName: 'dynamodb-s3-lambda-read-thread', managedPolicies: [ iam.ManagedPolicy.fromManagedPolicyArn( this, @@ -515,19 +517,24 @@ export class ForumThreadFunctions extends Construct { 'db-read-only', 'arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess', ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-read-only', + 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess', + ), ], }, ); - const dynamoDBPutRole: iam.LazyRole = new iam.LazyRole( + const DBPutRole: iam.LazyRole = new iam.LazyRole( this, - 'dynamo-put-role', + 'dynamo-s3-put-role', { assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), description: - 'Allow lambda function to perform crud operation on dynamodb', + 'Allow lambda function to perform crud operation on dynamodb and s3', path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, - roleName: 'dynamodb-lambda-write-thread', + roleName: 'dynamodb-s3-put-role', managedPolicies: [ iam.ManagedPolicy.fromManagedPolicyArn( this, @@ -539,6 +546,11 @@ export class ForumThreadFunctions extends Construct { 'db-full-access', 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess', ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-full-access', + 'arn:aws:iam::aws:policy/AmazonS3FullAccess', + ), ], }, ); @@ -552,9 +564,9 @@ export class ForumThreadFunctions extends Construct { functionName: 'get-all-threads', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 128, - role: dynamoDBReadRole, + role: DBReadRole, runtime: lambda.Runtime.PYTHON_3_9, - timeout: Duration.seconds(3), + timeout: Duration.seconds(10), environment: props.envVars, }, ); @@ -568,7 +580,7 @@ export class ForumThreadFunctions extends Construct { functionName: 'get-user-threads', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 128, - role: dynamoDBReadRole, + role: DBReadRole, runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.seconds(3), environment: props.envVars, @@ -584,7 +596,7 @@ export class ForumThreadFunctions extends Construct { functionName: 'get-single-thread', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 128, - role: dynamoDBPutRole, + role: DBPutRole, runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.seconds(3), environment: props.envVars, @@ -597,7 +609,7 @@ export class ForumThreadFunctions extends Construct { functionName: 'post-forum-thread', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 256, - role: dynamoDBPutRole, + role: DBPutRole, runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.seconds(5), environment: props.envVars, @@ -612,7 +624,7 @@ export class ForumThreadFunctions extends Construct { functionName: 'patch-forum-thread', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 256, - role: dynamoDBPutRole, + role: DBPutRole, runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.seconds(5), environment: props.envVars, @@ -627,11 +639,43 @@ export class ForumThreadFunctions extends Construct { functionName: 'delete-forum-thread', logRetention: logs.RetentionDays.ONE_MONTH, memorySize: 128, - role: dynamoDBPutRole, + role: DBPutRole, runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.seconds(3), environment: props.envVars, }); + + this.testPostFunction = new lambda_py.PythonFunction( + this, + 'test-post-thread', + { + entry: 'src/lambda/test-post-thread', + description: 'lambda to test forum functionalities', + functionName: 'test-post-forum-thread', + logRetention: logs.RetentionDays.ONE_MONTH, + memorySize: 128, + role: DBPutRole, + runtime: lambda.Runtime.PYTHON_3_9, + timeout: Duration.seconds(3), + environment: props.envVars, + }, + ); + + this.testGetFunction = new lambda_py.PythonFunction( + this, + 'test-get-thread', + { + entry: 'src/lambda/test-get-single-thread', + description: 'lambda to test forum get functionalities', + functionName: 'test-get-forum-thread', + logRetention: logs.RetentionDays.ONE_MONTH, + memorySize: 128, + role: DBPutRole, + runtime: lambda.Runtime.PYTHON_3_9, + timeout: Duration.seconds(3), + environment: props.envVars, + }, + ); } } @@ -747,3 +791,249 @@ export class ForumCommentFunctions extends Construct { }); } } + +export class ThreadImageProcessFunctions extends Construct { + // readonly getFunction: lambda.Function; + readonly syncImageFunction: lambda.Function; + readonly resizeImageFunction: lambda.Function; + // readonly deleteFunction: lambda.Function; + + constructor(scope: Construct, id: string, props: FunctionsProps) { + super(scope, id); + + const DBReadRole: iam.LazyRole = new iam.LazyRole( + this, + 'dynamo-s3-read-role', + { + assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), + description: + 'Allow lambda function to perform read operation on dynamodb and s3', + path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, + roleName: 'dynamodb-s3-lambda-read-thread', + managedPolicies: [ + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'basic-exec', + 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'db-read-only', + 'arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-read-only', + 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess', + ), + ], + }, + ); + + const DBSyncRole: iam.LazyRole = new iam.LazyRole( + this, + 'dynamo-s3-thread-sync-role', + { + assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), + description: + 'Allow lambda function to perform crud operation on dynamodb and s3', + path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, + roleName: 'dynamodb-s3-thread-sync-role', + managedPolicies: [ + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'basic-exec1', + 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'db-full-access', + 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-full-access', + 'arn:aws:iam::aws:policy/AmazonS3FullAccess', + ), + ], + }, + ); + + // this.syncImageFunction = new lambda_py.PythonFunction(this, 'sync-image', { + // entry: 'src/lambda/sync-image', + // description: + // 'post image to dyanamo db database when image inputed in s3 bucket', + // functionName: 'sync-image', + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 256, + // role: DBSyncRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(5), + // environment: props.envVars, + // }); + + this.resizeImageFunction = new lambda_py.PythonFunction( + this, + 'resize-image', + { + entry: 'src/lambda/resize-image', + description: + 'Resize uploaded image to a thumbnail and store in s3 bucket', + functionName: 'patch-image', + logRetention: logs.RetentionDays.ONE_MONTH, + memorySize: 256, + role: DBSyncRole, + runtime: lambda.Runtime.PYTHON_3_9, + timeout: Duration.seconds(5), + environment: props.envVars, + }, + ); + + // this.getFunction = new lambda_py.PythonFunction(this, "get-comment", { + // entry: "src/lambda/get-comments", + // description: "get forum comments from the database.", + // functionName: "get-forum-comments", + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 128, + // role: DBReadRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(3), + // environment: props.envVars, + // }); + + // this.deleteFunction = new lambda_py.PythonFunction(this, "delete-comment", { + // entry: "src/lambda/delete-comment", + // description: "Delete forum comment in the database.", + // functionName: "delete-forum-comment", + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 128, + // role: DBPutRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(3), + // environment: props.envVars, + // }); + } +} + +export class AdsImageProcessFunctions extends Construct { + // readonly getFunction: lambda.Function; + readonly syncImageFunction: lambda.Function; + readonly resizeImageFunction: lambda.Function; + // readonly deleteFunction: lambda.Function; + + constructor(scope: Construct, id: string, props: FunctionsProps) { + super(scope, id); + + const DBReadRole: iam.LazyRole = new iam.LazyRole( + this, + 'dynamo-s3-read-role', + { + assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), + description: + 'Allow lambda function to perform read operation on dynamodb and s3', + path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, + roleName: 'dynamodb-s3-lambda-read-thread', + managedPolicies: [ + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'basic-exec', + 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'db-read-only', + 'arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-read-only', + 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess', + ), + ], + }, + ); + + const DBSyncRole: iam.LazyRole = new iam.LazyRole( + this, + 'dynamo-s3-ads-sync-role', + { + assumedBy: new iam.ServicePrincipal(AwsServicePrincipal.LAMBDA), + description: + 'Allow lambda function to perform crud operation on dynamodb and s3', + path: `/service-role/${AwsServicePrincipal.LAMBDA}/`, + roleName: 'dynamodb-s3-ads-sync-role', + managedPolicies: [ + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'basic-exec1', + 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 'db-full-access', + 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess', + ), + iam.ManagedPolicy.fromManagedPolicyArn( + this, + 's3-full-access', + 'arn:aws:iam::aws:policy/AmazonS3FullAccess', + ), + ], + }, + ); + + this.syncImageFunction = new lambda_py.PythonFunction(this, 'sync-image', { + entry: 'src/lambda/sync-image', + description: + 'post image to dyanamo db database when image inputed in s3 bucket', + functionName: 'sync-image', + logRetention: logs.RetentionDays.ONE_MONTH, + memorySize: 256, + role: DBSyncRole, + runtime: lambda.Runtime.PYTHON_3_9, + timeout: Duration.seconds(5), + environment: props.envVars, + }); + + // this.resizeImageFunction = new lambda_py.PythonFunction( + // this, + // "resize-image", + // { + // entry: "src/lambda/resize-image", + // description: + // "Resize uploaded image to a thumbnail and store in s3 bucket", + // functionName: "patch-image", + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 256, + // role: DBSyncRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(5), + // environment: props.envVars, + // } + // ); + + // this.getFunction = new lambda_py.PythonFunction(this, "get-comment", { + // entry: "src/lambda/get-comments", + // description: "get forum comments from the database.", + // functionName: "get-forum-comments", + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 128, + // role: DBReadRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(3), + // environment: props.envVars, + // }); + + // this.deleteFunction = new lambda_py.PythonFunction(this, "delete-comment", { + // entry: "src/lambda/delete-comment", + // description: "Delete forum comment in the database.", + // functionName: "delete-forum-comment", + // logRetention: logs.RetentionDays.ONE_MONTH, + // memorySize: 128, + // role: DBPutRole, + // runtime: lambda.Runtime.PYTHON_3_9, + // timeout: Duration.seconds(3), + // environment: props.envVars, + // }); + } +} diff --git a/lib/constructs/persistence/data-pipeline.ts b/lib/constructs/persistence/data-pipeline.ts index 6ef0daf2a..9832abf3c 100644 --- a/lib/constructs/persistence/data-pipeline.ts +++ b/lib/constructs/persistence/data-pipeline.ts @@ -2,6 +2,7 @@ import { RemovalPolicy } from 'aws-cdk-lib'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as events from 'aws-cdk-lib/aws-events'; import * as events_targets from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as event_sources from 'aws-cdk-lib/aws-lambda-event-sources'; import * as s3 from 'aws-cdk-lib/aws-s3'; @@ -11,12 +12,19 @@ import { Construct } from 'constructs'; import { syllabusSchedule } from '../../configs/event/schedule'; import { prodCorsRule } from '../../configs/s3/cors'; import { allowApiGatewayPolicy, allowLambdaPolicy } from '../../utils/s3'; -import { SyllabusScraper, SyllabusUpdateFunction } from '../common/lambda-functions'; +import { + SyllabusScraper, + SyllabusUpdateFunction, + ThreadImageProcessFunctions, + AdsImageProcessFunctions, +} from '../common/lambda-functions'; export enum Worker { SYLLABUS, CAREER, - FEEDS + FEEDS, + THREADIMG, + ADS, //! New ADS value } export interface DataPipelineProps { @@ -52,14 +60,21 @@ export class SyllabusDataPipeline extends AbstractDataPipeline { allowApiGatewayPolicy(this.dataWarehouse); allowLambdaPolicy(this.dataWarehouse); - const scraperBaseFunction: lambda.Function = new SyllabusScraper(this, 'scraper-base-function', { - envVars: { - ['BUCKET_NAME']: this.dataWarehouse.bucketName, - ['OBJECT_PATH']: 'syllabus/', + const scraperBaseFunction: lambda.Function = new SyllabusScraper( + this, + 'scraper-base-function', + { + envVars: { + ['BUCKET_NAME']: this.dataWarehouse.bucketName, + ['OBJECT_PATH']: 'syllabus/', + }, }, - }).baseFunction; + ).baseFunction; - function getLambdaTaskInstance(schools: string[], num: string): sfn_tasks.LambdaInvoke { + function getLambdaTaskInstance( + schools: string[], + num: string, + ): sfn_tasks.LambdaInvoke { return new sfn_tasks.LambdaInvoke(scope, 'task-' + num, { lambdaFunction: scraperBaseFunction, comment: 'Scrape the syllabus info of school(s).', @@ -79,9 +94,35 @@ export class SyllabusDataPipeline extends AbstractDataPipeline { .next(getLambdaTaskInstance(['PSE', 'G_ASE', 'LAW'], '4')) .next(getLambdaTaskInstance(['G_FSE', 'SOC', 'SSS'], '5')) .next(getLambdaTaskInstance(['G_LAS', 'G_CSE', 'G_EDU', 'HUM'], '6')) - .next(getLambdaTaskInstance(['SILS', 'G_HUM', 'CJL', 'SPS', 'G_WBS', 'G_PS'], '7')) - .next(getLambdaTaskInstance(['G_SPS', 'G_IPS', 'G_WLS', 'G_E', 'G_SSS', 'G_SC', 'G_LAW', - 'G_SAPS', 'G_SA', 'G_SJAL', 'G_SICCS', 'G_SEEE', 'EHUM', 'ART', 'CIE', 'G_ITS'], '8')) + .next( + getLambdaTaskInstance( + ['SILS', 'G_HUM', 'CJL', 'SPS', 'G_WBS', 'G_PS'], + '7', + ), + ) + .next( + getLambdaTaskInstance( + [ + 'G_SPS', + 'G_IPS', + 'G_WLS', + 'G_E', + 'G_SSS', + 'G_SC', + 'G_LAW', + 'G_SAPS', + 'G_SA', + 'G_SJAL', + 'G_SICCS', + 'G_SEEE', + 'EHUM', + 'ART', + 'CIE', + 'G_ITS', + ], + '8', + ), + ) .next(new sfn.Succeed(this, 'success', {})), }); @@ -141,17 +182,122 @@ export class SyllabusSyncPipeline extends AbstractDataPipeline { //Use exsisting s3 bucket this.dataSource = props.dataSource!; - this.processor = new SyllabusUpdateFunction(this, 'syllabus-update-function', { - envVars: { - ['BUCKET_NAME']: this.dataSource.bucketName, - ['TABLE_NAME']: this.dataWarehouse.tableName, - ['OBJECT_PATH']: 'syllabus/', + this.processor = new SyllabusUpdateFunction( + this, + 'syllabus-update-function', + { + envVars: { + ['BUCKET_NAME']: this.dataSource.bucketName, + ['TABLE_NAME']: this.dataWarehouse.tableName, + ['OBJECT_PATH']: 'syllabus/', + }, }, - }).updateFunction; + ).updateFunction; - this.processor.addEventSource(new event_sources.S3EventSource(this.dataSource, { - events: [s3.EventType.OBJECT_CREATED_PUT], - filters: [{ prefix: 'syllabus/' }], - })); + this.processor.addEventSource( + new event_sources.S3EventSource(this.dataSource, { + events: [s3.EventType.OBJECT_CREATED_PUT], + filters: [{ prefix: 'syllabus/' }], + }), + ); + } +} + +export class ThreadImgDataPipeline extends AbstractDataPipeline { + readonly dataSource: s3.Bucket; + readonly processor: lambda.Function; + readonly dataWarehouse: s3.Bucket; + + constructor(scope: Construct, id: string, props?: DataPipelineProps) { + super(scope, id); + + // Initialize S3 bucket for storing thread images + this.dataSource = new s3.Bucket(this, 'thread-img-bucket', { + accessControl: s3.BucketAccessControl.PRIVATE, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + bucketName: 'wasedatime-thread-img', + encryption: s3.BucketEncryption.S3_MANAGED, + publicReadAccess: false, + removalPolicy: RemovalPolicy.RETAIN, + versioned: false, + }); + + this.dataWarehouse = new s3.Bucket(this, 'thumbnail-img-warehouse', { + bucketName: 'wasedatime-thumbnail-img', + encryption: s3.BucketEncryption.S3_MANAGED, + removalPolicy: RemovalPolicy.DESTROY, + versioned: false, + blockPublicAccess: new s3.BlockPublicAccess({ + blockPublicAcls: true, + blockPublicPolicy: false, + ignorePublicAcls: true, + restrictPublicBuckets: false, + }), + }); + + const publicReadStatement = new iam.PolicyStatement({ + actions: ['s3:GetObject'], + resources: [`${this.dataWarehouse.bucketArn}/*`], + effect: iam.Effect.ALLOW, + principals: [new iam.ArnPrincipal('*')], + }); + this.dataWarehouse.addToResourcePolicy(publicReadStatement); + + this.processor = new ThreadImageProcessFunctions( + this, + 'image-process-func', + { + envVars: { + INPUT_BUCKET: this.dataSource.bucketName, + OUTPUT_BUCKET: this.dataWarehouse.bucketName, + TABLE_NAME: 'forum-threads', + }, + }, + ).resizeImageFunction; + + this.processor.addEventSource( + new event_sources.S3EventSource(this.dataSource, { + events: [s3.EventType.OBJECT_CREATED], + }), + ); + } +} + +//! New pipeline for ads +export class AdsDataPipeline extends AbstractDataPipeline { + readonly dataSource?: s3.Bucket; + readonly processor: lambda.Function; + readonly dataWarehouse: dynamodb.Table; + + constructor(scope: Construct, id: string, props: DataPipelineProps) { + super(scope, id); + + this.dataSource = new s3.Bucket(this, 'ads-bucket', { + accessControl: s3.BucketAccessControl.PRIVATE, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + bucketName: 'wasedatime-ads', + cors: prodCorsRule, + encryption: s3.BucketEncryption.S3_MANAGED, + publicReadAccess: false, + removalPolicy: RemovalPolicy.DESTROY, + versioned: false, + }); + + this.dataWarehouse = props.dataWarehouse!; + + // this.processor = new AdsImageProcessFunctions(this, "image-process-func", { + // envVars: { + // ["BUCKET_NAME"]: this.dataSource.bucketName, + // ["TABLE_NAME"]: this.dataWarehouse.tableName, + // ["OBJECT_PATH"]: "syllabus/", + // }, + // }).syncImageFunction; + + // this.processor.addEventSource( + // new event_sources.S3EventSource(this.dataSource, { + // events: [s3.EventType.OBJECT_CREATED_PUT], + // filters: [{ prefix: "syllabus/" }], + // }) + // ); } } diff --git a/lib/constructs/persistence/database.ts b/lib/constructs/persistence/database.ts index 9f5eba639..c88024fbb 100644 --- a/lib/constructs/persistence/database.ts +++ b/lib/constructs/persistence/database.ts @@ -10,6 +10,7 @@ export enum Collection { TIMETABLE, THREAD, COMMENT, + ADS, //! New ADS value } export class DynamoDatabase extends Construct { @@ -137,6 +138,22 @@ export class DynamoDatabase extends Construct { pointInTimeRecovery: true, }, ); + + this.tables[Collection.ADS] = new dynamodb.Table( + this, + 'dynamodb-ads-table', + { + partitionKey: { name: 'board_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'ad_id', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PROVISIONED, + encryption: dynamodb.TableEncryption.DEFAULT, + removalPolicy: RemovalPolicy.RETAIN, + timeToLiveAttribute: 'ttl', + tableName: 'waseda-abs-count', + readCapacity: 1, + writeCapacity: 1, + }, + ); } } //comment to prevent empty thread diff --git a/lib/constructs/presentation/web-app.ts b/lib/constructs/presentation/web-app.ts index d377277ad..9fe9f5269 100644 --- a/lib/constructs/presentation/web-app.ts +++ b/lib/constructs/presentation/web-app.ts @@ -185,10 +185,15 @@ export class AmplifyMonoWebApp extends AbstractWebApp { status: amplify.RedirectStatus.REWRITE, }), ); - this.app.addEnvironment( + this.branches.dev.addEnvironment( `VITE_MF_${name.toUpperCase()}_BASE_PATH`, `https://develop.${appDomain}`, ); + + this.branches.main.addEnvironment( + `VITE_MF_${name.toUpperCase()}_BASE_PATH`, + `https://wasedatime.com/${name}`, + ); } } diff --git a/lib/stacks/persistence.ts b/lib/stacks/persistence.ts index ae2c94a98..ef75737be 100644 --- a/lib/stacks/persistence.ts +++ b/lib/stacks/persistence.ts @@ -7,6 +7,8 @@ import { CareerDataPipeline, SyllabusDataPipeline, SyllabusSyncPipeline, + ThreadImgDataPipeline, + AdsDataPipeline, //! New value Worker, } from '../constructs/persistence/data-pipeline'; import { Collection, DynamoDatabase } from '../constructs/persistence/database'; @@ -44,6 +46,18 @@ export class WasedaTimePersistenceLayer extends PersistenceLayer { }, ); + const threadImgDataPipeline = new ThreadImgDataPipeline( + this, + 'thread-img-data-pipeline', + ); + this.dataPipelines[Worker.THREADIMG] = threadImgDataPipeline; + + //! New pipeline for ads + const adsDataPipeline = new AdsDataPipeline(this, 'ads-data-pipeline', { + dataWarehouse: dynamoDatabase.tables[Collection.ADS], + }); + this.dataPipelines[Worker.ADS] = adsDataPipeline; + this.dataInterface.setEndpoint( DataEndpoint.COURSE_REVIEWS, dynamoDatabase.tables[Collection.COURSE_REVIEW].tableName, @@ -68,6 +82,13 @@ export class WasedaTimePersistenceLayer extends PersistenceLayer { DataEndpoint.COMMENT, dynamoDatabase.tables[Collection.COMMENT].tableName, ); + + //! new endpoint for adsPipeline + this.dataInterface.setEndpoint( + DataEndpoint.ADS, + dynamoDatabase.tables[Collection.ADS].tableName, + ); + // this.dataInterface.setEndpoint( // DataEndpoint.COURSE, // syllabusSyncPipeline.dataWarehouse.tableName, diff --git a/src/lambda/get-all-threads/index.py b/src/lambda/get-all-threads/index.py index e8c728c3d..5f353cbe9 100644 --- a/src/lambda/get-all-threads/index.py +++ b/src/lambda/get-all-threads/index.py @@ -1,7 +1,7 @@ from boto3.dynamodb.conditions import Key import boto3 from datetime import datetime -from utils import JsonPayloadBuilder, table, resp_handler +from utils import JsonPayloadBuilder, table, resp_handler, s3_client, bucket, generate_url @resp_handler @@ -37,8 +37,17 @@ def get_all_threads(uid, index, num, school, tags, board_id): item['user_liked'] = uid in item.get('likes', []) item['total_likes'] = len(item.get('likes', [])) + presigned_url = None + + if "obj_key" in item: + bucket_name = bucket + presigned_url = generate_url(bucket_name, item["obj_key"]) + if presigned_url: + item["url"] = presigned_url + item.pop('uid', None) item.pop('likes', None) + item.pop('obj_key', None) body = JsonPayloadBuilder().add_status( True).add_data(paginated_items).add_message(end_index).compile() diff --git a/src/lambda/get-all-threads/utils.py b/src/lambda/get-all-threads/utils.py index 05f0218dc..28cd921a5 100644 --- a/src/lambda/get-all-threads/utils.py +++ b/src/lambda/get-all-threads/utils.py @@ -7,6 +7,9 @@ db = boto3.resource("dynamodb", region_name="ap-northeast-1") table = db.Table(os.getenv('TABLE_NAME')) +s3_client = boto3.client('s3') +bucket = os.getenv('BUCKET_NAME') + class DecimalEncoder(json.JSONEncoder): def default(self, obj): @@ -64,3 +67,16 @@ def handle(*args, **kwargs): return api_response(500, resp) return handle + + +def generate_url(bucket_name, object_key, expiration=3600): + try: + response = s3_client.generate_presigned_url('get_object', + Params={'Bucket': bucket_name, + 'Key': object_key}, + ExpiresIn=expiration) + except Exception as e: + logging.error(str(e)) + return None + + return response diff --git a/src/lambda/get-single-thread/index.py b/src/lambda/get-single-thread/index.py index 479cd8b03..c6364f790 100644 --- a/src/lambda/get-single-thread/index.py +++ b/src/lambda/get-single-thread/index.py @@ -1,6 +1,6 @@ from boto3.dynamodb.conditions import Key, Attr from datetime import datetime -from utils import JsonPayloadBuilder, table, resp_handler +from utils import JsonPayloadBuilder, table, resp_handler, s3_client, bucket, generate_url @resp_handler @@ -56,8 +56,15 @@ def get_single_thread(board_id, thread_id, uid=""): item['user_liked'] = uid in item.get('likes', []) item['total_likes'] = len(item.get('likes', [])) + if "obj_key" in item: + bucket_name = bucket + presigned_url = generate_url(bucket_name, item["obj_key"]) + if presigned_url: + item["url"] = presigned_url + item.pop('uid', None) item.pop('likes', None) + item.pop('obj_key', None) body = JsonPayloadBuilder().add_status( True).add_data(item).add_message('').compile() diff --git a/src/lambda/get-single-thread/utils.py b/src/lambda/get-single-thread/utils.py index 05f0218dc..28cd921a5 100644 --- a/src/lambda/get-single-thread/utils.py +++ b/src/lambda/get-single-thread/utils.py @@ -7,6 +7,9 @@ db = boto3.resource("dynamodb", region_name="ap-northeast-1") table = db.Table(os.getenv('TABLE_NAME')) +s3_client = boto3.client('s3') +bucket = os.getenv('BUCKET_NAME') + class DecimalEncoder(json.JSONEncoder): def default(self, obj): @@ -64,3 +67,16 @@ def handle(*args, **kwargs): return api_response(500, resp) return handle + + +def generate_url(bucket_name, object_key, expiration=3600): + try: + response = s3_client.generate_presigned_url('get_object', + Params={'Bucket': bucket_name, + 'Key': object_key}, + ExpiresIn=expiration) + except Exception as e: + logging.error(str(e)) + return None + + return response diff --git a/src/lambda/get-user-threads/index.py b/src/lambda/get-user-threads/index.py index 6337a20c9..f301a36e8 100644 --- a/src/lambda/get-user-threads/index.py +++ b/src/lambda/get-user-threads/index.py @@ -9,7 +9,7 @@ def get_user_threads(uid=""): # Query the GSI response = table.query( - IndexName='UidbyThreadIDIndex', # Replace with your actual GSI name + IndexName='UidbyThreadIDIndex', KeyConditionExpression=Key('uid').eq(uid), FilterExpression=Attr('new_comment').eq(True), ScanIndexForward=False # Sorting by thread_id in descending order diff --git a/src/lambda/post-thread/index.py b/src/lambda/post-thread/index.py index 8f1728167..d38a8fd68 100644 --- a/src/lambda/post-thread/index.py +++ b/src/lambda/post-thread/index.py @@ -1,8 +1,8 @@ from boto3.dynamodb.conditions import Key import json from datetime import datetime -from utils import JsonPayloadBuilder, table, resp_handler, build_thread_id -import uuid +from utils import JsonPayloadBuilder, table, resp_handler, build_thread_id, s3_client, bucket +import base64 @resp_handler @@ -14,6 +14,20 @@ def post_thread(board_id, thread, uid): dt_now = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' + object_key = None + if "image" in thread: + image_data = base64.b64decode(thread["image"].split(',')[1]) + content_type = thread.get("contentType", "image/jpeg") + # Validate the content type + if content_type not in ["image/jpeg", "image/png", "image/gif"]: + raise ValueError("Invalid content type") + # Extracts 'jpeg', 'png', or 'gif' from the MIME type + extension = content_type.split("/")[-1] + object_key = f"{board_id}/{thread_id}/image.{extension}" + + s3_client.put_object(Bucket=bucket, Key=object_key, + Body=image_data, ContentType=content_type) + thread_item = { "board_id": board_id, "created_at": dt_now, @@ -27,7 +41,8 @@ def post_thread(board_id, thread, uid): "univ_id": thread["univ_id"], "views": 0, "comment_count": 0, - "new_comment": False + "new_comment": False, + "obj_key": object_key, } table.put_item(Item=thread_item) diff --git a/src/lambda/post-thread/utils.py b/src/lambda/post-thread/utils.py index 6c0ae57fc..7c9171986 100644 --- a/src/lambda/post-thread/utils.py +++ b/src/lambda/post-thread/utils.py @@ -6,11 +6,15 @@ from decimal import Decimal from datetime import datetime import uuid +from urllib.parse import quote # AWS DynamoDB Resources db = boto3.resource("dynamodb", region_name="ap-northeast-1") table = db.Table(os.getenv('TABLE_NAME')) +s3_client = boto3.client('s3') +bucket = os.getenv('BUCKET_NAME') + class DecimalEncoder(json.JSONEncoder): def default(self, obj): @@ -79,3 +83,10 @@ def build_thread_id(): thread_id = f"{ts}_{unique_id}" return thread_id + + +def sanitize_title(title): + valid_chars = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_- ") + sanitized_title = ''.join(c for c in title if c in valid_chars) + return quote(sanitized_title) # URL encode the sanitized title diff --git a/src/lambda/resize-image/index.py b/src/lambda/resize-image/index.py new file mode 100644 index 000000000..8ec2825fe --- /dev/null +++ b/src/lambda/resize-image/index.py @@ -0,0 +1,50 @@ +import boto3 +import os +import json +from PIL import Image +from io import BytesIO +from utils import JsonPayloadBuilder +from utils import resp_handler, input_bucket, output_bucket, table, s3_client + + +@resp_handler +def resize_image(key): + + # Download the image from S3 + s3_object = s3_client.get_object(Bucket=input_bucket, Key=key) + image_data = s3_object['Body'].read() + board_id, thread_id, _ = key.split('/') + + thumb_key = f"{thread_id}.jpeg" + + # Open the image with PIL + with Image.open(BytesIO(image_data)) as img: + # Resize the image + img.thumbnail((128, 128)) + buffer = BytesIO() + img.save(buffer, 'JPEG') + buffer.seek(0) + + # Upload the resized image to S3 + s3_client.put_object( + Body=buffer, ContentType='image/jpeg', Bucket=output_bucket, Key=thumb_key) + + board_id, thread_id, _ = key.split('/') + + table.update_item( + Key={'board_id': board_id, 'thread_id': thread_id}, + UpdateExpression='SET thumb_key = :thumbKey', + ExpressionAttributeValues={':thumbKey': thumb_key} + ) + + body = JsonPayloadBuilder().add_status(True).add_data( + None).add_message("Image resized successfully.").compile() + return body + + +def handler(event, context): + + # Get the object from the event + key = event['Records'][0]['s3']['object']['key'] + + return resize_image(key) diff --git a/src/lambda/resize-image/requirements.txt b/src/lambda/resize-image/requirements.txt new file mode 100644 index 000000000..a75b581bd --- /dev/null +++ b/src/lambda/resize-image/requirements.txt @@ -0,0 +1 @@ +Pillow==8.3.2 \ No newline at end of file diff --git a/src/lambda/resize-image/utils.py b/src/lambda/resize-image/utils.py new file mode 100644 index 000000000..672b44272 --- /dev/null +++ b/src/lambda/resize-image/utils.py @@ -0,0 +1,71 @@ +import boto3 +import json +import logging +import os +from decimal import Decimal + +db = boto3.resource("dynamodb", region_name="ap-northeast-1") +table = db.Table(os.getenv('TABLE_NAME')) + +s3_client = boto3.client('s3') + +input_bucket = os.environ['INPUT_BUCKET'] +output_bucket = os.environ['OUTPUT_BUCKET'] + + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return json.JSONEncoder.default(self, obj) + + +class JsonPayloadBuilder: + payload = {} + + def add_status(self, success): + self.payload['success'] = success + return self + + def add_data(self, data): + self.payload['data'] = data + return self + + def add_message(self, msg): + self.payload['message'] = msg + return self + + def compile(self): + return json.dumps(self.payload, cls=DecimalEncoder, ensure_ascii=False).encode('utf8') + + +def api_response(code, body): + return { + "isBase64Encoded": False, + "statusCode": code, + 'headers': { + "Access-Control-Allow-Origin": '*', + "Content-Type": "application/json", + "Referrer-Policy": "origin" + }, + "multiValueHeaders": {"Access-Control-Allow-Methods": ["POST", "OPTIONS", "GET", "PATCH", "DELETE"]}, + "body": body + } + + +def resp_handler(func): + def handle(*args, **kwargs): + try: + resp = func(*args, **kwargs) + return api_response(200, resp) + except LookupError: + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Not found").compile() + return api_response(404, resp) + except Exception as e: + logging.error(str(e)) + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Internal error, please contact bugs@wasedatime.com.").compile() + return api_response(500, resp) + + return handle diff --git a/src/lambda/sync-image/index.py b/src/lambda/sync-image/index.py new file mode 100644 index 000000000..2959113be --- /dev/null +++ b/src/lambda/sync-image/index.py @@ -0,0 +1,2 @@ +def handler(event): + pass diff --git a/src/lambda/test-get-single-thread/index.py b/src/lambda/test-get-single-thread/index.py new file mode 100644 index 000000000..bb7eb4c73 --- /dev/null +++ b/src/lambda/test-get-single-thread/index.py @@ -0,0 +1,82 @@ +from boto3.dynamodb.conditions import Key, Attr +from datetime import datetime +from utils import JsonPayloadBuilder, table, resp_handler, s3_client, bucket, generate_url + + +@resp_handler +def get_single_thread(board_id, thread_id, uid=""): + results = table.query( + KeyConditionExpression=Key("board_id").eq( + board_id) & Key("thread_id").eq(thread_id) + )["Items"] + + if not results: + raise LookupError + + item = results[0] + + if item["uid"] == uid: + table.update_item( + Key={ + "board_id": board_id, + "thread_id": thread_id, + }, + UpdateExpression="SET #v = #v + :incr, #nc = :newComment", + ConditionExpression="#uid = :uidValue", + ExpressionAttributeNames={ + '#v': 'views', + '#nc': 'new_comment', + '#uid': 'uid' + }, + ExpressionAttributeValues={ + ":incr": 1, + ":newComment": False, + ":uidValue": uid + } + ) + else: + # Increment the view count but do not update new_comment + table.update_item( + Key={ + "board_id": board_id, + "thread_id": thread_id, + }, + UpdateExpression="SET #v = #v + :incr", + ExpressionAttributeNames={ + '#v': 'views' + }, + ExpressionAttributeValues={ + ":incr": 1 + } + ) + + item["mod"] = False + if item["uid"] == uid: + item["mod"] = True + item['user_liked'] = uid in item.get('likes', []) + item['total_likes'] = len(item.get('likes', [])) + + if "obj_key" in item: + bucket_name = bucket + presigned_url = generate_url(bucket_name, item["obj_key"]) + if presigned_url: + item["url"] = presigned_url + + item.pop('uid', None) + item.pop('likes', None) + item.pop('obj_key', None) + + body = JsonPayloadBuilder().add_status( + True).add_data(item).add_message('').compile() + return body + + +def handler(event, context): + params = { + "board_id": event["queryStringParameters"]["board_id"], + "thread_id": event["queryStringParameters"]["thread_id"], + } + if "uid" in event["queryStringParameters"]: + params["uid"] = event["queryStringParameters"]["uid"] + + return get_single_thread(**params) diff --git a/src/lambda/test-get-single-thread/utils.py b/src/lambda/test-get-single-thread/utils.py new file mode 100644 index 000000000..28cd921a5 --- /dev/null +++ b/src/lambda/test-get-single-thread/utils.py @@ -0,0 +1,82 @@ +import boto3 +import json +import logging +import os +from decimal import Decimal + +db = boto3.resource("dynamodb", region_name="ap-northeast-1") +table = db.Table(os.getenv('TABLE_NAME')) + +s3_client = boto3.client('s3') +bucket = os.getenv('BUCKET_NAME') + + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return json.JSONEncoder.default(self, obj) + + +class JsonPayloadBuilder: + payload = {} + + def add_status(self, success): + self.payload['success'] = success + return self + + def add_data(self, data): + self.payload['data'] = data + return self + + def add_message(self, msg): + self.payload['message'] = msg + return self + + def compile(self): + return json.dumps(self.payload, cls=DecimalEncoder, ensure_ascii=False).encode('utf8') + + +def api_response(code, body): + return { + "isBase64Encoded": False, + "statusCode": code, + 'headers': { + "Access-Control-Allow-Origin": '*', + "Content-Type": "application/json", + "Referrer-Policy": "origin" + }, + "multiValueHeaders": {"Access-Control-Allow-Methods": ["POST", "OPTIONS", "GET", "PATCH", "DELETE"]}, + "body": body + } + + +def resp_handler(func): + def handle(*args, **kwargs): + try: + resp = func(*args, **kwargs) + return api_response(200, resp) + except LookupError: + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Not found").compile() + return api_response(404, resp) + except Exception as e: + logging.error(str(e)) + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Internal error, please contact bugs@wasedatime.com.").compile() + return api_response(500, resp) + + return handle + + +def generate_url(bucket_name, object_key, expiration=3600): + try: + response = s3_client.generate_presigned_url('get_object', + Params={'Bucket': bucket_name, + 'Key': object_key}, + ExpiresIn=expiration) + except Exception as e: + logging.error(str(e)) + return None + + return response diff --git a/src/lambda/test-post-thread/index.py b/src/lambda/test-post-thread/index.py new file mode 100644 index 000000000..79db2598c --- /dev/null +++ b/src/lambda/test-post-thread/index.py @@ -0,0 +1,69 @@ +from boto3.dynamodb.conditions import Key +import json +from datetime import datetime +from utils import JsonPayloadBuilder, table, resp_handler, build_thread_id, s3_client, bucket, sanitize_title +import uuid +import base64 + + +@resp_handler +def test_post_thread(thread, uid): + + thread_id = build_thread_id() + + text = thread["body"] + + dt_now = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' + + object_key = None + if "image" in thread: + image_data = base64.b64decode(thread["image"].split(',')[1]) + content_type = thread.get("contentType", "image/jpeg") + # Validate the content type + if content_type not in ["image/jpeg", "image/png", "image/gif"]: + raise ValueError("Invalid content type") + # Extracts 'jpeg', 'png', or 'gif' from the MIME type + extension = content_type.split("/")[-1] + sanitized_title = sanitize_title(thread["title"]) + object_key = f"{thread_id}/{sanitized_title}.{extension}" + + s3_client.put_object(Bucket=bucket, Key=object_key, + Body=image_data, ContentType=content_type) + + thread_item = { + "board_id": thread["board_id"], + "created_at": dt_now, + "updated_at": dt_now, + "title": thread["title"], + "body": text, + "uid": uid, + "thread_id": thread_id, + "tag_id": thread["tag_id"], + "group_id": thread["group_id"], + "univ_id": thread["univ_id"], + "views": 0, + "comment_count": 0, + "new_comment": False, + "obj_key": object_key, + } + + table.put_item(Item=thread_item) + + thread_item.pop('uid', None) + thread_item["mod"] = True + + body = JsonPayloadBuilder().add_status( + True).add_data(thread_item).add_message('').compile() + return body + + +def handler(event, context): + + req = json.loads(event['body']) + params = { + # "board_id": event["pathParameters"]["board_id"], + "thread": req["data"], + "uid": event['requestContext']['authorizer']['claims']['sub'] + } + + return test_post_thread(**params) diff --git a/src/lambda/test-post-thread/utils.py b/src/lambda/test-post-thread/utils.py new file mode 100644 index 000000000..7c9171986 --- /dev/null +++ b/src/lambda/test-post-thread/utils.py @@ -0,0 +1,92 @@ +import base64 +import boto3 +import json +import logging +import os +from decimal import Decimal +from datetime import datetime +import uuid +from urllib.parse import quote + +# AWS DynamoDB Resources +db = boto3.resource("dynamodb", region_name="ap-northeast-1") +table = db.Table(os.getenv('TABLE_NAME')) + +s3_client = boto3.client('s3') +bucket = os.getenv('BUCKET_NAME') + + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return json.JSONEncoder.default(self, obj) + + +class JsonPayloadBuilder: + payload = {} + + def add_status(self, success): + self.payload['success'] = success + return self + + def add_data(self, data): + self.payload['data'] = data + return self + + def add_message(self, msg): + self.payload['message'] = msg + return self + + def compile(self): + return json.dumps(self.payload, cls=DecimalEncoder, ensure_ascii=False).encode('utf8') + + +def api_response(code, body): + return { + "isBase64Encoded": False, + "statusCode": code, + 'headers': { + "Access-Control-Allow-Origin": '*', + "Content-Type": "application/json", + "Referrer-Policy": "origin" + }, + "multiValueHeaders": {"Access-Control-Allow-Methods": ["POST", "OPTIONS", "GET", "PATCH", "DELETE"]}, + "body": body + } + + +def resp_handler(func=None, headers=None): + def handle(*args, **kwargs): + try: + resp = func(*args, **kwargs) + return api_response(200, resp) + except LookupError: + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Not found").compile() + return api_response(404, resp) + except Exception as e: + logging.error(str(e)) + resp = JsonPayloadBuilder().add_status(False).add_data(None) \ + .add_message("Internal error, please contact bugs@wasedatime.com.").compile() + return api_response(500, resp) + + return handle + + +def build_thread_id(): + + unique_id = str(uuid.uuid4()) + + ts = datetime.now().strftime('%Y%m%d%H%M%S') + + thread_id = f"{ts}_{unique_id}" + + return thread_id + + +def sanitize_title(title): + valid_chars = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_- ") + sanitized_title = ''.join(c for c in title if c in valid_chars) + return quote(sanitized_title) # URL encode the sanitized title