diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 76cbd487fe..4fe6da89a0 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -88,7 +88,10 @@ export function CapVideoPlayer({ ? `${videoSrc}&_t=${timestamp}` : `${videoSrc}?_t=${timestamp}`; - const response = await fetch(urlWithTimestamp, { method: "HEAD" }); + const response = await fetch(urlWithTimestamp, { + method: "GET", + headers: { range: "bytes=0-0" }, + }); const finalUrl = response.redirected ? response.url : urlWithTimestamp; // Check if the resolved URL is from a CORS-incompatible service diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 8fdc660de8..d3e50c0787 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -80,7 +80,10 @@ export async function transcribeVideo( // Check if video file actually exists before transcribing try { - const headResponse = await fetch(videoUrl, { method: "HEAD" }); + const headResponse = await fetch(videoUrl, { + method: "GET", + headers: { range: "bytes=0-0" }, + }); if (!headResponse.ok) { // Video not ready yet - reset to null for retry await db() diff --git a/apps/web/package.json b/apps/web/package.json index e61110c48b..92358d25c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -68,6 +68,7 @@ "@tanstack/react-store": "^0.7.7", "@ts-rest/core": "^3.52.1", "@uidotdev/usehooks": "^2.4.1", + "@vercel/functions": "^3.1.0", "@virtual-grid/react": "^2.0.3", "@workos-inc/node": "^7.34.0", "aws-sdk": "^2.1530.0", diff --git a/apps/web/utils/s3.ts b/apps/web/utils/s3.ts index 32ada2562f..6f49b5f2e4 100644 --- a/apps/web/utils/s3.ts +++ b/apps/web/utils/s3.ts @@ -40,6 +40,7 @@ import type { RequestPresigningArguments, StreamingBlobPayloadInputTypes, } from "@smithy/types"; +import { awsCredentialsProvider } from "@vercel/functions/oidc"; import type { InferSelectModel } from "drizzle-orm"; type S3Config = { @@ -64,16 +65,19 @@ async function tryDecrypt( export async function getS3Config(config?: S3Config, internal = false) { if (!config) { + const env = serverEnv(); return { endpoint: internal - ? (serverEnv().S3_INTERNAL_ENDPOINT ?? serverEnv().CAP_AWS_ENDPOINT) - : (serverEnv().S3_PUBLIC_ENDPOINT ?? serverEnv().CAP_AWS_ENDPOINT), - region: serverEnv().CAP_AWS_REGION, - credentials: { - accessKeyId: serverEnv().CAP_AWS_ACCESS_KEY ?? "", - secretAccessKey: serverEnv().CAP_AWS_SECRET_KEY ?? "", - }, - forcePathStyle: serverEnv().S3_PATH_STYLE, + ? (env.S3_INTERNAL_ENDPOINT ?? env.CAP_AWS_ENDPOINT) + : (env.S3_PUBLIC_ENDPOINT ?? env.CAP_AWS_ENDPOINT), + region: env.CAP_AWS_REGION, + credentials: env.VERCEL_AWS_ROLE_ARN + ? awsCredentialsProvider({ roleArn: env.VERCEL_AWS_ROLE_ARN }) + : { + accessKeyId: env.CAP_AWS_ACCESS_KEY ?? "", + secretAccessKey: env.CAP_AWS_SECRET_KEY ?? "", + }, + forcePathStyle: env.S3_PATH_STYLE, }; } diff --git a/infra/sst-env.d.ts b/infra/sst-env.d.ts index cb54317edf..ba2fdf30c1 100644 --- a/infra/sst-env.d.ts +++ b/infra/sst-env.d.ts @@ -5,14 +5,7 @@ declare module "sst" { export interface Resource { - DISCORD_BOT_TOKEN: { - type: "sst.sst.Secret"; - value: string; - }; - DiscordBotScript: { - type: "sst.cloudflare.Worker"; - }; - GITHUB_APP_PRIVATE_KEY: { + DATABASE_URL: { type: "sst.sst.Secret"; value: string; }; diff --git a/infra/sst.config.ts b/infra/sst.config.ts index c85d7dae65..bbaee4ac2c 100644 --- a/infra/sst.config.ts +++ b/infra/sst.config.ts @@ -19,6 +19,7 @@ export default $config({ providers: { vercel: { team: VERCEL_TEAM_ID, + version: "3.15.1", }, github: { owner: GITHUB_ORG, @@ -28,6 +29,13 @@ export default $config({ }; }, async run() { + const WEB_URLS: Record = { + production: "https://cap.so", + staging: "https://staging.cap.so", + }; + const webUrl = WEB_URLS[$app.stage]; + // const planetscale = Planetscale(); + const recordingsBucket = new aws.s3.BucketV2("RecordingsBucket"); new aws.s3.BucketAccelerateConfigurationV2("RecordingsBucketAcceleration", { @@ -35,166 +43,82 @@ export default $config({ status: "Enabled", }); - const cloudfrontDistribution = new aws.cloudfront.Distribution( - "CapSoCloudfrontDistribution", - { - aliases: ["v.cap.so"], - defaultCacheBehavior: { - cachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", - compress: true, - allowedMethods: ["GET", "HEAD", "OPTIONS"], - cachedMethods: ["GET", "HEAD", "OPTIONS"], - targetOriginId: recordingsBucket.bucketRegionalDomainName, - viewerProtocolPolicy: "redirect-to-https", - originRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", - responseHeadersPolicyId: "07e54f95-0547-4c80-a967-95a236bd9b94", - }, - isIpv6Enabled: true, - enabled: true, - restrictions: { geoRestriction: { restrictionType: "none" } }, - viewerCertificate: { - acmCertificateArn: - "arn:aws:acm:us-east-1:211125561119:certificate/9165b27f-0f9e-497b-9ff5-5b6a885c5eed", - minimumProtocolVersion: "TLSv1.2_2021", - sslSupportMethod: "sni-only", - }, - webAclId: - "arn:aws:wafv2:us-east-1:211125561119:global/webacl/CreatedByCloudFront-4f671e75-3f7c-45dd-9283-979b497f5af7/0e2022cf-dd4a-4427-908f-f7e88530894b", - orderedCacheBehaviors: [ - { - allowedMethods: ["GET", "HEAD", "OPTIONS"], - cachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", - cachedMethods: ["GET", "HEAD", "OPTIONS"], - compress: true, - originRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", - pathPattern: "_recording", - realtimeLogConfigArn: "", - responseHeadersPolicyId: "5cc3b908-e619-4b99-88e5-2cf7f45965bd", - targetOriginId: "cap.so", - viewerProtocolPolicy: "redirect-to-https", - }, - { - allowedMethods: ["GET", "HEAD", "OPTIONS"], - cachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", - cachedMethods: ["GET", "HEAD", "OPTIONS"], - compress: true, - originRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", - pathPattern: "/dev/*", - responseHeadersPolicyId: "07e54f95-0547-4c80-a967-95a236bd9b94", - targetOriginId: "capso-dev.s3.us-east-1.amazonaws.com", - viewerProtocolPolicy: "redirect-to-https", - }, - ], - origins: [ - { - connectionAttempts: 3, - connectionTimeout: 10, - customOriginConfig: { - httpPort: 80, - httpsPort: 443, - originKeepaliveTimeout: 5, - originProtocolPolicy: "https-only", - originReadTimeout: 30, - originSslProtocols: ["TLSv1.2"], - }, - domainName: "cap.link", - originId: "cap.link", - originPath: "", - originShield: { - enabled: true, - originShieldRegion: "us-east-1", - }, - }, - { - customOriginConfig: { - httpPort: 80, - httpsPort: 443, - originKeepaliveTimeout: 5, - originProtocolPolicy: "https-only", - originReadTimeout: 30, - originSslProtocols: ["TLSv1.2"], - }, - domainName: "cap.so", - originId: "cap.so", - originShield: { - enabled: true, - originShieldRegion: "us-east-1", - }, - }, - { - domainName: "capso-dev.s3.us-east-1.amazonaws.com", - originAccessControlId: "E2CB8AE0M9IHH8", - originId: "capso-dev.s3.us-east-1.amazonaws.com", - }, - { - domainName: recordingsBucket.bucketRegionalDomainName, - originAccessControlId: "E26H3W7A2N2HP3", - originId: recordingsBucket.bucketRegionalDomainName, - originShield: { - enabled: true, - originShieldRegion: recordingsBucket.region, - }, - }, - ], - }, - ); - - const vercelUser = new aws.iam.User( - "VercelUser", - { - name: "uploader", - forceDestroy: false, - }, - { import: "uploader" }, - ); - - const vercelAccessKey = new aws.iam.AccessKey("VercelS3AccessKey", { - user: vercelUser.name, - }); - - const vercelProject = new vercel.Project("VercelProject", { - buildCommand: "cd ../.. && pnpm turbo run build --filter=@cap/web", - installCommand: "pnpm install --no-frozen-lockfile", - framework: "nextjs", - gitRepository: { - productionBranch: "main", - repo: `${GITHUB_ORG}/${GITHUB_REPO}`, - type: "github", - }, - protectionBypassForAutomation: true, - rootDirectory: "apps/web", + // const cloudfrontDistribution = aws.cloudfront.getDistributionOutput({ + // id: "E36XSZEM0VIIYB", + // }); + + const vercelUser = new aws.iam.User("VercelUser", { forceDestroy: false }); + + const vercelProject = vercel.getProjectOutput({ name: "cap-web" }); + + function vercelEnvVar( + name: string, + args: Omit< + vercel.ProjectEnvironmentVariableArgs, + "projectId" | "customEnvironmentIds" | "targets" + >, + ) { + new vercel.ProjectEnvironmentVariable(name, { + ...args, + projectId: vercelProject.id, + customEnvironmentIds: + $app.stage === "staging" + ? ["env_CFbtmnpsI11e4o8X5UD8MZzxELQi"] + : undefined, + targets: + $app.stage === "staging" ? undefined : ["preview", "production"], + }); + } + + vercelEnvVar("VercelDatabaseURLEnv", { + key: "DATABASE_URL", + value: new sst.Secret("DATABASE_URL").value, }); - new vercel.ProjectEnvironmentVariable("VercelS3AccessEnv", { - key: "CAP_AWS_ACCESS_KEY", - value: vercelAccessKey.id, - projectId: vercelProject.id, - targets: ["production", "preview", "development"], + if (webUrl) { + vercelEnvVar("VercelWebURLEnv", { + key: "WEB_URL", + value: webUrl, + }); + vercelEnvVar("VercelNextPublicWebURLEnv", { + key: "NEXT_PUBLIC_WEB_URL", + value: webUrl, + }); + vercelEnvVar("VercelNextAuthURLEnv", { + key: "NEXTAUTH_URL", + value: webUrl, + }); + } + + // vercelEnvVar("VercelCloudfrontEnv", { + // key: "CAP_CLOUDFRONT_DISTRIBUTION_ID", + // value: cloudfrontDistribution.id, + // }); + + vercelEnvVar("VercelAWSBucketEnv", { + key: "CAP_AWS_BUCKET", + value: recordingsBucket.bucket, }); - new vercel.ProjectEnvironmentVariable("VercelCloudfrontEnv", { - key: "CAP_CLOUDFRONT_DISTRIBUTION_ID", - value: cloudfrontDistribution.id, - projectId: vercelProject.id, - targets: ["production", "preview", "development"], + vercelEnvVar("VercelNextPublicAWSBucketEnv", { + key: "NEXT_PUBLIC_CAP_AWS_BUCKET", + value: recordingsBucket.bucket, }); - new aws.iam.OpenIdConnectProvider("VercelOIDCProvider", { - url: "https://oidc.vercel.com", - clientIdLists: [`https://vercel.com/${VERCEL_TEAM_ID}`], + const vercelOidc = aws.iam.getOpenIdConnectProviderOutput({ + url: `https://oidc.vercel.com/${VERCEL_TEAM_SLUG}`, }); - const awsAccount = await aws.getCallerIdentity(); + const awsAccount = aws.getCallerIdentityOutput(); const vercelAwsAccessRole = new aws.iam.Role("VercelAWSAccessRole", { - name: "VercelOIDCRole", assumeRolePolicy: { Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { - Federated: `arn:aws:iam::${awsAccount.id}:oidc-provider/oidc.vercel.com/${VERCEL_TEAM_SLUG}`, + Federated: $interpolate`arn:aws:iam::${awsAccount.id}:oidc-provider/oidc.vercel.com/${VERCEL_TEAM_SLUG}`, }, Action: "sts:AssumeRoleWithWebIdentity", Condition: { @@ -203,27 +127,61 @@ export default $config({ }, StringLike: { [`oidc.vercel.com/${VERCEL_TEAM_SLUG}:sub`]: [ - `owner:${VERCEL_TEAM_SLUG}:project:${vercelProject.name}:environment:preview`, - `owner:${VERCEL_TEAM_SLUG}:project:${vercelProject.name}:environment:production`, + `owner:${VERCEL_TEAM_SLUG}:project:*:environment:staging`, ], }, }, }, ], }, + inlinePolicies: [ + { + name: "VercelAWSAccessPolicy", + policy: recordingsBucket.arn.apply((arn) => + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:*"], + Resource: `${arn}/*`, + }, + { + Effect: "Allow", + Action: ["s3:*"], + Resource: `${arn}`, + }, + ], + }), + ), + }, + ], }); - new vercel.ProjectEnvironmentVariable("VercelAWSAccessRoleArn", { - key: "AWS_ROLE_ARN", + vercelEnvVar("VercelAWSAccessRoleArn", { + key: "VERCEL_AWS_ROLE_ARN", value: vercelAwsAccessRole.arn, - projectId: vercelProject.id, - targets: ["production", "preview"], }); - DiscordBot(); + // DiscordBot(); }, }); +// function Planetscale() { +// const org = planetscale.getOrganizationOutput({ name: "cap" }); +// const db = planetscale.getDatabaseOutput({ +// name: "cap-production", +// organization: org.name, +// }); +// const branch = planetscale.getBranchOutput({ +// name: $app.stage === "production" ? "main" : "staging", +// database: db.name, +// organization: org.name, +// }); + +// return { org, db, branch }; +// } + function DiscordBot() { new sst.cloudflare.Worker("DiscordBotScript", { handler: "../apps/discord-bot/src/index.ts", diff --git a/packages/env/server.ts b/packages/env/server.ts index 32f671735f..de9c6b27e1 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -21,8 +21,8 @@ function createServerEnv() { CAP_AWS_BUCKET: z.string(), CAP_AWS_REGION: z.string(), CAP_AWS_BUCKET_URL: z.string().optional(), - CAP_AWS_ACCESS_KEY: z.string(), - CAP_AWS_SECRET_KEY: z.string(), + CAP_AWS_ACCESS_KEY: z.string().optional(), + CAP_AWS_SECRET_KEY: z.string().optional(), CAP_AWS_ENDPOINT: z.string().optional(), CAP_AWS_MEDIACONVERT_ROLE_ARN: z.string().optional(), CAP_CLOUDFRONT_DISTRIBUTION_ID: z.string().optional(), @@ -65,6 +65,7 @@ function createServerEnv() { CLOUDFRONT_KEYPAIR_PRIVATE_KEY: z.string().optional(), S3_PUBLIC_ENDPOINT: z.string().optional(), S3_INTERNAL_ENDPOINT: z.string().optional(), + VERCEL_AWS_ROLE_ARN: z.string().optional(), REMOTE_WORKFLOW_URL: z.string().optional(), REMOTE_WORKFLOW_SECRET: z.string().optional(), }, diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json index 21fb14a505..902ad1c71d 100644 --- a/packages/web-backend/package.json +++ b/packages/web-backend/package.json @@ -17,6 +17,7 @@ "@effect/rpc": "^0.69.2", "@effect/workflow": "^0.9.5", "@smithy/types": "^4.3.1", + "@vercel/functions": "^3.1.0", "drizzle-orm": "0.44.5", "effect": "^3.17.13", "next": "^14", diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 1cbf140855..f5fb2d6c6f 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -3,6 +3,7 @@ import * as CloudFrontPresigner from "@aws-sdk/cloudfront-signer"; import { decrypt } from "@cap/database/crypto"; import { S3_BUCKET_URL } from "@cap/utils"; import type { S3Bucket } from "@cap/web-domain"; +import { awsCredentialsProvider } from "@vercel/functions/oidc"; import { Config, Context, Effect, Layer, Option } from "effect"; import { Database } from "../Database.ts"; @@ -18,26 +19,24 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { publicEndpoint: yield* Config.string("S3_PUBLIC_ENDPOINT").pipe( Config.orElse(() => Config.string("CAP_AWS_ENDPOINT")), Config.option, - Effect.flatten, - Effect.catchTag("NoSuchElementException", () => - Effect.dieMessage( - "Neither S3_PUBLIC_ENDPOINT nor CAP_AWS_ENDPOINT provided", - ), - ), ), internalEndpoint: yield* Config.string("S3_INTERNAL_ENDPOINT").pipe( Config.orElse(() => Config.string("CAP_AWS_ENDPOINT")), Config.option, - Effect.flatten, - Effect.catchTag("NoSuchElementException", () => - Effect.dieMessage( - "Neither S3_INTERNAL_ENDPOINT nor CAP_AWS_ENDPOINT provided", + ), + region: yield* Config.string("CAP_AWS_REGION"), + credentials: yield* Config.string("CAP_AWS_ACCESS_KEY").pipe( + Effect.zip(Config.string("CAP_AWS_SECRET_KEY")), + Effect.map(([accessKeyId, secretAccessKey]) => ({ + accessKeyId, + secretAccessKey, + })), + Effect.catchAll(() => + Config.string("VERCEL_AWS_ROLE_ARN").pipe( + Effect.map((arn) => awsCredentialsProvider({ roleArn: arn })), ), ), ), - region: yield* Config.string("CAP_AWS_REGION"), - accessKey: yield* Config.string("CAP_AWS_ACCESS_KEY"), - secretKey: yield* Config.string("CAP_AWS_SECRET_KEY"), forcePathStyle: Option.getOrNull( yield* Config.boolean("S3_PATH_STYLE").pipe(Config.option), @@ -48,13 +47,10 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { const createDefaultClient = (internal: boolean) => new S3.S3Client({ endpoint: internal - ? defaultConfigs.internalEndpoint - : defaultConfigs.publicEndpoint, + ? Option.getOrUndefined(defaultConfigs.internalEndpoint) + : Option.getOrUndefined(defaultConfigs.publicEndpoint), region: defaultConfigs.region, - credentials: { - accessKeyId: defaultConfigs.accessKey, - secretAccessKey: defaultConfigs.secretKey, - }, + credentials: defaultConfigs.credentials, forcePathStyle: defaultConfigs.forcePathStyle, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa8217d194..c7ca152122 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@vercel/functions': + specifier: ^3.1.0 + version: 3.1.0(@aws-sdk/credential-provider-web-identity@3.804.0) '@virtual-grid/react': specifier: ^2.0.3 version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1319,6 +1322,9 @@ importers: '@smithy/types': specifier: ^4.3.1 version: 4.3.1 + '@vercel/functions': + specifier: ^3.1.0 + version: 3.1.0(@aws-sdk/credential-provider-web-identity@3.804.0) drizzle-orm: specifier: 0.44.5 version: 0.44.5(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.14.1) @@ -6983,6 +6989,15 @@ packages: resolution: {integrity: sha512-1++yncEyIAi68D3UEOlytYb1IUcIulMWdoSzX2h9LuSeeyR7JtaIgR8DcTQ6+DmYOQn+5MCh6LY+UmK6QBByNA==} deprecated: This package is deprecated. You should to use `@vercel/functions` instead. + '@vercel/functions@3.1.0': + resolution: {integrity: sha512-V+p8dO+sg1VjiJJUO5rYPp1KG17SzDcR74OWwW7Euyde6L8U5wuTMe9QfEOfLTiWPUPzN1MXZvLcYxqSYhKc4Q==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + '@vercel/nft@0.27.7': resolution: {integrity: sha512-FG6H5YkP4bdw9Ll1qhmbxuE8KwW2E/g8fJpM183fWQLeVDGqzeywMIeJ9h2txdWZ03psgWMn6QymTxaDLmdwUg==} engines: {node: '>=16'} @@ -6993,6 +7008,10 @@ packages: engines: {node: '>=18'} hasBin: true + '@vercel/oidc@3.0.0': + resolution: {integrity: sha512-XOoUcf/1VfGArUAfq0ELxk6TD7l4jGcrOsWjQibj4wYM74uNihzZ9gA46ywWegoqKWWdph4y5CKxGI9823deoA==} + engines: {node: '>= 20'} + '@vinxi/listhen@1.5.6': resolution: {integrity: sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw==} hasBin: true @@ -20896,6 +20915,12 @@ snapshots: '@vercel/edge@1.2.1': {} + '@vercel/functions@3.1.0(@aws-sdk/credential-provider-web-identity@3.804.0)': + dependencies: + '@vercel/oidc': 3.0.0 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.804.0 + '@vercel/nft@0.27.7(encoding@0.1.13)(rollup@4.40.2)': dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) @@ -20934,6 +20959,11 @@ snapshots: - rollup - supports-color + '@vercel/oidc@3.0.0': + dependencies: + '@types/ms': 2.1.0 + ms: 2.1.3 + '@vinxi/listhen@1.5.6': dependencies: '@parcel/watcher': 2.5.1