Add authentication for update/delete endpoints
This commit is contained in:
26
app.py
26
app.py
@@ -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)
|
||||
|
||||
118
decorators.py
118
decorators.py
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user