using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Playwright; using Newtonsoft.Json; using OF_DL.Models; using Serilog; using UserEntities = OF_DL.Models.Entities.Users; namespace OF_DL.Services; public class AuthService(IServiceProvider serviceProvider) : IAuthService { private const float LoginTimeout = 600000f; // 10 minutes private const float FeedLoadTimeout = 60000f; // 1 minute private const int AdditionalWaitAfterPageLoad = 3000; // 3 seconds private readonly string _userDataDir = Path.GetFullPath("chromium-data"); private const string InitScriptsDirName = "chromium-scripts"; private readonly BrowserTypeLaunchPersistentContextOptions _options = new() { Headless = false, Channel = "chromium", Args = [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-blink-features=AutomationControlled", "--disable-infobars" ] }; private readonly string[] _desiredCookies = [ "auth_id", "sess" ]; /// /// Gets or sets the current authentication state. /// public Auth? CurrentAuth { get; set; } /// /// Loads authentication data from the 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 Task SetupBrowser(bool runningInDocker) { if (runningInDocker) { Log.Information("Running in Docker. Disabling sandbox and GPU."); _options.Args = [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-blink-features=AutomationControlled", "--disable-infobars" ]; // If chromium is already downloaded, skip installation string? playwrightBrowsersPath = Environment.GetEnvironmentVariable("PLAYWRIGHT_BROWSERS_PATH"); IEnumerable folders = Directory.GetDirectories(playwrightBrowsersPath ?? "/config/chromium") .Where(folder => folder.Contains("chromium-")); if (folders.Any()) { Log.Information("chromium already downloaded. Skipping install step."); return Task.CompletedTask; } } int exitCode = Program.Main(["install", "--with-deps", "chromium"]); return exitCode != 0 ? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}") : Task.CompletedTask; } private static async Task GetBcToken(IPage page) => await page.EvaluateAsync("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("chromium-data")) { Log.Information("Deleting chromium-data folder"); Directory.Delete("chromium-data", true); } if (File.Exists("auth.json")) { Log.Information("Deleting auth.json"); File.Delete("auth.json"); } } private async Task GetAuthFromBrowser() { try { IBrowserContext? browser; try { IPlaywright playwright = await Playwright.CreateAsync(); browser = await playwright.Chromium.LaunchPersistentContextAsync(_userDataDir, _options); } catch (Exception e) { if (( e.Message.Contains("An error occurred trying to start process") || e.Message.Contains("The profile appears to be in use by another Chromium process") ) && Directory.Exists(_userDataDir)) { Log.Error("Failed to launch browser. Deleting chromium-data directory and trying again."); Directory.Delete(_userDataDir, true); IPlaywright playwright = await Playwright.CreateAsync(); browser = await playwright.Chromium.LaunchPersistentContextAsync(_userDataDir, _options); } else { throw; } } if (browser == null) { throw new Exception("Could not get browser"); } IPage? page = browser.Pages[0]; if (page == null) { throw new Exception("Could not get page"); } string exeDirectory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty; string initScriptsDir = Path.Combine(exeDirectory, InitScriptsDirName); if (Directory.Exists(initScriptsDir)) { Log.Information("Loading init scripts from {initScriptsDir}", initScriptsDir); foreach (string initScript in Directory.GetFiles(initScriptsDir, "*.js")) { Log.Debug("Loading init script {initScript}", initScript); await page.AddInitScriptAsync(initScript); } } Log.Debug("Navigating to OnlyFans."); await page.GotoAsync("https://onlyfans.com"); Log.Debug("Waiting for user to login"); await page.WaitForSelectorAsync(".b-feed", new PageWaitForSelectorOptions { Timeout = LoginTimeout }); Log.Debug("Feed element detected (user logged in)"); await page.ReloadAsync( new PageReloadOptions { Timeout = FeedLoadTimeout, WaitUntil = WaitUntilState.DOMContentLoaded }); // Wait for an additional time to ensure the DOM is fully loaded await Task.Delay(AdditionalWaitAfterPageLoad); Log.Debug("DOM loaded. Getting BC token and cookies ..."); string xBc; try { xBc = await GetBcToken(page); } catch (Exception e) { await browser.CloseAsync(); throw new Exception($"Error getting bcToken. {e.Message}"); } Dictionary mappedCookies = (await browser.CookiesAsync()) .Where(cookie => cookie.Domain.Contains("onlyfans.com")) .ToDictionary(cookie => cookie.Name, cookie => cookie.Value); mappedCookies.TryGetValue("auth_id", out string? userId); if (userId == null) { await browser.CloseAsync(); throw new Exception("Could not find 'auth_id' cookie"); } mappedCookies.TryGetValue("sess", out string? sess); if (sess == null) { await browser.CloseAsync(); throw new Exception("Could not find 'sess' cookie"); } string userAgent = await page.EvaluateAsync("navigator.userAgent"); if (string.IsNullOrWhiteSpace(userAgent)) { await browser.CloseAsync(); 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; } } }