Add login functionality with password protection and create login page
This commit is contained in:
1 parent
443ed74093
commit
c237564f61
4 files changed
+205
-2
No files matched your search
+3
-1
@@ -8,4 +8,6 @@ FLASK_DEBUG=0
|
|||||||
|
|
||||||
# Seconds to cache the TeslaCam directory scan (default 300). Hit /refresh to
|
# Seconds to cache the TeslaCam directory scan (default 300). Hit /refresh to
|
||||||
# force a re-scan.
|
# force a re-scan.
|
||||||
SCAN_CACHE_TTL=3600
|
SCAN_CACHE_TTL=3600
|
||||||
|
|
||||||
|
LOGIN_PASSWORD=patreon
|
||||||
@@ -520,3 +520,117 @@ header {
|
|||||||
[hidden] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
Login Page
|
||||||
|
============================================= */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at 50% 30%,
|
||||||
|
#1a1a22 0%,
|
||||||
|
var(--bg-primary) 70%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand .logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand .logo svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="password"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(227, 25, 55, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form button:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(227, 25, 55, 0.08);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TeslaCam Player — Login</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<span class="logo" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 3C7 3 3.2 4.4 3.2 4.4l.9 1.7s3.1-1 5-.9l.7 1.3C7.9 7.7 6 9 6 9s.6 2.4 3 4.6c2.5-2.2 3-4.6 3-4.6s-1.9-1.3-3.7-1.5l.6-1.3c1.9-.1 5 .9 5 .9l.9-1.7S17 3 12 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h1>TeslaCam Player</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('login') }}" class="login-form">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{% if error %}
|
||||||
|
<p class="login-error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+45
-1
@@ -12,15 +12,26 @@ play videos from different camera views.
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import secrets
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, render_template, send_from_directory, abort, jsonify
|
from flask import (
|
||||||
|
Flask, render_template, send_from_directory, abort, jsonify,
|
||||||
|
request, session, redirect, url_for
|
||||||
|
)
|
||||||
|
|
||||||
# Load variables from a .env file if present (does not override real env vars)
|
# Load variables from a .env file if present (does not override real env vars)
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Session secret key — MUST be set in production. Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||||
|
|
||||||
|
# Password for web login. Default: 'patreon'. Override via LOGIN_PASSWORD env var.
|
||||||
|
LOGIN_PASSWORD = os.environ.get('LOGIN_PASSWORD', 'patreon')
|
||||||
|
|
||||||
# Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc.
|
# Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc.
|
||||||
# Override via the TESLACAM_DIRECTORY environment variable (see .env.example).
|
# Override via the TESLACAM_DIRECTORY environment variable (see .env.example).
|
||||||
TESLACAM_DIRECTORY = os.environ.get('TESLACAM_DIRECTORY', '/mnt/TeslaCam')
|
TESLACAM_DIRECTORY = os.environ.get('TESLACAM_DIRECTORY', '/mnt/TeslaCam')
|
||||||
@@ -113,7 +124,37 @@ def get_camera_display_name(camera_view):
|
|||||||
return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title())
|
return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title())
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
"""Decorator: redirect to /login if not authenticated."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not session.get('logged_in'):
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
"""Simple password-based login page."""
|
||||||
|
error = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.form.get('password') == LOGIN_PASSWORD:
|
||||||
|
session['logged_in'] = True
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
error = 'Invalid password. Please try again.'
|
||||||
|
return render_template('login.html', error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
"""Clear session and return to login page."""
|
||||||
|
session.pop('logged_in', None)
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
"""
|
"""
|
||||||
Main page listing all videos categorized by human-readable camera view.
|
Main page listing all videos categorized by human-readable camera view.
|
||||||
@@ -133,6 +174,7 @@ def index():
|
|||||||
|
|
||||||
|
|
||||||
@app.route('/camera/<camera_view>')
|
@app.route('/camera/<camera_view>')
|
||||||
|
@login_required
|
||||||
def camera_view(camera_view):
|
def camera_view(camera_view):
|
||||||
"""
|
"""
|
||||||
Displays all available videos for a single camera view.
|
Displays all available videos for a single camera view.
|
||||||
@@ -150,6 +192,7 @@ def camera_view(camera_view):
|
|||||||
|
|
||||||
|
|
||||||
@app.route('/video/<path:filepath>')
|
@app.route('/video/<path:filepath>')
|
||||||
|
@login_required
|
||||||
def serve_video(filepath):
|
def serve_video(filepath):
|
||||||
"""
|
"""
|
||||||
Safely streams video files from the TeslaCam directory structure.
|
Safely streams video files from the TeslaCam directory structure.
|
||||||
@@ -162,6 +205,7 @@ def serve_video(filepath):
|
|||||||
|
|
||||||
|
|
||||||
@app.route('/refresh')
|
@app.route('/refresh')
|
||||||
|
@login_required
|
||||||
def refresh():
|
def refresh():
|
||||||
"""
|
"""
|
||||||
Forces a re-scan of the TeslaCam directory, bypassing the cache TTL.
|
Forces a re-scan of the TeslaCam directory, bypassing the cache TTL.
|
||||||
|
|||||||
Reference in new issue
Block a user