Skip to content

Commit 2c30731

Browse files
committed
big app refacto
update all the ui add authentication (user/password/totp) add restore option based on backup option instead of downloading logs, show last 50 lines ability to enable/disable conf search bar fix gunicorn issue with workers
1 parent 3d17168 commit 2c30731

28 files changed

Lines changed: 3215 additions & 421 deletions

compose-build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ services:
3434
- certs:/app/nginx/certs
3535
- scripts:/app/scripts
3636
- ./logs:/app/logs
37+
- data:/app/data
3738
restart: unless-stopped
3839

3940
volumes:
4041
logs:
4142
conf:
4243
certs:
4344
scripts:
45+
data:

compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ services:
3030
- certs:/app/nginx/certs
3131
- scripts:/app/scripts
3232
- ./logs:/app/logs
33+
- data:/app/data
3334
restart: unless-stopped
3435

3536
volumes:
3637
logs:
3738
conf:
3839
certs:
3940
scripts:
41+
data:

web-manager/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ RUN pip install --no-cache-dir -r requirements.txt
99

1010
EXPOSE 5000
1111

12-
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]
12+
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
1313

web-manager/app.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1+
from datetime import timedelta
2+
13
from flask import Flask, render_template
4+
from flask_login import login_required
5+
26
from blueprints import manager
7+
from blueprints.auth import bp as auth_bp, login_manager
8+
from db import init_db, get_or_create_secret_key
39

410
app = Flask(__name__)
11+
app.secret_key = get_or_create_secret_key()
12+
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=30)
13+
14+
login_manager.init_app(app)
15+
516
app.register_blueprint(manager.bp)
17+
app.register_blueprint(auth_bp)
18+
19+
with app.app_context():
20+
init_db()
621

722

823
@app.route('/')
24+
@login_required
925
def index():
1026
return render_template('index.html')
1127

web-manager/blueprints/auth.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import io
2+
from datetime import timedelta
3+
4+
import pyotp
5+
import qrcode
6+
from flask import (Blueprint, render_template, request, redirect,
7+
url_for, session, send_file, abort)
8+
from flask_login import (LoginManager, UserMixin, login_user, logout_user,
9+
login_required, current_user)
10+
from werkzeug.security import generate_password_hash, check_password_hash
11+
12+
from db import (user_count, get_user, get_user_by_id, get_all_users,
13+
create_user, update_password, set_totp_secret, delete_user)
14+
15+
bp = Blueprint('auth', __name__, url_prefix='/auth')
16+
login_manager = LoginManager()
17+
18+
19+
class User(UserMixin):
20+
def __init__(self, data: dict):
21+
self.id = data['id']
22+
self.username = data['username']
23+
self.password_hash = data['password_hash']
24+
self.totp_secret = data['totp_secret']
25+
26+
@property
27+
def has_totp(self) -> bool:
28+
return bool(self.totp_secret)
29+
30+
31+
@login_manager.user_loader
32+
def load_user(user_id: str):
33+
data = get_user_by_id(int(user_id))
34+
return User(data) if data else None
35+
36+
37+
@login_manager.unauthorized_handler
38+
def unauthorized():
39+
if user_count() == 0:
40+
return redirect(url_for('auth.setup'))
41+
return redirect(url_for('auth.login', next=request.path))
42+
43+
44+
# ── Login / logout ────────────────────────────────────────────────────
45+
46+
@bp.route('/login', methods=['GET', 'POST'])
47+
def login():
48+
if user_count() == 0:
49+
return redirect(url_for('auth.setup'))
50+
if current_user.is_authenticated:
51+
return redirect(url_for('index'))
52+
53+
error = None
54+
if request.method == 'POST':
55+
username = request.form.get('username', '').strip()
56+
password = request.form.get('password', '')
57+
data = get_user(username)
58+
if data and check_password_hash(data['password_hash'], password):
59+
if data['totp_secret']:
60+
session['totp_pending'] = data['id']
61+
session['totp_next'] = (request.form.get('next')
62+
or request.args.get('next')
63+
or url_for('index'))
64+
return redirect(url_for('auth.totp'))
65+
login_user(User(data), remember=True)
66+
next_url = request.form.get('next') or request.args.get('next') or url_for('index')
67+
return redirect(next_url)
68+
error = 'Invalid username or password.'
69+
70+
return render_template('login.html', error=error, next=request.args.get('next', ''))
71+
72+
73+
@bp.route('/totp', methods=['GET', 'POST'])
74+
def totp():
75+
user_id = session.get('totp_pending')
76+
if not user_id:
77+
return redirect(url_for('auth.login'))
78+
79+
error = None
80+
if request.method == 'POST':
81+
code = request.form.get('code', '').strip()
82+
data = get_user_by_id(user_id)
83+
if data and data['totp_secret'] and pyotp.TOTP(data['totp_secret']).verify(code):
84+
session.pop('totp_pending', None)
85+
next_url = session.pop('totp_next', url_for('index'))
86+
login_user(User(data), remember=True)
87+
return redirect(next_url)
88+
error = 'Invalid code. Please try again.'
89+
90+
return render_template('totp.html', error=error)
91+
92+
93+
@bp.route('/logout')
94+
@login_required
95+
def logout():
96+
logout_user()
97+
return redirect(url_for('auth.login'))
98+
99+
100+
# ── First-run setup ───────────────────────────────────────────────────
101+
102+
@bp.route('/setup', methods=['GET', 'POST'])
103+
def setup():
104+
if user_count() > 0:
105+
return redirect(url_for('auth.login'))
106+
107+
error = None
108+
if request.method == 'POST':
109+
username = request.form.get('username', '').strip()
110+
password = request.form.get('password', '')
111+
confirm = request.form.get('confirm_password', '')
112+
if not username or not password:
113+
error = 'Username and password are required.'
114+
elif not password:
115+
error = 'Password is required.'
116+
elif password != confirm:
117+
error = 'Passwords do not match.'
118+
else:
119+
create_user(username, generate_password_hash(password))
120+
return redirect(url_for('auth.login'))
121+
122+
return render_template('setup.html', error=error)
123+
124+
125+
# ── Settings (password + TOTP) ────────────────────────────────────────
126+
127+
@bp.route('/settings', methods=['GET', 'POST'])
128+
@login_required
129+
def settings():
130+
message = None
131+
success = False
132+
133+
if request.method == 'POST':
134+
action = request.form.get('action')
135+
136+
if action == 'change_password':
137+
current_pw = request.form.get('current_password', '')
138+
new_pw = request.form.get('new_password', '')
139+
confirm_pw = request.form.get('confirm_password', '')
140+
data = get_user_by_id(current_user.id)
141+
if not check_password_hash(data['password_hash'], current_pw):
142+
message = 'Current password is incorrect.'
143+
elif not new_pw:
144+
message = 'New password is required.'
145+
elif new_pw != confirm_pw:
146+
message = 'Passwords do not match.'
147+
else:
148+
update_password(current_user.id, generate_password_hash(new_pw))
149+
message = 'Password updated successfully.'
150+
success = True
151+
152+
elif action == 'disable_totp':
153+
data = get_user_by_id(current_user.id)
154+
if not check_password_hash(data['password_hash'],
155+
request.form.get('totp_password', '')):
156+
message = 'Password is incorrect.'
157+
else:
158+
set_totp_secret(current_user.id, None)
159+
message = 'Two-factor authentication disabled.'
160+
success = True
161+
162+
return render_template('settings.html', message=message, success=success)
163+
164+
165+
@bp.route('/settings/totp/setup', methods=['GET', 'POST'])
166+
@login_required
167+
def totp_setup():
168+
if current_user.has_totp:
169+
return redirect(url_for('auth.settings'))
170+
171+
secret = session.get('totp_setup_secret') or pyotp.random_base32()
172+
session['totp_setup_secret'] = secret
173+
174+
error = None
175+
if request.method == 'POST':
176+
if pyotp.TOTP(secret).verify(request.form.get('code', '').strip()):
177+
set_totp_secret(current_user.id, secret)
178+
session.pop('totp_setup_secret', None)
179+
return redirect(url_for('auth.settings'))
180+
error = 'Invalid code. Check your authenticator app and try again.'
181+
182+
otp_uri = pyotp.TOTP(secret).provisioning_uri(
183+
name=current_user.username,
184+
issuer_name='Reverse Proxy Manager'
185+
)
186+
return render_template('totp_setup.html', secret=secret, otp_uri=otp_uri, error=error)
187+
188+
189+
@bp.route('/settings/totp/qr.png')
190+
@login_required
191+
def totp_qr():
192+
secret = session.get('totp_setup_secret')
193+
if not secret:
194+
abort(404)
195+
otp_uri = pyotp.TOTP(secret).provisioning_uri(
196+
name=current_user.username,
197+
issuer_name='Reverse Proxy Manager'
198+
)
199+
img = qrcode.make(otp_uri)
200+
buf = io.BytesIO()
201+
img.save(buf, format='PNG')
202+
buf.seek(0)
203+
return send_file(buf, mimetype='image/png')
204+
205+
206+
# ── User management ───────────────────────────────────────────────────
207+
208+
@bp.route('/users', methods=['GET', 'POST'])
209+
@login_required
210+
def users():
211+
message = None
212+
success = False
213+
214+
if request.method == 'POST':
215+
action = request.form.get('action')
216+
217+
if action == 'add':
218+
username = request.form.get('username', '').strip()
219+
password = request.form.get('password', '')
220+
if not username or not password:
221+
message = 'Username and password are required.'
222+
elif get_user(username):
223+
message = f'User "{username}" already exists.'
224+
else:
225+
create_user(username, generate_password_hash(password))
226+
message = f'User "{username}" created.'
227+
success = True
228+
229+
elif action == 'delete':
230+
if current_user.id != 1:
231+
message = 'Only the admin account can delete users.'
232+
else:
233+
user_id = int(request.form.get('user_id'))
234+
if user_id == current_user.id:
235+
message = 'You cannot delete your own account.'
236+
else:
237+
delete_user(user_id)
238+
message = 'User deleted.'
239+
success = True
240+
241+
return render_template('users.html', users=get_all_users(),
242+
message=message, success=success)

0 commit comments

Comments
 (0)