Add authentication for update/delete endpoints

This commit is contained in:
Peter Stockings
2026-01-29 18:41:24 +11:00
parent e7520035c7
commit 036d852aab
6 changed files with 197 additions and 17 deletions

26
app.py
View File

@@ -1,10 +1,11 @@
from datetime import date
import os
from flask import Flask, abort, render_template, redirect, request, url_for
from flask_login import LoginManager
from flask_login import LoginManager, login_required
import jinja_partials
from jinja2_fragments import render_block
from decorators import validate_person, validate_topset, validate_workout
from decorators import (validate_person, validate_topset, validate_workout,
require_ownership, get_auth_message, get_person_id_from_context)
from routes.auth import auth, get_person_by_id
from routes.changelog import changelog_bp
from routes.calendar import calendar_bp # Import the new calendar blueprint
@@ -40,6 +41,17 @@ login_manager.login_message_category = 'info'
def load_user(person_id):
return get_person_by_id(person_id)
@login_manager.unauthorized_handler
def unauthorized():
from flask import flash
person_id = get_person_id_from_context()
msg = get_auth_message(request.endpoint, person_id)
flash(msg, "info")
if request.headers.get('HX-Request'):
return '', 200, {'HX-Redirect': url_for('auth.login')}
return redirect(url_for('auth.login'))
app.register_blueprint(auth, url_prefix='/auth')
app.register_blueprint(changelog_bp, url_prefix='/changelog')
app.register_blueprint(calendar_bp) # Register the calendar blueprint
@@ -144,6 +156,7 @@ def person_overview(person_id):
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
@ app.route("/person", methods=['POST'])
@login_required
def create_person():
name = request.form.get("name")
new_person_id = db.create_person(name)
@@ -151,18 +164,27 @@ def create_person():
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
@login_required
@validate_person
@require_ownership
def delete_person(person_id):
db.delete_person(person_id)
return "", 200, {"HX-Trigger": "updatedPeople"}
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
@login_required
@validate_person
@require_ownership
def get_person_edit_form(person_id):
name = db.get_person_name(person_id)
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
@login_required
@validate_person
@require_ownership
def update_person_name(person_id):
new_name = request.form.get("name")
db.update_person_name(person_id, new_name)

View File

@@ -1,12 +1,49 @@
from functools import wraps
from flask import render_template, url_for, request
from flask_login import current_user
from flask import render_template, url_for
def get_params(*args):
"""Helper to get parameters from kwargs, form, or args."""
res = []
for arg in args:
val = request.view_args.get(arg)
if val is None:
val = request.form.get(arg, type=int)
if val is None:
val = request.args.get(arg, type=int)
res.append(val)
return res[0] if len(res) == 1 else tuple(res)
def get_person_id_from_context():
"""Helper to find person_id from URL/form context."""
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
from app import db
if person_id is not None:
return person_id
if workout_id is not None:
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [workout_id], one=True)
if workout_info:
return workout_info['person_id']
if topset_id is not None:
topset_info = db.execute("SELECT workout_id FROM topset WHERE topset_id = %s", [topset_id], one=True)
if topset_info:
w_id = topset_info['workout_id']
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [w_id], one=True)
if workout_info:
return workout_info['person_id']
return None
def validate_person(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
person_id = get_params('person_id')
from app import db
person = db.is_valid_person(person_id)
if person is None:
@@ -18,12 +55,14 @@ def validate_person(func):
def validate_workout(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
workout_id = kwargs.get('workout_id')
person_id, workout_id = get_params('person_id', 'workout_id')
from app import db
if person_id is None and workout_id is not None:
person_id = get_person_id_from_context()
workout = db.is_valid_workout(person_id, workout_id)
if workout is None:
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id))
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id) if person_id else '/')
return func(*args, **kwargs)
return wrapper
@@ -31,12 +70,73 @@ def validate_workout(func):
def validate_topset(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
workout_id = kwargs.get('workout_id')
topset_id = kwargs.get('topset_id')
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
from app import db
if (person_id is None or workout_id is None) and topset_id is not None:
person_id = get_person_id_from_context()
# We could also find workout_id, but is_valid_topset handles it if we have at least topset_id
topset = db.is_valid_topset(person_id, workout_id, topset_id)
if topset is None:
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id}) in Workout({workout_id}) completed by Person({person_id})', url=url_for('get_workout', person_id=person_id, workout_id=workout_id))
fallback_url = url_for('person_overview', person_id=person_id) if person_id else '/'
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id})', url=fallback_url)
return func(*args, **kwargs)
return wrapper
ACTION_MAP = {
'workout.create_workout': 'create a workout',
'workout.delete_workout': 'delete this workout',
'workout.update_workout_start_date': 'change the date for this workout',
'workout.create_topset': 'add a set',
'workout.update_topset': 'update this set',
'workout.delete_topset': 'delete this set',
'delete_person': 'delete this person',
'update_person_name': 'update this person\'s name',
'tags.add_tag': 'add a tag',
'tags.delete_tag': 'delete this tag',
'tags.add_tag_to_workout': 'add a tag to this workout',
'tags.create_new_tag_for_workout': 'create a new tag for this workout',
'programs.create_program': 'create a workout program',
'programs.delete_program': 'delete this workout program',
}
def get_auth_message(endpoint, person_id=None, is_authenticated=False):
"""Generates a friendly authorization message."""
action = ACTION_MAP.get(endpoint)
if not action:
# Fallback: prettify endpoint name if not in map
# e.g. 'workout.create_topset' -> 'create topset'
action = endpoint.split('.')[-1].replace('_', ' ')
if is_authenticated:
msg = f"You are not authorized to {action}"
else:
msg = f"Please log in to {action}"
if person_id:
from app import db
person_name = db.get_person_name(person_id)
if person_name:
msg += f" for {person_name}"
return msg
def require_ownership(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = get_person_id_from_context()
# Authorization check: must be logged in and the owner
if not current_user.is_authenticated or person_id is None or int(current_user.get_id()) != person_id:
from flask import flash
msg = get_auth_message(request.endpoint, person_id, is_authenticated=current_user.is_authenticated)
flash(msg, "info")
if request.headers.get('HX-Request'):
return '', 200, {'HX-Redirect': url_for('auth.login') if not current_user.is_authenticated else url_for('dashboard')}
return render_template('error.html', error='403', message='You are not authorized to modify this resource.', url='/')
return func(*args, **kwargs)
return wrapper

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, request, redirect, url_for, current_app
from extensions import db
# from flask_login import login_required, current_user # Add if authentication is needed
from flask_login import login_required, current_user
from jinja2_fragments import render_block # Import render_block
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
@@ -8,7 +8,7 @@ programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
from flask import flash # Import flash for displaying messages
@programs_bp.route('/create', methods=['GET', 'POST'])
# @login_required # Uncomment if login is required
@login_required
def create_program():
if request.method == 'POST':
program_name = request.form.get('program_name', '').strip()
@@ -157,7 +157,7 @@ def list_programs():
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
# @login_required # Add authentication if needed
@login_required
def delete_program(program_id):
"""Deletes a workout program and its associated sessions/assignments."""
try:

View File

@@ -1,8 +1,9 @@
from flask import Blueprint, request, redirect, url_for, render_template, current_app
from urllib.parse import urlencode, parse_qs, unquote_plus
from flask_login import current_user
from flask_login import current_user, login_required
from extensions import db
from jinja2_fragments import render_block
from decorators import validate_person, validate_workout, require_ownership
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
@@ -54,6 +55,8 @@ def goto_tag():
@tags_bp.route("/add", methods=['POST']) # Changed to POST
@login_required
@require_ownership
def add_tag():
"""Adds a tag and returns the updated tags partial."""
person_id = request.form.get("person_id") # Get from form data
@@ -85,6 +88,8 @@ def add_tag():
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
@login_required
@require_ownership
def delete_tag(tag_id):
"""Deletes a tag and returns the updated tags partial."""
# We might get person_id from request body/headers if needed, or assume context
@@ -105,6 +110,9 @@ def delete_tag(tag_id):
# --- Workout Specific Tag Routes ---
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
@login_required
@validate_workout
@require_ownership
def add_tag_to_workout(workout_id):
"""Adds existing tags to a specific workout."""
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
@@ -181,6 +189,9 @@ def add_tag_to_workout(workout_id):
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
@login_required
@validate_workout
@require_ownership
def create_new_tag_for_workout(workout_id):
"""Creates a new tag and associates it with a specific workout."""
# Note: Authorization might be needed here.

View File

@@ -1,8 +1,9 @@
from flask import Blueprint, render_template, redirect, url_for, request, current_app
from jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import login_required
from extensions import db
from decorators import validate_workout, validate_topset
from decorators import validate_workout, validate_topset, require_ownership, validate_person
from utils import convert_str_to_date
from collections import defaultdict # Import defaultdict
@@ -129,6 +130,9 @@ def _get_workout_view_model(person_id, workout_id):
# --- Routes ---
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
@login_required
@validate_person
@require_ownership
def create_workout(person_id):
new_workout_id = db.create_workout(person_id)
# Use the local helper function to get the view model
@@ -139,13 +143,17 @@ def create_workout(person_id):
return render_block(current_app.jinja_env, 'workout.html', 'content', **view_model)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/delete", methods=['GET'])
@login_required
@validate_workout
@require_ownership
def delete_workout(person_id, workout_id):
db.delete_workout(workout_id)
return redirect(url_for('calendar.get_calendar', person_id=person_id))
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
@login_required
@validate_workout
@require_ownership
def get_workout_start_date_edit_form(person_id, workout_id):
# Fetch only the necessary data (start_date)
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
@@ -153,7 +161,9 @@ def get_workout_start_date_edit_form(person_id, workout_id):
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date, is_edit=True)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
@login_required
@validate_workout
@require_ownership
def update_workout_start_date(person_id, workout_id):
new_start_date_str = request.form.get('start-date')
db.update_workout_start_date(workout_id, new_start_date_str)
@@ -176,14 +186,18 @@ def get_topset(person_id, workout_id, topset_id):
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'))
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
@login_required
@validate_topset
@require_ownership
def get_topset_edit_form(person_id, workout_id, topset_id):
exercises = db.get_all_exercises()
topset = db.get_topset(topset_id)
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercises=exercises, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'), is_edit=True)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
@login_required
@validate_workout
@require_ownership
def create_topset(person_id, workout_id):
exercise_id = request.form.get("exercise_id")
repetitions = request.form.get("repetitions")
@@ -193,7 +207,9 @@ def create_topset(person_id, workout_id):
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=new_topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"}
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
@login_required
@validate_workout
@require_ownership
def update_topset(person_id, workout_id, topset_id):
exercise_id = request.form.get("exercise_id")
repetitions = request.form.get("repetitions")
@@ -203,7 +219,9 @@ def update_topset(person_id, workout_id, topset_id):
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
@login_required
@validate_topset
@require_ownership
def delete_topset(person_id, workout_id, topset_id):
db.delete_topset(topset_id)
return ""

View File

@@ -239,7 +239,36 @@
<div class="absolute top-16 right-4 m-4">
<div class="bg-white rounded shadow-md w-64" id="notifications-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
role="alert" _="init wait 5s then remove me end on click remove me">
<div
class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8
{% if category == 'success' %}text-green-500 bg-green-100{% elif category == 'danger' %}text-red-500 bg-red-100{% else %}text-blue-500 bg-blue-100{% endif %} rounded-lg">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
viewBox="0 0 20 20">
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z">
</path>
</svg>
</div>
<div class="ml-3 text-sm font-normal">{{ message }}</div>
<button type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8"
_="on click remove the closest .flex">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"></path>
</svg>
</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
</div>
<template id="notification-template">