mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
223 lines
8.4 KiB
C#
223 lines
8.4 KiB
C#
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<long?> 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<IActionResult> 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<object>();
|
|
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<ActionResult<UploadResponse?>> 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" });
|
|
}
|
|
}
|
|
}
|