diff --git a/bun.lock b/bun.lock index 32727238..d1c4f24e 100644 --- a/bun.lock +++ b/bun.lock @@ -125,6 +125,7 @@ "@aws-sdk/client-sqs": "^3.806.0", "@nestri/core": "workspace:", "actor-core": "^0.8.0", + "aws4fetch": "^1.0.20", "hono": "^4.7.8", "hono-openapi": "^0.4.8", }, diff --git a/infra/api.ts b/infra/api.ts index 142cd0a3..79e38565 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -4,7 +4,6 @@ import { domain } from "./dns"; import { secret } from "./secret"; import { cluster } from "./cluster"; import { postgres } from "./postgres"; -import { storage } from "./storage"; export const apiService = new sst.aws.Service("Api", { cluster, @@ -13,7 +12,6 @@ export const apiService = new sst.aws.Service("Api", { link: [ bus, auth, - storage, postgres, secret.SteamApiKey, secret.PolarSecret, diff --git a/infra/images.ts b/infra/images.ts index de3f916b..db4e7084 100644 --- a/infra/images.ts +++ b/infra/images.ts @@ -1,170 +1,57 @@ -import {api} from "./api" import { domain } from "./dns"; +import { storage } from "./storage"; + +sst.Linkable.wrap(aws.iam.AccessKey, (resource) => ({ + properties: { + key: resource.id, + secret: resource.secret, + }, +})) const cache = new sst.cloudflare.Kv("ImageCache"); const bucket = new sst.cloudflare.Bucket("ImageBucket"); +const lambdaInvokerUser = new aws.iam.User("ImageIAMUser", { + name: `${$app.name}-${$app.stage}-ImageIAMUser`, + forceDestroy: true +}); + +const imageProcessorFunction = new sst.aws.Function("ImageProcessor", + { + memory: "1024 MB", + link: [storage], + timeout: "30 seconds", + nodejs: { install: ["sharp"] }, + handler: "packages/functions/src/images/processor.handler", + }, +); + +new aws.iam.UserPolicy("InvokeLambdaPolicy", { + user: lambdaInvokerUser.name, + policy: $output({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: imageProcessorFunction.arn, + }, + ], + }).apply(JSON.stringify), +}); + +const accessKey = new aws.iam.AccessKey("ImageInvokerAccessKey", { + user: lambdaInvokerUser.name, +}); + export const imageCdn = new sst.cloudflare.Worker("ImageCDN", { url: true, domain: "cdn." + domain, - link: [bucket, cache, api], + link: [bucket, cache, imageProcessorFunction, accessKey], handler: "packages/functions/src/images/index.ts", }); export const outputs = { cdn: imageCdn.url } - -// const transformedImageBucket = new sst.aws.Bucket("TranformedStorage"); - -// const imageProcessorFunction = new sst.aws.Function("ImageProcessor", -// { -// handler: "src/image-processor.handler", -// nodejs: { install: ["sharp"] }, -// memory: "1024 MB", -// timeout: "30 seconds", -// url: true, -// link: [storage, transformedImageBucket], -// environment: { -// transformedImageCacheTTL: -// process.env.transformedImageCacheTTL ?? "max-age=31622400", -// maxImageSize: process.env.MAX_IMAGE_SIZE ?? "4700000", -// }, -// }, -// ); - -// const cloudfrontOAI = new aws.cloudfront.OriginAccessIdentity("CloudfrontOAI"); - -// const lambdaOAC = new aws.cloudfront.OriginAccessControl("OriginAccessControl", -// { -// name: `${$app.name}-${$app.stage}-OriginAccessControl`, -// originAccessControlOriginType: "lambda", -// signingBehavior: "always", -// signingProtocol: "sigv4", -// }, -// ); - -// const cloudfrontResponseHeadersPolicy = -// new aws.cloudfront.ResponseHeadersPolicy("ResponseHeadersPolicy", -// { -// name: `${$app.name}-${$app.stage}-ResponseHeadersPolicy`, -// customHeadersConfig: { -// items: [ -// { -// header: "x-aws-image-optimization", -// value: "v1.0", -// override: true, -// }, -// { header: "vary", value: "accept", override: true }, -// ], -// }, -// corsConfig: { -// accessControlAllowCredentials: false, -// accessControlAllowHeaders: { -// items: ["*"], -// }, -// accessControlAllowMethods: { -// items: ["GET"], -// }, -// accessControlAllowOrigins: { -// items: ["*"], -// }, -// accessControlMaxAgeSec: 600, -// originOverride: false, -// }, -// }, -// ); - -// const cachePolicy = new aws.cloudfront.CachePolicy("CachePolicy", -// { -// name: `${$app.name}-${$app.stage}-CachePolicy`, -// defaultTtl: 86400, -// maxTtl: 31536000, -// minTtl: 0, -// parametersInCacheKeyAndForwardedToOrigin: { -// cookiesConfig: { -// cookieBehavior: "none", -// }, -// headersConfig: { -// headerBehavior: "none", -// }, -// queryStringsConfig: { -// queryStringBehavior: "whitelist", -// queryStrings: { -// items: ["w", "h", "dpr", "format"] -// }, -// }, -// }, -// }, -// ); - -// const groupOriginId = `${$app.name}-${$app.stage}-"GroupOrigin`; -// const primaryOriginId = `${$app.name}-${$app.stage}-PrimaryOrigin`; -// const secondaryOriginId = `${$app.name}-${$app.stage}-SecondaryOrigin`; - -// const s3Distribution = new sst.aws.Cdn("ImageDistribution", -// { -// originGroups: [ -// { -// originId: groupOriginId, -// failoverCriteria: { -// statusCodes: [403, 500, 503, 504], -// }, -// members: [ -// { -// originId: primaryOriginId, -// }, -// { -// originId: secondaryOriginId, -// }, -// ], -// }, -// ], -// origins: [ -// { -// originId: primaryOriginId, -// domainName: imageProcessorFunction.url.apply( -// (url) => new URL(url).hostname, -// ), -// originAccessControlId: lambdaOAC.id, -// customOriginConfig: { -// originProtocolPolicy: "https-only", -// httpPort: 443, -// httpsPort: 443, -// originSslProtocols: ["TLSv1.2"], -// }, -// }, -// { -// originId: secondaryOriginId, -// domainName: -// transformedImageBucket.nodes.bucket.bucketRegionalDomainName, -// s3OriginConfig: { -// originAccessIdentity: cloudfrontOAI.cloudfrontAccessIdentityPath, -// }, -// }, -// ], -// defaultCacheBehavior: { -// cachePolicyId: cachePolicy.id, -// allowedMethods: ["GET", "HEAD"], -// cachedMethods: ["GET", "HEAD"], -// targetOriginId: groupOriginId, -// viewerProtocolPolicy: "redirect-to-https", -// functionAssociations: [ -// { -// eventType: "viewer-request", -// functionArn: cloudfrontRewriteFunction.arn, -// }, -// ], -// responseHeadersPolicyId: cloudfrontResponseHeadersPolicy.id, -// }, -// }, -// ); - -// new aws.lambda.Permission("AllowCloudFrontServicePrincipal", { -// action: "lambda:InvokeFunctionUrl", -// function: imageProcessorFunction.arn, -// principal: "cloudfront.amazonaws.com", -// statementId: "AllowCloudFrontServicePrincipal", -// sourceArn: s3Distribution.nodes.distribution.arn, -// }); \ No newline at end of file diff --git a/packages/functions/package.json b/packages/functions/package.json index bae6f64f..b1ae7fdb 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -25,6 +25,7 @@ "@aws-sdk/client-sqs": "^3.806.0", "@nestri/core": "workspace:", "actor-core": "^0.8.0", + "aws4fetch": "^1.0.20", "hono": "^4.7.8", "hono-openapi": "^0.4.8" } diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index 812540b0..c30e8715 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -2,7 +2,6 @@ import "zod-openapi/extend"; import { cors } from "hono/cors"; import { GameApi } from "./game"; import { SteamApi } from "./steam"; -import { ImageApi } from "./image"; import { auth } from "./utils/auth"; import { FriendApi } from "./friend"; import { logger } from "hono/logger"; @@ -25,10 +24,6 @@ app return next(); }) .use(auth) - -// Private routes -app. - route("/image", ImageApi.route) const routes = app .get("/", (c) => c.text("Hello World!")) diff --git a/packages/functions/src/images/image.ts b/packages/functions/src/images/image.ts index c074d60c..5af091ae 100644 --- a/packages/functions/src/images/image.ts +++ b/packages/functions/src/images/image.ts @@ -1,67 +1,89 @@ import { Hono } from "hono"; +import { AwsClient } from 'aws4fetch' import { Resource } from "sst"; import { HTTPException } from "hono/http-exception"; + export namespace ImageRoute { export const route = new Hono() .get( "/:hashWithExt", async (c) => { - const { hashWithExt } = c.req.param(); + // const { hashWithExt } = c.req.param(); - // Validate format - // Split hash and extension - const match = hashWithExt.match(/^([a-zA-Z0-9_-]+)\.(avif|webp)$/); - if (!match) { - throw new HTTPException(400, { message: "Invalid image hash or format" }); - } - - const [, hash, format] = match; - - const query = c.req.query(); - // Parse dimensions - const width = parseInt(query.w || query.width || ""); - const height = parseInt(query.h || query.height || ""); - const dpr = parseFloat(query.dpr || "1"); - - if (isNaN(width) || width <= 0) { - throw new HTTPException(400, { message: "Invalid width" }); - } - if (!isNaN(height) && height < 0) { - throw new HTTPException(400, { message: "Invalid height" }); - } - if (dpr < 1 || dpr > 4) { - throw new HTTPException(400, { message: "Invalid dpr" }); - } - - console.log("url",Resource.Api.url) - - const imageBytes = await fetch(`${Resource.Api.url}/image/${hash}`,{ - method:"POST", - body:JSON.stringify({ - dpr, - width, - height, - format - }) + const client = new AwsClient({ + accessKeyId: Resource.ImageInvokerAccessKey.key, + secretAccessKey: Resource.ImageInvokerAccessKey.secret, }) - console.log("imahe",imageBytes.headers) + const LAMBDA_URL = `https://lambda.us-east-1.amazonaws.com/2015-03-31/functions/${Resource.ImageProcessor.name}/invocations` - // Normalize and build cache key - // const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`; + const lambdaResponse = await client.fetch(LAMBDA_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ world: "hello" }), + }) - // Add aggressive caching - // c.header("Cache-Control", "public, max-age=315360000, immutable"); + if (!lambdaResponse.ok) { + console.error(await lambdaResponse.text()) + return c.json({ error: `Lambda API returned ${lambdaResponse.status}` }, { status: 500 }) + } - // Placeholder image response (to be replaced by real logic) - return c.newResponse(await imageBytes.arrayBuffer(), - // { - // headers: { - // ...imageBytes.headers - // } - // } - ); + console.log(await lambdaResponse.json()) + + // // Validate format + // // Split hash and extension + // const match = hashWithExt.match(/^([a-zA-Z0-9_-]+)\.(avif|webp)$/); + // if (!match) { + // throw new HTTPException(400, { message: "Invalid image hash or format" }); + // } + + // const [, hash, format] = match; + + // const query = c.req.query(); + // // Parse dimensions + // const width = parseInt(query.w || query.width || ""); + // const height = parseInt(query.h || query.height || ""); + // const dpr = parseFloat(query.dpr || "1"); + + // if (isNaN(width) || width <= 0) { + // throw new HTTPException(400, { message: "Invalid width" }); + // } + // if (!isNaN(height) && height < 0) { + // throw new HTTPException(400, { message: "Invalid height" }); + // } + // if (dpr < 1 || dpr > 4) { + // throw new HTTPException(400, { message: "Invalid dpr" }); + // } + + // console.log("url",Resource.Api.url) + + // const imageBytes = await fetch(`${Resource.Api.url}/image/${hash}`,{ + // method:"POST", + // body:JSON.stringify({ + // dpr, + // width, + // height, + // format + // }) + // }) + + // console.log("imahe",imageBytes.headers) + + // // Normalize and build cache key + // // const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`; + + // // Add aggressive caching + // // c.header("Cache-Control", "public, max-age=315360000, immutable"); + + // // Placeholder image response (to be replaced by real logic) + // return c.newResponse(await imageBytes.arrayBuffer(), + // // { + // // headers: { + // // ...imageBytes.headers + // // } + // // } + // ); return c.text("success") } diff --git a/packages/functions/src/images/processor.ts b/packages/functions/src/images/processor.ts index 4dc28ea8..c296f668 100644 --- a/packages/functions/src/images/processor.ts +++ b/packages/functions/src/images/processor.ts @@ -1 +1,5 @@ -export const handler = () => { } \ No newline at end of file +export async function handler(event: any) { + console.log('Task completion event received:', JSON.stringify(event, null, 2)); + + return JSON.stringify({ hello: "world" }) +} \ No newline at end of file diff --git a/sst-env.d.ts b/sst-env.d.ts index bd9c4954..e6236386 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -57,6 +57,15 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ImageInvokerAccessKey": { + "key": string + "secret": string + "type": "aws.iam/accessKey.AccessKey" + } + "ImageProcessor": { + "name": string + "type": "sst.aws.Function" + } "NestriFamilyMonthly": { "type": "sst.sst.Secret" "value": string