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" }
|
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" }
|
||||||
|
@ -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_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=""
|
||||||
|
@ -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=""
|
||||||
|
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",
|
"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": [],
|
||||||
|
5
apps/recorder/.gitignore
vendored
@ -25,8 +25,3 @@ Cargo.lock
|
|||||||
# Dist
|
# Dist
|
||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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::{
|
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 {}
|
||||||
|
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::{
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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},
|
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}")]
|
||||||
|
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::{
|
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 {
|
||||||
|
@ -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};
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
|
||||||
|
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::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(
|
|
||||||
"/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)),
|
post(graphql_handler).layer(from_fn_with_state(ctx, api_auth_middleware)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn asset_middlewares() -> Vec<Box<dyn MiddlewareLayer>> {
|
|
||||||
vec![Box::new(altair_graphql_playground_asset_middleware(
|
|
||||||
"/api/graphql/playground/static/",
|
|
||||||
))]
|
|
||||||
}
|
|
||||||
|
@ -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 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;
|
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}")]
|
||||||
|
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::{
|
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());
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
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
|
} 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),
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
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": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off"
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
|
3
justfile
@ -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
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"
|
use_small_heuristics = "Default"
|
||||||
group_imports = "StdExternalCrate"
|
group_imports = "StdExternalCrate"
|
||||||
format_strings = true
|
format_strings = true
|
||||||
|
tab_spaces = 4
|
||||||
|
@ -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",
|
||||||
|