From 1a1a05941c6ed9b9b0e4e6977d5801f8b6d743f1 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 18 Jun 2026 13:49:08 +0000 Subject: [PATCH] fikfap --- build.rs | 5 + docs/provider-catalog.md | 2 + src/providers/fikfap.rs | 743 +++++++++++++++++++++++++++++++++++++ src/proxies/fikfapthumb.rs | 65 ++++ src/proxies/mod.rs | 1 + src/proxy.rs | 5 + 6 files changed, 821 insertions(+) create mode 100644 src/providers/fikfap.rs create mode 100644 src/proxies/fikfapthumb.rs diff --git a/build.rs b/build.rs index 7183d4b..316cfa9 100644 --- a/build.rs +++ b/build.rs @@ -286,6 +286,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "hentaihaven", ty: "HentaihavenProvider", }, + ProviderDef { + id: "fikfap", + module: "fikfap", + ty: "FikfapProvider", + }, ProviderDef { id: "chaturbate", module: "chaturbate", diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index 1575984..72e2a8e 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -14,6 +14,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us | `chaturbate` | `live-cams` | no | no | Live cam channel. | | `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. | | `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:`). | +| `fikfap` | `tiktok` | yes | yes | JSON-API provider for fikfap.com (TikTok-style swipe short clips); anonymous auth via a client-generated `Authorization-Anonymous` UUID header (no real login needed); listing via `GET api.fikfap.com/posts?sort=new\|trending\|random&amount=N&afterId=` (cursor pagination — page N costs N sequential requests); search via `GET search?q=` (single fixed-size batch, no pagination — page 2+ returns empty); hashtag feeds via `GET hashtags/label/{label}/posts` and creator feeds via `GET profile/username/{user}/posts`, both also cursor-paginated; `tag:`/`hashtag:`/`#` and `user:`/`uploader:` query prefixes route directly; `categories` option exposes a small curated static hashtag list (no full catalog endpoint exists anonymously); `video.url` is the `fikfap.com/post/{id}` page (a client-rendered SPA, not yt-dlp-resolvable on its own); `formats[0].url` is a directly playable signed Bunny CDN HLS `.m3u8` with a `Referer: https://fikfap.com/` HTTP header (required, ~24h token expiry); thumbnails are also signed Bunny CDN URLs requiring the same Referer, so they're proxied via `/proxy/fikfap-thumb/...`; `get_uploader` implemented (`fikfap:` IDs) using `GET profile/username/{user}`. | | `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. | | `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. | | `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. | @@ -106,6 +107,7 @@ These return binary media or images, sometimes rewriting manifests or forwarding - `/proxy/noodlemagazine/{endpoint}*` - `/proxy/noodlemagazine-thumb/{endpoint}*` - `/proxy/hanime-cdn/{endpoint}*` +- `/proxy/fikfap-thumb/{endpoint}*` - `/proxy/hqporner-thumb/{endpoint}*` - `/proxy/porndish-thumb/{endpoint}*` - `/proxy/pornhub-thumb/{endpoint}*` diff --git a/src/providers/fikfap.rs b/src/providers/fikfap.rs new file mode 100644 index 0000000..14a9db0 --- /dev/null +++ b/src/providers/fikfap.rs @@ -0,0 +1,743 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::{ + Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme, +}; +use crate::status::*; +use crate::uploaders::{UploaderChannelStat, UploaderLayoutRow, UploaderProfile, UploaderVideoRef}; +use crate::util::cache::VideoCache; +use crate::videos::{ServerOptions, VideoFormat, VideoItem}; +use async_trait::async_trait; +use chrono::DateTime; +use error_chain::error_chain; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use serde::Deserialize; + +pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = + crate::providers::ProviderChannelMetadata { + group_id: "tiktok", + tags: &["shortform", "onlyfans", "swipe", "amateur"], + }; + +const BASE_URL: &str = "https://fikfap.com"; +const API_BASE: &str = "https://api.fikfap.com"; +const CHANNEL_ID: &str = "fikfap"; +const DEFAULT_PER_PAGE: usize = 20; +const MAX_PER_PAGE: usize = 40; +// FikFap pagination is cursor-based (`afterId`), not page-number based, so reaching +// page N requires walking N sequential requests from the start of the feed. +const MAX_PAGE_WALK: u16 = 25; + +// A small curated set of well-known FikFap hashtags. The site exposes a hashtag +// catalog only through a randomized "discover" sample, so there is no stable full +// catalog to background-load; any hashtag label works directly as a routing target. +const CURATED_HASHTAGS: &[&str] = &[ + "anal", + "ass", + "milf", + "threesome", + "blonde", + "brunette", + "redhead", + "natural", + "hardcore", + "lingerie", + "masturbation", + "cumshot", + "squirting", + "creampie", + "bbc", + "gonewild", + "blowjob", + "doggystyle", + "lesbian", + "deepthroat", +]; + +error_chain! { + foreign_links { + Json(serde_json::Error); + } + errors { + Request(msg: String) { + description("request error") + display("request error: {}", msg) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FeedSort { + New, + Trending, + Random, +} + +impl FeedSort { + fn api_value(self) -> &'static str { + match self { + FeedSort::New => "new", + FeedSort::Trending => "trending", + FeedSort::Random => "random", + } + } + + fn from_sort_id(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "trending" | "hot" => FeedSort::Trending, + "random" | "foryou" | "for_you" => FeedSort::Random, + _ => FeedSort::New, + } + } +} + +#[derive(Debug, Clone)] +enum Target { + Feed(FeedSort), + Hashtag(String), + User(String), + Search(String), +} + +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct Post { + post_id: i64, + #[serde(default)] + label: String, + #[serde(default)] + views_count: u32, + #[serde(default)] + duration: Option, + #[serde(default)] + video_stream_url: String, + #[serde(default)] + thumbnail_stream_url: String, + #[serde(default)] + published_at: Option, + #[serde(default)] + created_at: String, + #[serde(default)] + author: Author, + #[serde(default)] + hashtags: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct Author { + #[serde(default)] + username: String, + #[serde(default)] + is_verified: bool, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct HashtagEntry { + #[serde(default)] + label: String, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct SearchResponse { + #[serde(default)] + posts: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct UserProfile { + #[serde(default)] + username: String, + #[serde(default)] + count_posts: u64, + #[serde(default)] + count_total_views: u64, + #[serde(default)] + is_verified: bool, + #[serde(default)] + description: Option, + #[serde(default)] + thumbnail_url: String, +} + +#[derive(Debug, Clone)] +pub struct FikfapProvider { + anon_id: String, +} + +impl FikfapProvider { + pub fn new() -> Self { + Self { + anon_id: Self::generate_anon_id(), + } + } + + fn generate_anon_id() -> String { + let mut bytes = [0u8; 16]; + rand::fill(&mut bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15] + ) + } + + fn anon_headers(&self) -> Vec<(String, String)> { + vec![ + ("Referer".to_string(), format!("{BASE_URL}/")), + ("Authorization-Anonymous".to_string(), self.anon_id.clone()), + ("IsLoggedIn".to_string(), "false".to_string()), + ] + } + + fn build_channel(&self, _clientversion: ClientVersion) -> Channel { + let category_titles = CURATED_HASHTAGS + .iter() + .map(|label| Self::title_case(label)) + .collect::>(); + let category_options = CURATED_HASHTAGS + .iter() + .map(|label| FilterOption { + id: label.to_string(), + title: Self::title_case(label), + }) + .collect::>(); + + Channel { + id: CHANNEL_ID.to_string(), + name: "FikFap".to_string(), + description: + "FikFap swipe-style short clips with direct HLS playback, hashtag browsing, and creator pages." + .to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=fikfap.com".to_string(), + status: "active".to_string(), + categories: category_titles, + options: vec![ + ChannelOption { + id: "sort".to_string(), + title: "Sort".to_string(), + description: "Browse FikFap by latest, trending, or a randomized For You feed." + .to_string(), + systemImage: "arrow.up.arrow.down".to_string(), + colorName: "blue".to_string(), + options: vec![ + FilterOption { + id: "new".to_string(), + title: "Latest".to_string(), + }, + FilterOption { + id: "trending".to_string(), + title: "Trending".to_string(), + }, + FilterOption { + id: "random".to_string(), + title: "For You".to_string(), + }, + ], + multiSelect: false, + }, + ChannelOption { + id: "categories".to_string(), + title: "Hashtags".to_string(), + description: "Open a FikFap hashtag feed directly.".to_string(), + systemImage: "tag".to_string(), + colorName: "orange".to_string(), + options: category_options, + multiSelect: false, + }, + ], + nsfw: true, + cacheDuration: Some(60), + } + } + + fn title_case(label: &str) -> String { + let mut chars = label.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } + + fn normalize_hashtag(value: &str) -> String { + value + .trim() + .trim_start_matches('#') + .to_ascii_lowercase() + .replace(' ', "") + } + + fn resolve_query_target(query: &str) -> Target { + let trimmed = query.trim(); + + if let Some((kind, value)) = trimmed.split_once(':') { + let value = value.trim(); + if !value.is_empty() { + match kind.trim().to_ascii_lowercase().as_str() { + "tag" | "hashtag" | "hash" => { + return Target::Hashtag(Self::normalize_hashtag(value)); + } + "user" | "uploader" | "creator" => { + return Target::User(value.to_string()); + } + _ => {} + } + } + } + + if let Some(hashtag) = trimmed.strip_prefix('#') { + if !hashtag.trim().is_empty() { + return Target::Hashtag(Self::normalize_hashtag(hashtag)); + } + } + + Target::Search(trimmed.to_string()) + } + + fn resolve_option_target(options: &ServerOptions) -> Option { + if let Some(category) = options.categories.as_deref() { + if category != "all" && !category.trim().is_empty() { + return Some(Target::Hashtag(Self::normalize_hashtag(category))); + } + } + None + } + + fn pick_target(query: Option<&str>, sort: &str, options: &ServerOptions) -> Target { + if let Some(query) = query { + if !query.trim().is_empty() { + return Self::resolve_query_target(query); + } + } + + if let Some(target) = Self::resolve_option_target(options) { + return target; + } + + Target::Feed(FeedSort::from_sort_id(sort)) + } + + fn parse_timestamp(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(value) + .ok() + .map(|parsed| parsed.timestamp().max(0) as u64) + } + + fn proxied_thumb(thumb_url: &str, options: &ServerOptions) -> Option { + let trimmed = thumb_url.trim(); + if trimmed.is_empty() { + return None; + } + let stripped = strip_url_scheme(trimmed); + Some(build_proxy_url(options, "fikfap-thumb", &stripped)) + } + + fn build_video_item(&self, post: Post, options: &ServerOptions) -> Option { + if post.video_stream_url.trim().is_empty() { + return None; + } + + let id = post.post_id.to_string(); + let title = if post.label.trim().is_empty() { + format!("FikFap post {}", post.post_id) + } else { + post.label.trim().to_string() + }; + let url = format!("{BASE_URL}/post/{}", post.post_id); + let thumb = Self::proxied_thumb(&post.thumbnail_stream_url, options) + .unwrap_or_else(|| post.thumbnail_stream_url.clone()); + let duration = post.duration.unwrap_or(0); + + let format = VideoFormat::m3u8( + post.video_stream_url.clone(), + "auto".to_string(), + "hls".to_string(), + ) + .http_header("Referer".to_string(), format!("{BASE_URL}/")); + + let mut item = VideoItem::new(id, title, url, CHANNEL_ID.to_string(), thumb, duration); + item.views = Some(post.views_count); + item.uploadedAt = post + .published_at + .as_deref() + .filter(|value| !value.trim().is_empty()) + .and_then(Self::parse_timestamp) + .or_else(|| Self::parse_timestamp(&post.created_at)); + + if !post.author.username.trim().is_empty() { + item.uploader = Some(post.author.username.clone()); + item.uploaderUrl = Some(format!("{BASE_URL}/user/{}", post.author.username)); + item.uploaderId = Some(format!("{CHANNEL_ID}:{}", post.author.username)); + } + item.verified = Some(post.author.is_verified); + + let tags = post + .hashtags + .iter() + .map(|entry| entry.label.clone()) + .filter(|label| !label.trim().is_empty()) + .collect::>(); + item.tags = (!tags.is_empty()).then_some(tags); + item.formats = Some(vec![format]); + + Some(item) + } + + async fn fetch_posts_page( + &self, + target_path: &str, + sort: Option<&str>, + after_id: Option, + amount: usize, + options: &ServerOptions, + ) -> Result> { + let mut url = format!("{API_BASE}/{target_path}?amount={amount}"); + if let Some(sort) = sort { + url.push_str(&format!("&sort={sort}")); + } + if let Some(after_id) = after_id { + url.push_str(&format!("&afterId={after_id}")); + } + + let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_posts_page"); + let text = requester + .get_with_headers(&url, self.anon_headers(), None) + .await + .map_err(|error| Error::from(format!("request failed for {url}: {error}")))?; + Ok(serde_json::from_str(&text)?) + } + + async fn fetch_cursor_page( + &self, + target_path: &str, + sort: Option<&str>, + page: u16, + per_page: usize, + options: &ServerOptions, + ) -> Result> { + let page = page.max(1).min(MAX_PAGE_WALK); + let mut after_id: Option = None; + let mut items = Vec::new(); + + for _ in 0..page { + let batch = self + .fetch_posts_page(target_path, sort, after_id, per_page, options) + .await?; + if batch.is_empty() { + items = Vec::new(); + break; + } + after_id = batch.last().map(|post| post.post_id); + items = batch; + } + + Ok(items) + } + + async fn fetch_search(&self, query: &str, options: &ServerOptions) -> Result> { + let encoded_query: String = url::form_urlencoded::byte_serialize(query.as_bytes()).collect(); + let url = format!("{API_BASE}/search?q={encoded_query}"); + + let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_search"); + let text = requester + .get_with_headers(&url, self.anon_headers(), None) + .await + .map_err(|error| Error::from(format!("request failed for {url}: {error}")))?; + let response: SearchResponse = serde_json::from_str(&text)?; + Ok(response.posts) + } + + async fn fetch_target_posts( + &self, + target: &Target, + page: u16, + per_page: usize, + options: &ServerOptions, + ) -> Result> { + match target { + Target::Feed(feed_sort) => { + self.fetch_cursor_page("posts", Some(feed_sort.api_value()), page, per_page, options) + .await + } + Target::Hashtag(label) => { + let encoded = utf8_percent_encode(label, NON_ALPHANUMERIC).to_string(); + let path = format!("hashtags/label/{encoded}/posts"); + self.fetch_cursor_page(&path, Some("new"), page, per_page, options) + .await + } + Target::User(username) => { + let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string(); + let path = format!("profile/username/{encoded}/posts"); + self.fetch_cursor_page(&path, None, page, per_page, options) + .await + } + Target::Search(query) => { + // FikFap's search endpoint returns a single fixed-size batch with no + // cursor or page parameter, so later pages have nothing new to offer. + if page > 1 { + Ok(Vec::new()) + } else { + self.fetch_search(query, options).await + } + } + } + } + + async fn fetch_target_items( + &self, + target: Target, + page: u16, + per_page: usize, + options: &ServerOptions, + ) -> Result> { + let posts = self.fetch_target_posts(&target, page, per_page, options).await?; + Ok(posts + .into_iter() + .filter_map(|post| self.build_video_item(post, options)) + .collect()) + } + + async fn fetch_user_profile( + &self, + username: &str, + options: &ServerOptions, + ) -> Result> { + let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string(); + let url = format!("{API_BASE}/profile/username/{encoded}"); + + let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_user_profile"); + let text = match requester.get_with_headers(&url, self.anon_headers(), None).await { + Ok(text) => text, + Err(_) => return Ok(None), + }; + + match serde_json::from_str::(&text) { + Ok(profile) if !profile.username.trim().is_empty() => Ok(Some(profile)), + _ => Ok(None), + } + } + + async fn build_uploader_profile( + &self, + username: &str, + query: Option<&str>, + profile_content: bool, + options: &ServerOptions, + ) -> Result> { + let Some(profile) = self.fetch_user_profile(username, options).await? else { + return Ok(None); + }; + + let canonical_id = format!("{CHANNEL_ID}:{}", profile.username); + let mut videos = None; + + if profile_content { + let items = self + .fetch_target_items(Target::User(profile.username.clone()), 1, 24, options) + .await?; + + let filtered_items = if let Some(query) = query.filter(|value| !value.trim().is_empty()) { + let normalized_query = query.to_ascii_lowercase(); + items + .into_iter() + .filter(|item| { + let haystack = format!( + "{} {}", + item.title, + item.tags.as_ref().map(|values| values.join(" ")).unwrap_or_default() + ) + .to_ascii_lowercase(); + haystack.contains(&normalized_query) + }) + .collect::>() + } else { + items + }; + + let refs = filtered_items + .iter() + .map(|item| UploaderVideoRef::from_video_item(item, &profile.username, &canonical_id)) + .collect::>(); + videos = Some(refs); + } + + Ok(Some(UploaderProfile { + id: canonical_id, + name: profile.username.clone(), + url: Some(format!("{BASE_URL}/user/{}", profile.username)), + channel: Some(CHANNEL_ID.to_string()), + verified: profile.is_verified, + videoCount: profile.count_posts, + totalViews: profile.count_total_views, + channels: Some(vec![UploaderChannelStat { + channel: CHANNEL_ID.to_string(), + videoCount: profile.count_posts, + firstSeenAt: None, + lastSeenAt: None, + }]), + avatar: Self::proxied_thumb(&profile.thumbnail_url, options), + description: profile.description.clone(), + bio: profile.description, + videos, + tapes: Some(vec![]), + playlists: Some(vec![]), + layout: Some(vec![UploaderLayoutRow::videos(Some("Posts".to_string()))]), + })) + } +} + +#[async_trait] +impl Provider for FikfapProvider { + async fn get_videos( + &self, + cache: VideoCache, + pool: DbPool, + sort: String, + query: Option, + page: String, + per_page: String, + options: ServerOptions, + ) -> Vec { + let _ = cache; + let _ = pool; + + let page = page.parse::().unwrap_or(1).max(1); + let per_page = per_page + .parse::() + .unwrap_or(DEFAULT_PER_PAGE) + .clamp(1, MAX_PER_PAGE); + let normalized_query = query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let target = Self::pick_target(normalized_query, &sort, &options); + + match self.fetch_target_items(target, page, per_page, &options).await { + Ok(items) => items, + Err(error) => { + report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await; + vec![] + } + } + } + + fn get_channel(&self, clientversion: ClientVersion) -> Option { + Some(self.build_channel(clientversion)) + } + + async fn get_uploader( + &self, + cache: VideoCache, + pool: DbPool, + uploader_id: Option, + uploader_name: Option, + query: Option, + profile_content: bool, + options: ServerOptions, + ) -> std::result::Result, String> { + let _ = cache; + let _ = pool; + + let username = uploader_id + .as_deref() + .and_then(|id| id.strip_prefix(&format!("{CHANNEL_ID}:"))) + .map(ToOwned::to_owned) + .or(uploader_name.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let Some(username) = username else { + return Ok(None); + }; + + self.build_uploader_profile(&username, query.as_deref(), profile_content, &options) + .await + .map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_query_shortcuts() { + match FikfapProvider::resolve_query_target("tag:Big Tits") { + Target::Hashtag(label) => assert_eq!(label, "bigtits"), + other => panic!("expected hashtag target, got {other:?}"), + } + + match FikfapProvider::resolve_query_target("#blonde") { + Target::Hashtag(label) => assert_eq!(label, "blonde"), + other => panic!("expected hashtag target, got {other:?}"), + } + + match FikfapProvider::resolve_query_target("user:AdultPrime") { + Target::User(username) => assert_eq!(username, "AdultPrime"), + other => panic!("expected user target, got {other:?}"), + } + + match FikfapProvider::resolve_query_target("blonde teen") { + Target::Search(query) => assert_eq!(query, "blonde teen"), + other => panic!("expected search target, got {other:?}"), + } + } + + #[test] + fn maps_sort_ids() { + assert_eq!(FeedSort::from_sort_id("new"), FeedSort::New); + assert_eq!(FeedSort::from_sort_id("trending"), FeedSort::Trending); + assert_eq!(FeedSort::from_sort_id("random"), FeedSort::Random); + assert_eq!(FeedSort::from_sort_id("unknown"), FeedSort::New); + } + + #[test] + fn parses_post_payload() { + let json = r#"{ + "postId": 123, + "label": "Test post", + "viewsCount": 42, + "duration": 12, + "videoStreamUrl": "https://vz-x.b-cdn.net/abc/playlist.m3u8", + "thumbnailStreamUrl": "https://vz-x.b-cdn.net/abc/thumbnail.jpg", + "publishedAt": "2026-01-01T00:00:00.000Z", + "createdAt": "2026-01-01T00:00:00.000Z", + "author": { + "username": "creator", + "isVerified": true, + "countPosts": 10, + "countTotalViews": 100, + "thumbnailUrl": "https://example.com/avatar.jpg", + "description": null + }, + "hashtags": [{"label": "blonde"}] + }"#; + + let post: Post = serde_json::from_str(json).expect("parses"); + assert_eq!(post.post_id, 123); + assert_eq!(post.author.username, "creator"); + assert_eq!(post.hashtags.len(), 1); + } +} diff --git a/src/proxies/fikfapthumb.rs b/src/proxies/fikfapthumb.rs new file mode 100644 index 0000000..65969ba --- /dev/null +++ b/src/proxies/fikfapthumb.rs @@ -0,0 +1,65 @@ +use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; +use ntex::{ + http::Response, + web::{self, HttpRequest, error}, +}; + +use crate::util::requester::Requester; + +const REFERER: &str = "https://fikfap.com/"; + +fn endpoint_to_image_url(req: &HttpRequest) -> String { + let endpoint = req.match_info().query("endpoint").trim_start_matches('/'); + let mut image_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint.to_string() + } else { + format!("https://{endpoint}") + }; + + let query = req.query_string(); + if !query.is_empty() && !image_url.contains('?') { + image_url.push('?'); + image_url.push_str(query); + } + + image_url +} + +pub async fn get_image( + req: HttpRequest, + requester: web::types::State, +) -> Result { + let image_url = endpoint_to_image_url(&req); + + let upstream = match requester + .get_ref() + .clone() + .get_raw_with_headers( + image_url.as_str(), + vec![("Referer".to_string(), REFERER.to_string())], + ) + .await + { + Ok(response) => response, + Err(_) => return Ok(web::HttpResponse::NotFound().finish()), + }; + + let status = upstream.status(); + let headers = upstream.headers().clone(); + let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?; + + let mut resp = Response::build(status); + + if let Some(ct) = headers.get(CONTENT_TYPE) { + if let Ok(ct_str) = ct.to_str() { + resp.set_header(CONTENT_TYPE, ct_str); + } + } + if let Some(cl) = headers.get(CONTENT_LENGTH) { + if let Ok(cl_str) = cl.to_str() { + resp.set_header(CONTENT_LENGTH, cl_str); + } + } + + Ok(resp.body(bytes.to_vec())) +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index f98588c..95eae17 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -21,6 +21,7 @@ pub mod allpornstream; pub mod archivebate; pub mod clapdat; pub mod doodstream; +pub mod fikfapthumb; pub mod hanimecdn; pub mod hanimethumb; pub mod heavyfetish; diff --git a/src/proxy.rs b/src/proxy.rs index ab4f3c6..5cacbc3 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -106,6 +106,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(crate::proxies::noodlemagazine::get_image)) .route(web::get().to(crate::proxies::noodlemagazine::get_image)), ) + .service( + web::resource("/fikfap-thumb/{endpoint}*") + .route(web::post().to(crate::proxies::fikfapthumb::get_image)) + .route(web::get().to(crate::proxies::fikfapthumb::get_image)), + ) .service( web::resource("/hanime-cdn/{endpoint}*") .route(web::post().to(crate::proxies::hanimecdn::get_image))