Compare commits

..

1 Commits

Author SHA1 Message Date
Crowdin Bot
d4e732ef8e New Crowdin translations by GitHub Action
Some checks are pending
Lint / Linting Checks (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
2026-04-01 12:32:20 +00:00
39 changed files with 191 additions and 897 deletions

View File

@@ -63,14 +63,65 @@ For configuration options, examples and more, [please check out the homepage doc
## Security Notice 🔒 ## Security Notice 🔒
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it: Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly.
1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. ## With Docker
2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard.
## Installation Using docker compose:
See the [Installation](https://gethomepage.dev/installation/) section of the docs for instructions on installing Homepage via Docker, Kubernetes, Unraid, or from source. ```yaml
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: 1000 # optional, your user id
PGID: 1000 # optional, your group id
ports:
- 3000:3000
volumes:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations
restart: unless-stopped
```
or docker run:
```bash
docker run --name homepage \
-e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \
-e PUID=1000 \
-e PGID=1000 \
-p 3000:3000 \
-v /path/to/config:/app/config \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--restart unless-stopped \
ghcr.io/gethomepage/homepage:latest
```
## From Source
First, clone the repository:
```bash
git clone https://github.com/gethomepage/homepage.git
```
Then install dependencies and build the production bundle:
```bash
pnpm install
pnpm build
```
If this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.
Finally, run the server in production mode:
```bash
pnpm start
```
# Configuration # Configuration

View File

@@ -129,7 +129,7 @@ A progressive web app is an app that can be installed on a device and provide us
More information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps). More information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).
### App icons ## App icons
You can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons). You can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons).
@@ -150,7 +150,7 @@ For icon `src` you can pass either full URL or a local path relative to the `/ap
### Shortcuts ### Shortcuts
Shortcuts can be used to specify links to tabs, to be preselected when the homepage is opened as an app. Shortcuts can e used to specify links to tabs, to be preselected when the homepage is opened as an app.
More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts). More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts).
```yaml ```yaml

View File

@@ -15,6 +15,8 @@ services:
volumes: volumes:
- /path/to/config:/app/config # Make sure your local config directory exists - /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations - /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
``` ```
### Running as non-root ### Running as non-root
@@ -36,6 +38,7 @@ services:
- /path/to/config:/app/config # Make sure your local config directory exists - /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods - /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods
environment: environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: $PUID PUID: $PUID
PGID: $PGID PGID: $PGID
``` ```
@@ -43,7 +46,7 @@ services:
### With Docker Run ### With Docker Run
```bash ```bash
docker run -p 3000:3000 -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest docker run -p 3000:3000 -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
``` ```
### Using Environment Secrets ### Using Environment Secrets

View File

@@ -27,25 +27,14 @@ You have a few options for deploying homepage, depending on your needs. We offer
</div> </div>
### Security & Authentication ### `HOMEPAGE_ALLOWED_HOSTS`
Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided. As of v1.0 there is one required environment variable to access homepage via a URL other than `localhost`, <code>HOMEPAGE_ALLOWED_HOSTS</code>. The setting helps prevent certain kinds of attacks when retrieving data from the homepage API proxy.
Required environment variables for authentication: The value is a comma-separated (no spaces) list of allowed hosts (sometimes with the port) that can host your homepage install. See the [docker](docker.md), [kubernetes](k8s.md) and [source](source.md) installation pages for more information about where / how to set the variable.
- `HOMEPAGE_AUTH_ENABLED=true` `localhost:3000` and `127.0.0.1:3000` are always included, but you can add a domain or IP address to this list to allow that host such as `HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev,192.168.1.2:1234`, etc.
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
For password-only login: If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list.
- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided) This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections.
For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
- `HOMEPAGE_OIDC_CLIENT_ID`
- `HOMEPAGE_OIDC_CLIENT_SECRET`
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
All app pages and `/api` routes will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only.

View File

@@ -238,6 +238,8 @@ spec:
valueFrom: valueFrom:
fieldRef: fieldRef:
fieldPath: status.podIP fieldPath: status.podIP
- name: HOMEPAGE_ALLOWED_HOSTS
value: "$(MY_POD_IP):3000,gethomepage.dev" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe
ports: ports:
- name: http - name: http
containerPort: 3000 containerPort: 3000

View File

@@ -27,7 +27,9 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi
Finally, run the server: Finally, run the server:
```bash ```bash
pnpm start HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start
``` ```
When updating homepage versions you will need to re-build the static files i.e. repeat the process above. When updating homepage versions you will need to re-build the static files i.e. repeat the process above.
See [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable.

View File

@@ -16,7 +16,6 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cpu: true # optional, enabled by default, disable by setting to false cpu: true # optional, enabled by default, disable by setting to false
mem: true # optional, enabled by default, disable by setting to false mem: true # optional, enabled by default, disable by setting to false
cputemp: true # disabled by default cputemp: true # disabled by default
unit: imperial # optional for temp, default is metric
uptime: true # disabled by default uptime: true # disabled by default
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below) disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
@@ -32,3 +31,5 @@ disk:
- /boot - /boot
... ...
``` ```
_Added in v0.4.18, updated in v0.6.11, v0.6.21_

View File

@@ -13,7 +13,7 @@ You can display general connectivity status from your Unifi (Network) Controller
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller. An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
!!! tip !!! hint
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container to clear the cache. If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container to clear the cache.

View File

@@ -17,7 +17,7 @@ An optional 'site' parameter can be supplied, if it is not the widget will use t
Allowed fields: `["uptime", "wan", "lan", "lan_users", "lan_devices", "wlan", "wlan_users", "wlan_devices"]` (maximum of four). Fields unsupported by the unifi device will not be shown. Allowed fields: `["uptime", "wan", "lan", "lan_users", "lan_devices", "wlan", "wlan_users", "wlan_devices"]` (maximum of four). Fields unsupported by the unifi device will not be shown.
!!! tip !!! hint
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache. If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.

View File

@@ -19,6 +19,6 @@ widget:
password: your_password password: your_password
``` ```
!!! tip !!! hint
If you enter incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache. If you enter incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.12.3", "version": "1.10.1",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -29,7 +29,6 @@
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2", "minecraftstatuspinger": "^1.2.2",
"next": "^16.1.7", "next": "^16.1.7",
"next-auth": "^4.24.10",
"next-i18next": "^15.4.3", "next-i18next": "^15.4.3",
"ping": "^0.4.4", "ping": "^0.4.4",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",

114
pnpm-lock.yaml generated
View File

@@ -53,9 +53,6 @@ importers:
next: next:
specifier: ^16.1.7 specifier: ^16.1.7
version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth:
specifier: ^4.24.10
version: 4.24.13(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-i18next: next-i18next:
specifier: ^15.4.3 specifier: ^15.4.3
version: 15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4) version: 15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4)
@@ -832,9 +829,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1725,10 +1719,6 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
core-js@3.48.0: core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
@@ -2604,9 +2594,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@5.10.0: jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
@@ -2783,10 +2770,6 @@ packages:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
luxon@3.6.1: luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2893,20 +2876,6 @@ packages:
net@1.0.2: net@1.0.2:
resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==} resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==}
next-auth@4.24.13:
resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==}
peerDependencies:
'@auth/core': 0.34.3
next: ^12.2.5 || ^13 || ^14 || ^15 || ^16
nodemailer: ^7.0.7
react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19
peerDependenciesMeta:
'@auth/core':
optional: true
nodemailer:
optional: true
next-i18next@15.4.3: next-i18next@15.4.3:
resolution: {integrity: sha512-ZRmiz72o1Jvh2ZghCUQX1Ua5F/f2W1/Ila/L1ZeKVuSWiH7J4zfUedfDxNBEhj9lajREC7aoJuPXMFtKi2bdIg==} resolution: {integrity: sha512-ZRmiz72o1Jvh2ZghCUQX1Ua5F/f2W1/Ila/L1ZeKVuSWiH7J4zfUedfDxNBEhj9lajREC7aoJuPXMFtKi2bdIg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2953,17 +2922,10 @@ packages:
oauth4webapi@3.3.0: oauth4webapi@3.3.0:
resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==} resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==}
oauth@0.9.15:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-inspect@1.13.4: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2992,19 +2954,12 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
oidc-token-hash@5.2.0:
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
engines: {node: ^10.13.0 || >=12.0.0}
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0: one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
openid-client@6.3.0: openid-client@6.3.0:
resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==} resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==}
@@ -3092,14 +3047,6 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
preact@10.28.2:
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -3131,9 +3078,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
prism-react-renderer@2.4.1: prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies: peerDependencies:
@@ -3739,10 +3683,6 @@ packages:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
varint@6.0.0: varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
@@ -3936,9 +3876,6 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0: yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4478,8 +4415,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -5352,8 +5287,6 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
cookie@0.7.2: {}
core-js@3.48.0: {} core-js@3.48.0: {}
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
@@ -6453,8 +6386,6 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jose@4.15.9: {}
jose@5.10.0: {} jose@5.10.0: {}
js-tokens@10.0.0: {} js-tokens@10.0.0: {}
@@ -6618,10 +6549,6 @@ snapshots:
lru-cache@11.2.6: {} lru-cache@11.2.6: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
luxon@3.6.1: {} luxon@3.6.1: {}
lz-string@1.5.0: {} lz-string@1.5.0: {}
@@ -6700,21 +6627,6 @@ snapshots:
net@1.0.2: {} net@1.0.2: {}
next-auth@4.24.13(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
preact-render-to-string: 5.2.6(preact@10.28.2)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
uuid: 8.3.2
next-i18next@15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4): next-i18next@15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4):
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -6761,12 +6673,8 @@ snapshots:
oauth4webapi@3.3.0: {} oauth4webapi@3.3.0: {}
oauth@0.9.15: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@2.2.0: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
object-keys@1.1.1: {} object-keys@1.1.1: {}
@@ -6806,8 +6714,6 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
oidc-token-hash@5.2.0: {}
once@1.4.0: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
@@ -6816,13 +6722,6 @@ snapshots:
dependencies: dependencies:
fn.name: 1.1.0 fn.name: 1.1.0
openid-client@5.7.1:
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.2.0
openid-client@6.3.0: openid-client@6.3.0:
dependencies: dependencies:
jose: 5.10.0 jose: 5.10.0
@@ -6903,13 +6802,6 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
preact-render-to-string@5.2.6(preact@10.28.2):
dependencies:
preact: 10.28.2
pretty-format: 3.8.0
preact@10.28.2: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1: prettier-linter-helpers@1.0.1:
@@ -6931,8 +6823,6 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 17.0.2 react-is: 17.0.2
pretty-format@3.8.0: {}
prism-react-renderer@2.4.1(react@19.2.4): prism-react-renderer@2.4.1(react@19.2.4):
dependencies: dependencies:
'@types/prismjs': 1.26.5 '@types/prismjs': 1.26.5
@@ -7661,8 +7551,6 @@ snapshots:
uuid@10.0.0: {} uuid@10.0.0: {}
uuid@8.3.2: {}
varint@6.0.0: {} varint@6.0.0: {}
victory-vendor@37.3.6: victory-vendor@37.3.6:
@@ -7898,8 +7786,6 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@4.0.0: {}
yallist@5.0.0: {} yallist@5.0.0: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}

View File

@@ -67,9 +67,9 @@
"empty_data": "Status do Subsistema desconhecido" "empty_data": "Status do Subsistema desconhecido"
}, },
"unifi_drive": { "unifi_drive": {
"healthy": "Healthy", "healthy": "Saudável",
"degraded": "Degraded", "degraded": "Degradado",
"no_data": "No storage data available" "no_data": "Sem dados de armazenamento disponíveis"
}, },
"docker": { "docker": {
"rx": "RX", "rx": "RX",
@@ -114,9 +114,9 @@
}, },
"jellyfin": { "jellyfin": {
"playing": "Jogando", "playing": "Jogando",
"transcoding": "Transcoding", "transcoding": "Transcodificando",
"bitrate": "Bitrate", "bitrate": "Bitrate",
"no_active": "No Active Streams", "no_active": "Sem Transmissões Ativas",
"movies": "Filmes", "movies": "Filmes",
"series": "Séries", "series": "Séries",
"episodes": "Episódios", "episodes": "Episódios",
@@ -190,10 +190,10 @@
"plex_connection_error": "Verifique a conexão do Plex" "plex_connection_error": "Verifique a conexão do Plex"
}, },
"tracearr": { "tracearr": {
"no_active": "No Active Streams", "no_active": "Sem Transmissões Ativas",
"streams": "Streams", "streams": "Transmissões",
"transcodes": "Transcodes", "transcodes": "Transcodificações",
"directplay": "Direct Play", "directplay": "Reprodução direta",
"bitrate": "Bitrate" "bitrate": "Bitrate"
}, },
"omada": { "omada": {
@@ -619,13 +619,13 @@
"total": "Total" "total": "Total"
}, },
"pangolin": { "pangolin": {
"orgs": "Orgs", "orgs": "Organizações",
"sites": "Sites", "sites": "Sites",
"resources": "Recursos", "resources": "Recursos",
"targets": "Targets", "targets": "Alvos",
"traffic": "Traffic", "traffic": "Tráfego",
"in": "In", "in": "Em",
"out": "Out" "out": "Saída"
}, },
"peanut": { "peanut": {
"battery_charge": "Carga da bateria", "battery_charge": "Carga da bateria",
@@ -1164,11 +1164,11 @@
"images": "Imagens", "images": "Imagens",
"image_updates": "Atualizações de Imagem", "image_updates": "Atualizações de Imagem",
"images_unused": "Não utilizado", "images_unused": "Não utilizado",
"environment_required": "Environment ID Required" "environment_required": "ID do ambiente necessário"
}, },
"dockhand": { "dockhand": {
"running": "Executando", "running": "Executando",
"stopped": "Stopped", "stopped": "Parado",
"cpu": "CPU", "cpu": "CPU",
"memory": "Memória", "memory": "Memória",
"images": "Imagens", "images": "Imagens",
@@ -1178,12 +1178,12 @@
"stacks": "Pilhas", "stacks": "Pilhas",
"paused": "Pausado", "paused": "Pausado",
"total": "Total", "total": "Total",
"environment_not_found": "Environment Not Found" "environment_not_found": "Ambiente não encontrado"
}, },
"sparkyfitness": { "sparkyfitness": {
"eaten": "Eaten", "eaten": "Comido",
"burned": "Burned", "burned": "Queimado",
"remaining": "Remaining", "remaining": "Restante",
"steps": "Steps" "steps": "Passos"
} }
} }

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { nextAuthMock } = vi.hoisted(() => ({
nextAuthMock: vi.fn((options) => ({ options })),
}));
vi.mock("next-auth", () => ({
default: nextAuthMock,
}));
describe("pages/api/auth/[...nextauth]", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
nextAuthMock.mockClear();
process.env = { ...originalEnv };
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
it("configures no providers when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(nextAuthMock).toHaveBeenCalledTimes(1);
expect(mod.default.options.providers).toEqual([]);
expect(mod.default.options.pages?.signIn).toBe("/auth/signin");
});
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
expect(mod.default.options.secret).toBe("secret");
});
it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/Password auth is enabled but required settings are missing/i,
);
});
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider.id).toBe("credentials");
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider).toMatchObject({
id: "homepage-oidc",
name: "My OIDC",
type: "oauth",
idToken: true,
issuer: "https://issuer.example",
wellKnown: "https://issuer.example/.well-known/openid-configuration",
clientId: "client-id",
clientSecret: "client-secret",
});
expect(provider.authorization.params.scope).toBe("openid email");
expect(
provider.profile({
sub: "sub",
preferred_username: "user",
email: "user@example.com",
picture: "https://example.com/p.png",
}),
).toEqual({
id: "sub",
name: "user",
email: "user@example.com",
image: "https://example.com/p.png",
});
expect(
provider.profile({
id: "id",
name: "name",
}),
).toEqual({
id: "id",
name: "name",
email: null,
image: null,
});
});
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
});

View File

@@ -92,23 +92,6 @@ describe("pages/api/widgets/glances", () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
it("falls back to version 3 when version is invalid", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))])
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))])
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]);
const req = { query: { index: "0", version: "3/../../secret-endpoint" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://glances/api/3/cpu", expect.any(Object));
expect(res.statusCode).toBe(200);
});
it("returns 400 when glances returns 401", async () => { it("returns 400 when glances returns 401", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" }); getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]); httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);

View File

@@ -1,78 +0,0 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock,
}));
vi.mock("next/router", () => ({
useRouter: () => ({
query: {},
}),
}));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "pages/auth/signin";
describe("pages/auth/signin", () => {
it("renders an error state when no providers are configured", async () => {
render(
<SignInPage
providers={{}}
settings={{
theme: "dark",
color: "slate",
title: "Homepage",
}}
/>,
);
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
expect(document.documentElement.classList.contains("theme-slate")).toBe(true);
});
});
it("renders provider buttons when providers are available", () => {
render(
<SignInPage
providers={{
oidc: { id: "oidc", name: "OIDC" },
}}
settings={{
theme: "light",
color: "emerald",
title: "My Dashboard",
}}
/>,
);
expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
});
it("getServerSideProps returns providers and settings", async () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({
props: {
providers: { foo: { id: "foo", name: "Foo" } },
settings: { theme: "dark" },
},
});
});
});

View File

@@ -1,34 +1,23 @@
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); export function middleware(req) {
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET; // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
let warnedAllowedHosts = false; const host = req.headers.get("host");
const port = process.env.PORT || 3000;
export async function middleware(req) { let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
if (!warnedAllowedHosts && process.env.HOMEPAGE_ALLOWED_HOSTS) { const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === "*";
warnedAllowedHosts = true; if (process.env.HOMEPAGE_ALLOWED_HOSTS) {
console.warn( allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(","));
"HOMEPAGE_ALLOWED_HOSTS is deprecated. To secure a publicly accessible homepage, configure authentication instead.", }
if (!allowAll && (!host || !allowedHosts.includes(host))) {
console.error(
`Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`,
); );
return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 });
} }
if (authEnabled) {
const token = await getToken({ req, secret: authSecret });
if (!token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", "/");
return NextResponse.redirect(signInUrl);
}
}
return NextResponse.next(); return NextResponse.next();
} }
export const config = { export const config = {
// Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints. matcher: "/api/:path*",
matcher: [
"/",
"/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)",
],
}; };

View File

@@ -1,89 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { NextResponse, getToken } = vi.hoisted(() => ({ const { NextResponse } = vi.hoisted(() => ({
NextResponse: { NextResponse: {
json: vi.fn((body, init) => ({ type: "json", body, init })),
next: vi.fn(() => ({ type: "next" })), next: vi.fn(() => ({ type: "next" })),
redirect: vi.fn((url) => ({ type: "redirect", url })),
}, },
getToken: vi.fn(),
})); }));
vi.mock("next/server", () => ({ NextResponse })); vi.mock("next/server", () => ({ NextResponse }));
vi.mock("next-auth/jwt", () => ({ getToken }));
async function loadMiddleware() { import { middleware } from "./middleware";
vi.resetModules();
const mod = await import("./middleware");
return mod.middleware;
}
function createReq(url = "http://localhost:3000/") { function createReq(host) {
return { return {
url,
headers: { headers: {
get: () => null, get: (key) => (key === "host" ? host : null),
}, },
}; };
} }
describe("middleware", () => { describe("middleware", () => {
const originalEnv = process.env; const originalEnv = process.env;
const originalConsoleWarn = console.warn; const originalConsoleError = console.error;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
console.warn = originalConsoleWarn; console.error = originalConsoleError;
}); });
it("allows requests when auth is disabled", async () => { it("allows requests for default localhost hosts", () => {
const middleware = await loadMiddleware(); process.env.PORT = "3000";
const res = await middleware(createReq()); const res = middleware(createReq("localhost:3000"));
expect(NextResponse.next).toHaveBeenCalled(); expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" }); expect(res).toEqual({ type: "next" });
}); });
it("warns once when HOMEPAGE_ALLOWED_HOSTS is set, but does not block", async () => { it("blocks requests when host is not allowed", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.PORT = "3000";
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com"; const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const middleware = await loadMiddleware(); const res = middleware(createReq("evil.com"));
const res1 = await middleware(createReq());
const res2 = await middleware(createReq()); expect(errSpy).toHaveBeenCalled();
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: "Host validation failed. See logs for more details." },
{ status: 400 },
);
expect(res.type).toBe("json");
expect(res.init.status).toBe(400);
});
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => {
process.env.HOMEPAGE_ALLOWED_HOSTS = "*";
const res = middleware(createReq("anything.example"));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(NextResponse.next).toHaveBeenCalled(); expect(NextResponse.next).toHaveBeenCalled();
expect(res1).toEqual({ type: "next" }); expect(res).toEqual({ type: "next" });
expect(res2).toEqual({ type: "next" });
}); });
it("redirects to signin when auth is enabled and no token is present", async () => { it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.PORT = "3000";
process.env.HOMEPAGE_AUTH_SECRET = "secret"; process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000";
getToken.mockResolvedValueOnce(null); const res = middleware(createReq("example.com:3000"));
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/some"));
expect(getToken).toHaveBeenCalledWith({
req: expect.objectContaining({ url: "http://localhost:3000/some" }),
secret: "secret",
});
expect(NextResponse.redirect).toHaveBeenCalled();
expect(res.type).toBe("redirect");
expect(String(res.url)).toContain("/auth/signin");
});
it("allows requests when auth is enabled and a token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce({ sub: "user" });
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/"));
expect(NextResponse.next).toHaveBeenCalled(); expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" }); expect(res).toEqual({ type: "next" });

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable react/jsx-props-no-spreading */
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next"; import { appWithTranslation } from "next-i18next";
import Head from "next/head"; import Head from "next/head";
import "styles/globals.css"; import "styles/globals.css";
@@ -70,30 +69,28 @@ const tailwindSafelist = [
function MyApp({ Component, pageProps }) { function MyApp({ Component, pageProps }) {
return ( return (
<SessionProvider session={pageProps.session}> <SWRConfig
<SWRConfig value={{
value={{ fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()), }}
}} >
> <Head>
<Head> {/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */} <meta
<meta name="viewport"
name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
/> </Head>
</Head> <ColorProvider>
<ColorProvider> <ThemeProvider>
<ThemeProvider> <SettingsProvider>
<SettingsProvider> <TabProvider>
<TabProvider> <Component {...pageProps} />
<Component {...pageProps} /> </TabProvider>
</TabProvider> </SettingsProvider>
</SettingsProvider> </ThemeProvider>
</ThemeProvider> </ColorProvider>
</ColorProvider> </SWRConfig>
</SWRConfig>
</SessionProvider>
); );
} }

View File

@@ -1,114 +0,0 @@
import { timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
// Map HOMEPAGE_* envs to what NextAuth expects
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
}
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
process.env.NEXTAUTH_URL = homepageExternalUrl;
}
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
if (authEnabled) {
if (hasOidcConfig) {
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
throw new Error("OIDC auth is enabled but required settings are missing.");
}
} else if (hasAnyOidcConfig) {
throw new Error("OIDC auth is enabled but required settings are missing.");
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
throw new Error("Password auth is enabled but required settings are missing.");
}
}
let providers = [];
if (authEnabled) {
if (hasOidcConfig) {
providers = [
{
id: "homepage-oidc",
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
type: "oauth",
idToken: true,
issuer: cleanedIssuer,
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
clientId,
clientSecret,
authorization: {
params: {
scope: defaultScope,
},
},
profile(profile) {
return {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
email: profile.email ?? null,
image: profile.picture ?? null,
};
},
},
];
} else {
providers = [
CredentialsProvider({
name: "Password",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const provided = credentials?.password ?? "";
const expected = homepageAuthPassword ?? "";
if (!expected || provided.length !== expected.length) {
return null;
}
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!isMatch) {
return null;
}
return {
id: "homepage",
name: "Homepage",
};
},
}),
];
}
}
export default NextAuth({
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/auth/signin",
},
debug: true,
logger: {
error: (...args) => console.error("[nextauth][error]", ...args),
warn: (...args) => console.warn("[nextauth][warn]", ...args),
debug: (...args) => console.debug("[nextauth][debug]", ...args),
},
events: {
signIn: async (message) => console.debug("[nextauth][event][signIn]", message),
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
error: async (message) => console.error("[nextauth][event][error]", message),
},
});

View File

@@ -1,6 +1,5 @@
import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http"; import { httpProxy } from "utils/proxy/http";
const logger = createLogger("glances"); const logger = createLogger("glances");
@@ -46,7 +45,7 @@ export default async function handler(req, res) {
const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query; const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;
const privateWidgetOptions = await getPrivateWidgetOptions("glances", index); const privateWidgetOptions = await getPrivateWidgetOptions("glances", index);
privateWidgetOptions.version = parseVersionForUrl(version, 3); privateWidgetOptions.version = version ?? 3;
try { try {
const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu"); const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu");

View File

@@ -1,210 +0,0 @@
import classNames from "classnames";
import { getProviders, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi";
import { getSettings } from "utils/config/config";
export default function SignIn({ providers, settings }) {
const router = useRouter();
const [password, setPassword] = useState("");
const theme = settings?.theme || "dark";
const color = settings?.color || "slate";
const title = settings?.title || "Homepage";
const callbackUrl = useMemo(() => {
const value = router.query?.callbackUrl;
return typeof value === "string" ? value : "/";
}, [router.query?.callbackUrl]);
const error = router.query?.error;
let backgroundImage = "";
let opacity = settings?.backgroundOpacity ?? 0;
let backgroundBlur = false;
let backgroundSaturate = false;
let backgroundBrightness = false;
if (settings?.background) {
const bg = settings.background;
if (typeof bg === "object") {
backgroundImage = bg.image || "";
if (bg.opacity !== undefined) {
opacity = 1 - bg.opacity / 100;
}
backgroundBlur = bg.blur !== undefined;
backgroundSaturate = bg.saturate !== undefined;
backgroundBrightness = bg.brightness !== undefined;
} else {
backgroundImage = bg;
}
}
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.classList.remove("dark", "scheme-dark", "scheme-light");
html.classList.toggle("dark", theme === "dark");
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
const desiredThemeClass = `theme-${color}`;
const themeClassesToRemove = Array.from(html.classList).filter(
(cls) => cls.startsWith("theme-") && cls !== desiredThemeClass,
);
if (themeClassesToRemove.length) {
html.classList.remove(...themeClassesToRemove);
}
if (!html.classList.contains(desiredThemeClass)) {
html.classList.add(desiredThemeClass);
}
body.style.backgroundImage = "";
body.style.backgroundColor = "";
body.style.backgroundAttachment = "";
}, [color, theme]);
if (!providers || Object.keys(providers).length === 0) {
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main
className={classNames(
"relative flex min-h-screen items-center justify-center px-6 py-12",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="relative w-full max-w-xl overflow-hidden rounded-3xl border border-white/40 bg-white/80 p-10 text-center shadow-2xl shadow-black/10 dark:border-white/10 dark:bg-slate-900/70">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-theme-500/20 via-theme-500/5 to-transparent"
/>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
<BiShieldQuarter className="h-6 w-6" />
</div>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
Authentication not configured
</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
</div>
</main>
</>
);
}
const passwordProvider = providers
? Object.values(providers).find((provider) => provider.type === "credentials")
: null;
const hasPasswordProvider = Boolean(passwordProvider);
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
<div
className={classNames(
"relative w-full max-w-4xl overflow-hidden rounded-3xl border border-white/50 bg-white/80 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="pointer-events-none absolute -left-24 -top-20 h-64 w-64 rounded-full bg-theme-500/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-theme-500/10 blur-3xl" />
<div className="grid gap-10 px-8 py-12 md:grid-cols-[1.2fr_1fr] md:px-12">
<section className="flex flex-col justify-between">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-theme-500/30 bg-theme-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-theme-600 dark:text-theme-300">
Login Required
</div>
<h1 className="mt-6 text-3xl font-semibold text-gray-900 dark:text-slate-100">{title}</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-300">Login to view your dashboard.</p>
</div>
</section>
<section className="flex flex-col justify-center gap-6">
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3">
{hasPasswordProvider && (
<form
className="space-y-3"
onSubmit={async (event) => {
event.preventDefault();
await signIn(passwordProvider?.id ?? "credentials", {
redirect: true,
callbackUrl,
password,
});
}}
>
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
required
/>
<button
type="submit"
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Sign in &rarr;</span>
</button>
</form>
)}
{!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span>
</button>
))}
</div>
{hasPasswordProvider && error && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
Invalid password. Please try again.
</p>
)}
</div>
</section>
</div>
</div>
</main>
</>
);
}
export async function getServerSideProps(context) {
const providers = await getProviders();
const settings = getSettings();
return {
props: { providers, settings },
};
}

View File

@@ -10,7 +10,6 @@ import { getKubeConfig } from "utils/config/kubernetes";
import * as shvl from "utils/config/shvl"; import * as shvl from "utils/config/shvl";
import kubernetes from "utils/kubernetes/export"; import kubernetes from "utils/kubernetes/export";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
const logger = createLogger("service-helpers"); const logger = createLogger("service-helpers");
@@ -114,7 +113,7 @@ export async function servicesFromDocker() {
} }
let substitutedVal = substituteEnvironmentVars(containerLabels[label]); let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) { if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
substitutedVal = parseVersionForUrl(substitutedVal); substitutedVal = parseInt(substitutedVal, 10);
} }
shvl.set(constructedService, value, substitutedVal); shvl.set(constructedService, value, substitutedVal);
} }
@@ -591,7 +590,7 @@ export function cleanServiceGroups(groups) {
"vikunja", "vikunja",
].includes(type) ].includes(type)
) { ) {
widget.version = parseVersionForUrl(version); if (version) widget.version = parseInt(version, 10);
} }
if (type === "glances") { if (type === "glances") {
if (metric) widget.metric = metric; if (metric) widget.metric = metric;

View File

@@ -12,22 +12,6 @@ export function formatApiCall(url, args) {
return url.replace(find, replace).replace(find, replace); return url.replace(find, replace).replace(find, replace);
} }
export function parseVersionForUrl(version, defaultValue = null) {
if (version === undefined || version === null || version === "") {
return defaultValue;
}
if (typeof version === "number") {
return Number.isInteger(version) && version >= 0 ? version : defaultValue;
}
if (typeof version === "string" && /^\d+$/.test(version)) {
return Number(version);
}
return defaultValue;
}
export function getURLSearchParams(widget, endpoint) { export function getURLSearchParams(widget, endpoint) {
const params = new URLSearchParams({ const params = new URLSearchParams({
group: widget.service_group, group: widget.service_group,

View File

@@ -7,7 +7,6 @@ import {
getURLSearchParams, getURLSearchParams,
jsonArrayFilter, jsonArrayFilter,
jsonArrayTransform, jsonArrayTransform,
parseVersionForUrl,
sanitizeErrorURL, sanitizeErrorURL,
} from "./api-helpers"; } from "./api-helpers";
@@ -22,20 +21,6 @@ describe("utils/proxy/api-helpers", () => {
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-"); expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
}); });
it("parseVersionForUrl accepts canonical non-negative integers", () => {
expect(parseVersionForUrl("3")).toBe(3);
expect(parseVersionForUrl(4)).toBe(4);
expect(parseVersionForUrl(undefined, 3)).toBe(3);
});
it("parseVersionForUrl rejects non-canonical values", () => {
expect(parseVersionForUrl("3/../../path", 3)).toBe(3);
expect(parseVersionForUrl("1e2", 3)).toBe(3);
expect(parseVersionForUrl("0x10", 3)).toBe(3);
expect(parseVersionForUrl(-1, 3)).toBe(3);
expect(parseVersionForUrl(Number.NaN, 3)).toBe(3);
});
it("getURLSearchParams includes group/service/index and optionally endpoint", () => { it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
const widget = { service_group: "g", service_name: "s", index: "0" }; const widget = { service_group: "g", service_name: "s", index: "0" };

View File

@@ -2,7 +2,7 @@ import cache from "memory-cache";
import getServiceWidget from "utils/config/service-helpers"; import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import { asJson, formatApiCall, parseVersionForUrl } from "utils/proxy/api-helpers"; import { asJson, formatApiCall } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http"; import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets"; import widgets from "widgets/widgets";
@@ -56,7 +56,7 @@ async function getApiInfo(serviceWidget, apiName, serviceName) {
const json = asJson(data); const json = asJson(data);
if (json?.data?.[apiName]) { if (json?.data?.[apiName]) {
cgiPath = json.data[apiName].path; cgiPath = json.data[apiName].path;
maxVersion = parseVersionForUrl(json.data[apiName].maxVersion); maxVersion = json.data[apiName].maxVersion;
logger.debug( logger.debug(
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`, `Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
); );

View File

@@ -4,7 +4,6 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = { const statusMap = {
@@ -20,12 +19,11 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const idKey = apiVersion === 3 ? "Id" : "id"; const idKey = version === 3 ? "Id" : "id";
const statusKey = apiVersion === 3 ? "Status" : "status"; const statusKey = version === 3 ? "Status" : "status";
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/containers`, { const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const Chart = dynamic(() => import("../components/chart"), { ssr: false }); const Chart = dynamic(() => import("../components/chart"), { ssr: false });
@@ -17,15 +16,14 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/cpu`, { const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`); const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
useEffect(() => { useEffect(() => {
if (data) { if (data) {

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -17,7 +16,6 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, diskName] = widget.metric.split(":"); const [, diskName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState( const [dataPoints, setDataPoints] = useState(
@@ -25,7 +23,7 @@ export default function Component({ service }) {
); );
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit)); const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/diskio`, { const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -3,7 +3,6 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const defaultInterval = 1000; const defaultInterval = 1000;
@@ -12,11 +11,10 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, fsName] = widget.metric.split("fs:"); const [, fsName] = widget.metric.split("fs:");
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes"; const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useWidgetAPI(widget, `${apiVersion}/fs`, { const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -17,12 +16,11 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, gpuName] = widget.metric.split(":"); const [, gpuName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit)); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, `${apiVersion}/gpu`, { const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -3,7 +3,6 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
function Swap({ quicklookData, className = "" }) { function Swap({ quicklookData, className = "" }) {
@@ -76,13 +75,12 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i
export default function Component({ service }) { export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`, { const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
refreshInterval, refreshInterval,
}); });
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${apiVersion}/system`, { const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
refreshInterval: defaultSystemInterval, refreshInterval: defaultSystemInterval,
}); });

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -18,11 +17,10 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { chart } = widget; const { chart } = widget;
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget; const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/mem`, { const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval), refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
}); });

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -18,16 +17,15 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { chart, metric } = widget; const { chart, metric } = widget;
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget; const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const rxKey = apiVersion === 3 ? "rx" : "bytes_recv"; const rxKey = version === 3 ? "rx" : "bytes_recv";
const txKey = apiVersion === 3 ? "tx" : "bytes_sent"; const txKey = version === 3 ? "tx" : "bytes_sent";
const [, interfaceName] = metric.split(":"); const [, interfaceName] = metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, `${apiVersion}/network`, { const { data, error } = useWidgetAPI(widget, `${version}/network`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval), refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
}); });

View File

@@ -4,7 +4,6 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = { const statusMap = {
@@ -23,11 +22,10 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const memoryInfoKey = apiVersion === 3 ? 0 : "rss"; const memoryInfoKey = version === 3 ? 0 : "rss";
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/processlist`, { const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import Block from "../components/block"; import Block from "../components/block";
import Container from "../components/container"; import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
const Chart = dynamic(() => import("../components/chart"), { ssr: false }); const Chart = dynamic(() => import("../components/chart"), { ssr: false });
@@ -17,12 +16,11 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, sensorName] = widget.metric.split(":"); const [, sensorName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/sensors`, { const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
refreshInterval: Math.max(defaultInterval, refreshInterval), refreshInterval: Math.max(defaultInterval, refreshInterval),
}); });

View File

@@ -3,7 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = { const widget = {
api: "{url}/api/{endpoint}", api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler, proxyHandler: credentialedProxyHandler,
allowedEndpoints: /^\d+\/(quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers)$/, allowedEndpoints: /\d\/quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers/,
}; };
export default widget; export default widget;

View File

@@ -8,10 +8,6 @@ describe("glances widget config", () => {
it("exports a valid widget config", () => { it("exports a valid widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true); expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true);
expect(widget.allowedEndpoints?.test("12/cpu")).toBe(true);
expect(widget.allowedEndpoints?.test("unknown")).toBe(false); expect(widget.allowedEndpoints?.test("unknown")).toBe(false);
expect(widget.allowedEndpoints?.test("xxcpuyy")).toBe(false);
expect(widget.allowedEndpoints?.test("3/cpu/extra")).toBe(false);
expect(widget.allowedEndpoints?.test("membrane")).toBe(false);
}); });
}); });

View File

@@ -8,12 +8,6 @@ afterEach(() => {
if (typeof document !== "undefined") cleanup(); if (typeof document !== "undefined") cleanup();
}); });
// Avoid NextAuth client-side fetches during unit tests.
vi.mock("next-auth/react", () => ({
SessionProvider: ({ children }) => children ?? null,
getProviders: vi.fn(async () => ({})),
}));
// implement a couple of common formatters mocked in next-i18next // implement a couple of common formatters mocked in next-i18next
vi.mock("next-i18next", () => ({ vi.mock("next-i18next", () => ({
// Keep app/page components importable in unit tests. // Keep app/page components importable in unit tests.