Merge branch 'dev'
This commit is contained in:
commit
e7aeff7cdc
19
README.md
19
README.md
@ -108,6 +108,25 @@ http://127.0.0.1:8000/login.html
|
|||||||
|
|
||||||
Keep both terminals open while using the app.
|
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
|
### Daily Development Workflow
|
||||||
|
|
||||||
1. Start the backend in `backend/`.
|
1. Start the backend in `backend/`.
|
||||||
|
|||||||
27
assets/images/c4_diagramm.puml
Normal file
27
assets/images/c4_diagramm.puml
Normal file
@ -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
|
||||||
BIN
assets/images/travel-journey-container.png
Normal file
BIN
assets/images/travel-journey-container.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
154
backend/app.py
154
backend/app.py
@ -2,6 +2,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import math
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
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')
|
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
|
||||||
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
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(DATA_DIR, exist_ok=True)
|
||||||
os.makedirs(UPLOAD_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 ====================
|
# ==================== User helpers ====================
|
||||||
def require_login():
|
def require_login():
|
||||||
@ -97,12 +199,11 @@ def allowed_image_file(filename):
|
|||||||
# ==================== Authentication endpoints ====================
|
# ==================== Authentication endpoints ====================
|
||||||
@app.route("/api/register", methods=["POST"])
|
@app.route("/api/register", methods=["POST"])
|
||||||
def register():
|
def register():
|
||||||
data = request.get_json()
|
data = get_json_object()
|
||||||
username = data.get("username")
|
username = clean_text(data.get("username"), "Username", 50, required=True)
|
||||||
password = data.get("password")
|
password = clean_text(data.get("password"), "Password", 200, required=True, strip=False)
|
||||||
|
if len(password) < 4:
|
||||||
if not username or not password:
|
raise ValidationError("Password must be at least 4 characters")
|
||||||
return jsonify({"error": "Username and password required"}), 400
|
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
if get_user_by_username(username):
|
if get_user_by_username(username):
|
||||||
@ -131,9 +232,9 @@ def register():
|
|||||||
|
|
||||||
@app.route("/api/login", methods=["POST"])
|
@app.route("/api/login", methods=["POST"])
|
||||||
def login():
|
def login():
|
||||||
data = request.get_json()
|
data = get_json_object()
|
||||||
username = data.get("username")
|
username = clean_text(data.get("username"), "Username", 50, required=True)
|
||||||
password = data.get("password")
|
password = clean_text(data.get("password"), "Password", 200, required=True, strip=False)
|
||||||
|
|
||||||
user = get_user_by_username(username)
|
user = get_user_by_username(username)
|
||||||
if not user or not check_password_hash(user["password_hash"], password):
|
if not user or not check_password_hash(user["password_hash"], password):
|
||||||
@ -242,13 +343,9 @@ def get_journeys():
|
|||||||
def create_journey():
|
def create_journey():
|
||||||
if not require_login():
|
if not require_login():
|
||||||
return jsonify({'error': 'Authentication required'}), 401
|
return jsonify({'error': 'Authentication required'}), 401
|
||||||
data = request.get_json()
|
data = get_json_object()
|
||||||
if not data:
|
|
||||||
return jsonify({'error': 'No data provided'}), 400
|
|
||||||
|
|
||||||
title = data.get('title')
|
title = clean_text(data.get('title'), "Journey title", 200, required=True)
|
||||||
if not title:
|
|
||||||
return jsonify({'error': 'Journey title is required'}), 400
|
|
||||||
|
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
journeys = load_all_journeys()
|
journeys = load_all_journeys()
|
||||||
@ -258,13 +355,13 @@ def create_journey():
|
|||||||
'id': new_id,
|
'id': new_id,
|
||||||
'owner_id': user_id,
|
'owner_id': user_id,
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': data.get('description', ''),
|
'description': clean_text(data.get('description'), "Journey description", 20000),
|
||||||
'markers': data.get('markers', []),
|
'markers': clean_markers(data.get('markers', [])),
|
||||||
'created_at': datetime.now().isoformat(),
|
'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_read': normalize_user_ids(data.get('shared_read', [])),
|
||||||
'shared_edit': normalize_user_ids(data.get('shared_edit', [])),
|
'shared_edit': normalize_user_ids(data.get('shared_edit', [])),
|
||||||
'comments': data.get('comments', [])
|
'comments': []
|
||||||
}
|
}
|
||||||
|
|
||||||
journeys.append(new_journey)
|
journeys.append(new_journey)
|
||||||
@ -295,26 +392,23 @@ def update_journey(journey_id):
|
|||||||
if not user_can_edit_journey(journey, user_id):
|
if not user_can_edit_journey(journey, user_id):
|
||||||
return jsonify({'error': 'Not authorized to edit this journey'}), 403
|
return jsonify({'error': 'Not authorized to edit this journey'}), 403
|
||||||
|
|
||||||
data = request.get_json()
|
data = get_json_object()
|
||||||
if 'title' in data:
|
if 'title' in data:
|
||||||
journey['title'] = data['title']
|
journey['title'] = clean_text(data['title'], "Journey title", 200, required=True)
|
||||||
if 'description' in data:
|
if 'description' in data:
|
||||||
journey['description'] = data['description']
|
journey['description'] = clean_text(data['description'], "Journey description", 20000)
|
||||||
if 'markers' in data:
|
if 'markers' in data:
|
||||||
journey['markers'] = data['markers']
|
journey['markers'] = clean_markers(data['markers'])
|
||||||
sharing_fields = {'visibility', 'shared_read', 'shared_edit'}
|
sharing_fields = {'visibility', 'shared_read', 'shared_edit'}
|
||||||
if sharing_fields.intersection(data.keys()):
|
if sharing_fields.intersection(data.keys()):
|
||||||
if journey['owner_id'] != user_id:
|
if journey['owner_id'] != user_id:
|
||||||
return jsonify({'error': 'Only the owner can update sharing settings'}), 403
|
return jsonify({'error': 'Only the owner can update sharing settings'}), 403
|
||||||
if 'visibility' in data:
|
if 'visibility' in data:
|
||||||
journey['visibility'] = data['visibility']
|
journey['visibility'] = clean_visibility(data['visibility'])
|
||||||
if 'shared_read' in data:
|
if 'shared_read' in data:
|
||||||
journey['shared_read'] = normalize_user_ids(data['shared_read'])
|
journey['shared_read'] = normalize_user_ids(data['shared_read'])
|
||||||
if 'shared_edit' in data:
|
if 'shared_edit' in data:
|
||||||
journey['shared_edit'] = normalize_user_ids(data['shared_edit'])
|
journey['shared_edit'] = normalize_user_ids(data['shared_edit'])
|
||||||
if 'comments' in data:
|
|
||||||
journey['comments'] = data ['comments']
|
|
||||||
|
|
||||||
save_all_journeys(journeys)
|
save_all_journeys(journeys)
|
||||||
return jsonify(journey)
|
return jsonify(journey)
|
||||||
|
|
||||||
@ -362,10 +456,8 @@ def add_journey_comment(journey_id):
|
|||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify({'error': 'Authentication required'}), 401
|
return jsonify({'error': 'Authentication required'}), 401
|
||||||
data = request.get_json()
|
data = get_json_object()
|
||||||
text = data.get('text')
|
text = clean_text(data.get('text'), "Comment", 2000, required=True)
|
||||||
if not text:
|
|
||||||
return jsonify({'error': 'Comment text required'}), 400
|
|
||||||
|
|
||||||
journey = get_journey_by_id(journey_id)
|
journey = get_journey_by_id(journey_id)
|
||||||
if not journey:
|
if not journey:
|
||||||
|
|||||||
942
backend/openapi.yaml
Normal file
942
backend/openapi.yaml
Normal file
@ -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: []
|
||||||
@ -335,11 +335,11 @@
|
|||||||
<form id="journey-form">
|
<form id="journey-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="journey-title">Title</label>
|
<label for="journey-title">Title</label>
|
||||||
<input type="text" id="journey-title" required>
|
<input type="text" id="journey-title" maxlength="200" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="journey-description">Description</label>
|
<label for="journey-description">Description</label>
|
||||||
<textarea id="journey-description" rows="4"></textarea>
|
<textarea id="journey-description" rows="4" maxlength="20000"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="journey-visibility">Visibility</label>
|
<label for="journey-visibility">Visibility</label>
|
||||||
|
|||||||
16
js/auth.js
16
js/auth.js
@ -104,14 +104,28 @@ async function logout() {
|
|||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return "";
|
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 ">";
|
if (m === ">") return ">";
|
||||||
|
if (m === '"') return """;
|
||||||
|
if (m === "'") return "'";
|
||||||
return m;
|
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) {
|
function showToast(msg, isError = false) {
|
||||||
const toast = document.getElementById("toast");
|
const toast = document.getElementById("toast");
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
|
|||||||
@ -25,11 +25,13 @@ function renderJourneys(journeys) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = journeys.map(journey => `
|
container.innerHTML = journeys.map(journey => {
|
||||||
|
const imageUrl = sanitizeDisplayUrl(journey.image || '');
|
||||||
|
return `
|
||||||
<article class="post-card">
|
<article class="post-card">
|
||||||
${journey.image ? `<img class="post-card-image" src="${journey.image}" alt="${journey.title}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'}
|
${imageUrl ? `<img class="post-card-image" src="${escapeAttribute(imageUrl)}" alt="${escapeAttribute(journey.title)}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'}
|
||||||
<div class="post-card-content">
|
<div class="post-card-content">
|
||||||
<h2 class="post-card-title"><a href="blog-post.html?id=${journey.id}">${escapeHtml(journey.title)}</a></h2>
|
<h2 class="post-card-title"><a href="blog-post.html?id=${encodeURIComponent(journey.id)}">${escapeHtml(journey.title)}</a></h2>
|
||||||
<div class="post-card-meta">
|
<div class="post-card-meta">
|
||||||
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
|
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
|
||||||
${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
|
${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
|
||||||
@ -38,7 +40,8 @@ function renderJourneys(journeys) {
|
|||||||
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
|
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getJourneyBadges(journey) {
|
function getJourneyBadges(journey) {
|
||||||
|
|||||||
@ -36,7 +36,7 @@ function renderJourney() {
|
|||||||
marker.images = getMarkerImages(marker);
|
marker.images = getMarkerImages(marker);
|
||||||
const images = getMarkerImagesHtml(marker.images, idx, canEdit);
|
const images = getMarkerImagesHtml(marker.images, idx, canEdit);
|
||||||
chaptersHtml += `
|
chaptersHtml += `
|
||||||
<div class="chapter" data-marker-id="${marker.id || idx}">
|
<div class="chapter" data-marker-id="${idx}">
|
||||||
<div class="chapter-header">
|
<div class="chapter-header">
|
||||||
<h3>${escapeHtml(title)}</h3>
|
<h3>${escapeHtml(title)}</h3>
|
||||||
${date}
|
${date}
|
||||||
@ -60,7 +60,7 @@ function renderJourney() {
|
|||||||
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
|
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
|
||||||
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
|
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
|
${currentJourney.image ? `<img class="post-image" src="${escapeAttribute(sanitizeDisplayUrl(getUploadUrl(currentJourney.image)))}" alt="${escapeAttribute(currentJourney.title)}">` : ''}
|
||||||
<div class="post-description markdown-content">${renderMarkdown(currentJourney.description)}</div>
|
<div class="post-description markdown-content">${renderMarkdown(currentJourney.description)}</div>
|
||||||
${chaptersHtml}
|
${chaptersHtml}
|
||||||
${canEdit || isOwner ? `
|
${canEdit || isOwner ? `
|
||||||
@ -82,7 +82,7 @@ function renderJourney() {
|
|||||||
|
|
||||||
function getUploadUrl(path) {
|
function getUploadUrl(path) {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
if (path.startsWith('http')) return path;
|
if (path.startsWith('http')) return sanitizeDisplayUrl(path);
|
||||||
return API_BASE.replace('/api', path);
|
return API_BASE.replace('/api', path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,10 +115,11 @@ function getMarkerImagesHtml(images, markerIndex, canEdit) {
|
|||||||
const imageHtml = images.map((image, imageIndex) => {
|
const imageHtml = images.map((image, imageIndex) => {
|
||||||
const url = typeof image === 'string' ? image : image.url;
|
const url = typeof image === 'string' ? image : image.url;
|
||||||
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
|
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
|
||||||
if (!url) return '';
|
const displayUrl = sanitizeDisplayUrl(getUploadUrl(url));
|
||||||
|
if (!displayUrl) return '';
|
||||||
return `
|
return `
|
||||||
<div class="chapter-image-item">
|
<div class="chapter-image-item">
|
||||||
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
|
<img src="${escapeAttribute(displayUrl)}" alt="${escapeAttribute(alt)}">
|
||||||
${canEdit ? `
|
${canEdit ? `
|
||||||
<button type="button" class="remove-chapter-image" data-marker-index="${markerIndex}" data-image-index="${imageIndex}" aria-label="Remove image">
|
<button type="button" class="remove-chapter-image" data-marker-index="${markerIndex}" data-image-index="${imageIndex}" aria-label="Remove image">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
@ -294,7 +295,7 @@ function renderComments(comments) {
|
|||||||
function getCommentFormHtml() {
|
function getCommentFormHtml() {
|
||||||
return `
|
return `
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
<textarea id="comment-text" rows="3" placeholder="Write a comment..."></textarea>
|
<textarea id="comment-text" rows="3" maxlength="2000" placeholder="Write a comment..."></textarea>
|
||||||
<button id="submit-comment" class="btn btn-sm" style="margin-top: var(--size-2);"><i class="fas fa-paper-plane"></i> Post Comment</button>
|
<button id="submit-comment" class="btn btn-sm" style="margin-top: var(--size-2);"><i class="fas fa-paper-plane"></i> Post Comment</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -21,7 +21,7 @@ function showToast(message, isError = false) {
|
|||||||
|
|
||||||
function getUploadUrl(path) {
|
function getUploadUrl(path) {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
if (path.startsWith('http')) return path;
|
if (path.startsWith('http')) return sanitizeDisplayUrl(path);
|
||||||
return API_BASE.replace('/api', path);
|
return API_BASE.replace('/api', path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,10 +130,11 @@ function renderMarkerImages(marker, idx) {
|
|||||||
${images.map((image, imageIdx) => {
|
${images.map((image, imageIdx) => {
|
||||||
const url = typeof image === 'string' ? image : image.url;
|
const url = typeof image === 'string' ? image : image.url;
|
||||||
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
|
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
|
||||||
if (!url) return '';
|
const displayUrl = sanitizeDisplayUrl(getUploadUrl(url));
|
||||||
|
if (!displayUrl) return '';
|
||||||
return `
|
return `
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
|
<img src="${escapeAttribute(displayUrl)}" alt="${escapeAttribute(alt)}">
|
||||||
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
|
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -183,15 +184,15 @@ function renderMarkers() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Chapter Title</label>
|
<label>Chapter Title</label>
|
||||||
<input type="text" class="marker-title" data-index="${idx}" value="${escapeHtml(marker.title || '')}" placeholder="Title">
|
<input type="text" class="marker-title" data-index="${idx}" value="${escapeHtml(marker.title || '')}" maxlength="200" placeholder="Title">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Date (optional)</label>
|
<label>Date (optional)</label>
|
||||||
<input type="date" class="marker-date" data-index="${idx}" value="${marker.date || ''}">
|
<input type="date" class="marker-date" data-index="${idx}" value="${escapeAttribute(marker.date || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<textarea class="marker-description" data-index="${idx}" rows="5" placeholder="Describe this chapter. You can use Markdown like **bold**, [link](https://example.com), or .">${escapeHtml(marker.description || '')}</textarea>
|
<textarea class="marker-description" data-index="${idx}" rows="5" maxlength="10000" placeholder="Describe this chapter. You can use Markdown like **bold**, [link](https://example.com), or .">${escapeHtml(marker.description || '')}</textarea>
|
||||||
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
|
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
|
||||||
${renderMarkdownPreview(marker.description)}
|
${renderMarkdownPreview(marker.description)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
marker.bindPopup(
|
marker.bindPopup(
|
||||||
`<strong>${content.title || "Untitled"}</strong>`,
|
`<strong>${escapeHtml(content.title || "Untitled")}</strong>`,
|
||||||
);
|
);
|
||||||
marker.on("click", () => openMarkerEditor(marker));
|
marker.on("click", () => openMarkerEditor(marker));
|
||||||
marker.on("dragend", () => {
|
marker.on("dragend", () => {
|
||||||
@ -90,7 +90,7 @@
|
|||||||
div.dataset.lat = latlng.lat;
|
div.dataset.lat = latlng.lat;
|
||||||
div.dataset.lng = latlng.lng;
|
div.dataset.lng = latlng.lng;
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="marker-title">${content.title || "Untitled"}</div>
|
<div class="marker-title">${escapeHtml(content.title || "Untitled")}</div>
|
||||||
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
|
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
|
||||||
`;
|
`;
|
||||||
div.addEventListener("click", () => {
|
div.addEventListener("click", () => {
|
||||||
@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
function getUploadUrl(path) {
|
function getUploadUrl(path) {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
if (path.startsWith("http")) return sanitizeDisplayUrl(path);
|
||||||
return API_BASE.replace("/api", path);
|
return API_BASE.replace("/api", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,8 +121,13 @@
|
|||||||
images.forEach((image, index) => {
|
images.forEach((image, index) => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "image-preview";
|
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 = `
|
item.innerHTML = `
|
||||||
<img src="${getUploadUrl(image.url)}" alt="${image.originalName || "Marker image"}">
|
<img src="${escapeAttribute(url)}" alt="${alt}">
|
||||||
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
|
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -208,7 +213,7 @@
|
|||||||
|
|
||||||
// Update marker's tooltip title and popup
|
// Update marker's tooltip title and popup
|
||||||
activeMarker.options.title = title;
|
activeMarker.options.title = title;
|
||||||
activeMarker.setPopupContent(`<strong>${title}</strong>`);
|
activeMarker.setPopupContent(`<strong>${escapeHtml(title)}</strong>`);
|
||||||
|
|
||||||
// Store content for saving
|
// Store content for saving
|
||||||
activeMarker._content = { title, date, description, images };
|
activeMarker._content = { title, date, description, images };
|
||||||
|
|||||||
@ -135,22 +135,22 @@
|
|||||||
<div id="login-form" class="auth-form active">
|
<div id="login-form" class="auth-form active">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" id="login-username" required>
|
<input type="text" id="login-username" maxlength="50" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input type="password" id="login-password" required>
|
<input type="password" id="login-password" maxlength="200" required>
|
||||||
</div>
|
</div>
|
||||||
<button id="login-submit" class="btn">Login</button>
|
<button id="login-submit" class="btn">Login</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="register-form" class="auth-form">
|
<div id="register-form" class="auth-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" id="register-username" required>
|
<input type="text" id="register-username" maxlength="50" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input type="password" id="register-password" required>
|
<input type="password" id="register-password" minlength="4" maxlength="200" required>
|
||||||
</div>
|
</div>
|
||||||
<button id="register-submit" class="btn">Register</button>
|
<button id="register-submit" class="btn">Register</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -641,6 +641,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="journey-title"
|
id="journey-title"
|
||||||
|
maxlength="200"
|
||||||
placeholder="My European Adventure"
|
placeholder="My European Adventure"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -660,6 +661,7 @@
|
|||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="journey-description"
|
id="journey-description"
|
||||||
|
maxlength="20000"
|
||||||
placeholder="Describe your journey..."
|
placeholder="Describe your journey..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@ -842,6 +844,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="marker-title"
|
id="marker-title"
|
||||||
|
maxlength="200"
|
||||||
placeholder="Eiffel Tower"
|
placeholder="Eiffel Tower"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -853,6 +856,7 @@
|
|||||||
<label for="marker-text">Description</label>
|
<label for="marker-text">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="marker-text"
|
id="marker-text"
|
||||||
|
maxlength="10000"
|
||||||
placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ."
|
placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ."
|
||||||
></textarea>
|
></textarea>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user