|
|
@@ -1,3 +1,7 @@
|
|
|
+import type { Stats } from 'node:fs';
|
|
|
+import fs from 'node:fs/promises';
|
|
|
+import path from 'node:path';
|
|
|
+
|
|
|
import { serverLogger as log } from '$lib/server/logger';
|
|
|
import type { Workspace } from '$lib/types/workspace';
|
|
|
|
|
|
@@ -11,6 +15,7 @@ import { WorkspaceManager } from './workspace';
|
|
|
class Server {
|
|
|
// Guards against double-init in HMR / test environments.
|
|
|
#initialized = false;
|
|
|
+ #fileSavePath = env.FILE_SAVE_PATH;
|
|
|
|
|
|
#ws!: WorkspaceManager;
|
|
|
#auth: Auth | undefined;
|
|
|
@@ -78,6 +83,89 @@ class Server {
|
|
|
get resources(): Resources {
|
|
|
return this.#resources;
|
|
|
}
|
|
|
+
|
|
|
+ async #saveFile(fileName: string, file: File): Promise<string> {
|
|
|
+ await fs.mkdir(path.dirname(fileName), { recursive: true });
|
|
|
+ let usedFileName = fileName;
|
|
|
+ while (true) {
|
|
|
+ const stat = await fs.stat(usedFileName).catch(() => undefined);
|
|
|
+ if (stat) {
|
|
|
+ // rename the file
|
|
|
+ const ext = path.extname(fileName);
|
|
|
+ const base = path.basename(fileName, ext);
|
|
|
+ const dir = path.dirname(fileName);
|
|
|
+ usedFileName = path.join(dir, `${base}-${Date.now()}${ext}`);
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const buffer = Buffer.from(await file.arrayBuffer());
|
|
|
+ await fs.writeFile(usedFileName, buffer);
|
|
|
+ return usedFileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ async saveFiles(
|
|
|
+ workspaceId: string,
|
|
|
+ files: Record<string, File>,
|
|
|
+ ): Promise<Record<string, string>> {
|
|
|
+ const root = this.#fileSavePath;
|
|
|
+ if (root === undefined) {
|
|
|
+ throw new Error('FILE_SAVE_PATH env var is not set');
|
|
|
+ }
|
|
|
+ const saved: Record<string, string> = {};
|
|
|
+ const savePromises = Object.entries(files).map(([fileName, file]) =>
|
|
|
+ (async () => {
|
|
|
+ const savedPath = await this.#saveFile(path.join(root, workspaceId, fileName), file);
|
|
|
+ saved[fileName] = path.relative(root, savedPath);
|
|
|
+ })(),
|
|
|
+ );
|
|
|
+ await Promise.all(savePromises);
|
|
|
+ return saved;
|
|
|
+ }
|
|
|
+
|
|
|
+ #getFilePath(workspaceId: string, filePath: string): string {
|
|
|
+ const root = this.#fileSavePath;
|
|
|
+ if (root === undefined) {
|
|
|
+ throw new Error('FILE_SAVE_PATH env var is not set');
|
|
|
+ }
|
|
|
+ const normalizedPath = path.normalize(filePath);
|
|
|
+ if (path.isAbsolute(normalizedPath) || normalizedPath.startsWith('..')) {
|
|
|
+ throw new Error('Invalid file path');
|
|
|
+ }
|
|
|
+
|
|
|
+ const absolutePath = path.resolve(root, normalizedPath);
|
|
|
+ const allowedRoot = path.resolve(root, workspaceId);
|
|
|
+ if (!absolutePath.startsWith(`${allowedRoot}${path.sep}`) && absolutePath !== allowedRoot) {
|
|
|
+ throw new Error('Invalid file path');
|
|
|
+ }
|
|
|
+ return absolutePath;
|
|
|
+ }
|
|
|
+
|
|
|
+ async readFile(
|
|
|
+ workspaceId: string,
|
|
|
+ filePath: string,
|
|
|
+ ): Promise<{ stats: Stats; content: Buffer<ArrayBuffer> }> {
|
|
|
+ const absolutePath = this.#getFilePath(workspaceId, filePath);
|
|
|
+ let stats: Stats;
|
|
|
+ try {
|
|
|
+ stats = await fs.stat(absolutePath);
|
|
|
+ } catch {
|
|
|
+ throw new Error('File not found');
|
|
|
+ }
|
|
|
+ if (!stats.isFile()) throw new Error('File not found');
|
|
|
+
|
|
|
+ return { stats, content: await fs.readFile(absolutePath) };
|
|
|
+ }
|
|
|
+
|
|
|
+ async deleteFile(workspaceId: string, filePath: string): Promise<void> {
|
|
|
+ const absolutePath = this.#getFilePath(workspaceId, filePath);
|
|
|
+ try {
|
|
|
+ await fs.unlink(absolutePath);
|
|
|
+ } catch (err: any) {
|
|
|
+ if (err.code === 'ENOENT') return;
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// Singleton shared across hooks and route handlers.
|