forked from sim0n00ps/OF-DL
345 lines
12 KiB
C#
345 lines
12 KiB
C#
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"
|
|
];
|
|
|
|
|
|
/// <summary>
|
|
/// Gets or sets the current authentication state.
|
|
/// </summary>
|
|
public Auth? CurrentAuth { get; set; }
|
|
|
|
/// <summary>
|
|
/// Loads authentication data from the 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(Action<string>? statusCallback = null)
|
|
{
|
|
statusCallback?.Invoke("Preparing browser dependencies ...");
|
|
|
|
try
|
|
{
|
|
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
|
|
await SetupBrowser(runningInDocker);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
statusCallback?.Invoke("Failed to prepare browser dependencies.");
|
|
Log.Error(ex, "Failed to download browser dependencies");
|
|
return false;
|
|
}
|
|
|
|
statusCallback?.Invoke("Please login using the opened Chromium window.");
|
|
|
|
try
|
|
{
|
|
CurrentAuth = await GetAuthFromBrowser();
|
|
|
|
return CurrentAuth != null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
statusCallback?.Invoke("Failed to get auth from browser.");
|
|
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}", filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to save auth to file");
|
|
}
|
|
}
|
|
|
|
private Task SetupBrowser(bool runningInDocker) => Task.Run(() =>
|
|
{
|
|
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<string> folders = Directory.GetDirectories(playwrightBrowsersPath ?? "/config/chromium")
|
|
.Where(folder => folder.Contains("chromium-"));
|
|
|
|
if (folders.Any())
|
|
{
|
|
Log.Information("chromium already downloaded. Skipping install step.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
|
|
if (exitCode != 0)
|
|
{
|
|
throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}");
|
|
}
|
|
});
|
|
|
|
private static async Task<string> GetBcToken(IPage page) =>
|
|
await page.EvaluateAsync<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("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<Auth?> 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<string, string> 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<string>("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;
|
|
}
|
|
}
|
|
}
|