From 4eed20457289e37a2a58add84eb1f433acf3d863 Mon Sep 17 00:00:00 2001 From: Tims777 Date: Sat, 28 Sep 2024 21:12:33 +0200 Subject: [PATCH] Persist resources on disk --- .gitignore | 3 ++ src/index.ts | 10 ++++-- src/repository.ts | 51 ++++++++++++++++++++++++++++ src/restHandler.ts | 84 +++++++++++++++++++++------------------------- 4 files changed, 101 insertions(+), 47 deletions(-) create mode 100644 src/repository.ts diff --git a/.gitignore b/.gitignore index 4259d00..8bc4560 100644 --- a/.gitignore +++ b/.gitignore @@ -104,5 +104,8 @@ dist # TernJS port file .tern-port +# Default data directory +data + .DS_Store dist diff --git a/src/index.ts b/src/index.ts index 313c6f0..615f4c2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import http from "http"; import { Server as SocketIO } from "socket.io"; import { restHandler } from "./restHandler"; import bodyParser from "body-parser"; +import { FileStorage } from "./repository"; type UserToFollow = { socketId: string; @@ -32,8 +33,13 @@ app.use(express.static("public")); app.use(bodyParser.raw({ limit: "10mb" })); -app.use("/scene/*", restHandler("scene")); -app.use("/file/*", restHandler("file")); +const dataDir = process.env.DATA_DIR || "./data"; + +const scenes = new FileStorage(`${dataDir}/scenes`); +app.use("/scene/*", restHandler(scenes)); + +const files = new FileStorage(`${dataDir}/files`); +app.use("/file/*", restHandler(files)); app.get("/", (req, res) => { res.send("Excalidraw collaboration server is up :)"); diff --git a/src/repository.ts b/src/repository.ts new file mode 100644 index 0000000..2c2daf4 --- /dev/null +++ b/src/repository.ts @@ -0,0 +1,51 @@ +import { existsSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +export interface Resource { + hash: string; + data: ArrayBuffer; +} + +export interface Repository { + get(id: string): Promise; + put(id: string, resource: T): Promise; + delete(id: string): Promise; +} + +export class FileStorage implements Repository { + + constructor(public dataPath: string) { } + + private resolvePaths(id: string) { + const baseFile = path.resolve(this.dataPath, "./" + id); + return { + dataFile: `${baseFile}.data`, + hashFile: `${baseFile}.hash`, + } + } + + public async get(id: string): Promise { + const { dataFile, hashFile } = this.resolvePaths(id); + if (!existsSync(dataFile) || !existsSync(hashFile)) { + return null; + } + return { + data: await fs.readFile(dataFile), + hash: (await fs.readFile(hashFile)).toString(), + }; + } + + public async put(id: string, resource: Resource): Promise { + const { dataFile, hashFile } = this.resolvePaths(id); + await fs.mkdir(path.dirname(dataFile), { recursive: true }); + await fs.writeFile(dataFile, new Uint8Array(resource.data)); + await fs.writeFile(hashFile, resource.hash); + } + + public async delete(id: string): Promise { + const { dataFile, hashFile } = this.resolvePaths(id); + await fs.rm(dataFile); + await fs.rm(hashFile); + } +} diff --git a/src/restHandler.ts b/src/restHandler.ts index 749d7ea..4474ed9 100644 --- a/src/restHandler.ts +++ b/src/restHandler.ts @@ -1,14 +1,11 @@ -import type { RequestHandler } from "express"; +import type { Request, RequestHandler, Response } from "express"; +import { Repository } from "./repository"; -interface Resource { - hash: string; - data: ArrayBuffer; -} +type AsyncHandler = (req: Request, res: Response) => Promise; -export function restHandler(_resName: string): RequestHandler { - const resources: Record = {}; +export function restHandler(repo: Repository): RequestHandler { - const _put: RequestHandler = (req, res, next) => { + const _put: AsyncHandler = async (req, res) => { const resId = req.params[0]; const newData = req.body as ArrayBuffer; const newHash = req.header("ETag"); @@ -19,75 +16,72 @@ export function restHandler(_resName: string): RequestHandler { return; } - if (resources[resId] && resources[resId].hash != oldHash) { + const resource = await repo.get(resId); + if (resource && resource.hash != oldHash) { res.status(409).send("Hash mismatch. Update denied."); return; } - resources[resId] = { + await repo.put(resId, { hash: newHash, data: newData, - }; + }); res.status(200).send(`Resource with ID ${resId} has been upserted.`); - next(); }; - const _get: RequestHandler = (req, res, next) => { + const _get: AsyncHandler = async (req, res) => { const resId = req.params[0]; const oldHash = req.header("If-None-Match"); - if (!resources[resId]) { + const resource = await repo.get(resId); + if (!resource) { res.status(404).send("Not found."); return; } - if (resources[resId] && resources[resId].hash == oldHash) { + if (resource.hash == oldHash) { res.status(304).send("Not modified."); return; } - // TODO: Respect If-None-Match header - - const resource = resources[resId]; res.header("ETag", resource.hash).header("Content-Type", "application/octet-stream"); res.status(200).send(resource.data); - next(); }; - const _options: RequestHandler = (req, res, next) => { - const resId = req.params[0]; - delete resources[resId]; + const _options: AsyncHandler = async (_req, res) => { res.status(200).end(); - next(); }; - const _delete: RequestHandler = (req, res, next) => { + const _delete: AsyncHandler = async (req, res) => { const resId = req.params[0]; - delete resources[resId]; + await repo.delete(resId); res.status(200).end(); - next(); }; - return (req, res, next) => { - res.header("Access-Control-Allow-Origin", process.env.CORS_ORIGIN || "*"); - res.header("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS"); - res.header("Access-Control-Allow-Headers", "Content-Type, ETag, If-Match, If-None-Match"); - switch (req.method) { - case "GET": - _get(req, res, next); - break; - case "PUT": - _put(req, res, next); - break; - case "OPTIONS": - _options(req, res, next); - break; - case "DELETE": - _delete(req, res, next); - break; - default: - next(); + return async (req, res, next) => { + try { + res.header("Access-Control-Allow-Origin", process.env.CORS_ORIGIN || "*"); + res.header("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS"); + res.header("Access-Control-Allow-Headers", "Content-Type, ETag, If-Match, If-None-Match"); + switch (req.method) { + case "GET": + await _get(req, res); + break; + case "PUT": + await _put(req, res); + break; + case "OPTIONS": + await _options(req, res); + break; + case "DELETE": + await _delete(req, res); + break; + default: + next(); + } + } catch(err) { + next(err); } }; }