Persist resources on disk
Some checks failed
Lint / lint (push) Has been cancelled
Test & Build / test (push) Has been cancelled

This commit is contained in:
Tims777 2024-09-28 21:12:33 +02:00
parent 7ba432faa9
commit 4eed204572
4 changed files with 101 additions and 47 deletions

3
.gitignore vendored
View File

@ -104,5 +104,8 @@ dist
# TernJS port file # TernJS port file
.tern-port .tern-port
# Default data directory
data
.DS_Store .DS_Store
dist dist

View File

@ -4,6 +4,7 @@ import http from "http";
import { Server as SocketIO } from "socket.io"; import { Server as SocketIO } from "socket.io";
import { restHandler } from "./restHandler"; import { restHandler } from "./restHandler";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import { FileStorage } from "./repository";
type UserToFollow = { type UserToFollow = {
socketId: string; socketId: string;
@ -32,8 +33,13 @@ app.use(express.static("public"));
app.use(bodyParser.raw({ limit: "10mb" })); app.use(bodyParser.raw({ limit: "10mb" }));
app.use("/scene/*", restHandler("scene")); const dataDir = process.env.DATA_DIR || "./data";
app.use("/file/*", restHandler("file"));
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) => { app.get("/", (req, res) => {
res.send("Excalidraw collaboration server is up :)"); res.send("Excalidraw collaboration server is up :)");

51
src/repository.ts Normal file
View File

@ -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<T = Resource> {
get(id: string): Promise<T | null>;
put(id: string, resource: T): Promise<void>;
delete(id: string): Promise<void>;
}
export class FileStorage implements Repository<Resource> {
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<Resource | null> {
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<void> {
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<void> {
const { dataFile, hashFile } = this.resolvePaths(id);
await fs.rm(dataFile);
await fs.rm(hashFile);
}
}

View File

@ -1,14 +1,11 @@
import type { RequestHandler } from "express"; import type { Request, RequestHandler, Response } from "express";
import { Repository } from "./repository";
interface Resource { type AsyncHandler = (req: Request, res: Response) => Promise<void>;
hash: string;
data: ArrayBuffer;
}
export function restHandler(_resName: string): RequestHandler { export function restHandler(repo: Repository): RequestHandler {
const resources: Record<string, Resource> = {};
const _put: RequestHandler = (req, res, next) => { const _put: AsyncHandler = async (req, res) => {
const resId = req.params[0]; const resId = req.params[0];
const newData = req.body as ArrayBuffer; const newData = req.body as ArrayBuffer;
const newHash = req.header("ETag"); const newHash = req.header("ETag");
@ -19,75 +16,72 @@ export function restHandler(_resName: string): RequestHandler {
return; 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."); res.status(409).send("Hash mismatch. Update denied.");
return; return;
} }
resources[resId] = { await repo.put(resId, {
hash: newHash, hash: newHash,
data: newData, data: newData,
}; });
res.status(200).send(`Resource with ID ${resId} has been upserted.`); 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 resId = req.params[0];
const oldHash = req.header("If-None-Match"); const oldHash = req.header("If-None-Match");
if (!resources[resId]) { const resource = await repo.get(resId);
if (!resource) {
res.status(404).send("Not found."); res.status(404).send("Not found.");
return; return;
} }
if (resources[resId] && resources[resId].hash == oldHash) { if (resource.hash == oldHash) {
res.status(304).send("Not modified."); res.status(304).send("Not modified.");
return; return;
} }
// TODO: Respect If-None-Match header
const resource = resources[resId];
res.header("ETag", resource.hash).header("Content-Type", "application/octet-stream"); res.header("ETag", resource.hash).header("Content-Type", "application/octet-stream");
res.status(200).send(resource.data); res.status(200).send(resource.data);
next();
}; };
const _options: RequestHandler = (req, res, next) => { const _options: AsyncHandler = async (_req, res) => {
const resId = req.params[0];
delete resources[resId];
res.status(200).end(); res.status(200).end();
next();
}; };
const _delete: RequestHandler = (req, res, next) => { const _delete: AsyncHandler = async (req, res) => {
const resId = req.params[0]; const resId = req.params[0];
delete resources[resId]; await repo.delete(resId);
res.status(200).end(); res.status(200).end();
next();
}; };
return (req, res, next) => { return async (req, res, next) => {
try {
res.header("Access-Control-Allow-Origin", process.env.CORS_ORIGIN || "*"); 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-Methods", "GET, PUT, DELETE, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, ETag, If-Match, If-None-Match"); res.header("Access-Control-Allow-Headers", "Content-Type, ETag, If-Match, If-None-Match");
switch (req.method) { switch (req.method) {
case "GET": case "GET":
_get(req, res, next); await _get(req, res);
break; break;
case "PUT": case "PUT":
_put(req, res, next); await _put(req, res);
break; break;
case "OPTIONS": case "OPTIONS":
_options(req, res, next); await _options(req, res);
break; break;
case "DELETE": case "DELETE":
_delete(req, res, next); await _delete(req, res);
break; break;
default: default:
next(); next();
} }
} catch(err) {
next(err);
}
}; };
} }