Skip to content

Commit 80e5568

Browse files
feat: refactor image download functionality to support spaceId and itemId, improving download handling
1 parent a6ea780 commit 80e5568

5 files changed

Lines changed: 168 additions & 174 deletions

File tree

apps/api/src/modules/image/download.ts

Lines changed: 84 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { 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";
63
import { GetFederationTokenCommand } from "@aws-sdk/client-sts";
74
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
85
import { s3, sts, expiresIn, s3Region, s3Bucket } from "@repo/s3";
@@ -11,11 +8,10 @@ import archiver from "archiver";
118
import { PassThrough, Readable } from "node:stream";
129
import { createDb } from "../../drizzle/client.js";
1310
import { 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";
1712
import { 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

2016
const S3_MIN_PART = 5 * 1024 * 1024; // 5 MiB
2117
const 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}`
4743
const extractItemIdFromKey = (key: string): string | null => {
48-
const uuidPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
44+
const uuidPattern =
45+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
4946
if (uuidPattern.test(key)) return key;
5047
const m = key.match(
5148
/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-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(

apps/api/src/utils/queryHelper.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ export function ensureOwner(ctx: AccessContext) {
4040
return true;
4141
}
4242

43-
export async function scopeItemRead<T extends PgSelect>(
43+
export async function scopeItemRead(
4444
ctx: AccessContext,
4545
args: {
4646
itemId: string;
4747
includeTrash?: boolean;
4848
}
4949
) {
50-
let qb = db.select().from(storageSchema.item)
50+
let qb = db.select().from(storageSchema.item);
5151
const base = ctx.isOwner
5252
? qb
5353
: qb.innerJoin(
@@ -62,26 +62,35 @@ export async function scopeItemRead<T extends PgSelect>(
6262
)
6363
);
6464

65-
return await base.where(
66-
and(
67-
eq(storageSchema.item.spaceId, ctx.spaceId),
68-
eq(storageSchema.item.id, args.itemId),
69-
args.includeTrash ? sql`TRUE` : isNull(storageSchema.item.purgeAt)
65+
return await base
66+
.where(
67+
and(
68+
eq(storageSchema.item.spaceId, ctx.spaceId),
69+
eq(storageSchema.item.id, args.itemId),
70+
args.includeTrash ? sql`TRUE` : isNull(storageSchema.item.purgeAt)
71+
)
7072
)
71-
).limit(1);
73+
.limit(1);
7274
}
7375

7476
export function scopeItemsRead<T extends PgSelect>(
7577
qb: T,
7678
ctx: AccessContext,
7779
args: {
7880
parentId: string | null;
81+
skipParentCheck?: boolean;
7982
includeTrash?: boolean;
8083
name?: string;
8184
match?: MatchType;
8285
}
8386
) {
84-
const match = args.match ?? MatchType.CONTAINS
87+
args.skipParentCheck = args.skipParentCheck ?? false;
88+
const parentIdFilter = args.skipParentCheck
89+
? sql`TRUE`
90+
: args.parentId === null
91+
? isNull(storageSchema.item.parentId)
92+
: eq(storageSchema.item.parentId, args.parentId);
93+
const match = args.match ?? MatchType.CONTAINS;
8594
const base = ctx.isOwner
8695
? qb
8796
: qb.innerJoin(
@@ -99,11 +108,11 @@ export function scopeItemsRead<T extends PgSelect>(
99108
return base.where(
100109
and(
101110
eq(storageSchema.item.spaceId, ctx.spaceId),
102-
args.parentId === null
103-
? isNull(storageSchema.item.parentId)
104-
: eq(storageSchema.item.parentId, args.parentId),
111+
parentIdFilter,
105112
args.includeTrash ? sql`TRUE` : isNull(storageSchema.item.purgeAt),
106-
args.name ? patternBuilder(storageSchema.item.name, args.name, match) : sql`TRUE`
113+
args.name
114+
? patternBuilder(storageSchema.item.name, args.name, match)
115+
: sql`TRUE`
107116
)
108117
);
109118
}
@@ -120,7 +129,7 @@ export const MatchType = {
120129
EXACT: "exact",
121130
CONTAINS: "contains",
122131
STARTS_WITH: "startsWith",
123-
ID: "id"
132+
ID: "id",
124133
} as const;
125134
export type MatchType = (typeof MatchType)[keyof typeof MatchType];
126135

@@ -130,11 +139,15 @@ export function withMatch<T extends PgSelect>(
130139
matchType: MatchType,
131140
searchString: string
132141
): T {
133-
return qb.where(patternBuilder(matchColumn, searchString, matchType))
142+
return qb.where(patternBuilder(matchColumn, searchString, matchType));
134143
}
135144

136-
function patternBuilder(matchColumn: PgColumn, searchString: string, matchType: MatchType) {
137-
switch (matchType) {
145+
function patternBuilder(
146+
matchColumn: PgColumn,
147+
searchString: string,
148+
matchType: MatchType
149+
) {
150+
switch (matchType) {
138151
case MatchType.EXACT:
139152
return eq(matchColumn, searchString);
140153

@@ -145,7 +158,7 @@ function patternBuilder(matchColumn: PgColumn, searchString: string, matchType:
145158
return like(matchColumn, `${searchString}%`);
146159

147160
case MatchType.ID:
148-
return eq(matchColumn, searchString) // This should be rewritten ASAP
161+
return eq(matchColumn, searchString); // This should be rewritten ASAP
149162

150163
default: {
151164
// Exhaustiveness guard – makes sure we handled every literal
@@ -167,4 +180,4 @@ export const getColumnLength = (column: PgColumn) => {
167180
}
168181

169182
return length;
170-
};
183+
};

apps/web/src/components/image-folder/file-tool-bar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function FileToolBar({
2828
<div className="sticky top-[10vh] z-50 bg-background py-4 ml-[20vw] flex justify-end gap-4">
2929
{isSelectable ? (
3030
<SelectionBar
31+
spaceId={spaceInfo.spaceId}
3132
selectedCount={selectedCount}
3233
selectedImageKeys={selectedImageKeys}
3334
onCancel={onCancel}

0 commit comments

Comments
 (0)