diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a8b6018..0c5cca0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -81,7 +81,7 @@ Validate and load from pandas DataFrame. - `GET /` - Main viewer page - `GET/POST /api/data` - Get/set scene data -- `GET /api/image/` - Serve image files +- `GET /images/` - Serve image files directly from the image base path - `GET /api/stats` - Get scene statistics ## Browser Requirements diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..50e7ffa --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,62 @@ +""" +Tests for the Flask application routes. +""" + +import os +import unittest +import tempfile +from pathlib import Path +from vest.app import create_app + + +class TestImageRoute(unittest.TestCase): + """Test the /images/ static route.""" + + def setUp(self): + self.app = create_app() + self.app.testing = True + self.client = self.app.test_client() + + def test_images_route_no_base_path(self): + """Return 404 when no image base path is set.""" + response = self.client.get('/images/test.png') + self.assertEqual(response.status_code, 404) + + def test_images_route_serves_file(self): + """Serve an image file from the configured base path.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a minimal PNG file (1x1 red pixel) + img_path = os.path.join(tmpdir, 'test.png') + with open(img_path, 'wb') as f: + # Minimal valid PNG bytes + f.write( + b'\x89PNG\r\n\x1a\n' + b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx' + b'\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N' + b'\x00\x00\x00\x00IEND\xaeB`\x82' + ) + + self.app.image_base_path = tmpdir + response = self.client.get('/images/test.png') + self.assertEqual(response.status_code, 200) + + def test_images_route_file_not_found(self): + """Return 404 for a missing image file.""" + with tempfile.TemporaryDirectory() as tmpdir: + self.app.image_base_path = tmpdir + response = self.client.get('/images/nonexistent.png') + self.assertEqual(response.status_code, 404) + + def test_images_route_path_traversal(self): + """Reject path traversal attempts.""" + with tempfile.TemporaryDirectory() as tmpdir: + self.app.image_base_path = tmpdir + response = self.client.get('/images/../../../etc/passwd') + # Flask normalises the URL before routing, so this becomes a + # redirect or 404 – either way it must not return 200. + self.assertNotEqual(response.status_code, 200) + + +if __name__ == '__main__': + unittest.main() diff --git a/vest/app.py b/vest/app.py index e244ef8..4861ad2 100644 --- a/vest/app.py +++ b/vest/app.py @@ -6,7 +6,7 @@ import json import math import pandas as pd -from flask import Flask, render_template, jsonify, request, send_file +from flask import Flask, render_template, jsonify, request, send_from_directory from pathlib import Path import glob @@ -67,41 +67,13 @@ def get_data(): 'image_base_path': app.image_base_path }) - @app.route('/api/image/') + @app.route('/images/') def get_image(filename): - """Serve image files.""" + """Serve image files directly from the image base path.""" if app.image_base_path is None: - return jsonify({'error': 'No image base path set'}), 400 - - # Normalize path separators and construct full path - filename = filename.replace('/', os.sep).replace('\\', os.sep) - file_path = os.path.join(app.image_base_path, filename) - file_path = os.path.normpath(file_path) - - if os.path.exists(file_path): - # Detect MIME type based on file extension - ext = os.path.splitext(filename)[1].lower() - mime_types = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp' - } - mimetype = mime_types.get(ext, 'image/png') - return send_file(file_path, mimetype=mimetype) - - # Log detailed error info - print(f"ERROR: Image not found") - print(f" Requested: {filename}") - print(f" Base path: {app.image_base_path}") - print(f" Full path: {file_path}") - print(f" Base path exists: {os.path.exists(app.image_base_path)}") - if os.path.exists(app.image_base_path): - files = os.listdir(app.image_base_path) - print(f" Files in base path: {files[:10]}...") # Show first 10 - return jsonify({'error': 'File not found', 'path': file_path}), 404 + return jsonify({'error': 'No image base path set'}), 404 + + return send_from_directory(app.image_base_path, filename) @app.route('/api/stats') def get_stats(): @@ -188,13 +160,25 @@ def load_keyframes(): filename = request.args.get('filename', '') if not filename: return jsonify({'error': 'No filename provided'}), 400 - - # Create full path in current working directory - file_path = os.path.join(os.getcwd(), filename) - - if not os.path.exists(file_path): + + # Accept only a plain filename (no path separators) to prevent + # path traversal attacks. + safe_name = os.path.basename(filename) + if not safe_name or safe_name != filename: + return jsonify({'error': 'Invalid filename'}), 400 + + # Discover the file via glob so the resulting path is not derived + # from user input, then match against the validated safe_name. + cwd = os.getcwd() + file_path = None + for candidate in glob.glob(os.path.join(cwd, '*.kf.csv')): + if os.path.basename(candidate) == safe_name: + file_path = candidate + break + + if file_path is None: return jsonify({'error': f'File not found: {filename}'}), 404 - + # Read CSV file df = pd.read_csv(file_path) diff --git a/vest/static/viewer.js b/vest/static/viewer.js index 5a6ded1..ff40bb3 100644 --- a/vest/static/viewer.js +++ b/vest/static/viewer.js @@ -895,7 +895,7 @@ class ImageViewer { const sprite = new THREE.Sprite(material); sprite.position.set(x, y, z); sprite.scale.set(0.5, 0.5, 1); - sprite.userData = { filename, index, imageUrl: `/api/image/${filename}`, textureLoader, isSprite: true }; + sprite.userData = { filename, index, imageUrl: `/images/${filename}`, textureLoader, isSprite: true }; this.scene.add(sprite); this.imageSprites.push(sprite);