using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Net.Http.Headers; using RadicadeServer.RecNet.CDN; namespace RadicadeServer.Storage { [Route("storage")] public class StorageAPI : Controller { private const long MaxUploadBytes = 256L * 1024 * 1024; private const int MaxMultipartPreambleBytes = 1024 * 1024; private static async Task FindFirstBoundaryAsync(Stream body, string boundary, CancellationToken cancellationToken) { var marker = System.Text.Encoding.ASCII.GetBytes("--" + boundary); if (marker.Length > MaxMultipartPreambleBytes) return null; var failure = new int[marker.Length]; for (var i = 1; i < marker.Length; i++) { var matched = failure[i - 1]; while (matched > 0 && marker[i] != marker[matched]) matched = failure[matched - 1]; if (marker[i] == marker[matched]) matched++; failure[i] = matched; } var buffer = new byte[8192]; var matchedBytes = 0; long scanned = 0; while (scanned <= MaxMultipartPreambleBytes) { var read = await body.ReadAsync(buffer.AsMemory(), cancellationToken); if (read == 0) break; var endOfBuffer = scanned + read; for (var i = 0; i < read; i++) { while (matchedBytes > 0 && buffer[i] != marker[matchedBytes]) matchedBytes = failure[matchedBytes - 1]; if (buffer[i] == marker[matchedBytes]) matchedBytes++; if (matchedBytes != marker.Length) continue; var markerStart = scanned + i + 1 - marker.Length; body.Position = markerStart + marker.Length; var nextByte = body.ReadByte(); var secondByte = body.ReadByte(); body.Position = endOfBuffer; if (markerStart <= MaxMultipartPreambleBytes && (nextByte == 13 && secondByte == 10 || nextByte == 45 && secondByte == 45) && (markerStart == 0 || ReadByteAt(body, markerStart - 1) == 10)) { body.Position = markerStart; return markerStart; } matchedBytes = failure[matchedBytes - 1]; } scanned = endOfBuffer; } return null; } private static int ReadByteAt(Stream body, long position) { var currentPosition = body.Position; body.Position = position; var value = body.ReadByte(); body.Position = currentPosition; return value; } [HttpPost("upload/unityassets"), Authorize] [RequestSizeLimit(MaxUploadBytes)] public async Task UploadUnityAssets() { if (!Request.HasFormContentType) return BadRequest(new { error = "multipart files required" }); var contentType = MediaTypeHeaderValue.Parse(Request.ContentType!); var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; if (string.IsNullOrWhiteSpace(boundary)) return BadRequest(new { error = "multipart boundary required" }); if (boundary.Length > 200) return BadRequest(new { error = "multipart boundary too long" }); var contentEncoding = Request.Headers["Content-Encoding"].ToString().Trim(); var isGzip = string.Equals(contentEncoding, "gzip", StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrEmpty(contentEncoding) && !isGzip) return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported content encoding" }); Stream multipartBody; await using var decompressedBody = isGzip ? new FileStream(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".studio-upload"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.DeleteOnClose) : null; if (decompressedBody != null) { await using (var gzip = new System.IO.Compression.GZipStream(Request.Body, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true)) { var buffer = new byte[81920]; long total = 0; int read; while ((read = await gzip.ReadAsync(buffer.AsMemory(), HttpContext.RequestAborted)) > 0) { total += read; if (total > MaxUploadBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); await decompressedBody.WriteAsync(buffer.AsMemory(0, read), HttpContext.RequestAborted); } } decompressedBody.Position = 0; multipartBody = decompressedBody; } else { Request.EnableBuffering(); multipartBody = Request.Body; } var preambleLength = await FindFirstBoundaryAsync(multipartBody, boundary, HttpContext.RequestAborted); if (preambleLength is null) return BadRequest(new { error = "multipart boundary not found within the preamble limit" }); if (preambleLength > 0) Console.WriteLine($"[StudioUpload] skipped {preambleLength} multipart preamble bytes"); var reader = new MultipartReader(boundary, multipartBody) { BodyLengthLimit = null, HeadersLengthLimit = 64 * 1024 }; var responses = new List(); MultipartSection? section; while ((section = await reader.ReadNextSectionAsync(HttpContext.RequestAborted)) != null) { if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) || !string.Equals(disposition.DispositionType.Value, "form-data", StringComparison.OrdinalIgnoreCase)) continue; var fileName = HeaderUtilities.RemoveQuotes(disposition.FileNameStar.HasValue ? disposition.FileNameStar : disposition.FileName).Value; if (string.IsNullOrWhiteSpace(fileName)) continue; responses.Add(await StudioAssetStorage.Save(section.Body, HttpContext.RequestAborted)); } if (responses.Count == 0) return BadRequest(new { error = "no files supplied" }); return Ok(responses); } [HttpPost("upload"), Authorize] [RequestSizeLimit(MaxUploadBytes)] public async Task> Upload() { var boundaryHeader = Request.ContentType == null ? null : MediaTypeHeaderValue.Parse(Request.ContentType).Boundary; var boundary = HeaderUtilities.RemoveQuotes(boundaryHeader).Value; if (string.IsNullOrWhiteSpace(boundary)) return BadRequest(new { error = "multipart boundary required" }); var contentEncoding = Request.Headers["Content-Encoding"].ToString().Trim(); var isGzip = string.Equals(contentEncoding, "gzip", StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrEmpty(contentEncoding) && !isGzip) return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported content encoding" }); Stream multipartBody; await using var decompressedBody = isGzip ? new FileStream(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".studio-upload"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.DeleteOnClose) : null; if (decompressedBody != null) { await using (var gzip = new System.IO.Compression.GZipStream(Request.Body, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true)) { var buffer = new byte[81920]; long total = 0; int read; while ((read = await gzip.ReadAsync(buffer.AsMemory(), HttpContext.RequestAborted)) > 0) { total += read; if (total > MaxUploadBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); await decompressedBody.WriteAsync(buffer.AsMemory(0, read), HttpContext.RequestAborted); } } decompressedBody.Position = 0; multipartBody = decompressedBody; } else { Request.EnableBuffering(); multipartBody = Request.Body; } var preambleLength = await FindFirstBoundaryAsync(multipartBody, boundary, HttpContext.RequestAborted); if (preambleLength is null) return BadRequest(new { error = "multipart boundary not found within the preamble limit" }); var reader = new MultipartReader(boundary, multipartBody) { BodyLengthLimit = null, HeadersLengthLimit = 64 * 1024 }; MultipartSection? section; while ((section = await reader.ReadNextSectionAsync(HttpContext.RequestAborted)) != null) { if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) || !string.Equals(disposition.DispositionType.Value, "form-data", StringComparison.OrdinalIgnoreCase)) continue; var fileName = HeaderUtilities.RemoveQuotes(disposition.FileNameStar.HasValue ? disposition.FileNameStar : disposition.FileName).Value; if (string.IsNullOrWhiteSpace(fileName)) continue; var stored = await StudioAssetStorage.Save(section.Body, HttpContext.RequestAborted); if (stored.Length <= 0) return BadRequest(new { error = "empty upload" }); return new UploadResponse(stored.Filename); } return BadRequest(new { error = "no file supplied" }); } } }