:3
This commit is contained in:
@@ -0,0 +1,2174 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Mocha2023.Auth;
|
||||
using Mocha2023.Classes;
|
||||
using Mocha2023.Classes.DBs;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Mocha2023.Classes.ApiProtection]
|
||||
public class ImageController : ControllerBase
|
||||
{
|
||||
private const long MaxUploadBytes = 10 * 1024 * 1024;
|
||||
private const int MaxServedDimension = 2048;
|
||||
private const int ShareCameraImageType = 1;
|
||||
private const int OutfitThumbnailImageType = 2;
|
||||
private const int ProfileThumbnailImageType = 4;
|
||||
private const int PublicImageAccessibility = 1;
|
||||
private const int MaxDailyImageUploadsPerAccount = 100;
|
||||
private const long MaxDailyImageBytesPerAccount = 256L * 1024L * 1024L;
|
||||
private const int MaxStoredImagesPerAccount = 2_000;
|
||||
private const int MaxBulkImageIds = 200;
|
||||
private const int MaxUploadAttemptsPerMinutePerAccount = 10;
|
||||
private const int MaxUploadAttemptsPerHourPerAccount = 60;
|
||||
private const int MaxUploadAttemptsPerMinutePerIp = 24;
|
||||
private const int MaxUploadAttemptsPerHourPerIp = 180;
|
||||
private const int MaxGlobalUploadAttemptsPerMinute = 80;
|
||||
private const int MaxGlobalUploadAttemptsPerHour = 600;
|
||||
private const int MaxConcurrentImageUploads = 6;
|
||||
private const int MaxGlobalNewImagesPerHour = 120;
|
||||
private const long MaxGlobalImageBytesPerHour = 512L * 1024L * 1024L;
|
||||
private const long MaxGlobalImageBytesPerDay = 2L * 1024L * 1024L * 1024L;
|
||||
private const int StaticImageCacheSeconds = 24 * 60 * 60;
|
||||
private const int RemoteImageCacheSeconds = 5 * 60;
|
||||
private const int RandomImageCacheSeconds = 30;
|
||||
private const int MissingImageCacheSeconds = 60;
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, string>
|
||||
PhotoPathsByImageId = new();
|
||||
private static readonly object SavedImageIdLock = new();
|
||||
private static readonly object LegacyPhotoIndexLock = new();
|
||||
private static bool LegacyPhotoIndexBuilt;
|
||||
private sealed class CachedImagePath
|
||||
{
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public DateTime ExpiresAtUtc { get; init; }
|
||||
}
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, CachedImagePath>
|
||||
LocalImagePathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte[]>
|
||||
ImageSignaturesByHash = new(StringComparer.Ordinal);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime>
|
||||
ImageIssueLogTimes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private sealed class UploadRateWindow
|
||||
{
|
||||
public Queue<DateTime> Attempts { get; } = new();
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
}
|
||||
|
||||
private sealed class StoredImageUsage
|
||||
{
|
||||
public DateTime CreatedAtUtc { get; init; }
|
||||
public long Bytes { get; init; }
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, UploadRateWindow>
|
||||
ImageUploadAttemptsByAccount = new();
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, UploadRateWindow>
|
||||
ImageUploadAttemptsByIp = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly UploadRateWindow GlobalImageUploadAttempts = new();
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, byte>
|
||||
ActiveImageUploadsByAccount = new();
|
||||
private static readonly SemaphoreSlim GlobalImageUploadGate =
|
||||
new(MaxConcurrentImageUploads, MaxConcurrentImageUploads);
|
||||
private static readonly Queue<StoredImageUsage> GlobalStoredImageUsage = new();
|
||||
private static readonly object GlobalStoredImageUsageLock = new();
|
||||
private static bool GlobalStoredImageUsageInitialized;
|
||||
|
||||
[HttpGet("/imageserver/{*img_path}")]
|
||||
[HttpGet("/imageserver-v2/{*img_path}")]
|
||||
public async Task<IActionResult> ImgServer(
|
||||
string img_path,
|
||||
int width = 0,
|
||||
int height = 0,
|
||||
string? sig = null)
|
||||
{
|
||||
if (width < 0 || height < 0 ||
|
||||
width > MaxServedDimension || height > MaxServedDimension)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid image dimensions." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
img_path = Uri.UnescapeDataString(img_path ?? string.Empty).TrimStart('/');
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid image path." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(img_path))
|
||||
img_path = "DefaultPFP.png";
|
||||
|
||||
string responseFileName = new string((Path.GetFileName(img_path) ?? "image.png")
|
||||
.Where(ch => char.IsLetterOrDigit(ch) || ch is '.' or '-' or '_')
|
||||
.Take(120)
|
||||
.ToArray());
|
||||
if (string.IsNullOrWhiteSpace(responseFileName))
|
||||
responseFileName = "image.png";
|
||||
|
||||
Response.Headers["Content-Disposition"] =
|
||||
$"inline; filename=\"{responseFileName}\"";
|
||||
Response.Headers["Access-Control-Allow-Origin"] = "*";
|
||||
Response.Headers["Access-Control-Allow-Headers"] =
|
||||
"Cache-Control, If-None-Match, If-Modified-Since";
|
||||
Response.Headers["Access-Control-Allow-Methods"] = "GET, OPTIONS";
|
||||
Response.Headers["Access-Control-Expose-Headers"] =
|
||||
"Content-Signature, Content-Type, ETag, Last-Modified";
|
||||
|
||||
bool cropSquare = Request.Query.ContainsKey("cropsquare") &&
|
||||
(string.Equals(Request.Query["cropsquare"].ToString(), "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
Request.Query["cropsquare"].ToString() == "1");
|
||||
bool shouldProcess = cropSquare || width > 0 || height > 0;
|
||||
bool wantsSignature = WantsP1Signature(sig);
|
||||
bool isRemoteAlias = LoadingScreenImageService.IsRemoteAlias(img_path);
|
||||
bool isRandomRequest = IsRandomRequest(img_path, out string randomFolder);
|
||||
int cacheSeconds = isRandomRequest
|
||||
? RandomImageCacheSeconds
|
||||
: isRemoteAlias
|
||||
? RemoteImageCacheSeconds
|
||||
: StaticImageCacheSeconds;
|
||||
|
||||
string baseImagesPath = Path.Combine(Program.dataDir, "Images");
|
||||
string foundLocalPath;
|
||||
|
||||
if (isRemoteAlias)
|
||||
{
|
||||
foundLocalPath = await LoadingScreenImageService.GetLocalImageAsync(
|
||||
img_path,
|
||||
HttpContext.RequestAborted);
|
||||
foundLocalPath ??= ResolveLocalImagePath(baseImagesPath, "RROs/DormRoom.jpg");
|
||||
}
|
||||
else if (isRandomRequest)
|
||||
{
|
||||
foundLocalPath = GetRandomImagePath(baseImagesPath, randomFolder);
|
||||
}
|
||||
else
|
||||
{
|
||||
foundLocalPath = ResolveLocalImagePath(baseImagesPath, img_path);
|
||||
}
|
||||
|
||||
if (foundLocalPath != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var localFile = new FileInfo(foundLocalPath);
|
||||
if ((shouldProcess || wantsSignature) &&
|
||||
localFile.Length > 32L * 1024L * 1024L)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, new
|
||||
{
|
||||
error = "Image is too large to transform or sign."
|
||||
});
|
||||
}
|
||||
|
||||
string variant =
|
||||
$"crop={cropSquare};width={width};height={height}";
|
||||
string etag = BuildImageEtag(localFile, variant);
|
||||
DateTimeOffset lastModified = new(localFile.LastWriteTimeUtc, TimeSpan.Zero);
|
||||
ApplyImageCacheHeaders(etag, lastModified, cacheSeconds);
|
||||
|
||||
if (ClientImageCacheIsFresh(etag, lastModified))
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
|
||||
if (!shouldProcess && !wantsSignature)
|
||||
{
|
||||
return PhysicalFile(
|
||||
Path.GetFullPath(foundLocalPath),
|
||||
GetMimeType(foundLocalPath),
|
||||
enableRangeProcessing: false);
|
||||
}
|
||||
|
||||
byte[] imageBytes = await System.IO.File.ReadAllBytesAsync(
|
||||
foundLocalPath,
|
||||
HttpContext.RequestAborted);
|
||||
if (shouldProcess)
|
||||
{
|
||||
imageBytes = await ProcessImageAsync(
|
||||
imageBytes,
|
||||
cropSquare,
|
||||
width,
|
||||
height,
|
||||
HttpContext.RequestAborted);
|
||||
return WithSignature(imageBytes, "image/png", sig);
|
||||
}
|
||||
|
||||
return WithSignature(imageBytes, GetMimeType(foundLocalPath), sig);
|
||||
}
|
||||
catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogImageIssue(
|
||||
$"serve:{img_path}:{ex.GetType().Name}:{ex.Message}",
|
||||
$"[image serve failed] path={img_path} error={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
string fallbackPath = Path.Combine(baseImagesPath, "DefaultPFP.png");
|
||||
if (System.IO.File.Exists(fallbackPath))
|
||||
{
|
||||
var fallbackFile = new FileInfo(fallbackPath);
|
||||
string fallbackEtag = BuildImageEtag(
|
||||
fallbackFile,
|
||||
$"fallback;crop={cropSquare};width={width};height={height}");
|
||||
DateTimeOffset fallbackLastModified =
|
||||
new(fallbackFile.LastWriteTimeUtc, TimeSpan.Zero);
|
||||
ApplyImageCacheHeaders(
|
||||
fallbackEtag,
|
||||
fallbackLastModified,
|
||||
MissingImageCacheSeconds);
|
||||
Response.Headers["X-Image-Fallback"] = "1";
|
||||
|
||||
if (ClientImageCacheIsFresh(fallbackEtag, fallbackLastModified))
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
|
||||
byte[] fallbackBytes = await System.IO.File.ReadAllBytesAsync(
|
||||
fallbackPath,
|
||||
HttpContext.RequestAborted);
|
||||
if (shouldProcess)
|
||||
{
|
||||
fallbackBytes = await ProcessImageAsync(
|
||||
fallbackBytes,
|
||||
cropSquare,
|
||||
width,
|
||||
height,
|
||||
HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
return WithSignature(fallbackBytes, "image/png", sig);
|
||||
}
|
||||
|
||||
Response.Headers["Cache-Control"] =
|
||||
$"public, max-age={MissingImageCacheSeconds}";
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
private static bool WantsP1Signature(string? signature)
|
||||
{
|
||||
return string.Equals(
|
||||
signature?.Trim(),
|
||||
"p1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildImageEtag(FileInfo file, string variant)
|
||||
{
|
||||
string fingerprint =
|
||||
$"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}|{variant}";
|
||||
byte[] hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(fingerprint));
|
||||
return $"\"{Convert.ToHexString(hash)[..24].ToLowerInvariant()}\"";
|
||||
}
|
||||
|
||||
private void ApplyImageCacheHeaders(
|
||||
string etag,
|
||||
DateTimeOffset lastModified,
|
||||
int maxAgeSeconds)
|
||||
{
|
||||
int staleSeconds = Math.Max(60, maxAgeSeconds * 4);
|
||||
Response.Headers["Cache-Control"] =
|
||||
$"public, max-age={maxAgeSeconds}, stale-while-revalidate={staleSeconds}";
|
||||
Response.Headers["ETag"] = etag;
|
||||
Response.Headers["Last-Modified"] = lastModified.ToUniversalTime().ToString("R");
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
}
|
||||
|
||||
private bool ClientImageCacheIsFresh(
|
||||
string etag,
|
||||
DateTimeOffset lastModified)
|
||||
{
|
||||
string ifNoneMatch = Request.Headers["If-None-Match"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(ifNoneMatch))
|
||||
{
|
||||
return ifNoneMatch
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(candidate =>
|
||||
candidate == "*" ||
|
||||
string.Equals(candidate, etag, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
string ifModifiedSince = Request.Headers["If-Modified-Since"].ToString();
|
||||
return DateTimeOffset.TryParse(ifModifiedSince, out DateTimeOffset cachedAt) &&
|
||||
lastModified <= cachedAt.ToUniversalTime().AddSeconds(1);
|
||||
}
|
||||
|
||||
private static void LogImageIssue(string key, string message)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
if (ImageIssueLogTimes.TryGetValue(key, out DateTime lastLogged) &&
|
||||
now - lastLogged < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ImageIssueLogTimes[key] = now;
|
||||
Console.WriteLine(message);
|
||||
|
||||
if (ImageIssueLogTimes.Count <= 2048)
|
||||
return;
|
||||
|
||||
DateTime cutoff = now.AddMinutes(-10);
|
||||
foreach (var entry in ImageIssueLogTimes)
|
||||
{
|
||||
if (entry.Value < cutoff)
|
||||
ImageIssueLogTimes.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private FileContentResult WithSignature(
|
||||
byte[] data,
|
||||
string contentType,
|
||||
string? signature)
|
||||
{
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
|
||||
if (WantsP1Signature(signature))
|
||||
{
|
||||
string contentHash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(data));
|
||||
byte[] signed = ImageSignaturesByHash.GetOrAdd(
|
||||
contentHash,
|
||||
_ => ImageSigner.Shared.SignImage(data));
|
||||
if (ImageSignaturesByHash.Count > 4096)
|
||||
ImageSignaturesByHash.Clear();
|
||||
|
||||
string headerValue =
|
||||
"key-id=KEY:RSA:p1.rec.net;data=" +
|
||||
Convert.ToBase64String(signed);
|
||||
|
||||
Response.Headers["Content-Signature"] = headerValue;
|
||||
Response.OnStarting(() =>
|
||||
{
|
||||
Response.Headers["Content-Signature"] = headerValue;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return File(data, contentType);
|
||||
}
|
||||
|
||||
[HttpGet("/api/images/v6")]
|
||||
public IActionResult GetImageV6(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return BadRequest();
|
||||
|
||||
name = Path.GetFileName(name);
|
||||
string baseImagesPath = Path.Combine(Program.dataDir, "Images");
|
||||
var savedImage = RecNetDB.SavedImages.FindAll()
|
||||
.OrderByDescending(image => image.CreatedAt)
|
||||
.FirstOrDefault(image => string.Equals(
|
||||
Path.GetFileName(image.PhotoPath),
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (savedImage == null ||
|
||||
!TryResolveContainedPath(baseImagesPath, savedImage.PhotoPath, out string found) ||
|
||||
!System.IO.File.Exists(found))
|
||||
{
|
||||
LogImageIssue(
|
||||
$"v6-missing:{name}",
|
||||
$"[ImageV6] Missing saved image metadata or file: {name}");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
int cheerCount = RecNetDB.CountPhotoCheers(savedImage.PhotoPath);
|
||||
int commentCount = RecNetDB.PhotoComments.FindAll()
|
||||
.Count(comment => string.Equals(
|
||||
comment.PhotoPath,
|
||||
savedImage.PhotoPath,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
long? callerAccountId = AuthStuff.GetPlayerId(Request);
|
||||
bool cheered = callerAccountId.HasValue &&
|
||||
RecNetDB.HasPhotoCheer(savedImage.PhotoPath, callerAccountId.Value);
|
||||
long imageId = GetOrCreateSavedImageId(savedImage);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Id = imageId,
|
||||
ImageName = name,
|
||||
PlayerId = checked((int)savedImage.AccountId),
|
||||
AccountId = checked((int)savedImage.AccountId),
|
||||
RoomId = savedImage.RoomId,
|
||||
PlayerEventId = savedImage.PlayerEventId,
|
||||
Accessibility = savedImage.Accessibility,
|
||||
IsDeleted = false,
|
||||
SavedImageType = savedImage.SavedImageType,
|
||||
CreatedAt = savedImage.CreatedAt,
|
||||
TaggedPlayerIds = savedImage.TaggedPlayerIds ?? new List<int>(),
|
||||
CheerCount = cheerCount,
|
||||
Cheered = cheered,
|
||||
IsCheered = cheered,
|
||||
CommentCount = commentCount
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/images/v4/uploadsaved")]
|
||||
[RequestSizeLimit(MaxUploadBytes + (256 * 1024))]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes + (256 * 1024))]
|
||||
public async Task<IActionResult> UploadSaved()
|
||||
{
|
||||
var player = AuthStuff.GetCurrentPlayer(Request);
|
||||
if (player?.Player == null)
|
||||
return Unauthorized(new { success = false, error = "Authentication required." });
|
||||
|
||||
if (!Request.HasFormContentType)
|
||||
return BadRequest(new { success = false, error = "Expected multipart form data." });
|
||||
|
||||
if (Request.ContentLength is long contentLength &&
|
||||
contentLength > MaxUploadBytes + (256 * 1024))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_too_large",
|
||||
message = "Images must be 10 MB or smaller."
|
||||
});
|
||||
}
|
||||
|
||||
if (!TryAcceptImageUploadAttempt(
|
||||
player.PlayerId,
|
||||
out int retryAfterSeconds,
|
||||
out string rateLimitScope))
|
||||
{
|
||||
Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_rate_limited",
|
||||
scope = rateLimitScope,
|
||||
retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
if (!ActiveImageUploadsByAccount.TryAdd(player.PlayerId, 0))
|
||||
{
|
||||
Response.Headers["Retry-After"] = "2";
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_already_in_progress",
|
||||
retryAfterSeconds = 2
|
||||
});
|
||||
}
|
||||
|
||||
bool globalGateAcquired = false;
|
||||
try
|
||||
{
|
||||
globalGateAcquired = await GlobalImageUploadGate.WaitAsync(
|
||||
0,
|
||||
HttpContext.RequestAborted);
|
||||
if (!globalGateAcquired)
|
||||
{
|
||||
Response.Headers["Retry-After"] = "2";
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_server_busy",
|
||||
retryAfterSeconds = 2
|
||||
});
|
||||
}
|
||||
|
||||
var form = await Request.ReadFormAsync(HttpContext.RequestAborted);
|
||||
var file = form.Files.FirstOrDefault();
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { success = false, error = "No file was uploaded." });
|
||||
|
||||
if (file.Length > MaxUploadBytes)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_too_large",
|
||||
message = "Images must be 10 MB or smaller."
|
||||
});
|
||||
}
|
||||
|
||||
string folderName = form["folder"].ToString().Trim();
|
||||
string imageMetaJson = form["imgMeta"].ToString();
|
||||
SavedImageMeta? imageMeta = ParseSavedImageMeta(imageMetaJson);
|
||||
if (!string.IsNullOrWhiteSpace(imageMetaJson) && imageMeta == null)
|
||||
return BadRequest(new { success = false, error = "Invalid saved-image metadata." });
|
||||
|
||||
if (imageMeta?.SavedImageType is int requestedType &&
|
||||
requestedType is < 0 or > 7)
|
||||
{
|
||||
return BadRequest(new { success = false, error = "Invalid saved-image type." });
|
||||
}
|
||||
|
||||
if (imageMeta?.Accessibility is int requestedAccessibility &&
|
||||
requestedAccessibility is < 0 or > 2)
|
||||
{
|
||||
return BadRequest(new { success = false, error = "Invalid image accessibility." });
|
||||
}
|
||||
|
||||
int inferredImageType = InferSavedImageType(folderName);
|
||||
int savedImageType = imageMeta?.SavedImageType is > 0
|
||||
? imageMeta.SavedImageType.Value
|
||||
: inferredImageType;
|
||||
int accessibility = imageMeta?.Accessibility ?? PublicImageAccessibility;
|
||||
long? taggedRoomId = imageMeta?.RoomId is > 0
|
||||
? imageMeta.RoomId
|
||||
: long.TryParse(form["roomId"].ToString(), out var parsedRoomId) &&
|
||||
parsedRoomId > 0
|
||||
? parsedRoomId
|
||||
: null;
|
||||
|
||||
string targetFolder = savedImageType == ShareCameraImageType && taggedRoomId.HasValue
|
||||
? $"WhereTaken/{taggedRoomId.Value}"
|
||||
: folderName.ToLowerInvariant() switch
|
||||
{
|
||||
"polaroid" => "PolaroidImages",
|
||||
"polaroidimages" => "PolaroidImages",
|
||||
"player" => "PlayerImages",
|
||||
"playerimages" => "PlayerImages",
|
||||
"photo" => "PlayerImages",
|
||||
"photos" => "PlayerImages",
|
||||
"avatar" => "PlayerImages",
|
||||
"saved" => "PlayerImages",
|
||||
"room" when taggedRoomId.HasValue => $"WhereTaken/{taggedRoomId.Value}",
|
||||
"wheretaken" when taggedRoomId.HasValue => $"WhereTaken/{taggedRoomId.Value}",
|
||||
_ => "PlayerImages"
|
||||
};
|
||||
|
||||
string baseImagesPath = Path.Combine(Program.dataDir, "Images", targetFolder);
|
||||
Directory.CreateDirectory(baseImagesPath);
|
||||
|
||||
string originalName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
string safeName = new string(originalName
|
||||
.Where(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_')
|
||||
.ToArray());
|
||||
if (string.IsNullOrWhiteSpace(safeName))
|
||||
safeName = "upload";
|
||||
if (safeName.Length > 48)
|
||||
safeName = safeName[..48];
|
||||
|
||||
byte[] fileBytes;
|
||||
await using (var input = file.OpenReadStream())
|
||||
using (var ms = new MemoryStream((int)Math.Min(file.Length, int.MaxValue)))
|
||||
{
|
||||
await input.CopyToAsync(ms, HttpContext.RequestAborted);
|
||||
if (ms.Length != file.Length || ms.Length > MaxUploadBytes)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_size_mismatch"
|
||||
});
|
||||
}
|
||||
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
SixLabors.ImageSharp.Formats.IImageFormat? format;
|
||||
SixLabors.ImageSharp.ImageInfo? info;
|
||||
try
|
||||
{
|
||||
format = SixLabors.ImageSharp.Image.DetectFormat(fileBytes);
|
||||
info = SixLabors.ImageSharp.Image.Identify(fileBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BadRequest(new { success = false, error = "That file is not a valid image." });
|
||||
}
|
||||
|
||||
if (format == null || info == null || info.Width <= 0 || info.Height <= 0 ||
|
||||
info.Width > 4096 || info.Height > 4096 ||
|
||||
(long)info.Width * info.Height > 16_777_216)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = "Images cannot exceed 4096 x 4096 pixels or 16 megapixels."
|
||||
});
|
||||
}
|
||||
|
||||
string detectedExtension =
|
||||
format.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty;
|
||||
if (detectedExtension is not ("png" or "jpg" or "jpeg" or "webp" or "gif" or "bmp"))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = "Use a PNG, JPG, WebP, GIF, or BMP image."
|
||||
});
|
||||
}
|
||||
|
||||
DateTime createdAt = DateTime.UtcNow;
|
||||
string contentHash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(fileBytes))
|
||||
.ToLowerInvariant();
|
||||
var taggedPlayerIds = imageMeta?.PlayerIds?
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToList() ?? new List<int>();
|
||||
|
||||
var savedImage = RecNetDB.SavedImages.Find(image =>
|
||||
image.AccountId == player.PlayerId)
|
||||
.Where(image =>
|
||||
image.SavedImageType == savedImageType &&
|
||||
image.RoomId == taggedRoomId &&
|
||||
image.ContentHash == contentHash &&
|
||||
image.CreatedAt >= createdAt.AddHours(-24))
|
||||
.OrderByDescending(image => image.CreatedAt)
|
||||
.FirstOrDefault(image =>
|
||||
TryResolveContainedPath(
|
||||
Path.Combine(Program.dataDir, "Images"),
|
||||
image.PhotoPath,
|
||||
out string duplicatePath) &&
|
||||
System.IO.File.Exists(duplicatePath));
|
||||
|
||||
bool createdNewImage = savedImage == null;
|
||||
if (createdNewImage)
|
||||
{
|
||||
DateTime dailyCutoff = createdAt.AddHours(-24);
|
||||
var accountImages = RecNetDB.SavedImages.Find(image =>
|
||||
image.AccountId == player.PlayerId)
|
||||
.ToList();
|
||||
var recentImages = accountImages
|
||||
.Where(image => image.CreatedAt >= dailyCutoff)
|
||||
.ToList();
|
||||
long recentBytes = recentImages.Sum(image => Math.Max(0, image.ByteLength));
|
||||
|
||||
if (accountImages.Count >= MaxStoredImagesPerAccount ||
|
||||
recentImages.Count >= MaxDailyImageUploadsPerAccount ||
|
||||
recentBytes + fileBytes.LongLength > MaxDailyImageBytesPerAccount)
|
||||
{
|
||||
Response.Headers["Retry-After"] = "3600";
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||
{
|
||||
success = false,
|
||||
error = "image_upload_quota_exceeded",
|
||||
message = "This account has reached its image storage quota."
|
||||
});
|
||||
}
|
||||
|
||||
if (!TryReserveGlobalStoredImage(
|
||||
fileBytes.LongLength,
|
||||
out int globalRetryAfterSeconds))
|
||||
{
|
||||
Response.Headers["Retry-After"] = globalRetryAfterSeconds.ToString();
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||
{
|
||||
success = false,
|
||||
error = "global_image_upload_quota_exceeded",
|
||||
retryAfterSeconds = globalRetryAfterSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
byte[]? shareCameraLogBytes = null;
|
||||
string savedFileName;
|
||||
string relativePath;
|
||||
|
||||
if (createdNewImage)
|
||||
{
|
||||
savedFileName = savedImageType == ShareCameraImageType
|
||||
? CreateNetworkImageName(detectedExtension)
|
||||
: $"{safeName}_{player.PlayerId}_{Guid.NewGuid():N}.{detectedExtension}";
|
||||
string savedPath = Path.Combine(baseImagesPath, savedFileName);
|
||||
string temporaryPath = savedPath + ".uploading";
|
||||
relativePath = Path.Combine(targetFolder, savedFileName).Replace('\\', '/');
|
||||
|
||||
try
|
||||
{
|
||||
await System.IO.File.WriteAllBytesAsync(
|
||||
temporaryPath,
|
||||
fileBytes,
|
||||
HttpContext.RequestAborted);
|
||||
System.IO.File.Move(temporaryPath, savedPath, overwrite: false);
|
||||
|
||||
savedImage = new RecNetDB.SavedImage
|
||||
{
|
||||
PhotoPath = relativePath,
|
||||
ImageId = 0,
|
||||
AccountId = player.PlayerId,
|
||||
RoomId = taggedRoomId,
|
||||
PlayerEventId = imageMeta?.PlayerEventId is > 0
|
||||
? imageMeta.PlayerEventId
|
||||
: null,
|
||||
SavedImageType = savedImageType,
|
||||
Accessibility = accessibility,
|
||||
TaggedPlayerIds = taggedPlayerIds,
|
||||
ContentHash = contentHash,
|
||||
LookupName = savedFileName.ToLowerInvariant(),
|
||||
ByteLength = fileBytes.LongLength,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
|
||||
if (!RecNetDB.SavedImages.Upsert(savedImage))
|
||||
throw new IOException("The image index could not be saved.");
|
||||
|
||||
RememberResolvedImagePath(relativePath, savedPath);
|
||||
RememberResolvedImagePath(savedFileName, savedPath);
|
||||
|
||||
if (savedImageType == ShareCameraImageType)
|
||||
shareCameraLogBytes = fileBytes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDeleteFile(temporaryPath);
|
||||
TryDeleteFile(savedPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
savedFileName = Path.GetFileName(savedImage!.PhotoPath);
|
||||
relativePath = savedImage.PhotoPath.Replace('\\', '/').TrimStart('/');
|
||||
createdAt = savedImage.CreatedAt;
|
||||
accessibility = savedImage.Accessibility;
|
||||
taggedRoomId = savedImage.RoomId;
|
||||
taggedPlayerIds = savedImage.TaggedPlayerIds ?? new List<int>();
|
||||
}
|
||||
|
||||
long savedImageId = GetOrCreateSavedImageId(savedImage!);
|
||||
string imageUrl = $"{ServerConfig.BaseURL}/imageserver-v2/{relativePath}";
|
||||
bool playerChanged = false;
|
||||
|
||||
if (savedImageType == ProfileThumbnailImageType)
|
||||
{
|
||||
player.Player.ProfileImage = relativePath;
|
||||
playerChanged = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(player.Player.BannerImage) &&
|
||||
string.Equals(
|
||||
Path.GetFileName(player.Player.BannerImage),
|
||||
savedFileName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
player.Player.BannerImage = relativePath;
|
||||
playerChanged = true;
|
||||
}
|
||||
|
||||
if (playerChanged)
|
||||
{
|
||||
PlayerDB.Players.Update(player);
|
||||
await NotiController.NotifyPlayerProfileUpdatedAsync(player.PlayerId);
|
||||
}
|
||||
|
||||
if (shareCameraLogBytes != null)
|
||||
{
|
||||
var taggedRoom = taggedRoomId.HasValue
|
||||
? RoomDB.GetRoom(taggedRoomId.Value)
|
||||
: null;
|
||||
DiscordLogger.LogShareCameraPhoto(
|
||||
player.PlayerId,
|
||||
player.Player.Username,
|
||||
shareCameraLogBytes,
|
||||
savedFileName,
|
||||
taggedRoomId,
|
||||
taggedRoom?.Name);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
duplicate = !createdNewImage,
|
||||
Id = savedImageId,
|
||||
ImageId = savedImageId,
|
||||
ImageName = savedFileName,
|
||||
PlayerId = checked((int)player.PlayerId),
|
||||
AccountId = checked((int)player.PlayerId),
|
||||
RoomId = taggedRoomId,
|
||||
PlayerEventId = savedImage!.PlayerEventId,
|
||||
Accessibility = accessibility,
|
||||
IsDeleted = false,
|
||||
SavedImageType = savedImageType,
|
||||
CreatedAt = createdAt,
|
||||
PlayerIds = taggedPlayerIds,
|
||||
TaggedPlayerIds = taggedPlayerIds,
|
||||
CheerCount = 0,
|
||||
Cheered = false,
|
||||
IsCheered = false,
|
||||
CommentCount = 0,
|
||||
path = relativePath,
|
||||
url = imageUrl
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (globalGateAcquired)
|
||||
GlobalImageUploadGate.Release();
|
||||
ActiveImageUploadsByAccount.TryRemove(player.PlayerId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryAcceptImageUploadAttempt(
|
||||
long accountId,
|
||||
out int retryAfterSeconds,
|
||||
out string scope)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
retryAfterSeconds = 0;
|
||||
scope = string.Empty;
|
||||
|
||||
UploadRateWindow accountWindow = ImageUploadAttemptsByAccount.GetOrAdd(
|
||||
accountId,
|
||||
_ => new UploadRateWindow());
|
||||
if (!TryConsumeUploadAttempt(
|
||||
accountWindow,
|
||||
now,
|
||||
MaxUploadAttemptsPerMinutePerAccount,
|
||||
MaxUploadAttemptsPerHourPerAccount,
|
||||
out retryAfterSeconds))
|
||||
{
|
||||
scope = "account";
|
||||
return false;
|
||||
}
|
||||
|
||||
string ipKey = GetUploadIpKey();
|
||||
UploadRateWindow ipWindow = ImageUploadAttemptsByIp.GetOrAdd(
|
||||
ipKey,
|
||||
_ => new UploadRateWindow());
|
||||
if (!TryConsumeUploadAttempt(
|
||||
ipWindow,
|
||||
now,
|
||||
MaxUploadAttemptsPerMinutePerIp,
|
||||
MaxUploadAttemptsPerHourPerIp,
|
||||
out retryAfterSeconds))
|
||||
{
|
||||
scope = "ip";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryConsumeUploadAttempt(
|
||||
GlobalImageUploadAttempts,
|
||||
now,
|
||||
MaxGlobalUploadAttemptsPerMinute,
|
||||
MaxGlobalUploadAttemptsPerHour,
|
||||
out retryAfterSeconds))
|
||||
{
|
||||
scope = "global";
|
||||
return false;
|
||||
}
|
||||
|
||||
CleanupUploadRateWindows(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string GetUploadIpKey()
|
||||
{
|
||||
string? cloudflareIp = Request.Headers["CF-Connecting-IP"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cloudflareIp) &&
|
||||
System.Net.IPAddress.TryParse(cloudflareIp, out var parsedCloudflareIp))
|
||||
{
|
||||
return parsedCloudflareIp.ToString();
|
||||
}
|
||||
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static bool TryConsumeUploadAttempt(
|
||||
UploadRateWindow window,
|
||||
DateTime now,
|
||||
int maxPerMinute,
|
||||
int maxPerHour,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
lock (window)
|
||||
{
|
||||
DateTime hourCutoff = now.AddHours(-1);
|
||||
while (window.Attempts.Count > 0 && window.Attempts.Peek() <= hourCutoff)
|
||||
window.Attempts.Dequeue();
|
||||
|
||||
DateTime minuteCutoff = now.AddMinutes(-1);
|
||||
DateTime? firstMinuteAttempt = null;
|
||||
int minuteCount = 0;
|
||||
foreach (DateTime attempt in window.Attempts)
|
||||
{
|
||||
if (attempt <= minuteCutoff)
|
||||
continue;
|
||||
|
||||
firstMinuteAttempt ??= attempt;
|
||||
minuteCount++;
|
||||
}
|
||||
|
||||
if (minuteCount >= maxPerMinute)
|
||||
{
|
||||
retryAfterSeconds = Math.Max(
|
||||
1,
|
||||
(int)Math.Ceiling(
|
||||
((firstMinuteAttempt ?? now).AddMinutes(1) - now).TotalSeconds));
|
||||
window.LastSeenUtc = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (window.Attempts.Count >= maxPerHour)
|
||||
{
|
||||
retryAfterSeconds = Math.Max(
|
||||
1,
|
||||
(int)Math.Ceiling(
|
||||
(window.Attempts.Peek().AddHours(1) - now).TotalSeconds));
|
||||
window.LastSeenUtc = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
window.Attempts.Enqueue(now);
|
||||
window.LastSeenUtc = now;
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupUploadRateWindows(DateTime now)
|
||||
{
|
||||
if (ImageUploadAttemptsByAccount.Count > 4096)
|
||||
{
|
||||
foreach (var entry in ImageUploadAttemptsByAccount)
|
||||
{
|
||||
if (now - entry.Value.LastSeenUtc > TimeSpan.FromHours(2))
|
||||
ImageUploadAttemptsByAccount.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
if (ImageUploadAttemptsByIp.Count > 4096)
|
||||
{
|
||||
foreach (var entry in ImageUploadAttemptsByIp)
|
||||
{
|
||||
if (now - entry.Value.LastSeenUtc > TimeSpan.FromHours(2))
|
||||
ImageUploadAttemptsByIp.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReserveGlobalStoredImage(
|
||||
long bytes,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
lock (GlobalStoredImageUsageLock)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
DateTime dayCutoff = now.AddHours(-24);
|
||||
|
||||
if (!GlobalStoredImageUsageInitialized)
|
||||
{
|
||||
foreach (var image in RecNetDB.SavedImages.FindAll()
|
||||
.Where(image => image.CreatedAt > dayCutoff)
|
||||
.OrderBy(image => image.CreatedAt))
|
||||
{
|
||||
GlobalStoredImageUsage.Enqueue(new StoredImageUsage
|
||||
{
|
||||
CreatedAtUtc = image.CreatedAt,
|
||||
Bytes = Math.Max(0, image.ByteLength)
|
||||
});
|
||||
}
|
||||
|
||||
GlobalStoredImageUsageInitialized = true;
|
||||
}
|
||||
while (GlobalStoredImageUsage.Count > 0 &&
|
||||
GlobalStoredImageUsage.Peek().CreatedAtUtc <= dayCutoff)
|
||||
{
|
||||
GlobalStoredImageUsage.Dequeue();
|
||||
}
|
||||
|
||||
DateTime hourCutoff = now.AddHours(-1);
|
||||
var hourly = GlobalStoredImageUsage
|
||||
.Where(item => item.CreatedAtUtc > hourCutoff)
|
||||
.ToList();
|
||||
long hourlyBytes = hourly.Sum(item => item.Bytes);
|
||||
long dailyBytes = GlobalStoredImageUsage.Sum(item => item.Bytes);
|
||||
|
||||
bool hourlyBlocked =
|
||||
hourly.Count >= MaxGlobalNewImagesPerHour ||
|
||||
hourlyBytes + bytes > MaxGlobalImageBytesPerHour;
|
||||
bool dailyBlocked = dailyBytes + bytes > MaxGlobalImageBytesPerDay;
|
||||
|
||||
if (hourlyBlocked || dailyBlocked)
|
||||
{
|
||||
DateTime hourlyUnlock = hourlyBlocked
|
||||
? (hourly.FirstOrDefault()?.CreatedAtUtc ?? now).AddHours(1)
|
||||
: now;
|
||||
DateTime dailyUnlock = dailyBlocked
|
||||
? (GlobalStoredImageUsage.FirstOrDefault()?.CreatedAtUtc ?? now).AddHours(24)
|
||||
: now;
|
||||
DateTime unlockAt = hourlyUnlock > dailyUnlock
|
||||
? hourlyUnlock
|
||||
: dailyUnlock;
|
||||
|
||||
retryAfterSeconds = Math.Max(
|
||||
60,
|
||||
(int)Math.Ceiling((unlockAt - now).TotalSeconds));
|
||||
return false;
|
||||
}
|
||||
|
||||
GlobalStoredImageUsage.Enqueue(new StoredImageUsage
|
||||
{
|
||||
CreatedAtUtc = now,
|
||||
Bytes = Math.Max(0, bytes)
|
||||
});
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedImageMeta? ParseSavedImageMeta(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SavedImageMeta>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int InferSavedImageType(string? folderName)
|
||||
{
|
||||
return folderName?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"outfit" or "outfitthumbnail" => OutfitThumbnailImageType,
|
||||
"profile" or "profilethumbnail" or "avatar" => ProfileThumbnailImageType,
|
||||
_ => ShareCameraImageType
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateNetworkImageName(string extension)
|
||||
{
|
||||
|
||||
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
Span<char> encoded = stackalloc char[25];
|
||||
var value = new System.Numerics.BigInteger(
|
||||
Guid.NewGuid().ToByteArray(),
|
||||
isUnsigned: true,
|
||||
isBigEndian: true);
|
||||
|
||||
for (int i = encoded.Length - 1; i >= 0; i--)
|
||||
{
|
||||
value = System.Numerics.BigInteger.DivRem(value, 36, out var remainder);
|
||||
encoded[i] = alphabet[(int)remainder];
|
||||
}
|
||||
|
||||
return $"{new string(encoded)}.{extension}";
|
||||
}
|
||||
|
||||
private sealed class SavedImageMeta
|
||||
{
|
||||
public List<int> PlayerIds { get; set; } = new();
|
||||
public int? SavedImageType { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public long? PlayerEventId { get; set; }
|
||||
public int? Accessibility { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RoomSavedImageResponse
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string ImageName { get; init; } = string.Empty;
|
||||
public int PlayerId { get; init; }
|
||||
public long? RoomId { get; init; }
|
||||
public long? PlayerEventId { get; init; }
|
||||
public long? ClubId { get; init; }
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public int Accessibility { get; init; }
|
||||
public bool IsDeleted { get; init; }
|
||||
public int SavedImageType { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public int CheerCount { get; init; }
|
||||
public bool Cheered { get; init; }
|
||||
public bool IsCheered { get; init; }
|
||||
public int CommentCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ImageCheerStateResponse
|
||||
{
|
||||
|
||||
public long Id { get; init; }
|
||||
public long ImageId { get; init; }
|
||||
public bool Cheered { get; init; }
|
||||
public bool IsCheered { get; init; }
|
||||
}
|
||||
|
||||
[HttpPost("/api/images/v1/modifyaccessibility")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> ModifySavedImageAccessibility()
|
||||
{
|
||||
var account = AuthStuff.GetCurrentPlayer(Request);
|
||||
if (account?.Player == null)
|
||||
return Unauthorized();
|
||||
|
||||
(string? imageName, long imageId, int? accessibility) =
|
||||
await ReadModifyAccessibilityRequestAsync();
|
||||
if (!accessibility.HasValue ||
|
||||
accessibility.Value is < 0 or > 2)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "Accessibility must be Private (0), Public (1), or FriendsOnly (2)."
|
||||
});
|
||||
}
|
||||
|
||||
string requestedName = Path.GetFileName(
|
||||
(imageName ?? string.Empty).Replace('\\', '/'));
|
||||
var savedImage = RecNetDB.SavedImages.FindAll()
|
||||
.Where(image =>
|
||||
image.SavedImageType == ShareCameraImageType &&
|
||||
(imageId <= 0 || image.ImageId == imageId) &&
|
||||
(string.IsNullOrWhiteSpace(requestedName) ||
|
||||
string.Equals(
|
||||
Path.GetFileName(image.PhotoPath),
|
||||
requestedName,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderByDescending(image => image.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (savedImage == null)
|
||||
return NotFound(new { error = "Image not found." });
|
||||
if (savedImage.AccountId != account.PlayerId)
|
||||
return StatusCode(403);
|
||||
|
||||
savedImage.Accessibility = accessibility.Value;
|
||||
if (!RecNetDB.SavedImages.Update(savedImage))
|
||||
return StatusCode(500, new { error = "Could not update image accessibility." });
|
||||
|
||||
long savedImageId = GetOrCreateSavedImageId(savedImage);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[IMAGE ACCESSIBILITY] image={Path.GetFileName(savedImage.PhotoPath)} " +
|
||||
$"id={savedImageId} accessibility={savedImage.Accessibility} " +
|
||||
$"player={account.PlayerId}");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Id = savedImageId,
|
||||
ImageId = savedImageId,
|
||||
ImageName = Path.GetFileName(savedImage.PhotoPath),
|
||||
Accessibility = savedImage.Accessibility
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/images/v4/room/{roomId}")]
|
||||
public IActionResult GetRoomImagesV4(long roomId, [FromQuery] int sort = 0, [FromQuery] int filter = 0, [FromQuery] int take = 100, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(skip, 0);
|
||||
long? callerId = AuthStuff.GetPlayerId(Request);
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
if (room == null ||
|
||||
((room.IsDorm ||
|
||||
room.Accessibility ==
|
||||
RoomDBClasses.RoomAccessibility.Private) &&
|
||||
(!callerId.HasValue ||
|
||||
!RoomDB.CanPlayerAccessRoom(room, callerId.Value))))
|
||||
{
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
string roomImagesPath = Path.Combine(Program.dataDir, "Images", "WhereTaken", roomId.ToString());
|
||||
|
||||
if (!Directory.Exists(roomImagesPath))
|
||||
return Ok(Array.Empty<RoomSavedImageResponse>());
|
||||
|
||||
string roomPrefix = $"WhereTaken/{roomId}/";
|
||||
var savedImagesByPath = RecNetDB.SavedImages.FindAll()
|
||||
.Where(image =>
|
||||
image.SavedImageType == ShareCameraImageType &&
|
||||
(image.RoomId == roomId ||
|
||||
image.PhotoPath.Replace('\\', '/')
|
||||
.StartsWith(roomPrefix, StringComparison.OrdinalIgnoreCase)))
|
||||
.GroupBy(
|
||||
image => image.PhotoPath.Replace('\\', '/').TrimStart('/'),
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderByDescending(image => image.CreatedAt).First(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var cheerCounts = RecNetDB.PhotoCheers.FindAll()
|
||||
.GroupBy(
|
||||
cheer => RecNetDB.NormalizePhotoPath(cheer.PhotoPath),
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
HashSet<string> callerCheeredPaths = callerId.HasValue
|
||||
? RecNetDB.PhotoCheers.Find(cheer => cheer.AccountId == callerId.Value)
|
||||
.Select(cheer => RecNetDB.NormalizePhotoPath(cheer.PhotoPath))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var commentCounts = RecNetDB.PhotoComments.FindAll()
|
||||
.GroupBy(
|
||||
comment => comment.PhotoPath.Replace('\\', '/').TrimStart('/'),
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var roomImages = new List<RoomSavedImageResponse>();
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
roomImagesPath,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly)
|
||||
.Where(file => _imageExtensions.Contains(
|
||||
Path.GetExtension(file).ToLowerInvariant())))
|
||||
{
|
||||
string filename = Path.GetFileName(file);
|
||||
string relativePath = $"{roomPrefix}{filename}";
|
||||
savedImagesByPath.TryGetValue(relativePath, out var savedImage);
|
||||
|
||||
long ownerId = savedImage?.AccountId ?? InferLegacyRoomPhotoOwner(filename);
|
||||
if (ownerId is <= 0 or > int.MaxValue)
|
||||
{
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
int accessibility = savedImage?.Accessibility ?? PublicImageAccessibility;
|
||||
bool visible = filter switch
|
||||
{
|
||||
|
||||
2 => accessibility == PublicImageAccessibility &&
|
||||
callerId.HasValue &&
|
||||
(savedImage?.TaggedPlayerIds?.Contains((int)callerId.Value) ?? false),
|
||||
|
||||
1 => accessibility == PublicImageAccessibility,
|
||||
|
||||
_ => accessibility == PublicImageAccessibility ||
|
||||
(callerId.HasValue &&
|
||||
(ownerId == callerId.Value ||
|
||||
(savedImage?.TaggedPlayerIds?.Contains((int)callerId.Value) ?? false)))
|
||||
};
|
||||
if (!visible)
|
||||
continue;
|
||||
|
||||
DateTime createdAt = savedImage?.CreatedAt is { } indexedCreatedAt &&
|
||||
indexedCreatedAt != default
|
||||
? indexedCreatedAt
|
||||
: System.IO.File.GetCreationTimeUtc(file);
|
||||
string normalizedRelativePath = RecNetDB.NormalizePhotoPath(relativePath);
|
||||
cheerCounts.TryGetValue(normalizedRelativePath, out int cheerCount);
|
||||
commentCounts.TryGetValue(relativePath, out int commentCount);
|
||||
bool cheered = callerCheeredPaths.Contains(normalizedRelativePath);
|
||||
|
||||
long imageId = savedImage != null
|
||||
? GetOrCreateSavedImageId(savedImage)
|
||||
: Math.Max(createdAt.Ticks, 1);
|
||||
PhotoPathsByImageId[imageId] = relativePath;
|
||||
|
||||
roomImages.Add(new RoomSavedImageResponse
|
||||
{
|
||||
Id = imageId,
|
||||
ImageName = filename,
|
||||
PlayerId = (int)ownerId,
|
||||
RoomId = roomId,
|
||||
PlayerEventId = savedImage?.PlayerEventId,
|
||||
ClubId = null,
|
||||
Description = string.Empty,
|
||||
Accessibility = accessibility,
|
||||
IsDeleted = false,
|
||||
SavedImageType = savedImage?.SavedImageType ?? ShareCameraImageType,
|
||||
CreatedAt = createdAt,
|
||||
CheerCount = cheerCount,
|
||||
Cheered = cheered,
|
||||
IsCheered = cheered,
|
||||
CommentCount = commentCount
|
||||
});
|
||||
}
|
||||
|
||||
IEnumerable<RoomSavedImageResponse> sorted = sort switch
|
||||
{
|
||||
|
||||
1 => roomImages
|
||||
.OrderByDescending(image => image.CheerCount)
|
||||
.ThenByDescending(image => image.CreatedAt),
|
||||
|
||||
2 => roomImages.OrderBy(image => image.CreatedAt),
|
||||
|
||||
_ => roomImages.OrderByDescending(image => image.CreatedAt)
|
||||
};
|
||||
|
||||
return Ok(sorted.Skip(skip).Take(take).ToList());
|
||||
}
|
||||
|
||||
private async Task<(string? ImageName, long ImageId, int? Accessibility)>
|
||||
ReadModifyAccessibilityRequestAsync()
|
||||
{
|
||||
string? imageName = null;
|
||||
long imageId = 0;
|
||||
int? accessibility = null;
|
||||
|
||||
void ReadValue(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
if (key.Equals("imageName", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("name", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
imageName = value;
|
||||
}
|
||||
else if ((key.Equals("id", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("imageId", StringComparison.OrdinalIgnoreCase)) &&
|
||||
long.TryParse(value, out long parsedImageId))
|
||||
{
|
||||
imageId = parsedImageId;
|
||||
}
|
||||
else if (key.Equals("accessibility", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value, out int parsedAccessibility))
|
||||
{
|
||||
accessibility = parsedAccessibility;
|
||||
}
|
||||
else
|
||||
{
|
||||
accessibility = value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"private" => 0,
|
||||
"public" => 1,
|
||||
"friendsonly" or "friends_only" or "friends only" => 2,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
ReadValue(pair.Key, pair.Value.FirstOrDefault());
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
ReadValue(pair.Key, pair.Value.FirstOrDefault());
|
||||
return (imageName, imageId, accessibility);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument body = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (body.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in body.RootElement.EnumerateObject())
|
||||
{
|
||||
string? value = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetRawText(),
|
||||
_ => null
|
||||
};
|
||||
ReadValue(property.Name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return (imageName, imageId, accessibility);
|
||||
}
|
||||
|
||||
[HttpGet("/api/images/v5/cheered/bulk")]
|
||||
[HttpPost("/api/images/v5/cheered/bulk")]
|
||||
[RequestSizeLimit(64 * 1024)]
|
||||
public async Task<IActionResult> GetImageCheerStates(
|
||||
[FromQuery(Name = "id")] long[] imageIds)
|
||||
{
|
||||
var requestedImageIds = new HashSet<long>(
|
||||
(imageIds ?? Array.Empty<long>())
|
||||
.Where(imageId => imageId > 0)
|
||||
.Take(MaxBulkImageIds));
|
||||
|
||||
foreach (var queryValue in Request.Query
|
||||
.Where(pair =>
|
||||
pair.Key.Contains("id", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(pair => pair.Value))
|
||||
{
|
||||
AddBulkImageIds(queryValue, requestedImageIds);
|
||||
}
|
||||
|
||||
if (HttpMethods.IsPost(Request.Method))
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync(
|
||||
HttpContext.RequestAborted);
|
||||
foreach (var pair in form.Where(pair =>
|
||||
pair.Key.Contains("id", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
foreach (string? value in pair.Value)
|
||||
AddBulkImageIds(value, requestedImageIds);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument body = await JsonDocument.ParseAsync(
|
||||
Request.Body,
|
||||
cancellationToken: HttpContext.RequestAborted);
|
||||
ExtractBulkImageIds(
|
||||
body.RootElement,
|
||||
requestedImageIds);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "Invalid bulk image ID payload."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long? accountId = AuthStuff.GetPlayerId(Request);
|
||||
var states = requestedImageIds
|
||||
.Select(imageId =>
|
||||
{
|
||||
bool cheered = accountId.HasValue &&
|
||||
TryResolvePhotoPathByImageId(imageId, out string photoPath) &&
|
||||
RecNetDB.HasPhotoCheer(
|
||||
photoPath,
|
||||
accountId.Value);
|
||||
return new ImageCheerStateResponse
|
||||
{
|
||||
Id = imageId,
|
||||
ImageId = imageId,
|
||||
Cheered = cheered,
|
||||
IsCheered = cheered
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine(
|
||||
$"[IMAGE CHEER BULK] method={Request.Method} " +
|
||||
$"player={accountId?.ToString() ?? "anonymous"} ids={states.Count}");
|
||||
return Ok(states);
|
||||
}
|
||||
|
||||
private static void AddBulkImageIds(
|
||||
string? value,
|
||||
ISet<long> imageIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || imageIds.Count >= MaxBulkImageIds)
|
||||
return;
|
||||
|
||||
foreach (string token in value.Split(
|
||||
new[] { ',', ';', ' ', '\t', '\r', '\n' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(token, out long imageId) && imageId > 0)
|
||||
imageIds.Add(imageId);
|
||||
if (imageIds.Count >= MaxBulkImageIds)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractBulkImageIds(
|
||||
JsonElement element,
|
||||
ISet<long> imageIds,
|
||||
bool idContext = true)
|
||||
{
|
||||
if (imageIds.Count >= MaxBulkImageIds)
|
||||
return;
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
bool propertyIsId =
|
||||
property.Name.Contains(
|
||||
"id",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
ExtractBulkImageIds(
|
||||
property.Value,
|
||||
imageIds,
|
||||
propertyIsId);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
ExtractBulkImageIds(item, imageIds, idContext);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
if (idContext &&
|
||||
element.TryGetInt64(out long imageId) &&
|
||||
imageId > 0)
|
||||
{
|
||||
imageIds.Add(imageId);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
if (idContext)
|
||||
AddBulkImageIds(element.GetString(), imageIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("/api/images/v1/cheer")]
|
||||
[HttpPut("/api/images/v1/cheer")]
|
||||
[HttpDelete("/api/images/v1/cheer")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> SetImageCheer()
|
||||
{
|
||||
var account = AuthStuff.GetCurrentPlayer(Request);
|
||||
if (account?.Player == null)
|
||||
return Unauthorized();
|
||||
|
||||
(long imageId, bool? cheer) = await ReadImageCheerRequestAsync();
|
||||
if (imageId <= 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[IMAGE CHEER] Missing image id; contentType={Request.ContentType ?? "none"} " +
|
||||
$"contentLength={Request.ContentLength?.ToString() ?? "unknown"}");
|
||||
return BadRequest(new { error = "Image id is required." });
|
||||
}
|
||||
if (!TryResolvePhotoPathByImageId(imageId, out string photoPath))
|
||||
{
|
||||
Console.WriteLine($"[IMAGE CHEER] Unknown image id: {imageId}");
|
||||
return NotFound(new { error = "Image not found." });
|
||||
}
|
||||
|
||||
bool shouldCheer = HttpMethods.IsDelete(Request.Method)
|
||||
? false
|
||||
: cheer ?? true;
|
||||
RecNetDB.SetPhotoCheer(
|
||||
photoPath,
|
||||
account.PlayerId,
|
||||
shouldCheer);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Id = imageId,
|
||||
ImageId = imageId,
|
||||
Cheered = shouldCheer,
|
||||
IsCheered = shouldCheer,
|
||||
CheerCount = RecNetDB.CountPhotoCheers(photoPath)
|
||||
});
|
||||
}
|
||||
|
||||
private static long InferLegacyRoomPhotoOwner(string filename)
|
||||
{
|
||||
const string prefix = "file_";
|
||||
if (!filename.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return 0;
|
||||
|
||||
int separator = filename.IndexOf('_', prefix.Length);
|
||||
return separator > prefix.Length &&
|
||||
long.TryParse(filename[prefix.Length..separator], out long accountId)
|
||||
? accountId
|
||||
: 0;
|
||||
}
|
||||
|
||||
private async Task<(long ImageId, bool? Cheer)> ReadImageCheerRequestAsync()
|
||||
{
|
||||
long imageId = 0;
|
||||
bool? cheer = null;
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
{
|
||||
if (pair.Key.Equals("id", StringComparison.OrdinalIgnoreCase) ||
|
||||
pair.Key.Equals("imageId", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
long.TryParse(pair.Value.FirstOrDefault(), out imageId);
|
||||
}
|
||||
else if (pair.Key.Equals("cheer", StringComparison.OrdinalIgnoreCase) ||
|
||||
pair.Key.Equals("cheered", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (bool.TryParse(pair.Value.FirstOrDefault(), out bool parsed))
|
||||
cheer = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
{
|
||||
if ((pair.Key.Equals("id", StringComparison.OrdinalIgnoreCase) ||
|
||||
pair.Key.Equals("imageId", StringComparison.OrdinalIgnoreCase)) &&
|
||||
long.TryParse(pair.Value.FirstOrDefault(), out long parsedId))
|
||||
{
|
||||
imageId = parsedId;
|
||||
}
|
||||
else if ((pair.Key.Equals("cheer", StringComparison.OrdinalIgnoreCase) ||
|
||||
pair.Key.Equals("cheered", StringComparison.OrdinalIgnoreCase)) &&
|
||||
bool.TryParse(pair.Value.FirstOrDefault(), out bool parsedCheer))
|
||||
{
|
||||
cheer = parsedCheer;
|
||||
}
|
||||
else if (imageId <= 0 &&
|
||||
long.TryParse(pair.Value.FirstOrDefault(), out long fallbackId) &&
|
||||
fallbackId > 0)
|
||||
{
|
||||
imageId = fallbackId;
|
||||
}
|
||||
}
|
||||
return (imageId, cheer);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument body = await JsonDocument.ParseAsync(Request.Body);
|
||||
ExtractImageCheerValues(body.RootElement, ref imageId, ref cheer);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return (imageId, cheer);
|
||||
}
|
||||
|
||||
private static void ExtractImageCheerValues(
|
||||
JsonElement element,
|
||||
ref long imageId,
|
||||
ref bool? cheer,
|
||||
string? propertyName = null)
|
||||
{
|
||||
bool isImageIdProperty =
|
||||
propertyName?.Equals("id", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
propertyName?.Contains("imageId", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
propertyName?.Equals("image", StringComparison.OrdinalIgnoreCase) == true;
|
||||
bool isCheerProperty =
|
||||
propertyName?.Contains("cheer", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
ExtractImageCheerValues(
|
||||
property.Value,
|
||||
ref imageId,
|
||||
ref cheer,
|
||||
property.Name);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
ExtractImageCheerValues(item, ref imageId, ref cheer);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out long number))
|
||||
{
|
||||
if ((isImageIdProperty || imageId <= 0) && number > 1)
|
||||
imageId = number;
|
||||
else if (isCheerProperty && number is 0 or 1)
|
||||
cheer = number == 1;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
string? text = element.GetString();
|
||||
if ((isImageIdProperty || imageId <= 0) &&
|
||||
long.TryParse(text, out long parsedId) &&
|
||||
parsedId > 1)
|
||||
{
|
||||
imageId = parsedId;
|
||||
}
|
||||
else if (isCheerProperty &&
|
||||
bool.TryParse(text, out bool parsedCheer))
|
||||
{
|
||||
cheer = parsedCheer;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
if (isCheerProperty || !cheer.HasValue)
|
||||
cheer = element.GetBoolean();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolvePhotoPathByImageId(
|
||||
long imageId,
|
||||
out string photoPath)
|
||||
{
|
||||
photoPath = string.Empty;
|
||||
if (imageId <= 0)
|
||||
return false;
|
||||
|
||||
string imageRoot = Path.Combine(Program.dataDir, "Images");
|
||||
if (PhotoPathsByImageId.TryGetValue(imageId, out string? cachedPath) &&
|
||||
TryResolveContainedPath(imageRoot, cachedPath, out string cachedFile) &&
|
||||
System.IO.File.Exists(cachedFile))
|
||||
{
|
||||
photoPath = cachedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
var savedImage = RecNetDB.SavedImages.FindOne(image =>
|
||||
image.SavedImageType == ShareCameraImageType &&
|
||||
image.ImageId == imageId);
|
||||
if (savedImage == null &&
|
||||
imageId <= DateTime.MaxValue.Ticks)
|
||||
{
|
||||
var legacyCreatedAt = new DateTime(imageId, DateTimeKind.Utc);
|
||||
savedImage = RecNetDB.SavedImages.FindOne(image =>
|
||||
image.SavedImageType == ShareCameraImageType &&
|
||||
image.CreatedAt == legacyCreatedAt);
|
||||
}
|
||||
if (savedImage != null)
|
||||
{
|
||||
string normalized = savedImage.PhotoPath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/');
|
||||
if (TryResolveContainedPath(imageRoot, normalized, out string fullPath) &&
|
||||
System.IO.File.Exists(fullPath))
|
||||
{
|
||||
photoPath = normalized;
|
||||
PhotoPathsByImageId[imageId] = normalized;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
EnsureLegacyPhotoIndex(imageRoot);
|
||||
if (PhotoPathsByImageId.TryGetValue(imageId, out string? legacyPath) &&
|
||||
TryResolveContainedPath(imageRoot, legacyPath, out string legacyFile) &&
|
||||
System.IO.File.Exists(legacyFile))
|
||||
{
|
||||
photoPath = legacyPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void EnsureLegacyPhotoIndex(string imageRoot)
|
||||
{
|
||||
if (LegacyPhotoIndexBuilt)
|
||||
return;
|
||||
|
||||
lock (LegacyPhotoIndexLock)
|
||||
{
|
||||
if (LegacyPhotoIndexBuilt)
|
||||
return;
|
||||
|
||||
string[] legacyFolders = { "WhereTaken", "PolaroidImages" };
|
||||
int indexed = 0;
|
||||
foreach (string folder in legacyFolders)
|
||||
{
|
||||
string directory = Path.Combine(imageRoot, folder);
|
||||
if (!Directory.Exists(directory))
|
||||
continue;
|
||||
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
directory,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
if (!_imageExtensions.Contains(
|
||||
Path.GetExtension(file).ToLowerInvariant()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long legacyId = System.IO.File.GetCreationTimeUtc(file).Ticks;
|
||||
string relativePath = Path.GetRelativePath(imageRoot, file)
|
||||
.Replace('\\', '/');
|
||||
PhotoPathsByImageId.TryAdd(legacyId, relativePath);
|
||||
indexed++;
|
||||
if (indexed >= 50_000)
|
||||
break;
|
||||
}
|
||||
|
||||
if (indexed >= 50_000)
|
||||
break;
|
||||
}
|
||||
|
||||
LegacyPhotoIndexBuilt = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal static long GetOrCreateSavedImageId(
|
||||
RecNetDB.SavedImage savedImage)
|
||||
{
|
||||
string normalized = savedImage.PhotoPath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/');
|
||||
|
||||
lock (SavedImageIdLock)
|
||||
{
|
||||
bool changed = false;
|
||||
if (savedImage.ImageId <= 0)
|
||||
{
|
||||
|
||||
long candidate = Math.Max(savedImage.CreatedAt.Ticks, 2);
|
||||
while (RecNetDB.SavedImages.Exists(image =>
|
||||
image.ImageId == candidate &&
|
||||
image.PhotoPath != savedImage.PhotoPath))
|
||||
{
|
||||
candidate++;
|
||||
}
|
||||
|
||||
savedImage.ImageId = candidate;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
string lookupName = Path.GetFileName(normalized).ToLowerInvariant();
|
||||
if (!string.Equals(
|
||||
savedImage.LookupName,
|
||||
lookupName,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
savedImage.LookupName = lookupName;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
RecNetDB.SavedImages.Update(savedImage);
|
||||
|
||||
PhotoPathsByImageId[savedImage.ImageId] = normalized;
|
||||
return savedImage.ImageId;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRandomRequest(string img_path, out string folder)
|
||||
{
|
||||
folder = null;
|
||||
if (string.IsNullOrWhiteSpace(img_path))
|
||||
return false;
|
||||
|
||||
string trimmed = img_path.Trim('/');
|
||||
var parts = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length == 0)
|
||||
return false;
|
||||
|
||||
if (parts.Length == 1 && parts[0].Equals("random", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
folder = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts[^1].Equals("random", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
folder = string.Join('/', parts[..^1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly string[] _imageExtensions = { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp" };
|
||||
|
||||
private static string GetRandomImagePath(string baseImagesPath, string folder)
|
||||
{
|
||||
|
||||
string[] safeRoots =
|
||||
{
|
||||
"RROs",
|
||||
"RecNet",
|
||||
"LoadingScreen",
|
||||
"LoadingScreens",
|
||||
"CommunityBoard"
|
||||
};
|
||||
|
||||
string normalizedFolder = (folder ?? string.Empty)
|
||||
.Replace('\\', '/')
|
||||
.Trim('/');
|
||||
string selectedRoot = string.IsNullOrWhiteSpace(normalizedFolder)
|
||||
? "RROs"
|
||||
: safeRoots.FirstOrDefault(root =>
|
||||
normalizedFolder.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedFolder.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase))
|
||||
?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(selectedRoot) ||
|
||||
!TryResolveContainedPath(baseImagesPath, normalizedFolder.Length == 0 ? selectedRoot : normalizedFolder, out string searchRoot) ||
|
||||
!Directory.Exists(searchRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.EnumerateFiles(searchRoot, "*", SearchOption.AllDirectories)
|
||||
.Where(file => _imageExtensions.Contains(
|
||||
Path.GetExtension(file).ToLowerInvariant()))
|
||||
.Take(10_000)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
return null;
|
||||
|
||||
return files[Random.Shared.Next(files.Count)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[random image pick failed] {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("/data/event")]
|
||||
[RequestSizeLimit(64 * 1024)]
|
||||
public async Task<IActionResult> DataEvent()
|
||||
{
|
||||
string eventType = "unknown";
|
||||
string? eventParams = null;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
eventType = form["eventType"].FirstOrDefault() ?? eventType;
|
||||
eventParams = form["eventParams"].FirstOrDefault();
|
||||
}
|
||||
|
||||
eventType = new string(eventType
|
||||
.Take(80)
|
||||
.Where(ch => !char.IsControl(ch))
|
||||
.ToArray());
|
||||
|
||||
long? accountId = AuthStuff.GetPlayerId(Request);
|
||||
Console.WriteLine(
|
||||
$"[DATA EVENT] eventType={eventType} " +
|
||||
$"account={accountId?.ToString() ?? "anonymous"}");
|
||||
if (accountId.HasValue && !string.IsNullOrWhiteSpace(eventParams))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(eventParams);
|
||||
RecNetDB.ApplyChallengeTelemetry(accountId.Value, eventType, document.RootElement);
|
||||
if (eventType.Equals(
|
||||
"ugc_room_save_stats",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RoomController.TryApplySaveRequesterTelemetry(
|
||||
accountId.Value);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveLocalImagePath(string baseImagesPath, string img_path)
|
||||
{
|
||||
if (!Directory.Exists(baseImagesPath) ||
|
||||
string.IsNullOrWhiteSpace(img_path) ||
|
||||
!_imageExtensions.Contains(Path.GetExtension(img_path).ToLowerInvariant()))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string cacheKey = img_path.Replace('\\', '/').TrimStart('/');
|
||||
if (LocalImagePathCache.TryGetValue(cacheKey, out var cached) &&
|
||||
cached.ExpiresAtUtc > DateTime.UtcNow &&
|
||||
System.IO.File.Exists(cached.FullPath))
|
||||
{
|
||||
return cached.FullPath;
|
||||
}
|
||||
LocalImagePathCache.TryRemove(cacheKey, out _);
|
||||
|
||||
string[] candidateRelativePaths = new[]
|
||||
{
|
||||
img_path,
|
||||
Path.Combine("PlayerImages", img_path),
|
||||
Path.Combine("CustomPFPS", img_path),
|
||||
Path.Combine("PolaroidImages", img_path),
|
||||
Path.Combine("RROs", img_path),
|
||||
Path.Combine("RecNet", img_path)
|
||||
};
|
||||
|
||||
foreach (var relativePath in candidateRelativePaths)
|
||||
{
|
||||
if (TryResolveContainedPath(baseImagesPath, relativePath, out string candidate) &&
|
||||
System.IO.File.Exists(candidate))
|
||||
{
|
||||
RememberResolvedImagePath(cacheKey, candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
string fileName = Path.GetFileName(img_path);
|
||||
if (!string.Equals(fileName, img_path, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
string lookupName = fileName.ToLowerInvariant();
|
||||
var indexedImage = RecNetDB.SavedImages.FindOne(image =>
|
||||
image.LookupName == lookupName);
|
||||
if (indexedImage != null &&
|
||||
TryResolveContainedPath(
|
||||
baseImagesPath,
|
||||
indexedImage.PhotoPath,
|
||||
out string indexedPath) &&
|
||||
System.IO.File.Exists(indexedPath))
|
||||
{
|
||||
RememberResolvedImagePath(cacheKey, indexedPath);
|
||||
return indexedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void RememberResolvedImagePath(string key, string fullPath)
|
||||
{
|
||||
LocalImagePathCache[key] = new CachedImagePath
|
||||
{
|
||||
FullPath = fullPath,
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5)
|
||||
};
|
||||
|
||||
if (LocalImagePathCache.Count <= 4096)
|
||||
return;
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (var entry in LocalImagePathCache)
|
||||
{
|
||||
if (entry.Value.ExpiresAtUtc <= now)
|
||||
LocalImagePathCache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
int overflow = LocalImagePathCache.Count - 8192;
|
||||
if (overflow > 0)
|
||||
{
|
||||
foreach (string staleKey in LocalImagePathCache.Keys.Take(overflow))
|
||||
LocalImagePathCache.TryRemove(staleKey, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveContainedPath(
|
||||
string root,
|
||||
string relativePath,
|
||||
out string fullPath)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(relativePath) ||
|
||||
relativePath.IndexOf('\0') >= 0 ||
|
||||
Path.IsPathRooted(relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] segments = relativePath
|
||||
.Replace('\\', '/')
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Any(segment => segment is "." or ".."))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
string canonicalRoot = Path.GetFullPath(root)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string candidate = Path.GetFullPath(Path.Combine(canonicalRoot, relativePath));
|
||||
string requiredPrefix = canonicalRoot + Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(requiredPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
fullPath = candidate;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMimeType(string filePath)
|
||||
{
|
||||
var provider = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
|
||||
if (!provider.TryGetContentType(filePath, out var contentType))
|
||||
{
|
||||
contentType = "image/png";
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ProcessImageAsync(
|
||||
byte[] imageBytes,
|
||||
bool cropSquare,
|
||||
int width,
|
||||
int height,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var info = SixLabors.ImageSharp.Image.Identify(imageBytes);
|
||||
if (info == null || info.Width <= 0 || info.Height <= 0 ||
|
||||
info.Width > 8192 || info.Height > 8192 ||
|
||||
(long)info.Width * info.Height > 40_000_000)
|
||||
{
|
||||
throw new InvalidDataException("Image dimensions are unsafe to process.");
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream(imageBytes);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var image = await SixLabors.ImageSharp.Image.LoadAsync<Rgba32>(ms);
|
||||
|
||||
if (cropSquare)
|
||||
{
|
||||
int size = Math.Min(image.Width, image.Height);
|
||||
int x = (image.Width - size) / 2;
|
||||
int y = (image.Height - size) / 2;
|
||||
image.Mutate(ctx => ctx.Crop(new Rectangle(x, y, size, size)));
|
||||
|
||||
int targetSize = width > 0 ? width : height;
|
||||
if (targetSize > 0)
|
||||
{
|
||||
image.Mutate(ctx => ctx.Resize(new ResizeOptions
|
||||
{
|
||||
Size = new Size(targetSize, targetSize),
|
||||
Mode = ResizeMode.Max,
|
||||
Sampler = KnownResamplers.Lanczos3
|
||||
}));
|
||||
}
|
||||
}
|
||||
else if (width > 0 || height > 0)
|
||||
{
|
||||
int resizeWidth = width;
|
||||
int resizeHeight = height;
|
||||
|
||||
if (width > 0 && height == 0)
|
||||
{
|
||||
resizeHeight = (int)((double)image.Height / image.Width * width);
|
||||
}
|
||||
else if (height > 0 && width == 0)
|
||||
{
|
||||
resizeWidth = (int)((double)image.Width / image.Height * height);
|
||||
}
|
||||
|
||||
image.Mutate(ctx => ctx.Resize(resizeWidth, resizeHeight));
|
||||
}
|
||||
|
||||
using var output = new MemoryStream();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await image.SaveAsync(output, new PngEncoder());
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user