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") }; /// /// Gets or sets the current authentication state. /// public Auth? CurrentAuth { get; set; } /// /// Loads authentication data from disk. /// /// The auth file path. /// True when auth data is loaded successfully. public async Task 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(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; } } /// /// Launches a browser session and extracts auth data after login. /// /// True when auth data is captured successfully. public async Task 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; } } /// /// Persists the current auth data to disk. /// /// The auth file path. 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 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 GetBcToken(IPage page) => await page.EvaluateExpressionAsync("window.localStorage.getItem('bcTokenSha') || ''"); /// /// Normalizes the stored cookie string to only include required cookie values. /// 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); } } /// /// Validates auth by requesting the current user profile. /// /// The authenticated user or null when validation fails. public async Task ValidateAuthAsync() { // Resolve IApiService lazily to avoid circular dependency IApiService apiService = serviceProvider.GetRequiredService(); return await apiService.GetUserInfo("/users/me"); } /// /// Clears persisted auth data and browser profile state. /// 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 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 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]};")); await browser.CloseAsync(); return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc }; } catch (Exception e) { Log.Error(e, "Error getting auth from browser"); return null; } } }