fix: fix production issues

This commit is contained in:
master 2025-06-25 05:21:08 +08:00
parent 571caf50ff
commit 41ff5c2a11
23 changed files with 109 additions and 50 deletions

View File

@ -4,6 +4,7 @@ STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
LOG_LEVEL = "debug"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_AUDIENCE = "https://konobangu.com/api" # OIDC_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"

View File

@ -4,6 +4,7 @@ STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
LOG_LEVEL = "info"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_AUDIENCE = "https://konobangu.com/api" # OIDC_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"
@ -11,7 +12,3 @@ BASIC_PASSWORD = "konobangu"
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu" # OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"
# OIDC_EXTRA_CLAIM_KEY = "" # OIDC_EXTRA_CLAIM_KEY = ""
# OIDC_EXTRA_CLAIM_VALUE = "" # OIDC_EXTRA_CLAIM_VALUE = ""
# MIKAN_PROXY = ""
# MIKAN_PROXY_AUTH_HEADER = ""
# MIKAN_NO_PROXY = ""
# MIKAN_PROXY_ACCEPT_INVALID_CERTS = "true"

View File

@ -29,3 +29,5 @@ temp/*
!temp/.gitkeep !temp/.gitkeep
tests/resources/mikan/classic_episodes/*/* tests/resources/mikan/classic_episodes/*/*
!tests/resources/mikan/classic_episodes/parquet/tiny.parquet !tests/resources/mikan/classic_episodes/parquet/tiny.parquet
webui/
data/

View File

@ -126,7 +126,7 @@ seaography = { version = "1.1", features = [
"with-postgres-array", "with-postgres-array",
"with-json-as-scalar", "with-json-as-scalar",
] } ] }
tower = "0.5.2" tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6", features = [ tower-http = { version = "0.6", features = [
"trace", "trace",
"catch-panic", "catch-panic",

View File

@ -4,8 +4,8 @@
enable = true enable = true
# Enable pretty backtrace (sets RUST_BACKTRACE=1) # Enable pretty backtrace (sets RUST_BACKTRACE=1)
pretty_backtrace = true pretty_backtrace = true
level = '{{ get_env(name="LOG_LEVEL", default="info") }}'
# Log level, options: trace, debug, info, warn or error. # Log level, options: trace, debug, info, warn or error.
level = "debug"
# Define the logging format. options: compact, pretty or Json # Define the logging format. options: compact, pretty or Json
format = "compact" format = "compact"
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries

View File

@ -72,6 +72,11 @@ impl AppBuilder {
} }
pub async fn build(self) -> RecorderResult<App> { pub async fn build(self) -> RecorderResult<App> {
if self.working_dir != "." {
std::env::set_current_dir(&self.working_dir)?;
println!("set current dir to working dir: {}", self.working_dir);
}
self.load_env().await?; self.load_env().await?;
let config = self.load_config().await?; let config = self.load_config().await?;
@ -86,22 +91,12 @@ impl AppBuilder {
} }
pub async fn load_env(&self) -> RecorderResult<()> { pub async fn load_env(&self) -> RecorderResult<()> {
AppConfig::load_dotenv( AppConfig::load_dotenv(&self.environment, self.dotenv_file.as_deref()).await?;
&self.environment,
&self.working_dir,
self.dotenv_file.as_deref(),
)
.await?;
Ok(()) Ok(())
} }
pub async fn load_config(&self) -> RecorderResult<AppConfig> { pub async fn load_config(&self) -> RecorderResult<AppConfig> {
let config = AppConfig::load_config( let config = AppConfig::load_config(&self.environment, self.config_file.as_deref()).await?;
&self.environment,
&self.working_dir,
self.config_file.as_deref(),
)
.await?;
Ok(config) Ok(config)
} }
@ -136,7 +131,7 @@ impl AppBuilder {
} }
pub fn working_dir_from_manifest_dir(self) -> Self { pub fn working_dir_from_manifest_dir(self) -> Self {
let manifest_dir = if cfg!(debug_assertions) || cfg!(test) { let manifest_dir = if cfg!(debug_assertions) || cfg!(test) || cfg!(feature = "playground") {
env!("CARGO_MANIFEST_DIR") env!("CARGO_MANIFEST_DIR")
} else { } else {
"./apps/recorder" "./apps/recorder"

View File

@ -55,8 +55,8 @@ impl AppConfig {
format!(".{}.local", environment.full_name()), format!(".{}.local", environment.full_name()),
format!(".{}.local", environment.short_name()), format!(".{}.local", environment.short_name()),
String::from(".local"), String::from(".local"),
environment.full_name().to_string(), format!(".{}", environment.full_name()),
environment.short_name().to_string(), format!(".{}", environment.short_name()),
String::from(""), String::from(""),
] ]
} }
@ -88,13 +88,12 @@ impl AppConfig {
pub async fn load_dotenv( pub async fn load_dotenv(
environment: &Environment, environment: &Environment,
working_dir: &str,
dotenv_file: Option<&str>, dotenv_file: Option<&str>,
) -> RecorderResult<()> { ) -> RecorderResult<()> {
let try_dotenv_file_or_dirs = if dotenv_file.is_some() { let try_dotenv_file_or_dirs = if dotenv_file.is_some() {
vec![dotenv_file] vec![dotenv_file]
} else { } else {
vec![Some(working_dir)] vec![Some(".")]
}; };
let priority_suffix = &AppConfig::priority_suffix(environment); let priority_suffix = &AppConfig::priority_suffix(environment);
@ -111,11 +110,16 @@ impl AppConfig {
for f in try_filenames.iter() { for f in try_filenames.iter() {
let p = try_dotenv_file_or_dir_path.join(f); let p = try_dotenv_file_or_dir_path.join(f);
if p.exists() && p.is_file() { if p.exists() && p.is_file() {
println!("Loading dotenv file: {}", p.display());
dotenvy::from_path(p)?; dotenvy::from_path(p)?;
break; break;
} }
} }
} else if try_dotenv_file_or_dir_path.is_file() { } else if try_dotenv_file_or_dir_path.is_file() {
println!(
"Loading dotenv file: {}",
try_dotenv_file_or_dir_path.display()
);
dotenvy::from_path(try_dotenv_file_or_dir_path)?; dotenvy::from_path(try_dotenv_file_or_dir_path)?;
break; break;
} }
@ -127,13 +131,12 @@ impl AppConfig {
pub async fn load_config( pub async fn load_config(
environment: &Environment, environment: &Environment,
working_dir: &str,
config_file: Option<&str>, config_file: Option<&str>,
) -> RecorderResult<AppConfig> { ) -> RecorderResult<AppConfig> {
let try_config_file_or_dirs = if config_file.is_some() { let try_config_file_or_dirs = if config_file.is_some() {
vec![config_file] vec![config_file]
} else { } else {
vec![Some(working_dir)] vec![Some(".")]
}; };
let allowed_extensions = &AppConfig::allowed_extension(); let allowed_extensions = &AppConfig::allowed_extension();
@ -159,6 +162,7 @@ impl AppConfig {
let p = try_config_file_or_dir_path.join(f); let p = try_config_file_or_dir_path.join(f);
if p.exists() && p.is_file() { if p.exists() && p.is_file() {
fig = AppConfig::merge_provider_from_file(fig, &p, ext)?; fig = AppConfig::merge_provider_from_file(fig, &p, ext)?;
println!("Loaded config file: {}", p.display());
break; break;
} }
} }
@ -169,6 +173,10 @@ impl AppConfig {
{ {
fig = fig =
AppConfig::merge_provider_from_file(fig, try_config_file_or_dir_path, ext)?; AppConfig::merge_provider_from_file(fig, try_config_file_or_dir_path, ext)?;
println!(
"Loaded config file: {}",
try_config_file_or_dir_path.display()
);
break; break;
} }
} }

View File

@ -1,11 +1,13 @@
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
use axum::Router; use axum::{Router, middleware::from_fn_with_state};
use tokio::{net::TcpSocket, signal}; use tokio::{net::TcpSocket, signal};
use tower_http::services::{ServeDir, ServeFile};
use tracing::instrument; use tracing::instrument;
use super::{builder::AppBuilder, context::AppContextTrait}; use super::{builder::AppBuilder, context::AppContextTrait};
use crate::{ use crate::{
auth::webui_auth_middleware,
errors::{RecorderError, RecorderResult}, errors::{RecorderError, RecorderResult},
web::{ web::{
controller::{self, core::ControllerTrait}, controller::{self, core::ControllerTrait},
@ -58,13 +60,19 @@ impl App {
controller::oidc::create(context.clone()), controller::oidc::create(context.clone()),
controller::metadata::create(context.clone()), controller::metadata::create(context.clone()),
controller::r#static::create(context.clone()), controller::r#static::create(context.clone()),
controller::feeds::create(context.clone()), controller::feeds::create(context.clone())
)?; )?;
for c in [graphql_c, oidc_c, metadata_c, static_c, feeds_c] { for c in [graphql_c, oidc_c, metadata_c, static_c, feeds_c] {
router = c.apply_to(router); router = c.apply_to(router);
} }
router = router
.fallback_service(
ServeDir::new("webui").not_found_service(ServeFile::new("webui/index.html")),
)
.layer(from_fn_with_state(context.clone(), webui_auth_middleware));
let middlewares = default_middleware_stack(context.clone()); let middlewares = default_middleware_stack(context.clone());
for mid in middlewares { for mid in middlewares {
if mid.is_enabled() { if mid.is_enabled() {

View File

@ -7,7 +7,10 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use crate::{app::AppContextTrait, auth::AuthServiceTrait}; use crate::{
app::AppContextTrait,
auth::{AuthService, AuthServiceTrait},
};
pub async fn auth_middleware( pub async fn auth_middleware(
State(ctx): State<Arc<dyn AppContextTrait>>, State(ctx): State<Arc<dyn AppContextTrait>>,
@ -38,3 +41,37 @@ pub async fn auth_middleware(
response response
} }
pub async fn webui_auth_middleware(
State(ctx): State<Arc<dyn AppContextTrait>>,
request: Request,
next: Next,
) -> Response {
if (!request.uri().path().starts_with("/api"))
&& let AuthService::Basic(auth_service) = ctx.auth()
{
let (mut parts, body) = request.into_parts();
let mut response = match auth_service
.extract_user_info(ctx.as_ref() as &dyn AppContextTrait, &mut parts)
.await
{
Ok(auth_user_info) => {
let mut request = Request::from_parts(parts, body);
request.extensions_mut().insert(auth_user_info);
next.run(request).await
}
Err(auth_error) => auth_error.into_response(),
};
if let Some(header_value) = auth_service.www_authenticate_header_value() {
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, header_value);
};
response
} else {
next.run(request).await
}
}

View File

@ -7,5 +7,5 @@ pub mod service;
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig}; pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
pub use errors::AuthError; pub use errors::AuthError;
pub use middleware::auth_middleware; pub use middleware::{auth_middleware, webui_auth_middleware};
pub use service::{AuthService, AuthServiceTrait, AuthUserInfo}; pub use service::{AuthService, AuthServiceTrait, AuthUserInfo};

View File

@ -167,6 +167,7 @@ impl ForwardedRelatedInfo {
.as_ref() .as_ref()
.and_then(|s| s.host.as_deref()) .and_then(|s| s.host.as_deref())
.or(self.x_forwarded_host.as_deref()) .or(self.x_forwarded_host.as_deref())
.or(self.host.as_deref())
.or(self.uri.host()) .or(self.uri.host())
} }

View File

@ -801,11 +801,6 @@ pub async fn scrape_mikan_poster_meta_from_image_url(
.write(storage_path.clone(), poster_data) .write(storage_path.clone(), poster_data)
.await?; .await?;
tracing::warn!(
poster_str = poster_str.to_string(),
"mikan poster meta extracted"
);
MikanBangumiPosterMeta { MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url, origin_poster_src: origin_poster_src_url,
poster_src: Some(poster_str.to_string()), poster_src: Some(poster_str.to_string()),

View File

@ -7,7 +7,8 @@
async_fn_traits, async_fn_traits,
error_generic_member_access, error_generic_member_access,
associated_type_defaults, associated_type_defaults,
let_chains let_chains,
impl_trait_in_fn_trait_return
)] )]
#![allow(clippy::enum_variant_names)] #![allow(clippy::enum_variant_names)]
pub use downloader; pub use downloader;

View File

@ -9,12 +9,12 @@ pub trait ControllerTrait: Sized {
-> Router<Arc<dyn AppContextTrait>>; -> Router<Arc<dyn AppContextTrait>>;
} }
pub struct PrefixController { pub struct NestRouterController {
prefix: Cow<'static, str>, prefix: Cow<'static, str>,
router: Router<Arc<dyn AppContextTrait>>, router: Router<Arc<dyn AppContextTrait>>,
} }
impl PrefixController { impl NestRouterController {
pub fn new( pub fn new(
prefix: impl Into<Cow<'static, str>>, prefix: impl Into<Cow<'static, str>>,
router: Router<Arc<dyn AppContextTrait>>, router: Router<Arc<dyn AppContextTrait>>,
@ -26,7 +26,7 @@ impl PrefixController {
} }
} }
impl ControllerTrait for PrefixController { impl ControllerTrait for NestRouterController {
fn apply_to( fn apply_to(
self, self,
router: Router<Arc<dyn AppContextTrait>>, router: Router<Arc<dyn AppContextTrait>>,
@ -36,15 +36,15 @@ impl ControllerTrait for PrefixController {
} }
pub enum Controller { pub enum Controller {
Prefix(PrefixController), NestRouter(NestRouterController),
} }
impl Controller { impl Controller {
pub fn from_prefix( pub fn from_nest_router(
prefix: impl Into<Cow<'static, str>>, prefix: impl Into<Cow<'static, str>>,
router: Router<Arc<dyn AppContextTrait>>, router: Router<Arc<dyn AppContextTrait>>,
) -> Self { ) -> Self {
Self::Prefix(PrefixController::new(prefix, router)) Self::NestRouter(NestRouterController::new(prefix, router))
} }
} }
@ -54,7 +54,7 @@ impl ControllerTrait for Controller {
router: Router<Arc<dyn AppContextTrait>>, router: Router<Arc<dyn AppContextTrait>>,
) -> Router<Arc<dyn AppContextTrait>> { ) -> Router<Arc<dyn AppContextTrait>> {
match self { match self {
Self::Prefix(p) => p.apply_to(router), Self::NestRouter(p) => p.apply_to(router),
} }
} }
} }

View File

@ -38,5 +38,5 @@ async fn rss_handler(
pub async fn create(_ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> { pub async fn create(_ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> {
let router = Router::<Arc<dyn AppContextTrait>>::new().route("/rss/{token}", get(rss_handler)); let router = Router::<Arc<dyn AppContextTrait>>::new().route("/rss/{token}", get(rss_handler));
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_nest_router(CONTROLLER_PREFIX, router))
} }

View File

@ -71,5 +71,5 @@ pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller>
post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)), post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)),
) )
.route("/introspection", introspection_handler); .route("/introspection", introspection_handler);
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_nest_router(CONTROLLER_PREFIX, router))
} }

View File

@ -38,5 +38,5 @@ pub async fn create(_context: Arc<dyn AppContextTrait>) -> RecorderResult<Contro
.route("/health", get(health)) .route("/health", get(health))
.route("/ping", get(ping)); .route("/ping", get(ping));
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_nest_router(CONTROLLER_PREFIX, router))
} }

View File

@ -5,4 +5,4 @@ pub mod metadata;
pub mod oidc; pub mod oidc;
pub mod r#static; pub mod r#static;
pub use core::{Controller, ControllerTrait, PrefixController}; pub use core::{Controller, ControllerTrait, NestRouterController};

View File

@ -77,5 +77,5 @@ pub async fn create(_context: Arc<dyn AppContextTrait>) -> RecorderResult<Contro
.route("/auth", get(oidc_auth)) .route("/auth", get(oidc_auth))
.route("/callback", get(oidc_callback)); .route("/callback", get(oidc_callback));
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_nest_router(CONTROLLER_PREFIX, router))
} }

View File

@ -99,5 +99,5 @@ pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller>
) )
.route("/public/{*path}", get(serve_public_static)); .route("/public/{*path}", get(serve_public_static));
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_nest_router(CONTROLLER_PREFIX, router))
} }

View File

@ -0,0 +1,6 @@
AUTH_TYPE = "basic" # or oidc
# OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id"
# OIDC_CLIENT_SECRET = "client_secret" # optional
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"

View File

@ -4,7 +4,7 @@ set dotenv-load := true
prepare-dev: prepare-dev:
cargo install cargo-binstall cargo install cargo-binstall
cargo binstall sea-orm-cli cargo-llvm-cov cargo-nextest cargo binstall sea-orm-cli cargo-llvm-cov cargo-nextest
# <package-manager> install watchexec just zellij nasm libjxl # <package-manager> install watchexec just zellij nasm libjxl netcat
prepare-dev-testcontainers: prepare-dev-testcontainers:
docker pull linuxserver/qbittorrent:latest docker pull linuxserver/qbittorrent:latest
@ -17,6 +17,11 @@ dev-optimize-images:
dev-webui: dev-webui:
pnpm run --filter=webui dev pnpm run --filter=webui dev
prod-webui:
pnpm run --filter=webui build
mkdir -p apps/recorder/webui
cp -r apps/webui/dist/* apps/recorder/webui/
dev-proxy: dev-proxy:
npx --yes kill-port --port 8899,5005 npx --yes kill-port --port 8899,5005
pnpm run --parallel --filter=proxy dev pnpm run --parallel --filter=proxy dev
@ -24,6 +29,9 @@ dev-proxy:
dev-recorder: dev-recorder:
watchexec -r -e rs,toml,yaml,json,env -- cargo run -p recorder --bin recorder_cli -- --environment=development --graceful-shutdown=false watchexec -r -e rs,toml,yaml,json,env -- cargo run -p recorder --bin recorder_cli -- --environment=development --graceful-shutdown=false
prod-recorder: prod-webui
cargo run --release -p recorder --bin recorder_cli -- --environment=production --working-dir=apps/recorder --graceful-shutdown=false
dev-recorder-migrate-down: dev-recorder-migrate-down:
cargo run -p recorder --bin migrate_down -- --environment development cargo run -p recorder --bin migrate_down -- --environment development