diff --git a/.cargo/config.toml b/.cargo/config.toml index 01ffcf7..83beeee 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,6 @@ [alias] -recorder = "run -p recorder --bin recorder_cli -- --environment recorder/development" -recorder-playground = "run -p recorder --example playground -- --environment recorder/development" +recorder = "run -p recorder --bin recorder_cli -- --environment development" +recorder-playground = "run -p recorder --example playground -- --environment development" [build] rustflags = ["-Zthreads=8"] diff --git a/Cargo.lock b/Cargo.lock index 005c6ef..829e444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-auth" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.21.7", + "http 1.2.0", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -1864,6 +1876,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http 1.2.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.2.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -2528,6 +2564,33 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jwt-authorizer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d72c47f96cdd849689e1a417a2b7206399b238dd8e2b1dfcea16f27185d00e" +dependencies = [ + "axum", + "chrono", + "futures-core", + "futures-util", + "headers", + "http 1.2.0", + "http-body-util", + "jsonwebtoken", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tower-http 0.5.2", + "tower-layer", + "tower-service", + "tracing", + "tracing-subscriber", +] + [[package]] name = "jxl-bitstream" version = "0.2.3" @@ -2946,7 +3009,7 @@ dependencies = [ "tokio-util", "toml", "tower 0.4.13", - "tower-http", + "tower-http 0.6.2", "tracing", "tracing-appender", "tracing-subscriber", @@ -3714,6 +3777,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -4094,6 +4177,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "axum-auth", "bytes", "chrono", "eyre", @@ -4101,6 +4185,7 @@ dependencies = [ "html-escape", "insta", "itertools 0.13.0", + "jwt-authorizer", "lazy_static", "leaky-bucket", "lightningcss", @@ -6091,6 +6176,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "base64 0.21.7", + "bitflags 2.6.0", + "bytes", + "http 1.2.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" version = "0.6.2" diff --git a/apps/api/.env.development b/apps/api/.env.development index 52a6723..a5eebbc 100644 --- a/apps/api/.env.development +++ b/apps/api/.env.development @@ -1,5 +1,4 @@ # Server -BETTER_AUTH_SECRET="konobangu" DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu" BETTERSTACK_API_KEY="" BETTERSTACK_URL="" diff --git a/apps/app/.env.development b/apps/app/.env.development index 52a6723..e76c34d 100644 --- a/apps/app/.env.development +++ b/apps/app/.env.development @@ -1,5 +1,19 @@ # Server -BETTER_AUTH_SECRET="konobangu" +AUTH_TYPE="basic" # + +BASIC_USER="konobangu" +BASIC_PASSWORD="konobangu" + +OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_API_ISSUER="https://some-oidc-auth.com/oidc" +OIDC_API_AUDIENCE="https://konobangu.com/api" +OIDC_ICON_URL="" +OIDC_EXTRA_SCOPE_REGEX="" +OIDC_EXTRA_CLAIM_KEY="" +OIDC_EXTRA_CLAIM_VALUE="" + DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu" BETTERSTACK_API_KEY="" BETTERSTACK_URL="" diff --git a/apps/app/.env.example b/apps/app/.env.example index ca7d1ee..905ce06 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -1,6 +1,20 @@ -# Server -BETTER_AUTH_SECRET="" -DATABASE_URL="" +# AUTH +AUTH_TYPE="basic" + +NEXT_PUBLIC_OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration" +NEXT_PUBLIC_OIDC_CLIENT_ID="" +NEXT_PUBLIC_OIDC_CLIENT_SECRET="" +NEXT_PUBLIC_OIDC_ICON_URL="" +OIDC_API_ISSUER="https://some-oidc-auth.com/oidc" +OIDC_API_AUDIENCE="https://konobangu.com/api" +OIDC_EXTRA_SCOPES="" # 如 "read:konobangu,write:konobangu" +OIDC_EXTRA_CLAIM_KEY="" +OIDC_EXTRA_CLAIM_VALUE="" + +# DATABASE +DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu" + +# SERVER MISC BETTERSTACK_API_KEY="" BETTERSTACK_URL="" FLAGS_SECRET="" @@ -8,8 +22,8 @@ ARCJET_KEY="" SVIX_TOKEN="" LIVEBLOCKS_SECRET="" -# Client +# WEBUI NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_WEB_URL="http://localhost:3001" NEXT_PUBLIC_DOCS_URL="http://localhost:3004" -NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000" \ No newline at end of file +NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="https://konobangu.com" \ No newline at end of file diff --git a/apps/recorder/Cargo.toml b/apps/recorder/Cargo.toml index 0ce4b1d..17a7d95 100644 --- a/apps/recorder/Cargo.toml +++ b/apps/recorder/Cargo.toml @@ -57,6 +57,8 @@ reqwest-tracing = "0.5.5" scraper = "0.22.0" leaky-bucket = "1.1.2" serde_with = "3" +jwt-authorizer = "0.15.0" +axum-auth = "0.7.0" [dev-dependencies] serial_test = "3" diff --git a/apps/recorder/examples/playground.rs b/apps/recorder/examples/playground.rs index 272c295..9e12319 100644 --- a/apps/recorder/examples/playground.rs +++ b/apps/recorder/examples/playground.rs @@ -12,7 +12,7 @@ use recorder::{ extract::mikan::parse_mikan_rss_items_from_rss_link, migrations::Migrator, models::{ - subscribers::ROOT_SUBSCRIBER, + subscribers::SEED_SUBSCRIBER, subscriptions::{self, SubscriptionCreateFromRssDto}, }, }; diff --git a/apps/recorder/src/app.rs b/apps/recorder/src/app.rs index 805964d..821e5d5 100644 --- a/apps/recorder/src/app.rs +++ b/apps/recorder/src/app.rs @@ -15,6 +15,7 @@ use loco_rs::{ use sea_orm::DatabaseConnection; use crate::{ + auth::service::AppAuthService, controllers, dal::{AppDalClient, AppDalInitalizer}, extract::mikan::{client::AppMikanClientInitializer, AppMikanClient}, @@ -25,11 +26,15 @@ use crate::{ pub trait AppContextExt { fn get_dal_client(&self) -> &AppDalClient { - AppDalClient::global() + AppDalClient::app_instance() } fn get_mikan_client(&self) -> &AppMikanClient { - AppMikanClient::global() + AppMikanClient::app_instance() + } + + fn get_auth_service(&self) -> &AppAuthService { + &AppAuthService::app_instance() } } diff --git a/apps/recorder/src/auth/basic.rs b/apps/recorder/src/auth/basic.rs new file mode 100644 index 0000000..4a13bad --- /dev/null +++ b/apps/recorder/src/auth/basic.rs @@ -0,0 +1,31 @@ +use axum::{http::request::Parts, RequestPartsExt}; +use axum_auth::AuthBasic; + +use super::{ + config::BasicAuthConfig, + errors::AuthError, + service::{AuthService, AuthUserInfo}, +}; +use crate::models::{auth::AuthType, subscribers::SEED_SUBSCRIBER}; + +#[derive(Debug)] +pub struct BasicAuthService { + pub config: BasicAuthConfig, +} + +#[async_trait::async_trait] +impl AuthService for BasicAuthService { + async fn extract_user_info(&self, request: &mut Parts) -> Result { + if let Ok(AuthBasic((found_user, found_password))) = request.extract().await { + if self.config.user == found_user + && self.config.password == found_password.unwrap_or_default() + { + return Ok(AuthUserInfo { + user_pid: SEED_SUBSCRIBER.to_string(), + auth_type: AuthType::Basic, + }); + } + } + Err(AuthError::BasicInvalidCredentials) + } +} diff --git a/apps/recorder/src/auth/config.rs b/apps/recorder/src/auth/config.rs new file mode 100644 index 0000000..83ef12d --- /dev/null +++ b/apps/recorder/src/auth/config.rs @@ -0,0 +1,31 @@ +use jwt_authorizer::OneOrArray; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BasicAuthConfig { + #[serde(rename = "basic_user")] + pub user: String, + #[serde(rename = "basic_password")] + pub password: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OidcAuthConfig { + #[serde(rename = "oidc_api_issuer")] + pub issuer: String, + #[serde(rename = "oidc_api_audience")] + pub audience: String, + #[serde(rename = "oidc_extra_scopes")] + pub extra_scopes: Option>, + #[serde(rename = "oidc_extra_claim_key")] + pub extra_claim_key: Option, + #[serde(rename = "oidc_extra_claim_value")] + pub extra_claim_value: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AppAuthConfig { + Basic(BasicAuthConfig), + Oidc(OidcAuthConfig), +} diff --git a/apps/recorder/src/auth/errors.rs b/apps/recorder/src/auth/errors.rs new file mode 100644 index 0000000..7c15816 --- /dev/null +++ b/apps/recorder/src/auth/errors.rs @@ -0,0 +1,35 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error(transparent)] + OidcInitError(#[from] jwt_authorizer::error::InitError), + #[error("Invalid credentials")] + BasicInvalidCredentials, + #[error(transparent)] + OidcJwtAuthError(#[from] jwt_authorizer::AuthError), + #[error("Extra scopes {expected} do not match found scopes {found}")] + OidcExtraScopesMatchError { expected: String, found: String }, + #[error("Extra claim {key} does not match expected value {expected}, found {found}")] + OidcExtraClaimMatchError { + key: String, + expected: String, + found: String, + }, + #[error("Extra claim {0} missing")] + OidcExtraClaimMissingError(String), + #[error("Audience {0} missing")] + OidcAudMissingError(String), + #[error("Subject missing")] + OidcSubMissingError, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } +} diff --git a/apps/recorder/src/auth/mod.rs b/apps/recorder/src/auth/mod.rs new file mode 100644 index 0000000..73d162f --- /dev/null +++ b/apps/recorder/src/auth/mod.rs @@ -0,0 +1,9 @@ +pub mod basic; +pub mod config; +pub mod errors; +pub mod oidc; +pub mod service; + +pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig}; +pub use errors::AuthError; +pub use service::{AppAuthService, AuthService, AuthUserInfo}; diff --git a/apps/recorder/src/auth/oidc.rs b/apps/recorder/src/auth/oidc.rs new file mode 100644 index 0000000..7e48c39 --- /dev/null +++ b/apps/recorder/src/auth/oidc.rs @@ -0,0 +1,137 @@ +use std::collections::{HashMap, HashSet}; + +use axum::http::request::Parts; +use itertools::Itertools; +use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + config::OidcAuthConfig, + errors::AuthError, + service::{AuthService, AuthUserInfo}, +}; +use crate::models::auth::AuthType; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct OidcAuthClaims { + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(flatten)] + pub custom: HashMap, +} + +impl OidcAuthClaims { + pub fn scopes(&self) -> std::str::Split<'_, char> { + self.scope.as_deref().unwrap_or_default().split(',') + } + + pub fn get_claim(&self, key: &str) -> Option { + match key { + "iss" => self.iss.clone(), + "sub" => self.sub.clone(), + "aud" => self.aud.as_ref().map(|s| s.iter().join(",")), + "exp" => self.exp.clone().map(|s| s.0.to_string()), + "nbf" => self.nbf.clone().map(|s| s.0.to_string()), + "iat" => self.iat.clone().map(|s| s.0.to_string()), + "jti" => self.jti.clone(), + "scope" => self.scope.clone(), + key => self.custom.get(key).map(|s| s.to_string()), + } + } + + pub fn has_claim(&self, key: &str) -> bool { + match key { + "iss" => self.iss.is_some(), + "sub" => self.sub.is_some(), + "aud" => self.aud.is_some(), + "exp" => self.exp.is_some(), + "nbf" => self.nbf.is_some(), + "iat" => self.iat.is_some(), + "jti" => self.jti.is_some(), + "scope" => self.scope.is_some(), + key => self.custom.contains_key(key), + } + } + + pub fn contains_audience(&self, aud: &str) -> bool { + self.aud + .as_ref() + .is_some_and(|arr| arr.iter().any(|s| s == aud)) + } +} + +pub struct OidcAuthService { + pub config: OidcAuthConfig, + pub authorizer: Authorizer, +} + +#[async_trait::async_trait] +impl AuthService for OidcAuthService { + async fn extract_user_info(&self, request: &mut Parts) -> Result { + let config = &self.config; + let token = + self.authorizer + .extract_token(&request.headers) + .ok_or(AuthError::OidcJwtAuthError( + jwt_authorizer::AuthError::MissingToken(), + ))?; + + let token_data = self.authorizer.check_auth(&token).await?; + let claims = token_data.claims; + if !claims.sub.as_deref().is_some_and(|s| !s.trim().is_empty()) { + return Err(AuthError::OidcSubMissingError); + } + if !claims.contains_audience(&config.audience) { + return Err(AuthError::OidcAudMissingError(config.audience.clone())); + } + if let Some(expected_scopes) = config.extra_scopes.as_ref() { + let found_scopes = claims.scopes().collect::>(); + if !expected_scopes + .iter() + .all(|es| found_scopes.contains(&es as &str)) + { + return Err(AuthError::OidcExtraScopesMatchError { + expected: expected_scopes.iter().join(","), + found: claims.scope.unwrap_or_default(), + }); + } + } + if let Some(key) = config.extra_claim_key.as_ref() { + if !claims.has_claim(key) { + return Err(AuthError::OidcExtraClaimMissingError(key.clone())); + } + if let Some(value) = config.extra_claim_value.as_ref() { + if claims.get_claim(key).is_none_or(|v| &v != value) { + return Err(AuthError::OidcExtraClaimMatchError { + expected: value.clone(), + found: claims.get_claim(key).unwrap_or_default().to_string(), + key: key.clone(), + }); + } + } + } + Ok(AuthUserInfo { + user_pid: claims + .sub + .as_deref() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| unreachable!("sub should be present and validated")), + auth_type: AuthType::Oidc, + }) + } +} diff --git a/apps/recorder/src/auth/service.rs b/apps/recorder/src/auth/service.rs new file mode 100644 index 0000000..a2395f1 --- /dev/null +++ b/apps/recorder/src/auth/service.rs @@ -0,0 +1,116 @@ +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponse as _, Response}, + Extension, +}; +use jwt_authorizer::{JwtAuthorizer, Validation}; +use loco_rs::app::{AppContext, Initializer}; +use once_cell::sync::OnceCell; + +use super::{ + basic::BasicAuthService, + errors::AuthError, + oidc::{OidcAuthClaims, OidcAuthService}, + AppAuthConfig, +}; +use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType}; + +pub struct AuthUserInfo { + pub user_pid: String, + pub auth_type: AuthType, +} + +#[async_trait::async_trait] +impl FromRequestParts for AuthUserInfo +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(req: &mut Parts, state: &S) -> Result { + let Extension(ctx) = Extension::::from_request_parts(req, state) + .await + .expect("AppContext should be present"); + + let auth_service = ctx.get_auth_service(); + + auth_service + .extract_user_info(req) + .await + .map_err(|err| err.into_response()) + } +} + +#[async_trait::async_trait] +pub trait AuthService { + async fn extract_user_info(&self, request: &mut Parts) -> Result; +} + +pub enum AppAuthService { + Basic(BasicAuthService), + Oidc(OidcAuthService), +} + +static APP_AUTH_SERVICE: OnceCell = OnceCell::new(); + +impl AppAuthService { + pub fn app_instance() -> &'static Self { + APP_AUTH_SERVICE + .get() + .expect("AppAuthService is not initialized") + } + + pub async fn from_conf(config: AppAuthConfig) -> Result { + let result = match config { + AppAuthConfig::Basic(config) => AppAuthService::Basic(BasicAuthService { config }), + AppAuthConfig::Oidc(config) => { + let validation = Validation::new() + .iss(&[&config.issuer]) + .aud(&[&config.audience]); + + let jwt_auth = JwtAuthorizer::::from_oidc(&config.issuer) + .validation(validation) + .build() + .await?; + + AppAuthService::Oidc(OidcAuthService { + config, + authorizer: jwt_auth, + }) + } + }; + Ok(result) + } +} + +#[async_trait::async_trait] +impl AuthService for AppAuthService { + async fn extract_user_info(&self, request: &mut Parts) -> Result { + match self { + AppAuthService::Basic(service) => service.extract_user_info(request).await, + AppAuthService::Oidc(service) => service.extract_user_info(request).await, + } + } +} + +pub struct AppAuthServiceInitializer; + +#[async_trait::async_trait] +impl Initializer for AppAuthServiceInitializer { + fn name(&self) -> String { + String::from("AppAuthServiceInitializer") + } + + async fn before_run(&self, ctx: &AppContext) -> Result<(), loco_rs::Error> { + let auth_conf = ctx.config.get_app_conf()?.auth; + + let service = AppAuthService::from_conf(auth_conf) + .await + .map_err(|e| loco_rs::Error::wrap(e))?; + + APP_AUTH_SERVICE.get_or_init(|| service); + + Ok(()) + } +} diff --git a/apps/recorder/src/config/mod.rs b/apps/recorder/src/config/mod.rs index 8457f1f..cd3dbb3 100644 --- a/apps/recorder/src/config/mod.rs +++ b/apps/recorder/src/config/mod.rs @@ -1,9 +1,13 @@ -use serde::de::DeserializeOwned; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use crate::{ - dal::{config::AppDalConfig, DAL_CONF_KEY}, - extract::mikan::{AppMikanConfig, MIKAN_CONF_KEY}, -}; +use crate::{auth::AppAuthConfig, dal::config::AppDalConfig, extract::mikan::AppMikanConfig}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AppConfig { + pub auth: AppAuthConfig, + pub dal: Option, + pub mikan: Option, +} pub fn deserialize_key_path_from_json_value( value: &serde_json::Value, @@ -37,12 +41,11 @@ pub fn deserialize_key_path_from_app_config( pub trait AppConfigExt { fn get_root_conf(&self) -> &loco_rs::config::Config; - fn get_dal_conf(&self) -> loco_rs::Result> { - deserialize_key_path_from_app_config(self.get_root_conf(), &[DAL_CONF_KEY]) - } - - fn get_mikan_conf(&self) -> loco_rs::Result> { - deserialize_key_path_from_app_config(self.get_root_conf(), &[MIKAN_CONF_KEY]) + fn get_app_conf(&self) -> loco_rs::Result { + Ok( + deserialize_key_path_from_app_config(self.get_root_conf(), &[])? + .expect("app config must be present"), + ) } } diff --git a/config/recorder/production.yaml b/apps/recorder/src/controllers/bangumi.rs similarity index 100% rename from config/recorder/production.yaml rename to apps/recorder/src/controllers/bangumi.rs diff --git a/apps/recorder/src/controllers/episodes.rs b/apps/recorder/src/controllers/episodes.rs new file mode 100644 index 0000000..e69de29 diff --git a/apps/recorder/src/controllers/subscriptions.rs b/apps/recorder/src/controllers/subscriptions.rs new file mode 100644 index 0000000..e69de29 diff --git a/apps/recorder/src/dal/client.rs b/apps/recorder/src/dal/client.rs index b96af7d..ecd27d6 100644 --- a/apps/recorder/src/dal/client.rs +++ b/apps/recorder/src/dal/client.rs @@ -27,11 +27,6 @@ impl AsRef for DalContentCategory { } } -#[derive(Debug, Clone)] -pub struct AppDalClient { - pub config: AppDalConfig, -} - static APP_DAL_CLIENT: OnceCell = OnceCell::new(); pub enum DalStoredUrl { @@ -54,15 +49,20 @@ impl fmt::Display for DalStoredUrl { } } +#[derive(Debug, Clone)] +pub struct AppDalClient { + pub config: AppDalConfig, +} + impl AppDalClient { pub fn new(config: AppDalConfig) -> Self { Self { config } } - pub fn global() -> &'static AppDalClient { + pub fn app_instance() -> &'static AppDalClient { APP_DAL_CLIENT .get() - .expect("Global app dal client is not initialized") + .expect("AppDalClient is not initialized") } pub fn get_fs(&self) -> Fs { @@ -192,7 +192,7 @@ impl Initializer for AppDalInitalizer { async fn before_run(&self, app_context: &AppContext) -> loco_rs::Result<()> { let config = &app_context.config; - let app_dal_conf = config.get_dal_conf()?; + let app_dal_conf = config.get_app_conf()?.dal; APP_DAL_CLIENT.get_or_init(|| AppDalClient::new(app_dal_conf.unwrap_or_default())); diff --git a/apps/recorder/src/dal/config.rs b/apps/recorder/src/dal/config.rs index e0daaf1..3f5c336 100644 --- a/apps/recorder/src/dal/config.rs +++ b/apps/recorder/src/dal/config.rs @@ -1,7 +1,5 @@ use serde::{Deserialize, Serialize}; -pub const DAL_CONF_KEY: &str = "dal"; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct AppDalConfig { pub data_dir: Option, diff --git a/apps/recorder/src/dal/mod.rs b/apps/recorder/src/dal/mod.rs index a369edf..1155dce 100644 --- a/apps/recorder/src/dal/mod.rs +++ b/apps/recorder/src/dal/mod.rs @@ -1,4 +1,4 @@ pub mod client; pub mod config; pub use client::{AppDalClient, AppDalInitalizer, DalContentCategory}; -pub use config::{AppDalConfig, DAL_CONF_KEY}; +pub use config::AppDalConfig; diff --git a/apps/recorder/src/extract/mikan/client.rs b/apps/recorder/src/extract/mikan/client.rs index 031bbcd..5c5521e 100644 --- a/apps/recorder/src/extract/mikan/client.rs +++ b/apps/recorder/src/extract/mikan/client.rs @@ -26,10 +26,10 @@ impl AppMikanClient { }) } - pub fn global() -> &'static AppMikanClient { + pub fn app_instance() -> &'static AppMikanClient { APP_MIKAN_CLIENT .get() - .expect("Global mikan http client is not initialized") + .expect("AppMikanClient is not initialized") } pub fn base_url(&self) -> &str { @@ -55,7 +55,7 @@ impl Initializer for AppMikanClientInitializer { async fn before_run(&self, app_context: &AppContext) -> loco_rs::Result<()> { let config = &app_context.config; - let app_mikan_conf = config.get_mikan_conf()?.unwrap_or_default(); + let app_mikan_conf = config.get_app_conf()?.mikan.unwrap_or_default(); APP_MIKAN_CLIENT.get_or_try_init(|| AppMikanClient::new(app_mikan_conf))?; diff --git a/apps/recorder/src/extract/mikan/config.rs b/apps/recorder/src/extract/mikan/config.rs index 770de5e..8335cd2 100644 --- a/apps/recorder/src/extract/mikan/config.rs +++ b/apps/recorder/src/extract/mikan/config.rs @@ -2,8 +2,6 @@ use serde::{Deserialize, Serialize}; use crate::fetch::HttpClientConfig; -pub const MIKAN_CONF_KEY: &str = "mikan"; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct AppMikanConfig { pub http_client: Option, diff --git a/apps/recorder/src/extract/mikan/mod.rs b/apps/recorder/src/extract/mikan/mod.rs index 53cc43a..43e9f08 100644 --- a/apps/recorder/src/extract/mikan/mod.rs +++ b/apps/recorder/src/extract/mikan/mod.rs @@ -5,7 +5,7 @@ pub mod rss_parser; pub mod web_parser; pub use client::{AppMikanClient, AppMikanClientInitializer}; -pub use config::{AppMikanConfig, MIKAN_CONF_KEY}; +pub use config::AppMikanConfig; pub use constants::{MIKAN_BASE_URL, MIKAN_BUCKET_KEY}; pub use rss_parser::{ build_mikan_bangumi_rss_link, build_mikan_subscriber_aggregation_rss_link, diff --git a/apps/recorder/src/lib.rs b/apps/recorder/src/lib.rs index 56955f7..59e37f1 100644 --- a/apps/recorder/src/lib.rs +++ b/apps/recorder/src/lib.rs @@ -11,3 +11,4 @@ pub mod models; pub mod tasks; pub mod views; pub mod workers; +pub mod auth; diff --git a/apps/recorder/src/migrations/defs.rs b/apps/recorder/src/migrations/defs.rs index 8204231..29db28a 100644 --- a/apps/recorder/src/migrations/defs.rs +++ b/apps/recorder/src/migrations/defs.rs @@ -107,6 +107,16 @@ pub enum Downloaders { SavePath, } +#[derive(DeriveIden)] +pub enum Auth { + Table, + Id, + Pid, + SubscriberId, + AvatarUrl, + AuthType, +} + macro_rules! create_postgres_enum_for_active_enum { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { { diff --git a/apps/recorder/src/migrations/m20220101_000001_init.rs b/apps/recorder/src/migrations/m20220101_000001_init.rs index bc0fde2..a221e85 100644 --- a/apps/recorder/src/migrations/m20220101_000001_init.rs +++ b/apps/recorder/src/migrations/m20220101_000001_init.rs @@ -5,7 +5,7 @@ use super::defs::{ Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, Subscriptions, }; use crate::models::{ - subscribers::ROOT_SUBSCRIBER, + subscribers::SEED_SUBSCRIBER, subscriptions::{self, SubscriptionCategoryEnum}, }; @@ -40,7 +40,7 @@ impl MigrationTrait for Migration { let insert = Query::insert() .into_table(Subscribers::Table) .columns([Subscribers::Pid, Subscribers::DisplayName]) - .values_panic([ROOT_SUBSCRIBER.into(), ROOT_SUBSCRIBER.into()]) + .values_panic([SEED_SUBSCRIBER.into(), SEED_SUBSCRIBER.into()]) .to_owned(); manager.exec_stmt(insert).await?; diff --git a/apps/recorder/src/migrations/m20241231_000001_auth.rs b/apps/recorder/src/migrations/m20241231_000001_auth.rs new file mode 100644 index 0000000..660e8ce --- /dev/null +++ b/apps/recorder/src/migrations/m20241231_000001_auth.rs @@ -0,0 +1,83 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use super::defs::Auth; +use crate::{ + migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers}, + models::auth::{AuthType, AuthTypeEnum}, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_postgres_enum_for_active_enum!( + manager, + AuthTypeEnum, + AuthType::Basic, + AuthType::Oidc + ) + .await?; + + manager + .create_table( + table_auto(Auth::Table) + .col(pk_auto(Auth::Id)) + .col(text(Auth::Pid)) + .col(enumeration( + Auth::AuthType, + AuthTypeEnum, + AuthType::iden_values(), + )) + .col(string_null(Auth::AvatarUrl)) + .col(integer(Auth::SubscriberId)) + .foreign_key( + ForeignKey::create() + .name("fk_auth_subscriber_id") + .from_tbl(Auth::Table) + .from_col(Auth::SubscriberId) + .to_tbl(Subscribers::Table) + .to_col(Subscribers::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Restrict), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_auth_pid_auth_type") + .unique() + .table(Auth::Table) + .col(Auth::Pid) + .col(Auth::AuthType) + .to_owned(), + ) + .await?; + + manager + .create_postgres_auto_update_ts_trigger_for_col(Auth::Table, GeneralIds::UpdatedAt) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_postgres_auto_update_ts_trigger_for_col(Auth::Table, GeneralIds::UpdatedAt) + .await?; + + manager + .drop_table(Table::drop().table(Auth::Table).to_owned()) + .await?; + + manager + .drop_postgres_enum_for_active_enum(AuthTypeEnum) + .await?; + + Ok(()) + } +} diff --git a/apps/recorder/src/migrations/mod.rs b/apps/recorder/src/migrations/mod.rs index c26c4bd..5a84f45 100644 --- a/apps/recorder/src/migrations/mod.rs +++ b/apps/recorder/src/migrations/mod.rs @@ -5,6 +5,7 @@ pub mod defs; pub mod m20220101_000001_init; pub mod m20240224_082543_add_downloads; pub mod m20240225_060853_subscriber_add_downloader; +pub mod m20241231_000001_auth; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20220101_000001_init::Migration), Box::new(m20240224_082543_add_downloads::Migration), Box::new(m20240225_060853_subscriber_add_downloader::Migration), + Box::new(m20241231_000001_auth::Migration), ] } } diff --git a/apps/recorder/src/models/auth.rs b/apps/recorder/src/models/auth.rs new file mode 100644 index 0000000..9d7edb5 --- /dev/null +++ b/apps/recorder/src/models/auth.rs @@ -0,0 +1,6 @@ +use sea_orm::entity::prelude::*; + +pub use super::entities::auth::*; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/recorder/src/models/entities/auth.rs b/apps/recorder/src/models/entities/auth.rs new file mode 100644 index 0000000..747e576 --- /dev/null +++ b/apps/recorder/src/models/entities/auth.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, DeriveDisplay, Serialize, Deserialize, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "auth_type")] +#[serde(rename_all = "snake_case")] +pub enum AuthType { + #[sea_orm(string_value = "basic")] + Basic, + #[sea_orm(string_value = "oidc")] + Oidc, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)] +#[sea_orm(table_name = "auth")] +pub struct Model { + pub created_at: DateTime, + pub updated_at: DateTime, + #[sea_orm(primary_key)] + pub id: i32, + pub pid: String, + pub subscriber_id: i32, + pub auth_type: AuthType, + pub avatar_url: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::subscribers::Entity", + from = "Column::SubscriberId", + to = "super::subscribers::Column::Id" + )] + SubscriberId, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SubscriberId.def() + } +} diff --git a/apps/recorder/src/models/entities/mod.rs b/apps/recorder/src/models/entities/mod.rs index 4f42fd5..e1820dc 100644 --- a/apps/recorder/src/models/entities/mod.rs +++ b/apps/recorder/src/models/entities/mod.rs @@ -1,7 +1,8 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4 +pub mod auth; pub mod bangumi; +pub mod downloaders; pub mod downloads; pub mod episodes; pub mod subscribers; pub mod subscriptions; -pub mod downloaders; diff --git a/apps/recorder/src/models/entities/subscribers.rs b/apps/recorder/src/models/entities/subscribers.rs index 3d9ed55..b6929d1 100644 --- a/apps/recorder/src/models/entities/subscribers.rs +++ b/apps/recorder/src/models/entities/subscribers.rs @@ -36,6 +36,8 @@ pub enum Relation { Bangumi, #[sea_orm(has_many = "super::episodes::Entity")] Episode, + #[sea_orm(has_many = "super::auth::Entity")] + Auth, } impl Related for Entity { @@ -61,3 +63,9 @@ impl Related for Entity { Relation::Episode.def() } } + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Auth.def() + } +} diff --git a/apps/recorder/src/models/mod.rs b/apps/recorder/src/models/mod.rs index d0dd646..5d5bdb4 100644 --- a/apps/recorder/src/models/mod.rs +++ b/apps/recorder/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod bangumi; pub mod downloaders; pub mod downloads; diff --git a/apps/recorder/src/models/subscribers.rs b/apps/recorder/src/models/subscribers.rs index 5d4da2c..33ad6bb 100644 --- a/apps/recorder/src/models/subscribers.rs +++ b/apps/recorder/src/models/subscribers.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; pub use super::entities::subscribers::*; -pub const ROOT_SUBSCRIBER: &str = "konobangu"; +pub const SEED_SUBSCRIBER: &str = "konobangu"; #[derive(Debug, Deserialize, Serialize)] pub struct SubscriberIdParams { @@ -69,7 +69,7 @@ impl Model { } pub async fn find_root(ctx: &AppContext) -> ModelResult { - Self::find_by_pid(ctx, ROOT_SUBSCRIBER).await + Self::find_by_pid(ctx, SEED_SUBSCRIBER).await } /// Asynchronously creates a user with a password and saves it to the @@ -83,8 +83,8 @@ impl Model { let txn = db.begin().await?; let user = ActiveModel { - display_name: ActiveValue::set(ROOT_SUBSCRIBER.to_string()), - pid: ActiveValue::set(ROOT_SUBSCRIBER.to_string()), + display_name: ActiveValue::set(SEED_SUBSCRIBER.to_string()), + pid: ActiveValue::set(SEED_SUBSCRIBER.to_string()), ..Default::default() } .insert(&txn) diff --git a/config/recorder/development.yaml b/config/development.yaml similarity index 93% rename from config/recorder/development.yaml rename to config/development.yaml index 5e78b13..ba90ba7 100644 --- a/config/recorder/development.yaml +++ b/config/development.yaml @@ -112,8 +112,10 @@ redis: dangerously_flush: false settings: + dal: data_dir: ./data + mikan: http_client: exponential_backoff_max_retries: 3 @@ -123,3 +125,13 @@ settings: leaky_bucket_refill_interval: 500 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0" base_url: "https://mikanani.me/" + + auth: + auth_type: "oidc" # or "basic" + basic_user: "konobangu" + basic_password: "konobangu" + oidc_api_issuer: "https://some-oidc-auth.com/oidc" + oidc_api_audience: "https://konobangu.com/api" + oidc_extra_scopes: "read:konobangu,write:konobangu" + oidc_extra_claim_key: "" + oidc_extra_claim_value: "" diff --git a/config/production.yaml b/config/production.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/recorder/test.yaml b/config/test.yaml similarity index 100% rename from config/recorder/test.yaml rename to config/test.yaml diff --git a/justfile b/justfile index a6db0cb..08a08b9 100644 --- a/justfile +++ b/justfile @@ -13,7 +13,7 @@ dev-recorder: cargo watch -w apps/recorder -w config -x 'recorder start' down-recorder: - cargo run -p recorder --bin recorder_cli -- db down 999 --environment recorder/development + cargo run -p recorder --bin recorder_cli -- db down 999 --environment development play-recorder: cargo recorder-playground \ No newline at end of file