@entity-access/server-pages 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.yml +24 -0
- package/.vscode/launch.json +24 -0
- package/.vscode/settings.json +66 -0
- package/LICENSE +201 -0
- package/README.md +2 -0
- package/package.json +39 -0
- package/src/Content.tsx +187 -0
- package/src/NotFoundPage.tsx +10 -0
- package/src/Page.tsx +217 -0
- package/src/ServerPages.ts +129 -0
- package/src/core/LocalFile.ts +92 -0
- package/src/core/RouteTree.ts +125 -0
- package/src/core/SessionUser.ts +95 -0
- package/src/core/TempFolder.ts +41 -0
- package/src/html/HtmlDocument.tsx +10 -0
- package/src/html/XNode.ts +52 -0
- package/src/index.d.ts +16 -0
- package/src/services/CookieService.ts +137 -0
- package/src/services/KeyProvider.ts +62 -0
- package/src/services/TokenService.ts +80 -0
- package/src/services/UserSessionProvider.ts +13 -0
- package/src/tests/logger/api/log/post.ts +6 -0
- package/src/tests/logger/index.tsx +20 -0
- package/test.js +12 -0
- package/tsconfig.json +31 -0
package/src/Page.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import busboy from "busboy";
|
|
2
|
+
import HtmlDocument from "./html/HtmlDocument.js";
|
|
3
|
+
import XNode from "./html/XNode.js";
|
|
4
|
+
import Content, { IPageResult, Redirect } from "./Content.js";
|
|
5
|
+
import { ServiceProvider } from "@entity-access/entity-access/dist/di/di.js";
|
|
6
|
+
import { Request } from "express";
|
|
7
|
+
import { LocalFile } from "./core/LocalFile.js";
|
|
8
|
+
import TempFolder from "./core/TempFolder.js";
|
|
9
|
+
import SessionUser from "./core/SessionUser.js";
|
|
10
|
+
|
|
11
|
+
export const isPage = Symbol("isPage");
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export interface IRouteCheck {
|
|
15
|
+
method: string;
|
|
16
|
+
current: string;
|
|
17
|
+
path: string[];
|
|
18
|
+
sessionUser: SessionUser;
|
|
19
|
+
params: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IPageContext {
|
|
23
|
+
/**
|
|
24
|
+
* path till the current folder where this page is located, including the name of current folder itself.
|
|
25
|
+
*/
|
|
26
|
+
currentPath: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Path to the next children to be precessed.
|
|
29
|
+
*/
|
|
30
|
+
childPath: string[];
|
|
31
|
+
|
|
32
|
+
// /**
|
|
33
|
+
// * List of all paths that were tried before executing this page.
|
|
34
|
+
// */
|
|
35
|
+
// notFoundPath: string[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Query string if associated, empty object is always present.
|
|
39
|
+
*/
|
|
40
|
+
query: any;
|
|
41
|
+
|
|
42
|
+
body: any;
|
|
43
|
+
|
|
44
|
+
url: string;
|
|
45
|
+
|
|
46
|
+
signal:AbortSignal;
|
|
47
|
+
/**
|
|
48
|
+
* Request
|
|
49
|
+
*/
|
|
50
|
+
// request: Request;
|
|
51
|
+
/**
|
|
52
|
+
* Response
|
|
53
|
+
*/
|
|
54
|
+
// response: Response;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Request method
|
|
58
|
+
*/
|
|
59
|
+
method: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Currently logged in user
|
|
63
|
+
*/
|
|
64
|
+
sessionUser: SessionUser;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Actual file path of the page
|
|
68
|
+
*/
|
|
69
|
+
filePath: string;
|
|
70
|
+
|
|
71
|
+
disposables: Disposable[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IFormData {
|
|
75
|
+
fields: { [key: string]: string};
|
|
76
|
+
files: LocalFile[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Page should not contain any reference to underlying request/response objects.
|
|
81
|
+
*/
|
|
82
|
+
export default class Page implements IPageContext {
|
|
83
|
+
|
|
84
|
+
static [isPage] = true;
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* This static method determines if the path can be handled by this page or not.
|
|
89
|
+
* @param pageContext page related items
|
|
90
|
+
* @returns true if it can handle the path, default is true
|
|
91
|
+
*/
|
|
92
|
+
static canHandle(pageContext: IRouteCheck) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
signal: AbortSignal;
|
|
97
|
+
|
|
98
|
+
currentPath: string[];
|
|
99
|
+
|
|
100
|
+
childPath: string[];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List of all paths that were tried before executing this page.
|
|
104
|
+
*/
|
|
105
|
+
notFoundPath: string[];
|
|
106
|
+
|
|
107
|
+
query: any;
|
|
108
|
+
|
|
109
|
+
body: any;
|
|
110
|
+
|
|
111
|
+
url: string;
|
|
112
|
+
|
|
113
|
+
// request: Request;
|
|
114
|
+
|
|
115
|
+
// response: Response;
|
|
116
|
+
|
|
117
|
+
method: string;
|
|
118
|
+
|
|
119
|
+
sessionUser: SessionUser;
|
|
120
|
+
|
|
121
|
+
filePath: string;
|
|
122
|
+
|
|
123
|
+
cacheControl: string;
|
|
124
|
+
|
|
125
|
+
disposables: Disposable[] = [];
|
|
126
|
+
|
|
127
|
+
private formDataPromise;
|
|
128
|
+
|
|
129
|
+
constructor() {
|
|
130
|
+
this.cacheControl = "no-cache, no-store, max-age=0";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
all(params: any): IPageResult | Promise<IPageResult> {
|
|
134
|
+
return this.notFound();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
readFormData(): Promise<IFormData> {
|
|
138
|
+
this.formDataPromise ??= (async () => {
|
|
139
|
+
let tempFolder: TempFolder;
|
|
140
|
+
const result: IFormData = {
|
|
141
|
+
fields: {},
|
|
142
|
+
files: []
|
|
143
|
+
};
|
|
144
|
+
const req = (this as any).req as Request;
|
|
145
|
+
const bb = busboy({ headers: req.headers , defParamCharset: "utf8" });
|
|
146
|
+
const tasks = [];
|
|
147
|
+
await new Promise((resolve, reject) => {
|
|
148
|
+
|
|
149
|
+
bb.on("field", (name, value) => {
|
|
150
|
+
result.fields[name] = value;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
bb.on("file", (name, file, info) => {
|
|
154
|
+
if(!tempFolder) {
|
|
155
|
+
tempFolder = new TempFolder();
|
|
156
|
+
this.disposables.push(tempFolder);
|
|
157
|
+
}
|
|
158
|
+
const tf = tempFolder.get(info.filename, info.mimeType);
|
|
159
|
+
tasks.push(tf.writeAll(file).then(() => {
|
|
160
|
+
result.files.push(tf);
|
|
161
|
+
}));
|
|
162
|
+
});
|
|
163
|
+
bb.on("error", reject);
|
|
164
|
+
bb.on("close", resolve);
|
|
165
|
+
req.pipe(bb);
|
|
166
|
+
});
|
|
167
|
+
await Promise.all(tasks);
|
|
168
|
+
return result;
|
|
169
|
+
})();
|
|
170
|
+
return this.formDataPromise;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
protected content(body: string, status = 200, contentType = "text/html") {
|
|
175
|
+
return Content.create({ body, status, contentType });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
protected json(o: any, indent = 0) {
|
|
179
|
+
const content = indent
|
|
180
|
+
? JSON.stringify(o, undefined, indent)
|
|
181
|
+
: JSON.stringify(o);
|
|
182
|
+
return this.content(content, 200, "application/json");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected redirect(location: string) {
|
|
186
|
+
return new Redirect(location);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
protected notFound(): Content | Promise<Content> {
|
|
190
|
+
return Content.html(<HtmlDocument>
|
|
191
|
+
<head>
|
|
192
|
+
<title>Not found</title>
|
|
193
|
+
</head>
|
|
194
|
+
<body>
|
|
195
|
+
The page you are looking for is not found.
|
|
196
|
+
<pre>{this.url} not found</pre>
|
|
197
|
+
</body>
|
|
198
|
+
</HtmlDocument>,
|
|
199
|
+
404
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
protected serverError(error, status = 500): Content | Promise<Content> {
|
|
204
|
+
return Content.create({
|
|
205
|
+
body: <HtmlDocument>
|
|
206
|
+
<head>
|
|
207
|
+
<title>Server Error</title>
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
There was an error processing you request.
|
|
211
|
+
<pre>{error.stack ?? error}</pre>
|
|
212
|
+
</body>
|
|
213
|
+
</HtmlDocument>,
|
|
214
|
+
status
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { RegisterSingleton, ServiceProvider } from "@entity-access/entity-access/dist/di/di.js";
|
|
3
|
+
import express, { Request, Response } from "express";
|
|
4
|
+
import Page from "./Page.js";
|
|
5
|
+
import Content from "./Content.js";
|
|
6
|
+
import SessionUser from "./core/SessionUser.js";
|
|
7
|
+
import RouteTree from "./core/RouteTree.js";
|
|
8
|
+
import CookieService from "./services/CookieService.js";
|
|
9
|
+
import cookieParser from "cookie-parser";
|
|
10
|
+
import bodyParser from "body-parser";
|
|
11
|
+
|
|
12
|
+
RegisterSingleton
|
|
13
|
+
export default class ServerPages {
|
|
14
|
+
|
|
15
|
+
public static create(globalServiceProvider: ServiceProvider = new ServiceProvider()) {
|
|
16
|
+
const sp = globalServiceProvider.create(ServerPages);
|
|
17
|
+
return sp;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private root: RouteTree = new RouteTree();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* We will register all sub folders starting with given path.
|
|
24
|
+
* @param folder string
|
|
25
|
+
* @param start string
|
|
26
|
+
*/
|
|
27
|
+
public registerRoutes(folder: string, start: string = "/") {
|
|
28
|
+
const startRoute = start.split("/").filter((x) => x);
|
|
29
|
+
let root = this.root;
|
|
30
|
+
for (const iterator of startRoute) {
|
|
31
|
+
root = root.getOrCreate(iterator);
|
|
32
|
+
}
|
|
33
|
+
root.register(folder);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* All services should be registered before calling build
|
|
38
|
+
* @param app Express App
|
|
39
|
+
*/
|
|
40
|
+
public build(app = express()) {
|
|
41
|
+
try {
|
|
42
|
+
const cookieService = ServiceProvider.resolve(this, CookieService);
|
|
43
|
+
|
|
44
|
+
// etag must be set by individual request processors if needed.
|
|
45
|
+
app.set("etag", false);
|
|
46
|
+
|
|
47
|
+
app.use(cookieParser());
|
|
48
|
+
app.use((req, res, next) => cookieService.createSessionUser(req, res).then(next, next));
|
|
49
|
+
|
|
50
|
+
app.use(bodyParser.json());
|
|
51
|
+
|
|
52
|
+
app.all(/./, (req, res, next) => this.process(req, res).then(next, next));
|
|
53
|
+
return app;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected async process(req: Request, resp: Response) {
|
|
60
|
+
|
|
61
|
+
if((req as any).processed) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
(req as any).processed = true;
|
|
65
|
+
|
|
66
|
+
// defaulting to no cache
|
|
67
|
+
// static content delivery should override this
|
|
68
|
+
resp.setHeader("cache-control", "no-cache");
|
|
69
|
+
|
|
70
|
+
using scope = ServiceProvider.createScope(this);
|
|
71
|
+
let sent = false;
|
|
72
|
+
const acceptJson = req.accepts().some((s) => /\/json$/i.test(s));
|
|
73
|
+
try {
|
|
74
|
+
const sessionUser = (req as any).user;
|
|
75
|
+
scope.add(SessionUser, sessionUser);
|
|
76
|
+
const path = req.path.substring(1).split("/");
|
|
77
|
+
const method = req.method;
|
|
78
|
+
const params = { ... req.params, ... req.query, ... req.body ?? {} };
|
|
79
|
+
const { pageClass, childPath } = (await this.root.getRoute({
|
|
80
|
+
method,
|
|
81
|
+
current: "",
|
|
82
|
+
path,
|
|
83
|
+
params,
|
|
84
|
+
sessionUser
|
|
85
|
+
})) ?? {
|
|
86
|
+
pageClass: Page,
|
|
87
|
+
childPath: path
|
|
88
|
+
};
|
|
89
|
+
const page = scope.create(pageClass);
|
|
90
|
+
page.method = method;
|
|
91
|
+
page.childPath = childPath;
|
|
92
|
+
page.body = req.body;
|
|
93
|
+
page.query = req.query;
|
|
94
|
+
page.sessionUser = sessionUser;
|
|
95
|
+
(page as any).req = req;
|
|
96
|
+
(page as any).res = resp;
|
|
97
|
+
const content = await page.all(params);
|
|
98
|
+
resp.setHeader("cache-control", page.cacheControl);
|
|
99
|
+
resp.removeHeader("etag");
|
|
100
|
+
sent = true;
|
|
101
|
+
await content.send(resp);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!sent) {
|
|
104
|
+
try {
|
|
105
|
+
|
|
106
|
+
if (acceptJson || error.errorModel) {
|
|
107
|
+
await Content.json(
|
|
108
|
+
{
|
|
109
|
+
... error.errorModel ?? {},
|
|
110
|
+
message: error.message ?? error,
|
|
111
|
+
detail: error.stack ?? error,
|
|
112
|
+
}
|
|
113
|
+
, 500).send(resp);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const content = Content.html(`<!DOCTYPE html>\n<html><body><pre>Server Error for ${req.url}\r\n${error?.stack ?? error}</pre></body></html>`, 500);
|
|
118
|
+
await content.send(resp);
|
|
119
|
+
} catch (e1) {
|
|
120
|
+
resp.send(e1.stack ?? e1);
|
|
121
|
+
console.error(e1);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.error(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream, existsSync, statSync } from "fs";
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import mime from "mime-types";
|
|
4
|
+
import internal, { Stream } from "stream";
|
|
5
|
+
import { appendFile, open, readFile, writeFile } from "fs/promises";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export class LocalFile {
|
|
9
|
+
|
|
10
|
+
public readonly contentType: string;
|
|
11
|
+
|
|
12
|
+
public readonly fileName: string;
|
|
13
|
+
|
|
14
|
+
public get exists() {
|
|
15
|
+
return existsSync(this.path);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public get contentSize() {
|
|
19
|
+
if (!this.exists) {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
const s = statSync(this.path);
|
|
23
|
+
return s.size;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
constructor(public readonly path: string, name?: string, mimeType?: string, private onDispose?: () => void) {
|
|
27
|
+
this.fileName = name ?? basename(path);
|
|
28
|
+
this.contentType = (mimeType || mime.lookup(this.fileName)) || "application/octet-stream";
|
|
29
|
+
this[Symbol.asyncDispose] = onDispose;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public openRead(): Stream {
|
|
33
|
+
return createReadStream(this.path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public openReadStream(): internal.Readable {
|
|
37
|
+
return createReadStream(this.path);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public openWrite(): Stream {
|
|
41
|
+
return createWriteStream(this.path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async appendLine(line: string) {
|
|
45
|
+
await appendFile(this.path, line + "\n");
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async readAsText() {
|
|
50
|
+
return await readFile(this.path, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public async readAsBuffer() {
|
|
54
|
+
return await readFile(this.path, { flag: "r" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async delete() {
|
|
58
|
+
return this.onDispose?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public writeAllText(text: string) {
|
|
62
|
+
return writeFile(this.path, text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public writeAll(buffer: string | Buffer | internal.Readable | Stream) {
|
|
66
|
+
return writeFile(this.path, buffer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async *readBuffers(bufferSize = 16 * 1024 * 1024) {
|
|
70
|
+
const size = this.contentSize;
|
|
71
|
+
let buffer = Buffer.alloc(bufferSize);
|
|
72
|
+
for (let offset = 0; offset < size; offset += bufferSize) {
|
|
73
|
+
const length = ((offset + bufferSize) > size )
|
|
74
|
+
? (size - offset)
|
|
75
|
+
: bufferSize;
|
|
76
|
+
let fd = await open(this.path);
|
|
77
|
+
try {
|
|
78
|
+
if (buffer.length !== length) {
|
|
79
|
+
buffer = Buffer.alloc(length);
|
|
80
|
+
}
|
|
81
|
+
await fd.read({ position: offset, length, buffer });
|
|
82
|
+
await fd.close();
|
|
83
|
+
fd = null;
|
|
84
|
+
yield buffer;
|
|
85
|
+
} finally {
|
|
86
|
+
if (fd) {
|
|
87
|
+
await fd.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import Page from "../Page.js";
|
|
4
|
+
import SessionUser from "./SessionUser.js";
|
|
5
|
+
import { IRouteCheck, isPage } from "../Page.js";
|
|
6
|
+
import { pathToFileURL } from "url";
|
|
7
|
+
import Content, { IPageResult } from "../Content.js";
|
|
8
|
+
|
|
9
|
+
export interface IRouteHandler {
|
|
10
|
+
get?: Promise<typeof Page>;
|
|
11
|
+
post?: Promise<typeof Page>;
|
|
12
|
+
put?: Promise<typeof Page>;
|
|
13
|
+
patch?: Promise<typeof Page>;
|
|
14
|
+
delete?: Promise<typeof Page>;
|
|
15
|
+
head?: Promise<typeof Page>;
|
|
16
|
+
index?: Promise<typeof Page>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type IPageRoute = { default: typeof Page };
|
|
20
|
+
|
|
21
|
+
export default class RouteTree {
|
|
22
|
+
|
|
23
|
+
private children = new Map<string,RouteTree>();
|
|
24
|
+
|
|
25
|
+
private handler: IRouteHandler;
|
|
26
|
+
|
|
27
|
+
constructor(public readonly path: string = "/") {
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getOrCreate(name: string): RouteTree {
|
|
32
|
+
let child = this.children.get(name);
|
|
33
|
+
if (!child) {
|
|
34
|
+
child = new RouteTree(this.path + name + "/");
|
|
35
|
+
this.children.set(name, child);
|
|
36
|
+
}
|
|
37
|
+
return child;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async getRoute(rc: IRouteCheck): Promise<{ pageClass: typeof Page, childPath: string[] }> {
|
|
41
|
+
const { method, path: [current, ... rest] } = rc;
|
|
42
|
+
const childRouteCheck = { ... rc, current, path: rest };
|
|
43
|
+
const childRoute = this.children.get(current);
|
|
44
|
+
if (childRoute) {
|
|
45
|
+
const nested = await childRoute.getRoute(childRouteCheck);
|
|
46
|
+
if (nested) {
|
|
47
|
+
return nested;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!this.handler) {
|
|
52
|
+
// we will not be able to handle this route
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pageClassPromise = this.handler[method] ?? this.handler["index"];
|
|
57
|
+
if (pageClassPromise) {
|
|
58
|
+
const pageClass = await pageClassPromise;
|
|
59
|
+
if(pageClass.canHandle(rc)) {
|
|
60
|
+
return { pageClass, childPath: rc.path };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public register(folder: string) {
|
|
66
|
+
|
|
67
|
+
for (const iterator of readdirSync(folder, { withFileTypes: true , recursive: false})) {
|
|
68
|
+
if (iterator.isDirectory()) {
|
|
69
|
+
const rt = this.getOrCreate(iterator.name);
|
|
70
|
+
rt.register(folder + "/" + iterator.name);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!iterator.name.endsWith(".js")) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let name: keyof IRouteHandler;
|
|
79
|
+
|
|
80
|
+
switch(iterator.name) {
|
|
81
|
+
case "index.js":
|
|
82
|
+
name = "index";
|
|
83
|
+
break;
|
|
84
|
+
case "get.js":
|
|
85
|
+
name = "get";
|
|
86
|
+
break;
|
|
87
|
+
case "post.js":
|
|
88
|
+
name = "post";
|
|
89
|
+
break;
|
|
90
|
+
case "put.js":
|
|
91
|
+
name = "put";
|
|
92
|
+
break;
|
|
93
|
+
case "patch.js":
|
|
94
|
+
name = "patch";
|
|
95
|
+
break;
|
|
96
|
+
case "head.js":
|
|
97
|
+
name = "head";
|
|
98
|
+
break;
|
|
99
|
+
case "delete.js":
|
|
100
|
+
name = "delete";
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const handler = pathToFileURL(join(folder, iterator.name)).toString();
|
|
105
|
+
console.log(`Registering Route ${this.path} with ${handler}`);
|
|
106
|
+
|
|
107
|
+
const promise = (async () => {
|
|
108
|
+
const { default: pageClass } = await import(handler);
|
|
109
|
+
if (pageClass[isPage]) {
|
|
110
|
+
return pageClass as typeof Page;
|
|
111
|
+
}
|
|
112
|
+
return class extends Page {
|
|
113
|
+
async all(params: any) {
|
|
114
|
+
const r = await pageClass.call(this, params);
|
|
115
|
+
return Content.create(r);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
(this.handler ??= { [name]: promise});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Request, Response } from "express";
|
|
2
|
+
import EntityAccessError from "@entity-access/entity-access/dist/common/EntityAccessError.js";
|
|
3
|
+
import { RegisterScoped } from "@entity-access/entity-access/dist/di/di.js";
|
|
4
|
+
import DateTime from "@entity-access/entity-access/dist/types/DateTime.js";
|
|
5
|
+
import TokenService, { IAuthCookie } from "../services/TokenService.js";
|
|
6
|
+
|
|
7
|
+
const secure = (process.env["SOCIAL_MAIL_AUTH_COOKIE_SECURE"] ?? "true") === "true";
|
|
8
|
+
|
|
9
|
+
export type roles = "Administrator" | "Contributor" | "Reader" | "Guest";
|
|
10
|
+
|
|
11
|
+
@RegisterScoped
|
|
12
|
+
export default class SessionUser {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SessionID saved in database for current session.
|
|
16
|
+
*/
|
|
17
|
+
sessionID: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* UserID
|
|
21
|
+
*/
|
|
22
|
+
userID?: number;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Logged in user name
|
|
26
|
+
*/
|
|
27
|
+
userName?: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Application Roles, user is associated with.
|
|
31
|
+
*/
|
|
32
|
+
roles?: string[];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Expiry date, after which this session is invalid
|
|
36
|
+
*/
|
|
37
|
+
expiry?: Date;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* If set to true, session is no longer valid.
|
|
41
|
+
*/
|
|
42
|
+
invalid?: boolean;
|
|
43
|
+
|
|
44
|
+
ipAddress: string;
|
|
45
|
+
|
|
46
|
+
get isAdmin() {
|
|
47
|
+
return this.roles?.includes("Administrator") ?? false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private resp: Response,
|
|
52
|
+
private cookieName: string,
|
|
53
|
+
private tokenService: TokenService
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
isInRole(role: roles) {
|
|
57
|
+
return this.roles?.includes(role) ?? false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ensureLoggedIn() {
|
|
61
|
+
if (!this.userName) {
|
|
62
|
+
throw new EntityAccessError();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ensureRole(role: roles) {
|
|
67
|
+
if (this.isInRole(role)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new EntityAccessError();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ensureIsAdmin() {
|
|
74
|
+
return this.ensureRole("Administrator");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async setAuthCookie(authCookie: Omit<IAuthCookie, "sign">) {
|
|
78
|
+
const cookie = await this.tokenService.getAuthToken(authCookie);
|
|
79
|
+
const maxAge = ((authCookie.expiry ? DateTime.from(authCookie.expiry) : null) ?? DateTime.now.addDays(30)).diff(DateTime.now).totalMilliseconds;
|
|
80
|
+
this.resp.cookie(
|
|
81
|
+
cookie.cookieName,
|
|
82
|
+
cookie.cookie, {
|
|
83
|
+
secure,
|
|
84
|
+
httpOnly: true,
|
|
85
|
+
maxAge
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
clearAuthCookie() {
|
|
90
|
+
this.resp.cookie(this.cookieName, "{}", {
|
|
91
|
+
secure,
|
|
92
|
+
httpOnly: true
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, rmdirSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { LocalFile } from "./LocalFile.js";
|
|
6
|
+
|
|
7
|
+
const doNothing = () => void 0;
|
|
8
|
+
|
|
9
|
+
const tmpdir = os.tmpdir();
|
|
10
|
+
|
|
11
|
+
const tmpFolder = join(tmpdir, "tmp-folders");
|
|
12
|
+
|
|
13
|
+
if (!existsSync(tmpFolder)) {
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(tmpFolder, { recursive: true });
|
|
16
|
+
} catch {
|
|
17
|
+
// ignore
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let id = 1;
|
|
22
|
+
|
|
23
|
+
export default class TempFolder implements Disposable {
|
|
24
|
+
|
|
25
|
+
constructor(public readonly folder = join(tmpFolder, `tf-${id++}`)) {
|
|
26
|
+
mkdirSync(folder);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(name, mimeType?: string, keep = false) {
|
|
30
|
+
return new LocalFile(join(this.folder, name), name, mimeType, keep ? doNothing : void 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
[Symbol.dispose]() {
|
|
34
|
+
try {
|
|
35
|
+
rmSync(this.folder, { recursive: true, force: true, maxRetries: 10, retryDelay: 10000});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.warn(error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|