Skip to content
Clone

refactor: reorganize configuration files and update module paths for improved structure

ZhenYi committed
parent 6962e15 commit b6f7f73 Browse files

151 files changed

+1626 −3337

File tree 151
Modified app/src/main.rs View app/src/main.rs
@@ -44,30 +44,30 @@
4444 .await
4545 .context("connecting to the shared database")?,
4646 );
47 let identity = match identity_config()? {
48 Some(identity_config) => {
47 let auth_runtime = match auth_config()? {
48 Some(auth_config) => {
4949 let db =
5050 auth::db::DbConn::new(database.primary().clone(), database.replicas().to_vec());
5151 Some(
52 auth::oauth::start(&identity_config, db)
52 auth::oauth::start(&auth_config, db)
5353 .await
54 .context("starting identity service")?,
54 .context("starting auth service")?,
5555 )
5656 }
5757 None => None,
5858 };
59 let client = identity.as_ref().map(|runtime| {
59 let client = auth_runtime.as_ref().map(|runtime| {
6060 std::sync::Arc::new(auth::client::KenAuthClient::local(
6161 runtime.service().clone(),
6262 cfg.oauth.http_timeout_ms,
6363 ))
6464 });
65 if let Some(runtime) = &identity
65 if let Some(runtime) = &auth_runtime
6666 && cfg.oauth.enabled
6767 && runtime.service().signing().issuer().trim_end_matches('/')
6868 != cfg.oauth.issuer.trim_end_matches('/')
6969 {
70 anyhow::bail!("oauth.issuer must match identity.jwt.issuer");
70 anyhow::bail!("oauth.issuer must match jwt.issuer");
7171 }
7272 let state = export::AppState::with_identity(app_config, database, client)
7373 .await
@@ -91,11 +91,11 @@
9191 let conn = queue::RocketMqConn::init(&cfg.rocketmq)
9292 .await
9393 .map_err(|err| anyhow::anyhow!("failed to start the rocketmq producer: {err}"))?;
94 let identity_events = identity
94 let auth_events = auth_runtime
9595 .as_ref()
9696 .and_then(|runtime| runtime.service().events().cloned());
9797 let relay = events::outbox::OutboxRelay::start(
98 identity_events,
98 auth_events,
9999 Some(queue::Publisher::new(conn.clone())),
100100 vec![("apihost", state.database.primary().clone())],
101101 );
@@ -166,12 +166,12 @@
166166 handle.stop(true).await;
167167 });
168168 let api = async move { server.await.context("actix server error") };
169 let identity_cancel = cancel.clone();
170 let identity_server = async move {
171 match identity {
172 Some(runtime) => runtime.serve_until(identity_cancel).await,
169 let auth_cancel = cancel.clone();
170 let auth_server = async move {
171 match auth_runtime {
172 Some(runtime) => runtime.serve_until(auth_cancel).await,
173173 None => {
174 identity_cancel.cancelled().await;
174 auth_cancel.cancelled().await;
175175 Ok(())
176176 }
177177 }
@@ -186,13 +186,13 @@
186186 if let Err(error) = relay.shutdown().await {
187187 warn!(%error, "shutting down the event outbox relay failed");
188188 }
189 let identity_result = identity_server.await;
189 let auth_result = auth_server.await;
190190 for task in push_tasks {
191191 let _ = task.await;
192192 }
193193 conn.shutdown().await;
194194 api_result?;
195 identity_result?;
195 auth_result?;
196196
197197 export::telemetry::shutdown_telemetry();
198198
@@ -228,12 +228,12 @@
228228
229229 info!("running schema migration");
230230 sync_schema(&db).await.context("running schema sync")?;
231 if let Some(identity) = identity_config()? {
231 if let Some(auth_config) = auth_config()? {
232232 let identity_db = auth::db::DbConn::new(db.clone(), Vec::new());
233233 auth::server::migrate::sync_schema(&identity_db)
234234 .await
235235 .context("synchronizing identity schema")?;
236 drop(identity);
236 drop(auth_config);
237237 }
238238 info!("schema migration completed");
239239
@@ -289,17 +289,15 @@
289289 Ok(())
290290}
291291
292fn identity_config() -> anyhow::Result<Option<auth::config::Config>> {
293 let figment = config::loader::figment();
292fn auth_config() -> anyhow::Result<Option<auth::config::Config>> {
293 let figment = config::loader::figment().context("loading configuration")?;
294294 let enabled = figment
295 .extract_inner::<bool>("identity.enabled")
296 .context("loading identity.enabled")?;
295 .extract_inner::<bool>("auth.enabled")
296 .context("loading auth.enabled")?;
297297 if !enabled {
298298 return Ok(None);
299299 }
300 let value: serde_json::Value = figment
301 .extract_inner("identity")
302 .context("loading [identity] configuration")?;
300 let value: serde_json::Value = figment.extract().context("loading configuration")?;
303301 Ok(Some(auth::config::Config::from_value(value)))
304302}
305303
Modified charts/core/templates/NOTES.txt View charts/core/templates/NOTES.txt
@@ -16,4 +16,4 @@
1616 - secret.env 中 APP_OAUTH__RPC_KEY / APP_INTERNAL__SERVICE_KEY / APP_POW__MAC_KEY / APP_AUTH__TOTP_ENCRYPTION_KEY 必填;
1717 生产环境建议 --set secret.existingSecret=<name> 并用外部 Secret 管理。
1818 - KenApi 不再持有 bare 仓库;clone/push 由 KenShell 提供,需配置 kenshell.endpoint。
19 - 磁盘上的 config/local.toml 含真实密钥,部署前请轮换。
19 - 磁盘上的 config/local/ 含真实密钥,部署前请轮换。
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -31,7 +31,7 @@
3131 # 读写库/副本、配额等其余段均可在此覆盖,例如:
3232 # APP_DATABASE__REPLICAS: '[{url="postgres://readonly@pg:5432/gitdata"}]'
3333 # Web Push:默认关闭;开启前必须同时提供 APP_PUSH__VAPID_PUBLIC_KEY(此处)
34 # 与 APP_PUSH__VAPID_PRIVATE_KEY(secret.env)。密钥生成见 config/default.toml。
34 # 与 APP_PUSH__VAPID_PRIVATE_KEY(secret.env)。密钥生成见 config/default/。
3535 APP_PUSH__ENABLED: "false"
3636 APP_PUSH__VAPID_PUBLIC_KEY: ""
3737 APP_PUSH__VAPID_SUBJECT: "mailto:ops@example.com"
Added config/default/apihost.json View config/default/apihost.json
@@ -0,0 +1,5 @@
1{
2 "enabled": true,
3 "bind_addr": "0.0.0.0",
4 "port": 7051
5}
Added config/default/apps.json View config/default/apps.json
@@ -0,0 +1,17 @@
1[
2 {
3 "id": "api",
4 "audience": "api",
5 "active_key_id": "app-example-1",
6 "keys": [
7 {
8 "key_id": "app-example-1",
9 "algorithm": "rsa_oaep256_a256_gcm",
10 "enabled": true,
11 "public_key": {
12 "file": "config/api/keys/app-example-1_public.pem"
13 }
14 }
15 ]
16 }
17]
Added config/default/auth.json View config/default/auth.json
@@ -0,0 +1,80 @@
1{
2 "password_policy": {
3 "min_length": 8,
4 "require_upper": true,
5 "require_lower": true,
6 "require_digit": true,
7 "require_symbol": false
8 },
9 "enabled": true,
10 "verify_code_ttl_secs": 600,
11 "verify_code_len": 6,
12 "reset_token_ttl_secs": 1800,
13 "reset_token_bytes": 32,
14 "email_code_rate_limit_secs": 60,
15 "max_verify_attempts": 5,
16 "min_age": 13,
17 "totp_encryption_key": "REPLACE_WITH_TOTP_ENCRYPTION_KEY_BASE64_32_BYTES",
18 "agreements": [
19 {
20 "key": "terms",
21 "version": "0.1.0",
22 "title": "Terms of Service",
23 "content_md": "Placeholder terms of service."
24 },
25 {
26 "key": "privacy",
27 "version": "0.1.0",
28 "title": "Privacy Policy",
29 "content_md": "Placeholder privacy policy."
30 }
31 ],
32 "oauth": {
33 "public_url": "https://oauth.gitdata.ai",
34 "rpc_key": "REPLACE_WITH_RPC_KEY_AT_LEAST_32_CHARS",
35 "socket_origins": [
36 "https://gitdata.ai"
37 ],
38 "clients": [
39 {
40 "client_id": "api",
41 "name": "GitDataAi API",
42 "kind": "public",
43 "redirect_uris": [
44 "https://gitdata.ai/oauth/callback"
45 ],
46 "post_logout_redirect_uris": [
47 "https://gitdata.ai/"
48 ],
49 "scopes": [
50 "openid",
51 "user_name",
52 "ssh_key",
53 "access_key",
54 "assets",
55 "email",
56 "profile",
57 "theme",
58 "guild",
59 "event",
60 "avatar"
61 ]
62 },
63 {
64 "client_id": "api-service",
65 "name": "GitDataAi Internal Service",
66 "kind": "confidential",
67 "enabled": false,
68 "secret": "REPLACE_WITH_CLIENT_SECRET_AT_LEAST_32_CHARS",
69 "scopes": [
70 "access_key",
71 "repo_read",
72 "repo_write"
73 ],
74 "service_scopes": [
75 "access_key"
76 ]
77 }
78 ]
79 }
80}
Added config/default/chat.json View config/default/chat.json
@@ -0,0 +1,21 @@
1{
2 "max_pagination": 200,
3 "default_pagination": 50,
4 "max_message_len": 4000,
5 "max_topic_len": 1024,
6 "max_thread_name_len": 255,
7 "max_reaction_emoji_len": 64,
8 "max_emoji_name_len": 64,
9 "max_bookmark_title_len": 255,
10 "max_webhook_name_len": 100,
11 "max_mentions_per_message": 20,
12 "typing_ttl_secs": 5,
13 "presence_ttl_secs": 300,
14 "ws": {
15 "heartbeat_secs": 30
16 },
17 "upload": {
18 "max_size": 104857600,
19 "presign_ttl_secs": 3600
20 }
21}
Added config/default/database.json View config/default/database.json
@@ -0,0 +1,19 @@
1{
2 "url": "postgres://postgres:postgres@localhost:5432/gitdata",
3 "host": "",
4 "port": 5432,
5 "username": "postgres",
6 "password": "",
7 "database": "gitdata",
8 "schema_search_path": "public",
9 "application_name": "gitdata",
10 "ssl_mode": "prefer",
11 "max_connections": 16,
12 "min_connections": 2,
13 "connect_timeout_secs": 10,
14 "idle_timeout_secs": 600,
15 "acquire_timeout_secs": 10,
16 "max_lifetime_secs": 1800,
17 "sqlx_logging": false,
18 "replicas": []
19}
Added config/default/email.json View config/default/email.json
@@ -0,0 +1,8 @@
1{
2 "default_locale": "en",
3 "workers": 4,
4 "capacity": 1024,
5 "enqueue_timeout_ms": 5000,
6 "retry_delay_ms": 2000,
7 "max_attempts": 3
8}
Added config/default/git.json View config/default/git.json
@@ -0,0 +1,28 @@
1{
2 "repo_root": "/opt/gitdata/repos",
3 "objects_root": "/opt/gitdata/objects",
4 "max_blob_size": 104857600,
5 "git_command_timeout_secs": 300,
6 "git_rpc_timeout_secs": 3600,
7 "base_url": "http://localhost:7051",
8 "http_clone_base_url": "",
9 "ssh_clone_host": "",
10 "ssh_clone_port": 0,
11 "ssh_clone_user": "git",
12 "graphql_complexity_limit": 1000,
13 "graphql_introspection": true,
14 "kenshell_endpoint": "http://127.0.0.1:7013",
15 "kenshell_rpc_key": "dev-kenshell-rpc-key-change-in-production",
16 "kenshell_timeout_secs": 3600,
17 "ssh": {
18 "bind_addr": "0.0.0.0",
19 "port": 7053,
20 "host_key_path": "/opt/gitdata/ssh_host_ed25519",
21 "banner": "",
22 "max_sessions": 64,
23 "auth_rejection_time": 2,
24 "inactivity_timeout": 300,
25 "keepalive_interval": 30,
26 "keepalive_max": 3
27 }
28}
Added config/default/internal.json View config/default/internal.json
@@ -0,0 +1,3 @@
1{
2 "service_key": ""
3}
Added config/default/issues.json View config/default/issues.json
@@ -0,0 +1,20 @@
1{
2 "max_pagination": 100,
3 "default_pagination": 20,
4 "max_board_name_len": 100,
5 "max_board_prefix_len": 10,
6 "max_title_len": 255,
7 "max_body_len": 65535,
8 "max_comment_len": 65535,
9 "attachment": {
10 "max_size": 26214400,
11 "presign_ttl_secs": 3600,
12 "max_pending_per_user": 50,
13 "max_label_name_len": 50,
14 "max_label_color_len": 7,
15 "max_milestone_title_len": 100,
16 "max_milestone_description_len": 1024,
17 "max_reaction_emoji_len": 32,
18 "max_mentions_per_message": 20
19 }
20}
Added config/default/jwt.json View config/default/jwt.json
@@ -0,0 +1,7 @@
1{
2 "issuer": "https://oauth.gitdata.ai",
3 "leeway_secs": 30,
4 "key_id": "k1",
5 "private_key": "REPLACE_WITH_JWT_ED25519_PRIVATE_KEY_BASE64URL",
6 "retired": []
7}
Added config/default/livekit.json View config/default/livekit.json
@@ -0,0 +1,14 @@
1{
2 "enabled": false,
3 "url": "ws://127.0.0.1:7880",
4 "api_url": "http://127.0.0.1:7880",
5 "api_key": "",
6 "api_secret": "",
7 "token_ttl_secs": 7200,
8 "empty_timeout_secs": 300,
9 "departure_timeout_secs": 20,
10 "default_max_participants": 50,
11 "presence_ttl_secs": 43200,
12 "hand_ttl_secs": 3600,
13 "recording_enabled": false
14}
Added config/default/oauth.json View config/default/oauth.json
@@ -0,0 +1,32 @@
1{
2 "enabled": false,
3 "issuer": "http://127.0.0.1:7021",
4 "client_id": "gitdata-web",
5 "client_secret": "",
6 "redirect_uri": "http://localhost:5173/api/v1/auth/callback",
7 "redirect_uri_hosts": [
8 "127.0.0.1:5173",
9 "localhost:5173",
10 "127.0.0.1:7041",
11 "localhost:7041"
12 ],
13 "token_endpoint": "",
14 "jwks_url": "",
15 "audience": "gitdata.ai",
16 "grpc_endpoint": "http://127.0.0.1:7022",
17 "rpc_key": "",
18 "session_cookie": "gitdata_session",
19 "refresh_cookie": "gitdata_refresh",
20 "cookie_domain": "",
21 "cookie_path": "/",
22 "cookie_secure": false,
23 "cookie_same_site": "lax",
24 "access_ttl_secs": 600,
25 "refresh_cookie_max_age_secs": 2592000,
26 "recheck_secs": 60,
27 "leeway_secs": 30,
28 "jwks_cache_secs": 3600,
29 "jwks_min_refresh_secs": 30,
30 "http_timeout_ms": 2000,
31 "chat_audience": "chat.gitdata.ai"
32}
Added config/default/pow.json View config/default/pow.json
@@ -0,0 +1,6 @@
1{
2 "difficulty": 16,
3 "max_difficulty": 24,
4 "expires_secs": 60,
5 "mac_key": ""
6}
Added config/default/push.json View config/default/push.json
@@ -0,0 +1,11 @@
1{
2 "enabled": false,
3 "vapid_public_key": "",
4 "vapid_private_key": "",
5 "vapid_subject": "mailto:ops@example.com",
6 "workers": 2,
7 "max_attempts": 3,
8 "retry_backoff_base_secs": 30,
9 "request_timeout_secs": 10,
10 "ttl_secs": 86400
11}
Added config/default/quota.json View config/default/quota.json
@@ -0,0 +1,10 @@
1{
2 "space_create_per_day": 10,
3 "room_create_per_day": 50,
4 "issue_create_per_day": 300,
5 "pull_create_per_day": 200,
6 "comment_create_per_day": 1000,
7 "dm_open_per_day": 100,
8 "webhook_create_per_day": 20,
9 "space_join_request_per_day": 20
10}
Added config/default/redis.json View config/default/redis.json
@@ -0,0 +1,11 @@
1{
2 "url": "redis://localhost:6379",
3 "mode": "standalone",
4 "database": 0,
5 "pool_size": 8,
6 "connection_timeout_secs": 2,
7 "username": "",
8 "password": "",
9 "key_prefix": "oauth",
10 "default_ttl_secs": 300
11}
Added config/default/rocketmq.json View config/default/rocketmq.json
@@ -0,0 +1,7 @@
1{
2 "name_server": "127.0.0.1:9876",
3 "producer_group": "gitdata",
4 "topic_prefix": "gitdata",
5 "send_timeout_ms": 5000,
6 "max_message_bytes": 4194304
7}
Added config/default/s3.json View config/default/s3.json
@@ -0,0 +1,10 @@
1{
2 "bucket": "",
3 "region": "us-east-1",
4 "endpoint": "",
5 "public_endpoint": "",
6 "access_key_id": "",
7 "secret_access_key": "",
8 "path_style": false,
9 "request_timeout_secs": 30
10}
Added config/default/server.json View config/default/server.json
@@ -0,0 +1,4 @@
1{
2 "http_addr": "127.0.0.1:7021",
3 "grpc_addr": "127.0.0.1:7022"
4}
Added config/default/service_auth.json View config/default/service_auth.json
@@ -0,0 +1,10 @@
1{
2 "service_id": "oauth",
3 "key": "REPLACE_WITH_OAUTH_SERVICE_SECRET_32_BYTES_MINIMUM",
4 "peers": {
5 "api": "sha256:REPLACE_WITH_API_SERVICE_SECRET_SHA256_HEX",
6 "socket": "sha256:REPLACE_WITH_SOCKET_SERVICE_SECRET_SHA256_HEX",
7 "oauth": "sha256:REPLACE_WITH_OAUTH_SERVICE_SECRET_SHA256_HEX",
8 "git": "sha256:REPLACE_WITH_GIT_SERVICE_SECRET_SHA256_HEX"
9 }
10}
Added config/default/session.json View config/default/session.json
@@ -0,0 +1,9 @@
1{
2 "cookie_name": "session",
3 "max_age": 2592000,
4 "anonymous_max_age": 300,
5 "path": "/",
6 "secure": false,
7 "same_site": "Lax",
8 "trust_proxy": false
9}
Added config/default/smtp.json View config/default/smtp.json
@@ -0,0 +1,8 @@
1{
2 "host": "127.0.0.1",
3 "port": 1025,
4 "security": "plain",
5 "from": "no-reply@gitdata.local",
6 "timeout_seconds": 30,
7 "max_attachment_bytes": 26214400
8}
Added config/default/space.json View config/default/space.json
@@ -0,0 +1,13 @@
1{
2 "max_pagination": 100,
3 "default_pagination": 20,
4 "max_space_name_len": 64,
5 "max_space_desc_len": 512,
6 "max_group_name_len": 64,
7 "max_alias_name_len": 64,
8 "max_invite_email_len": 254,
9 "allow_self_join_groups": false,
10 "join_request_cooldown_hours": 24,
11 "max_dir_nodes": 5000,
12 "deletion_grace_days": 14
13}
Added config/default/user.json View config/default/user.json
@@ -0,0 +1,7 @@
1{
2 "deletion_grace_days": 14,
3 "email_code_rate_limit_secs": 60,
4 "email_verify_code_ttl_secs": 900,
5 "email_verify_max_attempts": 5,
6 "email_verify_code_len": 6
7}
Added config/default/web.json View config/default/web.json
@@ -0,0 +1,34 @@
1{
2 "web_base_url": "http://localhost:7051",
3 "cors_allow_origins": [
4 "https://git.example.com"
5 ],
6 "cors_allow_methods": [
7 "GET",
8 "POST",
9 "PUT",
10 "PATCH",
11 "DELETE",
12 "HEAD",
13 "OPTIONS"
14 ],
15 "cors_allow_headers": [
16 "accept",
17 "authorization",
18 "content-type",
19 "x-csrf-token",
20 "x-log-id",
21 "x-request-id"
22 ],
23 "cors_expose_headers": [
24 "cache-control",
25 "content-disposition",
26 "etag",
27 "location",
28 "retry-after",
29 "x-log-id",
30 "x-request-id"
31 ],
32 "cors_allow_credentials": false,
33 "cors_max_age_secs": 600
34}
Added config/prod/.gitkeep View config/prod/.gitkeep

No content changes.

Deleted config/default.toml
@@ -1,462 +0,0 @@
1[s3]
2bucket = ""
3region = "us-east-1"
4endpoint = ""
5public_endpoint = ""
6access_key_id = ""
7secret_access_key = ""
8path_style = false
9request_timeout_secs = 30
10
11[redis]
12url = "redis://localhost:6379"
13mode = "standalone"
14database = 0
15pool_size = 8
16connection_timeout_secs = 2
17username = ""
18password = ""
19
20[database]
21url = "postgres://postgres:postgres@localhost:5432/gitdata"
22host = ""
23port = 5432
24username = "postgres"
25password = ""
26database = "gitdata"
27schema_search_path = "public"
28application_name = "gitdata"
29ssl_mode = "prefer"
30max_connections = 16
31min_connections = 2
32connect_timeout_secs = 10
33idle_timeout_secs = 600
34acquire_timeout_secs = 10
35max_lifetime_secs = 1800
36sqlx_logging = false
37replicas = []
38
39[pow]
40difficulty = 16
41max_difficulty = 24
42expires_secs = 60
43mac_key = ""
44
45[auth.password_policy]
46min_length = 8
47require_upper = true
48require_lower = true
49require_digit = true
50require_symbol = false
51
52[auth]
53verify_code_ttl_secs = 600
54verify_code_len = 6
55reset_token_ttl_secs = 1800
56reset_token_bytes = 32
57email_code_rate_limit_secs = 60
58max_verify_attempts = 5
59min_age = 13
60totp_encryption_key = ""
61
62[[auth.agreements]]
63key = "terms"
64version = "0.1.0"
65title = "Terms of Service"
66content_md = "Placeholder terms of service."
67
68[[auth.agreements]]
69key = "privacy"
70version = "0.1.0"
71title = "Privacy Policy"
72content_md = "Placeholder privacy policy."
73
74[web]
75web_base_url = "http://localhost:7051"
76cors_allow_origins = []
77cors_allow_credentials = false
78
79[user]
80deletion_grace_days = 14
81email_code_rate_limit_secs = 60
82email_verify_code_ttl_secs = 900
83email_verify_max_attempts = 5
84email_verify_code_len = 6
85
86[quota]
87space_create_per_day = 10
88room_create_per_day = 50
89issue_create_per_day = 300
90pull_create_per_day = 200
91comment_create_per_day = 1000
92dm_open_per_day = 100
93webhook_create_per_day = 20
94space_join_request_per_day = 20
95
96[space]
97max_pagination = 100
98default_pagination = 20
99max_space_name_len = 64
100max_space_desc_len = 512
101max_group_name_len = 64
102max_alias_name_len = 64
103max_invite_email_len = 254
104allow_self_join_groups = false
105join_request_cooldown_hours = 24
106max_dir_nodes = 5000
107deletion_grace_days = 14
108
109[chat]
110max_pagination = 200
111default_pagination = 50
112max_message_len = 4000
113max_topic_len = 1024
114max_thread_name_len = 255
115max_reaction_emoji_len = 64
116max_emoji_name_len = 64
117max_bookmark_title_len = 255
118max_webhook_name_len = 100
119max_mentions_per_message = 20
120typing_ttl_secs = 5
121presence_ttl_secs = 300
122
123[chat.ws]
124heartbeat_secs = 30
125
126[chat.upload]
127max_size = 104857600
128presign_ttl_secs = 3600
129
130[issues]
131max_pagination = 100
132default_pagination = 20
133max_board_name_len = 100
134max_board_prefix_len = 10
135max_title_len = 255
136max_body_len = 65535
137max_comment_len = 65535
138
139[issues.attachment]
140max_size = 26214400
141presign_ttl_secs = 3600
142max_pending_per_user = 50
143max_label_name_len = 50
144max_label_color_len = 7
145max_milestone_title_len = 100
146max_milestone_description_len = 1024
147max_reaction_emoji_len = 32
148max_mentions_per_message = 20
149
150
151[git]
152repo_root = "/opt/gitdata/repos"
153objects_root = "/opt/gitdata/objects"
154max_blob_size = 104857600
155git_command_timeout_secs = 300
156git_rpc_timeout_secs = 3600
157base_url = "http://localhost:7051"
158http_clone_base_url = ""
159ssh_clone_host = ""
160ssh_clone_port = 0
161ssh_clone_user = "git"
162graphql_complexity_limit = 1000
163graphql_introspection = true
164kenshell_endpoint = "http://127.0.0.1:7013"
165kenshell_rpc_key = "dev-kenshell-rpc-key-change-in-production"
166kenshell_timeout_secs = 3600
167
168[git.ssh]
169bind_addr = "0.0.0.0"
170port = 7053
171host_key_path = "/opt/gitdata/ssh_host_ed25519"
172banner = ""
173max_sessions = 64
174auth_rejection_time = 2
175inactivity_timeout = 300
176keepalive_interval = 30
177keepalive_max = 3
178
179[email]
180default_locale = "en"
181
182[email.rpc]
183endpoint = ""
184rpc_key = ""
185timeout_ms = 2000
186
187[apihost]
188enabled = true
189bind_addr = "0.0.0.0"
190port = 7051
191
192[internal]
193# /api/v1/internal/* 的服务密钥,至少 32 字符;留空则拒绝所有 internal 请求。
194service_key = ""
195
196[oauth]
197enabled = false
198issuer = "http://127.0.0.1:7021"
199client_id = "gitdata-web"
200client_secret = ""
201redirect_uri = "http://localhost:5173/api/v1/auth/callback"
202
203# 本机同时跑两套前端:vite dev(5173) 和网关托管的构建产物(7041)。
204# 请求 Host 命中这里时,回调地址按该主机改写,两套都能各自登录。
205redirect_uri_hosts = ["127.0.0.1:5173", "localhost:5173", "127.0.0.1:7041", "localhost:7041"]
206token_endpoint = ""
207jwks_url = ""
208audience = "gitdata.ai"
209grpc_endpoint = "http://127.0.0.1:7022"
210rpc_key = ""
211session_cookie = "gitdata_session"
212refresh_cookie = "gitdata_refresh"
213cookie_domain = ""
214cookie_path = "/"
215cookie_secure = false
216cookie_same_site = "lax"
217access_ttl_secs = 600
218refresh_cookie_max_age_secs = 2592000
219recheck_secs = 60
220leeway_secs = 30
221jwks_cache_secs = 3600
222jwks_min_refresh_secs = 30
223http_timeout_ms = 2000
224chat_audience = "chat.gitdata.ai"
225[githost]
226enabled = true
227http_bind_addr = "0.0.0.0"
228http_port = 7052
229
230# KenApi 的领域事件只走 RocketMQ:nameserver 不可达时进程启动失败。
231# 不提供 Redis pub/sub 兜底。
232[rocketmq]
233name_server = "127.0.0.1:9876"
234producer_group = "gitdata"
235topic_prefix = "gitdata"
236send_timeout_ms = 5000
237max_message_bytes = 4194304
238
239[push]
240enabled = false
241vapid_public_key = ""
242vapid_private_key = ""
243vapid_subject = "mailto:ops@example.com"
244workers = 2
245max_attempts = 3
246retry_backoff_base_secs = 30
247request_timeout_secs = 10
248ttl_secs = 86400
249
250[livekit]
251enabled = false
252url = "ws://127.0.0.1:7880"
253api_url = "http://127.0.0.1:7880"
254api_key = ""
255api_secret = ""
256token_ttl_secs = 7200
257empty_timeout_secs = 300
258departure_timeout_secs = 20
259default_max_participants = 50
260presence_ttl_secs = 43200
261hand_ttl_secs = 3600
262recording_enabled = false
263
264[identity]
265enabled = true
266
267[[identity.apps]]
268id = "api"
269audience = "api"
270active_key_id = "app-example-1"
271
272[[identity.apps.keys]]
273key_id = "app-example-1"
274algorithm = "rsa_oaep256_a256_gcm"
275enabled = true
276
277[identity.apps.keys.public_key]
278file = "config/api/keys/app-example-1_public.pem"
279
280[identity.auth]
281verify_code_ttl_secs = 600
282verify_code_len = 6
283reset_token_ttl_secs = 1800
284reset_token_bytes = 32
285email_code_rate_limit_secs = 60
286max_verify_attempts = 5
287min_age = 13
288totp_encryption_key = "REPLACE_WITH_TOTP_ENCRYPTION_KEY_BASE64_32_BYTES"
289
290[identity.auth.oauth]
291public_url = "https://oauth.gitdata.ai"
292rpc_key = "REPLACE_WITH_RPC_KEY_AT_LEAST_32_CHARS"
293socket_origins = [
294 "https://gitdata.ai",
295]
296
297[[identity.auth.oauth.clients]]
298client_id = "api"
299name = "GitDataAi API"
300kind = "public"
301redirect_uris = [
302 "https://gitdata.ai/oauth/callback",
303]
304post_logout_redirect_uris = [
305 "https://gitdata.ai/",
306]
307scopes = [
308 "openid",
309 "user_name",
310 "ssh_key",
311 "access_key",
312 "assets",
313 "email",
314 "profile",
315 "theme",
316 "guild",
317 "event",
318 "avatar",
319]
320
321[[identity.auth.oauth.clients]]
322client_id = "api-service"
323name = "GitDataAi Internal Service"
324kind = "confidential"
325enabled = false
326secret = "REPLACE_WITH_CLIENT_SECRET_AT_LEAST_32_CHARS"
327scopes = [
328 "access_key",
329 "repo_read",
330 "repo_write",
331]
332service_scopes = [
333 "access_key",
334]
335
336[identity.auth.password_policy]
337min_length = 8
338require_upper = true
339require_lower = true
340require_digit = true
341require_symbol = false
342
343[[identity.auth.agreements]]
344key = "terms"
345version = "0.1.0"
346title = "Terms of Service"
347content_md = "Placeholder terms of service."
348
349[[identity.auth.agreements]]
350key = "privacy"
351version = "0.1.0"
352title = "Privacy Policy"
353content_md = "Placeholder privacy policy."
354
355[identity.jwt]
356issuer = "https://oauth.gitdata.ai"
357leeway_secs = 30
358key_id = "k1"
359private_key = "REPLACE_WITH_JWT_ED25519_PRIVATE_KEY_BASE64URL"
360retired = []
361
362[identity.service_auth]
363service_id = "oauth"
364key = "REPLACE_WITH_OAUTH_SERVICE_SECRET_32_BYTES_MINIMUM"
365
366[identity.service_auth.peers]
367api = "sha256:REPLACE_WITH_API_SERVICE_SECRET_SHA256_HEX"
368socket = "sha256:REPLACE_WITH_SOCKET_SERVICE_SECRET_SHA256_HEX"
369oauth = "sha256:REPLACE_WITH_OAUTH_SERVICE_SECRET_SHA256_HEX"
370git = "sha256:REPLACE_WITH_GIT_SERVICE_SECRET_SHA256_HEX"
371
372[identity.rocketmq]
373name_server = "127.0.0.1:9876"
374producer_group = "kenoauth"
375topic_prefix = "oauth"
376send_timeout_ms = 5000
377max_message_bytes = 4194304
378
379[identity.email]
380workers = 4
381capacity = 1024
382enqueue_timeout_ms = 5000
383retry_delay_ms = 2000
384max_attempts = 3
385
386[identity.pow]
387difficulty = 16
388max_difficulty = 24
389expires_secs = 60
390mac_key = "REPLACE_WITH_POW_MAC_KEY_BASE64_32_BYTES"
391
392[identity.redis]
393host = "127.0.0.1"
394port = 6379
395db = 0
396type = "Standard"
397nodes = []
398tls = false
399key_prefix = "oauth"
400default_ttl_secs = 300
401
402[identity.redis.pool]
403max_size = 16
404wait_timeout_secs = 5
405create_timeout_secs = 5
406recycle_timeout_secs = 5
407
408[identity.server]
409http_addr = "127.0.0.1:7021"
410grpc_addr = "127.0.0.1:7022"
411
412[identity.session]
413cookie_name = "session"
414max_age = 2592000
415anonymous_max_age = 300
416path = "/"
417secure = false
418same_site = "Lax"
419http_only = true
420trust_proxy = false
421
422[identity.smtp]
423host = "127.0.0.1"
424port = 1025
425security = "plain"
426from = "no-reply@gitdata.local"
427timeout_seconds = 30
428max_attachment_bytes = 26214400
429
430[identity.web]
431web_base_url = "https://git.example.com"
432cors_allow_origins = [
433 "https://git.example.com",
434]
435cors_allow_methods = [
436 "GET",
437 "POST",
438 "PUT",
439 "PATCH",
440 "DELETE",
441 "HEAD",
442 "OPTIONS",
443]
444cors_allow_headers = [
445 "accept",
446 "authorization",
447 "content-type",
448 "x-csrf-token",
449 "x-log-id",
450 "x-request-id",
451]
452cors_expose_headers = [
453 "cache-control",
454 "content-disposition",
455 "etag",
456 "location",
457 "retry-after",
458 "x-log-id",
459 "x-request-id",
460]
461cors_allow_credentials = false
462cors_max_age_secs = 600
Modified deploy/compose.yaml View deploy/compose.yaml
@@ -15,10 +15,10 @@
1515# docker exec postgres psql -U postgres -c 'CREATE DATABASE gitdata'
1616# docker run --rm --network host -v "$PWD/config:/app/config:ro" \
1717# --entrypoint /usr/local/bin/kenapi kenapi:local migrate
18# 2. S3 桶(RustFS):gitdata(与 config/local.toml 的 [s3].bucket 一致)
18# 2. S3 桶(RustFS):gitdata(与 config/local/s3.json 的 bucket 一致)
1919# 3. [identity.auth.oauth] 配置 gitdata-web 客户端;OAuth 已内嵌,无需独立服务。
2020#
21# 配置:镜像内 config/default.toml + 挂载的 config/local.toml(本机地址与密钥)。
21# 配置:镜像内 config/default/ + 挂载的 config/local/(本机地址与密钥)。
2222
2323name: kenapi
2424
@@ -41,7 +41,7 @@
4141 # 进程内没有家目录,避免各 SDK 往 / 下写缓存。
4242 HOME: /tmp
4343 volumes:
44 # default.toml 已烘进镜像;local.toml 提供本机数据库/Redis/S3/OAuth 地址。
44 # config/default/ 已烘进镜像;config/local/ 提供本机数据库/Redis/S3/OAuth 地址。
4545 # 整个 config 目录只读挂载,顺便保留未来新增的本地配置文件。
4646 - ../config:/app/config:ro
4747 stop_grace_period: 120s
Modified docs/oauth.md View docs/oauth.md
@@ -692,7 +692,7 @@
692692| `charts/core/values.yaml` 的 `adminhost` 段 | — |
693693| `charts/prod/values/prod.yaml` 的 `adminhost` 段 | — |
694694| `lib/config/src/admin.rs` | — |
695| `config/default.toml` 的 `[admin]` | — |
695| `config/default/` | — |
696696| `apihost.admin_uids` | — |
697697| `lib/auth/src/share/guard.rs::admin_guard` | — |
698698| `scripts/dev.ps1`、`scripts/dev.sh` 中 adminhost | — |
@@ -1033,7 +1033,7 @@
10331033 depends_on: [postgres, redis, nats]
10341034```
10351035
1036`config/local.toml` 增 `[oauth]`、`[internal]`;`config/oauth.yaml` 单独维护(gitignore)。
1036`config/local/oauth.json`、`config/local/internal.json` 单独维护(gitignore)。
10371037
10381038### 12.3 Helm
10391039
Modified lib/auth/src/runtime/mod.rs View lib/auth/src/runtime/mod.rs
@@ -63,7 +63,7 @@
6363 let email_pool: crate::config::email::EmailPoolConfig = config.section_or_default()?;
6464
6565 auth_config
66 .validate()
66 .validate_auth()
6767 .map_err(anyhow::Error::msg)
6868 .context("invalid auth configuration")?;
6969 pow_config
Modified lib/auth/src/server/grpc/access_key.rs View lib/auth/src/server/grpc/access_key.rs
@@ -11,9 +11,9 @@
1111use uuid::Uuid;
1212
1313use crate::server::AuthSvc;
14use crate::server::access_token::types::TOKEN_HASH_KEY_ID;
1514use crate::server::errors::AuthError;
1615use crate::server::grpc::{context, convert, error, store};
16use crate::server::http::access_token::types::TOKEN_HASH_KEY_ID;
1717
1818#[derive(Clone)]
1919pub struct AccessKeyApi {
@@ -230,7 +230,7 @@
230230 .svc
231231 .create_access_token_for_user(
232232 request.user_id,
233 crate::server::access_token::types::AccessTokenCreateRequest {
233 crate::server::http::access_token::types::AccessTokenCreateRequest {
234234 name: request.name,
235235 description: Some(request.description).filter(|text| !text.is_empty()),
236236 scope,
Modified lib/auth/src/server/grpc/authorization.rs View lib/auth/src/server/grpc/authorization.rs
@@ -11,9 +11,9 @@
1111use tonic::{Request, Response, Status};
1212
1313use crate::server::AuthSvc;
14use crate::server::access_token::types::ALLOWED_APP_SCOPES;
1514use crate::server::errors::{AuthError, AuthResult};
1615use crate::server::grpc::{agreement, context, error, store};
16use crate::server::http::access_token::types::ALLOWED_APP_SCOPES;
1717
1818const MAX_BATCH_CAPABILITIES: usize = 32;
1919const ADMIN_CACHE_TTL_SECS: u64 = 300;
Modified lib/auth/src/server/grpc/cache_maintenance.rs View lib/auth/src/server/grpc/cache_maintenance.rs
@@ -11,7 +11,7 @@
1111use crate::server::AuthSvc;
1212use crate::server::errors::AuthError;
1313use crate::server::grpc::{context, error, store};
14use crate::server::me::types::MeResponse;
14use crate::server::http::me::types::MeResponse;
1515
1616const USER_SCOPED_PREFIXES: [&str; 3] = ["auth:me:", "auth:admin:", "auth:rate:mfa:"];
1717
Modified lib/auth/src/server/grpc/client.rs View lib/auth/src/server/grpc/client.rs
@@ -13,9 +13,9 @@
1313};
1414use crate::protos::auth::internal::v1::*;
1515use crate::server::AuthSvc;
16use crate::server::client::{ClientRecord, ClientSpec};
1716use crate::server::errors::AuthError;
1817use crate::server::grpc::{context, convert, error};
18use crate::server::oauth::client::{ClientRecord, ClientSpec};
1919
2020#[derive(Clone)]
2121pub struct ClientApi {
Modified lib/auth/src/server/grpc/identity.rs View lib/auth/src/server/grpc/identity.rs
@@ -12,10 +12,10 @@
1212use tonic::{Request, Response, Status};
1313
1414use crate::server::AuthSvc;
15use crate::server::access_token::types::AccessTokenAuthRequest;
1615use crate::server::errors::{AuthError, AuthResult};
1716use crate::server::grpc::{context, convert, error, service_token, store};
18use crate::server::ssh_key::service;
17use crate::server::http::access_token::types::AccessTokenAuthRequest;
18use crate::server::http::ssh_key::service;
1919
2020fn ensure_authorized(
2121 principal: &Principal,
@@ -356,7 +356,7 @@
356356 if let Err(err) = store::drop_user_session_states(self.svc.cache(), user.id, keep).await {
357357 tracing::warn!(%err, user_id = user.id, "failed to drop revoked sessions");
358358 }
359 crate::server::me::service::invalidate_me_cache(self.svc.cache(), user.id).await;
359 crate::server::http::me::service::invalidate_me_cache(self.svc.cache(), user.id).await;
360360
361361 store::audit(
362362 db,
Modified lib/auth/src/server/grpc/organization.rs View lib/auth/src/server/grpc/organization.rs
@@ -34,12 +34,12 @@
3434use crate::server::AuthSvc;
3535use crate::server::errors::{AuthError, AuthResult};
3636use crate::server::grpc::{context, convert, error, store};
37use crate::server::organization::types::{
37use crate::server::http::organization::types::{
3838 AddMemberBody, CreateOrganizationBody, CreateRoleBody, JoinRequestSummary, JoinRuleSummary,
3939 MAX_BATCH_PERMISSIONS, PermissionGrant, SetMemberRoleBody, SetRolePermissionsBody,
4040 UpdateBanBody, UpdateMemberBody, UpdateOrganizationBody,
4141};
42use crate::server::organization::{member, role, service};
42use crate::server::http::organization::{member, role, service};
4343
4444fn uuid_of(value: &str, request_id: Option<&str>) -> Result<Uuid, Status> {
4545 Uuid::parse_str(value.trim())
@@ -98,7 +98,9 @@
9898 }
9999}
100100
101fn org_info(summary: crate::server::organization::types::OrganizationSummary) -> OrganizationInfo {
101fn org_info(
102 summary: crate::server::http::organization::types::OrganizationSummary,
103) -> OrganizationInfo {
102104 OrganizationInfo {
103105 id: summary.id.to_string(),
104106 slug: summary.slug,
@@ -125,7 +127,7 @@
125127}
126128
127129fn member_info(
128 summary: crate::server::organization::types::MemberSummary,
130 summary: crate::server::http::organization::types::MemberSummary,
129131) -> OrganizationMemberInfo {
130132 OrganizationMemberInfo {
131133 id: summary.id.to_string(),
@@ -161,7 +163,9 @@
161163 }
162164}
163165
164fn role_info(summary: crate::server::organization::types::RoleSummary) -> OrganizationRoleInfo {
166fn role_info(
167 summary: crate::server::http::organization::types::RoleSummary,
168) -> OrganizationRoleInfo {
165169 OrganizationRoleInfo {
166170 id: summary.id.to_string(),
167171 organization_id: summary.organization_id.to_string(),
@@ -192,7 +196,7 @@
192196}
193197
194198fn invite_info(
195 summary: crate::server::organization::types::InviteSummary,
199 summary: crate::server::http::organization::types::InviteSummary,
196200) -> OrganizationInviteInfo {
197201 OrganizationInviteInfo {
198202 id: summary.id.to_string(),
Modified lib/auth/src/server/grpc/session.rs View lib/auth/src/server/grpc/session.rs
@@ -271,8 +271,11 @@
271271 .exec(self.svc.db().write())
272272 .await
273273 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
274 crate::server::me::service::invalidate_me_cache(self.svc.cache(), request.user_id)
275 .await;
274 crate::server::http::me::service::invalidate_me_cache(
275 self.svc.cache(),
276 request.user_id,
277 )
278 .await;
276279 security_version = store::load_user(self.svc.db(), request.user_id)
277280 .await
278281 .map_err(|err| error::status(&err, request_id))?
@@ -327,7 +330,8 @@
327330 revoked_sessions = revoked.rows_affected as u32;
328331 }
329332
330 crate::server::me::service::invalidate_me_cache(self.svc.cache(), request.user_id).await;
333 crate::server::http::me::service::invalidate_me_cache(self.svc.cache(), request.user_id)
334 .await;
331335 store::audit(
332336 self.svc.db(),
333337 request.user_id,
Modified lib/auth/src/server/grpc/ssh_key.rs View lib/auth/src/server/grpc/ssh_key.rs
@@ -109,7 +109,7 @@
109109 request_id,
110110 ));
111111 }
112 crate::server::ssh_key::service::parse_public_key(public_key)
112 crate::server::http::ssh_key::service::parse_public_key(public_key)
113113 .map_err(|err| error::status(&err, request_id))?
114114 .1
115115 } else {
@@ -166,7 +166,7 @@
166166 .svc
167167 .add_ssh_key_for_user(
168168 request.user_id,
169 crate::server::ssh_key::types::SshKeyAddRequest {
169 crate::server::http::ssh_key::types::SshKeyAddRequest {
170170 name: request.name,
171171 description: Some(request.description).filter(|text| !text.is_empty()),
172172 public_key: request.public_key,
@@ -313,8 +313,11 @@
313313 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
314314
315315 if request.bump_security_version {
316 crate::server::me::service::invalidate_me_cache(self.svc.cache(), request.user_id)
317 .await;
316 crate::server::http::me::service::invalidate_me_cache(
317 self.svc.cache(),
318 request.user_id,
319 )
320 .await;
318321 }
319322
320323 if let Err(err) = store::audit(
Modified lib/auth/src/server/grpc/store.rs View lib/auth/src/server/grpc/store.rs
@@ -7,10 +7,10 @@
77use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
88use uuid::Uuid;
99
10use crate::server::access_token::types::ALLOWED_APP_SCOPES;
1110use crate::server::errors::{AuthError, AuthResult};
1211use crate::server::grpc::context::RequestMeta;
1312use crate::server::grpc::convert;
13use crate::server::http::access_token::types::ALLOWED_APP_SCOPES;
1414use crate::server::share::user as user_store;
1515
1616pub const MAX_BATCH_USER_IDS: usize = 500;
Modified lib/auth/src/server/grpc/token.rs View lib/auth/src/server/grpc/token.rs
@@ -11,9 +11,9 @@
1111use tonic::{Request, Response, Status};
1212
1313use crate::server::AuthSvc;
14use crate::server::access_token::types::{ALLOWED_APP_SCOPES, AccessTokenAuthRequest};
1514use crate::server::errors::AuthError;
1615use crate::server::grpc::{context, convert, error, service_token, store};
16use crate::server::http::access_token::types::{ALLOWED_APP_SCOPES, AccessTokenAuthRequest};
1717use crate::server::share::crypto;
1818
1919const REVOKED_PREFIX: &str = "auth:svc-token:revoked:";
Modified lib/auth/src/server/http/access_token/service.rs View lib/auth/src/server/http/access_token/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::models::users::user_events::UserEventType;
33use crate::models::users::{user_access_tokens, users};
44use crate::session::Session;
@@ -207,12 +207,10 @@
207207 expires_at: inserted.expires_at,
208208 };
209209 self.emit_labeled(
210 RuntimeEvent::Auth(AuthEvent::AccessKey {
211 operate: RestOperate::Create,
212 }),
210 label::AUTH_ACCESS_KEY_CREATE,
213211 Labels::new()
214 .with("user_id", user_id)
215 .with("request_id", request_id),
212 .with(label::USER_ID, user_id)
213 .with(label::REQUEST_ID, request_id),
216214 serde_json::json!({
217215 "id": response.id,
218216 "name": &response.name,
@@ -297,12 +295,10 @@
297295 .await?;
298296
299297 self.emit_labeled(
300 RuntimeEvent::Auth(AuthEvent::AccessKey {
301 operate: RestOperate::Create,
302 }),
298 label::AUTH_ACCESS_KEY_CREATE,
303299 Labels::new()
304 .with("user_id", user_id)
305 .with("request_id", &request_id),
300 .with(label::USER_ID, user_id)
301 .with(label::REQUEST_ID, &request_id),
306302 serde_json::json!({ "id": record.id, "rotated": true }),
307303 Some(request_id.as_str()),
308304 )
@@ -391,12 +387,10 @@
391387 .await?;
392388
393389 self.emit_labeled(
394 RuntimeEvent::Auth(AuthEvent::AccessKey {
395 operate: RestOperate::Delete,
396 }),
390 label::AUTH_ACCESS_KEY_DELETE,
397391 Labels::new()
398 .with("user_id", user_id)
399 .with("request_id", &request_id),
392 .with(label::USER_ID, user_id)
393 .with(label::REQUEST_ID, &request_id),
400394 serde_json::json!({
401395 "user_id": user_id,
402396 "token_id": record.id,
@@ -467,12 +461,10 @@
467461 };
468462 if touched.rows_affected > 0 {
469463 self.emit_labeled(
470 RuntimeEvent::Auth(AuthEvent::AccessKey {
471 operate: RestOperate::Used,
472 }),
464 label::AUTH_ACCESS_KEY_USED,
473465 Labels::new()
474 .with("user_id", response.user_id)
475 .with("request_id", &request_id),
466 .with(label::USER_ID, response.user_id)
467 .with(label::REQUEST_ID, &request_id),
476468 &response,
477469 Some(request_id.as_str()),
478470 )
Modified lib/auth/src/server/http/app_token/service.rs View lib/auth/src/server/http/app_token/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22
33use crate::server::AuthSvc;
44use crate::server::errors::{AuthError, AuthResult};
@@ -42,10 +42,8 @@
4242 expires_at: issue::timestamp(issued.expires_at, "app token")?,
4343 };
4444 self.emit_labeled(
45 RuntimeEvent::Auth(AuthEvent::AppToken {
46 operate: RestOperate::Create,
47 }),
48 Labels::new().with("user_id", user_id),
45 label::AUTH_APP_TOKEN_CREATE,
46 Labels::new().with(label::USER_ID, user_id),
4947 serde_json::json!({
5048 "app_id": &response.app_id,
5149 "audience": &response.audience,
Modified lib/auth/src/server/http/challenge/service.rs View lib/auth/src/server/http/challenge/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::session::Session;
33
44use crate::server::AuthSvc;
@@ -14,12 +14,10 @@
1414 ) -> AuthResult<ChallengeResponse> {
1515 let response = crate::server::pow::store::issue(&session, self.pow()).await?;
1616 self.emit_labeled(
17 RuntimeEvent::Auth(AuthEvent::Challenge {
18 operate: RestOperate::Create,
19 }),
17 label::AUTH_CHALLENGE_CREATE,
2018 Labels::new()
21 .with_opt("user_id", session.user_id().ok().flatten())
22 .with("request_id", &request_id),
19 .with_opt(label::USER_ID, session.user_id().ok().flatten())
20 .with(label::REQUEST_ID, &request_id),
2321 &response,
2422 Some(request_id.as_str()),
2523 )
@@ -37,12 +35,10 @@
3735 ) -> AuthResult<()> {
3836 crate::server::pow::store::verify(&session, self.cache(), proof, expected_payload).await?;
3937 self.emit_labeled(
40 RuntimeEvent::Auth(AuthEvent::Challenge {
41 operate: RestOperate::Used,
42 }),
38 label::AUTH_CHALLENGE_USED,
4339 Labels::new()
44 .with_opt("user_id", session.user_id().ok().flatten())
45 .with("request_id", &request_id),
40 .with_opt(label::USER_ID, session.user_id().ok().flatten())
41 .with(label::REQUEST_ID, &request_id),
4642 serde_json::json!({
4743 "user_id": session.user_id().ok().flatten(),
4844 }),
Modified lib/auth/src/server/http/login/service.rs View lib/auth/src/server/http/login/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::models::users::user_events::UserEventType;
33use crate::models::users::user_mfa::{self, MfaStatus, MfaType};
44use crate::models::users::users;
@@ -145,12 +145,10 @@
145145 attempts_reset(self.cache(), &attempts_key, Some(request_id.as_str())).await?;
146146 let response = self.complete_login(session, user, &request_id).await?;
147147 self.emit_labeled(
148 RuntimeEvent::Auth(AuthEvent::Login {
149 operate: RestOperate::Create,
150 }),
148 label::AUTH_LOGIN_CREATE,
151149 Labels::new()
152 .with("user_id", response.user_id)
153 .with("request_id", &request_id),
150 .with(label::USER_ID, response.user_id)
151 .with(label::REQUEST_ID, &request_id),
154152 serde_json::json!({
155153 "user_id": response.user_id,
156154 "username": &response.username,
@@ -224,12 +222,10 @@
224222 expires_in_secs: ttl,
225223 };
226224 self.emit_labeled(
227 RuntimeEvent::Auth(AuthEvent::VerificationToken {
228 operate: RestOperate::Create,
229 }),
225 label::AUTH_VERIFICATION_TOKEN_CREATE,
230226 Labels::new()
231 .with("user_id", user.id)
232 .with("request_id", &request_id),
227 .with(label::USER_ID, user.id)
228 .with(label::REQUEST_ID, &request_id),
233229 &response,
234230 Some(request_id.as_str()),
235231 )
@@ -271,12 +267,10 @@
271267 .await?;
272268 let response = self.complete_login(session, user, &request_id).await?;
273269 self.emit_labeled(
274 RuntimeEvent::Auth(AuthEvent::Login {
275 operate: RestOperate::Create,
276 }),
270 label::AUTH_LOGIN_CREATE,
277271 Labels::new()
278 .with("user_id", response.user_id)
279 .with("request_id", &request_id),
272 .with(label::USER_ID, response.user_id)
273 .with(label::REQUEST_ID, &request_id),
280274 serde_json::json!({
281275 "user_id": response.user_id,
282276 "username": &response.username,
@@ -338,12 +332,10 @@
338332 attempts_reset(self.cache(), &attempts_key, Some(request_id.as_str())).await?;
339333 let response = self.bind_session(&session, &user, &request_id).await?;
340334 self.emit_labeled(
341 RuntimeEvent::Auth(AuthEvent::Login {
342 operate: RestOperate::Create,
343 }),
335 label::AUTH_LOGIN_CREATE,
344336 Labels::new()
345 .with("user_id", response.user_id)
346 .with("request_id", &request_id),
337 .with(label::USER_ID, response.user_id)
338 .with(label::REQUEST_ID, &request_id),
347339 serde_json::json!({
348340 "user_id": response.user_id,
349341 "username": &response.username,
Modified lib/auth/src/server/http/logout/service.rs View lib/auth/src/server/http/logout/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::models::users::user_events::UserEventType;
33use crate::session::Session;
44use sea_orm::Set;
@@ -72,12 +72,10 @@
7272
7373 let response = LogoutResponse { user_id };
7474 self.emit_labeled(
75 RuntimeEvent::Auth(AuthEvent::Logout {
76 operate: RestOperate::Delete,
77 }),
75 label::AUTH_LOGOUT_DELETE,
7876 Labels::new()
79 .with_opt("user_id", user_id)
80 .with("request_id", &request_id),
77 .with_opt(label::USER_ID, user_id)
78 .with(label::REQUEST_ID, &request_id),
8179 &response,
8280 Some(request_id.as_str()),
8381 )
Modified lib/auth/src/server/http/organization/invite.rs View lib/auth/src/server/http/organization/invite.rs
@@ -7,7 +7,7 @@
77};
88use uuid::Uuid;
99
10use crate::events::{OrganizationEvent, RestOperate};
10use crate::events::label;
1111use crate::models::space::{
1212 OrgJoinMethod, OrgMemberRole, OrganizationStatus, organization_invite_redemptions,
1313 organization_invites, organization_roles,
@@ -449,13 +449,11 @@
449449 _request_id: Option<&str>,
450450 ) -> AuthResult<()> {
451451 let labels = crate::events::Labels::new()
452 .with("organization_id", organization_id)
453 .with("actor_user_id", actor_user_id);
452 .with(label::ORGANIZATION_ID, organization_id)
453 .with(label::ACTOR_USER_ID, actor_user_id);
454454 self.emit_tx(
455455 txn,
456 crate::events::RuntimeEvent::Organization(OrganizationEvent::Invite {
457 operate: RestOperate::Create,
458 }),
456 label::ORGANIZATION_INVITE_CREATE,
459457 labels,
460458 payload,
461459 None,
Modified lib/auth/src/server/http/organization/join.rs View lib/auth/src/server/http/organization/join.rs
@@ -8,7 +8,7 @@
88
99use crate::models::space::{
1010 OrgJoinMethod, OrgMemberRole, OrganizationJoinRequestStatus, organization_join_requests,
11 organization_join_rules, organization_members, organization_roles, organizations,
11 organization_join_rules, organization_members, organization_roles,
1212};
1313use crate::models::users::users;
1414use crate::server::http::organization::types::{
@@ -56,17 +56,6 @@
5656 })
5757 }
5858
59 async fn load_join_organization(
60 &self,
61 organization_id: Uuid,
62 ) -> AuthResult<organizations::Model> {
63 organizations::Entity::find_by_id(organization_id)
64 .filter(organizations::Column::DeletedAt.is_null())
65 .one(self.db().read())
66 .await?
67 .ok_or(AuthError::OrganizationNotFound)
68 }
69
7059 // 加入规则的可选字段本身就多,参数表与 HTTP 请求体一一对应,拆结构体只会增加噪音。
7160 #[allow(clippy::too_many_arguments)]
7261 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
@@ -204,7 +193,7 @@
204193 note: Option<String>,
205194 request_id: Option<&str>,
206195 ) -> AuthResult<JoinRequestSummary> {
207 let organization = self.load_join_organization(organization_id).await?;
196 let organization = self.load_organization(organization_id).await?;
208197 if organization.status != crate::models::space::OrganizationStatus::Active {
209198 return Err(AuthError::OrganizationInactive);
210199 }
Modified lib/auth/src/server/http/organization/member.rs View lib/auth/src/server/http/organization/member.rs
@@ -7,7 +7,7 @@
77};
88use uuid::Uuid;
99
10use crate::events::{OrganizationEvent, RestOperate};
10use crate::events::label;
1111use crate::models::space::{
1212 OrgJoinMethod, OrgMemberRole, organization_member_permissions, organization_members,
1313};
@@ -920,13 +920,11 @@
920920 _request_id: Option<&str>,
921921 ) -> AuthResult<()> {
922922 let labels = crate::events::Labels::new()
923 .with("organization_id", organization_id)
924 .with("actor_user_id", actor_user_id);
923 .with(label::ORGANIZATION_ID, organization_id)
924 .with(label::ACTOR_USER_ID, actor_user_id);
925925 self.emit_tx(
926926 txn,
927 crate::events::RuntimeEvent::Organization(OrganizationEvent::Member {
928 operate: RestOperate::Update,
929 }),
927 label::ORGANIZATION_MEMBER_UPDATE,
930928 labels,
931929 payload,
932930 None,
Modified lib/auth/src/server/http/organization/role.rs View lib/auth/src/server/http/organization/role.rs
@@ -6,7 +6,7 @@
66};
77use uuid::Uuid;
88
9use crate::events::{OrganizationEvent, RestOperate};
9use crate::events::label;
1010use crate::models::space::{
1111 PermissionEffect, organization_member_roles, organization_role_permissions, organization_roles,
1212};
@@ -747,18 +747,10 @@
747747 _request_id: Option<&str>,
748748 ) -> AuthResult<()> {
749749 let labels = crate::events::Labels::new()
750 .with("organization_id", organization_id)
751 .with("actor_user_id", actor_user_id);
752 self.emit_tx(
753 txn,
754 crate::events::RuntimeEvent::Organization(OrganizationEvent::Role {
755 operate: RestOperate::Update,
756 }),
757 labels,
758 payload,
759 None,
760 )
761 .await
750 .with(label::ORGANIZATION_ID, organization_id)
751 .with(label::ACTOR_USER_ID, actor_user_id);
752 self.emit_tx(txn, label::ORGANIZATION_ROLE_UPDATE, labels, payload, None)
753 .await
762754 }
763755
764756 /// 单个角色详情。
Modified lib/auth/src/server/http/organization/service.rs View lib/auth/src/server/http/organization/service.rs
@@ -7,7 +7,7 @@
77};
88use uuid::Uuid;
99
10use crate::events::{Labels, OrganizationEvent, RestOperate, RuntimeEvent};
10use crate::events::{Labels, label};
1111use crate::models::space::{
1212 OrgMemberRole, OrganizationStatus, PermissionEffect, organization_invite_redemptions,
1313 organization_invites, organization_member_permissions, organization_member_roles,
@@ -146,23 +146,16 @@
146146 async fn emit_org<T: serde::Serialize>(
147147 &self,
148148 txn: &sea_orm::DatabaseTransaction,
149 event: OrganizationEvent,
149 kind: &str,
150150 organization_id: Uuid,
151151 actor_user_id: i64,
152152 payload: T,
153153 request_id: Option<&str>,
154154 ) -> AuthResult<()> {
155155 let labels = Labels::new()
156 .with("organization_id", organization_id)
157 .with("actor_user_id", actor_user_id);
158 self.emit_tx(
159 txn,
160 RuntimeEvent::Organization(event),
161 labels,
162 payload,
163 request_id,
164 )
165 .await
156 .with(label::ORGANIZATION_ID, organization_id)
157 .with(label::ACTOR_USER_ID, actor_user_id);
158 self.emit_tx(txn, kind, labels, payload, request_id).await
166159 }
167160
168161 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, request_id = %request_id.unwrap_or_default()))]
@@ -227,9 +220,7 @@
227220 role::bind_role(&txn, membership.id, owner_role, actor_user_id).await?;
228221 self.emit_org(
229222 &txn,
230 OrganizationEvent::LifeCycle {
231 operate: RestOperate::Create,
232 },
223 label::ORGANIZATION_LIFE_CYCLE_CREATE,
233224 organization.id,
234225 actor_user_id,
235226 serde_json::json!({
@@ -492,9 +483,7 @@
492483 let version = bump_version(&txn, organization_id).await?;
493484 self.emit_org(
494485 &txn,
495 OrganizationEvent::LifeCycle {
496 operate: RestOperate::Update,
497 },
486 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
498487 organization_id,
499488 actor_user_id,
500489 serde_json::json!({
@@ -543,9 +532,7 @@
543532 let version = bump_version(&txn, organization_id).await?;
544533 self.emit_org(
545534 &txn,
546 OrganizationEvent::LifeCycle {
547 operate: RestOperate::Update,
548 },
535 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
549536 organization_id,
550537 actor_user_id,
551538 serde_json::json!({
@@ -603,9 +590,7 @@
603590 let version = bump_version(&txn, organization_id).await?;
604591 self.emit_org(
605592 &txn,
606 OrganizationEvent::LifeCycle {
607 operate: RestOperate::Update,
608 },
593 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
609594 organization_id,
610595 actor_user_id,
611596 serde_json::json!({
@@ -726,9 +711,7 @@
726711 bump_version(&txn, organization_id).await?;
727712 self.emit_org(
728713 &txn,
729 OrganizationEvent::LifeCycle {
730 operate: RestOperate::Delete,
731 },
714 label::ORGANIZATION_LIFE_CYCLE_DELETE,
732715 organization_id,
733716 actor_user_id,
734717 serde_json::json!({
@@ -805,9 +788,7 @@
805788 let version = bump_version(&txn, organization_id).await?;
806789 self.emit_org(
807790 &txn,
808 OrganizationEvent::Member {
809 operate: RestOperate::Update,
810 },
791 label::ORGANIZATION_MEMBER_UPDATE,
811792 organization_id,
812793 actor_user_id,
813794 serde_json::json!({
Modified lib/auth/src/server/http/password_reset/service.rs View lib/auth/src/server/http/password_reset/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::session::Session;
33use chrono::{DateTime, Duration, Utc};
44use sea_orm::entity::Set;
@@ -123,12 +123,10 @@
123123 expires_in_secs: ttl,
124124 };
125125 self.emit_labeled(
126 RuntimeEvent::Auth(AuthEvent::Password {
127 operate: RestOperate::Create,
128 }),
126 label::AUTH_PASSWORD_CREATE,
129127 Labels::new()
130 .with("user_id", user.id)
131 .with("request_id", &request_id),
128 .with(label::USER_ID, user.id)
129 .with(label::REQUEST_ID, &request_id),
132130 &response,
133131 Some(request_id.as_str()),
134132 )
@@ -245,12 +243,10 @@
245243
246244 let response = PasswordResetConfirmResponse { user_id: user.id };
247245 self.emit_labeled(
248 RuntimeEvent::Auth(AuthEvent::Password {
249 operate: RestOperate::Update,
250 }),
246 label::AUTH_PASSWORD_UPDATE,
251247 Labels::new()
252 .with("user_id", response.user_id)
253 .with("request_id", &request_id),
248 .with(label::USER_ID, response.user_id)
249 .with(label::REQUEST_ID, &request_id),
254250 &response,
255251 Some(request_id.as_str()),
256252 )
Modified lib/auth/src/server/http/register/service.rs View lib/auth/src/server/http/register/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::session::Session;
33use chrono::Utc;
44use rand::RngExt;
@@ -78,10 +78,8 @@
7878 expires_in_secs: self.auth().verify_code_ttl_secs,
7979 };
8080 self.emit_labeled(
81 RuntimeEvent::Auth(AuthEvent::Register {
82 operate: RestOperate::Create,
83 }),
84 Labels::new().with("request_id", &request_id),
81 label::AUTH_REGISTER_CREATE,
82 Labels::new().with(label::REQUEST_ID, &request_id),
8583 &response,
8684 Some(request_id.as_str()),
8785 )
@@ -202,12 +200,10 @@
202200 displayname: user.displayname,
203201 };
204202 self.emit_labeled(
205 RuntimeEvent::Auth(AuthEvent::Register {
206 operate: RestOperate::Create,
207 }),
203 label::AUTH_REGISTER_CREATE,
208204 Labels::new()
209 .with("user_id", response.user_id)
210 .with("request_id", &request_id),
205 .with(label::USER_ID, response.user_id)
206 .with(label::REQUEST_ID, &request_id),
211207 &response,
212208 Some(request_id.as_str()),
213209 )
Modified lib/auth/src/server/http/ssh_key/service.rs View lib/auth/src/server/http/ssh_key/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22use crate::models::users::user_events::{self, UserEventType};
33use crate::models::users::user_ssh_keys::{self, PublicKeyType};
44use crate::models::users::users;
@@ -174,12 +174,10 @@
174174
175175 let response = summary(inserted);
176176 self.emit_labeled(
177 RuntimeEvent::Auth(AuthEvent::SshKey {
178 operate: RestOperate::Create,
179 }),
177 label::AUTH_SSH_KEY_CREATE,
180178 Labels::new()
181 .with("user_id", user_id)
182 .with("request_id", &request_id),
179 .with(label::USER_ID, user_id)
180 .with(label::REQUEST_ID, &request_id),
183181 &response,
184182 Some(request_id.as_str()),
185183 )
@@ -279,12 +277,10 @@
279277 }
280278
281279 self.emit_labeled(
282 RuntimeEvent::Auth(AuthEvent::SshKey {
283 operate: RestOperate::Delete,
284 }),
280 label::AUTH_SSH_KEY_DELETE,
285281 Labels::new()
286 .with("user_id", user_id)
287 .with("request_id", &request_id),
282 .with(label::USER_ID, user_id)
283 .with(label::REQUEST_ID, &request_id),
288284 serde_json::json!({
289285 "user_id": user_id,
290286 "ssh_key_id": record.id,
Modified lib/auth/src/server/http/token/service.rs View lib/auth/src/server/http/token/service.rs
@@ -1,4 +1,4 @@
1use crate::events::{AuthEvent, Labels, RestOperate, RuntimeEvent};
1use crate::events::{Labels, label};
22
33use crate::server::AuthSvc;
44use crate::server::errors::AuthResult;
@@ -47,10 +47,8 @@
4747 expires_at: issue::timestamp(issued.expires_at, "token")?,
4848 };
4949 self.emit_labeled(
50 RuntimeEvent::Auth(AuthEvent::AppToken {
51 operate: RestOperate::Create,
52 }),
53 Labels::new().with("user_id", user_id),
50 label::AUTH_APP_TOKEN_CREATE,
51 Labels::new().with(label::USER_ID, user_id),
5452 serde_json::json!({
5553 "audience": crate::jwt::PLATFORM_AUDIENCE,
5654 "kid": &response.kid,
Modified lib/auth/src/server/oauth/consent.rs View lib/auth/src/server/oauth/consent.rs
@@ -208,7 +208,7 @@
208208 svc: web::Data<AuthSvc>,
209209 session: crate::session::Session,
210210 request_id: crate::session::RequestId,
211 query: Result<web::Query<crate::server::protocol::AuthorizeRequest>, actix_web::Error>,
211 query: Result<web::Query<crate::server::oauth::protocol::AuthorizeRequest>, actix_web::Error>,
212212) -> HttpResponse {
213213 let Ok(query) = query else {
214214 return bad_request("invalid_request");
@@ -269,7 +269,7 @@
269269 session: crate::session::Session,
270270 req: actix_web::HttpRequest,
271271 request_id: crate::session::RequestId,
272 query: Result<web::Query<crate::server::protocol::AuthorizeRequest>, actix_web::Error>,
272 query: Result<web::Query<crate::server::oauth::protocol::AuthorizeRequest>, actix_web::Error>,
273273 form: Result<web::Form<ConsentForm>, actix_web::Error>,
274274) -> HttpResponse {
275275 let (Ok(query), Ok(form)) = (query, form) else {
Modified lib/auth/src/server/oauth/logout.rs View lib/auth/src/server/oauth/logout.rs
@@ -213,7 +213,7 @@
213213 svc: &AuthSvc,
214214 session_id: &str,
215215 user_id: i64,
216 client: Option<&crate::server::client::ClientRecord>,
216 client: Option<&crate::server::oauth::client::ClientRecord>,
217217 request_id: Option<&str>,
218218) {
219219 let targets: Vec<(String, Vec<String>)> = svc
Modified lib/auth/src/server/oauth/mod.rs View lib/auth/src/server/oauth/mod.rs
@@ -1,9 +1,11 @@
1//! OIDC 扩展:id_token、userinfo、consent、refresh token、单点登出。
1//! OAuth 2.0 与 OIDC:客户端注册、授权码、id_token、userinfo、consent、refresh token、单点登出。
22//!
3//! OAuth 2.0 的授权码流程仍在 `crate::server::protocol`,这里只补 OIDC 需要的额外产物。
3//! OAuth 2.0 的授权码流程在 `protocol`,客户端注册表在 `client`。
44
5pub mod client;
56pub mod consent;
67pub mod id_token;
78pub mod logout;
9pub mod protocol;
810pub mod refresh;
911pub mod userinfo;
Modified lib/auth/src/server/pow/hash.rs View lib/auth/src/server/pow/hash.rs
@@ -1,7 +1,7 @@
11use sha2::{Digest, Sha256};
22
3use crate::server::challenge::types::{ChallengeResponse, PowProofRequest};
43use crate::server::errors::{AuthError, AuthResult};
4use crate::server::http::challenge::types::{ChallengeResponse, PowProofRequest};
55use crate::server::share::crypto;
66
77pub fn pow_hash(challenge: &ChallengeResponse, nonce: u64, payload: &[u8]) -> [u8; 32] {
Modified lib/auth/src/server/pow/store.rs View lib/auth/src/server/pow/store.rs
@@ -4,8 +4,10 @@
44use crate::config::pow::PowConfig;
55use crate::session::Session;
66
7use crate::server::challenge::types::{ChallengeResponse, PowProofRequest, SESSION_CHALLENGE};
87use crate::server::errors::{AuthError, AuthResult};
8use crate::server::http::challenge::types::{
9 ChallengeResponse, PowProofRequest, SESSION_CHALLENGE,
10};
911use crate::server::share::crypto::{now_secs, random_alnum};
1012
1113pub const USED_KEY_PREFIX: &str = "auth:pow:used:";
Modified lib/auth/src/server/auth_svc.rs View lib/auth/src/server/auth_svc.rs
@@ -7,7 +7,7 @@
77use crate::config::pow::PowConfig;
88use crate::db::DbConn;
99use crate::email::EmailService;
10use crate::events::{EventHandler, Labels, RuntimeEvent};
10use crate::events::{EventHandler, Labels, label};
1111use serde::Serialize;
1212
1313pub use super::errors::{AuthError, AuthResult};
@@ -20,7 +20,7 @@
2020) -> Option<serde_json::Value> {
2121 use sea_orm::EntityTrait;
2222
23 let uid = labels.get("user_id")?.parse::<i64>().ok()?;
23 let uid = labels.get(label::USER_ID)?.parse::<i64>().ok()?;
2424 let user = crate::models::users::users::Entity::find_by_id(uid)
2525 .one(conn)
2626 .await
@@ -133,11 +133,11 @@
133133 self.session_max_age_secs
134134 }
135135
136 pub async fn emit<T>(&self, event: RuntimeEvent, payload: T, request_id: Option<&str>)
136 pub async fn emit<T>(&self, kind: &str, payload: T, request_id: Option<&str>)
137137 where
138138 T: Serialize + Clone,
139139 {
140 self.emit_labeled(event, Labels::default(), payload, request_id)
140 self.emit_labeled(kind, Labels::default(), payload, request_id)
141141 .await;
142142 }
143143
@@ -147,7 +147,7 @@
147147 /// 只是这时又回到「重试 3 次后丢弃」的旧语义。
148148 pub async fn emit_labeled<T>(
149149 &self,
150 event: RuntimeEvent,
150 kind: &str,
151151 labels: Labels,
152152 payload: T,
153153 request_id: Option<&str>,
@@ -156,8 +156,7 @@
156156 {
157157 let Some(events) = self.events.as_ref() else {
158158 tracing::error!(
159 event = %event,
160 subject = %event.subject(),
159 kind,
161160 "event handler is not configured; event was not forwarded to rocketmq"
162161 );
163162 return;
@@ -173,7 +172,7 @@
173172 }
174173 let enqueued = crate::events::outbox::enqueue_at(
175174 self.db.write(),
176 event,
175 kind,
177176 labels.clone(),
178177 &payload,
179178 &trace_id,
@@ -184,12 +183,11 @@
184183
185184 if let Err(error) = enqueued {
186185 tracing::error!(
187 event = %event,
188 subject = %event.subject(),
186 kind,
189187 %error,
190188 "failed to enqueue the event in the outbox; falling back to direct publish"
191189 );
192 crate::events::retry_publish(events, event, labels, payload, "auth", request_id).await;
190 crate::events::retry_publish(events, kind, labels, payload, "auth", request_id).await;
193191 }
194192 }
195193
@@ -201,7 +199,7 @@
201199 pub async fn emit_tx<T>(
202200 &self,
203201 conn: &impl sea_orm::ConnectionTrait,
204 event: RuntimeEvent,
202 kind: &str,
205203 labels: Labels,
206204 payload: T,
207205 request_id: Option<&str>,
@@ -219,7 +217,7 @@
219217 }
220218 crate::events::outbox::enqueue_at(
221219 conn,
222 event,
220 kind,
223221 labels,
224222 &payload,
225223 &trace_id,
Modified lib/auth/src/lib.rs View lib/auth/src/lib.rs
@@ -13,7 +13,7 @@
1313pub use runtime as oauth;
1414
1515pub use cache;
16pub use config::identity as config;
16pub use config;
1717pub use crypt;
1818pub use db;
1919pub use email;
Modified lib/cache/src/conn.rs View lib/cache/src/conn.rs
@@ -1,7 +1,7 @@
11use std::sync::Arc;
22use std::sync::atomic::{AtomicUsize, Ordering};
33
4use config::identity::redis::RedisConfig;
4use config::redis::RedisConfig;
55use redis::ConnectionInfo;
66use redis::aio::{ConnectionManager, ConnectionManagerConfig};
77use redis::cluster_async::ClusterConnection;
@@ -29,7 +29,7 @@
2929 let db = cfg.database();
3030 let cluster = cfg.is_cluster();
3131 let seeds = cfg.seeds().map_err(CacheError::Config)?;
32 let pool_size = cfg.pool.max_size.max(1);
32 let pool_size = cfg.pool_size().max(1);
3333
3434 let connections = if cluster {
3535 let nodes = seeds
@@ -37,7 +37,7 @@
3737 .map(|(host, port)| connection_info(host, *port, cfg))
3838 .collect::<CacheResult<Vec<_>>>()?;
3939 let client = redis::cluster::ClusterClient::builder(nodes)
40 .connection_timeout(cfg.pool.create_timeout())
40 .connection_timeout(cfg.create_timeout())
4141 .build()
4242 .map_err(|error| CacheError::config(error.to_string()))?;
4343
@@ -58,7 +58,7 @@
5858 let info = connection_info(&host, port, cfg)?;
5959 let client = redis::Client::open(info).map_err(CacheError::from)?;
6060 let manager_config = ConnectionManagerConfig::new()
61 .set_connection_timeout(Some(cfg.pool.create_timeout()));
61 .set_connection_timeout(Some(cfg.create_timeout()));
6262
6363 let mut connections = Vec::with_capacity(pool_size);
6464 for _ in 0..pool_size {
@@ -114,7 +114,11 @@
114114 }
115115}
116116
117fn connection_info(host: &str, port: u16, cfg: &RedisConfig) -> CacheResult<ConnectionInfo> {
117fn connection_info(
118 host: &str,
119 port: u16,
120 cfg: &RedisConfig,
121) -> CacheResult<ConnectionInfo> {
118122 let host = if host.contains(':') && !host.starts_with('[') {
119123 format!("[{host}]")
120124 } else {
Modified lib/cache/src/kv.rs View lib/cache/src/kv.rs
@@ -1,7 +1,6 @@
11use std::time::Duration;
22
3use config::identity::loader::Section;
4use config::identity::redis::RedisConfig;
3use config::redis::RedisConfig;
54use redis::{AsyncCommands, Cmd, FromRedisValue, ToRedisArgs, ToSingleRedisArg};
65
76use crate::conn::{CacheConn, CacheConnection};
@@ -22,11 +21,6 @@
2221 prefix: cfg.key_prefix().to_string(),
2322 default_ttl: cfg.default_ttl(),
2423 })
25 }
26
27 pub async fn load() -> CacheResult<Self> {
28 let cfg = RedisConfig::load().map_err(|error| CacheError::config(error.to_string()))?;
29 Self::connect(&cfg).await
3024 }
3125
3226 pub fn from_conn(conn: CacheConn) -> Self {
Modified lib/chat/src/services/outbound.rs View lib/chat/src/services/outbound.rs
@@ -3,6 +3,7 @@
33use sea_orm::sea_query::Expr;
44use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
55use serde::{Deserialize, Serialize};
6use social::net::is_public_ip;
67use time::OffsetDateTime;
78use utoipa::ToSchema;
89use uuid::Uuid;
@@ -91,37 +92,6 @@
9192 ));
9293 }
9394 Ok(uri)
94}
95
96pub(crate) fn is_public_ip(ip: &std::net::IpAddr) -> bool {
97 match ip {
98 std::net::IpAddr::V4(v4) => {
99 !v4.is_loopback()
100 && !v4.is_private()
101 && !v4.is_link_local()
102 && !v4.is_broadcast()
103 && !v4.is_documentation()
104 && !v4.is_unspecified()
105 && !(v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 0b0100_0000)
106 }
107 std::net::IpAddr::V6(v6) => {
108 !v6.is_loopback()
109 && !v6.is_unspecified()
110 && !v6.is_multicast()
111 && {
112 let segments = v6.segments();
113 // 64:ff9b::/96 (NAT64), fc00::/7 (ULA), fe80::/10 (link-local),
114 // ::ffff:0:0/96 (IPv4-mapped) handled via to_ipv4 below.
115 !(segments[0] == 0x0064 && segments[1] == 0xff9b)
116 && (segments[0] & 0xfe00) != 0xfc00
117 && (segments[0] & 0xffc0) != 0xfe80
118 }
119 && match v6.to_ipv4_mapped() {
120 Some(v4) => is_public_ip(&std::net::IpAddr::V4(v4)),
121 None => true,
122 }
123 }
124 }
12595}
12696
12797async fn require_room_manager(
Modified lib/chat/src/services/voice.rs View lib/chat/src/services/voice.rs
@@ -1223,18 +1223,7 @@
12231223 caller: i64,
12241224 room_id: Uuid,
12251225 ) -> Result<VoiceRecordingDto, ChatError> {
1226 require_voice_room(self, room_id, caller).await?;
1227 if !can_moderate_voice(self, room_id, caller).await? {
1228 return Err(ChatError::Forbidden);
1229 }
1230 if !self.config().livekit.recording_enabled {
1231 return Err(ChatError::BadRequest(
1232 "recording is not enabled".to_string(),
1233 ));
1234 }
1235 Err(ChatError::BadRequest(
1236 "recording requires the livekit egress component".to_string(),
1237 ))
1226 self.recording_unavailable(caller, room_id).await
12381227 }
12391228
12401229 pub async fn stop_recording(
@@ -1242,6 +1231,14 @@
12421231 caller: i64,
12431232 room_id: Uuid,
12441233 ) -> Result<VoiceRecordingDto, ChatError> {
1234 self.recording_unavailable(caller, room_id).await
1235 }
1236
1237 async fn recording_unavailable(
1238 &self,
1239 caller: i64,
1240 room_id: Uuid,
1241 ) -> Result<VoiceRecordingDto, ChatError> {
12451242 require_voice_room(self, room_id, caller).await?;
12461243 if !can_moderate_voice(self, room_id, caller).await? {
12471244 return Err(ChatError::Forbidden);
Renamed lib/config/src/identity/app/app_config.rs → lib/config/src/app/app_config.rs View lib/config/src/app/app_config.rs
@@ -108,6 +108,6 @@
108108 }
109109}
110110
111impl crate::identity::loader::Section for AppConfig {
111impl crate::loader::Section for AppConfig {
112112 const KEY: &'static str = "app";
113113}
Renamed lib/config/src/identity/app/app_registry.rs → lib/config/src/app/app_registry.rs View lib/config/src/app/app_registry.rs
@@ -90,10 +90,6 @@
9090}
9191
9292impl AppRegistry {
93 pub fn new(apps: Vec<AppRegistration>) -> Self {
94 Self { apps }
95 }
96
9793 pub fn apps(&self) -> &[AppRegistration] {
9894 &self.apps
9995 }
@@ -122,6 +118,6 @@
122118 }
123119}
124120
125impl crate::identity::loader::Section for AppRegistry {
121impl crate::loader::Section for AppRegistry {
126122 const KEY: &'static str = "apps";
127123}
Renamed lib/config/src/identity/app/key.rs → lib/config/src/app/key.rs View lib/config/src/app/key.rs

No content changes.

Renamed lib/config/src/identity/app/mod.rs → lib/config/src/app/mod.rs View lib/config/src/app/mod.rs

No content changes.

Deleted lib/config/src/identity/auth.rs
@@ -1,327 +0,0 @@
1use base64::Engine;
2use serde::Deserialize;
3
4pub const TOTP_KEY_MIN_BYTES: usize = 32;
5
6#[derive(Clone, Deserialize)]
7#[serde(default)]
8pub struct OAuthConfig {
9 pub public_url: String,
10 pub clients: Vec<OAuthClient>,
11 pub socket_origins: Vec<String>,
12 /// 访问令牌寿命(秒)。短寿命 + refresh_token 是推荐配置。
13 #[serde(default = "default_access_token_ttl")]
14 pub access_token_ttl_secs: i64,
15 /// id_token 寿命(秒),OIDC 建议远短于会话寿命。
16 #[serde(default = "default_id_token_ttl")]
17 pub id_token_ttl_secs: i64,
18 /// 刷新令牌寿命(秒),默认等于会话 30 天。
19 #[serde(default = "default_refresh_token_ttl")]
20 pub refresh_token_ttl_secs: i64,
21 /// 授权码寿命(秒)。
22 #[serde(default = "default_code_ttl")]
23 pub code_ttl_secs: i64,
24 /// 首次授权是否必须经过同意页;`prompt=consent` 永远生效。
25 #[serde(default = "default_true")]
26 pub require_consent: bool,
27 /// 是否允许 `https://*.example.com/callback` 形式的 redirect_uri 通配。
28 #[serde(default = "default_true")]
29 pub allow_wildcard_redirect: bool,
30 /// gRPC 面的共享密钥:调用方把它放进 `x-rpc-key` 头即可通过门禁。
31 /// 这些 RPC 只在受信网络内使用,所以不再校验服务身份、用户主体或 scope。
32 #[serde(default)]
33 pub rpc_key: String,
34}
35
36impl Default for OAuthConfig {
37 fn default() -> Self {
38 Self {
39 public_url: String::new(),
40 clients: Vec::new(),
41 socket_origins: Vec::new(),
42 access_token_ttl_secs: default_access_token_ttl(),
43 id_token_ttl_secs: default_id_token_ttl(),
44 refresh_token_ttl_secs: default_refresh_token_ttl(),
45 code_ttl_secs: default_code_ttl(),
46 require_consent: true,
47 allow_wildcard_redirect: true,
48 rpc_key: String::new(),
49 }
50 }
51}
52
53fn default_access_token_ttl() -> i64 {
54 600
55}
56
57fn default_id_token_ttl() -> i64 {
58 300
59}
60
61fn default_refresh_token_ttl() -> i64 {
62 2_592_000
63}
64
65fn default_code_ttl() -> i64 {
66 60
67}
68
69fn default_true() -> bool {
70 true
71}
72
73impl std::fmt::Debug for OAuthConfig {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 formatter
76 .debug_struct("OAuthConfig")
77 .field("public_url", &self.public_url)
78 .field("clients", &self.clients)
79 .field("socket_origins", &self.socket_origins)
80 .field("access_token_ttl_secs", &self.access_token_ttl_secs)
81 .field("id_token_ttl_secs", &self.id_token_ttl_secs)
82 .field("refresh_token_ttl_secs", &self.refresh_token_ttl_secs)
83 .field("code_ttl_secs", &self.code_ttl_secs)
84 .field("require_consent", &self.require_consent)
85 .field("allow_wildcard_redirect", &self.allow_wildcard_redirect)
86 .field("rpc_key", &"<redacted>")
87 .finish()
88 }
89}
90
91#[derive(Clone, Deserialize)]
92#[serde(default)]
93pub struct OAuthClient {
94 pub client_id: String,
95 /// 同意页展示的应用名。
96 pub name: String,
97 pub kind: models::users::oauth_clients::ClientKind,
98 /// 机密客户端的初始 secret;启动时会哈希后入库,明文不落库。
99 pub secret: Option<String>,
100 /// 令牌受众;为空时回落 `token::PLATFORM_AUDIENCE`。
101 pub audience: Option<String>,
102 #[serde(default = "default_true")]
103 pub enabled: bool,
104 pub redirect_uris: Vec<String>,
105 pub post_logout_redirect_uris: Vec<String>,
106 pub backchannel_logout_uris: Vec<String>,
107 pub scopes: Vec<String>,
108 /// client_credentials 令牌的 scope 天花板与缺省集;必须是 `scopes` 的子集。
109 /// 不配置则服务令牌不带任何 scope(不再默认全部注册 scope)。
110 pub service_scopes: Vec<String>,
111}
112
113impl Default for OAuthClient {
114 fn default() -> Self {
115 Self {
116 client_id: String::new(),
117 name: String::new(),
118 kind: models::users::oauth_clients::ClientKind::Public,
119 secret: None,
120 audience: None,
121 enabled: true,
122 redirect_uris: Vec::new(),
123 post_logout_redirect_uris: Vec::new(),
124 backchannel_logout_uris: Vec::new(),
125 scopes: Vec::new(),
126 service_scopes: Vec::new(),
127 }
128 }
129}
130
131impl std::fmt::Debug for OAuthClient {
132 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 formatter
134 .debug_struct("OAuthClient")
135 .field("client_id", &self.client_id)
136 .field("name", &self.name)
137 .field("kind", &self.kind)
138 .field("secret", &self.secret.as_ref().map(|_| "<redacted>"))
139 .field("audience", &self.audience)
140 .field("enabled", &self.enabled)
141 .field("redirect_uris", &self.redirect_uris)
142 .field("post_logout_redirect_uris", &self.post_logout_redirect_uris)
143 .field("backchannel_logout_uris", &self.backchannel_logout_uris)
144 .field("scopes", &self.scopes)
145 .field("service_scopes", &self.service_scopes)
146 .finish()
147 }
148}
149
150#[derive(Debug, Clone, Deserialize)]
151#[serde(default)]
152pub struct PasswordPolicy {
153 pub min_length: usize,
154 pub require_upper: bool,
155 pub require_lower: bool,
156 pub require_digit: bool,
157 pub require_symbol: bool,
158}
159
160impl Default for PasswordPolicy {
161 fn default() -> Self {
162 Self {
163 min_length: 8,
164 require_upper: true,
165 require_lower: true,
166 require_digit: true,
167 require_symbol: false,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Default, Deserialize)]
173#[serde(default)]
174pub struct AgreementDocConfig {
175 pub key: String,
176 pub version: String,
177 pub title: String,
178 pub content_md: String,
179}
180
181#[derive(Debug, Clone, Deserialize)]
182#[serde(default)]
183pub struct AuthConfig {
184 pub oauth: OAuthConfig,
185 pub password_policy: PasswordPolicy,
186 pub verify_code_ttl_secs: u64,
187 pub verify_code_len: usize,
188 pub reset_token_ttl_secs: u64,
189 pub reset_token_bytes: usize,
190 pub email_code_rate_limit_secs: u64,
191 pub max_verify_attempts: u64,
192 pub min_age: u8,
193 pub totp_encryption_key: Option<String>,
194 pub agreements: Vec<AgreementDocConfig>,
195}
196
197impl Default for AuthConfig {
198 fn default() -> Self {
199 Self {
200 oauth: OAuthConfig::default(),
201 password_policy: PasswordPolicy::default(),
202 verify_code_ttl_secs: 600,
203 verify_code_len: 6,
204 reset_token_ttl_secs: 1800,
205 reset_token_bytes: 32,
206 email_code_rate_limit_secs: 60,
207 max_verify_attempts: 5,
208 min_age: 13,
209 totp_encryption_key: None,
210 agreements: vec![
211 AgreementDocConfig {
212 key: "terms".to_string(),
213 version: "0.1.0".to_string(),
214 title: "Terms of Service".to_string(),
215 content_md: "Placeholder terms of service.".to_string(),
216 },
217 AgreementDocConfig {
218 key: "privacy".to_string(),
219 version: "0.1.0".to_string(),
220 title: "Privacy Policy".to_string(),
221 content_md: "Placeholder privacy policy.".to_string(),
222 },
223 ],
224 }
225 }
226}
227
228impl AuthConfig {
229 pub fn new() -> Self {
230 Self::default()
231 }
232
233 pub fn with_password_policy(mut self, policy: PasswordPolicy) -> Self {
234 self.password_policy = policy;
235 self
236 }
237
238 pub fn with_verify_code_ttl_secs(mut self, secs: u64) -> Self {
239 self.verify_code_ttl_secs = secs;
240 self
241 }
242
243 pub fn with_verify_code_len(mut self, len: usize) -> Self {
244 self.verify_code_len = len;
245 self
246 }
247
248 pub fn with_reset_token_ttl_secs(mut self, secs: u64) -> Self {
249 self.reset_token_ttl_secs = secs;
250 self
251 }
252
253 pub fn with_reset_token_bytes(mut self, bytes: usize) -> Self {
254 self.reset_token_bytes = bytes;
255 self
256 }
257
258 pub fn with_email_code_rate_limit_secs(mut self, secs: u64) -> Self {
259 self.email_code_rate_limit_secs = secs;
260 self
261 }
262
263 pub fn with_max_verify_attempts(mut self, attempts: u64) -> Self {
264 self.max_verify_attempts = attempts;
265 self
266 }
267
268 pub fn with_min_age(mut self, min_age: u8) -> Self {
269 self.min_age = min_age;
270 self
271 }
272
273 pub fn with_totp_encryption_key(mut self, key: impl Into<String>) -> Self {
274 self.totp_encryption_key = Some(key.into());
275 self
276 }
277
278 pub fn with_agreements(mut self, agreements: Vec<AgreementDocConfig>) -> Self {
279 self.agreements = agreements;
280 self
281 }
282
283 pub fn validate(&self) -> Result<(), String> {
284 if !(4..=10).contains(&self.verify_code_len) {
285 return Err("auth.verify_code_len must be between 4 and 10".to_string());
286 }
287 if self.verify_code_ttl_secs == 0 {
288 return Err("auth.verify_code_ttl_secs must be greater than 0".to_string());
289 }
290 if self.reset_token_ttl_secs == 0 {
291 return Err("auth.reset_token_ttl_secs must be greater than 0".to_string());
292 }
293 if self.reset_token_bytes < 16 {
294 return Err("auth.reset_token_bytes must be at least 16".to_string());
295 }
296 if self.email_code_rate_limit_secs == 0 {
297 return Err("auth.email_code_rate_limit_secs must be greater than 0".to_string());
298 }
299 if self.max_verify_attempts == 0 {
300 return Err("auth.max_verify_attempts must be greater than 0".to_string());
301 }
302 let Some(key) = &self.totp_encryption_key else {
303 return Err("auth.totp_encryption_key is required".to_string());
304 };
305 if key.trim().starts_with("REPLACE_WITH_") {
306 return Err(
307 "auth.totp_encryption_key is still set to the placeholder from \
308 config.yaml.example"
309 .to_string(),
310 );
311 }
312 let length = base64::engine::general_purpose::STANDARD
313 .decode(key.trim())
314 .map(|bytes| bytes.len())
315 .unwrap_or(0);
316 if length < TOTP_KEY_MIN_BYTES {
317 return Err(format!(
318 "auth.totp_encryption_key must be base64 of at least {TOTP_KEY_MIN_BYTES} bytes"
319 ));
320 }
321 Ok(())
322 }
323}
324
325impl crate::identity::loader::Section for AuthConfig {
326 const KEY: &'static str = "auth";
327}
Deleted lib/config/src/identity/core.rs
@@ -1,48 +0,0 @@
1use serde::Deserialize;
2
3#[derive(Debug, Clone, Deserialize)]
4#[serde(default)]
5pub struct CoreConfig {
6 pub grpc_addr: String,
7 pub public_grpc_addr: String,
8}
9
10impl Default for CoreConfig {
11 fn default() -> Self {
12 Self {
13 grpc_addr: "http://127.0.0.1:18092".to_owned(),
14 public_grpc_addr: "http://127.0.0.1:18092".to_owned(),
15 }
16 }
17}
18
19impl CoreConfig {
20 pub fn endpoint(&self) -> &str {
21 self.grpc_addr.trim()
22 }
23 pub fn public_endpoint(&self) -> &str {
24 self.public_grpc_addr.trim()
25 }
26
27 pub fn is_valid(&self) -> Result<(), String> {
28 let endpoint = self.endpoint();
29 if endpoint.is_empty() {
30 return Err("core.grpc_addr must not be empty".to_owned());
31 }
32 if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
33 return Err(format!(
34 "core.grpc_addr must be an http(s) URL, got `{endpoint}`"
35 ));
36 }
37 if !(self.public_endpoint().starts_with("http://")
38 || self.public_endpoint().starts_with("https://"))
39 {
40 return Err("core.public_grpc_addr must be an http(s) URL".to_owned());
41 }
42 Ok(())
43 }
44}
45
46impl crate::identity::loader::Section for CoreConfig {
47 const KEY: &'static str = "core";
48}
Deleted lib/config/src/identity/database.rs
@@ -1,328 +0,0 @@
1use sea_orm::ConnectOptions;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4
5#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
6#[serde(default)]
7pub struct DatabaseEndpoint {
8 pub url: Option<String>,
9 pub host: Option<String>,
10 pub port: Option<u16>,
11 pub username: Option<String>,
12 pub password: Option<String>,
13 pub database: Option<String>,
14 pub schema_search_path: Option<String>,
15 pub application_name: Option<String>,
16 pub ssl_mode: Option<SslMode>,
17 pub max_connections: Option<u32>,
18 pub min_connections: Option<u32>,
19 pub connect_timeout_secs: Option<u64>,
20 pub idle_timeout_secs: Option<u64>,
21 pub acquire_timeout_secs: Option<u64>,
22 pub max_lifetime_secs: Option<u64>,
23 pub sqlx_logging: Option<bool>,
24}
25
26impl std::fmt::Debug for DatabaseEndpoint {
27 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 // `url` 常内嵌 `user:password@host`,两者都只能以掩码形式出现。
29 formatter
30 .debug_struct("DatabaseEndpoint")
31 .field("url", &self.url.as_ref().map(|_| "<redacted>"))
32 .field("host", &self.host)
33 .field("port", &self.port)
34 .field("username", &self.username)
35 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
36 .field("database", &self.database)
37 .field("schema_search_path", &self.schema_search_path)
38 .field("application_name", &self.application_name)
39 .field("ssl_mode", &self.ssl_mode)
40 .field("max_connections", &self.max_connections)
41 .field("min_connections", &self.min_connections)
42 .field("connect_timeout_secs", &self.connect_timeout_secs)
43 .field("idle_timeout_secs", &self.idle_timeout_secs)
44 .field("acquire_timeout_secs", &self.acquire_timeout_secs)
45 .field("max_lifetime_secs", &self.max_lifetime_secs)
46 .field("sqlx_logging", &self.sqlx_logging)
47 .finish()
48 }
49}
50
51impl DatabaseEndpoint {
52 pub fn url(&self) -> String {
53 if let Some(url) = self.url.as_deref().filter(|s| !s.is_empty()) {
54 return url.to_string();
55 }
56
57 let host = self.host.as_deref().unwrap_or("localhost");
58 let port = self.port.unwrap_or(5432);
59 let user = url_encode(self.username.as_deref().unwrap_or("postgres"));
60 let db = url_encode(self.database.as_deref().unwrap_or("postgres"));
61 let password = match &self.password {
62 Some(p) if !p.is_empty() => format!(":{}", url_encode(p)),
63 _ => String::new(),
64 };
65
66 let mut url = format!("postgres://{user}{password}@{host}:{port}/{db}");
67
68 let mut params: Vec<String> = Vec::new();
69 if let Some(app) = self.application_name.as_deref().filter(|s| !s.is_empty()) {
70 params.push(format!("application_name={}", url_encode(app)));
71 }
72 if let Some(mode) = self.ssl_mode {
73 params.push(format!("sslmode={}", mode.as_query_value()));
74 }
75
76 if !params.is_empty() {
77 url.push('?');
78 url.push_str(&params.join("&"));
79 }
80
81 url
82 }
83
84 pub fn validate(&self) -> Result<(), String> {
85 if self.url.as_deref().is_some_and(|s| !s.is_empty()) {
86 return Ok(());
87 }
88 if self.host.as_deref().filter(|s| !s.is_empty()).is_none() {
89 return Err("endpoint.host must be set when endpoint.url is not provided".to_string());
90 }
91 if matches!(self.port, Some(0)) {
92 return Err("endpoint.port must be greater than 0".to_string());
93 }
94 Ok(())
95 }
96
97 pub fn inherit_from(&mut self, fallback: &Self) {
98 let describes_target = self.host.is_some()
99 || self.port.is_some()
100 || self.database.is_some()
101 || self.username.is_some()
102 || self.password.is_some();
103 if !describes_target && self.url.is_none() {
104 self.url = fallback.url.clone();
105 }
106
107 macro_rules! fill {
108 ($($field:ident),* $(,)?) => {
109 $(
110 if self.$field.is_none() {
111 self.$field = fallback.$field.clone();
112 }
113 )*
114 };
115 }
116
117 fill!(
118 host,
119 port,
120 username,
121 password,
122 database,
123 schema_search_path,
124 application_name,
125 ssl_mode,
126 max_connections,
127 min_connections,
128 connect_timeout_secs,
129 idle_timeout_secs,
130 acquire_timeout_secs,
131 max_lifetime_secs,
132 sqlx_logging,
133 );
134 }
135
136 pub fn apply_to(&self, opts: &mut ConnectOptions) -> Result<(), String> {
137 self.validate()?;
138 if let Some(v) = self.schema_search_path.as_deref().filter(|s| !s.is_empty()) {
139 opts.set_schema_search_path(v);
140 }
141 if let Some(v) = self.sqlx_logging {
142 opts.sqlx_logging(v);
143 }
144 if let Some(v) = self.max_connections {
145 opts.max_connections(v);
146 }
147 if let Some(v) = self.min_connections {
148 opts.min_connections(v);
149 }
150 if let Some(v) = self.connect_timeout_secs {
151 opts.connect_timeout(Duration::from_secs(v));
152 }
153 if let Some(v) = self.idle_timeout_secs {
154 opts.idle_timeout(Duration::from_secs(v));
155 }
156 if let Some(v) = self.acquire_timeout_secs {
157 opts.acquire_timeout(Duration::from_secs(v));
158 }
159 if let Some(v) = self.max_lifetime_secs {
160 opts.max_lifetime(Duration::from_secs(v));
161 }
162 Ok(())
163 }
164
165 pub fn connect_options(&self) -> Result<ConnectOptions, String> {
166 let mut opts = ConnectOptions::new(self.url());
167 self.apply_to(&mut opts)?;
168 Ok(opts)
169 }
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
173#[serde(default)]
174pub struct DatabaseGroup {
175 #[serde(flatten)]
176 pub primary: DatabaseEndpoint,
177 pub replicas: Vec<DatabaseEndpoint>,
178}
179
180impl DatabaseGroup {
181 pub fn primary_url(&self) -> String {
182 self.primary.url()
183 }
184
185 pub fn replica_urls(&self) -> Vec<String> {
186 self.replicas.iter().map(DatabaseEndpoint::url).collect()
187 }
188
189 pub fn replica_count(&self) -> usize {
190 self.replicas.len()
191 }
192
193 pub fn has_replicas(&self) -> bool {
194 !self.replicas.is_empty()
195 }
196
197 pub fn validate(&self) -> Result<(), String> {
198 self.primary.validate()?;
199 for (i, replica) in self.replicas.iter().enumerate() {
200 replica
201 .validate()
202 .map_err(|e| format!("replicas[{i}]: {e}"))?;
203 }
204 Ok(())
205 }
206
207 pub fn primary_connect_options(&self) -> Result<ConnectOptions, String> {
208 self.validate()?;
209 self.primary.connect_options()
210 }
211
212 pub fn replica_connect_options(&self, index: usize) -> Result<ConnectOptions, String> {
213 let replica = self
214 .replicas
215 .get(index)
216 .ok_or_else(|| format!("replica index {index} out of range"))?;
217 replica.connect_options()
218 }
219
220 pub fn connect_options_for(&self, role: DatabaseRole) -> Result<ConnectOptions, String> {
221 match role {
222 DatabaseRole::Primary => self.primary_connect_options(),
223 DatabaseRole::Replica => {
224 if self.replicas.is_empty() {
225 return self.primary_connect_options();
226 }
227 self.replica_connect_options(0)
228 }
229 }
230 }
231}
232
233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum SslMode {
236 #[default]
237 Disable,
238 Prefer,
239 Require,
240 VerifyCa,
241 VerifyFull,
242}
243
244impl SslMode {
245 pub fn as_query_value(&self) -> &'static str {
246 match self {
247 SslMode::Disable => "disable",
248 SslMode::Prefer => "prefer",
249 SslMode::Require => "require",
250 SslMode::VerifyCa => "verify-ca",
251 SslMode::VerifyFull => "verify-full",
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum DatabaseRole {
258 Primary,
259 Replica,
260}
261
262#[derive(Debug, Clone, Default, Serialize, Deserialize)]
263#[serde(default)]
264pub struct DatabaseConfig {
265 #[serde(flatten)]
266 pub default: DatabaseGroup,
267}
268
269impl DatabaseConfig {
270 pub fn default_group(&self) -> &DatabaseGroup {
271 &self.default
272 }
273
274 pub fn validate(&self) -> Result<(), String> {
275 self.default
276 .validate()
277 .map_err(|reason| format!("database: {reason}"))
278 }
279
280 pub fn primary_url(&self) -> String {
281 self.default.primary_url()
282 }
283
284 pub fn replica_url(&self, index: usize) -> Option<String> {
285 self.default.replicas.get(index).map(DatabaseEndpoint::url)
286 }
287
288 pub fn replica_urls(&self) -> Vec<String> {
289 self.default.replica_urls()
290 }
291
292 pub fn replica_count(&self) -> usize {
293 self.default.replica_count()
294 }
295
296 pub fn has_replicas(&self) -> bool {
297 self.default.has_replicas()
298 }
299
300 pub fn primary_connect_options(&self) -> Result<ConnectOptions, String> {
301 self.default.primary_connect_options()
302 }
303
304 pub fn replica_connect_options(&self, index: usize) -> Result<ConnectOptions, String> {
305 self.default.replica_connect_options(index)
306 }
307
308 pub fn connect_options_for(&self, role: DatabaseRole) -> Result<ConnectOptions, String> {
309 self.default.connect_options_for(role)
310 }
311}
312
313pub(crate) fn url_encode(value: &str) -> String {
314 let mut out = String::with_capacity(value.len());
315 for b in value.as_bytes() {
316 match b {
317 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
318 out.push(*b as char);
319 }
320 _ => out.push_str(&format!("%{:02X}", b)),
321 }
322 }
323 out
324}
325
326impl crate::identity::loader::Section for DatabaseConfig {
327 const KEY: &'static str = "database";
328}
Deleted lib/config/src/identity/email.rs
@@ -1,64 +0,0 @@
1use serde::{Deserialize, Serialize};
2
3use crate::identity::loader::Section;
4
5/// 邮件发送的本地队列配置。
6///
7/// 邮件不再经过 RocketMQ:请求进入进程内有界 channel,由内嵌 worker 直接走 SMTP。
8/// 队列满时按反压阻塞,等满 `enqueue_timeout_ms` 仍未腾出空间就把错误抛回调用方。
9#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
10#[serde(default)]
11pub struct EmailPoolConfig {
12 /// 并发发送 worker 数。
13 pub workers: usize,
14 /// 队列容量(条)。
15 pub capacity: usize,
16 /// 入队阻塞等待上限,超过即报队列已满。
17 pub enqueue_timeout_ms: u64,
18 /// 单次发送失败后的重试间隔。
19 pub retry_delay_ms: u64,
20 /// 单条消息的最大尝试次数(含首次)。
21 pub max_attempts: u32,
22}
23
24impl EmailPoolConfig {
25 pub fn is_valid(&self) -> Result<(), String> {
26 if self.workers == 0 {
27 return Err("email.workers must be greater than 0".to_string());
28 }
29 if self.capacity == 0 {
30 return Err("email.capacity must be greater than 0".to_string());
31 }
32 if self.enqueue_timeout_ms == 0 {
33 return Err("email.enqueue_timeout_ms must be greater than 0".to_string());
34 }
35 if !(1..=100).contains(&self.max_attempts) {
36 return Err("email.max_attempts must be between 1 and 100".to_string());
37 }
38 Ok(())
39 }
40
41 pub fn enqueue_timeout(&self) -> std::time::Duration {
42 std::time::Duration::from_millis(self.enqueue_timeout_ms)
43 }
44
45 pub fn retry_delay(&self) -> std::time::Duration {
46 std::time::Duration::from_millis(self.retry_delay_ms)
47 }
48}
49
50impl Default for EmailPoolConfig {
51 fn default() -> Self {
52 Self {
53 workers: 4,
54 capacity: 1024,
55 enqueue_timeout_ms: 5_000,
56 retry_delay_ms: 2_000,
57 max_attempts: 3,
58 }
59 }
60}
61
62impl Section for EmailPoolConfig {
63 const KEY: &'static str = "email";
64}
Deleted lib/config/src/identity/error.rs
@@ -1,55 +0,0 @@
1use std::fmt;
2use std::path::PathBuf;
3
4#[derive(Debug)]
5pub enum ConfigError {
6 MissingConfig { candidates: Vec<PathBuf> },
7 Missing { path: PathBuf },
8 Ambiguous { candidates: Vec<PathBuf> },
9 Section { key: String, path: PathBuf },
10 Load { path: PathBuf, message: String },
11 Validation(String),
12}
13
14impl ConfigError {
15 pub fn validation(message: impl Into<String>) -> Self {
16 Self::Validation(message.into())
17 }
18}
19
20impl fmt::Display for ConfigError {
21 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::MissingConfig { candidates } => write!(
24 formatter,
25 "no config file found, expected one of {} (or set KENOAUTH_CONFIG)",
26 list(candidates)
27 ),
28 Self::Missing { path } => {
29 write!(formatter, "config file {} does not exist", path.display())
30 }
31 Self::Ambiguous { candidates } => write!(
32 formatter,
33 "several config files found, keep exactly one of {}",
34 list(candidates)
35 ),
36 Self::Section { key, path } => {
37 write!(formatter, "{} has no `{key}` section", path.display())
38 }
39 Self::Load { path, message } => {
40 write!(formatter, "failed to load {}: {message}", path.display())
41 }
42 Self::Validation(message) => write!(formatter, "invalid config: {message}"),
43 }
44 }
45}
46
47impl std::error::Error for ConfigError {}
48
49fn list(paths: &[PathBuf]) -> String {
50 paths
51 .iter()
52 .map(|path| path.display().to_string())
53 .collect::<Vec<_>>()
54 .join(", ")
55}
Deleted lib/config/src/identity/loader.rs
@@ -1,162 +0,0 @@
1use std::env;
2use std::path::{Path, PathBuf};
3
4use figment::Figment;
5use figment::providers::{Format, Json, Toml, Yaml};
6use serde::de::DeserializeOwned;
7
8use crate::identity::error::ConfigError;
9use crate::identity::rocketmq::RocketMqConfig;
10
11/// 配置文件所在目录,仓库根下为 `config`。
12pub const ROOT_DIR: &str = "config";
13pub const FILE_STEM: &str = "config";
14/// 覆盖配置路径的环境变量;设置后不再看 `config/config.{yaml,yml,toml,json}`。
15pub const PATH_VAR: &str = "KENOAUTH_CONFIG";
16pub const EXTENSIONS: &[&str] = &["yaml", "yml", "toml", "json"];
17
18pub trait Section: DeserializeOwned + Sized {
19 const KEY: &'static str;
20
21 fn load() -> Result<Self, ConfigError> {
22 Config::load()?.section()
23 }
24
25 fn load_from(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
26 Config::load_from(path)?.section()
27 }
28
29 fn load_or_default() -> Result<Self, ConfigError>
30 where
31 Self: Default,
32 {
33 Config::load()?.section_or_default()
34 }
35}
36
37#[derive(Debug, Clone)]
38pub struct Config {
39 path: PathBuf,
40 figment: Figment,
41}
42
43impl Config {
44 pub fn from_value(value: serde_json::Value) -> Self {
45 Self {
46 path: PathBuf::from("config/local.toml [identity]"),
47 figment: Figment::from(figment::providers::Serialized::defaults(value)),
48 }
49 }
50
51 /// 加载默认配置:`$KenaOAUTH_CONFIG`,否则 `config/config.{yaml,yml,toml,json}`。
52 pub fn load() -> Result<Self, ConfigError> {
53 if let Some(path) = env::var(PATH_VAR)
54 .ok()
55 .map(|value| value.trim().to_owned())
56 .filter(|value| !value.is_empty())
57 {
58 return Self::load_from(path);
59 }
60 Self::load_from(resolve_default()?)
61 }
62
63 /// 加载指定文件。
64 pub fn load_from(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
65 let path = path.as_ref().to_path_buf();
66 if !path.is_file() {
67 return Err(ConfigError::Missing { path });
68 }
69 Ok(Self {
70 figment: figment(&path),
71 path,
72 })
73 }
74
75 pub fn path(&self) -> &Path {
76 &self.path
77 }
78
79 /// 读取一个配置段;段必须存在。
80 pub fn section<T: Section>(&self) -> Result<T, ConfigError> {
81 if !self.has(T::KEY) {
82 return Err(ConfigError::Section {
83 key: T::KEY.to_owned(),
84 path: self.path.clone(),
85 });
86 }
87 self.get(T::KEY)
88 }
89
90 /// 读取一个配置段;段缺失时取该类型的默认值。
91 pub fn section_or_default<T: Section + Default>(&self) -> Result<T, ConfigError> {
92 if !self.has(T::KEY) {
93 return Ok(T::default());
94 }
95 self.get(T::KEY)
96 }
97
98 pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
99 self.figment
100 .extract_inner(key)
101 .map_err(|error| ConfigError::Load {
102 path: self.path.clone(),
103 message: format!("{key}: {error}"),
104 })
105 }
106
107 pub fn has(&self, key: &str) -> bool {
108 self.figment.find_value(key).is_ok()
109 }
110
111 pub fn rocketmq(&self) -> Result<Option<RocketMqConfig>, ConfigError> {
112 if self.has(RocketMqConfig::KEY) {
113 return Ok(Some(self.get(RocketMqConfig::KEY)?));
114 }
115 Ok(None)
116 }
117
118 /// 叠加一个 `PREFIX` 环境变量层,用于部署时覆盖单个字段
119 /// (`KENOAUTH_DATABASE__URL` 覆盖 `database.url`,`__` 表示层级)。
120 pub fn with_env(mut self, prefix: &str) -> Result<Self, ConfigError> {
121 self.figment = self
122 .figment
123 .merge(figment::providers::Env::prefixed(prefix).split("__"));
124 Ok(self)
125 }
126}
127
128pub fn candidates() -> Vec<PathBuf> {
129 let base = Path::new(ROOT_DIR).join(FILE_STEM);
130 EXTENSIONS
131 .iter()
132 .map(|extension| base.with_extension(extension))
133 .collect()
134}
135
136fn resolve_default() -> Result<PathBuf, ConfigError> {
137 let mut found = candidates().into_iter().filter(|path| path.is_file());
138 let Some(path) = found.next() else {
139 return Err(ConfigError::MissingConfig {
140 candidates: candidates(),
141 });
142 };
143 let rest: Vec<PathBuf> = found.collect();
144 if rest.is_empty() {
145 return Ok(path);
146 }
147 let mut all = vec![path];
148 all.extend(rest);
149 Err(ConfigError::Ambiguous { candidates: all })
150}
151
152fn figment(path: &Path) -> Figment {
153 let extension = path
154 .extension()
155 .and_then(|extension| extension.to_str())
156 .map(str::to_ascii_lowercase);
157 match extension.as_deref() {
158 Some("json") => Figment::from(Json::file(path)),
159 Some("toml") => Figment::from(Toml::file(path)),
160 _ => Figment::from(Yaml::file(path)),
161 }
162}
Deleted lib/config/src/identity/mod.rs
@@ -1,19 +0,0 @@
1pub mod app;
2pub mod auth;
3pub mod core;
4pub mod database;
5pub mod email;
6pub mod error;
7pub mod jwt;
8pub mod loader;
9pub use crate::pow;
10pub mod redis;
11pub use crate::rocketmq;
12pub mod server;
13pub mod service_auth;
14pub mod session;
15pub mod smtp;
16pub mod web;
17
18pub use error::ConfigError;
19pub use loader::{Config, Section};
Deleted lib/config/src/identity/redis.rs
@@ -1,284 +0,0 @@
1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_HOST: &str = "127.0.0.1";
6pub const DEFAULT_PORT: u16 = 6379;
7pub const DEFAULT_POOL_SIZE: usize = 16;
8pub const DEFAULT_TIMEOUT_SECS: u64 = 5;
9pub const DEFAULT_TTL_SECS: u64 = 300;
10
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
12pub struct RedisConfig {
13 #[serde(default = "default_host")]
14 pub host: String,
15 #[serde(default = "default_port")]
16 pub port: u16,
17 #[serde(default)]
18 pub username: Option<String>,
19 #[serde(default)]
20 pub password: Option<String>,
21 #[serde(default)]
22 pub db: Option<i64>,
23 #[serde(default)]
24 pub r#type: RedisType,
25 #[serde(default)]
26 pub nodes: Vec<String>,
27 #[serde(default)]
28 pub tls: bool,
29 #[serde(default)]
30 pub key_prefix: String,
31 #[serde(default)]
32 pub default_ttl_secs: Option<u64>,
33 #[serde(default)]
34 pub pool: RedisPoolConfig,
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
38pub enum RedisType {
39 #[default]
40 Standard,
41 Cluster,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
45pub struct RedisPoolConfig {
46 #[serde(default = "default_pool_size")]
47 pub max_size: usize,
48 #[serde(default = "default_timeout_secs")]
49 pub wait_timeout_secs: u64,
50 #[serde(default = "default_timeout_secs")]
51 pub create_timeout_secs: u64,
52 #[serde(default = "default_timeout_secs")]
53 pub recycle_timeout_secs: u64,
54}
55
56impl Default for RedisPoolConfig {
57 fn default() -> Self {
58 Self {
59 max_size: DEFAULT_POOL_SIZE,
60 wait_timeout_secs: DEFAULT_TIMEOUT_SECS,
61 create_timeout_secs: DEFAULT_TIMEOUT_SECS,
62 recycle_timeout_secs: DEFAULT_TIMEOUT_SECS,
63 }
64 }
65}
66
67impl RedisPoolConfig {
68 pub fn wait_timeout(&self) -> Duration {
69 Duration::from_secs(self.wait_timeout_secs)
70 }
71
72 pub fn create_timeout(&self) -> Duration {
73 Duration::from_secs(self.create_timeout_secs)
74 }
75
76 pub fn recycle_timeout(&self) -> Duration {
77 Duration::from_secs(self.recycle_timeout_secs)
78 }
79
80 pub fn validate(&self) -> Result<(), String> {
81 if self.max_size == 0 {
82 return Err("redis.pool.max_size must be greater than 0".to_string());
83 }
84 if self.wait_timeout_secs == 0 {
85 return Err("redis.pool.wait_timeout_secs must be greater than 0".to_string());
86 }
87 if self.create_timeout_secs == 0 {
88 return Err("redis.pool.create_timeout_secs must be greater than 0".to_string());
89 }
90 if self.recycle_timeout_secs == 0 {
91 return Err("redis.pool.recycle_timeout_secs must be greater than 0".to_string());
92 }
93 Ok(())
94 }
95}
96
97impl Default for RedisConfig {
98 fn default() -> Self {
99 Self {
100 host: DEFAULT_HOST.to_string(),
101 port: DEFAULT_PORT,
102 username: None,
103 password: None,
104 db: None,
105 r#type: RedisType::Standard,
106 nodes: Vec::new(),
107 tls: false,
108 key_prefix: String::new(),
109 default_ttl_secs: Some(DEFAULT_TTL_SECS),
110 pool: RedisPoolConfig::default(),
111 }
112 }
113}
114
115impl RedisConfig {
116 pub fn is_cluster(&self) -> bool {
117 matches!(self.r#type, RedisType::Cluster)
118 }
119
120 pub fn database(&self) -> i64 {
121 self.db.unwrap_or(0)
122 }
123
124 pub fn address(&self) -> (String, u16) {
125 (self.host.clone(), self.port)
126 }
127
128 pub fn seeds(&self) -> Result<Vec<(String, u16)>, String> {
129 if self.nodes.is_empty() {
130 return Ok(vec![(self.host.clone(), self.port)]);
131 }
132 self.nodes.iter().map(|node| parse_node(node)).collect()
133 }
134
135 pub fn key_prefix(&self) -> &str {
136 &self.key_prefix
137 }
138
139 pub fn default_ttl(&self) -> Option<Duration> {
140 self.default_ttl_secs.map(Duration::from_secs)
141 }
142
143 pub fn has_credentials(&self) -> bool {
144 self.username.is_some() || self.password.is_some()
145 }
146
147 pub fn display_url(&self) -> String {
148 let scheme = match (self.is_cluster(), self.tls) {
149 (true, true) => "rediss-cluster",
150 (true, false) => "redis-cluster",
151 (false, true) => "rediss",
152 (false, false) => "redis",
153 };
154 // 这是可以出现在日志与 /status 里的字符串:密码只能以掩码形式出现。
155 let credentials = match (&self.username, &self.password) {
156 (Some(user), Some(_)) => {
157 format!(
158 "{}:<redacted>@",
159 crate::identity::database::url_encode(user)
160 )
161 }
162 (None, Some(_)) => "<redacted>@".to_string(),
163 (Some(user), None) => {
164 format!("{}@", crate::identity::database::url_encode(user))
165 }
166 (None, None) => String::new(),
167 };
168 format!(
169 "{}://{}{}:{}/{}",
170 scheme,
171 credentials,
172 bracketed_host(&self.host),
173 self.port,
174 self.database()
175 )
176 }
177
178 pub fn validate(&self) -> Result<(), String> {
179 let seeds = self.seeds()?;
180 if seeds.is_empty() {
181 return Err("redis requires at least one node".to_string());
182 }
183 for (host, port) in &seeds {
184 if host.trim().is_empty() {
185 return Err("redis. host must be non-empty".to_string());
186 }
187 if *port == 0 {
188 return Err(format!("redis node {host} has port 0"));
189 }
190 }
191 if self.database() < 0 {
192 return Err("redis.db must be non-negative".to_string());
193 }
194 if self.username.is_some() && self.password.is_none() {
195 return Err("redis.username requires redis.password".to_string());
196 }
197 if self.is_cluster() && self.database() != 0 {
198 return Err("redis cluster requires db = 0".to_string());
199 }
200 if self.key_prefix.contains(char::is_whitespace) {
201 return Err("redis.key_prefix must not contain whitespace".to_string());
202 }
203 if self.default_ttl_secs == Some(0) {
204 return Err("redis.default_ttl_secs must be greater than 0".to_string());
205 }
206 self.pool.validate()
207 }
208}
209
210impl crate::identity::loader::Section for RedisConfig {
211 const KEY: &'static str = "redis";
212}
213
214fn parse_node(node: &str) -> Result<(String, u16), String> {
215 let trimmed = node.trim();
216 if trimmed.is_empty() {
217 return Err("redis.nodes contains an empty entry".to_string());
218 }
219 let without_scheme = trimmed
220 .strip_prefix("redis://")
221 .or_else(|| trimmed.strip_prefix("rediss://"))
222 .unwrap_or(trimmed);
223 let authority = without_scheme
224 .split(['/', '?'])
225 .next()
226 .unwrap_or(without_scheme);
227 let authority = match authority.rsplit_once('@') {
228 Some((_, host)) => host,
229 None => authority,
230 };
231
232 if let Some(rest) = authority.strip_prefix('[') {
233 let (host, tail) = rest
234 .split_once(']')
235 .ok_or_else(|| format!("redis node {node:?} has an unterminated IPv6 address"))?;
236 let port = match tail.strip_prefix(':') {
237 Some(port) => parse_port(port, node)?,
238 None => DEFAULT_PORT,
239 };
240 return Ok((host.to_string(), port));
241 }
242
243 let colons = authority.matches(':').count();
244 if colons == 0 {
245 return Ok((authority.to_string(), DEFAULT_PORT));
246 }
247 if colons == 1 {
248 let (host, port) = authority
249 .rsplit_once(':')
250 .ok_or_else(|| format!("redis node {node:?} is invalid"))?;
251 return Ok((host.to_string(), parse_port(port, node)?));
252 }
253 Ok((authority.to_string(), DEFAULT_PORT))
254}
255
256fn parse_port(port: &str, node: &str) -> Result<u16, String> {
257 port.parse::<u16>()
258 .map_err(|_| format!("redis node {node:?} has an invalid port {port:?}"))
259}
260
261fn bracketed_host(host: &str) -> String {
262 let trimmed = host.trim();
263 if trimmed.contains(':') && !trimmed.starts_with('[') {
264 format!("[{trimmed}]")
265 } else {
266 trimmed.to_string()
267 }
268}
269
270fn default_host() -> String {
271 DEFAULT_HOST.to_string()
272}
273
274fn default_port() -> u16 {
275 DEFAULT_PORT
276}
277
278fn default_pool_size() -> usize {
279 DEFAULT_POOL_SIZE
280}
281
282fn default_timeout_secs() -> u64 {
283 DEFAULT_TIMEOUT_SECS
284}
Deleted lib/config/src/identity/web.rs
@@ -1,169 +0,0 @@
1use serde::{Deserialize, Serialize};
2
3pub const DEFAULT_CORS_ALLOW_METHODS: &[&str] =
4 &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
5pub const DEFAULT_CORS_ALLOW_HEADERS: &[&str] = &[
6 "accept",
7 "authorization",
8 "content-type",
9 "x-csrf-token",
10 "x-request-id",
11];
12pub const DEFAULT_CORS_EXPOSE_HEADERS: &[&str] = &[
13 "cache-control",
14 "content-disposition",
15 "etag",
16 "location",
17 "retry-after",
18 "x-request-id",
19];
20pub const DEFAULT_CORS_MAX_AGE_SECS: u64 = 600;
21pub const MAX_CORS_MAX_AGE_SECS: u64 = 86_400;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct WebConfig {
26 pub web_base_url: String,
27 pub cors_allow_origins: Vec<String>,
28 pub cors_allow_methods: Vec<String>,
29 pub cors_allow_headers: Vec<String>,
30 pub cors_expose_headers: Vec<String>,
31 pub cors_allow_credentials: bool,
32 pub cors_max_age_secs: u64,
33}
34
35impl Default for WebConfig {
36 fn default() -> Self {
37 Self {
38 web_base_url: String::new(),
39 cors_allow_origins: Vec::new(),
40 cors_allow_methods: expand(DEFAULT_CORS_ALLOW_METHODS),
41 cors_allow_headers: expand(DEFAULT_CORS_ALLOW_HEADERS),
42 cors_expose_headers: expand(DEFAULT_CORS_EXPOSE_HEADERS),
43 cors_allow_credentials: false,
44 cors_max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
45 }
46 }
47}
48
49impl WebConfig {
50 pub fn allow_origins(&self) -> Vec<String> {
51 let mut origins: Vec<String> = Vec::new();
52 for raw in std::iter::once(&self.web_base_url).chain(self.cors_allow_origins.iter()) {
53 let origin = raw.trim().trim_end_matches('/');
54 if origin.is_empty() {
55 continue;
56 }
57 if origins
58 .iter()
59 .any(|existing| existing.eq_ignore_ascii_case(origin))
60 {
61 continue;
62 }
63 origins.push(origin.to_owned());
64 }
65 origins
66 }
67
68 pub fn allow_any_origin(&self) -> bool {
69 self.allow_origins().iter().any(|origin| origin == "*")
70 }
71
72 pub fn allow_methods(&self) -> Vec<String> {
73 resolve(&self.cors_allow_methods, DEFAULT_CORS_ALLOW_METHODS)
74 }
75
76 pub fn allow_headers(&self) -> Vec<String> {
77 resolve(&self.cors_allow_headers, DEFAULT_CORS_ALLOW_HEADERS)
78 }
79
80 pub fn expose_headers(&self) -> Vec<String> {
81 resolve(&self.cors_expose_headers, DEFAULT_CORS_EXPOSE_HEADERS)
82 }
83
84 pub fn is_valid(&self) -> Result<(), String> {
85 // `web_base_url` 会并入实际允许来源,通配符判定必须基于合并后的集合。
86 let wildcard = self.allow_any_origin();
87 if wildcard && self.allow_origins().len() != 1 {
88 return Err(
89 "the allowed origins must list \"*\" alone or name explicit origins".into(),
90 );
91 }
92 if wildcard && self.cors_allow_credentials {
93 return Err(
94 "web.cors_allow_credentials must be false when the allowed origins include \"*\""
95 .into(),
96 );
97 }
98 for origin in &self.cors_allow_origins {
99 let origin = origin.trim();
100 if origin.is_empty() || origin == "*" {
101 continue;
102 }
103 if !is_origin(origin) {
104 return Err(format!(
105 "web.cors_allow_origins entry {origin:?} must be scheme://host[:port] without path or trailing slash"
106 ));
107 }
108 }
109 let base = self.web_base_url.trim().trim_end_matches('/');
110 if !base.is_empty() && base != "*" && !is_origin(base) {
111 return Err(format!(
112 "web.web_base_url {base:?} must be scheme://host[:port] without path"
113 ));
114 }
115 for method in &self.cors_allow_methods {
116 let method = method.trim();
117 if method.is_empty() || !method.bytes().all(|byte| byte.is_ascii_uppercase()) {
118 return Err(format!(
119 "web.cors_allow_methods entry {method:?} must be an upper case HTTP method"
120 ));
121 }
122 }
123 for header in &self.cors_allow_headers {
124 if header.trim().is_empty() {
125 return Err("web.cors_allow_headers entry must not be empty".into());
126 }
127 }
128 for header in &self.cors_expose_headers {
129 if header.trim().is_empty() {
130 return Err("web.cors_expose_headers entry must not be empty".into());
131 }
132 }
133 if self.cors_max_age_secs > MAX_CORS_MAX_AGE_SECS {
134 return Err(format!(
135 "web.cors_max_age_secs must be between 0 and {MAX_CORS_MAX_AGE_SECS}"
136 ));
137 }
138 Ok(())
139 }
140}
141
142fn is_origin(origin: &str) -> bool {
143 let Some(rest) = origin
144 .strip_prefix("https://")
145 .or_else(|| origin.strip_prefix("http://"))
146 else {
147 return false;
148 };
149 !rest.is_empty() && !rest.contains(['/', '?', '#', ' ', ','])
150}
151
152fn resolve(configured: &[String], fallback: &[&str]) -> Vec<String> {
153 if configured.is_empty() {
154 return expand(fallback);
155 }
156 configured
157 .iter()
158 .map(|value| value.trim().to_owned())
159 .filter(|value| !value.is_empty())
160 .collect()
161}
162
163fn expand(values: &[&str]) -> Vec<String> {
164 values.iter().map(|value| (*value).to_owned()).collect()
165}
166
167impl crate::identity::loader::Section for WebConfig {
168 const KEY: &'static str = "web";
169}
Modified lib/config/src/auth.rs View lib/config/src/auth.rs
@@ -1,6 +1,139 @@
11use base64::Engine;
22use serde::Deserialize;
33
4pub const TOTP_KEY_MIN_BYTES: usize = 32;
5
6#[derive(Clone, Deserialize)]
7#[serde(default)]
8pub struct OAuthConfig {
9 pub public_url: String,
10 pub clients: Vec<OAuthClient>,
11 pub socket_origins: Vec<String>,
12 #[serde(default = "default_access_token_ttl")]
13 pub access_token_ttl_secs: i64,
14 #[serde(default = "default_id_token_ttl")]
15 pub id_token_ttl_secs: i64,
16 #[serde(default = "default_refresh_token_ttl")]
17 pub refresh_token_ttl_secs: i64,
18 #[serde(default = "default_code_ttl")]
19 pub code_ttl_secs: i64,
20 #[serde(default = "default_true")]
21 pub require_consent: bool,
22 #[serde(default = "default_true")]
23 pub allow_wildcard_redirect: bool,
24 #[serde(default)]
25 pub rpc_key: String,
26}
27
28impl Default for OAuthConfig {
29 fn default() -> Self {
30 Self {
31 public_url: String::new(),
32 clients: Vec::new(),
33 socket_origins: Vec::new(),
34 access_token_ttl_secs: default_access_token_ttl(),
35 id_token_ttl_secs: default_id_token_ttl(),
36 refresh_token_ttl_secs: default_refresh_token_ttl(),
37 code_ttl_secs: default_code_ttl(),
38 require_consent: true,
39 allow_wildcard_redirect: true,
40 rpc_key: String::new(),
41 }
42 }
43}
44
45fn default_access_token_ttl() -> i64 {
46 600
47}
48
49fn default_id_token_ttl() -> i64 {
50 300
51}
52
53fn default_refresh_token_ttl() -> i64 {
54 2_592_000
55}
56
57fn default_code_ttl() -> i64 {
58 60
59}
60
61fn default_true() -> bool {
62 true
63}
64
65impl std::fmt::Debug for OAuthConfig {
66 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 formatter
68 .debug_struct("OAuthConfig")
69 .field("public_url", &self.public_url)
70 .field("clients", &self.clients)
71 .field("socket_origins", &self.socket_origins)
72 .field("access_token_ttl_secs", &self.access_token_ttl_secs)
73 .field("id_token_ttl_secs", &self.id_token_ttl_secs)
74 .field("refresh_token_ttl_secs", &self.refresh_token_ttl_secs)
75 .field("code_ttl_secs", &self.code_ttl_secs)
76 .field("require_consent", &self.require_consent)
77 .field("allow_wildcard_redirect", &self.allow_wildcard_redirect)
78 .field("rpc_key", &"<redacted>")
79 .finish()
80 }
81}
82
83#[derive(Clone, Deserialize)]
84#[serde(default)]
85pub struct OAuthClient {
86 pub client_id: String,
87 pub name: String,
88 pub kind: models::users::oauth_clients::ClientKind,
89 pub secret: Option<String>,
90 pub audience: Option<String>,
91 #[serde(default = "default_true")]
92 pub enabled: bool,
93 pub redirect_uris: Vec<String>,
94 pub post_logout_redirect_uris: Vec<String>,
95 pub backchannel_logout_uris: Vec<String>,
96 pub scopes: Vec<String>,
97 pub service_scopes: Vec<String>,
98}
99
100impl Default for OAuthClient {
101 fn default() -> Self {
102 Self {
103 client_id: String::new(),
104 name: String::new(),
105 kind: models::users::oauth_clients::ClientKind::Public,
106 secret: None,
107 audience: None,
108 enabled: true,
109 redirect_uris: Vec::new(),
110 post_logout_redirect_uris: Vec::new(),
111 backchannel_logout_uris: Vec::new(),
112 scopes: Vec::new(),
113 service_scopes: Vec::new(),
114 }
115 }
116}
117
118impl std::fmt::Debug for OAuthClient {
119 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 formatter
121 .debug_struct("OAuthClient")
122 .field("client_id", &self.client_id)
123 .field("name", &self.name)
124 .field("kind", &self.kind)
125 .field("secret", &self.secret.as_ref().map(|_| "<redacted>"))
126 .field("audience", &self.audience)
127 .field("enabled", &self.enabled)
128 .field("redirect_uris", &self.redirect_uris)
129 .field("post_logout_redirect_uris", &self.post_logout_redirect_uris)
130 .field("backchannel_logout_uris", &self.backchannel_logout_uris)
131 .field("scopes", &self.scopes)
132 .field("service_scopes", &self.service_scopes)
133 .finish()
134 }
135}
136
4137#[derive(Debug, Clone, Deserialize)]
5138#[serde(default)]
6139pub struct PasswordPolicy {
@@ -35,6 +168,7 @@
35168#[derive(Debug, Clone, Deserialize)]
36169#[serde(default)]
37170pub struct AuthConfig {
171 pub oauth: OAuthConfig,
38172 pub password_policy: PasswordPolicy,
39173 pub verify_code_ttl_secs: u64,
40174 pub verify_code_len: usize,
@@ -50,6 +184,7 @@
50184impl Default for AuthConfig {
51185 fn default() -> Self {
52186 Self {
187 oauth: OAuthConfig::default(),
53188 password_policy: PasswordPolicy::default(),
54189 verify_code_ttl_secs: 600,
55190 verify_code_len: 6,
@@ -78,61 +213,7 @@
78213}
79214
80215impl AuthConfig {
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn with_password_policy(mut self, policy: PasswordPolicy) -> Self {
86 self.password_policy = policy;
87 self
88 }
89
90 pub fn with_verify_code_ttl_secs(mut self, secs: u64) -> Self {
91 self.verify_code_ttl_secs = secs;
92 self
93 }
94
95 pub fn with_verify_code_len(mut self, len: usize) -> Self {
96 self.verify_code_len = len;
97 self
98 }
99
100 pub fn with_reset_token_ttl_secs(mut self, secs: u64) -> Self {
101 self.reset_token_ttl_secs = secs;
102 self
103 }
104
105 pub fn with_reset_token_bytes(mut self, bytes: usize) -> Self {
106 self.reset_token_bytes = bytes;
107 self
108 }
109
110 pub fn with_email_code_rate_limit_secs(mut self, secs: u64) -> Self {
111 self.email_code_rate_limit_secs = secs;
112 self
113 }
114
115 pub fn with_max_verify_attempts(mut self, attempts: u64) -> Self {
116 self.max_verify_attempts = attempts;
117 self
118 }
119
120 pub fn with_min_age(mut self, min_age: u8) -> Self {
121 self.min_age = min_age;
122 self
123 }
124
125 pub fn with_totp_encryption_key(mut self, key: impl Into<String>) -> Self {
126 self.totp_encryption_key = Some(key.into());
127 self
128 }
129
130 pub fn with_agreements(mut self, agreements: Vec<AgreementDocConfig>) -> Self {
131 self.agreements = agreements;
132 self
133 }
134
135 pub fn validate(&self) -> Result<(), String> {
216 fn validate_common(&self) -> Result<(), String> {
136217 if !(4..=10).contains(&self.verify_code_len) {
137218 return Err("auth.verify_code_len must be between 4 and 10".to_string());
138219 }
@@ -151,12 +232,17 @@
151232 if self.max_verify_attempts == 0 {
152233 return Err("auth.max_verify_attempts must be greater than 0".to_string());
153234 }
235 Ok(())
236 }
237
238 pub fn validate(&self) -> Result<(), String> {
239 self.validate_common()?;
154240 if let Some(key) = &self.totp_encryption_key {
155241 let length = base64::engine::general_purpose::STANDARD
156242 .decode(key)
157243 .map(|bytes| bytes.len())
158244 .unwrap_or(0);
159 if length < 32 {
245 if length < TOTP_KEY_MIN_BYTES {
160246 return Err(
161247 "auth.totp_encryption_key must be base64 of at least 32 bytes".to_string(),
162248 );
@@ -164,4 +250,32 @@
164250 }
165251 Ok(())
166252 }
253
254 pub fn validate_auth(&self) -> Result<(), String> {
255 self.validate_common()?;
256 let Some(key) = &self.totp_encryption_key else {
257 return Err("auth.totp_encryption_key is required".to_string());
258 };
259 if key.trim().starts_with("REPLACE_WITH_") {
260 return Err(
261 "auth.totp_encryption_key is still set to the placeholder from \
262 config.yaml.example"
263 .to_string(),
264 );
265 }
266 let length = base64::engine::general_purpose::STANDARD
267 .decode(key.trim())
268 .map(|bytes| bytes.len())
269 .unwrap_or(0);
270 if length < TOTP_KEY_MIN_BYTES {
271 return Err(format!(
272 "auth.totp_encryption_key must be base64 of at least {TOTP_KEY_MIN_BYTES} bytes"
273 ));
274 }
275 Ok(())
276 }
277}
278
279impl crate::loader::Section for AuthConfig {
280 const KEY: &'static str = "auth";
167281}
Modified lib/config/src/database.rs View lib/config/src/database.rs
@@ -164,14 +164,6 @@
164164 endpoint.url()
165165 }
166166
167 pub fn replica_url(&self, index: usize) -> Option<String> {
168 self.replicas.get(index).map(|r| r.url())
169 }
170
171 pub fn replica_urls(&self) -> Vec<String> {
172 self.replicas.iter().map(|r| r.url()).collect()
173 }
174
175167 pub fn replica_count(&self) -> usize {
176168 self.replicas.len()
177169 }
Modified lib/config/src/email.rs View lib/config/src/email.rs
@@ -1,28 +1,11 @@
11use serde::{Deserialize, Serialize};
22
3#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4#[serde(default)]
5pub struct EmailRpcConfig {
6 pub endpoint: String,
7 pub rpc_key: String,
8 pub timeout_ms: Option<u64>,
9}
10
11impl EmailRpcConfig {
12 pub fn enabled(&self) -> bool {
13 !self.endpoint.is_empty()
14 }
15
16 pub fn timeout_ms(&self) -> u64 {
17 self.timeout_ms.unwrap_or(2000)
18 }
19}
3use crate::loader::Section;
204
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226#[serde(default)]
237pub struct EmailConfig {
248 pub default_locale: Option<String>,
25 pub rpc: EmailRpcConfig,
269}
2710
2811impl EmailConfig {
@@ -30,3 +13,55 @@
3013 self.default_locale.as_deref().unwrap_or("en")
3114 }
3215}
16
17#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(default)]
19pub struct EmailPoolConfig {
20 pub workers: usize,
21 pub capacity: usize,
22 pub enqueue_timeout_ms: u64,
23 pub retry_delay_ms: u64,
24 pub max_attempts: u32,
25}
26
27impl EmailPoolConfig {
28 pub fn is_valid(&self) -> Result<(), String> {
29 if self.workers == 0 {
30 return Err("email.workers must be greater than 0".to_string());
31 }
32 if self.capacity == 0 {
33 return Err("email.capacity must be greater than 0".to_string());
34 }
35 if self.enqueue_timeout_ms == 0 {
36 return Err("email.enqueue_timeout_ms must be greater than 0".to_string());
37 }
38 if !(1..=100).contains(&self.max_attempts) {
39 return Err("email.max_attempts must be between 1 and 100".to_string());
40 }
41 Ok(())
42 }
43
44 pub fn enqueue_timeout(&self) -> std::time::Duration {
45 std::time::Duration::from_millis(self.enqueue_timeout_ms)
46 }
47
48 pub fn retry_delay(&self) -> std::time::Duration {
49 std::time::Duration::from_millis(self.retry_delay_ms)
50 }
51}
52
53impl Default for EmailPoolConfig {
54 fn default() -> Self {
55 Self {
56 workers: 4,
57 capacity: 1024,
58 enqueue_timeout_ms: 5_000,
59 retry_delay_ms: 2_000,
60 max_attempts: 3,
61 }
62 }
63}
64
65impl Section for EmailPoolConfig {
66 const KEY: &'static str = "email";
67}
Modified lib/config/src/error.rs View lib/config/src/error.rs
@@ -1,3 +1,6 @@
1use std::fmt;
2use std::path::PathBuf;
3
14use thiserror::Error;
25
36#[derive(Debug, Error)]
@@ -22,3 +25,24 @@
2225 Self::Figment(Box::new(error))
2326 }
2427}
28
29#[derive(Debug)]
30pub enum ConfigLoadError {
31 Section { key: String, path: PathBuf },
32 Load { path: PathBuf, message: String },
33}
34
35impl fmt::Display for ConfigLoadError {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Self::Section { key, path } => {
39 write!(formatter, "{} has no `{key}` section", path.display())
40 }
41 Self::Load { path, message } => {
42 write!(formatter, "failed to load {}: {message}", path.display())
43 }
44 }
45 }
46}
47
48impl std::error::Error for ConfigLoadError {}
Deleted lib/config/src/githost.rs
@@ -1,36 +0,0 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct GithostConfig {
8 pub enabled: Option<bool>,
9 pub http_bind_addr: Option<IpAddr>,
10 pub http_port: Option<u16>,
11}
12
13impl GithostConfig {
14 pub fn enabled(&self) -> bool {
15 self.enabled.unwrap_or(true)
16 }
17
18 pub fn http_bind_addr(&self) -> IpAddr {
19 self.http_bind_addr
20 .unwrap_or_else(|| "0.0.0.0".parse().expect("static ip"))
21 }
22
23 pub fn http_port(&self) -> u16 {
24 self.http_port.unwrap_or(9418)
25 }
26
27 pub fn is_valid(&self) -> Result<(), String> {
28 if !self.enabled() {
29 return Ok(());
30 }
31 if self.http_port() == 0 {
32 return Err("githost.http_port must be greater than 0".to_string());
33 }
34 Ok(())
35 }
36}
Renamed lib/config/src/identity/jwt.rs → lib/config/src/jwt.rs View lib/config/src/jwt.rs
@@ -77,6 +77,6 @@
7777 "k1".to_owned()
7878}
7979
80impl crate::identity::loader::Section for JwtConfig {
80impl crate::loader::Section for JwtConfig {
8181 const KEY: &'static str = "jwt";
8282}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,12 +1,13 @@
11pub mod apihost;
2pub mod app;
23pub mod auth;
34pub mod chat;
45pub mod database;
56pub mod email;
67pub mod git;
7pub mod githost;
88pub mod internal;
99pub mod issues;
10pub mod jwt;
1011pub mod livekit;
1112pub mod oauth;
1213pub mod pow;
@@ -16,16 +17,20 @@
1617pub mod rocketmq;
1718pub mod s3;
1819pub mod scoped;
20pub mod server;
21pub mod service_auth;
22pub mod session;
23pub mod smtp;
1924pub mod space;
2025pub mod user;
2126pub mod web;
2227
2328pub mod error;
24pub mod identity;
2529pub mod loader;
2630pub mod net;
2731
2832pub use error::ConfigError;
33pub use loader::{Config, Section};
2934pub use net::is_loopback_host;
3035
3136use crate::apihost::ApihostConfig;
@@ -34,7 +39,6 @@
3439use crate::database::DatabaseConfig;
3540use crate::email::EmailConfig;
3641use crate::git::GitConfig;
37use crate::githost::GithostConfig;
3842use crate::internal::InternalConfig;
3943use crate::issues::IssueConfig;
4044use crate::livekit::LiveKitConfig;
@@ -68,7 +72,6 @@
6872 pub git: GitConfig,
6973 pub email: EmailConfig,
7074 pub apihost: ApihostConfig,
71 pub githost: GithostConfig,
7275 pub rocketmq: RocketMqConfig,
7376 pub internal: InternalConfig,
7477 pub push: PushConfig,
@@ -86,7 +89,6 @@
8689 self.auth.validate().map_err(ConfigError::Validation)?;
8790 self.chat.is_valid().map_err(ConfigError::Validation)?;
8891 self.apihost.is_valid().map_err(ConfigError::Validation)?;
89 self.githost.is_valid().map_err(ConfigError::Validation)?;
9092 self.rocketmq.is_valid().map_err(ConfigError::Validation)?;
9193 self.internal.is_valid().map_err(ConfigError::Validation)?;
9294 self.livekit.is_valid().map_err(ConfigError::Validation)?;
Modified lib/config/src/loader.rs View lib/config/src/loader.rs
@@ -1,39 +1,138 @@
1use std::path::Path;
1use std::path::{Path, PathBuf};
2use std::{env, fs};
23
34use figment::Figment;
4use figment::providers::{Env, Format, Toml};
5use figment::providers::{Env, Serialized};
6use serde::de::DeserializeOwned;
57
68use crate::AppConfig;
7use crate::error::ConfigError;
9use crate::error::{ConfigError, ConfigLoadError};
810
911pub const ENV_PREFIX: &str = "APP_";
1012pub const ENV_SEPARATOR: &str = "__";
11pub const DEFAULT_CONFIG_PATH: &str = "config/default.toml";
12pub const LOCAL_CONFIG_PATH: &str = "config/local.toml";
13pub const CONFIG_DIR: &str = "config";
14pub const DEFAULT_PROFILE: &str = "default";
15pub const LOCAL_PROFILE: &str = "local";
16pub const PROD_PROFILE: &str = "prod";
17pub const PROFILE_VAR: &str = "APP_ENV";
1318
14pub fn figment() -> Figment {
19pub fn figment() -> Result<Figment, ConfigError> {
1520 let _ = dotenvy::dotenv();
1621
22 let profile = profile()?;
1723 let mut figment = Figment::new();
18 if Path::new(DEFAULT_CONFIG_PATH).exists() {
19 figment = figment.merge(Toml::file(DEFAULT_CONFIG_PATH));
24 figment = merge_profile(figment, DEFAULT_PROFILE)?;
25 if profile != DEFAULT_PROFILE {
26 figment = merge_profile(figment, &profile)?;
2027 }
21 if Path::new(LOCAL_CONFIG_PATH).exists() {
22 figment = figment.merge(Toml::file(LOCAL_CONFIG_PATH));
28 Ok(figment.merge(Env::prefixed(ENV_PREFIX).split(ENV_SEPARATOR)))
29}
30
31pub fn profile() -> Result<String, ConfigError> {
32 let profile = env::var(PROFILE_VAR)
33 .ok()
34 .map(|value| value.trim().to_ascii_lowercase())
35 .filter(|value| !value.is_empty())
36 .unwrap_or_else(|| LOCAL_PROFILE.to_owned());
37 match profile.as_str() {
38 DEFAULT_PROFILE | LOCAL_PROFILE | PROD_PROFILE => Ok(profile),
39 other => Err(ConfigError::Validation(format!(
40 "{PROFILE_VAR} must be one of {DEFAULT_PROFILE}, {LOCAL_PROFILE}, {PROD_PROFILE}; got `{other}`"
41 ))),
2342 }
24 figment.merge(Env::prefixed(ENV_PREFIX).split(ENV_SEPARATOR))
2543}
2644
45fn merge_profile(mut figment: Figment, profile: &str) -> Result<Figment, ConfigError> {
46 let dir = Path::new(CONFIG_DIR).join(profile);
47 let entries = match fs::read_dir(&dir) {
48 Ok(entries) => entries,
49 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(figment),
50 Err(error) => return Err(ConfigError::Io(error)),
51 };
52
53 let mut files: Vec<PathBuf> = entries
54 .filter_map(Result::ok)
55 .map(|entry| entry.path())
56 .filter(|path| {
57 path.extension()
58 .and_then(|extension| extension.to_str())
59 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
60 })
61 .collect();
62 files.sort();
63
64 for path in files {
65 let Some(key) = path.file_stem().and_then(|stem| stem.to_str()) else {
66 continue;
67 };
68 let text = fs::read_to_string(&path)?;
69 let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
70 ConfigError::Validation(format!("failed to parse {}: {error}", path.display()))
71 })?;
72 let mut domain = serde_json::Map::new();
73 domain.insert(key.to_owned(), value);
74 figment = figment.merge(Serialized::defaults(serde_json::Value::Object(domain)));
75 }
76 Ok(figment)
77}
78
2779pub fn load_default() -> Result<AppConfig, ConfigError> {
28 let cfg: AppConfig = figment().extract().map_err(ConfigError::from)?;
80 let cfg: AppConfig = figment()?.extract().map_err(ConfigError::from)?;
2981 cfg.validate()?;
3082 Ok(cfg)
3183}
3284
33pub fn load_from_env() -> Result<AppConfig, ConfigError> {
34 let _ = dotenvy::dotenv();
35 let figment = Figment::new().merge(Env::prefixed(ENV_PREFIX).split(ENV_SEPARATOR));
36 let cfg: AppConfig = figment.extract().map_err(ConfigError::from)?;
37 cfg.validate()?;
38 Ok(cfg)
85pub trait Section {
86 const KEY: &'static str;
87}
88
89#[derive(Debug, Clone)]
90pub struct Config {
91 path: PathBuf,
92 figment: Figment,
93}
94
95impl Config {
96 pub fn from_value(value: serde_json::Value) -> Self {
97 Self {
98 path: PathBuf::from(CONFIG_DIR),
99 figment: Figment::from(Serialized::defaults(value)),
100 }
101 }
102
103 pub fn path(&self) -> &Path {
104 &self.path
105 }
106
107 pub fn section<T: Section + DeserializeOwned>(&self) -> Result<T, ConfigLoadError> {
108 if !self.has(T::KEY) {
109 return Err(ConfigLoadError::Section {
110 key: T::KEY.to_owned(),
111 path: self.path.clone(),
112 });
113 }
114 self.get(T::KEY)
115 }
116
117 pub fn section_or_default<T: Section + Default + DeserializeOwned>(
118 &self,
119 ) -> Result<T, ConfigLoadError> {
120 if !self.has(T::KEY) {
121 return Ok(T::default());
122 }
123 self.get(T::KEY)
124 }
125
126 pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigLoadError> {
127 self.figment
128 .extract_inner(key)
129 .map_err(|error| ConfigLoadError::Load {
130 path: self.path.clone(),
131 message: format!("{key}: {error}"),
132 })
133 }
134
135 pub fn has(&self, key: &str) -> bool {
136 self.figment.find_value(key).is_ok()
137 }
39138}
Modified lib/config/src/pow.rs View lib/config/src/pow.rs
@@ -34,30 +34,6 @@
3434}
3535
3636impl PowConfig {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn with_difficulty(mut self, difficulty: u32) -> Self {
42 self.difficulty = difficulty;
43 self
44 }
45
46 pub fn with_max_difficulty(mut self, max_difficulty: u32) -> Self {
47 self.max_difficulty = max_difficulty;
48 self
49 }
50
51 pub fn with_expires_secs(mut self, expires_secs: u64) -> Self {
52 self.expires_secs = expires_secs;
53 self
54 }
55
56 pub fn with_mac_key(mut self, mac_key: impl Into<String>) -> Self {
57 self.mac_key = Some(mac_key.into());
58 self
59 }
60
6137 pub fn validate(&self) -> Result<(), String> {
6238 if self.difficulty == 0 {
6339 return Err("pow.difficulty must be greater than 0".to_string());
@@ -93,6 +69,6 @@
9369 }
9470}
9571
96impl crate::identity::loader::Section for PowConfig {
72impl crate::loader::Section for PowConfig {
9773 const KEY: &'static str = "pow";
9874}
Modified lib/config/src/redis.rs View lib/config/src/redis.rs
@@ -40,16 +40,11 @@
4040 pub connection_timeout_secs: Option<u64>,
4141 pub username: Option<String>,
4242 pub password: Option<String>,
43 pub key_prefix: Option<String>,
44 pub default_ttl_secs: Option<u64>,
4345}
4446
4547impl RedisConfig {
46 pub fn new(url: impl Into<String>) -> Self {
47 Self {
48 url: Some(url.into()),
49 ..Self::default()
50 }
51 }
52
5348 pub fn url(&self) -> &str {
5449 self.url.as_deref().unwrap_or("")
5550 }
@@ -58,6 +53,14 @@
5853 self.mode.unwrap_or_default()
5954 }
6055
56 pub fn is_cluster(&self) -> bool {
57 matches!(self.mode(), RedisMode::Cluster)
58 }
59
60 pub fn database(&self) -> i64 {
61 self.database.map(i64::from).unwrap_or(0)
62 }
63
6164 pub fn pool_size(&self) -> usize {
6265 self.pool_size.unwrap_or(8)
6366 }
@@ -66,6 +69,27 @@
6669 Duration::from_secs(self.connection_timeout_secs.unwrap_or(2))
6770 }
6871
72 pub fn create_timeout(&self) -> Duration {
73 self.connection_timeout()
74 }
75
76 pub fn key_prefix(&self) -> &str {
77 self.key_prefix.as_deref().unwrap_or("")
78 }
79
80 pub fn default_ttl(&self) -> Option<Duration> {
81 self.default_ttl_secs.map(Duration::from_secs)
82 }
83
84 pub fn seeds(&self) -> Result<Vec<(String, u16)>, String> {
85 let url = self
86 .url
87 .as_deref()
88 .filter(|value| !value.is_empty())
89 .ok_or_else(|| "redis.url must be set".to_string())?;
90 url.split(',').map(parse_node).collect()
91 }
92
6993 pub fn validate(&self) -> Result<&str, String> {
7094 if let Some(size) = self.pool_size {
7195 if size == 0 {
@@ -75,6 +99,16 @@
7599 return Err(format!("redis.pool_size must not exceed 128, got {size}"));
76100 }
77101 }
102 if self
103 .key_prefix
104 .as_deref()
105 .is_some_and(|value| value.contains(char::is_whitespace))
106 {
107 return Err("redis.key_prefix must not contain whitespace".to_string());
108 }
109 if self.default_ttl_secs == Some(0) {
110 return Err("redis.default_ttl_secs must be greater than 0".to_string());
111 }
78112
79113 let url = self
80114 .url
@@ -110,3 +144,54 @@
110144 Ok(url)
111145 }
112146}
147
148impl crate::loader::Section for RedisConfig {
149 const KEY: &'static str = "redis";
150}
151
152fn parse_node(node: &str) -> Result<(String, u16), String> {
153 let trimmed = node.trim();
154 if trimmed.is_empty() {
155 return Err("redis.url contains an empty entry".to_string());
156 }
157 let without_scheme = trimmed
158 .strip_prefix("redis://")
159 .or_else(|| trimmed.strip_prefix("rediss://"))
160 .unwrap_or(trimmed);
161 let authority = without_scheme
162 .split(['/', '?'])
163 .next()
164 .unwrap_or(without_scheme);
165 let authority = match authority.rsplit_once('@') {
166 Some((_, host)) => host,
167 None => authority,
168 };
169
170 if let Some(rest) = authority.strip_prefix('[') {
171 let (host, tail) = rest
172 .split_once(']')
173 .ok_or_else(|| format!("redis node {node:?} has an unterminated IPv6 address"))?;
174 let port = match tail.strip_prefix(':') {
175 Some(port) => parse_port(port, node)?,
176 None => 6379,
177 };
178 return Ok((host.to_string(), port));
179 }
180
181 let colons = authority.matches(':').count();
182 if colons == 0 {
183 return Ok((authority.to_string(), 6379));
184 }
185 if colons == 1 {
186 let (host, port) = authority
187 .rsplit_once(':')
188 .ok_or_else(|| format!("redis node {node:?} is invalid"))?;
189 return Ok((host.to_string(), parse_port(port, node)?));
190 }
191 Ok((authority.to_string(), 6379))
192}
193
194fn parse_port(port: &str, node: &str) -> Result<u16, String> {
195 port.parse::<u16>()
196 .map_err(|_| format!("redis node {node:?} has an invalid port {port:?}"))
197}
Modified lib/config/src/rocketmq.rs View lib/config/src/rocketmq.rs
@@ -111,7 +111,7 @@
111111 }
112112}
113113
114impl crate::identity::loader::Section for RocketMqConfig {
114impl crate::loader::Section for RocketMqConfig {
115115 const KEY: &'static str = "rocketmq";
116116}
117117
Modified lib/config/src/s3.rs View lib/config/src/s3.rs
@@ -41,15 +41,6 @@
4141 self.region.as_deref().unwrap_or("us-east-1")
4242 }
4343
44 /// Endpoint presigned URLs must be addressed to, falling back to the
45 /// internal endpoint when no public one is configured.
46 pub fn presign_endpoint(&self) -> Option<&str> {
47 self.public_endpoint
48 .as_deref()
49 .filter(|s| !s.is_empty())
50 .or_else(|| self.endpoint.as_deref().filter(|s| !s.is_empty()))
51 }
52
5344 pub fn use_path_style(&self) -> bool {
5445 self.path_style.unwrap_or(false)
5546 }
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -8,7 +8,6 @@
88use crate::email::EmailConfig;
99use crate::error::ConfigError;
1010use crate::git::GitConfig;
11use crate::githost::GithostConfig as GithostSection;
1211use crate::issues::IssueConfig;
1312use crate::livekit::LiveKitConfig;
1413use crate::oauth::OAuthConfig;
@@ -27,7 +26,7 @@
2726 fn validate_scoped(&self) -> Result<(), String>;
2827
2928 fn load() -> Result<Self, ConfigError> {
30 let cfg: Self = loader::figment().extract().map_err(ConfigError::from)?;
29 let cfg: Self = loader::figment()?.extract().map_err(ConfigError::from)?;
3130 cfg.validate_scoped().map_err(ConfigError::Validation)?;
3231 Ok(cfg)
3332 }
@@ -42,45 +41,6 @@
4241impl ScopedConfig for MigrateConfig {
4342 fn validate_scoped(&self) -> Result<(), String> {
4443 self.database.validate()
45 }
46}
47
48#[derive(Clone, Debug, Default, Deserialize)]
49#[serde(default)]
50pub struct GithostConfig {
51 pub database: DatabaseConfig,
52 pub redis: RedisConfig,
53 pub s3: S3Config,
54 pub email: EmailConfig,
55 pub oauth: OAuthConfig,
56 pub web: WebConfig,
57 pub git: GitConfig,
58 pub githost: GithostSection,
59}
60
61impl GithostConfig {
62 pub fn into_app_config(self) -> AppConfig {
63 AppConfig {
64 database: self.database,
65 redis: self.redis,
66 s3: self.s3,
67 email: self.email,
68 oauth: self.oauth,
69 web: self.web,
70 git: self.git,
71 githost: self.githost,
72 ..Default::default()
73 }
74 }
75}
76
77impl ScopedConfig for GithostConfig {
78 fn validate_scoped(&self) -> Result<(), String> {
79 self.database.validate()?;
80 self.redis.validate()?;
81 self.githost.is_valid()?;
82 self.oauth.is_valid()?;
83 Ok(())
8444 }
8545}
8646
Renamed lib/config/src/identity/server.rs → lib/config/src/server.rs View lib/config/src/server.rs
@@ -20,6 +20,6 @@
2020 }
2121}
2222
23impl crate::identity::loader::Section for ServerConfig {
23impl crate::loader::Section for ServerConfig {
2424 const KEY: &'static str = "server";
2525}
Renamed lib/config/src/identity/service_auth.rs → lib/config/src/service_auth.rs View lib/config/src/service_auth.rs
@@ -53,6 +53,6 @@
5353 }
5454}
5555
56impl crate::identity::loader::Section for ServiceAuthConfig {
56impl crate::loader::Section for ServiceAuthConfig {
5757 const KEY: &'static str = "service_auth";
5858}
Renamed lib/config/src/identity/session.rs → lib/config/src/session.rs View lib/config/src/session.rs
@@ -10,17 +10,10 @@
1010 pub domain: Option<String>,
1111 pub secure: bool,
1212 pub same_site: SameSite,
13 /// 已废弃:会话 cookie 一律 HttpOnly,此字段只保留在配置里兼容旧文件,不再生效。
14 #[serde(default = "default_true")]
15 pub http_only: bool,
1613 #[serde(default)]
1714 pub trust_proxy: bool,
1815}
1916
20fn default_true() -> bool {
21 true
22}
23
2417fn default_anonymous_max_age() -> i64 {
2518 300
2619}
@@ -49,7 +42,7 @@
4942 None,
5043}
5144
52impl crate::identity::loader::Section for SessionConfig {
45impl crate::loader::Section for SessionConfig {
5346 const KEY: &'static str = "session";
5447}
5548
Renamed lib/config/src/identity/smtp.rs → lib/config/src/smtp.rs View lib/config/src/smtp.rs
@@ -89,6 +89,6 @@
8989 26_214_400
9090}
9191
92impl crate::identity::loader::Section for SmtpConfig {
92impl crate::loader::Section for SmtpConfig {
9393 const KEY: &'static str = "smtp";
9494}
Modified lib/config/src/web.rs View lib/config/src/web.rs
@@ -1,9 +1,168 @@
11use serde::{Deserialize, Serialize};
22
3#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3pub const DEFAULT_CORS_ALLOW_METHODS: &[&str] =
4 &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
5pub const DEFAULT_CORS_ALLOW_HEADERS: &[&str] = &[
6 "accept",
7 "authorization",
8 "content-type",
9 "x-csrf-token",
10 "x-request-id",
11];
12pub const DEFAULT_CORS_EXPOSE_HEADERS: &[&str] = &[
13 "cache-control",
14 "content-disposition",
15 "etag",
16 "location",
17 "retry-after",
18 "x-request-id",
19];
20pub const DEFAULT_CORS_MAX_AGE_SECS: u64 = 600;
21pub const MAX_CORS_MAX_AGE_SECS: u64 = 86_400;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(default)]
525pub struct WebConfig {
626 pub web_base_url: String,
727 pub cors_allow_origins: Vec<String>,
28 pub cors_allow_methods: Vec<String>,
29 pub cors_allow_headers: Vec<String>,
30 pub cors_expose_headers: Vec<String>,
831 pub cors_allow_credentials: bool,
32 pub cors_max_age_secs: u64,
33}
34
35impl Default for WebConfig {
36 fn default() -> Self {
37 Self {
38 web_base_url: String::new(),
39 cors_allow_origins: Vec::new(),
40 cors_allow_methods: expand(DEFAULT_CORS_ALLOW_METHODS),
41 cors_allow_headers: expand(DEFAULT_CORS_ALLOW_HEADERS),
42 cors_expose_headers: expand(DEFAULT_CORS_EXPOSE_HEADERS),
43 cors_allow_credentials: false,
44 cors_max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
45 }
46 }
47}
48
49impl WebConfig {
50 pub fn allow_origins(&self) -> Vec<String> {
51 let mut origins: Vec<String> = Vec::new();
52 for raw in std::iter::once(&self.web_base_url).chain(self.cors_allow_origins.iter()) {
53 let origin = raw.trim().trim_end_matches('/');
54 if origin.is_empty() {
55 continue;
56 }
57 if origins
58 .iter()
59 .any(|existing| existing.eq_ignore_ascii_case(origin))
60 {
61 continue;
62 }
63 origins.push(origin.to_owned());
64 }
65 origins
66 }
67
68 pub fn allow_any_origin(&self) -> bool {
69 self.allow_origins().iter().any(|origin| origin == "*")
70 }
71
72 pub fn allow_methods(&self) -> Vec<String> {
73 resolve(&self.cors_allow_methods, DEFAULT_CORS_ALLOW_METHODS)
74 }
75
76 pub fn allow_headers(&self) -> Vec<String> {
77 resolve(&self.cors_allow_headers, DEFAULT_CORS_ALLOW_HEADERS)
78 }
79
80 pub fn expose_headers(&self) -> Vec<String> {
81 resolve(&self.cors_expose_headers, DEFAULT_CORS_EXPOSE_HEADERS)
82 }
83
84 pub fn is_valid(&self) -> Result<(), String> {
85 let wildcard = self.allow_any_origin();
86 if wildcard && self.allow_origins().len() != 1 {
87 return Err(
88 "the allowed origins must list \"*\" alone or name explicit origins".into(),
89 );
90 }
91 if wildcard && self.cors_allow_credentials {
92 return Err(
93 "web.cors_allow_credentials must be false when the allowed origins include \"*\""
94 .into(),
95 );
96 }
97 for origin in &self.cors_allow_origins {
98 let origin = origin.trim();
99 if origin.is_empty() || origin == "*" {
100 continue;
101 }
102 if !is_origin(origin) {
103 return Err(format!(
104 "web.cors_allow_origins entry {origin:?} must be scheme://host[:port] without path or trailing slash"
105 ));
106 }
107 }
108 let base = self.web_base_url.trim().trim_end_matches('/');
109 if !base.is_empty() && base != "*" && !is_origin(base) {
110 return Err(format!(
111 "web.web_base_url {base:?} must be scheme://host[:port] without path"
112 ));
113 }
114 for method in &self.cors_allow_methods {
115 let method = method.trim();
116 if method.is_empty() || !method.bytes().all(|byte| byte.is_ascii_uppercase()) {
117 return Err(format!(
118 "web.cors_allow_methods entry {method:?} must be an upper case HTTP method"
119 ));
120 }
121 }
122 for header in &self.cors_allow_headers {
123 if header.trim().is_empty() {
124 return Err("web.cors_allow_headers entry must not be empty".into());
125 }
126 }
127 for header in &self.cors_expose_headers {
128 if header.trim().is_empty() {
129 return Err("web.cors_expose_headers entry must not be empty".into());
130 }
131 }
132 if self.cors_max_age_secs > MAX_CORS_MAX_AGE_SECS {
133 return Err(format!(
134 "web.cors_max_age_secs must be between 0 and {MAX_CORS_MAX_AGE_SECS}"
135 ));
136 }
137 Ok(())
138 }
139}
140
141fn is_origin(origin: &str) -> bool {
142 let Some(rest) = origin
143 .strip_prefix("https://")
144 .or_else(|| origin.strip_prefix("http://"))
145 else {
146 return false;
147 };
148 !rest.is_empty() && !rest.contains(['/', '?', '#', ' ', ','])
149}
150
151fn resolve(configured: &[String], fallback: &[&str]) -> Vec<String> {
152 if configured.is_empty() {
153 return expand(fallback);
154 }
155 configured
156 .iter()
157 .map(|value| value.trim().to_owned())
158 .filter(|value| !value.is_empty())
159 .collect()
160}
161
162fn expand(values: &[&str]) -> Vec<String> {
163 values.iter().map(|value| (*value).to_owned()).collect()
164}
165
166impl crate::loader::Section for WebConfig {
167 const KEY: &'static str = "web";
9168}
Modified lib/crypt/src/app.rs View lib/crypt/src/app.rs
@@ -1,4 +1,4 @@
1use config::identity::app::{AppConfig, AppKeyAlgorithm, AppKeyConfig, SecretMaterial};
1use config::app::{AppConfig, AppKeyAlgorithm, AppKeyConfig, SecretMaterial};
22
33use crate::error::{CryptError, CryptResult};
44use crate::key::{RsaDecryptor, RsaEncryptor, RsaKeyPair};
Modified lib/db/src/conn.rs View lib/db/src/conn.rs
@@ -1,10 +1,7 @@
11use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33
4use config::identity::database::{DatabaseConfig, DatabaseGroup};
5use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr};
6
7use crate::error::{DbError, DbResult};
4use sea_orm::{DatabaseConnection, DbErr};
85
96#[derive(Clone)]
107pub struct DbConn {
@@ -42,43 +39,6 @@
4239 }
4340 let index = self.read_index.fetch_add(1, Ordering::Relaxed) % self.read.len();
4441 Some(&self.read[index])
45 }
46
47 pub async fn init(cfg: &DatabaseConfig) -> DbResult<Self> {
48 Self::init_group("database", cfg.default_group()).await
49 }
50
51 pub async fn init_group(label: &str, group: &DatabaseGroup) -> DbResult<Self> {
52 let main = group
53 .primary_connect_options()
54 .map_err(|reason| DbError::config(format!("{label}.main: {reason}")))?;
55 let replicas = (0..group.replica_count())
56 .map(|index| {
57 let mut options = group.replica_connect_options(index).map_err(|reason| {
58 DbError::config(format!("{label}.replicas[{index}]: {reason}"))
59 })?;
60 options.map_sqlx_postgres_opts(|options| {
61 options.options([("default_transaction_read_only", "on")])
62 });
63 Ok(options)
64 })
65 .collect::<DbResult<Vec<ConnectOptions>>>()?;
66
67 let write = Database::connect(main)
68 .await
69 .map_err(|source| DbError::connect(label, source))?;
70 let mut read = Vec::with_capacity(replicas.len());
71 for options in replicas {
72 match Database::connect(options).await {
73 Ok(connection) => read.push(connection),
74 Err(source) => {
75 let connections = Self::new(write, read);
76 let _ = connections.close().await;
77 return Err(DbError::connect(label, source));
78 }
79 }
80 }
81 Ok(Self::new(write, read))
8242 }
8343
8444 pub async fn close(&self) -> Result<(), DbErr> {
Modified lib/db/Cargo.toml View lib/db/Cargo.toml
@@ -4,7 +4,6 @@
44edition.workspace = true
55
66[dependencies]
7config = { workspace = true }
87async-trait = { workspace = true }
98sea-orm = { workspace = true }
109sqlparser = { version = "0.63.0", default-features = false, features = ["std"] }
Modified lib/email/src/queue.rs View lib/email/src/queue.rs
@@ -14,7 +14,7 @@
1414
1515use crate::error::EmailError;
1616use crate::events::{RenderAndSendEmailRequest, SendBatchEmailRequest, SendEmailRequest};
17use config::identity::email::EmailPoolConfig;
17use config::email::EmailPoolConfig;
1818
1919/// 队列里的一条工作项。
2020#[derive(Debug, Clone)]
Modified lib/email/src/service.rs View lib/email/src/service.rs
@@ -15,8 +15,7 @@
1515use crate::template::TemplateRegistry;
1616use crate::templates;
1717use crate::transport::{EmailTransport, EmailTransportBuilder, build_message};
18use ::events::event::{EmailEvent, RestOperate};
19use ::events::{EventHandler, Labels, RuntimeEvent};
18use ::events::{EventHandler, Labels, label};
2019use db::DbConn;
2120
2221/// 邮件服务:对外只做入队,实际投递由本地队列的 worker 完成。
@@ -270,10 +269,8 @@
270269 match result {
271270 Ok(sent) => {
272271 self.emit(
273 RuntimeEvent::Email(EmailEvent::Delivery {
274 operate: RestOperate::Create,
275 }),
276 Labels::new().with("request_id", sent.request_id),
272 label::EMAIL_DELIVERY_CREATE,
273 Labels::new().with(label::REQUEST_ID, sent.request_id),
277274 &sent,
278275 None,
279276 )
@@ -295,10 +292,8 @@
295292 metadata: Default::default(),
296293 };
297294 self.emit(
298 RuntimeEvent::Email(EmailEvent::Failure {
299 operate: RestOperate::Create,
300 }),
301 Labels::new().with("request_id", batch_id),
295 label::EMAIL_FAILURE_CREATE,
296 Labels::new().with(label::REQUEST_ID, batch_id),
302297 &failed,
303298 None,
304299 )
@@ -356,13 +351,8 @@
356351 }
357352
358353 /// 把事件写进 outbox,由 relay 投递到 RocketMQ;写库失败时退回直接投递。
359 async fn emit<T>(
360 &self,
361 event: RuntimeEvent,
362 labels: Labels,
363 payload: &T,
364 _request_id: Option<&str>,
365 ) where
354 async fn emit<T>(&self, kind: &str, labels: Labels, payload: &T, _request_id: Option<&str>)
355 where
366356 T: Serialize,
367357 {
368358 let Some(events) = self.events.as_ref() else {
@@ -375,7 +365,7 @@
375365 .unwrap_or_default();
376366 match ::events::outbox::enqueue_at(
377367 database.write(),
378 event,
368 kind,
379369 labels.clone(),
380370 payload,
381371 &trace_id,
@@ -386,13 +376,13 @@
386376 {
387377 Ok(_) => return,
388378 Err(error) => tracing::warn!(
389 event = %event,
379 kind,
390380 %error,
391381 "failed to enqueue the email event in the outbox; falling back to direct publish"
392382 ),
393383 }
394384 }
395 ::events::retry_publish(events, event, labels, payload, "email", _request_id).await;
385 ::events::retry_publish(events, kind, labels, payload, "email", _request_id).await;
396386 }
397387}
398388
@@ -405,10 +395,8 @@
405395 Job::Send(req) => {
406396 let sent = self.deliver(req.clone(), request_id).await?;
407397 self.emit(
408 RuntimeEvent::Email(EmailEvent::Delivery {
409 operate: RestOperate::Create,
410 }),
411 Labels::new().with("request_id", sent.request_id),
398 label::EMAIL_DELIVERY_CREATE,
399 Labels::new().with(label::REQUEST_ID, sent.request_id),
412400 &sent,
413401 None,
414402 )
@@ -422,10 +410,8 @@
422410 Job::Render(req) => {
423411 let sent = self.deliver_render(req.clone(), request_id).await?;
424412 self.emit(
425 RuntimeEvent::Email(EmailEvent::Delivery {
426 operate: RestOperate::Create,
427 }),
428 Labels::new().with("request_id", sent.request_id),
413 label::EMAIL_DELIVERY_CREATE,
414 Labels::new().with(label::REQUEST_ID, sent.request_id),
429415 &sent,
430416 None,
431417 )
@@ -457,10 +443,8 @@
457443 metadata,
458444 };
459445 self.emit(
460 RuntimeEvent::Email(EmailEvent::Failure {
461 operate: RestOperate::Create,
462 }),
463 Labels::new().with("request_id", request_id),
446 label::EMAIL_FAILURE_CREATE,
447 Labels::new().with(label::REQUEST_ID, request_id),
464448 &failed,
465449 None,
466450 )
@@ -484,15 +468,15 @@
484468}
485469
486470pub struct EmailServiceBuilder {
487 pool_config: config::identity::email::EmailPoolConfig,
488 transport_config: Option<config::identity::smtp::SmtpConfig>,
471 pool_config: config::email::EmailPoolConfig,
472 transport_config: Option<config::smtp::SmtpConfig>,
489473 events: Option<EventHandler>,
490474 database: Option<DbConn>,
491475 templates: TemplateRegistry,
492476}
493477
494478impl EmailServiceBuilder {
495 pub fn new(pool_config: config::identity::email::EmailPoolConfig) -> Self {
479 pub fn new(pool_config: config::email::EmailPoolConfig) -> Self {
496480 Self {
497481 pool_config,
498482 transport_config: None,
@@ -502,7 +486,7 @@
502486 }
503487 }
504488
505 pub fn with_transport(mut self, transport_config: config::identity::smtp::SmtpConfig) -> Self {
489 pub fn with_transport(mut self, transport_config: config::smtp::SmtpConfig) -> Self {
506490 self.transport_config = Some(transport_config);
507491 self
508492 }
Modified lib/email/src/transport.rs View lib/email/src/transport.rs
@@ -1,7 +1,7 @@
11use std::time::{Duration, Instant};
22
33use base64::Engine;
4use config::identity::smtp::SmtpSecurity;
4use config::smtp::SmtpSecurity;
55use lettre::address::Address;
66use lettre::message::header::ContentType;
77use lettre::message::{
Modified lib/envelope/src/cors.rs View lib/envelope/src/cors.rs
@@ -8,7 +8,7 @@
88use actix_web::http::Method;
99use actix_web::http::header::{self, HeaderMap, HeaderValue};
1010use actix_web::{Error, HttpResponse};
11use config::identity::web::WebConfig;
11use config::web::WebConfig;
1212use futures_util::future::LocalBoxFuture;
1313
1414#[derive(Debug, Clone)]
Deleted lib/events/src/event/agent.rs
@@ -1,15 +0,0 @@
1event_group!(AgentEvent {
2 Session => "session",
3 Run => "run",
4 Conversation => "conversation",
5 Generation => "generation",
6 Flow => "flow",
7 GoalPlan => "goal_plan",
8 Sandbox => "sandbox",
9 Actor => "actor",
10 Reminder => "reminder",
11 Checkpoint => "checkpoint",
12 ContextSnapshot => "context_snapshot",
13 Wait => "wait",
14 RouteDecision => "route_decision",
15});
Deleted lib/events/src/event/auth.rs
@@ -1,20 +0,0 @@
1event_group!(AuthEvent {
2 AccessKey => "access_key",
3 AppToken => "app_token",
4 Token => "token",
5 Session => "session",
6 SshKey => "ssh_key",
7 Password => "password",
8 Email => "email",
9 Mfa => "mfa",
10 RecoveryCode => "recovery_code",
11 Webauthn => "webauthn",
12 VerificationToken => "verification_token",
13 Agreement => "agreement",
14 Authorization => "authorization",
15 Identity => "identity",
16 Challenge => "challenge",
17 Register => "register",
18 Login => "login",
19 Logout => "logout",
20});
Deleted lib/events/src/event/chat.rs
@@ -1,12 +0,0 @@
1event_group!(ChatEvent {
2 Room => "room",
3 Message => "message",
4 Thread => "thread",
5 Reaction => "reaction",
6 Attachment => "attachment",
7 Draft => "draft",
8 Emoji => "emoji",
9 ReadState => "read_state",
10 Reminder => "reminder",
11 Presence => "presence",
12});
Deleted lib/events/src/event/email.rs
@@ -1,7 +0,0 @@
1event_group!(EmailEvent {
2 Message => "message",
3 Batch => "batch",
4 Render => "render",
5 Delivery => "delivery",
6 Failure => "failure",
7});
Deleted lib/events/src/event/git.rs
@@ -1,23 +0,0 @@
1event_group!(GitEvent {
2 Repository => "repository",
3 RepositoryPin => "repository_pin",
4 Ref => "ref",
5 Commit => "commit",
6 Hook => "hook",
7 Lfs => "lfs",
8 Object => "object",
9 Remote => "remote",
10 Quota => "quota",
11 Node => "node",
12 ObjectPool => "object_pool",
13 Membership => "membership",
14 Replica => "replica",
15 Routing => "routing",
16 Failover => "failover",
17 Job => "job",
18 Replication => "replication",
19 Repair => "repair",
20 Storage => "storage",
21 Leadership => "leadership",
22 Audit => "audit",
23});
Deleted lib/events/src/event/guild.rs
@@ -1,12 +0,0 @@
1event_group!(GuildEvent {
2 LifeCycle => "life_cycle",
3 Member => "member",
4 Role => "role",
5 Channel => "channel",
6 ChannelMember => "channel_member",
7 InviteLink => "invite_link",
8 InviteUser => "invite_user",
9 InviteRedemption => "invite_redemption",
10 Permission => "permission",
11 Audit => "audit",
12});
Deleted lib/events/src/event/issue.rs
@@ -1,18 +0,0 @@
1event_group!(IssueEvent {
2 Issue => "issue",
3 Comment => "comment",
4 ContentHistory => "content_history",
5 Attachment => "attachment",
6 Dependency => "dependency",
7 Label => "label",
8 Member => "member",
9 Milestone => "milestone",
10 Pin => "pin",
11 Reaction => "reaction",
12 Relation => "relation",
13 Stopwatch => "stopwatch",
14 TrackedTime => "tracked_time",
15 Watch => "watch",
16 Board => "board",
17 Event => "event",
18});
Deleted lib/events/src/event/macros.rs
@@ -1,75 +0,0 @@
1macro_rules! event_group {
2 ($name:ident { $($variant:ident => $label:literal),* $(,)? }) => {
3 #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
4 #[serde(rename_all = "snake_case")]
5 pub enum $name {
6 $($variant { operate: $crate::event::RestOperate }),*
7 }
8
9 impl $name {
10 pub const fn name(&self) -> &'static str {
11 match self {
12 $(Self::$variant { .. } => $label),*
13 }
14 }
15
16 pub const fn operate(&self) -> $crate::event::RestOperate {
17 match self {
18 $(Self::$variant { operate } => *operate),*
19 }
20 }
21 }
22
23 impl std::fmt::Display for $name {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "{}.{}", self.name(), self.operate().as_str())
26 }
27 }
28 };
29}
30
31macro_rules! runtime_event {
32 ($($variant:ident => $domain:literal : $ty:path),* $(,)?) => {
33 #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
34 #[serde(rename_all = "snake_case")]
35 pub enum RuntimeEvent {
36 $($variant($ty)),*
37 }
38
39 impl RuntimeEvent {
40 pub const fn domain(&self) -> &'static str {
41 match self {
42 $(Self::$variant(_) => $domain),*
43 }
44 }
45
46 pub const fn name(&self) -> &'static str {
47 match self {
48 $(Self::$variant(event) => event.name()),*
49 }
50 }
51
52 pub const fn operate(&self) -> $crate::event::RestOperate {
53 match self {
54 $(Self::$variant(event) => event.operate()),*
55 }
56 }
57
58 pub fn kind(&self) -> String {
59 format!("{}.{}", self.domain(), self)
60 }
61
62 pub fn subject(&self) -> String {
63 format!("{}{}", $crate::event::EVENT_PREFIX, self.kind())
64 }
65 }
66
67 impl std::fmt::Display for RuntimeEvent {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 $(Self::$variant(event) => std::fmt::Display::fmt(event, f)),*
71 }
72 }
73 }
74 };
75}
Deleted lib/events/src/event/mod.rs
@@ -1,39 +0,0 @@
1#[macro_use]
2mod macros;
3
4pub mod agent;
5pub mod auth;
6pub mod chat;
7pub mod email;
8pub mod git;
9pub mod guild;
10pub mod issue;
11pub mod notify;
12pub mod object;
13pub mod organization;
14pub mod pull_request;
15pub mod realtime;
16pub mod user;
17pub mod webhook;
18
19pub use agent::AgentEvent;
20pub use auth::AuthEvent;
21pub use chat::ChatEvent;
22pub use email::EmailEvent;
23pub use git::GitEvent;
24pub use guild::GuildEvent;
25pub use issue::IssueEvent;
26pub use notify::NotifyEvent;
27pub use object::ObjectEvent;
28pub use organization::OrganizationEvent;
29pub use pull_request::PullRequestEvent;
30pub use realtime::RealtimeEvent;
31pub use user::UserEvent;
32pub use webhook::WebhookEvent;
33
34pub use queue::DeliveryClass;
35pub use queue::envelope::EVENT_PREFIX;
36
37mod runtime;
38
39pub use runtime::{RestOperate, RuntimeEvent};
Deleted lib/events/src/event/notify.rs
@@ -1,6 +0,0 @@
1event_group!(NotifyEvent {
2 Notification => "notification",
3 Subscription => "subscription",
4 Device => "device",
5 Preference => "preference",
6});
Deleted lib/events/src/event/object.rs
@@ -1,6 +0,0 @@
1event_group!(ObjectEvent {
2 Object => "object",
3 Bucket => "bucket",
4 Upload => "upload",
5 Multipart => "multipart",
6});
Deleted lib/events/src/event/organization.rs
@@ -1,7 +0,0 @@
1event_group!(OrganizationEvent {
2 LifeCycle => "life_cycle",
3 Member => "member",
4 Role => "role",
5 Invite => "invite",
6 Permission => "permission",
7});
Deleted lib/events/src/event/pull_request.rs
@@ -1,15 +0,0 @@
1event_group!(PullRequestEvent {
2 PullRequest => "pull_request",
3 Comment => "comment",
4 Label => "label",
5 Assignee => "assignee",
6 Reviewer => "reviewer",
7 Review => "review",
8 Check => "check",
9 Merge => "merge",
10 Change => "change",
11 Relation => "relation",
12 Inbox => "inbox",
13 Ai => "ai",
14 Event => "event",
15});
Deleted lib/events/src/event/realtime.rs
@@ -1,6 +0,0 @@
1event_group!(RealtimeEvent {
2 Connection => "connection",
3 Room => "room",
4 Participant => "participant",
5 Presence => "presence",
6});
Deleted lib/events/src/event/runtime.rs
@@ -1,66 +0,0 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6 AgentEvent, AuthEvent, ChatEvent, DeliveryClass, EmailEvent, GitEvent, GuildEvent, IssueEvent,
7 NotifyEvent, ObjectEvent, OrganizationEvent, PullRequestEvent, RealtimeEvent, UserEvent,
8 WebhookEvent,
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum RestOperate {
14 Create,
15 Delete,
16 Update,
17 Query,
18 Used,
19}
20
21impl RestOperate {
22 pub const fn as_str(&self) -> &'static str {
23 match self {
24 RestOperate::Create => "create",
25 RestOperate::Delete => "delete",
26 RestOperate::Update => "update",
27 RestOperate::Query => "query",
28 RestOperate::Used => "used",
29 }
30 }
31}
32
33impl fmt::Display for RestOperate {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.write_str(self.as_str())
36 }
37}
38
39runtime_event! {
40 Agent => "agent": AgentEvent,
41 Auth => "auth": AuthEvent,
42 Chat => "chat": ChatEvent,
43 Email => "email": EmailEvent,
44 Git => "git": GitEvent,
45 Guild => "guild": GuildEvent,
46 Issue => "issue": IssueEvent,
47 Notify => "notify": NotifyEvent,
48 Object => "object": ObjectEvent,
49 Organization => "organization": OrganizationEvent,
50 PullRequest => "pull_request": PullRequestEvent,
51 Realtime => "realtime": RealtimeEvent,
52 User => "user": UserEvent,
53 Webhook => "webhook": WebhookEvent,
54}
55
56impl RuntimeEvent {
57 pub const fn delivery(&self) -> DeliveryClass {
58 match self {
59 Self::Chat(ChatEvent::Presence { .. }) | Self::Realtime(_) => DeliveryClass::Broadcast,
60 // 邮件投递结果在本服务里是供外部订阅的业务事件,不是发给 worker 的任务命令;
61 // 归类为 BusinessEvent 才能进 outbox。
62 Self::Agent(_) => DeliveryClass::TaskCommand,
63 _ => DeliveryClass::BusinessEvent,
64 }
65 }
66}
Deleted lib/events/src/event/user.rs
@@ -1,11 +0,0 @@
1event_group!(UserEvent {
2 Account => "account",
3 Profile => "profile",
4 Appearance => "appearance",
5 Theme => "theme",
6 Asset => "asset",
7 Follow => "follow",
8 Block => "block",
9 Relation => "relation",
10 Event => "event",
11});
Deleted lib/events/src/event/webhook.rs
@@ -1,7 +0,0 @@
1event_group!(WebhookEvent {
2 Webhook => "webhook",
3 Endpoint => "endpoint",
4 Subscription => "subscription",
5 Delivery => "delivery",
6 Secret => "secret",
7});
Modified lib/events/src/handler.rs View lib/events/src/handler.rs
@@ -1,9 +1,8 @@
11use serde::Serialize;
2use serde_json::Value;
32
4use crate::event::{EVENT_PREFIX, RuntimeEvent};
5use config::identity::rocketmq::RocketMqConfig;
6use queue::{Envelope, Labels, Publisher, QueueError, QueueResult, RocketMqConn};
3use crate::{label, topic};
4use config::rocketmq::RocketMqConfig;
5use queue::{EVENT_PREFIX, Envelope, Labels, Publisher, QueueError, QueueResult, RocketMqConn};
76
87pub const DURABLE_DOMAINS: &[&str] = &[
98 "auth",
@@ -48,30 +47,23 @@
4847 self.publisher.max_payload()
4948 }
5049
51 pub fn envelope<T>(&self, event: RuntimeEvent, payload: T) -> Envelope<T> {
52 Envelope::new(event.kind(), payload)
53 .with_source(event.domain())
54 .with_delivery(event.delivery())
50 pub fn envelope<T>(&self, kind: &str, payload: T) -> Envelope<T> {
51 Envelope::new(kind, payload)
5552 }
5653
57 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
58 pub async fn send<T>(
59 &self,
60 event: RuntimeEvent,
61 payload: T,
62 request_id: Option<&str>,
63 ) -> QueueResult<()>
54 #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))]
55 pub async fn send<T>(&self, kind: &str, payload: T, request_id: Option<&str>) -> QueueResult<()>
6456 where
6557 T: Serialize,
6658 {
67 self.send_labeled(event, Labels::default(), payload, request_id)
59 self.send_labeled(kind, Labels::default(), payload, request_id)
6860 .await
6961 }
7062
71 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
63 #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))]
7264 pub async fn send_labeled<T>(
7365 &self,
74 event: RuntimeEvent,
66 kind: &str,
7567 labels: Labels,
7668 payload: T,
7769 request_id: Option<&str>,
@@ -79,14 +71,14 @@
7971 where
8072 T: Serialize,
8173 {
82 self.send_keyed(event, labels, None, payload, request_id)
74 self.send_keyed(kind, labels, None, payload, request_id)
8375 .await
8476 }
8577
86 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
78 #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))]
8779 pub async fn send_keyed<T>(
8880 &self,
89 event: RuntimeEvent,
81 kind: &str,
9082 labels: Labels,
9183 idempotency_key: Option<String>,
9284 payload: T,
@@ -95,24 +87,23 @@
9587 where
9688 T: Serialize,
9789 {
98 let mut envelope = self.envelope(event, payload).with_labels(labels);
90 let mut envelope = self.envelope(kind, payload).with_labels(labels);
9991 if let Some(key) = idempotency_key {
10092 envelope = envelope.with_idempotency_key(key);
10193 }
102 self.send_envelope(event, &envelope, request_id).await
94 self.send_envelope(&envelope, request_id).await
10395 }
10496
10597 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
10698 pub async fn send_envelope<T>(
10799 &self,
108 event: RuntimeEvent,
109100 envelope: &Envelope<T>,
110101 request_id: Option<&str>,
111102 ) -> QueueResult<()>
112103 where
113104 T: Serialize,
114105 {
115 let subject = event.subject();
106 let subject = format!("{EVENT_PREFIX}{}", envelope.kind);
116107 envelope.verify_subject(EVENT_PREFIX, &subject)?;
117108 self.dispatch(envelope, request_id).await
118109 }
@@ -129,7 +120,7 @@
129120 }
130121
131122 pub async fn health(&self) -> QueueResult<()> {
132 let topic = self.publisher.topic("login");
123 let topic = self.publisher.topic(topic::HEALTH);
133124 self.publisher.conn().health(&topic).await
134125 }
135126
@@ -138,32 +129,40 @@
138129 Ok(())
139130 }
140131
141 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
132 #[tracing::instrument(skip_all, fields(kind = %envelope.kind, request_id = %request_id.unwrap_or_default()))]
142133 async fn dispatch<T: Serialize>(
143134 &self,
144135 envelope: &Envelope<T>,
145136 request_id: Option<&str>,
146137 ) -> QueueResult<()> {
138 ensure_kind(&envelope.kind)?;
147139 let payload = serde_json::to_value(&envelope.payload).map_err(QueueError::from)?;
148 let action = payload.get("action").and_then(Value::as_str);
149 let route = crate::route::route(&envelope.kind, action, Some(&payload))?;
150 let request_id = envelope::context::request_id();
140 let area = topic::of(&envelope.kind);
141 let tag = label::tag(&envelope.kind, Some(&payload));
142 let request_id = envelope::context::request_id().or_else(|| request_id.map(str::to_owned));
151143 let properties = envelope.properties(Some(&payload), request_id.as_deref());
152 let topic = self.publisher.topic(&route.area);
144 let topic = self.publisher.topic(area);
153145 let body = envelope.encode()?;
154146
155147 self.publisher
156 .publish(
157 &topic,
158 &route.tag,
159 vec![envelope.message_id()],
160 properties,
161 body,
162 )
148 .publish(&topic, tag, vec![envelope.message_id()], properties, body)
163149 .await
164150 }
165151}
166152
153fn ensure_kind(kind: &str) -> QueueResult<()> {
154 let mut parts = kind.split('.');
155 let domain = parts.next().unwrap_or_default();
156 let name = parts.next().unwrap_or_default();
157 let operate = parts.next().unwrap_or_default();
158 if domain.is_empty() || name.is_empty() || operate.is_empty() {
159 return Err(QueueError::payload(format!(
160 "event kind `{kind}` is not a concrete event"
161 )));
162 }
163 Ok(())
164}
165
167166const PUBLISH_ATTEMPTS: usize = 3;
168167const PUBLISH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
169168const PUBLISH_BACKOFF: std::time::Duration = std::time::Duration::from_millis(50);
@@ -171,10 +170,10 @@
171170/// 统一的事件转发重试:单次尝试带超时,失败按退避重试,
172171/// 最终仍失败打 ERROR,便于告警发现事件链断了。
173172/// `context` 仅用于日志文案(如 "auth" / "email")。
174#[tracing::instrument(skip_all, fields(event = %event, context = context, request_id = %_request_id.unwrap_or_default()))]
173#[tracing::instrument(skip_all, fields(kind = kind, context = context, request_id = %_request_id.unwrap_or_default()))]
175174pub async fn retry_publish<T>(
176175 events: &EventHandler,
177 event: RuntimeEvent,
176 kind: &str,
178177 labels: Labels,
179178 payload: T,
180179 context: &str,
@@ -183,25 +182,19 @@
183182 T: Serialize + Clone,
184183{
185184 for attempt in 1..=PUBLISH_ATTEMPTS {
186 let published = events.send_labeled(event, labels.clone(), payload.clone(), _request_id);
185 let published = events.send_labeled(kind, labels.clone(), payload.clone(), _request_id);
187186 match tokio::time::timeout(PUBLISH_TIMEOUT, published).await {
188187 Ok(Ok(())) => {
189188 if attempt > 1 {
190 tracing::info!(event = %event, attempt, "{context} event forwarded after retry");
189 tracing::info!(kind, attempt, "{context} event forwarded after retry");
191190 }
192191 return;
193192 }
194193 Ok(Err(error)) => {
195 tracing::warn!(
196 event = %event,
197 subject = %event.subject(),
198 attempt,
199 %error,
200 "failed to publish {context} event"
201 );
194 tracing::warn!(kind, attempt, %error, "failed to publish {context} event");
202195 }
203196 Err(_) => {
204 tracing::warn!(event = %event, attempt, "publishing {context} event timed out");
197 tracing::warn!(kind, attempt, "publishing {context} event timed out");
205198 }
206199 }
207200 if attempt < PUBLISH_ATTEMPTS {
@@ -209,8 +202,7 @@
209202 }
210203 }
211204 tracing::error!(
212 event = %event,
213 subject = %event.subject(),
205 kind,
214206 attempts = PUBLISH_ATTEMPTS,
215207 "{context} event could not be forwarded to rocketmq"
216208 );
Added lib/events/src/label.rs View lib/events/src/label.rs
@@ -0,0 +1,158 @@
1use serde_json::Value;
2
3pub const USER_ID: &str = "user_id";
4pub const ACTOR_USER_ID: &str = "actor_user_id";
5pub const ORGANIZATION_ID: &str = "organization_id";
6pub const REQUEST_ID: &str = "request_id";
7pub const GUILD_ID: &str = "guild_id";
8pub const REPOSITORY_ID: &str = "repository_id";
9pub const PULL_REQUEST_ID: &str = "pull_request_id";
10pub const ISSUE_ID: &str = "issue_id";
11pub const BOARD_ID: &str = "board_id";
12pub const CHANNEL_ID: &str = "channel_id";
13pub const ROOM_ID: &str = "room_id";
14
15pub const AUTH_REGISTER_CREATE: &str = "auth.register.create";
16pub const AUTH_LOGIN_CREATE: &str = "auth.login.create";
17pub const AUTH_LOGOUT_DELETE: &str = "auth.logout.delete";
18pub const AUTH_VERIFICATION_TOKEN_CREATE: &str = "auth.verification_token.create";
19pub const AUTH_PASSWORD_CREATE: &str = "auth.password.create";
20pub const AUTH_PASSWORD_UPDATE: &str = "auth.password.update";
21pub const AUTH_SSH_KEY_CREATE: &str = "auth.ssh_key.create";
22pub const AUTH_SSH_KEY_DELETE: &str = "auth.ssh_key.delete";
23pub const AUTH_ACCESS_KEY_CREATE: &str = "auth.access_key.create";
24pub const AUTH_ACCESS_KEY_DELETE: &str = "auth.access_key.delete";
25pub const AUTH_ACCESS_KEY_USED: &str = "auth.access_key.used";
26pub const AUTH_APP_TOKEN_CREATE: &str = "auth.app_token.create";
27pub const AUTH_TOKEN_CREATE: &str = "auth.token.create";
28pub const AUTH_CHALLENGE_CREATE: &str = "auth.challenge.create";
29pub const AUTH_CHALLENGE_USED: &str = "auth.challenge.used";
30pub const ORGANIZATION_LIFE_CYCLE_CREATE: &str = "organization.life_cycle.create";
31pub const ORGANIZATION_LIFE_CYCLE_UPDATE: &str = "organization.life_cycle.update";
32pub const ORGANIZATION_LIFE_CYCLE_DELETE: &str = "organization.life_cycle.delete";
33pub const ORGANIZATION_INVITE_CREATE: &str = "organization.invite.create";
34pub const ORGANIZATION_MEMBER_UPDATE: &str = "organization.member.update";
35pub const ORGANIZATION_ROLE_UPDATE: &str = "organization.role.update";
36pub const EMAIL_DELIVERY_CREATE: &str = "email.delivery.create";
37pub const EMAIL_FAILURE_CREATE: &str = "email.failure.create";
38
39pub const REGISTER: &str = "register";
40pub const SEND_CODE: &str = "send-code";
41pub const LOGIN: &str = "login";
42pub const LOGOUT: &str = "logout";
43pub const REQUEST_RESET: &str = "request-reset";
44pub const RESET_PASSWORD: &str = "reset-password";
45pub const ADD_SSH_KEY: &str = "add-sshkey";
46pub const DELETE_SSH_KEY: &str = "delete-sshkey";
47pub const CREATE_ACCESS_KEY: &str = "create-accesskey";
48pub const ROTATE_ACCESS_KEY: &str = "rotate-accesskey";
49pub const DELETE_ACCESS_KEY: &str = "delete-accesskey";
50pub const USE_ACCESS_KEY: &str = "use-accesskey";
51pub const CHALLENGE: &str = "challenge";
52pub const VERIFY_CHALLENGE: &str = "verify-challenge";
53pub const ISSUE_TOKEN: &str = "issue-token";
54pub const DELIVERY: &str = "delivery";
55pub const FAILURE: &str = "failure";
56pub const CREATE: &str = "create";
57pub const UPDATE: &str = "update";
58pub const DELETE: &str = "delete";
59pub const ARCHIVE: &str = "archive";
60pub const RESTORE: &str = "restore";
61pub const INVITE: &str = "invite";
62pub const REVOKE_INVITE: &str = "revoke-invite";
63pub const REDEEM_INVITE: &str = "redeem-invite";
64pub const JOIN: &str = "join";
65pub const LEAVE: &str = "leave";
66pub const ADD_MEMBER: &str = "add-member";
67pub const REMOVE_MEMBER: &str = "remove-member";
68pub const SET_ROLE: &str = "set-role";
69pub const BAN: &str = "ban";
70pub const UNBAN: &str = "unban";
71pub const UPDATE_BAN: &str = "update-ban";
72pub const TRANSFER_OWNER: &str = "transfer-owner";
73pub const UPDATE_MEMBER: &str = "update-member";
74pub const CREATE_ROLE: &str = "create-role";
75pub const SET_PERMISSIONS: &str = "set-permissions";
76pub const UPDATE_ROLE: &str = "update-role";
77pub const DELETE_ROLE: &str = "delete-role";
78pub const ASSIGN_ROLE: &str = "assign-role";
79pub const UNASSIGN_ROLE: &str = "unassign-role";
80pub const GRANT: &str = "grant";
81
82pub fn tag<'a>(kind: &'a str, payload: Option<&Value>) -> &'a str {
83 let mut parts = kind.split('.');
84 let domain = parts.next().unwrap_or_default();
85 let name = parts.next().unwrap_or_default();
86 let operate = parts.next().unwrap_or_default();
87 let action = payload
88 .and_then(|value| value.get("action"))
89 .and_then(Value::as_str);
90 match (domain, name, operate) {
91 ("auth", "register", "create") => {
92 if has_field(payload, "expires_in_secs") {
93 SEND_CODE
94 } else {
95 REGISTER
96 }
97 }
98 ("auth", "login", "create") => LOGIN,
99 ("auth", "logout", "delete") => LOGOUT,
100 ("auth", "verification_token", "create") => SEND_CODE,
101 ("auth", "password", "create") => REQUEST_RESET,
102 ("auth", "password", "update") => RESET_PASSWORD,
103 ("auth", "ssh_key", "create") => ADD_SSH_KEY,
104 ("auth", "ssh_key", "delete") => DELETE_SSH_KEY,
105 ("auth", "access_key", "create") => {
106 if has_field(payload, "rotated") {
107 ROTATE_ACCESS_KEY
108 } else {
109 CREATE_ACCESS_KEY
110 }
111 }
112 ("auth", "access_key", "delete") => DELETE_ACCESS_KEY,
113 ("auth", "access_key", "used") => USE_ACCESS_KEY,
114 ("auth", "challenge", "create") => CHALLENGE,
115 ("auth", "challenge", "used") => VERIFY_CHALLENGE,
116 ("auth", "app_token", "create") | ("auth", "token", "create") => ISSUE_TOKEN,
117 ("email", "delivery", _) => DELIVERY,
118 ("email", "failure", _) => FAILURE,
119 ("organization", "life_cycle", "create") => CREATE,
120 ("organization", "life_cycle", "update") => match action {
121 Some("archive") => ARCHIVE,
122 Some("restore") => RESTORE,
123 _ => UPDATE,
124 },
125 ("organization", "life_cycle", "delete") => DELETE,
126 ("organization", "invite", "create") => match action {
127 Some("revoke") => REVOKE_INVITE,
128 Some("redeem") => REDEEM_INVITE,
129 _ => INVITE,
130 },
131 ("organization", "member", "update") => match action {
132 Some("join_role") => JOIN,
133 Some("leave_role") => LEAVE,
134 Some("add") => ADD_MEMBER,
135 Some("remove") => REMOVE_MEMBER,
136 Some("set_role") => SET_ROLE,
137 Some("ban") => BAN,
138 Some("unban") => UNBAN,
139 Some("update_ban") => UPDATE_BAN,
140 Some("transfer_ownership") => TRANSFER_OWNER,
141 _ => UPDATE_MEMBER,
142 },
143 ("organization", "role", "update") => match action {
144 Some("create") => CREATE_ROLE,
145 Some("set_permissions") => SET_PERMISSIONS,
146 Some("update") => UPDATE_ROLE,
147 Some("delete") => DELETE_ROLE,
148 Some("assign") => ASSIGN_ROLE,
149 Some("unassign") => UNASSIGN_ROLE,
150 _ => GRANT,
151 },
152 (_, _, operate) => operate,
153 }
154}
155
156fn has_field(payload: Option<&Value>, key: &str) -> bool {
157 payload.is_some_and(|value| value.get(key).is_some())
158}
Modified lib/events/src/lib.rs View lib/events/src/lib.rs
@@ -1,10 +1,7 @@
1#![recursion_limit = "256"]
2
3pub mod event;
41pub mod handler;
2pub mod label;
53pub mod outbox;
6pub(crate) mod route;
4pub mod topic;
75
8pub use event::*;
96pub use handler::{EventHandler, retry_publish};
107pub use queue::{Envelope, Labels};
Modified lib/events/src/outbox.rs View lib/events/src/outbox.rs
@@ -6,7 +6,7 @@
66use models::event_outbox;
77use opentelemetry::metrics::Gauge;
88use opentelemetry::{KeyValue, global};
9use queue::{DeliveryClass, Envelope, Publisher, SCHEMA_VERSION};
9use queue::{DeliveryClass, EVENT_PREFIX, Envelope, Publisher, SCHEMA_VERSION};
1010use sea_orm::ActiveValue::Set;
1111use sea_orm::{
1212 ColumnTrait, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, DbErr,
@@ -19,7 +19,8 @@
1919use tracing::Instrument;
2020use uuid::Uuid;
2121
22use crate::{EventHandler, Labels, RuntimeEvent};
22use crate::handler::DURABLE_DOMAINS;
23use crate::{EventHandler, Labels, label, topic};
2324
2425const BATCH_SIZE: i64 = 16;
2526const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
@@ -76,46 +77,57 @@
7677 .await
7778}
7879
80fn domain(kind: &str) -> &str {
81 kind.split('.').next().unwrap_or_default()
82}
83
84fn is_durable_kind(kind: &str) -> bool {
85 DURABLE_DOMAINS.contains(&domain(kind))
86}
87
7988/// 串行化粒度:同一 `ordering_key` 的事件严格按写入顺序投递。
8089///
8190/// 组织事件按组织串行,认证事件按用户串行(没有用户主体时退回请求 id,
8291/// 相当于不串行),邮件事件按发信请求串行。其余域沿用资源 id。
83fn ordering_key(event: RuntimeEvent, labels: &Labels) -> Result<String, DbErr> {
84 let key = match event.domain() {
85 "guild" => labels.get("guild_id"),
86 "git" => labels.get("repository_id"),
92fn ordering_key(kind: &str, labels: &Labels) -> Result<String, DbErr> {
93 let domain = domain(kind);
94 let key = match domain {
95 "guild" => labels.get(label::GUILD_ID),
96 "git" => labels.get(label::REPOSITORY_ID),
8797 "pull_request" => labels
88 .get("pull_request_id")
89 .or_else(|| labels.get("repository_id")),
98 .get(label::PULL_REQUEST_ID)
99 .or_else(|| labels.get(label::REPOSITORY_ID)),
90100 "issue" => labels
91 .get("issue_id")
92 .or_else(|| labels.get("board_id"))
93 .or_else(|| labels.get("pull_request_id"))
94 .or_else(|| labels.get("guild_id")),
95 "organization" => labels.get("organization_id"),
96 "auth" => labels.get("user_id").or_else(|| labels.get("request_id")),
97 "email" => labels.get("request_id"),
101 .get(label::ISSUE_ID)
102 .or_else(|| labels.get(label::BOARD_ID))
103 .or_else(|| labels.get(label::PULL_REQUEST_ID))
104 .or_else(|| labels.get(label::GUILD_ID)),
105 "organization" => labels.get(label::ORGANIZATION_ID),
106 "auth" => labels
107 .get(label::USER_ID)
108 .or_else(|| labels.get(label::REQUEST_ID)),
109 "email" => labels.get(label::REQUEST_ID),
98110 _ => labels
99 .get("channel_id")
100 .or_else(|| labels.get("guild_id"))
101 .or_else(|| labels.get("room_id"))
102 .or_else(|| labels.get("organization_id"))
103 .or_else(|| labels.get("user_id"))
104 .or_else(|| labels.get("request_id")),
111 .get(label::CHANNEL_ID)
112 .or_else(|| labels.get(label::GUILD_ID))
113 .or_else(|| labels.get(label::ROOM_ID))
114 .or_else(|| labels.get(label::ORGANIZATION_ID))
115 .or_else(|| labels.get(label::USER_ID))
116 .or_else(|| labels.get(label::REQUEST_ID)),
105117 };
106118 key.filter(|key| !key.is_empty())
107 .map(|key| format!("{}:{key}", event.domain()))
119 .map(|key| format!("{domain}:{key}"))
108120 .ok_or_else(|| {
109121 DbErr::Custom(format!(
110 "event `{event}` requires a user, organization or request id label"
122 "event `{kind}` requires a user, organization or request id label"
111123 ))
112124 })
113125}
114126
115#[tracing::instrument(skip_all, fields(request_id = %_request_id.unwrap_or_default()))]
127#[tracing::instrument(skip_all, fields(kind, request_id = %_request_id.unwrap_or_default()))]
116128pub async fn enqueue<T: Serialize>(
117129 txn: &DatabaseTransaction,
118 event: RuntimeEvent,
130 kind: &str,
119131 labels: Labels,
120132 payload: T,
121133 trace_id: &str,
@@ -123,7 +135,7 @@
123135) -> Result<Uuid, DbErr> {
124136 enqueue_at(
125137 txn,
126 event,
138 kind,
127139 labels,
128140 payload,
129141 trace_id,
@@ -133,29 +145,28 @@
133145 .await
134146}
135147
136#[tracing::instrument(skip_all, fields(request_id = %_request_id.unwrap_or_default()))]
148#[tracing::instrument(skip_all, fields(kind, request_id = %_request_id.unwrap_or_default()))]
137149pub async fn enqueue_at<T: Serialize>(
138150 conn: &impl ConnectionTrait,
139 event: RuntimeEvent,
151 kind: &str,
140152 labels: Labels,
141153 payload: T,
142154 trace_id: &str,
143155 available_at: DateTime<Utc>,
144156 _request_id: Option<&str>,
145157) -> Result<Uuid, DbErr> {
146 if event.delivery() != DeliveryClass::BusinessEvent
147 || !crate::handler::DURABLE_DOMAINS.contains(&event.domain())
148 {
158 if !is_durable_kind(kind) {
149159 return Err(DbErr::Custom(format!(
150 "event `{event}` requires an outbox-enabled business domain"
160 "event `{kind}` requires an outbox-enabled business domain"
151161 )));
152162 }
153 let ordering_key = ordering_key(event, &labels)?;
154 let envelope = Envelope::new(event.kind(), payload)
155 .with_delivery(event.delivery())
156 .with_source(event.domain())
163 let ordering_key = ordering_key(kind, &labels)?;
164 let envelope = Envelope::new(kind, payload)
165 .with_delivery(DeliveryClass::BusinessEvent)
166 .with_source(domain(kind))
157167 .with_labels(labels)
158168 .with_trace_id(trace_id);
169 let subject = format!("{EVENT_PREFIX}{}", envelope.kind);
159170 let encoded = envelope
160171 .encode()
161172 .map_err(|error| DbErr::Custom(format!("encoding outbox event: {error}")))?;
@@ -168,7 +179,7 @@
168179 .map_err(|error| DbErr::Custom(format!("storing outbox event: {error}")))?;
169180 event_outbox::Entity::insert(event_outbox::ActiveModel {
170181 id: Set(envelope.id),
171 subject: Set(event.subject()),
182 subject: Set(subject),
172183 kind: Set(envelope.kind.clone()),
173184 ordering_key: Set(ordering_key),
174185 envelope: Set(stored),
@@ -249,10 +260,7 @@
249260
250261/// identity 事件与 apihost 事件共用一张 outbox,按 kind 的域段决定投递管线。
251262fn is_identity_kind(kind: &str) -> bool {
252 matches!(
253 kind.split('.').next().unwrap_or_default(),
254 "auth" | "organization" | "email"
255 )
263 matches!(domain(kind), "auth" | "organization" | "email")
256264}
257265
258266async fn publish_identity(
@@ -263,11 +271,11 @@
263271 let request_id = row
264272 .envelope
265273 .get("labels")
266 .and_then(|labels| labels.get("request_id"))
274 .and_then(|labels| labels.get(label::REQUEST_ID))
267275 .or_else(|| {
268276 row.envelope
269277 .get("payload")
270 .and_then(|payload| payload.get("request_id"))
278 .and_then(|payload| payload.get(label::REQUEST_ID))
271279 })
272280 .and_then(Value::as_str)
273281 .unwrap_or_default();
@@ -286,17 +294,20 @@
286294 let publisher = publisher.ok_or_else(|| "app event publisher is not configured".to_owned())?;
287295 let envelope: Envelope<Value> = serde_json::from_value(row.envelope.clone())
288296 .map_err(|error| format!("invalid outbox envelope: {error}"))?;
289 if envelope.id != row.id || envelope.kind != row.kind {
297 if envelope.id != row.id
298 || envelope.kind != row.kind
299 || envelope.schema_version != SCHEMA_VERSION
300 {
290301 return Err("outbox event identity is invalid".to_owned());
291302 }
292 let route = queue::route::route(&envelope.kind).map_err(|error| error.to_string())?;
293 let topic = publisher.topic(&route.area);
303 let topic = publisher.topic(topic::of(&envelope.kind));
294304 let body = envelope.encode().map_err(|error| error.to_string())?;
295305 let payload = serde_json::to_value(&envelope.payload).ok();
306 let tag = label::tag(&envelope.kind, payload.as_ref());
296307 publisher
297308 .publish(
298309 &topic,
299 &route.tag,
310 tag,
300311 vec![row.id.to_string()],
301312 envelope.properties(payload.as_ref(), None),
302313 body,
@@ -315,11 +326,11 @@
315326 let request_id = row
316327 .envelope
317328 .get("labels")
318 .and_then(|labels| labels.get("request_id"))
329 .and_then(|labels| labels.get(label::REQUEST_ID))
319330 .or_else(|| {
320331 row.envelope
321332 .get("payload")
322 .and_then(|payload| payload.get("request_id"))
333 .and_then(|payload| payload.get(label::REQUEST_ID))
323334 })
324335 .and_then(Value::as_str)
325336 .unwrap_or_default()
@@ -579,58 +590,45 @@
579590#[cfg(test)]
580591mod tests {
581592 use super::ordering_key;
582 use crate::event::{AuthEvent, EmailEvent, OrganizationEvent, RestOperate};
583 use crate::{Labels, RuntimeEvent};
584
585 fn login() -> RuntimeEvent {
586 RuntimeEvent::Auth(AuthEvent::Login {
587 operate: RestOperate::Create,
588 })
589 }
590
591 fn email_delivery() -> RuntimeEvent {
592 RuntimeEvent::Email(EmailEvent::Delivery {
593 operate: RestOperate::Create,
594 })
595 }
596
597 fn organization_created() -> RuntimeEvent {
598 RuntimeEvent::Organization(OrganizationEvent::LifeCycle {
599 operate: RestOperate::Create,
600 })
601 }
593 use crate::{Labels, label};
602594
603595 #[test]
604596 fn auth_events_serialize_per_user_and_fall_back_to_request_id() {
605597 let user = Labels::new()
606 .with("user_id", 42)
607 .with("request_id", "req-1");
608 assert_eq!(ordering_key(login(), &user).unwrap(), "auth:42");
609
610 let anonymous = Labels::new().with("request_id", "req-2");
611 assert_eq!(ordering_key(login(), &anonymous).unwrap(), "auth:req-2");
598 .with(label::USER_ID, 42)
599 .with(label::REQUEST_ID, "req-1");
600 assert_eq!(
601 ordering_key(label::AUTH_LOGIN_CREATE, &user).unwrap(),
602 "auth:42"
603 );
604
605 let anonymous = Labels::new().with(label::REQUEST_ID, "req-2");
606 assert_eq!(
607 ordering_key(label::AUTH_LOGIN_CREATE, &anonymous).unwrap(),
608 "auth:req-2"
609 );
612610 }
613611
614612 #[test]
615613 fn organization_and_email_use_their_own_resource_ids() {
616614 let org = Labels::new()
617 .with("organization_id", "org-1")
618 .with("actor_user_id", 7);
615 .with(label::ORGANIZATION_ID, "org-1")
616 .with(label::ACTOR_USER_ID, 7);
619617 assert_eq!(
620 ordering_key(organization_created(), &org).unwrap(),
618 ordering_key(label::ORGANIZATION_LIFE_CYCLE_CREATE, &org).unwrap(),
621619 "organization:org-1"
622620 );
623621
624 let mail = Labels::new().with("request_id", "mail-1");
622 let mail = Labels::new().with(label::REQUEST_ID, "mail-1");
625623 assert_eq!(
626 ordering_key(email_delivery(), &mail).unwrap(),
624 ordering_key(label::EMAIL_DELIVERY_CREATE, &mail).unwrap(),
627625 "email:mail-1"
628626 );
629627 }
630628
631629 #[test]
632630 fn events_without_any_correlation_label_are_rejected() {
633 let error = ordering_key(login(), &Labels::new()).unwrap_err();
631 let error = ordering_key(label::AUTH_LOGIN_CREATE, &Labels::new()).unwrap_err();
634632 assert!(
635633 error
636634 .to_string()
Deleted lib/events/src/route.rs
@@ -1,107 +0,0 @@
1use serde_json::Value;
2
3use queue::{QueueError, QueueResult};
4
5pub struct Route {
6 pub area: String,
7 pub tag: String,
8}
9
10pub fn route(kind: &str, action: Option<&str>, payload: Option<&Value>) -> QueueResult<Route> {
11 let mut parts = kind.split('.');
12 let domain = parts.next().unwrap_or_default();
13 let name = parts.next().unwrap_or_default();
14 let operate = parts.next().unwrap_or_default();
15 if domain.is_empty() || name.is_empty() || operate.is_empty() {
16 return Err(QueueError::payload(format!(
17 "event kind `{kind}` is not a concrete event"
18 )));
19 }
20
21 let area = match domain {
22 "auth" => match name {
23 "ssh_key" => "sshkey",
24 "access_key" => "accesskey",
25 "app_token" | "token" => "token",
26 "verification_token" => "verification",
27 other => other,
28 },
29 "organization" => "org",
30 "email" => "email",
31 other => other,
32 };
33
34 let tag = match (domain, name, operate) {
35 ("auth", "register", "create") => {
36 if has_field(payload, "expires_in_secs") {
37 "send-code"
38 } else {
39 "register"
40 }
41 }
42 ("auth", "login", "create") => "login",
43 ("auth", "logout", "delete") => "logout",
44 ("auth", "verification_token", "create") => "send-code",
45 ("auth", "password", "create") => "request-reset",
46 ("auth", "password", "update") => "reset-password",
47 ("auth", "ssh_key", "create") => "add-sshkey",
48 ("auth", "ssh_key", "delete") => "delete-sshkey",
49 ("auth", "access_key", "create") => {
50 if has_field(payload, "rotated") {
51 "rotate-accesskey"
52 } else {
53 "create-accesskey"
54 }
55 }
56 ("auth", "access_key", "delete") => "delete-accesskey",
57 ("auth", "access_key", "used") => "use-accesskey",
58 ("auth", "challenge", "create") => "challenge",
59 ("auth", "challenge", "used") => "verify-challenge",
60 ("auth", "app_token", "create") | ("auth", "token", "create") => "issue-token",
61 ("email", "delivery", _) => "delivery",
62 ("email", "failure", _) => "failure",
63 ("organization", "life_cycle", "create") => "create",
64 ("organization", "life_cycle", "update") => match action {
65 Some("archive") => "archive",
66 Some("restore") => "restore",
67 _ => "update",
68 },
69 ("organization", "life_cycle", "delete") => "delete",
70 ("organization", "invite", "create") => match action {
71 Some("revoke") => "revoke-invite",
72 Some("redeem") => "redeem-invite",
73 _ => "invite",
74 },
75 ("organization", "member", "update") => match action {
76 Some("join_role") => "join",
77 Some("leave_role") => "leave",
78 Some("add") => "add-member",
79 Some("remove") => "remove-member",
80 Some("set_role") => "set-role",
81 Some("ban") => "ban",
82 Some("unban") => "unban",
83 Some("update_ban") => "update-ban",
84 Some("transfer_ownership") => "transfer-owner",
85 _ => "update-member",
86 },
87 ("organization", "role", "update") => match action {
88 Some("create") => "create-role",
89 Some("set_permissions") => "set-permissions",
90 Some("update") => "update-role",
91 Some("delete") => "delete-role",
92 Some("assign") => "assign-role",
93 Some("unassign") => "unassign-role",
94 _ => "grant",
95 },
96 (_, _, operate) => operate,
97 };
98
99 Ok(Route {
100 area: area.to_string(),
101 tag: tag.to_string(),
102 })
103}
104
105fn has_field(payload: Option<&Value>, key: &str) -> bool {
106 payload.is_some_and(|value| value.get(key).is_some())
107}
Added lib/events/src/topic.rs View lib/events/src/topic.rs
@@ -0,0 +1,47 @@
1pub const AUTH: &str = "auth";
2pub const SSH_KEY: &str = "sshkey";
3pub const ACCESS_KEY: &str = "accesskey";
4pub const TOKEN: &str = "token";
5pub const VERIFICATION: &str = "verification";
6pub const ORGANIZATION: &str = "org";
7pub const EMAIL: &str = "email";
8pub const HEALTH: &str = "login";
9
10pub const AGENT: &str = "agent";
11pub const CHAT: &str = "chat";
12pub const GIT: &str = "git";
13pub const GUILD: &str = "guild";
14pub const ISSUE: &str = "issue";
15pub const NOTIFY: &str = "notify";
16pub const OBJECT: &str = "object";
17pub const PULL_REQUEST: &str = "pull_request";
18pub const REALTIME: &str = "realtime";
19pub const USER: &str = "user";
20pub const WEBHOOK: &str = "webhook";
21
22pub fn of(kind: &str) -> &str {
23 let mut parts = kind.split('.');
24 let domain = parts.next().unwrap_or_default();
25 let name = parts.next().unwrap_or_default();
26 match (domain, name) {
27 ("auth", "ssh_key") => SSH_KEY,
28 ("auth", "access_key") => ACCESS_KEY,
29 ("auth", "app_token" | "token") => TOKEN,
30 ("auth", "verification_token") => VERIFICATION,
31 ("auth", _) => AUTH,
32 ("organization", _) => ORGANIZATION,
33 ("email", _) => EMAIL,
34 ("agent", _) => AGENT,
35 ("chat", _) => CHAT,
36 ("git", _) => GIT,
37 ("guild", _) => GUILD,
38 ("issue", _) => ISSUE,
39 ("notify", _) => NOTIFY,
40 ("object", _) => OBJECT,
41 ("pull_request", _) => PULL_REQUEST,
42 ("realtime", _) => REALTIME,
43 ("user", _) => USER,
44 ("webhook", _) => WEBHOOK,
45 (other, _) => other,
46 }
47}
Deleted lib/models/src/chat/link_unfurls.rs
@@ -1,49 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4
5#[derive(
6 Debug,
7 Clone,
8 Copy,
9 PartialEq,
10 Eq,
11 Default,
12 EnumIter,
13 DeriveActiveEnum,
14 Serialize,
15 Deserialize,
16 utoipa::ToSchema,
17)]
18#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
19pub enum UnfurlStatus {
20 #[default]
21 #[sea_orm(num_value = 0)]
22 Pending,
23 #[sea_orm(num_value = 1)]
24 Ready,
25 #[sea_orm(num_value = 2)]
26 Failed,
27}
28
29#[sea_orm::model]
30#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
31#[sea_orm(table_name = "chat_link_unfurl")]
32pub struct Model {
33 #[sea_orm(primary_key, auto_increment = false)]
34 pub url_hash: String,
35 pub url: String,
36 /// 跟随重定向后的落地地址;短链靠它才能在缓存命中时仍认出真实平台。
37 pub final_url: Option<String>,
38 pub title: Option<String>,
39 pub description: Option<String>,
40 pub image_url: Option<String>,
41 pub site_name: Option<String>,
42 /// 页面自声明的 oEmbed 端点,跨请求复用免得每次重新解析 HTML。
43 pub oembed: Option<String>,
44 #[sea_orm(default_value = 0)]
45 pub status: UnfurlStatus,
46 pub fetched_at: OffsetDateTime,
47}
48
49impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/chat/mod.rs View lib/models/src/chat/mod.rs
@@ -1,6 +1,5 @@
11pub mod attachments;
22pub mod emojis;
3pub mod link_unfurls;
43pub mod message_edits;
54pub mod message_reminders;
65pub mod messages;
Modified lib/session/src/middleware.rs View lib/session/src/middleware.rs
@@ -4,7 +4,7 @@
44use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready};
55use actix_web::http::header::{HeaderName, HeaderValue, USER_AGENT};
66use actix_web::{HttpMessage, HttpRequest, HttpResponse};
7use config::identity::session::{SameSite, SessionConfig};
7use config::session::{SameSite, SessionConfig};
88use std::fmt;
99use std::future::{Ready, ready};
1010use std::pin::Pin;
@@ -302,7 +302,7 @@
302302mod tests {
303303 use super::*;
304304
305 fn config(http_only: bool, secure: bool, same_site: SameSite) -> SessionConfig {
305 fn config(secure: bool, same_site: SameSite) -> SessionConfig {
306306 SessionConfig {
307307 cookie_name: "session".into(),
308308 max_age: 3600,
@@ -311,7 +311,6 @@
311311 domain: None,
312312 secure,
313313 same_site,
314 http_only,
315314 trust_proxy: false,
316315 }
317316 }
@@ -319,7 +318,7 @@
319318 #[test]
320319 fn session_cookie_is_always_httponly() {
321320 let cookie = session_cookie(
322 &config(false, true, SameSite::Lax),
321 &config(true, SameSite::Lax),
323322 "key".into(),
324323 Duration::days(1),
325324 );
@@ -329,20 +328,20 @@
329328 #[test]
330329 fn same_site_none_forces_secure() {
331330 let cookie = session_cookie(
332 &config(true, false, SameSite::None),
331 &config(false, SameSite::None),
333332 "key".into(),
334333 Duration::days(1),
335334 );
336335 assert_eq!(cookie.secure(), Some(true));
337336 // Lax 下 secure 跟随配置(这里为 false),强制只发生在 SameSite=None。
338337 let cookie = session_cookie(
339 &config(true, false, SameSite::Lax),
338 &config(false, SameSite::Lax),
340339 "key".into(),
341340 Duration::days(1),
342341 );
343342 assert_eq!(cookie.secure(), Some(false));
344343 let cookie = session_cookie(
345 &config(true, true, SameSite::Lax),
344 &config(true, SameSite::Lax),
346345 "key".into(),
347346 Duration::days(1),
348347 );
Modified lib/session/src/redis.rs View lib/session/src/redis.rs
@@ -4,7 +4,7 @@
44use cache::redis::{AsyncCommands, Script, ScriptInvocation};
55use cache::{Cache, CacheConnection};
66
7use config::identity::redis::RedisConfig;
7use config::redis::RedisConfig;
88
99use crate::error::SessionError;
1010use crate::key::SessionKey;
@@ -145,10 +145,6 @@
145145 "session key rotation is not supported over a Redis Cluster connection",
146146 ))
147147 }
148}
149
150pub async fn load() -> Result<RedisStore, SessionError> {
151 Ok(RedisStore::new(Cache::load().await?))
152148}
153149
154150pub async fn connect(config: &RedisConfig) -> Result<RedisStore, SessionError> {
Modified lib/social/src/html.rs View lib/social/src/html.rs
@@ -13,7 +13,6 @@
1313 pub player: Option<String>,
1414 pub theme_color: Option<String>,
1515 pub oembed: Option<String>,
16 pub textual: bool,
1716}
1817
1918pub fn parse(html: &str, base: &Url) -> PageMetadata {
@@ -53,17 +52,6 @@
5352 player: meta_any(head, &["twitter:player"]).and_then(|value| absolute(base, &value)),
5453 theme_color: meta_any(head, &["theme-color"]),
5554 oembed: link_oembed(head).and_then(|value| absolute(base, &value)),
56 textual: meta_any(
57 head,
58 &[
59 "og:title",
60 "og:description",
61 "og:site_name",
62 "twitter:title",
63 "twitter:description",
64 ],
65 )
66 .is_some(),
6755 }
6856}
6957
Modified lib/social/src/lib.rs View lib/social/src/lib.rs
@@ -27,7 +27,7 @@
2727pub use error::UnfurlError;
2828pub use parse::{EmbedRule, Platform, provider_count, providers};
2929pub use render::{escape_html, render_html};
30pub use resolve::{embed, embed_json, embed_json_enriched, is_supported, oembed_endpoint, parse};
30pub use resolve::{embed_json, embed_json_enriched, oembed_endpoint, parse};
3131pub use resolver::{ResolveContext, Resolver, UnfurlEngine, UnfurlOptions, unfurl};
3232pub use types::{EmbedAuthor, EmbedField, EmbedFooter, EmbedMedia, EmbedProvider, MessageEmbed};
3333
Modified lib/social/src/resolve.rs View lib/social/src/resolve.rs
@@ -1,18 +1,13 @@
11//! URL → 平台匹配与 embed 解析。
22//!
33//! 平台定义与域名路由在 [`crate::parse`];这里只做入口校验、直接媒体兜底,
4//! 以及对外的 [`parse`] / [`embed`] / [`embed_json`] 包装。
4//! 以及对外的 [`parse`] / [`embed_json`] 包装。
55
66use url::Url;
77
88use crate::parse::OembedEndpoint;
99use crate::{Aspect, Category, Embed, EmbedKind, Enrichment, Resolved};
1010
11/// URL 是否能在本地(不联网)解析出 embed。
12pub fn is_supported(raw: &str) -> bool {
13 parse(raw).is_some()
14}
15
1611fn direct_media(url: &Url) -> Option<(&'static str, &'static str, EmbedKind)> {
1712 let path = url.path().to_ascii_lowercase();
1813 let ext = path.rsplit('.').next().unwrap_or_default();
@@ -66,16 +61,11 @@
6661 })
6762}
6863
69/// 解析并渲染成 embed。
70pub fn embed(raw: &str) -> Option<Embed> {
64/// chat 消息 `embeds` 数组里的一项(`type = "platform"`)。
65pub fn embed_json(raw: &str) -> Option<serde_json::Value> {
7166 let resolved = parse(raw)?;
7267 let html = crate::render_html(&resolved);
73 Some(Embed { resolved, html })
74}
75
76/// chat 消息 `embeds` 数组里的一项(`type = "platform"`)。
77pub fn embed_json(raw: &str) -> Option<serde_json::Value> {
78 embed(raw).map(|embed| embed.to_json())
68 Some(Embed { resolved, html }.to_json())
7969}
8070
8171/// URL 命中的平台声明的 oEmbed 端点(未声明时返回 `None`)。
Modified lib/social/src/resolver.rs View lib/social/src/resolver.rs
@@ -1,7 +1,6 @@
11use std::future::Future;
22use std::pin::Pin;
33use std::sync::OnceLock;
4use std::time::Duration;
54
65use url::Url;
76
@@ -13,14 +12,12 @@
1312#[derive(Debug, Clone)]
1413pub struct UnfurlOptions {
1514 pub max_bytes: usize,
16 pub timeout: Duration,
1715}
1816
1917impl Default for UnfurlOptions {
2018 fn default() -> Self {
2119 Self {
2220 max_bytes: 1_048_576,
23 timeout: Duration::from_secs(10),
2421 }
2522 }
2623}
Modified lib/social/src/types.rs View lib/social/src/types.rs
@@ -159,16 +159,6 @@
159159 && self.html.is_none()
160160 }
161161
162 pub fn kind(&self) -> EmbedKind {
163 match self.embed_type.as_str() {
164 "iframe" => EmbedKind::Iframe,
165 "video" => EmbedKind::Video,
166 "audio" => EmbedKind::Audio,
167 "image" => EmbedKind::Image,
168 _ => EmbedKind::Link,
169 }
170 }
171
172162 pub fn to_json(&self) -> serde_json::Value {
173163 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
174164 }
Modified lib/token/src/session/claims.rs View lib/token/src/session/claims.rs
@@ -3,7 +3,7 @@
33#[derive(Debug, Clone, Deserialize)]
44pub struct AccessClaims {
55 pub iss: String,
6 #[serde(deserialize_with = "subject::deserialize")]
6 #[serde(deserialize_with = "crate::claims::subject::deserialize")]
77 pub sub: i64,
88 #[serde(rename = "client_id", alias = "app_id")]
99 pub client_id: String,
@@ -19,11 +19,11 @@
1919 pub security_version: i32,
2020 #[serde(default, rename = "sid", alias = "session_id")]
2121 pub sid: Option<String>,
22 #[serde(default, with = "scopes")]
22 #[serde(default, deserialize_with = "crate::claims::scopes::deserialize")]
2323 pub scope: Option<Vec<String>>,
2424 #[serde(default)]
2525 pub username: Option<String>,
26 #[serde(default = "default_token_type")]
26 #[serde(default = "crate::claims::default_token_type")]
2727 pub token_type: String,
2828}
2929
@@ -61,7 +61,7 @@
6161#[derive(Debug, Clone, Deserialize)]
6262pub struct ServiceClaims {
6363 pub iss: String,
64 #[serde(deserialize_with = "subject::deserialize")]
64 #[serde(deserialize_with = "crate::claims::subject::deserialize")]
6565 pub sub: i64,
6666 #[serde(rename = "aud", alias = "audience")]
6767 pub aud: String,
@@ -77,50 +77,6 @@
7777 pub scopes: Vec<i32>,
7878 #[serde(default)]
7979 pub service_id: String,
80 #[serde(default = "default_token_type")]
80 #[serde(default = "crate::claims::default_token_type")]
8181 pub token_type: String,
82}
83
84fn default_token_type() -> String {
85 "access".to_owned()
86}
87
88pub mod subject {
89 use serde::{Deserialize, Deserializer};
90
91 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
92 #[derive(Deserialize)]
93 #[serde(untagged)]
94 enum Subject {
95 Text(String),
96 Number(i64),
97 }
98
99 match Subject::deserialize(deserializer)? {
100 Subject::Text(value) => value.parse().map_err(serde::de::Error::custom),
101 Subject::Number(value) => Ok(value),
102 }
103 }
104}
105
106mod scopes {
107 use serde::{Deserialize, Deserializer};
108
109 pub fn deserialize<'de, D: Deserializer<'de>>(
110 deserializer: D,
111 ) -> Result<Option<Vec<String>>, D::Error> {
112 #[derive(Deserialize)]
113 #[serde(untagged)]
114 enum Scopes {
115 Text(String),
116 List(Vec<String>),
117 }
118
119 Ok(
120 Option::<Scopes>::deserialize(deserializer)?.map(|value| match value {
121 Scopes::Text(value) => value.split_ascii_whitespace().map(str::to_owned).collect(),
122 Scopes::List(value) => value,
123 }),
124 )
125 }
12682}
Modified lib/token/src/authority.rs View lib/token/src/authority.rs
@@ -169,26 +169,12 @@
169169 installed()?.user(user_id).await
170170}
171171
172pub async fn users(user_ids: &[i64]) -> Result<Vec<UserRecord>, JwtError> {
173 if user_ids.is_empty() {
174 return Ok(Vec::new());
175 }
176 if user_ids.len() > MAX_BATCH_USER_IDS {
177 return Err(JwtError::Malformed);
178 }
179 installed()?.users(user_ids).await
180}
181
182172pub async fn session(
183173 user_id: i64,
184174 version: i32,
185175 session_id: &str,
186176) -> Result<AccessRecord, JwtError> {
187177 installed()?.session(user_id, version, session_id).await
188}
189
190pub async fn update(user_id: i64, patch: serde_json::Value) -> Result<UserRecord, JwtError> {
191 installed()?.update(user_id, patch).await
192178}
193179
194180#[cfg(test)]
@@ -266,7 +252,7 @@
266252 #[tokio::test]
267253 async fn signed_tokens_obey_current_account_and_session_state() {
268254 let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]);
269 let config = config::identity::jwt::JwtConfig {
255 let config = config::jwt::JwtConfig {
270256 issuer: "fixture".into(),
271257 audience: Some("gitdata.ai".into()),
272258 leeway_secs: 0,
Modified lib/token/src/claims.rs View lib/token/src/claims.rs
@@ -58,7 +58,7 @@
5858 }
5959}
6060
61mod scopes {
61pub(crate) mod scopes {
6262 use serde::{Deserialize, Deserializer, Serialize, Serializer};
6363
6464 pub fn serialize<S: Serializer>(
@@ -89,7 +89,7 @@
8989 }
9090}
9191
92fn default_token_type() -> String {
92pub(crate) fn default_token_type() -> String {
9393 "access".to_owned()
9494}
9595
Modified lib/token/src/service.rs View lib/token/src/service.rs
@@ -1,6 +1,6 @@
11use std::sync::OnceLock;
22
3use config::identity::service_auth::ServiceAuthConfig;
3use config::service_auth::ServiceAuthConfig;
44use sha2::{Digest, Sha256};
55
66use crate::JwtError;
@@ -54,16 +54,6 @@
5454fn matches_digest(expected: &str, supplied: &str) -> bool {
5555 let left = format!("sha256:{:x}", Sha256::digest(supplied.as_bytes()));
5656 crypt::constant_time_eq(left.as_bytes(), expected.to_ascii_lowercase().as_bytes())
57}
58
59pub fn attach(metadata: &mut tonic::metadata::MetadataMap) -> Result<(), JwtError> {
60 let (service, key) = credentials()?;
61 metadata.insert(
62 SERVICE_HEADER,
63 service.parse().map_err(|_| JwtError::Malformed)?,
64 );
65 metadata.insert(KEY_HEADER, key.parse().map_err(|_| JwtError::Malformed)?);
66 Ok(())
6757}
6858
6959pub fn from_metadata(metadata: &tonic::metadata::MetadataMap) -> Result<String, JwtError> {
Modified lib/token/src/state.rs View lib/token/src/state.rs
@@ -2,7 +2,7 @@
22use std::sync::{Arc, OnceLock, RwLock};
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
44
5use config::identity::jwt::JwtConfig;
5use config::jwt::JwtConfig;
66
77use crate::claims::AppClaims;
88use crate::codec;
Modified scripts/dev.ps1 View scripts/dev.ps1
@@ -146,7 +146,7 @@
146146 }
147147 }
148148 if (-not (Test-Path -LiteralPath 'C:\opt\gitdata\repos')) {
149 Write-Warn '[warn] 无法创建 C:\opt\gitdata\repos,请在 config/local.toml 或 APP_GIT__REPO_ROOT 指定可写目录'
149 Write-Warn '[warn] 无法创建 C:\opt\gitdata\repos,请在 config/local/git.json 或 APP_GIT__REPO_ROOT 指定可写目录'
150150 }
151151}
152152
@@ -406,26 +406,25 @@
406406
407407function Get-ConfigUrl {
408408 param([string]$Scheme)
409 $file = Join-Path $Root 'config\local.toml'
410 if (Test-Path -LiteralPath $file) {
411 $pattern = '^\s*url\s*=\s*"' + $Scheme + '://([^"]+)"'
412 foreach ($line in Get-Content -LiteralPath $file) {
413 if ($line -match $pattern) { return ($Scheme + '://' + $Matches[1]) }
414 }
409 $dir = Join-Path $Root 'config\local'
410 if (Test-Path -LiteralPath $dir) {
411 $match = Select-String -Path (Join-Path $dir '*.json') -Pattern ('"' + $Scheme + '://[^"]+"') -AllMatches |
412 Select-Object -First 1
413 if ($match) { return $match.Matches[0].Value.Trim('"') }
415414 }
416415 return ''
417416}
418417
419418function Invoke-Psql {
420419 $url = Get-ConfigUrl 'postgres'
421 if (-not $url) { throw '未能从 config/local.toml 解析 postgres url' }
420 if (-not $url) { throw '未能从 config/local 解析 postgres url' }
422421 Write-Info "[psql] $url"
423422 & wsl.exe -d Ubuntu -- psql $url
424423}
425424
426425function Invoke-Redis {
427426 $url = Get-ConfigUrl 'redis'
428 if (-not $url) { throw '未能从 config/local.toml 解析 redis url' }
427 if (-not $url) { throw '未能从 config/local 解析 redis url' }
429428 Write-Info "[redis-cli] $url"
430429 & wsl.exe -d Ubuntu -- redis-cli -u $url
431430}
Modified scripts/dev.sh View scripts/dev.sh
@@ -203,9 +203,9 @@
203203
204204config_url() {
205205 local scheme="$1" found=''
206 if [ -f "$ROOT/config/local.toml" ]; then
207 found="$(grep -E "^[[:space:]]*url[[:space:]]*=[[:space:]]*\"${scheme}://" "$ROOT/config/local.toml" 2>/dev/null \
208 | head -n1 | sed -E 's/^[^"]*"([^"]+)".*$/\1/')"
206 if [ -d "$ROOT/config/local" ]; then
207 found="$(grep -rhoE "\"${scheme}://[^\"]+\"" "$ROOT/config/local" 2>/dev/null \
208 | head -n1 | tr -d '"')"
209209 fi
210210 if [ -z "$found" ] && [ -f "$ROOT/.env" ]; then
211211 found="$(grep -E "^[[:space:]]*(export[[:space:]]+)?[A-Z_]*URL[[:space:]]*=[[:space:]]*.${scheme}://" "$ROOT/.env" 2>/dev/null \
@@ -215,8 +215,12 @@
215215}
216216
217217config_has_key() {
218 [ -f "$ROOT/config/local.toml" ] || return 1
219 grep -qE "^[[:space:]]*$1[[:space:]]*=" "$ROOT/config/local.toml" 2>/dev/null
218 local file
219 for file in "$ROOT"/config/local/*.json; do
220 [ -f "$file" ] || continue
221 grep -q "\"$1\"" "$file" 2>/dev/null && return 0
222 done
223 return 1
220224}
221225
222226# ---------- check ----------
@@ -363,7 +367,7 @@
363367 export APP_GIT__SSH__HOST_KEY_PATH APP_GIT__REPO_ROOT APP_GIT__OBJECTS_ROOT
364368 mkdir -p "$APP_GIT__REPO_ROOT" "$APP_GIT__OBJECTS_ROOT"
365369
366 # macOS 的 HOME 通常不可写 /opt;若 local.toml 指向 /opt 又不可写则给出提示
370 # macOS 的 HOME 通常不可写 /opt;若 config/local/ 指向 /opt 又不可写则给出提示
367371 if ! config_has_key 'repo_root'; then
368372 return 0
369373 fi
Modified .dockerignore View .dockerignore
@@ -6,7 +6,7 @@
66.zcode
77.env
88.env.*
9config/local.toml
9config/local
1010docs
1111openapi.json
1212*.iml
Modified .gitignore View .gitignore
@@ -2,7 +2,7 @@
22.idea
33target
44.zcode
5config/local.toml
5config/local/
66.env
77.agents
88web/dist
Modified Cargo.lock View Cargo.lock
@@ -1874,7 +1874,6 @@
18741874version = "1.0.0"
18751875dependencies = [
18761876 "async-trait",
1877 "config 1.0.0",
18781877 "sea-orm",
18791878 "sqlparser",
18801879 "thiserror 2.0.21",
Modified docker-compose.dev.yml View docker-compose.dev.yml
@@ -1,5 +1,5 @@
11# 本地开发依赖服务(macOS / Linux)。
2# 与 config/local.toml 的默认端口一一对应:
2# 与 config/local/ 的默认端口一一对应:
33# PostgreSQL 5432 / Redis 6379 / S3(RustFS) 9000
44# RocketMQ 是 KenApi 的启动必需项,由宿主机提供(默认 127.0.0.1:6876);
55# 本 compose 不启动 MQ,启动 KenApi 前请先确认 nameserver 可达。
@@ -80,7 +80,7 @@
8080 redis:
8181 condition: service_healthy
8282
83 # 本地 S3 bucket 初始化(建出 config/local.toml 里声明的 bucket)
83 # 本地 S3 bucket 初始化(建出 config/local/s3.json 里声明的 bucket)
8484 minio-init:
8585 image: minio/mc:latest
8686 container_name: gitdata-dev-bucket-init
Modified Dockerfile View Dockerfile
@@ -94,8 +94,8 @@
9494
9595WORKDIR /app
9696
97# 只带默认配置;config/local.toml 与各类密钥由部署方挂载或注入。
98COPY --chown=gitdata:gitdata config/default.toml config/default.toml
97# 只带默认配置;config/local/ 与各类密钥由部署方挂载或注入。
98COPY --chown=gitdata:gitdata config/default config/default
9999COPY --from=builder /out/kenapi /usr/local/bin/
100100
101101LABEL org.opencontainers.image.title="KenApi" \
Modified justfile View justfile
@@ -4,7 +4,7 @@
44# 由 WSL(Ubuntu) 的 systemd 托管(just infra)。
55# macOS/Linux:原生跑 Rust/前端,依赖服务由 docker compose 托管(just infra)。
66#
7# 配置沿用 config/local.toml(gitignore)。首次使用:
7# 配置沿用 config/local/(gitignore)。首次使用:
88# just check -> just infra -> just build -> just up
99# 常用:just up / just down / just status / just tail apihost
1010#