Skip to content

Commit b56b5ce

Browse files
authored
Merge pull request #28 from Nesteo/image-upload
Image upload and download
2 parents f7c2913 + 060ecca commit b56b5ce

File tree

13 files changed

+368
-22
lines changed

13 files changed

+368
-22
lines changed

Nesteo.Server.IntegrationTests/appsettings-sample.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"ConnectionStrings": {
33
"DefaultConnection": "Server=localhost;Database=test;User=root;Password=root"
44
},
5+
"Storage": {
6+
"ImageUploadsDirectoryPath": "/tmp/nesteo-uploads"
7+
},
58
"Logging": {
69
"LogLevel": {
710
"Default": "Information"

Nesteo.Server/Controllers/Api/InspectionsController.cs

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.IO;
34
using System.Threading.Tasks;
45
using Microsoft.AspNetCore.Http;
56
using Microsoft.AspNetCore.Mvc;
6-
using Nesteo.Server.Data.Entities;
7-
using Nesteo.Server.Data.Enums;
7+
using Microsoft.AspNetCore.StaticFiles;
8+
using Microsoft.Extensions.Options;
9+
using Nesteo.Server.Filters;
810
using Nesteo.Server.Models;
11+
using Nesteo.Server.Options;
912
using Nesteo.Server.Services;
1013

1114
namespace Nesteo.Server.Controllers.Api
@@ -14,10 +17,12 @@ namespace Nesteo.Server.Controllers.Api
1417
public class InspectionsController : ApiControllerBase
1518
{
1619
private readonly IInspectionService _inspectionService;
20+
private readonly IOptions<StorageOptions> _storageOptions;
1721

18-
public InspectionsController(IInspectionService inspectionService)
22+
public InspectionsController(IInspectionService inspectionService, IOptions<StorageOptions> storageOptions)
1923
{
2024
_inspectionService = inspectionService ?? throw new ArgumentNullException(nameof(inspectionService));
25+
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
2126
}
2227

2328
/// <summary>
@@ -69,12 +74,17 @@ public async Task<ActionResult<Inspection>> CreateInspectionAsync([FromBody] Ins
6974
/// <param name="inspection">The modified inspection</param>
7075
[HttpPatch("{id}")]
7176
[ProducesResponseType(StatusCodes.Status204NoContent)]
72-
public async Task<ActionResult> EditInspectionAsync(int id, [FromBody] Inspection inspection)
77+
public async Task<IActionResult> EditInspectionAsync(int id, [FromBody] Inspection inspection)
7378
{
7479
if (inspection.Id == null)
80+
{
7581
inspection.Id = id;
82+
}
7683
else if (inspection.Id != id)
77-
return BadRequest();
84+
{
85+
ModelState.AddModelError("Inspection.Id", "Inspection ID is set but different from the resource URL.");
86+
return BadRequest(ModelState);
87+
}
7888

7989
// Edit inspection
8090
inspection = await _inspectionService.UpdateAsync(inspection, HttpContext.RequestAborted).ConfigureAwait(false);
@@ -84,6 +94,54 @@ public async Task<ActionResult> EditInspectionAsync(int id, [FromBody] Inspectio
8494
return NoContent();
8595
}
8696

97+
/// <summary>
98+
/// Upload a new inspection image
99+
/// </summary>
100+
/// <remarks>
101+
/// Replaces the old one, when existing.
102+
/// </remarks>
103+
/// <param name="id">Inspection id</param>
104+
[HttpPost("{id}/upload-image")]
105+
[DisableFormValueModelBinding]
106+
[Consumes("multipart/form-data")]
107+
[ProducesResponseType(StatusCodes.Status204NoContent)]
108+
public async Task<IActionResult> UploadInspectionImageAsync(int id)
109+
{
110+
if (!await _inspectionService.ExistsIdAsync(id, HttpContext.RequestAborted).ConfigureAwait(false))
111+
return NotFound();
112+
113+
string imageFileName = await ReceiveMultipartImageFileUploadAsync(id.ToString(), HttpContext.RequestAborted).ConfigureAwait(false);
114+
if (imageFileName == null)
115+
return BadRequest(ModelState);
116+
117+
await _inspectionService.SetImageFileNameAsync(id, imageFileName, HttpContext.RequestAborted).ConfigureAwait(false);
118+
119+
return NoContent();
120+
}
121+
122+
/// <summary>
123+
/// Download an inspection image
124+
/// </summary>
125+
/// <param name="id">Inspection id</param>
126+
[HttpGet("{id}/image")]
127+
[ProducesResponseType(StatusCodes.Status200OK)]
128+
public async Task<IActionResult> GetInspectionImageAsync(int id)
129+
{
130+
string imageFileName = await _inspectionService.GetImageFileNameAsync(id, HttpContext.RequestAborted).ConfigureAwait(false);
131+
if (imageFileName == null)
132+
return NotFound();
133+
134+
string imageFilePath = Path.Join(_storageOptions.Value.ImageUploadsDirectoryPath, imageFileName);
135+
var fileInfo = new FileInfo(imageFilePath);
136+
if (!fileInfo.Exists)
137+
return NotFound();
138+
139+
string contentType = new FileExtensionContentTypeProvider().TryGetContentType(imageFilePath, out string result) ? result : "application/octet-stream";
140+
FileStream fileStream = fileInfo.OpenRead();
141+
142+
return File(fileStream, contentType, true);
143+
}
144+
87145
/// <summary>
88146
/// Preview all inspections with a reduced set of data
89147
/// </summary>

Nesteo.Server/Controllers/Api/NestingBoxesController.cs

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Linq;
4-
using System.Security.Claims;
3+
using System.IO;
54
using System.Threading.Tasks;
6-
using Hellang.Middleware.ProblemDetails;
75
using Microsoft.AspNetCore.Http;
86
using Microsoft.AspNetCore.Mvc;
9-
using Nesteo.Server.Data;
10-
using Nesteo.Server.Data.Enums;
11-
using Nesteo.Server.IdGeneration;
7+
using Microsoft.AspNetCore.StaticFiles;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Options;
10+
using Nesteo.Server.Filters;
1211
using Nesteo.Server.Models;
12+
using Nesteo.Server.Options;
1313
using Nesteo.Server.Services;
1414

1515
namespace Nesteo.Server.Controllers.Api
@@ -19,18 +19,18 @@ public class NestingBoxesController : ApiControllerBase
1919
{
2020
private readonly INestingBoxService _nestingBoxService;
2121
private readonly IInspectionService _inspectionService;
22-
private readonly INestingBoxIdGenerator _nestingBoxIdGenerator;
23-
private readonly IUserService _userService;
22+
private readonly IOptions<StorageOptions> _storageOptions;
23+
private readonly ILogger<NestingBoxesController> _logger;
2424

2525
public NestingBoxesController(INestingBoxService nestingBoxService,
2626
IInspectionService inspectionService,
27-
INestingBoxIdGenerator nestingBoxIdGenerator,
28-
IUserService userService)
27+
IOptions<StorageOptions> storageOptions,
28+
ILogger<NestingBoxesController> logger)
2929
{
3030
_nestingBoxService = nestingBoxService ?? throw new ArgumentNullException(nameof(nestingBoxService));
3131
_inspectionService = inspectionService ?? throw new ArgumentNullException(nameof(inspectionService));
32-
_nestingBoxIdGenerator = nestingBoxIdGenerator ?? throw new ArgumentNullException(nameof(nestingBoxIdGenerator));
33-
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
32+
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
33+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
3434
}
3535

3636
/// <summary>
@@ -86,12 +86,17 @@ public async Task<ActionResult<NestingBox>> CreateNestingBoxAsync([FromBody] Nes
8686
/// <param name="nestingBox">The modified nesting box</param>
8787
[HttpPatch("{id}")]
8888
[ProducesResponseType(StatusCodes.Status204NoContent)]
89-
public async Task<ActionResult> EditNestingBoxAsync(string id, [FromBody] NestingBox nestingBox)
89+
public async Task<IActionResult> EditNestingBoxAsync(string id, [FromBody] NestingBox nestingBox)
9090
{
9191
if (nestingBox.Id == null)
92+
{
9293
nestingBox.Id = id;
94+
}
9395
else if (nestingBox.Id != id)
94-
return BadRequest();
96+
{
97+
ModelState.AddModelError("NestingBox.Id", "Nesting box ID is set but different from the resource URL.");
98+
return BadRequest(ModelState);
99+
}
95100

96101
// Edit nesting box
97102
nestingBox = await _nestingBoxService.UpdateAsync(nestingBox, HttpContext.RequestAborted).ConfigureAwait(false);
@@ -101,6 +106,54 @@ public async Task<ActionResult> EditNestingBoxAsync(string id, [FromBody] Nestin
101106
return NoContent();
102107
}
103108

109+
/// <summary>
110+
/// Upload a new nesting box image
111+
/// </summary>
112+
/// <remarks>
113+
/// Replaces the old one, when existing.
114+
/// </remarks>
115+
/// <param name="id">Nesting box id</param>
116+
[HttpPost("{id}/upload-image")]
117+
[DisableFormValueModelBinding]
118+
[Consumes("multipart/form-data")]
119+
[ProducesResponseType(StatusCodes.Status204NoContent)]
120+
public async Task<IActionResult> UploadNestingBoxImageAsync(string id)
121+
{
122+
if (!await _nestingBoxService.ExistsIdAsync(id, HttpContext.RequestAborted).ConfigureAwait(false))
123+
return NotFound();
124+
125+
string imageFileName = await ReceiveMultipartImageFileUploadAsync(id, HttpContext.RequestAborted).ConfigureAwait(false);
126+
if (imageFileName == null)
127+
return BadRequest(ModelState);
128+
129+
await _nestingBoxService.SetImageFileNameAsync(id, imageFileName, HttpContext.RequestAborted).ConfigureAwait(false);
130+
131+
return NoContent();
132+
}
133+
134+
/// <summary>
135+
/// Download a nesting box image
136+
/// </summary>
137+
/// <param name="id">Nesting box id</param>
138+
[HttpGet("{id}/image")]
139+
[ProducesResponseType(StatusCodes.Status200OK)]
140+
public async Task<IActionResult> GetNestingBoxImageAsync(string id)
141+
{
142+
string imageFileName = await _nestingBoxService.GetImageFileNameAsync(id, HttpContext.RequestAborted).ConfigureAwait(false);
143+
if (imageFileName == null)
144+
return NotFound();
145+
146+
string imageFilePath = Path.Join(_storageOptions.Value.ImageUploadsDirectoryPath, imageFileName);
147+
var fileInfo = new FileInfo(imageFilePath);
148+
if (!fileInfo.Exists)
149+
return NotFound();
150+
151+
string contentType = new FileExtensionContentTypeProvider().TryGetContentType(imageFilePath, out string result) ? result : "application/octet-stream";
152+
FileStream fileStream = fileInfo.OpenRead();
153+
154+
return File(fileStream, contentType, true);
155+
}
156+
104157
/// <summary>
105158
/// Preview all nesting boxes with a reduced set of data
106159
/// </summary>
Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,86 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
16
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.WebUtilities;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.Options;
11+
using Microsoft.Net.Http.Headers;
12+
using Nesteo.Server.Options;
13+
using Nesteo.Server.Utils;
214

315
namespace Nesteo.Server.Controllers
416
{
517
[ApiController]
618
[Produces("application/json")]
7-
public abstract class ApiControllerBase : Controller { }
19+
public abstract class ApiControllerBase : Controller
20+
{
21+
private static readonly string[] ValidImageFileExtensions = { ".jpg", ".png" };
22+
23+
protected async Task<string> ReceiveMultipartImageFileUploadAsync(string namePrefix, CancellationToken cancellationToken = default)
24+
{
25+
IOptions<StorageOptions> storageOptions = HttpContext.RequestServices.GetRequiredService<IOptions<StorageOptions>>();
26+
ILogger<ApiControllerBase> logger = HttpContext.RequestServices.GetRequiredService<ILogger<ApiControllerBase>>();
27+
28+
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
29+
{
30+
ModelState.AddModelError("File", "Multipart request expected.");
31+
return null;
32+
}
33+
34+
string boundary = MultipartRequestHelper.GetBoundary(MediaTypeHeaderValue.Parse(Request.ContentType));
35+
var multipartReader = new MultipartReader(boundary, HttpContext.Request.Body);
36+
37+
MultipartSection section = await multipartReader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false);
38+
if (section == null)
39+
{
40+
ModelState.AddModelError("File", "No multipart section found.");
41+
return null;
42+
}
43+
44+
if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out ContentDispositionHeaderValue contentDisposition))
45+
{
46+
ModelState.AddModelError("File", "Content disposition header expected.");
47+
return null;
48+
}
49+
50+
if (!MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
51+
{
52+
ModelState.AddModelError("File", "File content disposition expected.");
53+
return null;
54+
}
55+
56+
string untrustedFileName = contentDisposition.FileName.Value;
57+
string fileExtension = Path.GetExtension(untrustedFileName).ToLowerInvariant();
58+
59+
if (string.IsNullOrEmpty(fileExtension) || !ValidImageFileExtensions.Contains(fileExtension))
60+
{
61+
ModelState.AddModelError("File", $"File extension {fileExtension} is not allowed. Valid extensions are: {string.Join(", ", ValidImageFileExtensions)}");
62+
return null;
63+
}
64+
65+
Directory.CreateDirectory(storageOptions.Value.ImageUploadsDirectoryPath);
66+
67+
string targetFileName = $"{namePrefix}-{Guid.NewGuid()}{fileExtension}";
68+
string targetFilePath = Path.Join(storageOptions.Value.ImageUploadsDirectoryPath, targetFileName);
69+
70+
try
71+
{
72+
await using FileStream targetStream = System.IO.File.Create(targetFilePath);
73+
await section.Body.CopyToAsync(targetStream, HttpContext.RequestAborted).ConfigureAwait(false);
74+
}
75+
catch (Exception)
76+
{
77+
System.IO.File.Delete(targetFilePath);
78+
throw;
79+
}
80+
81+
logger.LogInformation($"Successfully uploaded file {untrustedFileName} to {targetFilePath}");
82+
83+
return targetFileName;
84+
}
85+
}
886
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
using Microsoft.AspNetCore.Mvc.Filters;
3+
using Microsoft.AspNetCore.Mvc.ModelBinding;
4+
5+
namespace Nesteo.Server.Filters
6+
{
7+
// Source: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-3.0#upload-large-files-with-streaming
8+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
9+
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
10+
{
11+
public void OnResourceExecuting(ResourceExecutingContext context)
12+
{
13+
var factories = context.ValueProviderFactories;
14+
factories.RemoveType<FormValueProviderFactory>();
15+
factories.RemoveType<FormFileValueProviderFactory>();
16+
factories.RemoveType<JQueryFormValueProviderFactory>();
17+
}
18+
19+
public void OnResourceExecuted(ResourceExecutedContext context) { }
20+
}
21+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Nesteo.Server.Options
4+
{
5+
public class StorageOptions
6+
{
7+
[Required]
8+
public string ImageUploadsDirectoryPath { get; set; }
9+
}
10+
}

Nesteo.Server/Services/IInspectionService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,9 @@ public interface IInspectionService : ICrudService<Inspection, int?>
1919
Task<Inspection> AddAsync(Inspection inspection, CancellationToken cancellationToken = default);
2020

2121
Task<Inspection> UpdateAsync(Inspection inspection, CancellationToken cancellationToken = default);
22+
23+
Task<Inspection> SetImageFileNameAsync(int id, string imageFileName, CancellationToken cancellationToken = default);
24+
25+
Task<string> GetImageFileNameAsync(int id, CancellationToken cancellationToken = default);
2226
}
2327
}

Nesteo.Server/Services/INestingBoxService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,9 @@ public interface INestingBoxService : ICrudService<NestingBox, string>
1818
Task<NestingBox> AddAsync(NestingBox nestingBox, CancellationToken cancellationToken = default);
1919

2020
Task<NestingBox> UpdateAsync(NestingBox nestingBox, CancellationToken cancellationToken = default);
21+
22+
Task<NestingBox> SetImageFileNameAsync(string id, string imageFileName, CancellationToken cancellationToken = default);
23+
24+
Task<string> GetImageFileNameAsync(string id, CancellationToken cancellationToken = default);
2125
}
2226
}

0 commit comments

Comments
 (0)