refactor: organize Rust modules and simplify deployment README
This commit is contained in:
-248
@@ -1,248 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
domain::{DownloaderInput, SubscriptionInput},
|
||||
error::{Error, Result},
|
||||
media::{self, DestinationInput, MappingInput},
|
||||
service::Service,
|
||||
};
|
||||
use actix_web::{web, HttpResponse};
|
||||
use std::sync::Arc;
|
||||
|
||||
type State = web::Data<Arc<Service>>;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.route("/sources/mikan/search", web::get().to(mikan_search))
|
||||
.route("/sources/mikan/series/{id}", web::get().to(mikan_series))
|
||||
.route(
|
||||
"/sources/mikan/series/{id}/cover",
|
||||
web::get().to(mikan_cover),
|
||||
)
|
||||
.route("/sources/mikan/resolve", web::post().to(mikan_resolve))
|
||||
.route("/status", web::get().to(status))
|
||||
.route("/destinations", web::get().to(destinations))
|
||||
.route("/destinations", web::post().to(create_destination))
|
||||
.route("/destinations/active", web::put().to(activate_destination))
|
||||
.route("/destinations/{id}/mappings", web::get().to(mappings))
|
||||
.route(
|
||||
"/destinations/{id}/mappings",
|
||||
web::post().to(create_mapping),
|
||||
)
|
||||
.route(
|
||||
"/destinations/{id}/entries",
|
||||
web::get().to(directory_entries),
|
||||
)
|
||||
.route("/downloaders", web::get().to(downloaders))
|
||||
.route("/downloaders", web::post().to(create_downloader))
|
||||
.route("/downloaders/active", web::put().to(activate_downloader))
|
||||
.route(
|
||||
"/downloaders/{id}/claim-legacy-jobs",
|
||||
web::post().to(claim_legacy_jobs),
|
||||
)
|
||||
.route("/preview", web::post().to(preview))
|
||||
.route("/subscriptions", web::get().to(list))
|
||||
.route("/subscriptions", web::post().to(create))
|
||||
.route("/subscriptions/{id}", web::get().to(get))
|
||||
.route("/subscriptions/{id}", web::put().to(update))
|
||||
.route("/subscriptions/{id}", web::delete().to(delete))
|
||||
.route("/subscriptions/{id}/preview", web::get().to(preview_saved))
|
||||
.route("/subscriptions/{id}/scan", web::post().to(scan))
|
||||
.route("/subscriptions/{id}/releases", web::get().to(releases))
|
||||
.route("/jobs", web::get().to(jobs))
|
||||
.route("/jobs/{id}/files", web::get().to(job_files))
|
||||
.route("/jobs/{id}/files/check", web::post().to(check_job_files))
|
||||
.route("/jobs/{id}/retry", web::post().to(retry))
|
||||
.route("/run", web::post().to(run)),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
async fn mikan_search(state: State, query: web::Query<SearchQuery>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?))
|
||||
}
|
||||
|
||||
async fn mikan_series(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?))
|
||||
}
|
||||
|
||||
async fn mikan_cover(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let (mime, bytes) = state.mikan.cover(&id).await?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(mime)
|
||||
.insert_header(("X-Content-Type-Options", "nosniff"))
|
||||
.insert_header(("Cache-Control", "private, max-age=60"))
|
||||
.body(bytes))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ResolveInput {
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn mikan_resolve(state: State, body: web::Json<ResolveInput>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?))
|
||||
}
|
||||
|
||||
async fn status(state: State) -> Result<HttpResponse> {
|
||||
let active = state.store.active_downloader_id().await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"downloader_configured": active.is_some(),
|
||||
"active_downloader_id": active,
|
||||
"active_destination_id": state.store.active_destination_id().await?,
|
||||
"legacy_unassigned_jobs": state.store.legacy_unassigned_count().await?,
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
})))
|
||||
}
|
||||
|
||||
async fn destinations(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.destinations().await?))
|
||||
}
|
||||
|
||||
async fn create_destination(
|
||||
state: State,
|
||||
body: web::Json<DestinationInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_destination(body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DestinationSelection {
|
||||
destination_id: String,
|
||||
}
|
||||
|
||||
async fn activate_destination(
|
||||
state: State,
|
||||
body: web::Json<DestinationSelection>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state
|
||||
.store
|
||||
.activate_destination(&body.destination_id)
|
||||
.await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
async fn mappings(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?))
|
||||
}
|
||||
|
||||
async fn create_mapping(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
body: web::Json<MappingInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_mapping(&id, body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DirectoryQuery {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn directory_entries(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
query: web::Query<DirectoryQuery>,
|
||||
) -> Result<HttpResponse> {
|
||||
media::validate_relative(&query.path, true)?;
|
||||
let root = state.store.destination(&id).await?.root_path;
|
||||
let path = query.into_inner().path;
|
||||
let entries = tokio::task::spawn_blocking(move || media::list_local(&root, &path))
|
||||
.await
|
||||
.map_err(|_| Error::Upstream("local directory query failed".into()))?;
|
||||
Ok(HttpResponse::Ok().json(entries))
|
||||
}
|
||||
|
||||
async fn job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?))
|
||||
}
|
||||
|
||||
async fn check_job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?))
|
||||
}
|
||||
|
||||
async fn downloaders(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.downloaders().await?))
|
||||
}
|
||||
|
||||
async fn create_downloader(state: State, body: web::Json<DownloaderInput>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_downloader(body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DownloaderSelection {
|
||||
downloader_id: String,
|
||||
}
|
||||
|
||||
async fn activate_downloader(
|
||||
state: State,
|
||||
body: web::Json<DownloaderSelection>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.activate_downloader(&body.downloader_id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
async fn claim_legacy_jobs(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.json(serde_json::json!({"claimed": state.store.claim_legacy_jobs(&id).await?})))
|
||||
}
|
||||
async fn list(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.subscriptions().await?))
|
||||
}
|
||||
async fn get(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?))
|
||||
}
|
||||
async fn create(state: State, body: web::Json<SubscriptionInput>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create(body.into_inner()).await?))
|
||||
}
|
||||
async fn update(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
body: web::Json<SubscriptionInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Ok().json(state.store.update(&id, body.into_inner()).await?))
|
||||
}
|
||||
async fn delete(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.delete(&id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
async fn preview(state: State, body: web::Json<SubscriptionInput>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?))
|
||||
}
|
||||
async fn preview_saved(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?))
|
||||
}
|
||||
async fn scan(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?})))
|
||||
}
|
||||
async fn releases(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.releases(&id).await?))
|
||||
}
|
||||
async fn jobs(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.jobs().await?))
|
||||
}
|
||||
async fn retry(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.retry(&id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
async fn run(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.cycle().await?))
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Application startup and graceful shutdown.
|
||||
use crate::{
|
||||
config::Config,
|
||||
http,
|
||||
service::{worker, Service},
|
||||
storage::Store,
|
||||
};
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn run(config: Config) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all("data")?;
|
||||
let store = Store::open(&config.database_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let service = Arc::new(
|
||||
Service::new(store, &config)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?,
|
||||
);
|
||||
let app_service = service.clone();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(app_service.clone()))
|
||||
.app_data(web::JsonConfig::default().limit(32 * 1024))
|
||||
.configure(http::configure)
|
||||
})
|
||||
.bind(("127.0.0.1", config.port))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx));
|
||||
|
||||
tracing::info!(address = %format!("127.0.0.1:{}", config.port), "AutoComicRss started");
|
||||
|
||||
let result = server.run().await;
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = background.await;
|
||||
result
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Subscription, release and download job models.
|
||||
pub mod episode;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Downloader protocols and torrent metadata preparation.
|
||||
pub mod qbittorrent;
|
||||
pub mod torrent;
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
config::QbitConfig,
|
||||
error::{Error, Result},
|
||||
network::bounded_body,
|
||||
};
|
||||
use reqwest::{multipart, Client};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Qbit {
|
||||
client: Client,
|
||||
config: QbitConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TorrentStatus {
|
||||
pub hash: String,
|
||||
pub progress: f64,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub save_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TorrentFile {
|
||||
pub name: String,
|
||||
pub size: i64,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
impl Qbit {
|
||||
pub fn new(config: QbitConfig) -> Result<Self> {
|
||||
// Keep downloader cookies in a separate client from untrusted RSS hosts.
|
||||
let client = Client::builder()
|
||||
.no_proxy()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
|
||||
pub async fn login(&self) -> Result<()> {
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/v2/auth/login", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.form(&[
|
||||
("username", &self.config.username),
|
||||
("password", &self.config.password),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 1024).await?;
|
||||
if bytes != b"Ok." {
|
||||
return Err(Error::Upstream("qBittorrent login failed".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn torrents(&self) -> Result<Vec<TorrentStatus>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/api/v2/torrents/info", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 8 * 1024 * 1024).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|_| Error::Upstream("invalid qBittorrent task response".into()))
|
||||
}
|
||||
|
||||
pub async fn files(&self, hash: &str) -> Result<Vec<TorrentFile>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/api/v2/torrents/files", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.query(&[("hash", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 8 * 1024 * 1024).await?;
|
||||
let files: Vec<TorrentFile> = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| Error::Upstream("invalid qBittorrent file list".into()))?;
|
||||
if files.len() > 5000 || files.iter().any(|file| file.size < 0) {
|
||||
return Err(Error::Upstream(
|
||||
"qBittorrent file list exceeds limits or contains invalid sizes".into(),
|
||||
));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
&self,
|
||||
url: &str,
|
||||
bytes: Option<Vec<u8>>,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut form = multipart::Form::new()
|
||||
.text(
|
||||
"savepath",
|
||||
save_path.unwrap_or(&self.config.save_path).to_owned(),
|
||||
)
|
||||
.text("tags", "rust-project")
|
||||
.text("autoTMM", "false");
|
||||
form = if let Some(bytes) = bytes {
|
||||
form.part(
|
||||
"torrents",
|
||||
multipart::Part::bytes(bytes)
|
||||
.file_name("release.torrent")
|
||||
.mime_str("application/x-bittorrent")?,
|
||||
)
|
||||
} else {
|
||||
form.text("urls", url.to_owned())
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/v2/torrents/add", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 1024).await?;
|
||||
if bytes != b"Ok." {
|
||||
return Err(Error::Upstream("qBittorrent rejected torrent".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
config::QbitConfig,
|
||||
domain::validate_http_url,
|
||||
error::{Error, Result},
|
||||
feeds::bounded_body,
|
||||
network::{bounded_body, website_get},
|
||||
};
|
||||
use bendy::decoding::{Decoder, Object};
|
||||
use reqwest::{multipart, Client, Url};
|
||||
use serde::Deserialize;
|
||||
use reqwest::{Client, Url};
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct PreparedTorrent {
|
||||
pub hash: String,
|
||||
@@ -100,8 +97,7 @@ pub async fn prepare(client: &Client, url: &str) -> Result<PreparedTorrent> {
|
||||
});
|
||||
}
|
||||
validate_http_url(url)?;
|
||||
let response =
|
||||
crate::feeds::website_get(client, Url::parse(url).expect("validated URL")).await?;
|
||||
let response = website_get(client, Url::parse(url).expect("validated URL")).await?;
|
||||
let bytes = bounded_body(response, 4 * 1024 * 1024).await?;
|
||||
Ok(PreparedTorrent {
|
||||
hash: torrent_hash(&bytes)?,
|
||||
@@ -109,127 +105,6 @@ pub async fn prepare(client: &Client, url: &str) -> Result<PreparedTorrent> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Qbit {
|
||||
client: Client,
|
||||
config: QbitConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TorrentStatus {
|
||||
pub hash: String,
|
||||
pub progress: f64,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub save_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TorrentFile {
|
||||
pub name: String,
|
||||
pub size: i64,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
impl Qbit {
|
||||
pub fn new(config: QbitConfig) -> Result<Self> {
|
||||
// Keep downloader cookies in a separate client from untrusted RSS hosts.
|
||||
let client = Client::builder()
|
||||
.no_proxy()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
|
||||
pub async fn login(&self) -> Result<()> {
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/v2/auth/login", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.form(&[
|
||||
("username", &self.config.username),
|
||||
("password", &self.config.password),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 1024).await?;
|
||||
if bytes != b"Ok." {
|
||||
return Err(Error::Upstream("qBittorrent login failed".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn torrents(&self) -> Result<Vec<TorrentStatus>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/api/v2/torrents/info", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 8 * 1024 * 1024).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|_| Error::Upstream("invalid qBittorrent task response".into()))
|
||||
}
|
||||
|
||||
pub async fn files(&self, hash: &str) -> Result<Vec<TorrentFile>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/api/v2/torrents/files", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.query(&[("hash", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 8 * 1024 * 1024).await?;
|
||||
let files: Vec<TorrentFile> = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| Error::Upstream("invalid qBittorrent file list".into()))?;
|
||||
if files.len() > 5000 || files.iter().any(|file| file.size < 0) {
|
||||
return Err(Error::Upstream(
|
||||
"qBittorrent file list exceeds limits or contains invalid sizes".into(),
|
||||
));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
&self,
|
||||
url: &str,
|
||||
bytes: Option<Vec<u8>>,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut form = multipart::Form::new()
|
||||
.text(
|
||||
"savepath",
|
||||
save_path.unwrap_or(&self.config.save_path).to_owned(),
|
||||
)
|
||||
.text("tags", "rust-project")
|
||||
.text("autoTMM", "false");
|
||||
form = if let Some(bytes) = bytes {
|
||||
form.part(
|
||||
"torrents",
|
||||
multipart::Part::bytes(bytes)
|
||||
.file_name("release.torrent")
|
||||
.mime_str("application/x-bittorrent")?,
|
||||
)
|
||||
} else {
|
||||
form.text("urls", url.to_owned())
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/v2/torrents/add", self.config.url))
|
||||
.header("Referer", format!("{}/", self.config.url))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
let bytes = bounded_body(response, 1024).await?;
|
||||
if bytes != b"Ok." {
|
||||
return Err(Error::Upstream("qBittorrent rejected torrent".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1,5 +1,4 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -28,20 +27,3 @@ impl From<reqwest::Error> for Error {
|
||||
Self::Upstream(value.without_url().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Invalid(_) => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::Busy => StatusCode::CONFLICT,
|
||||
Self::SourceBusy => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Upstream(_) => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(serde_json::json!({"error": self.to_string()}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
media::{self, DestinationInput, MappingInput},
|
||||
};
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub(super) async fn destinations(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.destinations().await?))
|
||||
}
|
||||
|
||||
pub(super) async fn create_destination(
|
||||
state: State,
|
||||
body: web::Json<DestinationInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_destination(body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct DestinationSelection {
|
||||
destination_id: String,
|
||||
}
|
||||
|
||||
pub(super) async fn activate_destination(
|
||||
state: State,
|
||||
body: web::Json<DestinationSelection>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state
|
||||
.store
|
||||
.activate_destination(&body.destination_id)
|
||||
.await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
pub(super) async fn mappings(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?))
|
||||
}
|
||||
|
||||
pub(super) async fn create_mapping(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
body: web::Json<MappingInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_mapping(&id, body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct DirectoryQuery {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub(super) async fn directory_entries(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
query: web::Query<DirectoryQuery>,
|
||||
) -> Result<HttpResponse> {
|
||||
media::validate_relative(&query.path, true)?;
|
||||
let root = state.store.destination(&id).await?.root_path;
|
||||
let path = query.into_inner().path;
|
||||
let entries = tokio::task::spawn_blocking(move || media::list_local(&root, &path))
|
||||
.await
|
||||
.map_err(|_| Error::Upstream("local directory query failed".into()))?;
|
||||
Ok(HttpResponse::Ok().json(entries))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::{
|
||||
domain::DownloaderInput,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub(super) async fn downloaders(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.downloaders().await?))
|
||||
}
|
||||
|
||||
pub(super) async fn create_downloader(
|
||||
state: State,
|
||||
body: web::Json<DownloaderInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create_downloader(body.into_inner()).await?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct DownloaderSelection {
|
||||
downloader_id: String,
|
||||
}
|
||||
|
||||
pub(super) async fn activate_downloader(
|
||||
state: State,
|
||||
body: web::Json<DownloaderSelection>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.activate_downloader(&body.downloader_id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
pub(super) async fn claim_legacy_jobs(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.json(serde_json::json!({"claimed": state.store.claim_legacy_jobs(&id).await?})))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::error::Error;
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
|
||||
impl ResponseError for Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Invalid(_) => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::Busy => StatusCode::CONFLICT,
|
||||
Self::SourceBusy => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Upstream(_) => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(serde_json::json!({"error": self.to_string()}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::error::{Error, Result};
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub(super) async fn job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?))
|
||||
}
|
||||
|
||||
pub(super) async fn check_job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?))
|
||||
}
|
||||
|
||||
pub(super) async fn jobs(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.jobs().await?))
|
||||
}
|
||||
pub(super) async fn retry(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.retry(&id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::error::Result;
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
pub(super) async fn mikan_search(
|
||||
state: State,
|
||||
query: web::Query<SearchQuery>,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?))
|
||||
}
|
||||
|
||||
pub(super) async fn mikan_series(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?))
|
||||
}
|
||||
|
||||
pub(super) async fn mikan_cover(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let (mime, bytes) = state.mikan.cover(&id).await?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(mime)
|
||||
.insert_header(("X-Content-Type-Options", "nosniff"))
|
||||
.insert_header(("Cache-Control", "private, max-age=60"))
|
||||
.body(bytes))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ResolveInput {
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub(super) async fn mikan_resolve(
|
||||
state: State,
|
||||
body: web::Json<ResolveInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! HTTP routes and request/response translation.
|
||||
mod destinations;
|
||||
mod downloaders;
|
||||
mod error;
|
||||
mod jobs;
|
||||
mod mikan;
|
||||
mod subscriptions;
|
||||
mod system;
|
||||
|
||||
use crate::service::Service;
|
||||
use actix_web::web;
|
||||
use std::sync::Arc;
|
||||
|
||||
type State = web::Data<Arc<Service>>;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/health", web::get().to(system::health));
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.route("/sources/mikan/search", web::get().to(mikan::mikan_search))
|
||||
.route(
|
||||
"/sources/mikan/series/{id}",
|
||||
web::get().to(mikan::mikan_series),
|
||||
)
|
||||
.route(
|
||||
"/sources/mikan/series/{id}/cover",
|
||||
web::get().to(mikan::mikan_cover),
|
||||
)
|
||||
.route(
|
||||
"/sources/mikan/resolve",
|
||||
web::post().to(mikan::mikan_resolve),
|
||||
)
|
||||
.route("/status", web::get().to(system::status))
|
||||
.route("/destinations", web::get().to(destinations::destinations))
|
||||
.route(
|
||||
"/destinations",
|
||||
web::post().to(destinations::create_destination),
|
||||
)
|
||||
.route(
|
||||
"/destinations/active",
|
||||
web::put().to(destinations::activate_destination),
|
||||
)
|
||||
.route(
|
||||
"/destinations/{id}/mappings",
|
||||
web::get().to(destinations::mappings),
|
||||
)
|
||||
.route(
|
||||
"/destinations/{id}/mappings",
|
||||
web::post().to(destinations::create_mapping),
|
||||
)
|
||||
.route(
|
||||
"/destinations/{id}/entries",
|
||||
web::get().to(destinations::directory_entries),
|
||||
)
|
||||
.route("/downloaders", web::get().to(downloaders::downloaders))
|
||||
.route(
|
||||
"/downloaders",
|
||||
web::post().to(downloaders::create_downloader),
|
||||
)
|
||||
.route(
|
||||
"/downloaders/active",
|
||||
web::put().to(downloaders::activate_downloader),
|
||||
)
|
||||
.route(
|
||||
"/downloaders/{id}/claim-legacy-jobs",
|
||||
web::post().to(downloaders::claim_legacy_jobs),
|
||||
)
|
||||
.route("/preview", web::post().to(subscriptions::preview))
|
||||
.route("/subscriptions", web::get().to(subscriptions::list))
|
||||
.route("/subscriptions", web::post().to(subscriptions::create))
|
||||
.route("/subscriptions/{id}", web::get().to(subscriptions::get))
|
||||
.route("/subscriptions/{id}", web::put().to(subscriptions::update))
|
||||
.route(
|
||||
"/subscriptions/{id}",
|
||||
web::delete().to(subscriptions::delete),
|
||||
)
|
||||
.route(
|
||||
"/subscriptions/{id}/preview",
|
||||
web::get().to(subscriptions::preview_saved),
|
||||
)
|
||||
.route(
|
||||
"/subscriptions/{id}/scan",
|
||||
web::post().to(subscriptions::scan),
|
||||
)
|
||||
.route(
|
||||
"/subscriptions/{id}/releases",
|
||||
web::get().to(subscriptions::releases),
|
||||
)
|
||||
.route("/jobs", web::get().to(jobs::jobs))
|
||||
.route("/jobs/{id}/files", web::get().to(jobs::job_files))
|
||||
.route(
|
||||
"/jobs/{id}/files/check",
|
||||
web::post().to(jobs::check_job_files),
|
||||
)
|
||||
.route("/jobs/{id}/retry", web::post().to(jobs::retry))
|
||||
.route("/run", web::post().to(system::run)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::{
|
||||
domain::SubscriptionInput,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub(super) async fn list(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.subscriptions().await?))
|
||||
}
|
||||
pub(super) async fn get(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?))
|
||||
}
|
||||
pub(super) async fn create(
|
||||
state: State,
|
||||
body: web::Json<SubscriptionInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Created().json(state.store.create(body.into_inner()).await?))
|
||||
}
|
||||
pub(super) async fn update(
|
||||
state: State,
|
||||
id: web::Path<String>,
|
||||
body: web::Json<SubscriptionInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
Ok(HttpResponse::Ok().json(state.store.update(&id, body.into_inner()).await?))
|
||||
}
|
||||
pub(super) async fn delete(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
state.store.delete(&id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
pub(super) async fn preview(
|
||||
state: State,
|
||||
body: web::Json<SubscriptionInput>,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?))
|
||||
}
|
||||
pub(super) async fn preview_saved(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?))
|
||||
}
|
||||
pub(super) async fn scan(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?})))
|
||||
}
|
||||
pub(super) async fn releases(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.store.releases(&id).await?))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::State;
|
||||
use crate::error::Result;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
pub(super) async fn status(state: State) -> Result<HttpResponse> {
|
||||
let active = state.store.active_downloader_id().await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"downloader_configured": active.is_some(),
|
||||
"active_downloader_id": active,
|
||||
"active_destination_id": state.store.active_destination_id().await?,
|
||||
"legacy_unassigned_jobs": state.store.legacy_unassigned_count().await?,
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn run(state: State) -> Result<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(state.cycle().await?))
|
||||
}
|
||||
|
||||
pub(super) async fn health() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.json(serde_json::json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")}))
|
||||
}
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
pub mod api;
|
||||
//! AutoComicRss: RSS subscriptions, download automation and media presence checks.
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod downloader;
|
||||
pub mod downloaders;
|
||||
pub mod error;
|
||||
pub mod feeds;
|
||||
pub mod http;
|
||||
pub mod media;
|
||||
mod media_store;
|
||||
pub mod mikan;
|
||||
pub mod parser;
|
||||
mod network;
|
||||
pub mod service;
|
||||
pub mod sources;
|
||||
pub mod storage;
|
||||
|
||||
+2
-141
@@ -1,83 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||
use rust_project::{
|
||||
api,
|
||||
config::Config,
|
||||
service::{worker, Service},
|
||||
storage::Store,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Greeting {
|
||||
message: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Health {
|
||||
status: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CalcRequest {
|
||||
a: f64,
|
||||
b: f64,
|
||||
operation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CalcResponse {
|
||||
result: f64,
|
||||
operation: String,
|
||||
}
|
||||
|
||||
async fn hello() -> impl Responder {
|
||||
HttpResponse::Ok().body("Hello World! 欢迎使用 Rust Web 服务器")
|
||||
}
|
||||
|
||||
async fn health_check() -> impl Responder {
|
||||
let health = Health {
|
||||
status: "ok".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
};
|
||||
HttpResponse::Ok().json(health)
|
||||
}
|
||||
|
||||
async fn greet(name: web::Path<String>) -> impl Responder {
|
||||
let greeting = Greeting {
|
||||
message: format!("你好,{}!", name),
|
||||
name: name.into_inner(),
|
||||
};
|
||||
HttpResponse::Ok().json(greeting)
|
||||
}
|
||||
|
||||
async fn calculate(web::Json(req): web::Json<CalcRequest>) -> impl Responder {
|
||||
let result = match req.operation.as_str() {
|
||||
"add" => req.a + req.b,
|
||||
"subtract" => req.a - req.b,
|
||||
"multiply" => req.a * req.b,
|
||||
"divide" => {
|
||||
if req.b == 0.0 {
|
||||
return HttpResponse::BadRequest().json("除数不能为 0");
|
||||
}
|
||||
req.a / req.b
|
||||
}
|
||||
_ => return HttpResponse::BadRequest().json("不支持的操作"),
|
||||
};
|
||||
|
||||
let response = CalcResponse {
|
||||
result,
|
||||
operation: req.operation,
|
||||
};
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
|
||||
async fn echo(data: web::Json<serde_json::Value>) -> impl Responder {
|
||||
HttpResponse::Ok().json(data)
|
||||
}
|
||||
use autocomic_rss::{app, config::Config};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
@@ -87,66 +9,5 @@ async fn main() -> std::io::Result<()> {
|
||||
)
|
||||
.init();
|
||||
let config = Config::from_env().map_err(std::io::Error::other)?;
|
||||
std::fs::create_dir_all("data")?;
|
||||
let store = Store::open(&config.database_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let service = Arc::new(
|
||||
Service::new(store, &config)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?,
|
||||
);
|
||||
let app_service = service.clone();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(app_service.clone()))
|
||||
.app_data(web::JsonConfig::default().limit(32 * 1024))
|
||||
.route("/", web::get().to(hello))
|
||||
.route("/health", web::get().to(health_check))
|
||||
.route("/greet/{name}", web::get().to(greet))
|
||||
.route("/api/echo", web::post().to(echo))
|
||||
.route("/api/calculate", web::post().to(calculate))
|
||||
.configure(api::configure)
|
||||
.route(
|
||||
"/info",
|
||||
web::get().to(|| async {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"project": env!("CARGO_PKG_NAME"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"endpoints": [
|
||||
{ "method": "GET", "path": "/", "description": "欢迎页面" },
|
||||
{ "method": "GET", "path": "/health", "description": "健康检查" },
|
||||
{ "method": "GET", "path": "/greet/{name}", "description": "问候接口" },
|
||||
{ "method": "POST", "path": "/api/echo", "description": "回显数据" },
|
||||
{ "method": "POST", "path": "/api/calculate", "description": "计算接口" }
|
||||
]
|
||||
}))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.bind(("127.0.0.1", config.port))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx));
|
||||
|
||||
println!("\n==================================================");
|
||||
println!("🚀 Rust Web 服务器已启动");
|
||||
println!("📡 监听地址: http://127.0.0.1:{}", config.port);
|
||||
println!(" GET/POST /api/subscriptions - 订阅管理");
|
||||
println!(" POST /api/preview - RSS 解析预览");
|
||||
println!(" POST /api/run - 执行订阅与下载同步");
|
||||
println!(" GET /api/jobs - 下载任务");
|
||||
println!("📋 可用端点:");
|
||||
println!(" GET / - 欢迎页面");
|
||||
println!(" GET /health - 健康检查");
|
||||
println!(" GET /info - API 信息");
|
||||
println!(" GET /greet/{{name}} - 问候接口");
|
||||
println!(" POST /api/echo - 回显数据");
|
||||
println!(" POST /api/calculate - 计算接口");
|
||||
println!("==================================================\n");
|
||||
|
||||
let result = server.run().await;
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = background.await;
|
||||
result
|
||||
app::run(config).await
|
||||
}
|
||||
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::error::{Error, Result};
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
io,
|
||||
path::{Component, Path},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DestinationInput {
|
||||
pub name: String,
|
||||
pub kind: LocalKind,
|
||||
pub root_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocalKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
impl DestinationInput {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.name = self.name.trim().to_owned();
|
||||
if self.name.is_empty()
|
||||
|| self.name.chars().count() > 100
|
||||
|| self.root_path.len() > 4096
|
||||
|| self.root_path.chars().any(char::is_control)
|
||||
|| !Path::new(&self.root_path).is_absolute()
|
||||
|| Path::new(&self.root_path)
|
||||
.components()
|
||||
.any(|c| matches!(c, Component::ParentDir))
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"expected a name and an absolute local root without parent traversal".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct Destination {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub root_path: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MappingInput {
|
||||
pub downloader_id: String,
|
||||
pub download_root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Mapping {
|
||||
pub id: String,
|
||||
pub destination_id: String,
|
||||
pub downloader_id: String,
|
||||
pub version: i64,
|
||||
pub download_root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct MediaFile {
|
||||
pub relative_path: String,
|
||||
pub expected_size: i64,
|
||||
pub observed_size: Option<i64>,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileReport {
|
||||
pub job_id: String,
|
||||
pub destination_id: Option<String>,
|
||||
pub status: String,
|
||||
pub checked_at: Option<i64>,
|
||||
pub last_success_at: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
pub files: Vec<MediaFile>,
|
||||
}
|
||||
|
||||
// Wire paths always use '/' and must be relative on both Unix and Windows.
|
||||
pub fn validate_relative(path: &str, allow_empty: bool) -> Result<()> {
|
||||
if (path.is_empty() && !allow_empty)
|
||||
|| path.len() > 4096
|
||||
|| path.contains(['\\', ':'])
|
||||
|| path.chars().any(char::is_control)
|
||||
|| (!path.is_empty()
|
||||
&& path
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || matches!(part, "." | "..")))
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"expected a relative path without empty, dot, parent, drive or backslash components"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// qBittorrent may run on a different OS. Do not interpret its paths using the host OS.
|
||||
pub fn normalize_download_root(value: &str) -> Result<String> {
|
||||
let path = value.replace('\\', "/");
|
||||
let drive = path.len() >= 3
|
||||
&& path.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& path.as_bytes()[1] == b':'
|
||||
&& path.as_bytes()[2] == b'/';
|
||||
if path.len() > 4096 || path.chars().any(char::is_control) || !(path.starts_with('/') || drive)
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"downloader root must be an absolute Unix or Windows path".into(),
|
||||
));
|
||||
}
|
||||
let rest = if drive {
|
||||
&path[3..]
|
||||
} else {
|
||||
path.trim_start_matches('/')
|
||||
};
|
||||
if rest.contains(':') || rest.split('/').any(|s| matches!(s, "." | "..")) {
|
||||
return Err(Error::Invalid("invalid downloader root components".into()));
|
||||
}
|
||||
let path = path.trim_end_matches('/');
|
||||
Ok(if path.is_empty() {
|
||||
"/".into()
|
||||
} else if drive && path.len() == 2 {
|
||||
format!("{path}/")
|
||||
} else {
|
||||
path.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn same_download_root(expected: &str, actual: &str) -> bool {
|
||||
match (
|
||||
normalize_download_root(expected),
|
||||
normalize_download_root(actual),
|
||||
) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn access_error(error: &io::Error) -> String {
|
||||
match error.kind() {
|
||||
io::ErrorKind::PermissionDenied => "access denied or path escapes the configured root",
|
||||
io::ErrorKind::NotFound => "configured root or directory is unavailable",
|
||||
_ => "filesystem access failed",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn check_local(root: &str, mut files: Vec<MediaFile>) -> Vec<MediaFile> {
|
||||
let dir = Dir::open_ambient_dir(root, ambient_authority());
|
||||
for file in &mut files {
|
||||
file.observed_size = None;
|
||||
file.error = None;
|
||||
let result = match &dir {
|
||||
Ok(dir) => {
|
||||
if validate_relative(&file.relative_path, false).is_err() {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some("invalid stored relative path".into());
|
||||
continue;
|
||||
}
|
||||
dir.metadata(&file.relative_path)
|
||||
}
|
||||
Err(error) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some(access_error(error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
file.status = "present".into();
|
||||
file.observed_size = i64::try_from(metadata.len()).ok();
|
||||
}
|
||||
Ok(_) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some("expected a regular file".into());
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => file.status = "absent".into(),
|
||||
Err(error) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some(access_error(&error));
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectoryListing {
|
||||
pub status: &'static str,
|
||||
pub error: Option<String>,
|
||||
pub entries: Vec<DirectoryEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub fn list_local(root: &str, path: &str) -> DirectoryListing {
|
||||
if let Err(error) = validate_relative(path, true) {
|
||||
return DirectoryListing {
|
||||
status: "unavailable",
|
||||
error: Some(error.to_string()),
|
||||
entries: vec![],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
let result = (|| -> io::Result<_> {
|
||||
let dir = Dir::open_ambient_dir(root, ambient_authority())?;
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
for (index, item) in dir
|
||||
.read_dir(if path.is_empty() { "." } else { path })?
|
||||
.enumerate()
|
||||
{
|
||||
if index >= 1000 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let item = item?;
|
||||
let kind = item.file_type()?;
|
||||
entries.push(DirectoryEntry {
|
||||
name: item.file_name().to_string_lossy().into_owned(),
|
||||
size: if kind.is_file() {
|
||||
Some(item.metadata()?.len())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
kind: if kind.is_symlink() {
|
||||
"symlink"
|
||||
} else if kind.is_dir() {
|
||||
"directory"
|
||||
} else if kind.is_file() {
|
||||
"file"
|
||||
} else {
|
||||
"other"
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok((entries, truncated))
|
||||
})();
|
||||
match result {
|
||||
Ok((entries, truncated)) => DirectoryListing {
|
||||
status: "available",
|
||||
error: None,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
Err(error) => DirectoryListing {
|
||||
status: "unavailable",
|
||||
error: Some(access_error(&error)),
|
||||
entries: vec![],
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::{validate_relative, DirectoryEntry, DirectoryListing, MediaFile};
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use std::io;
|
||||
|
||||
fn access_error(error: &io::Error) -> String {
|
||||
match error.kind() {
|
||||
io::ErrorKind::PermissionDenied => "access denied or path escapes the configured root",
|
||||
io::ErrorKind::NotFound => "configured root or directory is unavailable",
|
||||
_ => "filesystem access failed",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn check_local(root: &str, mut files: Vec<MediaFile>) -> Vec<MediaFile> {
|
||||
let dir = Dir::open_ambient_dir(root, ambient_authority());
|
||||
for file in &mut files {
|
||||
file.observed_size = None;
|
||||
file.error = None;
|
||||
let result = match &dir {
|
||||
Ok(dir) => {
|
||||
if validate_relative(&file.relative_path, false).is_err() {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some("invalid stored relative path".into());
|
||||
continue;
|
||||
}
|
||||
dir.metadata(&file.relative_path)
|
||||
}
|
||||
Err(error) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some(access_error(error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
file.status = "present".into();
|
||||
file.observed_size = i64::try_from(metadata.len()).ok();
|
||||
}
|
||||
Ok(_) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some("expected a regular file".into());
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => file.status = "absent".into(),
|
||||
Err(error) => {
|
||||
file.status = "unavailable".into();
|
||||
file.error = Some(access_error(&error));
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
pub fn list_local(root: &str, path: &str) -> DirectoryListing {
|
||||
if let Err(error) = validate_relative(path, true) {
|
||||
return DirectoryListing {
|
||||
status: "unavailable",
|
||||
error: Some(error.to_string()),
|
||||
entries: vec![],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
let result = (|| -> io::Result<_> {
|
||||
let dir = Dir::open_ambient_dir(root, ambient_authority())?;
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
for (index, item) in dir
|
||||
.read_dir(if path.is_empty() { "." } else { path })?
|
||||
.enumerate()
|
||||
{
|
||||
if index >= 1000 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let item = item?;
|
||||
let kind = item.file_type()?;
|
||||
entries.push(DirectoryEntry {
|
||||
name: item.file_name().to_string_lossy().into_owned(),
|
||||
size: if kind.is_file() {
|
||||
Some(item.metadata()?.len())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
kind: if kind.is_symlink() {
|
||||
"symlink"
|
||||
} else if kind.is_dir() {
|
||||
"directory"
|
||||
} else if kind.is_file() {
|
||||
"file"
|
||||
} else {
|
||||
"other"
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok((entries, truncated))
|
||||
})();
|
||||
match result {
|
||||
Ok((entries, truncated)) => DirectoryListing {
|
||||
status: "available",
|
||||
error: None,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
Err(error) => DirectoryListing {
|
||||
status: "unavailable",
|
||||
error: Some(access_error(&error)),
|
||||
entries: vec![],
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Media destinations, path mappings and file presence reports.
|
||||
mod local;
|
||||
mod path;
|
||||
|
||||
pub use local::{check_local, list_local};
|
||||
pub use path::{normalize_download_root, same_download_root, validate_relative};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DestinationInput {
|
||||
pub name: String,
|
||||
pub kind: LocalKind,
|
||||
pub root_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocalKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
impl DestinationInput {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.name = self.name.trim().to_owned();
|
||||
if self.name.is_empty()
|
||||
|| self.name.chars().count() > 100
|
||||
|| self.root_path.len() > 4096
|
||||
|| self.root_path.chars().any(char::is_control)
|
||||
|| !Path::new(&self.root_path).is_absolute()
|
||||
|| Path::new(&self.root_path)
|
||||
.components()
|
||||
.any(|c| matches!(c, Component::ParentDir))
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"expected a name and an absolute local root without parent traversal".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct Destination {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub root_path: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MappingInput {
|
||||
pub downloader_id: String,
|
||||
pub download_root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Mapping {
|
||||
pub id: String,
|
||||
pub destination_id: String,
|
||||
pub downloader_id: String,
|
||||
pub version: i64,
|
||||
pub download_root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct MediaFile {
|
||||
pub relative_path: String,
|
||||
pub expected_size: i64,
|
||||
pub observed_size: Option<i64>,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileReport {
|
||||
pub job_id: String,
|
||||
pub destination_id: Option<String>,
|
||||
pub status: String,
|
||||
pub checked_at: Option<i64>,
|
||||
pub last_success_at: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
pub files: Vec<MediaFile>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectoryListing {
|
||||
pub status: &'static str,
|
||||
pub error: Option<String>,
|
||||
pub entries: Vec<DirectoryEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// Wire paths always use '/' and must be relative on both Unix and Windows.
|
||||
pub fn validate_relative(path: &str, allow_empty: bool) -> Result<()> {
|
||||
if (path.is_empty() && !allow_empty)
|
||||
|| path.len() > 4096
|
||||
|| path.contains(['\\', ':'])
|
||||
|| path.chars().any(char::is_control)
|
||||
|| (!path.is_empty()
|
||||
&& path
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || matches!(part, "." | "..")))
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"expected a relative path without empty, dot, parent, drive or backslash components"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// qBittorrent may run on a different OS. Do not interpret its paths using the host OS.
|
||||
pub fn normalize_download_root(value: &str) -> Result<String> {
|
||||
let path = value.replace('\\', "/");
|
||||
let drive = path.len() >= 3
|
||||
&& path.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& path.as_bytes()[1] == b':'
|
||||
&& path.as_bytes()[2] == b'/';
|
||||
if path.len() > 4096 || path.chars().any(char::is_control) || !(path.starts_with('/') || drive)
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"downloader root must be an absolute Unix or Windows path".into(),
|
||||
));
|
||||
}
|
||||
let rest = if drive {
|
||||
&path[3..]
|
||||
} else {
|
||||
path.trim_start_matches('/')
|
||||
};
|
||||
if rest.contains(':') || rest.split('/').any(|s| matches!(s, "." | "..")) {
|
||||
return Err(Error::Invalid("invalid downloader root components".into()));
|
||||
}
|
||||
let path = path.trim_end_matches('/');
|
||||
Ok(if path.is_empty() {
|
||||
"/".into()
|
||||
} else if drive && path.len() == 2 {
|
||||
format!("{path}/")
|
||||
} else {
|
||||
path.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn same_download_root(expected: &str, actual: &str) -> bool {
|
||||
match (
|
||||
normalize_download_root(expected),
|
||||
normalize_download_root(actual),
|
||||
) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! HTTP request policy and bounded responses shared by external adapters.
|
||||
use crate::{
|
||||
domain::validate_http_url,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use reqwest::{Client, Url};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn website_client(config: &crate::config::WebsiteConfig) -> Result<Client> {
|
||||
let mut builder = Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
// Rebuild each redirected request so proxy auth and direct-host rules are reapplied.
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if let Some(value) = &config.proxy_url {
|
||||
let invalid = || {
|
||||
Error::Invalid(
|
||||
"WEBSITE_PROXY_URL must be an HTTP(S), SOCKS5 or SOCKS5H proxy URL".into(),
|
||||
)
|
||||
};
|
||||
let url = Url::parse(value).map_err(|_| invalid())?;
|
||||
if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h")
|
||||
|| url.host_str().is_none()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| !matches!(url.path(), "" | "/")
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|_| invalid())?
|
||||
.no_proxy(reqwest::NoProxy::from_string(&config.direct_hosts));
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
pub async fn website_get(client: &Client, mut url: Url) -> Result<reqwest::Response> {
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
for hop in 0..=5 {
|
||||
validate_http_url(url.as_str())?;
|
||||
let response = client.get(url.clone()).send().await?;
|
||||
if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
|
||||
return Ok(response);
|
||||
}
|
||||
if hop == 5 {
|
||||
return Err(Error::Upstream("website redirect limit exceeded".into()));
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| Error::Upstream("invalid website redirect".into()))?;
|
||||
url = url
|
||||
.join(location)
|
||||
.map_err(|_| Error::Upstream("invalid website redirect URL".into()))?;
|
||||
validate_http_url(url.as_str())
|
||||
.map_err(|_| Error::Upstream("unsupported website redirect URL".into()))?;
|
||||
}
|
||||
unreachable!("redirect count is bounded")
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Upstream("website request timed out".into()))?
|
||||
}
|
||||
|
||||
pub async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result<Vec<u8>> {
|
||||
response = response.error_for_status()?;
|
||||
if response.content_length().is_some_and(|n| n > limit as u64) {
|
||||
return Err(Error::Upstream("response exceeds size limit".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if data.len() + chunk.len() > limit {
|
||||
return Err(Error::Upstream("response exceeds size limit".into()));
|
||||
}
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
+6
-6
@@ -2,10 +2,10 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
domain::*,
|
||||
downloader::{self, Qbit},
|
||||
downloaders::{qbittorrent::Qbit, torrent},
|
||||
error::{Error, Result},
|
||||
feeds,
|
||||
media::{self, FileReport, MediaFile},
|
||||
sources::feeds,
|
||||
storage::Store,
|
||||
};
|
||||
use serde::Serialize;
|
||||
@@ -18,7 +18,7 @@ use tokio::sync::{watch, Mutex};
|
||||
pub struct Service {
|
||||
pub store: Store,
|
||||
pub gate: Mutex<()>,
|
||||
pub mikan: crate::mikan::Mikan,
|
||||
pub mikan: crate::sources::mikan::Mikan,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ pub struct CycleReport {
|
||||
|
||||
impl Service {
|
||||
pub async fn new(store: Store, config: &Config) -> Result<Self> {
|
||||
let client = feeds::website_client(&config.website)?;
|
||||
let mikan = crate::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?;
|
||||
let client = crate::network::website_client(&config.website)?;
|
||||
let mikan = crate::sources::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?;
|
||||
store.bootstrap_downloader(config.qbit.as_ref()).await?;
|
||||
Ok(Self {
|
||||
store,
|
||||
@@ -180,7 +180,7 @@ impl Service {
|
||||
let result: Result<()> = async {
|
||||
if job.info_hash.is_none() {
|
||||
if !enabled { return Ok(()); }
|
||||
let prepared = downloader::prepare(&self.client, &job.download_url).await?;
|
||||
let prepared = torrent::prepare(&self.client, &job.download_url).await?;
|
||||
self.store.prepare(&job.id, &prepared.hash, prepared.bytes.as_deref()).await?;
|
||||
job.info_hash = Some(prepared.hash);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,11 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
domain::*,
|
||||
domain::{episode as parser, *},
|
||||
error::{Error, Result},
|
||||
parser,
|
||||
network::{bounded_body, website_get},
|
||||
};
|
||||
use reqwest::{Client, Url};
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn http_client() -> Result<Client> {
|
||||
website_client(&crate::config::WebsiteConfig::default())
|
||||
}
|
||||
|
||||
pub fn website_client(config: &crate::config::WebsiteConfig) -> Result<Client> {
|
||||
let mut builder = Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("rust_project/", env!("CARGO_PKG_VERSION")))
|
||||
// Rebuild each redirected request so proxy auth and direct-host rules are reapplied.
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if let Some(value) = &config.proxy_url {
|
||||
let invalid = || {
|
||||
Error::Invalid(
|
||||
"WEBSITE_PROXY_URL must be an HTTP(S), SOCKS5 or SOCKS5H proxy URL".into(),
|
||||
)
|
||||
};
|
||||
let url = Url::parse(value).map_err(|_| invalid())?;
|
||||
if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h")
|
||||
|| url.host_str().is_none()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| !matches!(url.path(), "" | "/")
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|_| invalid())?
|
||||
.no_proxy(reqwest::NoProxy::from_string(&config.direct_hosts));
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
pub async fn website_get(client: &Client, mut url: Url) -> Result<reqwest::Response> {
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
for hop in 0..=5 {
|
||||
validate_http_url(url.as_str())?;
|
||||
let response = client.get(url.clone()).send().await?;
|
||||
if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
|
||||
return Ok(response);
|
||||
}
|
||||
if hop == 5 {
|
||||
return Err(Error::Upstream("website redirect limit exceeded".into()));
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| Error::Upstream("invalid website redirect".into()))?;
|
||||
url = url
|
||||
.join(location)
|
||||
.map_err(|_| Error::Upstream("invalid website redirect URL".into()))?;
|
||||
validate_http_url(url.as_str())
|
||||
.map_err(|_| Error::Upstream("unsupported website redirect URL".into()))?;
|
||||
}
|
||||
unreachable!("redirect count is bounded")
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Upstream("website request timed out".into()))?
|
||||
}
|
||||
|
||||
pub async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result<Vec<u8>> {
|
||||
response = response.error_for_status()?;
|
||||
if response.content_length().is_some_and(|n| n > limit as u64) {
|
||||
return Err(Error::Upstream("response exceeds size limit".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if data.len() + chunk.len() > limit {
|
||||
return Err(Error::Upstream("response exceeds size limit".into()));
|
||||
}
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn fetch(client: &Client, sub: &SubscriptionInput) -> Result<Vec<Release>> {
|
||||
let response = website_get(
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
domain::{validate_http_url, SubscriptionInput},
|
||||
error::{Error, Result},
|
||||
feeds::{bounded_body, website_get},
|
||||
network::{bounded_body, website_get},
|
||||
};
|
||||
use reqwest::{Client, Url};
|
||||
use scraper::{Html, Selector};
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Subscription discovery and RSS/Atom parsing.
|
||||
pub mod feeds;
|
||||
pub mod mikan;
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Transactional persistence; SQLite owns connections, SQL and migrations.
|
||||
mod sqlite;
|
||||
|
||||
pub use sqlite::Store;
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use super::{now, Store};
|
||||
use crate::{
|
||||
domain::DownloadJob,
|
||||
error::{Error, Result},
|
||||
media::*,
|
||||
storage::{now, Store},
|
||||
};
|
||||
|
||||
impl Store {
|
||||
@@ -1,4 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
mod media;
|
||||
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result},
|
||||
@@ -17,7 +19,7 @@ pub struct Store {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
pub fn now() -> i64 {
|
||||
pub(crate) fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
Reference in New Issue
Block a user