Proxy/NetworkStorageHostProxyClient.cs
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace Sandbox;
public static partial class NetworkStorage
{
// ── Host "On Behalf Of" Methods ──
/// <summary>
/// Call an endpoint on behalf of another player (host only).
/// Requires the client's auth token as proof of consent.
/// Sends: host auth (Facepunch-verified) + client token + HMAC proxy signature
/// scoped to this project + endpoint to prevent cross-server replay.
/// </summary>
public static async Task<JsonElement?> CallEndpointAs( string targetSteamId, string clientToken, string slug, object input = null )
{
var endpoint = ResolveEndpointReference( slug );
ApplyEndpointReferenceConfiguration( endpoint );
slug = endpoint.Slug;
EnsureConfigured();
ClearLastEndpointError( slug );
// Same-machine shortcut: if the target is the host's own Steam ID (e.g. two
// editor instances on one machine share a Steam account), just call directly
// using the host's auth — no proxy headers needed.
if ( targetSteamId == Game.SteamId.ToString() )
{
if ( NetworkStorageLogConfig.LogProxy )
Log.Info( $"[NetworkStorage] {slug} same-account shortcut (targetSteamId == hostSteamId), calling directly" );
return await CallEndpoint( slug, input );
}
string url = null;
string bodyJson = null;
try
{
var routePath = $"/endpoints/{ProjectId}/{EscapeRouteSegment( slug )}";
var usesDedicatedSecret = ShouldUseDedicatedServerSecret( endpoint );
if ( !usesDedicatedSecret && TryRejectDedicatedServerPlayerAuth( slug ) )
return null;
var headers = usesDedicatedSecret ? BuildPublicHeaders() : await BuildAuthHeaders();
if ( usesDedicatedSecret )
{
TryAddDedicatedServerSecretHeaders( headers, endpoint );
headers["x-on-behalf-of"] = targetSteamId;
}
else
{
headers["x-on-behalf-of"] = targetSteamId;
headers["x-on-behalf-of-token"] = clientToken ?? "";
headers["x-proxy-signature"] = ComputeProxySignature( ApiKey, ProjectId, slug, targetSteamId, clientToken ?? "" );
}
url = BuildUrl( routePath, usesDedicatedSecret );
string result;
if ( input is not null )
{
bodyJson = JsonSerializer.Serialize( input );
if ( NetworkStorageLogConfig.LogRequests )
{
NetLog.Request( slug, $"POST (as {targetSteamId}) {bodyJson}" );
Log.Info( $"[NetworkStorage] {slug} request: POST (as {targetSteamId}) {ApiRoot}{routePath} body={bodyJson}" );
}
var content = Http.CreateJsonContent( input );
result = await Http.RequestStringAsync( url, "POST", content, headers );
}
else
{
if ( NetworkStorageLogConfig.LogRequests )
{
NetLog.Request( slug, $"GET (as {targetSteamId})" );
Log.Info( $"[NetworkStorage] {slug} request: GET (as {targetSteamId}) {ApiRoot}/endpoints/{ProjectId}/{slug}" );
}
result = await Http.RequestStringAsync( url, "GET", null, headers );
}
if ( NetworkStorageLogConfig.LogResponses )
Log.Info( $"[NetworkStorage] {slug} (as {targetSteamId}) → {result}" );
var parsed = ParseResponse( slug, result );
if ( parsed.HasValue && NetworkStorageLogConfig.LogResponses )
NetLog.Response( slug, TruncateJson( parsed.Value ) );
return parsed;
}
catch ( System.Net.Http.HttpRequestException httpEx )
{
var status = httpEx.StatusCode.HasValue ? $"{(int)httpEx.StatusCode.Value} {httpEx.StatusCode.Value}" : "unknown";
if ( NetworkStorageLogConfig.LogErrors )
{
Log.Warning( $"[NetworkStorage] {slug} (as {targetSteamId}) FAILED — HTTP {status}" );
Log.Warning( $"[NetworkStorage] URL: {ApiRoot}/endpoints/{ProjectId}/{slug}" );
Log.Warning( $"[NetworkStorage] Method: {( input is not null ? "POST" : "GET" )}" );
if ( bodyJson != null )
Log.Warning( $"[NetworkStorage] Body: {bodyJson}" );
Log.Warning( $"[NetworkStorage] Note: s&box Http API does not expose error response bodies — check server logs for details" );
NetLog.Error( slug, $"HTTP {status}" );
}
RecordEndpointError( slug, "HTTP_ERROR", $"HTTP {status}" );
return null;
}
catch ( Exception ex )
{
if ( NetworkStorageLogConfig.LogErrors )
{
Log.Warning( $"[NetworkStorage] {slug} (as {targetSteamId}) FAILED — {ex.Message}" );
Log.Warning( $"[NetworkStorage] URL: {ApiRoot}/endpoints/{ProjectId}/{slug}" );
Log.Warning( $"[NetworkStorage] Method: {( input is not null ? "POST" : "GET" )}" );
if ( bodyJson != null )
Log.Warning( $"[NetworkStorage] Body: {bodyJson}" );
Log.Warning( $"[NetworkStorage] Exception: {ex}" );
NetLog.Error( slug, ex.Message );
}
RecordEndpointError( slug, "REQUEST_FAILED", ex.Message );
return null;
}
}
/// <summary>
/// Read a document on behalf of another player (host only).
/// Requires the client's auth token as proof of consent.
/// </summary>
public static async Task<JsonElement?> GetDocumentAs( string targetSteamId, string clientToken, string collectionId, string documentId = null )
{
EnsureConfigured();
ClearLastEndpointError( "storage" );
// Same-machine shortcut: same Steam account as host, call directly
if ( targetSteamId == Game.SteamId.ToString() )
{
if ( NetworkStorageLogConfig.LogProxy )
Log.Info( $"[NetworkStorage] storage/{collectionId} same-account shortcut, calling directly" );
return await GetDocument( collectionId, documentId );
}
try
{
var docId = documentId ?? targetSteamId;
var path = $"/storage/{EscapeRouteSegment( ProjectId )}/{EscapeRouteSegment( collectionId )}/{EscapeRouteSegment( docId )}";
var usesDedicatedSecret = TryBuildDedicatedStorageHeaders( out var headers );
if ( !usesDedicatedSecret && TryRejectDedicatedServerPlayerAuth( "storage" ) )
return null;
if ( usesDedicatedSecret )
{
headers["x-on-behalf-of"] = targetSteamId;
}
else
{
headers = await BuildAuthHeaders();
var slugKey = $"storage-{collectionId}";
headers["x-on-behalf-of"] = targetSteamId;
headers["x-on-behalf-of-token"] = clientToken ?? "";
headers["x-proxy-signature"] = ComputeProxySignature( ApiKey, ProjectId, slugKey, targetSteamId, clientToken ?? "" );
}
var url = BuildUrl( path );
if ( NetworkStorageLogConfig.LogRequests )
NetLog.Request( "storage", $"GET (as {targetSteamId}) {collectionId}/{docId}" );
var result = await Http.RequestStringAsync( url, "GET", null, headers );
if ( NetworkStorageLogConfig.LogResponses )
Log.Info( $"[NetworkStorage] storage (as {targetSteamId}) → {TruncateJson( result, 300 )}" );
var parsed = ParseResponse( "storage", result );
if ( parsed.HasValue && NetworkStorageLogConfig.LogResponses )
NetLog.Response( "storage", $"OK ({result.Length} bytes)" );
return parsed;
}
catch ( Exception ex )
{
if ( IsHttpNotFoundException( ex ) )
{
RecordStorageNotFound( $"{collectionId}/{documentId ?? targetSteamId}" );
return null;
}
if ( NetworkStorageLogConfig.LogErrors )
{
Log.Warning( $"[NetworkStorage] GetDocumentAs({targetSteamId}): {ex.Message}" );
NetLog.Error( "storage", ex.Message );
}
RecordEndpointError( "storage", "REQUEST_FAILED", ex.Message );
return null;
}
}
}