Skip to content
Merged
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
27 changes: 7 additions & 20 deletions src/ImageMapper.Api/Controllers/ImagesController.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Api.Services;
using ImageMapper.Models;
using Microsoft.AspNetCore.Mvc;
using Serilog;
using System.Web;

namespace ImageMapper.Api.Controllers;

Expand All @@ -26,26 +24,15 @@ public async Task<ActionResult<int>> GetCount(CancellationToken ct)
return Ok(count);
}

[HttpGet("raw/{**relativePath}")]
public async Task<IActionResult> GetRaw(string relativePath, CancellationToken ct)
[HttpGet("raw/{id}")]
public async Task<IActionResult> GetRaw(string id, CancellationToken ct)
{
// URL-decode the path to handle encoded path separators (%2F -> /)
var decodedPath = HttpUtility.UrlDecode(relativePath);
Log.Debug("GET /api/images/raw/{Id} - Retrieving image", id);

Log.Debug("GET /api/images/raw/{RelativePath} - Retrieving image", decodedPath);
var bytes = await _svc.GetImageBytesAsync(id, ct);
if (bytes == null)
return NotFound();

try
{
var bytes = await _svc.GetImageBytesAsync(decodedPath, ct);
if (bytes == null)
return NotFound();

return File(bytes, "application/octet-stream");
}
catch (PathTraversalException ex)
{
Log.Warning("Path traversal rejected: {Message}", ex.Message);
return BadRequest("Invalid path");
}
return File(bytes, "application/octet-stream");
}
}
17 changes: 0 additions & 17 deletions src/ImageMapper.Api/Exceptions/PathTraversalException.cs

This file was deleted.

14 changes: 5 additions & 9 deletions src/ImageMapper.Api/Services/IImageService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Models;

namespace ImageMapper.Api.Services;
Expand All @@ -15,18 +14,15 @@ public interface IImageService
IAsyncEnumerable<ImageInfo> GetImagesAsync(CancellationToken ct = default);

/// <summary>
/// Asynchronously retrieves the image data as a byte array from the specified relative path.
/// Asynchronously retrieves the image data as a byte array from the specified image ID.
/// </summary>
/// <remarks>This method is intended for use in scenarios where image data needs to be loaded
/// asynchronously, such as in UI applications. Ensure that the relative path is correctly specified to avoid
/// errors.</remarks>
/// <param name="relativePath">The relative path to the image file. This path must be valid and accessible; otherwise, an exception may be
/// thrown.</param>
/// <remarks>The image ID is a unique identifier generated from the image's full path.
/// This prevents the frontend from accessing file system paths.</remarks>
/// <param name="id">The unique image ID</param>
/// <param name="ct">A cancellation token that can be used to cancel the operation. The default value is CancellationToken.None.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a byte array of the image data, or
/// null if the image could not be found.</returns>
/// <exception cref="PathTraversalException">Thrown when the provided relative path is invalid or attempts to traverse outside the allowed directory.</exception>"
Task<byte[]?> GetImageBytesAsync(string relativePath, CancellationToken ct = default);
Task<byte[]?> GetImageBytesAsync(string id, CancellationToken ct = default);

/// <summary>
/// Asynchronously retrieves the total count of image files.
Expand Down
61 changes: 41 additions & 20 deletions src/ImageMapper.Api/Services/ImageService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Models;
using MetadataExtractor;
using MetadataExtractor.Formats.Exif;
using Serilog;
using System.Buffers.Text;
using System.Runtime.CompilerServices;

namespace ImageMapper.Api.Services;
Expand All @@ -12,6 +12,10 @@ public class ImageService : IImageService
private readonly IConfiguration _config;
private readonly string _imagesRoot;

// In-memory lookup from ID to full file path
private static readonly Dictionary<string, string> IdToPathMapping = new(StringComparer.OrdinalIgnoreCase);
private static readonly SemaphoreSlim MappingSem = new(1, 1);

private static readonly string[] ValidExtensions = [".jpg", ".jpeg", ".png", ".tif", ".tiff", ".nef"];

public ImageService(IConfiguration config)
Expand All @@ -33,8 +37,8 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]
foreach (string f in files)
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(_imagesRoot, f).Replace("\\", "/");
var info = new ImageInfo { RelativePath = rel, FileName = Path.GetFileName(f) };
var id = await GenerateIdForPath(f, ct);
var info = new ImageInfo { Id = id, FileName = Path.GetFileName(f) };
try
{
var directories = ImageMetadataReader.ReadMetadata(f);
Expand All @@ -60,28 +64,45 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]
}
}

public async Task<byte[]?> GetImageBytesAsync(string relativePath, CancellationToken ct = default)
private static async Task<string> GenerateIdForPath(string fullPath, CancellationToken ct = default)
{
// Normalize path separators to OS-specific
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var full = Path.Combine(_imagesRoot, normalized);
var fullPath = Path.GetFullPath(full);
var rootPath = Path.GetFullPath(_imagesRoot);

// Ensure rootPath ends with separator for proper boundary checking
if (!rootPath.EndsWith(Path.DirectorySeparatorChar))
rootPath += Path.DirectorySeparatorChar;

// Validate that the resolved path is within the root directory
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
// Generate a unique ID based on the full path
var id = Base64Url.EncodeToString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(fullPath)));

await MappingSem.WaitAsync(ct);
try
{
IdToPathMapping[id] = fullPath;
}
finally
{
throw new PathTraversalException("Path traversal detected", nameof(relativePath));
MappingSem.Release();
}

if (!File.Exists(fullPath))
return null;
return id;
}

public async Task<byte[]?> GetImageBytesAsync(string id, CancellationToken ct = default)
{
await MappingSem.WaitAsync(ct);
try
{
if (!IdToPathMapping.TryGetValue(id, out var fullPath))
return null;

if (!File.Exists(fullPath))
{
// Remove stale mapping
IdToPathMapping.Remove(id);
return null;
}

return await File.ReadAllBytesAsync(fullPath, ct);
return await File.ReadAllBytesAsync(fullPath, ct);
}
finally
{
MappingSem.Release();
}
}

public Task<int> GetImageCountAsync(CancellationToken ct = default)
Expand Down
2 changes: 1 addition & 1 deletion src/ImageMapper.Models/ImageInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace ImageMapper.Models;

public class ImageInfo
{
public string RelativePath { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public double? Latitude { get; set; }
public double? Longitude { get; set; }
Expand Down
99 changes: 27 additions & 72 deletions src/ImageMapper.Tests/ImagesApiTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Api.Services;
using ImageMapper.Models;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -41,29 +40,6 @@ public void OneTimeTearDown()
Directory.Delete(_testImagesDirectory, recursive: true);
}

[Test]
[TestCase("../../etc/passwd")]
[TestCase("../../../windows/system32/config/sam")]
[TestCase("..\\..\\..\\windows\\system32\\config\\sam")]
[TestCase("images/../../etc/passwd")]
[TestCase("subfolder/../../../../sensitive.txt")]
[TestCase("valid-image.jpg/../../../etc/passwd")]
public void GetImageBytesAsyncRejectsPathTraversalAttempts(string traversalPath)
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "ImageFolder", _testImagesDirectory } })
.Build();
var service = new ImageService(config);

// Act & Assert
var ex = Assert.ThrowsAsync<PathTraversalException>(
async () => await service.GetImageBytesAsync(traversalPath));

Assert.That(ex.ParamName, Is.EqualTo("relativePath"));
Assert.That(ex.Message, Contains.Substring("Path traversal detected"));
}

[Test]
public async Task GetImageBytesAsyncReturnsValidImageBytes()
{
Expand All @@ -73,8 +49,18 @@ public async Task GetImageBytesAsyncReturnsValidImageBytes()
.Build();
var service = new ImageService(config);

// Get the image ID first
var images = new List<ImageInfo>();
await foreach (var image in service.GetImagesAsync())
{
if (image.FileName == "test-image.jpg")
images.Add(image);
}
Assert.That(images, Has.Count.GreaterThan(0), "test-image.jpg not found");
var testImageId = images[0].Id;

// Act
var bytes = await service.GetImageBytesAsync("test-image.jpg");
var bytes = await service.GetImageBytesAsync(testImageId);

// Assert
Assert.That(bytes, Is.Not.Null);
Expand All @@ -91,51 +77,42 @@ public async Task GetImageBytesAsyncReturnsNullForNonExistentFile()
.Build();
var service = new ImageService(config);

// Act
var bytes = await service.GetImageBytesAsync("nonexistent.jpg");
// Act - use a fake ID that doesn't exist
var bytes = await service.GetImageBytesAsync("nonexistent-id-12345");

// Assert
Assert.That(bytes, Is.Null);
}

[Test]
[TestCase("subfolder/nested-image.jpg")]
[TestCase("subfolder/deep/deep-image.png")]
public async Task GetImageBytesAsyncReturnsValidImageBytesFromSubfolders(string relativePath)
[TestCase("nested-image.jpg")]
[TestCase("deep-image.png")]
public async Task GetImageBytesAsyncReturnsValidImageBytesFromSubfolders(string fileName)
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "ImageFolder", _testImagesDirectory } })
.Build();
var service = new ImageService(config);

// Get the image ID first
var images = new List<ImageInfo>();
await foreach (var image in service.GetImagesAsync())
{
if (image.FileName == fileName)
images.Add(image);
}
Assert.That(images, Has.Count.GreaterThan(0), $"{fileName} not found");
var imageId = images[0].Id;

// Act
var bytes = await service.GetImageBytesAsync(relativePath);
var bytes = await service.GetImageBytesAsync(imageId);

// Assert
Assert.That(bytes, Is.Not.Null);
Assert.That(bytes, Has.Length.GreaterThan(0));
}

[Test]
[TestCase("subfolder/nonexistent.jpg")]
[TestCase("subfolder/deep/missing-image.png")]
[TestCase("nonexistent-folder/image.jpg")]
public async Task GetImageBytesAsyncReturnsNullForNonExistentFilesInSubfolders(string relativePath)
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "ImageFolder", _testImagesDirectory } })
.Build();
var service = new ImageService(config);

// Act
var bytes = await service.GetImageBytesAsync(relativePath);

// Assert
Assert.That(bytes, Is.Null);
}

[Test]
public async Task GetImagesAsyncReturnsAllImagesFromDirectory()
{
Expand All @@ -159,28 +136,6 @@ public async Task GetImagesAsyncReturnsAllImagesFromDirectory()
Assert.That(images.Select(i => i.FileName), Does.Contain("deep-image.png"));
}

[Test]
public async Task GetImagesAsyncReturnsCorrectRelativePaths()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "ImageFolder", _testImagesDirectory } })
.Build();
var service = new ImageService(config);

// Act
var images = new List<ImageInfo>();
await foreach (var image in service.GetImagesAsync())
{
images.Add(image);
}

// Assert
Assert.That(images.Select(i => i.RelativePath), Does.Contain("test-image.jpg"));
Assert.That(images.Select(i => i.RelativePath), Does.Contain("subfolder/nested-image.jpg"));
Assert.That(images.Select(i => i.RelativePath), Does.Contain("subfolder/deep/deep-image.png"));
}

[Test]
public async Task GetImagesAsyncReturnsEmptyListForNonExistentDirectory()
{
Expand Down
Loading
Loading