fix: add basic auth and oidc auth

This commit is contained in:
master 2024-12-31 00:52:44 +08:00
parent 4c6cc1116b
commit abd399aacd
39 changed files with 712 additions and 49 deletions

View File

@ -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"]

106
Cargo.lock generated
View File

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

View File

@ -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=""

View File

@ -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=""

View File

@ -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"
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="https://konobangu.com"

View File

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

View File

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

View File

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

View File

@ -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<AuthUserInfo, AuthError> {
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)
}
}

View File

@ -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<OneOrArray<String>>,
#[serde(rename = "oidc_extra_claim_key")]
pub extra_claim_key: Option<String>,
#[serde(rename = "oidc_extra_claim_value")]
pub extra_claim_value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppAuthConfig {
Basic(BasicAuthConfig),
Oidc(OidcAuthConfig),
}

View File

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

View File

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

View File

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<OneOrArray<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<NumericDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(flatten)]
pub custom: HashMap<String, Value>,
}
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<String> {
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<OidcAuthClaims>,
}
#[async_trait::async_trait]
impl AuthService for OidcAuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
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::<HashSet<_>>();
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,
})
}
}

View File

@ -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<S> FromRequestParts<S> for AuthUserInfo
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Extension(ctx) = Extension::<AppContext>::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<AuthUserInfo, AuthError>;
}
pub enum AppAuthService {
Basic(BasicAuthService),
Oidc(OidcAuthService),
}
static APP_AUTH_SERVICE: OnceCell<AppAuthService> = 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<Self, AuthError> {
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::<OidcAuthClaims>::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<AuthUserInfo, AuthError> {
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(())
}
}

View File

@ -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<AppDalConfig>,
pub mikan: Option<AppMikanConfig>,
}
pub fn deserialize_key_path_from_json_value<T: DeserializeOwned>(
value: &serde_json::Value,
@ -37,12 +41,11 @@ pub fn deserialize_key_path_from_app_config<T: DeserializeOwned>(
pub trait AppConfigExt {
fn get_root_conf(&self) -> &loco_rs::config::Config;
fn get_dal_conf(&self) -> loco_rs::Result<Option<AppDalConfig>> {
deserialize_key_path_from_app_config(self.get_root_conf(), &[DAL_CONF_KEY])
}
fn get_mikan_conf(&self) -> loco_rs::Result<Option<AppMikanConfig>> {
deserialize_key_path_from_app_config(self.get_root_conf(), &[MIKAN_CONF_KEY])
fn get_app_conf(&self) -> loco_rs::Result<AppConfig> {
Ok(
deserialize_key_path_from_app_config(self.get_root_conf(), &[])?
.expect("app config must be present"),
)
}
}

View File

@ -27,11 +27,6 @@ impl AsRef<str> for DalContentCategory {
}
}
#[derive(Debug, Clone)]
pub struct AppDalClient {
pub config: AppDalConfig,
}
static APP_DAL_CLIENT: OnceCell<AppDalClient> = 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()));

View File

@ -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<String>,

View File

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

View File

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

View File

@ -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<HttpClientConfig>,

View File

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

View File

@ -11,3 +11,4 @@ pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;
pub mod auth;

View File

@ -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),+) => {
{

View File

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

View File

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

View File

@ -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),
]
}
}

View File

@ -0,0 +1,6 @@
use sea_orm::entity::prelude::*;
pub use super::entities::auth::*;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {}

View File

@ -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<String>,
}
#[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<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::SubscriberId.def()
}
}

View File

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

View File

@ -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<super::subscriptions::Entity> for Entity {
@ -61,3 +63,9 @@ impl Related<super::episodes::Entity> for Entity {
Relation::Episode.def()
}
}
impl Related<super::auth::Entity> for Entity {
fn to() -> RelationDef {
Relation::Auth.def()
}
}

View File

@ -1,3 +1,4 @@
pub mod auth;
pub mod bangumi;
pub mod downloaders;
pub mod downloads;

View File

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

View File

@ -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: ""

0
config/production.yaml Normal file
View File

View File

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