Sanitize user text input

This commit is contained in:
André Rüegger 2026-06-07 23:08:29 +02:00
parent a8207c4f7b
commit 86c40aee91
9 changed files with 179 additions and 59 deletions

View File

@ -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:

View File

@ -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>

View File

@ -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 "&amp;"; if (m === "&") return "&amp;";
if (m === "<") return "&lt;"; if (m === "<") return "&lt;";
if (m === ">") return "&gt;"; if (m === ">") return "&gt;";
if (m === '"') return "&quot;";
if (m === "'") return "&#39;";
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;

View File

@ -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) {

View File

@ -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>
`; `;

View File

@ -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 ![image](https://example.com/image.jpg).">${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 ![image](https://example.com/image.jpg).">${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>

View File

@ -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 };

View File

@ -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>

View File

@ -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 ![image](https://example.com/image.jpg)." placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ![image](https://example.com/image.jpg)."
></textarea> ></textarea>
<div <div