OF-DL/OF DL.Core/Services/AuthService.cs

306 lines
9.7 KiB
C#

using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using OF_DL.Models;
using PuppeteerSharp;
using PuppeteerSharp.BrowserData;
using Serilog;
using UserEntities = OF_DL.Models.Entities.Users;
namespace OF_DL.Services;
public class AuthService(IServiceProvider serviceProvider) : IAuthService
{
private const int LoginTimeout = 600000; // 10 minutes
private const int FeedLoadTimeout = 60000; // 1 minute
private readonly string[] _desiredCookies =
[
"auth_id",
"sess"
];
private readonly LaunchOptions _options = new()
{
Headless = false,
Channel = ChromeReleaseChannel.Stable,
DefaultViewport = null,
Args = ["--no-sandbox", "--disable-setuid-sandbox"],
UserDataDir = Path.GetFullPath("chrome-data")
};
/// <summary>
/// Gets or sets the current authentication state.
/// </summary>
public Auth? CurrentAuth { get; set; }
/// <summary>
/// Loads authentication data from disk.
/// </summary>
/// <param name="filePath">The auth file path.</param>
/// <returns>True when auth data is loaded successfully.</returns>
public async Task<bool> LoadFromFileAsync(string filePath = "auth.json")
{
try
{
if (!File.Exists(filePath))
{
Log.Debug("Auth file not found: {FilePath}", filePath);
return false;
}
string json = await File.ReadAllTextAsync(filePath);
CurrentAuth = JsonConvert.DeserializeObject<Auth>(json);
Log.Debug("Auth file loaded and deserialized successfully");
return CurrentAuth != null;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to load auth from file");
return false;
}
}
/// <summary>
/// Launches a browser session and extracts auth data after login.
/// </summary>
/// <returns>True when auth data is captured successfully.</returns>
public async Task<bool> LoadFromBrowserAsync()
{
try
{
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
await SetupBrowser(runningInDocker);
CurrentAuth = await GetAuthFromBrowser();
return CurrentAuth != null;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to load auth from browser");
return false;
}
}
/// <summary>
/// Persists the current auth data to disk.
/// </summary>
/// <param name="filePath">The auth file path.</param>
public async Task SaveToFileAsync(string filePath = "auth.json")
{
if (CurrentAuth == null)
{
Log.Warning("Attempted to save null auth to file");
return;
}
try
{
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
await File.WriteAllTextAsync(filePath, json);
Log.Debug($"Auth saved to file: {filePath}");
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save auth to file");
}
}
private async Task SetupBrowser(bool runningInDocker)
{
string? executablePath = Environment.GetEnvironmentVariable("OFDL_PUPPETEER_EXECUTABLE_PATH");
if (executablePath != null)
{
Log.Information(
"OFDL_PUPPETEER_EXECUTABLE_PATH environment variable found. Using browser executable path: {executablePath}",
executablePath);
_options.ExecutablePath = executablePath;
}
else
{
BrowserFetcher browserFetcher = new();
List<InstalledBrowser> installedBrowsers = browserFetcher.GetInstalledBrowsers().ToList();
if (installedBrowsers.Count == 0)
{
Log.Information("Downloading browser.");
InstalledBrowser? downloadedBrowser = await browserFetcher.DownloadAsync();
Log.Information("Browser downloaded. Path: {executablePath}",
downloadedBrowser.GetExecutablePath());
_options.ExecutablePath = downloadedBrowser.GetExecutablePath();
}
else
{
_options.ExecutablePath = installedBrowsers.First().GetExecutablePath();
}
}
if (runningInDocker)
{
Log.Information("Running in Docker. Disabling sandbox and GPU.");
_options.Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"];
}
}
private async Task<string> GetBcToken(IPage page) =>
await page.EvaluateExpressionAsync<string>("window.localStorage.getItem('bcTokenSha') || ''");
/// <summary>
/// Normalizes the stored cookie string to only include required cookie values.
/// </summary>
public void ValidateCookieString()
{
if (CurrentAuth == null)
{
return;
}
if (string.IsNullOrWhiteSpace(CurrentAuth.Cookie))
{
return;
}
string pattern = @"(auth_id=\d+)|(sess=[^;]+)";
MatchCollection matches = Regex.Matches(CurrentAuth.Cookie, pattern);
string output = string.Join("; ", matches);
if (!output.EndsWith(";"))
{
output += ";";
}
if (CurrentAuth.Cookie.Trim() != output.Trim())
{
CurrentAuth.Cookie = output;
string newAuthString = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
File.WriteAllText("auth.json", newAuthString);
}
}
/// <summary>
/// Validates auth by requesting the current user profile.
/// </summary>
/// <returns>The authenticated user or null when validation fails.</returns>
public async Task<UserEntities.User?> ValidateAuthAsync()
{
// Resolve IApiService lazily to avoid circular dependency
IApiService apiService = serviceProvider.GetRequiredService<IApiService>();
return await apiService.GetUserInfo("/users/me");
}
/// <summary>
/// Clears persisted auth data and browser profile state.
/// </summary>
public void Logout()
{
if (Directory.Exists("chrome-data"))
{
Log.Information("Deleting chrome-data folder");
Directory.Delete("chrome-data", true);
}
if (File.Exists("auth.json"))
{
Log.Information("Deleting auth.json");
File.Delete("auth.json");
}
}
private async Task<Auth?> GetAuthFromBrowser()
{
try
{
IBrowser? browser;
try
{
browser = await Puppeteer.LaunchAsync(_options);
}
catch (ProcessException e)
{
if (e.Message.Contains("Failed to launch browser") && Directory.Exists(_options.UserDataDir))
{
Log.Error("Failed to launch browser. Deleting chrome-data directory and trying again.");
Directory.Delete(_options.UserDataDir, true);
browser = await Puppeteer.LaunchAsync(_options);
}
else
{
throw;
}
}
if (browser == null)
{
throw new Exception("Could not get browser");
}
IPage[]? pages = await browser.PagesAsync();
IPage? page = pages.First();
if (page == null)
{
throw new Exception("Could not get page");
}
Log.Debug("Navigating to OnlyFans.");
await page.GoToAsync("https://onlyfans.com");
Log.Debug("Waiting for user to login");
await page.WaitForSelectorAsync(".b-feed", new WaitForSelectorOptions { Timeout = LoginTimeout });
Log.Debug("Feed element detected (user logged in)");
await page.ReloadAsync();
await page.WaitForNavigationAsync(new NavigationOptions
{
WaitUntil = [WaitUntilNavigation.Networkidle2], Timeout = FeedLoadTimeout
});
Log.Debug("DOM loaded. Getting BC token and cookies ...");
string xBc;
try
{
xBc = await GetBcToken(page);
}
catch (Exception e)
{
Log.Error(e, "Error getting bcToken");
throw new Exception("Error getting bcToken");
}
Dictionary<string, string> mappedCookies = (await page.GetCookiesAsync())
.Where(cookie => cookie.Domain.Contains("onlyfans.com"))
.ToDictionary(cookie => cookie.Name, cookie => cookie.Value);
mappedCookies.TryGetValue("auth_id", out string? userId);
if (userId == null)
{
throw new Exception("Could not find 'auth_id' cookie");
}
mappedCookies.TryGetValue("sess", out string? sess);
if (sess == null)
{
throw new Exception("Could not find 'sess' cookie");
}
string? userAgent = await browser.GetUserAgentAsync();
if (userAgent == null)
{
throw new Exception("Could not get user agent");
}
string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key))
.Select(key => $"${key}={mappedCookies[key]};"));
return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc };
}
catch (Exception e)
{
Log.Error(e, "Error getting auth from browser");
return null;
}
}
}