diff --git a/backend/app.py b/backend/app.py index d4d0d60..8d0adcb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,6 +2,8 @@ import os import time import json import uuid +import math +import re from datetime import datetime from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename @@ -19,10 +21,110 @@ USERS_FILE = os.path.join(DATA_DIR, "users.json") JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json') UPLOAD_DIR = os.path.join(BASE_DIR, "uploads") ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +VALID_VISIBILITIES = {"private", "public", "shared"} +MAX_MARKERS_PER_JOURNEY = 500 os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True) +class ValidationError(ValueError): + pass + + +@app.errorhandler(ValidationError) +def handle_validation_error(error): + return jsonify({"error": str(error)}), 400 + + +def get_json_object(): + data = request.get_json(silent=True) + if not isinstance(data, dict): + raise ValidationError("Request body must be a JSON object") + return data + + +def clean_text(value, field, max_length, required=False, strip=True): + if value is None: + value = "" + if not isinstance(value, str): + raise ValidationError(f"{field} must be text") + + # Keep newlines/tabs for Markdown, but remove non-printing control characters. + value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", value) + if strip: + value = value.strip() + if required and not value: + raise ValidationError(f"{field} is required") + if len(value) > max_length: + raise ValidationError(f"{field} must be at most {max_length} characters") + return value + + +def clean_number(value, field, minimum, maximum): + if isinstance(value, bool): + raise ValidationError(f"{field} must be a number") + try: + value = float(value) + except (TypeError, ValueError): + raise ValidationError(f"{field} must be a number") + if not math.isfinite(value) or not minimum <= value <= maximum: + raise ValidationError(f"{field} must be between {minimum} and {maximum}") + return value + + +def clean_visibility(value): + if value not in VALID_VISIBILITIES: + raise ValidationError("Invalid journey visibility") + return value + + +def clean_images(images): + if images is None: + return [] + if not isinstance(images, list): + raise ValidationError("Marker images must be a list") + if len(images) > 20: + raise ValidationError("A marker can contain at most 20 images") + + cleaned = [] + for image in images: + if isinstance(image, str): + cleaned.append(clean_text(image, "Image URL", 2048, required=True)) + continue + if not isinstance(image, dict): + raise ValidationError("Invalid marker image") + url = clean_text(image.get("url"), "Image URL", 2048, required=True) + cleaned.append({ + "filename": clean_text(image.get("filename"), "Image filename", 255), + "originalName": clean_text(image.get("originalName"), "Original image name", 255), + "url": url, + }) + return cleaned + + +def clean_markers(markers): + if markers is None: + return [] + if not isinstance(markers, list): + raise ValidationError("Markers must be a list") + if len(markers) > MAX_MARKERS_PER_JOURNEY: + raise ValidationError(f"A journey can contain at most {MAX_MARKERS_PER_JOURNEY} markers") + + cleaned = [] + for marker in markers: + if not isinstance(marker, dict): + raise ValidationError("Invalid marker") + cleaned.append({ + "lat": clean_number(marker.get("lat"), "Marker latitude", -90, 90), + "lng": clean_number(marker.get("lng"), "Marker longitude", -180, 180), + "title": clean_text(marker.get("title"), "Marker title", 200), + "date": clean_text(marker.get("date"), "Marker date", 20), + "description": clean_text(marker.get("description"), "Marker description", 10000), + "images": clean_images(marker.get("images", [])), + }) + return cleaned + + # ==================== User helpers ==================== def require_login(): @@ -97,12 +199,11 @@ def allowed_image_file(filename): # ==================== Authentication endpoints ==================== @app.route("/api/register", methods=["POST"]) def register(): - data = request.get_json() - username = data.get("username") - password = data.get("password") - - if not username or not password: - return jsonify({"error": "Username and password required"}), 400 + data = get_json_object() + username = clean_text(data.get("username"), "Username", 50, required=True) + password = clean_text(data.get("password"), "Password", 200, required=True, strip=False) + if len(password) < 4: + raise ValidationError("Password must be at least 4 characters") # Check if username already exists if get_user_by_username(username): @@ -131,9 +232,9 @@ def register(): @app.route("/api/login", methods=["POST"]) def login(): - data = request.get_json() - username = data.get("username") - password = data.get("password") + data = get_json_object() + username = clean_text(data.get("username"), "Username", 50, required=True) + password = clean_text(data.get("password"), "Password", 200, required=True, strip=False) user = get_user_by_username(username) if not user or not check_password_hash(user["password_hash"], password): @@ -242,13 +343,9 @@ def get_journeys(): def create_journey(): if not require_login(): return jsonify({'error': 'Authentication required'}), 401 - data = request.get_json() - if not data: - return jsonify({'error': 'No data provided'}), 400 + data = get_json_object() - title = data.get('title') - if not title: - return jsonify({'error': 'Journey title is required'}), 400 + title = clean_text(data.get('title'), "Journey title", 200, required=True) user_id = get_current_user_id() journeys = load_all_journeys() @@ -258,13 +355,13 @@ def create_journey(): 'id': new_id, 'owner_id': user_id, 'title': title, - 'description': data.get('description', ''), - 'markers': data.get('markers', []), + 'description': clean_text(data.get('description'), "Journey description", 20000), + 'markers': clean_markers(data.get('markers', [])), 'created_at': datetime.now().isoformat(), - 'visibility': data.get('visibility', 'private'), + 'visibility': clean_visibility(data.get('visibility', 'private')), 'shared_read': normalize_user_ids(data.get('shared_read', [])), 'shared_edit': normalize_user_ids(data.get('shared_edit', [])), - 'comments': data.get('comments', []) + 'comments': [] } journeys.append(new_journey) @@ -295,26 +392,23 @@ def update_journey(journey_id): if not user_can_edit_journey(journey, user_id): return jsonify({'error': 'Not authorized to edit this journey'}), 403 - data = request.get_json() + data = get_json_object() if 'title' in data: - journey['title'] = data['title'] + journey['title'] = clean_text(data['title'], "Journey title", 200, required=True) if 'description' in data: - journey['description'] = data['description'] + journey['description'] = clean_text(data['description'], "Journey description", 20000) if 'markers' in data: - journey['markers'] = data['markers'] + journey['markers'] = clean_markers(data['markers']) sharing_fields = {'visibility', 'shared_read', 'shared_edit'} if sharing_fields.intersection(data.keys()): if journey['owner_id'] != user_id: return jsonify({'error': 'Only the owner can update sharing settings'}), 403 if 'visibility' in data: - journey['visibility'] = data['visibility'] + journey['visibility'] = clean_visibility(data['visibility']) if 'shared_read' in data: journey['shared_read'] = normalize_user_ids(data['shared_read']) if 'shared_edit' in data: journey['shared_edit'] = normalize_user_ids(data['shared_edit']) - if 'comments' in data: - journey['comments'] = data ['comments'] - save_all_journeys(journeys) return jsonify(journey) @@ -362,10 +456,8 @@ def add_journey_comment(journey_id): user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Authentication required'}), 401 - data = request.get_json() - text = data.get('text') - if not text: - return jsonify({'error': 'Comment text required'}), 400 + data = get_json_object() + text = clean_text(data.get('text'), "Comment", 2000, required=True) journey = get_journey_by_id(journey_id) if not journey: diff --git a/journey-edit.html b/journey-edit.html index 6d48f9d..3f1d505 100644 --- a/journey-edit.html +++ b/journey-edit.html @@ -335,11 +335,11 @@