nexus-shared 3.1.0 → 4.0.0
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/CHANGELOG.md +2268 -446
- package/LICENSE +21 -21
- package/README.md +1705 -1084
- package/dist/Client.Index.d.ts +17 -0
- package/dist/Client.Index.js +17 -0
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +871 -377
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +56 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +125 -16
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +195 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -5
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +39 -31
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +14 -2
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +107 -6
- package/dist/Components/Inputs/TreeView.js +513 -30
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +205 -61
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
- package/dist/Components/Viewers/DataTableParts.js +62 -21
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +114 -6
- package/dist/Helpers/CrudBackend.js +214 -11
- package/dist/Helpers/CrudColumns.d.ts +98 -0
- package/dist/Helpers/CrudColumns.js +160 -0
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +130 -6
- package/dist/Helpers/CrudHelpers.js +304 -12
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +78 -0
- package/dist/Helpers/FileAdapter.js +102 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +162 -71
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +47 -0
- package/dist/Helpers/RequestCache.js +132 -0
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +21 -15
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +24 -3
- package/dist/Helpers/TableHelpers.js +58 -12
- package/dist/Helpers/TableJoins.d.ts +83 -0
- package/dist/Helpers/TableJoins.js +284 -0
- package/dist/Helpers/TableStore.d.ts +26 -1
- package/dist/Helpers/TableStore.js +73 -7
- package/dist/Helpers/TreeStore.d.ts +306 -11
- package/dist/Helpers/TreeStore.js +1048 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +393 -29
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +39 -3
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +160 -5
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +80 -0
- package/dist/Services/JoinSources.js +150 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +20 -0
- package/dist/Shared.Index.js +20 -0
- package/package.json +92 -85
- package/src/Styles/Nexus.Avatar.css +80 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +186 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +359 -343
- package/src/Styles/Nexus.Crud.css +542 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +733 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +282 -279
- package/src/Styles/Nexus.Index.css +41 -37
- package/src/Styles/Nexus.Input.css +238 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +246 -237
- package/src/Styles/Nexus.Popup.css +800 -724
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +497 -432
- package/src/Styles/Nexus.Table.css +98 -35
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +200 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +94 -89
- package/src/Styles/Nexus.Tree.css +530 -431
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** The most bytes a request body may have, unless the app says otherwise (`maxBodySize`): 100 MB. */
|
|
2
|
+
export declare const DEFAULT_PROXY_BODY_LIMIT: number;
|
|
3
|
+
/** How many redirects inside a module the proxy follows for one call, unless the app says otherwise (`maxRedirects`). */
|
|
4
|
+
export declare const DEFAULT_PROXY_REDIRECTS = 3;
|
|
5
|
+
/** The most of an error answer the proxy reads to see what it is. A bigger one is never passed on. */
|
|
6
|
+
export declare const ERROR_BODY_LIMIT: number;
|
|
7
|
+
/**
|
|
8
|
+
* The browser's request headers the backend may see: all of them except the ones above, and except any that the
|
|
9
|
+
* request's own `Connection` header names (those belong to this hop too).
|
|
10
|
+
*/
|
|
11
|
+
export declare function forwardRequestHeaders(incoming: Headers): Headers;
|
|
12
|
+
/**
|
|
13
|
+
* An IP address as text, without the port some proxies add; `undefined` for anything else (a host name, "unknown", an
|
|
14
|
+
* obfuscated identifier, a zone such as `%eth0`). An IPv6 address is checked by the URL parser, which every runtime has,
|
|
15
|
+
* once its characters are known to be an address's: without that, text that closes the bracket itself and goes on
|
|
16
|
+
* (`::1]/,6.6.6.6`) parsed as an address followed by a path.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* readIpAddress(" 203.0.113.7:51000 ") // "203.0.113.7"
|
|
20
|
+
* readIpAddress("[2001:db8::1]:443") // "2001:db8::1"
|
|
21
|
+
* readIpAddress("unknown") // undefined
|
|
22
|
+
* readIpAddress("::1]/,6.6.6.6") // undefined
|
|
23
|
+
*/
|
|
24
|
+
export declare function readIpAddress(value: string | null | undefined): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* This server's own origin, for a module whose root is a path on the app (a pretend backend): the host and port of the
|
|
27
|
+
* request URL, over http. Next.js builds that URL from the host and port it listens on, never from the Host header, but
|
|
28
|
+
* takes its protocol from X-Forwarded-Proto, which anyone can send, so the protocol is not read from it. A server that
|
|
29
|
+
* listens on every address (`0.0.0.0`, `::`) is called on this machine's own, and the port is always the one Next.js
|
|
30
|
+
* wrote, even when the URL leaves it out as its protocol's default.
|
|
31
|
+
*
|
|
32
|
+
* Never the app's configured origin: an app may build that from a request's Host header, and then a client would choose
|
|
33
|
+
* which port on this machine the proxy calls, with the user's token.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ownOrigin(new URL("https://localhost:20016/api/app/drive/File/List")) // "http://localhost:20016"
|
|
37
|
+
* ownOrigin(new URL("http://0.0.0.0:3000/api/app/drive/File/List")) // "http://127.0.0.1:3000"
|
|
38
|
+
*/
|
|
39
|
+
export declare function ownOrigin(requestUrl: URL): string;
|
|
40
|
+
/**
|
|
41
|
+
* The client's address as the app received it: the **last** one in X-Forwarded-For, which is the one Next.js (from the
|
|
42
|
+
* connection) or the app's own reverse proxy in front of it wrote. The ones before it are what the client claims, and
|
|
43
|
+
* a client can claim anything. Behind two proxies of the app's own, or a CDN with a header of its own, the app passes
|
|
44
|
+
* `clientAddress` to the proxy instead. It reads anything with a `get`: a request's headers, or Next.js's `headers()`.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // A browser sent "X-Forwarded-For: 6.6.6.6"; the app's nginx appended the address it really came from.
|
|
48
|
+
* lastForwardedAddress(new Headers({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" })) // "203.0.113.7"
|
|
49
|
+
*/
|
|
50
|
+
export declare function lastForwardedAddress(headers: {
|
|
51
|
+
get(name: string): string | null;
|
|
52
|
+
}): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a browser sent this request from a page of another site, other than by following a link. Every current
|
|
55
|
+
* browser says where a request comes from in `Sec-Fetch-Site` and `Sec-Fetch-Mode`. The proxy turns the app's own
|
|
56
|
+
* session into the user's token, so a page on another site must not be able to call the backend through it (cross-site
|
|
57
|
+
* request forgery). A link followed from another site (an email, a chat) is a GET navigation and passes; a request
|
|
58
|
+
* without these headers (an older browser, a server, a tool) is no browser's cross-site call and passes too.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isCrossSiteCall(headers: Headers, method: string): boolean;
|
|
61
|
+
/** A path segment the proxy refuses: one that climbs (`.`, `..`, nothing but dots and spaces), or one holding a slash. */
|
|
62
|
+
export declare function isUnsafeSegment(segment: string): boolean;
|
|
63
|
+
/** Reads a body whole, up to `limit` bytes. `null` when it is longer: the rest is not read. */
|
|
64
|
+
export declare function readBody(stream: ReadableStream<Uint8Array> | null, limit: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
65
|
+
/** The backend's response headers that come back to the browser, and the only ones unless an app names more. */
|
|
66
|
+
export declare const DEFAULT_PROXY_RESPONSE_HEADERS: readonly string[];
|
|
67
|
+
export interface ResponseHeaderRules {
|
|
68
|
+
/** Header names to pass besides the defaults (any case). Never one of those that name the backend. */
|
|
69
|
+
extra?: readonly string[];
|
|
70
|
+
/** Pass the backend's Set-Cookie headers. */
|
|
71
|
+
passCookies?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/** The backend's response headers the browser may see: the allowlist and the app's own names, never one of the above. */
|
|
74
|
+
export declare function filterResponseHeaders(answer: Headers, rules?: ResponseHeaderRules): Headers;
|
|
75
|
+
export declare function isRedirect(status: number): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Where a backend's redirect leads, when it stays inside the module: on the root's own origin, or on the root's own
|
|
78
|
+
* host moved from http to https (a backend's HTTPS redirection, which picks its own https port), and under the root's
|
|
79
|
+
* path, whose casing may differ ("/App/Api" and "/app/api"). Anything else - another host or port, a move from https
|
|
80
|
+
* to http, a path outside the root, a URL with a user name in it - is `undefined`, and the proxy refuses it: following
|
|
81
|
+
* it would carry the user's token somewhere the app never named.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // The module's root is http://localhost:10040/App/Api, and the proxy asked for .../App/Api/Region/ActiveListing:
|
|
85
|
+
* redirectInsideModule("https://localhost:10041/App/Api/Region/ActiveListing", asked, root) // followed: the https upgrade
|
|
86
|
+
* redirectInsideModule("/App/Api/Region/ActiveListing/", asked, root) // followed: a trailing slash
|
|
87
|
+
* redirectInsideModule("https://sso.example.com/login?next=/App/Api", asked, root) // undefined: another host
|
|
88
|
+
*/
|
|
89
|
+
export declare function redirectInsideModule(location: string, from: URL, root: URL): URL | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The method of the request after a redirect, as the Fetch standard follows one: a 303 asks for a GET (a HEAD stays a
|
|
92
|
+
* HEAD), a 301 or 302 turns a POST into a GET, and a 307 or 308 keeps the method and the body.
|
|
93
|
+
*/
|
|
94
|
+
export declare function methodAfterRedirect(status: number, method: string): string;
|
|
95
|
+
/** Headers that describe a request's body: they go with the body when a redirect turns the request into a GET. */
|
|
96
|
+
export declare const BODY_HEADERS: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Why an error answer cannot reach the browser as it came, or `undefined` when it can. An empty answer passes (there is
|
|
99
|
+
* nothing in it), and so does JSON - the backend's envelope, or problem details - unless it is a stack trace or repeats
|
|
100
|
+
* the user's token (a page that lists the request's headers). Anything else (an HTML page, text, XML) is the backend
|
|
101
|
+
* talking about itself.
|
|
102
|
+
*/
|
|
103
|
+
export declare function whyHidden(contentType: string, text: string, token?: string | null): string | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* A short, one-line piece of an error answer for the server's log: the user's token, styles, scripts and tags taken
|
|
106
|
+
* out, spaces collapsed.
|
|
107
|
+
*/
|
|
108
|
+
export declare function logSnippet(text: string, token?: string | null, length?: number): string;
|
|
109
|
+
/** A URL for the server's log: its origin and path, never its query (it may hold what a user searched for, or a key). */
|
|
110
|
+
export declare function logUrl(url: URL): string;
|
|
111
|
+
/**
|
|
112
|
+
* The proxy's own refusals: each code is answered from one place in `ApiProxy.ts`, with its HTTP status and its kind
|
|
113
|
+
* as the Nexus backend numbers kinds (`EErrorCodes`, where a kind's category is its number divided by 20).
|
|
114
|
+
*/
|
|
115
|
+
export declare const PROXY_REFUSALS: {
|
|
116
|
+
/** A method other than GET, HEAD, POST, PUT, PATCH and DELETE. RequestInvalid. */
|
|
117
|
+
readonly METHOD_NOT_ALLOWED: {
|
|
118
|
+
readonly status: 405;
|
|
119
|
+
readonly errorCode: 29;
|
|
120
|
+
};
|
|
121
|
+
/** A call from a page of another site. AccessDenied. */
|
|
122
|
+
readonly CROSS_SITE_REFUSED: {
|
|
123
|
+
readonly status: 403;
|
|
124
|
+
readonly errorCode: 60;
|
|
125
|
+
};
|
|
126
|
+
/** A module the app does not have. RequestInvalid. */
|
|
127
|
+
readonly MODULE_NOT_FOUND: {
|
|
128
|
+
readonly status: 404;
|
|
129
|
+
readonly errorCode: 29;
|
|
130
|
+
};
|
|
131
|
+
/** A module the browser calls directly, not through the proxy. RequestInvalid. */
|
|
132
|
+
readonly MODULE_NOT_PROXIED: {
|
|
133
|
+
readonly status: 404;
|
|
134
|
+
readonly errorCode: 29;
|
|
135
|
+
};
|
|
136
|
+
/** A path that climbs out of its module. RequestInvalid. */
|
|
137
|
+
readonly INVALID_PATH: {
|
|
138
|
+
readonly status: 400;
|
|
139
|
+
readonly errorCode: 29;
|
|
140
|
+
};
|
|
141
|
+
/** A body over the limit. TooLong. */
|
|
142
|
+
readonly REQUEST_TOO_LARGE: {
|
|
143
|
+
readonly status: 413;
|
|
144
|
+
readonly errorCode: 22;
|
|
145
|
+
};
|
|
146
|
+
/** A body that could not be read (the client left while sending it). RequestInvalid. */
|
|
147
|
+
readonly REQUEST_UNREADABLE: {
|
|
148
|
+
readonly status: 400;
|
|
149
|
+
readonly errorCode: 29;
|
|
150
|
+
};
|
|
151
|
+
/** A module without a usable root URL on this server. SettingMissing. */
|
|
152
|
+
readonly MODULE_NOT_CONFIGURED: {
|
|
153
|
+
readonly status: 500;
|
|
154
|
+
readonly errorCode: 120;
|
|
155
|
+
};
|
|
156
|
+
/** A backend that cannot be reached: down, refused, or a certificate this server does not trust. ServiceUnavailable. */
|
|
157
|
+
readonly BACKEND_UNREACHABLE: {
|
|
158
|
+
readonly status: 502;
|
|
159
|
+
readonly errorCode: 160;
|
|
160
|
+
};
|
|
161
|
+
/** A backend that did not start answering in time. ServiceTimeout. */
|
|
162
|
+
readonly BACKEND_TIMEOUT: {
|
|
163
|
+
readonly status: 504;
|
|
164
|
+
readonly errorCode: 161;
|
|
165
|
+
};
|
|
166
|
+
/** A redirect out of the module, or one with no usable Location. ServiceAnswerInvalid. */
|
|
167
|
+
readonly BACKEND_REDIRECT_REFUSED: {
|
|
168
|
+
readonly status: 502;
|
|
169
|
+
readonly errorCode: 163;
|
|
170
|
+
};
|
|
171
|
+
/** More redirects inside the module than the limit. ServiceAnswerInvalid. */
|
|
172
|
+
readonly BACKEND_REDIRECT_LIMIT: {
|
|
173
|
+
readonly status: 502;
|
|
174
|
+
readonly errorCode: 163;
|
|
175
|
+
};
|
|
176
|
+
/** An error page that cannot be passed on, answered with the backend's own status. ServiceRefused. */
|
|
177
|
+
readonly BACKEND_ERROR_PAGE: {
|
|
178
|
+
readonly status: 502;
|
|
179
|
+
readonly errorCode: 162;
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
export type ApiProxyCode = keyof typeof PROXY_REFUSALS;
|
|
183
|
+
/** A refusal of the proxy's own, for the app's `failure` builder. */
|
|
184
|
+
export interface ApiProxyFailure {
|
|
185
|
+
/** The HTTP status. */
|
|
186
|
+
status: number;
|
|
187
|
+
/** What the user reads, in plain words: never a host, a port, a setting, or anything else about the backend. */
|
|
188
|
+
message: string;
|
|
189
|
+
/** The proxy's code for this refusal, for a developer and for a page to react to: "BACKEND_REDIRECT_REFUSED". */
|
|
190
|
+
messageCode: ApiProxyCode;
|
|
191
|
+
/** The kind, as the Nexus backend numbers kinds: 163 is an answer that cannot be passed on. */
|
|
192
|
+
errorCode: number;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* A refusal as the Nexus backend answers one: `{ isSuccess: false, code, errorMessages: [{ message, messageCode,
|
|
196
|
+
* errorCode }] }`, with the one problem at the top level too, for readers of the older shape.
|
|
197
|
+
*/
|
|
198
|
+
export declare function nexusFailure(failure: ApiProxyFailure): Response;
|
|
199
|
+
/**
|
|
200
|
+
* Every answer the proxy gives: never sniffed into another type, and kept out of every cache unless the backend said
|
|
201
|
+
* how to cache it - the answer was fetched with the user's token, so a shared cache must never keep it for another user.
|
|
202
|
+
*/
|
|
203
|
+
export declare function harden(response: Response): Response;
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The rules the API proxy keeps so that the browser never learns where the backend is or what it runs: which request
|
|
3
|
+
* headers go on, which response headers come back, which redirects the proxy follows itself, and which error answers it
|
|
4
|
+
* replaces. Plain functions over the standard Headers, URL and streams, so they run in Node, the Edge runtime and the
|
|
5
|
+
* unit tests alike. `ApiProxy.ts` puts them together.
|
|
6
|
+
*/
|
|
7
|
+
/** The most bytes a request body may have, unless the app says otherwise (`maxBodySize`): 100 MB. */
|
|
8
|
+
export const DEFAULT_PROXY_BODY_LIMIT = 100 * 1024 * 1024;
|
|
9
|
+
/** How many redirects inside a module the proxy follows for one call, unless the app says otherwise (`maxRedirects`). */
|
|
10
|
+
export const DEFAULT_PROXY_REDIRECTS = 3;
|
|
11
|
+
/** The most of an error answer the proxy reads to see what it is. A bigger one is never passed on. */
|
|
12
|
+
export const ERROR_BODY_LIMIT = 1024 * 1024;
|
|
13
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
// Request headers
|
|
15
|
+
/** Request headers that never reach the backend. */
|
|
16
|
+
const REQUEST_HEADERS_DROPPED = new Set([
|
|
17
|
+
// This hop's own, and what `fetch` sets by itself. `expect` would make Node's fetch throw.
|
|
18
|
+
"host",
|
|
19
|
+
"connection",
|
|
20
|
+
"keep-alive",
|
|
21
|
+
"proxy-connection",
|
|
22
|
+
"transfer-encoding",
|
|
23
|
+
"te",
|
|
24
|
+
"trailer",
|
|
25
|
+
"upgrade",
|
|
26
|
+
"expect",
|
|
27
|
+
"content-length",
|
|
28
|
+
"accept-encoding",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
// The app's session stays with the app; the user's token goes instead.
|
|
31
|
+
"cookie",
|
|
32
|
+
// Where the request came from, as the browser tells it: anyone can send these, so the proxy writes its own.
|
|
33
|
+
"forwarded",
|
|
34
|
+
"via",
|
|
35
|
+
"x-real-ip",
|
|
36
|
+
"x-client-ip",
|
|
37
|
+
"true-client-ip",
|
|
38
|
+
"cf-connecting-ip",
|
|
39
|
+
"fastly-client-ip",
|
|
40
|
+
"x-cluster-client-ip",
|
|
41
|
+
"x-original-forwarded-for",
|
|
42
|
+
// Headers some servers obey to change the method or the path after the proxy has checked both.
|
|
43
|
+
"x-http-method-override",
|
|
44
|
+
"x-http-method",
|
|
45
|
+
"x-method-override",
|
|
46
|
+
"x-original-url",
|
|
47
|
+
"x-rewrite-url",
|
|
48
|
+
// Next.js's own.
|
|
49
|
+
"rsc",
|
|
50
|
+
]);
|
|
51
|
+
/** Prefixes of request headers that never reach the backend: every X-Forwarded-* (the proxy writes its own), and Next.js's. */
|
|
52
|
+
const REQUEST_PREFIXES_DROPPED = ["x-forwarded-", "x-middleware-", "x-invoke-", "x-nextjs-", "next-"];
|
|
53
|
+
/**
|
|
54
|
+
* The browser's request headers the backend may see: all of them except the ones above, and except any that the
|
|
55
|
+
* request's own `Connection` header names (those belong to this hop too).
|
|
56
|
+
*/
|
|
57
|
+
export function forwardRequestHeaders(incoming) {
|
|
58
|
+
const named = (incoming.get("connection") ?? "")
|
|
59
|
+
.split(",")
|
|
60
|
+
.map(name => name.trim().toLowerCase())
|
|
61
|
+
.filter(name => name !== "");
|
|
62
|
+
const headers = new Headers();
|
|
63
|
+
incoming.forEach((value, name) => {
|
|
64
|
+
const lower = name.toLowerCase();
|
|
65
|
+
if (REQUEST_HEADERS_DROPPED.has(lower) || named.includes(lower))
|
|
66
|
+
return;
|
|
67
|
+
if (REQUEST_PREFIXES_DROPPED.some(prefix => lower.startsWith(prefix)))
|
|
68
|
+
return;
|
|
69
|
+
headers.set(name, value);
|
|
70
|
+
});
|
|
71
|
+
return headers;
|
|
72
|
+
}
|
|
73
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
74
|
+
/** The only characters an IPv6 address is written with: hex digits, colons, and the dots of an IPv4 address at its end. */
|
|
75
|
+
const IPV6_CHARACTERS = /^[0-9a-f:.]+$/i;
|
|
76
|
+
/**
|
|
77
|
+
* An IP address as text, without the port some proxies add; `undefined` for anything else (a host name, "unknown", an
|
|
78
|
+
* obfuscated identifier, a zone such as `%eth0`). An IPv6 address is checked by the URL parser, which every runtime has,
|
|
79
|
+
* once its characters are known to be an address's: without that, text that closes the bracket itself and goes on
|
|
80
|
+
* (`::1]/,6.6.6.6`) parsed as an address followed by a path.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* readIpAddress(" 203.0.113.7:51000 ") // "203.0.113.7"
|
|
84
|
+
* readIpAddress("[2001:db8::1]:443") // "2001:db8::1"
|
|
85
|
+
* readIpAddress("unknown") // undefined
|
|
86
|
+
* readIpAddress("::1]/,6.6.6.6") // undefined
|
|
87
|
+
*/
|
|
88
|
+
export function readIpAddress(value) {
|
|
89
|
+
let text = value?.trim();
|
|
90
|
+
if (!text)
|
|
91
|
+
return undefined;
|
|
92
|
+
const bracketed = /^\[([^\]]+)\](:\d+)?$/.exec(text);
|
|
93
|
+
if (bracketed)
|
|
94
|
+
text = bracketed[1];
|
|
95
|
+
else if (/^[\d.]+:\d+$/.test(text))
|
|
96
|
+
text = text.slice(0, text.lastIndexOf(":"));
|
|
97
|
+
if (IPV4.test(text))
|
|
98
|
+
return text;
|
|
99
|
+
if (!text.includes(":") || !IPV6_CHARACTERS.test(text))
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
new URL(`http://[${text}]/`);
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* This server's own origin, for a module whose root is a path on the app (a pretend backend): the host and port of the
|
|
111
|
+
* request URL, over http. Next.js builds that URL from the host and port it listens on, never from the Host header, but
|
|
112
|
+
* takes its protocol from X-Forwarded-Proto, which anyone can send, so the protocol is not read from it. A server that
|
|
113
|
+
* listens on every address (`0.0.0.0`, `::`) is called on this machine's own, and the port is always the one Next.js
|
|
114
|
+
* wrote, even when the URL leaves it out as its protocol's default.
|
|
115
|
+
*
|
|
116
|
+
* Never the app's configured origin: an app may build that from a request's Host header, and then a client would choose
|
|
117
|
+
* which port on this machine the proxy calls, with the user's token.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ownOrigin(new URL("https://localhost:20016/api/app/drive/File/List")) // "http://localhost:20016"
|
|
121
|
+
* ownOrigin(new URL("http://0.0.0.0:3000/api/app/drive/File/List")) // "http://127.0.0.1:3000"
|
|
122
|
+
*/
|
|
123
|
+
export function ownOrigin(requestUrl) {
|
|
124
|
+
let host = requestUrl.hostname;
|
|
125
|
+
if (host === "0.0.0.0")
|
|
126
|
+
host = "127.0.0.1";
|
|
127
|
+
else if (host === "[::]")
|
|
128
|
+
host = "[::1]";
|
|
129
|
+
let port = requestUrl.port;
|
|
130
|
+
if (port === "")
|
|
131
|
+
port = requestUrl.protocol === "https:" ? "443" : "80";
|
|
132
|
+
return `http://${host}:${port}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The client's address as the app received it: the **last** one in X-Forwarded-For, which is the one Next.js (from the
|
|
136
|
+
* connection) or the app's own reverse proxy in front of it wrote. The ones before it are what the client claims, and
|
|
137
|
+
* a client can claim anything. Behind two proxies of the app's own, or a CDN with a header of its own, the app passes
|
|
138
|
+
* `clientAddress` to the proxy instead. It reads anything with a `get`: a request's headers, or Next.js's `headers()`.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* // A browser sent "X-Forwarded-For: 6.6.6.6"; the app's nginx appended the address it really came from.
|
|
142
|
+
* lastForwardedAddress(new Headers({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" })) // "203.0.113.7"
|
|
143
|
+
*/
|
|
144
|
+
export function lastForwardedAddress(headers) {
|
|
145
|
+
const list = headers.get("x-forwarded-for");
|
|
146
|
+
if (!list)
|
|
147
|
+
return undefined;
|
|
148
|
+
const parts = list.split(",");
|
|
149
|
+
return readIpAddress(parts[parts.length - 1]);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether a browser sent this request from a page of another site, other than by following a link. Every current
|
|
153
|
+
* browser says where a request comes from in `Sec-Fetch-Site` and `Sec-Fetch-Mode`. The proxy turns the app's own
|
|
154
|
+
* session into the user's token, so a page on another site must not be able to call the backend through it (cross-site
|
|
155
|
+
* request forgery). A link followed from another site (an email, a chat) is a GET navigation and passes; a request
|
|
156
|
+
* without these headers (an older browser, a server, a tool) is no browser's cross-site call and passes too.
|
|
157
|
+
*/
|
|
158
|
+
export function isCrossSiteCall(headers, method) {
|
|
159
|
+
if (headers.get("sec-fetch-site") !== "cross-site")
|
|
160
|
+
return false;
|
|
161
|
+
const followedLink = headers.get("sec-fetch-mode") === "navigate" && (method === "GET" || method === "HEAD");
|
|
162
|
+
return !followedLink;
|
|
163
|
+
}
|
|
164
|
+
/** A path segment the proxy refuses: one that climbs (`.`, `..`, nothing but dots and spaces), or one holding a slash. */
|
|
165
|
+
export function isUnsafeSegment(segment) {
|
|
166
|
+
return /^[\s.]*$/.test(segment) || /[\\/]/.test(segment);
|
|
167
|
+
}
|
|
168
|
+
/** Reads a body whole, up to `limit` bytes. `null` when it is longer: the rest is not read. */
|
|
169
|
+
export async function readBody(stream, limit) {
|
|
170
|
+
if (!stream)
|
|
171
|
+
return new Uint8Array(0);
|
|
172
|
+
const reader = stream.getReader();
|
|
173
|
+
const chunks = [];
|
|
174
|
+
let size = 0;
|
|
175
|
+
for (;;) {
|
|
176
|
+
const { done, value } = await reader.read();
|
|
177
|
+
if (done)
|
|
178
|
+
break;
|
|
179
|
+
size += value.byteLength;
|
|
180
|
+
if (size > limit) {
|
|
181
|
+
await reader.cancel().catch(() => undefined);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
chunks.push(value);
|
|
185
|
+
}
|
|
186
|
+
const whole = new Uint8Array(size);
|
|
187
|
+
let at = 0;
|
|
188
|
+
for (const chunk of chunks) {
|
|
189
|
+
whole.set(chunk, at);
|
|
190
|
+
at += chunk.byteLength;
|
|
191
|
+
}
|
|
192
|
+
return whole;
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
195
|
+
// Response headers
|
|
196
|
+
/** The backend's response headers that come back to the browser, and the only ones unless an app names more. */
|
|
197
|
+
export const DEFAULT_PROXY_RESPONSE_HEADERS = [
|
|
198
|
+
"content-type",
|
|
199
|
+
"content-disposition",
|
|
200
|
+
"content-language",
|
|
201
|
+
"content-range",
|
|
202
|
+
"accept-ranges",
|
|
203
|
+
"cache-control",
|
|
204
|
+
"expires",
|
|
205
|
+
"etag",
|
|
206
|
+
"last-modified",
|
|
207
|
+
"vary",
|
|
208
|
+
"retry-after",
|
|
209
|
+
];
|
|
210
|
+
/** Prefixes that come back too: the Nexus backend's own headers for the client, such as a download's X-Nexus-Export-Rows. */
|
|
211
|
+
const RESPONSE_PREFIXES_KEPT = ["x-nexus-"];
|
|
212
|
+
/**
|
|
213
|
+
* Response headers that never come back, even when an app names them. They name the backend or its software, point at
|
|
214
|
+
* it (Location, Link, Refresh), carry its own policies for its own origin, or belong to this hop. CORS headers are here
|
|
215
|
+
* because on the app's origin they would let another site read a user's data through the proxy, with the user's token.
|
|
216
|
+
*/
|
|
217
|
+
const RESPONSE_HEADERS_NEVER = new Set([
|
|
218
|
+
"server",
|
|
219
|
+
"x-sourcefiles",
|
|
220
|
+
"via",
|
|
221
|
+
"alt-svc",
|
|
222
|
+
"strict-transport-security",
|
|
223
|
+
"report-to",
|
|
224
|
+
"reporting-endpoints",
|
|
225
|
+
"nel",
|
|
226
|
+
"expect-ct",
|
|
227
|
+
"public-key-pins",
|
|
228
|
+
"public-key-pins-report-only",
|
|
229
|
+
"location",
|
|
230
|
+
"content-location",
|
|
231
|
+
"link",
|
|
232
|
+
"refresh",
|
|
233
|
+
"www-authenticate",
|
|
234
|
+
"timing-allow-origin",
|
|
235
|
+
"connection",
|
|
236
|
+
"keep-alive",
|
|
237
|
+
"transfer-encoding",
|
|
238
|
+
"te",
|
|
239
|
+
"trailer",
|
|
240
|
+
"upgrade",
|
|
241
|
+
// `fetch` has already decoded the body, so its encoding and length no longer describe what the browser gets.
|
|
242
|
+
"content-encoding",
|
|
243
|
+
"content-length",
|
|
244
|
+
// Only with `passCookies`, and then read on its own (see below).
|
|
245
|
+
"set-cookie",
|
|
246
|
+
]);
|
|
247
|
+
/** Prefixes of response headers that never come back: X-AspNet-Version, X-AspNetMvc-Version, X-Powered-By*, CORS, Proxy-*. */
|
|
248
|
+
const RESPONSE_PREFIXES_NEVER = ["x-aspnet", "x-powered-by", "access-control-", "proxy-"];
|
|
249
|
+
/** Whether a response header (its lower-case name) may reach the browser. */
|
|
250
|
+
function mayPass(name, allowed) {
|
|
251
|
+
if (RESPONSE_HEADERS_NEVER.has(name))
|
|
252
|
+
return false;
|
|
253
|
+
if (RESPONSE_PREFIXES_NEVER.some(prefix => name.startsWith(prefix)))
|
|
254
|
+
return false;
|
|
255
|
+
return allowed.has(name) || RESPONSE_PREFIXES_KEPT.some(prefix => name.startsWith(prefix));
|
|
256
|
+
}
|
|
257
|
+
/** The backend's response headers the browser may see: the allowlist and the app's own names, never one of the above. */
|
|
258
|
+
export function filterResponseHeaders(answer, rules = {}) {
|
|
259
|
+
const allowed = new Set([...DEFAULT_PROXY_RESPONSE_HEADERS, ...(rules.extra ?? [])].map(name => name.toLowerCase()));
|
|
260
|
+
const headers = new Headers();
|
|
261
|
+
answer.forEach((value, name) => {
|
|
262
|
+
const lower = name.toLowerCase();
|
|
263
|
+
if (mayPass(lower, allowed))
|
|
264
|
+
headers.append(lower, value);
|
|
265
|
+
});
|
|
266
|
+
// Several cookies cannot be joined into one line, so they are read one by one.
|
|
267
|
+
if (rules.passCookies)
|
|
268
|
+
for (const cookie of answer.getSetCookie())
|
|
269
|
+
headers.append("set-cookie", cookie);
|
|
270
|
+
return headers;
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
273
|
+
// Redirects
|
|
274
|
+
/** The statuses `fetch` follows as redirects. The proxy follows each one itself or refuses it; none reaches the browser. */
|
|
275
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
276
|
+
export function isRedirect(status) {
|
|
277
|
+
return REDIRECT_STATUSES.has(status);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Where a backend's redirect leads, when it stays inside the module: on the root's own origin, or on the root's own
|
|
281
|
+
* host moved from http to https (a backend's HTTPS redirection, which picks its own https port), and under the root's
|
|
282
|
+
* path, whose casing may differ ("/App/Api" and "/app/api"). Anything else - another host or port, a move from https
|
|
283
|
+
* to http, a path outside the root, a URL with a user name in it - is `undefined`, and the proxy refuses it: following
|
|
284
|
+
* it would carry the user's token somewhere the app never named.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* // The module's root is http://localhost:10040/App/Api, and the proxy asked for .../App/Api/Region/ActiveListing:
|
|
288
|
+
* redirectInsideModule("https://localhost:10041/App/Api/Region/ActiveListing", asked, root) // followed: the https upgrade
|
|
289
|
+
* redirectInsideModule("/App/Api/Region/ActiveListing/", asked, root) // followed: a trailing slash
|
|
290
|
+
* redirectInsideModule("https://sso.example.com/login?next=/App/Api", asked, root) // undefined: another host
|
|
291
|
+
*/
|
|
292
|
+
export function redirectInsideModule(location, from, root) {
|
|
293
|
+
let target;
|
|
294
|
+
try {
|
|
295
|
+
target = new URL(location, from);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
if (target.username !== "" || target.password !== "")
|
|
301
|
+
return undefined;
|
|
302
|
+
const sameOrigin = target.origin === root.origin;
|
|
303
|
+
const upgraded = root.protocol === "http:" && target.protocol === "https:" && target.hostname === root.hostname;
|
|
304
|
+
if (!sameOrigin && !upgraded)
|
|
305
|
+
return undefined;
|
|
306
|
+
const base = root.pathname.replace(/\/+$/, "").toLowerCase();
|
|
307
|
+
const path = target.pathname.toLowerCase();
|
|
308
|
+
if (base !== "" && path !== base && !path.startsWith(`${base}/`))
|
|
309
|
+
return undefined;
|
|
310
|
+
target.hash = "";
|
|
311
|
+
return target;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The method of the request after a redirect, as the Fetch standard follows one: a 303 asks for a GET (a HEAD stays a
|
|
315
|
+
* HEAD), a 301 or 302 turns a POST into a GET, and a 307 or 308 keeps the method and the body.
|
|
316
|
+
*/
|
|
317
|
+
export function methodAfterRedirect(status, method) {
|
|
318
|
+
if (status === 303 && method !== "GET" && method !== "HEAD")
|
|
319
|
+
return "GET";
|
|
320
|
+
if ((status === 301 || status === 302) && method === "POST")
|
|
321
|
+
return "GET";
|
|
322
|
+
return method;
|
|
323
|
+
}
|
|
324
|
+
/** Headers that describe a request's body: they go with the body when a redirect turns the request into a GET. */
|
|
325
|
+
export const BODY_HEADERS = ["content-type", "content-language", "content-encoding", "content-location"];
|
|
326
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
327
|
+
// Error answers
|
|
328
|
+
/** Top-level keys of a JSON answer that is a stack trace: ASP.NET Core's developer exception page, a serialized exception. */
|
|
329
|
+
const TRACE_KEYS = new Set(["exception", "innerexception", "stacktrace", "stacktracestring", "stack"]);
|
|
330
|
+
/**
|
|
331
|
+
* Why an error answer cannot reach the browser as it came, or `undefined` when it can. An empty answer passes (there is
|
|
332
|
+
* nothing in it), and so does JSON - the backend's envelope, or problem details - unless it is a stack trace or repeats
|
|
333
|
+
* the user's token (a page that lists the request's headers). Anything else (an HTML page, text, XML) is the backend
|
|
334
|
+
* talking about itself.
|
|
335
|
+
*/
|
|
336
|
+
export function whyHidden(contentType, text, token) {
|
|
337
|
+
if (text.trim() === "")
|
|
338
|
+
return undefined;
|
|
339
|
+
if (token && text.includes(token))
|
|
340
|
+
return "it repeated the user's token";
|
|
341
|
+
if (!contentType.toLowerCase().includes("json"))
|
|
342
|
+
return `it was ${contentType.split(";")[0].trim() || "a body with no type"}, not JSON`;
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(text);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return "it said it was JSON and was not";
|
|
349
|
+
}
|
|
350
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
351
|
+
if (Object.keys(parsed).some(key => TRACE_KEYS.has(key.toLowerCase())))
|
|
352
|
+
return "it was a stack trace";
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A short, one-line piece of an error answer for the server's log: the user's token, styles, scripts and tags taken
|
|
358
|
+
* out, spaces collapsed.
|
|
359
|
+
*/
|
|
360
|
+
export function logSnippet(text, token, length = 300) {
|
|
361
|
+
let plain = token ? text.split(token).join("[token]") : text;
|
|
362
|
+
plain = plain
|
|
363
|
+
.replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, " ")
|
|
364
|
+
.replace(/<[^>]*>/g, " ")
|
|
365
|
+
.replace(/\s+/g, " ")
|
|
366
|
+
.trim();
|
|
367
|
+
return plain.length > length ? `${plain.slice(0, length)}...` : plain;
|
|
368
|
+
}
|
|
369
|
+
/** A URL for the server's log: its origin and path, never its query (it may hold what a user searched for, or a key). */
|
|
370
|
+
export function logUrl(url) {
|
|
371
|
+
return `${url.origin}${url.pathname}`;
|
|
372
|
+
}
|
|
373
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
374
|
+
// The proxy's own answers
|
|
375
|
+
/**
|
|
376
|
+
* The proxy's own refusals: each code is answered from one place in `ApiProxy.ts`, with its HTTP status and its kind
|
|
377
|
+
* as the Nexus backend numbers kinds (`EErrorCodes`, where a kind's category is its number divided by 20).
|
|
378
|
+
*/
|
|
379
|
+
export const PROXY_REFUSALS = {
|
|
380
|
+
/** A method other than GET, HEAD, POST, PUT, PATCH and DELETE. RequestInvalid. */
|
|
381
|
+
METHOD_NOT_ALLOWED: { status: 405, errorCode: 29 },
|
|
382
|
+
/** A call from a page of another site. AccessDenied. */
|
|
383
|
+
CROSS_SITE_REFUSED: { status: 403, errorCode: 60 },
|
|
384
|
+
/** A module the app does not have. RequestInvalid. */
|
|
385
|
+
MODULE_NOT_FOUND: { status: 404, errorCode: 29 },
|
|
386
|
+
/** A module the browser calls directly, not through the proxy. RequestInvalid. */
|
|
387
|
+
MODULE_NOT_PROXIED: { status: 404, errorCode: 29 },
|
|
388
|
+
/** A path that climbs out of its module. RequestInvalid. */
|
|
389
|
+
INVALID_PATH: { status: 400, errorCode: 29 },
|
|
390
|
+
/** A body over the limit. TooLong. */
|
|
391
|
+
REQUEST_TOO_LARGE: { status: 413, errorCode: 22 },
|
|
392
|
+
/** A body that could not be read (the client left while sending it). RequestInvalid. */
|
|
393
|
+
REQUEST_UNREADABLE: { status: 400, errorCode: 29 },
|
|
394
|
+
/** A module without a usable root URL on this server. SettingMissing. */
|
|
395
|
+
MODULE_NOT_CONFIGURED: { status: 500, errorCode: 120 },
|
|
396
|
+
/** A backend that cannot be reached: down, refused, or a certificate this server does not trust. ServiceUnavailable. */
|
|
397
|
+
BACKEND_UNREACHABLE: { status: 502, errorCode: 160 },
|
|
398
|
+
/** A backend that did not start answering in time. ServiceTimeout. */
|
|
399
|
+
BACKEND_TIMEOUT: { status: 504, errorCode: 161 },
|
|
400
|
+
/** A redirect out of the module, or one with no usable Location. ServiceAnswerInvalid. */
|
|
401
|
+
BACKEND_REDIRECT_REFUSED: { status: 502, errorCode: 163 },
|
|
402
|
+
/** More redirects inside the module than the limit. ServiceAnswerInvalid. */
|
|
403
|
+
BACKEND_REDIRECT_LIMIT: { status: 502, errorCode: 163 },
|
|
404
|
+
/** An error page that cannot be passed on, answered with the backend's own status. ServiceRefused. */
|
|
405
|
+
BACKEND_ERROR_PAGE: { status: 502, errorCode: 162 },
|
|
406
|
+
};
|
|
407
|
+
/**
|
|
408
|
+
* A refusal as the Nexus backend answers one: `{ isSuccess: false, code, errorMessages: [{ message, messageCode,
|
|
409
|
+
* errorCode }] }`, with the one problem at the top level too, for readers of the older shape.
|
|
410
|
+
*/
|
|
411
|
+
export function nexusFailure(failure) {
|
|
412
|
+
const { status, message, messageCode, errorCode } = failure;
|
|
413
|
+
const problem = { message, messageCode, errorCode };
|
|
414
|
+
return Response.json({ isSuccess: false, code: status, ...problem, errorMessages: [problem] }, { status });
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Every answer the proxy gives: never sniffed into another type, and kept out of every cache unless the backend said
|
|
418
|
+
* how to cache it - the answer was fetched with the user's token, so a shared cache must never keep it for another user.
|
|
419
|
+
*/
|
|
420
|
+
export function harden(response) {
|
|
421
|
+
const headers = new Headers(response.headers);
|
|
422
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
423
|
+
if (!headers.has("cache-control"))
|
|
424
|
+
headers.set("Cache-Control", "private, no-store");
|
|
425
|
+
return new Response(response.body, { status: response.status, headers });
|
|
426
|
+
}
|
|
@@ -39,6 +39,9 @@ export function clearExcelSettings() {
|
|
|
39
39
|
/** The template to fill in, as the backend built it, under the name the answer gives it. */
|
|
40
40
|
export async function downloadExcelTemplate(endpoints, options = {}) {
|
|
41
41
|
const limits = await excelSettings(endpoints, { signal: options.signal, params: options.params, toast: options.toast });
|
|
42
|
+
// A refused or failed settings call has said why, once: the file would only fail the same way, in a second toast.
|
|
43
|
+
if (!limits.ok)
|
|
44
|
+
return { ...limits, result: undefined };
|
|
42
45
|
const name = limits.result?.templateFileName ?? "template.xlsx";
|
|
43
46
|
const answer = await download(endpoints.template, options, name);
|
|
44
47
|
if (answer.ok && answer.result)
|
|
@@ -57,6 +60,8 @@ export async function exportExcel(endpoints, options = {}) {
|
|
|
57
60
|
if (!endpoint)
|
|
58
61
|
throw new Error("This model has no export endpoint: `excelEndpoints` was given `exportable: false`, or the app's own endpoints left `export` out.");
|
|
59
62
|
const limits = await excelSettings(endpoints, { signal: options.signal, params: options.params, toast: options.toast });
|
|
63
|
+
if (!limits.ok)
|
|
64
|
+
return { ...limits, result: undefined };
|
|
60
65
|
const subject = options.subject ?? limits.result?.subject ?? "row";
|
|
61
66
|
const limit = limits.result?.maxExportRows;
|
|
62
67
|
if (options.confirm !== false && limit !== undefined && options.rows !== undefined && options.rows > limit) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PrimaryPictures } from "../Helpers/FilePrimaries.ts";
|
|
2
|
+
import type { FileAdapter } from "../Helpers/FileAdapter.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Registers the app's file backend, once, beside its API modules. Without it, the Nexus Drive.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* defineFileAdapter(new NexusDriveAdapter({ client: api, drive: HrModule.Files }));
|
|
8
|
+
*/
|
|
9
|
+
export declare function defineFileAdapter<T extends FileAdapter>(adapter: T): T;
|
|
10
|
+
/** The registered file backend, or the Nexus Drive. */
|
|
11
|
+
export declare function getFileAdapter(): FileAdapter;
|
|
12
|
+
/** Each owner's picture for the page's rows, read in one call per page and kept until a change on the page. */
|
|
13
|
+
export declare const primaryPictures: PrimaryPictures;
|