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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.102

- name: Restore dependencies
run: dotnet restore
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,11 @@ The folder for images can be configured via `appsettings.json` file in ImageMapp

## TODO

- Restore cluster grouping of markers when dynamically loading images
- Click to show full size image and optionally metadata details / path
- Show progress when loading images
- Potential for optimising Leaflet rendering, only render markers in view etc? Would need back-end to cache image locations and support querying by bounding box?
- Abstract file enumeration and loading to allow varied sources not just a file folder
- Caching
- Configure map tile provider options
- Caching. Memory and/or stored cache of processed image metadata to speed up subsequent loads and reduce processing on each request. Would need to detect changes however.
- Configurable map tile provider options?
- UI improvements, filtering etc
- Error handling and logging improvements
- Container support
11 changes: 8 additions & 3 deletions src/ImageMapper.Api/Controllers/ImagesController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 @@ -20,17 +22,20 @@ public IAsyncEnumerable<ImageInfo> Get(CancellationToken ct)
[HttpGet("raw/{**relativePath}")]
public async Task<IActionResult> GetRaw(string relativePath, CancellationToken ct)
{
Log.Debug("GET /api/images/raw/{RelativePath} - Retrieving image", relativePath);
// URL-decode the path to handle encoded path separators (%2F -> /)
var decodedPath = HttpUtility.UrlDecode(relativePath);

Log.Debug("GET /api/images/raw/{RelativePath} - Retrieving image", decodedPath);

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

return File(bytes, "application/octet-stream");
}
catch (ArgumentException ex)
catch (PathTraversalException ex)
{
Log.Warning("Path traversal rejected: {Message}", ex.Message);
return BadRequest("Invalid path");
Expand Down
17 changes: 17 additions & 0 deletions src/ImageMapper.Api/Exceptions/PathTraversalException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace ImageMapper.Api.Exceptions;

/// <summary>
/// Thrown when a path traversal attempt is detected.
/// </summary>
public class PathTraversalException : ArgumentException
{
public PathTraversalException() : base("Path traversal detected") { }

public PathTraversalException(string message) : base(message) { }

public PathTraversalException(string message, Exception innerException)
: base(message, innerException) { }

public PathTraversalException(string message, string paramName)
: base(message, paramName) { }
}
6 changes: 3 additions & 3 deletions src/ImageMapper.Api/ImageMapper.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

<ItemGroup>
<PackageReference Include="MetadataExtractor" Version="2.9.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
</ItemGroup>

<ItemGroup>
Expand Down
21 changes: 21 additions & 0 deletions src/ImageMapper.Api/Services/IImageService.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Models;

namespace ImageMapper.Api.Services;

public interface IImageService
{
/// <summary>
/// Asynchronously retrieves a sequence of image information.
/// </summary>
/// <remarks>This method allows for cancellation of the operation through the provided cancellation token.
/// If the operation is canceled, an <see cref="OperationCanceledException"/> will be thrown.</remarks>
/// <param name="ct">The cancellation token to observe while waiting for the asynchronous operation to complete.</param>
/// <returns>An asynchronous sequence of <see cref="ImageInfo"/> objects representing the retrieved images.</returns>
IAsyncEnumerable<ImageInfo> GetImagesAsync(CancellationToken ct = default);

/// <summary>
/// Asynchronously retrieves the image data as a byte array from the specified relative path.
/// </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>
/// <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);
}
3 changes: 2 additions & 1 deletion src/ImageMapper.Api/Services/ImageService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Models;
using MetadataExtractor;
using MetadataExtractor.Formats.Exif;
Expand Down Expand Up @@ -74,7 +75,7 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]
// Validate that the resolved path is within the root directory
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Path traversal detected", nameof(relativePath));
throw new PathTraversalException("Path traversal detected", nameof(relativePath));
}

if (!File.Exists(fullPath))
Expand Down
2 changes: 1 addition & 1 deletion src/ImageMapper.AppHost/ImageMapper.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="13.1.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.3.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
</ItemGroup>

</Project>
9 changes: 6 additions & 3 deletions src/ImageMapper.Tests/ImageMapper.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="13.1.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Aspire.Hosting.Testing" Version="13.1.1" />
<PackageReference Include="coverlet.collector" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit" Version="4.5.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion src/ImageMapper.Tests/ImagesApiTest.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using ImageMapper.Api.Exceptions;
using ImageMapper.Api.Services;
using ImageMapper.Models;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -56,7 +57,7 @@ public void GetImageBytesAsyncRejectsPathTraversalAttempts(string traversalPath)
var service = new ImageService(config);

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

Assert.That(ex.ParamName, Is.EqualTo("relativePath"));
Expand Down
14 changes: 11 additions & 3 deletions src/ImageMapper.Web/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

<script>
let map;
let markerClusterGroup;
let markers = [];

window.initClusterMap = function() {
map = L.map('map').setView([0,0],2);
Expand All @@ -18,13 +20,19 @@
maxZoom:19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

// Create marker cluster group
markerClusterGroup = L.markerClusterGroup();
map.addLayer(markerClusterGroup);
}

window.addMarkerToMap = function(imageData) {
if (imageData.latitude && imageData.longitude) {
L.marker([imageData.latitude, imageData.longitude])
.bindPopup(`<div><strong>${imageData.fileName}</strong><br><img class="popup-thumb" src="${imageData.url}" style="max-width:100%; height: auto;">`)
.addTo(map);
const marker = L.marker([imageData.latitude, imageData.longitude])
.bindPopup(`<div><strong>${imageData.fileName}</strong><br><img class="popup-thumb" src="${imageData.url}" style="max-width:100%; height: auto;">`);

markers.push(marker);
markerClusterGroup.addLayer(marker);
}
}
</script>
17 changes: 13 additions & 4 deletions src/ImageMapper.Web/Components/Pages/Home.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Initialize the map once
// Initialize the map with cluster grouping
await JS.InvokeVoidAsync("initClusterMap", cts.Token);

// Add markers as images arrive
// Add markers as images arrive in batches for better performance
int batchSize = 10;
int batchCount = 0;
await foreach (ImageInfo? image in imageFetcher.Fetch(cts.Token))
{
if (image == null)
Expand All @@ -28,9 +30,16 @@ await JS.InvokeVoidAsync("addMarkerToMap",
image.Longitude,
Url = $"/api/images/raw/{Uri.EscapeDataString(image.RelativePath)}"
});

StateHasChanged();

batchCount++;
if (batchCount % batchSize == 0)
{
StateHasChanged();
}
}

// Final update to ensure UI is synchronized
StateHasChanged();
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/ImageMapper.Web/Controllers/ImagesController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ImageMapper.Web.Client;
using Microsoft.AspNetCore.Mvc;
using System.Web;

namespace ImageMapper.Web.Controllers
{
Expand All @@ -13,7 +14,9 @@ public async Task<IActionResult> GetRaw(string relativePath, CancellationToken c
if (string.IsNullOrWhiteSpace(relativePath))
return BadRequest("Relative path cannot be empty.");

var stream = await imageFetcher.FetchRawImageStream(relativePath, ct);
// URL-decode the path to handle encoded path separators (%2F -> /)
var decodedPath = HttpUtility.UrlDecode(relativePath);
var stream = await imageFetcher.FetchRawImageStream(decodedPath, ct);
if (stream == null)
return NotFound();

Expand Down
4 changes: 2 additions & 2 deletions src/ImageMapper.Web/ImageMapper.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.1.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.1.1" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
</ItemGroup>

Expand Down