From d7ebb30e05da107025d3e77f246f09ded77da686 Mon Sep 17 00:00:00 2001
From: David Sehnal <david.sehnal@gmail.com>
Date: Sat, 8 Feb 2020 16:26:29 +0100
Subject: [PATCH] servers/plugin-state

---
 README.md                             |   1 +
 package-lock.json                     | Bin 764447 -> 765664 bytes
 package.json                          |   3 +
 src/servers/model/server/api-local.ts |  15 +-
 src/servers/plugin-state/index.ts     | 242 ++++++++++++++++++++++++++
 5 files changed, 247 insertions(+), 14 deletions(-)
 create mode 100644 src/servers/plugin-state/index.ts

diff --git a/README.md b/README.md
index d5dd8c109..91d48e5e5 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ Moreover, the project contains the imlementation of `servers`, including
 
 - `servers/model` A tool for accessing coordinate and annotation data of molecular structures.
 - `servers/volume` A tool for accessing volumetric experimental data related to molecular structures.
+- `servers/plugin-state` A basic server to store Mol* Plugin states.
 
 The project also contains performance tests (`perf-tests`), `examples`, and basic proof of concept `apps` (CIF to BinaryCIF converter and JSON domain annotation to CIF converter).
 
diff --git a/package-lock.json b/package-lock.json
index 3cc465afa6de76dc269c4ad9dab41dd3bfd21d0c..9c313aac8c467562d01a37da6bac6b63406507e1 100644
GIT binary patch
delta 711
zcmZWkUr1AN6z1OFJ?(C0twAug2a}9I=G|<1UHITO-Q9F;>gIGp(*EhDb90+pv$D*B
zK@bR%M*}UXm!JoW;wKuFJ@gj6)I)?NJw*>;y+jb%-0iK0kMo`L@qOo<+i&Z)x9X>_
zF^}2xWfDrJ=N^&@(oo-wM;E||)hSkSusF>KI$^Kq_7w9isO^m<a_+HYreEKd3XYrW
zLhTKcNxRe=w+X@c<dB%K=-ravVu{%s#85iq8*6lp*#ZNj&Mu=jV2DbANG3FDjWx!7
z&iG{5)nUz#dfgseS!G-C^<7qhJxgpYU0-ImzhLVusi7MMG6*s~b%Pv>%snDn^t=P{
zzw->gisyAY>_KNoDl4Ask<vmm5lp*HSxY3AcS+ftr+XwD7e>OioJZ)ic^q~_w9gkd
zB!<QFZhOAYpKc#=B%G3%GA5cNd$%dnHj&Q>T}}A?9$Sn0PF{h(m-zkjAIV{65Y4Yt
zm2}_-*)7M}n|#HS6t~8d7&Wt;S;T2Gr!6d+IJ#(pZ<VMX1S4Jc0?U>t?i>QZr#?tx
z>ltXKwm$GOva&Wq%JEr0c;xotRh~z^A0l$Z13zq($@EeLc&22>3tyEjxHSZS<hHtn
zDTS9KABF33&iyFF<z4z1tdx8$bY}#P^RlkXz^?3m9|k==Hv!W;j!!`oRZoGF$kK;R
zg7m-@aQ)l69Z&$yE;2kmKf>!U`H}#B%7Yr65tWj@nE{5A(@&du6<$6;s_@P#qnS^F
z71drSKJt^%7KRH@#J(aFMeuaEptKI)*&4P2UxaxL-ga;^)c0A50#<JlZGqFMD5oi_
GTKEf^qwz!l

delta 543
zcmXX@OK1~O6y?pEo6by~cFZD4O`<7<fR)A({7{Kf6hshd!D4ZWcC-f48Z{vJS!k!A
zj*+$$ialaM>%y(N5FfgbLg=D`)rH`yB_OB^rS-E?sJ_g&IB>Y<+<VXE)|PS)-simQ
z?3wcNzR+it+86YswAk|sAEJN>Z-Q?vh@ZdsAG2PFiwIX=u#kRG)qT4Hn{Ve;VdrUO
z18iss(c4E%LG7)Y!o)E;KUIz<#SVvh8EO~Q1YY&iK**98sRiz5u4AM_S&yE6>I{X2
zYt)3LA5zG<6oq?*O6WHAR!q*23h#|#;ax%2R+rf};hdl&2CQ#-i}+Zi2t#H?@5Aw7
zTB~nHCeNsz!$f3Av+z5?(r9{Yw848QXC-f{7VMLh#j38vF<7Ef(6y;TJH?)}bcUg8
zoTaDoSwnn1M;o=!xAzkL4Shwj#iPsQ1wukNqjZ>0R~ypERrwu^kI22<rwko)zF|#w
z@@BY4=^!R=Q#&#hd8IdKG~-FJF$H%<+g*>c9p%@&9bT)p1#?wSFt3sYb65^pJj9|X
zcPY`j&p}~S_O9JymiX?|X&y#fcR(|+xS1z0`&7Dv+q_%6&CiaJL`0%NLKE3BeZTYn
W&!6-T8<`tg+@FsbVm{WGy!#IaLbv<?

diff --git a/package.json b/package.json
index f6d8ef285..9f4a69ce9 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "model-server": "node lib/servers/model/server.js",
     "model-server-watch": "nodemon --watch lib lib/servers/model/server.js",
     "volume-server": "node lib/servers/volume/server.js --idMap em 'test/${id}.mdb' --defaultPort 1336",
+    "plugin-state": "node lib/servers/plugin-state/index.js",
     "preversion": "npm run test",
     "postversion": "git push && git push --tags",
     "prepublishOnly": "npm run test && npm run build"
@@ -70,6 +71,7 @@
     "@graphql-codegen/typescript-graphql-files-modules": "^1.11.2",
     "@graphql-codegen/typescript-graphql-request": "^1.11.2",
     "@graphql-codegen/typescript-operations": "^1.11.2",
+    "@types/cors": "^2.8.6",
     "@typescript-eslint/eslint-plugin": "^2.17.0",
     "@typescript-eslint/eslint-plugin-tslint": "^2.17.0",
     "@typescript-eslint/parser": "^2.17.0",
@@ -112,6 +114,7 @@
     "argparse": "^1.0.10",
     "body-parser": "^1.19.0",
     "compression": "^1.7.4",
+    "cors": "^2.8.5",
     "express": "^4.17.1",
     "graphql": "^14.5.8",
     "immutable": "^3.8.2",
diff --git a/src/servers/model/server/api-local.ts b/src/servers/model/server/api-local.ts
index 5b27b9859..31aca674e 100644
--- a/src/servers/model/server/api-local.ts
+++ b/src/servers/model/server/api-local.ts
@@ -13,6 +13,7 @@ import { StructureCache } from './structure-wrapper';
 import { now } from '../../../mol-util/now';
 import { PerformanceMonitor } from '../../../mol-util/performance-monitor';
 import { QueryName } from './api';
+import { makeDir } from '../../../mol-util/make-dir';
 
 export type LocalInput = {
     input: string,
@@ -103,18 +104,4 @@ export function wrapFileToWriter(fn: string) {
     };
 
     return w;
-}
-
-function makeDir(path: string, root?: string): boolean {
-    let dirs = path.split(/\/|\\/g),
-        dir = dirs.shift();
-
-    root = (root || '') + dir + '/';
-
-    try { fs.mkdirSync(root); }
-    catch (e) {
-        if (!fs.statSync(root).isDirectory()) throw new Error(e);
-    }
-
-    return !dirs.length || makeDir(dirs.join('/'), root);
 }
\ No newline at end of file
diff --git a/src/servers/plugin-state/index.ts b/src/servers/plugin-state/index.ts
new file mode 100644
index 000000000..0c37c4959
--- /dev/null
+++ b/src/servers/plugin-state/index.ts
@@ -0,0 +1,242 @@
+/**
+ * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as express from 'express'
+import * as compression from 'compression'
+import * as cors from 'cors'
+import * as bodyParser from 'body-parser'
+import * as argparse from 'argparse'
+import * as fs from 'fs'
+import * as path from 'path'
+import { makeDir } from '../../mol-util/make-dir'
+
+interface Config {
+    working_folder: string,
+    port?: string | number,
+    app_prefix: string,
+    max_states: number
+}
+
+const cmdParser = new argparse.ArgumentParser({
+    addHelp: true
+});
+cmdParser.addArgument(['--working-folder'], { help: 'Working forlder path.', required: true });
+cmdParser.addArgument(['--port'], { help: 'Server port. Altenatively use ENV variable PORT.', type: 'int', required: false });
+cmdParser.addArgument(['--app-prefix'], { help: 'Server app prefix.', defaultValue: '', required: false });
+cmdParser.addArgument(['--max-states'], { help: 'Maxinum number of states that could be saved.', defaultValue: 40, type: 'int', required: false });
+
+const Config = cmdParser.parseArgs() as Config;
+
+if (!Config.port) Config.port = process.env.port || 1339;
+
+const app = express();
+app.use(compression(<any>{ level: 6, memLevel: 9, chunkSize: 16 * 16384, filter: () => true }));
+app.use(cors({ methods: ['GET', 'PUT'] }));
+app.use(bodyParser.json({ limit: '20mb' }));
+
+type Index = { timestamp: number, id: string, name: string, description: string, isSticky?: boolean }[]
+
+function createIndex() {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (fs.existsSync(fn)) return;
+    if (!fs.existsSync(Config.working_folder)) makeDir(Config.working_folder);
+    fs.writeFileSync(fn, '[]', 'utf-8');
+}
+
+function writeIndex(index: Index) {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (!fs.existsSync(Config.working_folder)) makeDir(Config.working_folder);
+    fs.writeFileSync(fn, JSON.stringify(index, null, 2), 'utf-8');
+}
+
+function readIndex() {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (!fs.existsSync(fn)) return [];
+    return JSON.parse(fs.readFileSync(fn, 'utf-8')) as Index;
+}
+
+function validateIndex(index: Index) {
+    if (index.length > Config.max_states) {
+        const deletes: Index = [], newIndex: Index = [];
+        const toDelete = index.length - Config.max_states; 
+        
+        for (const e of index) {
+            if (!e.isSticky && deletes.length < toDelete) {
+                deletes.push(e);
+            } else {
+                newIndex.push(e);
+            }
+        }
+        // index.slice(0, index.length - 30);
+        for (const d of deletes) {
+            try {
+                fs.unlinkSync(path.join(Config.working_folder, d.id + '.json'))
+            } catch { }
+        }
+        return newIndex;
+    }
+    return index;
+}
+
+function remove(id: string) {
+    let index = readIndex();
+    let i = 0;
+    for (const e of index) {
+        if (e.id !== id) {            
+            i++;
+            continue;
+        }
+        try {
+            for (let j = i + 1; j < index.length; j++) {
+                index[j - 1] = index[j];
+            }
+            index.pop();
+            writeIndex(index);
+        } catch { }
+        try {
+            fs.unlinkSync(path.join(Config.working_folder, e.id + '.json'))
+        } catch { }
+        return;
+    }
+}
+
+function clear() {
+    let index = readIndex();
+    for (const e of index) {
+        try {
+            fs.unlinkSync(path.join(Config.working_folder, e.id + '.json'))
+        } catch { }
+    }
+    writeIndex([]);
+}
+
+export function createv4() {
+    let d = (+new Date());
+    const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+        const r = (d + Math.random()*16)%16 | 0;
+        d = Math.floor(d/16);
+        return (c==='x' ? r : (r&0x3|0x8)).toString(16);
+    });
+    return uuid.toLowerCase();
+}
+
+function mapPath(path: string) {
+    if (!Config.app_prefix) return path;
+    return `/${Config.app_prefix}/${path}`;
+}
+
+app.get(mapPath(`/get/:id`), (req, res) => {
+    const id: string = req.params.id || '';
+    console.log('Reading', id);
+    if (id.length === 0 || id.indexOf('.') >= 0 || id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) {
+        res.status(404);
+        res.end();
+        return;
+    }
+
+    fs.readFile(path.join(Config.working_folder, id + '.json'), 'utf-8', (err, data) => {
+        if (err) {
+            res.status(404);
+            res.end();
+            return;
+        }
+
+        res.writeHead(200, {
+            'Content-Type': 'application/json; charset=utf-8',
+        });
+        res.write(data);
+        res.end();
+    });
+});
+
+app.get(mapPath(`/clear`), (req, res) => {
+    clear();
+    res.status(200);
+    res.end();
+});
+
+app.get(mapPath(`/remove/:id`), (req, res) => {
+    remove((req.params.id as string || '').toLowerCase());
+    res.status(200);
+    res.end();
+});
+
+app.get(mapPath(`/latest`), (req, res) => {
+    const index = readIndex();
+    const id: string = index.length > 0 ? index[index.length - 1].id : '';
+    console.log('Reading', id);
+    if (id.length === 0 || id.indexOf('.') >= 0 || id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) {
+        res.status(404);
+        res.end();
+        return;
+    }
+
+    fs.readFile(path.join(Config.working_folder, id + '.json'), 'utf-8', (err, data) => {
+        if (err) {
+            res.status(404);
+            res.end();
+            return;
+        }
+
+        res.writeHead(200, {
+            'Content-Type': 'application/json; charset=utf-8',
+        });
+        res.write(data);
+        res.end();
+    });
+});
+
+app.get(mapPath(`/list`), (req, res) => {
+    const index = readIndex();
+    res.writeHead(200, {
+        'Content-Type': 'application/json; charset=utf-8',
+    });
+    res.write(JSON.stringify(index, null, 2));
+    res.end();
+});
+
+app.post(mapPath(`/set`), (req, res) => {
+    console.log('SET', req.query.name, req.query.description);
+    const index = readIndex();
+    validateIndex(index);
+
+    const name = (req.query.name as string || new Date().toUTCString()).substr(0, 50);
+    const description = (req.query.description as string || '').substr(0, 100);
+
+    index.push({ timestamp: +new Date(), id: createv4(), name, description });
+    const entry = index[index.length - 1];
+
+    const data = JSON.stringify({
+        id: entry.id,
+        name,
+        description,
+        data: req.body
+    });
+
+    fs.writeFile(path.join(Config.working_folder, entry.id + '.json'), data, { encoding: 'utf8' }, () => res.end());
+    writeIndex(index);
+});
+
+app.get(`*`, (req, res) => {
+    res.writeHead(200, {
+        'Content-Type': 'text/plain; charset=utf-8'
+    });
+    res.write(`
+GET /list
+GET /get/:id
+GET /remove/:id
+GET /latest
+POST /set?name=...&description=... [JSON data]
+`);
+    res.end();
+})
+
+createIndex();
+app.listen(Config.port);
+
+console.log(`Mol* PluginState Server`);
+console.log('');
+console.log(JSON.stringify(Config, null, 2));
\ No newline at end of file
-- 
GitLab