inital commit

This commit is contained in:
2024-07-25 20:07:23 -05:00
commit 179fb2a45e
55 changed files with 11840 additions and 0 deletions

3
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

5313
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,31 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
default-run = "app"
edition = "2021"
rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.7.0", features = [ "os-all", "notification-all", "dialog-confirm", "clipboard-all", "dialog-message", "dialog-ask"] }
dirs = "5.0.1"
reqwest = { version = "0.11.18", features = ["json"] }
lazy_static = "1.5.0"
tokio = { version = "1.29.1", features = ["full"] }
tokio-macros = "2.3.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,43 @@
use crate::handlers::config::{get_config_path, Config};
#[tauri::command]
pub fn init_config() -> Result<(), String> {
println!("[commands::config::init_config] Initializing config...");
let config_path = get_config_path().map_err(|e| e.to_string())?;
println!(
"[commands::config::init_config] Config path: {}",
config_path.display()
);
Config::load_or_create(&config_path).map_err(|e| e.to_string())?;
println!("[commands::config::init_config] Config initialized successfully");
Ok(())
}
#[tauri::command]
pub fn get_config() -> Result<Config, String> {
let config_path = get_config_path().map_err(|e| e.to_string())?;
let config = Config::load_or_create(&config_path).map_err(|e| e.to_string())?;
println!(
"[commands::config::get_config] Config loaded successfully: {:?} from path: {}",
config,
config_path.display()
);
Ok(config)
}
#[tauri::command]
pub fn save_config(config: Config) -> Result<(), String> {
let config_path = get_config_path().map_err(|e| e.to_string())?;
println!(
"[commands::config::save_config] Saving config: {:?} to path: {}",
config,
config_path.display()
);
config.save(&config_path).map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -0,0 +1,37 @@
use crate::{
handlers::fansly::Fansly,
structs::{FanslyAccountResponse, FanslyBaseResponse, SyncDataResponse},
};
use lazy_static::lazy_static;
use tokio::sync::Mutex;
lazy_static! {
static ref FANSLY: Mutex<Fansly> = Mutex::new(Fansly::new(None));
}
#[tauri::command]
pub async fn fansly_set_token(token: Option<String>) {
FANSLY.lock().await.set_token(token);
}
#[tauri::command]
pub async fn fansly_get_me() -> Result<FanslyBaseResponse<FanslyAccountResponse>, String> {
let fansly = FANSLY.lock().await;
let response = fansly.get_profile().await;
match response {
Ok(response) => Ok(response),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
pub async fn fansly_sync() -> Result<SyncDataResponse, String> {
let fansly = FANSLY.lock().await;
let response = fansly.sync().await;
match response {
Ok(response) => Ok(response),
Err(e) => Err(e.to_string()),
}
}

View File

@ -0,0 +1,3 @@
pub mod config;
pub mod fansly;
pub mod utils;

View File

@ -0,0 +1,4 @@
#[tauri::command]
pub fn quit(code: i32) {
std::process::exit(code);
}

View File

@ -0,0 +1,105 @@
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::structs::{FanslyFollowersResponse, Subscription};
const CURRENT_VERSION: i32 = 1; // Set the current version of the config
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncData {
pub followers: Vec<FanslyFollowersResponse>,
pub subscribers: Vec<Subscription>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub version: i32, // Add a version field to the config (1, 2, 3, etc.)
pub is_first_run: bool,
pub fansly_token: String,
pub sync_interval: u64,
pub last_sync: u64,
pub last_sync_data: SyncData,
}
impl Default for Config {
fn default() -> Self {
Config {
version: CURRENT_VERSION, // Version is set to CURRENT_VERSION by default
is_first_run: true, // First run is set to true by default
fansly_token: String::new(), // Fansly token is stored as a string
sync_interval: 1, // Every hour - sync interval is interpreted as hours
last_sync: 0, // Last sync time is stored as a UNIX timestamp
last_sync_data: SyncData {
followers: Vec::new(),
subscribers: Vec::new(),
}, // Last sync data is stored as a list of followers and subscribers
}
}
}
impl Config {
pub fn load_or_create(path: &Path) -> io::Result<Self> {
if path.exists() {
let mut config: Self = serde_json::from_str(std::fs::read_to_string(path)?.as_str())
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Could not parse config file: {}", e),
)
})?;
if config.version != CURRENT_VERSION {
config = config.migrate()?;
config.save(path)?;
}
Ok(config)
} else {
let saved_config = Config::default().save(path);
saved_config
.and_then(|_| Config::load_or_create(path))
.or_else(|e| Err(e))
}
}
fn migrate(mut self) -> io::Result<Self> {
while self.version < CURRENT_VERSION {
self = match self.version {
1 => {
// If we're on version 1, migrate to version 2 (not implemented)
self.version += 1;
self
}
_ => {
// If we don't have a migration path, return an error
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("No migration path for version {}", self.version),
));
}
};
}
Ok(self)
}
pub fn save(&self, path: &Path) -> io::Result<()> {
let mut file = File::create(path)?;
file.write_all(serde_json::to_string_pretty(self).unwrap().as_bytes())?;
// Return the saved config
Ok(())
}
}
pub fn get_config_path() -> io::Result<PathBuf> {
let mut config_dir = dirs::config_dir().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"Could not determine user's config directory",
)
})?;
config_dir.push("FanslySync");
fs::create_dir_all(&config_dir)?;
config_dir.push("config.json");
Ok(config_dir)
}

View File

@ -0,0 +1,254 @@
// Create a simple module for handling the Fansly API, using reqwest to make requests to the API.
// This module will contain a struct Fansly, which will have a method to get the user's profile information.
use crate::structs::{
FanslyAccountResponse, FanslyBaseResponse, FanslyBaseResponseList, FanslyFollowersResponse,
FanslySubscriptionsResponse, Subscription, SyncDataResponse,
};
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
pub struct Fansly {
client: reqwest::Client,
token: Option<String>,
}
impl Fansly {
pub fn new(token: Option<String>) -> Self {
let mut headers = HeaderMap::new();
// Set the user agent to the FanslySync/0.1.0 tanner@fanslycreatorbot.com
headers.insert(
USER_AGENT,
HeaderValue::from_static("FanslySync/0.1.0 tanner@fanslycreatorbot.com"),
);
// If we have a token, add it to the headers\
if let Some(token) = &token {
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("{}", token)).unwrap(),
);
}
// Set our default base url to https://apiv3.fansly.com/api/v1/
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap();
Self { client, token }
}
// Helper function to set our token on the fly
pub fn set_token(&mut self, token: Option<String>) {
self.token = token;
// Re-create the client with the new token (if it exists)
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static("FanslySync/0.1.0 tanner@fanslycreatorbot.com"),
);
// If we have a token, add it to the headers
if let Some(token) = &self.token {
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("{}", token)).unwrap(),
);
}
self.client = reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap();
}
pub async fn get_profile(
&self,
) -> Result<FanslyBaseResponse<FanslyAccountResponse>, reqwest::Error> {
let response = self
.client
.get("https://apiv3.fansly.com/api/v1/account/me")
.send()
.await?;
if !response.status().is_success() {
eprintln!("[sync::process::get_profile] No successful response from API. Setting error state.");
return Err(response.error_for_status().unwrap_err());
} else {
println!("[sync::process::get_profile] Got successful response from API.");
}
let profile: FanslyBaseResponse<FanslyAccountResponse> = response.json().await?;
Ok(profile)
}
async fn fetch_followers(
&self,
account_id: &str,
auth_token: &str,
offset: u32,
) -> Result<FanslyBaseResponseList<FanslyFollowersResponse>, reqwest::Error> {
let url = format!("https://apiv3.fansly.com/api/v1/account/{}/followers?ngsw-bypass=true&limit=100&offset={}", account_id, offset);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("{}", auth_token).parse().unwrap(),
);
headers.insert(
reqwest::header::USER_AGENT,
"FanslySync/1.0.0 (tanner@teamhydra.dev)".parse().unwrap(),
);
headers.insert(
reqwest::header::CONTENT_TYPE,
"application/json".parse().unwrap(),
);
let response = self.client.get(url).headers(headers).send().await?;
if !response.status().is_success() {
eprintln!("[sync::process::fetch_followers] No successful response from API. Setting error state.");
return Err(response.error_for_status().unwrap_err());
}
let followers: FanslyBaseResponseList<FanslyFollowersResponse> = response.json().await?;
println!(
"[sync::process::fetch_followers] Got {} followers from API.",
followers.response.len()
);
Ok(followers)
}
async fn fetch_subscribers(
&self,
auth_token: &str,
offset: u32,
) -> Result<Vec<Subscription>, reqwest::Error> {
let url = format!("https://apiv3.fansly.com/api/v1/subscribers?status=3,4&limit=100&offset={}&ngsw-bypass=true", offset);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("{}", auth_token).parse().unwrap(),
);
headers.insert(
reqwest::header::USER_AGENT,
"FanslySync/1.0.0 (sticks@teamhydra.dev)".parse().unwrap(),
);
headers.insert(
reqwest::header::CONTENT_TYPE,
"application/json".parse().unwrap(),
);
let response = self.client.get(url).headers(headers).send().await?;
if !response.status().is_success() {
eprintln!("[fanslySyncExt] No successful response from API. Setting error state.");
let error = response.error_for_status().unwrap_err();
return Err(error);
}
let subscriptions: FanslyBaseResponse<FanslySubscriptionsResponse> =
response.json().await?;
println!(
"[fanslySyncExt] Got {} subscriptions from API.",
subscriptions.response.subscriptions.len()
);
Ok(subscriptions.response.subscriptions)
}
pub async fn sync(&self) -> Result<SyncDataResponse, String> {
// Fetch profile
println!("[sync::process] Fetching profile...");
let profile = self.get_profile().await.map_err(|e| e.to_string())?;
if !profile.success {
return Err("Failed to fetch profile".to_string());
}
println!("[sync::process] Profile retrieved successfully.");
let account = profile.response.account;
let total_followers = account.follow_count;
let total_subscribers = account.subscriber_count;
println!(
"[sync::process] Account {} has {} followers and {} subscribers. Starting sync...",
account.id, total_followers, total_subscribers
);
let mut followers: Vec<FanslyFollowersResponse> = Vec::new();
let mut subscribers: Vec<Subscription> = Vec::new();
println!("[sync::process] Fetching followers and subscribers...");
// Fetch followers until we have all of them
let mut offset = 0;
let mut total_requests = 0;
while followers.len() < total_followers as usize {
println!(
"[sync::process] Fetching followers for account {} with offset {} (total: {})",
account.id, offset, total_followers
);
let response = self
.fetch_followers(&account.id, &self.token.as_ref().unwrap(), offset)
.await
.map_err(|e| e.to_string())?;
println!(
"[sync::process] Got {} followers from API.",
response.response.len()
);
followers.extend(response.response);
offset += 100;
total_requests += 1;
// Every 10 requests, sleep for a bit to avoid rate limiting
if total_requests % 10 == 0 {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
// Fetch subscribers until we have all of them
offset = 0;
while subscribers.len() < total_subscribers as usize {
println!(
"[sync::process] Fetching subscribers with offset {} for account {} (total: {})",
offset, account.id, total_subscribers
);
let response = self
.fetch_subscribers(&self.token.as_ref().unwrap(), offset)
.await
.map_err(|e| e.to_string())?;
subscribers.extend(response);
offset += 100;
total_requests += 1;
// Every 10 requests, sleep for a bit to avoid rate limiting
if total_requests % 10 == 0 {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
println!(
"[sync::process] Got {} followers and {} subscribers from API.",
followers.len(),
subscribers.len()
);
println!("[sync::process] Sync complete.");
// Return JSON of what we fetched
Ok(SyncDataResponse {
followers,
subscribers,
})
}
}

View File

@ -0,0 +1,2 @@
pub mod config;
pub mod fansly;

26
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,26 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
mod handlers;
mod structs;
use commands::config::{get_config, init_config, save_config};
use commands::fansly::{fansly_get_me, fansly_set_token, fansly_sync};
use commands::utils::quit;
#[tokio::main]
async fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
init_config,
get_config,
save_config,
quit,
fansly_set_token,
fansly_get_me,
fansly_sync
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,196 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncDataResponse {
pub followers: Vec<FanslyFollowersResponse>,
pub subscribers: Vec<Subscription>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FanslyBaseResponse<T> {
pub success: bool,
pub response: T,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FanslyBaseResponseList<T> {
pub success: bool,
pub response: Vec<T>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FanslyFollowersResponse {
pub follower_id: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FanslySubscriptionsResponse {
pub stats: SubscriptionsStats,
pub subscriptions: Vec<Subscription>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionsStats {
pub total_active: i64,
pub total_expired: i64,
pub total: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Subscription {
pub id: String,
pub history_id: String,
pub subscriber_id: String,
pub subscription_tier_id: String,
pub subscription_tier_name: String,
pub subscription_tier_color: String,
pub plan_id: String,
pub promo_id: Option<String>,
pub gift_code_id: Value,
pub payment_method_id: String,
pub status: i64,
pub price: i64,
pub renew_price: i64,
pub renew_correlation_id: String,
pub auto_renew: i64,
pub billing_cycle: i64,
pub duration: i64,
pub renew_date: i64,
pub version: i64,
pub created_at: i64,
pub updated_at: i64,
pub ends_at: i64,
pub promo_price: Value,
pub promo_duration: Value,
pub promo_status: Value,
pub promo_starts_at: Value,
pub promo_ends_at: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FanslyAccountResponse {
pub account: Account,
pub correlation_id: String,
pub check_token: Value,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub id: String,
pub email: String,
pub username: String,
pub display_name: String,
pub flags: i64,
pub version: i64,
pub created_at: i64,
pub follow_count: i64,
pub subscriber_count: i64,
pub permissions: Permissions,
pub timeline_stats: TimelineStats,
pub profile_access_flags: i64,
pub profile_flags: i64,
pub about: String,
pub location: String,
pub profile_socials: Vec<Value>,
pub status_id: i64,
pub last_seen_at: i64,
pub post_likes: i64,
pub main_wallet: MainWallet,
pub streaming: Streaming,
pub account_media_likes: i64,
pub earnings_wallet: EarningsWallet,
pub subscription_tiers: Vec<SubscriptionTier>,
pub profile_access: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Permissions {
pub account_permission_flags: AccountPermissionFlags,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPermissionFlags {
pub flags: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimelineStats {
pub account_id: String,
pub image_count: i64,
pub video_count: i64,
pub bundle_count: i64,
pub bundle_image_count: i64,
pub bundle_video_count: i64,
pub fetched_at: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MainWallet {
pub id: String,
pub account_id: String,
pub balance: i64,
#[serde(rename = "type")]
pub type_field: i64,
pub wallet_version: i64,
pub flags: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Streaming {
pub account_id: String,
pub channel: Value,
pub enabled: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EarningsWallet {
pub id: String,
pub account_id: String,
pub balance: i64,
#[serde(rename = "type")]
pub type_field: i64,
pub wallet_version: i64,
pub flags: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionTier {
pub id: String,
pub account_id: String,
pub name: String,
pub color: String,
pub pos: i64,
pub price: i64,
pub max_subscribers: i64,
pub subscription_benefits: Vec<String>,
pub included_tier_ids: Vec<Value>,
pub plans: Vec<Plan>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Plan {
pub id: String,
pub status: i64,
pub billing_cycle: i64,
pub price: i64,
pub use_amounts: i64,
pub promos: Vec<Value>,
pub uses: i64,
}

89
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,89 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": {
"productName": "fanslysync-desktop",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"clipboard": {
"all": true,
"readText": false,
"writeText": false
},
"dialog": {
"all": false,
"ask": true,
"confirm": true,
"message": true,
"open": false,
"save": false
},
"notification": {
"all": true
},
"os": {
"all": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.fanslycreatorbot.fanslysync",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": true,
"endpoints": [
"https://cdn.crabnebula.app/update/fansly-creator-bot/fansly-sync/{{target}}-{{arch}}/{{current_version}}"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJFODZGRDI4NjBFMDQ1RUMKUldUc1JlQmdLUDJHTGdRdSt6dWFISXE0MThsa0tvUDA2RWdMSStjQ0J6NVBhdmU4ajRMMms4a1cK"
},
"windows": [
{
"fullscreen": false,
"height": 650,
"resizable": false,
"title": "FanslySync",
"width": 600
}
]
}
}