Implement complete user authentication system
- Configured Flask-Login with user_loader - Added register, login, logout routes with proper validation - Created login.html and register.html templates with auth forms - Updated base.html navigation to show username and conditional menu - Added auth page styling to style.css - Protected all routes with @login_required decorator - Updated all routes to filter by current_user.id - Added user ownership validation for: - Channels (can only view/refresh own channels) - Videos (can only watch/download own videos) - Streams (can only stream videos from own channels) - Updated save_to_db() calls to pass current_user.id - Improved user_loader to properly handle session management Features: - User registration with password confirmation - Secure password hashing with bcrypt - Login with "remember me" functionality - Flash messages for all auth actions - Redirect to requested page after login - User-specific data isolation (multi-tenant) Security: - All sensitive routes require authentication - Users can only access their own data - Passwords hashed with bcrypt salt - Session-based authentication via Flask-Login 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
186
main.py
186
main.py
@@ -2,9 +2,10 @@
|
||||
|
||||
import os
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||
from feed_parser import YouTubeFeedParser
|
||||
from database import init_db, get_db_session
|
||||
from models import Channel, VideoEntry, DownloadStatus
|
||||
from models import Channel, VideoEntry, DownloadStatus, User
|
||||
from download_service import download_video, download_videos_batch
|
||||
from sqlalchemy import desc
|
||||
|
||||
@@ -12,6 +13,27 @@ from sqlalchemy import desc
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
|
||||
# Configure Flask-Login
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "login"
|
||||
login_manager.login_message = "Please log in to access this page."
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user by ID for Flask-Login."""
|
||||
session = next(get_db_session())
|
||||
try:
|
||||
user = session.query(User).get(int(user_id))
|
||||
if user:
|
||||
# Expire the user from this session so it can be used in request context
|
||||
session.expunge(user)
|
||||
return user
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Default channel ID for demonstration
|
||||
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
||||
|
||||
@@ -21,15 +43,125 @@ with app.app_context():
|
||||
init_db()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Routes
|
||||
# ============================================================================
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
"""User registration page."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
email = request.form.get("email")
|
||||
password = request.form.get("password")
|
||||
confirm_password = request.form.get("confirm_password")
|
||||
|
||||
# Validation
|
||||
if not username or not email or not password:
|
||||
flash("All fields are required", "error")
|
||||
return render_template("register.html")
|
||||
|
||||
if password != confirm_password:
|
||||
flash("Passwords do not match", "error")
|
||||
return render_template("register.html")
|
||||
|
||||
if len(password) < 8:
|
||||
flash("Password must be at least 8 characters long", "error")
|
||||
return render_template("register.html")
|
||||
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
# Check if username or email already exists
|
||||
existing_user = session.query(User).filter(
|
||||
(User.username == username) | (User.email == email)
|
||||
).first()
|
||||
|
||||
if existing_user:
|
||||
if existing_user.username == username:
|
||||
flash("Username already taken", "error")
|
||||
else:
|
||||
flash("Email already registered", "error")
|
||||
return render_template("register.html")
|
||||
|
||||
# Create new user
|
||||
user = User(username=username, email=email)
|
||||
user.set_password(password)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
flash("Registration successful! Please log in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Registration failed: {str(e)}", "error")
|
||||
return render_template("register.html")
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""User login page."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
remember = request.form.get("remember") == "on"
|
||||
|
||||
if not username or not password:
|
||||
flash("Please enter both username and password", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
user = session.query(User).filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
# Expunge user from session before login
|
||||
session.expunge(user)
|
||||
login_user(user, remember=remember)
|
||||
flash(f"Welcome back, {user.username}!", "success")
|
||||
|
||||
# Redirect to next page if specified
|
||||
next_page = request.args.get("next")
|
||||
return redirect(next_page) if next_page else redirect(url_for("index"))
|
||||
else:
|
||||
flash("Invalid username or password", "error")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Login failed: {str(e)}", "error")
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log out the current user."""
|
||||
logout_user()
|
||||
flash("You have been logged out", "info")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Frontend Routes
|
||||
# ============================================================================
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
@login_required
|
||||
def index():
|
||||
"""Render the dashboard with all videos sorted by date."""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
# Query all videos with their channels, sorted by published date (newest first)
|
||||
videos = session.query(VideoEntry).join(Channel).order_by(
|
||||
desc(VideoEntry.published_at)
|
||||
).all()
|
||||
# Query videos for current user's channels, sorted by published date (newest first)
|
||||
videos = session.query(VideoEntry).join(Channel).filter(
|
||||
Channel.user_id == current_user.id
|
||||
).order_by(desc(VideoEntry.published_at)).all()
|
||||
|
||||
return render_template("dashboard.html", videos=videos)
|
||||
except Exception as e:
|
||||
@@ -38,11 +170,14 @@ def index():
|
||||
|
||||
|
||||
@app.route("/channels", methods=["GET"])
|
||||
@login_required
|
||||
def channels_page():
|
||||
"""Render the channels management page."""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
channels = session.query(Channel).order_by(desc(Channel.last_fetched_at)).all()
|
||||
channels = session.query(Channel).filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(desc(Channel.last_fetched_at)).all()
|
||||
return render_template("channels.html", channels=channels)
|
||||
except Exception as e:
|
||||
flash(f"Error loading channels: {str(e)}", "error")
|
||||
@@ -50,6 +185,7 @@ def channels_page():
|
||||
|
||||
|
||||
@app.route("/add-channel", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_channel_page():
|
||||
"""Render the add channel page and handle channel subscription."""
|
||||
if request.method == "GET":
|
||||
@@ -78,9 +214,9 @@ def add_channel_page():
|
||||
flash("Failed to fetch feed from YouTube", "error")
|
||||
return redirect(url_for("add_channel_page"))
|
||||
|
||||
# Save to database
|
||||
# Save to database with current user
|
||||
with get_db_session() as session:
|
||||
channel = parser.save_to_db(session, result)
|
||||
channel = parser.save_to_db(session, result, current_user.id)
|
||||
video_count = len(result["entries"])
|
||||
|
||||
flash(f"Successfully subscribed to {channel.title}! Added {video_count} videos.", "success")
|
||||
@@ -92,11 +228,17 @@ def add_channel_page():
|
||||
|
||||
|
||||
@app.route("/watch/<int:video_id>", methods=["GET"])
|
||||
@login_required
|
||||
def watch_video(video_id: int):
|
||||
"""Render the video watch page."""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
||||
# Only allow user to watch videos from their own channels
|
||||
video = session.query(VideoEntry).join(Channel).filter(
|
||||
VideoEntry.id == video_id,
|
||||
Channel.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
flash("Video not found", "error")
|
||||
return redirect(url_for("index"))
|
||||
@@ -214,6 +356,7 @@ def get_history(channel_id: str):
|
||||
|
||||
|
||||
@app.route("/api/download/<int:video_id>", methods=["POST"])
|
||||
@login_required
|
||||
def trigger_download(video_id: int):
|
||||
"""Trigger video download for a specific video.
|
||||
|
||||
@@ -225,7 +368,12 @@ def trigger_download(video_id: int):
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
||||
# Only allow downloading videos from user's own channels
|
||||
video = session.query(VideoEntry).join(Channel).filter(
|
||||
VideoEntry.id == video_id,
|
||||
Channel.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
return jsonify({"status": "error", "message": "Video not found"}), 404
|
||||
|
||||
@@ -345,6 +493,7 @@ def trigger_batch_download():
|
||||
|
||||
|
||||
@app.route("/api/videos/refresh/<int:channel_id>", methods=["POST"])
|
||||
@login_required
|
||||
def refresh_channel_videos(channel_id: int):
|
||||
"""Refresh videos for a specific channel by fetching latest from RSS feed.
|
||||
|
||||
@@ -356,7 +505,12 @@ def refresh_channel_videos(channel_id: int):
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
channel = session.query(Channel).filter_by(id=channel_id).first()
|
||||
# Only allow refresh for user's own channels
|
||||
channel = session.query(Channel).filter_by(
|
||||
id=channel_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not channel:
|
||||
return jsonify({"status": "error", "message": "Channel not found"}), 404
|
||||
|
||||
@@ -371,7 +525,7 @@ def refresh_channel_videos(channel_id: int):
|
||||
existing_count = len(channel.video_entries)
|
||||
|
||||
# Save to database (upsert logic will handle duplicates)
|
||||
parser.save_to_db(session, result)
|
||||
parser.save_to_db(session, result, current_user.id)
|
||||
|
||||
# Count after save
|
||||
session.refresh(channel)
|
||||
@@ -389,6 +543,7 @@ def refresh_channel_videos(channel_id: int):
|
||||
|
||||
|
||||
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
||||
@login_required
|
||||
def stream_video(video_id: int):
|
||||
"""Stream a downloaded video file.
|
||||
|
||||
@@ -403,7 +558,12 @@ def stream_video(video_id: int):
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
||||
# Only allow streaming videos from user's own channels
|
||||
video = session.query(VideoEntry).join(Channel).filter(
|
||||
VideoEntry.id == video_id,
|
||||
Channel.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
return jsonify({"error": "Video not found"}), 404
|
||||
|
||||
|
||||
Reference in New Issue
Block a user