Persist resources on disk
This commit is contained in:
parent
7ba432faa9
commit
4eed204572
3
.gitignore
vendored
3
.gitignore
vendored
@ -104,5 +104,8 @@ dist
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Default data directory
|
||||
data
|
||||
|
||||
.DS_Store
|
||||
dist
|
||||
|
||||
10
src/index.ts
10
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 :)");
|
||||
|
||||
51
src/repository.ts
Normal file
51
src/repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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<void>;
|
||||
|
||||
export function restHandler(_resName: string): RequestHandler {
|
||||
const resources: Record<string, Resource> = {};
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user