some ui refactorings,

use_user_db option for storing api user inside db file
This commit is contained in:
euzu
2025-02-26 13:46:23 +01:00
parent 4a5f1b488d
commit cbef4e3d4b
16 changed files with 493 additions and 123 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
- Added `user_config_dir` to `config.yml`. It is the storage path for user configurations (f.e. bouquets).
- New Filter field `input` can be used along `name`, `group`, `title`, `url` and `type`. Input is a `regexp` filter. `input ~ "provider\-\d+"`
- New option `use_user_db` in `api-proxy.yml`. The Playlist Users are stored inside the config file `api-proxy.yml`. When you set this option to `true`
the user are stored in a db file. This is a better choice if you have a lot of users. If you have only a few let it defaultto `false`
the user are stored in a db file. This is a better choice if you have a lot of users. If you have only a few let it default to `false`
# 2.2.1 (2025-02-14)
- Added more info to `/status`.
+71 -8
View File
@@ -501,6 +501,11 @@ If `kodi_style` set to `true` the property `#KODIPROP:seekable=true|false` is ad
- `xtream_skip_video_direct_source` if true the direct_source property from provider for movies is ignored
- `xtream_skip_series_direct_source` if true the direct_source property from provider for series is ignored
Iptv player can act differently and use the direct-source attribute or can compose the url based on the server info.
The options `xtream_skip_live_direct_source`, `xtream_skip_video_direct_source` and`xtream_skip_series_direct_source`
are default `true` to avoid this problem.
You can set them fo `false`to keep the direct-source attribute.
Because xtream api delivers only the metadata to series, we need to fetch the series and resolve them. But be aware,
each series info entry needs to be fetched one by one and the provider can ban you if you are doing request too frequently.
- `xtream_resolve_series` if is set to `true` and you have xtream input and m3u output, the series are fetched and resolved.
@@ -522,6 +527,7 @@ There is a difference for `xtream_resolve_vod` and `xtream_resolve_series`.
`xtream_resolve_series` works only when input: `xtream` and output: `m3u`.
`xtream_resolve_vod` works only when input: `xtream`.
### 2.2.2.5 `filter`
The filter is a string with a filter statement.
The filter can have UnaryExpression `NOT`, BinaryExpression `AND OR`, Regexp Comparison `(Group|Title|Name|Url) ~ "regexp"`
@@ -920,17 +926,48 @@ mappings:
```
## 3. Api-Proxy Config
If you use the proxy functionality,
you need to create a `api-proxy.yml` configuration.
You can specify the path for the file with the `-a` cli argument.
If you use m3u-filter to deliver playlists, we require a configuration to provide the necessary server information, rewrite URLs in reverse proxy mode, and define users who can access the API.
The configuration contains the server info for xtream accounts and user definitions.
You can define multiple server with unique names, one should be named `default`.
For this purpose, we use the `api-proxy.yml` configuration.
Iptv player can act differently and use the direct-source attribute or can compose the url based on the server info.
The options `xtream_skip_live_direct_source`, `xtream_skip_video_direct_source` and `xtream_skip_series_direct_source` are default `true` to avoid this problem.
You can set them fo `false`to keep the direct-source attribute.
You can specify the path to the file using the `-a` CLI argument.
You can define multiple servers with unique names; typically, two are defined—one for the local network and one for external access.
One server should be named `default`.
```yaml
server:
- name: default
protocol: http
host: 192.169.1.9
port: '8901'
timezone: Europe/Paris
message: Welcome to m3u-filter
- name: external
protocol: https
host: m3ufilter.mydomain.tv
port: '443'
timezone: Europe/Paris
message: Welcome to m3u-filter
path: m3uflt
```
User definitions are made for the targets. Each target can have multiple users. Usernames and tokens must be unique.
```yaml
user:
- target: xc_m3u
credentials:
- username: test1
password: secret1
token: 'token1'
proxy: reverse
server: default
exp_date: 1672705545
max_connections: 1
status: Active
```
`username` and `password`are mandatory for credentials. `username` is unique.
The `token` is _optional_. If defined it should be unique. The `token`can be used
@@ -939,6 +976,20 @@ instead of username+password
`server` is _optional_. It should match one server definition, if not given the server with the name `default` is used or the first one.
`epg_timeshift` is _optional_. It is only applied when source has `epg_url` configured. `epg_timeshift: [-+]hh:mm`, example
`-2:30`(-2h30m), `1:45` (1h45m), `+0:15` (15m), `2` (2h), `:30` (30m), `:3` (3m), `2:` (3h)
- `max_connections` is _optional_
- `status` is _optional_
- `exp_date` is _optional_
`max_connections`, `status` and `exp_date` are only used when `user_access_control` ist ste to true.
If you have a lot of users and dont want to keep them in `api-proxy.yml`, you can set the option
- `use_user_db` to true to store the user information inside a db-file.
If the `use_user_db` option is switched to `false` or `true`, the users will automatically
be migrated to the corresponding file (`false` → `api_proxy.yml`, `true` → `api_user.db`).
If you set `use_user_db` to `true` you need to use the `Web-UI` to `edit`/`add`/`remove` users.
To access the api for:
- `xtream` use url like `http://192.169.1.2/player_api.php?username={}&password={}`
@@ -966,6 +1017,7 @@ If you use `https` you need a ssl terminator. `m3u-filter` does not support http
If you use a ssl-terminator or proxy in front of m3u-filter you can set a `path` to make the configuration of your proxy simpler.
For example you use `nginx` as your reverse proxy.
`api-proxy.yml`
```yaml
server:
@@ -982,6 +1034,17 @@ server:
timezone: Europe/Paris
message: Welcome to m3u-filter
path: m3uflt
user:
- target: xc_m3u
credentials:
- username: test1
password: secret1
token: 'token1'
proxy: reverse
server: default
exp_date: 1672705545
max_connections: 1
status: Active
```
Now you can do `nginx` configuration like
+4 -1
View File
@@ -73,7 +73,9 @@
"MESSAGE": "Message",
"PROTOCOL": "Protocol",
"NAME": "Name",
"API_PROXY": "Api-Proxy"
"API_PROXY": "Api-Proxy",
"INFINITE": "Infinite",
"ONE_YEAR": "One year"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
@@ -106,6 +108,7 @@
"FAIL": "Failed to save user!",
"USERNAME_REQUIRED": "Username required",
"DUPLICATE_USERNAME": "Duplicate username!",
"DUPLICATE_TOKEN": "Duplicate token!",
"TARGET_NOT_FOUND": "Target not found!",
"MAX_CONNECTIONS_INVALID": "MaxConnections invalid"
},
@@ -1,11 +1,54 @@
@use '../../scss/common';
@import "react-datepicker/dist/react-datepicker.css";
.date-picker-container {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px;
&__toolbar {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px;
button {
@include common.iconButton();
}
svg {
fill: var(--icon-button-color);
}
}
}
.react-datepicker-wrapper {
box-sizing: border-box;
overflow: hidden;
}
.react-datepicker__header {
h2 {
display: none;
}
}
.react-datepicker__navigation--years-upcoming {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 12px solid var(--border-color);
transform: translateY(4px);
}
.react-datepicker__navigation--years-previous {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 12px solid var(--border-color);
transform: translateY(-4px);
}
.react-datepicker__input-container {
display: flex;
flex-flow: row-reverse nowrap;
@@ -15,6 +58,7 @@
border-radius: var(--border-radius);
user-select: none;
cursor: pointer;
box-sizing: border-box;
input {
user-select: none;
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect} from "react";
import "./date-picker.scss";
import ReactDatePicker from "react-datepicker";
import {getIconByName} from "../../icons/icons";
import useTranslator from "../../hook/use-translator";
interface DatePickerProps {
@@ -12,6 +13,7 @@ interface DatePickerProps {
export default function DatePicker(props: DatePickerProps) {
const {name, value, onChange} = props;
const translate= useTranslator();
const [selected, setSelected] = React.useState<any>(value);
const datePickerRef = React.useRef<ReactDatePicker>(null);
@@ -31,19 +33,36 @@ export default function DatePicker(props: DatePickerProps) {
}
}, []);
const handleInfiniteDate = useCallback(() => {
handleDateChange(new Date(0));
}, [handleDateChange]);
const handleNowDate = useCallback(() => {
const date = new Date();
date.setFullYear(date.getFullYear() + 1);
handleDateChange(date);
}, [handleDateChange]);
return (
<div className="date-picker-container" onClick={handleClick}>
<ReactDatePicker
ref={datePickerRef}
showIcon={true}
icon={getIconByName('Calendar')}
dateFormat={'YYYY-MM-dd'}
// readOnly={true}
showMonthDropdown={true}
showYearDropdown={true}
onSelect={handleDateChange}
selected={selected}>
</ReactDatePicker>
<div className="date-picker-container">
<div className="date-picker-container-wrapper" onClick={handleClick}>
<ReactDatePicker
ref={datePickerRef}
showIcon={true}
icon={getIconByName('Calendar')}
dateFormat={'YYYY-MM-dd'}
// readOnly={true}
showMonthDropdown={true}
showYearDropdown={true}
minDate={new Date(0)}
onSelect={handleDateChange}
selected={selected}>
</ReactDatePicker>
</div>
<div className={'date-picker-container__toolbar'}>
<button title={translate('LABEL.INFINITE')} onClick={handleInfiniteDate}>{getIconByName('Unlimited')}</button>
<button title={translate('LABEL.ONE_YEAR')} onClick={handleNowDate}>{getIconByName('Today')}</button>
</div>
</div>
);
}
@@ -1,6 +1,26 @@
@use '../../scss/common';
@use '../../scss/preferences';
.proxy-type {
&-reverse {
}
&-redirect {
color: #fffa00;
}
}
.status {
&-active, .status-trial {
color: var(--ok-color);
}
&-banned {
color: var(--attention-color);
}
&-expired, &-disabled, &-pending {
color: var(--warn-color);
}
}
.tag-select {
min-width: 160px;
}
+58 -14
View File
@@ -12,15 +12,43 @@ import PlaylistFilter from "../playlist-filter/playlist-filter";
import UserEditor from "../user-editor/user-editor";
import TextGenerator from "../../utils/text-generator";
const renderExpDate = (value: any, hidden?: boolean) => {
if (!value) {
return getIconByName('Unlimited');
}
return DateUtils.formatDate(value)
};
const renderMaxCon = (value: any, hidden?: boolean) => {
if (!value) {
return getIconByName('Unlimited');
}
return value;
};
const renderStatus = (value: any, hidden?: boolean) => {
if (value) {
return <span className={'status-' + value.toLowerCase()}>{value}</span>
}
return value;
};
const renderProxyType = (value: any, hidden?: boolean) => {
if (value) {
return <span className={'proxy-type-' + value.toLowerCase()}>{value}</span>
}
return value;
};
const COLUMNS = [
{field: 'username', label: 'LABEL.USERNAME'},
{field: 'password', label: 'LABEL.PASSWORD', hidden: true},
{field: 'token', label: 'LABEL.TOKEN', hidden: true},
{field: 'server', label: 'LABEL.SERVER'},
{field: 'proxy', label: 'LABEL.PROXY'},
{field: 'max_connections', label: 'LABEL.MAX_CON'},
{field: 'status', label: 'LABEL.STATUS'},
{field: 'exp_date', label: 'LABEL.EXP_DATE', render: (value: any, hidden?: boolean) => DateUtils.formatDate(value)},
{field: 'proxy', label: 'LABEL.PROXY', render: renderProxyType},
{field: 'max_connections', label: 'LABEL.MAX_CON', render: renderMaxCon},
{field: 'status', label: 'LABEL.STATUS', render: renderStatus},
{field: 'exp_date', label: 'LABEL.EXP_DATE', render: renderExpDate},
]
COLUMNS.forEach(col => {
@@ -33,7 +61,10 @@ const prepareCredentials = (targetUser: TargetUser[]) => {
targetUser.forEach((user) => {
user.credentials.forEach((credential) => {
if (credential.exp_date) {
credential.exp_date = new Date(credential.exp_date * 1000) as any;
credential.exp_date = DateUtils.unixSecondsToDate(credential.exp_date);
}
if (credential.created_at) {
credential.created_at = DateUtils.unixSecondsToDate(credential.created_at);
}
})
});
@@ -41,13 +72,12 @@ const prepareCredentials = (targetUser: TargetUser[]) => {
const prepareTargetUserForSave = (targetUser: TargetUser[]): TargetUser[] => {
return targetUser.map((user) => {
let newUser = {...user, credentials: user.credentials.map(c => ({...c}))};
newUser.credentials.forEach((credential) => {
if (credential.exp_date) {
credential.exp_date = Math.floor((credential.exp_date as any).getTime() / 1000);
}
let storeUser = {...user, credentials: user.credentials.map(c => ({...c}))};
storeUser.credentials.forEach((credential) => {
credential.exp_date = DateUtils.toUnixSeconds(credential.exp_date);
credential.created_at = DateUtils.toUnixSeconds(credential.created_at);
});
return newUser;
return storeUser;
});
}
@@ -93,15 +123,17 @@ const createNewUser = (targets: TargetUser[]): Credentials => {
break;
}
}
const created_at = Math.floor(Date.now() / 1000);
const created_at = Date.now();
const exp_date = new Date();
exp_date.setFullYear(exp_date.getFullYear()+1);
return {
username,
password: TextGenerator.generatePassword(),
token: TextGenerator.generatePassword(),
proxy: 'reverse',
created_at,
exp_date: undefined,
max_connections: undefined,
exp_date: exp_date.getTime(),
max_connections: 1,
status: "Active",
// @ts-ignore
_ref: undefined, // an indicator for new user
@@ -203,6 +235,7 @@ export default function UserView(props: UserViewProps) {
const handleSave = useCallback(() => {
const usernames: any = {};
const tokens: any = {};
for (const target of targets) {
for (const user of target.credentials) {
const err = checkuser(user);
@@ -210,7 +243,18 @@ export default function UserView(props: UserViewProps) {
enqueueSnackbar(translate(err), {variant: 'error'});
return;
}
if (usernames[user.username]) {
enqueueSnackbar(translate("MESSAGES.USER.DUPLICATE_USERNAME") + user.username, {variant: 'error'});
return;
}
usernames[user.username] = true;
if (user.token) {
if (tokens[user.token]) {
enqueueSnackbar(translate("MESSAGES.USER.DUPLICATE_TOKEN") + user.token, {variant: 'error'});
return;
}
tokens[user.token] = true;
}
}
}
const targetUser = targets.map(t => {
+3 -1
View File
@@ -51,7 +51,9 @@ const DEFAULT_ICONS = {
DeselectAll: 'M 2,5 V 7.1538458 H 13.612371 V 5 Z M 2,9.3076922 V 11.461539 H 13.612371 V 9.3076922 Z m 14.082473,1.7899618 -1.265978,1.392431 2.327832,2.559791 -2.327832,2.557693 L 16.082473,19 18.408247,16.442307 20.734022,19 22,17.607569 19.674225,15.049876 22,12.490085 20.734022,11.097654 18.408247,13.655347 Z M 2,13.615385 v 2.153847 h 7.3896896 v -2.153847 z',
Visibility: 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3',
Edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75z',
Calendar: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 16H5V10h14zM9 14H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2zm-8 4H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2z'
Calendar: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 16H5V10h14zM9 14H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2zm-8 4H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2z',
Unlimited: 'M18.6 6.62c-1.44 0-2.8.56-3.77 1.53L12 10.66 10.48 12h.01L7.8 14.39c-.64.64-1.49.99-2.4.99-1.87 0-3.39-1.51-3.39-3.38S3.53 8.62 5.4 8.62c.91 0 1.76.35 2.44 1.03l1.13 1 1.51-1.34L9.22 8.2C8.2 7.18 6.84 6.62 5.4 6.62 2.42 6.62 0 9.04 0 12s2.42 5.38 5.4 5.38c1.44 0 2.8-.56 3.77-1.53l2.83-2.5.01.01L13.52 12h-.01l2.69-2.39c.64-.64 1.49-.99 2.4-.99 1.87 0 3.39 1.51 3.39 3.38s-1.52 3.38-3.39 3.38c-.9 0-1.76-.35-2.44-1.03l-1.14-1.01-1.51 1.34 1.27 1.12c1.02 1.01 2.37 1.57 3.82 1.57 2.98 0 5.4-2.41 5.4-5.38s-2.42-5.37-5.4-5.37',
Today: 'M16.53 11.06 15.47 10l-4.88 4.88-2.12-2.12-1.06 1.06L10.59 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V8h14z'
}
+1
View File
@@ -34,6 +34,7 @@
--attention-color: red;
--warn-color: orange;
--ok-color: #21e821;
--card-background-color: #202733; //#282828;
--card-color: #ffffff;
+35 -4
View File
@@ -2,11 +2,18 @@ function formatDate(date: any): string {
if (typeof date === 'string') {
return date;
}
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
if (typeof date === 'number') {
date = new Date(date);
return `${year}-${month}-${day}`;
}
if (typeof date?.getTime === 'function') {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${year}-${month}-${day}`;
}
return '';
}
const DateUtils = {
@@ -15,6 +22,30 @@ const DateUtils = {
return formatDate(date);
}
return '';
},
toUnixSeconds: (date: any) => {
if (date && typeof date.getTime == 'function') {
if (date.getTime() > 0) {
return Math.floor(date.getTime() / 1000);
}
return 0;
}
if (date && typeof date == 'number') {
if ( date > 0) {
return Math.floor(date / 1000);
}
return 0;
}
return date;
},
unixSecondsToDate: (date: any) => {
if (date && typeof date == 'number') {
return new Date(date * 1000);
}
if (date && typeof date.getTime == 'function') {
return date;
}
return date;
}
}
+7
View File
@@ -50,6 +50,7 @@ async fn save_config_api_proxy_user(
) -> HttpResponse {
let mut users = req.0;
let mut usernames = HashSet::new();
let mut tokens = HashSet::new();
for target_user in &mut users {
for credential in &mut target_user.credentials {
credential.trim();
@@ -60,6 +61,12 @@ async fn save_config_api_proxy_user(
return HttpResponse::BadRequest().json(json!({"error": format!("Duplicate username {}", &credential.username)}));
}
usernames.insert(&credential.username);
if let Some(token) = &credential.token {
if tokens.contains(token) {
return HttpResponse::BadRequest().json(json!({"error": format!("Duplicate token {token}")}));
}
tokens.insert(token);
}
}
}
@@ -387,56 +387,56 @@ pub async fn create_provider_stream(cfg: &Config,
}
}
#[cfg(test)]
mod tests {
use crate::api::model::streams::provider_stream_factory::PlaylistItemType;
use crate::api::model::streams::provider_stream_factory::{create_provider_stream, BufferStreamOptions};
use actix_web::test;
use actix_web::test::TestRequest;
use actix_web::web;
use actix_web::App;
use actix_web::{HttpRequest, HttpResponse};
use futures::StreamExt;
use std::sync::Arc;
use crate::model::config::Config;
#[actix_rt::test]
async fn test_stream() {
let app = App::new().route("/test", web::get().to(test_stream_handler));
let server = test::init_service(app).await;
let req = TestRequest::get().uri("/test").to_request();
let _response = test::call_service(&server, req).await;
}
async fn test_stream_handler(req: HttpRequest) -> HttpResponse {
let cfg = Config::default();
let mut counter = 5;
let client = Arc::new(reqwest::Client::new());
let url = url::Url::parse("https://info.cern.ch/hypertext/WWW/TheProject.html").unwrap();
let input = None;
let options = BufferStreamOptions::new(PlaylistItemType::Live, true, true, 0, false);
let value = create_provider_stream(&cfg, Arc::clone(&client), &url, &req, input, options);
let mut values = value.await;
'outer: while let Some((ref mut stream, info)) = values.as_mut() {
if info.is_some() {
println!("{:?}", info.as_ref().unwrap());
}
while let Some(result) = stream.next().await {
match result {
Ok(bytes) => {
println!("Received {} bytes {bytes:?}", bytes.len());
counter -= 1;
if counter < 0 {
break 'outer;
}
}
Err(err) => {
eprintln!("Error occurred: {}", err);
break 'outer;
}
}
}
}
HttpResponse::Ok().finish()
}
}
// #[cfg(test)]
// mod tests {
// use crate::api::model::streams::provider_stream_factory::PlaylistItemType;
// use crate::api::model::streams::provider_stream_factory::{create_provider_stream, BufferStreamOptions};
// use actix_web::test;
// use actix_web::test::TestRequest;
// use actix_web::web;
// use actix_web::App;
// use actix_web::{HttpRequest, HttpResponse};
// use futures::StreamExt;
// use std::sync::Arc;
// use crate::model::config::Config;
//
// #[actix_rt::test]
// async fn test_stream() {
// let app = App::new().route("/test", web::get().to(test_stream_handler));
// let server = test::init_service(app).await;
// let req = TestRequest::get().uri("/test").to_request();
// let _response = test::call_service(&server, req).await;
// }
// async fn test_stream_handler(req: HttpRequest) -> HttpResponse {
// let cfg = Config::default();
// let mut counter = 5;
// let client = Arc::new(reqwest::Client::new());
// let url = url::Url::parse("https://info.cern.ch/hypertext/WWW/TheProject.html").unwrap();
// let input = None;
//
// let options = BufferStreamOptions::new(PlaylistItemType::Live, true, true, 0, false);
// let value = create_provider_stream(&cfg, Arc::clone(&client), &url, &req, input, options);
// let mut values = value.await;
// 'outer: while let Some((ref mut stream, info)) = values.as_mut() {
// if info.is_some() {
// println!("{:?}", info.as_ref().unwrap());
// }
// while let Some(result) = stream.next().await {
// match result {
// Ok(bytes) => {
// println!("Received {} bytes {bytes:?}", bytes.len());
// counter -= 1;
// if counter < 0 {
// break 'outer;
// }
// }
// Err(err) => {
// eprintln!("Error occurred: {}", err);
// break 'outer;
// }
// }
// }
// }
// HttpResponse::Ok().finish()
// }
// }
+1 -1
View File
@@ -1,4 +1,4 @@
#![warn(clippy::pedantic)]
#![warn(clippy::all,clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::return_self_not_must_use)]
+7 -6
View File
@@ -1,7 +1,7 @@
use crate::api::model::app_state::AppState;
use crate::m3u_filter_error::{create_m3u_filter_error_result, info_err, M3uFilterError, M3uFilterErrorKind};
use crate::model::config::Config;
use crate::repository::user_repository::{get_api_user_db_path, load_api_user, merge_api_user};
use crate::repository::user_repository::{backup_api_user_db_file, get_api_user_db_path, load_api_user, merge_api_user};
use crate::utils::file::config_reader;
use chrono::Local;
use enum_iterator::Sequence;
@@ -304,7 +304,7 @@ impl ApiProxyConfig {
let api_proxy_file = cfg.t_api_proxy_file_path.as_str();
let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str();
self.user = vec![];
if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, &self) {
if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, self) {
errors.push(format!("Error saving api proxy file: {err}"));
}
}
@@ -315,7 +315,7 @@ impl ApiProxyConfig {
}
Err(err) => {
println!("{err}");
errors.push(err.to_string())
errors.push(err.to_string());
},
};
} else {
@@ -326,8 +326,8 @@ impl ApiProxyConfig {
if let Ok(stored_users) = load_api_user(cfg) {
for stored_user in stored_users {
if let Some(target_user) = self.user.iter_mut().find(|t| t.target == stored_user.target) {
for stored_credential in stored_user.credentials.iter() {
if target_user.credentials.iter().find(|&c| c.username == stored_credential.username).is_none() {
for stored_credential in &stored_user.credentials {
if !target_user.credentials.iter().any(|c| c.username == stored_credential.username) {
target_user.credentials.push(stored_credential.clone());
};
}
@@ -338,9 +338,10 @@ impl ApiProxyConfig {
}
let api_proxy_file = cfg.t_api_proxy_file_path.as_str();
let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str();
if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, &self) {
if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, self) {
errors.push(format!("Error saving api proxy file: {err}"));
} else {
backup_api_user_db_file(cfg, &user_db_path);
let _ = fs::remove_file(&user_db_path);
}
}
+156 -21
View File
@@ -1,79 +1,137 @@
use crate::model::api_proxy::{ProxyUserCredentials, TargetUser};
use crate::model::api_proxy::{ProxyType, ProxyUserCredentials, ProxyUserStatus, TargetUser};
use crate::model::config::Config;
use crate::model::playlist::XtreamCluster;
use crate::model::playlist_categories::{PlaylistCategoriesDto, PlaylistCategoryDto};
use crate::repository::bplustree::BPlusTree;
use crate::utils::file::file_utils;
use crate::utils::json_utils::json_write_documents_to_file;
use log::error;
use log::{error};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use chrono::Local;
const USER_LIVE_BOUQUET: &str = "live_bouquet.json";
const USER_VOD_BOUQUET: &str = "vod_bouquet.json";
const USER_SERIES_BOUQUET: &str = "series_bouquet.json";
const API_USER_DB_FILE: &str = "api_user.db";
// This is a Helper class to store all user into one Database file.
// For the Config files we keep the old structure where a user is assigned to a target.
// But for storing inside one db file it is easier to store the target next to the user.
// due to known issue with bincode and skip_serialization_if we have to list all fields and cant use ProxyUserCredentials
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct StoredProxyUserCredentials {
pub target: String,
pub user: ProxyUserCredentials,
pub username: String,
pub password: String,
pub token: Option<String>,
pub proxy: ProxyType,
pub server: Option<String>,
pub epg_timeshift: Option<String>,
pub created_at: Option<i64>,
pub exp_date: Option<i64>,
pub max_connections: Option<u32>,
pub status: Option<ProxyUserStatus>,
}
impl StoredProxyUserCredentials {
fn from(proxy: &ProxyUserCredentials, target_name: &str) -> Self {
Self {
target: String::from(target_name),
username: proxy.username.clone(),
password: proxy.password.clone(),
token: proxy.token.clone(),
proxy: proxy.proxy.clone(),
server: proxy.server.clone(),
epg_timeshift: proxy.epg_timeshift.clone(),
created_at: proxy.created_at,
exp_date: proxy.exp_date,
max_connections: proxy.max_connections,
status: proxy.status.clone(),
}
}
fn to(stored: &StoredProxyUserCredentials) -> ProxyUserCredentials {
ProxyUserCredentials {
username: stored.username.clone(),
password: stored.password.clone(),
token: stored.token.clone(),
proxy: stored.proxy.clone(),
server: stored.server.clone(),
epg_timeshift: stored.epg_timeshift.clone(),
created_at: stored.created_at,
exp_date: stored.exp_date,
max_connections: stored.max_connections,
status: stored.status.clone(),
}
}
}
pub fn get_api_user_db_path(cfg: &Config) -> PathBuf {
PathBuf::from(&cfg.t_config_path).join("api_user.db")
PathBuf::from(&cfg.t_config_path).join(API_USER_DB_FILE)
}
fn add_target_user_to_user_tree(target_users: &[TargetUser], user_tree: &mut BPlusTree<String, StoredProxyUserCredentials>) {
for target_user in target_users {
for user in &target_user.credentials {
let store_user = StoredProxyUserCredentials {
target: target_user.target.clone(),
user: user.clone(),
};
let store_user: StoredProxyUserCredentials = StoredProxyUserCredentials::from(user, &target_user.target);
user_tree.insert(user.username.clone(), store_user);
}
}
}
pub fn merge_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result<u64, std::io::Error> {
let path = get_api_user_db_path(&cfg);
let _lock = cfg.file_locks.read_lock(&path);
let path = get_api_user_db_path(cfg);
let lock = cfg.file_locks.read_lock(&path);
let mut user_tree: BPlusTree<String, StoredProxyUserCredentials> = BPlusTree::load(&path).unwrap_or_else(|_| BPlusTree::new());
drop(_lock);
drop(lock);
add_target_user_to_user_tree(target_users, &mut user_tree);
let _lock = cfg.file_locks.write_lock(&path);
user_tree.store(&path)
}
/// # Panics
///
/// Will panic if `backup_dir` is not given
pub fn backup_api_user_db_file(cfg: &Config, path: &Path) {
let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str();
let backup_path = PathBuf::from(backup_dir).join(format!("{API_USER_DB_FILE}_{}", Local::now().format("%Y%m%d_%H%M%S")));
let _lock = cfg.file_locks.read_lock(path);
match std::fs::copy(path, &backup_path) {
Ok(_) => {}
Err(err) => { error!("Could not backup file {}:{}", &backup_path.to_str().unwrap_or("?"), err) }
}
}
pub fn store_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result<u64, std::io::Error> {
let mut user_tree = BPlusTree::<String, StoredProxyUserCredentials>::new();
add_target_user_to_user_tree(target_users, &mut user_tree);
let path = get_api_user_db_path(&cfg);
let path = get_api_user_db_path(cfg);
backup_api_user_db_file(cfg, &path);
let _lock = cfg.file_locks.write_lock(&path);
user_tree.store(&path)
}
pub fn load_api_user(cfg: &Config) -> Result<Vec<TargetUser>, std::io::Error> {
let path = get_api_user_db_path(&cfg);
let _lock = cfg.file_locks.read_lock(&path);
let path = get_api_user_db_path(cfg);
let lock = cfg.file_locks.read_lock(&path);
let user_tree = BPlusTree::<String, StoredProxyUserCredentials>::load(&path)?;
drop(_lock);
drop(lock);
let mut target_users: HashMap<String, TargetUser> = HashMap::new();
for (_uname, user_wrapper) in user_tree.iter() {
match target_users.entry(user_wrapper.target.clone()) {
for (_uname, stored_user) in &user_tree {
let proxy_user: ProxyUserCredentials = StoredProxyUserCredentials::to(stored_user);
let target_name = stored_user.target.clone();
match target_users.entry(target_name) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
let target = entry.get_mut();
target.credentials.push(user_wrapper.user.clone());
target.credentials.push(proxy_user);
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(TargetUser {
target: user_wrapper.target.clone(),
credentials: vec![user_wrapper.user.clone()],
target: stored_user.target.clone(),
credentials: vec![proxy_user],
});
}
}
@@ -189,4 +247,81 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i
} else {
Some(filter)
}
}
#[cfg(test)]
mod tests {
use std::env::temp_dir;
use crate::model::api_proxy::{ProxyType, ProxyUserStatus};
use super::*;
#[test]
pub fn save_target_user() {
let user =
TargetUser {
target: "test".to_string(),
credentials: vec![
ProxyUserCredentials {
username: "Test".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
proxy: ProxyType::Reverse,
server: Some("default".to_string()),
epg_timeshift: None,
created_at: None,
exp_date: Some(1672705545),
max_connections: Some(1),
status: Some(ProxyUserStatus::Active),
},
ProxyUserCredentials {
username: "Test2".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
proxy: ProxyType::Reverse,
server: Some("default".to_string()),
epg_timeshift: None,
created_at: None,
exp_date: Some(1672705545),
max_connections: Some(1),
status: Some(ProxyUserStatus::Expired),
},
ProxyUserCredentials {
username: "Test3".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
proxy: ProxyType::Reverse,
server: Some("default".to_string()),
epg_timeshift: None,
created_at: None,
exp_date: Some(1672705545),
max_connections: Some(1),
status: Some(ProxyUserStatus::Expired),
},
ProxyUserCredentials {
username: "Test4".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
proxy: ProxyType::Reverse,
server: Some("default".to_string()),
epg_timeshift: None,
created_at: None,
exp_date: Some(1672705545),
max_connections: Some(1),
status: Some(ProxyUserStatus::Expired),
}
]
};
let mut cfg = Config::default();
let target_user = vec![user];
cfg.t_config_path = temp_dir().to_string_lossy().to_string();
let _ = store_api_user(&cfg, &target_user);
let user_list = load_api_user(&cfg);
assert!(user_list.is_ok());
assert_eq!(user_list.as_ref().unwrap().len(), 1);
assert_eq!(user_list.as_ref().unwrap().get(0).unwrap().credentials.len(), 4);
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ fn write_config_file<T>(file_path: &str, backup_dir: &str, config: &T, default_n
T: ?Sized + Serialize {
let path = PathBuf::from(file_path);
let filename = path.file_name().map_or(default_name.to_string(), |f| f.to_string_lossy().to_string());
let backup_path = PathBuf::from(backup_dir).join(format!("{}_{}", filename, Local::now().format("%Y%m%d_%H%M%S")));
let backup_path = PathBuf::from(backup_dir).join(format!("{filename}_{}", Local::now().format("%Y%m%d_%H%M%S")));
match std::fs::copy(&path, &backup_path) {