feat: add oidc and basic support for playground

This commit is contained in:
master 2025-01-14 07:27:09 +08:00
parent c6677d414d
commit 877d90d1e2
72 changed files with 2769 additions and 376 deletions

690
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ resolver = "2"
testcontainers = { git = "https://github.com/testcontainers/testcontainers-rs.git", rev = "af21727" } testcontainers = { git = "https://github.com/testcontainers/testcontainers-rs.git", rev = "af21727" }
# loco-rs = { git = "https://github.com/lonelyhentxi/loco.git", rev = "beb890e" } # loco-rs = { git = "https://github.com/lonelyhentxi/loco.git", rev = "beb890e" }
# loco-rs = { git = "https://github.com/loco-rs/loco.git" } # loco-rs = { git = "https://github.com/loco-rs/loco.git" }
loco-rs = { path = "./patches/loco" } # loco-rs = { path = "./patches/loco" }
async-graphql = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" } async-graphql = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" }
async-graphql-axum = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" } async-graphql-axum = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" }
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" } jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }

View File

@ -1,3 +1,8 @@
# KONOBUNGU <h1 align="center">
<img src="./public/assets/icon.png" height=180>
<br />
<b>Konobangu</b>
<div align="center"><img src="https://img.shields.io/badge/status-work--in--progress-blue" alt="status-badge" /></div>
</h1>
Kono Bangumi? <p align="center">Kono bangumi?</p>

View File

@ -7,8 +7,8 @@ BASIC_PASSWORD="konobangu"
OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration" OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration"
OIDC_CLIENT_ID="" OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET="" OIDC_CLIENT_SECRET=""
OIDC_API_ISSUER="https://some-oidc-auth.com/oidc" OIDC_ISSUER="https://some-oidc-auth.com/oidc"
OIDC_API_AUDIENCE="https://konobangu.com/api" OIDC_AUDIENCE="https://konobangu.com/api"
OIDC_ICON_URL="" OIDC_ICON_URL=""
OIDC_EXTRA_SCOPE_REGEX="" OIDC_EXTRA_SCOPE_REGEX=""
OIDC_EXTRA_CLAIM_KEY="" OIDC_EXTRA_CLAIM_KEY=""

View File

@ -5,8 +5,8 @@ NEXT_PUBLIC_OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/
NEXT_PUBLIC_OIDC_CLIENT_ID="" NEXT_PUBLIC_OIDC_CLIENT_ID=""
NEXT_PUBLIC_OIDC_CLIENT_SECRET="" NEXT_PUBLIC_OIDC_CLIENT_SECRET=""
NEXT_PUBLIC_OIDC_ICON_URL="" NEXT_PUBLIC_OIDC_ICON_URL=""
OIDC_API_ISSUER="https://some-oidc-auth.com/oidc" OIDC_ISSUER="https://some-oidc-auth.com/oidc"
OIDC_API_AUDIENCE="https://konobangu.com/api" OIDC_AUDIENCE="https://konobangu.com/api"
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=""

View File

@ -0,0 +1,12 @@
```x-forwarded.json
{
"X-Forwarded-Host": "konobangu.com",
"X-Forwarded-Proto": "https"
}
```
^https://konobangu.com/api/playground*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5002/api/playground$1
^wss://konobangu.com/api/playground*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5002/api/playground$1
^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1 excludeFilter://^^https://konobangu.com/api/playground***
^https://konobangu.com*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000$1 excludeFilter://^https://konobangu.com/api***

View File

@ -1,2 +0,0 @@
^https://konobangu.com/*** http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api/***
^wss://konobangu.com/*** ws://127.0.0.1:5000/$1 ^excludeFilter://^wss://konobangu.com/api/***

View File

@ -1 +0,0 @@
^https://konobangu.com/api/*** http://127.0.0.1:5001/api/$1

View File

@ -1 +1 @@
{"filesOrder":["webui","recorder"],"selectedList":["webui","recorder"],"disabledDefalutRules":true} {"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true}

View File

@ -0,0 +1 @@
{"filesOrder":[]}

View File

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "cross-env WHISTLE_MODE=\"prod|capture|keepXFF\" whistle run -p 8899 -t 30000 -D . --no-global-plugins", "start": "cross-env WHISTLE_MODE=\"prod|capture|keepXFF|x-forwarded-host|x-forwarded-proto\" whistle run -p 8899 -t 30000 -D .",
"dev": "pnpm run start" "dev": "pnpm run start"
}, },
"keywords": [], "keywords": [],

View File

@ -25,8 +25,3 @@ Cargo.lock
# Dist # Dist
node_modules node_modules
dist/ dist/
# IDE
.vscode/*
!.vscode/extensions.json
.idea

View File

@ -84,10 +84,7 @@ testcontainers = { version = "0.23.1", features = [
"reusable-containers", "reusable-containers",
], optional = true } ], optional = true }
testcontainers-modules = { version = "0.11.4", optional = true } testcontainers-modules = { version = "0.11.4", optional = true }
color-eyre = "0.6" color-eyre = "0.6"
log = "0.4.22" log = "0.4.22"
anyhow = "1.0.95" anyhow = "1.0.95"
bollard = { version = "0.18", optional = true } bollard = { version = "0.18", optional = true }
@ -102,6 +99,19 @@ axum-extra = "0.10.0"
tower-http = "0.6.2" tower-http = "0.6.2"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tera = "1.20.0" tera = "1.20.0"
openidconnect = "4.0.0-rc.1"
http-cache-reqwest = { version = "0.15", features = [
"manager-cacache",
"manager-moka",
] }
moka = "0.12.10"
http-cache = { version = "0.20.0", features = [
"cacache-tokio",
"manager-cacache",
"manager-moka",
], default-features = false }
http-cache-semantics = "2.1.0"
dotenv = "0.15.0"
[dev-dependencies] [dev-dependencies]
serial_test = "3" serial_test = "3"

View File

@ -18,8 +18,9 @@ logger:
server: server:
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT} # Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
port: 5001 port: 5001
binding: "0.0.0.0"
# The UI hostname or IP address that mailers will point to. # The UI hostname or IP address that mailers will point to.
host: http://webui.konobangu.com host: '{{ get_env(name="HOST", default="localhost") }}'
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
middlewares: middlewares:
# Enable Etag cache header middleware # Enable Etag cache header middleware
@ -68,7 +69,7 @@ workers:
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued. # - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
mode: BackgroundQueue mode: BackgroundAsync
# Mailer Configuration. # Mailer Configuration.
mailer: mailer:
@ -89,7 +90,7 @@ mailer:
# Database Configuration # Database Configuration
database: database:
# Database connection URI # Database connection URI
uri: '{{ get_env(name="DATABASE_URL", default="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu") }}' uri: '{{ get_env(name="DATABASE_URL", default="postgres://konobangu:konobangu@localhost:5432/konobangu") }}'
# When enabled, the sql query will be logged. # When enabled, the sql query will be logged.
enable_logging: true enable_logging: true
# Set the timeout duration when acquiring a connection. # Set the timeout duration when acquiring a connection.
@ -110,13 +111,13 @@ database:
# Redis Configuration # Redis Configuration
redis: redis:
# Redis connection URI # Redis connection URI
uri: '{{ get_env(name="REDIS_URL", default="redis://127.0.0.1:6379") }}' uri: '{{ get_env(name="REDIS_URL", default="redis://localhost:6379") }}'
# Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode
dangerously_flush: false dangerously_flush: false
settings: settings:
dal: dal:
data_dir: ./data data_dir: '{{ get_env(name="DAL_DATA_DIR", default="./data") }}'
mikan: mikan:
base_url: "https://mikanani.me/" base_url: "https://mikanani.me/"
@ -128,11 +129,17 @@ settings:
leaky_bucket_refill_interval: 500 leaky_bucket_refill_interval: 500
auth: auth:
auth_type: "oidc" # or "basic" auth_type: '{{ get_env(name="AUTH_TYPE", default = "basic") }}'
basic_user: "konobangu" basic_user: '{{ get_env(name="BASIC_USER", default = "konobangu") }}'
basic_password: "konobangu" basic_password: '{{ get_env(name="BASIC_PASSWORD", default = "konobangu") }}'
oidc_api_issuer: "https://some-oidc-auth.com/oidc" oidc_issuer: '{{ get_env(name="OIDC_ISSUER", default = "") }}'
oidc_api_audience: "https://konobangu.com/api" oidc_audience: '{{ get_env(name="OIDC_AUDIENCE", default = "") }}'
oidc_extra_scopes: "read:konobangu,write:konobangu" oidc_client_id: '{{ get_env(name="OIDC_CLIENT_ID", default = "") }}'
oidc_extra_claim_key: "" oidc_client_secret: '{{ get_env(name="OIDC_CLIENT_SECRET", default = "") }}'
oidc_extra_claim_value: "" oidc_extra_scopes: '{{ get_env(name="OIDC_EXTRA_SCOPES", default = "") }}'
oidc_extra_claim_key: '{{ get_env(name="OIDC_EXTRA_CLAIM_KEY", default = "") }}'
oidc_extra_claim_value: '{{ get_env(name="OIDC_EXTRA_CLAIM_VALUE", default = "") }}'
graphql:
depth_limit: null
complexity_limit: null

View File

@ -1,7 +1,28 @@
{ {
"name": "recorder", "name": "recorder",
"version": "1.0.0", "version": "1.0.0",
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"preview": "rsbuild preview"
},
"dependencies": { "dependencies": {
"altair-static": "^8.1.3" "@graphiql/react": "^0.28.2",
"@graphiql/toolkit": "^0.11.1",
"@tanstack/react-router": "^1.95.5",
"@tanstack/router-devtools": "^1.95.5",
"graphql-ws": "^5.16.2",
"oidc-client-ts": "^3.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-oidc-context": "^3.2.0"
},
"devDependencies": {
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-react": "^1.0.7",
"@tanstack/router-plugin": "^1.95.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.2"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,72 @@
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';
export default defineConfig({
plugins: [pluginReact()],
html: {
favicon: './public/assets/favicon.ico',
tags: [
{
tag: 'script',
attrs: { src: 'https://cdn.tailwindcss.com' },
},
],
},
tools: {
rspack: {
plugins: [TanStackRouterRspack()],
},
},
source: {
define: {
'process.env.AUTH_TYPE': JSON.stringify(process.env.AUTH_TYPE),
'process.env.OIDC_CLIENT_ID': JSON.stringify(process.env.OIDC_CLIENT_ID),
'process.env.OIDC_CLIENT_SECRET': JSON.stringify(
process.env.OIDC_CLIENT_SECRET
),
'process.env.OIDC_ISSUER': JSON.stringify(process.env.OIDC_ISSUER),
'process.env.OIDC_AUDIENCE': JSON.stringify(process.env.OIDC_AUDIENCE),
'process.env.OIDC_EXTRA_SCOPES': JSON.stringify(
process.env.OIDC_EXTRA_SCOPES
),
},
},
dev: {
client: {
path: '/api/playground/rsbuild-hmr',
},
setupMiddlewares: [
(middlewares) => {
middlewares.unshift((req, res, next) => {
if (process.env.AUTH_TYPE === 'basic') {
res.setHeader('WWW-Authenticate', 'Basic realm="konobangu"');
const authorization =
(req.headers.authorization || '').split(' ')[1] || '';
const [user, password] = Buffer.from(authorization, 'base64')
.toString()
.split(':');
if (
user !== process.env.BASIC_USER ||
password !== process.env.BASIC_PASSWORD
) {
res.statusCode = 401;
res.write('Unauthorized');
res.end();
return;
}
}
next();
});
return middlewares;
},
],
},
server: {
base: '/api/playground/',
host: '0.0.0.0',
port: 5002,
},
});

View File

@ -1,4 +1,4 @@
use loco_rs::app::AppContext; use loco_rs::{app::AppContext, environment::Environment};
use crate::{ use crate::{
auth::service::AppAuthService, dal::AppDalClient, extract::mikan::AppMikanClient, auth::service::AppAuthService, dal::AppDalClient, extract::mikan::AppMikanClient,
@ -21,6 +21,14 @@ pub trait AppContextExt {
fn get_graphql_service(&self) -> &AppGraphQLService { fn get_graphql_service(&self) -> &AppGraphQLService {
AppGraphQLService::app_instance() AppGraphQLService::app_instance()
} }
fn get_node_env(&self) -> Environment {
let node_env = std::env::var("NODE_ENV");
match node_env.as_deref() {
Ok("production") => Environment::Production,
_ => Environment::Development,
}
}
} }
impl AppContextExt for AppContext {} impl AppContextExt for AppContext {}

View File

@ -0,0 +1,53 @@
import { RouterProvider, createRouter } from '@tanstack/react-router';
import type { UserManager } from 'oidc-client-ts';
import { useMemo } from 'react';
import { AuthProvider, useAuth } from 'react-oidc-context';
import { buildUserManager } from '../auth/config';
import { routeTree } from '../routeTree.gen';
// Set up a Router instance
const router = createRouter({
routeTree,
basepath: '/api/playground',
defaultPreload: 'intent',
context: {
isAuthenticated: process.env.AUTH_TYPE === 'basic',
auth: undefined!,
userManager: undefined!,
},
});
// Register things for typesafety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
const AppWithBasicAuth = () => {
return <RouterProvider router={router} />;
};
const AppWithOidcAuthInner = ({
userManager,
}: { userManager: UserManager }) => {
const auth = useAuth();
return (
<RouterProvider
router={router}
context={{ isAuthenticated: auth.isAuthenticated, auth, userManager }}
/>
);
};
const AppWithOidcAuth = () => {
const userManager = useMemo(() => buildUserManager(), []);
return (
<AuthProvider userManager={userManager}>
<AppWithOidcAuthInner userManager={userManager} />
</AuthProvider>
);
};
export const App =
process.env.AUTH_TYPE === 'oidc' ? AppWithOidcAuth : AppWithBasicAuth;

View File

@ -3,6 +3,7 @@ pub mod ext;
use std::{ use std::{
fs, fs,
path::{self, Path, PathBuf}, path::{self, Path, PathBuf},
sync::Arc,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@ -88,28 +89,49 @@ impl Hooks for App {
.flatten() .flatten()
.collect_vec(); .collect_vec();
for working_root in working_roots_to_search.iter() {
let working_root = PathBuf::from(working_root);
for env_file in [
working_root.join(format!(".env.{env}.local")),
working_root.join(format!(".env.{env}")),
working_root.join(".env.local"),
working_root.join(".env"),
] {
tracing::info!(env_file =? env_file);
if env_file.exists() && env_file.is_file() {
dotenv::from_path(&env_file).map_err(loco_rs::Error::wrap)?;
tracing::info!("loaded env from {} success.", env_file.to_string_lossy());
}
}
}
for working_root in working_roots_to_search.iter() { for working_root in working_roots_to_search.iter() {
let working_root = PathBuf::from(working_root); let working_root = PathBuf::from(working_root);
let config_dir = working_root.as_path().join("config"); let config_dir = working_root.as_path().join("config");
for config_file in [ for config_file in [
config_dir.join(format!("{env}.local.yaml")), config_dir.join(format!("{env}.local.yaml")),
config_dir.join(format!("{env}.yaml")), config_dir.join(format!("{env}.yaml")),
] { ] {
if config_file.exists() && config_file.is_file() { if config_file.exists() && config_file.is_file() {
tracing::info!(config_file =? config_file, "loading environment from");
let content = fs::read_to_string(config_file.clone())?; let content = fs::read_to_string(config_file.clone())?;
let rendered = tera::Tera::one_off( let rendered = tera::Tera::one_off(
&content, &content,
&tera::Context::from_serialize(serde_json::json!({}))?, &tera::Context::from_value(serde_json::json!({}))?,
false, false,
)?; )?;
App::set_working_root(working_root); App::set_working_root(working_root);
return serde_yaml::from_str(&rendered).map_err(|err| { let config_file = &config_file.to_string_lossy();
loco_rs::Error::YAMLFile(err, config_file.to_string_lossy().to_string())
}); let res = serde_yaml::from_str(&rendered)
.map_err(|err| loco_rs::Error::YAMLFile(err, config_file.to_string()))?;
tracing::info!("loading config from {} success", config_file);
return Ok(res);
} }
} }
} }
@ -118,8 +140,7 @@ impl Hooks for App {
"no configuration file found in search paths: {}", "no configuration file found in search paths: {}",
working_roots_to_search working_roots_to_search
.iter() .iter()
.map(|p| path::absolute(PathBuf::from(p))) .flat_map(|p| path::absolute(PathBuf::from(p)))
.flatten()
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.join(",") .join(",")
))) )))
@ -137,15 +158,28 @@ impl Hooks for App {
} }
fn routes(ctx: &AppContext) -> AppRoutes { fn routes(ctx: &AppContext) -> AppRoutes {
let ctx = Arc::new(ctx.clone());
AppRoutes::with_default_routes() AppRoutes::with_default_routes()
.prefix("/api") .prefix("/api")
.add_route(controllers::auth::routes())
.add_route(controllers::graphql::routes(ctx.clone())) .add_route(controllers::graphql::routes(ctx.clone()))
} }
fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>> { fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>> {
use loco_rs::controller::middleware::static_assets::{FolderConfig, StaticAssets};
let mut middlewares = middleware::default_middleware_stack(ctx); let mut middlewares = middleware::default_middleware_stack(ctx);
middlewares.extend(controllers::graphql::asset_middlewares()); middlewares.push(Box::new(StaticAssets {
enable: true,
must_exist: true,
folder: FolderConfig {
uri: String::from("/api/static"),
path: App::get_working_root().join("public").into(),
},
fallback: App::get_working_root()
.join("public/assets/404.html")
.into(),
precompressed: false,
}));
middlewares middlewares
} }

View File

@ -22,12 +22,12 @@ impl AuthBasic {
.headers .headers
.get(AUTHORIZATION) .get(AUTHORIZATION)
.and_then(|s| s.to_str().ok()) .and_then(|s| s.to_str().ok())
.ok_or_else(|| AuthError::BasicInvalidCredentials)?; .ok_or(AuthError::BasicInvalidCredentials)?;
let split = authorization.split_once(' '); let split = authorization.split_once(' ');
match split { match split {
Some((name, contents)) if name == "Basic" => { Some(("Basic", contents)) => {
let decoded = base64::engine::general_purpose::STANDARD let decoded = base64::engine::general_purpose::STANDARD
.decode(contents) .decode(contents)
.map_err(|_| AuthError::BasicInvalidCredentials)?; .map_err(|_| AuthError::BasicInvalidCredentials)?;
@ -80,4 +80,8 @@ impl AuthService for BasicAuthService {
fn www_authenticate_header_value(&self) -> Option<HeaderValue> { fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
Some(HeaderValue::from_static(r#"Basic realm="konobangu""#)) Some(HeaderValue::from_static(r#"Basic realm="konobangu""#))
} }
fn auth_type(&self) -> AuthType {
AuthType::Basic
}
} }

View File

@ -1,5 +1,6 @@
use jwt_authorizer::OneOrArray; use jwt_authorizer::OneOrArray;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, NoneAsEmptyString};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BasicAuthConfig { pub struct BasicAuthConfig {
@ -9,17 +10,24 @@ pub struct BasicAuthConfig {
pub password: String, pub password: String,
} }
#[serde_as]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OidcAuthConfig { pub struct OidcAuthConfig {
#[serde(rename = "oidc_api_issuer")] #[serde(rename = "oidc_issuer")]
pub issuer: String, pub issuer: String,
#[serde(rename = "oidc_api_audience")] #[serde(rename = "oidc_audience")]
pub audience: String, pub audience: String,
#[serde(rename = "oidc_client_id")]
pub client_id: String,
#[serde(rename = "oidc_client_secret")]
pub client_secret: String,
#[serde(rename = "oidc_extra_scopes")] #[serde(rename = "oidc_extra_scopes")]
pub extra_scopes: Option<OneOrArray<String>>, pub extra_scopes: Option<OneOrArray<String>>,
#[serde_as(as = "NoneAsEmptyString")]
#[serde(rename = "oidc_extra_claim_key")] #[serde(rename = "oidc_extra_claim_key")]
pub extra_claim_key: Option<String>, pub extra_claim_key: Option<String>,
#[serde(rename = "oidc_extra_claim_value")] #[serde(rename = "oidc_extra_claim_value")]
#[serde_as(as = "NoneAsEmptyString")]
pub extra_claim_value: Option<String>, pub extra_claim_value: Option<String>,
} }

View File

@ -0,0 +1,31 @@
import { type OidcClientSettings, UserManager } from 'oidc-client-ts';
export const PostLoginRedirectUriKey = 'post_login_redirect_uri';
export function buildOidcConfig(): OidcClientSettings {
const origin = window.location.origin;
const resource = process.env.OIDC_AUDIENCE!;
return {
authority: process.env.OIDC_ISSUER!,
client_id: process.env.OIDC_CLIENT_ID!,
client_secret: process.env.OIDC_CLIENT_SECRET!,
redirect_uri: `${origin}/api/playground/oidc/callback`,
disablePKCE: false,
scope: `openid profile email ${process.env.OIDC_EXTRA_SCOPES}`,
response_type: 'code',
resource,
post_logout_redirect_uri: `${origin}/api/playground`,
extraQueryParams: {
resource,
},
extraTokenParams: {
resource,
},
};
}
export function buildUserManager(): UserManager {
return new UserManager(buildOidcConfig());
}

View File

@ -3,15 +3,56 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use openidconnect::{
core::CoreErrorResponseType, ConfigurationError, RequestTokenError, SignatureVerificationError,
SigningError, StandardErrorResponse,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{fetch::HttpClientError, models::auth::AuthType};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AuthError { pub enum AuthError {
#[error("Not support auth method")]
NotSupportAuthMethod {
supported: Vec<AuthType>,
current: AuthType,
},
#[error("Invalid credentials")] #[error("Invalid credentials")]
BasicInvalidCredentials, BasicInvalidCredentials,
#[error(transparent)] #[error(transparent)]
OidcInitError(#[from] jwt_authorizer::error::InitError), OidcInitError(#[from] jwt_authorizer::error::InitError),
#[error("Invalid oidc provider meta client error: {0}")]
OidcProviderHttpClientError(HttpClientError),
#[error(transparent)]
OidcProviderMetaError(#[from] openidconnect::DiscoveryError<HttpClientError>),
#[error("Invalid oidc provider URL: {0}")]
OidcProviderUrlError(url::ParseError),
#[error("Invalid oidc redirect URI: {0}")]
OidcRequestRedirectUriError(url::ParseError),
#[error("Oidc request session not found or expired")]
OidcCallbackRecordNotFoundOrExpiredError,
#[error("Invalid oidc request callback nonce")]
OidcInvalidNonceError,
#[error("Invalid oidc request callback state")]
OidcInvalidStateError,
#[error("Invalid oidc request callback code")]
OidcInvalidCodeError,
#[error(transparent)]
OidcCallbackTokenConfigrationError(#[from] ConfigurationError),
#[error(transparent)]
OidcRequestTokenError(
#[from] RequestTokenError<HttpClientError, StandardErrorResponse<CoreErrorResponseType>>,
),
#[error("Invalid oidc id token")]
OidcInvalidIdTokenError,
#[error("Invalid oidc access token")]
OidcInvalidAccessTokenError,
#[error(transparent)]
OidcSignatureVerificationError(#[from] SignatureVerificationError),
#[error(transparent)]
OidcSigningError(#[from] SigningError),
#[error(transparent)] #[error(transparent)]
OidcJwtAuthError(#[from] jwt_authorizer::AuthError), OidcJwtAuthError(#[from] jwt_authorizer::AuthError),
#[error("Extra scopes {expected} do not match found scopes {found}")] #[error("Extra scopes {expected} do not match found scopes {found}")]

View File

@ -0,0 +1,21 @@
import type { ParsedLocation } from '@tanstack/react-router';
import type { RouterContext } from '../controllers/__root';
import { PostLoginRedirectUriKey } from './config';
export const beforeLoadGuard = async ({
context,
location,
// biome-ignore lint/complexity/noBannedTypes: <explanation>
}: { context: RouterContext; location: ParsedLocation<{}> }) => {
if (!context.isAuthenticated) {
// TODO: FIXME
const user = await context.userManager.getUser();
if (!user) {
try {
sessionStorage.setItem(PostLoginRedirectUriKey, location.href);
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
} catch {}
throw await context.auth.signinRedirect();
}
}
};

View File

@ -1,3 +1,5 @@
use std::sync::Arc;
use axum::{ use axum::{
extract::{Request, State}, extract::{Request, State},
http::header, http::header,
@ -9,34 +11,7 @@ use loco_rs::prelude::AppContext;
use crate::{app::AppContextExt, auth::AuthService}; use crate::{app::AppContextExt, auth::AuthService};
pub async fn api_auth_middleware( pub async fn api_auth_middleware(
State(ctx): State<AppContext>, State(ctx): State<Arc<AppContext>>,
request: Request,
next: Next,
) -> Response {
let auth_service = ctx.get_auth_service();
let (mut parts, body) = request.into_parts();
let mut response = match auth_service.extract_user_info(&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
}
pub async fn webui_auth_middleware(
State(ctx): State<AppContext>,
request: Request, request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {

View File

@ -7,5 +7,5 @@ pub mod service;
pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig}; pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig};
pub use errors::AuthError; pub use errors::AuthError;
pub use middleware::{api_auth_middleware, webui_auth_middleware}; pub use middleware::api_auth_middleware;
pub use service::{AppAuthService, AuthService, AuthUserInfo}; pub use service::{AppAuthService, AuthService, AuthUserInfo};

View File

@ -1,18 +1,28 @@
use std::collections::{HashMap, HashSet}; use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use async_trait::async_trait; use async_trait::async_trait;
use axum::http::{request::Parts, HeaderValue}; use axum::http::{request::Parts, HeaderValue};
use itertools::Itertools; use itertools::Itertools;
use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray}; use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray};
use moka::future::Cache;
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use url::Url;
use super::{ use super::{
config::OidcAuthConfig, config::OidcAuthConfig,
errors::AuthError, errors::AuthError,
service::{AuthService, AuthUserInfo}, service::{AuthService, AuthUserInfo},
}; };
use crate::models::auth::AuthType; use crate::{fetch::HttpClient, models::auth::AuthType};
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
pub struct OidcAuthClaims { pub struct OidcAuthClaims {
@ -76,23 +86,185 @@ impl OidcAuthClaims {
} }
} }
#[derive(Debug, Clone, Serialize)]
pub struct OidcAuthRequest {
pub auth_uri: Url,
#[serde(skip)]
pub redirect_uri: RedirectUrl,
#[serde(skip)]
pub csrf_token: CsrfToken,
#[serde(skip)]
pub nonce: Nonce,
#[serde(skip)]
pub pkce_verifier: Arc<PkceCodeVerifier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcAuthCallbackQuery {
pub state: Option<String>,
pub code: Option<String>,
pub redirect_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcAuthCallbackPayload {
pub access_token: String,
}
pub struct OidcAuthService { pub struct OidcAuthService {
pub config: OidcAuthConfig, pub config: OidcAuthConfig,
pub authorizer: Authorizer<OidcAuthClaims>, pub api_authorizer: Authorizer<OidcAuthClaims>,
pub oidc_provider_client: HttpClient,
pub oidc_request_cache: Cache<String, OidcAuthRequest>,
}
impl OidcAuthService {
pub async fn build_authorization_request(
&self,
redirect_uri: &str,
) -> Result<OidcAuthRequest, AuthError> {
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?,
&self.oidc_provider_client,
)
.await?;
let redirect_uri = RedirectUrl::new(redirect_uri.to_string())
.map_err(AuthError::OidcRequestRedirectUriError)?;
let oidc_client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(self.config.client_id.clone()),
Some(ClientSecret::new(self.config.client_secret.clone())),
)
.set_redirect_uri(redirect_uri.clone());
let (pkce_chanllenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let mut authorization_request = oidc_client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_chanllenge);
{
if let Some(scopes) = self.config.extra_scopes.as_ref() {
authorization_request = authorization_request.add_scopes(
scopes
.iter()
.map(|s| openidconnect::Scope::new(s.to_string())),
)
}
}
let (auth_uri, csrf_token, nonce) = authorization_request.url();
Ok(OidcAuthRequest {
auth_uri,
csrf_token,
nonce,
pkce_verifier: Arc::new(pkce_verifier),
redirect_uri,
})
}
pub async fn store_authorization_request(
&self,
request: OidcAuthRequest,
) -> Result<(), AuthError> {
self.oidc_request_cache
.insert(request.csrf_token.secret().to_string(), request)
.await;
Ok(())
}
pub async fn load_authorization_request(
&self,
state: &str,
) -> Result<OidcAuthRequest, AuthError> {
let result = self
.oidc_request_cache
.get(state)
.await
.ok_or(AuthError::OidcCallbackRecordNotFoundOrExpiredError)?;
self.oidc_request_cache.invalidate(state).await;
Ok(result)
}
pub async fn extract_authorization_request_callback(
&self,
query: OidcAuthCallbackQuery,
) -> Result<OidcAuthCallbackPayload, AuthError> {
let csrf_token = query.state.ok_or(AuthError::OidcInvalidStateError)?;
let code = query.code.ok_or(AuthError::OidcInvalidCodeError)?;
let request_cache = self.load_authorization_request(&csrf_token).await?;
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?,
&self.oidc_provider_client,
)
.await?;
let oidc_client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(self.config.client_id.clone()),
Some(ClientSecret::new(self.config.client_secret.clone())),
)
.set_redirect_uri(request_cache.redirect_uri);
let pkce_verifier = PkceCodeVerifier::new(request_cache.pkce_verifier.secret().to_string());
let token_response = oidc_client
.exchange_code(AuthorizationCode::new(code))?
.set_pkce_verifier(pkce_verifier)
.request_async(&HttpClient::default())
.await?;
let id_token = token_response
.id_token()
.ok_or(AuthError::OidcInvalidIdTokenError)?;
let id_token_verifier = &oidc_client.id_token_verifier();
let claims = id_token
.claims(id_token_verifier, &request_cache.nonce)
.map_err(|_| AuthError::OidcInvalidNonceError)?;
let access_token = token_response.access_token();
let actual_access_token_hash = AccessTokenHash::from_token(
access_token,
id_token.signing_alg()?,
id_token.signing_key(id_token_verifier)?,
)?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
if actual_access_token_hash != *expected_access_token_hash {
return Err(AuthError::OidcInvalidAccessTokenError);
}
}
Ok(OidcAuthCallbackPayload {
access_token: access_token.secret().to_string(),
})
}
} }
#[async_trait] #[async_trait]
impl AuthService for OidcAuthService { impl AuthService for OidcAuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> { async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
let config = &self.config; let config = &self.config;
let token = let token = self.api_authorizer.extract_token(&request.headers).ok_or(
self.authorizer AuthError::OidcJwtAuthError(jwt_authorizer::AuthError::MissingToken()),
.extract_token(&request.headers) )?;
.ok_or(AuthError::OidcJwtAuthError(
jwt_authorizer::AuthError::MissingToken(),
))?;
let token_data = self.authorizer.check_auth(&token).await?; let token_data = self.api_authorizer.check_auth(&token).await?;
let claims = token_data.claims; let claims = token_data.claims;
if claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) { if claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) {
return Err(AuthError::OidcSubMissingError); return Err(AuthError::OidcSubMissingError);
@ -139,4 +311,8 @@ impl AuthService for OidcAuthService {
fn www_authenticate_header_value(&self) -> Option<HeaderValue> { fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#)) Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#))
} }
fn auth_type(&self) -> AuthType {
AuthType::Oidc
}
} }

View File

@ -1,3 +1,5 @@
use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use axum::{ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
@ -6,6 +8,7 @@ use axum::{
}; };
use jwt_authorizer::{JwtAuthorizer, Validation}; use jwt_authorizer::{JwtAuthorizer, Validation};
use loco_rs::app::{AppContext, Initializer}; use loco_rs::app::{AppContext, Initializer};
use moka::future::Cache;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use reqwest::header::HeaderValue; use reqwest::header::HeaderValue;
@ -15,7 +18,15 @@ use super::{
oidc::{OidcAuthClaims, OidcAuthService}, oidc::{OidcAuthClaims, OidcAuthService},
AppAuthConfig, AppAuthConfig,
}; };
use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType}; use crate::{
app::AppContextExt as _,
config::AppConfigExt,
fetch::{
client::{HttpClientCacheBackendConfig, HttpClientCachePresetConfig},
HttpClient, HttpClientConfig,
},
models::auth::AuthType,
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AuthUserInfo { pub struct AuthUserInfo {
@ -43,6 +54,7 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
pub trait AuthService { pub trait AuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>; async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
fn www_authenticate_header_value(&self) -> Option<HeaderValue>; fn www_authenticate_header_value(&self) -> Option<HeaderValue>;
fn auth_type(&self) -> AuthType;
} }
pub enum AppAuthService { pub enum AppAuthService {
@ -74,7 +86,18 @@ impl AppAuthService {
AppAuthService::Oidc(OidcAuthService { AppAuthService::Oidc(OidcAuthService {
config, config,
authorizer: jwt_auth, api_authorizer: jwt_auth,
oidc_provider_client: HttpClient::from_config(HttpClientConfig {
exponential_backoff_max_retries: Some(3),
cache_backend: Some(HttpClientCacheBackendConfig::Moka { cache_size: 1 }),
cache_preset: Some(HttpClientCachePresetConfig::RFC7234),
..Default::default()
})
.map_err(AuthError::OidcProviderHttpClientError)?,
oidc_request_cache: Cache::builder()
.time_to_live(Duration::from_mins(5))
.name("oidc_request_cache")
.build(),
}) })
} }
}; };
@ -97,6 +120,13 @@ impl AuthService for AppAuthService {
AppAuthService::Oidc(service) => service.www_authenticate_header_value(), AppAuthService::Oidc(service) => service.www_authenticate_header_value(),
} }
} }
fn auth_type(&self) -> AuthType {
match self {
AppAuthService::Basic(service) => service.auth_type(),
AppAuthService::Oidc(service) => service.auth_type(),
}
}
} }
pub struct AppAuthServiceInitializer; pub struct AppAuthServiceInitializer;

View File

@ -1,16 +1,15 @@
dal: # dal:
data_dir: ./data # data_dir: ./data
mikan: # mikan:
http_client: # http_client:
exponential_backoff_max_retries: 3 # exponential_backoff_max_retries: 3
leaky_bucket_max_tokens: 2 # leaky_bucket_max_tokens: 2
leaky_bucket_initial_tokens: 0 # leaky_bucket_initial_tokens: 0
leaky_bucket_refill_tokens: 1 # leaky_bucket_refill_tokens: 1
leaky_bucket_refill_interval: 500 # leaky_bucket_refill_interval: 500
base_url: "https://mikanani.me/" # base_url: "https://mikanani.me/"
graphql: # graphql:
playground_static: "./node_modules/altair-static/build/dist" # depth_limit: null
depth_limit: null # complexity_limit: null
complexity_limit: null

View File

@ -0,0 +1,52 @@
import {
Link,
Outlet,
createRootRouteWithContext,
} from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import type { UserManager } from 'oidc-client-ts';
import type { AuthContextProps } from 'react-oidc-context';
export type RouterContext =
| {
isAuthenticated: false;
auth: AuthContextProps;
userManager: UserManager;
}
| {
isAuthenticated: true;
auth?: AuthContextProps;
userManager?: UserManager;
};
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});
function RootComponent() {
return (
<>
<div className="flex gap-2 p-2 text-lg">
<Link
to="/"
activeProps={{
className: 'font-bold',
}}
>
Home
</Link>{' '}
<Link
to="/graphql"
activeProps={{
className: 'font-bold',
}}
>
GraphQL
</Link>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
</>
);
}

View File

@ -1,10 +0,0 @@
use axum::response::IntoResponse;
use loco_rs::prelude::*;
async fn current() -> impl IntoResponse {
""
}
pub fn routes() -> Routes {
Routes::new().prefix("/auth").add("/current", get(current))
}

View File

@ -0,0 +1,37 @@
import { GraphiQLProvider, QueryEditor } from '@graphiql/react';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { createFileRoute } from '@tanstack/react-router';
import { useMemo } from 'react';
import { useAuth } from 'react-oidc-context';
import { beforeLoadGuard } from '../../auth/guard';
import '@graphiql/react/dist/style.css';
export const Route = createFileRoute('/graphql/')({
component: RouteComponent,
beforeLoad: beforeLoadGuard,
});
function RouteComponent() {
const auth = useAuth();
const fetcher = useMemo(
() =>
createGraphiQLFetcher({
url: '/api/graphql',
headers: auth?.user?.access_token
? {
Authorization: `Bearer ${auth.user.access_token}`,
}
: undefined,
}),
[auth]
);
return (
<GraphiQLProvider fetcher={fetcher}>
<div className="graphiql-container h-svh">
<QueryEditor />
</div>
</GraphiQLProvider>
);
}

View File

@ -1,43 +1,14 @@
pub mod playground; use std::sync::Arc;
use std::collections::HashMap;
use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{ use axum::{extract::State, middleware::from_fn_with_state, routing::post, Extension};
extract::State, use loco_rs::{app::AppContext, prelude::Routes};
http::HeaderMap,
middleware::from_fn_with_state,
response::Html,
routing::{get, post},
Extension,
};
use loco_rs::{app::AppContext, controller::middleware::MiddlewareLayer, prelude::Routes};
use playground::{altair_graphql_playground_asset_middleware, AltairGraphQLPlayground};
use reqwest::header;
use crate::{ use crate::{
app::AppContextExt, app::AppContextExt,
auth::{api_auth_middleware, webui_auth_middleware, AuthUserInfo}, auth::{api_auth_middleware, AuthUserInfo},
}; };
async fn graphql_playground(header_map: HeaderMap) -> loco_rs::Result<Html<String>> {
let mut playground_config = AltairGraphQLPlayground::new("/api/graphql");
if let Some(authorization) = header_map.get(header::AUTHORIZATION) {
if let Ok(authorization) = authorization.to_str() {
playground_config.initial_headers = {
let mut m = HashMap::new();
m.insert(header::AUTHORIZATION.to_string(), authorization.to_string());
Some(m)
}
}
}
let html = Html(playground_config.render("/api/graphql/playground/static/")?);
Ok(html)
}
async fn graphql_handler( async fn graphql_handler(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Extension(auth_user_info): Extension<AuthUserInfo>, Extension(auth_user_info): Extension<AuthUserInfo>,
@ -51,21 +22,9 @@ async fn graphql_handler(
graphql_service.schema.execute(req).await.into() graphql_service.schema.execute(req).await.into()
} }
pub fn routes(state: AppContext) -> Routes { pub fn routes(ctx: Arc<AppContext>) -> Routes {
Routes::new() Routes::new().prefix("/graphql").add(
.prefix("/graphql") "/",
.add( post(graphql_handler).layer(from_fn_with_state(ctx, api_auth_middleware)),
"/playground", )
get(graphql_playground).layer(from_fn_with_state(state.clone(), webui_auth_middleware)),
)
.add(
"/",
post(graphql_handler).layer(from_fn_with_state(state, api_auth_middleware)),
)
}
pub fn asset_middlewares() -> Vec<Box<dyn MiddlewareLayer>> {
vec![Box::new(altair_graphql_playground_asset_middleware(
"/api/graphql/playground/static/",
))]
} }

View File

@ -1,74 +0,0 @@
use std::collections::HashMap;
use lazy_static::lazy_static;
use loco_rs::controller::middleware::static_assets::{FolderConfig, StaticAssets};
use regex::Regex;
use serde::Serialize;
use serde_json::Value;
use crate::app::App;
const ALTAIR_GRAPHQL_HTML: &'static str =
include_str!("../../../node_modules/altair-static/build/dist/index.html");
lazy_static! {
static ref ALTAIR_GRAPHQL_BASE_REGEX: Regex = Regex::new(r"<base.*>").unwrap();
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AltairGraphQLPlayground<'a> {
#[serde(rename = "endpointURL")]
pub endpoint_url: &'a str,
/**
* URL to set as the subscription endpoint. This can be relative or
* absolute.
*/
pub subscriptions_endpoint: Option<&'a str>,
pub initial_headers: Option<HashMap<String, String>>,
pub initial_settings: Option<HashMap<String, Value>>,
#[serde(flatten)]
pub other: Option<HashMap<String, Value>>,
}
impl<'a> AltairGraphQLPlayground<'a> {
/// Create a config for GraphQL playground.
pub fn new(endpoint_url: &'a str) -> Self {
Self {
endpoint_url,
subscriptions_endpoint: Default::default(),
initial_headers: Default::default(),
initial_settings: Default::default(),
other: Default::default(),
}
}
pub fn render(&self, base_url: &str) -> loco_rs::Result<String> {
let option = serde_json::to_string(self)?;
let render_str = ALTAIR_GRAPHQL_BASE_REGEX
.replace(ALTAIR_GRAPHQL_HTML, format!(r#"<base href="{base_url}">"#))
.replace(
"</body>",
&format!("<script>AltairGraphQL.init({});</script></body>", option),
);
Ok(render_str)
}
}
pub fn altair_graphql_playground_asset_middleware(base_url: &str) -> StaticAssets {
StaticAssets {
enable: true,
must_exist: true,
folder: FolderConfig {
uri: String::from(base_url),
path: App::get_working_root()
.join("node_modules/altair-static/build/dist")
.into(),
},
fallback: App::get_working_root()
.join("assets/static/404.html")
.into(),
precompressed: false,
}
}

View File

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello to playground!</div>;
}

View File

@ -1,2 +1,2 @@
pub mod auth;
pub mod graphql; pub mod graphql;
pub mod oidc;

View File

@ -0,0 +1,42 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
import { PostLoginRedirectUriKey } from '../../auth/config';
export const Route = createFileRoute('/oidc/callback')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (!context.auth) {
throw redirect({
to: '/',
});
}
},
});
function RouteComponent() {
const auth = useAuth();
useEffect(() => {
if (!auth?.isLoading && auth?.isAuthenticated) {
try {
const redirectUri = sessionStorage.getItem(PostLoginRedirectUriKey);
if (redirectUri) {
history.replaceState(null, '', redirectUri);
}
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
} catch {}
}
}, [auth]);
if (auth?.isLoading) {
return <div>Loading...</div>;
}
return (
<div>
OpenID Connect Auth Callback Result:{' '}
{auth.error ? auth.error?.message : 'unknown'}
</div>
);
}

View File

@ -0,0 +1,70 @@
use std::sync::Arc;
use axum::{extract::Query, http::request::Parts};
use loco_rs::prelude::*;
use crate::{
app::AppContextExt,
auth::{
oidc::{OidcAuthCallbackPayload, OidcAuthCallbackQuery, OidcAuthRequest},
AppAuthService, AuthError, AuthService,
},
extract::http::ForwardedRelatedInfo,
models::auth::AuthType,
};
async fn oidc_callback(
State(ctx): State<Arc<AppContext>>,
Query(query): Query<OidcAuthCallbackQuery>,
) -> Result<Json<OidcAuthCallbackPayload>, AuthError> {
let auth_service = ctx.get_auth_service();
if let AppAuthService::Oidc(oidc_auth_service) = auth_service {
let response = oidc_auth_service
.extract_authorization_request_callback(query)
.await?;
Ok(Json(response))
} else {
Err(AuthError::NotSupportAuthMethod {
supported: vec![auth_service.auth_type()],
current: AuthType::Oidc,
})
}
}
async fn oidc_auth(
State(ctx): State<Arc<AppContext>>,
parts: Parts,
) -> Result<Json<OidcAuthRequest>, AuthError> {
let auth_service = ctx.get_auth_service();
if let AppAuthService::Oidc(oidc_auth_service) = auth_service {
let mut redirect_uri = ForwardedRelatedInfo::from_request_parts(&parts)
.resolved_origin()
.ok_or_else(|| AuthError::OidcRequestRedirectUriError(url::ParseError::EmptyHost))?;
redirect_uri.set_path("/api/oidc/callback");
let auth_request = oidc_auth_service
.build_authorization_request(redirect_uri.as_str())
.await?;
{
oidc_auth_service
.store_authorization_request(auth_request.clone())
.await?;
}
Ok(Json(auth_request))
} else {
Err(AuthError::NotSupportAuthMethod {
supported: vec![auth_service.auth_type()],
current: AuthType::Oidc,
})
}
}
pub fn routes(state: Arc<AppContext>) -> Routes {
Routes::new()
.prefix("/oidc")
.add("/auth", get(oidc_auth).with_state(state.clone()))
.add("/callback", get(oidc_callback).with_state(state))
}

1
apps/recorder/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@rsbuild/core/types" />

View File

@ -1,7 +1,7 @@
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ParseError { pub enum ExtractError {
#[error("Parse bangumi season error: {0}")] #[error("Parse bangumi season error: {0}")]
BangumiSeasonError(#[from] std::num::ParseIntError), BangumiSeasonError(#[from] std::num::ParseIntError),
#[error("Parse file url error: {0}")] #[error("Parse file url error: {0}")]

View File

@ -0,0 +1,174 @@
use axum::http::{header, request::Parts, HeaderName, HeaderValue, Uri};
use itertools::Itertools;
use url::Url;
/// Fields from a "Forwarded" header per [RFC7239 sec 4](https://www.rfc-editor.org/rfc/rfc7239#section-4)
#[derive(Debug, Clone)]
pub struct ForwardedHeader {
pub for_field: Vec<String>,
pub by: Option<String>,
pub host: Option<String>,
pub proto: Option<String>,
}
impl ForwardedHeader {
/// Return the 'for' headers as a list of [std::net::IpAddr]'s.
pub fn for_as_ipaddr(self) -> Vec<std::net::IpAddr> {
self.for_field
.iter()
.filter_map(|ip| {
if ip.contains(']') {
// this is an IPv6 address, get what's between the []
ip.split(']')
.next()?
.split('[')
.next_back()?
.parse::<std::net::IpAddr>()
.ok()
} else {
ip.parse::<std::net::IpAddr>().ok()
}
})
.collect::<Vec<std::net::IpAddr>>()
}
}
/// This parses the Forwarded header, and returns a list of the IPs in the
/// "for=" fields. Per [RFC7239 sec 4](https://www.rfc-editor.org/rfc/rfc7239#section-4)
impl TryFrom<HeaderValue> for ForwardedHeader {
type Error = String;
fn try_from(forwarded: HeaderValue) -> Result<ForwardedHeader, String> {
ForwardedHeader::try_from(&forwarded)
}
}
/// This parses the Forwarded header, and returns a list of the IPs in the
/// "for=" fields. Per [RFC7239 sec 4](https://www.rfc-editor.org/rfc/rfc7239#section-4)
impl TryFrom<&HeaderValue> for ForwardedHeader {
type Error = String;
fn try_from(forwarded: &HeaderValue) -> Result<ForwardedHeader, String> {
let mut for_field: Vec<String> = Vec::new();
let mut by: Option<String> = None;
let mut host: Option<String> = None;
let mut proto: Option<String> = None;
// first get the k=v pairs
forwarded
.to_str()
.map_err(|err| err.to_string())?
.split(';')
.for_each(|s| {
let s = s.trim().to_lowercase();
// The for value can look like this:
// for=192.0.2.43, for=198.51.100.17
// so we need to handle this case
if s.starts_with("for=") || s.starts_with("for =") {
// we have a valid thing to grab
let chunks: Vec<String> = s
.split(',')
.filter_map(|chunk| {
chunk.trim().split('=').next_back().map(|c| c.to_string())
})
.collect::<Vec<String>>();
for_field.extend(chunks);
} else if s.starts_with("by=") {
by = s.split('=').next_back().map(|c| c.to_string());
} else if s.starts_with("host=") {
host = s.split('=').next_back().map(|c| c.to_string());
} else if s.starts_with("proto=") {
proto = s.split('=').next_back().map(|c| c.to_string());
} else {
// probably need to work out what to do here
}
});
Ok(ForwardedHeader {
for_field,
by,
host,
proto,
})
}
}
#[derive(Clone, Debug)]
pub struct ForwardedRelatedInfo {
pub forwarded: Option<ForwardedHeader>,
pub x_forwarded_proto: Option<String>,
pub x_forwarded_host: Option<String>,
pub x_forwarded_for: Option<Vec<String>>,
pub host: Option<String>,
pub uri: Uri,
pub origin: Option<String>,
}
impl ForwardedRelatedInfo {
pub fn from_request_parts(request_parts: &Parts) -> ForwardedRelatedInfo {
let headers = &request_parts.headers;
let forwarded = headers
.get(header::FORWARDED)
.and_then(|s| ForwardedHeader::try_from(s.clone()).ok());
let x_forwarded_proto = headers
.get(HeaderName::from_static("x-forwarded-proto"))
.and_then(|s| s.to_str().map(String::from).ok());
let x_forwarded_host = headers
.get(HeaderName::from_static("x-forwarded-host"))
.and_then(|s| s.to_str().map(String::from).ok());
let x_forwarded_for = headers
.get(HeaderName::from_static("x-forwarded-for"))
.and_then(|s| s.to_str().ok())
.and_then(|s| {
let l = s.split(",").map(|s| s.trim().to_string()).collect_vec();
if l.is_empty() {
None
} else {
Some(l)
}
});
let host = headers
.get(header::HOST)
.and_then(|s| s.to_str().map(String::from).ok());
let origin = headers
.get(header::ORIGIN)
.and_then(|s| s.to_str().map(String::from).ok());
ForwardedRelatedInfo {
host,
x_forwarded_for,
x_forwarded_host,
x_forwarded_proto,
forwarded,
uri: request_parts.uri.clone(),
origin,
}
}
pub fn resolved_protocol(&self) -> Option<&str> {
self.forwarded
.as_ref()
.and_then(|s| s.proto.as_deref())
.or(self.x_forwarded_proto.as_deref())
.or(self.uri.scheme_str())
}
pub fn resolved_host(&self) -> Option<&str> {
self.forwarded
.as_ref()
.and_then(|s| s.host.as_deref())
.or(self.x_forwarded_host.as_deref())
.or(self.uri.host())
}
pub fn resolved_origin(&self) -> Option<Url> {
if let (Some(protocol), Some(host)) = (self.resolved_protocol(), self.resolved_host()) {
let origin = format!("{}://{}", protocol, host);
Url::parse(&origin).ok()
} else {
None
}
}
}

View File

@ -8,7 +8,7 @@ use url::Url;
use crate::{ use crate::{
extract::{ extract::{
errors::ParseError, errors::ExtractError,
mikan::{ mikan::{
web_parser::{parse_mikan_episode_id_from_homepage, MikanEpisodeHomepage}, web_parser::{parse_mikan_episode_id_from_homepage, MikanEpisodeHomepage},
AppMikanClient, AppMikanClient,
@ -101,7 +101,7 @@ impl MikanRssChannel {
} }
impl TryFrom<rss::Item> for MikanRssItem { impl TryFrom<rss::Item> for MikanRssItem {
type Error = ParseError; type Error = ExtractError;
fn try_from(item: rss::Item) -> Result<Self, Self::Error> { fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
let mime_type = item let mime_type = item
@ -113,7 +113,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
let homepage = item let homepage = item
.link .link
.ok_or_else(|| ParseError::MikanRssItemFormatError { .ok_or_else(|| ExtractError::MikanRssItemFormatError {
reason: String::from("must to have link for homepage"), reason: String::from("must to have link for homepage"),
})?; })?;
@ -124,7 +124,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
let MikanEpisodeHomepage { let MikanEpisodeHomepage {
mikan_episode_id, .. mikan_episode_id, ..
} = parse_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| { } = parse_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
ParseError::MikanRssItemFormatError { ExtractError::MikanRssItemFormatError {
reason: String::from("homepage link format invalid"), reason: String::from("homepage link format invalid"),
} }
})?; })?;
@ -142,7 +142,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
mikan_episode_id, mikan_episode_id,
}) })
} else { } else {
Err(ParseError::MimeError { Err(ExtractError::MimeError {
expected: String::from(BITTORRENT_MIME_TYPE), expected: String::from(BITTORRENT_MIME_TYPE),
found: mime_type, found: mime_type,
desc: String::from("MikanRssItem"), desc: String::from("MikanRssItem"),
@ -291,7 +291,7 @@ pub async fn parse_mikan_rss_channel_from_rss_link(
}, },
)); ));
} else { } else {
return Err(ParseError::MikanRssFormatError { return Err(ExtractError::MikanRssFormatError {
url: url.as_str().into(), url: url.as_str().into(),
} }
.into()); .into());

View File

@ -1,6 +1,7 @@
pub mod defs; pub mod defs;
pub mod errors; pub mod errors;
pub mod html; pub mod html;
pub mod http;
pub mod mikan; pub mod mikan;
pub mod rawname; pub mod rawname;
pub mod torrent; pub mod torrent;

View File

@ -1,6 +1,10 @@
use std::{ops::Deref, time::Duration}; use std::{ops::Deref, sync::Arc, time::Duration};
use axum::http::Extensions; use async_trait::async_trait;
use axum::http::{self, Extensions};
use http_cache_reqwest::{
CACacheManager, Cache, CacheManager, CacheMode, HttpCache, HttpCacheOptions, MokaManager,
};
use leaky_bucket::RateLimiter; use leaky_bucket::RateLimiter;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use reqwest::{ClientBuilder, Request, Response}; use reqwest::{ClientBuilder, Request, Response};
@ -11,9 +15,29 @@ use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use reqwest_tracing::TracingMiddleware; use reqwest_tracing::TracingMiddleware;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::serde_as; use serde_with::serde_as;
use async_trait::async_trait; use thiserror::Error;
use super::get_random_mobile_ua; use super::get_random_mobile_ua;
use crate::app::App;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum HttpClientCacheBackendConfig {
Moka { cache_size: u64 },
CACache { cache_path: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HttpClientCachePresetConfig {
#[serde(rename = "rfc7234")]
RFC7234,
}
impl Default for HttpClientCachePresetConfig {
fn default() -> Self {
Self::RFC7234
}
}
#[serde_as] #[serde_as]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -25,6 +49,51 @@ pub struct HttpClientConfig {
#[serde_as(as = "Option<serde_with::DurationMilliSeconds>")] #[serde_as(as = "Option<serde_with::DurationMilliSeconds>")]
pub leaky_bucket_refill_interval: Option<Duration>, pub leaky_bucket_refill_interval: Option<Duration>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub cache_backend: Option<HttpClientCacheBackendConfig>,
pub cache_preset: Option<HttpClientCachePresetConfig>,
}
struct CacheBackend(Box<dyn CacheManager>);
impl CacheBackend {
fn new<T: CacheManager>(backend: T) -> Self {
Self(Box::new(backend))
}
}
#[async_trait::async_trait]
impl CacheManager for CacheBackend {
async fn get(
&self,
cache_key: &str,
) -> http_cache::Result<Option<(http_cache::HttpResponse, http_cache_semantics::CachePolicy)>>
{
self.0.get(cache_key).await
}
/// Attempts to cache a response and related policy.
async fn put(
&self,
cache_key: String,
res: http_cache::HttpResponse,
policy: http_cache_semantics::CachePolicy,
) -> http_cache::Result<http_cache::HttpResponse> {
self.0.put(cache_key, res, policy).await
}
/// Attempts to remove a record from cache.
async fn delete(&self, cache_key: &str) -> http_cache::Result<()> {
self.0.delete(cache_key).await
}
}
#[derive(Debug, Error)]
pub enum HttpClientError {
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
#[error(transparent)]
ReqwestMiddlewareError(#[from] reqwest_middleware::Error),
#[error(transparent)]
HttpError(#[from] http::Error),
} }
pub struct HttpClient { pub struct HttpClient {
@ -64,7 +133,7 @@ impl reqwest_middleware::Middleware for RateLimiterMiddleware {
} }
impl HttpClient { impl HttpClient {
pub fn from_config(config: HttpClientConfig) -> reqwest::Result<Self> { pub fn from_config(config: HttpClientConfig) -> Result<Self, HttpClientError> {
let reqwest_client_builder = ClientBuilder::new().user_agent( let reqwest_client_builder = ClientBuilder::new().user_agent(
config config
.user_agent .user_agent
@ -72,6 +141,10 @@ impl HttpClient {
.unwrap_or_else(|| get_random_mobile_ua()), .unwrap_or_else(|| get_random_mobile_ua()),
); );
#[cfg(not(target_arch = "wasm32"))]
let reqwest_client_builder =
reqwest_client_builder.redirect(reqwest::redirect::Policy::none());
let reqwest_client = reqwest_client_builder.build()?; let reqwest_client = reqwest_client_builder.build()?;
let mut reqwest_with_middleware_builder = let mut reqwest_with_middleware_builder =
@ -112,6 +185,38 @@ impl HttpClient {
reqwest_with_middleware_builder.with(RateLimiterMiddleware { rate_limiter }); reqwest_with_middleware_builder.with(RateLimiterMiddleware { rate_limiter });
} }
if let (None, None) = (config.cache_backend.as_ref(), config.cache_preset.as_ref()) {
} else {
let cache_preset = config.cache_preset.as_ref().cloned().unwrap_or_default();
let cache_backend = config
.cache_backend
.as_ref()
.map(|b| match b {
HttpClientCacheBackendConfig::CACache { cache_path } => {
let path = std::path::PathBuf::from(
App::get_working_root().join(cache_path).as_str(),
);
CacheBackend::new(CACacheManager { path })
}
HttpClientCacheBackendConfig::Moka { cache_size } => {
CacheBackend::new(MokaManager {
cache: Arc::new(moka::future::Cache::new(u64::max(*cache_size, 1))),
})
}
})
.unwrap_or_else(|| CacheBackend::new(MokaManager::default()));
let http_cache = match cache_preset {
HttpClientCachePresetConfig::RFC7234 => HttpCache {
mode: CacheMode::Default,
manager: cache_backend,
options: HttpCacheOptions::default(),
},
};
reqwest_with_middleware_builder =
reqwest_with_middleware_builder.with(Cache(http_cache));
}
let reqwest_with_middleware = reqwest_with_middleware_builder.build(); let reqwest_with_middleware = reqwest_with_middleware_builder.build();
Ok(Self { Ok(Self {

View File

@ -3,10 +3,11 @@ pub mod client;
pub mod core; pub mod core;
pub mod html; pub mod html;
pub mod image; pub mod image;
pub mod oidc;
pub use core::get_random_mobile_ua; pub use core::get_random_mobile_ua;
pub use bytes::fetch_bytes; pub use bytes::fetch_bytes;
pub use client::{HttpClient, HttpClientConfig}; pub use client::{HttpClient, HttpClientConfig, HttpClientError};
pub use html::fetch_html; pub use html::fetch_html;
pub use image::fetch_image; pub use image::fetch_image;

View File

@ -0,0 +1,36 @@
use std::{future::Future, pin::Pin};
use axum::http;
use super::{client::HttpClientError, HttpClient};
impl<'c> openidconnect::AsyncHttpClient<'c> for HttpClient {
type Error = HttpClientError;
#[cfg(target_arch = "wasm32")]
type Future = Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + 'c>>;
#[cfg(not(target_arch = "wasm32"))]
type Future =
Pin<Box<dyn Future<Output = Result<openidconnect::HttpResponse, Self::Error>> + Send + 'c>>;
fn call(&'c self, request: openidconnect::HttpRequest) -> Self::Future {
Box::pin(async move {
let response = self.execute(request.try_into()?).await?;
let mut builder = http::Response::builder().status(response.status());
#[cfg(not(target_arch = "wasm32"))]
{
builder = builder.version(response.version());
}
for (name, value) in response.headers().iter() {
builder = builder.header(name, value);
}
builder
.body(response.bytes().await?.to_vec())
.map_err(HttpClientError::HttpError)
})
}
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app';
const rootEl = document.getElementById('root');
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}

View File

@ -0,0 +1,134 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './controllers/__root'
import { Route as IndexImport } from './controllers/index'
import { Route as GraphqlIndexImport } from './controllers/graphql/index'
import { Route as OidcCallbackImport } from './controllers/oidc/callback'
// Create/Update Routes
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const GraphqlIndexRoute = GraphqlIndexImport.update({
id: '/graphql/',
path: '/graphql/',
getParentRoute: () => rootRoute,
} as any)
const OidcCallbackRoute = OidcCallbackImport.update({
id: '/oidc/callback',
path: '/oidc/callback',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/oidc/callback': {
id: '/oidc/callback'
path: '/oidc/callback'
fullPath: '/oidc/callback'
preLoaderRoute: typeof OidcCallbackImport
parentRoute: typeof rootRoute
}
'/graphql/': {
id: '/graphql/'
path: '/graphql'
fullPath: '/graphql'
preLoaderRoute: typeof GraphqlIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/oidc/callback': typeof OidcCallbackRoute
'/graphql': typeof GraphqlIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/oidc/callback': typeof OidcCallbackRoute
'/graphql': typeof GraphqlIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/oidc/callback': typeof OidcCallbackRoute
'/graphql/': typeof GraphqlIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/oidc/callback' | '/graphql'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/oidc/callback' | '/graphql'
id: '__root__' | '/' | '/oidc/callback' | '/graphql/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OidcCallbackRoute: typeof OidcCallbackRoute
GraphqlIndexRoute: typeof GraphqlIndexRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OidcCallbackRoute: OidcCallbackRoute,
GraphqlIndexRoute: GraphqlIndexRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/oidc/callback",
"/graphql/"
]
},
"/": {
"filePath": "index.tsx"
},
"/oidc/callback": {
"filePath": "oidc/callback.tsx"
},
"/graphql/": {
"filePath": "graphql/index.tsx"
}
}
}
ROUTE_MANIFEST_END */

View File

@ -64,7 +64,7 @@ impl TorrentSource {
} else if let Some(basename) = url } else if let Some(basename) = url
.clone() .clone()
.path_segments() .path_segments()
.and_then(|segments| segments.last()) .and_then(|mut segments| segments.next_back())
{ {
if let (Some(match_hash), true) = ( if let (Some(match_hash), true) = (
TORRENT_HASH_RE.find(basename), TORRENT_HASH_RE.find(basename),

View File

@ -21,7 +21,8 @@ async fn can_find_by_pid() {
// testing::seed::<App>(&boot.app_context.db).await.unwrap(); // testing::seed::<App>(&boot.app_context.db).await.unwrap();
// //
// let existing_subscriber = // let existing_subscriber =
// Model::find_by_pid(&boot.app_context, "11111111-1111-1111-1111-111111111111").await; // Model::find_by_pid(&boot.app_context,
// "11111111-1111-1111-1111-111111111111").await;
// //
// assert_debug_snapshot!(existing_subscriber); // assert_debug_snapshot!(existing_subscriber);
} }

View File

@ -0,0 +1,20 @@
{
"extends": "@konobangu/typescript-config/base.json",
"compilerOptions": {
"lib": ["DOM", "ES2022", "DOM.AsyncIterable", "DOM.Iterable"],
"jsx": "react-jsx",
"target": "ES2020",
"noEmit": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"module": "ESNext",
"isolatedModules": true,
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"routesDirectory": "./src/controllers",
"generatedRouteTree": "./src/routeTree.gen.ts"
}

View File

@ -6,6 +6,9 @@
}, },
"linter": { "linter": {
"rules": { "rules": {
"style": {
"noNonNullAssertion": "off"
},
"suspicious": { "suspicious": {
"noExplicitAny": "off" "noExplicitAny": "off"
}, },

View File

@ -15,6 +15,9 @@ dev-proxy:
dev-recorder: dev-recorder:
cargo watch -w apps/recorder -x 'recorder start' cargo watch -w apps/recorder -x 'recorder start'
dev-playground:
pnpm run --filter=recorder dev
down-recorder: down-recorder:
cargo run -p recorder --bin recorder_cli -- db down 999 --environment development cargo run -p recorder --bin recorder_cli -- db down 999 --environment development

857
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

BIN
public/assets/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

BIN
public/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/assets/icon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}

View File

@ -5,3 +5,4 @@ imports_granularity = "Crate"
use_small_heuristics = "Default" use_small_heuristics = "Default"
group_imports = "StdExternalCrate" group_imports = "StdExternalCrate"
format_strings = true format_strings = true
tab_spaces = 4

View File

@ -1,8 +1,6 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"ui": "tui", "ui": "tui",
"tasks": { "tasks": {
"build": { "build": {
@ -14,7 +12,6 @@
"BETTERSTACK_URL", "BETTERSTACK_URL",
"DATABASE_URL", "DATABASE_URL",
"FLAGS_SECRET", "FLAGS_SECRET",
"STRIPE_SECRET_KEY",
"SENTRY_AUTH_TOKEN", "SENTRY_AUTH_TOKEN",
"SENTRY_ORG", "SENTRY_ORG",
"SENTRY_PROJECT", "SENTRY_PROJECT",