From 86c40aee91be9d96e85c9d937426057a348388b9 Mon Sep 17 00:00:00 2001 From: justanamelessguy Date: Sun, 7 Jun 2026 23:08:29 +0200 Subject: [PATCH 1/3] Sanitize user text input --- backend/app.py | 154 ++++++++++++++++++++++++++++++++++++--------- journey-edit.html | 4 +- js/auth.js | 16 ++++- js/blog-list.js | 11 ++-- js/blog-post.js | 13 ++-- js/journey-edit.js | 13 ++-- js/map-page.js | 15 +++-- login.html | 8 +-- map-page.html | 4 ++ 9 files changed, 179 insertions(+), 59 deletions(-) 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 @@
- +
- +
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 @@
Date: Sun, 7 Jun 2026 23:24:39 +0200 Subject: [PATCH 2/3] Add OpenAPI documentation --- README.md | 19 + backend/openapi.yaml | 942 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 961 insertions(+) create mode 100644 backend/openapi.yaml diff --git a/README.md b/README.md index 778455c..f8041b9 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,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/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: [] From b7675da67a34cd40a0bf75a3f5fc6f2a1eb6f857 Mon Sep 17 00:00:00 2001 From: Josh-Dev-Quest Date: Mon, 8 Jun 2026 06:43:49 +0200 Subject: [PATCH 3/3] Add c4-diagramm --- assets/images/c4_diagramm.puml | 27 +++++++++++++++++++++ assets/images/travel-journey-container.png | Bin 0 -> 37739 bytes 2 files changed, 27 insertions(+) create mode 100644 assets/images/c4_diagramm.puml create mode 100644 assets/images/travel-journey-container.png 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 0000000000000000000000000000000000000000..52ba1735073bdc478d6324af42273af852f600c3 GIT binary patch literal 37739 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 Vh`Ilp;XMfOGSEfp6l>pq{x9eZVlw~$ literal 0 HcmV?d00001