Merge pull request #666 from arabcoders/user-pass

Migrate to user/pass auth
This commit is contained in:
Abdulmohsen
2025-05-15 00:13:44 +03:00
committed by GitHub
47 changed files with 1368 additions and 1052 deletions

275
FAQ.md
View File

@@ -1,57 +1,5 @@
# FAQ
# How to find the API key?
There are two ways to locate the API key:
## Via `.env` file
The API key is stored in the following file `/config/config/.env`, Open the file and look for the line starting with:
```
WS_API_KEY=random_string
```
The value after the equals sign is your API key.
## Via command
You can also retrieve the API key by running the following command on the docker host machine:
```bash
docker exec watchstate console system:apikey
```
This command will show the following lines:
```
Current API key:
random_string
```
The `random_string` is your API key.
----
# What Is the API key used for?
The API key is used to authenticate requests to the system and prevent unauthorized access.
It is required for all API endpoints, **except** the following:
```
/v1/api/[user@backend_name]/webhook
```
This webhook endpoint is open by default, unless you have enabled the `WS_SECURE_API_ENDPOINTS` environment variable.
If enabled, the API key will also be required for webhook access.
> [!IMPORTANT]
> The WebUI operates in standalone mode and is decoupled from the backend, so it requires the API key to fetch and
> display data.
----
# How to enable scheduled/automatic tasks?
To turn on automatic import or export tasks:
@@ -351,7 +299,7 @@ If there are no errors, the database has been repaired successfully. And you can
---
# Which external db ids `GUIDS` supported for Plex Media Server?
# Which Providers id `GUIDs` supported for Plex Media Server?
* tvdb://(id) `New plex agent`
* imdb://(id) `New plex agent`
@@ -371,7 +319,7 @@ If there are no errors, the database has been repaired successfully. And you can
---
# Which external db ids supported for Jellyfin and Emby?
# Which Providers id supported for Jellyfin and Emby?
* imdb://(id)
* tvdb://(id)
@@ -390,15 +338,14 @@ If there are no errors, the database has been repaired successfully. And you can
# Environment Variables
The recommended approach is for keys that starts with `WS_` use the `WebUI > Env` page, or `system:env` command via CLI.
For other keys that aren't directly related to the tool, you **MUST** load them via container environment or
the `compose.yaml` file.
The recommended approach is for keys that starts with `WS_` use the `WebUI > Env` page. For other keys that aren't
directly related to the tool, you **MUST** load them via container environment or the `compose.yaml` file.
to see list of loaded environment variables, click on `Env` page in the WebUI.
## Tool specific environment variables.
## WatchState specific environment variables.
These environment variables relates to the tool itself, You should manage them via `WebUI > Env` page
Should be added/managed via the `WebUI > Env` page.
| Key | Type | Description | Default |
|-------------------------|---------|-------------------------------------------------------------------------|--------------------------|
@@ -418,13 +365,11 @@ These environment variables relates to the tool itself, You should manage them v
| WS_CACHE_URL | string | Cache server URL. | `redis://127.0.0.1:6379` |
| WS_SECURE_API_ENDPOINTS | bool | Disregard the open route policy and require API key for all endpoints. | `false` |
> [!IMPORTANT]
> [!NOTE]
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one of `IMPORT`, `EXPORT`, `BACKUP`,
`PRUNE`, `INDEXES` or `VALIDATE`.
## Add tool specific environment variables
Go to the `Env` page, click `+` button, you will get list of all supported keys with description.
>
> Note, those are just sample of the environment variables, to see the entire, please visit the `Env` page in the WebUI.
## Container specific environment variables.
@@ -432,22 +377,18 @@ Go to the `Env` page, click `+` button, you will get list of all supported keys
> These environment variables relates to the container itself, and MUST be added via container environment or by
> the `compose.yaml` file.
| Key | Type | Description | Default |
|---------------|---------|--------------------------------------|----------|
| WEBUI_ENABLED | bool | Enable WebUI. Value casted to a bool | `true` |
| DISABLE_HTTP | integer | Disable included `HTTP Server`. | `0` |
| DISABLE_CRON | integer | Disable included `Task Scheduler`. | `0` |
| DISABLE_CACHE | integer | Disable included `Cache Server`. | `0` |
| HTTP_PORT | string | Change the `HTTP` listen port. | `"8080"` |
| FPM_PORT | string | Change the `PHP-FPM` listen port. | `"9000"` |
| Key | Type | Description | Default |
|---------------|---------|------------------------------------|----------|
| DISABLE_HTTP | integer | Disable included `HTTP Server`. | `0` |
| DISABLE_CRON | integer | Disable included `Task Scheduler`. | `0` |
| DISABLE_CACHE | integer | Disable included `Cache Server`. | `0` |
| HTTP_PORT | string | Change the `HTTP` listen port. | `"8080"` |
| FPM_PORT | string | Change the `PHP-FPM` listen port. | `"9000"` |
> [!NOTE]
> You need to restart the container after changing these environment variables. those variables are not managed by the
> WatchState tool, they are managed by the container itself.
---
---
# How to disable the included HTTP server and use external server?
@@ -487,38 +428,6 @@ https://watchstate.example.org {
---
# WS_API_AUTO
The `WS_API_AUTO` environment variable is designed to **automate the initial configuration process**, particularly
useful for users who access the WebUI from multiple browsers or devices. Since the WebUI requires API settings to be
configured before use, enabling this variable allows the system to auto-configure itself.
To enable it, write `WS_API_AUTO=true` to `/config/.env` file, note the file may not exist, and you may need to create
it.
## Why You Might Use It
You may consider using this if:
- You're operating in a **secure, local environment**.
- You want to **automate setup** across multiple devices or browsers without repeatedly entering API details.
## Why You Should **NOT** Use It (Recommended)
Enabling this poses a **serious security risk**:
- It **exposes your API key** publicly through the endpoint `/v1/api/system/auto`.
- Anyone (or any bot) that can access the WebUI can retrieve your API key and gain **access** to any and all data that
is exposed by the API including your media servers API keys.
**If WatchState is exposed to the internet, do not enable this setting.**
> [!IMPORTANT]
> The `WS_API_AUTO` variable is a **major security risk**. It should only be used in isolated or trusted environments.
> We strongly recommend keeping this option disabled.
---
# How to disable the included cache server and use an external cache server?
To disable the built-in cache server and connect to an external Redis instance, follow these steps:
@@ -652,107 +561,6 @@ jellyfin devs fixes the issue. Please take look at the webhooks section to enabl
---
# Bare metal installation
We officially only support the docker container, however for the brave souls who want to install the tool directly on
their server, You can follow these steps.
## Requirements
* [PHP 8.4](http://https://www.php.net/downloads.php) with both the `CLI` and `fpm` mode.
* PHP Extensions `pdo`, `pdo-sqlite`, `mbstring`, `json`, `ctype`, `curl`, `redis`, `sodium` and `simplexml`.
* [Composer](https://getcomposer.org/download/) for dependency management.
* [Redis-server](https://redis.io/) for caching or a compatible implementation that works
with [php-redis](https://github.com/phpredis/phpredis).
* [Caddy](https://caddyserver.com/) for frontend handling. However, you can use whatever you like. As long as it has
support for fastcgi.
* [Node.js v20+](https://nodejs.org/en/download/) for `WebUI` compilation.
## Installation
* Clone the repository.
```bash
$ git clone https://github.com/arabcoders/watchstate.git
```
* Install the dependencies.
```bash
$ cd watchstate
$ composer install --no-dev
```
* If you your redis server on external server, run the following command
```bash
$ ./bin/console system:env -k WS_CACHE_URL -e '"redis://127.0.0.1:6379?password=your_password"'
```
Change the connection string to match your redis server.
* Compile the `WebUI`.
First you need to install `yarn` as it's our package manager of choice.
```bash
$ npm -g install yarn
```
Once that is done you are ready to compile the `WebUI`.
```bash
$ cd frontend
$ yarn install --production --prefer-offline --frozen-lockfile && yarn run generate
```
There should be a new directory called `exported`, you need to move that folder to the `public` directory.
```bash
$ mv exported ../public
```
If you do `ls ../public` you should see the following contents
```bash
ws:/opt/app/public$ ls
exported index.php
```
There must be exactly one `index.php` file and one `exported` directory. inside that directory, or if you prefer, you
can add `WS_WEBUI_PATH` environment variable to point to the `exported` directory.
* link the app to the frontend proxy. For caddy, you can use the following configuration.
> [!NOTE]
> frontend server is needed All the `API`, `WebUI` and `Webhooks` operations.
```Caddyfile
http://watchstate.example.org {
# Change "[user]" to your user name.
root * /home/[user]/watchstate/public
# Change "unix//var/run/php/php8.3-fpm.sock" to your php-fpm socket.
php_fastcgi unix//var/run/php/php8.3-fpm.sock
}
```
* To access the console you can run the following command.
```bash
$ ./bin/console help
```
* To make the tasks scheduler work you need to add the following to your crontab.
```crontab
* * * * * /home/[user]/watchstate/bin/console system:tasks --run --save-log
```
For more information, please refer to the [Dockerfile](/Dockerfile). On how we do things to get the tool running.
---
# How does the file integrity feature works?
The feature first scan your entire history for reported media file paths. Depending on the results we do the following:
@@ -907,55 +715,22 @@ database indexes with the new guids.
# Sync watch progress.
In order to sync the watch progress between media backends, you need to enable the following environment variable
`WS_SYNC_PROGRESS` in `(WebUI) > Env` page or via the cli using the following command:
`WS_SYNC_PROGRESS` in `WebUI > Env` page.
```bash
$ docker exec -ti watchstate console system:env -k WS_SYNC_PROGRESS -e true
```
For best experience, you should enable the [webhook](guides/webhooks.md) feature for the media backends you want to sync
the watch progress, however, if you are unable to do so, the `Tasks > import` task will also generate progress watch
events. However, it's not as reliable as the `Webhooks` or as fast. using `Webhooks` is the recommended way and offers
the best experience.
For best experience, you should enable the [webhook](guides/webhooks.md) feature for the media backends you want to sync the watch
progress,
however, if you are unable to do so, the `Tasks > import` task will also generate progress watch events. However, it's
not as reliable as the `Webhooks` or as fast. using `Webhooks` is the recommended way and offers the best experience.
To check if there is any watch progress events being registered, You can go to `(WebUI) > More > Events` and check
To check if there is any watch progress events being registered, You can go to `WebUI > More > Events` and check
`on_progress` events, if you are seeing those, this means the progress is being synced. Check the `Tasks logs` to see
the event log.
If this is setup and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to something less frequenet as
the sync progress working will update the progress near realtime. For example you could change these tasks to run daily instead of hourly.
If this is setup and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to something
less frequent as the sync progress working will update the progress near realtime. For example you could change these
tasks to run daily instead of hourly.
```
WS_CRON_IMPORT_AT=0 11 * * *
WS_CRON_EXPORT_AT=30 11 * * *
```
---
# Sub users support.
### API/WebUI endpoints that supports sub users.
These endpoints support sub-users via `?user=username` query parameter, or via `X-User` header. The recommended
approach is to use the header.
* `/v1/api/backend/*`.
* `/v1/api/system/parity`.
* `/v1/api/system/integrity`.
* `/v1/api/ignore/*`.
* `/v1/api/history/*`.
### CLI commands that supports sub users.
These commands sub users can via `[-u, --user]` option flag.
* `state:import`.
* `state:export`.
* `state:backup`.
* `config:list`.
* `config:view`.
* `config:edit`.
* `db:list`.
* `backend:restore`.
* `backend:ignore:*`.

View File

@@ -9,8 +9,24 @@ box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
# Updates
### 2025-05-14
**Breaking change**, we have switched to using user/password form of authentication instead of API key for the WebUI,
this will lead to better security and easier to use. The API key is still available for the API, but not for the WebUI.
The first time you access the WebUI after the update, you will be asked to create a new system user/password. This is a
one time operation. Sorry about that. if you somehow lost your password, you can reset it by running the following
command from the host machine.
```bash
# change docker to podman if you are using podman
$ docker exec watchstate console system:resetpassword
```
Please refer to [NEWS](/NEWS.md) for the latest updates and changes.
------
# Features
* Management via WebUI.
@@ -97,36 +113,15 @@ container, but it will be mapped to the user in which the command was run under.
After starting the container, you can access the WebUI by visiting `http://localhost:8080` in your browser.
> [!NOTE]
> The very first time you access the WebUI, we will attempt to autoconfigure your API connection. Should this fail, you
> can manually configure the API connection by following the instructions below.
At the start you won't see anything as the `WebUI` is decoupled from the WatchState and need to be configured to be able
to access the API. In the top right corner, you will see a cogwheel icon, click on it and then Configure the connection
settings.
![Connection settings](screenshots/api_settings.png)
As shown in the screenshot, to get your `API Token`, you can do via two methods
### Method 1
view the contents of the `./data/config/.env` file, and copy the contents after `WS_API_KEY=` variable.
### Method 2
From the host machine, you can run the following command
```bash
# change docker to podman if you are using podman
$ docker exec watchstate console system:apikey
```
Insert the `API key` into the `API Token` field and make sure to set the `API URL` or click the `current page URL` link.
If everything is ok, the reset of the navbar will show up.
> Note, For the first time, you will be prompted to create a new system user, this is a one time operation.
To add your backends, please click on the help button in the top right corner, and choose which method you
want [one-way](guides/one-way-sync.md) or [two-way](guides/two-way-sync.md) sync. and follow the instructions.
Once you have setup your backends and imported your data you should see something like
![WebUI](/screenshots/index.png)
### Supported import methods
Currently, the tool supports three methods to import data from backends.
@@ -141,14 +136,12 @@ Currently, the tool supports three methods to import data from backends.
> [!NOTE]
> Even if all your backends support webhooks, you should keep import task enabled. This help keep healthy relationship
> and pick up any missed events. For more information please check the [webhook guide](/guides/webhooks.md) to
> understand
> webhooks limitations.
> understand webhooks limitations.
# FAQ
Take look at this [frequently asked questions](FAQ.md) page, or the [guides](/guides/) for more in-depth guides on how
to
configure things.
to configure things.
# Social channels

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
use App\Libs\Middlewares\{AddCorsMiddleware,
AddTimingMiddleware,
APIKeyRequiredMiddleware,
AuthorizationMiddleware,
NoAccessLogMiddleware,
ParseJsonBodyMiddleware};
return static fn(): array => [
fn() => new AddTimingMiddleware(),
fn() => new APIKeyRequiredMiddleware(),
fn() => new AuthorizationMiddleware(),
fn() => new ParseJsonBodyMiddleware(),
fn() => new NoAccessLogMiddleware(),
fn() => new AddCorsMiddleware(),

View File

@@ -35,7 +35,6 @@ return (function () {
'prefix' => '/v1/api',
'key' => env('WS_API_KEY', null),
'secure' => (bool)env('WS_SECURE_API_ENDPOINTS', false),
'auto' => (bool)env('WS_API_AUTO', false),
'pattern_match' => [
'backend' => '[a-zA-Z0-9_\-]+',
'ubackend' => '[a-zA-Z0-9_\-\@]+',
@@ -343,5 +342,16 @@ return (function () {
],
];
$config['password'] = [
'prefix' => 'ws_hash@:',
'algo' => PASSWORD_BCRYPT,
'options' => ['cost' => 10],
];
$config['system'] = [
'user' => env('WS_SYSTEM_USER', null),
'password' => env('WS_SYSTEM_PASSWORD', null),
];
return $config;
})();

View File

@@ -1,4 +1,5 @@
<?php
/**
* Last update: 2024-05-10
*
@@ -7,6 +8,7 @@
* Avoid using complex datatypes, the value should be a simple scalar value.
*/
use App\Libs\Config;
use App\Libs\Exceptions\ValidationException;
use Cron\CronExpression;
@@ -144,12 +146,6 @@ return (function () {
'description' => 'Expose debug information in the API when an error occurs.',
'type' => 'bool',
],
[
'key' => 'WS_API_AUTO',
'description' => 'PUBLICLY EXPOSE the api token for automated WebUI configuration. This should NEVER be enabled if WatchState is exposed to the internet.',
'danger' => true,
'type' => 'bool',
],
[
'key' => 'WS_CONSOLE_ENABLE_ALL',
'description' => 'All executing all commands in the console. They must be prefixed with $',
@@ -228,6 +224,51 @@ return (function () {
'description' => 'Whether to send backend requests in parallel or sequentially.',
'type' => 'bool',
],
[
'key' => 'WS_SYSTEM_USER',
'description' => 'The login user name',
'type' => 'string',
'validate' => function (mixed $value): string {
if (!is_numeric($value) && empty($value)) {
throw new ValidationException('Invalid username. Empty value.');
}
if (false === isValidName($value)) {
throw new ValidationException(
'Invalid username. Username can only contains [lower case a-z, 0-9 and _].'
);
}
return $value;
},
'mask' => true,
'hidden' => true,
],
[
'key' => 'WS_SYSTEM_PASSWORD',
'description' => 'The login password. The given plaintext password will be converted to hash.',
'type' => 'string',
'validate' => function (mixed $value): string {
if (empty($value)) {
throw new ValidationException('Invalid password. Empty value.');
}
$prefix = Config::get('password.prefix', 'ws_hash@:');
if (true === str_starts_with($value, $prefix)) {
return $value;
}
$hash = password_hash($value, Config::get('password.algo'), Config::get('password.options', []));
if (false === $hash) {
throw new ValidationException('Invalid password. Password hashing failed.');
}
return $prefix . $hash;
},
'mask' => true,
'hidden' => true,
],
];
$validateCronExpression = function (string $value): string {

View File

@@ -1,274 +0,0 @@
<template>
<div class="columns is-multiline mb-2">
<div class="column is-6-tablet">
<div class="card">
<header class="card-header">
<p class="card-header-title">
Configure API Connection
</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-cog"/></span>
</span>
</header>
<div class="card-content">
<form id="api_connection" @submit.prevent="testApi">
<div class="field">
<label class="label" for="api_token">
<span class="icon-text">
<span class="icon"><i class="fas fa-key"/></span>
<span>API Token</span>
</span>
</label>
<div class="field-body">
<div class="field">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" id="api_token" v-model="api_token" required placeholder="API Token..."
:type="false === exposeToken ? 'password' : 'text'">
</div>
<div class="control">
<button type="button" class="button is-primary" @click="exposeToken = !exposeToken"
v-tooltip="'Show/Hide token'">
<span class="icon" v-if="!exposeToken"><i class="fas fa-eye"/></span>
<span class="icon" v-else><i class="fas fa-eye-slash"/></span>
</button>
</div>
</div>
<p class="help">
You can obtain the <code>API TOKEN</code> by using the <code>system:apikey</code> command or by
viewing the <code>/config/.env</code> inside <code>WS_DATA_PATH</code> variable and looking for
the <code>WS_API_KEY=</code> key.
</p>
</div>
</div>
</div>
<div class="field">
<label class="label" for="api_url">
<span class="icon-text">
<span class="icon"><i class="fas fa-link"/></span>
<span>API URL</span>
</span>
</label>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" id="api_url" type="url" v-model="api_url" required
placeholder="API URL... http://localhost:8081">
<p class="help">
Use <a href="javascript:void(0)" @click="setOrigin">current page URL</a>.
</p>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label" for="api_path">
<span class="icon-text">
<span class="icon"><i class="fas fa-folder"/></span>
<span>API Path</span>
</span>
</label>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" id="api_path" type="text" v-model="api_path" required
placeholder="API Path... /v1/api">
<p class="help">
Use <a href="javascript:void(0)" @click="api_path = '/v1/api'">Set default API Path</a>.
</p>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="column is-6-tablet">
<div class="card">
<header class="card-header">
<p class="card-header-title">
WebUI Look & Feel
</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-paint-brush"/></span>
</span>
</header>
<div class="card-content">
<div class="field">
<label class="label" for="random_bg">Color scheme</label>
<div class="control">
<label for="auto" class="radio">
<input id="auto" type="radio" v-model="webui_theme" value="auto"> System Default
</label>
<label for="light" class="radio">
<input id="light" type="radio" v-model="webui_theme" value="light"> Light
</label>
<label for="dark" class="radio">
<input id="dark" type="radio" v-model="webui_theme" value="dark"> Dark
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info"/></span>
<span>Select the color scheme for the WebUI.</span>
</p>
</div>
<div class="field">
<label class="label" for="random_bg">Backgrounds</label>
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg">Enable</label>
<p class="help">Use random background image from your media backends.</p>
</div>
</div>
<div class="field">
<label class="label" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>)
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.60"
max="1.00" step="0.05">
<p class="help">How visible the background image should be.</p>
</div>
</div>
</div>
</div>
</div>
<div class="column is-12">
<div class="control">
<button form="api_connection" type="submit" class="button is-primary is-fullwidth"
:disabled="!api_url || !api_token">
<span class="icon-text">
<span class="icon"><i class="fas fa-save"/></span>
<span>Save</span>
</span>
</button>
</div>
</div>
<div class="column is-12 mt-2">
<Message title="Information" message_class="has-background-info-90 has-text-dark" icon="fas fa-info-circle">
<ul>
<li>
These settings are stored locally in your browser. You need to re-add them if you access the
<strong>WebUI</strong> from different browser.
</li>
<li>
The very first time you access the <strong>WebUI</strong>, it will auto configure the connection if
possible.
</li>
</ul>
</Message>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue'
import {useStorage} from '@vueuse/core'
import {notification} from '~/utils/index'
import awaiter from '~/utils/awaiter'
const emitter = defineEmits(['update'])
const real_api_user = useStorage('api_user', 'main')
const real_api_url = useStorage('api_url', window.location.origin)
const real_api_path = useStorage('api_path', '/v1/api')
const real_api_token = useStorage('api_token', '')
const webui_theme = useStorage('theme', 'auto')
const api_url = ref(toRaw(real_api_url.value))
const api_path = ref(toRaw(real_api_path.value))
const api_user = ref(toRaw(real_api_user.value))
const api_token = ref(toRaw(real_api_token.value))
const exposeToken = ref(false)
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const testApi = async () => {
try {
const response = await fetch(`${api_url.value}${api_path.value}/system/version`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${api_token.value}`
}
})
const json = await response.json()
if (json.error) {
notification('error', 'API Connection', `Error ${json.error.code} - ${json.error.message}`)
return
}
if (200 === response.status) {
real_api_url.value = api_url.value
real_api_user.value = api_user.value
real_api_path.value = api_path.value
real_api_token.value = api_token.value
}
const message = 200 === response.status ? `Status: OK` : `Status: ${response.status} - ${response.statusText}`;
notification('success', 'API Connection', `${response.status}: ${message}`)
if (200 === response.status) {
await awaiter(() => '' !== real_api_token.value)
emitter('update', {version: json.version})
}
} catch (e) {
notification('error', 'API Connection', `Request error. ${e.message}`)
}
}
onMounted(async () => {
if ('' === api_token.value) {
await autoConfig()
}
})
const autoConfig = async () => {
try {
const response = await fetch(`${api_url.value}${api_path.value}/system/auto`, {
method: 'POST',
headers: {
'Accept': 'application/json'
},
body: JSON.stringify({'origin': window.location.origin})
})
const json = await response.json()
if (200 !== response.status) {
return;
}
if (!api_url.value) {
api_url.value = json.url
}
if (!api_path.value) {
api_path.value = json.path
}
if (!api_token.value) {
api_token.value = json.token
}
await testApi();
} catch (e) {
}
}
const setOrigin = () => api_url.value = window.location.origin;
</script>

View File

@@ -73,7 +73,6 @@
</template>
<script setup>
import {useStorage} from '@vueuse/core'
import {marked} from 'marked'
import {baseUrl} from 'marked-base-url'
import markedAlert from 'marked-alert'
@@ -88,7 +87,6 @@ const props = defineProps({
});
const content = ref('')
const api_url = useStorage('api_url', '')
const error = ref('')
const isLoading = ref(true)
@@ -138,7 +136,7 @@ const removeListeners = () => {
onMounted(async () => {
try {
isLoading.value = true
const response = await fetch(`${api_url.value}${props.file}?_=${Date.now()}`)
const response = await fetch(`${props.file}?_=${Date.now()}`)
if (!response.ok) {
const err = await parse_api_response(response)
console.log(err)
@@ -148,36 +146,15 @@ onMounted(async () => {
const text = await response.text()
marked.use(baseUrl(api_url.value))
marked.use(baseUrl(window.origin))
marked.use(markedAlert());
marked.use({
gfm: true,
hooks: {
postprocess: (text) => {
// // -- replace GitHub [! with icon
// text = text.replace(/\[!IMPORTANT]/g, `
// <span class="is-block title mb-2 is-5">
// <span class="icon-text">
// <span class="icon"><i class="fas fa-exclamation-triangle has-text-danger"></i></span>
// <span>IMPORTANT</span>
// </span>
// </span>`)
//
// text = text.replace(/\[!NOTE]/g, `
// <span class="is-block title is-5">
// <span class="icon-text">
// <span class="icon"><i class="fas fa-info has-text-info-50"></i></span>
// <span>NOTE</span>
// </span>
// </span>`)
text = text.replace(
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map(n => n.trim()).join(' ')}"></i></span>`
);
return text
}
postprocess: (text) => text.replace(
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map(n => n.trim()).join(' ')}"></i></span>`
)
},
walkTokens: token => {
if ('link' !== token.type) {
@@ -187,16 +164,19 @@ onMounted(async () => {
if (true === token.href.startsWith('#')) {
return;
}
const urls = ['/FAQ.md', '/README.md', '/NEWS.md'];
const list = ['/guides/', ...urls];
const urls = ['FAQ.md', 'README.md', 'NEWS.md'];
const list = ['guides/', ...urls];
if (false === list.some(l => token.href.includes(l))) {
return;
}
if (urls.some(l => token.href.includes(l))) {
const url = new URL(token.href);
if (!token.href.startsWith('/')) {
token.href = '/' + token.href;
}
const url = new URL(window.origin + token.href);
url.pathname = `/guides${url.pathname}`;
token.href = url.pathname;
token.href = url.toString();
}
token.href = token.href.replace('/guides/', '/help/').replace('.md', '');

View File

@@ -1,13 +0,0 @@
<template>
<Message title="Warning" message_class="has-background-warning-80 has-text-dark" icon="fas fa-exclamation-triangle">
There is no <em class="is-bold">{{ api_var }}</em> configured. Please configure the API connection using the button
<i class="fa fa-cog"></i> in the top right corner of the page.
</Message>
</template>
<script setup>
import {useStorage} from "@vueuse/core";
const api_token = useStorage('api_token', '')
const api_var = computed(() => (!api_token.value) ? 'API Token' : 'API URL')
</script>

View File

@@ -26,7 +26,7 @@
<script setup>
import Hls from 'hls.js'
import 'plyr/dist/plyr.css'
import {notification} from '~/utils/index'
import {disableOpacity, enableOpacity, notification} from '~/utils/index'
import request from '~/utils/request'
import Plyr from 'plyr'
@@ -72,13 +72,17 @@ const destroyPlayer = () => {
}
onMounted(() => {
disableOpacity()
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
Promise.all([getPoster(), prepareVideoPlayer()])
})
onUpdated(() => prepareVideoPlayer())
onUnmounted(() => destroyPlayer())
onUnmounted(() => {
destroyPlayer()
enableOpacity()
})
const getPoster = async () => {
if (props.poster) {

View File

@@ -0,0 +1,64 @@
<template>
<div class="columns is-multiline mb-2">
<div class="column">
<div class="card">
<header class="card-header">
<p class="card-header-title">
WebUI Look & Feel
</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-paint-brush"/></span>
</span>
</header>
<div class="card-content">
<div class="field">
<label class="label" for="random_bg">Color scheme</label>
<div class="control">
<label for="auto" class="radio">
<input id="auto" type="radio" v-model="webui_theme" value="auto"> System Default
</label>
<label for="light" class="radio">
<input id="light" type="radio" v-model="webui_theme" value="light"> Light
</label>
<label for="dark" class="radio">
<input id="dark" type="radio" v-model="webui_theme" value="dark"> Dark
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info"/></span>
<span>Select the color scheme for the WebUI.</span>
</p>
</div>
<div class="field">
<label class="label" for="random_bg">Backgrounds</label>
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg">Enable</label>
<p class="help">Use random background image from your media backends.</p>
</div>
</div>
<div class="field">
<label class="label" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>)
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.60"
max="1.00" step="0.05">
<p class="help">How visible the background image should be.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {useStorage} from '@vueuse/core'
const webui_theme = useStorage('theme', 'auto')
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
</script>

View File

@@ -19,7 +19,7 @@
</div>
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="hasAPISettings && !showConnection">
<div class="navbar-start">
<NuxtLink class="navbar-item" to="/backends" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-server"/></span>
<span>Backends</span>
@@ -54,22 +54,19 @@
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-key"/></span>
<span>Plex Token</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-users"/></span>
<span>Sub Users</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-microchip"/></span>
<span>Processes</span>
</NuxtLink>
@@ -123,7 +120,7 @@
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
@@ -152,34 +149,70 @@
</div>
</div>
<div class="navbar-end pr-3">
<template v-if="hasAPISettings && !showConnection">
<template v-if="'mobile' === breakpoints.active().value">
<div class="navbar-item">
<NuxtLink class="button is-dark" to="/help">
<span class="icon"><i class="fas fa-circle-question"/></span>
<span>Guides</span>
</NuxtLink>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="showUserSelection = !showUserSelection">
<span class="icon"><i class="fas fa-users"/></span>
<span>Change User</span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="showSettings = !showSettings">
<span class="icon"><i class="fas fa-cog"/></span>
<span>Settings</span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="logout">
<span class="icon"><i class="fas fa-sign-out"/></span>
<span>Logout</span>
</button>
</div>
</template>
<template v-if="'mobile' !== breakpoints.active().value">
<div class="navbar-item">
<NuxtLink class="button is-dark" v-tooltip="'Guides'" to="/help">
<span class="icon"><i class="fas fa-circle-question"/></span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="hasAPISettings && !showConnection">
<div class="navbar-item">
<button class="button is-dark" @click="showUserSelection = !showUserSelection" v-tooltip="'Change User'">
<span class="icon"><i class="fas fa-users"/></span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="showSettings = !showSettings"
v-tooltip="'WebUI Settings'">
<span class="icon"><i class="fas fa-cog"/></span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="logout" v-tooltip="'Logout'">
<span class="icon"><i class="fas fa-sign-out"/></span>
</button>
</div>
</template>
<div class="navbar-item">
<button class="button is-dark" @click="showConnection = !showConnection"
v-tooltip="'Configure connection'">
<span class="icon"><i class="fas fa-cog"/></span>
</button>
</div>
</div>
</div>
</nav>
<div>
<div>
<no-api v-if="!hasAPISettings"/>
<Connection v-if="showConnection" @update="data => handleConnection(data)"/>
<NuxtPage v-if="!showConnection && hasAPISettings"/>
<Settings v-if="showSettings"/>
<NuxtPage/>
</div>
<div class="columns is-multiline is-mobile mt-3">
@@ -228,14 +261,14 @@ import {useBreakpoints, useStorage} from '@vueuse/core'
import request from '~/utils/request'
import Markdown from '~/components/Markdown'
import UserSelection from '~/components/UserSelection'
import Connection from '~/components/Connection'
import {useAuthStore} from '~/store/auth'
import Settings from "~/components/Settings.vue";
const selectedTheme = useStorage('theme', 'auto')
const showUserSelection = ref(false)
const showConnection = ref(false)
const showSettings = ref(false)
const api_url = useStorage('api_user', 'main')
const api_token = useStorage('api_token', '')
const auth = useAuthStore()
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const api_version = ref()
@@ -299,12 +332,6 @@ const applyPreferredColorScheme = scheme => {
onMounted(async () => {
try {
applyPreferredColorScheme(selectedTheme.value)
if ('' === api_token.value || '' === api_url.value) {
showConnection.value = true
return
}
await getVersion()
await loadImage()
} catch (e) {
@@ -336,8 +363,6 @@ const getVersion = async () => {
}
}
const hasAPISettings = computed(() => '' !== api_token.value && '' !== api_url.value)
const closeOverlay = () => loadFile.value = ''
const openMenu = e => {
@@ -360,13 +385,8 @@ const changeRoute = async (_, callback) => {
}
}
const handleConnection = data => {
api_version.value = data.version
showConnection.value = false
}
watch(bgImage, async v => {
if (false === hasAPISettings.value || false === bg_enable.value) {
if (false === bg_enable.value) {
return
}
@@ -392,12 +412,11 @@ watch(bgImage, async v => {
}, {immediate: true})
watch(bg_opacity, v => {
if (false === hasAPISettings.value || false === bg_enable.value) {
if (false === bg_enable.value) {
return
}
document.querySelector('body').setAttribute("style", `opacity: ${v}`)
})
watch(hasAPISettings, async () => await loadImage())
watch(breakpoints.active(), async () => await loadImage())
watch(bg_enable, async v => {
if (true === v) {
@@ -423,7 +442,7 @@ watch(bg_enable, async v => {
})
const loadImage = async () => {
if (!bg_enable.value || !hasAPISettings) {
if (!bg_enable.value) {
return
}
@@ -456,4 +475,15 @@ const loadImage = async () => {
}
}
const logout = async () => {
if (!confirm('Logout?')) {
return false
}
auth.logout()
await navigateTo('/auth')
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<NuxtPage />
</template>
<script setup>
import 'assets/css/bulma.css'
import 'assets/css/style.css'
import 'assets/css/all.css'
</script>

View File

@@ -0,0 +1,36 @@
import {storeToRefs} from 'pinia'
import {useAuthStore} from '~/store/auth'
import {useStorage} from '@vueuse/core'
let next_check = 0
export default defineNuxtRouteMiddleware(async to => {
if (to.fullPath.startsWith('/auth') || to.fullPath.startsWith('/v1/api')) {
return
}
const {authenticated} = storeToRefs(useAuthStore())
const token = useStorage('token', null)
if (token.value) {
if (Date.now() > next_check) {
console.debug('Validating user token...')
const {validate} = useAuthStore()
if (!await validate(token.value)) {
token.value = null
abortNavigation()
console.error('Token is invalid, redirecting to login page...')
return navigateTo('/auth')
}
console.debug('Token is valid.')
next_check = Date.now() + 1000 * 60 * 5
}
authenticated.value = true
}
if (!token.value && to?.name !== 'auth') {
abortNavigation()
return navigateTo('/auth')
}
})

View File

@@ -9,9 +9,13 @@ try {
extraNitro = {
devProxy: {
'/v1/api/': {
target: API_URL,
target: API_URL + '/v1/api/',
changeOrigin: true
}
},
'/guides/': {
target: API_URL + '/guides/',
changeOrigin: true
},
}
}
}
@@ -26,7 +30,12 @@ export default defineNuxtConfig({
port: 8081,
host: "0.0.0.0",
},
runtimeConfig: {
public: {
domain: '/',
version: '1.0.0',
}
},
app: {
head: {
"meta": [
@@ -48,6 +57,7 @@ export default defineNuxtConfig({
modules: [
'@vueuse/nuxt',
'floating-vue/nuxt',
'@pinia/nuxt',
],
nitro: {

View File

@@ -11,6 +11,8 @@
},
"web-types": "./web-types.json",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@pinia/nuxt": "^0.11.0",
"@vueuse/core": "^13.1.0",
"@vueuse/nuxt": "^13.1.0",
"@xterm/addon-fit": "^0.10.0",
@@ -23,6 +25,7 @@
"marked-base-url": "^1.1.3",
"moment": "^2.30.1",
"nuxt": "^3.17.1",
"pinia": "^3.0.2",
"plyr": "^3.7.8",
"vue": "^3.4.21",
"vue-router": "^4.5.1",

135
frontend/pages/auth.vue Normal file
View File

@@ -0,0 +1,135 @@
<template>
<div>
<div class="hero is-dark is-fullheight">
<div class="hero-body">
<div class="container" style="background-color: unset !important;">
<div class="columns is-centered">
<div class="column is-6-tablet is-6-desktop is-4-widescreen">
<div class="box" v-if="error">
<span class="icon"><i class="fa fa-info"/></span>
<span class="has-text-danger">{{ error }}</span>
</div>
<form method="post" @submit.prevent="formValidate()" class="box">
<div class="field">
<label for="user-id" class="label">
{{ signup ? 'Create an account' : 'Login' }}
</label>
<div class="control has-icons-left">
<input id="user-id" type="text" placeholder="Username" class="input" required
autocomplete="username" name="username" v-model="user.username" autofocus>
<span class="icon is-left"><i class="fa fa-user"/></span>
</div>
</div>
<div class="field">
<label for="user-password" class="label">Password</label>
<div class="field-body">
<div class="field">
<div class="field has-addons">
<div class="control is-expanded has-icons-left">
<input class="input" id="user-password" v-model="user.password"
required placeholder="Password"
:type="false === form_expose ? 'password' : 'text'">
<span class="icon is-left"><i class="fa fa-lock"/></span>
</div>
<div class="control">
<button type="button" class="button is-primary"
@click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i
class="fas fa-eye"/></span>
<span class="icon" v-else><i class="fas fa-eye-slash"/></span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="field">
<button type="submit" class="button is-fullwidth is-dark is-light">
<span class="icon"><i class="fa fa-sign-in"/></span>
<span>
{{ signup ? 'Signup' : 'Login' }}
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {onMounted, ref} from 'vue'
import {useAuthStore} from '~/store/auth'
definePageMeta({name: "auth", layout: 'guest'})
useHead({title: 'WatchState: Auth'})
const error = ref('')
const form_expose = ref(false)
const signup = ref(false)
const auth = useAuthStore()
const user = ref({username: '', password: ''})
onMounted(async () => signup.value = false === (await auth.has_user()))
const formValidate = async () => {
if (user.value.username.length < 1) {
error.value = 'Username is required.'
return false
}
if (user.value.password.length < 1) {
error.value = 'Password is required.'
return false
}
if (false === /^[a-z_0-9]+$/.test(user.value.username)) {
error.value = 'Username can only contain lowercase letters, numbers and underscores.'
return false
}
if (signup.value) {
return await do_signup()
}
return await do_login()
}
const do_login = async () => {
try {
await auth.login(user.value.username, user.value.password)
if (auth.authenticated) {
notification('success', 'Success', 'Login successful. Redirecting...')
return await navigateTo('/')
}
throw new Error('Login failed. Please check your username and password.')
} catch (e) {
console.log(e)
error.value = e.message
return false
}
}
const do_signup = async () => {
try {
const state = await auth.signup(user.value.username, user.value.password)
if (false === state) {
error.value = 'Failed to create an account.'
return false
}
return await do_login()
} catch (e) {
console.log(e)
error.value = e.message
return false
}
}
</script>

View File

@@ -135,12 +135,12 @@
</div>
</div>
<div class="card-footer-item">
<NuxtLink :to="api_url + backend.urls.webhook" class="is-info is-light"
@click.prevent="copyUrl(backend)">
<a :href="backend.urls.webhook" class="is-info is-light"
@click.prevent="copyUrl(backend)">
<span class="icon"><i class="fas fa-copy"/></span>
<span class="is-hidden-mobile">Copy Webhook URL</span>
<span class="is-hidden-tablet">Webhook</span>
</NuxtLink>
</a>
</div>
</div>
<footer class="card-footer">
@@ -200,7 +200,6 @@ useHead({title: 'Backends'})
const backends = ref([])
const toggleForm = ref(false)
const api_url = useStorage('api_url', '')
const api_user = useStorage('api_user', 'main')
const show_page_tips = useStorage('show_page_tips', true)
const isLoading = ref(false)
@@ -285,7 +284,7 @@ const loadContent = async () => {
onMounted(() => loadContent())
const copyUrl = (backend) => copyText(api_url.value + backend.urls.webhook)
const copyUrl = b => copyText(window.origin + b.urls.webhook)
const updateValue = async (backend, key, newValue) => {
const response = await request(`/backend/${backend.name}`, {

View File

@@ -128,7 +128,8 @@ import {FitAddon} from "@xterm/addon-fit"
import {useStorage} from '@vueuse/core'
import {disableOpacity, enableOpacity, notification} from '~/utils/index'
import Message from '~/components/Message'
import request from "~/utils/request.js";
import request from "~/utils/request.js"
import {fetchEventSource} from '@microsoft/fetch-event-source'
useHead({title: `Console`})
@@ -150,11 +151,10 @@ const hasPrefix = computed(() => command.value.startsWith('console') || command.
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*]/))
const show_page_tips = useStorage('show_page_tips', true)
const allEnabled = ref(false)
const ctrl = new AbortController();
const RunCommand = async () => {
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const token = useStorage('token', '')
/** @type {string} */
let userCommand = command.value
@@ -200,7 +200,7 @@ const RunCommand = async () => {
}
isLoading.value = true
let token;
let commandToken;
try {
const response = await request('/system/command', {
@@ -215,29 +215,54 @@ const RunCommand = async () => {
return;
}
token = json.token
commandToken = json.token
} catch (e) {
await finished()
notification('error', 'Error', e.message, 5000)
return;
}
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
sse = fetchEventSource(`/v1/api/system/command/${commandToken}`, {
signal: ctrl.signal,
headers: {'Authorization': `Token ${token.value}`},
async onmessage(evt) {
switch (evt.event) {
case 'data':
terminal.value.write(JSON.parse(evt.data).data)
break
case 'close':
await finished()
break
case 'exit_code':
exitCode.value = parseInt(evt.data)
break
default:
break
}
},
async onopen(response) {
if (response.ok) {
return
}
const json = await parse_api_response(response)
const message = `${json.error.code}: ${json.error.message}`
notification('error', 'Error', message, 3000)
await finished()
},
async onerror() {
await finished()
},
})
if ('' !== command.value) {
terminal.value.writeln(`(${exitCode.value}) ~ ${userCommand}`)
}
sse.addEventListener('data', async e => terminal.value.write(JSON.parse(e.data).data))
sse.addEventListener('close', async () => finished())
sse.addEventListener('exit_code', async e => exitCode.value = e.data)
sse.onclose = async () => finished()
sse.onerror = async () => finished()
}
const finished = async () => {
if (sse) {
sse.close()
ctrl.abort();
}
isLoading.value = false
@@ -287,7 +312,7 @@ const clearOutput = async () => {
onUnmounted(() => {
window.removeEventListener("resize", reSizeTerminal)
if (sse) {
sse.close()
ctrl.abort();
}
enableOpacity()
})

View File

@@ -10,19 +10,19 @@
<div class="field is-grouped">
<div class="control has-icons-left" v-if="toggleFilter || query">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter"/></span>
</div>
<div class="control">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span class="icon"><i class="fas fa-filter"/></span>
</button>
</div>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Add new variable'" @click="toggleForm = !toggleForm"
:disabled="isLoading">
:disabled="isLoading">
<span class="icon">
<i class="fas fa-add"></i>
</span>
@@ -30,7 +30,7 @@
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading || toggleForm"
:class="{ 'is-loading': isLoading }">
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-sync"></i></span>
</button>
</p>
@@ -45,9 +45,9 @@
<div class="column is-12" v-if="!toggleForm && filteredRows.length < 1">
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..." />
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
<Message v-else message_class="has-background-warning-90 has-text-dark"
:title="query ? 'No results' : 'Information'" icon="fas fa-info-circle">
:title="query ? 'No results' : 'Information'" icon="fas fa-info-circle">
<p v-if="query">
No environment variables found matching <strong>{{ query }}</strong>. Please try a different filter.
</p>
@@ -88,25 +88,25 @@
<div class="field">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" id="api_token" v-model="form_value" required placeholder="Masked value"
:type="false === form_expose ? 'password' : 'text'">
<input class="input" id="form_value" v-model="form_value" required placeholder="Masked value"
:type="false === form_expose ? 'password' : 'text'">
</div>
<div class="control">
<button type="button" class="button is-primary" @click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i class="fas fa-eye" /></span>
<span class="icon" v-else><i class="fas fa-eye-slash" /></span>
<span class="icon" v-if="!form_expose"><i class="fas fa-eye"/></span>
<span class="icon" v-else><i class="fas fa-eye-slash"/></span>
</button>
</div>
</div>
<div>
<p class="help" v-html="getHelp(form_key)" />
<p class="help title is-6" v-html="getHelp(form_key)"/>
</div>
</div>
</div>
<div v-else class="control has-icons-left">
<template v-if="'bool' === form_type">
<input id="form_value" type="checkbox" class="switch is-success" :checked="fixBool(form_value)"
@change="form_value = !fixBool(form_value)">
@change="form_value = !fixBool(form_value)">
<label for="form_value">
<template v-if="fixBool(form_value)">On (True)</template>
<template v-else>Off (False)</template>
@@ -114,7 +114,7 @@
</template>
<template v-else-if="'int' === form_type">
<input class="input" id="form_value" type="number" placeholder="Value" v-model="form_value"
pattern="[0-9]*" inputmode="numeric">
pattern="[0-9]*" inputmode="numeric">
<div class="icon is-small is-left">
<i class="fas fa-font"></i>
</div>
@@ -124,7 +124,7 @@
<div class="icon is-small is-left"><i class="fas fa-font"></i></div>
</template>
<div>
<p class="help" v-html="getHelp(form_key)"></p>
<p class="help title is-6" v-html="getHelp(form_key)"></p>
</div>
</div>
</div>
@@ -150,10 +150,11 @@
</div>
</form>
</div>
<div v-else class="column is-12" v-if="filteredRows">
<div class="columns is-multiline">
<div class="column" v-for="item in filteredRows" :key="item.key"
:class="{ 'is-4': !item?.danger, 'is-12': item.danger }">
:class="{ 'is-4': !item?.danger, 'is-12': item.danger }">
<div class="card" :class="{ 'is-danger': item?.danger }">
<header class="card-header">
<p class="card-header-title is-unselectable">
@@ -171,7 +172,7 @@
</template>
</p>
<span class="card-header-icon" v-if="item.mask" @click="item.mask = false"
v-tooltip="'Unmask the value'">
v-tooltip="'Unmask the value'">
<span class="icon"><i class="fas fa-unlock"></i></span>
</span>
</header>
@@ -187,9 +188,10 @@
</span>
</p>
<p v-else class="is-text-overflow is-clickable is-unselectable"
:class="{ 'is-masked': item.mask, 'is-unselectable': item.mask }"
@click="(e) => e.target.classList.toggle('is-text-overflow')">
{{ item.value }}</p>
:class="{ 'is-masked': item.mask, 'is-unselectable': item.mask }"
@click="(e) => e.target.classList.toggle('is-text-overflow')">
{{ item.value }}
</p>
<p v-if="item?.danger" class="title is-5 has-text-danger">
{{ item.description }}
@@ -229,7 +231,7 @@
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>Some variables values are masked, to unmask them click on icon <i class="fa fa-unlock"></i>.</li>
<li>Some values are too large to fit into the view, clicking on the value will show the full value.</li>
@@ -250,13 +252,13 @@
<script setup>
import 'assets/css/bulma-switch.css'
import request from '~/utils/request'
import { awaitElement, copyText, notification } from '~/utils/index'
import { useStorage } from '@vueuse/core'
import {awaitElement, copyText, notification} from '~/utils/index'
import {useStorage} from '@vueuse/core'
import Message from '~/components/Message'
const route = useRoute()
useHead({ title: 'Environment Variables' })
useHead({title: 'Environment Variables'})
const items = ref([])
const toggleForm = ref(false)
@@ -292,10 +294,15 @@ const loadContent = async () => {
if (item && route.query?.value && !item?.value) {
item.value = route.query.value
}
editEnv(item)
if (!item) {
notification('error', 'Error', `Invalid key '${route.query.edit}'.`, 2000)
await cancelForm()
} else {
editEnv(item);
}
}
} catch (e) {
notification('error', 'Error', e.message, 5000)
notification('error', 'Error', `Error. ${e.message}`, 5000)
} finally {
isLoading.value = false
}
@@ -307,10 +314,10 @@ const deleteEnv = async env => {
}
try {
const response = await request(`/system/env/${env.key}`, { method: 'DELETE' })
const response = await request(`/system/env/${env.key}`, {method: 'DELETE'})
if (200 !== response.status) {
json = await parse_api_response(response)
const json = await parse_api_response(response)
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
return
}
@@ -345,7 +352,7 @@ const addVariable = async () => {
try {
const response = await request(`/system/env/${key}`, {
method: 'POST',
body: JSON.stringify({ value: form_value.value })
body: JSON.stringify({value: form_value.value})
})
if (304 === response.status) {
@@ -383,7 +390,7 @@ const editEnv = env => {
form_mask.value = env.mask
toggleForm.value = true
if (!useRoute().query.edit) {
useRouter().push({ 'path': '/env', query: { 'edit': env.key } })
useRouter().push({'path': '/env', query: {'edit': env.key}})
}
}
@@ -395,12 +402,12 @@ const cancelForm = async () => {
form_mask.value = false
toggleForm.value = false
if (route.query?.callback) {
await navigateTo({ path: route.query.callback })
await navigateTo({path: route.query.callback})
return
}
if (route.query?.edit || route.query?.value) {
await useRouter().push({ path: '/env' })
await useRouter().push({path: '/env'})
}
}
@@ -408,7 +415,7 @@ watch(toggleForm, async value => {
if (!value) {
await cancelForm()
} else {
awaitElement('#env_page_title', (_, el) => el.scrollIntoView({ behavior: 'smooth' }))
awaitElement('#env_page_title', (_, el) => el.scrollIntoView({behavior: 'smooth'}))
}
})
@@ -426,7 +433,7 @@ const keyChanged = () => {
form_value.value = false
}
});
useRouter().push({ 'path': '/env', query: { 'edit': form_key.value } })
useRouter().push({'path': '/env', query: {'edit': form_key.value}})
}
const getHelp = key => {

View File

@@ -8,7 +8,7 @@
<script setup>
const route = useRoute()
const slug = ref(`${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`)
const slug = ref(`${route.params.slug?.length > 0 ? route.params.slug.join('/') : ''}`)
const url = ref('')
onMounted(async () => {
const to_lower = String(slug.value).toLowerCase()
@@ -19,7 +19,7 @@ onMounted(async () => {
const special = ['faq', 'readme', 'news']
if (special.includes(to_lower)) {
url.value = '/' + to_lower.toUpperCase() + '.md'
url.value = '/guides/' + to_lower.toUpperCase() + '.md'
return
}

View File

@@ -3,7 +3,7 @@
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-globe" :class="{'fa-spin': isLoading }"/>&nbsp;</span>
<span class="icon"><i class="fas fa-globe" :class="{ 'fa-spin': isLoading }"/>&nbsp;</span>
<NuxtLink to="/logs">Logs</NuxtLink>
: {{ filename }}
</span>
@@ -74,7 +74,9 @@
</span>
</span>
<span v-for="item in filterItems" :key="item.id" class="is-block">
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{ formatDate(item.date) }}</span>]:&nbsp;</span>
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{
formatDate(item.date)
}}</span>]:&nbsp;</span>
<span v-if="item?.item_id"><span class="is-clickable has-tooltip" @click="goto_history_item(item)"><span
class="icon"><i class="fas fa-history"/></span><span>View</span></span>&nbsp;</span>
<span>{{ item.text }}</span>
@@ -146,6 +148,7 @@ import moment from 'moment'
import {useStorage} from '@vueuse/core'
import {disableOpacity, enableOpacity, goto_history_item, notification, parse_api_response} from '~/utils/index'
import request from '~/utils/request'
import {fetchEventSource} from '@microsoft/fetch-event-source'
const router = useRouter()
const filename = useRoute().params.filename
@@ -165,12 +168,7 @@ const reachedEnd = ref(false)
const offset = ref(0)
let scrollTimeout = null
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const token = useStorage('token', '')
watch(toggleFilter, async () => {
if (!toggleFilter.value) {
@@ -194,6 +192,8 @@ const logContainer = ref(null)
/** @type {Ref<HTMLPreElement|null>} */
const bottomMarker = ref(null)
const ctrl = new AbortController();
const loadContent = async () => {
try {
isLoading.value = true
@@ -298,32 +298,42 @@ const watchLog = () => {
}
// noinspection JSValidateTypes
stream.value = new EventSource(`${api_url.value}${api_path.value}/log/${filename}?stream=1&apikey=${api_token.value}`)
stream.value.addEventListener('data', async (e) => {
let lines = e.data.split(/\n/g);
for (let x = 0; x < lines.length; x++) {
try {
const line = String(lines[x])
if (!line.trim()) {
continue
}
data.value.push(JSON.parse(line))
await nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
}
})
} catch (error) {
console.error(error)
stream.value = fetchEventSource(`/v1/api/log/${filename}?stream=1`, {
onmessage: async evt => {
if ('data' !== evt.event) {
return
}
}
});
let lines = evt.data.split(/\n/g);
for (let x = 0; x < lines.length; x++) {
try {
const line = String(lines[x])
if (!line.trim()) {
continue
}
data.value.push(JSON.parse(line))
await nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
}
})
} catch (error) {
console.error(error)
}
}
},
headers: {
Authorization: `Token ${token.value}`,
},
signal: ctrl.signal,
})
}
const closeStream = () => {
if (stream.value) {
stream.value.close()
ctrl.abort()
stream.value = null
}
}

View File

@@ -337,16 +337,7 @@ const generateToken = async () => {
return;
}
const api_path = useStorage('api_path', '/v1/api').value
const api_url = useStorage('api_url', '').value
let url = `${api_url}${api_path}/player/playlist/${json.token}/master.m3u8`
if (true === json?.secure) {
url = `${url}?apikey=${useStorage('api_token', '').value}`
}
playUrl.value = url
playUrl.value = `/v1/api/player/playlist/${json.token}/master.m3u8`
isPlaying.value = true
await useRouter().push({

95
frontend/store/auth.js Normal file
View File

@@ -0,0 +1,95 @@
import {defineStore} from 'pinia';
import {useStorage} from '@vueuse/core'
export const useAuthStore = defineStore('auth', () => {
const state = reactive({
token: null, authenticated: false, loading: false,
});
const actions = {
async has_user() {
const req = await request('/system/auth/has_user')
return 200 === req.status
},
async signup(username, password) {
if (!username || !password) {
throw new Error('Please provide a valid username and password');
}
const req = await request('/system/auth/signup', {
method: 'POST',
body: JSON.stringify({username: username, password: password})
})
if (201 === req.status) {
return true
}
const json = await parse_api_response(req)
throw new Error(json.error.message);
},
async login(username, password) {
if (!username || !password) {
throw new Error('Please provide a valid username and password');
}
this.loading = true;
try {
const response = await request(`/system/auth/login`, {
method: 'POST',
body: JSON.stringify({username: username, password: password}),
});
const json = await parse_api_response(response)
if (200 !== response.status) {
throw new Error(json.error.message);
}
if (!json?.token) {
throw new Error('Error. API did not return a token.');
}
const token = useStorage('token', null);
token.value = json.token;
this.token = json.token;
this.authenticated = true;
} finally {
this.loading = false;
}
}, async logout() {
const token = useStorage('token', null);
this.authenticated = false;
token.value = null;
return true
}, async validate(token) {
try {
const response = await request('/system/auth/user', {
method: 'GET',
headers: {
Authorization: 'Token ' + token,
}
});
if (200 !== response.status) {
this.token = null;
this.authenticated = false;
return false;
}
this.token = token;
this.authenticated = true;
return true
} catch (e) {
this.token = null;
this.authenticated = false;
return false;
}
}
}
return {...toRefs(state), ...actions};
});

View File

@@ -7,6 +7,7 @@
*/
class Cache {
supportedEngines = ['session', 'local']
namespace = ''
constructor(engine = 'session', namespace = '') {
if (!this.supportedEngines.includes(engine)) {

View File

@@ -1,8 +1,6 @@
import {useStorage} from "@vueuse/core";
import { useStorage } from "@vueuse/core";
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const token = useStorage('token', '')
const api_user = useStorage('api_user', 'main')
/**
@@ -15,18 +13,18 @@ const api_user = useStorage('api_user', 'main')
* @returns {Promise<Response>}
*/
export default async function request(url, options = {}) {
if (!api_token.value) {
throw new Error('API token is not set');
}
options = options || {};
options.method = options.method || 'GET';
options.headers = options.headers || {};
if (options.headers['Authorization'] === undefined) {
options.headers['Authorization'] = 'Bearer ' + api_token.value;
if (options.headers['Authorization'] === undefined && token.value) {
options.headers['Authorization'] = 'Token ' + token.value;
}
if (options.headers['Content-Type'] === undefined) {
options.headers['Content-Type'] = 'application/json';
}
if (options.headers['Accept'] === undefined) {
options.headers['Accept'] = 'application/json';
}
@@ -35,6 +33,6 @@ export default async function request(url, options = {}) {
options.headers['X-User'] = api_user.value;
}
return fetch(`${api_url.value}${api_path.value}${url}`, options);
return fetch(`/v1/api${url}`, options);
}

View File

@@ -653,6 +653,11 @@
semver "^7.5.3"
tar "^7.4.0"
"@microsoft/fetch-event-source@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d"
integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==
"@napi-rs/wasm-runtime@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz#7278122cf94f3b36d8170a8eee7d85356dfa6a96"
@@ -909,6 +914,34 @@
unimport "^5.0.0"
untyped "^2.0.0"
"@nuxt/kit@^3.9.0":
version "3.17.3"
resolved "https://registry.yarnpkg.com/@nuxt/kit/-/kit-3.17.3.tgz#58a54b0155b1a3b277f434de024d7d5e738c38e2"
integrity sha512-aw6u6mT3TnM/MmcCRDMv3i9Sbm5/ZMSJgDl+N+WsrWNDIQ2sWmsqdDkjb/HyXF20SNwc2891hRBkaQr3hG2mhA==
dependencies:
c12 "^3.0.3"
consola "^3.4.2"
defu "^6.1.4"
destr "^2.0.5"
errx "^0.1.0"
exsolve "^1.0.5"
ignore "^7.0.4"
jiti "^2.4.2"
klona "^2.0.6"
knitwork "^1.2.0"
mlly "^1.7.4"
ohash "^2.0.11"
pathe "^2.0.3"
pkg-types "^2.1.0"
scule "^1.3.0"
semver "^7.7.1"
std-env "^3.9.0"
tinyglobby "^0.2.13"
ufo "^1.6.1"
unctx "^2.4.1"
unimport "^5.0.1"
untyped "^2.0.0"
"@nuxt/schema@3.17.1", "@nuxt/schema@^3.16.2":
version "3.17.1"
resolved "https://registry.yarnpkg.com/@nuxt/schema/-/schema-3.17.1.tgz#91a7a47adb82eb445e261e9602bd8de41074a91f"
@@ -1132,6 +1165,13 @@
"@parcel/watcher-win32-ia32" "2.5.1"
"@parcel/watcher-win32-x64" "2.5.1"
"@pinia/nuxt@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pinia/nuxt/-/nuxt-0.11.0.tgz#f97d0c4cbaa3a8b6606eee562ec5c9098fcd1905"
integrity sha512-QGFlUAkeVAhPCTXacrtNP4ti24sGEleVzmxcTALY9IkS6U5OUox7vmNL1pkqBeW39oSNq/UC5m40ofDEPHB1fg==
dependencies:
"@nuxt/kit" "^3.9.0"
"@pkgjs/parseargs@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -1579,6 +1619,13 @@
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/devtools-api@^7.7.2":
version "7.7.6"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.6.tgz#4af5dbc77bcc8543f0a8e6f029f598ed978d6c7d"
integrity sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==
dependencies:
"@vue/devtools-kit" "^7.7.6"
"@vue/devtools-core@^7.7.2":
version "7.7.6"
resolved "https://registry.yarnpkg.com/@vue/devtools-core/-/devtools-core-7.7.6.tgz#7e2ef93c05af809e5ed159ffc1b910f030976b83"
@@ -4750,6 +4797,13 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
pinia@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.2.tgz#0616c2e1b39915f253c7626db3c81b7cdad695da"
integrity sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==
dependencies:
"@vue/devtools-api" "^7.7.2"
pkg-types@^1.0.3, pkg-types@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
@@ -5953,6 +6007,26 @@ unimport@^5.0.0:
unplugin "^2.2.2"
unplugin-utils "^0.2.4"
unimport@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/unimport/-/unimport-5.0.1.tgz#c823ace5819fc810c25435450b22ebc4ab8b11f9"
integrity sha512-1YWzPj6wYhtwHE+9LxRlyqP4DiRrhGfJxdtH475im8ktyZXO3jHj/3PZ97zDdvkYoovFdi0K4SKl3a7l92v3sQ==
dependencies:
acorn "^8.14.1"
escape-string-regexp "^5.0.0"
estree-walker "^3.0.3"
local-pkg "^1.1.1"
magic-string "^0.30.17"
mlly "^1.7.4"
pathe "^2.0.3"
picomatch "^4.0.2"
pkg-types "^2.1.0"
scule "^1.3.0"
strip-literal "^3.0.0"
tinyglobby "^0.2.13"
unplugin "^2.3.2"
unplugin-utils "^0.2.4"
unixify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

BIN
screenshots/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

10
src/API/Player/Index.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\API\Player;
final readonly class Index
{
public const string URL = '%{api.prefix}/player';
}

View File

@@ -15,7 +15,7 @@ use Psr\SimpleCache\InvalidArgumentException;
final readonly class M3u8
{
public const string URL = '%{api.prefix}/player/m3u8';
public const string URL = Index::URL . '/m3u8';
public function __construct(private iCache $cache)
{

View File

@@ -21,7 +21,7 @@ use Throwable;
readonly class Playlist
{
public const string URL = '%{api.prefix}/player/playlist';
public const string URL = Index::URL . '/playlist';
public const float SEGMENT_DUR = 6.000;
public function __construct(private iCache $cache, private iLogger $logger)

View File

@@ -21,7 +21,7 @@ use Throwable;
readonly class Segments
{
public const string URL = '%{api.prefix}/player/segments';
public const string URL = Index::URL . '/segments';
private const array OVERLAY = [
'hdmv_pgs_subtitle',

View File

@@ -21,6 +21,7 @@ use Throwable;
final readonly class Subtitle
{
public const string URL = Index::URL . '/subtitle';
public const array FORMATS = [
'vtt' => 'text/vtt',
'webvtt' => 'text/vtt',
@@ -34,7 +35,6 @@ final readonly class Subtitle
'vtt'
];
public const string URL = '%{api.prefix}/player/subtitle';
private const string EXTERNAL = 'x';
private const string INTERNAL = 'i';

196
src/API/System/Auth.php Normal file
View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
use App\Libs\Middlewares\AuthorizationMiddleware;
use App\Libs\TokenUtil;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Auth
{
use APITraits;
public const string URL = '%{api.prefix}/system/auth';
#[Get(self::URL . '/test[/]', name: 'system.auth.test')]
public function test_open(): iResponse
{
return api_response(Status::OK);
}
#[Get(self::URL . '/has_user[/]', name: 'system.auth.has_user')]
public function has_user(): iResponse
{
$user = Config::get('system.user');
$password = Config::get('system.password');
return api_response(empty($user) || empty($password) ? Status::NO_CONTENT : Status::OK);
}
#[Get(self::URL . '/user[/]', name: 'system.auth.user')]
public function me(iRequest $request): iResponse
{
$user = Config::get('system.user');
$pass = Config::get('system.password');
if (empty($user) || empty($pass)) {
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
}
$token = null;
foreach ($request->getHeader('Authorization') as $auth) {
[$type, $value] = explode(' ', $auth, 2);
$type = strtolower(trim($type));
if ('token' !== $type) {
continue;
}
$token = trim($value);
break;
}
if (empty($token) && ag_exists($request->getQueryParams(), AuthorizationMiddleware::TOKEN_NAME)) {
$token = ag($request->getQueryParams(), AuthorizationMiddleware::TOKEN_NAME);
}
if (empty($token)) {
return api_error('This endpoint only works with user tokens.', Status::UNAUTHORIZED);
}
$token = rawurldecode($token);
try {
$decoded = TokenUtil::decode($token);
if (false === $decoded) {
throw new \RuntimeException('Failed to decode token.');
}
} catch (Throwable) {
return api_error('Failed to decode token.', Status::UNAUTHORIZED);
}
$parts = explode('.', $decoded, 2);
if (2 !== count($parts)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
}
[$signature, $payload] = $parts;
if (false === TokenUtil::verify($payload, $signature)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
}
try {
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
$tokenUser = ag($payload, 'username', fn() => TokenUtil::generateSecret());
$systemUser = Config::get('system.user', fn() => TokenUtil::generateSecret());
if (false === hash_equals($systemUser, $tokenUser)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
}
return api_response(Status::OK, [
'username' => ag($payload, 'username', '??'),
'created_at' => makeDate(ag($payload, 'iat', 0)),
]);
} catch (Throwable) {
return api_error('Failed to decode payload.', Status::UNAUTHORIZED);
}
}
#[Post(self::URL . '/signup[/]', name: 'system.auth.signup')]
public function do_signup(iRequest $request): iResponse
{
$user = Config::get('system.user');
$pass = Config::get('system.password');
if (!empty($user) && !empty($pass)) {
return api_error('System user and password is already configured.', Status::FORBIDDEN);
}
$data = DataUtil::fromRequest($request);
$username = $data->get('username');
$password = $data->get('password');
if (empty($username) || empty($password)) {
return api_error('Username and password are required.', Status::BAD_REQUEST);
}
$response = APIRequest(Method::POST, '/system/env/WS_SYSTEM_PASSWORD', ['value' => $password]);
if (Status::OK !== $response->status) {
$message = r("Failed to set system password. {status}: {message}", [
'status' => $response->status->value,
'message' => ag($response->body, 'error.message', 'Unknown error.')
]);
return api_error($message, $response->status);
}
$response = APIRequest(Method::POST, '/system/env/WS_SYSTEM_USER', ['value' => $username]);
if (Status::OK !== $response->status) {
$message = r("Failed to set system user. {status}: {message}", [
'status' => $response->status->value,
'message' => ag($response->body, 'error.message', 'Unknown error.')
]);
return api_error($message, $response->status);
}
return api_response(Status::CREATED);
}
#[Post(self::URL . '/login[/]', name: 'system.auth.login')]
public function do_login(iRequest $request): iResponse
{
$data = DataUtil::fromRequest($request);
$username = $data->get('username');
$password = $data->get('password');
if (empty($username) || empty($password)) {
return api_error('Username and password are required.', Status::BAD_REQUEST);
}
$system_user = Config::get('system.user');
$system_pass = Config::get('system.password');
if (empty($system_user) || empty($system_pass)) {
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
}
$validUser = true === hash_equals($username, $system_user);
$validPass = password_verify(
$password,
after($system_pass, Config::get('password.prefix', 'ws_hash@:'))
);
if (false === $validUser || false === $validPass) {
return api_error('Invalid username or password.', Status::UNAUTHORIZED);
}
$payload = [
'username' => $system_user,
'iat' => time(),
'version' => getAppVersion(),
];
if (false === ($token = json_encode($payload))) {
return api_error('Failed to encode token.', Status::INTERNAL_SERVER_ERROR);
}
$token = TokenUtil::encode(TokenUtil::sign($token) . '.' . $token);
return api_response(Status::OK, ['token' => $token]);
}
}

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Status;
use App\Libs\Mappers\ImportInterface as iImport;
use App\Libs\Stream;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface as iLogger;
use Throwable;
final class AutoConfig
{
use APITraits;
public const string URL = '%{api.prefix}/system/auto';
public function __construct(private readonly iImport $mapper, private readonly iLogger $logger)
{
}
#[Post(self::URL . '[/]', name: 'system.autoconfig')]
public function __invoke(iRequest $request): iResponse
{
$isEnabled = false;
try {
$initial_file = Config::get('path') . '/config/disable_auto_config.txt';
if (false === file_exists($initial_file)) {
$uc = $this->getUserContext($request, $this->mapper, $this->logger);
$isEnabled = 'main' === $uc->name && count($uc->config) < 1;
$stream = Stream::make($initial_file, 'w+');
$stream->write(r('Auto configure was called and disabled at {time}', [
'time' => makeDate('now'),
]));
$stream->close();
}
} catch (Throwable $e) {
syslog(LOG_ERR, __METHOD__ . ' Exception: ' . $e->getMessage() . ' ' . $e->getTraceAsString());
}
if (false === $isEnabled && false === (bool)Config::get('api.auto', false)) {
return api_error('auto configuration is disabled.', Status::FORBIDDEN);
}
$data = DataUtil::fromRequest($request);
return api_response(Status::OK, [
'url' => $data->get('origin', ag($_SERVER, 'HTTP_ORIGIN', 'localhost')),
'path' => Config::get('api.prefix'),
'token' => Config::get('api.key'),
]);
}
}

View File

@@ -33,6 +33,10 @@ final class Env
continue;
}
if (true === (bool)ag($info, 'hidden', false)) {
continue;
}
$info['value'] = $this->setType($info, $this->envFile->get($info['key']));
}
@@ -50,11 +54,14 @@ final class Env
if (array_key_exists('validate', $info)) {
unset($info['validate']);
}
if (true === (bool)ag($info, 'hidden', false)) {
continue;
}
$list[] = $info;
}
if (true === (bool)$params->get('set', false)) {
$list = array_filter($list, fn ($info) => $this->envFile->has($info['key']));
$list = array_filter($list, fn($info) => $this->envFile->has($info['key']));
}
return api_response(Status::OK, [
@@ -82,7 +89,7 @@ final class Env
return api_response(Status::OK, [
'key' => $key,
'value' => $this->settype($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'value' => $this->settype($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),
@@ -103,12 +110,17 @@ final class Env
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), Status::BAD_REQUEST);
}
$isHidden = true === (bool)ag($spec, 'hidden', false);
if ($isHidden && false === $request->getAttribute('INTERNAL_REQUEST', false)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), Status::NOT_FOUND);
}
if ('DELETE' === $request->getMethod()) {
$this->envFile->remove($key)->persist();
return api_response(Status::OK, [
'key' => $key,
'value' => $this->setType($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'value' => $this->setType($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\DataUtil;
@@ -60,7 +59,6 @@ final readonly class Sign
return api_response(Status::OK, [
'token' => $key,
'secure' => (bool)Config::get('api.secure', false),
'expires' => makeDate()->add($expires)->format(DateTimeInterface::ATOM),
]);
}

View File

@@ -6,7 +6,6 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Input\InputOption;
@@ -28,99 +27,12 @@ final class EnvCommand extends Command
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Show/edit environment variables.')
->setDescription('Manage Environment Variables.')
->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'Key to update.')
->addOption('set', 'e', InputOption::VALUE_REQUIRED, 'Value to set.')
->addOption('delete', 'd', InputOption::VALUE_NONE, 'Delete key.')
->addOption('list', 'l', InputOption::VALUE_NONE, 'List All Supported keys.')
->addOption('expose', 'x', InputOption::VALUE_NONE, 'Expose Hidden values.')
->setHelp(
r(
<<<HELP
This command display the environment variables that was loaded during execution of the tool.
-------------------------------
<notice>[ Environment variables rules ]</notice>
-------------------------------
* The key MUST be in CAPITAL LETTERS. For example [<flag>WS_CRON_IMPORT</flag>].
* The key MUST start with [<flag>WS_</flag>]. For example [<flag>WS_CRON_EXPORT</flag>].
* The value is simple string. No complex data types are allowed. or shell expansion variables.
* The value MUST be in one line. No multi-line values are allowed.
* The key SHOULD attempt to mirror the key path in default config, If not applicable or otherwise impossible it
should then use an approximate path.
-------
<notice>[ FAQ ]</notice>
-------
<question># How to load environment variables?</question>
For <comment>WatchState</comment> specific environment variables, we recommend using the <comment>WebUI</comment>,
to manage the environment variables. However, you can also use this command to manage the environment variables.
We use this file to load your environment variables:
- <flag>{path}</flag>/<comment>.env</comment>
To load container specific variables i,e, the keys that does not start with <comment>WS_</comment> prefix,
you can use the <comment>compose.yaml</comment> file.
For example,
-------------------------------
services:
watchstate:
image: ghcr.io/arabcoders/watchstate:latest
restart: unless-stopped
container_name: watchstate
<flag>environment:</flag>
- <flag>HTTP_PORT</flag>=<value>8080</value>
- <flag>DISABLE_CACHE</flag>=<value>1</value>
.......
-------------------------------
<question># How to set environment variables?</question>
To set an environment variable, you can use the following command:
{cmd} <cmd>{route}</cmd> <flag>-k <value>ENV_NAME</value> -e <value>ENV_VALUE</value></flag>
<notice>Note: if you are using a space within the value you need to use the long form --set, for example:
{cmd} <cmd>{route}</cmd> <flag>-k <value>ENV_NAME</value> --set=<notice>"</notice><value>ENV VALUE</value><notice>"</notice></flag>
As you can notice the spaced value is wrapped with double <value>""</value> quotes.</notice>
<question># How to see all possible environment variables?</question>
{cmd} <cmd>{route}</cmd> <flag>--list</flag>
<question># How to delete environment variable?</question>
{cmd} <cmd>{route}</cmd> <flag>-d -k</flag> <value>ENV_NAME</value>
<question># How to get specific environment variable value?</question>
{cmd} <cmd>{route}</cmd> <flag>-k</flag> <value>ENV_NAME</value>
<notice>This will show the hidden value if the environment variable marked as sensitive.</notice>
<question># How to expose the hidden values for secret environment variables?</question>
You can use the <flag>--expose</flag> flag to expose the hidden values. for both <flag>--list</flag>
or just the normal table display. For example:
{cmd} <cmd>{route}</cmd> <flag>--expose</flag>
HELP,
[
'cmd' => trim(commandContext()),
'route' => self::ROUTE,
'path' => after(Config::get('path') . '/config', ROOT_PATH),
]
)
);
->addOption('expose', 'x', InputOption::VALUE_NONE, 'Expose Hidden values.');
}
/**

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Output\OutputInterface as iOutput;
/**
* Class ResetPasswordCommand
*/
#[Cli(command: self::ROUTE)]
final class ResetPasswordCommand extends Command
{
public const string ROUTE = 'system:resetpassword';
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Reset the system user and password.')
->setHelp(
'Resets the current system user and password to allow you to signup again. It will also reset the secret key'
);
}
protected function runCommand(iInput $input, iOutput $output): int
{
$secret_file = Config::get('path') . '/config/.secret.key';
if (file_exists($secret_file)) {
unlink($secret_file);
}
$response = APIRequest('DELETE', '/system/env/WS_SYSTEM_USER');
if (Status::OK !== $response->status) {
$output->writeln(r("<error>Failed to reset the system user.</error>"));
return self::FAILURE;
}
$response = APIRequest('DELETE', '/system/env/WS_SYSTEM_PASSWORD');
if (Status::OK !== $response->status) {
$output->writeln(r("<error>Failed to reset the system password.</error>"));
return self::FAILURE;
}
$output->writeln(r("<info>System user and password has been reset.</info>"));
return self::SUCCESS;
}
}

View File

@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Libs\Middlewares;
use App\API\Backends\AccessToken;
use App\API\System\AutoConfig;
use App\API\System\HealthCheck;
use App\Libs\Config;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as iHandler;
final class APIKeyRequiredMiddleware implements MiddlewareInterface
{
public const string KEY_NAME = 'apikey';
/**
* Public routes that are accessible without an API key. and must remain open.
*/
private const array PUBLIC_ROUTES = [
HealthCheck::URL,
AutoConfig::URL,
];
/**
* Routes that follow the open route policy. However, those routes are subject to user configuration.
*/
private const array OPEN_ROUTES = [
'/webhook',
'%{api.prefix}/player/'
];
public function process(iRequest $request, iHandler $handler): iResponse
{
if (true === (bool)$request->getAttribute('INTERNAL_REQUEST')) {
return $handler->handle($request);
}
if (Method::OPTIONS === Method::from($request->getMethod())) {
return $handler->handle($request);
}
$requestPath = rtrim($request->getUri()->getPath(), '/');
$openRoutes = self::PUBLIC_ROUTES;
if (false === (bool)Config::get('api.secure', false)) {
$openRoutes = array_merge($openRoutes, self::OPEN_ROUTES);
}
foreach ($openRoutes as $route) {
$route = rtrim(parseConfigValue($route), '/');
if (true === str_starts_with($requestPath, $route) || true === str_ends_with($requestPath, $route)) {
return $handler->handle($request);
}
}
$tokens = $this->parseTokens($request);
if (count($tokens) < 1) {
return api_error('API key is required to access the API.', Status::BAD_REQUEST);
}
if (array_any($tokens, fn ($token) => true === $this->validate($token))) {
return $handler->handle($request);
}
return api_error('incorrect API key.', Status::FORBIDDEN);
}
private function validate(?string $token): bool
{
if (empty($token) || !($storedKey = Config::get('api.key')) || empty($storedKey)) {
return false;
}
return hash_equals($storedKey, $token);
}
private function parseTokens(iRequest $request): array
{
$tokens = [];
if (true === $request->hasHeader('x-' . self::KEY_NAME)) {
$tokens['header'] = $request->getHeaderLine('x-' . self::KEY_NAME);
}
if (true === ag_exists($request->getQueryParams(), self::KEY_NAME)) {
$tokens['param'] = ag($request->getQueryParams(), self::KEY_NAME);
}
$auth = $request->getHeaderLine('Authorization');
if (!empty($auth)) {
[$type, $key] = explode(' ', $auth, 2);
if (true === in_array(strtolower($type), ['bearer', 'token'])) {
$tokens['auth'] = trim($key);
}
}
return array_map(fn ($val) => rawurldecode($val), array_values(array_unique(array_filter($tokens))));
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Libs\Middlewares;
use App\API\Player\Index as PlayerIndex;
use App\API\System\Auth;
use App\API\System\HealthCheck;
use App\Libs\Config;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
use App\Libs\TokenUtil;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as iHandler;
use Throwable;
final class AuthorizationMiddleware implements MiddlewareInterface
{
public const string KEY_NAME = 'apikey';
public const string TOKEN_NAME = 'ws_token';
/**
* Public routes that are accessible without an API key. and must remain open.
*/
private const array PUBLIC_ROUTES = [
HealthCheck::URL,
Auth::URL,
PlayerIndex::URL,
];
/**
* Routes that follow the open route policy. However, those routes are subject to user configuration.
*/
private const array OPEN_ROUTES = [
'/webhook',
];
public function process(iRequest $request, iHandler $handler): iResponse
{
if (true === (bool)$request->getAttribute('INTERNAL_REQUEST')) {
return $handler->handle($request);
}
if (Method::OPTIONS === Method::from($request->getMethod())) {
return $handler->handle($request);
}
$requestPath = rtrim($request->getUri()->getPath(), '/');
$openRoutes = self::PUBLIC_ROUTES;
if (false === (bool)Config::get('api.secure', false)) {
$openRoutes = array_merge($openRoutes, self::OPEN_ROUTES);
}
foreach ($openRoutes as $route) {
$route = rtrim(parseConfigValue($route), '/');
if (true === str_starts_with($requestPath, $route) || true === str_ends_with($requestPath, $route)) {
return $handler->handle($request);
}
}
$tokens = $this->parseTokens($request);
if (count($tokens) < 1) {
return api_error('Authorization is required to access the API.', Status::BAD_REQUEST);
}
if (array_any($tokens, fn($token, $type) => true === $this->validate($type, $token))) {
return $handler->handle($request);
}
return api_error('Incorrect authorization credentials.', Status::UNAUTHORIZED);
}
private function validate(string $type, ?string $token): bool
{
if (empty($token)) {
return false;
}
if ('token' === $type || 'ws_token' === $type) {
return $this->validateToken($token);
}
if (!($storedKey = Config::get('api.key')) || empty($storedKey)) {
return false;
}
return hash_equals($storedKey, $token);
}
/**
* Validate user token.
*
* @param string|null $token The token to validate.
*
* @return bool True if the tken is valid. False otherwise.
*/
public static function validateToken(?string $token): bool
{
if (empty($token)) {
return false;
}
try {
$decoded = TokenUtil::decode($token);
} catch (Throwable) {
return false;
}
if (false === $decoded) {
return false;
}
$parts = explode('.', $decoded, 2);
if (2 !== count($parts)) {
return false;
}
[$signature, $payload] = $parts;
if (false === TokenUtil::verify($payload, $signature)) {
return false;
}
try {
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
$rand = fn() => TokenUtil::generateSecret();
$systemUser = (string)Config::get('system.user', $rand);
$payloadUser = (string)ag($payload, 'username', $rand);
if (false === hash_equals($systemUser, $payloadUser)) {
return false;
}
// $version = (string)ag($payload, 'version', '');
// $currentVersion = getAppVersion();
// if (false === hash_equals($currentVersion, $version)) {
// return false;
// }
} catch (Throwable) {
return false;
}
return true;
}
private function parseTokens(iRequest $request): array
{
$tokens = [];
if (true === $request->hasHeader('x-' . self::KEY_NAME)) {
$tokens['header'] = $request->getHeaderLine('x-' . self::KEY_NAME);
}
if (true === ag_exists($request->getQueryParams(), self::KEY_NAME)) {
$tokens['param'] = ag($request->getQueryParams(), self::KEY_NAME);
}
if (true === ag_exists($request->getQueryParams(), self::TOKEN_NAME)) {
$tokens['ws_token'] = ag($request->getQueryParams(), self::TOKEN_NAME);
}
foreach ($request->getHeader('Authorization') as $auth) {
[$type, $value] = explode(' ', $auth, 2);
$type = strtolower(trim($type));
if (false === in_array($type, ['bearer', 'token'])) {
continue;
}
$tokens[$type] = trim($value);
}
return array_unique(array_map(fn($val) => rawurldecode($val), $tokens));
}
}

View File

@@ -39,6 +39,9 @@ final class ServeStatic implements LoggerAwareInterface
'/NEWS.md' => __DIR__ . '/../../NEWS.md',
'/FAQ.md' => __DIR__ . '/../../FAQ.md',
'/CHANGELOG.md' => __DIR__ . '/../../CHANGELOG.md',
'/guides/README.md' => __DIR__ . '/../../README.md',
'/guides/NEWS.md' => __DIR__ . '/../../NEWS.md',
'/guides/FAQ.md' => __DIR__ . '/../../FAQ.md',
];
private const array MD_IMAGES = [

114
src/Libs/TokenUtil.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Libs;
use App\Libs\Exceptions\RuntimeException;
final class TokenUtil
{
/**
* Sign the given data using HMAC.
*
* @param string $data The data to sign.
* @param string $algo The hashing algorithm to use (default: 'sha256').
*
* @return string The HMAC signature.
*/
public static function sign(string $data, string $algo = 'sha256'): string
{
return hash_hmac($algo, $data, static::getSecret());
}
/**
* Verify the given signature against the data.
*
* @param string $data The data to verify.
* @param string $signature The signature to verify against.
* @param string $algo The hashing algorithm to use (default: 'sha256').
*
* @return bool True if the signature is valid, false otherwise.
*/
public static function verify(string $data, string $signature, $algo = 'sha256'): bool
{
return hash_equals(static::sign($data, $algo), $signature);
}
/**
* URL-safe Base64 Encode.
*
* @param string $data The data to encode.
*
* @return string The encoded data.
*/
public static function encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* URL-safe Base64 Decode.
*
* @param string $input The URL-safe Base64 string to decode.
*
* @return string|false The decoded data, or false on failure.
*/
public static function decode(string $input): string|false
{
$base64 = strtr($input, '-_', '+/');
$pad = strlen($base64) % 4;
if ($pad > 0) {
$base64 .= str_repeat('=', 4 - $pad);
}
return base64_decode($base64);
}
/**
* Generate a random string.
*
* @param int $length The length. (default: 16).
*
*
* @return string The generated random string.
*/
public static function generateSecret(int $length = 16): string
{
return bin2hex(random_bytes($length));
}
/**
* Get the secret key from the config file or generate a new one if it doesn't exist.
*
* @return string The secret key.
*/
private static function getSecret(): string
{
static $_secretKey = null;
if (null !== $_secretKey) {
return $_secretKey;
}
$secretFile = fixPath(Config::get('path') . '/config/.secret.key');
if (false === file_exists($secretFile) || filesize($secretFile) < 32) {
$_secretKey = static::generateSecret();
$stream = Stream::make($secretFile, 'w');
$stream->write($_secretKey);
$stream->close();
return $_secretKey;
}
$_secretKey = Stream::make($secretFile, 'r')->getContents();
if (empty($_secretKey)) {
throw new RuntimeException('Failed to read secret key from file.');
}
return $_secretKey;
}
}

View File

@@ -4,17 +4,16 @@ declare(strict_types=1);
namespace Tests\Libs\Middlewares;
use App\API\Backends\AccessToken;
use App\API\System\AutoConfig;
use App\API\System\Auth;
use App\API\System\HealthCheck;
use App\Libs\Config;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
use App\Libs\Middlewares\APIKeyRequiredMiddleware;
use App\Libs\Middlewares\AuthorizationMiddleware;
use App\Libs\TestCase;
use Tests\Support\RequestResponseTrait;
class APIKeyRequiredMiddlewareTest extends TestCase
class AuthorizationMiddlewareTest extends TestCase
{
use RequestResponseTrait;
@@ -25,7 +24,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
public function test_internal_request()
{
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true),
handler: $this->getHandler()
);
@@ -34,76 +33,77 @@ class APIKeyRequiredMiddlewareTest extends TestCase
public function test_options_request()
{
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(method: Method::OPTIONS),
handler: $this->getHandler()
);
$this->assertSame(Status::OK->value, $result->getStatusCode(), 'Options request failed');
$this->assertSame(Status::OK, Status::from($result->getStatusCode()), 'Options request failed');
}
public function test_open_routes()
{
Config::save('api.prefix', '/v1/api');
$routes = [
HealthCheck::URL,
AutoConfig::URL,
Auth::URL . '/test'
];
$routesSemiOpen = [
'/webhook',
'%{api.prefix}/player/',
];
foreach ($routes as $route) {
$uri = parseConfigValue($route);
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(uri: $uri),
handler: $this->getHandler()
);
$this->assertSame(Status::OK->value, $result->getStatusCode(), "Open route '{$route}' failed");
$this->assertSame(Status::OK, Status::from($result->getStatusCode()), "Open route '{$uri}' failed");
}
foreach ($routesSemiOpen as $route) {
$uri = parseConfigValue($route);
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(uri: $uri),
handler: $this->getHandler()
);
$this->assertSame(Status::OK->value, $result->getStatusCode(), "Open route '{$route}' failed");
$this->assertSame(Status::OK, Status::from($result->getStatusCode()), "Open route '{$uri}' failed");
}
Config::save('api.secure', true);
foreach ($routesSemiOpen as $route) {
$uri = parseConfigValue($route);
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(uri: $uri)->withoutHeader('Authorization'),
handler: $this->getHandler()
);
$this->assertSame(
Status::BAD_REQUEST->value,
$result->getStatusCode(),
"Route '{$route}' should fail without API key"
Status::BAD_REQUEST,
Status::from($result->getStatusCode()),
"Route '{$uri}' should fail without API key"
);
}
foreach ($routesSemiOpen as $route) {
$uri = parseConfigValue($route);
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(uri: $uri)->withHeader('Authorization', 'Bearer api'),
handler: $this->getHandler()
);
$this->assertSame(
Status::FORBIDDEN->value,
$result->getStatusCode(),
"Route '{$route}' should fail without correct API key"
Status::UNAUTHORIZED,
Status::from($result->getStatusCode()),
"Route '{$uri}' should fail without correct API key"
);
}
Config::save('api.key', 'api_test_token');
foreach ($routesSemiOpen as $route) {
$uri = parseConfigValue($route);
$result = new APIKeyRequiredMiddleware()->process(
$result = new AuthorizationMiddleware()->process(
request: $this->getRequest(uri: $uri, query: ['apikey' => 'api_test_token'])->withHeader(
'X-apikey',
'api_test_token'
@@ -111,9 +111,9 @@ class APIKeyRequiredMiddlewareTest extends TestCase
handler: $this->getHandler()
);
$this->assertSame(
Status::OK->value,
$result->getStatusCode(),
"Route '{$route}' should pass with correct API key"
Status::OK,
Status::from($result->getStatusCode()),
"Route '{$uri}' should pass with correct API key"
);
}

View File

@@ -100,11 +100,11 @@ class ServeStaticTest extends TestCase
// -- test screenshots serving, as screenshots path is not in public directory and not subject
// -- to same path restrictions as other files.
$response = $this->server->serve($this->createRequest('GET', '/screenshots/add_backend.png'));
$response = $this->server->serve($this->createRequest('GET', '/screenshots/index.png'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('image/png', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../../screenshots/add_backend.png'),
file_get_contents(__DIR__ . '/../../screenshots/index.png'),
(string)$response->getBody()
);

View File

@@ -18,7 +18,7 @@ trait RequestResponseTrait
{
$response ??= new Response(Status::OK);
return new readonly class($response) implements iHandler {
return new class($response) implements iHandler {
private mixed $response;
public function __construct(iResponse|callable $response)