Files
raven/server/AyaNova/Controllers/LogoController.cs
John Cardinal 289058bad2 case 4727 - logo uploads checked by content, change rights, served sandboxed
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>
2026-10-24 20:56:56 -07:00

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();
}
}
}