mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-29 20:42:31 +02:00
some ui refactorings,
use_user_db option for storing api user inside db file
This commit is contained in:
+1
-1
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
--attention-color: red;
|
||||
--warn-color: orange;
|
||||
--ok-color: #21e821;
|
||||
|
||||
--card-background-color: #202733; //#282828;
|
||||
--card-color: #ffffff;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user