major update to sub-users creation

This commit is contained in:
arabcoders
2025-04-11 19:09:01 +03:00
parent 85b2476ca5
commit 9e9dfb4868
10 changed files with 1164 additions and 653 deletions

739
FAQ.md
View File

@@ -1,386 +1,350 @@
# FAQ
To see list of all the available commands run
# How to find the API key?
```shell
$ docker exec -ti watchstate console list
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
```
This command will list all available commands, and each command has help document attached to it, simply run
The value after the equals sign is your API key.
```shell
$ docker exec -ti watchstate console help [COMMAND_NAME]
## 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
```
It will show you the relevant information regarding the command and some frequently asked question about that command.
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 help document attached to each command is more up to date and precise. So, please read it.**
> 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 turn on scheduled tasks for import/export?
# How to enable scheduled/automatic tasks?
Scheduled tasks are configured via specific environment variables refers
to [environment variables](#environment-variables) section,
To turn on automatic import or export tasks:
## Via WebUI
1. Go to the `Tasks` page in the WebUI.
2. Enable the tasks you want to schedule for automatic execution.
Simply go to `Tasks` page and enable the tasks you want to run.
By default:
## Via CLI
- **Import** task runs every **1 hour**
- **Export** task runs every **1 hour and 30 minutes**
```bash
$ docker exec -ti watchstate console system:env -k WS_CRON_IMPORT -e true
$ docker exec -ti watchstate console system:env -k WS_CRON_EXPORT -e true
```
If you want to customize the schedule, you can do so by adding environment variables with valid cron expressions:
By default, `import` is scheduled to run every `1hour` while `export` is scheduled to run every `1hour 30minutes`, you
can alter the time when the tasks are run via adding the following variables with valid cron expression. good source to
check your cron expression is [crontab.guru](https://crontab.guru/).
- **WS_CRON_IMPORT_AT**
- **WS_CRON_EXPORT_AT**
While we think they are reasonable defaults, you can change them by setting the following environment variables:
## Via WebUI
Go to the `env` page, click on `+` button, then select the key in this case `WS_CRON_IMPORT_AT`, `WS_CRON_EXPORT_AT`
and set the value to a valid cron expression. Then click save to apply the new timer. This will be change later to be
included with
the tasks page.
## Via CLI
Execute the following commands:
```bash
$ docker exec -ti watchstate console system:env -k WS_CRON_IMPORT_AT -e '"*/1 * * * *"'
$ docker exec -ti watchstate console system:env -k WS_CRON_EXPORT_AT -e '"30 */1 * * *"'
```
For Values with space, they must be enclosed in double quotes. to see the status of your scheduled tasks,
## Via WebUI
Go to the `Tasks` page, you will see the status of each task.
## Via CLI
```bash
$ docker exec -ti watchstate console system:tasks
```
You can set these variables from the <code>Env</code> page.
> [!NOTE]
> All scheduled tasks are configured via the same variables style, refer
> to [Tool specific environment variables](#tool-specific-environment-variables) for more information.
> A great tool to validate your cron expression is [crontab.guru](https://crontab.guru/)
----
After making changes, visit the `Tasks` page again to see the updated schedule next to `Next Run`.
# Container is crashing on startup?
---
This is likely due to misconfigured `user:` in `compose.yaml`, the container is rootless as such it will crash if
the tool unable to access the data path. to check permissions simply do the following
# Container is crashing on start-up?
This usually happens due to a misconfigured `user:` in your `compose.yaml` file or an incorrect `--user` argument. The
container runs in **rootless mode**, so it will crash if it doesnt have permission to access the data directory.
## Check permissions
Run the following command to inspect ownership of your data directory:
```bash
$ stat data/config/ | grep 'Uid:'
```
It should show something like
The path `data/config/` refers to where you have mounted your data directory. Adjust it if necessary.
You should see output like:
```
Access: (0755/drwxr-xr-x) Uid: ( 1000/ user) Gid: ( 1000/ user)
```
Use the ids as parameters for `user:` in this case it should be `user:"1000:1000"`.
## Fixing the issue
----
Use the UID and GID from the output as parameters:
# How to find the apikey?
### compose.yaml
You can find the apikey inside the following file `/config/config/.env`. The apikey is stored inside this
variable `WS_API_KEY=`.
Or you can run the following command to get it directly:
```yaml
user: "1000:1000"
```
### docker run
```bash
$ docker exec -ti console system:apikey
docker run ... --user 1000:1000
```
----
# What the API key used for?
The API key is used to authenticate the requests to the tool, it's used to prevent unauthorized access. The API key is
required for all endpoints except the `/v1/api/[backend_name]/webhook` endpoint which is open by default unless you have
enabled `WS_SECURE_API_ENDPOINTS` environment variable. which then you also need to use the apikey for it webhook
endpoint.
The new `WebUI` will also require the API key to access data as it's decoupled from the backend and run in standalone
mode.
Make sure the container has the correct permissions to access all necessary data paths to prevent crashes.
----
# MAPPER: Watch state conflict detected in [BACKEND_NAME]...?
# MAPPER: Conflict Detected in `'user@backend_name`...?
This warning occurs when the database has the movie/episode marked as played but a backend reporting the
item as unplayed and there is no metadata that indicates that the movie was previously imported from the backend.
So, Preserving your current watch state takes priority, and thus we mark the item as tainted and re-process it.
To Fix this conflict you should re-export your database state back to the problematic backend using the following
command:
This warning appears when there is a mismatch between the local database and a backend regarding the watch state of a
movie or episode. Specifically, it means:
- The item is marked as **played** in the local database.
- The backend is reporting it as **unplayed**.
- There is no metadata indicating that the item was previously imported from that backend to make it possible to mark it
as unplayed.
In this case, the system prioritizes preserving your local play state. The item is marked as **tainted** and will be
re-processed accordingly.
## How to resolve the conflict?
To resolve this conflict and sync the backend with your local state:
* Go to the `WebUI > Backends`.
* Under the relevant backend, find the **Frequently used commands** list.
* Select **3. Force export local play state to this backend.**
This operation will overwrite the backend's watch state with your current local state to bring them back in sync.
----
# How to Use Jellyfin or Emby OAuth Tokens
Due to limitations on the Jellyfin/Emby side, our implementation requires you to provide your credentials in the
`username:password` format. This is necessary because their API does not allow us to determine the currently
authenticated user directly.
When prompted for the API key, simply enter your credentials like this:
```
username:password
```
WatchState will then generate an OAuth token for you and automatically replace the username and password with the token.
This is a one-time process, and you wont need to repeat it.
Your `username` and `password` are **not** stored.
----
# My New Backend Is Overriding My Old Backends State / My Watch State Is Incorrect
This issue typically occurs when a newly added backend reports **newer timestamps** than an existing backend.
By default, the system prioritizes data with the latest timestamps, which usually ensures the most up-to-date watch
state is preserved.
However, if the new backend's state is incorrect, it may unintentionally override your accurate local watch history.
## How to Fix the the play state
To synchronize both backends correctly:
* **Add the backend** that contains the correct watch state first.
* Enable **Full Import** for that backend.
* Go to `Tasks` page, and run the **Import** task via `Run via console` button.
* Once the import is complete, **add the second backend** (the one with incorrect or outdated play state).
* Under the newly added backend, locate the **Frequently used commands** section.
* Select **3. Force export local play state to this backend.**
This will push your local watch state to the backend and ensure both are in sync.
----
# My New Backend Watch State Is Not Being Updated?
This issue is most likely caused by a **date mismatch**. When exporting watch state, the system compares the date of the
item on the backend with the date stored in the local database. If the backend's date is equal to or newer than the
local one, the export may be skipped.
To confirm if this is the issue, follow these steps:
1. Go to the `WebUI > Backends`.
2. Under the relevant backend, locate the **Frequently Used Commands** section.
3. Select **7. Run export and save debug log.**
This will generate a log file at `/config/user@backend_name.export.txt` If the file is too large to view in a regular
text editor, you can read it using:
```bash
$ docker exec -ti console state:export -fi -s [BACKEND_NAME]
docker exec -ti watchstate bash -c "cat /config/user@backend_name.export.txt | more"
```
----
# How to use Jellyfin, Emby oauth tokens?
Due to limitation on jellyfin/emby side, the way we implemented support for oauth token require that you provide the
username and password in `username:password` format, This is due to the API not providing a way for us to inquiry about
the current user.
Simply, when asked for API Key, provide the username and password in the following format `username:password`.
`WatchState` will then generate the token for you. and replace the username and password with the generated token. This
is a one time process, and you should not need to do it again. Your `username` and `password` will not be stored.
----
# My new backend overriding my old backend state / My watch state is not correct?
This likely due to the new backend reporting newer date than your old backend. as such the typical setup is to
prioritize items with newer date compared to old ones. This is what you want to happen normally. However, if the new
media backend state is not correct this might override your current watch state. The solution to get both in sync, and
to do so follow these steps:
Add your backend that has correct watch state and enable full import. Second, add your new backend as metadata source.
In `CLI` context Answer `N` to the question `Enable importing of metadata and play state from this backend?`. Make sure
to select yes
for export to get the option to select the backend as metadata source.
In `WebUI` if you disable import, you will get an extra option that is normally hidden to select the backend as metadata
source.
After that, do single backend export by using the following command:
```bash
$ docker exec -ti watchstate console state:export -vvif -s new_backend_name
```
Running this command will force full export your current database state to the selected backend. Once that done you can
turn on import from the new backend.
In `CLI` context you can enable import by running the following command:
```bash
$ docker exec -ti watchstate console config:manage -s backend_name
```
In `WebUI` you can enable import by going to the `backends` page and click on import for the new backend.
----
# My new backend watch state not being updated?
The likely cause of this problem is date related problem, as we check the date on backend object and compare that to the
date in local database, to make sure this is the error you are facing please do the following.
Look for lines like the following:
```
$ docker exec -ti watchstate console state:export -s new_backend_name --debug --logfile /config/export.txt
[YYYY-MM-DDTHH:MM:SS-ZZ] DEBUG: Ignoring 'user@backend_name' 'Title - (Year or episode)'. reason. { ..., (comparison: [ ... ]) }
```
After running the command, open the log file and look for episode and movie that has the problem and read the text next
to it. The error usually looks like:
If you see messages such as `Backend date is equal or newer than database date.` that confirms the issue.
```
[YYYY-MM-DDTHH:MM:SS-ZZ] DEBUG: Ignoring [backend_name] [Title - (Year or episode)]. reason. { ..., (comparison: [ ... ]) }
```
## How to Fix It
In this case the error text should be `Backend date is equal or newer than database date.`
To override the date check and force an update do the following:
To bypass the date check you need to force ignore date comparison by using the `[-i, --ignore-date]` flag, so to get
your new backend in sync with your old database, do the following:
* Go to the `WebUI > Backends`.
* Under the relevant backend, find the **Frequently used commands** list.
* Select **3. Force export local play state to this backend.**
```bash
$ docker exec -ti watchstate console state:export -vvif -s new_backend_name
```
This command will ignore your `lastSync` date and will also ignore `object date comparison` and thus will mirror your
database state back to the selected backend.
This will sync your local database state to the backend, ignoring date comparisons.
----
# Is there support for Multi-user setup?
We are on early stage of supporting multi-user setups, initially few operations are supported. To get started, first you
need to create your own main user backends using admin token for Plex and api key for Jellyfin/Emby.
There is **basic** support for multi-user setups, but it's not fully developed yet. The tool is primarily designed for
single-user use, and multi-user functionality is built on top of that. Because of this, you might encounter some issues
when using it with multiple users.
Once your own main user is added, make sure to turn on the `import` and `export` for all backends, as the sub users are
initial configuration is based on your own main user configuration. Once your own user is working, turn on the `import`
and `export` tasks in the Tasks page.
## Getting started with a multi-user setup
Now, to create the sub users configurations, you need to run `backend:create` command, which can be done via
`WebUI > Backends > Purple button (users) icon` or via CLI by running the following command:
1. **Add your backends** as you normally would. Make sure to include the backends for your main user.
2. For the `Plex` backend, you must use an **Admin-level `X-Plex-Token`**. Without it, we wont be able to retrieve the
list of users.
You can check your token by going to `Tools > Plex Token`.
- If you see a success message, youre good to go.
- If you see an error message, it likely means your token has limited permissions and cant be used to access the
user list.
3. For `Jellyfin` and `Emby` backends, use an **API key**, which can be generated from your server settings:
- Go to `Dashboard > Advanced > API Keys` and create a new key.
4. After setting up your backends and verifying they work, go to `Tools > Sub Users`.
The system will attempt to automatically group users based on their names. However, because naming can vary between
setups, not all users may be matched correctly. You can manually organize the groups by dragging and dropping
users.
5. Once you're satisfied with the setup, click the `Create Sub-users` button to generate the configuration.
```bash
$ docker exec -ti watchstate console backend:create -v
```
> [!NOTE]
> The sub-user configurations are based on your current main user settings. If you change the main configuration (e.g.,
> backend URL), you must either:
> * Manually update the sub-user backends, or
> * Click `Update Sub-users`, which will try to update them automatically. This action can also **create new sub-users**
if they dont already exist—so use it carefully.
Once the sub users configuration is created, You can start using the multi-user functionality.
If your users usernames are different between the backends, you can use the `mapper.yaml` file to map the users between
the backends. For more information about the `mapper.yaml` file, please refer to
the [mapper.yaml](#whats-the-schema-for-the-mapperyaml-file) section.
# Whats the schema for the `mapper.yaml` file?
The schema is simple, it's a list of users in the following format:
```yaml
version: "1.5"
map:
# 1st user...
- my_plex_server: # your main user backend name
name: "mike_jones"
options: { } # optional key.
my_jellyfin_server: # your main user backend name
name: "jones_mike"
my_emby_server: # your main user backend name
name: "mikeJones"
replace_with: "mike_jones" # optional action, to replace the username with the new one.
# 2nd user...
- my_emby_server: # your main user backend name
name: "jiji_jones"
options: { } # optional key.
my_plex_server: # your main user backend name
name: "jones_jiji"
my_jellyfin_server: # your main user backend name
name: "jiji.jones"
replace_with: "jiji_jones" # optional action, to replace the username with the new one.
#.... more users
```
If you create a map for a user, it SHOULD include all the backends you want to sync the user data with. while the
matcher might automatically detect the other backends even if not included in the map, it best to manually set them in
group to prevent any issues that might arise. Each list item is a user, and each user has a list of backends. Each
backend.
> [!NOTE]
> the backend names `my_plex_server`, `my_jellyfin_server`, `my_emby_server` are the names you have chosen for
> your backends.
>
> The `name` field must match the name after normalization, so if you have a backend with the name `Mike Jones` as
> username, the `name:` in the `mapper.yaml` file should be `mike_jones` as the `backend:create` command will normalize
> the name before passing it to the mapper which the mapper will convert it to `mike_jones`.
Once your sub-user setup is ready, you can start using the multi-user features.
## Important
We enforce strict naming convention for backend names and usernames, So they must follow the following format
`^[a-z_0-9]+$` which means, lowercase letters, numbers and `_` are allowed. No spaces, uppercase letters or special
characters or name entirely made of digits are allowed. If the username is not complying with the naming convention, we
forcibly normalize it to make it comply with the naming convention.
We enforce a strict naming convention for both backend names and usernames:
If you want another name, you can use `replace_with` key to replace the username with the new one. However, the name
also must comply with the naming convention.
**Format:** `^[a-z_0-9]+$`
This yaml file helps map your users usernames in the different backends, so the tool can sync the correct user data. If
you added or updated mapping, you should delete `users` directory and generate new data. by running the `backend:create`
command as described in the previous section.
Which means
You can run the `backend:create` command with `-v --dry-run` to see what it will do and if you need to create a mapping
file or not.
* Allowed: lowercase letters, numbers, and underscores (`_`)
* Not allowed: spaces, uppercase letters, or special characters
If any username doesnt follow this convention, well **automatically normalize** it, if the name is made entirely of
digits, well automatically prefix it with `user_`.
----
# How do i migrate invited friends i.e. (external user) data from from plex to emby/jellyfin?
# Does WatchState requires Webhooks to work?
As this tool is designed to work with single user, You have to treat each invited friend as a separate user. what is
needed, you need to contact that friend of yours and ask him/her to give you a copy of
the [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/),
then create new container and add the backend with the token you got from your friend.
After that, add your other backends like emby/jellyfin using your regular API key. jellyfin/emby differentiate between
users by using the userId which you should select at the start of the add process.
After that. run the `state:import -f -s [plex_server_name]` command to import the user watch state. After that, you can
run the `state:export -fi -s [emby/jellyfin_server_name]` to export the watch state to the new backend.
You have to repeat these steps for each user you want to migrate their data off the plex server.
> [!IMPORTANT]
> YOU MUST always start with fresh data for **EACH USER**, otherwise unexpected things might happen.
> Make sure to delete compose.yaml `./data` directory. to start fresh.
----
# Does this tool require webhooks to work?
No, You can use the `task scheduler` or on `on demand sync` if you want.
No, webhooks are **not required** for the tool to function. You can use the built-in **scheduled tasks** or manually run
**import/export operations** on demand through the WebUI or console.
---
# I get tired of writing the whole command everytime is there an easy way run the commands?
# I'm Using Media Backends Hosted Behind HTTPS and See Errors Related to HTTP/2
Good News, There is a way to make your life easier, We recently added a `WebUI` which should cover most of the use
cases.
However, if you still want to use the `CLI` You can create a shell script to omit
the `docker exec -ti watchstate console`
In some cases, issues may arise due to HTTP/2 compatibility problems with our internal http client. Before submitting a
bug report, please try the following workaround:
```bash
$ echo 'docker exec -ti watchstate console "$@"' > ws
$ chmod +x ws
```
* Go to the `WebUI > Backends`.
* Find the backend where the issue occurs and click the **Edit** button.
* Expand the **Additional options...** section.
* Under **Add new option**, select `client.http_version` from the dropdown list.
* Click the green **+** add button.
* Once the option appears, set its value to `1.0`.
* Click **Save Settings**.
after that you can do `./ws command` for example, `./ws db:list`
---
# I am using media backends hosted behind HTTPS, and see errors related to HTTP/2?
Sometimes there are problems related to HTTP/2, so before reporting bug please try running the following command:
```bash
$ docker exec -ti watchstate console config:edit --key options.client.http_version --set 1.0 -s backend_name
```
This will force set the internal http client to use `http/v1` if it does not fix your problem, please open bug report
about it.
This setting forces the internal HTTP client to use **HTTP/1.1** instead of HTTP/2. If the issue persists after making
this change, please open a bug report so it can be investigated further.
---
# Sync operations are failing due to request timeout?
If you want to increase the timeout for specific backend you can run the following command:
If you're encountering request timeouts during sync operations, you can increase the timeout for a specific backend by
following these steps:
```bash
$ docker exec -ti watchstate console config:edit --key options.client.timeout --set 600 -s backend_name
```
* Go to the `WebUI > Backends`.
* Find the backend where the issue occurs and click the **Edit** button.
* Expand the **Additional options...** section.
* Under **Add new option**, select `client.timeout` from the dropdown list.
* Click the green **+** add button.
* Once the option appears, set its value to `600`.
* Click **Save Settings**.
where `600` is the number of seconds before the timeout handler will kill the request.
The value `600` represents the number of seconds the system will wait before terminating the request due to a timeout.
---
# How to fix corrupt SQLite database?
# How to fix a corrupt sqlite database
Sometimes your SQLite database will be corrupted, and you will get an error similar to this
`General error: 11 database disk image is malformed`. To fix this error simply execute the following commands:
If your SQLite database becomes corrupted, you may see an error like:
```
General error: 11 database disk image is malformed
```
To repair the database, follow these steps:
```bash
$ docker exec -ti watchstate bash
$ sqlite3 /config/db/watchstate_v01.db '.dump' | sqlite3 /config/db/watchstate_v01-repaired.db
```
After executing the previous command you should run `integrity check`, by running the following command:
Once the dump and rebuild are complete, perform an integrity check:
```bash
$ sqlite3 /config/db/watchstate_v01-repaired.db 'PRAGMA integrity_check'
```
it should simply say `ok`. then you should run the following command to replace the corrupted database.
If the output is simply `ok`, the repaired database is valid. You can then replace the corrupted database with the
repaired one:
```bash
$ mv /config/db/watchstate_v01-repaired.db /config/db/watchstate_v01.db
```
Your system should now use the repaired database without errors.
---
# Which external db ids `GUIDS` supported for Plex Media Server?
@@ -426,22 +390,11 @@ The recommended approach is for keys that starts with `WS_` use the `WebUI > Env
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
## Via WebUI
Go to `Env` page, you will see all the environment variables loaded.
## Via CLI
```shell
$ docker exec -ti watchstate console system:env
```
to see list of loaded environment variables, click on `Env` page in the WebUI.
## Tool specific environment variables.
These environment variables relates to the tool itself, You should manage them via `WebUI > Env` page or `system:env`
command via CLI.
These environment variables relates to the tool itself, You should manage them via `WebUI > Env` page
| Key | Type | Description | Default |
|-------------------------|---------|-------------------------------------------------------------------------|--------------------------|
@@ -462,26 +415,13 @@ command via CLI.
| WS_SECURE_API_ENDPOINTS | bool | Disregard the open route policy and require API key for all endpoints. | `false` |
> [!IMPORTANT]
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one
> of `IMPORT`, `EXPORT`, `BACKUP`, `PRUNE`, `INDEXES`. To see tasks active settings run
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one of `IMPORT`, `EXPORT`, `BACKUP`,
`PRUNE`, `INDEXES`.
```bash
$ docker exec -ti watchstate console system:tasks
```
> [!NOTE]
> To see all supported tool specific environment variables
### Via WebUI
## Add tool specific environment variables
Go to the `Env` page, click `+` button, you will get list of all supported keys with description.
### Via CLI
```bash
$ docker exec -ti watchstate console system:env --list
```
## Container specific environment variables.
> [!IMPORTANT]
@@ -503,27 +443,50 @@ $ docker exec -ti watchstate console system:env --list
---
# How to add webhooks?
# How to Add Webhooks
The Webhook URL is backend specific, the request path is `/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`,
Where `[USER]` is the username for sub user or `main` for main user and `[BACKEND_NAME]` is the name of the backend you
want to add webhook for. Typically, the full URL
is `http://localhost:8080/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`. Or simply go to the `WebUI > Backends` and
click on `Copy Webhook URL`.
Webhook URLs are **backend-specific** and follow this structure:
> [!NOTE]
> You will keep seeing the `webhook.token` key, it's being kept for backward compatibility, and will be removed in the
> future. It has no effect except as pointer to the new method.
```
/v1/api/backend/[USER]@[BACKEND_NAME]/webhook
```
- `[USER]` should be the username of the sub-user, or `main` for the main user.
- `[BACKEND_NAME]` is the name of the backend you want to configure the webhook for.
A typical full URL might look like:
```
http://localhost:8080/v1/api/backend/main@plex_foo/webhook
```
To get the correct URL easily:
* Go to `WebUI > Backends`.
* Click on **Copy Webhook URL** next to the relevant backend.
> [!IMPORTANT]
> As support more sub users expands, it's important to turn on `Webhook match user` for all backends to prevent
> sub users from changing the main user watch state. in case of plex backend.
> If you have enabled `WS_SECURE_API_ENDPOINTS`, you have to add `?apikey=yourapikey` to the end of the the webhook URL.
> [!NOTE]
> You may see a `webhook.token` key in older configurations. This is retained only for backward compatibility and has no
> effect. It will be removed in future versions.
>
> If you're using Plex and have sub-users, make sure to enable **Webhook Match User** to prevent sub-user activity from
> affecting the main user's watch state.
-----
## Emby (you need `Emby Premiere` to use webhooks).
Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
Go to your Manage Emby Server:
* Old Emby versions: Server > Webhooks > (Click Add Webhook) `Old version`
* New Emby versions: username Preferences > Notifications > + Add Notification > Webhooks
### Name (Emby v4.9+):
`user@backend` or whatever you want, i simply prefer the name to reflect which user it belongs to.
### Webhook/Notifications URL:
@@ -569,7 +532,7 @@ go back again to dashboard > plugins > webhook. Add `Add Generic Destination`,
### Webhook Name:
`Watchstate-Webhook`
`user@backend` or whatever you want, i simply prefer the name to reflect which user it belongs to.
#### Webhook Url:
@@ -613,9 +576,6 @@ Go to your Plex Web UI > Settings > Your Account > Webhooks > (Click ADD WEBHOOK
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[USER]` with the `main` for main user or the sub user username.
> [!IMPORTANT]
> If you have enabled `WS_SECURE_API_ENDPOINTS`, you have to add `?apikey=yourapikey` to the end of the URL.
Click `Save Changes`
> [!NOTE]
@@ -763,60 +723,67 @@ Click `Save`
---
# What are the webhook limitations?
# Webhook Limitations by Media Backend
Those are some webhook limitations we discovered for the following media backends.
Below are known limitations and issues when using webhooks with different media backends:
## Plex
* Plex does not send webhooks events for "marked as played/unplayed" for all item types.
* Sometimes does not send events if you add more than one item at time.
* When you mark items as unwatched, Plex reset the date on the object.
- Webhooks are **not sent** for "marked as played/unplayed" actions on all item types.
- Webhook events may be **skipped** if multiple items are added at once.
- When items are marked as **unwatched**, Plex resets the date on the media object.
## Plex Via Tautulli
## Plex via Tautulli
* Tautulli does not send user id with itemAdd `created` event. as such if you enabled `Match webhook user`, new items
will not be added and fail with `Request user id '' does not match configured value`.
* Marked as unplayed will most likely not work with Tautulli webhook events as it's missing critical data we need to
determine if the item is marked as unplayed.
- Tautulli does **not send user IDs** with `itemAdd` (`created`) events. If `Match webhook user` is enabled, the request
will fail with: `Request user id '' does not match configured value`.
- **Marking items as unplayed** is not reliable, as Tautulli's webhook payload lacks critical data required to detect
this change.
## Emby
* Emby does not send webhooks events for newly added items.
~~[See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)~~
implemented in `4.7.9` ~~still does not work as expected no metadata being sent when the item notification goes out.
* Emby webhook test event does not contain data~~. It seems to have been fixed in `4.9.0.37+` To test if your setup
works, play something or do mark an item as played or unplayed you should see changes reflected in
`docker exec -ti watchstate console db:list`.
- Emby does **not send webhook events** for newly added items.
~~[See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)~~ This
was implemented in version `4.7.9`, but still does **not include metadata**, making it ineffective.
- The Emby **webhook test event** previously contained no data. This appears to be **fixed in `4.9.0.37+`**.
- To verify if your Emby webhook setup is working, try playing or marking an item as played/unplayed, go to the history
page after a min or two and check if the changes are reflected in the database.
## Jellyfin
* If you don't select a user id, the plugin will send `itemAdd` event without user data, and will fail the check if you
happen to enable `webhook.match.user` for jellyfin.
* Sometimes jellyfin will fire webhook `itemAdd` event without the item being matched.
* Even if you select user id, sometimes `itemAdd` event will fire without user data.
* Items might be marked as unplayed if Libraries > Display - `Date added behavior for new content:` is set
to `Use date scanned into library`. This happens if the media file has been replaced.
- If no user ID is selected in the plugin, the `itemAdd` event will be sent **without user data**, which will cause a
failure if `webhook.match.user` is enabled.
- Occasionally, Jellyfin will fire `itemAdd` events that **without it being matched**.
- Even if a user ID is selected, Jellyfin may **still send events without user data**.
- Items may be marked as **unplayed** if the following setting is enabled **Libraries > Display > Date added behavior
for new content: `Use date scanned into library`** This often happens when media files are replaced or updated.
---
# Sometimes newly added episodes or movies don't make it to webhook endpoint?
# Sometimes newly added episodes or movies don't reach the webhook endpoint?
As stated in webhook limitation section sometimes media backends don't make it easy to receive those events, as such, to
complement webhooks, you should enable import/export tasks by settings their respective environment variables in
your `compose.yaml` file. For more information run help on `system:env` command as well as `system:tasks`
command.
As noted in the webhook limitations section, some media backends do not reliably send webhook events for newly added
content. To address this, you should enable **import/export tasks** to complement webhook functionality.
Simply, visit the `Tasks` page and enable the `import` and `export` task.
---
# How to disable the included HTTP server and use external server?
Set this environment variable in your `compose.yaml` file `DISABLE_HTTP` with value of `1`. your external
server need to send correct fastcgi environment variables. Example caddy file:
To disable the built-in HTTP server, set the following environment variable and restart the container:
```
DISABLE_HTTP=1
```
Your external web server must forward requests using the correct **FastCGI** environment variables.
## Example: Caddy Configuration
```caddyfile
https://watchstate.example.org {
# Change "172.23.1.2" to your watchstate container ip e.g. "172.23.20.20"
# Replace "172.23.1.2" with the actual IP address of your WatchState container
reverse_proxy 172.23.1.2:9000 {
transport fastcgi {
root /opt/app/public
@@ -829,47 +796,73 @@ https://watchstate.example.org {
}
```
> [!NOTE]
> If you change the FastCGI Process Manager port using the `FPM_PORT` environment variable, make sure to update the port
> in the reverse proxy configuration as well.
> [!IMPORTANT]
> If you change the FastCGI Process Manager TCP port via FPM_PORT environment variable, you should change the port in
> the caddy file as well.
> This configuration mode is **not officially supported** by WatchState. If issues arise, please verify your web server
> setup. Support does not cover external web server configurations.
---
# WS_API_AUTO
The purpose of this environment variable is to automate the configuration process. It's mainly used for people who use
many browsers to access the `WebUI` and want to automate the configuration process. as it's requires the API settings to
be configured before it can be used. This environment variable can be enabled by setting `WS_API_AUTO=true` in
`${WS_DATA_PATH}/config/.env`.
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.
## Why you should use it?
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.
You normally should not use it, as it's a **GREAT SECURITY RISK**. However, if you are using the tool in a secure
environment and not worried about exposing your API key, you can use it to automate the configuration process.
## Why You Might Use It
## Why you should not use it?
You may consider using this if:
Because, by exposing your API key, you are also exposing every data you have in the tool. This is a **GREAT SECURITY
RISK**, any person or bot that are able to access the `WebUI` will also be able to visit `/v1/api/system/auto` and get
your API key. And with this key they can do anything they want with your data. including viewing your media servers API
keys. So, please while we have this option available, we strongly recommend not to use it if `WatchState` is exposed to
the internet.
- You're operating in a **secure, local environment**.
- You want to **automate setup** across multiple devices or browsers without repeatedly entering API details.
> [!IMPORTANT]
> This environment variable is **GREAT SECURITY RISK**, and we strongly recommend not to use it if `WatchState` is
> exposed to the internet. I cannot stress this enough, please do not use it unless you are in a secure environment.
## 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 external cache server?
# How to disable the included cache server and use an external cache server?
Set this environment variable in your `compose.yaml` file `DISABLE_CACHE` with value of `1`. to use external redis
server you need to alter the value of `WS_CACHE_URL` environment variable. the format for this variable is
`redis://host:port?password=auth&db=db_num`, for example to use redis from another container you could use something
like `redis://172.23.1.10:6379?password=my_secert_password&db=8`. We only support `redis` and API compatible
alternative.
To disable the built-in cache server and connect to an external Redis instance, follow these steps:
Once that done, restart the container.
In your `compose.yaml` file, set the following environment variable `DISABLE_CACHE=1`.
Configure the external Redis connection by setting the `WS_CACHE_URL` environment variable.
The format is:
```
redis://host:port?password=your_password&db=db_number
```
For example, to connect to a Redis server running in another container:
```
redis://172.23.1.10:6379?password=my_secret_password&db=8
```
> [!NOTE]
* Only **Redis** and **API-compatible alternatives** are supported.
After updating the environment variables, **restart the container** to apply the changes.
---

View File

@@ -53,6 +53,7 @@
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<span class="icon"><i class="fas fa-key"/></span>
@@ -64,20 +65,8 @@
<span class="icon"><i class="fas fa-users"/></span>
<span>Sub Users</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e) => openMenu(e)">
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
<span>More</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
@@ -96,33 +85,19 @@
<span class="icon"><i class="fas fa-file"/></span>
<span>Files Integrity</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-calendar-alt"/></span>
<span>Events</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/ignore" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-ban"/></span>
<span>Ignore List</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-flag"/></span>
<span>Basic Report</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/suppression" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-bug-slash"/></span>
<span>Log Suppression</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-map"/></span>
<span>Custom GUIDs</span>
</NuxtLink>
<hr class="navbar-divider">
@@ -137,6 +112,42 @@
<span class="icon"><i class="fas fa-redo"/></span>
<span>System reset</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e) => openMenu(e)">
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
<span>More</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-calendar-alt"/></span>
<span>Events</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-flag"/></span>
<span>Basic Report</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-map"/></span>
<span>Custom GUIDs</span>
</NuxtLink>
</div>
</div>
</div>

View File

@@ -3,28 +3,21 @@
<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-server"></i></span>
<span class="icon"><i class="fas fa-server"/></span>
Backends
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control" v-if="backends && backends.length>0">
<button class="button is-purple" v-tooltip.bottom="'Create sub users backends.'"
@click="navigateTo(makeConsoleCommand('backend:create -B -v', true))"
:disabled="'main' !== api_user">
<span class="icon"><i class="fas fa-users"></i></span>
</button>
</p>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Add New Backend'"
@click="toggleForm = !toggleForm" :disabled="isLoading">
<span class="icon"><i class="fas fa-add"></i></span>
<span class="icon"><i class="fas fa-add"/></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading"
:class="{'is-loading':isLoading}">
<span class="icon"><i class="fas fa-sync"></i></span>
<span class="icon"><i class="fas fa-sync"/></span>
</button>
</p>
</div>
@@ -48,18 +41,23 @@
No backends found. Please add new backends to start using the tool. You can add new backend by
<NuxtLink @click="toggleForm=true" v-text="'clicking here'"/>
or by clicking the <span class="icon is-clickable" @click="toggleForm=true"><i
class="fas fa-add"></i></span>
class="fas fa-add"/></span>
button above.
</Message>
</div>
<div class="column is-12">
<div class="content">
<h1 class="title is-4">Tools</h1>
<h1 class="title is-4">
<span class="icon"><i class="fas fa-tools"/></span> Tools
</h1>
<ul>
<li>
<NuxtLink :to="`/tools/plex_token`" v-text="'Validate plex token'"/>
</li>
<li v-if="backends && backends.length>0">
<NuxtLink :to="`/tools/sub_users`" v-text="'Create sub-users'"/>
</li>
</ul>
</div>
</div>
@@ -77,12 +75,12 @@
<div class="control">
<NuxtLink :to="`/backend/${backend.name}/edit?redirect=/backends`"
v-tooltip="'Edit backend settings'">
<span class="icon has-text-warning"><i class="fas fa-cog"></i></span>
<span class="icon has-text-warning"><i class="fas fa-cog"/></span>
</NuxtLink>
</div>
<div class="control">
<NuxtLink :to="`/backend/${backend.name}/delete?redirect=/backends`" v-tooltip="'Delete backend'">
<span class="icon has-text-danger"><i class="fas fa-trash"></i></span>
<span class="icon has-text-danger"><i class="fas fa-trash"/></span>
</NuxtLink>
</div>
</div>
@@ -147,7 +145,7 @@
<div class="card-footer-item">
<NuxtLink :to="api_url + backend.urls.webhook" class="is-info is-light"
@click.prevent="copyUrl(backend)">
<span class="icon"><i class="fas fa-copy"></i></span>
<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>
@@ -166,7 +164,7 @@
</select>
</div>
<div class="icon is-left">
<i class="fas fa-terminal"></i>
<i class="fas fa-terminal"/>
</div>
</div>
</div>
@@ -184,10 +182,6 @@
<li>
<strong>Export</strong> means pushing data from the local database to the backends.
</li>
<li v-if="backends && backends.length>0">
To create sub users backends, click on the <span class="icon has-text-purple"><i class="fas fa-users"/></span>
button.
</li>
</ul>
</Message>
</div>
@@ -241,6 +235,16 @@ const usefulCommands = {
title: "Import this backend metadata.",
command: "state:import -v --metadata-only -u {user} -s {name}",
},
import_debug: {
id: 6,
title: "Run import and save debug log.",
command: "state:import -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.import.txt'",
},
export_debug: {
id: 7,
title: "Run export and save debug log.",
command: "state:export -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.export.txt'",
},
}
const forwardCommand = async backend => {

View File

@@ -38,7 +38,7 @@
<input type="text" class="input is-fullwidth" v-model="command"
:placeholder="`system:view ${allEnabled ? 'or $ ls' : ''}`"
list="recent_commands"
autocomplete="off" ref="command_input" @keydown.enter="RunCommand" :disabled="isLoading">
autocomplete="off" ref="commandInput" @keydown.enter="RunCommand" :disabled="isLoading">
<span class="icon is-left"><i class="fas fa-terminal" :class="{'fa-spin':isLoading}"></i></span>
</p>
<p class="control" v-if="!isLoading">
@@ -53,49 +53,50 @@
</p>
</div>
</div>
<p class="help" v-if="hasPrefix">
<span class="icon-text">
<span class="icon has-text-danger"><i class="fas fa-exclamation-triangle"></i></span>
<span>Remove the <code>console</code> or <code>docker exec -ti watchstate console</code> from the
input. You should use the command directly, For example i.e <code>db:list --output
yaml</code></span>
</span>
</p>
<p class="help" v-if="hasPlaceholder">
<span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
<code>[...]</code> with the intended value if applicable.</span>
</span>
</p>
</div>
</section>
</div>
</div>
<div class="column is-12" v-if="hasPrefix || hasPlaceholder">
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPrefix">
<p>Use the command directly, For example i.e. <code>db:list -o yaml</code></p>
</Message>
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPlaceholder">
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
<code>[...]</code> with the intended value if applicable.</span>
</Message>
</div>
<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">
<ul>
<li>
You don't need to write <code>console</code> or <code>docker exec -ti watchstate console</code> Using
this interface. Use the command followed by the options directly. For example, <code>db:list --output
yaml</code>.
<li>You dont need to type <code>console</code> or run <code>docker exec -ti watchstate console</code> when
using this interface. Just enter the command and options directly. For example: <code>db:list --output
yaml</code>.
</li>
<li>
Clicking close connection does not stop the command. It only stops the output from being displayed. The
command will continue to run until it finishes.
<li>Clicking <strong>Close Connection</strong> only stops the output from being shownit does <em>not</em>
stop the command itself. The command will continue running until it finishes.
</li>
<li>
The majority of the commands will not show any output unless error has occurred or important information
needs to be communicated. Use the <code>-v[v[v]]</code> option to increase verbosity. <code>-v</code>
should be enough for most people, If you are debugging, then use <code>-vv --context</code>.
<li>Most commands wont display anything unless theres an error or important message. Use <code>-v</code>
to see more details. If youre debugging, try <code>-vv --context</code> for even more information.
</li>
<li>
There is an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that can be set to <code>true</code>
to enable all commands to be run from the console. This is disabled by default.
<li>Theres an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that you can set to <code>true</code>
to allow all commands to run from the console. Its turned off by default.
</li>
<li>To clear the recent command suggestions, use the <code>clear_ac</code> command.</li>
<li>
The number inside the parentheses is the exit code of the last command. If its <code>0</code>, the
command ran successfully. Any other value usually means something went wrong.
</li>
<li>To clear the recent commands auto-suggestions, you can use the <code>clear_ac</code> command.</li>
</ul>
</Message>
</div>
@@ -139,14 +140,15 @@ const response = ref([])
const command = ref(fromCommand)
const isLoading = ref(false)
const outputConsole = ref()
const command_input = ref()
const commandInput = ref()
const executedCommands = useStorage('executedCommands', [])
const exitCode = ref(0)
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const hasPrefix = computed(() => command.value.startsWith('console') || command.value.startsWith('docker'))
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*\]/))
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*]/))
const show_page_tips = useStorage('show_page_tips', true)
const allEnabled = ref(false)
@@ -155,12 +157,13 @@ const RunCommand = async () => {
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
/** @type {string} */
let userCommand = command.value
// -- check if the user command starts with console or docker exec -ti watchstate
if (userCommand.startsWith('console') || userCommand.startsWith('docker')) {
notification('error', 'Warning', 'Please remove the [console] or [docker exec -ti watchstate console] from the command.')
return
notification('info', 'Warning', 'Removing leading prefix command from the input.', 2000)
userCommand = userCommand.replace(/^(console|docker exec -ti watchstate)/i, '')
}
// use regex to check if command contains [...]
@@ -189,7 +192,7 @@ const RunCommand = async () => {
if (userCommand.startsWith('$')) {
if (!allEnabled.value) {
notification('error', 'Error', 'The option to execute all commands is disabled.')
command_input.value.focus()
commandInput.value.focus()
return
}
userCommand = userCommand.slice(1)
@@ -223,11 +226,12 @@ const RunCommand = async () => {
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
if ('' !== command.value) {
terminal.value.writeln(`~ ${userCommand}`)
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()
}
@@ -257,10 +261,12 @@ const finished = async () => {
executedCommands.value.shift()
}
terminal.value.writeln(`\n(${exitCode.value}) ~ `)
command.value = ''
await nextTick()
command_input.value.focus()
commandInput.value.focus()
}
const recentCommands = computed(() => executedCommands.value.reverse().slice(-10))
@@ -276,7 +282,7 @@ const clearOutput = async () => {
if (terminal.value) {
terminal.value ? terminal.value.clear() : ''
}
command_input.value.focus()
commandInput.value.focus()
}
onUnmounted(() => {
@@ -296,7 +302,7 @@ onMounted(async () => {
}
window.addEventListener("resize", reSizeTerminal);
command_input.value.focus()
commandInput.value.focus()
if (!terminal.value) {
terminalFit.value = new FitAddon()
@@ -304,7 +310,6 @@ onMounted(async () => {
fontSize: 16,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'none',
cols: 108,
rows: 10,
disableStdin: true,

View File

@@ -1,26 +1,26 @@
<template>
<div>
<div class="columns is-multiline">
<div class="columns is-multiline is-mobile">
<div class="column is-12 is-clearfix is-unselectable">
<span id="env_page_title" class="title is-4">
<span class="icon"><i class="fas fa-users"/></span>
Create Sub-users
Sub Users
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-purple" v-tooltip.bottom="'Export Association.'" @click="exportMapping">
<button class="button is-purple" v-tooltip.bottom="'Export to mapper.yaml file.'" @click="generateFile">
<span class="icon"><i class="fas fa-file-export"/></span>
</button>
</p>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Create new user assoication.'" @click="addNewUser">
<span class="icon"><i class="fas fa-plus"></i></span>
<button class="button is-primary" v-tooltip.bottom="'Create new user association.'" @click="addNewUser">
<span class="icon"><i class="fas fa-plus"/></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading"
:class="{'is-loading':isLoading}">
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-sync"/></span>
</button>
</p>
@@ -28,29 +28,46 @@
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
Drag & Drop the relevant users accounts to form association.
Drag & Drop the relevant users accounts to form association. Read the information section.
<template v-if="expires">The cached users list will expire {{ moment(expires).fromNow() }}</template>
</span>
</div>
</div>
<div class="column is-12">
<h2 class="title is-4">Matched users</h2>
<div class="column is-12" v-if="isLoading">
<Message v-if="isLoading" message_class="is-background-info-90 has-text-dark" icon="fas fa-spinner fa-spin"
title="Loading" message="Loading data. Please wait..."/>
</div>
<div class="column is-12" v-for="(group, index) in matchedUsers" :key="index">
<div class="card">
<header class="card-header is-block">
<div class="control has-icons-left">
<input type="text" class="input is-fullwidth" v-model="group.user" required>
<span class="icon is-left"><i class="fas fa-user"/></span>
</div>
<div class="column is-12" v-if="matched?.length < 1 && !isLoading">
<Message message_class="has-background-danger-90 has-text-dark" icon="fas fa-exclamation-triangle"
title="No matched users.">
<p>
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
<span>Click on the add button to user group</span>
</p>
</Message>
</div>
<div class="column is-6-tablet is-12-mobile" v-for="(group, index) in matched" :key="index">
<div class="card" :class="{ 'is-success': group.matched.length >= 2, 'is-warning': group.matched.length <= 1 }">
<header class="card-header">
<p class="card-header-title is-centered is-text-overflow">{{ group.user }}</p>
<span class="card-header-icon">
<span class="icon" @click="deleteGroup(index)"><i class="fas fa-trash-can"/></span>
</span>
</header>
<div class="card-content">
<draggable v-model="group.matched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
item-key="id">
:move="checkBackend" item-key="id">
<template #item="{ element }">
<div class="draggable-item">
<span>{{ element.backend }}@{{ element.username }}</span>
<span>
{{ element.backend }}@{{ element.username }}
<span v-if="!isSameName(element.real_name, element.username)">
( <u>{{ element.real_name }}</u> )
</span>
</span>
</div>
</template>
</draggable>
@@ -58,26 +75,30 @@
</div>
</div>
<div class="column is-12">
<h2 class="title is-4">Users with no association.</h2>
</div>
<div class="column is-12">
<div class="card">
<div class="column is-12" v-if="!isLoading">
<div class="card is-danger">
<header class="card-header is-block">
<p class="card-header-title is-text-overflow">Users with no association.</p>
<p class="card-header-title is-centered is-text-overflow has-text-danger">
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
Unmatched Users
</p>
</header>
<div class="card-content">
<draggable v-model="unmatched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
item-key="id">
:move="checkBackend" item-key="id">
<template #item="{ element }">
<div class="draggable-item">
<span>{{ element.backend }}@{{ element.username }}</span>
<span>
{{ element.backend }}@{{ element.username }}
<span v-if="!isSameName(element.real_name, element.username)">
( <u>{{ element.real_name }}</u> )
</span>
</span>
</div>
</template>
</draggable>
</div>
<div v-if="unmatched?.length <1">
<div v-if="unmatched?.length < 1">
<Message message_class="has-background-success-90 has-text-dark" icon="fas fa-check-circle">
<p>
<span class="icon"><i class="fas fa-check"/></span>
@@ -87,106 +108,348 @@
</div>
</div>
</div>
<div class="column is-12" v-if="!isLoading">
<div class="box">
<h1 class="title is-4">
Action form
</h1>
<div class="field" v-if="hasUsers">
<div class="control">
<input id="recreate" type="checkbox" class="switch is-success" v-model="recreate">
<label for="recreate" class="has-text-danger">
Delete current local sub-users data, and re-create them.
</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="backup" type="checkbox" class="switch is-success" v-model="backup">
<label for="backup">Create initial backup for each sub-user remote backend data.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="no_save" type="checkbox" class="switch is-danger" v-model="noSave">
<label for="no_save">Do not save mapper.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="verbose" type="checkbox" class="switch is-info" v-model="verbose">
<label for="verbose">Show more indepth logs.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="dry_run" type="checkbox" class="switch is-info" v-model="dryRun">
<label for="dry_run">Dry-run do not make changes.</label>
</div>
</div>
<div class="field is-fullwidth is-grouped">
<div class="control is-expanded">
<button class="button is-fullwidth is-warning" @click="saveMap">
<span class="icon"><i class="fas fa-save"/></span>
<span>Save mapping</span>
</button>
</div>
<div class="control is-expanded">
<button class="button is-fullwidth" @click="createUsers"
:class="{'is-primary': !dryRun && !recreate, 'is-info':dryRun, 'is-danger': !dryRun && recreate}">
<span class="icon"><i class="fas fa-users"/></span>
<span v-if="!dryRun">
<span v-if="recreate || !hasUsers">
{{ recreate ? 'Re-create' : 'Create' }} sub-users
</span>
<span v-if="!recreate && hasUsers">Update sub-users</span>
</span>
<span v-else>
Test create sub-users
<span v-if="hasUsers">(Safe operation)</span>
</span>
</button>
</div>
</div>
</div>
</div>
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Information" icon="fas fa-info-circle">
<ul>
<li>This page lets you guide the system in matching sub-users across different backends.</li>
<li>
When you click <code>Create sub-users</code>, your mapping will be uploadedunless youve selected <code>Do
not save mapper</code>. Based on your choice, the system will either delete and recreate the local
sub-users, or try to update the existing ones.
</li>
<li class="has-text-danger is-bold">
Warning: If you choose not to delete the existing local sub-users and the matching changes for any reason,
you may end up with duplicate users. We strongly recommend deleting the current local sub-users.
</li>
<li>
Clicking <code>Save mapping</code> will only save your current mapping to the system. It will
<strong>not</strong> create any sub-users.
</li>
<li>
Clicking the <i class="fas fa-file-export"></i> icon will download the current mapping as a YAML file. You
can review and manually upload it to the system later if needed.
</li>
<li>
Users in the <b>Not matched</b> group arent currently linked to any others and likely wont be matched
automatically.
</li>
<li>
Each user group must have at least two users to be considered a valid group.
</li>
<li>
You can drag and drop users from the <b>Not matched</b> group into any other group to manually associate
them.
</li>
<li>
A user group can only include <b>one</b> user from <b>each</b> backend. If you try to add a second user
from the same backend, an error will be shown.
</li>
<li>
The display name format is: <code>backend_name@normalized_name (real_username)</code>. The <code>(real_username)</code>
part only appears if its different from the <code>normalized_name</code>.
</li>
<li>
There is a 5-minute cache when retrieving users from the API, so the data you see might be slightly out of
date. This is to prevent overwhelming external APIs with requests and to have better response times.
</li>
</ul>
</Message>
</div>
</div>
</div>
</template>
<script setup>
import moment from 'moment'
import {makeConsoleCommand, parse_api_response} from "~/utils/index.js";
import {notification} from '~/utils/index'
const data = {
"matched": [
{
"user": "user1",
"matched": [
{
"id": "u1a", "backend": "backend_name1", "username": "user_u1a"
},
{
"id": "u1b", "backend": "backend_name2", "username": "user_u1b"
},
{
"id": "u1c", "backend": "backend_name3", "username": "user_u1c"
}
]
},
{
"user": "user2",
"matched": [
{
"id": "u2a", "backend": "backend_name1", "username": "user_u2a"
},
{
"id": "u2b", "backend": "backend_name2", "username": "user_u2b"
},
{
"id": "u2c", "backend": "backend_name3", "username": "user_u2c"
}
]
},
{
"user": "user3",
"matched": [
{
"id": "u3a", "backend": "backend_name1", "username": "user_u3a"
},
{
"id": "u3b", "backend": "backend_name2", "username": "user_u3b"
},
{
"id": "u3c", "backend": "backend_name3", "username": "user_u3c"
}
]
}
],
"unmatched": [
{
"id": "u4a", "backend": "backend_name1", "username": "user_u4a"
},
{
"id": "u4b", "backend": "backend_name2", "username": "user_u4b"
},
{
"id": "u4c", "backend": "backend_name3", "username": "user_u4c"
}
]
const matched = ref([])
const unmatched = ref([])
const isLoading = ref(false)
const toastIsVisible = ref(false)
const recreate = ref(false)
const backup = ref(false)
const noSave = ref(false)
const dryRun = ref(false)
const hasUsers = ref(false)
const verbose = ref(false)
const expires = ref()
const addNewUser = () => {
const newUserName = `User group #${matched.value.length + 1}`
matched.value.push({user: newUserName, matched: []})
}
const matchedUsers = ref(data.matched)
const unmatched = ref(data.unmatched)
const loadContent = async () => {
if (matched.value.length > 0) {
if (!confirm('Reloading will remove all modifications. Are you sure?')) {
return
}
}
// Function to add a new matched group with a default name
const addNewUser = () => {
const newUserName = 'user ' + (matchedUsers.value.length + 1)
matchedUsers.value.push({
user: newUserName,
matched: []
matched.value = []
unmatched.value = []
isLoading.value = true
try {
const response = await request('/backends/mapper')
const json = await response.json()
if (useRoute().name !== 'tools-sub_users') {
return
}
matched.value = json.matched
unmatched.value = json.unmatched
recreate.value = json.has_users
backup.value = !json.has_users
hasUsers.value = json.has_users
expires.value = json?.expires
} catch (e) {
notification('error', 'Error', e.message)
} finally {
isLoading.value = false
}
}
const generateFile = async () => {
const filename = 'mapper.yaml'
const data = formatData()
if (!data.map.length) {
notification('error', 'Error', 'No data to export.')
return
}
const response = request(`/system/yaml/${filename}`, {
method: 'POST',
headers: {'Accept': 'text/yaml'},
body: JSON.stringify(data)
})
if ('showSaveFilePicker' in window) {
response.then(async res => {
return res.body.pipeTo(await (await showSaveFilePicker({
suggestedName: `${filename}`
})).createWritable())
})
}
response.then(res => res.blob()).then(blob => {
const fileURL = URL.createObjectURL(blob)
const fileLink = document.createElement('a')
fileLink.href = fileURL
fileLink.download = `${filename}`
fileLink.click()
})
}
const checkBackend = e => {
if (e.draggedContext.list === e.relatedContext.list) {
return true;
}
const isMatchedContainer = matched.value.some(
group => group.matched === e.relatedContext.list
);
if (false === isMatchedContainer) {
return true;
}
const draggedUser = e.draggedContext.element;
const alreadyExists = e.relatedContext.list.some(item => item.backend === draggedUser.backend)
if (true === alreadyExists) {
if (!toastIsVisible.value) {
toastIsVisible.value = true;
nextTick(() => {
notification('error', 'error', `A user from '${draggedUser.backend}' backend, already mapped in this group.`, 3001, {
onClose: () => toastIsVisible.value = false,
})
})
}
return false;
}
return true;
}
const deleteGroup = i => {
const group = matched.value[i]
if (group && group.matched && group.matched.length) {
if (false === confirm(`Delete user group #${i + 1}?, Users will be moved to unmatched`)) {
return
}
unmatched.value.push(...group.matched)
}
nextTick(() => matched.value.splice(i, 1))
}
const saveMap = async (no_toast = false) => {
const data = formatData()
if (!data.map.length) {
if (!no_toast) {
notification('error', 'Error', 'No mapping data to save.')
}
return true
}
try {
const req = await request('/backends/mapper', {
method: 'PUT',
body: JSON.stringify(data)
})
const response = await parse_api_response(req)
if (req.status >= 200 && req.status < 300 && !no_toast) {
notification('success', 'Success', response.info.message)
return true
}
if (!no_toast) {
notification('error', 'Error', `${req.status}: ${response.error.message}`)
}
return false
} catch (e) {
notification('error', 'Error', `Error: ${e.message}`)
}
return false
}
const formatData = () => {
const data = {version: "1.5", map: []}
matched.value.forEach((group, i) => {
const users = {}
group.matched.forEach(user => users[user.backend] = {name: user.username})
if (Object.keys(users).length < 2) {
return
}
data.map.push(users)
})
return data
}
const createUsers = async () => {
if (!noSave.value) {
const state = await saveMap()
if (state === false) {
return
}
}
const command = ['backend:create']
command.push(verbose.value ? '-vvv' : '-vv')
command.push(recreate.value ? '--re-create' : '--run --update')
if (backup.value) {
command.push('--generate-backup')
}
if (dryRun.value) {
command.push('--dry-run')
}
await navigateTo(makeConsoleCommand(command.join(' '), true))
}
const isSameName = (name1, name2) => {
return name1.toLowerCase() === name2.toLowerCase()
}
onMounted(async () => await loadContent())
</script>
<style scoped>
table {
margin-bottom: 1em;
}
th, td {
padding: 8px;
text-align: left;
}
/* Make containers flex so items wrap side by side */
.users-list,
.unmatched-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
border: 1px dashed #ccc;
background-color: #fafafa;
}
/* Let draggable items size to content */
.draggable-item {
display: inline-flex;
align-items: center;

View File

@@ -161,14 +161,19 @@ const ucFirst = (str) => {
* @param {string} title The title of the notification.
* @param {string} text The text of the notification.
* @param {number} duration The duration of the notification.
* @param {object} opts Additional options for the notification.
*
* @returns {void}
*/
const notification = (type, title, text, duration = 3000) => {
const notification = (type, title, text, duration = 3000, opts = {}) => {
let method = '', options = {
timeout: duration,
}
if (opts) {
options = {...options, ...opts}
}
switch (type.toLowerCase()) {
case 'info':
default:

154
src/API/Backends/Mapper.php Normal file
View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Commands\Backend\CreateUsersCommand;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Put;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Status;
use App\Libs\Traits\APITraits;
use DateInterval;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface as iCache;
use Psr\SimpleCache\InvalidArgumentException;
final class Mapper
{
use APITraits;
private const string CACHE_KEY = 'all-backends-users';
public function __construct(private readonly iLogger $logger, private readonly iCache $cache)
{
}
#[Get(Index::URL . '/mapper[/]', name: 'backends.mappers.list')]
public function list(iRequest $request): iResponse
{
$ignore = (bool)ag($request->getQueryParams(), 'force', false);
$cached = new DateInterval('PT5M');
$mapping = CreateUsersCommand::loadMappings();
$data = cacheableItem(key: self::CACHE_KEY, function: function () use ($mapping, $cached): array {
$backends = CreateUsersCommand::loadBackends();
if (count($backends) < 1) {
return [];
}
$backendsUser = CreateUsersCommand::get_backends_users($backends, $mapping);
if (count($backendsUser) < 1) {
return [];
}
$obj = CreateUsersCommand::generate_users_list($backendsUser, $mapping);
$matched = ag($obj, 'matched', []);
$unmatched = ag($obj, 'unmatched', []);
if (count($matched) < 1 && count($unmatched) < 1) {
return [];
}
$data = [
'matched' => [],
'unmatched' => [],
];
foreach ($unmatched as $user) {
$data['unmatched'][] = [
'username' => ag($user, 'name', null),
'backend' => ag($user, 'backend', null),
'real_name' => ag($user, 'real_name', null),
];
}
foreach ($matched as $i => $user) {
$perUser = [
'user' => "User group #" . ($i + 1),
'matched' => [],
];
foreach (ag($user, 'backends', []) as $backend => $backendData) {
$perUser['matched'][] = [
'id' => ag($backendData, 'id', null),
'username' => ag($backendData, 'name', null),
'backend' => $backend,
'real_name' => ag($backendData, 'real_name', null),
];
}
$data['matched'][] = $perUser;
}
$data['expires'] = (string)makeDate()->add($cached);
return $data;
}, ttl: $cached, ignoreCache: $ignore, opts: [iCache::class => $this->cache]);
$response = [
'has_users' => CreateUsersCommand::hasUsers(),
'has_mapper' => count($mapping) > 0,
...$data,
];
return api_response(Status::OK, $response);
}
/**
* Update the mapper file.
*
* @param iRequest $request The request object.
*
* @throws InvalidArgumentException May be thrown by the cache service.
*/
#[Put(Index::URL . '/mapper[/]', name: 'backends.mappers.create')]
public function update(iRequest $request): iResponse
{
$body = DataUtil::fromRequest($request);
$data = [
'version' => (string)$body->get('version', '1.5'),
'map' => $body->get('map', []),
];
if (!is_array($data['map'])) {
return api_error('Invalid map data.', Status::BAD_REQUEST);
}
if (count($data['map']) < 1) {
return api_error('Empty map data.', Status::BAD_REQUEST);
}
if (true === version_compare($data['version'], '1.5', '<')) {
return api_error('Invalid version. must be 1.5 or greater.', Status::BAD_REQUEST);
}
$mapFile = Config::get('mapper_file');
$exists = file_exists($mapFile);
if (true === $exists) {
unlink($mapFile);
}
$c = ConfigFile::open($mapFile, 'yaml', true, true, true)
->set('version', $data['version'])
->set('map', $data['map'])
->persist();
if ($this->cache->has(self::CACHE_KEY)) {
$this->cache->delete(self::CACHE_KEY);
}
return api_message(r('Mapper file successfully {state}.', [
'state' => $exists ? 'updated' : 'created',
]), $exists ? Status::OK : Status::CREATED, body: $c->getAll());
}
}

View File

@@ -12,22 +12,16 @@ use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerInterface as iLogger;
use Throwable;
final class Users
{
use APITraits;
public function __construct(private readonly LoggerInterface $logger) {}
#[Route(['GET', 'POST'], Index::URL . '/users/{type}[/]', name: 'backends.get.users')]
public function __invoke(iRequest $request, array $args = []): iResponse
#[Route(['GET', 'POST'], Index::URL . '/users/{type}[/]', name: 'backends.get.backend.users')]
public function __invoke(iRequest $request, string $type, iLogger $logger): iResponse
{
if (null === ($type = ag($args, 'type'))) {
return api_error('Invalid value for type path parameter.', Status::BAD_REQUEST);
}
$params = DataUtil::fromRequest($request, true);
try {
@@ -51,7 +45,7 @@ final class Users
$users[] = $user;
}
} catch (Throwable $e) {
$this->logger->error($e->getMessage(), ['trace' => $e->getTrace()]);
$logger->error($e->getMessage(), $e->getTrace());
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
}

44
src/API/System/ToYaml.php Normal file
View File

@@ -0,0 +1,44 @@
<?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\Stream;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Symfony\Component\Yaml\Exception\DumpException;
use Symfony\Component\Yaml\Yaml;
final readonly class ToYaml
{
public const string URL = '%{api.prefix}/system/yaml';
#[Post(self::URL . '[/[{filename}[/]]]', name: 'system.to_yaml')]
public function __invoke(iRequest $request, string|null $filename = null): iResponse
{
$params = DataUtil::fromArray($request->getQueryParams());
try {
$stream = Stream::create(Yaml::dump(
input: $request->getParsedBody(),
inline: (int)$params->get('inline', 4),
indent: (int)$params->get('indent', 2),
flags: Yaml::DUMP_OBJECT | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
));
} catch (DumpException $e) {
return api_error(r("Failed to convert to yaml. '{error}'.", ['error' => $e->getMessage()]), Status::BAD_REQUEST);
}
return api_response(Status::OK, body:$stream, headers: [
'Content-Type' => 'text/yaml',
'Content-Disposition' => r('{mode}; filename="{filename}"', [
'mode' => $filename ? 'attachment' : 'inline',
'filename' => $filename ?? 'to_yaml.yaml',
])
]);
}
}

View File

@@ -31,8 +31,11 @@ class CreateUsersCommand extends Command
{
public const string ROUTE = 'backend:create';
public function __construct(private iLogger $logger)
private static iLogger|null $logger = null;
public function __construct(iLogger $logger)
{
self::$logger = $logger;
parent::__construct();
}
@@ -141,11 +144,11 @@ class CreateUsersCommand extends Command
return;
}
$this->logger->notice("SYSTEM: Deleting users directory '{path}' contents.", [
self::$logger?->notice("SYSTEM: Deleting users directory '{path}' contents.", [
'path' => $path
]);
deletePath(path: $path, logger: $this->logger, dryRun: $dryRun);
deletePath(path: $path, logger: self::$logger, dryRun: $dryRun);
}
/**
@@ -153,18 +156,20 @@ class CreateUsersCommand extends Command
*
* @return array The list of backends.
*/
private function loadBackends(): array
public static function loadBackends(): array
{
$backends = [];
$supported = Config::get('supported', []);
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
$configFile->setLogger($this->logger);
if (null !== self::$logger) {
$configFile->setLogger(self::$logger);
}
foreach ($configFile->getAll() as $backendName => $backend) {
$type = strtolower(ag($backend, 'type', 'unknown'));
if (!isset($supported[$type])) {
$this->logger->error("SYSTEM: Ignoring '{backend}'. Unexpected backend type '{type}'.", [
self::$logger?->error("SYSTEM: Ignoring '{backend}'. Unexpected backend type '{type}'.", [
'type' => $type,
'backend' => $backendName,
'types' => implode(', ', array_keys($supported)),
@@ -173,7 +178,7 @@ class CreateUsersCommand extends Command
}
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
$this->logger->error("SYSTEM: Ignoring '{backend}'. Invalid url '{url}'.", [
self::$logger?->error("SYSTEM: Ignoring '{backend}'. Invalid url '{url}'.", [
'url' => $url ?? 'None',
'backend' => $backendName,
]);
@@ -181,7 +186,13 @@ class CreateUsersCommand extends Command
}
$backend['name'] = $backendName;
$backend['class'] = $this->getBackend($backendName, $backend)->setLogger($this->logger);
$backend['class'] = makeBackend($backend, $backendName);
if (null !== self::$logger) {
$backend['class']->setLogger(self::$logger);
}
$backends[$backendName] = $backend;
}
@@ -193,7 +204,7 @@ class CreateUsersCommand extends Command
*
* @return array The list of user mappings. or empty array.
*/
private function loadMappings(): array
public static function loadMappings(): array
{
$mapFile = Config::get('mapper_file');
@@ -210,14 +221,14 @@ class CreateUsersCommand extends Command
}
if (false === $map->has('version')) {
$this->logger->warning("SYSTEM: Starting with mapper.yaml v1.5, the version key is required.");
self::$logger?->warning("SYSTEM: Starting with mapper.yaml v1.5, the version key is required.");
}
if (false === $map->has('map')) {
$this->logger->warning("SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec.");
self::$logger?->warning("SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec.");
}
$this->logger->info("SYSTEM: Mapper file found, using it to map users.", [
self::$logger?->info("SYSTEM: Mapper file found, using it to map users.", [
'map' => arrayToString($mapping)
]);
@@ -229,10 +240,11 @@ class CreateUsersCommand extends Command
*
* @param array $backends The list of backends.
* @param array $map The user mappings.
* @param bool $noMapActions If true, do not run map actions.
*
* @return array The list of backends users.
*/
private function get_backends_users(array $backends, array &$map): array
public static function get_backends_users(array $backends, array &$map, bool $noMapActions = false): array
{
$users = [];
@@ -241,7 +253,7 @@ class CreateUsersCommand extends Command
$client = ag($backend, 'class');
assert($backend instanceof iClient);
$this->logger->info("SYSTEM: Getting users from '{backend}'.", [
self::$logger?->info("SYSTEM: Getting users from '{backend}'.", [
'backend' => $client->getContext()->backendName
]);
@@ -252,19 +264,22 @@ class CreateUsersCommand extends Command
$backedName = ag($backend, 'name');
$user['real_name'] = ag($user, 'name');
// -- this was source of lots of bugs and confusion for users,
// -- we decided to normalize the user-names early in the process.
$user['name'] = normalizeName((string)$user['name'], $this->logger, [
$user['name'] = normalizeName((string)ag($user, 'name'), self::$logger, [
'log_message' => "Normalized '{backend}: {name}' to '{backend}: {new_name}'",
'context' => [ 'backend' => $backedName ],
'context' => ['backend' => $backedName],
]);
// -- run map actions.
$this->map_actions($backedName, $user, $map);
if (false === $noMapActions) {
self::map_actions($backedName, $user, $map);
}
// -- If normalization fails, ignore the user.
if (false === isValidName($user['name'])) {
$this->logger->error(
self::$logger?->error(
message: "SYSTEM: Invalid user name '{backend}: {name}'. User names must be in [a-z_0-9] format. Skipping user.",
context: ['name' => $user['name'], 'backend' => $backedName]
);
@@ -280,10 +295,10 @@ class CreateUsersCommand extends Command
$info['backendName'] = normalizeName(r("{backend}_{user}", [
'backend' => $backedName,
'user' => $user['name']
]),$this->logger);
]), self::$logger);
if (false === isValidName($info['backendName'])) {
$this->logger->error(
self::$logger?->error(
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping the associated users.",
context: ['name' => $info['backendName']]
);
@@ -316,7 +331,7 @@ class CreateUsersCommand extends Command
$users[] = $user;
}
} catch (Throwable $e) {
$this->logger->error(
self::$logger?->error(
"Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get users list. '{error.message}' at '{error.file}:{error.line}'.",
[
'client' => $client->getContext()->clientName,
@@ -350,7 +365,7 @@ class CreateUsersCommand extends Command
*
* @return void
*/
private function create_user(iInput $input, array $users): void
public static function create_user(iInput $input, array $users): void
{
$dryRun = (bool)$input->getOption('dry-run');
$updateUsers = (bool)$input->getOption('update');
@@ -360,10 +375,10 @@ class CreateUsersCommand extends Command
foreach ($users as $user) {
// -- User subdirectory name.
$userName = normalizeName(ag($user, 'name', 'unknown'), $this->logger);
$userName = normalizeName(ag($user, 'name', 'unknown'), self::$logger);
if (false === isValidName($userName)) {
$this->logger->error(
self::$logger?->error(
message: "SYSTEM: Invalid username '{user}'. User names must be in [a-z_0-9] format. skipping user.",
context: ['user' => $userName]
);
@@ -372,7 +387,7 @@ class CreateUsersCommand extends Command
$subUserPath = r(fixPath(Config::get('path') . '/users/{user}'), ['user' => $userName]);
$this->logger->info(
self::$logger?->info(
false === is_dir(
$subUserPath
) ? "SYSTEM: Creating '{user}' directory '{path}'." : "SYSTEM: '{user}' directory '{path}' already exists.",
@@ -383,7 +398,7 @@ class CreateUsersCommand extends Command
);
if (false === $dryRun && false === is_dir($subUserPath) && false === mkdir($subUserPath, 0755, true)) {
$this->logger->error("SYSTEM: Failed to create '{user}' directory '{path}'.", [
self::$logger?->error("SYSTEM: Failed to create '{user}' directory '{path}'.", [
'user' => $userName,
'path' => $subUserPath
]);
@@ -391,7 +406,7 @@ class CreateUsersCommand extends Command
}
$config_file = "{$subUserPath}/servers.yaml";
$this->logger->notice(
self::$logger?->notice(
file_exists(
$config_file
) ? "SYSTEM: '{user}' configuration file '{file}' already exists." : "SYSTEM: Creating '{user}' configuration file '{file}'.",
@@ -409,13 +424,15 @@ class CreateUsersCommand extends Command
autoBackup: !$dryRun
);
$perUser->setLogger($this->logger);
if (null !== self::$logger) {
$perUser->setLogger(self::$logger);
}
foreach (ag($user, 'backends', []) as $backend) {
$name = ag($backend, 'client_data.backendName');
if (false === isValidName($name)) {
$this->logger->error(
self::$logger?->error(
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping backend.",
context: ['name' => $name]
);
@@ -466,7 +483,7 @@ class CreateUsersCommand extends Command
$update['options.' . Options::ADMIN_TOKEN] = $adminToken;
}
$this->logger->info("SYSTEM: Updating user configuration for '{user}@{name}' backend.", [
self::$logger?->info("SYSTEM: Updating user configuration for '{user}@{name}' backend.", [
'name' => $name,
'user' => $userName,
]);
@@ -493,7 +510,7 @@ class CreateUsersCommand extends Command
opts: $requestOpts,
);
if (false === $token) {
$this->logger->error(
self::$logger?->error(
message: "Failed to generate access token for '{user}@{backend}' backend.",
context: ['user' => $userName, 'backend' => $name]
);
@@ -503,7 +520,7 @@ class CreateUsersCommand extends Command
}
}
} catch (Throwable $e) {
$this->logger->error(
self::$logger?->error(
message: "Failed to generate access token for '{user}@{name}' backend. {error} at '{file}:{line}'.",
context: [
'name' => $name,
@@ -527,7 +544,7 @@ class CreateUsersCommand extends Command
}
$dbFile = $subUserPath . "/user.db";
$this->logger->notice(
self::$logger?->notice(
file_exists(
$dbFile
) ? "SYSTEM: '{user}' database file '{db}' already exists." : "SYSTEM: Creating '{user}' database file '{db}'.",
@@ -546,7 +563,7 @@ class CreateUsersCommand extends Command
}
if (true === $generateBackups && false === $isReCreate) {
$this->logger->notice("SYSTEM: Queuing event to backup '{user}' remote watch state.", [
self::$logger?->notice("SYSTEM: Queuing event to backup '{user}' remote watch state.", [
'user' => $userName
]);
@@ -571,13 +588,10 @@ class CreateUsersCommand extends Command
protected function runCommand(iInput $input, iOutput $output): int
{
if (true === ($dryRun = $input->getOption('dry-run'))) {
$this->logger->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
self::$logger?->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
}
$usersPath = Config::get('path') . '/users';
$hasConfig = is_dir($usersPath) && count(glob($usersPath . '/*/*.yaml')) > 0;
if ($hasConfig && (false === $input->getOption('run') && false === $input->getOption('re-create'))) {
if (self::hasUsers() && (false === $input->getOption('run') && false === $input->getOption('re-create'))) {
$output->writeln(
<<<Text
<error>ERROR:</error> Users configuration already exists.
@@ -602,41 +616,42 @@ class CreateUsersCommand extends Command
}
if (true === $input->getOption('re-create')) {
$this->purgeUsersConfig($usersPath, $dryRun);
self::purgeUsersConfig(Config::get('path') . '/users', $dryRun);
}
$backends = $this->loadBackends();
$backends = self::loadBackends();
if (empty($backends)) {
$this->logger->error('SYSTEM: No valid backends were found.');
self::$logger?->error('SYSTEM: No valid backends were found.');
return self::FAILURE;
}
$mapping = $this->loadMappings();
$mapping = self::loadMappings();
$this->logger->notice("SYSTEM: Getting users list from '{backends}'.", [
self::$logger?->notice("SYSTEM: Getting users list from '{backends}'.", [
'backends' => join(', ', array_keys($backends))
]);
$backendsUser = $this->get_backends_users($backends, $mapping);
$backendsUser = self::get_backends_users($backends, $mapping);
if (count($backendsUser) < 1) {
$this->logger->error('SYSTEM: No Backend users were found.');
self::$logger?->error('SYSTEM: No Backend users were found.');
return self::FAILURE;
}
$users = $this->generate_users_list($backendsUser, $mapping);
$obj = self::generate_users_list($backendsUser, $mapping);
$users = ag($obj, 'matched', []);
if (count($users) < 1) {
$this->logger->warning("We weren't able to match any users across backends.");
self::$logger?->warning("We weren't able to match any users across backends.");
return self::FAILURE;
}
$this->logger->notice("SYSTEM: User matching results {results}.", [
'results' => arrayToString($this->usersList($users)),
self::$logger?->notice("SYSTEM: Matched '{results}'.", [
'results' => arrayToString(self::usersList($users)),
]);
$this->create_user(input: $input, users: $users);
self::create_user(input: $input, users: $users);
return self::SUCCESS;
}
@@ -647,9 +662,10 @@ class CreateUsersCommand extends Command
* @param array $users The list of users from all backends.
* @param array{string: array{string: string, options: array}} $map The map of users to match.
*
* @return array{matched: array{name: string, backends: array<string, array<string, mixed>>,unmatched: array<array-key,string>} The list of matched users and unmatched.
* @return array{name: string, backends: array<string, array<string, mixed>>}[] The list of matched users.
*/
private function generate_users_list(array $users, array $map = []): array
public static function generate_users_list(array $users, array $map = []): array
{
$allBackends = [];
foreach ($users as $u) {
@@ -665,7 +681,7 @@ class CreateUsersCommand extends Command
$backend = $user['backend'];
$nameLower = strtolower($user['name']);
if (ag($user, 'id') === ag($user, 'client_data.options.' . Options::ALT_ID)) {
$this->logger->debug('Skipping main user "{name}".', ['name' => $user['name']]);
self::$logger?->debug('Skipping main user "{name}".', ['name' => $user['name']]);
continue;
}
if (!isset($usersBy[$backend])) {
@@ -675,6 +691,7 @@ class CreateUsersCommand extends Command
$usersList[$backend][] = $nameLower;
}
$unmatched = [];
$results = [];
// Track used combos: array of [backend, nameLower].
@@ -776,7 +793,7 @@ class CreateUsersCommand extends Command
$matchedMapEntry = null;
foreach ($map as $mapRow) {
if (ag($mapRow, "{$backend}.name") === $nameLower) {
$this->logger->notice("Mapper: Found map entry for '{backend}: {user}'", [
self::$logger?->notice("Mapper: Found map entry for '{backend}: {user}'", [
'backend' => $backend,
'user' => $nameLower,
'map' => $mapRow,
@@ -844,7 +861,7 @@ class CreateUsersCommand extends Command
}
continue;
} else {
$this->logger->error("No partial fallback match via map for '{backend}: {user}'", [
self::$logger?->error("No partial fallback match via map for '{backend}: {user}'", [
'backend' => $userObj['backend'],
'user' => $userObj['name'],
]);
@@ -878,19 +895,29 @@ class CreateUsersCommand extends Command
}
// If neither map nor direct matched for ≥2
$this->logger->error("No other users were found that match '{backend}: {user}'.", [
self::$logger?->error("No other users were found that match '{backend}: {user}{real_name}'.", [
'backend' => $userObj['backend'],
'user' => $userObj['name'],
'real_name' => $userObj['real_name'] !== $userObj['name'] ? r(' ({rl})', [
'rl' => $userObj['real_name']
]) : '',
'map' => arrayToString($map),
'list' => arrayToString($usersList),
]);
$unmatched[] = [
'id' => $userObj['id'],
'name' => $userObj['name'],
'backend' => $backend,
'real_name' => $userObj['real_name'] ?? null,
];
}
}
return $results;
return ['matched' => $results, 'unmatched' => $unmatched];
}
private function usersList(array $list): array
public static function usersList(array $list): array
{
$chunks = [];
@@ -931,10 +958,12 @@ class CreateUsersCommand extends Command
* options: { }
* ```
*/
private function map_actions(string $backend, array &$user, array &$mapping): void
public static function map_actions(string $backend, array &$user, array &$mapping): void
{
static $reported = [];
if (null === ($username = ag($user, 'name'))) {
$this->logger->error("MAPPER: No username was given from one user of '{backend}' backend.", [
self::$logger?->error("MAPPER: No username was given from one user of '{backend}' backend.", [
'backend' => $backend
]);
return;
@@ -942,9 +971,12 @@ class CreateUsersCommand extends Command
$hasMapping = array_filter($mapping, fn($map) => array_key_exists($backend, $map));
if (count($hasMapping) < 1) {
$this->logger->info("MAPPER: No mapping exists for '{backend}' backend.", [
'backend' => $backend
]);
if (!isset($reported[$backend])) {
$reported[$backend] = true;
self::$logger?->info("MAPPER: No mapping with '{backend}' as backend exists.", [
'backend' => $backend
]);
}
return;
}
@@ -966,7 +998,7 @@ class CreateUsersCommand extends Command
}
if (false === $found) {
$this->logger->debug("MAPPER: No map exists for '{backend}: {username}'.", [
self::$logger?->debug("MAPPER: No map exists for '{backend}: {username}'.", [
'backend' => $backend,
'username' => $username
]);
@@ -975,7 +1007,7 @@ class CreateUsersCommand extends Command
if (null !== ($newUsername = ag($user_map, 'replace_with'))) {
if (false === is_string($newUsername) || false === isValidName($newUsername)) {
$this->logger->error(
self::$logger?->error(
message: "MAPPER: Failed to replace '{backend}: {username}' with '{backend}: {new_username}' name must be in [a-z_0-9] format.",
context: [
'backend' => $backend,
@@ -986,7 +1018,7 @@ class CreateUsersCommand extends Command
return;
}
$this->logger->notice(
self::$logger?->notice(
message: "MAPPER: Renaming '{backend}: {username}' to '{backend}: {new_username}'.",
context: [
'backend' => $backend,
@@ -999,4 +1031,10 @@ class CreateUsersCommand extends Command
$user_map['name'] = $newUsername;
}
}
public static function hasUsers(): bool
{
$usersPath = Config::get('path') . '/users';
return is_dir($usersPath) && count(glob($usersPath . '/*/*.yaml')) > 0;
}
}