Logo upload and delete need change rights on Global (was read rights, which BizAdminRestricted has). The image type is taken from the bytes: PNG, JPEG, GIF, WebP or SVG, anything else refused; the claimed type is not stored. Downloads send nosniff and a sandbox Content-Security-Policy, so an SVG opened by its URL runs no script. F23 fixed (4727 step 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
10 KiB
C#
255 lines
10 KiB
C#
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
using AyaNova.Models;
|
|
using AyaNova.Api.ControllerHelpers;
|
|
using AyaNova.Biz;
|
|
using AyaNova.Util;
|
|
using System.IO;
|
|
|
|
|
|
namespace AyaNova.Api.Controllers
|
|
{
|
|
|
|
/// <summary>
|
|
/// Logo controller
|
|
/// </summary>
|
|
[ApiController]
|
|
[Asp.Versioning.ApiVersion("8.0")]
|
|
[Route("api/v{version:apiVersion}/logo")]
|
|
[Produces("application/json")]
|
|
[Authorize]
|
|
public class LogoController : ControllerBase
|
|
{
|
|
private readonly AyContext ct;
|
|
private readonly ILogger<LogoController> log;
|
|
private readonly ApiServerState serverState;
|
|
// private const int MAXIMUM_LOGO_SIZE = 512000;//We really don't want it too big or it will slow the fuck down for everything
|
|
|
|
|
|
/// <summary>
|
|
/// ctor
|
|
/// </summary>
|
|
/// <param name="dbcontext"></param>
|
|
/// <param name="logger"></param>
|
|
/// <param name="apiServerState"></param>
|
|
public LogoController(AyContext dbcontext, ILogger<LogoController> logger, ApiServerState apiServerState)
|
|
{
|
|
ct = dbcontext;
|
|
log = logger;
|
|
serverState = apiServerState;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Get Logo
|
|
/// </summary>
|
|
/// <param name="size">One of "small", "medium", "large"</param>
|
|
/// <returns>A single Logo and it's values</returns>
|
|
[AllowAnonymous]
|
|
[HttpGet("{size}")]
|
|
public async Task<IActionResult> DownloadLogo([FromRoute] string size)
|
|
{
|
|
//allowing this because it messes up the login form needlessly
|
|
// if (serverState.IsClosed)
|
|
// return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
if (string.IsNullOrWhiteSpace(size))
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "size", "Size is required and must be one of 'small', 'medium' or 'large'"));
|
|
|
|
size = size.ToLowerInvariant();
|
|
if (size != "small" && size != "medium" && size != "large")
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "size", "Size parameter must be one of 'small', 'medium' or 'large'"));
|
|
|
|
var logo = await ct.Logo.SingleOrDefaultAsync();
|
|
if (logo == null)
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
|
|
|
|
|
switch (size)
|
|
{
|
|
case "small":
|
|
if (logo.Small == null)
|
|
return NotFound();
|
|
return LogoFile(logo.Small);
|
|
|
|
case "medium":
|
|
if (logo.Medium == null)
|
|
return NotFound();
|
|
return LogoFile(logo.Medium);
|
|
|
|
case "large":
|
|
if (logo.Large == null)
|
|
return NotFound();
|
|
return LogoFile(logo.Large);
|
|
}
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
|
}
|
|
|
|
//case 4727 (F23): the type comes from the bytes, never from what was sent or stored, and the response may not run
|
|
//script: an SVG opened directly by its URL is shown, its script is not run (an <img> tag is not affected)
|
|
private FileStreamResult LogoFile(byte[] bytes)
|
|
{
|
|
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
|
return new FileStreamResult(new MemoryStream(bytes), ImageTypeOrNull(bytes) ?? "application/octet-stream");
|
|
}
|
|
|
|
//case 4727 (F23): PNG, JPEG, GIF, WebP or SVG, recognised by their content; null for anything else
|
|
internal static string ImageTypeOrNull(byte[] b)
|
|
{
|
|
if (b == null || b.Length < 12)
|
|
return null;
|
|
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 && b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A)
|
|
return "image/png";
|
|
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF)
|
|
return "image/jpeg";
|
|
if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8' && (b[4] == '7' || b[4] == '9') && b[5] == 'a')
|
|
return "image/gif";
|
|
if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P')
|
|
return "image/webp";
|
|
var head = System.Text.Encoding.UTF8.GetString(b, 0, System.Math.Min(b.Length, 4096)).TrimStart((char)0xFEFF, ' ', '\t', '\r', '\n');
|
|
if (head.StartsWith("<") && head.Contains("<svg", System.StringComparison.OrdinalIgnoreCase))
|
|
return "image/svg+xml";
|
|
return null;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Upload Logo
|
|
/// Max 500 KiB total (512000 bytes)
|
|
/// Must have full rights to Global object
|
|
/// </summary>
|
|
/// <param name="size">One of "small", "medium", "large"</param>
|
|
/// <returns>Accepted</returns>
|
|
[Authorize]
|
|
[HttpPost("{size}")]
|
|
//[DisableFormValueModelBinding]
|
|
[RequestSizeLimit(ServerBootConfig.MAX_LOGO_UPLOAD_BYTES)]
|
|
public async Task<IActionResult> UploadAsync([FromRoute] string size)
|
|
{
|
|
|
|
//https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-3.1#upload-small-files-with-buffered-model-binding-to-a-database
|
|
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
//case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
|
|
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
|
|
if (string.IsNullOrWhiteSpace(size))
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "size", "Size is required and must be one of 'small', 'medium' or 'large'"));
|
|
|
|
size = size.ToLowerInvariant();
|
|
if (size != "small" && size != "medium" && size != "large")
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "size", "Size parameter must be one of 'small', 'medium' or 'large'"));
|
|
|
|
//get the one and only logo object
|
|
var logo = await ct.Logo.FirstOrDefaultAsync();
|
|
if (logo == null)
|
|
{
|
|
logo = new Logo();
|
|
ct.Logo.Add(logo);
|
|
await ct.SaveChangesAsync();
|
|
}
|
|
|
|
var file = Request.Form.Files[0];
|
|
|
|
//var file=files[0];
|
|
using (var memoryStream = new MemoryStream())
|
|
{
|
|
await file.CopyToAsync(memoryStream);
|
|
if (memoryStream.Length > ServerBootConfig.MAX_LOGO_UPLOAD_BYTES)
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_LENGTH_EXCEEDED, null, $"Logo files must be smaller than {ServerBootConfig.MAX_LOGO_UPLOAD_BYTES} maximum"));
|
|
var imageType = ImageTypeOrNull(memoryStream.ToArray());
|
|
if (imageType == null)
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, "Logo files must be PNG, JPEG, GIF, WebP or SVG images"));
|
|
switch (size)
|
|
{
|
|
case "small":
|
|
logo.Small = memoryStream.ToArray();
|
|
logo.SmallType = imageType;
|
|
break;
|
|
case "medium":
|
|
logo.Medium = memoryStream.ToArray();
|
|
logo.MediumType = imageType;
|
|
break;
|
|
case "large":
|
|
logo.Large = memoryStream.ToArray();
|
|
logo.LargeType = imageType;
|
|
break;
|
|
|
|
}
|
|
await ct.SaveChangesAsync();
|
|
}
|
|
return Accepted();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Delete logo
|
|
/// </summary>
|
|
/// <param name="size"></param>
|
|
/// <returns>NoContent</returns>
|
|
[Authorize]
|
|
[HttpDelete("{size}")]
|
|
public async Task<IActionResult> DeleteLogo([FromRoute] string size)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
//case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
|
|
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
|
|
if (string.IsNullOrWhiteSpace(size))
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "size", "Size is required and must be one of 'small', 'medium' or 'large'"));
|
|
|
|
size = size.ToLowerInvariant();
|
|
if (size != "small" && size != "medium" && size != "large")
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "size", "Size parameter must be one of 'small', 'medium' or 'large'"));
|
|
|
|
//get the one and only logo object
|
|
var logo = await ct.Logo.FirstOrDefaultAsync();
|
|
if (logo != null)
|
|
{
|
|
switch (size)
|
|
{
|
|
case "small":
|
|
logo.Small = null;
|
|
logo.SmallType = string.Empty;
|
|
break;
|
|
case "medium":
|
|
logo.Medium = null;
|
|
logo.MediumType = string.Empty;
|
|
break;
|
|
case "large":
|
|
logo.Large = null;
|
|
logo.LargeType = string.Empty;
|
|
break;
|
|
|
|
}
|
|
await ct.SaveChangesAsync();
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
} |