1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
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 { assets, AssetTypes } from "@karakeep/db/schema";
import { QuotaService, StorageQuotaError } from "@karakeep/shared-server";
import {
newAssetId,
saveAssetFromFile,
SUPPORTED_UPLOAD_ASSET_TYPES,
} from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import { AuthedContext } from "@karakeep/trpc";
const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024;
// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18)
export 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 function toWebReadableStream(
nodeStream: NodeJS.ReadableStream,
): ReadableStream<Uint8Array> {
const reader = nodeStream as unknown as Readable;
return new ReadableStream({
start(controller) {
reader.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
reader.on("end", () => controller.close());
reader.on("error", (err) => controller.error(err));
},
});
}
export async function uploadAsset(
user: AuthedContext["user"],
db: AuthedContext["db"],
formData: { file: File } | { image: File },
): Promise<
| { error: string; status: 400 | 413 | 403 }
| {
assetId: string;
contentType: string;
fileName: string;
size: number;
}
> {
let data: File;
if ("file" in formData) {
data = formData.file;
} else {
data = formData.image;
}
const contentType = data.type;
// Replace all non-ascii characters with underscores
const fileName = data.name.replace(/[^\x20-\x7E]/g, "_");
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 quotaApproved;
try {
quotaApproved = await QuotaService.checkStorageQuota(
db,
user.id,
data.size,
);
} catch (error) {
if (error instanceof StorageQuotaError) {
return { error: error.message, status: 403 };
}
throw error;
}
let tempFilePath: string | undefined;
try {
tempFilePath = path.join(os.tmpdir(), `karakeep-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 },
quotaApproved,
});
return {
assetId: assetDb.id,
contentType,
size: data.size,
fileName,
};
} finally {
if (
tempFilePath &&
(await fs.promises
.access(tempFilePath)
.then(() => true)
.catch(() => false))
) {
await fs.promises.unlink(tempFilePath).catch(() => ({}));
}
}
}
|