mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:38:36 +00:00
Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66ab4b24d1 | ||
|
|
5b7e34eb67 | ||
|
|
04c1f5ae76 | ||
|
|
0792a5e24f | ||
|
|
779927c995 | ||
|
|
87b77d9461 | ||
|
|
8342713d45 | ||
|
|
4175cf66c9 | ||
|
|
af54d2e346 | ||
|
|
9b1566aa6b | ||
|
|
fff2b0d097 | ||
|
|
99678969b3 | ||
|
|
0cde59a8ad | ||
|
|
36b3434b9c | ||
|
|
71be654418 | ||
|
|
47c9bfba4f | ||
|
|
7c767f43b0 | ||
|
|
15beff767d | ||
|
|
53ee436e35 | ||
|
|
e824d353f0 | ||
|
|
3f2c186771 | ||
|
|
ea7e7468df | ||
|
|
e93b771ff5 | ||
|
|
c08f2e65a8 | ||
|
|
0d90542119 | ||
|
|
2f5342b427 | ||
|
|
bc8bbf3562 | ||
|
|
cc46ce5ab6 | ||
|
|
b5ee4f7b1b | ||
|
|
9be1cb7818 | ||
|
|
fe58b4eba0 | ||
|
|
18fc758b4f | ||
|
|
5795699091 | ||
|
|
b88ca8e2e5 | ||
|
|
d504c2bf25 | ||
|
|
49662bdc91 | ||
|
|
f23dd9bff0 | ||
|
|
2a8b39bdef | ||
|
|
23e56e9581 | ||
|
|
3d09072ea7 | ||
|
|
4daa54c12e | ||
|
|
5457ca0965 | ||
|
|
1dd0b8fbe8 | ||
|
|
5db7516f40 | ||
|
|
bd34c94222 | ||
|
|
9754c4c639 | ||
|
|
db292e947d | ||
|
|
ec0f30106d | ||
|
|
1c76fb76ea | ||
|
|
f915ca2462 | ||
|
|
55481adede | ||
|
|
7e240e4136 | ||
|
|
3fb2f7261f | ||
|
|
bea51343ef | ||
|
|
42fbab8058 | ||
|
|
dca46af3fc | ||
|
|
4aab1f50a4 | ||
|
|
44ada6ad5a | ||
|
|
e3631e88c6 | ||
|
|
fdf51a654c | ||
|
|
0dc9933409 | ||
|
|
044d093ec5 | ||
|
|
d7a79a547f | ||
|
|
d131419907 | ||
|
|
c68456b205 | ||
|
|
5aed3a5539 | ||
|
|
c214a49afb | ||
|
|
b28e0d5442 | ||
|
|
2a27e542e9 | ||
|
|
66fd49ee46 | ||
|
|
d0739b7210 | ||
|
|
435ddeee34 | ||
|
|
c19ce19f90 | ||
|
|
c23a1cb343 | ||
|
|
d2e8e5650a | ||
|
|
50d413f464 | ||
|
|
5e76f904ee | ||
|
|
6838868fdc | ||
|
|
26e27e3503 | ||
|
|
9780e22792 | ||
|
|
076c522ccc | ||
|
|
518dd04679 | ||
|
|
d9c4d33a7c | ||
|
|
b6e9038f0c | ||
|
|
a57ffb0ea9 | ||
|
|
b7d1e99e8c | ||
|
|
3a455595e7 | ||
|
|
c50c06530b | ||
|
|
1aca775cb9 | ||
|
|
556738fd4d | ||
|
|
f1bd157438 | ||
|
|
6acf346262 | ||
|
|
82529d6402 | ||
|
|
8c5b4d8ffd | ||
|
|
d780474583 | ||
|
|
9972e78dc8 | ||
|
|
41713715ca | ||
|
|
06b02f7731 | ||
|
|
56ab784117 | ||
|
|
717877db3f | ||
|
|
1454252acd | ||
|
|
6b26999e34 | ||
|
|
1028e139e4 | ||
|
|
7d7ed25715 | ||
|
|
3270873cc8 | ||
|
|
81e7044a17 | ||
|
|
3f1c0f27d3 | ||
|
|
2afd723cd8 | ||
|
|
f5e6dfecfb | ||
|
|
778ab6b375 | ||
|
|
092d212ad7 | ||
|
|
e7e69abb84 | ||
|
|
f64d43b9ae | ||
|
|
77dfc3d3c1 | ||
|
|
2dfd691169 | ||
|
|
c699dc96d4 | ||
|
|
5dcaf28d7c | ||
|
|
de65105744 | ||
|
|
a54b73f153 | ||
|
|
ccbffa76ff | ||
|
|
4725153962 | ||
|
|
a29eae9dc2 | ||
|
|
2cfd3395cf |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Force LF everywhere: these files run inside Linux containers
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -1,6 +1,16 @@
|
||||
<!-- WARNING: Any PR without a description will be closed. The title is not enough! -->
|
||||
<!--
|
||||
|
||||
<!-- ANOTHER WARNING: Don't go creating a duplicate PR! Check that someone hasn't already created something that tackles your fix and if so, help them first -->
|
||||
WARNINGS:
|
||||
|
||||
1. An AI bot will comment on this PR! If you don't get a perfect score or don't
|
||||
address any issues raised, your PR won't get merged!
|
||||
|
||||
2. Any PR without a description will be closed. The title is not enough!
|
||||
|
||||
3. Don't go creating a duplicate PR! Check that someone hasn't already created
|
||||
something that tackles your fix and if so, help them first
|
||||
|
||||
-->
|
||||
|
||||
## Why
|
||||
|
||||
@@ -28,4 +38,3 @@ you change will break API for existing users -->
|
||||
|
||||
- [ ] AI was used to write this
|
||||
- [ ] AI was used to review this
|
||||
|
||||
@@ -9,4 +9,5 @@ test/node_modules
|
||||
docker/dev/dnsrouter-config.json.tmp
|
||||
docker/dev/resolv.conf
|
||||
.claude
|
||||
test/__pycache__
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://nginxproxymanager.com/github.png">
|
||||
<br><br>
|
||||
<img src="https://img.shields.io/badge/version-2.16.0-green.svg?style=for-the-badge">
|
||||
<img src="https://img.shields.io/badge/version-2.17.0-green.svg?style=for-the-badge">
|
||||
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
|
||||
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
|
||||
</a>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -44,16 +44,16 @@
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-baidu",
|
||||
"name": "baidu",
|
||||
"package_name": "certbot-dns-baidu",
|
||||
"version": "~=0.1.1"
|
||||
"package_name": "certbot-dns-baidu-bcd",
|
||||
"version": "~=2.0.0"
|
||||
},
|
||||
"beget": {
|
||||
"credentials": "# Beget API credentials used by Certbot\nbeget_plugin_username = username\nbeget_plugin_password = password",
|
||||
"credentials": "# Beget API credentials used by Certbot\n# Find on Settings -> Security -> BegetAPI\ndns_beget_plugin_username = username\ndns_beget_plugin_password = password",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "beget-plugin",
|
||||
"full_plugin_name": "dns-beget-plugin",
|
||||
"name": "Beget",
|
||||
"package_name": "certbot-beget-plugin",
|
||||
"version": "~=1.0.0.dev9"
|
||||
"package_name": "certbot-dns-beget-plugin",
|
||||
"version": "~=1.0.0"
|
||||
},
|
||||
"bunny": {
|
||||
"credentials": "# Bunny API token used by Certbot (see https://dash.bunny.net/account/settings)\ndns_bunny_api_key = xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
|
||||
@@ -173,7 +173,7 @@
|
||||
"full_plugin_name": "dns-multi",
|
||||
"name": "DnsMulti",
|
||||
"package_name": "certbot-dns-multi",
|
||||
"version": "~=4.9"
|
||||
"version": "~=5.6.0"
|
||||
},
|
||||
"dnspod": {
|
||||
"credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"",
|
||||
@@ -224,12 +224,12 @@
|
||||
"version": "~=0.1.2"
|
||||
},
|
||||
"edgedns": {
|
||||
"credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "edgedns",
|
||||
"name": "Akamai Edge DNS",
|
||||
"package_name": "certbot-plugin-edgedns",
|
||||
"version": "~=0.1.0"
|
||||
"credentials": "client_token = YOUR_CLIENT_TOKEN\naccess_token = YOUR_ACCESS_TOKEN\nclient_secret = YOUR_CLIENT_SECRET\nhost = YOUR_HOST.luna.akamaiapis.net\n# Optional: account_key = YOUR_ACCOUNT_KEY",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "edgedns",
|
||||
"name": "Akamai Edge DNS",
|
||||
"package_name": "certbot-plugin-edgedns",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"edgeone": {
|
||||
"credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY",
|
||||
@@ -333,7 +333,7 @@
|
||||
"full_plugin_name": "dns-hetzner",
|
||||
"name": "Hetzner",
|
||||
"package_name": "certbot-dns-hetzner",
|
||||
"version": "~=1.0.4"
|
||||
"version": "~=4.0.0"
|
||||
},
|
||||
"hetzner-cloud": {
|
||||
"credentials": "dns_hetzner_cloud_api_token = your_api_token_here",
|
||||
@@ -381,7 +381,7 @@
|
||||
"full_plugin_name": "dns-infomaniak",
|
||||
"name": "Infomaniak",
|
||||
"package_name": "certbot-dns-infomaniak",
|
||||
"version": "~=0.2.2"
|
||||
"version": "~=0.3.3"
|
||||
},
|
||||
"inwx": {
|
||||
"credentials": "dns_inwx_url = https://api.domrobot.com/xmlrpc/\ndns_inwx_username = your_username\ndns_inwx_password = your_password\ndns_inwx_shared_secret = your_shared_secret optional",
|
||||
@@ -399,6 +399,14 @@
|
||||
"package_name": "certbot-dns-ionos",
|
||||
"version": "==2022.11.24"
|
||||
},
|
||||
"ip-projects": {
|
||||
"credentials": "dns_ipp_acme_api_key = <API key>",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-ipp",
|
||||
"name": "ip-projects.de",
|
||||
"package_name": "certbot-dns-ipp",
|
||||
"version": "~=1.0.0"
|
||||
},
|
||||
"ispconfig": {
|
||||
"credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080",
|
||||
"dependencies": "",
|
||||
@@ -599,6 +607,14 @@
|
||||
"package_name": "certbot-dns-route53",
|
||||
"version": "=={{certbot-version}}"
|
||||
},
|
||||
"scaleway": {
|
||||
"credentials": "dns_scaleway_application_token = b3a0b9a9-3814-4f12-95b0-a7473bf8b306",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-scaleway",
|
||||
"name": "Scaleway",
|
||||
"package_name": "certbot-dns-scaleway",
|
||||
"version": "~=0.0.7"
|
||||
},
|
||||
"selectelv2": {
|
||||
"credentials": "dns_selectel_api_v2_account_id = your_account_id\ndns_selectel_api_v2_project_name = your_project\ndns_selectel_api_v2_username = your_username\ndns_selectel_api_v2_password = your_password",
|
||||
"dependencies": "",
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ async function appStart() {
|
||||
})
|
||||
.then(() => {
|
||||
internalCertificate.initTimer();
|
||||
internalIpRanges.initTimer();
|
||||
if (IP_RANGES_FETCH_ENABLED) {
|
||||
internalIpRanges.initTimer();
|
||||
}
|
||||
|
||||
const server = app.listen(3000, () => {
|
||||
logger.info(`Backend PID ${process.pid} listening on port 3000 ...`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import batchflow from "batchflow";
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { isValid, unsafeValueError } from "../lib/nginx-values.js";
|
||||
import { isMysql, isPostgres } from "../lib/config.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import db from "../db.js";
|
||||
@@ -27,24 +28,24 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
|
||||
let result;
|
||||
if (isMysql()) {
|
||||
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)",
|
||||
[searchObj, "$"],
|
||||
);
|
||||
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", [
|
||||
searchObj,
|
||||
"$",
|
||||
]);
|
||||
} else if (isPostgres()) {
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb",
|
||||
[JSON.stringify([{ access_list_id: accessListId }])],
|
||||
);
|
||||
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", [
|
||||
JSON.stringify([{ access_list_id: accessListId }]),
|
||||
]);
|
||||
} else {
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?",
|
||||
[`%"access_list_id":${accessListId}%`],
|
||||
);
|
||||
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", [
|
||||
`%"access_list_id":${accessListId}%`,
|
||||
]);
|
||||
}
|
||||
// knex raw() returns [rows, metadata] for MySQL
|
||||
const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
||||
return rows || [];
|
||||
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
|
||||
if (!Array.isArray(result)) {
|
||||
return result?.rows || [];
|
||||
}
|
||||
return (Array.isArray(result[0]) ? result[0] : result) || [];
|
||||
};
|
||||
|
||||
const internalAccessList = {
|
||||
@@ -223,36 +224,19 @@ const internalAccessList = {
|
||||
// Also regenerate configs for proxy hosts that reference this access list in their locations
|
||||
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
|
||||
if (locationHostRows?.length) {
|
||||
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
|
||||
// Exclude hosts already regenerated above
|
||||
return !freshRow.proxy_hosts?.find((h) => h.id === id);
|
||||
});
|
||||
const locationHostIds = locationHostRows
|
||||
.map((r) => r.id)
|
||||
.filter((id) => {
|
||||
// Exclude hosts already regenerated above
|
||||
return !freshRow.proxy_hosts?.find((h) => h.id === id);
|
||||
});
|
||||
if (locationHostIds.length) {
|
||||
const locationHosts = await proxyHostModel.query()
|
||||
const locationHosts = await proxyHostModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.whereIn("id", locationHostIds)
|
||||
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||
for (const host of locationHosts) {
|
||||
// Fetch access lists for locations
|
||||
if (host.locations?.length) {
|
||||
for (let i = 0; i < host.locations.length; i++) {
|
||||
const loc = host.locations[i];
|
||||
if (loc.access_list_id && loc.access_list_id > 0) {
|
||||
const locAccessList = await accessListModel
|
||||
.query()
|
||||
.allowGraph("[clients,items]")
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", loc.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
if (locAccessList) {
|
||||
host.locations[i].access_list = locAccessList;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
||||
}
|
||||
}
|
||||
@@ -338,50 +322,38 @@ const internalAccessList = {
|
||||
});
|
||||
|
||||
// 2. update any proxy hosts that were using it (ignoring permissions)
|
||||
if (row.proxy_hosts) {
|
||||
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
|
||||
if (affectedHostIds.size) {
|
||||
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
|
||||
|
||||
// 3. reconfigure those hosts, then reload nginx
|
||||
// set the access_list_id to zero for these items
|
||||
row.proxy_hosts.map((_val, idx) => {
|
||||
row.proxy_hosts[idx].access_list_id = 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
|
||||
}
|
||||
|
||||
// Also handle proxy hosts that reference this access list in their locations JSON
|
||||
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
|
||||
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
|
||||
if (locationHostRows?.length) {
|
||||
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
|
||||
return !row.proxy_hosts?.find((h) => h.id === id);
|
||||
});
|
||||
if (locationHostIds.length) {
|
||||
// Clear the access_list_id in locations JSON for these hosts
|
||||
for (const hostId of locationHostIds) {
|
||||
const host = await proxyHostModel.query().where("id", hostId).first();
|
||||
if (host?.locations) {
|
||||
const updatedLocations = host.locations.map((loc) => {
|
||||
if (loc.access_list_id === row.id) {
|
||||
return { ...loc, access_list_id: 0 };
|
||||
}
|
||||
return loc;
|
||||
});
|
||||
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
|
||||
for (const { id: hostId } of locationHostRows) {
|
||||
const host = await proxyHostModel.query().where("id", hostId).first();
|
||||
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
|
||||
const updatedLocations = host.locations.map((loc) => {
|
||||
if (loc.access_list_id === row.id) {
|
||||
return { ...loc, access_list_id: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch and regenerate configs
|
||||
const locationHosts = await proxyHostModel.query()
|
||||
.where("is_deleted", 0)
|
||||
.whereIn("id", locationHostIds)
|
||||
.allowGraph(proxyHostModel.defaultExpand)
|
||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
||||
return loc;
|
||||
});
|
||||
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
|
||||
affectedHostIds.add(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. reconfigure those hosts from fresh rows, then reload nginx
|
||||
if (affectedHostIds.size) {
|
||||
const affectedHosts = await proxyHostModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.whereIn("id", [...affectedHostIds])
|
||||
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
|
||||
}
|
||||
|
||||
await internalNginx.reload();
|
||||
|
||||
// delete the htpasswd file
|
||||
@@ -529,6 +501,11 @@ const internalAccessList = {
|
||||
.sequential()
|
||||
.each((_i, item, next) => {
|
||||
if (item.password?.length) {
|
||||
// One "user:hash" line each; a ":" or line break in the name would corrupt the file
|
||||
if (!isValid("accessUsername", item.username)) {
|
||||
reject(unsafeValueError(`Access List #${list.id}: user name is not allowed`));
|
||||
return;
|
||||
}
|
||||
logger.info(`Adding: ${item.username}`);
|
||||
|
||||
utils
|
||||
|
||||
+319
-18
@@ -24,8 +24,163 @@ const certbotCommand = "certbot";
|
||||
const certbotLogsDir = "/data/logs";
|
||||
const certbotWorkDir = "/tmp/letsencrypt-lib";
|
||||
|
||||
// Certbot output lines that say nothing about why a request failed
|
||||
const certbotNoise = [
|
||||
/^Saving debug log/,
|
||||
/^Ask for help/,
|
||||
/^See the logfile/,
|
||||
/^(- )+-?$/,
|
||||
/^Processing \//,
|
||||
/^Renewing an existing/,
|
||||
/^Requesting a certificate/,
|
||||
/^Plugins selected/,
|
||||
/^Account registered/,
|
||||
/^Waiting \d+ seconds/,
|
||||
/^All renewals failed/,
|
||||
/^\S+\/fullchain\.pem \(failure\)$/,
|
||||
/^\d+ renew failure\(s\)/,
|
||||
];
|
||||
|
||||
// Plain-language hints for failures that are common and fixable outside NPM.
|
||||
// [pattern, hint, dnsProvider?]: a hint with a dnsProvider only applies to certificates using that
|
||||
// provider, so a generic message (e.g. "Authentication error" from another DNS plugin) isn't
|
||||
// reported as a Cloudflare problem.
|
||||
const certbotHints = [
|
||||
// Checked first: a rate limit is unambiguous and the most useful thing to report
|
||||
[
|
||||
/too many (certificates|failed authorizations|new orders)|rateLimited/i,
|
||||
() => "Let's Encrypt rate limit reached. Wait before retrying (see https://letsencrypt.org/docs/rate-limits/).",
|
||||
],
|
||||
[
|
||||
/expected \S+ to be a symlink/i,
|
||||
() =>
|
||||
"This certificate's files in /etc/letsencrypt were copied without their symlinks (live/ must link to archive/). Restore /etc/letsencrypt from a copy made with tar or rsync -a, or delete this certificate and create it again.",
|
||||
],
|
||||
[
|
||||
/Renewal configuration file \S+ is broken/i,
|
||||
() =>
|
||||
"This certificate's files in /etc/letsencrypt are damaged or missing. Restore /etc/letsencrypt from a backup, or delete this certificate and create it again.",
|
||||
],
|
||||
[
|
||||
/\b9109\b.*?location: ([0-9a-f.:]+)/i,
|
||||
(m) => {
|
||||
const ip = m[1].replace(/\.+$/, "");
|
||||
return `Cloudflare refused the API token from this server's public IP (${ip}). The token has Client IP Address Filtering: add ${ip} to the token in Cloudflare (My Profile > API Tokens), or remove the filter, then renew again.`;
|
||||
},
|
||||
"cloudflare",
|
||||
],
|
||||
[
|
||||
// certbot-dns-cloudflare errors read "<context>: <code> <message>", e.g.
|
||||
// "Error determining zone_id: 9109 Invalid access token."
|
||||
/: (6003|6111|9103|9109|10000) [A-Za-z]|Invalid request headers|Invalid access token|Authentication error/i,
|
||||
() =>
|
||||
"Cloudflare rejected the API token. Check the token value and that it has Zone > DNS > Edit for this domain's zone.",
|
||||
"cloudflare",
|
||||
],
|
||||
[
|
||||
/NXDOMAIN|DNS problem/i,
|
||||
() => "Let's Encrypt could not look up the domain. Check the DNS records of every domain on the certificate.",
|
||||
],
|
||||
[
|
||||
/Timeout during connect|Connection refused|Invalid response from/i,
|
||||
() =>
|
||||
"Let's Encrypt could not reach this server over HTTP (port 80) for the challenge. Check that the domain points here and port 80 is open, or use a DNS challenge.",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The values in a DNS credentials file ("key = value" lines), for redacting from certbot output.
|
||||
*
|
||||
* @param {String} [credentials]
|
||||
* @returns {String[]}
|
||||
*/
|
||||
const credentialValues = (credentials) =>
|
||||
`${credentials || ""}`
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[^=:]*[=:]/, "").trim())
|
||||
.filter((value) => value.length >= 4);
|
||||
|
||||
/**
|
||||
* Removes anything that could be a secret from certbot output before it leaves the server.
|
||||
* DNS plugins can echo provider errors verbatim, including request URLs that carry API keys in
|
||||
* the query string (e.g. "401 Client Error ... for url: https://...?token=..."), so this strips
|
||||
* the given credential values, URL query strings and userinfo, and key=value pairs whose name
|
||||
* looks secret.
|
||||
*
|
||||
* @param {String} text
|
||||
* @param {String[]} [secrets]
|
||||
* @returns {String}
|
||||
*/
|
||||
const redactCertbotOutput = (text, secrets = []) => {
|
||||
let out = `${text || ""}`;
|
||||
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
||||
out = out.split(secret).join("[REDACTED]");
|
||||
}
|
||||
return out
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
||||
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, "$1[REDACTED]@")
|
||||
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)\?[^\s#]*/gi, "$1?[REDACTED]")
|
||||
.replace(
|
||||
/\b([\w-]*(?:token|key|secret|password|passwd|pass|auth|credential|signature|sig)[\w-]*)(["']?\s*[=:]\s*["']?)[^\s"'&,;]+/gi,
|
||||
"$1$2[REDACTED]",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns certbot's stderr into a message the UI can show: a hint for common causes plus certbot's own reason.
|
||||
* The output is redacted first (see redactCertbotOutput), since DNS plugins can echo credentials.
|
||||
*
|
||||
* @param {String} stderr
|
||||
* @param {String} [dnsProvider] The certificate's DNS provider, if it uses a DNS challenge
|
||||
* @param {String[]} [secrets] Values that must never appear in the message
|
||||
* @returns {String}
|
||||
*/
|
||||
const certbotFailureMessage = (stderr, dnsProvider, secrets) => {
|
||||
const reason = redactCertbotOutput(stderr, secrets)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !certbotNoise.some((re) => re.test(line)))
|
||||
.join(" ")
|
||||
.slice(0, 800);
|
||||
|
||||
for (const [pattern, hint, provider] of certbotHints) {
|
||||
if (provider && provider !== dnsProvider) {
|
||||
continue;
|
||||
}
|
||||
const match = reason.match(pattern);
|
||||
if (match) {
|
||||
return `${hint(match)} (Certbot: ${reason})`;
|
||||
}
|
||||
}
|
||||
return reason ? `Certbot failed: ${reason}` : "Certbot failed. See /data/logs/letsencrypt.log for details.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs certbot; a failure becomes a public error with the real reason instead of "Internal Error".
|
||||
*
|
||||
* @param {Array} args
|
||||
* @param {Object} [opts]
|
||||
* @param {String} [dnsProvider] Passed through so provider-specific hints only show for that provider
|
||||
* @param {String} [credentials] The DNS credentials file contents, so its values are redacted from errors
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const runCertbot = async (args, opts, dnsProvider, credentials) => {
|
||||
try {
|
||||
return await utils.execFile(certbotCommand, args, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof error.CommandError) {
|
||||
const secrets = credentialValues(credentials);
|
||||
logger.error(redactCertbotOutput(err.message, secrets));
|
||||
// The raw error is not attached as `previous`: the API error handler returns
|
||||
// `previous` to the client for certificate routes, and it holds unredacted output.
|
||||
throw new error.ValidationError(certbotFailureMessage(err.message, dnsProvider, secrets));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const omissions = () => {
|
||||
return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials"];
|
||||
return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials", "meta.certificate_key"];
|
||||
};
|
||||
|
||||
const internalCertificate = {
|
||||
@@ -75,6 +230,7 @@ const internalCertificate = {
|
||||
* instance of Certbot is already running.'
|
||||
*/
|
||||
let sequence = Promise.resolve();
|
||||
let renewed = 0;
|
||||
|
||||
certificates.forEach((certificate) => {
|
||||
sequence = sequence.then(() =>
|
||||
@@ -88,7 +244,11 @@ const internalCertificate = {
|
||||
token: tokenModel(),
|
||||
},
|
||||
{ id: certificate.id },
|
||||
{ reload: false }, // one reload for the whole batch, below
|
||||
)
|
||||
.then(() => {
|
||||
renewed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
// Don't want to stop the train here, just log the error
|
||||
logger.error(err.message);
|
||||
@@ -96,7 +256,11 @@ const internalCertificate = {
|
||||
);
|
||||
});
|
||||
|
||||
return sequence;
|
||||
return sequence.then(() => {
|
||||
if (renewed) {
|
||||
return internalCertificate.reloadAfterRenew();
|
||||
}
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("Completed SSL cert renew process");
|
||||
@@ -395,12 +559,35 @@ const internalCertificate = {
|
||||
*/
|
||||
delete: async (access, data) => {
|
||||
await access.can("certificates:delete", data.id);
|
||||
const row = await internalCertificate.get(access, { id: data.id });
|
||||
const hostRelations = ["proxy_hosts", "redirection_hosts", "dead_hosts", "streams"];
|
||||
const row = await internalCertificate.get(access, { id: data.id, expand: hostRelations });
|
||||
|
||||
if (!row?.id) {
|
||||
throw new error.ItemNotFoundError(data.id);
|
||||
}
|
||||
|
||||
// A certificate that hosts still use can't be deleted: their rendered configs include its
|
||||
// files, and revoking it would leave them serving a revoked certificate. Hosts may belong
|
||||
// to other users, so only counts are reported.
|
||||
const labels = {
|
||||
proxy_hosts: "proxy host",
|
||||
redirection_hosts: "redirection host",
|
||||
dead_hosts: "404 host",
|
||||
streams: "stream",
|
||||
};
|
||||
const usage = hostRelations
|
||||
.map((rel) => ({ rel, count: row[rel]?.length || 0 }))
|
||||
.filter((u) => u.count > 0)
|
||||
.map((u) => `${u.count} ${labels[u.rel]}${u.count === 1 ? "" : "s"}`);
|
||||
if (usage.length) {
|
||||
throw new error.ValidationError(
|
||||
`Certificate is in use by ${usage.join(", ")}. Remove it from them before deleting it.`,
|
||||
);
|
||||
}
|
||||
for (const rel of hostRelations) {
|
||||
delete row[rel];
|
||||
}
|
||||
|
||||
await certificateModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
@@ -416,12 +603,47 @@ const internalCertificate = {
|
||||
});
|
||||
|
||||
if (row.provider === "letsencrypt") {
|
||||
// Revoke the cert
|
||||
// Revoke the cert. Errors are logged by revokeLetsEncryptSsl.
|
||||
await internalCertificate.revokeLetsEncryptSsl(row);
|
||||
// Certbot leaves the files behind when it can't revoke (e.g. a broken renewal config),
|
||||
// so remove them either way. No host uses them; that was checked above.
|
||||
// The row is already deleted and audited, so leftover files are logged, not thrown:
|
||||
// an error here would report a completed delete as failed, and a retry would 404.
|
||||
const leftover = internalCertificate.removeLetsEncryptFiles(row.id);
|
||||
if (leftover.length) {
|
||||
logger.warn(
|
||||
`Certificate #${row.id} was deleted, but these files could not be removed and should be deleted by hand: ${leftover.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a certificate's certbot files, as `certbot delete` would. Never throws.
|
||||
*
|
||||
* @param {Number} certificateId
|
||||
* @returns {String[]} paths that could not be removed
|
||||
*/
|
||||
removeLetsEncryptFiles: (certificateId) => {
|
||||
const paths = [
|
||||
internalCertificate.getLiveCertPath(certificateId),
|
||||
`/etc/letsencrypt/archive/npm-${certificateId}`,
|
||||
`/etc/letsencrypt/renewal/npm-${certificateId}.conf`,
|
||||
`/etc/letsencrypt/credentials/credentials-${certificateId}`,
|
||||
];
|
||||
const failed = [];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.error(`Could not remove ${p}: ${err.message}`);
|
||||
failed.push(p);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
},
|
||||
|
||||
/**
|
||||
* All Certs
|
||||
*
|
||||
@@ -545,13 +767,15 @@ const internalCertificate = {
|
||||
|
||||
/**
|
||||
* Validates that the certs provided are good.
|
||||
* No access required here, nothing is changed or stored.
|
||||
* Nothing is changed or stored, but the files go through openssl, so it needs the same access as creating one.
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Object} data.files
|
||||
* @returns {Promise}
|
||||
*/
|
||||
validate: (data) => {
|
||||
validate: async (access, data) => {
|
||||
await access.can("certificates:create");
|
||||
// Put file contents into an object
|
||||
const files = {};
|
||||
_.map(data.files, (file, name) => {
|
||||
@@ -600,27 +824,75 @@ const internalCertificate = {
|
||||
throw new error.ValidationError("Cannot upload certificates for this type of provider");
|
||||
}
|
||||
|
||||
const validations = await internalCertificate.validate(data);
|
||||
const validations = await internalCertificate.validate(access, data);
|
||||
if (typeof validations.certificate === "undefined") {
|
||||
throw new error.ValidationError("Certificate file was not provided");
|
||||
}
|
||||
|
||||
// row came through get(), which omits meta.certificate_key so it never goes out over
|
||||
// the API. update() replaces meta wholesale, so build on the stored meta (read from the
|
||||
// model directly) or an upload without a key would erase the stored one.
|
||||
const stored = await certificateModel.query().where("id", row.id).first();
|
||||
const meta = { ...(stored?.meta || {}) };
|
||||
const uploaded = [];
|
||||
|
||||
_.map(data.files, (file, name) => {
|
||||
if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) {
|
||||
row.meta[name] = file.data.toString();
|
||||
meta[name] = file.data.toString();
|
||||
uploaded.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Fail before anything is written: without a key, writeCustomCert would overwrite
|
||||
// fullchain.pem and then fail on privkey.pem, leaving nginx with a mismatched pair.
|
||||
if (!meta.certificate_key) {
|
||||
throw new error.ValidationError("Certificate Key file was not provided");
|
||||
}
|
||||
// The key may be the stored one (a certificate-only upload), so check the pair belongs
|
||||
// together; a mismatch would make nginx -t fail for every host on the next reload.
|
||||
await internalCertificate.checkKeyMatchesCertificate(meta.certificate, meta.certificate_key);
|
||||
|
||||
const certificate = await internalCertificate.update(access, {
|
||||
id: data.id,
|
||||
expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
|
||||
domain_names: validations.certificate.cn ? [validations.certificate.cn] : [],
|
||||
meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
|
||||
meta: _.clone(meta), // Prevent the update method from changing this value that we'll use later
|
||||
});
|
||||
|
||||
certificate.meta = row.meta;
|
||||
certificate.meta = meta;
|
||||
await internalCertificate.writeCustomCert(certificate);
|
||||
return _.pick(row.meta, internalCertificate.allowedSslFiles);
|
||||
// Only echo back what was uploaded in this request, never the stored key
|
||||
return _.pick(meta, uploaded);
|
||||
},
|
||||
|
||||
/**
|
||||
* Throws unless the private key is the one the certificate was issued for, by comparing the
|
||||
* public key derived from each.
|
||||
*
|
||||
* @param {String} certificate PEM certificate (the leaf comes first)
|
||||
* @param {String} privateKey PEM private key
|
||||
* @returns {Promise}
|
||||
*/
|
||||
checkKeyMatchesCertificate: async (certificate, privateKey) => {
|
||||
const certPath = await tempWrite(certificate);
|
||||
const keyPath = await tempWrite(privateKey);
|
||||
try {
|
||||
const [certPub, keyPub] = await Promise.all([
|
||||
utils.execFile("openssl", ["x509", "-in", certPath, "-pubkey", "-noout"]),
|
||||
utils.execFile("openssl", ["pkey", "-in", keyPath, "-pubout"]),
|
||||
]);
|
||||
if (!certPub.trim() || certPub.trim() !== keyPub.trim()) {
|
||||
throw new error.ValidationError("Certificate does not match the Certificate Key");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof error.ValidationError) {
|
||||
throw err;
|
||||
}
|
||||
throw new error.ValidationError("Could not check that the Certificate matches the Certificate Key");
|
||||
} finally {
|
||||
fs.rmSync(certPath, { force: true });
|
||||
fs.rmSync(keyPath, { force: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -811,7 +1083,7 @@ const internalCertificate = {
|
||||
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
logger.success(result);
|
||||
return result;
|
||||
},
|
||||
@@ -860,7 +1132,8 @@ const internalCertificate = {
|
||||
if (hasConfigArg) {
|
||||
args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation);
|
||||
}
|
||||
if (certificate.meta.propagation_seconds !== undefined) {
|
||||
// The route53 plugin waits for the change through the AWS API and has no propagation option
|
||||
if (certificate.meta.propagation_seconds !== undefined && certificate.meta.dns_provider !== "route53") {
|
||||
args.push(
|
||||
`--${dnsPlugin.full_plugin_name}-propagation-seconds`,
|
||||
certificate.meta.propagation_seconds.toString(),
|
||||
@@ -878,7 +1151,12 @@ const internalCertificate = {
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(
|
||||
args,
|
||||
adds.opts,
|
||||
certificate.meta.dns_provider,
|
||||
certificate.meta.dns_provider_credentials,
|
||||
);
|
||||
logger.info(result);
|
||||
return result;
|
||||
} finally {
|
||||
@@ -904,7 +1182,8 @@ const internalCertificate = {
|
||||
* @param {Number} data.id
|
||||
* @returns {Promise}
|
||||
*/
|
||||
renew: async (access, data) => {
|
||||
renew: async (access, data, options = {}) => {
|
||||
const reload = options.reload !== false;
|
||||
await access.can("certificates:update", data);
|
||||
const certificate = await internalCertificate.get(access, data);
|
||||
|
||||
@@ -930,12 +1209,34 @@ const internalCertificate = {
|
||||
meta: updatedCertificate,
|
||||
});
|
||||
|
||||
// The renewal has succeeded and been recorded at this point. A reload failure
|
||||
// (e.g. an unrelated host's config is broken) must not turn it into an error;
|
||||
// nginx picks the new files up on the next successful reload.
|
||||
if (reload) {
|
||||
await internalCertificate.reloadAfterRenew();
|
||||
}
|
||||
|
||||
return updatedCertificate;
|
||||
}
|
||||
|
||||
throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");
|
||||
},
|
||||
|
||||
/**
|
||||
* Reloads nginx so it serves renewed certificate files. Errors are logged, not thrown.
|
||||
*
|
||||
* @returns {Promise<Boolean>} whether the reload succeeded
|
||||
*/
|
||||
reloadAfterRenew: async () => {
|
||||
try {
|
||||
await internalNginx.reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Certificate renewed but nginx could not be reloaded: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} certificate the certificate row
|
||||
* @returns {Promise}
|
||||
@@ -972,7 +1273,7 @@ const internalCertificate = {
|
||||
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
logger.info(result);
|
||||
return result;
|
||||
},
|
||||
@@ -1056,7 +1357,7 @@ const internalCertificate = {
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts, certificate.meta.dns_provider, credentials);
|
||||
logger.info(result);
|
||||
return result;
|
||||
} finally {
|
||||
@@ -1099,7 +1400,7 @@ const internalCertificate = {
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`);
|
||||
logger.info(result);
|
||||
return result;
|
||||
|
||||
@@ -54,6 +54,8 @@ const internalDeadHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
|
||||
// Add to audit log
|
||||
@@ -130,6 +132,9 @@ const internalDeadHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, data, row);
|
||||
|
||||
if (createCertificate) {
|
||||
const cert = await internalCertificate.createQuickCertificate(access, {
|
||||
domain_names: data.domain_names || row.domain_names,
|
||||
|
||||
@@ -1,10 +1,110 @@
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import deadHostModel from "../models/dead_host.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import redirectionHostModel from "../models/redirection_host.js";
|
||||
|
||||
const internalHost = {
|
||||
/**
|
||||
* Only admins may set or change advanced_config (raw nginx config). Throws for anyone else.
|
||||
* On create (no `row`), any non-empty advanced_config is refused, including on locations.
|
||||
* On update, the host's advanced_config must be unchanged (or omitted), and locations must
|
||||
* only carry advanced configs the host already has (see assertLocationAdvancedConfigUnchanged).
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data incoming host data; data.locations may be filled in on update
|
||||
* @param {Object} [row] the existing host, when updating
|
||||
* @throws {PermissionError}
|
||||
*/
|
||||
assertAdvancedConfigAllowed: (access, data, row) => {
|
||||
if (access.isAdmin?.()) {
|
||||
return;
|
||||
}
|
||||
const deny = () => {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
};
|
||||
|
||||
if (!row) {
|
||||
const hasConfig = (v) => typeof v === "string" && v.trim() !== "";
|
||||
if (hasConfig(data.advanced_config)) {
|
||||
deny();
|
||||
}
|
||||
if (Array.isArray(data.locations) && data.locations.some((loc) => hasConfig(loc?.advanced_config))) {
|
||||
deny();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.advanced_config !== "undefined" && data.advanced_config !== row.advanced_config) {
|
||||
deny();
|
||||
}
|
||||
if (Array.isArray(data.locations)) {
|
||||
internalHost.assertLocationAdvancedConfigUnchanged(data.locations, row.locations);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* For non-admins: every location's advanced_config must be empty or one that already exists
|
||||
* on this host. Matching is by value, not path, so renaming or reordering a location that
|
||||
* carries admin-written config is allowed, but new or edited config is not. Each existing
|
||||
* config can be used once (no duplicating it onto other paths), and same-path matches are
|
||||
* taken first. Clearing a config is allowed: deleting the location is, and has the same
|
||||
* effect, and a location deleted and re-added at the same path arrives with an empty config.
|
||||
* A location that omits advanced_config keeps the one at its path, unless another location
|
||||
* explicitly carries it. Mutates `locations`.
|
||||
*
|
||||
* @param {Array} locations incoming locations
|
||||
* @param {Array} [existingLocations] the host's current locations
|
||||
* @throws {PermissionError}
|
||||
*/
|
||||
assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => {
|
||||
const existing = Array.isArray(existingLocations) ? existingLocations : [];
|
||||
const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || "";
|
||||
const pool = existing.map((l) => l.advanced_config || "").filter(Boolean);
|
||||
const take = (value) => {
|
||||
const i = pool.indexOf(value);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
pool.splice(i, 1);
|
||||
return true;
|
||||
};
|
||||
const deny = () => {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
};
|
||||
|
||||
// Pass 1: locations that keep the config at their own path
|
||||
const unresolved = [];
|
||||
const omitted = [];
|
||||
for (const loc of locations) {
|
||||
const atPath = advAtPath(loc.path);
|
||||
if (typeof loc.advanced_config === "undefined") {
|
||||
omitted.push(loc);
|
||||
} else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) {
|
||||
// unchanged
|
||||
} else {
|
||||
unresolved.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: renamed/moved locations may carry an existing config not already claimed, or
|
||||
// none at all
|
||||
for (const loc of unresolved) {
|
||||
if (loc.advanced_config && !take(loc.advanced_config)) {
|
||||
deny();
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: locations that omit advanced_config keep the one at their path, if no location
|
||||
// that names it explicitly has claimed it. Running this last stops a new location at an
|
||||
// old path taking the config away from the location it was renamed to.
|
||||
for (const loc of omitted) {
|
||||
const atPath = advAtPath(loc.path);
|
||||
loc.advanced_config = atPath && take(atPath) ? atPath : "";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes sure that the ssl_* and hsts_* fields play nicely together.
|
||||
* ie: if there is no cert, then force_ssl is off.
|
||||
@@ -79,6 +179,9 @@ const internalHost = {
|
||||
redirection_hosts: [],
|
||||
};
|
||||
|
||||
// No relations are eager-loaded here: this scans every host just to match domains, and
|
||||
// the few that match are rendered through internalNginx.generateConfig(), which loads
|
||||
// their certificate and access list itself (loadRenderRelations).
|
||||
const proxyRes = await proxyHostModel.query().where("is_deleted", 0);
|
||||
responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames);
|
||||
responseObject.total_count += responseObject.proxy_hosts.length;
|
||||
|
||||
+144
-19
@@ -4,8 +4,11 @@ import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import { debug, nginx as logger } from "../logger.js";
|
||||
import accessListModel from "../models/access_list.js";
|
||||
import certificateModel from "../models/certificate.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -38,9 +41,30 @@ const internalNginx = {
|
||||
return internalNginx.deleteConfig(host_type, host, true);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.generateConfig(host_type, host);
|
||||
return internalNginx.generateConfig(host_type, host).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
if (!err.unsafeNginxValue) {
|
||||
throw err;
|
||||
}
|
||||
// Nothing was written. Report it on the host like a failed nginx test.
|
||||
logger.error(err.message);
|
||||
combined_meta = _.assign({}, host.meta, {
|
||||
nginx_online: false,
|
||||
nginx_err: err.message,
|
||||
});
|
||||
return model
|
||||
.query()
|
||||
.where("id", host.id)
|
||||
.patch({ meta: combined_meta })
|
||||
.then(() => false);
|
||||
},
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
.then((generated) => {
|
||||
if (!generated) {
|
||||
return;
|
||||
}
|
||||
// Test nginx again and update meta with result
|
||||
return internalNginx
|
||||
.test()
|
||||
@@ -168,6 +192,38 @@ const internalNginx = {
|
||||
host.locations[i],
|
||||
);
|
||||
|
||||
// A location with its own access list overrides the host's,
|
||||
// otherwise it inherits the host's access list
|
||||
let locationAccessList = null;
|
||||
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
|
||||
locationAccessList = await accessListModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", locationCopy.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
}
|
||||
if (locationAccessList) {
|
||||
assertAccessListRenderable(
|
||||
`Proxy Host #${host.id} location ${locationCopy.path}`,
|
||||
locationAccessList,
|
||||
);
|
||||
locationCopy.access_list = locationAccessList;
|
||||
} else if (
|
||||
locationCopy.access_list_id > 0 &&
|
||||
locationCopy.access_list_id !== host.access_list?.id
|
||||
) {
|
||||
// The location names its own list but it's gone. Don't fall back to the
|
||||
// host's (possibly less restrictive) list; _access.conf denies all instead.
|
||||
logger.error(
|
||||
`Proxy Host #${host.id} location "${locationCopy.path}" references Access List #${locationCopy.access_list_id} which does not exist, all access will be denied`,
|
||||
);
|
||||
locationCopy.access_list = null;
|
||||
} else {
|
||||
locationCopy.access_list_id = host.access_list_id;
|
||||
locationCopy.access_list = host.access_list;
|
||||
}
|
||||
|
||||
if (locationCopy.forward_host.indexOf("/") > -1) {
|
||||
const splitted = locationCopy.forward_host.split("/");
|
||||
|
||||
@@ -179,7 +235,9 @@ const internalNginx = {
|
||||
}
|
||||
};
|
||||
|
||||
locationRendering().then(() => resolve(renderedLocations));
|
||||
locationRendering()
|
||||
.then(() => resolve(renderedLocations))
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -188,11 +246,16 @@ const internalNginx = {
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
generateConfig: (host_type, host_row) => {
|
||||
generateConfig: async (host_type, host_row) => {
|
||||
// Prevent modifying the original object:
|
||||
const host = JSON.parse(JSON.stringify(host_row));
|
||||
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
|
||||
|
||||
await internalNginx.loadRenderRelations(nice_host_type, host);
|
||||
|
||||
// Values are written into the config as-is; refuse any that could add directives
|
||||
assertHostRenderable(nice_host_type, host);
|
||||
|
||||
debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2));
|
||||
|
||||
const renderEngine = utils.getRenderEngine();
|
||||
@@ -255,26 +318,79 @@ const internalNginx = {
|
||||
// Set the IPv6 setting for the host
|
||||
host.ipv6 = internalNginx.ipv6Enabled();
|
||||
|
||||
locationsPromise.then(() => {
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then((config_text) => {
|
||||
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
||||
debug(logger, "Wrote config:", filename, config_text);
|
||||
locationsPromise
|
||||
.then(() => {
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then((config_text) => {
|
||||
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
||||
debug(logger, "Wrote config:", filename, config_text);
|
||||
|
||||
// Restore locations array
|
||||
host.locations = origLocations;
|
||||
// Restore locations array
|
||||
host.locations = origLocations;
|
||||
|
||||
resolve(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
debug(logger, `Could not write ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
});
|
||||
resolve(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
debug(logger, `Could not write ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
debug(logger, `Could not render locations for ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The templates rely on the certificate and access list relations being loaded.
|
||||
* Not every caller fetches them (or fetches them fully), and when they're missing
|
||||
* the config silently renders without SSL or without any access rules (#5919).
|
||||
* Load anything that is referenced but not loaded onto the given host object.
|
||||
*
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadRenderRelations: async (host_type, host) => {
|
||||
if (host.certificate_id > 0 && host.certificate?.id !== host.certificate_id) {
|
||||
const certificate = await certificateModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", host.certificate_id)
|
||||
.first();
|
||||
host.certificate = certificate ? JSON.parse(JSON.stringify(certificate)) : null;
|
||||
}
|
||||
|
||||
if (host_type === "proxy_host" && host.access_list_id > 0) {
|
||||
if (
|
||||
host.access_list?.id !== host.access_list_id ||
|
||||
!Array.isArray(host.access_list.clients) ||
|
||||
!Array.isArray(host.access_list.items)
|
||||
) {
|
||||
const accessList = await accessListModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", host.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
host.access_list = accessList ? JSON.parse(JSON.stringify(accessList)) : null;
|
||||
}
|
||||
|
||||
if (!host.access_list) {
|
||||
// _access.conf renders "deny all" when the list is missing, so fail closed
|
||||
logger.error(
|
||||
`Proxy Host #${host.id} references Access List #${host.access_list_id} which does not exist, all access will be denied`,
|
||||
);
|
||||
} else if (!host.access_list.clients.length && !host.access_list.items.length) {
|
||||
logger.warn(
|
||||
`Proxy Host #${host.id} uses Access List #${host.access_list_id} which has no clients or authorizations, no access restrictions will apply`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* This generates a temporary nginx config listening on port 80 for the domain names listed
|
||||
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
|
||||
@@ -287,6 +403,15 @@ const internalNginx = {
|
||||
debug(logger, "Generating LetsEncrypt Request Config:", certificate);
|
||||
const renderEngine = utils.getRenderEngine();
|
||||
|
||||
const unsafe = (certificate.domain_names || []).find((name) => !isTokenSafe(name));
|
||||
if (typeof unsafe !== "undefined") {
|
||||
return Promise.reject(
|
||||
unsafeValueError(
|
||||
`Certificate #${certificate.id}: domain name ${JSON.stringify(unsafe)} is not allowed`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let template = null;
|
||||
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
|
||||
|
||||
+70
-100
@@ -2,8 +2,7 @@ import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import accessListModel from "../models/access_list.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
import internalCertificate from "./certificate.js";
|
||||
import internalHost from "./host.js";
|
||||
@@ -13,33 +12,6 @@ const omissions = () => {
|
||||
return ["is_deleted", "owner.is_deleted"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches access lists for each location that has its own access_list_id.
|
||||
* Attaches the expanded access_list object (with clients and items) to each location.
|
||||
*
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const fetchLocationAccessLists = async (host) => {
|
||||
if (!host.locations?.length) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < host.locations.length; i++) {
|
||||
const loc = host.locations[i];
|
||||
if (loc.access_list_id && loc.access_list_id > 0) {
|
||||
const accessList = await accessListModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", loc.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
if (accessList) {
|
||||
host.locations[i].access_list = accessList;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const internalProxyHost = {
|
||||
/**
|
||||
* @param {Access} access
|
||||
@@ -85,6 +57,8 @@ const internalProxyHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
})
|
||||
.then((row) => {
|
||||
@@ -111,29 +85,28 @@ const internalProxyHost = {
|
||||
expand: ["certificate", "owner", "access_list.[clients,items]"],
|
||||
});
|
||||
})
|
||||
.then(async (row) => {
|
||||
await fetchLocationAccessLists(row);
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||
return row;
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
// Audit log
|
||||
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
|
||||
|
||||
// Add to audit log
|
||||
return internalAuditLog
|
||||
.add(access, {
|
||||
action: "created",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: thisData,
|
||||
})
|
||||
.then(() => {
|
||||
.then(async (row) => {
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||
return row;
|
||||
});
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
// Audit log
|
||||
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
|
||||
|
||||
// Add to audit log
|
||||
return internalAuditLog
|
||||
.add(access, {
|
||||
action: "created",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: thisData,
|
||||
})
|
||||
.then(() => {
|
||||
return row;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -184,6 +157,9 @@ const internalProxyHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
|
||||
|
||||
if (createCertificate) {
|
||||
return internalCertificate
|
||||
.createQuickCertificate(access, {
|
||||
@@ -231,25 +207,24 @@ const internalProxyHost = {
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return internalProxyHost
|
||||
.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate", "access_list.[clients,items]"],
|
||||
})
|
||||
.then(async (row) => {
|
||||
if (!row.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return row;
|
||||
}
|
||||
await fetchLocationAccessLists(row);
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
||||
row.meta = new_meta;
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
|
||||
.then(() => {
|
||||
return internalProxyHost
|
||||
.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate", "access_list.[clients,items]"],
|
||||
})
|
||||
.then(async (row) => {
|
||||
if (!row.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return row;
|
||||
}
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
||||
row.meta = new_meta;
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -273,7 +248,7 @@ const internalProxyHost = {
|
||||
.first();
|
||||
|
||||
if (access_data.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
|
||||
}
|
||||
|
||||
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
|
||||
@@ -353,41 +328,36 @@ const internalProxyHost = {
|
||||
.then(() => {
|
||||
return internalProxyHost.get(access, {
|
||||
id: data.id,
|
||||
expand: ["certificate", "owner", "access_list"],
|
||||
expand: ["certificate", "owner", "access_list.[clients,items]"],
|
||||
});
|
||||
})
|
||||
.then(async (row) => {
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
if (row.enabled) {
|
||||
throw new errs.ValidationError("Host is already enabled");
|
||||
}
|
||||
.then(async (row) => {
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
if (row.enabled) {
|
||||
throw new errs.ValidationError("Host is already enabled");
|
||||
}
|
||||
|
||||
row.enabled = 1;
|
||||
row.enabled = 1;
|
||||
|
||||
await proxyHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
await proxyHostModel.query().where("id", row.id).patch({
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
await fetchLocationAccessLists(row);
|
||||
// Configure nginx
|
||||
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||
|
||||
// Configure nginx
|
||||
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "enabled",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: _.omit(row, omissions()),
|
||||
});
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "enabled",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: _.omit(row, omissions()),
|
||||
return true;
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -459,7 +429,7 @@ const internalProxyHost = {
|
||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
|
||||
}
|
||||
|
||||
// Query is used for searching
|
||||
@@ -485,18 +455,18 @@ const internalProxyHost = {
|
||||
*
|
||||
* @param {Number} user_id
|
||||
* @param {String} visibility
|
||||
* @param {Object} [permissionsMeta] the user's permissions meta, as already loaded by access
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: (user_id, visibility) => {
|
||||
getCount: async (user_id, visibility, permissionsMeta) => {
|
||||
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", user_id);
|
||||
scopeToOwnerOrAllowed(query, user_id, permissionsMeta);
|
||||
}
|
||||
|
||||
return query.first().then((row) => {
|
||||
return Number.parseInt(row.count, 10);
|
||||
});
|
||||
const row = await query.first();
|
||||
return Number.parseInt(row.count, 10);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ const internalRedirectionHost = {
|
||||
|
||||
// Fix for db field not having a default value
|
||||
// for this optional field.
|
||||
if (typeof data.advanced_config === "undefined") {
|
||||
data.advanced_config = "";
|
||||
if (typeof thisData.advanced_config === "undefined") {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
})
|
||||
.then((row) => {
|
||||
@@ -155,6 +157,9 @@ const internalRedirectionHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
|
||||
|
||||
if (createCertificate) {
|
||||
return internalCertificate
|
||||
.createQuickCertificate(access, {
|
||||
|
||||
@@ -15,7 +15,11 @@ const internalReport = {
|
||||
const userId = access.token.getUserId(1);
|
||||
|
||||
const promises = [
|
||||
internalProxyHost.getCount(userId, access_data.permission_visibility),
|
||||
internalProxyHost.getCount(
|
||||
userId,
|
||||
access_data.permission_visibility,
|
||||
access.getPermissions?.()?.meta,
|
||||
),
|
||||
internalRedirectionHost.getCount(userId, access_data.permission_visibility),
|
||||
internalStream.getCount(userId, access_data.permission_visibility),
|
||||
internalDeadHost.getCount(userId, access_data.permission_visibility),
|
||||
|
||||
+13
-11
@@ -34,18 +34,20 @@ export default {
|
||||
.andWhere("is_disabled", 0)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
}
|
||||
// An unknown, disabled or passwordless account goes through the same queries and the same password check
|
||||
// as a real one, and fails with the same error: answering any sooner or any differently would tell anyone
|
||||
// trying addresses which ones have an account. There is no user 0, so its lookup finds nothing. An empty
|
||||
// secret, which creating a user through the API can store, is no hash: bcrypt would reject it at once.
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", "=", user?.id ?? 0)
|
||||
.where("type", "=", "password")
|
||||
.first();
|
||||
|
||||
const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
}
|
||||
|
||||
const valid = await auth.verifyPassword(data.secret);
|
||||
if (!valid) {
|
||||
const valid = auth?.secret
|
||||
? await auth.verifyPassword(data.secret)
|
||||
: await authModel.verifyDummyPassword(data.secret);
|
||||
if (!user || !valid) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import authModel from "../models/auth.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import userModel from "../models/user.js";
|
||||
import userPermissionModel from "../models/user_permission.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
@@ -444,7 +445,7 @@ const internalUser = {
|
||||
return internalUser.get(access, { id: data.id });
|
||||
})
|
||||
.then((user) => {
|
||||
if (user.id !== data.id) {
|
||||
if (Number(user.id) !== Number(data.id)) {
|
||||
// Sanity check that something crazy hasn't happened
|
||||
throw new errs.InternalValidationError(
|
||||
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
|
||||
@@ -454,21 +455,23 @@ const internalUser = {
|
||||
return user;
|
||||
})
|
||||
.then((user) => {
|
||||
const permData = _.omit(data, ["id"]);
|
||||
// Get perms row, patch if it exists
|
||||
return userPermissionModel
|
||||
.query()
|
||||
.where("user_id", user.id)
|
||||
.first()
|
||||
.then((existing_auth) => {
|
||||
.then(async (existing_auth) => {
|
||||
permData.meta = await internalUser.normalisePermissionMeta(existing_auth, permData);
|
||||
if (existing_auth) {
|
||||
// patch
|
||||
return userPermissionModel
|
||||
.query()
|
||||
.where("user_id", user.id)
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data));
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
|
||||
}
|
||||
// insert
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
|
||||
})
|
||||
.then((permissions) => {
|
||||
// Add to Audit Log
|
||||
@@ -488,6 +491,36 @@ const internalUser = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The permission meta to store. Proxy host grants (meta.proxy_host_ids) only apply while
|
||||
* visibility is "user" and proxy hosts are not hidden, so they're cleared otherwise; kept,
|
||||
* they would silently come back when proxy hosts are shown again. Ids of hosts that no
|
||||
* longer exist are dropped. `permData` may be partial, so the stored values fill the gaps.
|
||||
*
|
||||
* @param {Object} [existing] the stored user_permission row
|
||||
* @param {Object} permData incoming permission fields
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
normalisePermissionMeta: async (existing, permData) => {
|
||||
const effective = _.assign(
|
||||
{ visibility: "user", proxy_hosts: "hidden" },
|
||||
existing || {},
|
||||
_.omit(permData, ["meta"]),
|
||||
);
|
||||
const meta = _.assign({}, existing?.meta || {}, permData.meta || {});
|
||||
const requested = Array.isArray(meta.proxy_host_ids) ? _.uniq(meta.proxy_host_ids) : [];
|
||||
|
||||
if (effective.visibility !== "user" || effective.proxy_hosts === "hidden" || !requested.length) {
|
||||
meta.proxy_host_ids = [];
|
||||
return meta;
|
||||
}
|
||||
|
||||
const rows = await proxyHostModel.query().select("id").where("is_deleted", 0).whereIn("id", requested);
|
||||
const found = new Set(rows.map((r) => r.id));
|
||||
meta.proxy_host_ids = requested.filter((id) => found.has(id));
|
||||
return meta;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
|
||||
+10
-3
@@ -13,7 +13,7 @@ import Ajv from "ajv/dist/2020.js";
|
||||
import _ from "lodash";
|
||||
import { access as logger } from "../logger.js";
|
||||
import authModel from "../models/auth.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
|
||||
import TokenModel from "../models/token.js";
|
||||
import userModel from "../models/user.js";
|
||||
import permsSchema from "./access/permissions.json" with { type: "json" };
|
||||
@@ -139,7 +139,7 @@ export default function (tokenString) {
|
||||
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
|
||||
|
||||
if (permissions.visibility === "user") {
|
||||
query.andWhere("owner_user_id", tokenUserId);
|
||||
scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
@@ -213,6 +213,11 @@ export default function (tokenString) {
|
||||
return {
|
||||
token: Token,
|
||||
|
||||
getPermissions: () => permissions,
|
||||
getRoles: () => userRoles,
|
||||
hasRole: (role) => userRoles.includes(role),
|
||||
isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Boolean} [allowInternal]
|
||||
@@ -220,7 +225,8 @@ export default function (tokenString) {
|
||||
*/
|
||||
load: async (allowInternal) => {
|
||||
if (tokenString) {
|
||||
return await Token.load(tokenString);
|
||||
await this.init();
|
||||
return tokenData;
|
||||
}
|
||||
allowInternalAccess = allowInternal;
|
||||
return allowInternal || null;
|
||||
@@ -255,6 +261,7 @@ export default function (tokenString) {
|
||||
permission_streams: permissions.streams,
|
||||
permission_access_lists: permissions.access_lists,
|
||||
permission_certificates: permissions.certificates,
|
||||
permission_meta: permissions.meta,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isCI } from "../config.js";
|
||||
|
||||
/**
|
||||
* In-memory fixed window rate limiter for authentication endpoints
|
||||
*/
|
||||
|
||||
// Keys longer than this are hashed, so a client can't make the store hold arbitrarily large
|
||||
// strings (keys can include request body values such as the login identity).
|
||||
const MAX_KEY_LENGTH = 128;
|
||||
// Entries per limiter. A flood of unique keys sweeps expired entries, then evicts the oldest,
|
||||
// instead of growing the store until the periodic cleanup runs.
|
||||
const MAX_STORE_SIZE = 50000;
|
||||
|
||||
// One store per limiter, so separate endpoints never share buckets
|
||||
const stores = new Set();
|
||||
|
||||
// Clean up old entries every 2 minutes
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const store of stores) {
|
||||
for (const [key, record] of store.entries()) {
|
||||
if (now - record.startTime > record.windowMs * 2) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
// Generated at container start (prepare/45-admin-port.sh) and sent by the admin nginx as
|
||||
// X-NPM-Peer-Secret alongside X-NPM-Peer-Addr. Unset (development, or a mounted production.conf)
|
||||
// means the header is never trusted.
|
||||
const peerSecret = Buffer.from(process.env.NPM_PEER_SECRET || "");
|
||||
|
||||
const hasPeerSecret = (value) => {
|
||||
if (!peerSecret.length || typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const given = Buffer.from(value);
|
||||
return given.length === peerSecret.length && crypto.timingSafeEqual(given, peerSecret);
|
||||
};
|
||||
|
||||
/**
|
||||
* The address to rate limit on: the TCP peer of the admin nginx, which it passes as
|
||||
* X-NPM-Peer-Addr ($realip_remote_addr). That header is only honoured from a loopback socket
|
||||
* carrying the startup secret, so it can only have come from the admin nginx: a proxy host or
|
||||
* stream forwarding to the backend port is also a loopback peer, but can't know the secret.
|
||||
* The realip-resolved client address is deliberately not used: nginx trusts X-Real-IP from
|
||||
* private ranges, so a client on the LAN or Docker network could pick a new address, and a fresh
|
||||
* bucket, on every request. Behind another proxy (including an NPM proxy host in front of the
|
||||
* admin UI) this resolves to that proxy's address.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @returns {String}
|
||||
*/
|
||||
export const getClientIp = (req) => {
|
||||
const socketAddr = req.socket?.remoteAddress || "unknown";
|
||||
const isLoopback = socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
|
||||
const trusted = isLoopback && hasPeerSecret(req.headers["x-npm-peer-secret"]);
|
||||
const peerAddr = trusted ? req.headers["x-npm-peer-addr"]?.trim() : null;
|
||||
return peerAddr || socketAddr;
|
||||
};
|
||||
|
||||
const makeRoom = (store, now) => {
|
||||
for (const [k, r] of store.entries()) {
|
||||
if (now - r.startTime > r.windowMs) {
|
||||
store.delete(k);
|
||||
}
|
||||
}
|
||||
// Maps iterate in insertion order, so this drops the oldest windows first
|
||||
for (const k of store.keys()) {
|
||||
if (store.size < MAX_STORE_SIZE) {
|
||||
break;
|
||||
}
|
||||
store.delete(k);
|
||||
}
|
||||
};
|
||||
|
||||
const boundedKey = (key) =>
|
||||
key.length > MAX_KEY_LENGTH ? `sha256:${crypto.createHash("sha256").update(key).digest("hex")}` : key;
|
||||
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {Number} [options.windowMs]
|
||||
* @param {Number} [options.max]
|
||||
* @param {String} [options.message]
|
||||
* @param {Boolean} [options.skipSuccessfulRequests]
|
||||
* @param {Function} [options.keyGenerator] (req, clientIp) => String. Defaults to the client IP.
|
||||
* Including the target account in the key stops every
|
||||
* user being locked out when many clients share an IP
|
||||
* (e.g. the admin UI behind another reverse proxy).
|
||||
*/
|
||||
export default function createRateLimiter(options = {}) {
|
||||
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
|
||||
const max = options.max || 10; // 10 requests default
|
||||
const message = options.message || "Too many attempts from this IP, please try again later.";
|
||||
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
|
||||
const keyGenerator = options.keyGenerator || ((_req, clientIp) => clientIp);
|
||||
|
||||
const store = new Map();
|
||||
stores.add(store);
|
||||
|
||||
return (req, res, next) => {
|
||||
// Bypass rate limiting in CI, test environments, or when explicitly disabled
|
||||
if (
|
||||
isCI() ||
|
||||
process.env.CI === "true" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DISABLE_RATE_LIMIT === "true"
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const key = boundedKey(String(keyGenerator(req, getClientIp(req))));
|
||||
|
||||
const now = Date.now();
|
||||
let record = store.get(key);
|
||||
|
||||
if (!record || now - record.startTime > windowMs) {
|
||||
record = {
|
||||
count: 0,
|
||||
startTime: now,
|
||||
windowMs,
|
||||
};
|
||||
if (!store.has(key) && store.size >= MAX_STORE_SIZE) {
|
||||
makeRoom(store, now);
|
||||
}
|
||||
store.set(key, record);
|
||||
}
|
||||
|
||||
record.count++;
|
||||
|
||||
if (skipSuccessfulRequests) {
|
||||
const currentRecord = record;
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode < 400 && currentRecord.count > 0) {
|
||||
currentRecord.count--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (record.count > max) {
|
||||
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
|
||||
return res.status(429).json({
|
||||
error: {
|
||||
code: 429,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import net from "node:net";
|
||||
import errs from "./error.js";
|
||||
|
||||
/**
|
||||
* Rules for user-supplied values that the templates write into nginx config as-is.
|
||||
*
|
||||
* Anything that ends or changes an nginx token (whitespace, ";", "{", "}", quotes, "#" which
|
||||
* starts a comment, and "\" which escapes the next character) would let the value add its own
|
||||
* directives, which only admins may do (advanced_config). The API schema rejects these values
|
||||
* with the same patterns (a test keeps the two in step). generateConfig() checks stored values
|
||||
* again, so rows saved before these rules can't add directives either. That check is only about
|
||||
* nginx structure, not format: a stored value in a format the API no longer accepts, but which
|
||||
* can't add directives, still renders.
|
||||
*
|
||||
* Patterns are strings so they can be compared with the JSON schema; compile them with the "u"
|
||||
* flag, as ajv does.
|
||||
*/
|
||||
|
||||
// One character that can't end or change an unquoted nginx token
|
||||
const TOKEN_CHAR = String.raw`[^\s\p{Cc};{}"'#\\]`;
|
||||
// Host part of a forward host: also no "$" (no variables in the upstream address) and no "/"
|
||||
const HOST_CHARS = String.raw`[^\s\p{Cc};{}"'#\\$/]+`;
|
||||
// The existing domain name rule ("a.b.c", no "*", no empty labels), written so it can't
|
||||
// backtrack exponentially
|
||||
const DOMAIN = String.raw`[^.*]+(?:\.[^.*]+)*\.?[^.]`;
|
||||
|
||||
export const patterns = {
|
||||
// server_name entries. The old character blacklist plus whitespace and control characters.
|
||||
domainName: "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$",
|
||||
// proxy_host forward_host and location forward_host: a host, optionally followed by a path
|
||||
// (locations split "host/path" at render time)
|
||||
forwardHost: String.raw`^${HOST_CHARS}(?:/${TOKEN_CHAR}*)?$`,
|
||||
// location forward_path, appended straight after the port
|
||||
forwardPath: String.raw`^${TOKEN_CHAR}*$`,
|
||||
// location path: an optional modifier, then one token, either unquoted (a "\" must escape a
|
||||
// visible character, so it can't swallow the space before "{") or double quoted. A modifier
|
||||
// on its own isn't a location.
|
||||
locationPath: String.raw`^(?!(?:=|~\*?|\^~)$)(?:(?:=|~\*?|\^~) +)?(?:"(?:[^"\\\p{Cc}]|\\[^\p{Cc}])+"|(?:[^\s\p{Cc};{}"'#\\]|\\[^\s\p{Cc}])+)$`,
|
||||
// redirection forward_domain_name, e.g. "example.com:8443/path"
|
||||
redirectDomain: String.raw`^(?=${TOKEN_CHAR}+$)${DOMAIN}$`,
|
||||
// stream forwarding_host when it is a domain name (IPs are matched separately)
|
||||
streamDomain: String.raw`^(?=${HOST_CHARS}$)${DOMAIN}$`,
|
||||
// access list client address: IPv6 with an optional prefix. No zone id ("%eth0"): nginx's
|
||||
// allow/deny rejects them, and the old "%.+" let anything through, including "; allow all"
|
||||
clientIpv6: String.raw`^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$`,
|
||||
// htpasswd user name: a ":" or a line break would corrupt the file
|
||||
accessUsername: String.raw`^[^:\p{Cc}]+$`,
|
||||
};
|
||||
|
||||
const re = Object.fromEntries(Object.entries(patterns).map(([k, v]) => [k, new RegExp(v, "u")]));
|
||||
const unsafeToken = /[\s\p{Cc};{}"'#\\]/u;
|
||||
const ipv4WithPrefix = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/;
|
||||
|
||||
export const isValid = (name, value) => typeof value === "string" && re[name].test(value);
|
||||
|
||||
export const isValidClientAddress = (value) =>
|
||||
typeof value === "string" && (value === "all" || ipv4WithPrefix.test(value) || re.clientIpv6.test(value));
|
||||
|
||||
export const isValidStreamHost = (value) =>
|
||||
typeof value === "string" && (net.isIP(value) !== 0 || re.streamDomain.test(value));
|
||||
|
||||
/**
|
||||
* Thrown when a stored value can't be written into nginx config safely
|
||||
*
|
||||
* @param {String} message
|
||||
*/
|
||||
export const unsafeValueError = (message) => {
|
||||
const err = new errs.ConfigurationError(message);
|
||||
err.unsafeNginxValue = true;
|
||||
return err;
|
||||
};
|
||||
|
||||
const describe = (value) => JSON.stringify(String(value)).slice(0, 80);
|
||||
|
||||
/**
|
||||
* @param {String} label e.g. "Proxy Host #3"
|
||||
* @param {Object} [accessList]
|
||||
* @throws {ConfigurationError}
|
||||
*/
|
||||
export const assertAccessListRenderable = (label, accessList) => {
|
||||
for (const client of accessList?.clients || []) {
|
||||
// "address" is rendered unquoted ("allow <address>;")
|
||||
if (typeof client.address !== "string" || unsafeToken.test(client.address)) {
|
||||
throw unsafeValueError(`${label}: access list client address ${describe(client.address)} is not allowed`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Can be written into config without ending or changing a token
|
||||
export const isTokenSafe = (value) => typeof value === "string" && value.length > 0 && !unsafeToken.test(value);
|
||||
|
||||
/**
|
||||
* Checks every user-supplied value the host's template renders. Call before rendering.
|
||||
*
|
||||
* @param {String} hostType proxy_host, redirection_host, dead_host, stream or default
|
||||
* @param {Object} host with access_list loaded, for proxy hosts
|
||||
* @throws {ConfigurationError}
|
||||
*/
|
||||
export const assertHostRenderable = (hostType, host) => {
|
||||
const label = `${hostType.replace(/_/g, " ")} #${host.id}`;
|
||||
const check = (ok, what, value) => {
|
||||
if (!ok) {
|
||||
throw unsafeValueError(`${label}: ${what} ${describe(value)} is not allowed in nginx config`);
|
||||
}
|
||||
};
|
||||
|
||||
if (hostType !== "stream" && hostType !== "default") {
|
||||
for (const name of host.domain_names || []) {
|
||||
check(isTokenSafe(name), "domain name", name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hostType === "proxy_host") {
|
||||
check(isTokenSafe(host.forward_host), "forward host", host.forward_host);
|
||||
assertAccessListRenderable(label, host.access_list);
|
||||
for (const loc of host.locations || []) {
|
||||
// The path is several tokens (modifier, then a quoted or unquoted URI): its rule is the
|
||||
// structural one
|
||||
check(isValid("locationPath", loc.path), "location path", loc.path);
|
||||
check(isTokenSafe(loc.forward_host), "location forward host", loc.forward_host);
|
||||
if (loc.forward_path) {
|
||||
check(isTokenSafe(loc.forward_path), "location forward path", loc.forward_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostType === "redirection_host") {
|
||||
check(isTokenSafe(host.forward_domain_name), "forward domain", host.forward_domain_name);
|
||||
}
|
||||
|
||||
if (hostType === "stream") {
|
||||
check(isTokenSafe(host.forwarding_host), "forwarding host", host.forwarding_host);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import net from "node:net";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import errs from "../error.js";
|
||||
|
||||
@@ -9,6 +10,8 @@ const ajv = new Ajv({
|
||||
coerceTypes: true,
|
||||
});
|
||||
|
||||
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||
|
||||
/**
|
||||
* @param {Object} schema
|
||||
* @param {Object} payload
|
||||
@@ -32,6 +35,13 @@ const apiValidator = async (schema, payload /*, description*/) => {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Keep ajv's wording, which clients may match on, but not the regex it quotes: some are long
|
||||
// and mean nothing to a user
|
||||
for (const e of validate.errors || []) {
|
||||
if (e.keyword === "pattern") {
|
||||
e.message = "must match pattern";
|
||||
}
|
||||
}
|
||||
const message = ajv.errorsText(validate.errors);
|
||||
const err = new errs.ValidationError(message);
|
||||
err.debug = { validationErrors: validate.errors, payload };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import net from "node:net";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import _ from "lodash";
|
||||
import commonDefinitions from "../../schema/common.json" with { type: "json" };
|
||||
@@ -14,6 +15,8 @@ const ajv = new Ajv({
|
||||
schemas: [commonDefinitions],
|
||||
});
|
||||
|
||||
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} schema
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { migrate as logger } from "../logger.js";
|
||||
|
||||
const migrateName = "user_permission_meta";
|
||||
|
||||
/**
|
||||
* Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const up = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.json("meta").nullable();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const down = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Down...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.dropColumn("meta");
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
export { up, down };
|
||||
@@ -31,6 +31,10 @@ class AccessList extends Model {
|
||||
|
||||
$parseDatabaseJson(json) {
|
||||
const thisJson = super.$parseDatabaseJson(json);
|
||||
// Postgres returns COUNT() as a string
|
||||
if (typeof thisJson.proxy_host_count === "string") {
|
||||
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
|
||||
}
|
||||
return convertIntFieldsToBool(thisJson, boolFields);
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
// Objection Docs:
|
||||
// http://vincit.github.io/objection.js/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import { Model } from "objection";
|
||||
import db from "../db.js";
|
||||
@@ -11,10 +12,15 @@ import User from "./user.js";
|
||||
Model.knex(db());
|
||||
|
||||
const boolFields = ["is_deleted"];
|
||||
const saltRounds = 13;
|
||||
|
||||
// A hash of a random value, for a login that names an account with no password to check it against. Hashed
|
||||
// with the same cost as real passwords, and made at startup so the first such login takes no longer either.
|
||||
const dummyHash = bcrypt.hash(crypto.randomBytes(32).toString("hex"), saltRounds);
|
||||
|
||||
function encryptPassword() {
|
||||
if (this.type === "password" && this.secret) {
|
||||
return bcrypt.hash(this.secret, 13).then((hash) => {
|
||||
return bcrypt.hash(this.secret, saltRounds).then((hash) => {
|
||||
this.secret = hash;
|
||||
});
|
||||
}
|
||||
@@ -60,6 +66,18 @@ class Auth extends Model {
|
||||
return bcrypt.compare(password, this.secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take as long as verifyPassword does, then fail. bcrypt is nearly all of the time a login takes, so a login
|
||||
* that skipped it for an unknown account would answer far sooner, and say which accounts exist.
|
||||
*
|
||||
* @param {String} password
|
||||
* @returns {Promise<false>}
|
||||
*/
|
||||
static async verifyDummyPassword(password) {
|
||||
await bcrypt.compare(password, await dummyHash);
|
||||
return false;
|
||||
}
|
||||
|
||||
static get name() {
|
||||
return "Auth";
|
||||
}
|
||||
|
||||
@@ -124,4 +124,23 @@ class ProxyHost extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts a proxy host query to hosts the user owns or has been granted by id
|
||||
* (user permission meta.proxy_host_ids).
|
||||
*
|
||||
* @param {Object} query objection query on proxy_host
|
||||
* @param {Number} userId
|
||||
* @param {Object} [permissionMeta] the user's permission meta
|
||||
* @returns {Object} the query
|
||||
*/
|
||||
export const scopeToOwnerOrAllowed = (query, userId, permissionMeta) => {
|
||||
const allowedIds = permissionMeta?.proxy_host_ids || [];
|
||||
return query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", userId);
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default ProxyHost;
|
||||
@@ -24,6 +24,10 @@ class UserPermission extends Model {
|
||||
static get tableName() {
|
||||
return "user_permission";
|
||||
}
|
||||
|
||||
static get jsonAttributes() {
|
||||
return ["meta"];
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPermission;
|
||||
+13
-12
@@ -8,42 +8,43 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "biome lint",
|
||||
"test": "node --test test/",
|
||||
"prettier": "biome format --write .",
|
||||
"validate-schema": "node validate-schema.js",
|
||||
"regenerate-config": "node scripts/regenerate-config"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^16.0.2",
|
||||
"@apidevtools/json-schema-ref-parser": "^16.0.6",
|
||||
"ajv": "^8.20.0",
|
||||
"archiver": "^8.0.0",
|
||||
"batchflow": "^0.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "5.6.2",
|
||||
"body-parser": "^2.4.0",
|
||||
"chalk": "6.0.1",
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"express": "^5.3.0",
|
||||
"express-fileupload": "^1.6.0",
|
||||
"gravatar": "^1.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knex": "3.3.0",
|
||||
"liquidjs": "10.29.0",
|
||||
"liquidjs": "10.30.1",
|
||||
"lodash": "^4.18.1",
|
||||
"moment": "^2.31.0",
|
||||
"mysql2": "^3.24.4",
|
||||
"mysql2": "^3.24.6",
|
||||
"node-rsa": "^2.0.0",
|
||||
"objection": "3.1.5",
|
||||
"otplib": "^13.5.0",
|
||||
"objection": "4.4.3",
|
||||
"otplib": "^13.6.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.23.0",
|
||||
"pg": "^8.23.1",
|
||||
"proxy-agent": "^8.0.2",
|
||||
"signale": "1.4.0",
|
||||
"sqlite3": "^6.0.1",
|
||||
"temp-write": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "^13.0.0",
|
||||
"@biomejs/biome": "^2.5.10",
|
||||
"@apidevtools/swagger-parser": "^13.1.0",
|
||||
"@biomejs/biome": "^2.6.6",
|
||||
"nodemon": "^3.1.14"
|
||||
},
|
||||
"signale": {
|
||||
|
||||
@@ -62,7 +62,10 @@ router
|
||||
enum: ["system", "letsencrypt", "host"],
|
||||
},
|
||||
host_type: {
|
||||
anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }],
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{ type: "string", enum: ["proxy", "redirection", "dead", "stream"] },
|
||||
],
|
||||
},
|
||||
host_id: {
|
||||
anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }],
|
||||
@@ -76,7 +79,10 @@ router
|
||||
level: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{ type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] },
|
||||
{
|
||||
type: "string",
|
||||
enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"],
|
||||
},
|
||||
],
|
||||
},
|
||||
search: {
|
||||
|
||||
@@ -161,7 +161,7 @@ router
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await internalCertificate.validate({
|
||||
const result = await internalCertificate.validate(res.locals.access, {
|
||||
files: req.files,
|
||||
});
|
||||
res.status(200).send(result);
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
import express from "express";
|
||||
import internalToken from "../internal/token.js";
|
||||
import jwtdecode from "../lib/express/jwt-decode.js";
|
||||
import createRateLimiter from "../lib/express/rate-limit.js";
|
||||
import apiValidator from "../lib/validator/api.js";
|
||||
import { debug, express as logger } from "../logger.js";
|
||||
import { getValidationSchema } from "../schema/index.js";
|
||||
|
||||
// Two limits apply to logins. Per IP + target account: when the admin UI sits behind another
|
||||
// proxy every client can resolve to the same IP, and an IP-only bucket at this size would let
|
||||
// anyone lock out every user with a handful of bad logins.
|
||||
const tokenRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req, clientIp) => {
|
||||
const identity = typeof req.body?.identity === "string" ? req.body.identity.trim().toLowerCase() : "";
|
||||
return `${clientIp}|${identity}`;
|
||||
},
|
||||
});
|
||||
|
||||
// And per IP across all accounts, so one address can't spray passwords over many accounts at
|
||||
// 10 a minute each. Only failures count. Behind a shared proxy this cap is shared by everyone
|
||||
// using it, which is why it's higher than the per-account one.
|
||||
const tokenIpRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
// Keyed on the user the challenge belongs to, not the challenge token itself, so fetching
|
||||
// fresh challenge tokens doesn't reset the code-guessing budget. The payload is read without
|
||||
// verifying the signature; verify2FA does that. A forged token only affects buckets for the
|
||||
// sender's own IP.
|
||||
const challengeUserId = (token) => {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
|
||||
return String(payload?.attrs?.id ?? "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const twoFactorRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req, clientIp) => `${clientIp}|${challengeUserId(req.body?.challenge_token)}`,
|
||||
});
|
||||
|
||||
const router = express.Router({
|
||||
caseSensitive: true,
|
||||
strict: true,
|
||||
@@ -42,7 +88,7 @@ router
|
||||
*
|
||||
* Create a new Token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
|
||||
const result = await internalToken.getTokenFromEmail(data);
|
||||
@@ -64,7 +110,7 @@ router
|
||||
*
|
||||
* Verify 2FA code and get full token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(twoFactorRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
|
||||
const result = await internalToken.verify2FA(challenge_token, code);
|
||||
|
||||
@@ -260,7 +260,7 @@ router
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
|
||||
payload.id = req.params.user_id;
|
||||
payload.id = Number.parseInt(req.params.user_id, 10);
|
||||
const result = await internalUser.setPermissions(res.locals.access, payload);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
|
||||
},
|
||||
"example": ["example.com", "www.example.com"]
|
||||
},
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"
|
||||
"pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -164,7 +164,8 @@
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"pattern": "^[^:\\p{Cc}]+$"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
|
||||
},
|
||||
"example": ["example.com", "www.example.com"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visibility": {
|
||||
"type": "string",
|
||||
@@ -43,6 +44,25 @@
|
||||
"description": "Certificates Permissions",
|
||||
"enum": ["hidden", "view", "manage"],
|
||||
"example": "hidden"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Permission metadata",
|
||||
"example": {
|
||||
"proxy_host_ids": [1]
|
||||
},
|
||||
"properties": {
|
||||
"proxy_host_ids": {
|
||||
"type": "array",
|
||||
"description": "Allowed Proxy Host IDs",
|
||||
"example": [1],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"description": "Proxy Host ID",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$",
|
||||
"example": "127.0.0.1"
|
||||
},
|
||||
"forward_port": {
|
||||
@@ -108,7 +109,8 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$"
|
||||
},
|
||||
"forward_scheme": {
|
||||
"$ref": "#/properties/forward_scheme"
|
||||
@@ -120,7 +122,8 @@
|
||||
"$ref": "#/properties/forward_port"
|
||||
},
|
||||
"forward_path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$"
|
||||
},
|
||||
"advanced_config": {
|
||||
"type": "string"
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"forward_domain_name": {
|
||||
"description": "Domain Name",
|
||||
"type": "string",
|
||||
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
|
||||
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
|
||||
"example": "jc21.com"
|
||||
},
|
||||
"preserve_path": {
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
{
|
||||
"description": "Domain Name",
|
||||
"type": "string",
|
||||
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
|
||||
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
|
||||
"example": "example.com"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
||||
"pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
|
||||
@@ -35,6 +35,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "400 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Certificate is in use by 1 proxy host. Remove it from them before deleting it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "../../../../components/error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@
|
||||
"dead_hosts": "hidden",
|
||||
"proxy_hosts": "manage",
|
||||
"redirection_hosts": "hidden",
|
||||
"streams": "hidden"
|
||||
"streams": "hidden",
|
||||
"meta": {
|
||||
"proxy_host_ids": [1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% if access_list_id > 0 %}
|
||||
{% if access_list %}
|
||||
{% if access_list.items.length > 0 %}
|
||||
# Authorization
|
||||
auth_basic "Authorization required";
|
||||
@@ -24,4 +25,8 @@
|
||||
{% else %}
|
||||
satisfy all;
|
||||
{% endif %}
|
||||
{% else %}
|
||||
# Access List #{{ access_list_id }} could not be loaded: deny rather than serve unrestricted
|
||||
deny all;
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -2,7 +2,11 @@
|
||||
{% if ssl_forced == 1 or ssl_forced == true %}
|
||||
{% if hsts_enabled == 1 or hsts_enabled == true %}
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years)
|
||||
add_header Strict-Transport-Security $hsts_header always;
|
||||
{% if hsts_subdomains == 1 or hsts_subdomains == true %}
|
||||
add_header Strict-Transport-Security $hsts_header_subdomains always;
|
||||
{% else %}
|
||||
add_header Strict-Transport-Security $hsts_header_plain always;
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,3 +1,6 @@
|
||||
map $scheme $hsts_header {
|
||||
https "max-age=63072000;{% if hsts_subdomains == 1 or hsts_subdomains == true -%} includeSubDomains;{% endif %} preload";
|
||||
map $scheme $hsts_header_plain {
|
||||
https "max-age=63072000; preload";
|
||||
}
|
||||
map $scheme $hsts_header_subdomains {
|
||||
https "max-age=63072000;includeSubDomains; preload";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
location {{ path }} {
|
||||
{{ advanced_config }}
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $forward_host;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Regression tests: values a non-admin can set must never add nginx directives.
|
||||
*
|
||||
* Templates write these values into config as-is, so each one is checked three ways:
|
||||
* - the API rejects injection attempts (real request schemas, real validator)
|
||||
* - accepted values, rendered through the real templates, produce exactly the same nginx
|
||||
* statement structure as a plain value (parsed with nginx's tokenizer rules)
|
||||
* - generateConfig's render-time check refuses unsafe values already stored in the database
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
assertHostRenderable,
|
||||
isValid,
|
||||
isValidClientAddress,
|
||||
isValidStreamHost,
|
||||
patterns,
|
||||
} from "../lib/nginx-values.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import apiValidator from "../lib/validator/api.js";
|
||||
import { getCompiledSchema, getValidationSchema } from "../schema/index.js";
|
||||
|
||||
const TEMPLATES = new URL("../templates/", import.meta.url);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A tokenizer following ngx_conf_read_token: whitespace separates, ";" ends a statement, "{"
|
||||
// opens a block, "}" closes one, "#" at the start of a token comments to end of line, quotes
|
||||
// group, "\" escapes the next character, and "${" doesn't open a block.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const parseNginx = (text) => {
|
||||
const root = [];
|
||||
const stack = [root];
|
||||
let args = [];
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\r" || c === "\n";
|
||||
|
||||
while (i < n) {
|
||||
const c = text[i];
|
||||
if (isSpace(c)) {
|
||||
i++;
|
||||
} else if (c === "#") {
|
||||
while (i < n && text[i] !== "\n") i++;
|
||||
} else if (c === ";") {
|
||||
if (!args.length) throw new Error(`unexpected ";" at ${i}`);
|
||||
stack.at(-1).push({ name: args[0], args: args.slice(1) });
|
||||
args = [];
|
||||
i++;
|
||||
} else if (c === "{") {
|
||||
if (!args.length) throw new Error(`unexpected "{" at ${i}`);
|
||||
const block = { name: args[0], args: args.slice(1), block: [] };
|
||||
stack.at(-1).push(block);
|
||||
stack.push(block.block);
|
||||
args = [];
|
||||
i++;
|
||||
} else if (c === "}") {
|
||||
if (args.length) throw new Error(`unexpected "}" at ${i}`);
|
||||
if (stack.length === 1) throw new Error(`unbalanced "}" at ${i}`);
|
||||
stack.pop();
|
||||
i++;
|
||||
} else if (c === '"' || c === "'") {
|
||||
let tok = c;
|
||||
i++;
|
||||
while (i < n && text[i] !== c) {
|
||||
if (text[i] === "\\" && i + 1 < n) {
|
||||
tok += text[i] + text[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
tok += text[i++];
|
||||
}
|
||||
}
|
||||
if (i >= n) throw new Error("unterminated quoted string");
|
||||
tok += c;
|
||||
i++;
|
||||
if (i < n && !isSpace(text[i]) && text[i] !== ";" && text[i] !== "{") {
|
||||
throw new Error(`unexpected "${text[i]}" after quoted string`);
|
||||
}
|
||||
args.push(tok);
|
||||
} else {
|
||||
let tok = "";
|
||||
while (i < n) {
|
||||
const d = text[i];
|
||||
if (d === "\\" && i + 1 < n) {
|
||||
tok += d + text[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (d === "$" && text[i + 1] === "{") {
|
||||
tok += "${";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (isSpace(d) || d === ";" || d === "{") break;
|
||||
tok += d;
|
||||
i++;
|
||||
}
|
||||
args.push(tok);
|
||||
}
|
||||
}
|
||||
if (args.length) throw new Error("statement without ;");
|
||||
if (stack.length !== 1) throw new Error("unclosed block");
|
||||
return root;
|
||||
};
|
||||
|
||||
// Statement names and argument counts, nested; values themselves don't matter
|
||||
const shape = (stmts) =>
|
||||
stmts.map((s) => `${s.name}/${s.args.length}${s.block ? `{${shape(s.block).join(",")}}` : ""}`);
|
||||
|
||||
const find = (stmts, name) => {
|
||||
for (const s of stmts) {
|
||||
if (s.name === name) return s;
|
||||
if (s.block) {
|
||||
const f = find(s.block, name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const render = (file, ctx) =>
|
||||
utils.getRenderEngine().parseAndRender(fs.readFileSync(new URL(file, TEMPLATES), "utf8"), ctx);
|
||||
|
||||
// Same structure as a plain value, or throws (a value that breaks parsing changes it too)
|
||||
const sameShapeAs = async (file, makeCtx, plain, value) => {
|
||||
const base = shape(parseNginx(await render(file, makeCtx(plain))));
|
||||
let got;
|
||||
try {
|
||||
got = shape(parseNginx(await render(file, makeCtx(value))));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(got) === JSON.stringify(base);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Corpora. "injections" add or change directives when rendered unchecked; "rejected" are other
|
||||
// values the rules refuse (they would break or confuse the config without injecting).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const cases = {
|
||||
locationPath: {
|
||||
valid: [
|
||||
"/",
|
||||
"/api",
|
||||
"/api/",
|
||||
"= /exact",
|
||||
"~ \\.php$",
|
||||
"~* \\.(jpg|png|gif)$",
|
||||
"^~ /static/",
|
||||
'~ "^/v[0-9]{1,2}/"',
|
||||
'"/with space"',
|
||||
"/über",
|
||||
"/a$b",
|
||||
"/a\\;b",
|
||||
],
|
||||
injections: [
|
||||
"/x { return 200; } location /y",
|
||||
"/x {} include /etc/passwd; location /y",
|
||||
"/a; } server { listen 8080",
|
||||
"/a\nreturn 200 x;\nlocation /b",
|
||||
'"/a" { return 200; } location "/b"',
|
||||
"/a {",
|
||||
],
|
||||
rejected: ["", " /a", "/a b", "/a#", "/a\\", '"/a', '"/a\\"', "'/a'", "~", "~ /a\tb", "/a}", "/a\u0000"],
|
||||
},
|
||||
forwardHost: {
|
||||
valid: ["127.0.0.1", "backend", "my_service.internal", "10.0.0.1/path/", "backend/$1", "[::1]", "host:8080"],
|
||||
injections: [
|
||||
"a; return 200; #",
|
||||
'a"; return 200; set $x "',
|
||||
"a:80; } location /x { return 200",
|
||||
"a\nreturn 200;",
|
||||
],
|
||||
rejected: ["", "a b", "a'", "a{", "a}", "a#b", "a\\b", "$http_host", "a$b", "/path", "a/b c"],
|
||||
},
|
||||
forwardPath: {
|
||||
valid: ["", "/", "/x", "/x/$1/"],
|
||||
injections: ["/x; return 200", "/x\nreturn 200"],
|
||||
rejected: ["/x y", "/x#", "/x{", "/x\\"],
|
||||
},
|
||||
redirectDomain: {
|
||||
valid: [
|
||||
"example.com",
|
||||
"example.com:8443",
|
||||
"example.com/path",
|
||||
"sub.example.co.uk/a?b=c",
|
||||
"example.com/$1",
|
||||
"ab",
|
||||
],
|
||||
injections: ["example.com; return 200 x; #", "example.com;\nreturn 200", "a.com { return 200; }"],
|
||||
rejected: ["a b.com", "a.com#x", 'a.com"', "a.com\\", ".com", "a..b", "a", "a.com."],
|
||||
},
|
||||
streamHost: {
|
||||
valid: ["example.com", "1.2.3.4", "::1", "backend.internal"],
|
||||
injections: ["a.com:80; } server { listen 9999", "a.com;\nlisten 1"],
|
||||
rejected: ["a.com/x", "$host", "a b", "a.com#"],
|
||||
},
|
||||
domainName: {
|
||||
valid: ["example.com", "*.example.com", "xn--bcher-kva.example", "a-b.example"],
|
||||
injections: ["a.com\nreturn 200 x", "a.com\n} server {"],
|
||||
rejected: ["a\tb.com", "a;b", "a{b", "a b"],
|
||||
},
|
||||
clientAddress: {
|
||||
valid: ["192.168.0.1", "10.0.0.0/8", "all", "::1", "2001:db8::/32", "::ffff:192.168.1.1", "fe80::1"],
|
||||
injections: ["::1%x; allow all", "::1%x\nallow all", "1.2.3.4; allow all"],
|
||||
rejected: ["fe80::1%eth0", "s::1", "1.2.3.4/33", "::ffff:1d.1.1.1"],
|
||||
},
|
||||
accessUsername: {
|
||||
valid: ["admin", "john smith", "ü"],
|
||||
injections: [],
|
||||
rejected: ["a:b", "a\nroot:$apr1$x$y", "\u0000"],
|
||||
},
|
||||
};
|
||||
|
||||
const checkers = {
|
||||
locationPath: (v) => isValid("locationPath", v),
|
||||
forwardHost: (v) => isValid("forwardHost", v),
|
||||
forwardPath: (v) => isValid("forwardPath", v),
|
||||
redirectDomain: (v) => isValid("redirectDomain", v),
|
||||
streamHost: isValidStreamHost,
|
||||
domainName: (v) => isValid("domainName", v),
|
||||
clientAddress: isValidClientAddress,
|
||||
accessUsername: (v) => isValid("accessUsername", v),
|
||||
};
|
||||
|
||||
describe("nginx value rules", () => {
|
||||
for (const [name, { valid, injections, rejected }] of Object.entries(cases)) {
|
||||
it(`${name}: accepts valid values`, () => {
|
||||
for (const v of valid) assert.ok(checkers[name](v), `should accept ${JSON.stringify(v)}`);
|
||||
});
|
||||
it(`${name}: rejects injections and other unsafe values`, () => {
|
||||
for (const v of [...injections, ...rejected]) {
|
||||
assert.ok(!checkers[name](v), `should reject ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("redirect domain rule matches the old rule for token-safe values", () => {
|
||||
const old = /^(?:[^.*]+\.?)+[^.]$/u;
|
||||
const corpus = [
|
||||
"ab",
|
||||
"a.b",
|
||||
"a.b.c",
|
||||
"a..b",
|
||||
".a",
|
||||
"a.",
|
||||
"a*",
|
||||
"a.*",
|
||||
"*a",
|
||||
"a*b",
|
||||
"a.b:80/x",
|
||||
"x",
|
||||
"ab.",
|
||||
"a.b.",
|
||||
];
|
||||
for (const v of corpus) {
|
||||
assert.equal(isValid("redirectDomain", v), old.test(v), `differs for ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("domain rules don't backtrack exponentially", () => {
|
||||
const evil = `${"a".repeat(30000)}.`;
|
||||
const start = process.hrtime.bigint();
|
||||
isValid("redirectDomain", evil);
|
||||
isValidStreamHost(evil);
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
assert.ok(ms < 1000, `took ${ms}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API request validation", async () => {
|
||||
await getCompiledSchema();
|
||||
|
||||
const location = (o) => ({ path: "/api", forward_scheme: "http", forward_host: "backend", forward_port: 80, ...o });
|
||||
const proxyHost = (o) => ({
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 80,
|
||||
...o,
|
||||
});
|
||||
const redirection = (o) => ({
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_http_code: 301,
|
||||
forward_domain_name: "example.org",
|
||||
...o,
|
||||
});
|
||||
const stream = (o) => ({
|
||||
incoming_port: 9000,
|
||||
forwarding_host: "backend",
|
||||
forwarding_port: 80,
|
||||
tcp_forwarding: true,
|
||||
udp_forwarding: false,
|
||||
...o,
|
||||
});
|
||||
|
||||
// [field, endpoint, method, payload builder]
|
||||
const endpoints = {
|
||||
locationPath: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ locations: [location({ path: v })] })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ path: v })] })],
|
||||
],
|
||||
forwardHost: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ forward_host: v })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ forward_host: v })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_host: v })] })],
|
||||
],
|
||||
forwardPath: [["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_path: v })] })]],
|
||||
redirectDomain: [
|
||||
["/nginx/redirection-hosts", "post", (v) => redirection({ forward_domain_name: v })],
|
||||
["/nginx/redirection-hosts/{hostID}", "put", (v) => ({ forward_domain_name: v })],
|
||||
],
|
||||
streamHost: [
|
||||
["/nginx/streams", "post", (v) => stream({ forwarding_host: v })],
|
||||
["/nginx/streams/{streamID}", "put", (v) => ({ forwarding_host: v })],
|
||||
],
|
||||
domainName: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ domain_names: [v] })],
|
||||
["/nginx/redirection-hosts", "post", (v) => redirection({ domain_names: [v] })],
|
||||
["/nginx/dead-hosts", "post", (v) => ({ domain_names: [v] })],
|
||||
["/nginx/certificates", "post", (v) => ({ provider: "letsencrypt", domain_names: [v] })],
|
||||
],
|
||||
clientAddress: [
|
||||
["/nginx/access-lists", "post", (v) => ({ name: "x", clients: [{ directive: "allow", address: v }] })],
|
||||
["/nginx/access-lists/{listID}", "put", (v) => ({ clients: [{ directive: "allow", address: v }] })],
|
||||
],
|
||||
accessUsername: [
|
||||
["/nginx/access-lists", "post", (v) => ({ name: "x", items: [{ username: v, password: "pw" }] })],
|
||||
["/nginx/access-lists/{listID}", "put", (v) => ({ items: [{ username: v, password: "pw" }] })],
|
||||
],
|
||||
};
|
||||
|
||||
const validate = (path, method, payload) => {
|
||||
const schema = getValidationSchema(path, method);
|
||||
assert.ok(schema, `no request schema for ${method} ${path}`);
|
||||
return apiValidator(schema, payload);
|
||||
};
|
||||
|
||||
for (const [name, list] of Object.entries(endpoints)) {
|
||||
for (const [path, method, build] of list) {
|
||||
it(`${method.toUpperCase()} ${path} ${name}: rejects injections`, async () => {
|
||||
for (const v of [...cases[name].injections, ...cases[name].rejected]) {
|
||||
if (v === "") continue; // minLength cases, covered by the rules above
|
||||
await assert.rejects(validate(path, method, build(v)), /must/, `accepted ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
it(`${method.toUpperCase()} ${path} ${name}: accepts valid values`, async () => {
|
||||
for (const v of cases[name].valid) {
|
||||
if (name === "forwardHost" && v === "") continue;
|
||||
await validate(path, method, build(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("schema patterns are the ones the render-time check uses", async () => {
|
||||
const s = await getCompiledSchema();
|
||||
const body = (path, method) => s.paths[path][method].requestBody.content["application/json"].schema;
|
||||
const ph = body("/nginx/proxy-hosts", "post").properties;
|
||||
const loc = ph.locations.items.properties;
|
||||
const al = body("/nginx/access-lists", "post").properties;
|
||||
assert.equal(ph.domain_names.items.pattern, patterns.domainName);
|
||||
assert.equal(body("/nginx/certificates", "post").properties.domain_names.items.pattern, patterns.domainName);
|
||||
assert.equal(ph.forward_host.pattern, patterns.forwardHost);
|
||||
assert.equal(loc.path.pattern, patterns.locationPath);
|
||||
assert.equal(loc.forward_host.pattern, patterns.forwardHost);
|
||||
assert.equal(loc.forward_path.pattern, patterns.forwardPath);
|
||||
assert.equal(
|
||||
body("/nginx/redirection-hosts", "post").properties.forward_domain_name.pattern,
|
||||
patterns.redirectDomain,
|
||||
);
|
||||
assert.equal(body("/nginx/streams", "post").properties.forwarding_host.anyOf[0].pattern, patterns.streamDomain);
|
||||
assert.equal(al.clients.items.properties.address.oneOf[1].pattern, patterns.clientIpv6);
|
||||
assert.equal(al.items.items.properties.username.pattern, patterns.accessUsername);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered templates", () => {
|
||||
const locationCtx = (field) => (v) => ({
|
||||
path: "/api",
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 8080,
|
||||
forward_path: "",
|
||||
advanced_config: "",
|
||||
access_list_id: 0,
|
||||
[field]: v,
|
||||
});
|
||||
const proxyCtx = (field) => (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 80,
|
||||
certificate_id: 0,
|
||||
locations: "",
|
||||
advanced_config: "",
|
||||
use_default_location: true,
|
||||
ipv6: true,
|
||||
[field]: v,
|
||||
});
|
||||
const redirectCtx = (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_http_code: 301,
|
||||
forward_domain_name: v,
|
||||
preserve_path: true,
|
||||
advanced_config: "",
|
||||
use_default_location: true,
|
||||
ipv6: true,
|
||||
});
|
||||
const streamCtx = (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
incoming_port: 9000,
|
||||
forwarding_host: v,
|
||||
forwarding_port: 80,
|
||||
tcp_forwarding: true,
|
||||
udp_forwarding: true,
|
||||
ipv6: true,
|
||||
});
|
||||
const accessCtx = (v) => ({
|
||||
access_list_id: 1,
|
||||
access_list: { items: [], clients: [{ directive: "allow", address: v }], satisfy_any: false },
|
||||
});
|
||||
|
||||
// A location modifier is a separate argument, so compare with a plain path using the same one
|
||||
const plainPath = (v) => {
|
||||
const m = v.match(/^(=|~\*?|\^~) +/);
|
||||
return m ? `${m[1]} /api` : "/api";
|
||||
};
|
||||
|
||||
// [template, context builder, plain value (or builder), injections that apply to it]
|
||||
const targets = {
|
||||
locationPath: [["_location.conf", locationCtx("path"), plainPath]],
|
||||
forwardHost: [
|
||||
["_location.conf", locationCtx("forward_host"), "backend"],
|
||||
// Rendered inside double quotes here, so only a quote can break out
|
||||
["proxy_host.conf", proxyCtx("forward_host"), "backend", ['a"; return 200; set $x "', 'a" ; } server { "']],
|
||||
],
|
||||
forwardPath: [["_location.conf", locationCtx("forward_path"), "/x"]],
|
||||
redirectDomain: [["redirection_host.conf", redirectCtx, "example.org"]],
|
||||
streamHost: [["stream.conf", streamCtx, "backend"]],
|
||||
domainName: [["proxy_host.conf", (v) => proxyCtx("domain_names")([v]), "example.com"]],
|
||||
clientAddress: [["_access.conf", accessCtx, "10.0.0.1"]],
|
||||
};
|
||||
|
||||
for (const [name, list] of Object.entries(targets)) {
|
||||
for (const [file, ctx, plain, injections = cases[name].injections] of list) {
|
||||
const plainFor = (v) => (typeof plain === "function" ? plain(v) : plain);
|
||||
it(`${file} ${name}: accepted values keep the statement structure`, async () => {
|
||||
for (const v of cases[name].valid) {
|
||||
if (v === "" && name !== "forwardPath") continue;
|
||||
// generateConfig brackets IPv6 stream hosts before rendering
|
||||
const value = name === "streamHost" && v.includes(":") ? `[${v}]` : v;
|
||||
assert.ok(
|
||||
await sameShapeAs(file, ctx, plainFor(v), value),
|
||||
`${JSON.stringify(v)} changed the config`,
|
||||
);
|
||||
}
|
||||
});
|
||||
it(`${file} ${name}: the injection corpus would change it unchecked`, async () => {
|
||||
assert.ok(injections.length > 0);
|
||||
for (const v of injections) {
|
||||
assert.ok(!checkers[name](v), `rules accept ${JSON.stringify(v)}`);
|
||||
assert.ok(
|
||||
!(await sameShapeAs(file, ctx, plainFor(v), v)),
|
||||
`${JSON.stringify(v)} is not an injection`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("location path renders as the location's own arguments", async () => {
|
||||
for (const v of cases.locationPath.valid) {
|
||||
const loc = find(parseNginx(await render("_location.conf", locationCtx("path")(v))), "location");
|
||||
assert.equal(loc.args.join(" "), v.replace(/ +/, " "));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("render-time check (rows saved before validation)", () => {
|
||||
const host = (o) => ({
|
||||
id: 7,
|
||||
domain_names: ["example.com"],
|
||||
forward_host: "backend",
|
||||
locations: [],
|
||||
...o,
|
||||
});
|
||||
|
||||
it("passes valid hosts", () => {
|
||||
assertHostRenderable("proxy_host", host({ locations: [{ path: "~* \\.php$", forward_host: "10.0.0.1/x/" }] }));
|
||||
assertHostRenderable("redirection_host", host({ forward_domain_name: "example.org/path" }));
|
||||
assertHostRenderable("dead_host", host({}));
|
||||
assertHostRenderable("stream", { id: 1, forwarding_host: "::1" });
|
||||
assertHostRenderable(
|
||||
"proxy_host",
|
||||
host({ access_list: { clients: [{ directive: "allow", address: "10.0.0.0/8" }] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders stored values the API no longer accepts but that can't add directives", () => {
|
||||
// e.g. an IPv6 literal server name, as the host-header smoke test renders
|
||||
assertHostRenderable("proxy_host", host({ domain_names: ["host.example.test", "[::1]"] }));
|
||||
assertHostRenderable("proxy_host", host({ forward_host: "$upstream" }));
|
||||
assertHostRenderable("redirection_host", host({ forward_domain_name: "a" }));
|
||||
assertHostRenderable("stream", { id: 1, forwarding_host: "a.com/x" });
|
||||
});
|
||||
|
||||
const refuse = (type, row, what) => {
|
||||
assert.throws(
|
||||
() => assertHostRenderable(type, row),
|
||||
(err) => err.unsafeNginxValue === true && err.message.includes(what),
|
||||
);
|
||||
};
|
||||
|
||||
it("refuses every injected field", () => {
|
||||
for (const v of cases.locationPath.injections) {
|
||||
refuse("proxy_host", host({ locations: [{ path: v, forward_host: "b" }] }), "location path");
|
||||
}
|
||||
for (const v of cases.forwardHost.injections) {
|
||||
refuse("proxy_host", host({ forward_host: v }), "forward host");
|
||||
refuse("proxy_host", host({ locations: [{ path: "/a", forward_host: v }] }), "location forward host");
|
||||
}
|
||||
for (const v of cases.forwardPath.injections) {
|
||||
refuse(
|
||||
"proxy_host",
|
||||
host({ locations: [{ path: "/a", forward_host: "b", forward_path: v }] }),
|
||||
"forward path",
|
||||
);
|
||||
}
|
||||
for (const v of cases.redirectDomain.injections) {
|
||||
refuse("redirection_host", host({ forward_domain_name: v }), "forward domain");
|
||||
}
|
||||
for (const v of cases.streamHost.injections) {
|
||||
refuse("stream", { id: 1, forwarding_host: v }, "forwarding host");
|
||||
}
|
||||
for (const v of cases.domainName.injections) {
|
||||
refuse("dead_host", host({ domain_names: [v] }), "domain name");
|
||||
}
|
||||
for (const v of cases.clientAddress.injections) {
|
||||
refuse("proxy_host", host({ access_list: { clients: [{ directive: "allow", address: v }] } }), "address");
|
||||
}
|
||||
});
|
||||
});
|
||||
+294
-307
@@ -2,20 +2,12 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@apidevtools/json-schema-ref-parser@15.3.6":
|
||||
version "15.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.6.tgz#9e6360264d90b209eb09f9e4dc9a32316d86f4ea"
|
||||
integrity sha512-oPFDSviRrreUmCZn09LCD4S9QQa6j7zfEwBM1pkGa/kXY0O4sXsEXn4emLP/Tt6tik2+BtgwKk2eldAeOWZ2Rw==
|
||||
"@apidevtools/json-schema-ref-parser@^16.0.3", "@apidevtools/json-schema-ref-parser@^16.0.6":
|
||||
version "16.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.6.tgz#30b21ddbaa6ddd1161d1534829002dab0d720980"
|
||||
integrity sha512-cJXhmvqCn4Ud7X7d4L/BtAiJRIrLNiuIZ48ZTuDfN0mYejVXxdzMGR07teW6eKzLwcN0FwMi7gykE2Kr09LS1g==
|
||||
dependencies:
|
||||
js-yaml "^4.2.0"
|
||||
|
||||
"@apidevtools/json-schema-ref-parser@^16.0.2":
|
||||
version "16.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.2.tgz#21f55458a55d1b02bc62f19c731913d8e74976b3"
|
||||
integrity sha512-oCwWUyRdMtt/hnp6ONPudFZzF7tSqiHy46lLx5VsnsLTacs8TSU558VPAiOueFJ8WddCH30PINWDA8ByxhGHmQ==
|
||||
dependencies:
|
||||
js-yaml "^5.4.1"
|
||||
undici "^8.10.2"
|
||||
js-yaml "^5.4.2"
|
||||
|
||||
"@apidevtools/openapi-schemas@^2.1.0":
|
||||
version "2.1.0"
|
||||
@@ -27,72 +19,84 @@
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267"
|
||||
integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==
|
||||
|
||||
"@apidevtools/swagger-parser@^13.0.0":
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.0.0.tgz#a8363c086cc2ae0005b270cfc241f353a741e49c"
|
||||
integrity sha512-TcvnmBzJD7DBREkc/2qaxMqbKpttOaHhOFsYRYKdYAv6HGY+iNlWY2IUA209C2/NmvL9z8RBZhkrESqvGkjqmw==
|
||||
"@apidevtools/swagger-parser@^13.1.0":
|
||||
version "13.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.1.0.tgz#efc8ffcc573838e715d0bce83d917b79636ca172"
|
||||
integrity sha512-dkCuqCWh5U7u0aurmmc/qvel2KeKDxHTwVtUdYonndIpD7mXV6AVaWNdvaja2HAy628z7FT/Qdz8sCgzwhgwKg==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "15.3.6"
|
||||
"@apidevtools/json-schema-ref-parser" "^16.0.3"
|
||||
"@apidevtools/openapi-schemas" "^2.1.0"
|
||||
"@apidevtools/swagger-methods" "^3.0.2"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
ajv "^8.17.1"
|
||||
ajv "^8.20.0"
|
||||
ajv-draft-04 "^1.0.0"
|
||||
call-me-maybe "^1.0.2"
|
||||
|
||||
"@biomejs/biome@^2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.5.10.tgz#c2a42fc6f9c7d9e12c1f77f63dfb6b5489c497b7"
|
||||
integrity sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==
|
||||
"@biomejs/biome@^2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.6.6.tgz#b40e03ff3dbe070963dd6332171ffffeb3cf7619"
|
||||
integrity sha512-NCgVJRyrU1+hsW1ILNaRqNAb9fRL4n49bwyef+wjVJQ488/bXbp0BfnNqTTExkiNQT3ugkgYKR1vK8NxsjlNSQ==
|
||||
optionalDependencies:
|
||||
"@biomejs/cli-darwin-arm64" "2.5.10"
|
||||
"@biomejs/cli-darwin-x64" "2.5.10"
|
||||
"@biomejs/cli-linux-arm64" "2.5.10"
|
||||
"@biomejs/cli-linux-arm64-musl" "2.5.10"
|
||||
"@biomejs/cli-linux-x64" "2.5.10"
|
||||
"@biomejs/cli-linux-x64-musl" "2.5.10"
|
||||
"@biomejs/cli-win32-arm64" "2.5.10"
|
||||
"@biomejs/cli-win32-x64" "2.5.10"
|
||||
"@biomejs/cli-android-arm64" "2.6.6"
|
||||
"@biomejs/cli-android-x64" "2.6.6"
|
||||
"@biomejs/cli-darwin-arm64" "2.6.6"
|
||||
"@biomejs/cli-darwin-x64" "2.6.6"
|
||||
"@biomejs/cli-linux-arm64" "2.6.6"
|
||||
"@biomejs/cli-linux-arm64-musl" "2.6.6"
|
||||
"@biomejs/cli-linux-x64" "2.6.6"
|
||||
"@biomejs/cli-linux-x64-musl" "2.6.6"
|
||||
"@biomejs/cli-win32-arm64" "2.6.6"
|
||||
"@biomejs/cli-win32-x64" "2.6.6"
|
||||
|
||||
"@biomejs/cli-darwin-arm64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz#f1453a7f2e63f228a34645df1c33962f22a6c2a8"
|
||||
integrity sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==
|
||||
"@biomejs/cli-android-arm64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.6.tgz#dd4a994b35837a521459fea4f4f27a194c47258d"
|
||||
integrity sha512-bicnqNNxEX7pjkDQaptYvPRIWDfgXJLl/Ok7iZjC+F1AhfReor3FSS7SgPYnOvUy/jf++5U/KYN4T8OKH5rrNA==
|
||||
|
||||
"@biomejs/cli-darwin-x64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz#e21041e7bfab01cd453f609b940bf613713d0e24"
|
||||
integrity sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==
|
||||
"@biomejs/cli-android-x64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-android-x64/-/cli-android-x64-2.6.6.tgz#1804e55a64e9e0b11f56492863c98fd80f1a4028"
|
||||
integrity sha512-N443kDSElF4kDMKjarsL+ymj/ewLA5R9VAdawA7fJ7pwgN3JVDZp0K+xhKBglH8EwsKi2+K8xC6WxXSkgziFfw==
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz#57fc8aee25c08ca6c14526ccc44e282e7ba96eb6"
|
||||
integrity sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==
|
||||
"@biomejs/cli-darwin-arm64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.6.tgz#dd6be6a1d8457a465c555311c909e6aa81f1c9ea"
|
||||
integrity sha512-/vSAqWx3by3p8w5ew+HVJDgguuaTK3U6Fb5gk/oDUH21iWOopm3gOlhT6tZfy5mTaM5R4CVc3uFWkxBtEDrKyw==
|
||||
|
||||
"@biomejs/cli-linux-arm64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz#aef789a71e5aa86f5a0e1aff6cb4c1b757ec5be1"
|
||||
integrity sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==
|
||||
"@biomejs/cli-darwin-x64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.6.tgz#fb2609d97f4ead53dbff30507a42be1fe56a1e9c"
|
||||
integrity sha512-lLdOAJKbrAoDs+3GvTyVmMW345D9IhrIE3GgoL2eeHwO1boN4VhrSlALtFYsP0p+Jy4udUp4bTguij0qjKrH8w==
|
||||
|
||||
"@biomejs/cli-linux-x64-musl@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz#1aebe9f928dc72dc3c112e60f1211a4d646990c4"
|
||||
integrity sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==
|
||||
"@biomejs/cli-linux-arm64-musl@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.6.tgz#28412c5039466b87c9d3a59176994a45422d60a4"
|
||||
integrity sha512-rw2Vsoi/WZGDyRjMXx9KZH9zsHe79LIzkdWHkLqsIkeKZm6qiGxtiIApxn7cmJSoEPaSvHaH5pRj1FhTcyL/XA==
|
||||
|
||||
"@biomejs/cli-linux-x64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz#a82b414abf5d9668fb035ee50a710bf78a7c5fbf"
|
||||
integrity sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==
|
||||
"@biomejs/cli-linux-arm64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.6.tgz#04ced06069af3c7a4d20da44c53c21e70ce42176"
|
||||
integrity sha512-UzJdUCg9dbEGpZOuZRyehEPrnwKjx9z4Um7sWLG5x6txu7UjeWdJZuBXVGlyAdD39w2Hx0nZeK1jKC4NFUTCNQ==
|
||||
|
||||
"@biomejs/cli-win32-arm64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz#db671cf67d7040a374e8cdda5aebe73c3f4ffba0"
|
||||
integrity sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==
|
||||
"@biomejs/cli-linux-x64-musl@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.6.tgz#78109fb485dcbce9eaf5977e2a70658696dc6405"
|
||||
integrity sha512-DiKejs+GnFbOVQsBnukUpiB+T13tqUDFGwwjsTq51vatAsaflzFzVD0BOgjVUGRrqKgE0K0Xf+wNtkcmdN2chw==
|
||||
|
||||
"@biomejs/cli-win32-x64@2.5.10":
|
||||
version "2.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz#3bdeaf5856f21aec6b6c50a7bca5464b9cda5623"
|
||||
integrity sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==
|
||||
"@biomejs/cli-linux-x64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.6.tgz#b5315ccaa511a2bfb103db50013a266887d793bb"
|
||||
integrity sha512-l8/aPXc1cz8VpUyd4hb5Pm5zx+X3voQBks4aLYbp5pC8KSchuHyvqDfY5xax39qCHomI/2xn6NKEot+zebG38g==
|
||||
|
||||
"@biomejs/cli-win32-arm64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.6.tgz#41c0a80a9b98e9b879fb2413a5faf0775a526f07"
|
||||
integrity sha512-DjVXwQAQeHrO1JEZAEus54k40Tnh4glyU3VFLcDBKwoz0xnxCEnqw6TCMXoujcQMQynWd3AY8bLrbR1o0XNqow==
|
||||
|
||||
"@biomejs/cli-win32-x64@2.6.6":
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.6.tgz#f3e5230d59a48ac1da6ab951ad5be364591fb64c"
|
||||
integrity sha512-B7KMG26A4I9lAuxwBGMmiVcjMGAeJRoz+pzFt9brRvOAsIrgOu8ptJld5rUWui6tM1uiJwxeJhhDPuAiZtxXdw==
|
||||
|
||||
"@isaacs/fs-minipass@^4.0.0":
|
||||
version "4.0.1"
|
||||
@@ -101,60 +105,60 @@
|
||||
dependencies:
|
||||
minipass "^7.0.4"
|
||||
|
||||
"@noble/hashes@^2.2.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.3.0.tgz#505fd39c3134a37e67c8c4e6c6049a496154879c"
|
||||
integrity sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==
|
||||
"@noble/hashes@^2.2.0", "@noble/hashes@^2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.4.0.tgz#e6a3e98edbed3c8659b28bb8f9b18ca02ec8ea4b"
|
||||
integrity sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==
|
||||
|
||||
"@otplib/core@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.5.0.tgz#629a7fef5b6394f34ff55213b936223b368796a4"
|
||||
integrity sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==
|
||||
"@otplib/core@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.6.0.tgz#f939a821604467b87be29b465cb54e2f270c3789"
|
||||
integrity sha512-TL6/FKKzR55Vr7zo3YGFtPMCSV3SpYPtuvE3T1XcwjFczD9pHU4dYYQBl0AcNvGcqAt4/fPqhZEFfB2OBcnlPA==
|
||||
|
||||
"@otplib/hotp@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.5.0.tgz#2832cc6e52f3d037428f094177b4779babe19aa5"
|
||||
integrity sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==
|
||||
"@otplib/hotp@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.6.0.tgz#b9c0e2ed27f63dff9bb704c881bb006f54a2d4b4"
|
||||
integrity sha512-oPpaQBkAYjBxAs+FWdGNumsEwWJhIZVcsv0vEi+w4OzzLQ6UDZ2qXZNSeF9NljTj6/8d4e1Z4o6RgJa3aIxAOA==
|
||||
dependencies:
|
||||
"@otplib/core" "13.5.0"
|
||||
"@otplib/uri" "13.5.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
"@otplib/uri" "13.6.0"
|
||||
|
||||
"@otplib/plugin-base32-scure@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz#ba2f329aeea3ed4df3c4e21ce2e9c2e8c9aaed4f"
|
||||
integrity sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==
|
||||
"@otplib/plugin-base32-scure@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.6.0.tgz#73509fbd9946642dd78f2990190f5cdb86aa0f81"
|
||||
integrity sha512-s6uMbj4MBebvAPnBns1y2RhQ765/U04qHlZmFLV02dlD1S06pU46g/7tHX0vkCx5Pnty723loXto/PM5gFEEdg==
|
||||
dependencies:
|
||||
"@otplib/core" "13.5.0"
|
||||
"@scure/base" "^2.2.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
"@scure/base" "^2.4.0"
|
||||
|
||||
"@otplib/plugin-crypto-noble@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz#820227beb159090a7448ce7245a4e615b5579f0e"
|
||||
integrity sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==
|
||||
"@otplib/plugin-crypto-noble@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.6.0.tgz#c2702906e6cc128cf8b32fafd35163683a02394e"
|
||||
integrity sha512-m/8mOhtIhsr4zLtb9MlLI6+w7ZNiKSFnT8Dv7eHR9TCyf2Sxva5e+MbV0wONmF7ZLuvo9F3DtzfPjc748PhJcw==
|
||||
dependencies:
|
||||
"@noble/hashes" "^2.2.0"
|
||||
"@otplib/core" "13.5.0"
|
||||
"@noble/hashes" "^2.4.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
|
||||
"@otplib/totp@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.5.0.tgz#48a60807457726ea34f1d1185f994120652e4468"
|
||||
integrity sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==
|
||||
"@otplib/totp@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.6.0.tgz#fa309ced2594e83b060d9b746d0218d2957153a3"
|
||||
integrity sha512-bMlq0MaUaQE3lOSH352Mrzcwpau5pMf7pMLROS+llk2evpC/sE7SHl9ajBl+PiPnGGb6LGYEjDvKqX8caXkGdw==
|
||||
dependencies:
|
||||
"@otplib/core" "13.5.0"
|
||||
"@otplib/hotp" "13.5.0"
|
||||
"@otplib/uri" "13.5.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
"@otplib/hotp" "13.6.0"
|
||||
"@otplib/uri" "13.6.0"
|
||||
|
||||
"@otplib/uri@13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.5.0.tgz#d2da470be24f872bb1c24717bcdd2e5c122e3ce3"
|
||||
integrity sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==
|
||||
"@otplib/uri@13.6.0":
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.6.0.tgz#c26e70293ccd4bc8f1d6e46c051e2b70bf1a56dc"
|
||||
integrity sha512-CiLFMONTpCi3ara6yQQX3nlpYhafCALKJweA7+7Z+aDkRrW3QmD5a/9thKmQ0cGp2W70AMG/6BSTy82h74LHRw==
|
||||
dependencies:
|
||||
"@otplib/core" "13.5.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
|
||||
"@scure/base@^2.2.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.3.0.tgz#fc7acb5b7084c53e4e2537d83fa72cddac7143fd"
|
||||
integrity sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==
|
||||
"@scure/base@^2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.4.0.tgz#61e4ed7d4447afc247f2bff9aeb8fca53267db86"
|
||||
integrity sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
@@ -191,10 +195,10 @@ ajv-draft-04@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8"
|
||||
integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==
|
||||
|
||||
ajv-formats@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
|
||||
integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==
|
||||
ajv-formats@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578"
|
||||
integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==
|
||||
dependencies:
|
||||
ajv "^8.0.0"
|
||||
|
||||
@@ -250,10 +254,10 @@ archiver@^8.0.0:
|
||||
tar-stream "^3.0.0"
|
||||
zip-stream "^7.0.2"
|
||||
|
||||
argparse@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||
argparse@^3.0.3:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-3.0.4.tgz#ba802dd75fdc3d5bdcb4339b713d62c6c5d4565d"
|
||||
integrity sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==
|
||||
|
||||
ast-types@^0.13.4:
|
||||
version "0.13.4"
|
||||
@@ -272,25 +276,30 @@ aws-ssl-profiles@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
|
||||
integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
|
||||
|
||||
b4a@^1.6.4, b4a@^1.8.1:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4"
|
||||
integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==
|
||||
b4a@^1.6.4, b4a@^1.8.1, b4a@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845"
|
||||
integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==
|
||||
|
||||
balanced-match@^4.0.2:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
bare-events@^2.5.4, bare-events@^2.7.0:
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.1.tgz#5c86616966343bcb03a1b3155feab253eadbf349"
|
||||
integrity sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==
|
||||
bare-buffer@^3.0.0:
|
||||
version "3.7.1"
|
||||
resolved "https://registry.yarnpkg.com/bare-buffer/-/bare-buffer-3.7.1.tgz#dcd2fb7907cfe7df0cf18cb752a04ae2a41140bc"
|
||||
integrity sha512-cIjZnSO+y89ykVX9x96OVnA6tGORBn7vGJ/e3PCbBHMEyfMNG/ixzOS0lLa6saMlWcs69bSKjxFyDwjK152OMg==
|
||||
|
||||
bare-fs@^4.5.5:
|
||||
version "4.8.0"
|
||||
resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.0.tgz#8c0f916833ae11e6d9d347f9e0a7f46f7375bb8f"
|
||||
integrity sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==
|
||||
bare-events@^2.5.4, bare-events@^2.7.0:
|
||||
version "2.9.2"
|
||||
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f"
|
||||
integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==
|
||||
|
||||
bare-fs@^4.8.2:
|
||||
version "4.8.4"
|
||||
resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.4.tgz#f342288e7e5d9d8e79917078e1f45950507828c5"
|
||||
integrity sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==
|
||||
dependencies:
|
||||
bare-events "^2.5.4"
|
||||
bare-path "^3.0.0"
|
||||
@@ -299,24 +308,25 @@ bare-fs@^4.5.5:
|
||||
fast-fifo "^1.3.2"
|
||||
|
||||
bare-path@^3.0.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.1.tgz#d4a207c088609b4663a7556a46a97342398bf7e2"
|
||||
integrity sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e"
|
||||
integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==
|
||||
|
||||
bare-stream@^2.6.4:
|
||||
version "2.13.3"
|
||||
resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.3.tgz#f6186c7cbb4bbf53a4560f35e48b16373ba51ce6"
|
||||
integrity sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==
|
||||
version "2.13.4"
|
||||
resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0"
|
||||
integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==
|
||||
dependencies:
|
||||
b4a "^1.8.1"
|
||||
streamx "^2.25.0"
|
||||
teex "^1.0.1"
|
||||
|
||||
bare-url@^2.2.2:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.2.tgz#e8dafaefbdb56891a0fbc2b0e1c051296d241cd6"
|
||||
integrity sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.6.tgz#7f7a96c459bd432bec43f797c0afca1325cb9b5f"
|
||||
integrity sha512-iqkXHkW65De+BT14nHLiBFbNuJ/DdMrZaVNJ1IXPctpK6b1olUJ5Qmy8QBvwROajTiGztso2n82kFEvCsNG7Rg==
|
||||
dependencies:
|
||||
bare-buffer "^3.0.0"
|
||||
bare-path "^3.0.0"
|
||||
|
||||
base64-js@^1.3.1:
|
||||
@@ -375,25 +385,25 @@ blueimp-md5@^2.16.0:
|
||||
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
|
||||
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
|
||||
|
||||
body-parser@^2.2.1, body-parser@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437"
|
||||
integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==
|
||||
body-parser@^2.3.0, body-parser@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.4.0.tgz#337a76249270f83e450ad385a9606ca3b4a75c06"
|
||||
integrity sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==
|
||||
dependencies:
|
||||
bytes "^3.1.2"
|
||||
content-type "^2.0.0"
|
||||
content-type "^2.1.0"
|
||||
debug "^4.4.3"
|
||||
http-errors "^2.0.1"
|
||||
iconv-lite "^0.7.2"
|
||||
iconv-lite "^0.7.3"
|
||||
on-finished "^2.4.1"
|
||||
qs "^6.15.2"
|
||||
qs "^6.16.1"
|
||||
raw-body "^3.0.2"
|
||||
type-is "^2.1.0"
|
||||
|
||||
brace-expansion@^5.0.8:
|
||||
version "5.0.9"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
|
||||
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
|
||||
version "5.0.12"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
|
||||
integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
|
||||
dependencies:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
@@ -468,10 +478,10 @@ camelcase@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
chalk@5.6.2:
|
||||
version "5.6.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
|
||||
integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
|
||||
chalk@6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-6.0.1.tgz#077d4667ad6e608c9687782caed3d534d7f9abc3"
|
||||
integrity sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==
|
||||
|
||||
chalk@^2.3.2:
|
||||
version "2.4.2"
|
||||
@@ -582,15 +592,10 @@ compression@^1.8.2:
|
||||
safe-buffer "5.2.1"
|
||||
vary "~1.1.2"
|
||||
|
||||
content-disposition@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17"
|
||||
integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==
|
||||
|
||||
content-type@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
|
||||
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
|
||||
content-disposition@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-2.0.1.tgz#88a75c7a52ef9c407c96035fe83a66939c2a4dc9"
|
||||
integrity sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==
|
||||
|
||||
content-type@^2.0.0, content-type@^2.1.0:
|
||||
version "2.1.0"
|
||||
@@ -630,11 +635,6 @@ data-uri-to-buffer@8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz#c642ffbc96d8fae76cccecbad43cbddd7818c785"
|
||||
integrity sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==
|
||||
|
||||
db-errors@^0.2.3:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2"
|
||||
integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
@@ -729,9 +729,9 @@ emoji-regex@^8.0.0:
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
encodeurl@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.1.tgz#f268548822bd87b5b4fdce83c56388c6d33c9de6"
|
||||
integrity sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==
|
||||
|
||||
end-of-stream@^1.1.0, end-of-stream@^1.4.1:
|
||||
version "1.4.5"
|
||||
@@ -847,22 +847,22 @@ exponential-backoff@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6"
|
||||
integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==
|
||||
|
||||
express-fileupload@^1.5.2:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.5.2.tgz#4da70ba6f2ffd4c736eab0776445865a9dbd9bfa"
|
||||
integrity sha512-wxUJn2vTHvj/kZCVmc5/bJO15C7aSMyHeuXYY3geKpeKibaAoQGcEv5+sM6nHS2T7VF+QHS4hTWPiY2mKofEdg==
|
||||
express-fileupload@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.6.0.tgz#fbc7035f090a554ae54005b1d8a7bb46c8440a62"
|
||||
integrity sha512-jIVP7PB+uNLrsglQUKzlK8PjxGkzPy3h0kH0XKpoxQnoT5sPNnHvfWnMMkLoP36LJS5Y8NkkdvkAYQnGCB6tdQ==
|
||||
dependencies:
|
||||
busboy "^1.6.0"
|
||||
|
||||
express@^5.2.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04"
|
||||
integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==
|
||||
express@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-5.3.0.tgz#ab72dace89c50c8417bd3f865dad0b4fd1ca1c73"
|
||||
integrity sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==
|
||||
dependencies:
|
||||
accepts "^2.0.0"
|
||||
body-parser "^2.2.1"
|
||||
content-disposition "^1.0.0"
|
||||
content-type "^1.0.5"
|
||||
body-parser "^2.3.0"
|
||||
content-disposition "^2.0.1"
|
||||
content-type "^2.0.0"
|
||||
cookie "^0.7.1"
|
||||
cookie-signature "^1.2.1"
|
||||
debug "^4.4.0"
|
||||
@@ -878,14 +878,14 @@ express@^5.2.1:
|
||||
on-finished "^2.4.1"
|
||||
once "^1.4.0"
|
||||
parseurl "^1.3.3"
|
||||
proxy-addr "^2.0.7"
|
||||
qs "^6.14.0"
|
||||
proxy-addr "^2.0.8"
|
||||
qs "^6.16.0"
|
||||
range-parser "^1.2.1"
|
||||
router "^2.2.0"
|
||||
send "^1.1.0"
|
||||
serve-static "^2.2.0"
|
||||
statuses "^2.0.1"
|
||||
type-is "^2.0.1"
|
||||
type-is "^2.1.0"
|
||||
vary "^1.1.2"
|
||||
|
||||
fast-deep-equal@^3.1.3:
|
||||
@@ -893,15 +893,15 @@ fast-deep-equal@^3.1.3:
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-fifo@^1.2.0, fast-fifo@^1.3.2:
|
||||
fast-fifo@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
|
||||
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.1.7"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a"
|
||||
integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==
|
||||
version "3.1.9"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.9.tgz#f1deba822b1d6ac79da2f860e872a63470cff203"
|
||||
integrity sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==
|
||||
|
||||
fdir@^6.5.0:
|
||||
version "6.5.0"
|
||||
@@ -1076,7 +1076,7 @@ has-symbols@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
|
||||
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||
|
||||
hasown@^2.0.2, hasown@^2.0.3:
|
||||
hasown@^2.0.2, hasown@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
||||
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
|
||||
@@ -1112,7 +1112,7 @@ https-proxy-agent@9.1.0:
|
||||
debug "^4.3.4"
|
||||
proxy-agent-negotiate "1.1.0"
|
||||
|
||||
iconv-lite@^0.7.2, iconv-lite@^0.7.3, iconv-lite@~0.7.0:
|
||||
iconv-lite@^0.7.3, iconv-lite@~0.7.0:
|
||||
version "0.7.3"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f"
|
||||
integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==
|
||||
@@ -1150,9 +1150,9 @@ interpret@^2.2.0:
|
||||
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
|
||||
|
||||
ip-address@^10.1.1:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b"
|
||||
integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==
|
||||
version "10.7.6"
|
||||
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.6.tgz#e3c06961cedf352494c06c916c696cfdb11068dd"
|
||||
integrity sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
version "1.9.1"
|
||||
@@ -1171,12 +1171,12 @@ is-binary-path@~2.1.0:
|
||||
dependencies:
|
||||
binary-extensions "^2.0.0"
|
||||
|
||||
is-core-module@^2.16.1:
|
||||
version "2.16.2"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082"
|
||||
integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==
|
||||
is-core-module@^2.18.0:
|
||||
version "2.18.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.18.0.tgz#8a988af8d3729fb6bbcaf025bf820cfe46b95f4c"
|
||||
integrity sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==
|
||||
dependencies:
|
||||
hasown "^2.0.3"
|
||||
hasown "^2.0.4"
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
@@ -1225,19 +1225,12 @@ isexe@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca"
|
||||
integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==
|
||||
|
||||
js-yaml@^4.2.0:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
|
||||
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
|
||||
js-yaml@^5.4.2:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.5.0.tgz#ec843f09ffb2a54b6758a41eee109a1815d16442"
|
||||
integrity sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^5.4.1:
|
||||
version "5.4.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.2.tgz#eebc7a57e0bbe1d6b6d9416a72e7463082c7da85"
|
||||
integrity sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
argparse "^3.0.3"
|
||||
|
||||
json-parse-better-errors@^1.0.1:
|
||||
version "1.0.2"
|
||||
@@ -1309,10 +1302,10 @@ lazystream@^1.0.0:
|
||||
dependencies:
|
||||
readable-stream "^2.0.5"
|
||||
|
||||
liquidjs@10.29.0:
|
||||
version "10.29.0"
|
||||
resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.29.0.tgz#ee061890ccf0da26be377aad4f02bd32da0140d8"
|
||||
integrity sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==
|
||||
liquidjs@10.30.1:
|
||||
version "10.30.1"
|
||||
resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.30.1.tgz#aa8c24535d638f88679a22cd8042c38b3ca61dc6"
|
||||
integrity sha512-P1Iz14WUHZdfbOYwHOIhtl/iAWIOYk1ks5l9rPZlDM6vZo+6BLeYIuWHAPkIz+U8Mm8HKZKITIrjX7DQJO2fqA==
|
||||
dependencies:
|
||||
commander "^10.0.0"
|
||||
|
||||
@@ -1392,9 +1385,9 @@ lru-cache@^7.14.1:
|
||||
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
||||
|
||||
lru.min@^1.1.0, lru.min@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b"
|
||||
integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.5.tgz#bd32483f4e342261d0eb011b06d2219e6b5ef818"
|
||||
integrity sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
@@ -1477,10 +1470,10 @@ ms@^2.1.1, ms@^2.1.3:
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
mysql2@^3.24.4:
|
||||
version "3.24.4"
|
||||
resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.4.tgz#7752de0cf10b1d2984103bdbc60f696c60756f31"
|
||||
integrity sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==
|
||||
mysql2@^3.24.6:
|
||||
version "3.24.6"
|
||||
resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.6.tgz#8e9471f0ceca917599ac021356bb24f08f168b3c"
|
||||
integrity sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==
|
||||
dependencies:
|
||||
aws-ssl-profiles "^1.1.2"
|
||||
generate-function "^2.3.1"
|
||||
@@ -1520,9 +1513,9 @@ netmask@^2.0.2:
|
||||
integrity sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==
|
||||
|
||||
node-abi@^3.3.0:
|
||||
version "3.94.0"
|
||||
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.94.0.tgz#007181ed0d1b56ae9670ea6c084d2bf83538405f"
|
||||
integrity sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==
|
||||
version "3.101.0"
|
||||
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.101.0.tgz#3c99775cb0a4ddf4f3f0dad2a9ad940d67ef0052"
|
||||
integrity sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==
|
||||
dependencies:
|
||||
semver "^7.3.5"
|
||||
|
||||
@@ -1592,19 +1585,18 @@ object-inspect@^1.13.3, object-inspect@^1.13.4:
|
||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
|
||||
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
|
||||
|
||||
objection@3.1.5:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/objection/-/objection-3.1.5.tgz#53c32f6b6cba2958bc28cf723de96c2676da8286"
|
||||
integrity sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ==
|
||||
objection@4.4.3:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/objection/-/objection-4.4.3.tgz#aa965ccb29cfd97b3717539ac73f9095c94a5cbf"
|
||||
integrity sha512-Ie1jBkoQvzCT7SBuh1nNl/c2gC6lACOy6vY7dygWyZr70bP1Um660XbxCLhXAQ9Uqwok4LWLjzsnKgNd7JpV6w==
|
||||
dependencies:
|
||||
ajv "^8.17.1"
|
||||
ajv-formats "^2.1.1"
|
||||
db-errors "^0.2.3"
|
||||
ajv-formats "^3.0.1"
|
||||
|
||||
on-finished@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
||||
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.2.tgz#6932b04b95bf6a4828201db10af75fe5c9c38b73"
|
||||
integrity sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==
|
||||
dependencies:
|
||||
ee-first "1.1.1"
|
||||
|
||||
@@ -1620,17 +1612,17 @@ once@^1.3.1, once@^1.4.0:
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
otplib@^13.5.0:
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.5.0.tgz#78b5d0d9edf83078d7fe37243bbcdd96d7a65d37"
|
||||
integrity sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==
|
||||
otplib@^13.6.0:
|
||||
version "13.6.0"
|
||||
resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.6.0.tgz#aa462e7e2c0370278666a608144c3194c7b2f7fe"
|
||||
integrity sha512-PCx6+14PYndCRk0G3JuJHmlExzENs43gafqMUVmSFmzMbEweVhBiYcBCL8B32ZMEDPZA4OsMxFEeAob1H+YFZg==
|
||||
dependencies:
|
||||
"@otplib/core" "13.5.0"
|
||||
"@otplib/hotp" "13.5.0"
|
||||
"@otplib/plugin-base32-scure" "13.5.0"
|
||||
"@otplib/plugin-crypto-noble" "13.5.0"
|
||||
"@otplib/totp" "13.5.0"
|
||||
"@otplib/uri" "13.5.0"
|
||||
"@otplib/core" "13.6.0"
|
||||
"@otplib/hotp" "13.6.0"
|
||||
"@otplib/plugin-base32-scure" "13.6.0"
|
||||
"@otplib/plugin-crypto-noble" "13.6.0"
|
||||
"@otplib/totp" "13.6.0"
|
||||
"@otplib/uri" "13.6.0"
|
||||
|
||||
p-limit@^1.1.0:
|
||||
version "1.3.0"
|
||||
@@ -1733,20 +1725,20 @@ path@^0.12.7:
|
||||
process "^0.11.1"
|
||||
util "^0.10.3"
|
||||
|
||||
pg-cloudflare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497"
|
||||
integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
|
||||
pg-cloudflare@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz#0fc904c9a8b3ca3a4f71e16bcc9171224ecbb2a8"
|
||||
integrity sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==
|
||||
|
||||
pg-connection-string@2.6.2:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475"
|
||||
integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==
|
||||
|
||||
pg-connection-string@^2.14.0:
|
||||
version "2.14.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
|
||||
integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
|
||||
pg-connection-string@^2.14.1:
|
||||
version "2.14.1"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz#d9529fe2497c0cac8a9f5f6f059679c52f1bd964"
|
||||
integrity sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==
|
||||
|
||||
pg-int8@1.0.1:
|
||||
version "1.0.1"
|
||||
@@ -1758,10 +1750,10 @@ pg-pool@^3.14.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
|
||||
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
|
||||
|
||||
pg-protocol@^1.16.0:
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305"
|
||||
integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==
|
||||
pg-protocol@^1.16.1:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.1.tgz#87a3951b57002682d63166c029859bd834e64bf1"
|
||||
integrity sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==
|
||||
|
||||
pg-types@2.2.0:
|
||||
version "2.2.0"
|
||||
@@ -1774,18 +1766,18 @@ pg-types@2.2.0:
|
||||
postgres-date "~1.0.4"
|
||||
postgres-interval "^1.1.0"
|
||||
|
||||
pg@^8.23.0:
|
||||
version "8.23.0"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180"
|
||||
integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==
|
||||
pg@^8.23.1:
|
||||
version "8.23.1"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.1.tgz#e4848051a04aa2f968fd5671d075f89e5e7918b9"
|
||||
integrity sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==
|
||||
dependencies:
|
||||
pg-connection-string "^2.14.0"
|
||||
pg-connection-string "^2.14.1"
|
||||
pg-pool "^3.14.0"
|
||||
pg-protocol "^1.16.0"
|
||||
pg-protocol "^1.16.1"
|
||||
pg-types "2.2.0"
|
||||
pgpass "1.0.5"
|
||||
optionalDependencies:
|
||||
pg-cloudflare "^1.4.0"
|
||||
pg-cloudflare "^1.4.1"
|
||||
|
||||
pgpass@1.0.5:
|
||||
version "1.0.5"
|
||||
@@ -1800,9 +1792,9 @@ picomatch@^2.0.4, picomatch@^2.2.1:
|
||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||
|
||||
picomatch@^4.0.4:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
|
||||
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
|
||||
integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
|
||||
|
||||
pify@^3.0.0:
|
||||
version "3.0.0"
|
||||
@@ -1872,10 +1864,10 @@ process@^0.11.1, process@^0.11.10:
|
||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||
|
||||
proxy-addr@^2.0.7:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
||||
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
|
||||
proxy-addr@^2.0.8:
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932"
|
||||
integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==
|
||||
dependencies:
|
||||
forwarded "0.2.0"
|
||||
ipaddr.js "1.9.1"
|
||||
@@ -1917,10 +1909,10 @@ pump@^3.0.0:
|
||||
end-of-stream "^1.1.0"
|
||||
once "^1.3.1"
|
||||
|
||||
qs@^6.14.0, qs@^6.15.2:
|
||||
version "6.16.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd"
|
||||
integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==
|
||||
qs@^6.16.0, qs@^6.16.1:
|
||||
version "6.17.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.17.1.tgz#43ac71d29ca0e9d0eed16a218e35b6cde5628e81"
|
||||
integrity sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==
|
||||
dependencies:
|
||||
es-define-property "^1.0.1"
|
||||
side-channel "^1.1.1"
|
||||
@@ -1983,9 +1975,9 @@ readable-stream@^3.1.1, readable-stream@^3.4.0:
|
||||
util-deprecate "^1.0.1"
|
||||
|
||||
readable-stream@^4.0.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91"
|
||||
integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==
|
||||
version "4.8.0"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.8.0.tgz#22af894e6d60cb0064410fc376367ec4ce470adb"
|
||||
integrity sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==
|
||||
dependencies:
|
||||
abort-controller "^3.0.0"
|
||||
buffer "^6.0.3"
|
||||
@@ -2035,12 +2027,12 @@ resolve-from@^5.0.0:
|
||||
integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
|
||||
|
||||
resolve@^1.20.0:
|
||||
version "1.22.12"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
|
||||
integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
|
||||
version "1.22.14"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.14.tgz#302388cd2d7731a100db1dedce7b050f74ba38f7"
|
||||
integrity sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
is-core-module "^2.16.1"
|
||||
is-core-module "^2.18.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
@@ -2071,9 +2063,9 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
semver@^7.3.5, semver@^7.5.3, semver@^7.5.4:
|
||||
version "7.8.5"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
|
||||
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
|
||||
version "7.8.6"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.6.tgz#67c0cda133a3ceb6f70b803e67625bf969000424"
|
||||
integrity sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==
|
||||
|
||||
send@^1.1.0, send@^1.2.0:
|
||||
version "1.2.1"
|
||||
@@ -2197,9 +2189,9 @@ socks-proxy-agent@10.1.0:
|
||||
socks "^2.8.3"
|
||||
|
||||
socks@^2.8.3:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752"
|
||||
integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.10.tgz#23aab4dccc1fdae110aaf4fa51cd190552432dc5"
|
||||
integrity sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==
|
||||
dependencies:
|
||||
ip-address "^10.1.1"
|
||||
smart-buffer "^4.2.0"
|
||||
@@ -2215,9 +2207,9 @@ split2@^4.1.0:
|
||||
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
|
||||
|
||||
sql-escaper@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.1.tgz#fbaee511294d1f7da92fb68ca1faee2255839c7b"
|
||||
integrity sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.2.tgz#5b59840dc4034bd23a9b7d00501ba947a90364b7"
|
||||
integrity sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==
|
||||
|
||||
sqlite3@^6.0.1:
|
||||
version "6.0.1"
|
||||
@@ -2241,10 +2233,10 @@ streamsearch@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0:
|
||||
version "2.28.0"
|
||||
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11"
|
||||
integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==
|
||||
streamx@^2.12.5, streamx@^2.25.0, streamx@^2.28.1:
|
||||
version "2.28.1"
|
||||
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f"
|
||||
integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==
|
||||
dependencies:
|
||||
events-universal "^1.0.0"
|
||||
fast-fifo "^1.3.2"
|
||||
@@ -2324,14 +2316,14 @@ tar-stream@^2.1.4:
|
||||
readable-stream "^3.1.1"
|
||||
|
||||
tar-stream@^3.0.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591"
|
||||
integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.2.tgz#aec7f257e712192a177a93aa0450c2e76855c8be"
|
||||
integrity sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==
|
||||
dependencies:
|
||||
b4a "^1.6.4"
|
||||
bare-fs "^4.5.5"
|
||||
fast-fifo "^1.2.0"
|
||||
streamx "^2.15.0"
|
||||
b4a "^1.9.0"
|
||||
bare-fs "^4.8.2"
|
||||
fast-fifo "^1.3.2"
|
||||
streamx "^2.28.1"
|
||||
|
||||
tar@^7.5.10, tar@^7.5.4:
|
||||
version "7.5.22"
|
||||
@@ -2408,9 +2400,9 @@ touch@^3.1.0:
|
||||
integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==
|
||||
|
||||
tslib@^2.0.1:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.9.1.tgz#8986213ba2f17f38ba39a7c43d0e565a9134ff5f"
|
||||
integrity sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==
|
||||
|
||||
tunnel-agent@^0.6.0:
|
||||
version "0.6.0"
|
||||
@@ -2419,7 +2411,7 @@ tunnel-agent@^0.6.0:
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
type-is@^2.0.1, type-is@^2.1.0:
|
||||
type-is@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570"
|
||||
integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==
|
||||
@@ -2434,14 +2426,9 @@ undefsafe@^2.0.5:
|
||||
integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
|
||||
|
||||
undici@^6.25.0:
|
||||
version "6.28.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c"
|
||||
integrity sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==
|
||||
|
||||
undici@^8.10.2:
|
||||
version "8.10.2"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7"
|
||||
integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==
|
||||
version "6.29.1"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.29.1.tgz#a3317c5df0236e0633727d9a7bd4f7ed195d8575"
|
||||
integrity sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==
|
||||
|
||||
unpipe@~1.0.0:
|
||||
version "1.0.0"
|
||||
|
||||
@@ -37,6 +37,17 @@ services:
|
||||
- website1.example.com
|
||||
- website2.example.com
|
||||
- website3.example.com
|
||||
- website4.example.com
|
||||
|
||||
examplesite:
|
||||
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
|
||||
build:
|
||||
context: ../test/docker
|
||||
dockerfile: Dockerfile.website
|
||||
expose:
|
||||
- "80/tcp"
|
||||
networks:
|
||||
- fulltest
|
||||
|
||||
stepca:
|
||||
image: nginxproxymanager/testca
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- website1.example.com
|
||||
- website2.example.com
|
||||
- website3.example.com
|
||||
- website4.example.com
|
||||
environment:
|
||||
TZ: "${TZ:-Australia/Brisbane}"
|
||||
PUID: 1000
|
||||
|
||||
@@ -17,6 +17,9 @@ server {
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# The TCP peer before realip processing. In development there is no peer secret
|
||||
# (see production.conf.template), so the backend ignores this and limits on the socket
|
||||
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
|
||||
proxy_read_timeout 15m;
|
||||
|
||||
@@ -3,7 +3,7 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|
|
||||
|
||||
# use the public cache
|
||||
proxy_cache public-cache;
|
||||
proxy_cache_key $host$request_uri;
|
||||
proxy_cache_key $forward_host$request_uri;
|
||||
|
||||
# ignore these headers for media
|
||||
proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
add_header X-Served-By $host;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $forward_host;
|
||||
proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme;
|
||||
proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -17,6 +17,11 @@ server {
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# The TCP peer before realip processing; the backend rate limits on this because
|
||||
# X-Real-IP can be set by any client in the trusted private ranges. The secret, generated
|
||||
# at startup, is how the backend knows this header came from here.
|
||||
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
|
||||
proxy_set_header X-NPM-Peer-Secret "{{NPM_PEER_SECRET}}";
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
|
||||
proxy_read_timeout 15m;
|
||||
|
||||
@@ -52,6 +52,18 @@ http {
|
||||
# Dynamically generated resolvers file
|
||||
include /etc/nginx/conf.d/include/resolvers[.]conf;
|
||||
|
||||
# Forwarded Host: nginx's normalised $host (lowercased, taken from the request line for
|
||||
# absolute-form requests) plus the client's port, so upstreams that compare Host with
|
||||
# Origin/Referer on non-standard ports keep working. The port is only carried over when
|
||||
# the Host header names the same host nginx routed on; otherwise (no Host, absolute-form
|
||||
# URI naming a different host) it falls back to bare $host. Using $host rather than the
|
||||
# raw header keeps case variants and mismatched Host values out of upstream requests and
|
||||
# the asset cache key.
|
||||
map "$host|$http_host" $forward_host {
|
||||
"~*^(?<fwd_name>[^|]+)\|\k<fwd_name>(?<fwd_port>:[0-9]+)$" "$host$fwd_port";
|
||||
default $host;
|
||||
}
|
||||
|
||||
# Default upstream scheme
|
||||
map $host $forward_scheme {
|
||||
default http;
|
||||
|
||||
@@ -39,3 +39,7 @@ touch /var/log/nginx/error.log || true
|
||||
chmod 777 /var/log/nginx/error.log || true
|
||||
chmod -R 777 /var/cache/nginx || true
|
||||
chmod 644 /etc/logrotate.d/nginx-proxy-manager
|
||||
|
||||
# nginx has not been started yet, so any pid file here is stale. Remove it,
|
||||
# otherwise a pid file owned by another user stops nginx starting as PUID:PGID
|
||||
rm -f /run/nginx/nginx.pid
|
||||
@@ -49,7 +49,9 @@ done
|
||||
# Ensure the JWT key file is owned by the runtime user, even when the /data
|
||||
# directory ownership already matches PUID:PGID (chownit skips recursion then)
|
||||
if [ -f /data/keys.json ]; then
|
||||
chown "$PUID:$PGID" /data/keys.json
|
||||
if [ "$(stat -c '%u:%g' '/data/keys.json')" != "$PUID:$PGID" ]; then
|
||||
chown "$PUID:$PGID" /data/keys.json
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
|
||||
|
||||
@@ -16,7 +16,16 @@ if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
|
||||
fi
|
||||
|
||||
PRODFILE="/etc/nginx/conf.d/production.conf"
|
||||
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
|
||||
|
||||
# A new secret on every start. The admin nginx sends it with X-NPM-Peer-Addr, and the backend
|
||||
# only trusts that header when the secret matches, so a proxy host or stream forwarding to the
|
||||
# backend port can't pass a forged one through.
|
||||
NPM_PEER_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
if [ "${#NPM_PEER_SECRET}" -ne 64 ]; then
|
||||
log_fatal "Failed to generate the backend peer secret"
|
||||
fi
|
||||
|
||||
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g;s/\{\{NPM_PEER_SECRET\}\}/${NPM_PEER_SECRET}/g"
|
||||
|
||||
if is_mounted "$PRODFILE"; then
|
||||
echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
|
||||
@@ -24,6 +33,8 @@ elif [ -f "$PRODFILE.template" ]; then
|
||||
if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
|
||||
# success
|
||||
log_info "Generated ${PRODFILE} from template"
|
||||
# Hand the secret to the backend; with-contenv loads this directory into its environment
|
||||
printf '%s' "$NPM_PEER_SECRET" > /var/run/s6/container_environment/NPM_PEER_SECRET
|
||||
else
|
||||
log_fatal "Failed to generate ${PRODFILE} from template"
|
||||
fi
|
||||
|
||||
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
|
||||
GREEN='\E[1;32m'
|
||||
RESET='\E[0m'
|
||||
|
||||
S6_OVERLAY_VERSION=3.2.3.0
|
||||
S6_OVERLAY_VERSION=3.2.3.2
|
||||
TARGETPLATFORM=${1:-linux/amd64}
|
||||
|
||||
# Determine the correct binary file for the architecture given
|
||||
|
||||
+435
-435
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -23,6 +23,7 @@ const allLocales = [
|
||||
["cs", "cs-CZ"],
|
||||
["vi", "vi-VN"],
|
||||
["zh", "zh-CN"],
|
||||
["zh-tw", "zh-TW"],
|
||||
["ko", "ko-KR"],
|
||||
["bg", "bg-BG"],
|
||||
["id", "id-ID"],
|
||||
|
||||
+23
-23
@@ -16,11 +16,11 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.5.1",
|
||||
"@tabler/icons-react": "^3.46.0",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-table": "^9.2.4",
|
||||
"@uiw/react-textarea-code-editor": "^3.1.1",
|
||||
"@tabler/core": "^1.7.4",
|
||||
"@tabler/icons-react": "^3.51.0",
|
||||
"@tanstack/react-query": "^5.107.1",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
"@uiw/react-textarea-code-editor": "^3.1.3",
|
||||
"classnames": "^2.5.1",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"date-fns": "^4.4.0",
|
||||
@@ -28,40 +28,40 @@
|
||||
"formik": "^2.4.9",
|
||||
"generate-password-browser": "^1.1.0",
|
||||
"humps": "^2.0.1",
|
||||
"query-string": "^9.5.1",
|
||||
"react": "^19.3.0",
|
||||
"query-string": "^10.0.0",
|
||||
"react": "^19.3.1",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-intl": "^10.2.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-intl": "^12.1.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-router-dom": "^7.18.5",
|
||||
"react-select": "^5.10.2",
|
||||
"react-toastify": "^11.1.0",
|
||||
"react-toastify": "^11.2.0",
|
||||
"rooks": "^9.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.10",
|
||||
"@formatjs/cli": "^6.16.30",
|
||||
"@tanstack/react-query-devtools": "^5.103.1",
|
||||
"@biomejs/biome": "^2.6.6",
|
||||
"@formatjs/cli": "^6.18.1",
|
||||
"@tanstack/react-query-devtools": "^5.107.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/country-flag-icons": "^1.2.2",
|
||||
"@types/country-flag-icons": "^1.6.20",
|
||||
"@types/humps": "^2.0.6",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"happy-dom": "^20.14.5",
|
||||
"postcss": "^8.5.28",
|
||||
"@vitejs/plugin-react": "^6.2.1",
|
||||
"happy-dom": "^21.0.4",
|
||||
"postcss": "^8.5.32",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.104.1",
|
||||
"sass": "^1.108.1",
|
||||
"tmp": "^0.2.7",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.3.6",
|
||||
"vite-plugin-checker": "^1.0.0",
|
||||
"vitest": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,31 @@ label.row {
|
||||
:host, :root {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep Tabler 1.5's behaviour on Tabler 1.6/1.7. App.css loads before the
|
||||
dynamically imported Tabler CSS, hence the doubled :root selectors. */
|
||||
|
||||
/* 1.6 dropped the reserved scrollbar gutter, so the layout shifted 7px between short and long pages */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
/* 1.6+ mixes the focus ring 50% with the background (75% dark, about 2.1:1 on white); restore full strength */
|
||||
:root:root {
|
||||
--tblr-focus-ring-opacity: 1;
|
||||
--tblr-focus-ring-opacity-dark: 1;
|
||||
}
|
||||
|
||||
/* Coloured primary buttons (btn-primary + bg-<colour>) got a blue primary border in 1.7 */
|
||||
.btn-primary[class*=" bg-"] {
|
||||
--tblr-btn-border-color: transparent;
|
||||
--tblr-btn-hover-border-color: transparent;
|
||||
--tblr-btn-active-border-color: transparent;
|
||||
--tblr-btn-box-shadow: var(--tblr-shadow-input);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function buildBody(data?: Record<string, any>): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
async function processResponse(response: Response) {
|
||||
async function processResponse(response: Response, preserveResponseKeys = false) {
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -59,7 +59,7 @@ async function processResponse(response: Response) {
|
||||
typeof payload.error.messageI18n !== "undefined" ? payload.error.messageI18n : payload.error.message,
|
||||
);
|
||||
}
|
||||
return camelizeKeys(payload) as any;
|
||||
return (preserveResponseKeys ? payload : camelizeKeys(payload)) as any;
|
||||
}
|
||||
|
||||
interface GetArgs {
|
||||
@@ -97,9 +97,10 @@ interface PostArgs {
|
||||
params?: queryString.StringifiableRecord;
|
||||
data?: any;
|
||||
noAuth?: boolean;
|
||||
preserveResponseKeys?: boolean;
|
||||
}
|
||||
|
||||
export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) {
|
||||
export async function post({ url, params, data, noAuth, preserveResponseKeys }: PostArgs, abortController?: AbortController) {
|
||||
const apiUrl = buildUrl({ url, params });
|
||||
const method = "POST";
|
||||
|
||||
@@ -126,7 +127,7 @@ export async function post({ url, params, data, noAuth }: PostArgs, abortControl
|
||||
|
||||
const signal = abortController?.signal;
|
||||
const response = await fetch(apiUrl, { method, headers, body, signal });
|
||||
return processResponse(response);
|
||||
return processResponse(response, preserveResponseKeys);
|
||||
}
|
||||
|
||||
interface PutArgs {
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface UserPermissions {
|
||||
streams: string;
|
||||
accessLists: string;
|
||||
certificates: string;
|
||||
meta?: {
|
||||
proxyHostIds?: number[];
|
||||
proxy_host_ids?: number[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "./base";
|
||||
import { testHttpCertificate } from "./testHttpCertificate";
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const reply = (payload: unknown, status = 200) => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status }));
|
||||
};
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
describe("certificate HTTP result keys", () => {
|
||||
it.each([
|
||||
"test-domain.example",
|
||||
"test--domain.example",
|
||||
"xn--bcher-kva.example",
|
||||
"one.two-three.example",
|
||||
"_acme-challenge.example",
|
||||
"Test-Domain.example",
|
||||
"plain.example",
|
||||
])("preserves the exact response key %s", async (domain) => {
|
||||
const result = { [domain]: "wrong-data" };
|
||||
reply(result);
|
||||
expect(await testHttpCertificate([domain])).toEqual(result);
|
||||
});
|
||||
it("keeps colliding spellings and their distinct statuses", async () => {
|
||||
const result = { "test-domain.example": "ok", "test--domain.example": "no-host" };
|
||||
reply(result);
|
||||
expect(await testHttpCertificate(Object.keys(result))).toEqual(result);
|
||||
});
|
||||
it("sends unchanged domains with the normal request headers", async () => {
|
||||
const domains = ["test-domain.example", "test--domain.example"];
|
||||
reply({});
|
||||
await testHttpCertificate(domains);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/nginx/certificates/test-http", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domains }),
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
it("preserves an empty result", async () => {
|
||||
reply({});
|
||||
expect(await testHttpCertificate([])).toEqual({});
|
||||
});
|
||||
it.each([
|
||||
[400, { message: "invalid domain" }, "invalid domain"],
|
||||
[500, { message: "fallback", messageI18n: "certificate-error" }, "certificate-error"],
|
||||
])("preserves error handling for status %s", async (status, error, message) => {
|
||||
reply({ error }, status);
|
||||
await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow(message);
|
||||
});
|
||||
it("still clears authentication on 401", async () => {
|
||||
const clear = vi.spyOn(AuthStore, "clear");
|
||||
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
reply({ error: { message: "unauthorized" } }, 401);
|
||||
await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow("unauthorized");
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each([
|
||||
["get", api.get],
|
||||
["post", api.post],
|
||||
["put", api.put],
|
||||
["del", api.del],
|
||||
] as const)("keeps default %s response normalization", async (_name, method) => {
|
||||
reply({ domain_names: ["test-domain.example"], nested_value: { created_on: "today" } });
|
||||
expect(await method({ url: "/control" })).toEqual({
|
||||
domainNames: ["test-domain.example"],
|
||||
nestedValue: { createdOn: "today" },
|
||||
});
|
||||
});
|
||||
it("preserves POST abort forwarding and request normalization", async () => {
|
||||
const controller = new AbortController();
|
||||
reply({ domain_names: [] });
|
||||
await api.post({ url: "/control", data: { domainNames: ["a-b.example"] } }, controller);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/control",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ domain_names: ["a-b.example"] }),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as api from "./base";
|
||||
export async function testHttpCertificate(domains: string[]): Promise<Record<string, string>> {
|
||||
return await api.post({
|
||||
url: "/nginx/certificates/test-http",
|
||||
preserveResponseKeys: true,
|
||||
data: {
|
||||
domains,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
||||
import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import type { ReactNode } from "react";
|
||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||
@@ -32,8 +32,16 @@ interface Props {
|
||||
name?: string;
|
||||
label?: string;
|
||||
onFormChange?: (value: number) => void;
|
||||
// When set, the 0 option inherits the host's access list instead of being public
|
||||
inheritHost?: boolean;
|
||||
}
|
||||
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", onFormChange }: Props) {
|
||||
export function AccessField({
|
||||
name = "accessListId",
|
||||
label = "access-list",
|
||||
id = "accessListId",
|
||||
onFormChange,
|
||||
inheritHost = false,
|
||||
}: Props) {
|
||||
const { locale } = useLocaleState();
|
||||
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
||||
const { setFieldValue } = useFormikContext();
|
||||
@@ -60,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
|
||||
icon: <IconLock size={14} className="text-lime" />,
|
||||
})) || [];
|
||||
|
||||
// Public option
|
||||
options?.unshift({
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.public" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
||||
icon: <IconLockOpen2 size={14} className="text-red" />,
|
||||
});
|
||||
// Public or inherit option
|
||||
options?.unshift(
|
||||
inheritHost
|
||||
? {
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.inherit" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }),
|
||||
icon: <IconArrowBackUp size={14} className="text-secondary" />,
|
||||
}
|
||||
: {
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.public" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
||||
icon: <IconLockOpen2 size={14} className="text-red" />,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Field name={name}>
|
||||
|
||||
@@ -38,8 +38,7 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
|
||||
};
|
||||
|
||||
const setFormField = (newValues: AccessListItem[]) => {
|
||||
const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== "");
|
||||
setFieldValue(name, filtered);
|
||||
setFieldValue(name, newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Form } from "formik";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BasicAuthFields } from "./BasicAuthFields";
|
||||
import { FormModal } from "./FormModal";
|
||||
import { LocationsFields } from "./LocationsFields";
|
||||
|
||||
vi.mock("./AccessField", () => ({ AccessField: () => null }));
|
||||
|
||||
const renderDraft = (initialValues: Record<string, unknown>, children: ReactNode) => {
|
||||
const onHide = vi.fn();
|
||||
render(
|
||||
<FormModal show onHide={onHide} initialValues={initialValues} onSubmit={vi.fn()}>
|
||||
{({ onHide: hide }) => (
|
||||
<Form>
|
||||
{children}
|
||||
<button type="button" onClick={hide}>
|
||||
Cancel
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
</FormModal>,
|
||||
);
|
||||
return onHide;
|
||||
};
|
||||
|
||||
describe("FormModal local drafts", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"confirm",
|
||||
vi.fn(() => false),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("protects a location draft without a path", () => {
|
||||
const onHide = renderDraft({ locations: [] }, <LocationsFields initialValues={[]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Location" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("eg: 10.0.0.1/path/"), { target: { value: "example.com" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("protects a basic auth password draft without a username", () => {
|
||||
const onHide = renderDraft({ items: [] }, <BasicAuthFields initialValues={[]} />);
|
||||
fireEvent.change(document.querySelector('input[type="password"]') as Element, {
|
||||
target: { value: "secret" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Field, Form } from "formik";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FormModal } from "./FormModal";
|
||||
|
||||
const renderModal = (onHide = vi.fn(), preventHide = false) => {
|
||||
render(
|
||||
<FormModal show onHide={onHide} preventHide={preventHide} initialValues={{ name: "" }} onSubmit={vi.fn()}>
|
||||
{({ onHide: hide }) => (
|
||||
<Form>
|
||||
<Modal.Header closeButton />
|
||||
<label htmlFor="name">Name</label>
|
||||
<Field id="name" name="name" />
|
||||
<button type="button" onClick={hide}>
|
||||
Cancel
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
</FormModal>,
|
||||
);
|
||||
return onHide;
|
||||
};
|
||||
|
||||
describe("FormModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("confirm", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("dismisses a pristine form without confirmation", () => {
|
||||
const onHide = renderModal();
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a dirty form open when dismissal is cancelled", () => {
|
||||
const onHide = renderModal();
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
fireEvent.click(document.querySelector(".modal") as Element);
|
||||
fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(4);
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("example.com");
|
||||
});
|
||||
|
||||
it("dismisses a dirty form after confirmation", () => {
|
||||
const onHide = renderModal();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not offer to discard changes while submission is in progress", () => {
|
||||
const onHide = renderModal(vi.fn(), true);
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Formik, type FormikConfig, type FormikProps, type FormikValues } from "formik";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { intl } from "src/locale";
|
||||
|
||||
const confirmDiscardChanges = (dirty: boolean) =>
|
||||
!dirty ||
|
||||
window.confirm(
|
||||
intl.formatMessage({
|
||||
id: "form.unsaved-changes",
|
||||
defaultMessage: "You have unsaved changes. Are you sure you want to discard them?",
|
||||
}),
|
||||
);
|
||||
|
||||
const FormDirtyState = ({ dirty, onChange }: { dirty: boolean; onChange: (dirty: boolean) => void }) => {
|
||||
useEffect(() => {
|
||||
onChange(dirty);
|
||||
return () => onChange(false);
|
||||
}, [dirty, onChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface FormModalProps<Values extends FormikValues> extends Omit<FormikConfig<Values>, "children"> {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
preventHide?: boolean;
|
||||
children: (props: FormikProps<Values> & { onHide: () => void }) => ReactNode;
|
||||
}
|
||||
|
||||
const FormModal = <Values extends FormikValues>({
|
||||
show,
|
||||
onHide,
|
||||
preventHide = false,
|
||||
children,
|
||||
...formikProps
|
||||
}: FormModalProps<Values>) => (
|
||||
<Formik {...formikProps}>
|
||||
{(formikProps) => {
|
||||
const handleHide = () => {
|
||||
if (!preventHide && confirmDiscardChanges(formikProps.dirty)) {
|
||||
onHide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleHide}>
|
||||
{children({ ...formikProps, onHide: handleHide })}
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
export { confirmDiscardChanges, FormDirtyState, FormModal };
|
||||
@@ -30,8 +30,9 @@ interface Row {
|
||||
interface Props {
|
||||
initialValues: ProxyLocation[];
|
||||
name?: string;
|
||||
allowAdvanced?: boolean;
|
||||
}
|
||||
export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
export function LocationsFields({ initialValues, name = "locations", allowAdvanced = true }: Props) {
|
||||
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [expanded, setExpanded] = useState<number[]>([]);
|
||||
@@ -88,8 +89,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
};
|
||||
|
||||
const setFormField = (newRows: Row[]) => {
|
||||
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||
setFieldValue(name, filtered);
|
||||
setFieldValue(
|
||||
name,
|
||||
newRows.map((r: Row) => r.value),
|
||||
);
|
||||
};
|
||||
|
||||
const forwardSummary = (item: ProxyLocation) => {
|
||||
@@ -215,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
{isOpen && (
|
||||
<div className="card-body" id={bodyId}>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className={allowAdvanced ? "col-md-10" : "col-md-12"}>
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
@@ -228,23 +231,25 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
aria-expanded={advVisible.includes(row.id)}
|
||||
onClick={() => toggleAdvVisible(row.id)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{allowAdvanced && (
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
aria-expanded={advVisible.includes(row.id)}
|
||||
onClick={() => toggleAdvVisible(row.id)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="col-sm-3 col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardScheme-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-scheme" />
|
||||
@@ -262,10 +267,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="col-sm-5 col-md-5">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardHost-${row.id}`}
|
||||
>
|
||||
<T id="proxy-host.forward-host" />
|
||||
@@ -283,10 +288,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="col-sm-4 col-md-4">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardPort-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-port" />
|
||||
@@ -312,8 +317,9 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
label="access-list"
|
||||
id={`locations-access-list-${row.id}`}
|
||||
onFormChange={(value) => handleAccessListChange(row.id, value)}
|
||||
inheritHost
|
||||
/>
|
||||
{advVisible.includes(row.id) && (
|
||||
{allowAdvanced && advVisible.includes(row.id) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./AccessField";
|
||||
export * from "./BasicAuthFields";
|
||||
export * from "./DNSProviderFields";
|
||||
export * from "./DomainNamesField";
|
||||
export * from "./FormModal";
|
||||
export * from "./LocationsFields";
|
||||
export * from "./NginxConfigField";
|
||||
export * from "./SSLCertificateField";
|
||||
|
||||
@@ -1,18 +1,95 @@
|
||||
import cn from "classnames";
|
||||
import { differenceInDays, isPast } from "date-fns";
|
||||
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
|
||||
import Popover from "react-bootstrap/Popover";
|
||||
import type { Certificate } from "src/api/backend";
|
||||
import { T } from "src/locale";
|
||||
import { DateFormatter } from "src/components";
|
||||
import { parseDate, T } from "src/locale";
|
||||
|
||||
const getProviderLabel = (provider: string) => {
|
||||
if (provider === "letsencrypt") {
|
||||
return "lets-encrypt";
|
||||
}
|
||||
if (provider === "other") {
|
||||
return "certificates.custom";
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
certificate?: Certificate;
|
||||
}
|
||||
export function CertificateFormatter({ certificate }: Props) {
|
||||
let translation = "http-only";
|
||||
if (certificate) {
|
||||
translation = certificate.provider;
|
||||
if (translation === "letsencrypt") {
|
||||
translation = "lets-encrypt";
|
||||
} else if (translation === "other") {
|
||||
translation = "certificates.custom";
|
||||
}
|
||||
if (!certificate) {
|
||||
return <T id="http-only" />;
|
||||
}
|
||||
return <T id={translation} />;
|
||||
|
||||
const translation = getProviderLabel(certificate.provider);
|
||||
const expires = certificate.expiresOn ? parseDate(certificate.expiresOn) : null;
|
||||
const expired = expires ? isPast(expires) : false;
|
||||
const daysLeft = expires ? differenceInDays(expires, new Date()) : null;
|
||||
const labelClass = cn({
|
||||
"text-danger": expired,
|
||||
"text-warning": !expired && daysLeft !== null && daysLeft <= 30,
|
||||
});
|
||||
|
||||
const name = certificate.niceName || certificate.domainNames?.[0] || `#${certificate.id}`;
|
||||
|
||||
const popover = (
|
||||
<Popover id={`popover-certificate-${certificate.id}`}>
|
||||
<Popover.Body>
|
||||
<div className="mb-2">
|
||||
<strong>{name}</strong>
|
||||
<span className="text-secondary ms-1">#{certificate.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<T id="column.provider" />
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ms-1 mb-2">
|
||||
<T id={translation} />
|
||||
</div>
|
||||
{certificate.domainNames?.length ? (
|
||||
<>
|
||||
<div>
|
||||
<strong>
|
||||
<T id="domain-names" />
|
||||
</strong>
|
||||
</div>
|
||||
{certificate.domainNames.map((domain) => (
|
||||
<div key={domain} className="ms-1">
|
||||
{domain}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{certificate.expiresOn ? (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<strong>
|
||||
<T id="column.expires" />
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ms-1">
|
||||
<DateFormatter value={certificate.expiresOn} highlightPast highlistNearlyExpired />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Popover.Body>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger trigger={["hover", "click", "focus"]} placement="bottom" overlay={popover}>
|
||||
<div style={{ cursor: "help" }}>
|
||||
<span className={labelClass}>
|
||||
<T id={translation} />
|
||||
</span>
|
||||
{certificate.provider === "other" && certificate.niceName ? (
|
||||
<div className="text-secondary small">{certificate.niceName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import langSk from "./lang/sk.json";
|
||||
import langCs from "./lang/cs.json";
|
||||
import langVi from "./lang/vi.json";
|
||||
import langZh from "./lang/zh.json";
|
||||
import langZhTw from "./lang/zh-tw.json";
|
||||
import langTr from "./lang/tr.json";
|
||||
import langHu from "./lang/hu.json";
|
||||
import langNo from "./lang/no.json";
|
||||
@@ -27,7 +28,7 @@ import langFa from "./lang/fa.json";
|
||||
import langList from "./lang/lang-list.json";
|
||||
|
||||
// first item of each array should be the language code,
|
||||
// not the country code
|
||||
// or the full locale when multiple variants share a language
|
||||
// Remember when adding to this list, also update check-locales.js script
|
||||
const localeOptions = [
|
||||
["en", "en-US", langEn],
|
||||
@@ -46,6 +47,7 @@ const localeOptions = [
|
||||
["cs", "cs-CZ", langCs],
|
||||
["vi", "vi-VN", langVi],
|
||||
["zh", "zh-CN", langZh],
|
||||
["zh-TW", "zh-TW", langZhTw],
|
||||
["ko", "ko-KR", langKo],
|
||||
["bg", "bg-BG", langBg],
|
||||
["id", "id-ID", langId],
|
||||
@@ -58,18 +60,26 @@ const localeOptions = [
|
||||
];
|
||||
|
||||
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
const requestedLocale = (locale || "en").toLowerCase();
|
||||
const shortLocale = requestedLocale.slice(0, 2);
|
||||
const localeOption =
|
||||
localeOptions.find((option) => String(option[1]).toLowerCase() === requestedLocale) ??
|
||||
localeOptions.find((option) => String(option[0]).toLowerCase() === shortLocale);
|
||||
|
||||
// ensure this lang exists in localeOptions above, otherwise fallback to en
|
||||
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
|
||||
if (!localeOption || localeOption[0] === "en") {
|
||||
return Object.assign({}, langList, langEn);
|
||||
}
|
||||
|
||||
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
|
||||
return Object.assign({}, langList, langEn, localeOption[2]);
|
||||
};
|
||||
|
||||
const getFlagCodeForLocale = (locale?: string) => {
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
const requestedLocale = (locale || "en").toLowerCase();
|
||||
if (requestedLocale === "zh-tw") {
|
||||
return "TW";
|
||||
}
|
||||
const thisLocale = requestedLocale.slice(0, 2);
|
||||
|
||||
// only add to this if your flag is different from the locale code
|
||||
const specialCases: Record<string, string> = {
|
||||
@@ -160,4 +170,4 @@ const T = ({
|
||||
|
||||
//console.log("L:", localeOptions);
|
||||
|
||||
export { localeOptions, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };
|
||||
export { localeOptions, loadMessages, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatDateTime, getFlagCodeForLocale } from "src/locale";
|
||||
import { changeLocale, formatDateTime, getFlagCodeForLocale, getLocale, loadMessages } from "src/locale";
|
||||
import { getHelpFile } from "src/locale/src/HelpDoc";
|
||||
import * as zhTwHelp from "src/locale/src/HelpDoc/zh-tw";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("DateFormatter", () => {
|
||||
@@ -83,6 +85,9 @@ describe("getFlagCodeForLocale", () => {
|
||||
it("returns correct flag code for special-case locales", () => {
|
||||
expect(getFlagCodeForLocale("ja-JP")).toBe("JP");
|
||||
expect(getFlagCodeForLocale("zh-CN")).toBe("CN");
|
||||
expect(getFlagCodeForLocale("zh")).toBe("CN");
|
||||
expect(getFlagCodeForLocale("zh-TW")).toBe("TW");
|
||||
expect(getFlagCodeForLocale("ZH-tw")).toBe("TW");
|
||||
expect(getFlagCodeForLocale("vi-VN")).toBe("VN");
|
||||
expect(getFlagCodeForLocale("ko-KR")).toBe("KR");
|
||||
expect(getFlagCodeForLocale("cs-CZ")).toBe("CZ");
|
||||
@@ -112,3 +117,51 @@ describe("formatDateTime Estonian locale", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateTime Vietnamese locale", () => {
|
||||
it("uses 24-hour clock for vi and vi-VN", () => {
|
||||
const value = "2024-06-15T15:30:00.000Z";
|
||||
for (const locale of ["vi", "vi-VN"]) {
|
||||
const text = formatDateTime(value, locale);
|
||||
// Vietnamese 12-hour output marks the period with SA (sáng) / CH (chiều)
|
||||
expect(text).not.toMatch(/\b(SA|CH)\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chinese locale variants", () => {
|
||||
it("loads Traditional Chinese by full locale before falling back to Simplified Chinese", () => {
|
||||
expect(loadMessages("ZH-tw")).toEqual(loadMessages("zh-TW"));
|
||||
expect(loadMessages("zh-TW").save).toBe("儲存");
|
||||
expect(loadMessages("zh").save).toBe("保存");
|
||||
expect(loadMessages("zh-CN")).toEqual(loadMessages("zh"));
|
||||
expect(loadMessages("zh-HK")).toEqual(loadMessages("zh"));
|
||||
});
|
||||
|
||||
it("preserves the full locale and resets RTL layout when switching to Traditional Chinese", () => {
|
||||
const originalLocale = getLocale();
|
||||
const savedLocale = window.localStorage.getItem("locale");
|
||||
try {
|
||||
changeLocale("fa-IR");
|
||||
expect(document.documentElement.dir).toBe("rtl");
|
||||
changeLocale("zh-TW");
|
||||
expect(getLocale()).toBe("zh-TW");
|
||||
expect(window.localStorage.getItem("locale")).toBe("zh-TW");
|
||||
expect(document.documentElement.lang).toBe("zh-TW");
|
||||
expect(document.documentElement.dir).toBe("ltr");
|
||||
} finally {
|
||||
changeLocale(originalLocale);
|
||||
if (savedLocale === null) {
|
||||
window.localStorage.removeItem("locale");
|
||||
} else {
|
||||
window.localStorage.setItem("locale", savedLocale);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("selects HelpDocs by full locale before falling back to the language code", () => {
|
||||
expect(getHelpFile("ZH-tw", "AccessLists")).toBe(getHelpFile("zh-TW", "AccessLists"));
|
||||
expect(getHelpFile("zh-TW", "AccessLists")).toBe(zhTwHelp.AccessLists.default);
|
||||
expect(getHelpFile("zh-CN", "AccessLists")).toBe(getHelpFile("zh", "AccessLists"));
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const DATE_LOCALE_BY_LANG: Record<string, string> = {
|
||||
|
||||
const HOUR_CYCLE_BY_LANG: Record<string, "h12" | "h23"> = {
|
||||
et: "h23",
|
||||
vi: "h23",
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string | number, locale = "en-US"): string => {
|
||||
|
||||
@@ -17,19 +17,53 @@ import * as sk from "./sk/index";
|
||||
import * as cs from "./cs/index";
|
||||
import * as vi from "./vi/index";
|
||||
import * as zh from "./zh/index";
|
||||
import * as zhTw from "./zh-tw/index";
|
||||
import * as tr from "./tr/index";
|
||||
import * as hu from "./hu/index";
|
||||
import * as uk from "./uk/index";
|
||||
import * as az from "./az/index";
|
||||
import * as fa from "./fa/index";
|
||||
|
||||
const items: any = { en, de, pt, es, et, ja, sk, cs, zh, pl, ru, it, vi, nl, bg, ko, ga, id, fr, tr, hu, uk, az, fa };
|
||||
const items: any = {
|
||||
en,
|
||||
de,
|
||||
pt,
|
||||
es,
|
||||
et,
|
||||
ja,
|
||||
sk,
|
||||
cs,
|
||||
zh,
|
||||
"zh-TW": zhTw,
|
||||
pl,
|
||||
ru,
|
||||
it,
|
||||
vi,
|
||||
nl,
|
||||
bg,
|
||||
ko,
|
||||
ga,
|
||||
id,
|
||||
fr,
|
||||
tr,
|
||||
hu,
|
||||
uk,
|
||||
az,
|
||||
fa,
|
||||
};
|
||||
|
||||
const fallbackLang = "en";
|
||||
|
||||
export const getHelpFile = (lang: string, section: string): string => {
|
||||
if (typeof items[lang] !== "undefined" && typeof items[lang][section] !== "undefined") {
|
||||
return items[lang][section].default;
|
||||
const requestedLang = lang.toLowerCase();
|
||||
const exactLang = Object.keys(items).find((key) => key.toLowerCase() === requestedLang);
|
||||
const shortLang = Object.keys(items).find((key) => key.toLowerCase() === requestedLang.slice(0, 2));
|
||||
|
||||
if (exactLang && typeof items[exactLang][section] !== "undefined") {
|
||||
return items[exactLang][section].default;
|
||||
}
|
||||
if (shortLang && typeof items[shortLang][section] !== "undefined") {
|
||||
return items[shortLang][section].default;
|
||||
}
|
||||
// Fallback to English
|
||||
if (typeof items[fallbackLang] !== "undefined" && typeof items[fallbackLang][section] !== "undefined") {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
## 접근 정책이란?
|
||||
|
||||
접근 정책은 특정 클라이언트 IP 주소를 허용하거나 거부할 수 있으며,
|
||||
프록시 호스트에 기본 HTTP 인증(Basic Auth) 을 적용할 수 있는 기능입니다.
|
||||
프록시 호스트에 기본 HTTP 인증(Basic Auth)을 적용할 수 있는 기능입니다.
|
||||
|
||||
하나의 접근 목록에 여러 클라이언트 규칙과 사용자 이름, 비밀번호를 추가한 뒤
|
||||
하나의 접근 정책에 여러 클라이언트 규칙과 사용자 이름, 비밀번호를 추가한 뒤
|
||||
이를 하나 이상의 프록시 호스트에 적용할 수 있습니다.
|
||||
|
||||
이 기능은 인증 기능이 없는 웹 서비스에 인증을 추가하거나,
|
||||
|
||||
@@ -24,5 +24,5 @@ DNS 검증 방식의 인증서는 DNS 공급자 플러그인을 사용해야 합
|
||||
|
||||
### 사용자 지정 인증서
|
||||
|
||||
이 옵션을 사용하면 직접 보유한 인증 기관(CA)에서 발급한 SSL 인증서를 직접 업로드하여 사용할 수 있습니다.
|
||||
이 옵션을 사용하면 사용 중인 인증 기관(CA)에서 발급받은 SSL 인증서를 직접 업로드하여 사용할 수 있습니다.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
이 기능은 도메인이 검색 엔진에 이미 색인되어 있을 때,
|
||||
더 깔끔한 오류 페이지를 제공하거나 해당 페이지가 더 이상 존재하지 않음을
|
||||
검색 엔진에게 명확하게 알려야 할 때 유용합니다.
|
||||
검색 엔진에 명확하게 알려야 할 때 유용합니다.
|
||||
|
||||
또한 404 호스트를 사용하면 접근 로그를 확인하고, 어떤 경로(Referrer)를 통해 들어왔는지 추적할 수 있다는 장점도 있습니다.
|
||||
또한 이 호스트로 들어오는 요청을 로그로 추적하고 리퍼러(유입 경로)를 확인할 수 있다는 장점도 있습니다.
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
리다이렉션 호스트는 외부에서 들어오는 도메인 요청을 다른 도메인으로 자동 이동(리다이렉트)시키는 역할을 합니다.
|
||||
|
||||
이 유형의 호스트는 주로 웹사이트의 도메인이 변경되었지만,
|
||||
검색 엔진이나 다른 사이트에 이전 도메인 링크가 남아 있을 때 사용하면 가장 효과적입니다.
|
||||
검색 엔진이나 다른 사이트의 링크가 여전히 이전 도메인을 가리키고 있을 때 사용합니다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 호스트 스트림이란?
|
||||
## 스트림이란?
|
||||
|
||||
호스트 스트림은 비교적 최근에 Nginx에 추가된 기능으로,
|
||||
스트림은 비교적 최근에 Nginx에 추가된 기능으로,
|
||||
TCP/UDP 트래픽을 네트워크 내의 다른 컴퓨터로 직접 전달하는 데 사용됩니다.
|
||||
|
||||
게임 서버나 FTP, SSH 서버 등을 운영할 때 유용하게 사용할 수 있습니다.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
## Khái niệm Access List là gì?
|
||||
## Danh sách truy cập là gì?
|
||||
|
||||
Access List (Danh sách truy cập) cung cấp cơ chế chặn (blacklist) hoặc cho phép (whitelist) các địa chỉ IP của client, đồng thời hỗ trợ xác thực Basic HTTP Authentication cho các Proxy Host.
|
||||
Danh sách truy cập (Access List) cung cấp danh sách chặn hoặc danh sách cho phép theo địa chỉ IP của client, đồng thời hỗ trợ xác thực HTTP cơ bản (Basic HTTP Authentication) cho các máy chủ proxy.
|
||||
|
||||
Bạn có thể cấu hình nhiều quy tắc client, nhiều tên người dùng và mật khẩu trong một Access List duy nhất, sau đó áp dụng Access List đó cho một hoặc nhiều Proxy Host.
|
||||
Bạn có thể cấu hình nhiều quy tắc client, nhiều tên người dùng và mật khẩu trong một danh sách truy cập, sau đó áp dụng nó cho một hoặc nhiều _máy chủ proxy_.
|
||||
|
||||
Tính năng này đặc biệt hữu ích đối với:
|
||||
Tính năng này đặc biệt hữu ích khi:
|
||||
|
||||
các dịch vụ web được forward mà không có cơ chế xác thực tích hợp, hoặc
|
||||
|
||||
khi bạn muốn bảo vệ tài nguyên khỏi những client không xác định.
|
||||
- dịch vụ web được chuyển tiếp không có sẵn cơ chế xác thực, hoặc
|
||||
- bạn muốn chặn các client không xác định.
|
||||
@@ -1,25 +1,24 @@
|
||||
## Hỗ trợ Chứng chỉ
|
||||
## Trợ giúp về chứng chỉ
|
||||
|
||||
### Chứng chỉ HTTP (HTTP Certificate)
|
||||
### Chứng chỉ HTTP
|
||||
|
||||
Chứng chỉ được xác thực qua HTTP nghĩa là máy chủ của Let's Encrypt sẽ cố gắng truy cập vào tên miền của bạn thông qua HTTP (không phải HTTPS!). Nếu kiểm tra thành công, chứng chỉ sẽ được cấp.
|
||||
Chứng chỉ xác thực qua HTTP nghĩa là máy chủ của Let's Encrypt sẽ truy cập vào tên miền của bạn qua HTTP (không phải HTTPS!). Nếu kiểm tra thành công, chứng chỉ sẽ được cấp.
|
||||
|
||||
Với phương thức này, bạn phải tạo trước một Proxy Host cho tên miền, có thể truy cập qua HTTP và trỏ về đúng cài đặt Nginx này.
|
||||
Sau khi chứng chỉ được cấp, bạn có thể chỉnh sửa Proxy Host để sử dụng chứng chỉ đó cho kết nối HTTPS.
|
||||
Với phương thức này, bạn phải tạo trước một _máy chủ proxy_ cho tên miền, truy cập được qua HTTP và trỏ về máy chủ Nginx này.
|
||||
Sau khi chứng chỉ được cấp, bạn có thể chỉnh sửa _máy chủ proxy_ để dùng chứng chỉ đó cho kết nối HTTPS.
|
||||
Tuy nhiên, _máy chủ proxy_ vẫn phải truy cập được qua HTTP thì chứng chỉ mới gia hạn được.
|
||||
|
||||
Tuy nhiên, Proxy Host vẫn phải hỗ trợ truy cập HTTP để việc gia hạn chứng chỉ diễn ra bình thường.
|
||||
Phương thức này _không hỗ trợ_ tên miền wildcard (ví dụ `*.example.com`).
|
||||
|
||||
Phương thức này _không hỗ trợ_ wildcard domain.
|
||||
### Chứng chỉ DNS
|
||||
|
||||
### Chứng chỉ DNS (DNS Certificate)
|
||||
Chứng chỉ xác thực qua DNS yêu cầu bạn dùng plugin của nhà cung cấp DNS.
|
||||
Plugin sẽ tạo các bản ghi tạm thời cho tên miền của bạn, sau đó Let's Encrypt truy vấn các bản ghi này để xác nhận bạn là chủ sở hữu. Nếu hợp lệ, chứng chỉ sẽ được cấp.
|
||||
|
||||
Chứng chỉ được xác thực qua DNS yêu cầu bạn sử dụng plugin của DNS Provider.
|
||||
Plugin này sẽ tạo các bản ghi tạm thời trong DNS của bạn để Let's Encrypt kiểm tra quyền sở hữu tên miền. Nếu hợp lệ, chứng chỉ sẽ được cấp.
|
||||
Bạn không cần tạo _máy chủ proxy_ trước khi yêu cầu loại chứng chỉ này, cũng không cần cấu hình _máy chủ proxy_ cho truy cập HTTP.
|
||||
|
||||
Khi dùng phương thức này: Bạn không cần tạo sẵn Proxy Host trước và bạn không cần mở HTTP cho Proxy Host.
|
||||
Phương thức này _có hỗ trợ_ tên miền wildcard.
|
||||
|
||||
Phương thức DNS _có hỗ trợ_ wildcard domain.
|
||||
### Chứng chỉ tùy chỉnh
|
||||
|
||||
### Chứng chỉ tùy chỉnh (Custom Certificate)
|
||||
|
||||
Tùy chọn này cho phép bạn tải lên chứng chỉ SSL của riêng mình, được cung cấp bởi Certificate Authority (CA) mà bạn tự chọn.
|
||||
Dùng tùy chọn này để tải lên chứng chỉ SSL của riêng bạn, do tổ chức cấp chứng chỉ (CA) của bạn cung cấp.
|
||||
@@ -1,8 +1,7 @@
|
||||
## 404 Host là gì?
|
||||
## Máy chủ 404 là gì?
|
||||
|
||||
404 Host đơn giản là một host được thiết lập để hiển thị trang 404.
|
||||
Máy chủ 404 đơn giản là một cấu hình máy chủ chỉ hiển thị trang 404.
|
||||
|
||||
Điều này có thể hữu ích khi tên miền của bạn vẫn xuất hiện trên các công cụ tìm kiếm và bạn muốn hiển thị một trang lỗi đẹp hơn, hoặc muốn thông báo rõ ràng cho các trình thu thập dữ liệu tìm kiếm rằng các trang thuộc tên miền đó không còn tồn tại.
|
||||
Nó hữu ích khi tên miền của bạn vẫn xuất hiện trên các công cụ tìm kiếm và bạn muốn hiển thị một trang lỗi đẹp hơn, hoặc muốn báo rõ cho các trình thu thập dữ liệu rằng các trang thuộc tên miền đó không còn tồn tại.
|
||||
|
||||
Một lợi ích khác của việc có 404 Host là bạn có thể theo dõi nhật ký truy cập vào nó và
|
||||
xem các nguồn giới thiệu (referrers).
|
||||
Một lợi ích khác là bạn có thể theo dõi nhật ký các lượt truy cập vào đó và xem trang giới thiệu (referrer).
|
||||
@@ -1,7 +1,7 @@
|
||||
## Proxy Host là gì?
|
||||
## Máy chủ proxy là gì?
|
||||
|
||||
Proxy Host là điểm truy cập đầu vào cho một dịch vụ web mà bạn muốn chuyển tiếp.
|
||||
Máy chủ proxy là điểm tiếp nhận yêu cầu cho một dịch vụ web mà bạn muốn chuyển tiếp.
|
||||
|
||||
Nó cung cấp khả năng kết thúc SSL (SSL termination) tùy chọn cho các dịch vụ vốn không hỗ trợ SSL tích hợp.
|
||||
Nó có thể đảm nhận phần mã hóa SSL (SSL termination) cho những dịch vụ không có sẵn hỗ trợ SSL.
|
||||
|
||||
Proxy Host là loại cấu hình phổ biến nhất trong Nginx Proxy Manager.
|
||||
Máy chủ proxy là tính năng được dùng nhiều nhất trong Nginx Proxy Manager.
|
||||
@@ -1,5 +1,5 @@
|
||||
## Redirection Host là gì?
|
||||
## Máy chủ chuyển hướng là gì?
|
||||
|
||||
Redirection Host sẽ chuyển hướng các yêu cầu từ tên miền truy cập vào và đưa người xem sang một tên miền khác
|
||||
Máy chủ chuyển hướng sẽ chuyển các yêu cầu tới tên miền này sang một tên miền khác.
|
||||
|
||||
Lý do phổ biến nhất để sử dụng loại host này là khi trang web của bạn đổi sang tên miền mới nhưng vẫn còn các liên kết từ công cụ tìm kiếm hoặc nguồn giới thiệu trỏ về tên miền cũ.
|
||||
Lý do phổ biến nhất để dùng loại máy chủ này là khi trang web của bạn đổi sang tên miền mới nhưng các công cụ tìm kiếm hoặc trang giới thiệu vẫn còn liên kết trỏ về tên miền cũ.
|
||||
@@ -1,6 +1,5 @@
|
||||
## Stream là gì?
|
||||
## Luồng TCP/UDP là gì?
|
||||
|
||||
Stream là một tính năng tương đối mới của Nginx, dùng để chuyển tiếp lưu lượng
|
||||
TCP/UDP trực tiếp tới một máy khác trong mạng.
|
||||
Luồng TCP/UDP (Stream) là một tính năng tương đối mới của Nginx, dùng để chuyển tiếp lưu lượng TCP/UDP trực tiếp tới một máy khác trong mạng.
|
||||
|
||||
Nếu bạn đang vận hành các máy chủ game, FTP hoặc SSH thì tính năng này sẽ rất hữu ích.
|
||||
Tính năng này rất hữu ích nếu bạn đang chạy máy chủ game, FTP hoặc SSH.
|
||||
@@ -0,0 +1,7 @@
|
||||
## 什麼是存取清單?
|
||||
|
||||
存取清單可針對特定用戶端 IP 位址設定黑名單或白名單,並可為代理主機設定 HTTP 基本驗證。
|
||||
|
||||
您可以在單一存取清單中設定多項用戶端規則、使用者名稱與密碼,再將其套用至一個或多個 _代理主機_。
|
||||
|
||||
對於透過代理轉送、但本身未內建驗證機制的網路服務,或需要防止未知用戶端存取時,這項功能格外實用。
|
||||
@@ -0,0 +1,21 @@
|
||||
## 憑證說明
|
||||
|
||||
### HTTP 憑證
|
||||
|
||||
使用 HTTP 驗證方式申請憑證時,Let's Encrypt 伺服器會嘗試透過 HTTP(不是 HTTPS!)連線至您的網域;驗證成功後,便會核發憑證。
|
||||
|
||||
使用此方式前,您必須為網域建立一個可透過 HTTP 存取、且指向此 Nginx 安裝環境的 _代理主機_。憑證核發後,您可以修改該 _代理主機_,讓 HTTPS 連線也使用此憑證。不過,為了讓憑證能夠續期,_代理主機_ 仍須保留 HTTP 存取設定。
|
||||
|
||||
此流程不支援萬用字元網域。
|
||||
|
||||
### DNS 憑證
|
||||
|
||||
使用 DNS 驗證方式申請憑證時,您需要使用 DNS 服務供應商外掛程式。系統會透過該 DNS 服務供應商在您的網域中建立暫時性的 DNS 紀錄,接著 Let's Encrypt 會查詢這些紀錄,以確認您是網域所有者;驗證成功後,便會核發憑證。
|
||||
|
||||
申請此類憑證前,您不必先建立 _代理主機_,也不必將 _代理主機_ 設定為可透過 HTTP 存取。
|
||||
|
||||
此流程支援萬用字元網域。
|
||||
|
||||
### 自訂憑證
|
||||
|
||||
使用此選項,上傳由您自己的憑證授權單位提供的 SSL 憑證。
|
||||
@@ -0,0 +1,7 @@
|
||||
## 什麼是 404 主機?
|
||||
|
||||
404 主機就是用來顯示 404 頁面的主機設定。
|
||||
|
||||
當您的網域已被搜尋引擎收錄,而您想提供更友善的錯誤頁面,或明確告知搜尋引擎的索引程式該網域的頁面已不存在時,這項功能會很實用。
|
||||
|
||||
使用 404 主機的另一項好處,是可以從記錄檔追蹤對該主機的存取,並查看來源網址。
|
||||
@@ -0,0 +1,7 @@
|
||||
## 什麼是代理主機?
|
||||
|
||||
代理主機是接收連線的端點,會將連線轉送至您指定的網路服務。
|
||||
|
||||
它可選擇代為處理 SSL 連線,適用於未內建 SSL 支援的服務。
|
||||
|
||||
代理主機是 Nginx Proxy Manager 最常見的用途。
|
||||
@@ -0,0 +1,5 @@
|
||||
## 什麼是重新導向主機?
|
||||
|
||||
重新導向主機會將送往指定網域的請求重新導向,讓訪客前往另一個網域。
|
||||
|
||||
這類主機最常用於網站更換網域後,搜尋引擎或其他來源的連結仍指向舊網域的情況。
|
||||
@@ -0,0 +1,5 @@
|
||||
## 什麼是連接埠轉送?
|
||||
|
||||
連接埠轉送是 Nginx 相對較新的功能,可將 TCP/UDP 流量直接轉送至網路中的另一台電腦。
|
||||
|
||||
若您正在架設遊戲伺服器、FTP 伺服器或 SSH 伺服器,這項功能會很方便。
|
||||
@@ -0,0 +1,6 @@
|
||||
export * as AccessLists from "./AccessLists.md";
|
||||
export * as Certificates from "./Certificates.md";
|
||||
export * as DeadHosts from "./DeadHosts.md";
|
||||
export * as ProxyHosts from "./ProxyHosts.md";
|
||||
export * as RedirectionHosts from "./RedirectionHosts.md";
|
||||
export * as Streams from "./Streams.md";
|
||||
@@ -71,6 +71,12 @@
|
||||
"access-list.help.rules-order": {
|
||||
"defaultMessage": "Beachten Sie, dass die Anweisungen „Erlauben“ und „Verbieten“ in der Reihenfolge ihrer Definition angewendet werden."
|
||||
},
|
||||
"access-list.inherit": {
|
||||
"defaultMessage": "Vom Proxy Host übernehmen"
|
||||
},
|
||||
"access-list.inherit.subtitle": {
|
||||
"defaultMessage": "Verwendet die Zugriffsliste des Proxy Hosts"
|
||||
},
|
||||
"access-list.pass-auth": {
|
||||
"defaultMessage": "Authentifizierung an Upstream weiterleiten"
|
||||
},
|
||||
@@ -101,6 +107,9 @@
|
||||
"action.allow": {
|
||||
"defaultMessage": "Erlauben"
|
||||
},
|
||||
"action.clear": {
|
||||
"defaultMessage": "Leeren"
|
||||
},
|
||||
"action.close": {
|
||||
"defaultMessage": "Schließen"
|
||||
},
|
||||
@@ -122,6 +131,9 @@
|
||||
"action.enable": {
|
||||
"defaultMessage": "Aktivieren"
|
||||
},
|
||||
"action.logs": {
|
||||
"defaultMessage": "Logs"
|
||||
},
|
||||
"action.permissions": {
|
||||
"defaultMessage": "Berechtigungen"
|
||||
},
|
||||
@@ -266,6 +278,9 @@
|
||||
"column.email": {
|
||||
"defaultMessage": "E-Mail"
|
||||
},
|
||||
"column.error": {
|
||||
"defaultMessage": "Fehler"
|
||||
},
|
||||
"column.event": {
|
||||
"defaultMessage": "Ereignis"
|
||||
},
|
||||
@@ -461,6 +476,12 @@
|
||||
"loading": {
|
||||
"defaultMessage": "Laden…"
|
||||
},
|
||||
"location.advanced-config": {
|
||||
"defaultMessage": "Hat eine eigene Nginx-Konfiguration"
|
||||
},
|
||||
"location.filter": {
|
||||
"defaultMessage": "Nach Pfad oder Ziel filtern"
|
||||
},
|
||||
"login.2fa-code": {
|
||||
"defaultMessage": "Verifizierungscode"
|
||||
},
|
||||
@@ -479,6 +500,51 @@
|
||||
"login.title": {
|
||||
"defaultMessage": "Anmelden"
|
||||
},
|
||||
"logs": {
|
||||
"defaultMessage": "Logs"
|
||||
},
|
||||
"logs.channel.access": {
|
||||
"defaultMessage": "Zugriff"
|
||||
},
|
||||
"logs.channel.error": {
|
||||
"defaultMessage": "Fehler"
|
||||
},
|
||||
"logs.download": {
|
||||
"defaultMessage": "Herunterladen"
|
||||
},
|
||||
"logs.empty": {
|
||||
"defaultMessage": "Noch keine Log-Einträge."
|
||||
},
|
||||
"logs.level": {
|
||||
"defaultMessage": "Level"
|
||||
},
|
||||
"logs.level.all": {
|
||||
"defaultMessage": "Alle Level"
|
||||
},
|
||||
"logs.lines": {
|
||||
"defaultMessage": "Zeilen"
|
||||
},
|
||||
"logs.live": {
|
||||
"defaultMessage": "Live"
|
||||
},
|
||||
"logs.paused": {
|
||||
"defaultMessage": "Pausiert"
|
||||
},
|
||||
"logs.refresh": {
|
||||
"defaultMessage": "Aktualisieren"
|
||||
},
|
||||
"logs.search-placeholder": {
|
||||
"defaultMessage": "Log durchsuchen…"
|
||||
},
|
||||
"logs.source": {
|
||||
"defaultMessage": "Quelle"
|
||||
},
|
||||
"logs.source.system": {
|
||||
"defaultMessage": "System"
|
||||
},
|
||||
"logs.truncated-warning": {
|
||||
"defaultMessage": "Es wird nur der neueste Teil dieser Datei angezeigt."
|
||||
},
|
||||
"nginx-config.label": {
|
||||
"defaultMessage": "Benutzerdefinierte Nginx Konfiguration"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,12 @@
|
||||
"access-list.help.rules-order": {
|
||||
"defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined."
|
||||
},
|
||||
"access-list.inherit": {
|
||||
"defaultMessage": "Inherit from Proxy Host"
|
||||
},
|
||||
"access-list.inherit.subtitle": {
|
||||
"defaultMessage": "Uses the proxy host's access list"
|
||||
},
|
||||
"access-list.pass-auth": {
|
||||
"defaultMessage": "Pass Auth to Upstream"
|
||||
},
|
||||
@@ -431,6 +437,9 @@
|
||||
"footer.github-fork": {
|
||||
"defaultMessage": "Fork me on Github"
|
||||
},
|
||||
"form.unsaved-changes": {
|
||||
"defaultMessage": "You have unsaved changes. Are you sure you want to discard them?"
|
||||
},
|
||||
"host.flags.block-exploits": {
|
||||
"defaultMessage": "Block Common Exploits"
|
||||
},
|
||||
@@ -635,12 +644,21 @@
|
||||
"password.show": {
|
||||
"defaultMessage": "Show Password"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts": {
|
||||
"defaultMessage": "Allowed Proxy Hosts"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts-help": {
|
||||
"defaultMessage": "Grants additional access to selected proxy hosts created by other users (in addition to the user's own created hosts)."
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Hidden"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Manage"
|
||||
},
|
||||
"permissions.no-proxy-hosts": {
|
||||
"defaultMessage": "No proxy hosts found"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "View Only"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,12 @@
|
||||
"access-list.help.rules-order": {
|
||||
"defaultMessage": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas."
|
||||
},
|
||||
"access-list.inherit": {
|
||||
"defaultMessage": "Päri puhverserverilt"
|
||||
},
|
||||
"access-list.inherit.subtitle": {
|
||||
"defaultMessage": "Kasutab puhverserveri juurdepääsuloendit"
|
||||
},
|
||||
"access-list.pass-auth": {
|
||||
"defaultMessage": "Edasta autentimine sihtserverile"
|
||||
},
|
||||
@@ -431,6 +437,9 @@
|
||||
"footer.github-fork": {
|
||||
"defaultMessage": "Tee GitHubis fork"
|
||||
},
|
||||
"form.unsaved-changes": {
|
||||
"defaultMessage": "Sul on salvestamata muudatusi. Kas oled kindel, et soovid need tühistada?"
|
||||
},
|
||||
"host.flags.block-exploits": {
|
||||
"defaultMessage": "Blokeeri levinud ründed"
|
||||
},
|
||||
@@ -635,12 +644,21 @@
|
||||
"password.show": {
|
||||
"defaultMessage": "Näita parooli"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts": {
|
||||
"defaultMessage": "Lubatud puhverserverid"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts-help": {
|
||||
"defaultMessage": "Annab täiendava ligipääsu teiste kasutajate loodud valitud puhverserveritele (lisaks kasutaja enda loodud kirjetele)."
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Peidetud"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Halda"
|
||||
},
|
||||
"permissions.no-proxy-hosts": {
|
||||
"defaultMessage": "Ühtegi puhverserverit ei leitud"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "Ainult vaatamine"
|
||||
},
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user