fix(security): prevent option injection, add SSRF URL filtering, harden parameters and XSS escaping
This commit is contained in:
parent
3eaf9a659b
commit
29cd9a8688
11 changed files with 421 additions and 184 deletions
24
.gitignore
vendored
24
.gitignore
vendored
|
|
@ -1,12 +1,30 @@
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
.env
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
*.log
|
|
||||||
.DS_Store
|
# Rust
|
||||||
target/
|
target/
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# Downloads & runtime artifacts
|
||||||
downloads/
|
downloads/
|
||||||
yt-dlp
|
yt-dlp
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Credentials, secrets, sessions
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
ig_session*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# OS & Editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,19 @@ async fn main() {
|
||||||
// Static file serving for /files/*
|
// Static file serving for /files/*
|
||||||
let serve_dir = ServeDir::new(&config.download_dir);
|
let serve_dir = ServeDir::new(&config.download_dir);
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_methods([
|
||||||
|
axum::http::Method::GET,
|
||||||
|
axum::http::Method::POST,
|
||||||
|
axum::http::Method::OPTIONS,
|
||||||
|
])
|
||||||
|
.allow_origin(tower_http::cors::Any)
|
||||||
|
.allow_headers([
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::header::ACCEPT,
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
]);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root_handler))
|
.route("/", get(root_handler))
|
||||||
.route("/health", get(health_handler))
|
.route("/health", get(health_handler))
|
||||||
|
|
@ -71,7 +84,8 @@ async fn main() {
|
||||||
.nest("/api/youtube", yt_routes)
|
.nest("/api/youtube", yt_routes)
|
||||||
.nest("/api/instagram", ig_routes)
|
.nest("/api/instagram", ig_routes)
|
||||||
.nest_service("/files", serve_dir)
|
.nest_service("/files", serve_dir)
|
||||||
.layer(CorsLayer::permissive())
|
.layer(axum::extract::DefaultBodyLimit::max(64 * 1024))
|
||||||
|
.layer(cors)
|
||||||
.layer(CompressionLayer::new())
|
.layer(CompressionLayer::new())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(config.clone());
|
.with_state(config.clone());
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use regex::Regex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::instagram::InstagramService;
|
use crate::services::instagram::InstagramService;
|
||||||
use crate::services::youtube::YouTubeService;
|
use crate::services::youtube::YouTubeService;
|
||||||
use crate::utils::{clean_ig_url, clean_yt_url, extract_ig_username, is_ig_profile_url};
|
use crate::utils::{
|
||||||
|
clean_ig_url, clean_yt_url, extract_ig_username, is_ig_profile_url, is_valid_instagram_url,
|
||||||
static YT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
is_valid_youtube_url,
|
||||||
Regex::new(r"(youtube\.com|youtu\.be)").expect("valid regex")
|
};
|
||||||
});
|
|
||||||
static IG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
||||||
Regex::new(r"(instagram\.com|instagr\.am)").expect("valid regex")
|
|
||||||
});
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct InfoRequest {
|
pub struct InfoRequest {
|
||||||
|
|
@ -30,9 +24,10 @@ pub struct DetectResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_platform(url: &str) -> &'static str {
|
pub fn detect_platform(url: &str) -> &'static str {
|
||||||
if YT_RE.is_match(url) {
|
let trimmed = url.trim();
|
||||||
|
if is_valid_youtube_url(trimmed) {
|
||||||
"youtube"
|
"youtube"
|
||||||
} else if IG_RE.is_match(url) {
|
} else if is_valid_instagram_url(trimmed) {
|
||||||
"instagram"
|
"instagram"
|
||||||
} else {
|
} else {
|
||||||
"unknown"
|
"unknown"
|
||||||
|
|
@ -43,7 +38,7 @@ pub async fn detect(Json(req): Json<InfoRequest>) -> Result<Json<DetectResponse>
|
||||||
let platform = detect_platform(&req.url);
|
let platform = detect_platform(&req.url);
|
||||||
if platform == "unknown" {
|
if platform == "unknown" {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"could not detect platform from that url".to_string(),
|
"Unsupported or invalid URL — only YouTube and Instagram are supported".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Json(DetectResponse {
|
Ok(Json(DetectResponse {
|
||||||
|
|
@ -79,7 +74,7 @@ pub async fn preview(
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"unsupported platform — only YouTube and Instagram are supported".to_string(),
|
"Unsupported or invalid URL — only YouTube and Instagram are supported".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::instagram::InstagramService;
|
use crate::services::instagram::InstagramService;
|
||||||
use crate::utils::{clean_ig_url, extract_ig_username};
|
use crate::utils::{clean_ig_url, extract_ig_username, is_valid_ig_username, is_valid_instagram_url, is_valid_time_format};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PostRequest {
|
pub struct PostRequest {
|
||||||
|
|
@ -35,11 +35,29 @@ pub async fn post(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<PostRequest>,
|
Json(req): Json<PostRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_instagram_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string()));
|
||||||
|
}
|
||||||
|
if req.format != "mp4" {
|
||||||
|
return Err(AppError::BadRequest("Invalid format for Instagram video — only mp4 is supported".to_string()));
|
||||||
|
}
|
||||||
|
if let Some(ref st) = req.start_time {
|
||||||
|
if !is_valid_time_format(st) {
|
||||||
|
return Err(AppError::BadRequest("Invalid start time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref et) = req.end_time {
|
||||||
|
if !is_valid_time_format(et) {
|
||||||
|
return Err(AppError::BadRequest("Invalid end time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_ig_url(req.url.trim());
|
let url = clean_ig_url(raw_url);
|
||||||
let (path, filename) = InstagramService::download_post(
|
let (path, filename) = InstagramService::download_post(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -57,11 +75,26 @@ pub async fn reel(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<PostRequest>,
|
Json(req): Json<PostRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_instagram_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string()));
|
||||||
|
}
|
||||||
|
if let Some(ref st) = req.start_time {
|
||||||
|
if !is_valid_time_format(st) {
|
||||||
|
return Err(AppError::BadRequest("Invalid start time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref et) = req.end_time {
|
||||||
|
if !is_valid_time_format(et) {
|
||||||
|
return Err(AppError::BadRequest("Invalid end time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_ig_url(req.url.trim());
|
let url = clean_ig_url(raw_url);
|
||||||
let (path, filename) = InstagramService::download_reel(
|
let (path, filename) = InstagramService::download_reel(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -78,10 +111,6 @@ pub async fn profile(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<ProfileRequest>,
|
Json(req): Json<ProfileRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let raw = req.username.trim();
|
let raw = req.username.trim();
|
||||||
let username = if raw.contains("instagram.com") {
|
let username = if raw.contains("instagram.com") {
|
||||||
extract_ig_username(raw).map_err(|e| AppError::BadRequest(e))?
|
extract_ig_username(raw).map_err(|e| AppError::BadRequest(e))?
|
||||||
|
|
@ -89,10 +118,14 @@ pub async fn profile(
|
||||||
raw.trim_start_matches('@').to_string()
|
raw.trim_start_matches('@').to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
if username.is_empty() {
|
if username.is_empty() || !is_valid_ig_username(&username) {
|
||||||
return Err(AppError::BadRequest("no username provided".to_string()));
|
return Err(AppError::BadRequest("Invalid Instagram username provided".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let (path, filename) = InstagramService::download_profile(
|
let (path, filename) = InstagramService::download_profile(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -107,11 +140,16 @@ pub async fn thumbnail(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<SimpleRequest>,
|
Json(req): Json<SimpleRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_instagram_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_ig_url(req.url.trim());
|
let url = clean_ig_url(raw_url);
|
||||||
let (path, filename) = InstagramService::download_thumbnail(
|
let (path, filename) = InstagramService::download_thumbnail(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ use serde_json::json;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::youtube::YouTubeService;
|
use crate::services::youtube::YouTubeService;
|
||||||
use crate::utils::clean_yt_url;
|
use crate::utils::{
|
||||||
|
clean_yt_url, is_valid_audio_format, is_valid_audio_quality, is_valid_lang_code,
|
||||||
|
is_valid_resolution, is_valid_time_format, is_valid_video_format, is_valid_youtube_url,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct VideoRequest {
|
pub struct VideoRequest {
|
||||||
|
|
@ -68,11 +71,32 @@ pub async fn video(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<VideoRequest>,
|
Json(req): Json<VideoRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_youtube_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string()));
|
||||||
|
}
|
||||||
|
if !is_valid_resolution(&req.resolution) {
|
||||||
|
return Err(AppError::BadRequest("Invalid video resolution specified".to_string()));
|
||||||
|
}
|
||||||
|
if !is_valid_video_format(&req.format) {
|
||||||
|
return Err(AppError::BadRequest("Invalid video format specified".to_string()));
|
||||||
|
}
|
||||||
|
if let Some(ref st) = req.start_time {
|
||||||
|
if !is_valid_time_format(st) {
|
||||||
|
return Err(AppError::BadRequest("Invalid start time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref et) = req.end_time {
|
||||||
|
if !is_valid_time_format(et) {
|
||||||
|
return Err(AppError::BadRequest("Invalid end time format".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_yt_url(&req.url.trim());
|
let url = clean_yt_url(raw_url);
|
||||||
let (path, filename) = YouTubeService::download_video(
|
let (path, filename) = YouTubeService::download_video(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -91,11 +115,22 @@ pub async fn audio(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<AudioRequest>,
|
Json(req): Json<AudioRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_youtube_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string()));
|
||||||
|
}
|
||||||
|
if !is_valid_audio_quality(&req.quality) {
|
||||||
|
return Err(AppError::BadRequest("Invalid audio quality specified".to_string()));
|
||||||
|
}
|
||||||
|
if !is_valid_audio_format(&req.format) {
|
||||||
|
return Err(AppError::BadRequest("Invalid audio format specified".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_yt_url(&req.url.trim());
|
let url = clean_yt_url(raw_url);
|
||||||
let (path, filename) = YouTubeService::download_audio(
|
let (path, filename) = YouTubeService::download_audio(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -112,11 +147,16 @@ pub async fn thumbnail(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<SimpleRequest>,
|
Json(req): Json<SimpleRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_youtube_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_yt_url(&req.url.trim());
|
let url = clean_yt_url(raw_url);
|
||||||
let (path, filename) = YouTubeService::download_thumbnail(
|
let (path, filename) = YouTubeService::download_thumbnail(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -131,11 +171,19 @@ pub async fn subtitles(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<SubtitleRequest>,
|
Json(req): Json<SubtitleRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
if !is_valid_youtube_url(raw_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string()));
|
||||||
|
}
|
||||||
|
if !is_valid_lang_code(&req.lang) {
|
||||||
|
return Err(AppError::BadRequest("Invalid language code specified".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = clean_yt_url(&req.url.trim());
|
let url = clean_yt_url(raw_url);
|
||||||
let (path, filename) = YouTubeService::download_subtitles(
|
let (path, filename) = YouTubeService::download_subtitles(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
|
|
@ -151,6 +199,11 @@ pub async fn channel_art(
|
||||||
State(config): State<AppConfig>,
|
State(config): State<AppConfig>,
|
||||||
Json(req): Json<ChannelRequest>,
|
Json(req): Json<ChannelRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let channel_url = req.channel_url.trim();
|
||||||
|
if !is_valid_youtube_url(channel_url) {
|
||||||
|
return Err(AppError::BadRequest("Invalid YouTube channel URL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
AppError::Internal("Server busy, please retry shortly".to_string())
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -158,7 +211,7 @@ pub async fn channel_art(
|
||||||
let assets = YouTubeService::download_channel_art(
|
let assets = YouTubeService::download_channel_art(
|
||||||
&config.ytdlp_bin,
|
&config.ytdlp_bin,
|
||||||
&config.download_dir,
|
&config.download_dir,
|
||||||
req.channel_url.trim(),
|
channel_url,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,9 @@ impl InstagramService {
|
||||||
"-f", "bestvideo+bestaudio/best",
|
"-f", "bestvideo+bestaudio/best",
|
||||||
"--merge-output-format", fmt,
|
"--merge-output-format", fmt,
|
||||||
"-o", &out_tmpl,
|
"-o", &out_tmpl,
|
||||||
url,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound("Instagram post file not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Instagram post file not found".to_string()))?;
|
||||||
|
|
@ -124,10 +123,9 @@ impl InstagramService {
|
||||||
"-f", "bestvideo+bestaudio/best",
|
"-f", "bestvideo+bestaudio/best",
|
||||||
"--merge-output-format", "mp4",
|
"--merge-output-format", "mp4",
|
||||||
"-o", &out_tmpl,
|
"-o", &out_tmpl,
|
||||||
url,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound("Instagram reel file not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Instagram reel file not found".to_string()))?;
|
||||||
|
|
@ -157,12 +155,15 @@ impl InstagramService {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| AppError::NotFound("No thumbnail found for Instagram post".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("No thumbnail found for Instagram post".to_string()))?;
|
||||||
|
|
||||||
|
let parsed_url = crate::utils::parse_safe_url(thumb_url)
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("Invalid thumbnail URL: {}", e)))?;
|
||||||
|
|
||||||
let filename = format!("ig_thumb_{}.jpg", uid);
|
let filename = format!("ig_thumb_{}.jpg", uid);
|
||||||
let dest = download_dir.join(&filename);
|
let dest = download_dir.join(&filename);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(thumb_url)
|
.get(parsed_url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to fetch thumbnail: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("Failed to fetch thumbnail: {}", e)))?
|
||||||
|
|
@ -189,13 +190,16 @@ impl InstagramService {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Could not find profile picture for @{}", username)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Could not find profile picture for @{}", username)))?;
|
||||||
|
|
||||||
|
let parsed_url = crate::utils::parse_safe_url(thumb_url)
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("Invalid profile picture URL: {}", e)))?;
|
||||||
|
|
||||||
let safe_user = safe_filename(username);
|
let safe_user = safe_filename(username);
|
||||||
let filename = format!("{}_profile.jpg", safe_user);
|
let filename = format!("{}_profile.jpg", safe_user);
|
||||||
let dest = download_dir.join(format!("ig_pfp_{}_{}.jpg", safe_user, uid));
|
let dest = download_dir.join(format!("ig_pfp_{}_{}.jpg", safe_user, uid));
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(thumb_url)
|
.get(parsed_url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to fetch profile picture: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("Failed to fetch profile picture: {}", e)))?
|
||||||
|
|
|
||||||
|
|
@ -119,23 +119,25 @@ impl YouTubeService {
|
||||||
let start_owned;
|
let start_owned;
|
||||||
let end_owned;
|
let end_owned;
|
||||||
if let Some(st) = start_time {
|
if let Some(st) = start_time {
|
||||||
start_owned = format!("*{}", st);
|
if crate::utils::is_valid_time_format(st) {
|
||||||
args.push("--download-sections");
|
start_owned = format!("*{}", st);
|
||||||
args.push(&start_owned);
|
args.push("--download-sections");
|
||||||
|
args.push(&start_owned);
|
||||||
|
}
|
||||||
} else if let Some(et) = end_time {
|
} else if let Some(et) = end_time {
|
||||||
end_owned = format!("*-{}", et);
|
if crate::utils::is_valid_time_format(et) {
|
||||||
args.push("--download-sections");
|
end_owned = format!("*-{}", et);
|
||||||
args.push(&end_owned);
|
args.push("--download-sections");
|
||||||
|
args.push(&end_owned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(url);
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
|
||||||
|
|
||||||
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound("Output video file not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Output video file not found".to_string()))?;
|
||||||
|
|
||||||
// Extract video title
|
// Extract video title safely
|
||||||
let title = match YtDlp::dump_json(bin, url).await {
|
let title = match YtDlp::dump_json(bin, url).await {
|
||||||
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("video")),
|
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("video")),
|
||||||
Err(_) => "video".to_string(),
|
Err(_) => "video".to_string(),
|
||||||
|
|
@ -162,6 +164,7 @@ impl YouTubeService {
|
||||||
"opus" => "opus",
|
"opus" => "opus",
|
||||||
"flac" => "flac",
|
"flac" => "flac",
|
||||||
"m4a" => "m4a",
|
"m4a" => "m4a",
|
||||||
|
"wav" => "wav",
|
||||||
_ => "mp3",
|
_ => "mp3",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -170,10 +173,9 @@ impl YouTubeService {
|
||||||
"--audio-format", codec,
|
"--audio-format", codec,
|
||||||
"--audio-quality", quality,
|
"--audio-quality", quality,
|
||||||
"-o", &out_tmpl,
|
"-o", &out_tmpl,
|
||||||
url,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound("Audio file not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Audio file not found".to_string()))?;
|
||||||
|
|
@ -202,10 +204,9 @@ impl YouTubeService {
|
||||||
"--skip-download",
|
"--skip-download",
|
||||||
"--write-thumbnail",
|
"--write-thumbnail",
|
||||||
"-o", &out_tmpl,
|
"-o", &out_tmpl,
|
||||||
url,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound("Thumbnail file not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Thumbnail file not found".to_string()))?;
|
||||||
|
|
@ -238,10 +239,9 @@ impl YouTubeService {
|
||||||
"--sub-lang", lang,
|
"--sub-lang", lang,
|
||||||
"--sub-format", "srt",
|
"--sub-format", "srt",
|
||||||
"-o", &out_tmpl,
|
"-o", &out_tmpl,
|
||||||
url,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
YtDlp::execute(bin, &args).await?;
|
YtDlp::execute(bin, &args, url).await?;
|
||||||
|
|
||||||
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
.ok_or_else(|| AppError::NotFound(format!("No subtitles found for lang={}", lang)))?;
|
.ok_or_else(|| AppError::NotFound(format!("No subtitles found for lang={}", lang)))?;
|
||||||
|
|
@ -289,16 +289,19 @@ impl YouTubeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, best_avatar_url)) = avatar_candidates.into_iter().max_by_key(|c| c.0) {
|
if let Some((_, best_avatar_url)) = avatar_candidates.into_iter().max_by_key(|c| c.0) {
|
||||||
let filename = format!("{}_avatar_{}.jpg", channel_name, uid);
|
// Ensure avatar URL is valid safe HTTPS URL
|
||||||
let dest = download_dir.join(&filename);
|
if let Ok(parsed_url) = crate::utils::parse_safe_url(best_avatar_url) {
|
||||||
if let Ok(resp) = client.get(best_avatar_url).send().await {
|
let filename = format!("{}_avatar_{}.jpg", channel_name, uid);
|
||||||
if let Ok(bytes) = resp.bytes().await {
|
let dest = download_dir.join(&filename);
|
||||||
let _ = tokio::fs::write(&dest, bytes).await;
|
if let Ok(resp) = client.get(parsed_url).send().await {
|
||||||
assets.push(ChannelAsset {
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
asset_type: "avatar".to_string(),
|
let _ = tokio::fs::write(&dest, bytes).await;
|
||||||
filename: filename.clone(),
|
assets.push(ChannelAsset {
|
||||||
url: format!("/files/{}", filename),
|
asset_type: "avatar".to_string(),
|
||||||
});
|
filename: filename.clone(),
|
||||||
|
url: format!("/files/{}", filename),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -316,16 +319,18 @@ impl YouTubeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, best_banner_url)) = banner_candidates.into_iter().max_by_key(|c| c.0) {
|
if let Some((_, best_banner_url)) = banner_candidates.into_iter().max_by_key(|c| c.0) {
|
||||||
let filename = format!("{}_banner_{}.jpg", channel_name, uid);
|
if let Ok(parsed_url) = crate::utils::parse_safe_url(best_banner_url) {
|
||||||
let dest = download_dir.join(&filename);
|
let filename = format!("{}_banner_{}.jpg", channel_name, uid);
|
||||||
if let Ok(resp) = client.get(best_banner_url).send().await {
|
let dest = download_dir.join(&filename);
|
||||||
if let Ok(bytes) = resp.bytes().await {
|
if let Ok(resp) = client.get(parsed_url).send().await {
|
||||||
let _ = tokio::fs::write(&dest, bytes).await;
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
assets.push(ChannelAsset {
|
let _ = tokio::fs::write(&dest, bytes).await;
|
||||||
asset_type: "banner".to_string(),
|
assets.push(ChannelAsset {
|
||||||
filename: filename.clone(),
|
asset_type: "banner".to_string(),
|
||||||
url: format!("/files/{}", filename),
|
filename: filename.clone(),
|
||||||
});
|
url: format!("/files/{}", filename),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ impl YtDlp {
|
||||||
cmd.arg("--js-runtimes").arg("node");
|
cmd.arg("--js-runtimes").arg("node");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use "--" delimiter to guarantee the URL is never parsed as a CLI flag
|
||||||
|
cmd.arg("--");
|
||||||
cmd.arg(url);
|
cmd.arg(url);
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
|
@ -46,8 +48,8 @@ impl YtDlp {
|
||||||
Ok(json_val)
|
Ok(json_val)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs yt-dlp with custom arguments
|
/// Runs yt-dlp with custom arguments and a target URL
|
||||||
pub async fn execute(bin: &str, args: &[&str]) -> Result<(), AppError> {
|
pub async fn execute(bin: &str, args: &[&str], url: &str) -> Result<(), AppError> {
|
||||||
let mut cmd = Command::new(bin);
|
let mut cmd = Command::new(bin);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
cmd.arg("--no-warnings").arg("--no-playlist");
|
cmd.arg("--no-warnings").arg("--no-playlist");
|
||||||
|
|
@ -56,9 +58,13 @@ impl YtDlp {
|
||||||
cmd.arg("--js-runtimes").arg("node");
|
cmd.arg("--js-runtimes").arg("node");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use "--" delimiter to guarantee the URL is never parsed as a CLI flag
|
||||||
|
cmd.arg("--");
|
||||||
|
cmd.arg(url);
|
||||||
|
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
info!("Executing yt-dlp with {} args", args.len());
|
info!("Executing yt-dlp with {} flags against target", args.len());
|
||||||
let output = cmd.output().await.map_err(|e| {
|
let output = cmd.output().await.map_err(|e| {
|
||||||
AppError::Internal(format!("Failed to run yt-dlp: {}", e))
|
AppError::Internal(format!("Failed to run yt-dlp: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -86,12 +92,16 @@ impl YtDlp {
|
||||||
cmd.arg("-y");
|
cmd.arg("-y");
|
||||||
|
|
||||||
if let Some(start) = start_time {
|
if let Some(start) = start_time {
|
||||||
cmd.arg("-ss").arg(start);
|
if crate::utils::is_valid_time_format(start) {
|
||||||
|
cmd.arg("-ss").arg(start);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmd.arg("-i").arg(src);
|
cmd.arg("-i").arg(src);
|
||||||
|
|
||||||
if let Some(end) = end_time {
|
if let Some(end) = end_time {
|
||||||
cmd.arg("-to").arg(end);
|
if crate::utils::is_valid_time_format(end) {
|
||||||
|
cmd.arg("-to").arg(end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmd.arg("-c").arg("copy").arg(dest);
|
cmd.arg("-c").arg("copy").arg(dest);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,154 @@
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use reqwest::Url;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
static YT_V_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static YT_V_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"[?&]v=([A-Za-z0-9_-]{11})").expect("valid regex")
|
Regex::new(r"[?&]v=([A-Za-z0-9_-]{11})").expect("valid regex")
|
||||||
});
|
});
|
||||||
static YT_SHORT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static YT_SHORT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"https?://youtu\.be/([A-Za-z0-9_-]{11})").expect("valid regex")
|
Regex::new(r"^https?://youtu\.be/([A-Za-z0-9_-]{11})").expect("valid regex")
|
||||||
});
|
});
|
||||||
#[allow(dead_code)]
|
static TIME_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
static IG_SHORTCODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
Regex::new(r"^(\d{1,2}:)?\d{1,2}:\d{2}(\.\d+)?$|^\d+(\.\d+)?$").expect("valid regex")
|
||||||
Regex::new(r"/(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)").expect("valid regex")
|
});
|
||||||
|
static LANG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,10})?$").expect("valid regex")
|
||||||
|
});
|
||||||
|
static IG_USER_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[a-zA-Z0-9._]{1,30}$").expect("valid regex")
|
||||||
});
|
});
|
||||||
static SAFE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static SAFE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r#"[\\/*?:"<>|]"#).expect("valid regex")
|
Regex::new(r#"[^a-zA-Z0-9._-]"#).expect("valid regex")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Validates that a string is a safe HTTP/HTTPS URL and returns the parsed URL.
|
||||||
|
pub fn parse_safe_url(raw: &str) -> Result<Url, &'static str> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.len() > 2048 {
|
||||||
|
return Err("URL exceeds maximum allowed length");
|
||||||
|
}
|
||||||
|
if raw.starts_with('-') || raw.contains('\0') || raw.contains('\n') || raw.contains('\r') {
|
||||||
|
return Err("Invalid characters in URL");
|
||||||
|
}
|
||||||
|
let parsed = Url::parse(raw).map_err(|_| "Malformed URL format")?;
|
||||||
|
let scheme = parsed.scheme();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return Err("Only HTTP and HTTPS URLs are allowed");
|
||||||
|
}
|
||||||
|
let host = match parsed.host_str() {
|
||||||
|
Some(h) => h.to_ascii_lowercase(),
|
||||||
|
None => return Err("URL must have a valid hostname"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Block localhost, IP addresses, private IP ranges (anti-SSRF)
|
||||||
|
if host == "localhost"
|
||||||
|
|| host == "127.0.0.1"
|
||||||
|
|| host == "0.0.0.0"
|
||||||
|
|| host == "::1"
|
||||||
|
|| host.starts_with("192.168.")
|
||||||
|
|| host.starts_with("10.")
|
||||||
|
|| host.starts_with("172.")
|
||||||
|
|| host.starts_with("169.254.")
|
||||||
|
{
|
||||||
|
return Err("Requests to local or private networks are forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a URL is a valid, allowed YouTube URL
|
||||||
|
pub fn is_valid_youtube_url(url: &str) -> bool {
|
||||||
|
match parse_safe_url(url) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
let host = host.to_ascii_lowercase();
|
||||||
|
host == "youtube.com"
|
||||||
|
|| host == "www.youtube.com"
|
||||||
|
|| host == "m.youtube.com"
|
||||||
|
|| host == "music.youtube.com"
|
||||||
|
|| host == "youtu.be"
|
||||||
|
|| host == "www.youtu.be"
|
||||||
|
|| host.ends_with(".youtube.com")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a URL is a valid, allowed Instagram URL
|
||||||
|
pub fn is_valid_instagram_url(url: &str) -> bool {
|
||||||
|
match parse_safe_url(url) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
let host = host.to_ascii_lowercase();
|
||||||
|
host == "instagram.com"
|
||||||
|
|| host == "www.instagram.com"
|
||||||
|
|| host == "instagr.am"
|
||||||
|
|| host == "www.instagr.am"
|
||||||
|
|| host.ends_with(".instagram.com")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_resolution(res: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
res,
|
||||||
|
"best"
|
||||||
|
| "144"
|
||||||
|
| "240"
|
||||||
|
| "360"
|
||||||
|
| "480"
|
||||||
|
| "720"
|
||||||
|
| "1080"
|
||||||
|
| "1440"
|
||||||
|
| "2160"
|
||||||
|
| "4320"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_video_format(fmt: &str) -> bool {
|
||||||
|
matches!(fmt, "mp4" | "webm" | "mkv")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_audio_format(fmt: &str) -> bool {
|
||||||
|
matches!(fmt, "mp3" | "m4a" | "opus" | "flac" | "wav")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_audio_quality(quality: &str) -> bool {
|
||||||
|
matches!(quality, "0" | "64" | "128" | "192" | "256" | "320")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_lang_code(lang: &str) -> bool {
|
||||||
|
LANG_RE.is_match(lang) && lang.len() <= 15
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_time_format(time: &str) -> bool {
|
||||||
|
TIME_RE.is_match(time) && time.len() <= 15
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_ig_username(user: &str) -> bool {
|
||||||
|
IG_USER_RE.is_match(user) && !user.starts_with('.') && !user.contains("..")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clean_yt_url(url: &str) -> String {
|
pub fn clean_yt_url(url: &str) -> String {
|
||||||
if let Some(caps) = YT_V_RE.captures(url) {
|
let trimmed = url.trim();
|
||||||
|
if let Some(caps) = YT_V_RE.captures(trimmed) {
|
||||||
if let Some(id) = caps.get(1) {
|
if let Some(id) = caps.get(1) {
|
||||||
return format!("https://www.youtube.com/watch?v={}", id.as_str());
|
return format!("https://www.youtube.com/watch?v={}", id.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(caps) = YT_SHORT_RE.captures(url) {
|
if let Some(caps) = YT_SHORT_RE.captures(trimmed) {
|
||||||
if let Some(id) = caps.get(1) {
|
if let Some(id) = caps.get(1) {
|
||||||
return format!("https://youtu.be/{}", id.as_str());
|
return format!("https://youtu.be/{}", id.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
url.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clean_ig_url(url: &str) -> String {
|
pub fn clean_ig_url(url: &str) -> String {
|
||||||
|
|
@ -74,29 +196,16 @@ pub fn extract_ig_username(url: &str) -> Result<String, String> {
|
||||||
.trim_start_matches('@')
|
.trim_start_matches('@')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if username.is_empty() {
|
if username.is_empty() || !is_valid_ig_username(username) {
|
||||||
Err("could not extract username from URL".to_string())
|
Err("could not extract valid username from URL".to_string())
|
||||||
} else {
|
} else {
|
||||||
Ok(username.to_string())
|
Ok(username.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn extract_ig_shortcode(url: &str) -> Result<String, String> {
|
|
||||||
if let Some(caps) = IG_SHORTCODE_RE.captures(url) {
|
|
||||||
if let Some(sc) = caps.get(1) {
|
|
||||||
return Ok(sc.as_str().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"could not extract shortcode from: {} — expected a post, reel, or tv URL",
|
|
||||||
url
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn safe_filename(name: &str) -> String {
|
pub fn safe_filename(name: &str) -> String {
|
||||||
let sanitized = SAFE_RE.replace_all(name, "_");
|
let sanitized = SAFE_RE.replace_all(name, "_");
|
||||||
let trimmed = sanitized.trim();
|
let trimmed = sanitized.trim_matches(|c| c == '.' || c == '_' || c == '-');
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
"file".to_string()
|
"file".to_string()
|
||||||
} else if trimmed.len() > 80 {
|
} else if trimmed.len() > 80 {
|
||||||
|
|
@ -123,22 +232,62 @@ pub fn format_duration(seconds: Option<f64>) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[cfg(test)]
|
||||||
pub fn parse_time_to_seconds(t: &str) -> Option<f64> {
|
mod tests {
|
||||||
let parts: Vec<&str> = t.trim().split(':').collect();
|
use super::*;
|
||||||
match parts.len() {
|
|
||||||
3 => {
|
#[test]
|
||||||
let h = parts[0].parse::<f64>().ok()?;
|
fn test_valid_youtube_urls() {
|
||||||
let m = parts[1].parse::<f64>().ok()?;
|
assert!(is_valid_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
|
||||||
let s = parts[2].parse::<f64>().ok()?;
|
assert!(is_valid_youtube_url("https://youtu.be/dQw4w9WgXcQ"));
|
||||||
Some(h * 3600.0 + m * 60.0 + s)
|
assert!(is_valid_youtube_url("https://m.youtube.com/watch?v=dQw4w9WgXcQ"));
|
||||||
}
|
assert!(is_valid_youtube_url("http://youtube.com/shorts/dQw4w9WgXcQ"));
|
||||||
2 => {
|
}
|
||||||
let m = parts[0].parse::<f64>().ok()?;
|
|
||||||
let s = parts[1].parse::<f64>().ok()?;
|
#[test]
|
||||||
Some(m * 60.0 + s)
|
fn test_invalid_youtube_urls() {
|
||||||
}
|
assert!(!is_valid_youtube_url("file:///etc/passwd"));
|
||||||
1 => parts[0].parse::<f64>().ok(),
|
assert!(!is_valid_youtube_url("http://localhost:8022#youtube.com"));
|
||||||
_ => None,
|
assert!(!is_valid_youtube_url("http://127.0.0.1:8000/youtube.com"));
|
||||||
|
assert!(!is_valid_youtube_url("http://192.168.0.124:8022#youtube.com"));
|
||||||
|
assert!(!is_valid_youtube_url("http://attacker.com/youtube.com"));
|
||||||
|
assert!(!is_valid_youtube_url("http://youtube.com.attacker.com"));
|
||||||
|
assert!(!is_valid_youtube_url("--exec echo pwned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_instagram_urls() {
|
||||||
|
assert!(is_valid_instagram_url("https://www.instagram.com/p/DF123/"));
|
||||||
|
assert!(is_valid_instagram_url("https://instagram.com/reel/C890/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_instagram_urls() {
|
||||||
|
assert!(!is_valid_instagram_url("file:///etc/shadow"));
|
||||||
|
assert!(!is_valid_instagram_url("http://localhost:3000/instagram.com"));
|
||||||
|
assert!(!is_valid_instagram_url("http://instagram.com.evil.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolutions() {
|
||||||
|
assert!(is_valid_resolution("1080"));
|
||||||
|
assert!(is_valid_resolution("best"));
|
||||||
|
assert!(!is_valid_resolution("--exec"));
|
||||||
|
assert!(!is_valid_resolution("99999"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_formats() {
|
||||||
|
assert!(is_valid_video_format("mp4"));
|
||||||
|
assert!(is_valid_video_format("webm"));
|
||||||
|
assert!(!is_valid_video_format("exe"));
|
||||||
|
assert!(!is_valid_video_format("--exec"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_safe_filename() {
|
||||||
|
assert_eq!(safe_filename("../../etc/passwd"), "etc_passwd");
|
||||||
|
assert_eq!(safe_filename("great video [1080p].mp4"), "great_video__1080p_.mp4");
|
||||||
|
assert_eq!(safe_filename("..."), "file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1051,15 +1051,22 @@
|
||||||
function renderPreview(data, platform) {
|
function renderPreview(data, platform) {
|
||||||
document.getElementById('previewCard').classList.add('show');
|
document.getElementById('previewCard').classList.add('show');
|
||||||
const thumbEl = document.getElementById('previewThumb');
|
const thumbEl = document.getElementById('previewThumb');
|
||||||
if (data.thumbnail) thumbEl.innerHTML = `<img src="${data.thumbnail}" alt="" onerror="this.style.display='none'"/>`;
|
thumbEl.innerHTML = '';
|
||||||
|
if (data.thumbnail && typeof data.thumbnail === 'string' && (data.thumbnail.startsWith('http://') || data.thumbnail.startsWith('https://'))) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = data.thumbnail;
|
||||||
|
img.alt = '';
|
||||||
|
img.onerror = () => { img.style.display = 'none'; };
|
||||||
|
thumbEl.appendChild(img);
|
||||||
|
}
|
||||||
document.getElementById('previewTitle').textContent = data.title || data.owner || '—';
|
document.getElementById('previewTitle').textContent = data.title || data.owner || '—';
|
||||||
const color = platform === 'yt' ? 'var(--yt)' : 'var(--ig)';
|
const color = platform === 'yt' ? 'var(--yt)' : 'var(--ig)';
|
||||||
const pills = [];
|
const pills = [];
|
||||||
if (data.duration_str) pills.push(`<span class="meta-pill">${data.duration_str}</span>`);
|
if (data.duration_str) pills.push(`<span class="meta-pill">${escapeHtml(data.duration_str)}</span>`);
|
||||||
if (data.view_count) pills.push(`<span class="meta-pill">${fmtNum(data.view_count)} views</span>`);
|
if (data.view_count) pills.push(`<span class="meta-pill">${escapeHtml(fmtNum(data.view_count))} views</span>`);
|
||||||
if (data.likes) pills.push(`<span class="meta-pill">${fmtNum(data.likes)} ${data.type==='profile'?'followers':'likes'}</span>`);
|
if (data.likes) pills.push(`<span class="meta-pill">${escapeHtml(fmtNum(data.likes))} ${data.type==='profile'?'followers':'likes'}</span>`);
|
||||||
if (data.uploader) pills.push(`<span class="meta-pill" style="color:${color}">${data.uploader}</span>`);
|
if (data.uploader) pills.push(`<span class="meta-pill" style="color:${color}">${escapeHtml(data.uploader)}</span>`);
|
||||||
if (data.owner) pills.push(`<span class="meta-pill" style="color:${color}">@${data.owner}</span>`);
|
if (data.owner) pills.push(`<span class="meta-pill" style="color:${color}">@${escapeHtml(data.owner)}</span>`);
|
||||||
document.getElementById('previewMeta').innerHTML = pills.join('');
|
document.getElementById('previewMeta').innerHTML = pills.join('');
|
||||||
const res = data.available_resolutions || [];
|
const res = data.available_resolutions || [];
|
||||||
document.getElementById('previewQuality').textContent = res.length ? `// max: ${res[0]}p available` : '';
|
document.getElementById('previewQuality').textContent = res.length ? `// max: ${res[0]}p available` : '';
|
||||||
|
|
|
||||||
56
setup.sh
56
setup.sh
|
|
@ -1,56 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "==> [1/5] Installing system dependencies (ffmpeg, zram-tools, curl)..."
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq ffmpeg zram-tools curl
|
|
||||||
|
|
||||||
echo "==> [2/5] Downloading optimized just-download Rust binary..."
|
|
||||||
curl -sL https://github.com/oiupoyt/just-download/releases/download/v2.0.0/just-download-backend -o /usr/local/bin/just-download-backend
|
|
||||||
chmod +x /usr/local/bin/just-download-backend
|
|
||||||
|
|
||||||
echo "==> [3/5] Downloading standalone yt-dlp binary..."
|
|
||||||
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
|
||||||
chmod +x /usr/local/bin/yt-dlp
|
|
||||||
|
|
||||||
echo "==> [4/5] Configuring directories & systemd service..."
|
|
||||||
mkdir -p /var/www/just-download/downloads
|
|
||||||
|
|
||||||
cat << 'EOF' > /etc/systemd/system/just-download.service
|
|
||||||
[Unit]
|
|
||||||
Description=Just-Download Rust Backend Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/var/www/just-download
|
|
||||||
Environment=HOST=0.0.0.0
|
|
||||||
Environment=PORT=8000
|
|
||||||
Environment=DOWNLOAD_DIR=/var/www/just-download/downloads
|
|
||||||
Environment=MAX_CONCURRENT_DOWNLOADS=2
|
|
||||||
Environment=MAX_FILE_AGE_SECS=1800
|
|
||||||
ExecStart=/usr/local/bin/just-download-backend
|
|
||||||
Restart=always
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "==> [5/5] Enabling and starting just-download service..."
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now just-download
|
|
||||||
systemctl restart just-download
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
if systemctl is-active --quiet just-download; then
|
|
||||||
echo "============================================================"
|
|
||||||
echo "✓ Success: just-download Rust backend is running on port 8000!"
|
|
||||||
echo "============================================================"
|
|
||||||
curl -s http://localhost:8000/health
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "❌ Service failed to start. Logs:"
|
|
||||||
journalctl -u just-download -n 20 --no-pager
|
|
||||||
fi
|
|
||||||
Loading…
Add table
Reference in a new issue