This file defines structures and methods for interacting with a network storage service, allowing game code to send requests to storage endpoints via HTTP.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Sandbox;
/// <summary>Which credentials a <see cref="NetworkStorageRequest"/> carries.</summary>
public enum NetworkStorageAuth
{
/// <summary>The player's Steam ID and s&box token, as the rest of the library sends them.</summary>
Player,
/// <summary>The project's public key only.</summary>
Public,
/// <summary>The dedicated server secret. Valid only on a dedicated server with a secure transport.</summary>
DedicatedSecret
}
/// <summary>
/// A request for <see cref="NetworkStorage.SendAsync"/>: one route under the project's API root.
/// Routes that have a typed helper (<c>/endpoints/...</c>) are refused, so callers cannot skip their checks.
/// </summary>
public sealed class NetworkStorageRequest
{
/// <summary>GET, POST, PUT, PATCH or DELETE. Defaults to GET.</summary>
public string Method { get; init; } = "GET";
/// <summary>Route path starting with "/", for example "/storage/{project}/{collection}". No scheme, host, query, or dot segments.</summary>
public string Path { get; init; }
/// <summary>Extra query values. Keys and values are escaped. The keys <c>apiKey</c>, <c>revisionTarget</c>, <c>includeStaged</c> and <c>secret-key</c> are reserved.</summary>
public Dictionary<string, string> Query { get; init; }
/// <summary>JSON body for POST, PUT, PATCH or DELETE. Not allowed on GET.</summary>
public object Body { get; init; }
/// <summary>Credentials to send. Defaults to the player.</summary>
public NetworkStorageAuth Auth { get; init; } = NetworkStorageAuth.Player;
/// <summary>
/// <c>Idempotency-Key</c> for a write (1 to 128 visible characters). When omitted, one is generated for this call and
/// reused by every retry of it. Ignored for GET.
/// </summary>
public string IdempotencyKey { get; init; }
/// <summary>
/// Use the API root the server advertises in server-info (<c>api.root</c>) instead of the configured version.
/// Fails with <c>API_ROOT_UNAVAILABLE</c> when the server advertises none.
/// </summary>
public bool UseServerRoot { get; init; }
}
/// <summary>
/// Outcome of <see cref="NetworkStorage.SendAsync"/>. Check <see cref="Ok"/>; nothing is thrown for a server or transport failure.
/// </summary>
public sealed class NetworkStorageResult
{
/// <summary>True for a 2xx reply whose body is empty or valid JSON.</summary>
public bool Ok { get; }
/// <summary>HTTP status of the reply. 0 when no reply arrived or the request was refused before it was sent.</summary>
public int Status { get; }
/// <summary>
/// Null when <see cref="Ok"/>. Otherwise the server's error code when it sent one, or a library code such as
/// <c>REQUEST_FAILED</c>, <c>RATE_LIMITED</c>, <c>NOT_FOUND</c> or <c>INVALID_PATH</c>.
/// </summary>
public string Code { get; }
/// <summary>Human-readable detail. Null when <see cref="Ok"/>.</summary>
public string Message { get; }
/// <summary>The reply body parsed as JSON, verbatim: nothing is unwrapped. Null when the body is empty or not JSON.</summary>
public JsonElement? Body { get; }
/// <summary>Seconds the server asked the caller to wait (Retry-After, else retryAfterSeconds). Null when not given.</summary>
public double? RetryAfterSeconds { get; }
/// <summary>The server's request id (<c>_requestId</c> or <c>error.requestId</c>), for support. Null when absent.</summary>
public string RequestId { get; }
internal NetworkStorageResult( bool ok, int status, string code, string message, JsonElement? body, double? retryAfterSeconds, string requestId )
{
Ok = ok;
Status = status;
Code = code;
Message = message;
Body = body;
RetryAfterSeconds = retryAfterSeconds;
RequestId = requestId;
}
}
public static partial class NetworkStorage
{
/// <summary>
/// Sends one request and returns the outcome. Never throws. Unlike the typed helpers, a failure here does not write the
/// endpoint error ledger; the caller reads <see cref="NetworkStorageResult.Code"/> instead.
/// Endpoints are refused with <c>USE_CALL_ENDPOINT</c>: they need the encrypted and signed path in <see cref="CallEndpoint"/>.
/// </summary>
public static async Task<NetworkStorageResult> SendAsync( NetworkStorageRequest request )
{
if ( request is null ) return Refused( "INVALID_REQUEST", "A request is required." );
try
{
if ( !TryValidateSendRequest( request, out var refusedCode, out var refusedMessage ) )
return Refused( refusedCode, refusedMessage );
if ( !IsConfigured ) AutoConfigure();
if ( !IsConfigured ) return Refused( "NOT_CONFIGURED", "Network Storage is not configured for this project." );
EnsureConfigured();
await _serverInfoTask;
var root = request.UseServerRoot ? ServerRootUrl() : ApiRoot;
if ( root is null )
return Refused( "API_ROOT_UNAVAILABLE", "The server did not advertise an API root (api.root in server-info)." );
var method = request.Method.ToUpperInvariant();
Dictionary<string, string> headers;
switch ( request.Auth )
{
case NetworkStorageAuth.Public:
headers = BuildPublicHeaders();
break;
case NetworkStorageAuth.DedicatedSecret:
if ( !TryBuildDedicatedStorageHeaders( out headers ) )
return Refused( "DEDICATED_SECRET_UNAVAILABLE", "The dedicated server secret is not available on this process or transport." );
break;
default:
if ( IsDedicatedServerProcess )
return Refused( "DEDICATED_SECRET_REQUIRED", DedicatedServerAuthUnavailableMessage() );
headers = await BuildAuthHeaders();
break;
}
if ( method != "GET" && request.IdempotencyKey is not null )
headers["Idempotency-Key"] = request.IdempotencyKey;
Func<HttpContent> createContent = null;
if ( request.Body is not null )
createContent = () => Http.CreateJsonContent( request.Body );
var url = BuildSendUrl( root, request.Path, request.Query );
if ( NetworkStorageLogConfig.LogRequests )
NetLog.Request( "send", $"{method} {request.Path}" );
var reply = await SendRawContentAsync( url, method, createContent, headers );
if ( NetworkStorageLogConfig.LogResponses )
Log.Info( $"[NetworkStorage] send {method} {request.Path} → HTTP {reply.Status}" );
return ReplyResult( request, reply );
}
catch ( Exception ex )
{
if ( NetworkStorageLogConfig.LogErrors )
Log.Warning( $"[NetworkStorage] send {request.Method} {request.Path} FAILED — {ex.Message}" );
return Refused( "REQUEST_FAILED", ex.Message );
}
}
private static NetworkStorageResult Refused( string code, string message )
{
if ( NetworkStorageLogConfig.LogErrors )
Log.Warning( $"[NetworkStorage] send refused: {code} — {message}" );
return new NetworkStorageResult( false, 0, code, message, null, null, null );
}
private static string ServerRootUrl() => ServerApiRoot is null ? null : $"{BaseUrl}{ServerApiRoot}";
private static string BuildSendUrl( string root, string path, Dictionary<string, string> query )
{
var url = $"{root}{path}?apiKey={Uri.EscapeDataString( ApiKey )}";
if ( query is not null )
{
foreach ( var pair in query )
url += $"&{Uri.EscapeDataString( pair.Key )}={Uri.EscapeDataString( pair.Value ?? "" )}";
}
if ( string.Equals( PublishTarget, "next", StringComparison.OrdinalIgnoreCase ) )
url += "&revisionTarget=next&includeStaged=true";
if ( IsCdnRoot( root ) )
url += $"&v={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
return url;
}
private static bool TryValidateSendRequest( NetworkStorageRequest request, out string code, out string message )
{
code = null;
message = null;
var method = request.Method?.ToUpperInvariant();
if ( method is not ( "GET" or "POST" or "PUT" or "PATCH" or "DELETE" ) )
{
code = "INVALID_METHOD";
message = "Method must be GET, POST, PUT, PATCH or DELETE.";
return false;
}
if ( !TryValidateSendPath( request.Path, out code, out message ) ) return false;
if ( method == "GET" && request.Body is not null )
{
code = "INVALID_REQUEST";
message = "A GET request cannot carry a body.";
return false;
}
if ( request.IdempotencyKey is not null && !IsValidIdempotencyKey( request.IdempotencyKey ) )
{
code = "INVALID_REQUEST";
message = "IdempotencyKey must be 1 to 128 visible characters.";
return false;
}
if ( request.Query is not null )
{
foreach ( var key in request.Query.Keys )
{
if ( string.IsNullOrEmpty( key ) || IsReservedQueryKey( key ) )
{
code = "INVALID_REQUEST";
message = $"Query key '{key}' is empty or reserved.";
return false;
}
}
}
return true;
}
private static bool TryValidateSendPath( string path, out string code, out string message )
{
code = null;
message = null;
if ( string.IsNullOrEmpty( path ) || path.Length < 2 || path[0] != '/' )
{
code = "INVALID_PATH";
message = "Path must start with '/'.";
return false;
}
if ( path.Contains( "//", StringComparison.Ordinal ) || path.Contains( "://", StringComparison.Ordinal ) )
{
code = "INVALID_PATH";
message = "Path must not contain a scheme, a host, or an empty segment.";
return false;
}
foreach ( var c in path )
{
if ( c == '\\' || c == '?' || c == '#' || c < ' ' || c == '\u007f' )
{
code = "INVALID_PATH";
message = "Path must not contain a backslash, query, fragment, or control character.";
return false;
}
}
// Checks run on decoded segments: "%2e%2e" is a dot segment and "%65ndpoints" is "endpoints" to the server's router.
var segments = path.Split( '/' );
for ( var i = 0; i < segments.Length; i++ )
{
var decoded = Uri.UnescapeDataString( segments[i] );
if ( decoded == "." || decoded == ".." )
{
code = "INVALID_PATH";
message = "Path must not contain dot segments.";
return false;
}
foreach ( var c in decoded )
{
if ( c == '/' || c == '\\' || c == '?' || c == '#' || c < ' ' || c == '\u007f' )
{
code = "INVALID_PATH";
message = "Path must not contain an encoded separator, query, fragment, or control character.";
return false;
}
}
segments[i] = decoded;
}
var first = segments[1];
if ( string.Equals( first, "endpoints", StringComparison.OrdinalIgnoreCase ) )
{
code = "USE_CALL_ENDPOINT";
message = "Endpoints are called with NetworkStorage.CallEndpoint, which applies the encrypted and signed request path.";
return false;
}
if ( string.Equals( first, "manage", StringComparison.OrdinalIgnoreCase ) )
{
code = "UNSUPPORTED_PATH";
message = "Routes under /manage are not available to game code.";
return false;
}
return true;
}
private static bool IsValidIdempotencyKey( string key )
{
if ( key.Length == 0 || key.Length > 128 ) return false;
foreach ( var c in key )
{
if ( c <= ' ' || c == '\u007f' ) return false;
}
return true;
}
private static bool IsReservedQueryKey( string key )
=> string.Equals( key, "apiKey", StringComparison.OrdinalIgnoreCase )
|| string.Equals( key, "revisionTarget", StringComparison.OrdinalIgnoreCase )
|| string.Equals( key, "includeStaged", StringComparison.OrdinalIgnoreCase )
|| string.Equals( key, "secret-key", StringComparison.OrdinalIgnoreCase );
private static NetworkStorageResult ReplyResult( NetworkStorageRequest request, RawReply reply )
{
var hasBody = !string.IsNullOrWhiteSpace( reply.Body );
JsonElement? body = null;
if ( hasBody && TryParseJsonBody( reply.Body, out var element ) ) body = element;
var requestId = ReadRequestId( body );
if ( reply.Ok )
{
if ( !hasBody || body.HasValue )
return new NetworkStorageResult( true, reply.Status, null, null, body, null, requestId );
// A 2xx body that is not JSON is a failure for a caller who asked for a response.
TryReadRawDocument( reply, out _, out var invalidCode, out var invalidMessage );
return Failed( request, reply.Status, invalidCode, invalidMessage, body, null, requestId );
}
ClassifyFailure( reply, reply.Status == 404 ? "NOT_FOUND" : "REQUEST_FAILED", out var code, out var message );
if ( request.Auth == NetworkStorageAuth.Player && string.Equals( code, "SBOX_AUTH_FAILED", StringComparison.OrdinalIgnoreCase ) )
InvalidateCachedAuthToken( $"send {request.Method} {request.Path} {code}" );
return Failed( request, reply.Status, code, message, body, RetryAfterSecondsOf( reply ), requestId );
}
private static NetworkStorageResult Failed( NetworkStorageRequest request, int status, string code, string message, JsonElement? body, double? retryAfterSeconds, string requestId )
{
if ( NetworkStorageLogConfig.LogErrors )
Log.Warning( $"[NetworkStorage] send {request.Method} {request.Path} HTTP {status} {code} — {message}" );
return new NetworkStorageResult( false, status, code, message, body, retryAfterSeconds, requestId );
}
private static double? RetryAfterSecondsOf( RawReply reply )
{
if ( double.TryParse( reply.RetryAfter, NumberStyles.Float, CultureInfo.InvariantCulture, out var header ) && header >= 0 )
return header;
return ReadBodySeconds( reply.Body, "retryAfterSeconds" ) ?? ReadBodySeconds( reply.Body, "retryAfterSec" );
}
private static string ReadRequestId( JsonElement? body )
{
if ( !body.HasValue || body.Value.ValueKind != JsonValueKind.Object ) return null;
var root = body.Value;
return ReadStringProperty( root, "_requestId" )
?? ( root.TryGetProperty( "error", out var error ) ? ReadStringProperty( error, "requestId" ) : null );
}
private static bool TryParseJsonBody( string text, out JsonElement element )
{
element = default;
try
{
element = JsonSerializer.Deserialize<JsonElement>( text );
return true;
}
catch ( JsonException )
{
return false;
}
}
}