mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-03-13 11:00:09 +00:00
Compare commits
314 Commits
v2.13.5
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a08bbe8ad | ||
|
|
15896132ff | ||
|
|
604b32ffbf | ||
|
|
eb67b3bfb6 | ||
|
|
f1e95c9c52 | ||
|
|
d6e1d15996 | ||
|
|
99886f3111 | ||
|
|
6b2d9a77a3 | ||
|
|
d37ab95ed8 | ||
|
|
e869518dd1 | ||
|
|
37712ba2a4 | ||
|
|
a714b41fd6 | ||
|
|
b87bedc347 | ||
|
|
a62068275d | ||
|
|
c1d09eaceb | ||
|
|
9c509f30de | ||
|
|
c85b11ee33 | ||
|
|
cd5ef390b9 | ||
|
|
d49cab1c0e | ||
|
|
33b1a993ec | ||
|
|
67d40e186f | ||
|
|
52be66c43e | ||
|
|
ec46cabcd4 | ||
|
|
a7a9cc3acb | ||
|
|
020b3ebb33 | ||
|
|
c1c4baf389 | ||
|
|
672b5d6dd9 | ||
|
|
cd230b5878 | ||
|
|
a8f35062af | ||
|
|
da5955412d | ||
|
|
adb27fe67d | ||
|
|
d874af8692 | ||
|
|
0844dade98 | ||
|
|
71d59516e8 | ||
|
|
06e220e184 | ||
|
|
dc53647e76 | ||
|
|
4c04e89483 | ||
|
|
7241869a9e | ||
|
|
94f6191a21 | ||
|
|
cac52dd0ff | ||
|
|
906f177960 | ||
|
|
f52afced5d | ||
|
|
e8224ff0af | ||
|
|
a4fa83d0ce | ||
|
|
770716ebf8 | ||
|
|
f1067d3308 | ||
|
|
85c1a935ea | ||
|
|
51ef7f3b86 | ||
|
|
846b94f7e8 | ||
|
|
19e24c7e7e | ||
|
|
c1bc471dac | ||
|
|
608dc0b6bf | ||
|
|
0dbf268f37 | ||
|
|
c7437ddf8f | ||
|
|
627f43c729 | ||
|
|
fc4c5aac86 | ||
|
|
aff390f35d | ||
|
|
5f5a3870e4 | ||
|
|
40f363bd4f | ||
|
|
678fdd22c6 | ||
|
|
6c3cc83d66 | ||
|
|
5916fd5bee | ||
|
|
f105673904 | ||
|
|
a37d0b88d6 | ||
|
|
43bc2a743e | ||
|
|
269545256a | ||
|
|
e5df45e9ef | ||
|
|
5601dd14fc | ||
|
|
3e5655cfcd | ||
|
|
a90af83270 | ||
|
|
619a8e5acc | ||
|
|
6dcdefb57e | ||
|
|
787616010b | ||
|
|
5891c291d2 | ||
|
|
41a2a41e67 | ||
|
|
379099d7ed | ||
|
|
dbeab93c02 | ||
|
|
010cb562a0 | ||
|
|
7ff2fc1900 | ||
|
|
1c189a1888 | ||
|
|
f3c46487f6 | ||
|
|
fcca481d1b | ||
|
|
c59c237000 | ||
|
|
a62b6de9f2 | ||
|
|
d92cc953e1 | ||
|
|
1b6412688b | ||
|
|
1d14f72ba5 | ||
|
|
099243aff7 | ||
|
|
5fe12f69ba | ||
|
|
011191f645 | ||
|
|
eeab425ea4 | ||
|
|
13fbc53591 | ||
|
|
3f2aec7b86 | ||
|
|
09a3d65aa1 | ||
|
|
c910cf9512 | ||
|
|
304c51aae8 | ||
|
|
b552eb90ed | ||
|
|
b78ef9bcd3 | ||
|
|
7c67fafedf | ||
|
|
47b367d61e | ||
|
|
d19f5c1960 | ||
|
|
77662b4e7f | ||
|
|
c88de65d3a | ||
|
|
ac4efd2333 | ||
|
|
eab38d8934 | ||
|
|
4833dcbf3a | ||
|
|
c6fba1cbfe | ||
|
|
cdde543e8a | ||
|
|
0d62c26164 | ||
|
|
c3173d83b8 | ||
|
|
6ba40216cd | ||
|
|
3c54413752 | ||
|
|
65cf8ce583 | ||
|
|
a4bc8d5d21 | ||
|
|
2bcf5e91ce | ||
|
|
3e3d08b68f | ||
|
|
f90066822f | ||
|
|
bb4b5fb3aa | ||
|
|
8014f34195 | ||
|
|
4f8037ded2 | ||
|
|
e7a1f84e45 | ||
|
|
6f0931bed5 | ||
|
|
7f0c5d4364 | ||
|
|
60404b6f7e | ||
|
|
c2fddee2c7 | ||
|
|
b7402d47a0 | ||
|
|
f09876d31b | ||
|
|
8708a3bab8 | ||
|
|
218fadd168 | ||
|
|
9cf1d000c8 | ||
|
|
714bebbbc7 | ||
|
|
127008c9b5 | ||
|
|
7cc2bfbf6a | ||
|
|
de3b543d08 | ||
|
|
21f63e3db3 | ||
|
|
232b5b759a | ||
|
|
054742539f | ||
|
|
2b6a617599 | ||
|
|
187d21a0d5 | ||
|
|
c515815b0e | ||
|
|
3db02370fd | ||
|
|
4ad1af5576 | ||
|
|
a73d54fedc | ||
|
|
8c8005f817 | ||
|
|
83d993578b | ||
|
|
8532e7520f | ||
|
|
58d47cd69a | ||
|
|
bad3eac515 | ||
|
|
00b58f73f8 | ||
|
|
47981f0d56 | ||
|
|
38257859e2 | ||
|
|
a169e1131c | ||
|
|
a99cde9cd8 | ||
|
|
c69bd187af | ||
|
|
98fe622967 | ||
|
|
eddca3597d | ||
|
|
ed0b2306a2 | ||
|
|
17f6050de2 | ||
|
|
469d72a2f9 | ||
|
|
3ed3ec0001 | ||
|
|
24ff3c7b11 | ||
|
|
58dda941b8 | ||
|
|
f9f743499f | ||
|
|
534afe6067 | ||
|
|
9580903f5d | ||
|
|
df81c8425f | ||
|
|
b6f421c5fc | ||
|
|
c1ef3a3795 | ||
|
|
0aad939ccc | ||
|
|
7e092e265c | ||
|
|
cd01a2ee6b | ||
|
|
9e6720561a | ||
|
|
c50f0a144e | ||
|
|
2a9c1df3cb | ||
|
|
ef6391f22e | ||
|
|
0f46337710 | ||
|
|
1b84b8ace2 | ||
|
|
8ea8286cec | ||
|
|
7ca48f876b | ||
|
|
7c3c59c79f | ||
|
|
ef7f444404 | ||
|
|
f509e0bdba | ||
|
|
9b7af474bb | ||
|
|
28982b8bc2 | ||
|
|
19e654b998 | ||
|
|
eaf9f5ab1e | ||
|
|
4af0a968f0 | ||
|
|
df06eb6c2f | ||
|
|
74360cc9b3 | ||
|
|
16a301fc64 | ||
|
|
2d774124dc | ||
|
|
124737bbc6 | ||
|
|
d5d222ef2d | ||
|
|
b96e932c64 | ||
|
|
d09cb2884c | ||
|
|
71deabcc67 | ||
|
|
a78039b65f | ||
|
|
48acbd33ab | ||
|
|
32cabc0f83 | ||
|
|
03a82cd861 | ||
|
|
5f19f7125e | ||
|
|
8d35644190 | ||
|
|
ad2e4c8afe | ||
|
|
69f9031447 | ||
|
|
3308a308df | ||
|
|
59b0e75324 | ||
|
|
727bc944ea | ||
|
|
a0ef0d9048 | ||
|
|
d2e346c912 | ||
|
|
32a716b3a9 | ||
|
|
ef6918947c | ||
|
|
2deb5447d6 | ||
|
|
1bb29259ea | ||
|
|
fa20c7d8a4 | ||
|
|
4ed17fef01 | ||
|
|
fe316252f1 | ||
|
|
7747db994d | ||
|
|
9ffced265b | ||
|
|
50cf275328 | ||
|
|
7bcc34dea9 | ||
|
|
131e5fea4f | ||
|
|
4e412f18bb | ||
|
|
bb0a50eccb | ||
|
|
4185665570 | ||
|
|
9ea6fee3ce | ||
|
|
7ee9a3c9f0 | ||
|
|
afb196e5b6 | ||
|
|
0b464ac9fd | ||
|
|
f3efaae320 | ||
|
|
7b3c1fd061 | ||
|
|
ee42202348 | ||
|
|
c1ad7788f1 | ||
|
|
d33bb02c74 | ||
|
|
462c134751 | ||
|
|
b7dfaddbb1 | ||
|
|
11ee4f0820 | ||
|
|
19970a4220 | ||
|
|
59bac3b468 | ||
|
|
48753fb101 | ||
|
|
2a3978ae3f | ||
|
|
4ce5da5930 | ||
|
|
89d3756ee6 | ||
|
|
58c63096e4 | ||
|
|
b01a22c393 | ||
|
|
9c25410331 | ||
|
|
b3a901bbc5 | ||
|
|
3e3396ba9a | ||
|
|
3eb493bb8b | ||
|
|
8c8221a352 | ||
|
|
582681e3ff | ||
|
|
52fae6d35f | ||
|
|
6c0ea835ce | ||
|
|
fb52655374 | ||
|
|
336726db8d | ||
|
|
4a7853163e | ||
|
|
b30f8e47e2 | ||
|
|
6fa30840be | ||
|
|
05726aaab9 | ||
|
|
f85bb79f13 | ||
|
|
471b62c7fe | ||
|
|
55a1e0a4e7 | ||
|
|
f25afa3590 | ||
|
|
9211ba6d1a | ||
|
|
aeb44244a7 | ||
|
|
d2d204ab8e | ||
|
|
427afa55b4 | ||
|
|
bbe98a639a | ||
|
|
f0c0b465d9 | ||
|
|
6c2f6a9d39 | ||
|
|
2f6e3ad804 | ||
|
|
c9f453714b | ||
|
|
5e6ead1eee | ||
|
|
da519e72ba | ||
|
|
b13ebb2247 | ||
|
|
6b322582b9 | ||
|
|
7fe5070337 | ||
|
|
1b8f1fbb79 | ||
|
|
4abea1247d | ||
|
|
073ee95e56 | ||
|
|
fec8b3b083 | ||
|
|
168078eb40 | ||
|
|
2c9f8f4d64 | ||
|
|
8403a0c761 | ||
|
|
d18c8cf4f1 | ||
|
|
bf4eab541a | ||
|
|
f9edcb10e6 | ||
|
|
ba43c144f6 | ||
|
|
896951f6cd | ||
|
|
865b566ea6 | ||
|
|
45bc44c6fa | ||
|
|
4ff402fff4 | ||
|
|
1c6f54fa3c | ||
|
|
e8ca72fb6a | ||
|
|
4712633568 | ||
|
|
a1fb54c394 | ||
|
|
927e57257b | ||
|
|
e353a66556 | ||
|
|
991bddf891 | ||
|
|
c076ad145c | ||
|
|
80cf4406d5 | ||
|
|
3cb124d5a0 | ||
|
|
03b0513a24 | ||
|
|
0528d65317 | ||
|
|
f9991084fc | ||
|
|
56875bba52 | ||
|
|
b55f51bd63 | ||
|
|
20e2d5ffb3 | ||
|
|
86b7394620 | ||
|
|
91a1f39c02 | ||
|
|
5c114e9db7 | ||
|
|
fec9bffe29 | ||
|
|
e3cdc8bb30 | ||
|
|
ba79eefe5e | ||
|
|
bb94ce75c1 |
104
.github/dependabot.yml
vendored
Normal file
104
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/backend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/docs"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/test"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/docker"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
updates:
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
stale-pr-label: 'stale'
|
stale-pr-label: 'stale'
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,7 +1,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://nginxproxymanager.com/github.png">
|
<img src="https://nginxproxymanager.com/github.png">
|
||||||
<br><br>
|
<br><br>
|
||||||
<img src="https://img.shields.io/badge/version-2.13.5-green.svg?style=for-the-badge">
|
<img src="https://img.shields.io/badge/version-2.14.0-green.svg?style=for-the-badge">
|
||||||
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
|
<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">
|
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
|
||||||
</a>
|
</a>
|
||||||
@@ -36,6 +36,10 @@ so that the barrier for entry here is low.
|
|||||||
- Advanced Nginx configuration available for super users
|
- Advanced Nginx configuration available for super users
|
||||||
- User management, permissions and audit log
|
- User management, permissions and audit log
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
`armv7` is no longer supported in version 2.14+. This is due to Nodejs dropping support for armhf. Please
|
||||||
|
use the `2.13.7` image tag if this applies to you.
|
||||||
|
:::
|
||||||
|
|
||||||
## Hosting your home network
|
## Hosting your home network
|
||||||
|
|
||||||
@@ -43,16 +47,15 @@ I won't go in to too much detail here but here are the basics for someone new to
|
|||||||
|
|
||||||
1. Your home router will have a Port Forwarding section somewhere. Log in and find it
|
1. Your home router will have a Port Forwarding section somewhere. Log in and find it
|
||||||
2. Add port forwarding for port 80 and 443 to the server hosting this project
|
2. Add port forwarding for port 80 and 443 to the server hosting this project
|
||||||
3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns)
|
3. Configure your domain name details to point to your home, either with a static ip or a service like
|
||||||
|
- DuckDNS
|
||||||
|
- [Amazon Route53](https://github.com/jc21/route53-ddns)
|
||||||
|
- [Cloudflare](https://github.com/jc21/cloudflare-ddns)
|
||||||
4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services
|
4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
1. Install Docker and Docker-Compose
|
1. [Install Docker](https://docs.docker.com/install/)
|
||||||
|
|
||||||
- [Docker Install documentation](https://docs.docker.com/install/)
|
|
||||||
- [Docker-Compose Install documentation](https://docs.docker.com/compose/install/)
|
|
||||||
|
|
||||||
2. Create a docker-compose.yml file similar to this:
|
2. Create a docker-compose.yml file similar to this:
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
"credentials": "dns_aliyun_access_key = 12345678\ndns_aliyun_access_key_secret = 1234567890abcdef1234567890abcdef",
|
"credentials": "dns_aliyun_access_key = 12345678\ndns_aliyun_access_key_secret = 1234567890abcdef1234567890abcdef",
|
||||||
"full_plugin_name": "dns-aliyun"
|
"full_plugin_name": "dns-aliyun"
|
||||||
},
|
},
|
||||||
|
"arvan": {
|
||||||
|
"name": "ArvanCloud",
|
||||||
|
"package_name": "certbot-dns-arvan",
|
||||||
|
"version": ">=0.1.0",
|
||||||
|
"dependencies": "",
|
||||||
|
"credentials": "dns_arvan_key = Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"full_plugin_name": "dns-arvan"
|
||||||
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"name": "Azure",
|
"name": "Azure",
|
||||||
"package_name": "certbot-dns-azure",
|
"package_name": "certbot-dns-azure",
|
||||||
@@ -74,7 +82,7 @@
|
|||||||
"cloudns": {
|
"cloudns": {
|
||||||
"name": "ClouDNS",
|
"name": "ClouDNS",
|
||||||
"package_name": "certbot-dns-cloudns",
|
"package_name": "certbot-dns-cloudns",
|
||||||
"version": "~=0.6.0",
|
"version": "~=0.7.0",
|
||||||
"dependencies": "",
|
"dependencies": "",
|
||||||
"credentials": "# Target user ID (see https://www.cloudns.net/api-settings/)\n\tdns_cloudns_auth_id=1234\n\t# Alternatively, one of the following two options can be set:\n\t# dns_cloudns_sub_auth_id=1234\n\t# dns_cloudns_sub_auth_user=foobar\n\n\t# API password\n\tdns_cloudns_auth_password=password1",
|
"credentials": "# Target user ID (see https://www.cloudns.net/api-settings/)\n\tdns_cloudns_auth_id=1234\n\t# Alternatively, one of the following two options can be set:\n\t# dns_cloudns_sub_auth_id=1234\n\t# dns_cloudns_sub_auth_user=foobar\n\n\t# API password\n\tdns_cloudns_auth_password=password1",
|
||||||
"full_plugin_name": "dns-cloudns"
|
"full_plugin_name": "dns-cloudns"
|
||||||
@@ -255,6 +263,14 @@
|
|||||||
"credentials": "dns_gcore_apitoken = 0123456789abcdef0123456789abcdef01234567",
|
"credentials": "dns_gcore_apitoken = 0123456789abcdef0123456789abcdef01234567",
|
||||||
"full_plugin_name": "dns-gcore"
|
"full_plugin_name": "dns-gcore"
|
||||||
},
|
},
|
||||||
|
"glesys": {
|
||||||
|
"name": "Glesys",
|
||||||
|
"package_name": "certbot-dns-glesys",
|
||||||
|
"version": "~=2.1.0",
|
||||||
|
"dependencies": "",
|
||||||
|
"credentials": "dns_glesys_user = CL00000\ndns_glesys_password = apikeyvalue",
|
||||||
|
"full_plugin_name": "dns-glesys"
|
||||||
|
},
|
||||||
"godaddy": {
|
"godaddy": {
|
||||||
"name": "GoDaddy",
|
"name": "GoDaddy",
|
||||||
"package_name": "certbot-dns-godaddy",
|
"package_name": "certbot-dns-godaddy",
|
||||||
@@ -287,6 +303,14 @@
|
|||||||
"credentials": "dns_he_user = Me\ndns_he_pass = my HE password",
|
"credentials": "dns_he_user = Me\ndns_he_pass = my HE password",
|
||||||
"full_plugin_name": "dns-he"
|
"full_plugin_name": "dns-he"
|
||||||
},
|
},
|
||||||
|
"he-ddns": {
|
||||||
|
"name": "Hurricane Electric - DDNS",
|
||||||
|
"package_name": "certbot-dns-he-ddns",
|
||||||
|
"version": "~=0.1.0",
|
||||||
|
"dependencies": "",
|
||||||
|
"credentials": "dns_he_ddns_password = verysecurepassword",
|
||||||
|
"full_plugin_name": "dns-he-ddns"
|
||||||
|
},
|
||||||
"hetzner": {
|
"hetzner": {
|
||||||
"name": "Hetzner",
|
"name": "Hetzner",
|
||||||
"package_name": "certbot-dns-hetzner",
|
"package_name": "certbot-dns-hetzner",
|
||||||
@@ -367,6 +391,14 @@
|
|||||||
"credentials": "dns_joker_username = <Dynamic DNS Authentication Username>\ndns_joker_password = <Dynamic DNS Authentication Password>\ndns_joker_domain = <Dynamic DNS Domain>",
|
"credentials": "dns_joker_username = <Dynamic DNS Authentication Username>\ndns_joker_password = <Dynamic DNS Authentication Password>\ndns_joker_domain = <Dynamic DNS Domain>",
|
||||||
"full_plugin_name": "dns-joker"
|
"full_plugin_name": "dns-joker"
|
||||||
},
|
},
|
||||||
|
"kas": {
|
||||||
|
"name": "All-Inkl",
|
||||||
|
"package_name": "certbot-dns-kas",
|
||||||
|
"version": "~=0.1.1",
|
||||||
|
"dependencies": "kasserver",
|
||||||
|
"credentials": "dns_kas_user = your_kas_user\ndns_kas_password = your_kas_password",
|
||||||
|
"full_plugin_name": "dns-kas"
|
||||||
|
},
|
||||||
"leaseweb": {
|
"leaseweb": {
|
||||||
"name": "LeaseWeb",
|
"name": "LeaseWeb",
|
||||||
"package_name": "certbot-dns-leaseweb",
|
"package_name": "certbot-dns-leaseweb",
|
||||||
@@ -527,6 +559,14 @@
|
|||||||
"credentials": "[default]\naws_access_key_id=AKIAIOSFODNN7EXAMPLE\naws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
"credentials": "[default]\naws_access_key_id=AKIAIOSFODNN7EXAMPLE\naws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
"full_plugin_name": "dns-route53"
|
"full_plugin_name": "dns-route53"
|
||||||
},
|
},
|
||||||
|
"simply": {
|
||||||
|
"name": "Simply",
|
||||||
|
"package_name": "certbot-dns-simply",
|
||||||
|
"version": "~=0.1.2",
|
||||||
|
"dependencies": "",
|
||||||
|
"credentials": "dns_simply_account_name = UExxxxxx\ndns_simply_api_key = DsHJdsjh2812872sahj",
|
||||||
|
"full_plugin_name": "dns-simply"
|
||||||
|
},
|
||||||
"spaceship": {
|
"spaceship": {
|
||||||
"name": "Spaceship",
|
"name": "Spaceship",
|
||||||
"package_name": "certbot-dns-spaceship",
|
"package_name": "certbot-dns-spaceship",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"database": {
|
"database": {
|
||||||
"engine": "knex-native",
|
"engine": "knex-native",
|
||||||
"knex": {
|
"knex": {
|
||||||
"client": "sqlite3",
|
"client": "better-sqlite3",
|
||||||
"connection": {
|
"connection": {
|
||||||
"filename": "/app/config/mydb.sqlite"
|
"filename": "/app/config/mydb.sqlite"
|
||||||
},
|
},
|
||||||
|
|||||||
305
backend/internal/2fa.js
Normal file
305
backend/internal/2fa.js
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { createGuardrails, generateSecret, generateURI, verify } from "otplib";
|
||||||
|
import errs from "../lib/error.js";
|
||||||
|
import authModel from "../models/auth.js";
|
||||||
|
import internalUser from "./user.js";
|
||||||
|
|
||||||
|
const APP_NAME = "Nginx Proxy Manager";
|
||||||
|
const BACKUP_CODE_COUNT = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate backup codes
|
||||||
|
* @returns {Promise<{plain: string[], hashed: string[]}>}
|
||||||
|
*/
|
||||||
|
const generateBackupCodes = async () => {
|
||||||
|
const plain = [];
|
||||||
|
const hashed = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
||||||
|
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||||
|
plain.push(code);
|
||||||
|
const hash = await bcrypt.hash(code, 10);
|
||||||
|
hashed.push(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plain, hashed };
|
||||||
|
};
|
||||||
|
|
||||||
|
const internal2fa = {
|
||||||
|
/**
|
||||||
|
* Check if user has 2FA enabled
|
||||||
|
* @param {number} userId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
isEnabled: async (userId) => {
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
return auth?.meta?.totp_enabled === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get 2FA status for user
|
||||||
|
* @param {Access} access
|
||||||
|
* @param {number} userId
|
||||||
|
* @returns {Promise<{enabled: boolean, backup_codes_remaining: number}>}
|
||||||
|
*/
|
||||||
|
getStatus: async (access, userId) => {
|
||||||
|
await access.can("users:password", userId);
|
||||||
|
await internalUser.get(access, { id: userId });
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
const enabled = auth?.meta?.totp_enabled === true;
|
||||||
|
let backup_codes_remaining = 0;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
const backupCodes = auth.meta.backup_codes || [];
|
||||||
|
backup_codes_remaining = backupCodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
backup_codes_remaining,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start 2FA setup - store pending secret
|
||||||
|
*
|
||||||
|
* @param {Access} access
|
||||||
|
* @param {number} userId
|
||||||
|
* @returns {Promise<{secret: string, otpauth_url: string}>}
|
||||||
|
*/
|
||||||
|
startSetup: async (access, userId) => {
|
||||||
|
await access.can("users:password", userId);
|
||||||
|
const user = await internalUser.get(access, { id: userId });
|
||||||
|
const secret = generateSecret();
|
||||||
|
const otpauth_url = generateURI({
|
||||||
|
issuer: APP_NAME,
|
||||||
|
label: user.email,
|
||||||
|
secret: secret,
|
||||||
|
});
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
|
||||||
|
// ensure user isn't already setup for 2fa
|
||||||
|
const enabled = auth?.meta?.totp_enabled === true;
|
||||||
|
if (enabled) {
|
||||||
|
throw new errs.ValidationError("2FA is already enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = auth.meta || {};
|
||||||
|
meta.totp_pending_secret = secret;
|
||||||
|
|
||||||
|
await authModel
|
||||||
|
.query()
|
||||||
|
.where("id", auth.id)
|
||||||
|
.andWhere("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.patch({ meta });
|
||||||
|
|
||||||
|
return { secret, otpauth_url };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable 2FA after verifying code
|
||||||
|
*
|
||||||
|
* @param {Access} access
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {string} code
|
||||||
|
* @returns {Promise<{backup_codes: string[]}>}
|
||||||
|
*/
|
||||||
|
enable: async (access, userId, code) => {
|
||||||
|
await access.can("users:password", userId);
|
||||||
|
await internalUser.get(access, { id: userId });
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
const secret = auth?.meta?.totp_pending_secret || false;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new errs.ValidationError("No pending 2FA setup found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verify({ token: code, secret });
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new errs.ValidationError("Invalid verification code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plain, hashed } = await generateBackupCodes();
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
...auth.meta,
|
||||||
|
totp_secret: secret,
|
||||||
|
totp_enabled: true,
|
||||||
|
totp_enabled_at: new Date().toISOString(),
|
||||||
|
backup_codes: hashed,
|
||||||
|
};
|
||||||
|
delete meta.totp_pending_secret;
|
||||||
|
|
||||||
|
await authModel
|
||||||
|
.query()
|
||||||
|
.where("id", auth.id)
|
||||||
|
.andWhere("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.patch({ meta });
|
||||||
|
|
||||||
|
return { backup_codes: plain };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable 2FA
|
||||||
|
*
|
||||||
|
* @param {Access} access
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {string} code
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
disable: async (access, userId, code) => {
|
||||||
|
await access.can("users:password", userId);
|
||||||
|
await internalUser.get(access, { id: userId });
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
|
||||||
|
const enabled = auth?.meta?.totp_enabled === true;
|
||||||
|
if (!enabled) {
|
||||||
|
throw new errs.ValidationError("2FA is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verify({
|
||||||
|
token: code,
|
||||||
|
secret: auth.meta.totp_secret,
|
||||||
|
guardrails: createGuardrails({
|
||||||
|
MIN_SECRET_BYTES: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new errs.AuthError("Invalid verification code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = { ...auth.meta };
|
||||||
|
delete meta.totp_secret;
|
||||||
|
delete meta.totp_enabled;
|
||||||
|
delete meta.totp_enabled_at;
|
||||||
|
delete meta.backup_codes;
|
||||||
|
|
||||||
|
await authModel
|
||||||
|
.query()
|
||||||
|
.where("id", auth.id)
|
||||||
|
.andWhere("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.patch({ meta });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify 2FA code for login
|
||||||
|
*
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {string} token
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
verifyForLogin: async (userId, token) => {
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
const secret = auth?.meta?.totp_secret || false;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TOTP code first, if it's 6 chars. it will throw errors if it's not 6 chars
|
||||||
|
// and the backup codes are 8 chars.
|
||||||
|
if (token.length === 6) {
|
||||||
|
const result = await verify({
|
||||||
|
token,
|
||||||
|
secret,
|
||||||
|
// These guardrails lower the minimum length requirement for secrets.
|
||||||
|
// In v12 of otplib the default minimum length is 10 and in v13 it is 16.
|
||||||
|
// Since there are 2fa secrets in the wild generated with v12 we need to allow shorter secrets
|
||||||
|
// so people won't be locked out when upgrading.
|
||||||
|
guardrails: createGuardrails({
|
||||||
|
MIN_SECRET_BYTES: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try backup codes
|
||||||
|
const backupCodes = auth?.meta?.backup_codes || [];
|
||||||
|
for (let i = 0; i < backupCodes.length; i++) {
|
||||||
|
const match = await bcrypt.compare(token.toUpperCase(), backupCodes[i]);
|
||||||
|
if (match) {
|
||||||
|
// Remove used backup code
|
||||||
|
const updatedCodes = [...backupCodes];
|
||||||
|
updatedCodes.splice(i, 1);
|
||||||
|
const meta = { ...auth.meta, backup_codes: updatedCodes };
|
||||||
|
await authModel
|
||||||
|
.query()
|
||||||
|
.where("id", auth.id)
|
||||||
|
.andWhere("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.patch({ meta });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate backup codes
|
||||||
|
*
|
||||||
|
* @param {Access} access
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {string} token
|
||||||
|
* @returns {Promise<{backup_codes: string[]}>}
|
||||||
|
*/
|
||||||
|
regenerateBackupCodes: async (access, userId, token) => {
|
||||||
|
await access.can("users:password", userId);
|
||||||
|
await internalUser.get(access, { id: userId });
|
||||||
|
const auth = await internal2fa.getUserPasswordAuth(userId);
|
||||||
|
const enabled = auth?.meta?.totp_enabled === true;
|
||||||
|
const secret = auth?.meta?.totp_secret || false;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
throw new errs.ValidationError("2FA is not enabled");
|
||||||
|
}
|
||||||
|
if (!secret) {
|
||||||
|
throw new errs.ValidationError("No 2FA secret found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verify({
|
||||||
|
token,
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new errs.ValidationError("Invalid verification code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plain, hashed } = await generateBackupCodes();
|
||||||
|
|
||||||
|
const meta = { ...auth.meta, backup_codes: hashed };
|
||||||
|
await authModel
|
||||||
|
.query()
|
||||||
|
.where("id", auth.id)
|
||||||
|
.andWhere("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.patch({ meta });
|
||||||
|
|
||||||
|
return { backup_codes: plain };
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserPasswordAuth: async (userId) => {
|
||||||
|
const auth = await authModel
|
||||||
|
.query()
|
||||||
|
.where("user_id", userId)
|
||||||
|
.andWhere("type", "password")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
throw new errs.ItemNotFoundError("Auth not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default internal2fa;
|
||||||
@@ -630,7 +630,7 @@ const internalCertificate = {
|
|||||||
* @param {String} privateKey This is the entire key contents as a string
|
* @param {String} privateKey This is the entire key contents as a string
|
||||||
*/
|
*/
|
||||||
checkPrivateKey: async (privateKey) => {
|
checkPrivateKey: async (privateKey) => {
|
||||||
const filepath = await tempWrite(privateKey, "/tmp");
|
const filepath = await tempWrite(privateKey);
|
||||||
const failTimeout = setTimeout(() => {
|
const failTimeout = setTimeout(() => {
|
||||||
throw new error.ValidationError(
|
throw new error.ValidationError(
|
||||||
"Result Validation Error: Validation timed out. This could be due to the key being passphrase-protected.",
|
"Result Validation Error: Validation timed out. This could be due to the key being passphrase-protected.",
|
||||||
@@ -660,8 +660,8 @@ const internalCertificate = {
|
|||||||
* @param {Boolean} [throwExpired] Throw when the certificate is out of date
|
* @param {Boolean} [throwExpired] Throw when the certificate is out of date
|
||||||
*/
|
*/
|
||||||
getCertificateInfo: async (certificate, throwExpired) => {
|
getCertificateInfo: async (certificate, throwExpired) => {
|
||||||
|
const filepath = await tempWrite(certificate);
|
||||||
try {
|
try {
|
||||||
const filepath = await tempWrite(certificate, "/tmp");
|
|
||||||
const certData = await internalCertificate.getCertificateInfoFromFile(filepath, throwExpired);
|
const certData = await internalCertificate.getCertificateInfoFromFile(filepath, throwExpired);
|
||||||
fs.unlinkSync(filepath);
|
fs.unlinkSync(filepath);
|
||||||
return certData;
|
return certData;
|
||||||
@@ -798,6 +798,11 @@ const internalCertificate = {
|
|||||||
certificate.domain_names.join(","),
|
certificate.domain_names.join(","),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add key-type parameter if specified
|
||||||
|
if (certificate.meta?.key_type) {
|
||||||
|
args.push("--key-type", certificate.meta.key_type);
|
||||||
|
}
|
||||||
|
|
||||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
|
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
|
||||||
args.push(...adds.args);
|
args.push(...adds.args);
|
||||||
|
|
||||||
@@ -858,6 +863,11 @@ const internalCertificate = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add key-type parameter if specified
|
||||||
|
if (certificate.meta?.key_type) {
|
||||||
|
args.push("--key-type", certificate.meta.key_type);
|
||||||
|
}
|
||||||
|
|
||||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||||
args.push(...adds.args);
|
args.push(...adds.args);
|
||||||
|
|
||||||
@@ -938,6 +948,11 @@ const internalCertificate = {
|
|||||||
"--disable-hook-validation",
|
"--disable-hook-validation",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add key-type parameter if specified
|
||||||
|
if (certificate.meta?.key_type) {
|
||||||
|
args.push("--key-type", certificate.meta.key_type);
|
||||||
|
}
|
||||||
|
|
||||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||||
args.push(...adds.args);
|
args.push(...adds.args);
|
||||||
|
|
||||||
@@ -979,6 +994,11 @@ const internalCertificate = {
|
|||||||
"--no-random-sleep-on-renew",
|
"--no-random-sleep-on-renew",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add key-type parameter if specified
|
||||||
|
if (certificate.meta?.key_type) {
|
||||||
|
args.push("--key-type", certificate.meta.key_type);
|
||||||
|
}
|
||||||
|
|
||||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||||
args.push(...adds.args);
|
args.push(...adds.args);
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const internalDeadHost = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.andWhere("id", data.id)
|
.andWhere("id", data.id)
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(deadHostModel.defaultAllowGraph)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (accessData.permission_visibility !== "all") {
|
if (accessData.permission_visibility !== "all") {
|
||||||
@@ -347,7 +347,7 @@ const internalDeadHost = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.groupBy("id")
|
.groupBy("id")
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(deadHostModel.defaultAllowGraph)
|
||||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||||
|
|
||||||
if (accessData.permission_visibility !== "all") {
|
if (accessData.permission_visibility !== "all") {
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ const internalProxyHost = {
|
|||||||
*/
|
*/
|
||||||
update: (access, data) => {
|
update: (access, data) => {
|
||||||
let thisData = data;
|
let thisData = data;
|
||||||
const create_certificate = thisData.certificate_id === "new";
|
const createCertificate = thisData.certificate_id === "new";
|
||||||
|
|
||||||
if (create_certificate) {
|
if (createCertificate) {
|
||||||
delete thisData.certificate_id;
|
delete thisData.certificate_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ const internalProxyHost = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (create_certificate) {
|
if (createCertificate) {
|
||||||
return internalCertificate
|
return internalCertificate
|
||||||
.createQuickCertificate(access, {
|
.createQuickCertificate(access, {
|
||||||
domain_names: thisData.domain_names || row.domain_names,
|
domain_names: thisData.domain_names || row.domain_names,
|
||||||
@@ -232,7 +232,6 @@ const internalProxyHost = {
|
|||||||
*/
|
*/
|
||||||
get: (access, data) => {
|
get: (access, data) => {
|
||||||
const thisData = data || {};
|
const thisData = data || {};
|
||||||
|
|
||||||
return access
|
return access
|
||||||
.can("proxy_hosts:get", thisData.id)
|
.can("proxy_hosts:get", thisData.id)
|
||||||
.then((access_data) => {
|
.then((access_data) => {
|
||||||
@@ -240,7 +239,7 @@ const internalProxyHost = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.andWhere("id", thisData.id)
|
.andWhere("id", thisData.id)
|
||||||
.allowGraph("[owner,access_list.[clients,items],certificate]")
|
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (access_data.permission_visibility !== "all") {
|
if (access_data.permission_visibility !== "all") {
|
||||||
@@ -422,11 +421,12 @@ const internalProxyHost = {
|
|||||||
*/
|
*/
|
||||||
getAll: async (access, expand, searchQuery) => {
|
getAll: async (access, expand, searchQuery) => {
|
||||||
const accessData = await access.can("proxy_hosts:list");
|
const accessData = await access.can("proxy_hosts:list");
|
||||||
|
|
||||||
const query = proxyHostModel
|
const query = proxyHostModel
|
||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.groupBy("id")
|
.groupBy("id")
|
||||||
.allowGraph("[owner,access_list,certificate]")
|
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||||
|
|
||||||
if (accessData.permission_visibility !== "all") {
|
if (accessData.permission_visibility !== "all") {
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ const internalRedirectionHost = {
|
|||||||
*/
|
*/
|
||||||
get: (access, data) => {
|
get: (access, data) => {
|
||||||
const thisData = data || {};
|
const thisData = data || {};
|
||||||
|
|
||||||
return access
|
return access
|
||||||
.can("redirection_hosts:get", thisData.id)
|
.can("redirection_hosts:get", thisData.id)
|
||||||
.then((access_data) => {
|
.then((access_data) => {
|
||||||
@@ -237,7 +236,7 @@ const internalRedirectionHost = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.andWhere("id", thisData.id)
|
.andWhere("id", thisData.id)
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(redirectionHostModel.defaultAllowGraph)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (access_data.permission_visibility !== "all") {
|
if (access_data.permission_visibility !== "all") {
|
||||||
@@ -426,7 +425,7 @@ const internalRedirectionHost = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.groupBy("id")
|
.groupBy("id")
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(redirectionHostModel.defaultAllowGraph)
|
||||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||||
|
|
||||||
if (access_data.permission_visibility !== "all") {
|
if (access_data.permission_visibility !== "all") {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ const internalReport = {
|
|||||||
const userId = access.token.getUserId(1);
|
const userId = access.token.getUserId(1);
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
internalProxyHost.getCount(userId, access_data.visibility),
|
internalProxyHost.getCount(userId, access_data.permission_visibility),
|
||||||
internalRedirectionHost.getCount(userId, access_data.visibility),
|
internalRedirectionHost.getCount(userId, access_data.permission_visibility),
|
||||||
internalStream.getCount(userId, access_data.visibility),
|
internalStream.getCount(userId, access_data.permission_visibility),
|
||||||
internalDeadHost.getCount(userId, access_data.visibility),
|
internalDeadHost.getCount(userId, access_data.permission_visibility),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ const internalStream = {
|
|||||||
*/
|
*/
|
||||||
get: (access, data) => {
|
get: (access, data) => {
|
||||||
const thisData = data || {};
|
const thisData = data || {};
|
||||||
|
|
||||||
return access
|
return access
|
||||||
.can("streams:get", thisData.id)
|
.can("streams:get", thisData.id)
|
||||||
.then((access_data) => {
|
.then((access_data) => {
|
||||||
@@ -186,7 +185,7 @@ const internalStream = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.andWhere("id", thisData.id)
|
.andWhere("id", thisData.id)
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(streamModel.defaultAllowGraph)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (access_data.permission_visibility !== "all") {
|
if (access_data.permission_visibility !== "all") {
|
||||||
@@ -375,7 +374,7 @@ const internalStream = {
|
|||||||
.query()
|
.query()
|
||||||
.where("is_deleted", 0)
|
.where("is_deleted", 0)
|
||||||
.groupBy("id")
|
.groupBy("id")
|
||||||
.allowGraph("[owner,certificate]")
|
.allowGraph(streamModel.defaultAllowGraph)
|
||||||
.orderBy("incoming_port", "ASC");
|
.orderBy("incoming_port", "ASC");
|
||||||
|
|
||||||
if (access_data.permission_visibility !== "all") {
|
if (access_data.permission_visibility !== "all") {
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { parseDatePeriod } from "../lib/helpers.js";
|
|||||||
import authModel from "../models/auth.js";
|
import authModel from "../models/auth.js";
|
||||||
import TokenModel from "../models/token.js";
|
import TokenModel from "../models/token.js";
|
||||||
import userModel from "../models/user.js";
|
import userModel from "../models/user.js";
|
||||||
|
import twoFactor from "./2fa.js";
|
||||||
|
|
||||||
const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
|
const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
|
||||||
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
|
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
|
||||||
|
const ERROR_MESSAGE_INVALID_2FA = "Invalid verification code";
|
||||||
|
const ERROR_MESSAGE_INVALID_2FA_I18N = "error.invalid-2fa";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +62,25 @@ export default {
|
|||||||
throw new errs.AuthError(`Invalid scope: ${data.scope}`);
|
throw new errs.AuthError(`Invalid scope: ${data.scope}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is enabled
|
||||||
|
const has2FA = await twoFactor.isEnabled(user.id);
|
||||||
|
if (has2FA) {
|
||||||
|
// Return challenge token instead of full token
|
||||||
|
const challengeToken = await Token.create({
|
||||||
|
iss: issuer || "api",
|
||||||
|
attrs: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
scope: ["2fa-challenge"],
|
||||||
|
expiresIn: "5m",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requires_2fa: true,
|
||||||
|
challenge_token: challengeToken.token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Create a moment of the expiry expression
|
// Create a moment of the expiry expression
|
||||||
const expiry = parseDatePeriod(data.expiry);
|
const expiry = parseDatePeriod(data.expiry);
|
||||||
if (expiry === null) {
|
if (expiry === null) {
|
||||||
@@ -129,6 +151,65 @@ export default {
|
|||||||
throw new error.AssertionFailedError("Existing token contained invalid user data");
|
throw new error.AssertionFailedError("Existing token contained invalid user data");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify 2FA code and return full token
|
||||||
|
* @param {string} challengeToken
|
||||||
|
* @param {string} code
|
||||||
|
* @param {string} [expiry]
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
verify2FA: async (challengeToken, code, expiry) => {
|
||||||
|
const Token = TokenModel();
|
||||||
|
const tokenExpiry = expiry || "1d";
|
||||||
|
|
||||||
|
// Verify challenge token
|
||||||
|
let tokenData;
|
||||||
|
try {
|
||||||
|
tokenData = await Token.load(challengeToken);
|
||||||
|
} catch {
|
||||||
|
throw new errs.AuthError("Invalid or expired challenge token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check scope
|
||||||
|
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
|
||||||
|
throw new errs.AuthError("Invalid challenge token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = tokenData.attrs?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new errs.AuthError("Invalid challenge token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 2FA code
|
||||||
|
const valid = await twoFactor.verifyForLogin(userId, code);
|
||||||
|
if (!valid) {
|
||||||
|
throw new errs.AuthError(
|
||||||
|
ERROR_MESSAGE_INVALID_2FA,
|
||||||
|
ERROR_MESSAGE_INVALID_2FA_I18N,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create full token
|
||||||
|
const expiryDate = parseDatePeriod(tokenExpiry);
|
||||||
|
if (expiryDate === null) {
|
||||||
|
throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = await Token.create({
|
||||||
|
iss: "api",
|
||||||
|
attrs: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
scope: ["user"],
|
||||||
|
expiresIn: tokenExpiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: signed.token,
|
||||||
|
expires: expiryDate.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} user
|
* @param {Object} user
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import fs from "node:fs";
|
|||||||
import NodeRSA from "node-rsa";
|
import NodeRSA from "node-rsa";
|
||||||
import { global as logger } from "../logger.js";
|
import { global as logger } from "../logger.js";
|
||||||
|
|
||||||
const keysFile = '/data/keys.json';
|
const keysFile = '/data/keys.json';
|
||||||
const mysqlEngine = 'mysql2';
|
const mysqlEngine = 'mysql2';
|
||||||
const postgresEngine = 'pg';
|
const postgresEngine = 'pg';
|
||||||
const sqliteClientName = 'sqlite3';
|
const sqliteClientName = 'better-sqlite3';
|
||||||
|
|
||||||
|
// Not used for new setups anymore but may exist in legacy setups
|
||||||
|
const legacySqliteClientName = 'sqlite3';
|
||||||
|
|
||||||
let instance = null;
|
let instance = null;
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ const configure = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envSqliteFile = process.env.DB_SQLITE_FILE || "/data/database.sqlite";
|
const envSqliteFile = process.env.DB_SQLITE_FILE || "/data/database.sqlite";
|
||||||
|
|
||||||
logger.info(`Using Sqlite: ${envSqliteFile}`);
|
logger.info(`Using Sqlite: ${envSqliteFile}`);
|
||||||
instance = {
|
instance = {
|
||||||
database: {
|
database: {
|
||||||
@@ -183,7 +187,7 @@ const configGet = (key) => {
|
|||||||
*/
|
*/
|
||||||
const isSqlite = () => {
|
const isSqlite = () => {
|
||||||
instance === null && configure();
|
instance === null && configure();
|
||||||
return instance.database.knex && instance.database.knex.client === sqliteClientName;
|
return instance.database.knex && [sqliteClientName, legacySqliteClientName].includes(instance.database.knex.client);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
43
backend/migrations/20260131163528_trust_forwarded_proto.js
Normal file
43
backend/migrations/20260131163528_trust_forwarded_proto.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { migrate as logger } from "../logger.js";
|
||||||
|
|
||||||
|
const migrateName = "trust_forwarded_proto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate
|
||||||
|
*
|
||||||
|
* @see http://knexjs.org/#Schema
|
||||||
|
*
|
||||||
|
* @param {Object} knex
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
const up = function (knex) {
|
||||||
|
logger.info(`[${migrateName}] Migrating Up...`);
|
||||||
|
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('proxy_host', (table) => {
|
||||||
|
table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo Migrate
|
||||||
|
*
|
||||||
|
* @param {Object} knex
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
const down = function (knex) {
|
||||||
|
logger.info(`[${migrateName}] Migrating Down...`);
|
||||||
|
|
||||||
|
return knex.schema
|
||||||
|
.alterTable('proxy_host', (table) => {
|
||||||
|
table.dropColumn('trust_forwarded_proto');
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { up, down };
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { Model } from "objection";
|
import { Model } from "objection";
|
||||||
import db from "../db.js";
|
import db from "../db.js";
|
||||||
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
import { castJsonIfNeed, convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
||||||
import Certificate from "./certificate.js";
|
import Certificate from "./certificate.js";
|
||||||
import now from "./now_helper.js";
|
import now from "./now_helper.js";
|
||||||
import User from "./user.js";
|
import User from "./user.js";
|
||||||
@@ -61,6 +61,18 @@ class DeadHost extends Model {
|
|||||||
return ["domain_names", "meta"];
|
return ["domain_names", "meta"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get defaultAllowGraph() {
|
||||||
|
return "[owner,certificate]";
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultExpand() {
|
||||||
|
return ["certificate", "owner"];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultOrder() {
|
||||||
|
return [castJsonIfNeed("domain_names"), "ASC"];
|
||||||
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
return {
|
return {
|
||||||
owner: {
|
owner: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { Model } from "objection";
|
import { Model } from "objection";
|
||||||
import db from "../db.js";
|
import db from "../db.js";
|
||||||
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
import { castJsonIfNeed, convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
||||||
import AccessList from "./access_list.js";
|
import AccessList from "./access_list.js";
|
||||||
import Certificate from "./certificate.js";
|
import Certificate from "./certificate.js";
|
||||||
import now from "./now_helper.js";
|
import now from "./now_helper.js";
|
||||||
@@ -21,6 +21,7 @@ const boolFields = [
|
|||||||
"enabled",
|
"enabled",
|
||||||
"hsts_enabled",
|
"hsts_enabled",
|
||||||
"hsts_subdomains",
|
"hsts_subdomains",
|
||||||
|
"trust_forwarded_proto",
|
||||||
];
|
];
|
||||||
|
|
||||||
class ProxyHost extends Model {
|
class ProxyHost extends Model {
|
||||||
@@ -72,6 +73,18 @@ class ProxyHost extends Model {
|
|||||||
return ["domain_names", "meta", "locations"];
|
return ["domain_names", "meta", "locations"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get defaultAllowGraph() {
|
||||||
|
return "[owner,access_list.[clients,items],certificate]";
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultExpand() {
|
||||||
|
return ["owner", "certificate", "access_list.[clients,items]"];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultOrder() {
|
||||||
|
return [castJsonIfNeed("domain_names"), "ASC"];
|
||||||
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
return {
|
return {
|
||||||
owner: {
|
owner: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { Model } from "objection";
|
import { Model } from "objection";
|
||||||
import db from "../db.js";
|
import db from "../db.js";
|
||||||
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
import { castJsonIfNeed, convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
||||||
import Certificate from "./certificate.js";
|
import Certificate from "./certificate.js";
|
||||||
import now from "./now_helper.js";
|
import now from "./now_helper.js";
|
||||||
import User from "./user.js";
|
import User from "./user.js";
|
||||||
@@ -70,6 +70,18 @@ class RedirectionHost extends Model {
|
|||||||
return ["domain_names", "meta"];
|
return ["domain_names", "meta"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get defaultAllowGraph() {
|
||||||
|
return "[owner,certificate]";
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultExpand() {
|
||||||
|
return ["certificate", "owner"];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultOrder() {
|
||||||
|
return [castJsonIfNeed("domain_names"), "ASC"];
|
||||||
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
return {
|
return {
|
||||||
owner: {
|
owner: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Model } from "objection";
|
import { Model } from "objection";
|
||||||
import db from "../db.js";
|
import db from "../db.js";
|
||||||
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
import { castJsonIfNeed, convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
|
||||||
import Certificate from "./certificate.js";
|
import Certificate from "./certificate.js";
|
||||||
import now from "./now_helper.js";
|
import now from "./now_helper.js";
|
||||||
import User from "./user.js";
|
import User from "./user.js";
|
||||||
@@ -46,6 +46,18 @@ class Stream extends Model {
|
|||||||
return ["meta"];
|
return ["meta"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get defaultAllowGraph() {
|
||||||
|
return "[owner,certificate]";
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultExpand() {
|
||||||
|
return ["certificate", "owner"];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get defaultOrder() {
|
||||||
|
return [castJsonIfNeed("incoming_port"), "ASC"];
|
||||||
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
return {
|
return {
|
||||||
owner: {
|
owner: {
|
||||||
|
|||||||
@@ -9,39 +9,42 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"prettier": "biome format --write .",
|
"prettier": "biome format --write .",
|
||||||
"validate-schema": "node validate-schema.js"
|
"validate-schema": "node validate-schema.js",
|
||||||
|
"regenerate-config": "node scripts/regenerate-config"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apidevtools/json-schema-ref-parser": "^11.7.0",
|
"@apidevtools/json-schema-ref-parser": "^15.3.1",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.18.0",
|
||||||
"archiver": "^5.3.0",
|
"archiver": "^7.0.1",
|
||||||
"batchflow": "^0.4.0",
|
"batchflow": "^0.4.0",
|
||||||
"bcrypt": "^5.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"body-parser": "^1.20.3",
|
"better-sqlite3": "^12.6.2",
|
||||||
"compression": "^1.7.4",
|
"body-parser": "^2.2.2",
|
||||||
"express": "^4.20.0",
|
"compression": "^1.8.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
"gravatar": "^1.8.2",
|
"gravatar": "^1.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"knex": "2.4.2",
|
"knex": "3.1.0",
|
||||||
"liquidjs": "10.6.1",
|
"liquidjs": "10.25.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.23",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.18.2",
|
||||||
"node-rsa": "^1.1.1",
|
"node-rsa": "^1.1.1",
|
||||||
"objection": "3.0.1",
|
"objection": "3.1.5",
|
||||||
|
"otplib": "^13.3.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.19.0",
|
||||||
"proxy-agent": "^6.5.0",
|
"proxy-agent": "^6.5.0",
|
||||||
"signale": "1.4.0",
|
"signale": "1.4.0",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"temp-write": "^4.0.0"
|
"temp-write": "^6.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apidevtools/swagger-parser": "^10.1.0",
|
"@apidevtools/swagger-parser": "^12.1.0",
|
||||||
"@biomejs/biome": "^2.3.2",
|
"@biomejs/biome": "^2.4.5",
|
||||||
"chalk": "4.1.2",
|
"chalk": "5.6.2",
|
||||||
"nodemon": "^2.0.2"
|
"nodemon": "^3.1.14"
|
||||||
},
|
},
|
||||||
"signale": {
|
"signale": {
|
||||||
"displayDate": true,
|
"displayDate": true,
|
||||||
|
|||||||
@@ -53,4 +53,26 @@ router
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router
|
||||||
|
.route("/2fa")
|
||||||
|
.options((_, res) => {
|
||||||
|
res.sendStatus(204);
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tokens/2fa
|
||||||
|
*
|
||||||
|
* Verify 2FA code and get full token
|
||||||
|
*/
|
||||||
|
.post(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);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import internal2FA from "../internal/2fa.js";
|
||||||
import internalUser from "../internal/user.js";
|
import internalUser from "../internal/user.js";
|
||||||
import Access from "../lib/access.js";
|
import Access from "../lib/access.js";
|
||||||
import { isCI } from "../lib/config.js";
|
import { isCI } from "../lib/config.js";
|
||||||
@@ -325,4 +326,130 @@ router
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User 2FA status
|
||||||
|
*
|
||||||
|
* /api/users/123/2fa
|
||||||
|
*/
|
||||||
|
router
|
||||||
|
.route("/:user_id/2fa")
|
||||||
|
.options((_, res) => {
|
||||||
|
res.sendStatus(204);
|
||||||
|
})
|
||||||
|
.all(jwtdecode())
|
||||||
|
.all(userIdFromMe)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/123/2fa
|
||||||
|
*
|
||||||
|
* Start 2FA setup, returns QR code URL
|
||||||
|
*/
|
||||||
|
.post(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await internal2FA.startSetup(res.locals.access, req.params.user_id);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/users/123/2fa
|
||||||
|
*
|
||||||
|
* Get 2FA status for a user
|
||||||
|
*/
|
||||||
|
.get(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = await internal2FA.getStatus(res.locals.access, req.params.user_id);
|
||||||
|
res.status(200).send(status);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/users/123/2fa?code=XXXXXX
|
||||||
|
*
|
||||||
|
* Disable 2FA for a user
|
||||||
|
*/
|
||||||
|
.delete(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const code = typeof req.query.code === "string" ? req.query.code : null;
|
||||||
|
if (!code) {
|
||||||
|
throw new errs.ValidationError("Missing required parameter: code");
|
||||||
|
}
|
||||||
|
await internal2FA.disable(res.locals.access, req.params.user_id, code);
|
||||||
|
res.status(200).send(true);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User 2FA enable
|
||||||
|
*
|
||||||
|
* /api/users/123/2fa/enable
|
||||||
|
*/
|
||||||
|
router
|
||||||
|
.route("/:user_id/2fa/enable")
|
||||||
|
.options((_, res) => {
|
||||||
|
res.sendStatus(204);
|
||||||
|
})
|
||||||
|
.all(jwtdecode())
|
||||||
|
.all(userIdFromMe)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/123/2fa/enable
|
||||||
|
*
|
||||||
|
* Verify code and enable 2FA
|
||||||
|
*/
|
||||||
|
.post(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { code } = await apiValidator(
|
||||||
|
getValidationSchema("/users/{userID}/2fa/enable", "post"),
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User 2FA backup codes
|
||||||
|
*
|
||||||
|
* /api/users/123/2fa/backup-codes
|
||||||
|
*/
|
||||||
|
router
|
||||||
|
.route("/:user_id/2fa/backup-codes")
|
||||||
|
.options((_, res) => {
|
||||||
|
res.sendStatus(204);
|
||||||
|
})
|
||||||
|
.all(jwtdecode())
|
||||||
|
.all(userIdFromMe)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/123/2fa/backup-codes
|
||||||
|
*
|
||||||
|
* Regenerate backup codes
|
||||||
|
*/
|
||||||
|
.post(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { code } = await apiValidator(
|
||||||
|
getValidationSchema("/users/{userID}/2fa/backup-codes", "post"),
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
const result = await internal2FA.regenerateBackupCodes(res.locals.access, req.params.user_id, code);
|
||||||
|
res.status(200).send(result);
|
||||||
|
} catch (err) {
|
||||||
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -71,6 +71,11 @@
|
|||||||
"propagation_seconds": {
|
"propagation_seconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"key_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["rsa", "ecdsa"],
|
||||||
|
"default": "rsa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"enabled",
|
"enabled",
|
||||||
"locations",
|
"locations",
|
||||||
"hsts_enabled",
|
"hsts_enabled",
|
||||||
"hsts_subdomains"
|
"hsts_subdomains",
|
||||||
|
"trust_forwarded_proto"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -141,6 +142,11 @@
|
|||||||
"hsts_subdomains": {
|
"hsts_subdomains": {
|
||||||
"$ref": "../common.json#/properties/hsts_subdomains"
|
"$ref": "../common.json#/properties/hsts_subdomains"
|
||||||
},
|
},
|
||||||
|
"trust_forwarded_proto":{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Trust the forwarded headers",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
"certificate": {
|
"certificate": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
18
backend/schema/components/token-challenge.json
Normal file
18
backend/schema/components/token-challenge.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Token object",
|
||||||
|
"required": ["requires_2fa", "challenge_token"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"requires_2fa": {
|
||||||
|
"description": "Whether this token request requires two-factor authentication",
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"challenge_token": {
|
||||||
|
"description": "Challenge Token used in subsequent 2FA verification",
|
||||||
|
"example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"locations": [],
|
"locations": [],
|
||||||
"hsts_enabled": false,
|
"hsts_enabled": false,
|
||||||
"hsts_subdomains": false
|
"hsts_subdomains": false,
|
||||||
|
"trust_forwarded_proto": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"locations": [],
|
"locations": [],
|
||||||
"hsts_enabled": false,
|
"hsts_enabled": false,
|
||||||
"hsts_subdomains": false,
|
"hsts_subdomains": false,
|
||||||
|
"trust_forwarded_proto": false,
|
||||||
"owner": {
|
"owner": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"created_on": "2025-10-28T00:50:24.000Z",
|
"created_on": "2025-10-28T00:50:24.000Z",
|
||||||
|
|||||||
@@ -56,6 +56,9 @@
|
|||||||
"hsts_subdomains": {
|
"hsts_subdomains": {
|
||||||
"$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains"
|
"$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains"
|
||||||
},
|
},
|
||||||
|
"trust_forwarded_proto": {
|
||||||
|
"$ref": "../../../../components/proxy-host-object.json#/properties/trust_forwarded_proto"
|
||||||
|
},
|
||||||
"http2_support": {
|
"http2_support": {
|
||||||
"$ref": "../../../../components/proxy-host-object.json#/properties/http2_support"
|
"$ref": "../../../../components/proxy-host-object.json#/properties/http2_support"
|
||||||
},
|
},
|
||||||
@@ -122,6 +125,7 @@
|
|||||||
"locations": [],
|
"locations": [],
|
||||||
"hsts_enabled": false,
|
"hsts_enabled": false,
|
||||||
"hsts_subdomains": false,
|
"hsts_subdomains": false,
|
||||||
|
"trust_forwarded_proto": false,
|
||||||
"owner": {
|
"owner": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"created_on": "2025-10-28T00:50:24.000Z",
|
"created_on": "2025-10-28T00:50:24.000Z",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"hsts_subdomains": {
|
"hsts_subdomains": {
|
||||||
"$ref": "../../../components/proxy-host-object.json#/properties/hsts_subdomains"
|
"$ref": "../../../components/proxy-host-object.json#/properties/hsts_subdomains"
|
||||||
},
|
},
|
||||||
|
"trust_forwarded_proto": {
|
||||||
|
"$ref": "../../../components/proxy-host-object.json#/properties/trust_forwarded_proto"
|
||||||
|
},
|
||||||
"http2_support": {
|
"http2_support": {
|
||||||
"$ref": "../../../components/proxy-host-object.json#/properties/http2_support"
|
"$ref": "../../../components/proxy-host-object.json#/properties/http2_support"
|
||||||
},
|
},
|
||||||
@@ -119,6 +122,7 @@
|
|||||||
"locations": [],
|
"locations": [],
|
||||||
"hsts_enabled": false,
|
"hsts_enabled": false,
|
||||||
"hsts_subdomains": false,
|
"hsts_subdomains": false,
|
||||||
|
"trust_forwarded_proto": false,
|
||||||
"certificate": null,
|
"certificate": null,
|
||||||
"owner": {
|
"owner": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
|||||||
55
backend/schema/paths/tokens/2fa/post.json
Normal file
55
backend/schema/paths/tokens/2fa/post.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"operationId": "loginWith2FA",
|
||||||
|
"summary": "Verify 2FA code and get full token",
|
||||||
|
"tags": ["tokens"],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "2fa Challenge Payload",
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"challenge_token": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string",
|
||||||
|
"example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"minLength": 6,
|
||||||
|
"maxLength": 8,
|
||||||
|
"type": "string",
|
||||||
|
"example": "012345"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["challenge_token", "code"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"challenge_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4",
|
||||||
|
"code": "012345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"expires": "2025-02-04T20:40:46.340Z",
|
||||||
|
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"$ref": "../../../components/token-object.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "200 response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../../components/token-object.json"
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "../../components/token-object.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../../components/token-challenge.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
92
backend/schema/paths/users/userID/2fa/backup-codes/post.json
Normal file
92
backend/schema/paths/users/userID/2fa/backup-codes/post.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"operationId": "regenUser2faCodes",
|
||||||
|
"summary": "Regenerate 2FA backup codes",
|
||||||
|
"tags": ["users"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "userID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "User ID",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "Verification Payload",
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"minLength": 6,
|
||||||
|
"maxLength": 8,
|
||||||
|
"type": "string",
|
||||||
|
"example": "123456"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"backup_codes": [
|
||||||
|
"6CD7CB06",
|
||||||
|
"495302F3",
|
||||||
|
"D8037852",
|
||||||
|
"A6FFC956",
|
||||||
|
"BC1A1851",
|
||||||
|
"A05E644F",
|
||||||
|
"A406D2E8",
|
||||||
|
"0AE3C522"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["backup_codes"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"backup_codes": {
|
||||||
|
"description": "Backup codes",
|
||||||
|
"example": [
|
||||||
|
"6CD7CB06",
|
||||||
|
"495302F3",
|
||||||
|
"D8037852",
|
||||||
|
"A6FFC956",
|
||||||
|
"BC1A1851",
|
||||||
|
"A05E644F",
|
||||||
|
"A406D2E8",
|
||||||
|
"0AE3C522"
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "6CD7CB06"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "200 response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/schema/paths/users/userID/2fa/delete.json
Normal file
48
backend/schema/paths/users/userID/2fa/delete.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"operationId": "disableUser2fa",
|
||||||
|
"summary": "Disable 2fa for user",
|
||||||
|
"tags": ["users"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "userID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "User ID",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "code",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 6,
|
||||||
|
"maxLength": 6,
|
||||||
|
"example": "012345"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "2fa Code",
|
||||||
|
"example": "012345"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "200 response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
backend/schema/paths/users/userID/2fa/enable/post.json
Normal file
92
backend/schema/paths/users/userID/2fa/enable/post.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"operationId": "enableUser2fa",
|
||||||
|
"summary": "Verify code and enable 2FA",
|
||||||
|
"tags": ["users"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "userID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "User ID",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "Verification Payload",
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"minLength": 6,
|
||||||
|
"maxLength": 8,
|
||||||
|
"type": "string",
|
||||||
|
"example": "123456"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"backup_codes": [
|
||||||
|
"6CD7CB06",
|
||||||
|
"495302F3",
|
||||||
|
"D8037852",
|
||||||
|
"A6FFC956",
|
||||||
|
"BC1A1851",
|
||||||
|
"A05E644F",
|
||||||
|
"A406D2E8",
|
||||||
|
"0AE3C522"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["backup_codes"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"backup_codes": {
|
||||||
|
"description": "Backup codes",
|
||||||
|
"example": [
|
||||||
|
"6CD7CB06",
|
||||||
|
"495302F3",
|
||||||
|
"D8037852",
|
||||||
|
"A6FFC956",
|
||||||
|
"BC1A1851",
|
||||||
|
"A05E644F",
|
||||||
|
"A406D2E8",
|
||||||
|
"0AE3C522"
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "6CD7CB06"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "200 response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/schema/paths/users/userID/2fa/get.json
Normal file
57
backend/schema/paths/users/userID/2fa/get.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"operationId": "getUser2faStatus",
|
||||||
|
"summary": "Get user 2fa Status",
|
||||||
|
"tags": ["users"],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "userID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "User ID",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "200 response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"enabled": false,
|
||||||
|
"backup_codes_remaining": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["enabled", "backup_codes_remaining"],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Is 2FA enabled for this user",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"backup_codes_remaining": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of remaining backup codes for this user",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/schema/paths/users/userID/2fa/post.json
Normal file
52
backend/schema/paths/users/userID/2fa/post.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"operationId": "setupUser2fa",
|
||||||
|
"summary": "Start 2FA setup, returns QR code URL",
|
||||||
|
"tags": ["users"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "userID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "User ID",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"secret": "JZYCEBIEEJYUGPQM",
|
||||||
|
"otpauth_url": "otpauth://totp/Nginx%20Proxy%20Manager:jc%40jc21.com?secret=JZYCEBIEEJYUGPQM&period=30&digits=6&algorithm=SHA1&issuer=Nginx%20Proxy%20Manager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["secret", "otpauth_url"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"description": "TOTP Secret",
|
||||||
|
"example": "JZYCEBIEEJYUGPQM",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"otpauth_url": {
|
||||||
|
"description": "OTP Auth URL for QR Code generation",
|
||||||
|
"example": "otpauth://totp/Nginx%20Proxy%20Manager:jc%40jc21.com?secret=JZYCEBIEEJYUGPQM&period=30&digits=6&algorithm=SHA1&issuer=Nginx%20Proxy%20Manager",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "200 response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,11 @@
|
|||||||
"$ref": "./paths/tokens/post.json"
|
"$ref": "./paths/tokens/post.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tokens/2fa": {
|
||||||
|
"post": {
|
||||||
|
"$ref": "./paths/tokens/2fa/post.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/version/check": {
|
"/version/check": {
|
||||||
"get": {
|
"get": {
|
||||||
"$ref": "./paths/version/check/get.json"
|
"$ref": "./paths/version/check/get.json"
|
||||||
@@ -317,6 +322,27 @@
|
|||||||
"$ref": "./paths/users/userID/delete.json"
|
"$ref": "./paths/users/userID/delete.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/users/{userID}/2fa": {
|
||||||
|
"post": {
|
||||||
|
"$ref": "./paths/users/userID/2fa/post.json"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"$ref": "./paths/users/userID/2fa/get.json"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"$ref": "./paths/users/userID/2fa/delete.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/users/{userID}/2fa/enable": {
|
||||||
|
"post": {
|
||||||
|
"$ref": "./paths/users/userID/2fa/enable/post.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/users/{userID}/2fa/backup-codes": {
|
||||||
|
"post": {
|
||||||
|
"$ref": "./paths/users/userID/2fa/backup-codes/post.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/users/{userID}/auth": {
|
"/users/{userID}/auth": {
|
||||||
"put": {
|
"put": {
|
||||||
"$ref": "./paths/users/userID/auth/put.json"
|
"$ref": "./paths/users/userID/auth/put.json"
|
||||||
|
|||||||
76
backend/scripts/regenerate-config
Executable file
76
backend/scripts/regenerate-config
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import * as process from "node:process"; // Use the node: protocol for built-ins
|
||||||
|
import internalNginx from "../internal/nginx.js";
|
||||||
|
import { global as logger } from "../logger.js";
|
||||||
|
import deadHostModel from "../models/dead_host.js";
|
||||||
|
import proxyHostModel from "../models/proxy_host.js";
|
||||||
|
import redirectionHostModel from "../models/redirection_host.js";
|
||||||
|
import streamModel from "../models/stream.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const UNATTENDED = args.includes("-y") || args.includes("--yes");
|
||||||
|
const DRY_RUN = args.includes("--dry-run");
|
||||||
|
|
||||||
|
if (args.includes("--help") || args.includes("-h")) {
|
||||||
|
console.log("\nThis will iterate over all Hosts and regnerate their Nginx configs.\n")
|
||||||
|
console.log("Usage: ./regenerate-config [-h|--help] [-y|--yes] [--dry-run]\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask for the user to confirm the action if not in unattended mode
|
||||||
|
if (!UNATTENDED && !DRY_RUN) {
|
||||||
|
const readline = await import("node:readline");
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const question = (query) =>
|
||||||
|
new Promise((resolve) => rl.question(query, resolve));
|
||||||
|
|
||||||
|
const answer = await question(
|
||||||
|
"This will iterate over all Hosts and regnerate their Nginx configs.\n\nAre you sure you want to proceed? (y/N) ",
|
||||||
|
);
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
if (answer.toLowerCase() !== "y") {
|
||||||
|
console.log("Aborting.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logIt = (msg, type = "info") => logger[type](
|
||||||
|
`${DRY_RUN ? '[DRY RUN] ' : ''}${msg}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let's do it.
|
||||||
|
|
||||||
|
const processItems = async (model, type) => {
|
||||||
|
const rows = await model
|
||||||
|
.query()
|
||||||
|
.where("is_deleted", 0)
|
||||||
|
.andWhere("enabled", 1)
|
||||||
|
.groupBy("id")
|
||||||
|
.allowGraph(model.defaultAllowGraph)
|
||||||
|
.withGraphFetched(`[${model.defaultExpand.join(", ")}]`)
|
||||||
|
.orderBy(...model.defaultOrder);
|
||||||
|
|
||||||
|
logIt(`[${type}] Found ${rows.length} rows to process...`);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
|
||||||
|
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||||
|
} else {
|
||||||
|
logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await processItems(proxyHostModel, "Proxy Host");
|
||||||
|
await processItems(redirectionHostModel, "Redirection Host");
|
||||||
|
await processItems(deadHostModel, "404 Host");
|
||||||
|
await processItems(streamModel, "Stream");
|
||||||
|
|
||||||
|
logIt("Completed", "success");
|
||||||
|
process.exit(0);
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
{% if certificate and certificate_id > 0 -%}
|
{% if certificate and certificate_id > 0 -%}
|
||||||
{% if ssl_forced == 1 or ssl_forced == true %}
|
{% if ssl_forced == 1 or ssl_forced == true %}
|
||||||
# Force SSL
|
# Force SSL
|
||||||
|
{% if trust_forwarded_proto == true %}
|
||||||
|
set $trust_forwarded_proto "T";
|
||||||
|
{% else %}
|
||||||
|
set $trust_forwarded_proto "F";
|
||||||
|
{% endif %}
|
||||||
include conf.d/include/force-ssl.conf;
|
include conf.d/include/force-ssl.conf;
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -12,6 +12,9 @@ server {
|
|||||||
|
|
||||||
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
||||||
|
|
||||||
|
access_log /data/logs/stream-{{ id }}_access.log stream;
|
||||||
|
error_log /data/logs/stream-{{ id }}_error.log warn;
|
||||||
|
|
||||||
# Custom
|
# Custom
|
||||||
include /data/nginx/custom/server_stream[.]conf;
|
include /data/nginx/custom/server_stream[.]conf;
|
||||||
include /data/nginx/custom/server_stream_tcp[.]conf;
|
include /data/nginx/custom/server_stream_tcp[.]conf;
|
||||||
@@ -25,9 +28,12 @@ server {
|
|||||||
|
|
||||||
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
||||||
|
|
||||||
|
access_log /data/logs/stream-{{ id }}_access.log stream;
|
||||||
|
error_log /data/logs/stream-{{ id }}_error.log warn;
|
||||||
|
|
||||||
# Custom
|
# Custom
|
||||||
include /data/nginx/custom/server_stream[.]conf;
|
include /data/nginx/custom/server_stream[.]conf;
|
||||||
include /data/nginx/custom/server_stream_udp[.]conf;
|
include /data/nginx/custom/server_stream_udp[.]conf;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
1536
backend/yarn.lock
1536
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ services:
|
|||||||
- "cypress_logs:/test/results"
|
- "cypress_logs:/test/results"
|
||||||
- "./dev/resolv.conf:/etc/resolv.conf:ro"
|
- "./dev/resolv.conf:/etc/resolv.conf:ro"
|
||||||
- "/etc/localtime:/etc/localtime:ro"
|
- "/etc/localtime:/etc/localtime:ro"
|
||||||
command: cypress run --browser chrome --config-file=cypress/config/ci.js
|
command: cypress run --browser chrome --config-file=cypress/config/ci.mjs
|
||||||
networks:
|
networks:
|
||||||
- fulltest
|
- fulltest
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ services:
|
|||||||
- "../test/results:/results"
|
- "../test/results:/results"
|
||||||
- "./dev/resolv.conf:/etc/resolv.conf:ro"
|
- "./dev/resolv.conf:/etc/resolv.conf:ro"
|
||||||
- "/etc/localtime:/etc/localtime:ro"
|
- "/etc/localtime:/etc/localtime:ro"
|
||||||
command: cypress run --browser chrome --config-file=cypress/config/ci.js
|
command: cypress run --browser chrome --config-file=cypress/config/ci.mjs
|
||||||
networks:
|
networks:
|
||||||
- nginx_proxy_manager
|
- nginx_proxy_manager
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ server {
|
|||||||
set $port "80";
|
set $port "80";
|
||||||
|
|
||||||
server_name localhost-nginx-proxy-manager;
|
server_name localhost-nginx-proxy-manager;
|
||||||
access_log /data/logs/fallback_access.log standard;
|
access_log /data/logs/fallback_http_access.log standard;
|
||||||
error_log /data/logs/fallback_error.log warn;
|
error_log /data/logs/fallback_http_error.log warn;
|
||||||
include conf.d/include/assets.conf;
|
include conf.d/include/assets.conf;
|
||||||
include conf.d/include/block-exploits.conf;
|
include conf.d/include/block-exploits.conf;
|
||||||
include conf.d/include/letsencrypt-acme-challenge.conf;
|
include conf.d/include/letsencrypt-acme-challenge.conf;
|
||||||
@@ -30,7 +30,7 @@ server {
|
|||||||
set $port "443";
|
set $port "443";
|
||||||
|
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
access_log /data/logs/fallback_access.log standard;
|
access_log /data/logs/fallback_http_access.log standard;
|
||||||
error_log /dev/null crit;
|
error_log /dev/null crit;
|
||||||
include conf.d/include/ssl-ciphers.conf;
|
include conf.d/include/ssl-ciphers.conf;
|
||||||
ssl_reject_handshake on;
|
ssl_reject_handshake on;
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ if ($scheme = "http") {
|
|||||||
if ($request_uri = /.well-known/acme-challenge/test-challenge) {
|
if ($request_uri = /.well-known/acme-challenge/test-challenge) {
|
||||||
set $test "${test}T";
|
set $test "${test}T";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if the ssl staff has been handled
|
||||||
|
set $test_ssl_handled "";
|
||||||
|
if ($trust_forwarded_proto = "") {
|
||||||
|
set $trust_forwarded_proto "F";
|
||||||
|
}
|
||||||
|
if ($trust_forwarded_proto = "T") {
|
||||||
|
set $test_ssl_handled "${test_ssl_handled}T";
|
||||||
|
}
|
||||||
|
if ($http_x_forwarded_proto = "https") {
|
||||||
|
set $test_ssl_handled "${test_ssl_handled}S";
|
||||||
|
}
|
||||||
|
if ($http_x_forwarded_scheme = "https") {
|
||||||
|
set $test_ssl_handled "${test_ssl_handled}S";
|
||||||
|
}
|
||||||
|
if ($test_ssl_handled = "TSS") {
|
||||||
|
set $test_ssl_handled "TS";
|
||||||
|
}
|
||||||
|
if ($test_ssl_handled = "TS") {
|
||||||
|
set $test "${test}S";
|
||||||
|
}
|
||||||
|
|
||||||
if ($test = H) {
|
if ($test = H) {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';
|
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';
|
||||||
log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"';
|
log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"';
|
||||||
|
|
||||||
access_log /data/logs/fallback_access.log proxy;
|
access_log /data/logs/fallback_http_access.log proxy;
|
||||||
3
docker/rootfs/etc/nginx/conf.d/include/log-stream.conf
Normal file
3
docker/rootfs/etc/nginx/conf.d/include/log-stream.conf
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
log_format stream '[$time_local] [Client $remote_addr:$remote_port] $protocol $status $bytes_sent $bytes_received $session_time [Sent-to $upstream_addr] [Sent $upstream_bytes_sent] [Received $upstream_bytes_received] [Time $upstream_connect_time] $ssl_protocol $ssl_cipher';
|
||||||
|
|
||||||
|
access_log /data/logs/fallback_stream_access.log stream;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
add_header X-Served-By $host;
|
add_header X-Served-By $host;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_pass $forward_scheme://$server:$port$request_uri;
|
proxy_pass $forward_scheme://$server:$port$request_uri;
|
||||||
|
|||||||
@@ -47,16 +47,28 @@ http {
|
|||||||
proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m;
|
proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m;
|
||||||
|
|
||||||
# Log format and fallback log file
|
# Log format and fallback log file
|
||||||
include /etc/nginx/conf.d/include/log.conf;
|
include /etc/nginx/conf.d/include/log-proxy[.]conf;
|
||||||
|
|
||||||
# Dynamically generated resolvers file
|
# Dynamically generated resolvers file
|
||||||
include /etc/nginx/conf.d/include/resolvers.conf;
|
include /etc/nginx/conf.d/include/resolvers[.]conf;
|
||||||
|
|
||||||
# Default upstream scheme
|
# Default upstream scheme
|
||||||
map $host $forward_scheme {
|
map $host $forward_scheme {
|
||||||
default http;
|
default http;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Handle upstream X-Forwarded-Proto and X-Forwarded-Scheme header
|
||||||
|
map $http_x_forwarded_proto $x_forwarded_proto {
|
||||||
|
"http" "http";
|
||||||
|
"https" "https";
|
||||||
|
default $scheme;
|
||||||
|
}
|
||||||
|
map $http_x_forwarded_scheme $x_forwarded_scheme {
|
||||||
|
"http" "http";
|
||||||
|
"https" "https";
|
||||||
|
default $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# Real IP Determination
|
# Real IP Determination
|
||||||
|
|
||||||
# Local subnets:
|
# Local subnets:
|
||||||
@@ -64,7 +76,7 @@ http {
|
|||||||
set_real_ip_from 172.16.0.0/12; # Includes Docker subnet
|
set_real_ip_from 172.16.0.0/12; # Includes Docker subnet
|
||||||
set_real_ip_from 192.168.0.0/16;
|
set_real_ip_from 192.168.0.0/16;
|
||||||
# NPM generated CDN ip ranges:
|
# NPM generated CDN ip ranges:
|
||||||
include conf.d/include/ip_ranges.conf;
|
include conf.d/include/ip_ranges[.]conf;
|
||||||
# always put the following 2 lines after ip subnets:
|
# always put the following 2 lines after ip subnets:
|
||||||
real_ip_header X-Real-IP;
|
real_ip_header X-Real-IP;
|
||||||
real_ip_recursive on;
|
real_ip_recursive on;
|
||||||
@@ -85,6 +97,9 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stream {
|
stream {
|
||||||
|
# Log format and fallback log file
|
||||||
|
include /etc/nginx/conf.d/include/log-stream[.]conf;
|
||||||
|
|
||||||
# Files generated by NPM
|
# Files generated by NPM
|
||||||
include /data/nginx/stream/*.conf;
|
include /data/nginx/stream/*.conf;
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ log_info 'Dynamic resolvers ...'
|
|||||||
|
|
||||||
# Dynamically generate resolvers file, if resolver is IPv6, enclose in `[]`
|
# Dynamically generate resolvers file, if resolver is IPv6, enclose in `[]`
|
||||||
# thanks @tfmm
|
# thanks @tfmm
|
||||||
if [ "$(is_true "$DISABLE_IPV6")" = '1' ]; then
|
if [ "$(is_true "${DISABLE_RESOLVER:-}")" = '0' ]; then
|
||||||
echo resolver "$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf) ipv6=off valid=10s;" > /etc/nginx/conf.d/include/resolvers.conf
|
if [ "$(is_true "${DISABLE_IPV6:-}")" = '1' ]; then
|
||||||
else
|
echo resolver "$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf) ipv6=off valid=10s;" > /etc/nginx/conf.d/include/resolvers.conf
|
||||||
echo resolver "$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf) valid=10s;" > /etc/nginx/conf.d/include/resolvers.conf
|
else
|
||||||
|
echo resolver "$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf) valid=10s;" > /etc/nginx/conf.d/include/resolvers.conf
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ process_folder () {
|
|||||||
FILES=$(find "$1" -type f -name "*.conf")
|
FILES=$(find "$1" -type f -name "*.conf")
|
||||||
SED_REGEX=
|
SED_REGEX=
|
||||||
|
|
||||||
if [ "$(is_true "$DISABLE_IPV6")" = '1' ]; then
|
if [ "$(is_true "${DISABLE_IPV6:-}")" = '1' ]; then
|
||||||
# IPV6 is disabled
|
# IPV6 is disabled
|
||||||
echo "Disabling IPV6 in hosts in: $1"
|
echo "Disabling IPV6 in hosts in: $1"
|
||||||
SED_REGEX='s/^([^#]*)listen \[::\]/\1#listen [::]/g'
|
SED_REGEX='s/^([^#]*)listen \[::\]/\1#listen [::]/g'
|
||||||
@@ -25,7 +25,13 @@ process_folder () {
|
|||||||
for FILE in $FILES
|
for FILE in $FILES
|
||||||
do
|
do
|
||||||
echo "- ${FILE}"
|
echo "- ${FILE}"
|
||||||
echo "$(sed -E "$SED_REGEX" "$FILE")" > $FILE
|
TMPFILE="${FILE}.tmp"
|
||||||
|
if sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
|
||||||
|
mv "$TMPFILE" "$FILE"
|
||||||
|
else
|
||||||
|
echo "WARNING: skipping ${FILE} — sed produced empty output" >&2
|
||||||
|
rm -f "$TMPFILE"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ensure the files are still owned by the npm user
|
# ensure the files are still owned by the npm user
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ case $TARGETPLATFORM in
|
|||||||
S6_ARCH=aarch64
|
S6_ARCH=aarch64
|
||||||
;;
|
;;
|
||||||
|
|
||||||
linux/arm/v7)
|
|
||||||
S6_ARCH=armhf
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
*)
|
||||||
S6_ARCH=x86_64
|
S6_ARCH=x86_64
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vitepress dev --host",
|
"dev": "vitepress dev --host",
|
||||||
"build": "vitepress build",
|
"build": "vitepress build",
|
||||||
"preview": "vitepress preview"
|
"preview": "vitepress preview",
|
||||||
|
"set-version": "./scripts/set-version.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitepress": "^1.6.4"
|
"vitepress": "^1.6.4"
|
||||||
|
|||||||
17
docs/scripts/set-version.sh
Executable file
17
docs/scripts/set-version.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euf
|
||||||
|
|
||||||
|
# this script accepts a version number as an argument
|
||||||
|
# and replaces {{VERSION}} in src/*.md with the provided version number.
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <version>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$DIR/.." || exit 1
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
# find all .md files in src/ and replace {{VERSION}} with the provided version number
|
||||||
|
find src/ -type f -name "*.md" -exec sed -i "s/{{VERSION}}/$VERSION/g" {} \;
|
||||||
@@ -14,7 +14,7 @@ on the `data` and `letsencrypt` folders at startup.
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
environment:
|
environment:
|
||||||
PUID: 1000
|
PUID: 1000
|
||||||
PGID: 1000
|
PGID: 1000
|
||||||
@@ -101,7 +101,7 @@ secrets:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
# Public HTTP Port:
|
# Public HTTP Port:
|
||||||
@@ -130,18 +130,16 @@ services:
|
|||||||
- db
|
- db
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: jc21/mariadb-aria
|
image: 'linuxserver/mariadb'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# MYSQL_ROOT_PASSWORD: "npm" # use secret instead
|
|
||||||
MYSQL_ROOT_PASSWORD__FILE: /run/secrets/DB_ROOT_PWD
|
MYSQL_ROOT_PASSWORD__FILE: /run/secrets/DB_ROOT_PWD
|
||||||
MYSQL_DATABASE: "npm"
|
MYSQL_DATABASE: 'npm'
|
||||||
MYSQL_USER: "npm"
|
MYSQL_USER: 'npm'
|
||||||
# MYSQL_PASSWORD: "npm" # use secret instead
|
|
||||||
MYSQL_PASSWORD__FILE: /run/secrets/MYSQL_PWD
|
MYSQL_PASSWORD__FILE: /run/secrets/MYSQL_PWD
|
||||||
MARIADB_AUTO_UPGRADE: '1'
|
TZ: 'Australia/Brisbane'
|
||||||
volumes:
|
volumes:
|
||||||
- ./mysql:/var/lib/mysql
|
- ./mariadb:/config
|
||||||
secrets:
|
secrets:
|
||||||
- DB_ROOT_PWD
|
- DB_ROOT_PWD
|
||||||
- MYSQL_PWD
|
- MYSQL_PWD
|
||||||
@@ -233,8 +231,20 @@ load_module /usr/lib/nginx/modules/ngx_stream_geoip2_module.so;
|
|||||||
|
|
||||||
Setting these environment variables will create the default user on startup, skipping the UI first user setup screen:
|
Setting these environment variables will create the default user on startup, skipping the UI first user setup screen:
|
||||||
|
|
||||||
```
|
```yml
|
||||||
environment:
|
environment:
|
||||||
INITIAL_ADMIN_EMAIL: my@example.com
|
INITIAL_ADMIN_EMAIL: my@example.com
|
||||||
INITIAL_ADMIN_PASSWORD: mypassword1
|
INITIAL_ADMIN_PASSWORD: mypassword1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Disable Nginx Resolver
|
||||||
|
|
||||||
|
On startup, we generate a resolvers directive for Nginx unless this is defined:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
environment:
|
||||||
|
DISABLE_RESOLVER: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file
|
||||||
|
and then the `/etc/resolv.conf`.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ I won't go in to too much detail here but here are the basics for someone new to
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
TZ: "Australia/Brisbane"
|
TZ: "Australia/Brisbane"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Create a `docker-compose.yml` file:
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
@@ -45,10 +45,7 @@ docker compose up -d
|
|||||||
|
|
||||||
## Using MySQL / MariaDB Database
|
## Using MySQL / MariaDB Database
|
||||||
|
|
||||||
If you opt for the MySQL configuration you will have to provide the database server yourself. You can also use MariaDB. Here are the minimum supported versions:
|
If you opt for the MySQL configuration you will have to provide the database server yourself.
|
||||||
|
|
||||||
- MySQL v5.7.8+
|
|
||||||
- MariaDB v10.2.7+
|
|
||||||
|
|
||||||
It's easy to use another docker container for your database also and link it as part of the docker stack, so that's what the following examples
|
It's easy to use another docker container for your database also and link it as part of the docker stack, so that's what the following examples
|
||||||
are going to use.
|
are going to use.
|
||||||
@@ -58,7 +55,7 @@ Here is an example of what your `docker-compose.yml` will look like when using a
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
# These ports are in format <host-port>:<container-port>
|
# These ports are in format <host-port>:<container-port>
|
||||||
@@ -88,31 +85,29 @@ services:
|
|||||||
- db
|
- db
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: 'jc21/mariadb-aria:latest'
|
image: 'linuxserver/mariadb'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: 'npm'
|
MYSQL_ROOT_PASSWORD: 'npm'
|
||||||
MYSQL_DATABASE: 'npm'
|
MYSQL_DATABASE: 'npm'
|
||||||
MYSQL_USER: 'npm'
|
MYSQL_USER: 'npm'
|
||||||
MYSQL_PASSWORD: 'npm'
|
MYSQL_PASSWORD: 'npm'
|
||||||
MARIADB_AUTO_UPGRADE: '1'
|
TZ: 'Australia/Brisbane'
|
||||||
volumes:
|
volumes:
|
||||||
- ./mysql:/var/lib/mysql
|
- ./mariadb:/config
|
||||||
```
|
```
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
|
|
||||||
Please note, that `DB_MYSQL_*` environment variables will take precedent over `DB_SQLITE_*` variables. So if you keep the MySQL variables, you will not be able to use SQLite.
|
Please note, that `DB_MYSQL_*` environment variables will take precedent over `DB_SQLITE_*` variables. So if you keep the MySQL variables, you will not be able to use SQLite.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Optional: MySQL / MariaDB SSL
|
### Optional: MySQL / MariaDB SSL
|
||||||
|
|
||||||
You can enable TLS for the MySQL/MariaDB connection with these environment variables:
|
You can enable TLS for the MySQL/MariaDB connection with these environment variables:
|
||||||
|
|
||||||
- DB_MYSQL_SSL: Enable SSL when set to true. If unset or false, SSL disabled (previous default behaviour).
|
- `DB_MYSQL_SSL`: Enable SSL when set to true. If unset or false, SSL disabled (previous default behaviour).
|
||||||
- DB_MYSQL_SSL_REJECT_UNAUTHORIZED: (default: true) Validate the server certificate chain. Set to false to allow self‑signed/unknown CA.
|
- `DB_MYSQL_SSL_REJECT_UNAUTHORIZED`: (default: true) Validate the server certificate chain. Set to false to allow self‑signed/unknown CA.
|
||||||
- DB_MYSQL_SSL_VERIFY_IDENTITY: (default: true) Performs host name / identity verification.
|
- `DB_MYSQL_SSL_VERIFY_IDENTITY`: (default: true) Performs host name / identity verification.
|
||||||
|
|
||||||
Enabling SSL using a self-signed cert (not recommended for production).
|
Enabling SSL using a self-signed cert (not recommended for production).
|
||||||
|
|
||||||
@@ -123,7 +118,7 @@ Similar to the MySQL server setup:
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: 'jc21/nginx-proxy-manager:latest'
|
image: 'jc21/nginx-proxy-manager:{{VERSION}}'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
# These ports are in format <host-port>:<container-port>
|
# These ports are in format <host-port>:<container-port>
|
||||||
@@ -169,7 +164,11 @@ Custom Postgres schema is not supported, as such `public` will be used.
|
|||||||
The docker images support the following architectures:
|
The docker images support the following architectures:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- armv7
|
|
||||||
|
::: warning
|
||||||
|
`armv7` is no longer supported in version 2.14+. This is due to Nodejs dropping support for armhf. Please
|
||||||
|
use the `2.13.7` image tag if this applies to you.
|
||||||
|
:::
|
||||||
|
|
||||||
The docker images are a manifest of all the architecture docker builds supported, so this means
|
The docker images are a manifest of all the architecture docker builds supported, so this means
|
||||||
you don't have to worry about doing anything special and you can follow the common instructions above.
|
you don't have to worry about doing anything special and you can follow the common instructions above.
|
||||||
@@ -181,8 +180,6 @@ for a list of supported architectures and if you want one that doesn't exist,
|
|||||||
Also, if you don't know how to already, follow [this guide to install docker and docker-compose](https://manre-universe.net/how-to-run-docker-and-docker-compose-on-raspbian/)
|
Also, if you don't know how to already, follow [this guide to install docker and docker-compose](https://manre-universe.net/how-to-run-docker-and-docker-compose-on-raspbian/)
|
||||||
on Raspbian.
|
on Raspbian.
|
||||||
|
|
||||||
Please note that the `jc21/mariadb-aria:latest` image might have some problems on some ARM devices, if you want a separate database container, use the `yobasystems/alpine-mariadb:latest` image.
|
|
||||||
|
|
||||||
## Initial Run
|
## Initial Run
|
||||||
|
|
||||||
After the app is running for the first time, the following will happen:
|
After the app is running for the first time, the following will happen:
|
||||||
|
|||||||
1210
docs/yarn.lock
1210
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -7,22 +7,30 @@
|
|||||||
// - Also checks the error messages returned by the backend
|
// - Also checks the error messages returned by the backend
|
||||||
|
|
||||||
const allLocales = [
|
const allLocales = [
|
||||||
["en", "en-US"],
|
["en", "en-US"],
|
||||||
["de", "de-DE"],
|
["de", "de-DE"],
|
||||||
["es", "es-ES"],
|
["pt", "pt-PT"],
|
||||||
["it", "it-IT"],
|
["es", "es-ES"],
|
||||||
["ja", "ja-JP"],
|
["et", "et-EE"],
|
||||||
["nl", "nl-NL"],
|
["fr", "fr-FR"],
|
||||||
["pl", "pl-PL"],
|
["it", "it-IT"],
|
||||||
["ru", "ru-RU"],
|
["ja", "ja-JP"],
|
||||||
["sk", "sk-SK"],
|
["nl", "nl-NL"],
|
||||||
["vi", "vi-VN"],
|
["pl", "pl-PL"],
|
||||||
["zh", "zh-CN"],
|
["ru", "ru-RU"],
|
||||||
|
["sk", "sk-SK"],
|
||||||
|
["cs", "cs-CZ"],
|
||||||
|
["vi", "vi-VN"],
|
||||||
|
["zh", "zh-CN"],
|
||||||
|
["ko", "ko-KR"],
|
||||||
|
["bg", "bg-BG"],
|
||||||
|
["id", "id-ID"],
|
||||||
|
["tr", "tr-TR"],
|
||||||
|
["hu", "hu-HU"],
|
||||||
|
["no", "no-NO"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const ignoreUnused = [
|
const ignoreUnused = [/^.*$/];
|
||||||
/^.*$/,
|
|
||||||
];
|
|
||||||
|
|
||||||
const { spawnSync } = require("child_process");
|
const { spawnSync } = require("child_process");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -63,105 +71,95 @@ const allWarnings = [];
|
|||||||
const allKeys = [];
|
const allKeys = [];
|
||||||
|
|
||||||
const checkLangList = (fullCode) => {
|
const checkLangList = (fullCode) => {
|
||||||
const key = "locale-" + fullCode;
|
const key = "locale-" + fullCode;
|
||||||
if (typeof langList[key] === "undefined") {
|
if (typeof langList[key] === "undefined") {
|
||||||
allErrors.push(
|
allErrors.push("ERROR: `" + key + "` language does not exist in lang-list.json");
|
||||||
"ERROR: `" + key + "` language does not exist in lang-list.json",
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const compareLocale = (locale) => {
|
const compareLocale = (locale) => {
|
||||||
const projectLocaleKeys = Object.keys(allLocalesInProject);
|
const projectLocaleKeys = Object.keys(allLocalesInProject);
|
||||||
// Check that locale contains the items used in the codebase
|
// Check that locale contains the items used in the codebase
|
||||||
projectLocaleKeys.map((key) => {
|
projectLocaleKeys.map((key) => {
|
||||||
if (typeof locale.data[key] === "undefined") {
|
if (typeof locale.data[key] === "undefined") {
|
||||||
allErrors.push(
|
allErrors.push("ERROR: `" + locale[0] + "` does not contain item: `" + key + "`");
|
||||||
"ERROR: `" + locale[0] + "` does not contain item: `" + key + "`",
|
}
|
||||||
);
|
return null;
|
||||||
}
|
});
|
||||||
return null;
|
// Check that locale contains all error.* items
|
||||||
});
|
BACKEND_ERRORS.forEach((key) => {
|
||||||
// Check that locale contains all error.* items
|
if (typeof locale.data[key] === "undefined") {
|
||||||
BACKEND_ERRORS.forEach((key) => {
|
allErrors.push("ERROR: `" + locale[0] + "` does not contain item: `" + key + "`");
|
||||||
if (typeof locale.data[key] === "undefined") {
|
}
|
||||||
allErrors.push(
|
return null;
|
||||||
"ERROR: `" + locale[0] + "` does not contain item: `" + key + "`",
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that locale does not contain items not used in the codebase
|
// Check that locale does not contain items not used in the codebase
|
||||||
const localeKeys = Object.keys(locale.data);
|
const localeKeys = Object.keys(locale.data);
|
||||||
localeKeys.map((key) => {
|
localeKeys.map((key) => {
|
||||||
let ignored = false;
|
let ignored = false;
|
||||||
ignoreUnused.map((regex) => {
|
ignoreUnused.map((regex) => {
|
||||||
if (key.match(regex)) {
|
if (key.match(regex)) {
|
||||||
ignored = true;
|
ignored = true;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ignored && typeof allLocalesInProject[key] === "undefined") {
|
if (!ignored && typeof allLocalesInProject[key] === "undefined") {
|
||||||
// ensure this key doesn't exist in the backend errors either
|
// ensure this key doesn't exist in the backend errors either
|
||||||
if (!BACKEND_ERRORS.includes(key)) {
|
if (!BACKEND_ERRORS.includes(key)) {
|
||||||
allErrors.push(
|
allErrors.push("ERROR: `" + locale[0] + "` contains unused item: `" + key + "`");
|
||||||
"ERROR: `" + locale[0] + "` contains unused item: `" + key + "`",
|
}
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this key to allKeys
|
// Add this key to allKeys
|
||||||
if (allKeys.indexOf(key) === -1) {
|
if (allKeys.indexOf(key) === -1) {
|
||||||
allKeys.push(key);
|
allKeys.push(key);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Checks for any keys missing from this locale, that
|
// Checks for any keys missing from this locale, that
|
||||||
// have been defined in any other locales
|
// have been defined in any other locales
|
||||||
const checkForMissing = (locale) => {
|
const checkForMissing = (locale) => {
|
||||||
allKeys.forEach((key) => {
|
allKeys.forEach((key) => {
|
||||||
if (typeof locale.data[key] === "undefined") {
|
if (typeof locale.data[key] === "undefined") {
|
||||||
allWarnings.push(
|
allWarnings.push("WARN: `" + locale[0] + "` does not contain item: `" + key + "`");
|
||||||
"WARN: `" + locale[0] + "` does not contain item: `" + key + "`",
|
}
|
||||||
);
|
return null;
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local all locale data
|
// Local all locale data
|
||||||
allLocales.map((locale, idx) => {
|
allLocales.map((locale, idx) => {
|
||||||
checkLangList(locale[1]);
|
checkLangList(locale[1]);
|
||||||
allLocales[idx].data = require("./src/locale/src/" + locale[0] + ".json");
|
allLocales[idx].data = require("./src/locale/src/" + locale[0] + ".json");
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify all locale data
|
// Verify all locale data
|
||||||
allLocales.map((locale) => {
|
allLocales.map((locale) => {
|
||||||
compareLocale(locale);
|
compareLocale(locale);
|
||||||
checkForMissing(locale);
|
checkForMissing(locale);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allErrors.length) {
|
if (allErrors.length) {
|
||||||
allErrors.map((err) => {
|
allErrors.map((err) => {
|
||||||
console.log("\x1b[31m%s\x1b[0m", err);
|
console.log("\x1b[31m%s\x1b[0m", err);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (allWarnings.length) {
|
if (allWarnings.length) {
|
||||||
allWarnings.map((err) => {
|
allWarnings.map((err) => {
|
||||||
console.log("\x1b[33m%s\x1b[0m", err);
|
console.log("\x1b[33m%s\x1b[0m", err);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allErrors.length) {
|
if (allErrors.length) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("\x1b[32m%s\x1b[0m", "Locale check passed");
|
console.log("\x1b[32m%s\x1b[0m", "Locale check passed");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Nginx Proxy Manager</title>
|
<title>Nginx Proxy Manager</title>
|
||||||
<meta name="description" content="In The Office Planner" />
|
<meta name="description" content="Manage Nginx hosts with a simple, powerful interface" />
|
||||||
<link rel="preload" href="/images/logo-no-text.svg" as="image" type="image/svg+xml" fetchPriority="high">
|
<link rel="preload" href="/images/logo-no-text.svg" as="image" type="image/svg+xml" fetchPriority="high">
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-icon"
|
rel="apple-touch-icon"
|
||||||
|
|||||||
@@ -17,50 +17,50 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/core": "^1.4.0",
|
"@tabler/core": "^1.4.0",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.38.0",
|
||||||
"@tanstack/react-query": "^5.90.6",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-textarea-code-editor": "^3.1.1",
|
"@uiw/react-textarea-code-editor": "^3.1.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"country-flag-icons": "^1.5.21",
|
"country-flag-icons": "^1.6.15",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"ez-modal-react": "^1.0.5",
|
"ez-modal-react": "^1.0.5",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.9",
|
||||||
"generate-password-browser": "^1.1.0",
|
"generate-password-browser": "^1.1.0",
|
||||||
"humps": "^2.0.1",
|
"humps": "^2.0.1",
|
||||||
"query-string": "^9.3.1",
|
"query-string": "^9.3.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^8.1.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"rooks": "^9.3.0"
|
"rooks": "^9.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.2",
|
"@biomejs/biome": "^2.4.5",
|
||||||
"@formatjs/cli": "^6.7.4",
|
"@formatjs/cli": "^6.13.0",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/country-flag-icons": "^1.2.2",
|
"@types/country-flag-icons": "^1.2.2",
|
||||||
"@types/humps": "^2.0.6",
|
"@types/humps": "^2.0.6",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-table": "^7.7.20",
|
"@types/react-table": "^7.7.20",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"happy-dom": "^20.0.10",
|
"happy-dom": "^20.8.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.8",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"sass": "^1.93.3",
|
"sass": "^1.97.3",
|
||||||
"tmp": "^0.2.5",
|
"tmp": "^0.2.5",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "^7.1.12",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-checker": "^0.11.0",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vitest": "^4.0.6"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ export async function del({ url, params }: DeleteArgs, abortController?: AbortCo
|
|||||||
const method = "DELETE";
|
const method = "DELETE";
|
||||||
const headers = {
|
const headers = {
|
||||||
...buildAuthHeader(),
|
...buildAuthHeader(),
|
||||||
[contentTypeHeader]: "application/json",
|
|
||||||
};
|
};
|
||||||
const signal = abortController?.signal;
|
const signal = abortController?.signal;
|
||||||
const response = await fetch(apiUrl, { method, headers, signal });
|
const response = await fetch(apiUrl, { method, headers, signal });
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import * as api from "./base";
|
import * as api from "./base";
|
||||||
import type { TokenResponse } from "./responseTypes";
|
import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes";
|
||||||
|
|
||||||
export async function getToken(identity: string, secret: string): Promise<TokenResponse> {
|
export type LoginResponse = TokenResponse | TwoFactorChallengeResponse;
|
||||||
|
|
||||||
|
export function isTwoFactorChallenge(response: LoginResponse): response is TwoFactorChallengeResponse {
|
||||||
|
return "requires2fa" in response && response.requires2fa === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToken(identity: string, secret: string): Promise<LoginResponse> {
|
||||||
return await api.post({
|
return await api.post({
|
||||||
url: "/tokens",
|
url: "/tokens",
|
||||||
data: { identity, secret },
|
data: { identity, secret },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verify2FA(challengeToken: string, code: string): Promise<TokenResponse> {
|
||||||
|
return await api.post({
|
||||||
|
url: "/tokens/2fa",
|
||||||
|
data: { challengeToken, code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,3 +60,4 @@ export * from "./updateStream";
|
|||||||
export * from "./updateUser";
|
export * from "./updateUser";
|
||||||
export * from "./uploadCertificate";
|
export * from "./uploadCertificate";
|
||||||
export * from "./validateCertificate";
|
export * from "./validateCertificate";
|
||||||
|
export * from "./twoFactor";
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export interface ProxyHost {
|
|||||||
locations?: ProxyLocation[];
|
locations?: ProxyLocation[];
|
||||||
hstsEnabled: boolean;
|
hstsEnabled: boolean;
|
||||||
hstsSubdomains: boolean;
|
hstsSubdomains: boolean;
|
||||||
|
trustForwardedProto: boolean;
|
||||||
// Expansions:
|
// Expansions:
|
||||||
owner?: User;
|
owner?: User;
|
||||||
accessList?: AccessList;
|
accessList?: AccessList;
|
||||||
|
|||||||
@@ -25,3 +25,22 @@ export interface VersionCheckResponse {
|
|||||||
latest: string | null;
|
latest: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorChallengeResponse {
|
||||||
|
requires2fa: boolean;
|
||||||
|
challengeToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorStatusResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
backupCodesRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorSetupResponse {
|
||||||
|
secret: string;
|
||||||
|
otpauthUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorEnableResponse {
|
||||||
|
backupCodes: string[];
|
||||||
|
}
|
||||||
|
|||||||
37
frontend/src/api/backend/twoFactor.ts
Normal file
37
frontend/src/api/backend/twoFactor.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as api from "./base";
|
||||||
|
import type { TwoFactorEnableResponse, TwoFactorSetupResponse, TwoFactorStatusResponse } from "./responseTypes";
|
||||||
|
|
||||||
|
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
|
||||||
|
return await api.get({
|
||||||
|
url: `/users/${userId}/2fa`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
|
||||||
|
return await api.post({
|
||||||
|
url: `/users/${userId}/2fa`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||||
|
return await api.post({
|
||||||
|
url: `/users/${userId}/2fa/enable`,
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disable2FA(userId: number | "me", code: string): Promise<boolean> {
|
||||||
|
return await api.del({
|
||||||
|
url: `/users/${userId}/2fa`,
|
||||||
|
params: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateBackupCodes(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||||
|
return await api.post({
|
||||||
|
url: `/users/${userId}/2fa/backup-codes`,
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Field, useFormikContext } from "formik";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||||
import type { AccessList } from "src/api/backend";
|
import type { AccessList } from "src/api/backend";
|
||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { useAccessLists } from "src/hooks";
|
import { useAccessLists } from "src/hooks";
|
||||||
import { formatDateTime, intl, T } from "src/locale";
|
import { formatDateTime, intl, T } from "src/locale";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface Props {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
|
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
||||||
const { setFieldValue } = useFormikContext();
|
const { setFieldValue } = useFormikContext();
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
|
|||||||
{
|
{
|
||||||
users: item?.items?.length,
|
users: item?.items?.length,
|
||||||
rules: item?.clients?.length,
|
rules: item?.clients?.length,
|
||||||
date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A",
|
date: item?.createdOn ? formatDateTime(item?.createdOn, locale) : "N/A",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
icon: <IconLock size={14} className="text-lime" />,
|
icon: <IconLock size={14} className="text-lime" />,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) {
|
|||||||
type="number"
|
type="number"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
min={0}
|
min={0}
|
||||||
max={600}
|
max={7200}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IconShield } from "@tabler/icons-react";
|
|||||||
import { Field, useFormikContext } from "formik";
|
import { Field, useFormikContext } from "formik";
|
||||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||||
import type { Certificate } from "src/api/backend";
|
import type { Certificate } from "src/api/backend";
|
||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { useCertificates } from "src/hooks";
|
import { useCertificates } from "src/hooks";
|
||||||
import { formatDateTime, intl, T } from "src/locale";
|
import { formatDateTime, intl, T } from "src/locale";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function SSLCertificateField({
|
|||||||
allowNew,
|
allowNew,
|
||||||
forHttp = true,
|
forHttp = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
const { isLoading, isError, error, data } = useCertificates();
|
const { isLoading, isError, error, data } = useCertificates();
|
||||||
const { values, setFieldValue } = useFormikContext();
|
const { values, setFieldValue } = useFormikContext();
|
||||||
const v: any = values || {};
|
const v: any = values || {};
|
||||||
@@ -75,7 +77,7 @@ export function SSLCertificateField({
|
|||||||
data?.map((cert: Certificate) => ({
|
data?.map((cert: Certificate) => ({
|
||||||
value: cert.id,
|
value: cert.id,
|
||||||
label: cert.niceName,
|
label: cert.niceName,
|
||||||
subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`,
|
subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn, locale) : "N/A" })}`,
|
||||||
icon: <IconShield size={14} className="text-pink" />,
|
icon: <IconShield size={14} className="text-pink" />,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import { T } from "src/locale";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
|
forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
|
||||||
|
forProxyHost?: boolean; // the advanced fields
|
||||||
forceDNSForNew?: boolean;
|
forceDNSForNew?: boolean;
|
||||||
requireDomainNames?: boolean; // used for streams
|
requireDomainNames?: boolean; // used for streams
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
|
export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
|
||||||
const { values, setFieldValue } = useFormikContext();
|
const { values, setFieldValue } = useFormikContext();
|
||||||
const v: any = values || {};
|
const v: any = values || {};
|
||||||
|
|
||||||
const newCertificate = v?.certificateId === "new";
|
const newCertificate = v?.certificateId === "new";
|
||||||
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
|
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
|
||||||
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v;
|
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, trustForwardedProto, meta } = v;
|
||||||
const { dnsChallenge } = meta || {};
|
const { dnsChallenge } = meta || {};
|
||||||
|
|
||||||
if (forceDNSForNew && newCertificate && !dnsChallenge) {
|
if (forceDNSForNew && newCertificate && !dnsChallenge) {
|
||||||
@@ -115,6 +116,34 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getHttpAdvancedOptions = () =>(
|
||||||
|
<div>
|
||||||
|
<details>
|
||||||
|
<summary className="mb-1"><T id="domains.advanced" /></summary>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
<Field name="trustForwardedProto">
|
||||||
|
{({ field }: any) => (
|
||||||
|
<label className="form-check form-switch mt-1">
|
||||||
|
<input
|
||||||
|
className={trustForwardedProto ? toggleEnabled : toggleClasses}
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!trustForwardedProto}
|
||||||
|
onChange={(e) => handleToggleChange(e, field.name)}
|
||||||
|
disabled={!hasCertificate || !sslForced}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
<T id="domains.trust-forwarded-proto" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{forHttp ? getHttpOptions() : null}
|
{forHttp ? getHttpOptions() : null}
|
||||||
@@ -140,6 +169,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
|
|||||||
{dnsChallenge ? <DNSProviderFields showBoundaryBox /> : null}
|
{dnsChallenge ? <DNSProviderFields showBoundaryBox /> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{forProxyHost && forHttp ? getHttpAdvancedOptions() : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
|
import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react";
|
||||||
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
|
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
|
||||||
import { useAuthState } from "src/context";
|
import { useAuthState } from "src/context";
|
||||||
import { useUser } from "src/hooks";
|
import { useUser } from "src/hooks";
|
||||||
import { T } from "src/locale";
|
import { T } from "src/locale";
|
||||||
import { showChangePasswordModal, showUserModal } from "src/modals";
|
import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals";
|
||||||
import styles from "./SiteHeader.module.css";
|
import styles from "./SiteHeader.module.css";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
@@ -108,6 +108,17 @@ export function SiteHeader() {
|
|||||||
<IconLock width={18} />
|
<IconLock width={18} />
|
||||||
<T id="user.change-password" />
|
<T id="user.change-password" />
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="?"
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showTwoFactorModal("me");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconShieldLock width={18} />
|
||||||
|
<T id="user.two-factor" />
|
||||||
|
</a>
|
||||||
<div className="dropdown-divider" />
|
<div className="dropdown-divider" />
|
||||||
<a
|
<a
|
||||||
href="?"
|
href="?"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import { differenceInDays, isPast } from "date-fns";
|
import { differenceInDays, isPast } from "date-fns";
|
||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { formatDateTime, parseDate } from "src/locale";
|
import { formatDateTime, parseDate } from "src/locale";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
highlistNearlyExpired?: boolean;
|
highlistNearlyExpired?: boolean;
|
||||||
}
|
}
|
||||||
export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) {
|
export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
const d = parseDate(value);
|
const d = parseDate(value);
|
||||||
const dateIsPast = d ? isPast(d) : false;
|
const dateIsPast = d ? isPast(d) : false;
|
||||||
const days = d ? differenceInDays(d, new Date()) : 0;
|
const days = d ? differenceInDays(d, new Date()) : 0;
|
||||||
@@ -15,5 +17,5 @@ export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: P
|
|||||||
"text-danger": highlightPast && dateIsPast,
|
"text-danger": highlightPast && dateIsPast,
|
||||||
"text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0,
|
"text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0,
|
||||||
});
|
});
|
||||||
return <span className={cl}>{formatDateTime(value)}</span>;
|
return <span className={cl}>{formatDateTime(value, locale)}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { formatDateTime, T } from "src/locale";
|
import { formatDateTime, T } from "src/locale";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,7 +38,9 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function DomainsFormatter({ domains, createdOn, niceName, provider, color }: Props) {
|
export function DomainsFormatter({ domains, createdOn, niceName, provider, color }: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
const elms: ReactNode[] = [];
|
const elms: ReactNode[] = [];
|
||||||
|
|
||||||
if ((!domains || domains.length === 0) && !niceName) {
|
if ((!domains || domains.length === 0) && !niceName) {
|
||||||
elms.push(
|
elms.push(
|
||||||
<span key="nice-name" className="badge bg-danger-lt me-2">
|
<span key="nice-name" className="badge bg-danger-lt me-2">
|
||||||
@@ -62,7 +65,7 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
|
|||||||
<div className="font-weight-medium">{...elms}</div>
|
<div className="font-weight-medium">{...elms}</div>
|
||||||
{createdOn ? (
|
{createdOn ? (
|
||||||
<div className="text-secondary mt-1">
|
<div className="text-secondary mt-1">
|
||||||
<T id="created-on" data={{ date: formatDateTime(createdOn) }} />
|
<T id="created-on" data={{ date: formatDateTime(createdOn, locale) }} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react";
|
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import type { AuditLog } from "src/api/backend";
|
import type { AuditLog } from "src/api/backend";
|
||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { formatDateTime, T } from "src/locale";
|
import { formatDateTime, T } from "src/locale";
|
||||||
|
|
||||||
const getEventValue = (event: AuditLog) => {
|
const getEventValue = (event: AuditLog) => {
|
||||||
@@ -66,6 +67,7 @@ interface Props {
|
|||||||
row: AuditLog;
|
row: AuditLog;
|
||||||
}
|
}
|
||||||
export function EventFormatter({ row }: Props) {
|
export function EventFormatter({ row }: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
return (
|
return (
|
||||||
<div className="flex-fill">
|
<div className="flex-fill">
|
||||||
<div className="font-weight-medium">
|
<div className="font-weight-medium">
|
||||||
@@ -73,7 +75,7 @@ export function EventFormatter({ row }: Props) {
|
|||||||
<T id={`object.event.${row.action}`} tData={{ object: row.objectType }} />
|
<T id={`object.event.${row.action}`} tData={{ object: row.objectType }} />
|
||||||
— <span className="badge">{getEventValue(row)}</span>
|
— <span className="badge">{getEventValue(row)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-secondary mt-1">{formatDateTime(row.createdOn)}</div>
|
<div className="text-secondary mt-1">{formatDateTime(row.createdOn, locale)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLocaleState } from "src/context";
|
||||||
import { formatDateTime, T } from "src/locale";
|
import { formatDateTime, T } from "src/locale";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,6 +7,7 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
|
export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
|
||||||
|
const { locale } = useLocaleState();
|
||||||
return (
|
return (
|
||||||
<div className="flex-fill">
|
<div className="flex-fill">
|
||||||
<div className="font-weight-medium">
|
<div className="font-weight-medium">
|
||||||
@@ -13,7 +15,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{createdOn ? (
|
{createdOn ? (
|
||||||
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
|
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
|
||||||
<T id={disabled ? "disabled" : "created-on"} data={{ date: formatDateTime(createdOn) }} />
|
<T id={disabled ? "disabled" : "created-on"} data={{ date: formatDateTime(createdOn, locale) }} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||||
import { useIntervalWhen } from "rooks";
|
import { useIntervalWhen } from "rooks";
|
||||||
import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend";
|
import {
|
||||||
|
getToken,
|
||||||
|
isTwoFactorChallenge,
|
||||||
|
loginAsUser,
|
||||||
|
refreshToken,
|
||||||
|
verify2FA,
|
||||||
|
type TokenResponse,
|
||||||
|
} from "src/api/backend";
|
||||||
import AuthStore from "src/modules/AuthStore";
|
import AuthStore from "src/modules/AuthStore";
|
||||||
|
|
||||||
|
// 2FA challenge state
|
||||||
|
export interface TwoFactorChallenge {
|
||||||
|
challengeToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
twoFactorChallenge: TwoFactorChallenge | null;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
verifyTwoFactor: (code: string) => Promise<void>;
|
||||||
|
cancelTwoFactor: () => void;
|
||||||
loginAs: (id: number) => Promise<void>;
|
loginAs: (id: number) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -24,17 +39,35 @@ interface Props {
|
|||||||
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
|
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
|
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
|
||||||
|
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||||
|
|
||||||
const handleTokenUpdate = (response: TokenResponse) => {
|
const handleTokenUpdate = (response: TokenResponse) => {
|
||||||
AuthStore.set(response);
|
AuthStore.set(response);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
|
setTwoFactorChallenge(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = async (identity: string, secret: string) => {
|
const login = async (identity: string, secret: string) => {
|
||||||
const response = await getToken(identity, secret);
|
const response = await getToken(identity, secret);
|
||||||
|
if (isTwoFactorChallenge(response)) {
|
||||||
|
setTwoFactorChallenge({ challengeToken: response.challengeToken });
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleTokenUpdate(response);
|
handleTokenUpdate(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const verifyTwoFactor = async (code: string) => {
|
||||||
|
if (!twoFactorChallenge) {
|
||||||
|
throw new Error("No 2FA challenge pending");
|
||||||
|
}
|
||||||
|
const response = await verify2FA(twoFactorChallenge.challengeToken, code);
|
||||||
|
handleTokenUpdate(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelTwoFactor = () => {
|
||||||
|
setTwoFactorChallenge(null);
|
||||||
|
};
|
||||||
|
|
||||||
const loginAs = async (id: number) => {
|
const loginAs = async (id: number) => {
|
||||||
const response = await loginAsUser(id);
|
const response = await loginAsUser(id);
|
||||||
AuthStore.add(response);
|
AuthStore.add(response);
|
||||||
@@ -69,7 +102,15 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = { authenticated, login, logout, loginAs };
|
const value = {
|
||||||
|
authenticated,
|
||||||
|
twoFactorChallenge,
|
||||||
|
login,
|
||||||
|
verifyTwoFactor,
|
||||||
|
cancelTwoFactor,
|
||||||
|
loginAs,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const fetchProxyHost = (id: number | "new") => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
hstsEnabled: false,
|
hstsEnabled: false,
|
||||||
hstsSubdomains: false,
|
hstsSubdomains: false,
|
||||||
|
trustForwardedProto: false,
|
||||||
} as ProxyHost);
|
} as ProxyHost);
|
||||||
}
|
}
|
||||||
return getProxyHost(id, ["owner"]);
|
return getProxyHost(id, ["owner"]);
|
||||||
|
|||||||
@@ -1,73 +1,98 @@
|
|||||||
import { createIntl, createIntlCache } from "react-intl";
|
import { createIntl, createIntlCache } from "react-intl";
|
||||||
|
import langBg from "./lang/bg.json";
|
||||||
import langDe from "./lang/de.json";
|
import langDe from "./lang/de.json";
|
||||||
|
import langPt from "./lang/pt.json";
|
||||||
import langEn from "./lang/en.json";
|
import langEn from "./lang/en.json";
|
||||||
import langEs from "./lang/es.json";
|
import langEs from "./lang/es.json";
|
||||||
|
import langEt from "./lang/et.json";
|
||||||
|
import langFr from "./lang/fr.json";
|
||||||
|
import langGa from "./lang/ga.json";
|
||||||
|
import langId from "./lang/id.json";
|
||||||
import langIt from "./lang/it.json";
|
import langIt from "./lang/it.json";
|
||||||
import langJa from "./lang/ja.json";
|
import langJa from "./lang/ja.json";
|
||||||
import langList from "./lang/lang-list.json";
|
import langKo from "./lang/ko.json";
|
||||||
import langNl from "./lang/nl.json";
|
import langNl from "./lang/nl.json";
|
||||||
import langPl from "./lang/pl.json";
|
import langPl from "./lang/pl.json";
|
||||||
import langRu from "./lang/ru.json";
|
import langRu from "./lang/ru.json";
|
||||||
import langSk from "./lang/sk.json";
|
import langSk from "./lang/sk.json";
|
||||||
|
import langCs from "./lang/cs.json";
|
||||||
import langVi from "./lang/vi.json";
|
import langVi from "./lang/vi.json";
|
||||||
import langZh from "./lang/zh.json";
|
import langZh from "./lang/zh.json";
|
||||||
|
import langTr from "./lang/tr.json";
|
||||||
|
import langHu from "./lang/hu.json";
|
||||||
|
import langNo from "./lang/no.json";
|
||||||
|
import langList from "./lang/lang-list.json";
|
||||||
|
|
||||||
// first item of each array should be the language code,
|
// first item of each array should be the language code,
|
||||||
// not the country code
|
// not the country code
|
||||||
// Remember when adding to this list, also update check-locales.js script
|
// Remember when adding to this list, also update check-locales.js script
|
||||||
const localeOptions = [
|
const localeOptions = [
|
||||||
["en", "en-US", langEn],
|
["en", "en-US", langEn],
|
||||||
["de", "de-DE", langDe],
|
["de", "de-DE", langDe],
|
||||||
["es", "es-ES", langEs],
|
["es", "es-ES", langEs],
|
||||||
["ja", "ja-JP", langJa],
|
["et", "et-EE", langEt],
|
||||||
["it", "it-IT", langIt],
|
["pt", "pt-PT", langPt],
|
||||||
["nl", "nl-NL", langNl],
|
["fr", "fr-FR", langFr],
|
||||||
["pl", "pl-PL", langPl],
|
["ga", "ga-IE", langGa],
|
||||||
["ru", "ru-RU", langRu],
|
["ja", "ja-JP", langJa],
|
||||||
["sk", "sk-SK", langSk],
|
["it", "it-IT", langIt],
|
||||||
["vi", "vi-VN", langVi],
|
["nl", "nl-NL", langNl],
|
||||||
["zh", "zh-CN", langZh],
|
["pl", "pl-PL", langPl],
|
||||||
|
["ru", "ru-RU", langRu],
|
||||||
|
["sk", "sk-SK", langSk],
|
||||||
|
["cs", "cs-CZ", langCs],
|
||||||
|
["vi", "vi-VN", langVi],
|
||||||
|
["zh", "zh-CN", langZh],
|
||||||
|
["ko", "ko-KR", langKo],
|
||||||
|
["bg", "bg-BG", langBg],
|
||||||
|
["id", "id-ID", langId],
|
||||||
|
["tr", "tr-TR", langTr],
|
||||||
|
["hu", "hu-HU", langHu],
|
||||||
|
["no", "no-NO", langNo],
|
||||||
];
|
];
|
||||||
|
|
||||||
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
|
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
|
||||||
const thisLocale = (locale || "en").slice(0, 2);
|
const thisLocale = (locale || "en").slice(0, 2);
|
||||||
|
|
||||||
// ensure this lang exists in localeOptions above, otherwise fallback to en
|
// ensure this lang exists in localeOptions above, otherwise fallback to en
|
||||||
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
|
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
|
||||||
return Object.assign({}, langList, langEn);
|
return Object.assign({}, langList, langEn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
|
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFlagCodeForLocale = (locale?: string) => {
|
const getFlagCodeForLocale = (locale?: string) => {
|
||||||
const thisLocale = (locale || "en").slice(0, 2);
|
const thisLocale = (locale || "en").slice(0, 2);
|
||||||
|
|
||||||
// only add to this if your flag is different from the locale code
|
// only add to this if your flag is different from the locale code
|
||||||
const specialCases: Record<string, string> = {
|
const specialCases: Record<string, string> = {
|
||||||
ja: "jp", // Japan
|
ja: "jp", // Japan
|
||||||
zh: "cn", // China
|
zh: "cn", // China
|
||||||
};
|
vi: "vn", // Vietnam
|
||||||
|
ko: "kr", // Korea
|
||||||
|
cs: "cz", // Czechia
|
||||||
|
};
|
||||||
|
|
||||||
if (specialCases[thisLocale]) {
|
if (specialCases[thisLocale]) {
|
||||||
return specialCases[thisLocale].toUpperCase();
|
return specialCases[thisLocale].toUpperCase();
|
||||||
}
|
}
|
||||||
return thisLocale.toUpperCase();
|
return thisLocale.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLocale = (short = false) => {
|
const getLocale = (short = false) => {
|
||||||
let loc = window.localStorage.getItem("locale");
|
let loc = window.localStorage.getItem("locale");
|
||||||
if (!loc) {
|
if (!loc) {
|
||||||
loc = document.documentElement.lang;
|
loc = document.documentElement.lang;
|
||||||
}
|
}
|
||||||
if (short) {
|
if (short) {
|
||||||
return loc.slice(0, 2);
|
return loc.slice(0, 2);
|
||||||
}
|
}
|
||||||
// finally, fallback
|
// finally, fallback
|
||||||
if (!loc) {
|
if (!loc) {
|
||||||
loc = "en";
|
loc = "en";
|
||||||
}
|
}
|
||||||
return loc;
|
return loc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = createIntlCache();
|
const cache = createIntlCache();
|
||||||
@@ -76,43 +101,43 @@ const initialMessages = loadMessages(getLocale());
|
|||||||
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
|
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
|
||||||
|
|
||||||
const changeLocale = (locale: string): void => {
|
const changeLocale = (locale: string): void => {
|
||||||
const messages = loadMessages(locale);
|
const messages = loadMessages(locale);
|
||||||
intl = createIntl({ locale, messages }, cache);
|
intl = createIntl({ locale, messages }, cache);
|
||||||
window.localStorage.setItem("locale", locale);
|
window.localStorage.setItem("locale", locale);
|
||||||
document.documentElement.lang = locale;
|
document.documentElement.lang = locale;
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is a translation component that wraps the translation in a span with a data
|
// This is a translation component that wraps the translation in a span with a data
|
||||||
// attribute so devs can inspect the element to see the translation ID
|
// attribute so devs can inspect the element to see the translation ID
|
||||||
const T = ({
|
const T = ({
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
tData,
|
tData,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
data?: Record<string, string | number | undefined>;
|
data?: Record<string, string | number | undefined>;
|
||||||
tData?: Record<string, string>;
|
tData?: Record<string, string>;
|
||||||
}) => {
|
}) => {
|
||||||
const translatedData: Record<string, string> = {};
|
const translatedData: Record<string, string> = {};
|
||||||
if (tData) {
|
if (tData) {
|
||||||
// iterate over tData and translate each value
|
// iterate over tData and translate each value
|
||||||
Object.entries(tData).forEach(([key, value]) => {
|
Object.entries(tData).forEach(([key, value]) => {
|
||||||
translatedData[key] = intl.formatMessage({ id: value });
|
translatedData[key] = intl.formatMessage({ id: value });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span data-translation-id={id}>
|
<span data-translation-id={id}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id },
|
{ id },
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
...translatedData,
|
...translatedData,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("L:", localeOptions);
|
//console.log("L:", localeOptions);
|
||||||
|
|
||||||
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
|
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ not be complete by the time you're reading this:
|
|||||||
- frontend/src/locale/src/[yourlang].json
|
- frontend/src/locale/src/[yourlang].json
|
||||||
- frontend/src/locale/src/lang-list.json
|
- frontend/src/locale/src/lang-list.json
|
||||||
- frontend/src/locale/src/HelpDoc/[yourlang]/*
|
- frontend/src/locale/src/HelpDoc/[yourlang]/*
|
||||||
|
- frontend/src/locale/src/HelpDoc/index.tsx
|
||||||
- frontend/src/locale/IntlProvider.tsx
|
- frontend/src/locale/IntlProvider.tsx
|
||||||
- frontend/check-locales.cjs
|
- frontend/check-locales.cjs
|
||||||
|
|
||||||
|
|||||||
@@ -39,19 +39,19 @@ describe("DateFormatter", () => {
|
|||||||
it("format date from iso date", () => {
|
it("format date from iso date", () => {
|
||||||
const value = "2024-01-01T00:00:00.000Z";
|
const value = "2024-01-01T00:00:00.000Z";
|
||||||
const text = formatDateTime(value);
|
const text = formatDateTime(value);
|
||||||
expect(text).toBe("Monday, 01/01/2024, 12:00:00 am");
|
expect(text).toBe("1 Jan 2024, 12:00:00 am");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("format date from unix timestamp number", () => {
|
it("format date from unix timestamp number", () => {
|
||||||
const value = 1762476112;
|
const value = 1762476112;
|
||||||
const text = formatDateTime(value);
|
const text = formatDateTime(value);
|
||||||
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
|
expect(text).toBe("7 Nov 2025, 12:41:52 am");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("format date from unix timestamp string", () => {
|
it("format date from unix timestamp string", () => {
|
||||||
const value = "1762476112";
|
const value = "1762476112";
|
||||||
const text = formatDateTime(value);
|
const text = formatDateTime(value);
|
||||||
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
|
expect(text).toBe("7 Nov 2025, 12:41:52 am");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("catch bad format from string", () => {
|
it("catch bad format from string", () => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { fromUnixTime, intlFormat, parseISO } from "date-fns";
|
import {
|
||||||
|
fromUnixTime,
|
||||||
|
type IntlFormatFormatOptions,
|
||||||
|
intlFormat,
|
||||||
|
parseISO,
|
||||||
|
} from "date-fns";
|
||||||
|
|
||||||
const isUnixTimestamp = (value: unknown): boolean => {
|
const isUnixTimestamp = (value: unknown): boolean => {
|
||||||
if (typeof value !== "number" && typeof value !== "string") return false;
|
if (typeof value !== "number" && typeof value !== "string") return false;
|
||||||
@@ -20,20 +25,19 @@ const parseDate = (value: string | number): Date | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (value: string | number): string => {
|
const formatDateTime = (value: string | number, locale = "en-US"): string => {
|
||||||
const d = parseDate(value);
|
const d = parseDate(value);
|
||||||
if (!d) return `${value}`;
|
if (!d) return `${value}`;
|
||||||
try {
|
try {
|
||||||
return intlFormat(d, {
|
return intlFormat(
|
||||||
weekday: "long",
|
d,
|
||||||
year: "numeric",
|
{
|
||||||
month: "numeric",
|
dateStyle: "medium",
|
||||||
day: "numeric",
|
timeStyle: "medium",
|
||||||
hour: "numeric",
|
hourCycle: "h12",
|
||||||
minute: "numeric",
|
} as IntlFormatFormatOptions,
|
||||||
second: "numeric",
|
{ locale },
|
||||||
hour12: true,
|
);
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
return `${value}`;
|
return `${value}`;
|
||||||
}
|
}
|
||||||
|
|||||||
69
frontend/src/locale/scripts/locale-sort.cjs
Normal file
69
frontend/src/locale/scripts/locale-sort.cjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DIR = path.resolve(__dirname, "../src");
|
||||||
|
|
||||||
|
// Function to sort object keys recursively
|
||||||
|
function sortKeys(obj) {
|
||||||
|
if (obj === null || typeof obj !== "object" || obj instanceof Array) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = {};
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (typeof value === "object" && value !== null && !(value instanceof Array)) {
|
||||||
|
sorted[key] = sortKeys(value);
|
||||||
|
} else {
|
||||||
|
sorted[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all JSON files in the directory
|
||||||
|
const files = fs.readdirSync(DIR).filter((file) => {
|
||||||
|
return file.endsWith(".json") && file !== "lang-list.json";
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const filePath = path.join(DIR, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.size === 0) {
|
||||||
|
console.log(`Skipping empty file ${file}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read original content
|
||||||
|
const originalContent = fs.readFileSync(filePath, "utf8");
|
||||||
|
const originalJson = JSON.parse(originalContent);
|
||||||
|
|
||||||
|
// Sort keys
|
||||||
|
const sortedJson = sortKeys(originalJson);
|
||||||
|
|
||||||
|
// Convert back to string with tabs
|
||||||
|
const sortedContent = JSON.stringify(sortedJson, null, "\t") + "\n";
|
||||||
|
|
||||||
|
// Compare (normalize whitespace)
|
||||||
|
if (originalContent.trim() === sortedContent.trim()) {
|
||||||
|
console.log(`${file} is already sorted`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sorted content
|
||||||
|
fs.writeFileSync(filePath, sortedContent, "utf8");
|
||||||
|
console.log(`Sorted ${file}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing ${file}:`, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -31,6 +31,6 @@ for file in *.json; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Sorting $file"
|
echo "Sorting $file"
|
||||||
jq --tab --sort-keys . "$file" | sponge "$file"
|
tmp=$(mktemp) && jq --tab --sort-keys . "$file" > "$tmp" && mv "$tmp" "$file"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
7
frontend/src/locale/src/HelpDoc/bg/AccessLists.md
Normal file
7
frontend/src/locale/src/HelpDoc/bg/AccessLists.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Какво представлява Списъкът за достъп?
|
||||||
|
|
||||||
|
Списъците за достъп предоставят черен или бял списък от конкретни клиентски IP адреси, както и удостоверяване за Прокси хостове чрез базова HTTP автентикация.
|
||||||
|
|
||||||
|
Можете да конфигурирате множество клиентски правила, потребителски имена и пароли в един Списък за достъп и след това да го приложите към един или повече _Прокси хостове_.
|
||||||
|
|
||||||
|
Това е най-полезно при препращани уеб услуги, които нямат вградени механизми за удостоверяване, или когато искате да защитите достъпа от неизвестни клиенти.
|
||||||
21
frontend/src/locale/src/HelpDoc/bg/Certificates.md
Normal file
21
frontend/src/locale/src/HelpDoc/bg/Certificates.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## Помощ за сертификати
|
||||||
|
|
||||||
|
### HTTP сертификат
|
||||||
|
|
||||||
|
HTTP валидираният сертификат означава, че сървърите на Let’s Encrypt ще се опитат да достигнат вашите домейни по HTTP (не по HTTPS!) и ако успеят, ще издадат сертификата.
|
||||||
|
|
||||||
|
За този метод трябва да имате създаден _Прокси хост_ за вашия/вашите домейни, който да е достъпен по HTTP и да сочи към тази Nginx инсталация. След като бъде издаден сертификат, можете да промените _Прокси хоста_ така, че да използва сертификата и за HTTPS връзки. Въпреки това, _Прокси хостът_ трябва да остане конфигуриран за достъп по HTTP, за да може сертификатът да се подновява.
|
||||||
|
|
||||||
|
Този процес _не_ поддържа wildcard домейни.
|
||||||
|
|
||||||
|
### DNS сертификат
|
||||||
|
|
||||||
|
DNS валидираният сертификат изисква използването на DNS Provider плъгин. Този DNS Provider ще бъде използван за временно създаване на записи във вашия домейн, след което Let’s Encrypt ще ги провери, за да се увери, че сте собственикът, и при успех ще издаде сертификата.
|
||||||
|
|
||||||
|
Не е необходимо да имате _Прокси хост_, създаден предварително, за да заявите този тип сертификат. Нито е нужно вашият _Прокси хост_ да бъде конфигуриран за достъп по HTTP.
|
||||||
|
|
||||||
|
Този процес _поддържа_ wildcard домейни.
|
||||||
|
|
||||||
|
### Персонализиран сертификат
|
||||||
|
|
||||||
|
Използвайте тази опция, за да качите собствен SSL сертификат, предоставен от ваша сертификатна агенция.
|
||||||
10
frontend/src/locale/src/HelpDoc/bg/DeadHosts.md
Normal file
10
frontend/src/locale/src/HelpDoc/bg/DeadHosts.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Какво представлява 404 хост?
|
||||||
|
|
||||||
|
404 хост е просто конфигурация на хост, който показва страница с грешка 404.
|
||||||
|
|
||||||
|
Това може да е полезно, когато вашият домейн е индексиран в търсачките и искате
|
||||||
|
да предоставите по-приятна страница за грешка или да уведомите индексиращите системи,
|
||||||
|
че страниците на домейна вече не съществуват.
|
||||||
|
|
||||||
|
Допълнително предимство на този хост е възможността да проследявате логовете на заявките
|
||||||
|
към него и да виждате реферерите.
|
||||||
7
frontend/src/locale/src/HelpDoc/bg/ProxyHosts.md
Normal file
7
frontend/src/locale/src/HelpDoc/bg/ProxyHosts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Какво представлява Прокси хост?
|
||||||
|
|
||||||
|
Прокси хост е входна точка за уеб услуга, която искате да препращате.
|
||||||
|
|
||||||
|
Той предоставя възможност за SSL терминaция на услуга, която може да няма вградена поддръжка на SSL.
|
||||||
|
|
||||||
|
Прокси хостовете са най-често използваната функция в Nginx Proxy Manager.
|
||||||
7
frontend/src/locale/src/HelpDoc/bg/RedirectionHosts.md
Normal file
7
frontend/src/locale/src/HelpDoc/bg/RedirectionHosts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Какво представлява Хост за пренасочване?
|
||||||
|
|
||||||
|
Хостът за пренасочване пренасочва заявките от входящия домейн и прехвърля
|
||||||
|
потребителя към друг домейн.
|
||||||
|
|
||||||
|
Най-честата причина за използване на този тип хост е, когато вашият уебсайт
|
||||||
|
промени домейна си, но все още има линкове от търсачки или реферери, които сочат към стария домейн.
|
||||||
6
frontend/src/locale/src/HelpDoc/bg/Streams.md
Normal file
6
frontend/src/locale/src/HelpDoc/bg/Streams.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
## Какво представлява Потокът (Stream)?
|
||||||
|
|
||||||
|
Относително нова функция за Nginx, Потокът позволява препращане на TCP/UDP
|
||||||
|
трафик директно към друг компютър в мрежата.
|
||||||
|
|
||||||
|
Това е полезно, ако хоствате игрови сървъри, FTP или SSH сървъри.
|
||||||
6
frontend/src/locale/src/HelpDoc/bg/index.ts
Normal file
6
frontend/src/locale/src/HelpDoc/bg/index.ts
Normal file
@@ -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";
|
||||||
7
frontend/src/locale/src/HelpDoc/cs/AccessLists.md
Normal file
7
frontend/src/locale/src/HelpDoc/cs/AccessLists.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Co je seznam přístupů?
|
||||||
|
|
||||||
|
Seznamy přístupů poskytují blacklist nebo whitelist konkrétních IP adres klientů spolu s ověřením pro proxy hostitele prostřednictvím základního ověřování HTTP.
|
||||||
|
|
||||||
|
Můžete nakonfigurovat více pravidel pro klienty, uživatelská jména a hesla pro jeden seznam přístupu a poté ho použít na jednoho nebo více proxy hostitelů.
|
||||||
|
|
||||||
|
Toto je nejužitečnější pro přesměrované webové služby, které nemají vestavěné ověřovací mechanismy, nebo pokud se chcete chránit před neznámými klienty.
|
||||||
32
frontend/src/locale/src/HelpDoc/cs/Certificates.md
Normal file
32
frontend/src/locale/src/HelpDoc/cs/Certificates.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
## Pomoc s certifikáty
|
||||||
|
|
||||||
|
### Certifikát HTTP
|
||||||
|
|
||||||
|
Certifikát ověřený prostřednictvím protokolu HTTP znamená, že servery Let's Encrypt se
|
||||||
|
pokusí připojit k vašim doménám přes protokol HTTP (nikoli HTTPS!) a v případě úspěchu
|
||||||
|
vydají váš certifikát.
|
||||||
|
|
||||||
|
Pro tuto metodu budete muset mít pro své domény vytvořeného _Proxy Host_, který
|
||||||
|
je přístupný přes HTTP a směruje na tuto instalaci Nginx. Po vydání certifikátu
|
||||||
|
můžete změnit _Proxy Host_ tak, aby tento certifikát používal i pro HTTPS
|
||||||
|
připojení. _Proxy Host_ však bude stále potřeba nakonfigurovat pro přístup přes HTTP,
|
||||||
|
aby se certifikát mohl obnovit.
|
||||||
|
|
||||||
|
Tento proces _nepodporuje_ domény se zástupnými znaky.
|
||||||
|
|
||||||
|
### Certifikát DNS
|
||||||
|
|
||||||
|
Certifikát ověřený DNS vyžaduje použití pluginu DNS Provider. Tento DNS
|
||||||
|
Provider se použije na vytvoření dočasných záznamů ve vaší doméně a poté Let's
|
||||||
|
Encrypt ověří tyto záznamy, aby se ujistil, že jste vlastníkem, a pokud bude úspěšný,
|
||||||
|
vydá váš certifikát.
|
||||||
|
|
||||||
|
Před požádáním o tento typ certifikátu není potřeba vytvořit _Proxy Host_.
|
||||||
|
Není také potřeba mít _Proxy Host_ nakonfigurovaný pro přístup HTTP.
|
||||||
|
|
||||||
|
Tento proces _podporuje_ domény se zástupnými znaky.
|
||||||
|
|
||||||
|
### Vlastní certifikát
|
||||||
|
|
||||||
|
Tuto možnost použijte na nahrání vlastního SSL certifikátu, který vám poskytla vaše
|
||||||
|
certifikační autorita.
|
||||||
10
frontend/src/locale/src/HelpDoc/cs/DeadHosts.md
Normal file
10
frontend/src/locale/src/HelpDoc/cs/DeadHosts.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Co je to 404 Host?
|
||||||
|
|
||||||
|
404 Host je jednoduše nastavení hostitele, které zobrazuje stránku 404.
|
||||||
|
|
||||||
|
To může být užitečné, pokud je vaše doména uvedena ve vyhledávačích a chcete
|
||||||
|
poskytnout hezčí chybovou stránku nebo konkrétně oznámit vyhledávačům, že
|
||||||
|
stránky domény již neexistují.
|
||||||
|
|
||||||
|
Další výhodou tohoto hostitele je sledování protokolů o návštěvách a
|
||||||
|
zobrazení odkazů.
|
||||||
7
frontend/src/locale/src/HelpDoc/cs/ProxyHosts.md
Normal file
7
frontend/src/locale/src/HelpDoc/cs/ProxyHosts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Co je proxy hostitel?
|
||||||
|
|
||||||
|
Proxy hostitel je příchozí koncový bod pro webovou službu, kterou chcete přesměrovat.
|
||||||
|
|
||||||
|
Poskytuje volitelné ukončení SSL pro vaši službu, která nemusí mít zabudovanou podporu SSL.
|
||||||
|
|
||||||
|
Proxy hostitelé jsou nejběžnějším použitím pro Nginx Proxy Manager.
|
||||||
7
frontend/src/locale/src/HelpDoc/cs/RedirectionHosts.md
Normal file
7
frontend/src/locale/src/HelpDoc/cs/RedirectionHosts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Co je přesměrovací hostitel?
|
||||||
|
|
||||||
|
Přesměrovací hostitel přesměruje požadavky z příchozí domény a přesměruje
|
||||||
|
návštěvníka na jinou doménu.
|
||||||
|
|
||||||
|
Nejčastějším důvodem pro použití tohoto typu hostitele je situace, kdy vaše webová stránka změní
|
||||||
|
doménu, ale stále máte odkazy ve vyhledávačích nebo referenční odkazy směřující na starou doménu.
|
||||||
6
frontend/src/locale/src/HelpDoc/cs/Streams.md
Normal file
6
frontend/src/locale/src/HelpDoc/cs/Streams.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
## Co je stream?
|
||||||
|
|
||||||
|
Stream je relativně nová funkce pro Nginx, která slouží na přesměrování TCP/UDP
|
||||||
|
datového toku přímo do jiného počítače v síti.
|
||||||
|
|
||||||
|
Pokud provozujete herní servery, FTP nebo SSH servery, tato funkce se vám může hodit.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user