aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-03-29 13:15:57 +0000
committerMohamed Bassem <me@mbassem.com>2025-03-29 13:15:57 +0000
commit453f2dd2a2810c4345c4d97c6b74d0169832cf75 (patch)
tree03a15ec4f26d06ab1acf7c27907f7242e128ac3a /apps/web
parentc193c6e7c4c7eede9c9ee699fb9d20fde7e9b5fb (diff)
downloadkarakeep-453f2dd2a2810c4345c4d97c6b74d0169832cf75.tar.zst
fix: Dont buffer uploaded files in memory. Fixes #1173
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/api/assets/route.ts96
1 files changed, 59 insertions, 37 deletions
diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts
index 81ee454e..b3bd9bc0 100644
--- a/apps/web/app/api/assets/route.ts
+++ b/apps/web/app/api/assets/route.ts
@@ -1,3 +1,8 @@
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
import { createContextFromRequest } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
@@ -5,7 +10,7 @@ import type { ZUploadResponse } from "@hoarder/shared/types/uploads";
import { assets, AssetTypes } from "@hoarder/db/schema";
import {
newAssetId,
- saveAsset,
+ saveAssetFromFile,
SUPPORTED_UPLOAD_ASSET_TYPES,
} from "@hoarder/shared/assetdb";
import serverConfig from "@hoarder/shared/config";
@@ -15,6 +20,12 @@ const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024;
export const dynamic = "force-dynamic";
+// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18)
+function webStreamToNode(webStream: ReadableStream<Uint8Array>): Readable {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+ return Readable.fromWeb(webStream as any); // Type assertion might be needed
+}
+
export async function uploadFromPostData(
user: AuthedContext["user"],
db: AuthedContext["db"],
@@ -29,50 +40,61 @@ export async function uploadFromPostData(
}
> {
const data = formData.get("file") ?? formData.get("image");
- let buffer;
- let contentType;
- if (data instanceof File) {
- contentType = data.type;
- if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) {
- return { error: "Unsupported asset type", status: 400 };
- }
- if (data.size > MAX_UPLOAD_SIZE_BYTES) {
- return { error: "Asset is too big", status: 413 };
- }
- buffer = Buffer.from(await data.arrayBuffer());
- } else {
+
+ if (!(data instanceof File)) {
return { error: "Bad request", status: 400 };
}
+ const contentType = data.type;
const fileName = data.name;
- const [assetDb] = await db
- .insert(assets)
- .values({
- id: newAssetId(),
- // Initially, uploads are uploaded for unknown purpose
- // And without an attached bookmark.
- assetType: AssetTypes.UNKNOWN,
- bookmarkId: null,
+ if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) {
+ return { error: "Unsupported asset type", status: 400 };
+ }
+ if (data.size > MAX_UPLOAD_SIZE_BYTES) {
+ return { error: "Asset is too big", status: 413 };
+ }
+
+ let tempFilePath: string | undefined;
+
+ try {
+ tempFilePath = path.join(os.tmpdir(), `hoarder-upload-${Date.now()}`);
+ await pipeline(
+ webStreamToNode(data.stream()),
+ fs.createWriteStream(tempFilePath),
+ );
+ const [assetDb] = await db
+ .insert(assets)
+ .values({
+ id: newAssetId(),
+ // Initially, uploads are uploaded for unknown purpose
+ // And without an attached bookmark.
+ assetType: AssetTypes.UNKNOWN,
+ bookmarkId: null,
+ userId: user.id,
+ contentType,
+ size: data.size,
+ fileName,
+ })
+ .returning();
+
+ await saveAssetFromFile({
userId: user.id,
+ assetId: assetDb.id,
+ assetPath: tempFilePath,
+ metadata: { contentType, fileName },
+ });
+
+ return {
+ assetId: assetDb.id,
contentType,
size: data.size,
fileName,
- })
- .returning();
-
- await saveAsset({
- userId: user.id,
- assetId: assetDb.id,
- metadata: { contentType, fileName },
- asset: buffer,
- });
-
- return {
- assetId: assetDb.id,
- contentType,
- size: buffer.byteLength,
- fileName,
- };
+ };
+ } finally {
+ if (tempFilePath) {
+ await fs.promises.unlink(tempFilePath).catch(() => ({}));
+ }
+ }
}
export async function POST(request: Request) {