diff --git a/README.md b/README.md index d063dbd..6b1bdea 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,25 @@ http://127.0.0.1:8000/login.html Keep both terminals open while using the app. +### API Documentation + +The backend API is documented with an OpenAPI/Swagger specification: + +```text +backend/openapi.yaml +``` + +To view the interactive documentation, open [Swagger Editor](https://editor.swagger.io/) +and import `backend/openapi.yaml`. To make requests, start the backend and use +`http://127.0.0.1:5000`. Browser cookie policies may make authenticated requests +from the hosted Swagger Editor unreliable, so the app or an API client may be +easier for testing authenticated endpoints. + +The API uses a Flask session cookie for authentication. Register or log in first, +then include the returned `session` cookie in authenticated requests. The +specification documents all authentication, user, journey, comment, image upload, +uploaded-file, and health endpoints. + ### Daily Development Workflow 1. Start the backend in `backend/`. diff --git a/assets/images/c4_diagramm.puml b/assets/images/c4_diagramm.puml new file mode 100644 index 0000000..efdc5f7 --- /dev/null +++ b/assets/images/c4_diagramm.puml @@ -0,0 +1,27 @@ +@startuml travel-journey-container +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml +LAYOUT_LEFT_RIGHT() + +LAYOUT_WITH_LEGEND() + +title Container diagram — Travel Journey Blog & Map Application + +Person(traveler, "Traveler", "Writes blog posts, edits journeys, views maps") + +System_Boundary(app_sys, "Travel Journey Platform") { + + Container(web_app, "Frontend SPA", "HTML, CSS, JavaScript", "Static web pages served by browser.\nKey modules: auth, blog, journey-edit, map, markdown.") + + Container(backend_api, "Backend API", "Python (Flask/FastAPI)", "app.py — serves REST endpoints\nfor users, journeys, uploads.\nReads/writes local JSON & file storage.") + + ContainerDb(json_data, "JSON Data Store", "File System", "journeys.json, users.json\n— structured travel & user data.") + + ContainerDb(uploads, "Uploads Storage", "File System", "backend/uploads/\n— user-uploaded images & videos.") +} + +Rel(traveler, web_app, "Views pages, interacts via browser", "HTTPS") +Rel(web_app, backend_api, "REST API calls (JSON)", "HTTPS") +Rel(backend_api, json_data, "Reads/Writes", "File I/O") +Rel(backend_api, uploads, "Stores/Retrieves", "File I/O") + +@enduml \ No newline at end of file diff --git a/assets/images/travel-journey-container.png b/assets/images/travel-journey-container.png new file mode 100644 index 0000000..52ba173 Binary files /dev/null and b/assets/images/travel-journey-container.png differ 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/backend/openapi.yaml b/backend/openapi.yaml new file mode 100644 index 0000000..e10014b --- /dev/null +++ b/backend/openapi.yaml @@ -0,0 +1,942 @@ +openapi: 3.0.3 +info: + title: Journey Mapper API + version: 1.0.0 + description: | + Backend API for the Journey Mapper application. + + Authentication uses a Flask session cookie. After registering or logging in, + clients must send the returned `session` cookie with authenticated requests. + Browser requests from the frontend must use credentials, for example + `fetch(url, { credentials: "include" })`. + + Journey and marker descriptions support Markdown. Raw HTML is stored as text + and escaped by the frontend when rendered. +servers: + - url: http://127.0.0.1:5000 + description: Local development backend + +tags: + - name: Authentication + - name: Users + - name: Journeys + - name: Comments + - name: Uploads + - name: System + +paths: + /api/register: + post: + tags: [Authentication] + summary: Register a user + description: Creates a user, starts a session, and returns the new public user data. + operationId: registerUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuthRequest" + example: + username: traveller + password: secret123 + responses: + "201": + description: Registration successful + headers: + Set-Cookie: + description: Flask session cookie used for authenticated requests. + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + example: + id: 1 + username: traveller + message: Registration successful + "400": + $ref: "#/components/responses/ValidationError" + "409": + description: Username is already taken + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Username already taken + + /api/login: + post: + tags: [Authentication] + summary: Log in + description: Validates the credentials and starts a session. + operationId: loginUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuthRequest" + example: + username: traveller + password: secret123 + responses: + "200": + description: Login successful + headers: + Set-Cookie: + description: Flask session cookie used for authenticated requests. + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + example: + id: 1 + username: traveller + message: Login successful + "400": + $ref: "#/components/responses/ValidationError" + "401": + description: Invalid username or password + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Invalid username or password + + /api/logout: + post: + tags: [Authentication] + summary: Log out + description: Removes the user ID from the current session. This operation also succeeds if no session exists. + operationId: logoutUser + security: [] + responses: + "200": + description: Logged out + content: + application/json: + schema: + $ref: "#/components/schemas/Message" + example: + message: Logged out + + /api/me: + get: + tags: [Authentication] + summary: Get the current user + operationId: getCurrentUser + responses: + "200": + description: Current public user data + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/NotLoggedIn" + + /api/users: + get: + tags: [Users] + summary: List other users + description: Returns every public user except the currently logged-in user. + operationId: listUsers + responses: + "200": + description: Public users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/AuthenticationRequired" + + /api/journeys: + get: + tags: [Journeys] + summary: List visible journeys + description: | + Returns journeys owned by the current user, public journeys, and journeys + shared with the current user. Each returned journey contains a `can_edit` + flag calculated for the current user. + operationId: listJourneys + responses: + "200": + description: Visible journeys + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/JourneyListItem" + "401": + $ref: "#/components/responses/AuthenticationRequired" + post: + tags: [Journeys] + summary: Create a journey + description: | + Creates a journey owned by the current user. Supplied comments and + server-managed fields such as IDs and timestamps are ignored. + operationId: createJourney + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/JourneyCreate" + responses: + "201": + description: Journey created + content: + application/json: + schema: + $ref: "#/components/schemas/Journey" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/AuthenticationRequired" + + /api/journeys/{journeyId}: + parameters: + - $ref: "#/components/parameters/JourneyId" + get: + tags: [Journeys] + summary: Get a journey + operationId: getJourney + responses: + "200": + description: Journey data + content: + application/json: + schema: + $ref: "#/components/schemas/Journey" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: The current user cannot view this journey + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Access denied + "404": + $ref: "#/components/responses/JourneyNotFound" + put: + tags: [Journeys] + summary: Update a journey + description: | + The owner and users with shared edit access may update the title, + description, and markers. Only the owner may update visibility or sharing. + Omitted fields remain unchanged. + operationId: updateJourney + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/JourneyUpdate" + responses: + "200": + description: Journey updated + content: + application/json: + schema: + $ref: "#/components/schemas/Journey" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: The current user cannot edit the journey or its sharing settings + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + cannotEdit: + value: + error: Not authorized to edit this journey + sharingOwnerOnly: + value: + error: Only the owner can update sharing settings + "404": + $ref: "#/components/responses/JourneyNotFound" + delete: + tags: [Journeys] + summary: Delete a journey + description: Only the journey owner may delete it. + operationId: deleteJourney + responses: + "200": + description: Journey deleted + content: + application/json: + schema: + type: object + required: [message, journey] + properties: + message: + type: string + journey: + $ref: "#/components/schemas/Journey" + example: + message: Journey deleted successfully + journey: + id: 1 + owner_id: 1 + title: Switzerland + description: My summer journey + markers: [] + created_at: "2026-06-07T18:30:00" + visibility: private + shared_read: [] + shared_edit: [] + comments: [] + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: Only the journey owner may delete it + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Only the owner can delete this journey + "404": + $ref: "#/components/responses/JourneyNotFound" + + /api/journeys/{journeyId}/comments: + parameters: + - $ref: "#/components/parameters/JourneyId" + get: + tags: [Comments] + summary: List journey comments + description: The current user must be able to view the journey. + operationId: listJourneyComments + responses: + "200": + description: Journey comments + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Comment" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: The current user cannot view the journey + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Access denied + "404": + $ref: "#/components/responses/JourneyNotFound" + post: + tags: [Comments] + summary: Add a journey comment + description: The current user must be able to view the journey. + operationId: addJourneyComment + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentCreate" + example: + text: This looks like a wonderful trip. + responses: + "201": + description: Comment created + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: The current user cannot view the journey + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Access denied + "404": + $ref: "#/components/responses/JourneyNotFound" + + /api/comments/{commentId}: + parameters: + - $ref: "#/components/parameters/CommentId" + delete: + tags: [Comments] + summary: Delete a comment + description: A comment may be deleted by its author or the journey owner. + operationId: deleteComment + responses: + "200": + description: Comment deleted + content: + application/json: + schema: + $ref: "#/components/schemas/Message" + example: + message: Comment deleted + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + description: The current user cannot delete the comment + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Not authorized + "404": + description: Comment not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Comment not found + + /api/uploads/images: + post: + tags: [Uploads] + summary: Upload marker images + description: | + Uploads one or more images using repeated `images` form fields. Accepted + filename extensions are `.png`, `.jpg`, `.jpeg`, `.gif`, and `.webp`. + The returned image objects can be included in a marker's `images` array. + operationId: uploadImages + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [images] + properties: + images: + type: array + items: + type: string + format: binary + responses: + "201": + description: Images uploaded + content: + application/json: + schema: + type: object + required: [images] + properties: + images: + type: array + items: + $ref: "#/components/schemas/Image" + "400": + description: No valid images were provided or an extension is unsupported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + noImages: + value: + error: No images provided + unsupported: + value: + error: "Unsupported image type: notes.txt" + "401": + $ref: "#/components/responses/AuthenticationRequired" + + /uploads/{filename}: + parameters: + - name: filename + in: path + required: true + description: Server-generated image filename returned by the upload endpoint. + schema: + type: string + example: 79ce8a0727d846f4bffb1fbf94365191.jpg + get: + tags: [Uploads] + summary: Get an uploaded image + description: Uploaded images are publicly accessible. + operationId: getUploadedImage + security: [] + responses: + "200": + description: Image file + content: + image/png: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/gif: + schema: + type: string + format: binary + image/webp: + schema: + type: string + format: binary + "404": + description: Image not found + + /api/journeys/health: + get: + tags: [System] + summary: Check API health + operationId: getHealth + security: [] + responses: + "200": + description: Backend is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + + /: + get: + tags: [System] + summary: Get the API landing page + description: Returns a small HTML page confirming that the backend is running. + operationId: getApiLandingPage + security: [] + responses: + "200": + description: API landing page + content: + text/html: + schema: + type: string + +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: session + description: Flask session cookie returned after registration or login. + + parameters: + JourneyId: + name: journeyId + in: path + required: true + description: Journey ID + schema: + type: integer + minimum: 1 + CommentId: + name: commentId + in: path + required: true + description: Millisecond timestamp used as the comment ID + schema: + type: integer + format: int64 + minimum: 1 + + responses: + ValidationError: + description: Request validation failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + missingTitle: + value: + error: Journey title is required + invalidCoordinates: + value: + error: Marker latitude must be between -90 and 90 + AuthenticationRequired: + description: Authentication is required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Authentication required + NotLoggedIn: + description: There is no valid current user session + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + noSession: + value: + error: Not logged in + missingUser: + value: + error: User not found + JourneyNotFound: + description: Journey not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: Journey not found + + schemas: + Error: + type: object + required: [error] + additionalProperties: false + properties: + error: + type: string + + Message: + type: object + required: [message] + additionalProperties: false + properties: + message: + type: string + + User: + type: object + required: [id, username] + additionalProperties: false + properties: + id: + type: integer + minimum: 1 + username: + type: string + maxLength: 50 + + AuthRequest: + type: object + required: [username, password] + properties: + username: + type: string + minLength: 1 + maxLength: 50 + password: + type: string + format: password + minLength: 4 + maxLength: 200 + + AuthResponse: + type: object + required: [id, username, message] + additionalProperties: false + properties: + id: + type: integer + minimum: 1 + username: + type: string + maxLength: 50 + message: + type: string + + Image: + type: object + required: [filename, originalName, url] + additionalProperties: false + properties: + filename: + type: string + maxLength: 255 + description: Server-generated filename. + originalName: + type: string + maxLength: 255 + description: Sanitized original filename. + url: + type: string + maxLength: 2048 + description: Relative or absolute image URL. + example: /uploads/79ce8a0727d846f4bffb1fbf94365191.jpg + + MarkerImageInput: + description: A marker image may be supplied as a URL string or a full image object. + oneOf: + - type: string + minLength: 1 + maxLength: 2048 + - $ref: "#/components/schemas/Image" + + Marker: + type: object + required: [lat, lng, title, date, description, images] + additionalProperties: false + properties: + lat: + type: number + format: double + minimum: -90 + maximum: 90 + lng: + type: number + format: double + minimum: -180 + maximum: 180 + title: + type: string + maxLength: 200 + date: + type: string + maxLength: 20 + description: Date text, normally formatted as `YYYY-MM-DD`. + example: "2026-06-07" + description: + type: string + maxLength: 10000 + description: Markdown-supported marker description. + images: + type: array + maxItems: 20 + items: + $ref: "#/components/schemas/MarkerImageInput" + + MarkerInput: + type: object + required: [lat, lng] + properties: + lat: + type: number + format: double + minimum: -90 + maximum: 90 + lng: + type: number + format: double + minimum: -180 + maximum: 180 + title: + type: string + maxLength: 200 + default: "" + date: + type: string + maxLength: 20 + default: "" + example: "2026-06-07" + description: + type: string + maxLength: 10000 + default: "" + description: Markdown-supported marker description. + images: + type: array + maxItems: 20 + default: [] + items: + $ref: "#/components/schemas/MarkerImageInput" + + Comment: + type: object + required: [id, author_id, author_name, text, created_at] + additionalProperties: false + properties: + id: + type: integer + format: int64 + minimum: 1 + author_id: + type: integer + minimum: 1 + author_name: + type: string + maxLength: 50 + text: + type: string + minLength: 1 + maxLength: 2000 + created_at: + type: string + format: date-time + + CommentCreate: + type: object + required: [text] + properties: + text: + type: string + minLength: 1 + maxLength: 2000 + + Visibility: + type: string + enum: [private, public, shared] + default: private + + Journey: + type: object + required: + - id + - owner_id + - title + - description + - markers + - created_at + - visibility + - shared_read + - shared_edit + - comments + properties: + id: + type: integer + minimum: 1 + owner_id: + type: integer + minimum: 1 + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 20000 + description: Markdown-supported journey description. + markers: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/Marker" + created_at: + type: string + format: date-time + visibility: + $ref: "#/components/schemas/Visibility" + shared_read: + type: array + uniqueItems: true + items: + type: integer + minimum: 1 + shared_edit: + type: array + uniqueItems: true + items: + type: integer + minimum: 1 + comments: + type: array + items: + $ref: "#/components/schemas/Comment" + + JourneyListItem: + allOf: + - $ref: "#/components/schemas/Journey" + - type: object + required: [can_edit] + properties: + can_edit: + type: boolean + description: Whether the current user may edit this journey. + + JourneyCreate: + type: object + required: [title] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 20000 + default: "" + description: Markdown-supported journey description. + markers: + type: array + maxItems: 500 + default: [] + items: + $ref: "#/components/schemas/MarkerInput" + visibility: + $ref: "#/components/schemas/Visibility" + shared_read: + type: array + default: [] + description: Invalid, duplicate, and unknown user IDs are silently removed. + items: + type: integer + minimum: 1 + shared_edit: + type: array + default: [] + description: Invalid, duplicate, and unknown user IDs are silently removed. + items: + type: integer + minimum: 1 + + JourneyUpdate: + type: object + properties: + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 20000 + description: Markdown-supported journey description. + markers: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/MarkerInput" + visibility: + $ref: "#/components/schemas/Visibility" + shared_read: + type: array + description: Owner-only field. Invalid, duplicate, and unknown user IDs are silently removed. + items: + type: integer + minimum: 1 + shared_edit: + type: array + description: Owner-only field. Invalid, duplicate, and unknown user IDs are silently removed. + items: + type: integer + minimum: 1 + + Health: + type: object + required: [status, timestamp] + additionalProperties: false + properties: + status: + type: string + enum: [healthy] + timestamp: + type: string + format: date-time + +security: + - cookieAuth: [] 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 @@
- +
- +
diff --git a/js/auth.js b/js/auth.js index df2c6c6..5507441 100644 --- a/js/auth.js +++ b/js/auth.js @@ -104,14 +104,28 @@ async function logout() { function escapeHtml(str) { if (!str) return ""; - return str.replace(/[&<>]/g, function (m) { + return String(str).replace(/[&<>"']/g, function (m) { if (m === "&") return "&"; if (m === "<") return "<"; if (m === ">") return ">"; + if (m === '"') return """; + if (m === "'") return "'"; return m; }); } +function escapeAttribute(str) { + return escapeHtml(str); +} + +function sanitizeDisplayUrl(url) { + const value = String(url || "").trim().replace(/[\u0000-\u001f\u007f\s]+/g, ""); + if (/^(https?:\/\/|\/uploads\/|uploads\/)/i.test(value)) { + return value; + } + return ""; +} + function showToast(msg, isError = false) { const toast = document.getElementById("toast"); if (!toast) return; diff --git a/js/blog-list.js b/js/blog-list.js index 1e1d6de..f44e511 100644 --- a/js/blog-list.js +++ b/js/blog-list.js @@ -25,11 +25,13 @@ function renderJourneys(journeys) { return; } - container.innerHTML = journeys.map(journey => ` + container.innerHTML = journeys.map(journey => { + const imageUrl = sanitizeDisplayUrl(journey.image || ''); + return `
- ${journey.image ? `${journey.title}` : '
'} + ${imageUrl ? `${escapeAttribute(journey.title)}` : '
'}
-

${escapeHtml(journey.title)}

+

${escapeHtml(journey.title)}

${new Date(journey.created_at).toLocaleDateString()} ${journey.markers ? ` ${journey.markers.length} chapters` : ''} @@ -38,7 +40,8 @@ function renderJourneys(journeys) {
${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}
- `).join(''); + `; + }).join(''); } function getJourneyBadges(journey) { diff --git a/js/blog-post.js b/js/blog-post.js index 0bc599a..9b2c8d4 100644 --- a/js/blog-post.js +++ b/js/blog-post.js @@ -36,7 +36,7 @@ function renderJourney() { marker.images = getMarkerImages(marker); const images = getMarkerImagesHtml(marker.images, idx, canEdit); chaptersHtml += ` -
+

${escapeHtml(title)}

${date} @@ -60,7 +60,7 @@ function renderJourney() { ${currentJourney.visibility === 'public' ? 'Public' : ''} ${currentJourney.visibility === 'shared' && !isOwner ? `${canEdit ? 'Shared edit' : 'Shared'}` : ''}
- ${currentJourney.image ? `${currentJourney.title}` : ''} + ${currentJourney.image ? `${escapeAttribute(currentJourney.title)}` : ''}
${renderMarkdown(currentJourney.description)}
${chaptersHtml} ${canEdit || isOwner ? ` @@ -82,7 +82,7 @@ function renderJourney() { function getUploadUrl(path) { if (!path) return ''; - if (path.startsWith('http')) return path; + if (path.startsWith('http')) return sanitizeDisplayUrl(path); return API_BASE.replace('/api', path); } @@ -115,10 +115,11 @@ function getMarkerImagesHtml(images, markerIndex, canEdit) { const imageHtml = images.map((image, imageIndex) => { const url = typeof image === 'string' ? image : image.url; const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image'; - if (!url) return ''; + const displayUrl = sanitizeDisplayUrl(getUploadUrl(url)); + if (!displayUrl) return ''; return `
- ${escapeAttribute(alt)} + ${escapeAttribute(alt)} ${canEdit ? `
`; diff --git a/js/journey-edit.js b/js/journey-edit.js index 736ff7d..512fbf1 100644 --- a/js/journey-edit.js +++ b/js/journey-edit.js @@ -21,7 +21,7 @@ function showToast(message, isError = false) { function getUploadUrl(path) { if (!path) return ''; - if (path.startsWith('http')) return path; + if (path.startsWith('http')) return sanitizeDisplayUrl(path); return API_BASE.replace('/api', path); } @@ -130,10 +130,11 @@ function renderMarkerImages(marker, idx) { ${images.map((image, imageIdx) => { const url = typeof image === 'string' ? image : image.url; const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image'; - if (!url) return ''; + const displayUrl = sanitizeDisplayUrl(getUploadUrl(url)); + if (!displayUrl) return ''; return `
- ${escapeAttribute(alt)} + ${escapeAttribute(alt)} @@ -183,15 +184,15 @@ function renderMarkers() {
- +
- +
- +
${renderMarkdownPreview(marker.description)}
diff --git a/js/map-page.js b/js/map-page.js index e240c58..a833e4e 100644 --- a/js/map-page.js +++ b/js/map-page.js @@ -59,7 +59,7 @@ }).addTo(map); marker.bindPopup( - `${content.title || "Untitled"}`, + `${escapeHtml(content.title || "Untitled")}`, ); marker.on("click", () => openMarkerEditor(marker)); marker.on("dragend", () => { @@ -90,7 +90,7 @@ div.dataset.lat = latlng.lat; div.dataset.lng = latlng.lng; div.innerHTML = ` -
${content.title || "Untitled"}
+
${escapeHtml(content.title || "Untitled")}
${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}
`; div.addEventListener("click", () => { @@ -110,7 +110,7 @@ function getUploadUrl(path) { if (!path) return ""; - if (path.startsWith("http")) return path; + if (path.startsWith("http")) return sanitizeDisplayUrl(path); return API_BASE.replace("/api", path); } @@ -121,8 +121,13 @@ images.forEach((image, index) => { const item = document.createElement("div"); item.className = "image-preview"; + const imagePath = typeof image === "string" ? image : image.url; + const imageName = typeof image === "string" ? "Marker image" : image.originalName; + const url = sanitizeDisplayUrl(getUploadUrl(imagePath)); + const alt = escapeAttribute(imageName || "Marker image"); + if (!url) return; item.innerHTML = ` - ${image.originalName || + ${alt} @@ -208,7 +213,7 @@ // Update marker's tooltip title and popup activeMarker.options.title = title; - activeMarker.setPopupContent(`${title}`); + activeMarker.setPopupContent(`${escapeHtml(title)}`); // Store content for saving activeMarker._content = { title, date, description, images }; diff --git a/login.html b/login.html index d31cef5..43dc2e3 100644 --- a/login.html +++ b/login.html @@ -135,22 +135,22 @@
- +
- +
- +
- +
diff --git a/map-page.html b/map-page.html index c4caf95..479359a 100644 --- a/map-page.html +++ b/map-page.html @@ -641,6 +641,7 @@ @@ -660,6 +661,7 @@ >
@@ -842,6 +844,7 @@
@@ -853,6 +856,7 @@