Merge pull request #666 from arabcoders/user-pass
Migrate to user/pass auth
This commit is contained in:
275
FAQ.md
275
FAQ.md
@@ -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:*`.
|
||||
|
||||
|
||||
53
README.md
53
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
64
frontend/components/Settings.vue
Normal file
64
frontend/components/Settings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
9
frontend/layouts/guest.vue
Normal file
9
frontend/layouts/guest.vue
Normal 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>
|
||||
36
frontend/middleware/auth.global.js
Normal file
36
frontend/middleware/auth.global.js
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
135
frontend/pages/auth.vue
Normal 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>
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }"/> </span>
|
||||
<span class="icon"><i class="fas fa-globe" :class="{ 'fa-spin': isLoading }"/> </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>]: </span>
|
||||
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{
|
||||
formatDate(item.date)
|
||||
}}</span>]: </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> </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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
95
frontend/store/auth.js
Normal 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};
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
class Cache {
|
||||
supportedEngines = ['session', 'local']
|
||||
namespace = ''
|
||||
|
||||
constructor(engine = 'session', namespace = '') {
|
||||
if (!this.supportedEngines.includes(engine)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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
10
src/API/Player/Index.php
Normal 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';
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
196
src/API/System/Auth.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
54
src/Commands/System/ResetPasswordCommand.php
Normal file
54
src/Commands/System/ResetPasswordCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
180
src/Libs/Middlewares/AuthorizationMiddleware.php
Normal file
180
src/Libs/Middlewares/AuthorizationMiddleware.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
114
src/Libs/TokenUtil.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user