Spaces:
Sleeping
Sleeping
Bot commited on
Commit ·
a375c71
1
Parent(s): c27c221
feat: implement zero-config local filesystem storage driver for out-of-the-box file uploads on Hugging Face Spaces
Browse files
src/app/api/storage/actions.ts
CHANGED
|
@@ -11,7 +11,7 @@ export async function getStorageInfoAction() {
|
|
| 11 |
return {
|
| 12 |
type: storageDriver,
|
| 13 |
supportsDirectUpload:
|
| 14 |
-
storageDriver === "vercel-blob" || storageDriver === "s3",
|
| 15 |
};
|
| 16 |
}
|
| 17 |
|
|
@@ -69,20 +69,24 @@ export async function checkStorageAction(): Promise<StorageCheckResult> {
|
|
| 69 |
};
|
| 70 |
}
|
| 71 |
|
| 72 |
-
// Warn if neither a public base URL nor a public bucket policy is set.
|
| 73 |
-
// We can't reliably detect bucket policy here; we just pass validation.
|
| 74 |
return { isValid: true };
|
| 75 |
}
|
| 76 |
|
| 77 |
-
// 3.
|
| 78 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return {
|
| 80 |
isValid: false,
|
| 81 |
error: `Invalid storage driver: ${storageDriver}`,
|
| 82 |
solution:
|
| 83 |
"FILE_STORAGE_TYPE must be one of:\n" +
|
| 84 |
"- 'vercel-blob' (default)\n" +
|
| 85 |
-
"- 's3'
|
|
|
|
| 86 |
};
|
| 87 |
}
|
| 88 |
|
|
|
|
| 11 |
return {
|
| 12 |
type: storageDriver,
|
| 13 |
supportsDirectUpload:
|
| 14 |
+
storageDriver === "vercel-blob" || storageDriver === "s3" || storageDriver === "local",
|
| 15 |
};
|
| 16 |
}
|
| 17 |
|
|
|
|
| 69 |
};
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
| 72 |
return { isValid: true };
|
| 73 |
}
|
| 74 |
|
| 75 |
+
// 3. Local filesystem storage
|
| 76 |
+
if (storageDriver === "local") {
|
| 77 |
+
return { isValid: true };
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// 4. Validate storage driver
|
| 81 |
+
if (!["vercel-blob", "s3", "local"].includes(storageDriver)) {
|
| 82 |
return {
|
| 83 |
isValid: false,
|
| 84 |
error: `Invalid storage driver: ${storageDriver}`,
|
| 85 |
solution:
|
| 86 |
"FILE_STORAGE_TYPE must be one of:\n" +
|
| 87 |
"- 'vercel-blob' (default)\n" +
|
| 88 |
+
"- 's3'\n" +
|
| 89 |
+
"- 'local'",
|
| 90 |
};
|
| 91 |
}
|
| 92 |
|
src/app/api/storage/file/[key]/route.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import fs from "fs/promises";
|
| 3 |
+
import path from "path";
|
| 4 |
+
|
| 5 |
+
const UPLOADS_DIR = "/tmp/uploads";
|
| 6 |
+
|
| 7 |
+
export async function GET(
|
| 8 |
+
req: NextRequest,
|
| 9 |
+
{ params }: { params: Promise<{ key: string }> },
|
| 10 |
+
) {
|
| 11 |
+
const { key } = await params;
|
| 12 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
const fileBuffer = await fs.readFile(filePath);
|
| 16 |
+
|
| 17 |
+
// Parse original filename from key (Format: timestamp-random-filename)
|
| 18 |
+
const filename = key.split("-").slice(2).join("-") || "file";
|
| 19 |
+
const ext = path.extname(filename).toLowerCase();
|
| 20 |
+
|
| 21 |
+
let contentType = "application/octet-stream";
|
| 22 |
+
if (ext === ".png") contentType = "image/png";
|
| 23 |
+
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
| 24 |
+
else if (ext === ".gif") contentType = "image/gif";
|
| 25 |
+
else if (ext === ".svg") contentType = "image/svg+xml";
|
| 26 |
+
else if (ext === ".pdf") contentType = "application/pdf";
|
| 27 |
+
else if (ext === ".txt") contentType = "text/plain";
|
| 28 |
+
else if (ext === ".json") contentType = "application/json";
|
| 29 |
+
else if (ext === ".csv") contentType = "text/csv";
|
| 30 |
+
|
| 31 |
+
return new NextResponse(fileBuffer, {
|
| 32 |
+
headers: {
|
| 33 |
+
"Content-Type": contentType,
|
| 34 |
+
"Content-Disposition": `inline; filename="${encodeURIComponent(filename)}"`,
|
| 35 |
+
"Cache-Control": "public, max-age=31536000, immutable",
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
} catch (error) {
|
| 39 |
+
return new NextResponse("File not found", { status: 404 });
|
| 40 |
+
}
|
| 41 |
+
}
|
src/lib/file-storage/index.ts
CHANGED
|
@@ -3,20 +3,21 @@ import { IS_DEV } from "lib/const";
|
|
| 3 |
import type { FileStorage } from "./file-storage.interface";
|
| 4 |
import { createS3FileStorage } from "./s3-file-storage";
|
| 5 |
import { createVercelBlobStorage } from "./vercel-blob-storage";
|
|
|
|
| 6 |
import logger from "logger";
|
| 7 |
|
| 8 |
-
export type FileStorageDriver = "vercel-blob" | "s3";
|
| 9 |
|
| 10 |
const resolveDriver = (): FileStorageDriver => {
|
| 11 |
const candidate = process.env.FILE_STORAGE_TYPE;
|
| 12 |
|
| 13 |
const normalized = candidate?.trim().toLowerCase();
|
| 14 |
-
if (normalized === "vercel-blob" || normalized === "s3") {
|
| 15 |
return normalized;
|
| 16 |
}
|
| 17 |
|
| 18 |
-
//
|
| 19 |
-
return "
|
| 20 |
};
|
| 21 |
|
| 22 |
declare global {
|
|
@@ -33,6 +34,8 @@ const createFileStorage = (): FileStorage => {
|
|
| 33 |
return createVercelBlobStorage();
|
| 34 |
case "s3":
|
| 35 |
return createS3FileStorage();
|
|
|
|
|
|
|
| 36 |
default: {
|
| 37 |
const exhaustiveCheck: never = storageDriver;
|
| 38 |
throw new Error(`Unsupported file storage driver: ${exhaustiveCheck}`);
|
|
|
|
| 3 |
import type { FileStorage } from "./file-storage.interface";
|
| 4 |
import { createS3FileStorage } from "./s3-file-storage";
|
| 5 |
import { createVercelBlobStorage } from "./vercel-blob-storage";
|
| 6 |
+
import { createLocalFileStorage } from "./local-file-storage";
|
| 7 |
import logger from "logger";
|
| 8 |
|
| 9 |
+
export type FileStorageDriver = "vercel-blob" | "s3" | "local";
|
| 10 |
|
| 11 |
const resolveDriver = (): FileStorageDriver => {
|
| 12 |
const candidate = process.env.FILE_STORAGE_TYPE;
|
| 13 |
|
| 14 |
const normalized = candidate?.trim().toLowerCase();
|
| 15 |
+
if (normalized === "vercel-blob" || normalized === "s3" || normalized === "local") {
|
| 16 |
return normalized;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
// If no env is configured, default to local filesystem storage
|
| 20 |
+
return "local";
|
| 21 |
};
|
| 22 |
|
| 23 |
declare global {
|
|
|
|
| 34 |
return createVercelBlobStorage();
|
| 35 |
case "s3":
|
| 36 |
return createS3FileStorage();
|
| 37 |
+
case "local":
|
| 38 |
+
return createLocalFileStorage();
|
| 39 |
default: {
|
| 40 |
const exhaustiveCheck: never = storageDriver;
|
| 41 |
throw new Error(`Unsupported file storage driver: ${exhaustiveCheck}`);
|
src/lib/file-storage/local-file-storage.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FileStorage, UploadContent, UploadOptions, UploadResult, FileMetadata } from "./file-storage.interface";
|
| 2 |
+
import fs from "fs/promises";
|
| 3 |
+
import path from "path";
|
| 4 |
+
import { BASE_URL } from "lib/const";
|
| 5 |
+
|
| 6 |
+
const UPLOADS_DIR = "/tmp/uploads";
|
| 7 |
+
|
| 8 |
+
export class LocalFileStorage implements FileStorage {
|
| 9 |
+
constructor() {
|
| 10 |
+
fs.mkdir(UPLOADS_DIR, { recursive: true }).catch(() => {});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async upload(content: UploadContent, options?: UploadOptions): Promise<UploadResult> {
|
| 14 |
+
const filename = options?.filename || "file";
|
| 15 |
+
const contentType = options?.contentType || "application/octet-stream";
|
| 16 |
+
const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${filename}`;
|
| 17 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 18 |
+
|
| 19 |
+
let buffer: Buffer;
|
| 20 |
+
if (Buffer.isBuffer(content)) {
|
| 21 |
+
buffer = content;
|
| 22 |
+
} else if (content instanceof ArrayBuffer) {
|
| 23 |
+
buffer = Buffer.from(content);
|
| 24 |
+
} else if (ArrayBuffer.isView(content)) {
|
| 25 |
+
buffer = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
|
| 26 |
+
} else if (content instanceof Blob) {
|
| 27 |
+
buffer = Buffer.from(await content.arrayBuffer());
|
| 28 |
+
} else if (typeof (content as any).read === "function" || typeof (content as any).on === "function") {
|
| 29 |
+
const chunks: any[] = [];
|
| 30 |
+
for await (const chunk of content as any) {
|
| 31 |
+
chunks.push(chunk);
|
| 32 |
+
}
|
| 33 |
+
buffer = Buffer.concat(chunks);
|
| 34 |
+
} else {
|
| 35 |
+
throw new Error("Unsupported upload content type");
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
await fs.writeFile(filePath, buffer);
|
| 39 |
+
|
| 40 |
+
const metadata: FileMetadata = {
|
| 41 |
+
key,
|
| 42 |
+
filename,
|
| 43 |
+
contentType,
|
| 44 |
+
size: buffer.length,
|
| 45 |
+
uploadedAt: new Date(),
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
key,
|
| 50 |
+
sourceUrl: `${BASE_URL}/api/storage/file/${key}`,
|
| 51 |
+
metadata,
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async download(key: string): Promise<Buffer> {
|
| 56 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 57 |
+
return fs.readFile(filePath);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
async delete(key: string): Promise<void> {
|
| 61 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 62 |
+
await fs.unlink(filePath).catch(() => {});
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async exists(key: string): Promise<boolean> {
|
| 66 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 67 |
+
try {
|
| 68 |
+
await fs.access(filePath);
|
| 69 |
+
return true;
|
| 70 |
+
} catch {
|
| 71 |
+
return false;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async getMetadata(key: string): Promise<FileMetadata | null> {
|
| 76 |
+
const filePath = path.join(UPLOADS_DIR, key);
|
| 77 |
+
try {
|
| 78 |
+
const stat = await fs.stat(filePath);
|
| 79 |
+
return {
|
| 80 |
+
key,
|
| 81 |
+
filename: key.split("-").slice(2).join("-") || "file",
|
| 82 |
+
contentType: "application/octet-stream",
|
| 83 |
+
size: stat.size,
|
| 84 |
+
uploadedAt: stat.mtime,
|
| 85 |
+
};
|
| 86 |
+
} catch {
|
| 87 |
+
return null;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async getSourceUrl(key: string): Promise<string | null> {
|
| 92 |
+
return `${BASE_URL}/api/storage/file/${key}`;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export const createLocalFileStorage = () => new LocalFileStorage();
|