mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace RadicadeServer.Storage;
|
||||
|
||||
public sealed record StudioAssetUpload(string Filename, string Hash, long Length);
|
||||
|
||||
public static class StudioAssetStorage
|
||||
{
|
||||
|
||||
|
||||
public static string Filename(byte[] digest)
|
||||
{
|
||||
var value = new BigInteger(digest, isUnsigned: true, isBigEndian: false);
|
||||
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
var result = "";
|
||||
while (value > 0)
|
||||
{
|
||||
value = BigInteger.DivRem(value, 36, out var remainder);
|
||||
result = alphabet[(int)remainder] + result;
|
||||
}
|
||||
return result.PadLeft(50, '0')[..40];
|
||||
}
|
||||
|
||||
public static async Task<StudioAssetUpload> Save(IFormFile file, CancellationToken cancellation)
|
||||
{
|
||||
await using var input = file.OpenReadStream();
|
||||
return await Save(input, cancellation);
|
||||
}
|
||||
|
||||
public static async Task<StudioAssetUpload> Save(Stream input, CancellationToken cancellation)
|
||||
{
|
||||
Directory.CreateDirectory(DBConfig.CDNStorage);
|
||||
var temporary = Path.Combine(DBConfig.CDNStorage, Guid.NewGuid() + ".uploading");
|
||||
try
|
||||
{
|
||||
await using (var output = System.IO.File.Create(temporary))
|
||||
await input.CopyToAsync(output, cancellation);
|
||||
var length = new FileInfo(temporary).Length;
|
||||
byte[] hash;
|
||||
await using (var storedFile = System.IO.File.OpenRead(temporary))
|
||||
hash = await SHA256.HashDataAsync(storedFile, cancellation);
|
||||
var filename = Filename(hash);
|
||||
var destination = Path.Combine(DBConfig.CDNStorage, filename);
|
||||
try { System.IO.File.Move(temporary, destination); }
|
||||
catch (IOException) when (System.IO.File.Exists(destination)) { }
|
||||
return new StudioAssetUpload(filename, Convert.ToBase64String(hash), length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (System.IO.File.Exists(temporary)) System.IO.File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user