11import type { AuthenticatedEnvironment } from "~/services/apiAuth.server" ;
22import { BaseService } from "./baseService.server" ;
33import { env } from "~/env.server" ;
4+ import { logger } from "~/services/logger.server" ;
45import { createPresignedPost } from "@aws-sdk/s3-presigned-post" ;
5- import { S3Client } from "@aws-sdk/client-s3" ;
6+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner" ;
7+ import { GetObjectCommand , HeadObjectCommand , S3Client } from "@aws-sdk/client-s3" ;
8+ import { BuildServerMetadata } from "@trigger.dev/core/v3" ;
69import { customAlphabet } from "nanoid" ;
7- import { errAsync , fromPromise } from "neverthrow" ;
10+ import { errAsync , fromPromise , okAsync } from "neverthrow" ;
11+ import type { PrismaClientOrTransaction } from "@trigger.dev/database" ;
812
913const nanoid = customAlphabet ( "1234567890abcdefghijklmnopqrstuvwxyz" , 24 ) ;
1014const objectStoreClient =
@@ -32,8 +36,40 @@ const artifactBytesSizeLimitByType = {
3236 deployment_bundle : env . DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES ,
3337} as const ;
3438
39+ export type ArtifactOwner = Pick < AuthenticatedEnvironment , "id" | "slug" | "type" > & {
40+ project : { externalRef : string } ;
41+ } ;
42+
43+ const ArtifactKeyMetadata = BuildServerMetadata . pick ( { artifactKey : true } ) ;
44+
45+ export function isArtifactKeyOwnedBy ( owner : ArtifactOwner , key : string ) : boolean {
46+ const [ prefix , projectRef , envSlug , file , ...rest ] = key . split ( "/" ) ;
47+ return (
48+ rest . length === 0 &&
49+ Object . values < string > ( artifactKeyPrefixByType ) . includes ( prefix ) &&
50+ projectRef === owner . project . externalRef &&
51+ envSlug === owner . slug &&
52+ ! ! file
53+ ) ;
54+ }
55+
3556export class ArtifactsService extends BaseService {
36- private readonly bucket = env . ARTIFACTS_OBJECT_STORE_BUCKET ;
57+ private readonly client : S3Client ;
58+ private readonly bucket : string | undefined ;
59+ private readonly downloadUrlTtlSeconds : number ;
60+
61+ constructor ( options ?: {
62+ prisma ?: PrismaClientOrTransaction ;
63+ client ?: S3Client ;
64+ bucket ?: string ;
65+ downloadUrlTtlSeconds ?: number ;
66+ } ) {
67+ super ( options ?. prisma ) ;
68+ this . client = options ?. client ?? objectStoreClient ;
69+ this . bucket = options ?. bucket ?? env . ARTIFACTS_OBJECT_STORE_BUCKET ;
70+ this . downloadUrlTtlSeconds =
71+ options ?. downloadUrlTtlSeconds ?? env . DEPLOYMENT_ARTIFACT_DOWNLOAD_URL_TTL_SECONDS ;
72+ }
3773
3874 public createArtifact (
3975 type : "deployment_context" | "deployment_bundle" ,
@@ -63,6 +99,74 @@ export class ArtifactsService extends BaseService {
6399 } ) ) ;
64100 }
65101
102+ public createDeploymentDownloadUrl (
103+ authenticatedEnv : ArtifactOwner ,
104+ deploymentFriendlyId : string
105+ ) {
106+ if ( authenticatedEnv . type === "DEVELOPMENT" ) {
107+ return errAsync ( { type : "development_environment" as const } ) ;
108+ }
109+
110+ return fromPromise (
111+ this . _prisma . workerDeployment . findFirst ( {
112+ where : { friendlyId : deploymentFriendlyId , environmentId : authenticatedEnv . id } ,
113+ select : { buildServerMetadata : true } ,
114+ } ) ,
115+ ( error ) => ( { type : "other" as const , cause : error } )
116+ )
117+ . andThen ( ( deployment ) =>
118+ deployment ? okAsync ( deployment ) : errAsync ( { type : "deployment_not_found" as const } )
119+ )
120+ . andThen ( ( deployment ) => {
121+ const key = ArtifactKeyMetadata . safeParse ( deployment . buildServerMetadata ) . data ?. artifactKey ;
122+ return key ? okAsync ( key ) : errAsync ( { type : "artifact_not_found" as const } ) ;
123+ } )
124+ . andThen ( ( key ) => this . createDownloadUrl ( authenticatedEnv , key ) ) ;
125+ }
126+
127+ private createDownloadUrl ( owner : ArtifactOwner , key : string ) {
128+ if ( ! this . bucket ) {
129+ return errAsync ( {
130+ type : "artifacts_bucket_not_configured" as const ,
131+ } ) ;
132+ }
133+
134+ if ( ! isArtifactKeyOwnedBy ( owner , key ) ) {
135+ return errAsync ( { type : "artifact_key_not_owned" as const , key : key . slice ( 0 , 200 ) } ) ;
136+ }
137+
138+ const bucket = this . bucket ;
139+ const ttlSeconds = this . downloadUrlTtlSeconds ;
140+ const signedAt = Date . now ( ) ;
141+
142+ return fromPromise (
143+ this . client . send ( new HeadObjectCommand ( { Bucket : bucket , Key : key } ) ) ,
144+ ( error ) => error
145+ )
146+ . mapErr ( ( error ) => {
147+ const status = httpStatusOf ( error ) ;
148+ // 403 is also what S3 answers for a missing key without s3:ListBucket
149+ if ( status === 403 ) {
150+ logger . warn ( "Artifact HEAD returned 403; treating as missing" , { key } ) ;
151+ }
152+ return status === 404 || status === 403
153+ ? { type : "artifact_not_found" as const }
154+ : { type : "failed_to_check_artifact" as const , cause : error } ;
155+ } )
156+ . andThen ( ( ) =>
157+ fromPromise (
158+ getSignedUrl ( this . client , new GetObjectCommand ( { Bucket : bucket , Key : key } ) , {
159+ expiresIn : ttlSeconds ,
160+ } ) ,
161+ ( error ) => ( {
162+ type : "failed_to_create_download_url" as const ,
163+ cause : error ,
164+ } )
165+ )
166+ )
167+ . map ( ( url ) => ( { url, expiresAt : new Date ( signedAt + ttlSeconds * 1000 ) } ) ) ;
168+ }
169+
66170 private createPresignedPost ( key : string , sizeLimit : number , contentLength ?: number ) {
67171 if ( ! this . bucket ) {
68172 return errAsync ( {
@@ -74,7 +178,7 @@ export class ArtifactsService extends BaseService {
74178 const expiresAt = new Date ( Date . now ( ) + ttlSeconds * 1000 ) ;
75179
76180 return fromPromise (
77- createPresignedPost ( objectStoreClient , {
181+ createPresignedPost ( this . client , {
78182 Bucket : this . bucket ,
79183 Key : key ,
80184 Conditions : [ [ "content-length-range" , 0 , sizeLimit ] ] ,
@@ -93,3 +197,7 @@ export class ArtifactsService extends BaseService {
93197 } ) ) ;
94198 }
95199}
200+
201+ function httpStatusOf ( error : unknown ) : number | undefined {
202+ return ( error as { $metadata ?: { httpStatusCode ?: number } } ) ?. $metadata ?. httpStatusCode ;
203+ }
0 commit comments