mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Upgraded API and authentication services with dynamic scaling, enhanced load balancing, and real-time interaction endpoints. - Introduced new commands to streamline local development and container builds. - Added new endpoints for retrieving Steam account information and managing connections. - Implemented a QR code authentication interface for Steam, enhancing user login experiences. - **Database Updates** - Rolled out comprehensive schema migrations that improve data integrity and indexing. - Introduced new tables for managing Steam user credentials and machine information. - **UI Enhancements** - Added refreshed animated assets and an improved QR code login flow for a more engaging experience. - Introduced new styled components for displaying friends and games. - **Maintenance** - Completed extensive refactoring and configuration updates to optimize performance and development workflows. - Updated logging configurations and improved error handling mechanisms. - Streamlined resource definitions in the configuration files. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
389 lines
14 KiB
C#
389 lines
14 KiB
C#
using SteamKit2;
|
|
using SteamKit2.Authentication;
|
|
|
|
namespace Steam
|
|
{
|
|
public class SteamAuthService
|
|
{
|
|
private readonly SteamClient _steamClient;
|
|
private readonly SteamUser _steamUser;
|
|
private readonly SteamFriends _steamFriends;
|
|
private readonly CallbackManager _manager;
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _callbackTask;
|
|
private readonly Dictionary<string, TaskCompletionSource<bool>> _authCompletionSources = new();
|
|
|
|
public SteamAuthService()
|
|
{
|
|
var configuration = SteamConfiguration.Create(config =>
|
|
{
|
|
config.WithHttpClientFactory(HttpClientFactory.CreateHttpClient);
|
|
config.WithMachineInfoProvider(new IMachineInfoProvider());
|
|
config.WithConnectionTimeout(TimeSpan.FromSeconds(10));
|
|
});
|
|
|
|
_steamClient = new SteamClient(configuration);
|
|
_manager = new CallbackManager(_steamClient);
|
|
_steamUser = _steamClient.GetHandler<SteamUser>() ?? throw new InvalidOperationException("SteamUser handler not available");
|
|
_steamFriends = _steamClient.GetHandler<SteamFriends>() ?? throw new InvalidOperationException("SteamFriends handler not available");
|
|
|
|
// Register basic callbacks
|
|
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
|
|
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
|
|
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
|
|
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
|
|
}
|
|
|
|
// Main login method - initiates QR authentication and sends SSE updates
|
|
public async Task StartQrLoginSessionAsync(HttpResponse response, string sessionId)
|
|
{
|
|
response.Headers.Append("Content-Type", "text/event-stream");
|
|
response.Headers.Append("Cache-Control", "no-cache");
|
|
response.Headers.Append("Connection", "keep-alive");
|
|
|
|
// Create a completion source for this session
|
|
var tcs = new TaskCompletionSource<bool>();
|
|
_authCompletionSources[sessionId] = tcs;
|
|
|
|
try
|
|
{
|
|
// Connect to Steam if not already connected
|
|
await EnsureConnectedAsync();
|
|
|
|
// Send initial status
|
|
await SendSseEvent(response, "status", new { message = "Starting QR authentication..." });
|
|
|
|
// Begin auth session
|
|
var authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(
|
|
new AuthSessionDetails
|
|
{
|
|
PlatformType = SteamKit2.Internal.EAuthTokenPlatformType.k_EAuthTokenPlatformType_SteamClient,
|
|
DeviceFriendlyName = "Nestri Cloud Gaming",
|
|
ClientOSType = EOSType.Linux5x
|
|
}
|
|
);
|
|
|
|
// Handle URL changes
|
|
authSession.ChallengeURLChanged = async () =>
|
|
{
|
|
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
|
};
|
|
|
|
// Send initial QR code URL
|
|
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
|
|
|
// Poll for authentication result
|
|
try
|
|
{
|
|
var pollResponse = await authSession.PollingWaitForResultAsync();
|
|
|
|
// Send credentials to client
|
|
await SendSseEvent(response, "credentials", new
|
|
{
|
|
username = pollResponse.AccountName,
|
|
refreshToken = pollResponse.RefreshToken
|
|
});
|
|
|
|
// Log in with obtained credentials
|
|
await SendSseEvent(response, "status", new { message = $"Logging in as '{pollResponse.AccountName}'..." });
|
|
|
|
_steamUser.LogOn(new SteamUser.LogOnDetails
|
|
{
|
|
Username = pollResponse.AccountName,
|
|
MachineName = "Nestri Cloud Gaming",
|
|
ClientOSType = EOSType.Linux5x,
|
|
AccessToken = pollResponse.RefreshToken
|
|
});
|
|
|
|
// Wait for login to complete (handled by OnLoggedOn callback)
|
|
await tcs.Task;
|
|
|
|
// Send final success message
|
|
await SendSseEvent(response, "login-successful", new
|
|
{
|
|
steamId = _steamUser.SteamID?.ConvertToUInt64(),
|
|
username = pollResponse.AccountName
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await SendSseEvent(response, "login-unsuccessful", new { error = ex.Message });
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await SendSseEvent(response, "error", new { message = ex.Message });
|
|
}
|
|
finally
|
|
{
|
|
// Clean up
|
|
_authCompletionSources.Remove(sessionId);
|
|
await response.Body.FlushAsync();
|
|
}
|
|
}
|
|
|
|
// Method to login with existing credentials and return result (no SSE)
|
|
public async Task<LoginResult> LoginWithCredentialsAsync(string username, string refreshToken)
|
|
{
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
var tcs = new TaskCompletionSource<bool>();
|
|
_authCompletionSources[sessionId] = tcs;
|
|
|
|
try
|
|
{
|
|
// Connect to Steam if not already connected
|
|
await EnsureConnectedAsync();
|
|
|
|
// Log in with provided credentials
|
|
_steamUser.LogOn(new SteamUser.LogOnDetails
|
|
{
|
|
Username = username,
|
|
MachineName = "Nestri Cloud Gaming",
|
|
AccessToken = refreshToken,
|
|
ClientOSType = EOSType.Linux5x,
|
|
});
|
|
|
|
// Wait for login to complete (handled by OnLoggedOn callback)
|
|
var success = await tcs.Task;
|
|
|
|
if (success)
|
|
{
|
|
return new LoginResult
|
|
{
|
|
Success = true,
|
|
SteamId = _steamUser.SteamID?.ConvertToUInt64(),
|
|
Username = username
|
|
};
|
|
}
|
|
else
|
|
{
|
|
return new LoginResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = "Login failed"
|
|
};
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new LoginResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = ex.Message
|
|
};
|
|
}
|
|
finally
|
|
{
|
|
_authCompletionSources.Remove(sessionId);
|
|
}
|
|
}
|
|
|
|
// Method to get user information - waits for all required callbacks to complete
|
|
public async Task<UserInfo> GetUserInfoAsync(string username, string refreshToken)
|
|
{
|
|
// First ensure we're logged in
|
|
var loginResult = await LoginWithCredentialsAsync(username, refreshToken);
|
|
if (!loginResult.Success)
|
|
{
|
|
throw new Exception($"Failed to log in: {loginResult.ErrorMessage}");
|
|
}
|
|
|
|
var userInfo = new UserInfo
|
|
{
|
|
SteamId = _steamUser.SteamID?.ConvertToUInt64() ?? 0,
|
|
Username = username
|
|
};
|
|
|
|
// Set up completion sources for each piece of information
|
|
var accountInfoTcs = new TaskCompletionSource<bool>();
|
|
var personaStateTcs = new TaskCompletionSource<bool>();
|
|
var emailInfoTcs = new TaskCompletionSource<bool>();
|
|
|
|
// Subscribe to one-time callbacks
|
|
var accountSub = _manager.Subscribe<SteamUser.AccountInfoCallback>(callback =>
|
|
{
|
|
userInfo.Country = callback.Country;
|
|
userInfo.PersonaName = callback.PersonaName;
|
|
accountInfoTcs.TrySetResult(true);
|
|
});
|
|
|
|
var personaSub = _manager.Subscribe<SteamFriends.PersonaStateCallback>(callback =>
|
|
{
|
|
if (callback.FriendID == _steamUser.SteamID)
|
|
{
|
|
// Convert avatar hash to URL
|
|
if (callback.AvatarHash != null && callback.AvatarHash.Length > 0)
|
|
{
|
|
var avatarStr = BitConverter.ToString(callback.AvatarHash).Replace("-", "").ToLowerInvariant();
|
|
userInfo.AvatarUrl = $"https://avatars.akamai.steamstatic.com/{avatarStr}_full.jpg";
|
|
}
|
|
|
|
userInfo.PersonaName = callback.Name;
|
|
userInfo.GameId = callback.GameID?.ToUInt64() ?? 0;
|
|
userInfo.GamePlayingName = callback.GameName;
|
|
userInfo.LastLogOn = callback.LastLogOn;
|
|
userInfo.LastLogOff = callback.LastLogOff;
|
|
personaStateTcs.TrySetResult(true);
|
|
}
|
|
});
|
|
|
|
var emailSub = _manager.Subscribe<SteamUser.EmailAddrInfoCallback>(callback =>
|
|
{
|
|
userInfo.Email = callback.EmailAddress;
|
|
emailInfoTcs.TrySetResult(true);
|
|
});
|
|
|
|
try
|
|
{
|
|
// Request all the info
|
|
if (_steamUser.SteamID != null)
|
|
{
|
|
_steamFriends.RequestFriendInfo(_steamUser.SteamID);
|
|
}
|
|
|
|
// Wait for all callbacks with timeout
|
|
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
|
var tasks = new[]
|
|
{
|
|
accountInfoTcs.Task,
|
|
personaStateTcs.Task,
|
|
emailInfoTcs.Task
|
|
};
|
|
|
|
await Task.WhenAny(Task.WhenAll(tasks), timeoutTask);
|
|
|
|
return userInfo;
|
|
}
|
|
finally
|
|
{
|
|
// Unsubscribe from callbacks
|
|
// _manager.Unsubscribe(accountSub);
|
|
// _manager.Unsubscribe(personaSub);
|
|
// _manager.Unsubscribe(emailSub);
|
|
}
|
|
}
|
|
|
|
public void Disconnect()
|
|
{
|
|
_cts?.Cancel();
|
|
|
|
if (_steamUser.SteamID != null)
|
|
{
|
|
_steamUser.LogOff();
|
|
}
|
|
|
|
_steamClient.Disconnect();
|
|
}
|
|
|
|
#region Private Helper Methods
|
|
|
|
private async Task EnsureConnectedAsync()
|
|
{
|
|
if (_callbackTask == null)
|
|
{
|
|
_cts = new CancellationTokenSource();
|
|
_steamClient.Connect();
|
|
|
|
// Run callback loop in background
|
|
_callbackTask = Task.Run(() =>
|
|
{
|
|
while (!_cts.Token.IsCancellationRequested)
|
|
{
|
|
_manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(500));
|
|
Thread.Sleep(10);
|
|
}
|
|
}, _cts.Token);
|
|
var connectionTcs = new TaskCompletionSource<bool>();
|
|
var connectionSub = _manager.Subscribe<SteamClient.ConnectedCallback>(_ =>
|
|
{
|
|
connectionTcs.TrySetResult(true);
|
|
});
|
|
|
|
try
|
|
{
|
|
// Wait up to 10 seconds for connection
|
|
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
|
var completedTask = await Task.WhenAny(connectionTcs.Task, timeoutTask);
|
|
|
|
if (completedTask == timeoutTask)
|
|
{
|
|
throw new TimeoutException("Connection to Steam timed out");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// _manager.Unsubscribe(connectionSub);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task SendSseEvent(HttpResponse response, string eventType, object data)
|
|
{
|
|
var json = System.Text.Json.JsonSerializer.Serialize(data);
|
|
await response.WriteAsync($"event: {eventType}\n");
|
|
await response.WriteAsync($"data: {json}\n\n");
|
|
await response.Body.FlushAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Callback Handlers
|
|
|
|
private void OnConnected(SteamClient.ConnectedCallback callback)
|
|
{
|
|
Console.WriteLine("Connected to Steam");
|
|
}
|
|
|
|
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
|
|
{
|
|
Console.WriteLine("Disconnected from Steam");
|
|
|
|
// Only try to reconnect if not deliberately disconnected
|
|
if (_callbackTask != null && !_cts!.IsCancellationRequested)
|
|
{
|
|
Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ => _steamClient.Connect());
|
|
}
|
|
}
|
|
|
|
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
|
|
{
|
|
var success = callback.Result == EResult.OK;
|
|
Console.WriteLine($"Logged on: {success}");
|
|
|
|
// Complete all pending auth completion sources
|
|
foreach (var tcs in _authCompletionSources.Values)
|
|
{
|
|
tcs.TrySetResult(success);
|
|
}
|
|
}
|
|
|
|
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
|
|
{
|
|
Console.WriteLine($"Logged off: {callback.Result}");
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
public class LoginResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public ulong? SteamId { get; set; }
|
|
public string? Username { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
}
|
|
|
|
public class UserInfo
|
|
{
|
|
public ulong SteamId { get; set; }
|
|
public string? Username { get; set; }
|
|
public string? PersonaName { get; set; }
|
|
public string? Country { get; set; }
|
|
public string? Email { get; set; }
|
|
public string? AvatarUrl { get; set; }
|
|
public ulong GameId { get; set; }
|
|
public string? GamePlayingName { get; set; }
|
|
public DateTime LastLogOn { get; set; }
|
|
public DateTime LastLogOff { get; set; }
|
|
}
|
|
} |