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. Validate storage driver
78
- if (!["vercel-blob", "s3"].includes(storageDriver)) {
 
 
 
 
 
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' (coming soon)",
 
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
- // Default to Vercel Blob
19
- return "vercel-blob";
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();