Add login functionality with password protection and create login page
This commit is contained in:
1 parent
443ed74093
commit
c237564f61
4 files changed
+204
-1
No files matched your search
@@ -9,3 +9,5 @@ FLASK_DEBUG=0
|
||||
# Seconds to cache the TeslaCam directory scan (default 300). Hit /refresh to
|
||||
# force a re-scan.
|
||||
SCAN_CACHE_TTL=3600
|
||||
|
||||
LOGIN_PASSWORD=patreon
|
||||
@@ -520,3 +520,117 @@ header {
|
||||
[hidden] {
|
||||
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 re
|
||||
import time
|
||||
import secrets
|
||||
from functools import wraps
|
||||
|
||||
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_dotenv()
|
||||
|
||||
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.
|
||||
# Override via the TESLACAM_DIRECTORY environment variable (see .env.example).
|
||||
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())
|
||||
|
||||
|
||||
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('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""
|
||||
Main page listing all videos categorized by human-readable camera view.
|
||||
@@ -133,6 +174,7 @@ def index():
|
||||
|
||||
|
||||
@app.route('/camera/<camera_view>')
|
||||
@login_required
|
||||
def camera_view(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>')
|
||||
@login_required
|
||||
def serve_video(filepath):
|
||||
"""
|
||||
Safely streams video files from the TeslaCam directory structure.
|
||||
@@ -162,6 +205,7 @@ def serve_video(filepath):
|
||||
|
||||
|
||||
@app.route('/refresh')
|
||||
@login_required
|
||||
def refresh():
|
||||
"""
|
||||
Forces a re-scan of the TeslaCam directory, bypassing the cache TTL.
|
||||
|
||||
Reference in new issue
Block a user