11import { Elysia , t } from "elysia" ;
2- import {
3- GetObjectCommand ,
4- DeleteObjectCommand ,
5- } from "@aws-sdk/client-s3" ;
2+ import { GetObjectCommand , DeleteObjectCommand } from "@aws-sdk/client-s3" ;
63import { GetFederationTokenCommand } from "@aws-sdk/client-sts" ;
74import { getSignedUrl } from "@aws-sdk/s3-request-presigner" ;
85import { s3 , sts , expiresIn , s3Region , s3Bucket } from "@repo/s3" ;
@@ -11,11 +8,10 @@ import archiver from "archiver";
118import { PassThrough , Readable } from "node:stream" ;
129import { createDb } from "../../drizzle/client.js" ;
1310import { storageSchema } from "@repo/rdb/schema" ;
14- import {
15- item ,
16- } from "../../../../../packages/rdb/src/schemas/storage.js" ;
11+ import { item } from "../../../../../packages/rdb/src/schemas/storage.js" ;
1712import { eq , inArray , sql , and } from "drizzle-orm" ;
18- import { loadAccessContext } from "../../utils/queryHelper.js" ;
13+ import { loadAccessContext , scopeItemRead } from "../../utils/queryHelper.js" ;
14+ import { errorFormatter } from "../../utils/resFormatter.js" ;
1915
2016const S3_MIN_PART = 5 * 1024 * 1024 ; // 5 MiB
2117const S3_MAX_PART = 5 * 1024 * 1024 * 1024 ; // 5 GiB
@@ -39,13 +35,14 @@ function recommendPartSize(total: bigint): number {
3935 const parts = ( total + S3_MAX_PARTS - 1n ) / S3_MAX_PARTS ;
4036 const miB = 1024n * 1024n ;
4137 const rounded = ( ( parts + miB - 1n ) / miB ) * miB ;
42- const clamped = rounded < MIN ? MIN : ( rounded > MAX ? MAX : rounded ) ;
38+ const clamped = rounded < MIN ? MIN : rounded > MAX ? MAX : rounded ;
4339 return Number ( clamped ) ;
4440}
4541
4642// Helper: extract itemId from an object key that is either a UUID or `${uuid}-${name}`
4743const extractItemIdFromKey = ( key : string ) : string | null => {
48- const uuidPattern = / ^ [ 0 - 9 a - f A - F ] { 8 } - [ 0 - 9 a - f A - F ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f A - F ] { 3 } - [ 8 9 a b A B ] [ 0 - 9 a - f A - F ] { 3 } - [ 0 - 9 a - f A - F ] { 12 } $ / ;
44+ const uuidPattern =
45+ / ^ [ 0 - 9 a - f A - F ] { 8 } - [ 0 - 9 a - f A - F ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f A - F ] { 3 } - [ 8 9 a b A B ] [ 0 - 9 a - f A - F ] { 3 } - [ 0 - 9 a - f A - F ] { 12 } $ / ;
4946 if ( uuidPattern . test ( key ) ) return key ;
5047 const m = key . match (
5148 / ^ ( [ 0 - 9 a - f A - F ] { 8 } - [ 0 - 9 a - f A - F ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f A - F ] { 3 } - [ 8 9 a b A B ] [ 0 - 9 a - f A - F ] { 3 } - [ 0 - 9 a - f A - F ] { 12 } ) - /
@@ -57,55 +54,81 @@ export const s3Router = new Elysia({ prefix: "/v1" })
5754 . use ( betterAuthMiddleware )
5855 //Get single image
5956 . get (
60- "/image/:imageKey " ,
57+ "/spaces/:spaceId/items/:itemId/download " ,
6158 async ( { params, set, query, user } ) => {
62- const { imageKey } = params ;
63- const { download } = query ;
64-
59+ const doDownload = query . download ?? true ;
6560 // AuthN/Z: allow if public or owner
66- const itemId = extractItemIdFromKey ( imageKey ) ;
67- if ( ! itemId ) {
68- set . status = 403 ;
69- return { error : "forbidden" } ;
70- }
61+ const ctx = await loadAccessContext ( db , user ?. id ?? null , params . spaceId ) ;
7162
72- const rows = await db
73- . select ( {
74- id : item . id ,
75- createdBy : item . createdBy ,
76- accessType : item . accessType ,
77- trashedAt : item . trashedAt ,
78- } )
79- . from ( item )
80- . where ( eq ( item . id , itemId ) )
81- . limit ( 1 ) ;
63+ const canAccessItem = await scopeItemRead ( ctx , {
64+ itemId : params . itemId ,
65+ includeTrash : false ,
66+ } ) ;
8267
83- if ( rows . length === 0 ) {
84- set . status = 404 ;
85- return { error : "not_found" } ;
68+ if ( canAccessItem . length === 0 ) {
69+ const errMessage = errorFormatter ( 403 , "ERR_FORBIDDEN_WRITE" , { } ) ;
70+ set . status = errMessage . status ;
71+ return {
72+ success : false ,
73+ data : null ,
74+ error : { errMessage } ,
75+ } ;
8676 }
8777
88- const rec = rows [ 0 ] ;
89- const isOwner = ! ! user ?. id && String ( rec . createdBy ) === String ( user . id ) ;
90- const isPublic =
91- String ( ( rec as any ) . accessType ?? "" ) . toLowerCase ( ) === "public" ;
78+ // Normalize to a flat item row for a consistent API contract.
79+ let [ item ] : ( typeof storageSchema . item . $inferSelect ) [ ] = (
80+ canAccessItem as any [ ]
81+ ) . map ( ( r ) =>
82+ "item" in r
83+ ? ( r . item as typeof storageSchema . item . $inferSelect )
84+ : ( r as typeof storageSchema . item . $inferSelect )
85+ ) ;
9286
93- if ( ! isPublic && ! isOwner ) {
94- set . status = 403 ;
95- return { error : "forbidden" } ;
87+ if ( item . itemType !== "file" ) {
88+ const errMessage = errorFormatter ( 422 , "INVALID_TYPE" , {
89+ field : "Item" ,
90+ expected : "file" ,
91+ actualType : item . itemType ,
92+ } ) ;
93+ set . status = errMessage . status ;
94+ return { success : false , data : null , error : errMessage } ;
95+ }
96+
97+ if ( item . assetId === null ) {
98+ const errMessage = errorFormatter ( 500 , "UNEXPECTED_TYPE" , {
99+ field : "assetId" ,
100+ expected : "not empty" ,
101+ actualType : "empty" ,
102+ } ) ;
96103 }
97- if ( rec . trashedAt ) {
98- set . status = 410 ;
99- return { error : "gone" } ;
104+
105+ let [ file ] = await db
106+ . select ( )
107+ . from ( storageSchema . fileBlobLocation )
108+ . where (
109+ and (
110+ eq ( storageSchema . fileBlobLocation . assetId , item . assetId ! ) ,
111+ eq ( storageSchema . fileBlobLocation . kind , "canon" )
112+ )
113+ ) ;
114+
115+ if ( ! file ) {
116+ const errMessage = errorFormatter ( 404 , "NOT_FOUND" , {
117+ obj : "file" ,
118+ queryKey : "itemId" ,
119+ queryValue : params . itemId ,
120+ } ) ;
121+ set . status = errMessage . status ;
122+ return { success : false , data : null , error : errMessage } ;
100123 }
101124
102125 const getObjectCommandInput = {
103126 Bucket : BUCKET ,
104- Key : imageKey ,
127+ Key : file . objectKey ,
105128 } ;
106129
107- if ( download === " true" ) {
108- const filename = imageKey . split ( "/" ) . pop ( ) || "download" ;
130+ if ( doDownload === true ) {
131+ const filename = item . liveName ?? item . name ;
109132 Object . assign ( getObjectCommandInput , {
110133 ResponseContentDisposition : `attachment; filename="${ decodeURIComponent ( filename ) } "` ,
111134 } ) ;
@@ -123,65 +146,24 @@ export const s3Router = new Elysia({ prefix: "/v1" })
123146 return { url, expires : expiresIn } ;
124147 } ,
125148 {
126- params : t . Object ( { imageKey : t . String ( ) } ) ,
127- query : t . Object ( { download : t . Optional ( t . String ( ) ) } ) ,
149+ params : t . Object ( {
150+ spaceId : t . String ( { format : "uuid" } ) ,
151+ itemId : t . String ( { format : "uuid" } ) ,
152+ } ) ,
153+ query : t . Object ( { download : t . Optional ( t . Boolean ( ) ) } ) ,
128154 auth : { allowPublic : true } ,
129155 }
130156 )
131157
132158 //Bulk download image
133- . post (
134- "/batch-download" ,
135- async ( { body, set, user } ) => {
159+ /* .post(
160+ "/spaces/:spaceId/ batch-download",
161+ async ({ body, set, params, user }) => {
136162 const { imageKeys } = body;
137163
138- const keysArray = imageKeys . filter ( ( key : string ) => key !== "" ) ;
139- if ( keysArray . length === 0 ) {
140- set . status = 400 ;
141- return { error : "No valid image keys provided for download." } ;
142- }
143-
144- // Resolve and filter authorized keys (public or owned by requester)
145- const idToKeys = new Map < string , string [ ] > ( ) ;
146- const ids : string [ ] = [ ] ;
147- for ( const k of keysArray ) {
148- const id = extractItemIdFromKey ( k ) ;
149- if ( id ) {
150- if ( ! idToKeys . has ( id ) ) idToKeys . set ( id , [ ] ) ;
151- idToKeys . get ( id ) ! . push ( k ) ;
152- ids . push ( id ) ;
153- }
154- }
155- const uniqueIds = Array . from ( new Set ( ids ) ) ;
156- if ( uniqueIds . length === 0 ) {
157- set . status = 403 ;
158- return { error : "No authorized files for download." } ;
159- }
160-
161- const dbRows = await db
162- . select ( {
163- id : item . id ,
164- createdBy : item . createdBy ,
165- accessType : item . accessType ,
166- trashedAt : item . trashedAt ,
167- } )
168- . from ( item )
169- . where ( inArray ( item . id , uniqueIds ) ) ;
170-
171- const allowedIds = new Set < string > ( ) ;
172- for ( const r of dbRows ) {
173- const isOwner = ! ! user ?. id && String ( r . createdBy ) === String ( user . id ) ;
174- const isPublic =
175- String ( ( r as any ) . accessType ?? "" ) . toLowerCase ( ) === "public" ;
176- if ( ! r . trashedAt && ( isPublic || isOwner ) ) {
177- allowedIds . add ( String ( r . id ) ) ;
178- }
179- }
164+ const ctx = await loadAccessContext(db, user?.id ?? null, params.spaceId)
180165
181- const allowedKeys : string [ ] = [ ] ;
182- for ( const [ id , ks ] of idToKeys ) {
183- if ( allowedIds . has ( id ) ) allowedKeys . push ( ...ks ) ;
184- }
166+
185167
186168 if (allowedKeys.length === 0) {
187169 set.status = 403;
@@ -235,20 +217,24 @@ export const s3Router = new Elysia({ prefix: "/v1" })
235217 }
236218 }
237219 await Promise.all(
238- Array . from ( { length : Math . min ( CONCURRENCY , allowedKeys . length ) } , worker )
220+ Array.from(
221+ { length: Math.min(CONCURRENCY, allowedKeys.length) },
222+ worker
223+ )
239224 );
240225 await archive.finalize();
241226 })();
242227
243228 return zipStream;
244229 },
245230 {
231+ params: t.Object({ spaceId: t.String({ format: "uuid" }) }),
246232 body: t.Object({
247233 imageKeys: t.Array(t.String()),
248234 }),
249235 auth: { allowPublic: true },
250236 }
251- )
237+ ) */
252238
253239 //get bulk image (maybe optimizing for scalability ex pagination in the future??)
254240 . get (
0 commit comments