Compare commits

...

159 Commits
1.2.3 ... main

Author SHA1 Message Date
8b9c100e87 Bump version: 1.4.9 → 1.4.10
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m40s
2025-07-23 09:28:24 +01:00
6462fc6009 execute on url update 2025-07-23 09:28:17 +01:00
dbf0161133 rework config login and add update NPM function 2025-07-23 09:26:06 +01:00
0d56235863 Bump version: 1.4.8 → 1.4.9
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m28s
2025-07-19 23:19:19 +01:00
5c80751484 cleanup 2025-07-19 23:19:16 +01:00
1d2b1db9df Bump version: 1.4.7 → 1.4.8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m26s
2025-07-19 23:13:54 +01:00
1dd83f4230 looking for extra urls 2025-07-19 23:13:40 +01:00
3888e6d536 Bump version: 1.4.6 → 1.4.7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m23s
2025-07-19 11:56:40 +01:00
d063d7fb7f lets deploy and see 2025-07-19 11:56:17 +01:00
70e7782918 extra url modifications 2025-07-19 11:31:00 +01:00
785fdb6dbb working add and remove dns via config 2025-07-19 11:05:09 +01:00
5106686a12 Bump version: 1.4.5 → 1.4.6
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m23s
2025-07-19 10:20:50 +01:00
21c48d0b6a show current entries in dns 2025-07-19 10:20:47 +01:00
7f42202383 Bump version: 1.4.4 → 1.4.5
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m27s
2025-07-19 10:08:41 +01:00
824bdd080f remove the route 2025-07-19 10:08:37 +01:00
ef38edfcd4 Bump version: 1.4.3 → 1.4.4
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m32s
2025-07-19 09:44:05 +01:00
7622716b92 fix the template 2025-07-19 09:44:02 +01:00
08ebb7a265 Bump version: 1.4.2 → 1.4.3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m23s
2025-07-19 09:33:00 +01:00
644ba005aa dns on config page 2025-07-19 09:32:49 +01:00
eea6708f42 Bump version: 1.4.1 → 1.4.2
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m25s
2025-07-19 09:16:36 +01:00
8b9880ce44 modify dns in config 2025-07-19 09:16:15 +01:00
21234fccc6 Bump version: 1.4.0 → 1.4.1
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m35s
2025-07-19 09:05:40 +01:00
c62441a2d1 manual check on config page 2025-07-19 09:05:28 +01:00
d519a268a0 Bump version: 1.3.53 → 1.4.0
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m33s
2025-07-19 08:56:19 +01:00
e76a54854b config page 2025-07-19 08:56:13 +01:00
6cf291f857 fix spacing 2025-07-18 17:45:21 +01:00
2cdb601706 Bump version: 1.3.52 → 1.3.53
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m23s
2025-07-18 17:36:28 +01:00
e84441e7f1 add and hide button 2025-07-18 17:36:25 +01:00
7ad67a80f5 better logged in logic 2025-07-18 17:33:26 +01:00
64f0da662b Bump version: 1.3.51 → 1.3.52
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m24s
2025-07-18 17:19:16 +01:00
472098f9f1 show me the error 2025-07-18 17:19:14 +01:00
ec928cf631 Bump version: 1.3.50 → 1.3.51
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m31s
2025-07-18 16:58:45 +01:00
9e3348d9b6 notification 2025-07-18 16:58:42 +01:00
75f210df5f Bump version: 1.3.49 → 1.3.50
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m36s
2025-07-18 16:31:42 +01:00
b5fcc31cf4 proxy workaround 2025-07-18 16:31:39 +01:00
898d737324 Bump version: 1.3.48 → 1.3.49
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m25s
2025-07-18 16:18:21 +01:00
33c8af61ca proxy to backend 2025-07-18 16:18:18 +01:00
b274bf12d3 Bump version: 1.3.47 → 1.3.48
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m27s
2025-07-18 16:09:34 +01:00
868571f1a8 logging on requesst 2025-07-18 16:09:30 +01:00
15a09789a0 Bump version: 1.3.46 → 1.3.47
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m48s
2025-07-18 15:57:07 +01:00
44fa24f2b7 notifications....again 2025-07-18 15:56:56 +01:00
c494a90227 Bump version: 1.3.45 → 1.3.46
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m24s
2025-07-18 14:12:46 +01:00
fc16f5f4f1 add media query 2025-07-18 14:12:43 +01:00
fc359a844f Bump version: 1.3.44 → 1.3.45
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m32s
2025-07-18 13:47:29 +01:00
a45f2ba3cb cache buster 2025-07-18 13:47:26 +01:00
7329313399 Bump version: 1.3.43 → 1.3.44
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m25s
2025-07-18 13:20:49 +01:00
37f888fa2d pixel6a style fix? 2025-07-18 13:20:44 +01:00
22be992500 Bump version: 1.3.42 → 1.3.43
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m25s
2025-07-18 12:43:07 +01:00
aec1549698 updated to fi x view on duly phone 2025-07-18 12:43:01 +01:00
e30d85065d Bump version: 1.3.41 → 1.3.42
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m24s
2025-07-18 11:57:31 +01:00
a6cddef761 remove paddleocr 2025-07-18 11:51:01 +01:00
70c900fc67 Bump version: 1.3.40 → 1.3.41
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 4m30s
2025-07-18 11:20:07 +01:00
fcd35f166e corrected url 2025-07-18 11:20:03 +01:00
a9ece95d07 Bump version: 1.3.39 → 1.3.40
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m50s
2025-07-18 10:32:45 +01:00
85f1ddb877 add dep 2025-07-18 10:32:42 +01:00
f77f395de6 Bump version: 1.3.38 → 1.3.39
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m34s
2025-07-18 09:50:06 +01:00
e16fb42e37 try fix notifications 2025-07-18 09:50:02 +01:00
7468b1b391 Bump version: 1.3.37 → 1.3.38
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m31s
2025-07-18 09:23:33 +01:00
3191fabf39 notification test 2025-07-18 09:23:30 +01:00
6736680131 Bump version: 1.3.36 → 1.3.37
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 15m23s
2025-07-18 09:01:19 +01:00
16dfcab6ba read from config 2025-07-18 09:01:13 +01:00
a041ec2abb Bump version: 1.3.35 → 1.3.36
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m29s
2025-07-18 08:49:42 +01:00
8c982666d6 redis fix 2025-07-18 08:49:26 +01:00
9e63cbc2f0 Bump version: 1.3.34 → 1.3.35
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m30s
2025-07-18 07:37:29 +01:00
e4f92a1db2 fix cache issue 2025-07-18 07:37:20 +01:00
b81d0ae81c Bump version: 1.3.33 → 1.3.34
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m26s
2025-07-17 19:23:37 +01:00
0f1e97bba2 refactor(notifications): rename test notification proxy function
Rename the `proxy_send_test_notification` function to `send_test_notification_proxy` for better naming consistency.

The corresponding template is updated to use `url_for` for the fetch request, making the URL generation more robust and maintainable.
2025-07-17 19:23:32 +01:00
a9276f63dc Bump version: 1.3.32 → 1.3.33
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m27s
2025-07-17 19:06:07 +01:00
cf46844a4e test notification 2025-07-17 19:05:59 +01:00
07dc830160 Bump version: 1.3.31 → 1.3.32
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m35s
2025-07-17 18:44:11 +01:00
6fa693ecd0 notifications 2025-07-17 18:44:09 +01:00
2e63cd951a Bump version: 1.3.30 → 1.3.31
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m30s
2025-07-17 18:13:18 +01:00
b082500c01 notifications 2025-07-17 18:13:10 +01:00
ab90dd2679 Bump version: 1.3.29 → 1.3.30
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m24s
2025-07-17 17:19:26 +01:00
978b9b2c71 loggin 2025-07-17 17:19:21 +01:00
d048360c11 Bump version: 1.3.28 → 1.3.29
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m33s
2025-07-17 17:08:27 +01:00
be49a31e54 notification fix 2025-07-17 17:08:11 +01:00
6b30769d96 Bump version: 1.3.27 → 1.3.28
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m38s
2025-07-17 16:58:28 +01:00
051e5fb290 more changes 2025-07-17 16:58:24 +01:00
c9329a33ea Bump version: 1.3.26 → 1.3.27
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m31s
2025-07-17 16:50:11 +01:00
b95f00486b relative path 2025-07-17 16:50:08 +01:00
b446b00fc6 Bump version: 1.3.25 → 1.3.26
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m27s
2025-07-17 16:43:10 +01:00
4caf1add26 send over https 2025-07-17 16:43:07 +01:00
d40f98f5e4 Bump version: 1.3.24 → 1.3.25
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m27s
2025-07-17 16:34:22 +01:00
b5f1375a87 use the baseurl for the call 2025-07-17 16:34:17 +01:00
fbaf79c9ae Bump version: 1.3.23 → 1.3.24
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m22s
2025-07-17 16:23:03 +01:00
2f68a82c33 notification support 2025-07-17 16:22:56 +01:00
1e36958279 Bump version: 1.3.22 → 1.3.23
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m22s
2025-07-17 16:13:11 +01:00
4c33235322 no need to copy the config 2025-07-17 16:13:00 +01:00
b994f22725 Bump version: 1.3.21 → 1.3.22
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 18s
2025-07-17 16:04:07 +01:00
4677ed76af Bump version: 1.3.20 → 1.3.21
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 18s
2025-07-17 16:02:55 +01:00
c2080357f2 Bump version: 1.3.19 → 1.3.20
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 18s
2025-07-17 16:02:10 +01:00
63b5db6fd0 Bump version: 1.3.18 → 1.3.19
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 18s
2025-07-17 16:02:06 +01:00
3917b41968 hmmm 2025-07-17 16:02:01 +01:00
bc01f4d980 Bump version: 1.3.17 → 1.3.18
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m19s
2025-07-17 15:41:54 +01:00
3e1e7a5ce2 notification support 2025-07-17 15:41:16 +01:00
1fc2fc46e0 Bump version: 1.3.16 → 1.3.17
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m14s
2025-07-17 15:24:00 +01:00
603d85b529 fix text parsing 2025-07-17 15:23:55 +01:00
c1a3bb6ba4 Bump version: 1.3.15 → 1.3.16
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m26s
2025-07-17 11:21:31 +01:00
a81a096cfb working injection? 2025-07-17 11:21:22 +01:00
2a49ca6bf2 Bump version: 1.3.14 → 1.3.15
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m24s
2025-07-17 10:32:09 +01:00
4305ea61ba move serivce worker to base.html 2025-07-17 10:32:06 +01:00
26ef5b082a Bump version: 1.3.13 → 1.3.14
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
2025-07-17 10:23:23 +01:00
4f9371ae0f icon at wrong path 2025-07-17 10:23:21 +01:00
630c5aa537 Bump version: 1.3.12 → 1.3.13
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
2025-07-17 10:05:17 +01:00
31480c10a4 rework pwa 2025-07-17 10:05:14 +01:00
8784e141eb Bump version: 1.3.11 → 1.3.12
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m16s
2025-07-17 08:58:41 +01:00
d623725289 retry pwa 2025-07-17 08:58:37 +01:00
0d056fa5de Bump version: 1.3.10 → 1.3.11
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m22s
2025-07-17 08:35:30 +01:00
424d58c4cb pwa change 2025-07-17 08:35:27 +01:00
2da6c6584a Bump version: 1.3.9 → 1.3.10
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m17s
2025-07-17 08:21:06 +01:00
e04bdea613 shared text 2025-07-17 08:21:01 +01:00
d3fe4ad380 Bump version: 1.3.8 → 1.3.9
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m18s
2025-07-17 07:43:07 +01:00
72d238b05d share on android 2025-07-17 07:43:00 +01:00
ef9804da72 Bump version: 1.3.7 → 1.3.8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m15s
2025-07-16 09:20:56 +01:00
37cefe3422 rework api calls 2025-07-16 09:19:48 +01:00
31ef25ead3 update cache logic 2025-07-16 09:09:05 +01:00
6689e3fad5 Bump version: 1.3.6 → 1.3.7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m21s
2025-07-15 19:22:23 +01:00
47d4c24518 update stream url if changed 2025-07-15 19:22:17 +01:00
32ca9b5dfb Bump version: 1.3.5 → 1.3.6
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m15s
2025-07-15 17:59:52 +01:00
cb7c994e11 try using a script 2025-07-15 17:59:49 +01:00
f03f56f76b Bump version: 1.3.4 → 1.3.5
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m18s
2025-07-15 17:58:44 +01:00
7d98dd82c9 try push = true 2025-07-15 17:58:40 +01:00
c389ffb496 Bump version: 1.3.3 → 1.3.4
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m18s
2025-07-15 17:56:49 +01:00
5e76631c75 test new bump code 2025-07-15 17:56:43 +01:00
65356eef6f Bump version: 1.3.2 → 1.3.3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m15s
2025-07-15 17:54:58 +01:00
988ecd11d2 show max connection next to stream url 2025-07-15 17:54:42 +01:00
260e330f9a reload on refresh 2025-07-15 17:22:23 +01:00
cf2c004958 update expiry 2025-07-15 16:56:43 +01:00
d3913d786c Bump version: 1.3.1 → 1.3.2
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m26s
2025-07-15 16:08:24 +01:00
0e9d8f2a9f fix validate button on mobiles 2025-07-15 16:08:16 +01:00
ee0d216045 Bump version: 1.3.0 → 1.3.1
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m24s
2025-07-15 15:48:55 +01:00
8b9c6da2b7 docstrings 2025-07-15 15:44:19 +01:00
259b128af0 Bump version: 1.2.11 → 1.3.0
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m18s
2025-07-15 15:13:25 +01:00
2e2ee7c693 allow user to validate an account 2025-07-15 15:13:16 +01:00
a77bc95ce3 Bump version: 1.2.10 → 1.2.11
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
2025-07-15 14:03:01 +01:00
0eb79c3775 show days in red 2025-07-15 14:02:52 +01:00
9425fde4b8 Bump version: 1.2.9 → 1.2.10
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m15s
2025-07-15 13:49:49 +01:00
0705011384 dockerfile cleanup 2025-07-15 13:49:42 +01:00
8f23b9d3b1 Bump version: 1.2.8 → 1.2.9
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 19s
2025-07-15 13:45:34 +01:00
e559156232 fixed docker file 2025-07-15 13:45:20 +01:00
96d0fc8da7 Bump version: 1.2.7 → 1.2.8
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 18s
2025-07-15 11:44:36 +01:00
9748b664aa fix bumpversion 2025-07-15 11:44:29 +01:00
9dc9b7e1b5 rework to base template 2025-07-15 11:43:28 +01:00
8df6af5edf rework 2025-07-15 11:38:50 +01:00
e3cce698a0 Bump version: 1.2.6 → 1.2.7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 4m4s
2025-07-15 11:28:10 +01:00
318468fe53 fix: Align version to 1.2.6 2025-07-15 11:27:52 +01:00
41fef3cb15 feat: Use .bumpversion.toml for configuration 2025-07-15 11:25:59 +01:00
cc576e3c91 Bump version: 1.2.6 → 1.2.7 2025-07-15 11:23:28 +01:00
1455e45554 fix: Set current version to 1.2.6 2025-07-15 11:23:23 +01:00
a442460338 fix: Align version with latest tag 2025-07-15 11:22:32 +01:00
f3b37d6ac2 feat: Configure bump-my-version 2025-07-15 11:22:14 +01:00
c174f1dbc9 reduced dockerfile 2025-07-15 11:19:44 +01:00
13368a0437 1.2.6
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 4m24s
2025-07-15 10:37:45 +01:00
8ce43b9328 simple service-worker 2025-07-15 10:37:34 +01:00
891be7d502 1.2.5
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 4m16s
2025-07-15 10:20:06 +01:00
b339a00d4b 1.2.4
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 4m29s
2025-07-15 10:11:35 +01:00
b24ecee2ba 1.2.3 2025-07-15 10:11:22 +01:00
9e1e442c74 rework popper issue 2025-07-15 10:11:12 +01:00
23 changed files with 1595 additions and 758 deletions

8
.bumpversion.toml Normal file
View File

@ -0,0 +1,8 @@
[tool.bumpversion]
current_version = "1.4.10"
commit = true
tag = true
tag_name = "{new_version}"
[[tool.bumpversion.files]]
filename = "VERSION"

View File

@ -1 +1 @@
1.2.2 1.4.10

553
app.py
View File

@ -1,18 +1,22 @@
# app.py # app.py
from flask import Flask, render_template, request, redirect, url_for, session, send_file, jsonify from flask import (Flask, render_template, request, redirect, url_for, session,
send_file, jsonify, send_from_directory, Response)
from flask_caching import Cache from flask_caching import Cache
import requests.auth import requests.auth
import os import os
from lib.datetime import filter_accounts_next_30_days, filter_accounts_expired
from lib.reqs import get_urls, get_user_accounts, add_user_account, delete_user_account, get_user_accounts_count, get_stream_names
from flask import send_from_directory
import requests
import base64 import base64
from flask import Flask from typing import Dict, Any, Tuple, Union
import sys
import redis
import json
import mysql.connector
import re
import threading
from lib.datetime import filter_accounts_next_30_days, filter_accounts_expired
from lib.reqs import (get_urls, get_user_accounts, add_user_account,
delete_user_account, get_stream_names)
from config import DevelopmentConfig, ProductionConfig from config import DevelopmentConfig, ProductionConfig
from paddleocr import PaddleOCR
from PIL import Image
import numpy as np
os.environ["OMP_NUM_THREADS"] = "1" os.environ["OMP_NUM_THREADS"] = "1"
@ -24,18 +28,37 @@ if os.environ.get("FLASK_ENV") == "production":
app.config.from_object(ProductionConfig) app.config.from_object(ProductionConfig)
else: else:
app.config.from_object(DevelopmentConfig) app.config.from_object(DevelopmentConfig)
cache = Cache(app, config={"CACHE_TYPE": "SimpleCache"})
if app.config.get("OCR_ENABLED"): # Check for Redis availability and configure cache
ocr = PaddleOCR(use_angle_cls=True, lang='en') # Adjust language if needed redis_url = app.config["REDIS_URL"]
cache_config = {"CACHE_TYPE": "redis", "CACHE_REDIS_URL": redis_url}
try:
# Use a short timeout to prevent hanging
r = redis.from_url(redis_url, socket_connect_timeout=1)
r.ping()
except redis.exceptions.ConnectionError as e:
print(
f"WARNING: Redis connection failed: {e}. Falling back to SimpleCache. "
"This is not recommended for production with multiple workers.",
file=sys.stderr,
)
cache_config = {"CACHE_TYPE": "SimpleCache"}
cache = Cache(app, config=cache_config)
app.config["OCR_ENABLED"] = False
app.config["SESSION_COOKIE_SECURE"] = not app.config["DEBUG"] app.config["SESSION_COOKIE_SECURE"] = not app.config["DEBUG"]
app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent JavaScript access app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Adjust for cross-site requests app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 365 # 1 year in seconds app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 365 # 1 year
cache.clear() # Clears all cache entries
def get_version(): def get_version() -> str:
"""Retrieves the application version from the VERSION file.
Returns:
The version string, or 'dev' if the file is not found.
"""
try: try:
with open('VERSION', 'r') as f: with open('VERSION', 'r') as f:
return f.read().strip() return f.read().strip()
@ -43,109 +66,162 @@ def get_version():
return 'dev' return 'dev'
@app.context_processor @app.context_processor
def inject_version(): def inject_version() -> Dict[str, str]:
return dict(version=get_version()) """Injects the version into all templates."""
return dict(version=get_version(), config=app.config, session=session)
def make_cache_key(*args, **kwargs):
"""Generate a cache key based on the user's session and request path."""
username = session.get('username', 'anonymous')
path = request.path
return f"view/{username}/{path}"
@app.before_request @app.before_request
def make_session_permanent(): def make_session_permanent() -> None:
"""Makes the user session permanent."""
session.permanent = True session.permanent = True
@app.route('/manifest.json') @app.route('/site.webmanifest')
def serve_manifest(): def serve_manifest() -> Response:
return send_file('manifest.json', mimetype='application/manifest+json') """Serves the site manifest file."""
return send_from_directory(
os.path.join(app.root_path, 'static'),
'site.webmanifest',
mimetype='application/manifest+json'
)
@app.route("/favicon.ico") @app.route("/favicon.ico")
def favicon(): def favicon() -> Response:
"""Serves the favicon."""
return send_from_directory( return send_from_directory(
os.path.join(app.root_path, "static"), os.path.join(app.root_path, "static"),
"favicon.ico", "favicon.ico",
mimetype="image/vnd.microsoft.icon", mimetype="image/vnd.microsoft.icon",
) )
@app.route("/") @app.route("/")
def index(): def index() -> Union[Response, str]:
# If the user is logged in, redirect to a protected page like /accounts """Renders the index page or redirects to home if logged in."""
if session.get("logged_in"): if session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
return render_template("index.html") return render_template("index.html")
@app.route('/vapid-public-key', methods=['GET'])
def proxy_vapid_public_key():
"""Proxies the request for the VAPID public key to the backend."""
backend_url = f"{app.config['BACKEND_URL']}/vapid-public-key"
try:
response = requests.get(backend_url)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/save-subscription', methods=['POST'])
def proxy_save_subscription():
"""Proxies the request to save a push subscription to the backend."""
if not session.get("logged_in"):
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/save-subscription"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
backend_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json=request.get_json()
)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/send-test-notification', methods=['POST'])
def send_test_notification():
"""Proxies the request to send a test notification to the backend."""
if not session.get("logged_in"):
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/send-test-notification"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
backend_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json={}
)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route("/home") @app.route("/home")
@cache.cached(timeout=60) # cache for 120 seconds @cache.cached(timeout=60, key_prefix=make_cache_key)
def home(): def home() -> str:
"""Renders the home page with account statistics."""
if session.get("logged_in"): if session.get("logged_in"):
base_url = app.config["BASE_URL"] # Access base_url from the config base_url = app.config["BACKEND_URL"]
all_accounts = get_user_accounts(base_url, session["auth_credentials"]) all_accounts = get_user_accounts(base_url, session["auth_credentials"])
count = len(all_accounts)
current_month_accounts = filter_accounts_next_30_days(all_accounts)
expired_accounts = filter_accounts_expired(all_accounts)
return render_template( return render_template(
"home.html", "home.html",
username=session["username"], username=session["username"],
accounts=count, accounts=len(all_accounts),
current_month_accounts=current_month_accounts, current_month_accounts=filter_accounts_next_30_days(all_accounts),
expired_accounts=expired_accounts, expired_accounts=filter_accounts_expired(all_accounts),
ocr_enabled=app.config.get("OCR_ENABLED"),
) )
return render_template("index.html") return render_template("index.html")
@app.route("/login", methods=["POST"]) @app.route("/login", methods=["POST"])
def login(): def login() -> Union[Response, str]:
"""Handles user login."""
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
# Encode the username and password in Base64
credentials = f"{username}:{password}" credentials = f"{username}:{password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode() encoded_credentials = base64.b64encode(credentials.encode()).decode()
base_url = app.config["BACKEND_URL"]
login_url = f"{base_url}/Login"
base_url = app.config["BASE_URL"] # Access base_url from the config try:
login_url = f"{base_url}/Login" # Construct the full URL response = requests.get(
login_url, auth=requests.auth.HTTPBasicAuth(username, password)
# Send GET request to the external login API with Basic Auth )
response = requests.get( response.raise_for_status()
login_url, auth=requests.auth.HTTPBasicAuth(username, password) response_data = response.json()
) if response_data.get("auth") == "Success":
session["logged_in"] = True
# Check if login was successful session["username"] = response_data.get("username", username)
if response.status_code == 200 and response.json().get("auth") == "Success": session["user_id"] = response_data.get("user_id")
# Set session variable to indicate the user is logged in session["auth_credentials"] = encoded_credentials
session["logged_in"] = True next_url = request.args.get("next")
session["username"] = username if next_url:
session["auth_credentials"] = encoded_credentials return redirect(next_url)
return redirect(url_for("home")) # Redirect to the Accounts page return redirect(url_for("home", loggedin=True))
else: except requests.exceptions.RequestException:
# Show error on the login page pass # Fall through to error
error = "Invalid username or password. Please try again."
return render_template("index.html", error=error)
error = "Invalid username or password. Please try again."
return render_template("index.html", error=error)
@app.route("/urls", methods=["GET"]) @app.route("/urls", methods=["GET"])
@cache.cached(timeout=300) # cache for 5 minutes @cache.cached(timeout=300, key_prefix=make_cache_key)
def urls(): def urls() -> Union[Response, str]:
# Check if the user is logged in """Renders the URLs page."""
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
# Placeholder content for Accounts page base_url = app.config["BACKEND_URL"]
base_url = app.config["BASE_URL"] # Access base_url from the config
return render_template( return render_template(
"urls.html", urls=get_urls(base_url, session["auth_credentials"]) "urls.html", urls=get_urls(base_url, session["auth_credentials"])
) )
@app.route("/accounts", methods=["GET"]) @app.route("/accounts", methods=["GET"])
def user_accounts(): @cache.cached(timeout=60, key_prefix=make_cache_key)
# Check if the user is logged in def user_accounts() -> Union[Response, str]:
"""Renders the user accounts page."""
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
# Placeholder content for Accounts page base_url = app.config["BACKEND_URL"]
base_url = app.config["BASE_URL"] # Access base_url from the config
user_accounts_data = get_user_accounts(base_url, session["auth_credentials"]) user_accounts_data = get_user_accounts(base_url, session["auth_credentials"])
# Clear the cache for 'user_accounts' view specifically
cache.delete_memoized(user_accounts)
return render_template( return render_template(
"user_accounts.html", "user_accounts.html",
username=session["username"], username=session["username"],
@ -153,65 +229,326 @@ def user_accounts():
auth=session["auth_credentials"], auth=session["auth_credentials"],
) )
@app.route("/share", methods=["GET"])
def share() -> Response:
"""Handles shared text from PWA."""
if not session.get("logged_in"):
return redirect(url_for("index", next=request.url))
shared_text = request.args.get("text")
return redirect(url_for("add_account", shared_text=shared_text))
@app.route("/accounts/add", methods=["GET", "POST"]) @app.route("/accounts/add", methods=["GET", "POST"])
def add_account(): def add_account() -> Union[Response, str]:
base_url = app.config["BASE_URL"] """Handles adding a new user account."""
if not session.get("logged_in"):
return redirect(url_for("index", next=request.url))
base_url = app.config["BACKEND_URL"]
shared_text = request.args.get('shared_text') shared_text = request.args.get('shared_text')
if request.method == "POST": if request.method == "POST":
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
stream = request.form["stream"] stream = request.form["stream"]
if add_user_account( if add_user_account(
base_url, session["auth_credentials"], username, password, stream base_url, session["auth_credentials"], username, password, stream
): ):
cache.clear() cache.delete_memoized(user_accounts, key_prefix=make_cache_key)
# Run the NPM config update in a background thread
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return redirect(url_for("user_accounts")) return redirect(url_for("user_accounts"))
return render_template("add_account.html", ocr_enabled=app.config.get("OCR_ENABLED"), text_input_enabled=app.config.get("TEXT_INPUT_ENABLED"), shared_text=shared_text)
return render_template("add_account.html", ocr_enabled=app.config.get("OCR_ENABLED"), text_input_enabled=app.config.get("TEXT_INPUT_ENABLED"), shared_text=shared_text)
return render_template(
"add_account.html",
text_input_enabled=app.config.get("TEXT_INPUT_ENABLED"),
shared_text=shared_text
)
@app.route("/accounts/delete", methods=["POST"]) @app.route("/accounts/delete", methods=["POST"])
def delete_account(): def delete_account() -> Response:
"""Handles deleting a user account."""
stream = request.form.get("stream") stream = request.form.get("stream")
username = request.form.get("username") username = request.form.get("username")
base_url = app.config["BASE_URL"] base_url = app.config["BACKEND_URL"]
delete_user_account(base_url, session["auth_credentials"], stream, username)
if delete_user_account(base_url, session["auth_credentials"], stream, username): cache.delete_memoized(user_accounts, key_prefix=make_cache_key)
return redirect(url_for("user_accounts"))
return redirect(url_for("user_accounts")) return redirect(url_for("user_accounts"))
@app.route("/validateAccount", methods=["POST"])
def validate_account() -> Tuple[Response, int]:
"""Forwards account validation requests to the backend."""
base_url = app.config["BACKEND_URL"]
validate_url = f"{base_url}/validateAccount"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
validate_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json=request.get_json()
)
response.raise_for_status()
response_data = response.json()
if response_data.get("message") == "Account is valid and updated":
cache.delete_memoized(user_accounts, key_prefix=make_cache_key)
# Run the NPM config update in a background thread
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return jsonify(response_data), response.status_code
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 500
@app.route("/get_stream_names", methods=["GET"]) @app.route("/get_stream_names", methods=["GET"])
def stream_names(): def stream_names() -> Union[Response, str]:
"""Fetches and returns stream names as JSON."""
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
base_url = app.config["BASE_URL"] base_url = app.config["BACKEND_URL"]
stream_names = get_stream_names(base_url, session["auth_credentials"]) return jsonify(get_stream_names(base_url, session["auth_credentials"]))
return jsonify(stream_names)
if app.config.get("OCR_ENABLED"): @app.route('/config')
@app.route('/OCRupload', methods=['POST']) def config():
def OCRupload(): """Handles access to the configuration page."""
if 'image' not in request.files: if session.get('user_id') and int(session.get('user_id')) == 1:
return jsonify({"error": "No image file found"}), 400 return redirect(url_for('config_dashboard'))
# Get the uploaded file return redirect(url_for('home'))
file = request.files['image']
@app.route('/config/dashboard')
def config_dashboard():
"""Renders the configuration dashboard."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return redirect(url_for('home'))
return render_template('config_dashboard.html')
@app.route('/check-expiring-accounts', methods=['POST'])
def check_expiring_accounts():
"""Proxies the request to check for expiring accounts to the backend."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/check-expiry"
try:
response = requests.post(backend_url)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/dns', methods=['GET', 'POST', 'DELETE'])
def proxy_dns():
"""Proxies DNS management requests to the backend."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/dns"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try:
if request.method == 'GET':
response = requests.get(backend_url, auth=auth)
elif request.method == 'POST':
response = requests.post(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
elif request.method == 'DELETE':
response = requests.delete(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/extra_urls', methods=['GET', 'POST', 'DELETE'])
def proxy_extra_urls():
"""Proxies extra URL management requests to the backend."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/extra_urls"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try:
if request.method == 'GET':
response = requests.get(backend_url, auth=auth)
elif request.method == 'POST':
response = requests.post(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
elif request.method == 'DELETE':
response = requests.delete(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
class NginxProxyManager:
def __init__(self, host, email, password):
self.host = host
self.email = email
self.password = password
self.token = None
def login(self):
url = f"{self.host}/api/tokens"
payload = {
"identity": self.email,
"secret": self.password
}
headers = {
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
if response.status_code == 200:
self.token = response.json()["token"]
print("Login successful.")
else:
print(f"Failed to login: {response.text}")
exit(1)
def get_proxy_host(self, host_id):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
headers = {
"Authorization": f"Bearer {self.token}"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"Failed to get proxy host {host_id}: {response.text}")
return None
def update_proxy_host_config(self, host_id, config):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
original_host_data = self.get_proxy_host(host_id)
if not original_host_data:
return
# Construct a new payload with only the allowed fields for an update
update_payload = {
"domain_names": original_host_data.get("domain_names", []),
"forward_scheme": original_host_data.get("forward_scheme", "http"),
"forward_host": original_host_data.get("forward_host"),
"forward_port": original_host_data.get("forward_port"),
"access_list_id": original_host_data.get("access_list_id", 0),
"certificate_id": original_host_data.get("certificate_id", 0),
"ssl_forced": original_host_data.get("ssl_forced", False),
"hsts_enabled": original_host_data.get("hsts_enabled", False),
"hsts_subdomains": original_host_data.get("hsts_subdomains", False),
"http2_support": original_host_data.get("http2_support", False),
"block_exploits": original_host_data.get("block_exploits", False),
"caching_enabled": original_host_data.get("caching_enabled", False),
"allow_websocket_upgrade": original_host_data.get("allow_websocket_upgrade", False),
"advanced_config": config, # The updated advanced config
"meta": original_host_data.get("meta", {}),
"locations": original_host_data.get("locations", []),
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.put(url, headers=headers, data=json.dumps(update_payload))
if response.status_code == 200:
print(f"Successfully updated proxy host {host_id}")
else:
print(f"Failed to update proxy host {host_id}: {response.text}")
def update_config_with_streams(config, streams):
# Get all stream names from the database
db_stream_names = {stream['streamName'] for stream in streams}
# Find all location blocks in the config
location_blocks = re.findall(r'location ~ \^/(\w+)\(\.\*\)\$ \{[^}]+\}', config)
# Remove location blocks that are not in the database
for stream_name in location_blocks:
if stream_name not in db_stream_names:
print(f"Removing location block for stream: {stream_name}")
pattern = re.compile(f'location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{[^}}]+}}\\s*', re.DOTALL)
config = pattern.sub('', config)
# Update existing stream URLs
for stream in streams:
stream_name = stream['streamName']
stream_url = stream['streamURL']
if stream_url: # Ensure there is a URL to update to
# Use a more specific regex to avoid replacing parts of other URLs
pattern = re.compile(f'(location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{\\s*return 302 )([^;]+)(;\\s*}})')
config = pattern.sub(f'\\1{stream_url}/$1$is_args$args\\3', config)
return config
def _update_npm_config():
"""Helper function to update the NPM config."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
print("Unauthorized attempt to update NPM config.")
return
npm = NginxProxyManager(app.config['NPM_HOST'], app.config['NPM_EMAIL'], app.config['NPM_PASSWORD'])
npm.login()
host = npm.get_proxy_host(9)
if host:
current_config = host.get('advanced_config', '')
backend_url = f"{app.config['BACKEND_URL']}/get_all_stream_urls"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try: try:
image = Image.open(file.stream) response = requests.get(backend_url, auth=auth)
image_np = np.array(image) response.raise_for_status()
result = ocr.ocr(image_np) streams = response.json()
# Extract text except requests.exceptions.RequestException as e:
extracted_text = [] print(f"Failed to fetch streams from backend: {e}")
for line in result[0]: return
extracted_text.append(line[1][0])
return render_template("add_account.html", username=extracted_text[2], password=extracted_text[3], ocr_enabled=app.config.get("OCR_ENABLED"), text_input_enabled=app.config.get("TEXT_INPUT_ENABLED")) if streams:
except Exception as e: new_config = update_config_with_streams(current_config, streams)
return jsonify({"error": str(e)}), 500 npm.update_proxy_host_config(9, new_config)
print("NPM config updated successfully.")
else:
print("Failed to update NPM config.")
def _update_npm_config_in_background():
with app.app_context():
_update_npm_config()
@app.route('/update_host_9_config', methods=['POST'])
def update_host_9_config():
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return jsonify({'message': 'NPM config update started in the background.'}), 202
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=app.config["DEBUG"], host=app.config["HOST"], port=app.config["PORT"]) app.run(
debug=app.config["DEBUG"],
host=app.config["HOST"],
port=app.config["PORT"]
)

View File

@ -1,19 +0,0 @@
from flask import Flask, jsonify
from config import DevelopmentConfig
from lib.mysql import execute_query
app = Flask(__name__)
app.config.from_object(DevelopmentConfig)
@app.route('/getUserAccounts', methods=['GET'])
def get_user_accounts():
# Use the execute_query function to get user accounts
data = execute_query("SELECT COUNT(*) AS account_count FROM userAccounts WHERE userID = %s;", (1,))
if data is None:
return jsonify({"error": "Database query failed"}), 500
return jsonify(data), 200
# Run the app
if __name__ == '__main__':
app.run(debug=app.config["DEBUG"], port=app.config["PORT"])

View File

@ -1,37 +0,0 @@
import mysql.connector
from flask import current_app
def execute_query(query, params=None, fetch_one=False):
"""Execute a SQL query and optionally fetch results."""
try:
# Get database configuration from the current app context
db_config = {
"host": current_app.config['DBHOST'],
"user": current_app.config['DBUSER'],
"password": current_app.config['DBPASS'],
"database": current_app.config['DATABASE'],
}
# Establish database connection
connection = mysql.connector.connect(**db_config)
cursor = connection.cursor(dictionary=True)
# Execute the query with optional parameters
cursor.execute(query, params)
# Fetch results if it's a SELECT query
if query.strip().upper().startswith("SELECT"):
result = cursor.fetchone() if fetch_one else cursor.fetchall()
else:
# Commit changes for INSERT, UPDATE, DELETE
connection.commit()
result = cursor.rowcount # Number of affected rows
# Close the database connection
cursor.close()
connection.close()
return result
except mysql.connector.Error as err:
print("Error: ", err)
return None

18
bump_and_push.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
set -ex
# Check if an argument is provided
if [ -z "$1" ]; then
echo "Usage: $0 <part>"
echo "Example: $0 patch"
exit 1
fi
PART=$1
# Bump the version
bump-my-version bump $PART
# Push the changes
git push
git push origin --tags

View File

@ -1,11 +0,0 @@
# config.py
class Config:
DEBUG = False
BASE_URL = '' # Set your base URL here
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
BASE_URL = '' # Production base URL

View File

@ -1,5 +1,5 @@
# Builder stage # Builder stage
FROM python:3.11-slim-bookworm as builder FROM python:3.11-slim-bookworm AS builder
WORKDIR /app WORKDIR /app
@ -7,17 +7,8 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Final stage # Final stage
FROM python:3.11-slim-bookworm as final FROM python:3.11-slim-bookworm AS final
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
libgomp1 \
libgl1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@ -26,7 +17,6 @@ COPY app.py .
COPY gunicorn.conf.py . COPY gunicorn.conf.py .
COPY run.sh . COPY run.sh .
COPY VERSION . COPY VERSION .
COPY backend/ backend/
COPY lib/ lib/ COPY lib/ lib/
COPY static/ static/ COPY static/ static/
COPY templates/ templates/ COPY templates/ templates/
@ -38,10 +28,12 @@ ARG VERSION
RUN echo $VERSION > VERSION RUN echo $VERSION > VERSION
# Create a non-root user # Create a non-root user
RUN useradd --create-home appuser RUN useradd --create-home appuser && \
mkdir -p /app/tmp && \
chown -R appuser:appuser /app/tmp
USER appuser USER appuser
EXPOSE 5000 EXPOSE 5000
ENV FLASK_ENV production ENV FLASK_ENV=production
CMD ["./run.sh"] CMD ["./run.sh"]

View File

@ -8,3 +8,4 @@ loglevel = "info"
workers = 2 workers = 2
bind = "0.0.0.0:5000" bind = "0.0.0.0:5000"
timeout = 120 timeout = 120
worker_tmp_dir = "/app/tmp"

View File

@ -1,46 +1,51 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Dict from typing import List, Dict, Any
def filter_accounts_next_30_days(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]: def filter_accounts_next_30_days(accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Filter accounts whose expiry date falls within the next 30 days. """Filters accounts expiring within the next 30 days.
Args: Args:
accounts (List[Dict[str, int]]): A list of account dictionaries, each containing accounts: A list of account dictionaries, each with an 'expiaryDate'
an 'expiaryDate' key with an epoch timestamp as its value. (epoch timestamp).
Returns: Returns:
List[Dict[str, int]]: A list of accounts expiring within the next 30 days. A list of accounts expiring within the next 30 days, with added
'expiaryDate_rendered' and 'days_to_expiry' keys.
""" """
now = datetime.now() now = datetime.now()
thirty_days_later = now + timedelta(days=30) thirty_days_later = now + timedelta(days=30)
# Convert current time and 30 days later to epoch timestamps
now_timestamp = int(now.timestamp()) now_timestamp = int(now.timestamp())
thirty_days_later_timestamp = int(thirty_days_later.timestamp()) thirty_days_later_timestamp = int(thirty_days_later.timestamp())
# Filter accounts with expiryDate within the next 30 days result = []
return [ today = now.date()
account for account in accounts
if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp
]
def filter_accounts_expired(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]: for account in accounts:
"""Filter accounts whose expiry date has passed. if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp:
expiry_date = datetime.fromtimestamp(account['expiaryDate'])
account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y')
expiry_date_date = expiry_date.date()
account['days_to_expiry'] = (expiry_date_date - today).days
result.append(account)
return result
def filter_accounts_expired(accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Filters accounts that have already expired.
Args: Args:
accounts (List[Dict[str, int]]): A list of account dictionaries, each containing accounts: A list of account dictionaries, each with an 'expiaryDate'
an 'expiaryDate' key with an epoch timestamp as its value. (epoch timestamp).
Returns: Returns:
List[Dict[str, int]]: A list of accounts that have expired. A list of expired accounts with an added 'expiaryDate_rendered' key.
""" """
# Get the current epoch timestamp
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
# Filter accounts where the current date is greater than the expiryDate expired_accounts = []
expired_accounts = [ for account in accounts:
account for account in accounts if account['expiaryDate'] < current_timestamp:
if account['expiaryDate'] < current_timestamp expiry_date = datetime.fromtimestamp(account['expiaryDate'])
] account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y')
expired_accounts.append(account)
return expired_accounts return expired_accounts

View File

@ -1,129 +1,84 @@
import requests import requests
import json import json
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
# Create a session object to reuse TCP connections
session = requests.Session()
def _make_api_request(
method: str,
base_url: str,
auth: str,
endpoint: str,
payload: Optional[Dict[str, Any]] = None,
) -> Any:
"""
A helper function to make API requests.
Args:
method: The HTTP method to use (e.g., 'GET', 'POST').
base_url: The base URL of the API.
auth: The authorization token.
endpoint: The API endpoint to call.
payload: The data to send with the request.
Returns:
The JSON response from the API.
"""
url = f"{base_url}/{endpoint}"
headers = {"Authorization": f"Basic {auth}"}
try:
response = session.request(method, url, headers=headers, data=payload)
response.raise_for_status()
return response
except (requests.exceptions.RequestException, json.JSONDecodeError) as e:
# Log the error for debugging purposes
print(f"API request failed: {e}")
return None
def get_urls(base_url: str, auth: str) -> List[Dict[str, Any]]: def get_urls(base_url: str, auth: str) -> List[Dict[str, Any]]:
"""Retrieve user account streams from the specified base URL. """Retrieves user account streams from the API."""
response = _make_api_request("GET", base_url, auth, "getUserAccounts/streams")
Args: return response.json() if response else []
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
Returns:
List[Dict[str, Any]]: A list of user account streams.
"""
url = f"{base_url}/getUserAccounts/streams"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
return json.loads(response.text)
def get_user_accounts(base_url: str, auth: str) -> List[Dict[str, Any]]: def get_user_accounts(base_url: str, auth: str) -> List[Dict[str, Any]]:
"""Retrieve user accounts from the specified base URL. """Retrieves user accounts from the API."""
response = _make_api_request("GET", base_url, auth, "getUserAccounts")
if not response:
return []
Args: accounts = response.json()
base_url (str): The base URL of the API. for account in accounts:
auth (str): The authorization token for accessing the API.
Returns:
List[Dict[str, Any]]: A list of user accounts with their expiration dates rendered.
"""
url = f"{base_url}/getUserAccounts"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
res_json = json.loads(response.text)
for account in res_json:
account["expiaryDate_rendered"] = datetime.utcfromtimestamp( account["expiaryDate_rendered"] = datetime.utcfromtimestamp(
account["expiaryDate"] account["expiaryDate"]
).strftime("%d/%m/%Y") ).strftime("%d/%m/%Y")
return accounts
return res_json
def delete_user_account(base_url: str, auth: str, stream: str, username: str) -> bool: def delete_user_account(base_url: str, auth: str, stream: str, username: str) -> bool:
"""Delete a user account from the specified base URL. """Deletes a user account via the API."""
Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
stream (str): The name of the stream associated with the user account.
username (str): The username of the account to delete.
Returns:
bool: True if the account was deleted successfully, False otherwise.
"""
url = f"{base_url}/deleteAccount"
payload = {"stream": stream, "user": username} payload = {"stream": stream, "user": username}
headers = {"Authorization": f"Basic {auth}"} response = _make_api_request(
"POST", base_url, auth, "deleteAccount", payload=payload
response = requests.request("POST", url, headers=headers, data=payload) )
return "Deleted" in response.text return response and "Deleted" in response.text
def add_user_account(base_url: str, auth: str, username: str, password: str, stream: str) -> bool: def add_user_account(
"""Add a user account to the specified base URL. base_url: str, auth: str, username: str, password: str, stream: str
) -> bool:
Args: """Adds a user account via the API."""
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
username (str): The username of the account to add.
password (str): The password of the account to add.
stream (str): The name of the stream associated with the user account.
Returns:
bool: True if the account was added successfully, False otherwise.
"""
url = f"{base_url}/addAccount"
payload = {"username": username, "password": password, "stream": stream} payload = {"username": username, "password": password, "stream": stream}
headers = {"Authorization": f"Basic {auth}"} response = _make_api_request(
"POST", base_url, auth, "addAccount", payload=payload
response = requests.request("POST", url, headers=headers, data=payload) )
return response.status_code == 200 return response and response.status_code == 200
def get_user_accounts_count(base_url: str, auth: str) -> int:
"""Get the count of user accounts from the specified base URL.
Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
Returns:
int: The count of user accounts.
"""
url = f"{base_url}/getUserAccounts/count"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
res_json = json.loads(response.text)
return res_json['count']
def get_stream_names(base_url: str, auth: str) -> List[str]: def get_stream_names(base_url: str, auth: str) -> List[str]:
"""Get a list of stream names from the API. """Retrieves a list of stream names from the API."""
response = _make_api_request("GET", base_url, auth, "getStreamNames")
Args: return response.json() if response else []
base_url (str): The base URL of the API.
auth (str): The authorization token.
Returns:
List[str]: A list of stream names.
"""
url = f"{base_url}/getStreamNames"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
if response.status_code == 200 and response.text:
try:
return json.loads(response.text)
except json.JSONDecodeError:
return []
return []

Binary file not shown.

View File

@ -1,16 +1,51 @@
self.addEventListener('install', e => { const CACHE_NAME = 'ktvmanager-cache-v1';
// console.log('[Service Worker] Installed'); const urlsToCache = [
'/',
'/static/styles.css',
'/static/favicon.ico',
'/static/favicon-96x96.png',
'/static/favicon.svg',
'/static/apple-touch-icon.png',
'/static/web-app-manifest-192x192.png',
'/static/web-app-manifest-512x512.png',
'/static/site.webmanifest'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
}); });
self.addEventListener('activate', e => { self.addEventListener('push', function(event) {
// console.log('[Service Worker] Activated'); console.log('[Service Worker] Push Received.');
console.log(`[Service Worker] Push data: "${event.data.text()}"`);
const data = event.data.json();
const options = {
body: data.body,
icon: '/static/web-app-manifest-192x192.png',
badge: '/static/favicon-96x96.png'
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', function(event) {
const url = new URL(event.request.url); event.respondWith(
if (url.pathname === '/share-target/') { caches.match(event.request)
const text = url.searchParams.get('text'); .then(function(response) {
const redirectUrl = `/accounts/add?shared_text=${encodeURIComponent(text)}`; if (response) {
event.respondWith(Response.redirect(redirectUrl, 303)); return response;
} }
return fetch(event.request);
}
)
);
}); });

View File

@ -1,28 +1,29 @@
{ {
"name": "kTvManager",
"short_name": "kTv", "short_name": "kTv",
"start_url": "/", "name": "kTvManager",
"description": "KTVManager PWA",
"icons": [ "icons": [
{ {
"src": "/favicon-96x96.png", "src": "/static/web-app-manifest-192x192.png",
"sizes": "144x144",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "sizes": "192x192"
}, },
{ {
"src": "/web-app-manifest-192x192.png", "src": "/static/web-app-manifest-512x512.png",
"sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "sizes": "512x512"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"theme_color": "#ffffff", "start_url": "/",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone",
"theme_color": "#ffffff",
"share_target": {
"action": "/share",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"text": "text"
}
}
} }

View File

@ -34,3 +34,39 @@ div.awesomplete {
display: block; display: block;
width: 100%; width: 100%;
} }
/* Responsive table styles */
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before {
background-color: #337ab7;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 3px #444;
box-sizing: content-box;
content: '+';
color: white;
display: block;
height: 16px;
left: 4px;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 16px;
font-size: 14px;
line-height: 16px;
}
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before {
content: '-';
background-color: #d33333;
}
/* Media query for mobile devices */
@media (max-width: 768px) {
.container {
margin-top: 1rem !important;
}
main {
padding: 0.5em;
}
}

View File

@ -1,44 +1,18 @@
<!-- templates/add_account.html --> {% extends "base.html" %}
<!DOCTYPE html>
<html lang="en"> {% block title %}Add Account - KTVManager{% endblock %}
<head>
<meta charset="UTF-8"> {% block head_content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Account - KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}?v={{ version }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" />
<style> <style>
/* Hide the spinner by default */ /* Hide the spinner by default */
#loadingSpinner, #loadingSpinner {
#ocrLoadingSpinner {
display: none; display: none;
} }
</style> </style>
</head> {% endblock %}
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
{% block sub_nav %}
<!-- Sub-navigation for Accounts --> <!-- Sub-navigation for Accounts -->
<div class="bg-light py-2"> <div class="bg-light py-2">
<div class="container"> <div class="container">
@ -52,56 +26,40 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endblock %}
<!-- Main Content --> {% block content %}
<main class="container mt-5"> <h1>Add Account</h1>
<h1>Add Account</h1> <div>
<div> <form action="/accounts/add" method="POST" onsubmit="showLoading()">
<form action="/accounts/add" method="POST" onsubmit="showLoading()">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" value="{{ username }}" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="text" class="form-control" id="password" name="password" value="{{ password }}" required>
</div>
<div class="form-group">
<label for="stream">Stream Name</label>
<input type="text" class="form-control" id="stream" name="stream" required>
</div>
<button type="submit" class="btn btn-primary" id="submitButton">
<span class="spinner-border spinner-border-sm" id="loadingSpinner" role="status" aria-hidden="true"></span>
<span id="buttonText">Add Account</span>
</button>
</form>
{% if ocr_enabled %}
<hr>
<h2>Load Details Via OCR</h2>
<form action="/OCRupload" method="POST" enctype="multipart/form-data" onsubmit="showLoadingOCR()">
<div class="form-group">
<label for="image">Select Image</label>
<input type="file" class="form-control-file" id="image" name="image" accept="image/*" required>
</div>
<button type="submit" class="btn btn-success" id="ocrButton">
<span class="spinner-border spinner-border-sm" id="ocrLoadingSpinner" role="status" aria-hidden="true"></span>
<span id="ocrButtonText">Load Details</span>
</button>
</form>
{% endif %}
{% if text_input_enabled %}
<hr>
<h2>Load Details Via Text</h2>
<div class="form-group"> <div class="form-group">
<label for="accountDetails">Paste Account Details</label> <label for="username">Username</label>
<textarea class="form-control" id="accountDetails" rows="4"></textarea> <input type="text" class="form-control" id="username" name="username" value="{{ username }}" required>
</div> </div>
{% endif %} <div class="form-group">
<label for="password">Password</label>
<input type="text" class="form-control" id="password" name="password" value="{{ password }}" required>
</div>
<div class="form-group">
<label for="stream">Stream Name</label>
<input type="text" class="form-control" id="stream" name="stream" required>
</div>
<button type="submit" class="btn btn-primary" id="submitButton">
<span class="spinner-border spinner-border-sm" id="loadingSpinner" role="status" aria-hidden="true"></span>
<span id="buttonText">Add Account</span>
</button>
</form>
{% if text_input_enabled %}
<hr>
<h2>Load Details Via Text</h2>
<div class="form-group">
<label for="accountDetails">Paste Account Details</label>
<textarea class="form-control" id="accountDetails" rows="4" data-shared-text="{{ shared_text }}"></textarea>
</div>
{% endif %}
{% endblock %}
</main> {% block scripts %}
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script>
<script> <script>
function showLoading() { function showLoading() {
@ -109,11 +67,6 @@
document.getElementById("loadingSpinner").style.display = "inline-block"; document.getElementById("loadingSpinner").style.display = "inline-block";
document.getElementById("buttonText").textContent = "Working..."; document.getElementById("buttonText").textContent = "Working...";
} }
function showLoadingOCR() {
document.getElementById("ocrButton").disabled = true;
document.getElementById("ocrLoadingSpinner").style.display = "inline-block";
document.getElementById("ocrButtonText").textContent = "Processing...";
}
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
var streamInput = document.getElementById("stream"); var streamInput = document.getElementById("stream");
@ -155,24 +108,19 @@
} }
} }
}); });
const sharedText = accountDetailsTextarea.dataset.sharedText;
if (sharedText && sharedText !== 'None') {
accountDetailsTextarea.value = sharedText;
const event = new Event('input', {
bubbles: true,
cancelable: true,
});
accountDetailsTextarea.dispatchEvent(event);
}
} }
}); });
const sharedTextJson = '{{ shared_text|tojson|safe }}';
const sharedText = JSON.parse(sharedTextJson);
if (sharedText) {
const accountDetailsTextarea = document.getElementById('accountDetails');
if (accountDetailsTextarea) {
accountDetailsTextarea.value = sharedText;
const event = new Event('input', {
bubbles: true,
cancelable: true,
});
accountDetailsTextarea.dispatchEvent(event);
}
}
}); });
</script> </script>
</body> {% endblock %}
</html>

192
templates/base.html Normal file
View File

@ -0,0 +1,192 @@
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}KTVManager{% endblock %}</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}" />
<meta name="apple-mobile-web-app-title" content="kTvManager" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}?v={{ version }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}?v={{ version }}" />
{% block head_content %}{% endblock %}
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
{% block sub_nav %}{% endblock %}
<!-- Main Content -->
<main class="container mt-5">
{% block content %}{% endblock %}
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p>Version: {% if session.user_id and session.user_id|int == 1 %}<a href="{{ url_for('config') }}" style="color: inherit; text-decoration: none;">{{ version }}</a>{% else %}{{ version }}{% endif %}</p>
</footer>
<input type="hidden" id="is-logged-in" value="{{ 'true' if session.get('logged_in') else 'false' }}">
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
{% block scripts %}{% endblock %}
<script>
if ('serviceWorker' in navigator && 'PushManager' in window) {
const isLoggedIn = document.getElementById('is-logged-in').value === 'true';
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}').then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
const enableNotificationsBtn = document.getElementById('enable-notifications-btn');
function setupNotificationButton() {
registration.pushManager.getSubscription().then(function(subscription) {
if (enableNotificationsBtn) {
if (subscription) {
enableNotificationsBtn.style.display = 'none';
} else {
enableNotificationsBtn.style.display = 'block';
enableNotificationsBtn.addEventListener('click', function() {
askPermission(registration);
});
}
}
});
}
if (enableNotificationsBtn) {
setupNotificationButton();
}
}, function(err) {
console.log('ServiceWorker registration failed: ', err);
});
const forceResubscribeBtn = document.getElementById('force-resubscribe-btn');
if (forceResubscribeBtn) {
forceResubscribeBtn.addEventListener('click', function() {
navigator.serviceWorker.ready.then(function(registration) {
registration.pushManager.getSubscription().then(function(subscription) {
if (subscription) {
subscription.unsubscribe().then(function(successful) {
if (successful) {
console.log('Unsubscribed successfully.');
askPermission(registration);
} else {
console.log('Unsubscribe failed.');
}
});
} else {
askPermission(registration);
}
});
});
});
}
}
function askPermission(registration) {
Notification.requestPermission().then(function(result) {
if (result === 'granted') {
subscribeUser(registration);
}
});
}
function subscribeUser(registration) {
fetch('/vapid-public-key')
.then(response => {
if (!response.ok) {
return response.text().then(text => {
console.error('Failed to fetch VAPID public key. Server response:', text);
throw new Error('Failed to fetch VAPID public key. See server response in logs.');
});
}
return response.json();
})
.then(data => {
console.log('Received VAPID public key:', data.public_key);
const applicationServerKey = urlB64ToUint8Array(data.public_key);
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
}).then(function(subscription) {
console.log('User is subscribed.');
saveSubscription(subscription);
}).catch(function(err) {
console.log('Failed to subscribe the user: ', err);
});
}).catch(function(err) {
console.error('Error during subscription process:', err);
});
}
function saveSubscription(subscription) {
console.log('Attempting to save subscription...');
fetch('/save-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
.then(response => {
if (response.ok) {
console.log('Subscription saved successfully.');
const enableNotificationsBtn = document.getElementById('enable-notifications-btn');
if (enableNotificationsBtn) {
enableNotificationsBtn.style.display = 'none';
}
return response.json();
} else {
console.error('Failed to save subscription. Status:', response.status);
response.text().then(text => console.error('Server response:', text));
throw new Error('Failed to save subscription.');
}
})
.then(data => {
console.log('Server response on save:', data);
})
.catch(err => {
console.error('Error during saveSubscription fetch:', err);
});
}
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
</script>
</body>
</html>

View File

@ -0,0 +1,301 @@
{% extends "base.html" %}
{% block title %}Config Dashboard{% endblock %}
{% block content %}
<div class="container mt-4">
<h2 class="mb-4">Configuration Dashboard</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
Actions
</div>
<div class="card-body">
<button id="send-test-notification-btn" class="btn btn-primary">Send Test Notification</button>
<button id="check-expiring-accounts-btn" class="btn btn-info">Check Expiring Accounts</button>
<button id="force-resubscribe-btn" class="btn btn-warning">Force Re-subscribe</button>
<button id="update-host-9-btn" class="btn btn-success">Update Redirect URLS</button>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
DNS Manager
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="dns-entry-input" placeholder="Enter DNS entry">
<div class="input-group-append">
<button class="btn btn-primary" id="add-dns-btn">Add</button>
</div>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>DNS Entry</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody id="dns-list-table-body">
<!-- DNS entries will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
Extra URLs Manager
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="extra-url-input" placeholder="Enter Extra URL">
<div class="input-group-append">
<button class="btn btn-primary" id="add-extra-url-btn">Add</button>
</div>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Extra URL</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody id="extra-urls-table-body">
<!-- Extra URLs will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// DNS Manager
const dnsListTableBody = document.getElementById('dns-list-table-body');
const addDnsBtn = document.getElementById('add-dns-btn');
const dnsEntryInput = document.getElementById('dns-entry-input');
function fetchDnsList() {
fetch("{{ url_for('proxy_dns') }}")
.then(response => {
if (!response.ok) {
// Log the error response text for debugging
response.text().then(text => console.error('Error response from proxy:', text));
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
dnsListTableBody.innerHTML = '';
if (!Array.isArray(data)) {
console.error("Received data is not an array:", data);
throw new Error("Invalid data format received from server.");
}
if (data.length === 0) {
const row = dnsListTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'No DNS entries found.';
cell.classList.add('text-center');
} else {
data.forEach(entry => {
const row = dnsListTableBody.insertRow();
const entryCell = row.insertCell();
entryCell.textContent = entry;
const actionCell = row.insertCell();
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-danger btn-sm';
removeBtn.textContent = 'Delete';
removeBtn.addEventListener('click', () => removeDnsEntry(entry));
actionCell.appendChild(removeBtn);
});
}
})
.catch(e => {
console.error('Error during fetchDnsList:', e);
dnsListTableBody.innerHTML = '';
const row = dnsListTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'Error loading DNS entries. See browser console for details.';
cell.classList.add('text-center', 'text-danger');
});
}
function addDnsEntry() {
const dnsEntry = dnsEntryInput.value.trim();
if (dnsEntry) {
fetch("{{ url_for('proxy_dns') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ dns_entry: dnsEntry })
}).then(() => {
dnsEntryInput.value = '';
fetchDnsList();
});
}
}
function removeDnsEntry(dnsEntry) {
fetch("{{ url_for('proxy_dns') }}", {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ dns_entry: dnsEntry })
}).then(() => {
fetchDnsList();
});
}
addDnsBtn.addEventListener('click', addDnsEntry);
fetchDnsList();
// Extra URLs Manager
const extraUrlsTableBody = document.getElementById('extra-urls-table-body');
const addExtraUrlBtn = document.getElementById('add-extra-url-btn');
const extraUrlInput = document.getElementById('extra-url-input');
function fetchExtraUrlsList() {
fetch("{{ url_for('proxy_extra_urls') }}")
.then(response => {
if (!response.ok) {
// Log the error response text for debugging
response.text().then(text => console.error('Error response from proxy:', text));
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
extraUrlsTableBody.innerHTML = '';
if (!Array.isArray(data)) {
console.error("Received data is not an array:", data);
throw new Error("Invalid data format received from server.");
}
if (data.length === 0) {
const row = extraUrlsTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'No extra URLs found.';
cell.classList.add('text-center');
} else {
data.forEach(entry => {
const row = extraUrlsTableBody.insertRow();
const entryCell = row.insertCell();
entryCell.textContent = entry;
const actionCell = row.insertCell();
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-danger btn-sm';
removeBtn.textContent = 'Delete';
removeBtn.addEventListener('click', () => removeExtraUrl(entry));
actionCell.appendChild(removeBtn);
});
}
})
.catch(e => {
console.error('Error during fetchExtraUrlsList:', e);
extraUrlsTableBody.innerHTML = '';
const row = extraUrlsTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'Error loading extra URLs. See browser console for details.';
cell.classList.add('text-center', 'text-danger');
});
}
function addExtraUrl() {
const extraUrl = extraUrlInput.value.trim();
if (extraUrl) {
fetch("{{ url_for('proxy_extra_urls') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ extra_url: extraUrl })
}).then(() => {
extraUrlInput.value = '';
fetchExtraUrlsList();
});
}
}
function removeExtraUrl(extraUrl) {
fetch("{{ url_for('proxy_extra_urls') }}", {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ extra_url: extraUrl })
}).then(() => {
fetchExtraUrlsList();
});
}
addExtraUrlBtn.addEventListener('click', addExtraUrl);
fetchExtraUrlsList();
// Other buttons
document.getElementById('send-test-notification-btn').addEventListener('click', function() {
fetch('{{ url_for("send_test_notification") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Test notification sent successfully!');
} else {
alert('Failed to send test notification.');
}
}).catch(err => {
console.error('Error sending test notification:', err);
alert('An error occurred while sending the test notification.');
});
});
document.getElementById('check-expiring-accounts-btn').addEventListener('click', function() {
fetch('{{ url_for("check_expiring_accounts") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Expiring accounts check triggered successfully!');
} else {
alert('Failed to trigger expiring accounts check.');
}
}).catch(err => {
console.error('Error triggering expiring accounts check:', err);
alert('An error occurred while triggering the expiring accounts check.');
});
});
document.getElementById('update-host-9-btn').addEventListener('click', function() {
fetch('{{ url_for("update_host_9_config") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Host 9 config updated successfully!');
} else {
alert('Failed to update Host 9 config.');
}
}).catch(err => {
console.error('Error updating Host 9 config:', err);
alert('An error occurred while updating the Host 9 config.');
});
});
});
</script>
{% endblock %}

View File

@ -1,98 +1,56 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}?v={{ version }}" />
</head>
<body>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
}
</script>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content --> {% block title %}KTVManager{% endblock %}
<main class="container mt-5">
<h1>Welcome {{ username }}!</h1>
<br>
<h2>You have {{ accounts }} active accounts</h2>
<br>
{% if current_month_accounts %} {% block content %}
<h3>Accounts Expiring Within 30 Days</h3> <h1>Welcome {{ username }}!</h1>
<table class="table table-bordered table-striped"> <button id="enable-notifications-btn" class="btn btn-primary my-3">Enable Notifications</button>
<thead class="thead-dark"> <h2>You have {{ accounts }} active accounts</h2>
<br>
{% if current_month_accounts %}
<h3>Accounts Expiring Within 30 Days</h3>
<table class="table table-bordered table-striped">
<thead class="thead-dark">
<tr>
<th>Stream Name</th>
<th>Username</th>
<th>Expiry Date</th>
</tr>
</thead>
<tbody>
{% for account in current_month_accounts %}
<tr> <tr>
<th>Stream Name</th> <td>{{ account.stream }}</td>
<th>Username</th> <td>{{ account.username }}</td>
<th>Expiry Date</th> <td>{{ account.expiaryDate_rendered }} {% if account.days_to_expiry is defined %}<span style="color: red;">({{ account.days_to_expiry }} days)</span>{% endif %}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for account in current_month_accounts %} </table>
<tr> {% endif %}
<td>{{ account.stream }}</td> {% if expired_accounts %}
<td>{{ account.username }}</td> <h3>Expired Accounts</h3>
<td>{{ account.expiaryDate_rendered }}</td> <table class="table table-bordered table-striped">
</tr> <thead class="thead-dark">
{% endfor %} <tr>
</tbody> <th>Stream Name</th>
</table> <th>Username</th>
{% endif %} <th>Expiry Date</th>
{% if expired_accounts %} </tr>
<h3>Expired Accounts</h3> </thead>
<table class="table table-bordered table-striped"> <tbody>
<thead class="thead-dark"> {% for account in expired_accounts %}
<tr> <tr>
<th>Stream Name</th> <td>{{ account.stream }}</td>
<th>Username</th> <td>{{ account.username }}</td>
<th>Expiry Date</th> <td>{{ account.expiaryDate_rendered }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for account in expired_accounts %} </table>
<tr> {% endif %}
<td>{{ account.stream }}</td> {% endblock %}
<td>{{ account.username }}</td>
<td>{{ account.expiaryDate_rendered }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</main>
<!-- Footer --> {% block scripts %}
<footer class="bg-dark text-white text-center py-3 mt-5"> {% endblock %}
<p>Version: {{ version }}</p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

View File

@ -1,80 +1,36 @@
<!-- templates/index.html --> {% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}" />
<meta name="apple-mobile-web-app-title" content="kTvManager" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}?v={{ version }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
<!-- Navbar --> {% block title %}KTVManager{% endblock %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a> {% block content %}
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <h1>Welcome to KTV Manager</h1>
<span class="navbar-toggler-icon"></span>
</button> <!-- Login Form -->
<div class="collapse navbar-collapse" id="navbarNav"> <form action="/login" method="post" class="mt-3">
<ul class="navbar-nav ml-auto"> <div class="form-group">
<li class="nav-item"> <label for="username">Username:</label>
<a class="nav-link" href="/">Home</a> <input type="text" class="form-control" id="username" name="username" required>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div> </div>
</nav> <div class="form-group">
<label for="password">Password:</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
{% if error %}
<div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %}
</form>
{% endblock %}
<!-- Main Content --> {% block scripts %}
<main div class="container mt-5"> <script>
<h1>Welcome to KTV Manager</h1> if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
<!-- Login Form --> // .then(reg => {
<form action="/login" method="post" class="mt-3"> // console.log('Service worker:', reg);
<div class="form-group"> // .catch(err => {
<label for="username">Username:</label> // console.log('Service worker:', err);
<input type="text" class="form-control" id="username" name="username" required> // });
</div> }
<div class="form-group"> </script>
<label for="password">Password:</label> {% endblock %}
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
{% if error %}
<div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %}
</form>
</div>
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
// .then(reg => {
// console.log('Service worker:', reg);
// .catch(err => {
// console.log('Service worker:', err);
// });
}
</script>
</body>
</html>

View File

@ -1,65 +1,23 @@
<!-- templates/urls.html --> {% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
<!-- Navbar (same as index.html) --> {% block title %}URLs - KTVManager{% endblock %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a> <!-- Link to the URLs page -->
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a> <!-- Link to the URLs page -->
</li>
</ul>
</div>
</nav>
{% block content %}
<!-- Main Content --> <h2>URLs</h2>
<main div class="container mt-5"> <table class="table table-striped">
<h2>URLs</h2> <thead>
<table class="table table-striped"> <tr>
<thead> <th>#</th>
<tr> <th>URL</th>
<th>#</th> </tr>
<th>URL</th> </thead>
</tr> <tbody>
</thead> {% for url in urls %}
<tbody> <tr>
{% for url in urls %} <td>{{ loop.index }}</td>
<tr> <td><a href="{{ url }}" target="_blank">{{ url }}</a></td>
<td>{{ loop.index }}</td> </tr>
<td><a href="{{ url }}" target="_blank">{{ url }}</a></td> {% endfor %}
</tr> </tbody>
{% endfor %} </table>
</tbody> {% endblock %}
</table>
</div>
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

View File

@ -1,30 +1,13 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Accounts - KTVManager{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block head_content %}
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.dataTables.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.dataTables.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" /> {% endblock %}
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link" href="/accounts">Accounts</a></li>
<li class="nav-item"><a class="nav-link" href="/urls">URLs</a></li>
</ul>
</div>
</nav>
{% block sub_nav %}
<!-- Sub-navigation for Accounts --> <!-- Sub-navigation for Accounts -->
<div class="bg-light py-2"> <div class="bg-light py-2">
<div class="container"> <div class="container">
@ -38,56 +21,53 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endblock %}
<!-- Main Content --> {% block content %}
<main div class="container mt-5"> <h2>{{ username }}'s Accounts</h2>
<h2>{{ username }}'s Accounts</h2> <div>
<div class="table-responsive"> <table class="table table-striped dt-responsive nowrap" id="accountsTable" style="width:100%">
<table class="table table-striped" id="accountsTable"> <thead>
<thead> <tr>
<tr> <!-- <th>#</th> -->
<!-- <th>#</th> --> <th>Username</th>
<th>Username</th> <th>Stream</th>
<th>Stream</th> <th>Stream URL</th>
<th>Stream URL</th> <th>Expiry Date</th>
<th>Expiry Date</th> <th>Password</th>
<th>Password</th> <th>Actions</th>
<th>Actions</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for account in user_accounts %}
{% for account in user_accounts %} <tr>
<tr> <!-- <td>{{ loop.index }}</td> -->
<!-- <td>{{ loop.index }}</td> --> <td>{{ account.username }}</td>
<td>{{ account.username }}</td> <td>{{ account.stream }}</td>
<td>{{ account.stream }}</td> <td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a> ({{ account.maxConnections }})</td>
<td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a></td> <td>{{ account.expiaryDate_rendered }}</td>
<td>{{ account.expiaryDate_rendered }}</td> <td>{{ account.password }}</td>
<td>{{ account.password }}</td> <td>
<td> <button type="button" class="btn btn-info btn-validate" data-username="{{ account.username }}" data-password="{{ account.password }}" data-stream="{{ account.stream }}" data-stream-url="{{ account.streamURL }}" data-expiry-date="{{ account.expiaryDate }}" data-max-connections="{{ account.maxConnections }}" style="margin-right: 5px;">
<form action="/accounts/delete" method="POST" style="display:inline;"> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
<input type="hidden" name="stream" value="{{ account.stream }}"> <span class="button-text">Validate</span>
<input type="hidden" name="username" value="{{ account.username }}"> </button>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this account?');"> <form action="/accounts/delete" method="POST" style="display:inline;">
Delete <input type="hidden" name="stream" value="{{ account.stream }}">
</button> <input type="hidden" name="username" value="{{ account.username }}">
</form> <button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this account?');">
</td> Delete
</tr> </button>
{% endfor %} </form>
</tbody> </td>
</table> </tr>
</div> {% endfor %}
</tbody>
</table>
</div> </div>
</main> {% endblock %}
<footer class="bg-dark text-white text-center py-3 mt-5"> {% block scripts %}
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script> <script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script> <script src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script> <script>
@ -113,8 +93,67 @@
] ]
}); });
}); });
$('#accountsTable tbody').on('click', '.btn-validate', function() {
var button = $(this);
var spinner = button.find('.spinner-border');
var buttonText = button.find('.button-text');
var username = button.data('username');
var password = button.data('password');
var stream = button.data('stream');
var streamURL = button.data('stream-url');
var expiryDate = button.data('expiry-date');
var maxConnections = button.data('max-connections');
spinner.show();
buttonText.hide();
button.prop('disabled', true);
$.ajax({
url: '/validateAccount',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
username: username,
password: password,
stream: stream,
streamURL: streamURL,
expiry_date: expiryDate,
max_connections: maxConnections
}),
success: function(response) {
spinner.hide();
buttonText.show();
if (response.message === 'Account is valid and updated') {
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-success');
buttonText.text('Valid & Updated');
setTimeout(function() {
button.removeClass('btn-success').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
} else {
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-success');
buttonText.text('Valid');
setTimeout(function() {
button.removeClass('btn-success').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
}
},
error: function(xhr, status, error) {
spinner.hide();
buttonText.show();
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-danger');
buttonText.text('Invalid');
setTimeout(function() {
button.removeClass('btn-danger').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
}
});
});
</script> </script>
{% endblock %}
</body>
</html>

164
test_npm_update.py Normal file
View File

@ -0,0 +1,164 @@
import requests
import json
import mysql.connector
import re
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Assuming config.py is in the same directory or accessible via PYTHONPATH
from config import DevelopmentConfig as app_config
class NginxProxyManager:
def __init__(self, host, email, password):
self.host = host
self.email = email
self.password = password
self.token = None
def login(self):
url = f"{self.host}/api/tokens"
payload = {
"identity": self.email,
"secret": self.password
}
headers = {
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
if response.status_code == 200:
self.token = response.json()["token"]
print("Login successful.")
else:
print(f"Failed to login: {response.text}")
exit(1)
def get_proxy_host(self, host_id):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
headers = {
"Authorization": f"Bearer {self.token}"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"Failed to get proxy host {host_id}: {response.text}")
return None
def update_proxy_host_config(self, host_id, config):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
original_host_data = self.get_proxy_host(host_id)
if not original_host_data:
return
# Construct a new payload with only the allowed fields for an update
update_payload = {
"domain_names": original_host_data.get("domain_names", []),
"forward_scheme": original_host_data.get("forward_scheme", "http"),
"forward_host": original_host_data.get("forward_host"),
"forward_port": original_host_data.get("forward_port"),
"access_list_id": original_host_data.get("access_list_id", 0),
"certificate_id": original_host_data.get("certificate_id", 0),
"ssl_forced": original_host_data.get("ssl_forced", False),
"hsts_enabled": original_host_data.get("hsts_enabled", False),
"hsts_subdomains": original_host_data.get("hsts_subdomains", False),
"http2_support": original_host_data.get("http2_support", False),
"block_exploits": original_host_data.get("block_exploits", False),
"caching_enabled": original_host_data.get("caching_enabled", False),
"allow_websocket_upgrade": original_host_data.get("allow_websocket_upgrade", False),
"advanced_config": config, # The updated advanced config
"meta": original_host_data.get("meta", {}),
"locations": original_host_data.get("locations", []),
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.put(url, headers=headers, data=json.dumps(update_payload))
if response.status_code == 200:
print(f"Successfully updated proxy host {host_id}")
else:
print(f"Failed to update proxy host {host_id}: {response.text}")
def get_streams_from_db(db_host, db_user, db_pass, db_name, db_port):
try:
conn = mysql.connector.connect(
host=db_host,
user=db_user,
password=db_pass,
database=db_name,
port=db_port
)
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT DISTINCT
SUBSTRING_INDEX(stream, ' ', 1) AS streamName,
streamURL
FROM userAccounts
""")
streams = cursor.fetchall()
cursor.close()
conn.close()
return streams
except mysql.connector.Error as err:
print(f"Error connecting to database: {err}")
return []
def update_config_with_streams(config, streams):
# Get all stream names from the database
db_stream_names = {stream['streamName'] for stream in streams}
# Find all location blocks in the config
location_blocks = re.findall(r'location ~ \^/(\w+)\(\.\*\)\$ \{[^}]+\}', config)
# Remove location blocks that are not in the database
for stream_name in location_blocks:
if stream_name not in db_stream_names:
print(f"Removing location block for stream: {stream_name}")
pattern = re.compile(f'location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{[^}}]+}}\\s*', re.DOTALL)
config = pattern.sub('', config)
# Update existing stream URLs
for stream in streams:
stream_name = stream['streamName']
stream_url = stream['streamURL']
if stream_url: # Ensure there is a URL to update to
# Use a more specific regex to avoid replacing parts of other URLs
pattern = re.compile(f'(location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{\\s*return 302 )([^;]+)(;\\s*}})')
config = pattern.sub(f'\\1{stream_url}/$1$is_args$args\\3', config)
return config
def main():
npm = NginxProxyManager(app_config.NPM_HOST, app_config.NPM_EMAIL, app_config.NPM_PASSWORD)
npm.login()
host = npm.get_proxy_host(9)
if host:
current_config = host.get('advanced_config', '')
print("Current Config:")
print(current_config)
streams = get_streams_from_db(app_config.DBHOST, app_config.DBUSER, app_config.DBPASS, app_config.DATABASE, app_config.DBPORT)
if streams:
new_config = update_config_with_streams(current_config, streams)
print("\nNew Config:")
print(new_config)
# Uncomment the following line to apply the changes
npm.update_proxy_host_config(9, new_config)
print("\nTo apply the changes, uncomment the last line in the main function.")
if __name__ == "__main__":
main()