This commit is contained in:
Simon
2026-06-18 13:49:08 +00:00
parent 25ea03d696
commit 1a1a05941c
6 changed files with 821 additions and 0 deletions

View File

@@ -286,6 +286,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "hentaihaven",
ty: "HentaihavenProvider",
},
ProviderDef {
id: "fikfap",
module: "fikfap",
ty: "FikfapProvider",
},
ProviderDef {
id: "chaturbate",
module: "chaturbate",

View File

@@ -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:<name>`). |
| `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=<lastPostId>` (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:<username>` 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}*`

743
src/providers/fikfap.rs Normal file
View File

@@ -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<u32>,
#[serde(default)]
video_stream_url: String,
#[serde(default)]
thumbnail_stream_url: String,
#[serde(default)]
published_at: Option<String>,
#[serde(default)]
created_at: String,
#[serde(default)]
author: Author,
#[serde(default)]
hashtags: Vec<HashtagEntry>,
}
#[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<Post>,
}
#[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<String>,
#[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::<Vec<_>>();
let category_options = CURATED_HASHTAGS
.iter()
.map(|label| FilterOption {
id: label.to_string(),
title: Self::title_case(label),
})
.collect::<Vec<_>>();
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::<String>() + 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<Target> {
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<u64> {
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<String> {
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<VideoItem> {
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::<Vec<_>>();
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<i64>,
amount: usize,
options: &ServerOptions,
) -> Result<Vec<Post>> {
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<Vec<Post>> {
let page = page.max(1).min(MAX_PAGE_WALK);
let mut after_id: Option<i64> = 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<Vec<Post>> {
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<Vec<Post>> {
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<Vec<VideoItem>> {
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<Option<UserProfile>> {
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::<UserProfile>(&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<Option<UploaderProfile>> {
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::<Vec<_>>()
} else {
items
};
let refs = filtered_items
.iter()
.map(|item| UploaderVideoRef::from_video_item(item, &profile.username, &canonical_id))
.collect::<Vec<_>>();
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<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = cache;
let _ = pool;
let page = page.parse::<u16>().unwrap_or(1).max(1);
let per_page = per_page
.parse::<usize>()
.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<Channel> {
Some(self.build_channel(clientversion))
}
async fn get_uploader(
&self,
cache: VideoCache,
pool: DbPool,
uploader_id: Option<String>,
uploader_name: Option<String>,
query: Option<String>,
profile_content: bool,
options: ServerOptions,
) -> std::result::Result<Option<UploaderProfile>, 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);
}
}

View File

@@ -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<Requester>,
) -> Result<impl web::Responder, web::Error> {
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()))
}

View File

@@ -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;

View File

@@ -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))