mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
d471b418b9
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
dorknet-server-march_2020_03_10-win-x64.zip and
dorknet-clientpatch-march_2020_03_10.zip as Release assets.
Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
361 lines
16 KiB
C#
361 lines
16 KiB
C#
using Amazon.S3;
|
|
using Amazon.S3.Model;
|
|
using Amazon.Runtime;
|
|
|
|
namespace DorkNet.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Thin S3-compatible object-storage abstraction. Targets Garage in
|
|
/// production Coolify (one-click service); same SDK works against any
|
|
/// S3 endpoint for local dev (MinIO / LocalStack) or future migration
|
|
/// (Cloudflare R2, real AWS).
|
|
///
|
|
/// Two buckets, split by mutability rather than feature:
|
|
/// <list type="bullet">
|
|
/// <item><c>Buckets.Content</c> — hash-addressed, immutable, dedup'd.
|
|
/// HTR audio/holotar bytes, PV preview images, polaroids, camera
|
|
/// photos, profile avatars, room thumbnails. Same bytes are
|
|
/// referenced by many rooms / players, so they're keyed primarily
|
|
/// by hash and (for player-attributed content) sharded by
|
|
/// <c>{kind}/{playerId}/{filename}</c>.</item>
|
|
/// <item><c>Buckets.Saves</c> — owner-mutable user data. Dorm save
|
|
/// snapshots, custom-room save snapshots, invention saves, holotar
|
|
/// / video user uploads. Keyed by <c>{kind}/{ownerId or roomId}/
|
|
/// {filename}</c>. Old versions can be lifecycle'd off; daily
|
|
/// backup priority lives here.</item>
|
|
/// </list>
|
|
///
|
|
/// Routing happens via <see cref="BlobRouter"/> — every BlobName has
|
|
/// exactly one canonical (bucket, key). Removed the candidate-list
|
|
/// fallback the older 3-bucket layout needed because content was
|
|
/// scattered across feature-themed buckets.
|
|
///
|
|
/// **Fallback when no S3 is configured**: writes go to disk under
|
|
/// <c>data/object-fallback/{bucket}/{key}</c>. Keeps local single-instance
|
|
/// dev working without spinning up MinIO. Production Coolify always
|
|
/// has the env vars set so the disk path is a dev-only convenience.
|
|
///
|
|
/// Falls back silently to disk so the controllers don't have to
|
|
/// branch on configuration. <see cref="IsS3Configured"/> is exposed
|
|
/// for the migrator + admin tooling that needs to know the actual mode.
|
|
/// </summary>
|
|
public interface IObjectStorage
|
|
{
|
|
bool IsS3Configured { get; }
|
|
|
|
/// <summary>Upload bytes. Overwrites any existing object with the
|
|
/// same key. Returns the byte count actually written (matches
|
|
/// <paramref name="bytes"/>.Length in success cases; differs only
|
|
/// if the underlying store reports otherwise).</summary>
|
|
Task<long> PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default);
|
|
|
|
/// <summary>Download bytes for a given key. Returns null when the
|
|
/// object doesn't exist. Throws on transport errors so callers can
|
|
/// distinguish "not there" from "broken".</summary>
|
|
Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default);
|
|
|
|
/// <summary>True when an object with this key exists. Cheaper than
|
|
/// GetAsync when the caller only needs existence (e.g. CdnController
|
|
/// MISS check before falling back to default blob).</summary>
|
|
Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default);
|
|
}
|
|
|
|
public static class Buckets
|
|
{
|
|
public const string Content = "dorknet-content";
|
|
public const string Saves = "dorknet-saves";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single source of truth for "where does this filename live in S3?".
|
|
/// Read path (Cdn/Img controllers) and write path (Storage/Images
|
|
/// controllers, importers, mirrors) BOTH call <see cref="Route(string)"/>
|
|
/// with the same filename — guaranteed-consistent placement, no
|
|
/// candidate fallback chain, no read/write divergence.
|
|
///
|
|
/// Routing rules, in priority order:
|
|
/// 1. Save-shaped filenames (<c>dorm_p<player>_v*.dat</c>,
|
|
/// <c>room_<id>_v*.dat</c>, <c>invention_p<player>_*.dat</c>)
|
|
/// → <see cref="Buckets.Saves"/>, sharded by owner/room so per-owner
|
|
/// lifecycle policies can prune old versions independently.
|
|
/// 2. Everything else routes to <see cref="Buckets.Content"/> by file
|
|
/// extension: <c>.htr</c> → <c>htr/</c>, image extensions →
|
|
/// <c>image/</c>, <c>.mp4</c> → <c>video/</c>, anything else →
|
|
/// <c>blob/</c>. Ownership is already encoded in the filename
|
|
/// (<c>img_p<player>_*</c>, <c>holotar_p<player>_*</c>,
|
|
/// …) so we don't shard by player in Content — a query like "all
|
|
/// of player N's stuff" goes through the DB
|
|
/// (<c>RoomDataBlobs.UploadedByPlayerId</c>) which is the source
|
|
/// of truth anyway.
|
|
///
|
|
/// Net effect: any two paths that share the same BlobName produce the
|
|
/// same (bucket, key). Same holotar bytes uploaded by a player then
|
|
/// later referenced from a room save → one S3 object, not two.
|
|
/// </summary>
|
|
public static class BlobRouter
|
|
{
|
|
public static (string Bucket, string Key) Route(string fileName)
|
|
{
|
|
// Saves first — these filename shapes are unambiguous.
|
|
if (TryGetPlayerId(fileName, "dorm_p", out var p)) return (Buckets.Saves, $"dorm/{p}/{fileName}");
|
|
if (TryGetPlayerId(fileName, "invention_p", out p)) return (Buckets.Saves, $"invention/{p}/{fileName}");
|
|
if (TryGetRoomId (fileName, out var r)) return (Buckets.Saves, $"room/{r}/{fileName}");
|
|
|
|
// Content, by file extension. Owner (if any) stays in the filename.
|
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
if (ext == ".htr") return (Buckets.Content, $"htr/{fileName}");
|
|
if (ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp")
|
|
return (Buckets.Content, $"image/{fileName}");
|
|
// All video formats land under video/ — the CdnController's
|
|
// `/video/{*path}` route maps every BlobName here verbatim, and
|
|
// keeping the prefix consistent across extensions means an
|
|
// admin grep'ing the bucket finds every uploaded clip in one
|
|
// place regardless of source container format.
|
|
if (ext is ".mp4" or ".m4v" or ".webm" or ".mov")
|
|
return (Buckets.Content, $"video/{fileName}");
|
|
return (Buckets.Content, $"blob/{fileName}");
|
|
}
|
|
|
|
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
|
|
{
|
|
playerId = 0;
|
|
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
|
var start = prefix.Length;
|
|
var end = fileName.IndexOf('_', start);
|
|
return end > start && long.TryParse(fileName[start..end], out playerId);
|
|
}
|
|
|
|
private static bool TryGetRoomId(string fileName, out long roomId)
|
|
{
|
|
roomId = 0;
|
|
const string prefix = "room_";
|
|
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
|
// Permissive: anything starting with "room_<digits>_" or
|
|
// "room_<digits>." routes to that room's save folder. Covers
|
|
// the canonical save snapshot (room_<id>_v<N>.dat) AND any
|
|
// room-tagged sidecar (room_<id>_<hash>.meta, room_<id>_thumb.jpg,
|
|
// future per-room attachments) — same parser, same folder,
|
|
// no separate matcher per content type.
|
|
var endUnderscore = fileName.IndexOf('_', prefix.Length);
|
|
var endDot = fileName.IndexOf('.', prefix.Length);
|
|
var end =
|
|
endUnderscore > 0 && (endDot < 0 || endUnderscore < endDot) ? endUnderscore :
|
|
endDot > 0 ? endDot : -1;
|
|
return end > prefix.Length && long.TryParse(fileName[prefix.Length..end], out roomId);
|
|
}
|
|
}
|
|
|
|
public class ObjectStorageService : IObjectStorage, IDisposable
|
|
{
|
|
private readonly IAmazonS3? _s3;
|
|
private readonly string _diskFallbackRoot;
|
|
private readonly ILogger<ObjectStorageService> _log;
|
|
|
|
public bool IsS3Configured => _s3 is not null;
|
|
|
|
public ObjectStorageService(IConfiguration config, ILogger<ObjectStorageService> log)
|
|
{
|
|
_log = log;
|
|
_diskFallbackRoot = Path.Combine(AppContext.BaseDirectory, "data", "object-fallback");
|
|
|
|
// Provider switch: env-driven. The Garage one-click service in
|
|
// Coolify exposes its endpoint on http://garage:3900 and supplies
|
|
// access/secret keys via env. ForcePathStyle is required because
|
|
// Garage (like MinIO) doesn't do virtual-host-style.
|
|
var endpoint = config["S3:Endpoint"];
|
|
var accessKey = config["S3:AccessKey"];
|
|
var secretKey = config["S3:SecretKey"];
|
|
if (string.IsNullOrWhiteSpace(endpoint)
|
|
|| string.IsNullOrWhiteSpace(accessKey)
|
|
|| string.IsNullOrWhiteSpace(secretKey))
|
|
{
|
|
_log.LogInformation(
|
|
"[storage] no S3 endpoint/credentials configured — falling back to disk at {Root}",
|
|
_diskFallbackRoot);
|
|
return;
|
|
}
|
|
var s3Config = new AmazonS3Config
|
|
{
|
|
ServiceURL = endpoint,
|
|
ForcePathStyle = true,
|
|
MaxErrorRetry = config.GetValue<int?>("S3:MaxErrorRetry") ?? 1,
|
|
// S3:TimeoutSeconds is the HARD ceiling enforced by the AWS
|
|
// SDK at the HttpClient level — it kills the entire upload
|
|
// request once exceeded, even if a CancellationToken passed
|
|
// by the caller is more generous. The 8-second value we
|
|
// shipped originally was too tight for the storage
|
|
// migrator (some HTR blobs are 25-50 MB; on a typical
|
|
// 10 Mbps home upload that's 20-40 s). 300 s gives plenty
|
|
// of headroom; per-call CancellationTokens still impose
|
|
// tighter bounds where the caller needs them (e.g. the
|
|
// 8 s CTS in StorageController.StoreBlobObjectAsync keeps
|
|
// in-game upload paths fast-failing).
|
|
Timeout = TimeSpan.FromSeconds(config.GetValue<int?>("S3:TimeoutSeconds") ?? 300),
|
|
// Garage requires *some* AuthenticationRegion string, value
|
|
// irrelevant. The S3 SDK signs requests against this region;
|
|
// using a non-empty placeholder avoids a SignatureDoesNotMatch
|
|
// error. R2 / real AWS will set this via env.
|
|
AuthenticationRegion = config["S3:Region"] ?? "garage",
|
|
// AWSSDK 3.7.412+ Flexible Checksums sends x-amz-checksum-*
|
|
// headers Garage rejects, breaking payload signatures. Limit
|
|
// checksum computation to operations that genuinely require one.
|
|
RequestChecksumCalculation = Amazon.Runtime.RequestChecksumCalculation.WHEN_REQUIRED,
|
|
ResponseChecksumValidation = Amazon.Runtime.ResponseChecksumValidation.WHEN_REQUIRED,
|
|
};
|
|
if (config.GetValue<bool>("S3:DisableTlsCertificateValidation"))
|
|
{
|
|
_log.LogWarning(
|
|
"[storage] S3 TLS certificate validation is disabled. Use only for private/self-hosted endpoints.");
|
|
s3Config.HttpClientFactory = new UnsafeCertificateHttpClientFactory();
|
|
}
|
|
|
|
_s3 = new AmazonS3Client(accessKey, secretKey, s3Config);
|
|
_log.LogInformation("[storage] S3 client configured for endpoint={Endpoint}", endpoint);
|
|
}
|
|
|
|
public async Task<long> PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
|
|
{
|
|
if (_s3 is { } s3)
|
|
{
|
|
async Task<PutObjectResponse> PutObjectAsync()
|
|
{
|
|
using var stream = new MemoryStream(bytes);
|
|
return await s3.PutObjectAsync(new PutObjectRequest
|
|
{
|
|
BucketName = bucket,
|
|
Key = key,
|
|
InputStream = stream,
|
|
ContentType = contentType,
|
|
UseChunkEncoding = false,
|
|
}, ct);
|
|
}
|
|
|
|
PutObjectResponse resp;
|
|
try
|
|
{
|
|
resp = await PutObjectAsync();
|
|
}
|
|
catch (AmazonS3Exception ex) when (IsNoSuchBucket(ex))
|
|
{
|
|
_log.LogWarning(
|
|
ex,
|
|
"[storage] bucket {Bucket} was missing while writing {Key}; creating it and retrying",
|
|
bucket,
|
|
key);
|
|
await CreateBucketAsync(s3, bucket, ct);
|
|
resp = await PutObjectAsync();
|
|
}
|
|
|
|
if (resp.HttpStatusCode is not (System.Net.HttpStatusCode.OK or System.Net.HttpStatusCode.Created))
|
|
throw new InvalidOperationException($"S3 PUT {bucket}/{key} returned {resp.HttpStatusCode}");
|
|
return bytes.Length;
|
|
}
|
|
|
|
// Disk fallback — used in dev when no S3 is configured.
|
|
var path = DiskPath(bucket, key);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await File.WriteAllBytesAsync(path, bytes, ct);
|
|
return bytes.Length;
|
|
}
|
|
|
|
public async Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default)
|
|
{
|
|
if (_s3 is { } s3)
|
|
{
|
|
try
|
|
{
|
|
using var resp = await s3.GetObjectAsync(bucket, key, ct);
|
|
using var ms = new MemoryStream();
|
|
await resp.ResponseStream.CopyToAsync(ms, ct);
|
|
return ms.ToArray();
|
|
}
|
|
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
var path = DiskPath(bucket, key);
|
|
return File.Exists(path) ? await File.ReadAllBytesAsync(path, ct) : null;
|
|
}
|
|
|
|
public async Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default)
|
|
{
|
|
if (_s3 is { } s3)
|
|
{
|
|
try
|
|
{
|
|
await s3.GetObjectMetadataAsync(bucket, key, ct);
|
|
return true;
|
|
}
|
|
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return false;
|
|
}
|
|
catch (AmazonS3Exception ex)
|
|
{
|
|
// Anything else — 403 (Garage's admin key lacks the
|
|
// HeadObject permission on this bucket), 5xx, transport
|
|
// wobble — is NOT a definitive "object missing" signal.
|
|
// Treat as "unknown" and return true so the upload path
|
|
// doesn't return 500 just because verification couldn't
|
|
// run. The caller logs the warning so the asymmetric
|
|
// ACL still shows up in telemetry.
|
|
_log.LogWarning(ex,
|
|
"[storage] HEAD {Bucket}/{Key} returned {Status}; treating as 'present (unverifiable)'",
|
|
bucket, key, ex.StatusCode);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogWarning(ex,
|
|
"[storage] HEAD {Bucket}/{Key} threw {Type}; treating as 'present (unverifiable)'",
|
|
bucket, key, ex.GetType().Name);
|
|
return true;
|
|
}
|
|
}
|
|
return File.Exists(DiskPath(bucket, key));
|
|
}
|
|
|
|
private string DiskPath(string bucket, string key) =>
|
|
Path.Combine(_diskFallbackRoot, bucket, key.Replace('/', Path.DirectorySeparatorChar));
|
|
|
|
private static bool IsNoSuchBucket(AmazonS3Exception ex) =>
|
|
ex.StatusCode == System.Net.HttpStatusCode.NotFound
|
|
|| string.Equals(ex.ErrorCode, "NoSuchBucket", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private async Task CreateBucketAsync(IAmazonS3 s3, string bucket, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await s3.PutBucketAsync(new PutBucketRequest
|
|
{
|
|
BucketName = bucket,
|
|
}, ct);
|
|
_log.LogInformation("[storage] created S3 bucket {Bucket}", bucket);
|
|
}
|
|
catch (AmazonS3Exception ex) when (
|
|
ex.StatusCode == System.Net.HttpStatusCode.Conflict
|
|
|| string.Equals(ex.ErrorCode, "BucketAlreadyExists", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(ex.ErrorCode, "BucketAlreadyOwnedByYou", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_log.LogInformation("[storage] S3 bucket {Bucket} already exists", bucket);
|
|
}
|
|
}
|
|
|
|
private sealed class UnsafeCertificateHttpClientFactory : HttpClientFactory
|
|
{
|
|
public override HttpClient CreateHttpClient(IClientConfig clientConfig)
|
|
{
|
|
var handler = new HttpClientHandler
|
|
{
|
|
ServerCertificateCustomValidationCallback =
|
|
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
|
|
};
|
|
return new HttpClient(handler);
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _s3?.Dispose();
|
|
}
|