Skip to content

Commit afa7c70

Browse files
improve filename generation and conflict handling (#3380)
* fix(storage): improve filename generation and conflict handling Refactor file upload mechanism to generate more readable filenames, prevent special characters, and handle potential filename conflicts by appending a numeric suffix when needed. * refactor(media): replace postgres direct query with supabase storage methods * feat(media): implement drag and drop for file management * fix(file-upload): ensure filename is valid when no basename
1 parent 9fc2886 commit afa7c70

File tree

2 files changed

+323
-85
lines changed

2 files changed

+323
-85
lines changed

apps/web/src/functions/supabase-media.ts

Lines changed: 108 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import type { SupabaseClient } from "@supabase/supabase-js";
22
import { createClient } from "@supabase/supabase-js";
3-
import postgres from "postgres";
43

54
import { env } from "@/env";
65

76
const BUCKET_NAME = "blog";
87

9-
function getDbClient() {
10-
return postgres(env.DATABASE_URL, { prepare: false });
11-
}
12-
138
export interface MediaItem {
149
name: string;
1510
path: string;
@@ -101,11 +96,6 @@ export async function uploadMediaFile(
10196
publicUrl?: string;
10297
error?: string;
10398
}> {
104-
const timestamp = Date.now();
105-
const sanitizedFilename = `${timestamp}-${filename
106-
.replace(/[^a-zA-Z0-9.-]/g, "-")
107-
.toLowerCase()}`;
108-
10999
const allowedExtensions = [
110100
"jpg",
111101
"jpeg",
@@ -118,7 +108,10 @@ export async function uploadMediaFile(
118108
"webm",
119109
"mov",
120110
];
121-
const ext = sanitizedFilename.toLowerCase().split(".").pop();
111+
112+
const parts = filename.split(".");
113+
const ext = parts.pop()?.toLowerCase();
114+
const baseName = parts.join(".").replace(/[^a-zA-Z0-9.-]/g, "-") || "file";
122115

123116
if (!ext || !allowedExtensions.includes(ext)) {
124117
return {
@@ -127,7 +120,24 @@ export async function uploadMediaFile(
127120
};
128121
}
129122

130-
const path = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
123+
let finalFilename = `${baseName}.${ext}`;
124+
let path = folder ? `${folder}/${finalFilename}` : finalFilename;
125+
126+
const { data: existingFiles } = await supabase.storage
127+
.from(BUCKET_NAME)
128+
.list(folder || undefined, { limit: 1000 });
129+
130+
if (existingFiles) {
131+
const existingNames = new Set(existingFiles.map((f) => f.name));
132+
let counter = 1;
133+
134+
while (existingNames.has(finalFilename)) {
135+
finalFilename = `${baseName}-${counter}.${ext}`;
136+
counter++;
137+
}
138+
139+
path = folder ? `${folder}/${finalFilename}` : finalFilename;
140+
}
131141

132142
try {
133143
const fileBuffer = Buffer.from(content, "base64");
@@ -170,32 +180,64 @@ export async function uploadMediaFile(
170180
}
171181
}
172182

183+
async function listAllFilesInFolder(
184+
supabase: SupabaseClient,
185+
folderPath: string,
186+
): Promise<string[]> {
187+
const allFiles: string[] = [];
188+
189+
const { data } = await supabase.storage
190+
.from(BUCKET_NAME)
191+
.list(folderPath, { limit: 1000 });
192+
193+
if (!data) return allFiles;
194+
195+
for (const item of data) {
196+
const itemPath = folderPath ? `${folderPath}/${item.name}` : item.name;
197+
const isFolder = item.id === null;
198+
199+
if (isFolder) {
200+
const nestedFiles = await listAllFilesInFolder(supabase, itemPath);
201+
allFiles.push(...nestedFiles);
202+
} else {
203+
allFiles.push(itemPath);
204+
}
205+
}
206+
207+
return allFiles;
208+
}
209+
173210
export async function deleteMediaFiles(
174211
supabase: SupabaseClient,
175212
paths: string[],
176213
): Promise<{ success: boolean; deleted: string[]; errors: string[] }> {
177214
const deleted: string[] = [];
178215
const errors: string[] = [];
179-
const sql = getDbClient();
180216

181217
try {
182218
for (const path of paths) {
183-
const isFolder =
184-
(
185-
await sql`
186-
SELECT COUNT(*) as count FROM storage.objects
187-
WHERE bucket_id = ${BUCKET_NAME}
188-
AND name LIKE ${path + "/%"}
189-
`
190-
)[0].count > 0;
219+
const { data: folderContents } = await supabase.storage
220+
.from(BUCKET_NAME)
221+
.list(path, { limit: 1 });
222+
223+
const isFolder = folderContents && folderContents.length > 0;
191224

192225
if (isFolder) {
193-
await sql`
194-
DELETE FROM storage.objects
195-
WHERE bucket_id = ${BUCKET_NAME}
196-
AND (name = ${path} OR name LIKE ${path + "/%"})
197-
`;
198-
deleted.push(path);
226+
const allFiles = await listAllFilesInFolder(supabase, path);
227+
228+
if (allFiles.length > 0) {
229+
const { error } = await supabase.storage
230+
.from(BUCKET_NAME)
231+
.remove(allFiles);
232+
233+
if (error) {
234+
errors.push(`${path}: ${error.message}`);
235+
} else {
236+
deleted.push(path);
237+
}
238+
} else {
239+
deleted.push(path);
240+
}
199241
} else {
200242
const { data, error } = await supabase.storage
201243
.from(BUCKET_NAME)
@@ -224,18 +266,14 @@ export async function deleteMediaFiles(
224266
deleted,
225267
errors: [`Delete failed: ${(error as Error).message}`],
226268
};
227-
} finally {
228-
await sql.end();
229269
}
230270
}
231271

232272
export async function createMediaFolder(
233-
_supabase: SupabaseClient,
273+
supabase: SupabaseClient,
234274
folderName: string,
235275
parentFolder: string = "",
236276
): Promise<{ success: boolean; path?: string; error?: string }> {
237-
const sql = getDbClient();
238-
239277
const sanitizedFolderName = folderName
240278
.replace(/[^a-zA-Z0-9-_]/g, "-")
241279
.toLowerCase();
@@ -244,22 +282,27 @@ export async function createMediaFolder(
244282
? `${parentFolder}/${sanitizedFolderName}`
245283
: sanitizedFolderName;
246284

285+
const placeholderPath = `${folderPath}/.emptyFolderPlaceholder`;
286+
247287
try {
248-
const existing = await sql`
249-
SELECT id FROM storage.objects
250-
WHERE bucket_id = ${BUCKET_NAME}
251-
AND name LIKE ${folderPath + "/%"}
252-
LIMIT 1
253-
`;
254-
255-
if (existing.length > 0) {
288+
const { data: existing } = await supabase.storage
289+
.from(BUCKET_NAME)
290+
.list(folderPath, { limit: 1 });
291+
292+
if (existing && existing.length > 0) {
256293
return { success: false, error: "Folder already exists" };
257294
}
258295

259-
await sql`
260-
INSERT INTO storage.objects (bucket_id, name, owner, metadata)
261-
VALUES (${BUCKET_NAME}, ${folderPath + "/.folder"}, NULL, '{"mimetype": "application/x-directory"}')
262-
`;
296+
const { error } = await supabase.storage
297+
.from(BUCKET_NAME)
298+
.upload(placeholderPath, new Uint8Array(0), {
299+
contentType: "application/x-empty",
300+
upsert: false,
301+
});
302+
303+
if (error) {
304+
return { success: false, error: error.message };
305+
}
263306

264307
return {
265308
success: true,
@@ -270,8 +313,6 @@ export async function createMediaFolder(
270313
success: false,
271314
error: `Failed to create folder: ${(error as Error).message}`,
272315
};
273-
} finally {
274-
await sql.end();
275316
}
276317
}
277318

@@ -280,24 +321,31 @@ export async function moveMediaFile(
280321
fromPath: string,
281322
toPath: string,
282323
): Promise<{ success: boolean; newPath?: string; error?: string }> {
283-
const sql = getDbClient();
284-
285324
try {
286-
const filesInFolder = await sql`
287-
SELECT name FROM storage.objects
288-
WHERE bucket_id = ${BUCKET_NAME}
289-
AND name LIKE ${fromPath + "/%"}
290-
`;
325+
const { data: folderContents } = await supabase.storage
326+
.from(BUCKET_NAME)
327+
.list(fromPath, { limit: 1 });
291328

292-
const isFolder = filesInFolder.length > 0;
329+
const isFolder = folderContents && folderContents.length > 0;
293330

294331
if (isFolder) {
295-
await sql`
296-
UPDATE storage.objects
297-
SET name = ${toPath} || SUBSTRING(name FROM ${fromPath.length + 1})
298-
WHERE bucket_id = ${BUCKET_NAME}
299-
AND name LIKE ${fromPath + "/%"}
300-
`;
332+
const allFiles = await listAllFilesInFolder(supabase, fromPath);
333+
334+
for (const filePath of allFiles) {
335+
const relativePath = filePath.substring(fromPath.length);
336+
const newFilePath = toPath + relativePath;
337+
338+
const { error } = await supabase.storage
339+
.from(BUCKET_NAME)
340+
.move(filePath, newFilePath);
341+
342+
if (error) {
343+
return {
344+
success: false,
345+
error: `Failed to move ${filePath}: ${error.message}`,
346+
};
347+
}
348+
}
301349

302350
return {
303351
success: true,
@@ -322,7 +370,5 @@ export async function moveMediaFile(
322370
success: false,
323371
error: `Move failed: ${(error as Error).message}`,
324372
};
325-
} finally {
326-
await sql.end();
327373
}
328374
}

0 commit comments

Comments
 (0)