feat: add oidc and basic support for playground
690
Cargo.lock
generated
@ -6,7 +6,7 @@ resolver = "2"
|
||||
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/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-axum = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" }
|
||||
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }
|
||||
|
@ -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>
|
@ -7,8 +7,8 @@ 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_ISSUER="https://some-oidc-auth.com/oidc"
|
||||
OIDC_AUDIENCE="https://konobangu.com/api"
|
||||
OIDC_ICON_URL=""
|
||||
OIDC_EXTRA_SCOPE_REGEX=""
|
||||
OIDC_EXTRA_CLAIM_KEY=""
|
||||
|
@ -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_SECRET=""
|
||||
NEXT_PUBLIC_OIDC_ICON_URL=""
|
||||
OIDC_API_ISSUER="https://some-oidc-auth.com/oidc"
|
||||
OIDC_API_AUDIENCE="https://konobangu.com/api"
|
||||
OIDC_ISSUER="https://some-oidc-auth.com/oidc"
|
||||
OIDC_AUDIENCE="https://konobangu.com/api"
|
||||
OIDC_EXTRA_SCOPES="" # 如 "read:konobangu,write:konobangu"
|
||||
OIDC_EXTRA_CLAIM_KEY=""
|
||||
OIDC_EXTRA_CLAIM_VALUE=""
|
||||
|
12
apps/proxy/.whistle/rules/files/0.konobangu
Normal 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***
|
||||
|
@ -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/***
|
@ -1 +0,0 @@
|
||||
^https://konobangu.com/api/*** http://127.0.0.1:5001/api/$1
|
@ -1 +1 @@
|
||||
{"filesOrder":["webui","recorder"],"selectedList":["webui","recorder"],"disabledDefalutRules":true}
|
||||
{"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true}
|
||||
|
@ -0,0 +1 @@
|
||||
{"filesOrder":[]}
|
@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
|
5
apps/recorder/.gitignore
vendored
@ -25,8 +25,3 @@ Cargo.lock
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
@ -84,10 +84,7 @@ testcontainers = { version = "0.23.1", features = [
|
||||
"reusable-containers",
|
||||
], optional = true }
|
||||
testcontainers-modules = { version = "0.11.4", optional = true }
|
||||
|
||||
color-eyre = "0.6"
|
||||
|
||||
|
||||
log = "0.4.22"
|
||||
anyhow = "1.0.95"
|
||||
bollard = { version = "0.18", optional = true }
|
||||
@ -102,6 +99,19 @@ axum-extra = "0.10.0"
|
||||
tower-http = "0.6.2"
|
||||
serde_yaml = "0.9.34"
|
||||
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]
|
||||
serial_test = "3"
|
||||
|
@ -18,8 +18,9 @@ logger:
|
||||
server:
|
||||
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||
port: 5001
|
||||
binding: "0.0.0.0"
|
||||
# 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
|
||||
middlewares:
|
||||
# Enable Etag cache header middleware
|
||||
@ -68,7 +69,7 @@ workers:
|
||||
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||
mode: BackgroundQueue
|
||||
mode: BackgroundAsync
|
||||
|
||||
# Mailer Configuration.
|
||||
mailer:
|
||||
@ -89,7 +90,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# 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.
|
||||
enable_logging: true
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
@ -110,13 +111,13 @@ database:
|
||||
# Redis Configuration
|
||||
redis:
|
||||
# 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: false
|
||||
|
||||
settings:
|
||||
dal:
|
||||
data_dir: ./data
|
||||
data_dir: '{{ get_env(name="DAL_DATA_DIR", default="./data") }}'
|
||||
|
||||
mikan:
|
||||
base_url: "https://mikanani.me/"
|
||||
@ -128,11 +129,17 @@ settings:
|
||||
leaky_bucket_refill_interval: 500
|
||||
|
||||
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: ""
|
||||
auth_type: '{{ get_env(name="AUTH_TYPE", default = "basic") }}'
|
||||
basic_user: '{{ get_env(name="BASIC_USER", default = "konobangu") }}'
|
||||
basic_password: '{{ get_env(name="BASIC_PASSWORD", default = "konobangu") }}'
|
||||
oidc_issuer: '{{ get_env(name="OIDC_ISSUER", default = "") }}'
|
||||
oidc_audience: '{{ get_env(name="OIDC_AUDIENCE", default = "") }}'
|
||||
oidc_client_id: '{{ get_env(name="OIDC_CLIENT_ID", default = "") }}'
|
||||
oidc_client_secret: '{{ get_env(name="OIDC_CLIENT_SECRET", default = "") }}'
|
||||
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
|
||||
|
@ -1,7 +1,28 @@
|
||||
{
|
||||
"name": "recorder",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"preview": "rsbuild preview"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
BIN
apps/recorder/public/assets/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
72
apps/recorder/rsbuild.config.ts
Normal 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,
|
||||
},
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
use loco_rs::app::AppContext;
|
||||
use loco_rs::{app::AppContext, environment::Environment};
|
||||
|
||||
use crate::{
|
||||
auth::service::AppAuthService, dal::AppDalClient, extract::mikan::AppMikanClient,
|
||||
@ -21,6 +21,14 @@ pub trait AppContextExt {
|
||||
fn get_graphql_service(&self) -> &AppGraphQLService {
|
||||
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 {}
|
||||
|
53
apps/recorder/src/app/index.tsx
Normal 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;
|
@ -3,6 +3,7 @@ pub mod ext;
|
||||
use std::{
|
||||
fs,
|
||||
path::{self, Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -88,28 +89,49 @@ impl Hooks for App {
|
||||
.flatten()
|
||||
.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() {
|
||||
let working_root = PathBuf::from(working_root);
|
||||
let config_dir = working_root.as_path().join("config");
|
||||
|
||||
for config_file in [
|
||||
config_dir.join(format!("{env}.local.yaml")),
|
||||
config_dir.join(format!("{env}.yaml")),
|
||||
] {
|
||||
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 rendered = tera::Tera::one_off(
|
||||
&content,
|
||||
&tera::Context::from_serialize(serde_json::json!({}))?,
|
||||
&tera::Context::from_value(serde_json::json!({}))?,
|
||||
false,
|
||||
)?;
|
||||
|
||||
App::set_working_root(working_root);
|
||||
|
||||
return serde_yaml::from_str(&rendered).map_err(|err| {
|
||||
loco_rs::Error::YAMLFile(err, config_file.to_string_lossy().to_string())
|
||||
});
|
||||
let config_file = &config_file.to_string_lossy();
|
||||
|
||||
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: {}",
|
||||
working_roots_to_search
|
||||
.iter()
|
||||
.map(|p| path::absolute(PathBuf::from(p)))
|
||||
.flatten()
|
||||
.flat_map(|p| path::absolute(PathBuf::from(p)))
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.join(",")
|
||||
)))
|
||||
@ -137,15 +158,28 @@ impl Hooks for App {
|
||||
}
|
||||
|
||||
fn routes(ctx: &AppContext) -> AppRoutes {
|
||||
let ctx = Arc::new(ctx.clone());
|
||||
AppRoutes::with_default_routes()
|
||||
.prefix("/api")
|
||||
.add_route(controllers::auth::routes())
|
||||
.add_route(controllers::graphql::routes(ctx.clone()))
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -22,12 +22,12 @@ impl AuthBasic {
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|s| s.to_str().ok())
|
||||
.ok_or_else(|| AuthError::BasicInvalidCredentials)?;
|
||||
.ok_or(AuthError::BasicInvalidCredentials)?;
|
||||
|
||||
let split = authorization.split_once(' ');
|
||||
|
||||
match split {
|
||||
Some((name, contents)) if name == "Basic" => {
|
||||
Some(("Basic", contents)) => {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(contents)
|
||||
.map_err(|_| AuthError::BasicInvalidCredentials)?;
|
||||
@ -80,4 +80,8 @@ impl AuthService for BasicAuthService {
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
|
||||
Some(HeaderValue::from_static(r#"Basic realm="konobangu""#))
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
AuthType::Basic
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
use jwt_authorizer::OneOrArray;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, NoneAsEmptyString};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BasicAuthConfig {
|
||||
@ -9,17 +10,24 @@ pub struct BasicAuthConfig {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OidcAuthConfig {
|
||||
#[serde(rename = "oidc_api_issuer")]
|
||||
#[serde(rename = "oidc_issuer")]
|
||||
pub issuer: String,
|
||||
#[serde(rename = "oidc_api_audience")]
|
||||
#[serde(rename = "oidc_audience")]
|
||||
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")]
|
||||
pub extra_scopes: Option<OneOrArray<String>>,
|
||||
#[serde_as(as = "NoneAsEmptyString")]
|
||||
#[serde(rename = "oidc_extra_claim_key")]
|
||||
pub extra_claim_key: Option<String>,
|
||||
#[serde(rename = "oidc_extra_claim_value")]
|
||||
#[serde_as(as = "NoneAsEmptyString")]
|
||||
pub extra_claim_value: Option<String>,
|
||||
}
|
||||
|
||||
|
31
apps/recorder/src/auth/config.ts
Normal 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());
|
||||
}
|
@ -3,15 +3,56 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use openidconnect::{
|
||||
core::CoreErrorResponseType, ConfigurationError, RequestTokenError, SignatureVerificationError,
|
||||
SigningError, StandardErrorResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{fetch::HttpClientError, models::auth::AuthType};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Not support auth method")]
|
||||
NotSupportAuthMethod {
|
||||
supported: Vec<AuthType>,
|
||||
current: AuthType,
|
||||
},
|
||||
#[error("Invalid credentials")]
|
||||
BasicInvalidCredentials,
|
||||
#[error(transparent)]
|
||||
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)]
|
||||
OidcJwtAuthError(#[from] jwt_authorizer::AuthError),
|
||||
#[error("Extra scopes {expected} do not match found scopes {found}")]
|
||||
|
21
apps/recorder/src/auth/guard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header,
|
||||
@ -9,34 +11,7 @@ use loco_rs::prelude::AppContext;
|
||||
use crate::{app::AppContextExt, auth::AuthService};
|
||||
|
||||
pub async fn api_auth_middleware(
|
||||
State(ctx): State<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>,
|
||||
State(ctx): State<Arc<AppContext>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
|
@ -7,5 +7,5 @@ pub mod service;
|
||||
|
||||
pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig};
|
||||
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};
|
||||
|
@ -1,18 +1,28 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::http::{request::Parts, HeaderValue};
|
||||
use itertools::Itertools;
|
||||
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_json::Value;
|
||||
use url::Url;
|
||||
|
||||
use super::{
|
||||
config::OidcAuthConfig,
|
||||
errors::AuthError,
|
||||
service::{AuthService, AuthUserInfo},
|
||||
};
|
||||
use crate::models::auth::AuthType;
|
||||
use crate::{fetch::HttpClient, models::auth::AuthType};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
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 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]
|
||||
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 = self.api_authorizer.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;
|
||||
if claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) {
|
||||
return Err(AuthError::OidcSubMissingError);
|
||||
@ -139,4 +311,8 @@ impl AuthService for OidcAuthService {
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
|
||||
Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#))
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
AuthType::Oidc
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
@ -6,6 +8,7 @@ use axum::{
|
||||
};
|
||||
use jwt_authorizer::{JwtAuthorizer, Validation};
|
||||
use loco_rs::app::{AppContext, Initializer};
|
||||
use moka::future::Cache;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
@ -15,7 +18,15 @@ use super::{
|
||||
oidc::{OidcAuthClaims, OidcAuthService},
|
||||
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)]
|
||||
pub struct AuthUserInfo {
|
||||
@ -43,6 +54,7 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
|
||||
pub trait AuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue>;
|
||||
fn auth_type(&self) -> AuthType;
|
||||
}
|
||||
|
||||
pub enum AppAuthService {
|
||||
@ -74,7 +86,18 @@ impl AppAuthService {
|
||||
|
||||
AppAuthService::Oidc(OidcAuthService {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
match self {
|
||||
AppAuthService::Basic(service) => service.auth_type(),
|
||||
AppAuthService::Oidc(service) => service.auth_type(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppAuthServiceInitializer;
|
||||
|
@ -1,16 +1,15 @@
|
||||
dal:
|
||||
data_dir: ./data
|
||||
# dal:
|
||||
# data_dir: ./data
|
||||
|
||||
mikan:
|
||||
http_client:
|
||||
exponential_backoff_max_retries: 3
|
||||
leaky_bucket_max_tokens: 2
|
||||
leaky_bucket_initial_tokens: 0
|
||||
leaky_bucket_refill_tokens: 1
|
||||
leaky_bucket_refill_interval: 500
|
||||
base_url: "https://mikanani.me/"
|
||||
# mikan:
|
||||
# http_client:
|
||||
# exponential_backoff_max_retries: 3
|
||||
# leaky_bucket_max_tokens: 2
|
||||
# leaky_bucket_initial_tokens: 0
|
||||
# leaky_bucket_refill_tokens: 1
|
||||
# leaky_bucket_refill_interval: 500
|
||||
# base_url: "https://mikanani.me/"
|
||||
|
||||
graphql:
|
||||
playground_static: "./node_modules/altair-static/build/dist"
|
||||
depth_limit: null
|
||||
complexity_limit: null
|
||||
# graphql:
|
||||
# depth_limit: null
|
||||
# complexity_limit: null
|
||||
|
52
apps/recorder/src/controllers/__root.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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))
|
||||
}
|
37
apps/recorder/src/controllers/graphql/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,43 +1,14 @@
|
||||
pub mod playground;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
|
||||
use axum::{
|
||||
extract::State,
|
||||
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 axum::{extract::State, middleware::from_fn_with_state, routing::post, Extension};
|
||||
use loco_rs::{app::AppContext, prelude::Routes};
|
||||
|
||||
use crate::{
|
||||
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(
|
||||
State(ctx): State<AppContext>,
|
||||
Extension(auth_user_info): Extension<AuthUserInfo>,
|
||||
@ -51,21 +22,9 @@ async fn graphql_handler(
|
||||
graphql_service.schema.execute(req).await.into()
|
||||
}
|
||||
|
||||
pub fn routes(state: AppContext) -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/graphql")
|
||||
.add(
|
||||
"/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/",
|
||||
))]
|
||||
pub fn routes(ctx: Arc<AppContext>) -> Routes {
|
||||
Routes::new().prefix("/graphql").add(
|
||||
"/",
|
||||
post(graphql_handler).layer(from_fn_with_state(ctx, api_auth_middleware)),
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
0
apps/recorder/src/controllers/graphql/style.css
Normal file
9
apps/recorder/src/controllers/index.tsx
Normal 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>;
|
||||
}
|
@ -1,2 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod graphql;
|
||||
pub mod oidc;
|
||||
|
42
apps/recorder/src/controllers/oidc/callback.tsx
Normal 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>
|
||||
);
|
||||
}
|
70
apps/recorder/src/controllers/oidc/mod.rs
Normal 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
@ -0,0 +1 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
@ -1,7 +1,7 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ParseError {
|
||||
pub enum ExtractError {
|
||||
#[error("Parse bangumi season error: {0}")]
|
||||
BangumiSeasonError(#[from] std::num::ParseIntError),
|
||||
#[error("Parse file url error: {0}")]
|
||||
|
174
apps/recorder/src/extract/http.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ use url::Url;
|
||||
|
||||
use crate::{
|
||||
extract::{
|
||||
errors::ParseError,
|
||||
errors::ExtractError,
|
||||
mikan::{
|
||||
web_parser::{parse_mikan_episode_id_from_homepage, MikanEpisodeHomepage},
|
||||
AppMikanClient,
|
||||
@ -101,7 +101,7 @@ impl MikanRssChannel {
|
||||
}
|
||||
|
||||
impl TryFrom<rss::Item> for MikanRssItem {
|
||||
type Error = ParseError;
|
||||
type Error = ExtractError;
|
||||
|
||||
fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
|
||||
let mime_type = item
|
||||
@ -113,7 +113,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
||||
|
||||
let homepage = item
|
||||
.link
|
||||
.ok_or_else(|| ParseError::MikanRssItemFormatError {
|
||||
.ok_or_else(|| ExtractError::MikanRssItemFormatError {
|
||||
reason: String::from("must to have link for homepage"),
|
||||
})?;
|
||||
|
||||
@ -124,7 +124,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
||||
let MikanEpisodeHomepage {
|
||||
mikan_episode_id, ..
|
||||
} = parse_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
|
||||
ParseError::MikanRssItemFormatError {
|
||||
ExtractError::MikanRssItemFormatError {
|
||||
reason: String::from("homepage link format invalid"),
|
||||
}
|
||||
})?;
|
||||
@ -142,7 +142,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
||||
mikan_episode_id,
|
||||
})
|
||||
} else {
|
||||
Err(ParseError::MimeError {
|
||||
Err(ExtractError::MimeError {
|
||||
expected: String::from(BITTORRENT_MIME_TYPE),
|
||||
found: mime_type,
|
||||
desc: String::from("MikanRssItem"),
|
||||
@ -291,7 +291,7 @@ pub async fn parse_mikan_rss_channel_from_rss_link(
|
||||
},
|
||||
));
|
||||
} else {
|
||||
return Err(ParseError::MikanRssFormatError {
|
||||
return Err(ExtractError::MikanRssFormatError {
|
||||
url: url.as_str().into(),
|
||||
}
|
||||
.into());
|
||||
|
@ -1,6 +1,7 @@
|
||||
pub mod defs;
|
||||
pub mod errors;
|
||||
pub mod html;
|
||||
pub mod http;
|
||||
pub mod mikan;
|
||||
pub mod rawname;
|
||||
pub mod torrent;
|
||||
|
@ -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 once_cell::sync::OnceCell;
|
||||
use reqwest::{ClientBuilder, Request, Response};
|
||||
@ -11,9 +15,29 @@ use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
|
||||
use reqwest_tracing::TracingMiddleware;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
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]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@ -25,6 +49,51 @@ pub struct HttpClientConfig {
|
||||
#[serde_as(as = "Option<serde_with::DurationMilliSeconds>")]
|
||||
pub leaky_bucket_refill_interval: Option<Duration>,
|
||||
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 {
|
||||
@ -64,7 +133,7 @@ impl reqwest_middleware::Middleware for RateLimiterMiddleware {
|
||||
}
|
||||
|
||||
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(
|
||||
config
|
||||
.user_agent
|
||||
@ -72,6 +141,10 @@ impl HttpClient {
|
||||
.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 mut reqwest_with_middleware_builder =
|
||||
@ -112,6 +185,38 @@ impl HttpClient {
|
||||
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();
|
||||
|
||||
Ok(Self {
|
||||
|
@ -3,10 +3,11 @@ pub mod client;
|
||||
pub mod core;
|
||||
pub mod html;
|
||||
pub mod image;
|
||||
pub mod oidc;
|
||||
|
||||
pub use core::get_random_mobile_ua;
|
||||
|
||||
pub use bytes::fetch_bytes;
|
||||
pub use client::{HttpClient, HttpClientConfig};
|
||||
pub use client::{HttpClient, HttpClientConfig, HttpClientError};
|
||||
pub use html::fetch_html;
|
||||
pub use image::fetch_image;
|
||||
|
36
apps/recorder/src/fetch/oidc.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
13
apps/recorder/src/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
134
apps/recorder/src/routeTree.gen.ts
Normal 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 */
|
@ -64,7 +64,7 @@ impl TorrentSource {
|
||||
} else if let Some(basename) = url
|
||||
.clone()
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last())
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
{
|
||||
if let (Some(match_hash), true) = (
|
||||
TORRENT_HASH_RE.find(basename),
|
||||
|
@ -21,7 +21,8 @@ async fn can_find_by_pid() {
|
||||
// testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
|
20
apps/recorder/tsconfig.json
Normal 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"]
|
||||
}
|
4
apps/recorder/tsr.config.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"routesDirectory": "./src/controllers",
|
||||
"generatedRouteTree": "./src/routeTree.gen.ts"
|
||||
}
|
@ -6,6 +6,9 @@
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
|
3
justfile
@ -15,6 +15,9 @@ dev-proxy:
|
||||
dev-recorder:
|
||||
cargo watch -w apps/recorder -x 'recorder start'
|
||||
|
||||
dev-playground:
|
||||
pnpm run --filter=recorder dev
|
||||
|
||||
down-recorder:
|
||||
cargo run -p recorder --bin recorder_cli -- db down 999 --environment development
|
||||
|
||||
|
857
pnpm-lock.yaml
generated
BIN
public/assets/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
public/assets/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 247 KiB |
BIN
public/assets/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
public/assets/favicon-16x16.png
Normal file
After Width: | Height: | Size: 813 B |
BIN
public/assets/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
public/assets/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/favicon.png
Normal file
After Width: | Height: | Size: 717 KiB |
BIN
public/assets/favicon.webp
Normal file
After Width: | Height: | Size: 459 KiB |
BIN
public/assets/icon.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/icon.webp
Normal file
After Width: | Height: | Size: 245 KiB |
1
public/assets/site.webmanifest
Normal 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"}
|
@ -5,3 +5,4 @@ imports_granularity = "Crate"
|
||||
use_small_heuristics = "Default"
|
||||
group_imports = "StdExternalCrate"
|
||||
format_strings = true
|
||||
tab_spaces = 4
|
||||
|
@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"build": {
|
||||
@ -14,7 +12,6 @@
|
||||
"BETTERSTACK_URL",
|
||||
"DATABASE_URL",
|
||||
"FLAGS_SECRET",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"SENTRY_AUTH_TOKEN",
|
||||
"SENTRY_ORG",
|
||||
"SENTRY_PROJECT",
|
||||
|