using Amazon.S3; using Amazon.S3.Model; using Amazon.Runtime; namespace DorkNet.Server.Services; /// /// 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: /// /// Buckets.Content — 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 /// {kind}/{playerId}/{filename}. /// Buckets.Saves — owner-mutable user data. Dorm save /// snapshots, custom-room save snapshots, invention saves, holotar /// / video user uploads. Keyed by {kind}/{ownerId or roomId}/ /// {filename}. Old versions can be lifecycle'd off; daily /// backup priority lives here. /// /// /// Routing happens via — 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 /// data/object-fallback/{bucket}/{key}. 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. is exposed /// for the migrator + admin tooling that needs to know the actual mode. /// public interface IObjectStorage { bool IsS3Configured { get; } /// Upload bytes. Overwrites any existing object with the /// same key. Returns the byte count actually written (matches /// .Length in success cases; differs only /// if the underlying store reports otherwise). Task PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default); /// 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". Task GetAsync(string bucket, string key, CancellationToken ct = default); /// 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). Task ExistsAsync(string bucket, string key, CancellationToken ct = default); } public static class Buckets { public const string Content = "dorknet-content"; public const string Saves = "dorknet-saves"; } /// /// 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 /// with the same filename — guaranteed-consistent placement, no /// candidate fallback chain, no read/write divergence. /// /// Routing rules, in priority order: /// 1. Save-shaped filenames (dorm_p<player>_v*.dat, /// room_<id>_v*.dat, invention_p<player>_*.dat) /// → , sharded by owner/room so per-owner /// lifecycle policies can prune old versions independently. /// 2. Everything else routes to by file /// extension: .htr → htr/, image extensions → /// image/, .mp4 → video/, anything else → /// blob/. Ownership is already encoded in the filename /// (img_p<player>_*, holotar_p<player>_*, /// …) so we don't shard by player in Content — a query like "all /// of player N's stuff" goes through the DB /// (RoomDataBlobs.UploadedByPlayerId) 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. /// 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__" or // "room_." routes to that room's save folder. Covers // the canonical save snapshot (room__v.dat) AND any // room-tagged sidecar (room__.meta, room__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 _log; public bool IsS3Configured => _s3 is not null; public ObjectStorageService(IConfiguration config, ILogger 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("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("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("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 PutAsync(string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default) { if (_s3 is { } s3) { async Task 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 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 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(); }