Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<filename>` - Serve image files
- `GET /images/<filename>` - Serve image files directly from the image base path
- `GET /api/stats` - Get scene statistics

## Browser Requirements
Expand Down
62 changes: 62 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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()
64 changes: 24 additions & 40 deletions vest/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -67,41 +67,13 @@ def get_data():
'image_base_path': app.image_base_path
})

@app.route('/api/image/<path:filename>')
@app.route('/images/<path:filename>')
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():
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion vest/static/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down