@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/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
+ }
@@ -0,0 +1,10 @@
1
+ import XNode from "./XNode.js";
2
+
3
+ export default function HtmlDocument({ lang = "en"}, ... nodes: (XNode | string)[]): XNode {
4
+ return XNode.create("", { },
5
+ "<!DOCTYPE html>",
6
+ <html lang={lang}>
7
+ { ... nodes}
8
+ </html>
9
+ );
10
+ }