refactor: inline closures for improved readability and remove unused functions
ZhenYi committed
Modified app/src/main.rs app/src/main.rs +9 −10 View app/src/main.rs
@@ -16,7 +16,15 @@
1616 Command::Mail => mail(),
1717 Command::Migrate => migrate(),
1818 Command::Openapi { check } => openapi(check),
19 Command::IdentityOpenapi => identity_openapi(),
19 Command::IdentityOpenapi => {
20 use utoipa::OpenApi;
21 let doc = auth::openapi::ApiDoc::openapi();
22 let path =
23 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../lib/auth/OpenApi.Auth.json");
24 std::fs::write(&path, doc.to_pretty_json()?).context("writing identity OpenAPI")?;
25 println!("wrote {}", path.display());
26 Ok(())
27 }
2028 }
2129}
2230
@@ -332,15 +340,6 @@
332340 }
333341 let value: serde_json::Value = figment.extract().context("loading configuration")?;
334342 Ok(Some(auth::config::Config::from_value(value)))
335}
336
337fn identity_openapi() -> anyhow::Result<()> {
338 use utoipa::OpenApi;
339 let doc = auth::openapi::ApiDoc::openapi();
340 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../lib/auth/OpenApi.Auth.json");
341 std::fs::write(&path, doc.to_pretty_json()?).context("writing identity OpenAPI")?;
342 println!("wrote {}", path.display());
343 Ok(())
344343}
345344
346345const QUEUE_DEPTH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
Modified lib/auth/src/api/authority.rs lib/auth/src/api/authority.rs +34 −32 View lib/auth/src/api/authority.rs
@@ -100,37 +100,6 @@
100100 }
101101}
102102
103fn patch_value(patch: &AuthorityUserPatch) -> Result<serde_json::Value, String> {
104 if patch.displayname.is_some() && patch.clear_displayname {
105 return Err("patch carries both displayname and clear_displayname".into());
106 }
107 if let Some(username) = patch.username.as_deref()
108 && username.trim().is_empty()
109 {
110 return Err("patch carries an empty username".into());
111 }
112 if patch.username.is_none()
113 && patch.displayname.is_none()
114 && !patch.clear_displayname
115 && patch.public_show_email.is_none()
116 {
117 return Err("patch carries no updatable field".into());
118 }
119 let mut value = serde_json::json!({ "version": i64::from(patch.version) });
120 if let Some(username) = patch.username.as_deref() {
121 value["username"] = serde_json::Value::String(username.to_owned());
122 }
123 if patch.clear_displayname {
124 value["displayname"] = serde_json::Value::Null;
125 } else if let Some(displayname) = patch.displayname.as_deref() {
126 value["displayname"] = serde_json::Value::String(displayname.to_owned());
127 }
128 if let Some(show) = patch.public_show_email {
129 value["public_show_email"] = serde_json::Value::Bool(show);
130 }
131 Ok(value)
132}
133
134103#[tonic::async_trait]
135104impl AuthorityService for AuthorityApi {
136105 async fn redeem_socket_ticket(
@@ -400,7 +369,40 @@
400369 request_id,
401370 ));
402371 }
403 let value = patch_value(&patch).map_err(|message| {
372 let value = {
373 let __inline = |patch: &AuthorityUserPatch| -> Result<serde_json::Value, String> {
374 if patch.displayname.is_some() && patch.clear_displayname {
375 return Err("patch carries both displayname and clear_displayname".into());
376 }
377 if let Some(username) = patch.username.as_deref()
378 && username.trim().is_empty()
379 {
380 return Err("patch carries an empty username".into());
381 }
382 if patch.username.is_none()
383 && patch.displayname.is_none()
384 && !patch.clear_displayname
385 && patch.public_show_email.is_none()
386 {
387 return Err("patch carries no updatable field".into());
388 }
389 let mut value = serde_json::json!({ "version": i64::from(patch.version) });
390 if let Some(username) = patch.username.as_deref() {
391 value["username"] = serde_json::Value::String(username.to_owned());
392 }
393 if patch.clear_displayname {
394 value["displayname"] = serde_json::Value::Null;
395 } else if let Some(displayname) = patch.displayname.as_deref() {
396 value["displayname"] = serde_json::Value::String(displayname.to_owned());
397 }
398 if let Some(show) = patch.public_show_email {
399 value["public_show_email"] = serde_json::Value::Bool(show);
400 }
401 Ok(value)
402 };
403 __inline(&patch)
404 }
405 .map_err(|message| {
404406 error::invalid_argument(format!("invalid patch: {message}"), request_id)
405407 })?;
406408 let record =
Modified lib/auth/src/api/identity.rs lib/auth/src/api/identity.rs +6 −8 View lib/auth/src/api/identity.rs
@@ -32,13 +32,6 @@
3232 }
3333}
3434
35fn random_bot_id() -> i64 {
36 let mut rng = rand::rng();
37 let digits = rng.random_range(5u32..=10);
38 let upper = 10i64.pow(digits);
39 rng.random_range((upper / 10)..upper)
40}
41
4235fn bot_public(user: &users::Model) -> crate::protos::auth::common::v1::UserPublic {
4336 crate::api::store::user_public(user, String::new(), None)
4437}
@@ -366,7 +359,12 @@
366359 .await
367360 .map_err(|value| error::status(&value, request_id))?;
368361 let inserted = users::ActiveModel {
369 id: Set(random_bot_id()),
362 id: Set({
363 let mut rng = rand::rng();
364 let digits = rng.random_range(5u32..=10);
365 let upper = 10i64.pow(digits);
366 rng.random_range((upper / 10)..upper)
367 }),
370368 username: Set(username.to_string()),
371369 lowername: Set(lower.clone()),
372370 displayname: Set(Some(displayname)),
Modified lib/auth/src/api/mfa.rs lib/auth/src/api/mfa.rs +40 −36 View lib/auth/src/api/mfa.rs
@@ -59,8 +59,28 @@
5959 }
6060 factors.push(get_mfa_status_response::Factor {
6161 factor_id: row.id.to_string(),
62 mfa_type: store::mfa_type_code(proto_type(&row.mfa_type)),
63 status: store::mfa_status_code(proto_status(&row.mfa_status)),
62 mfa_type: store::mfa_type_code({
63 let __inline = |value: &MfaType| -> ProtoMfaType {
64 match value {
65 MfaType::Totp => ProtoMfaType::Totp,
66 MfaType::Webauthn => ProtoMfaType::Webauthn,
67 MfaType::Sms => ProtoMfaType::Sms,
68 MfaType::Email => ProtoMfaType::Email,
69 }
70 };
71 __inline(&row.mfa_type)
72 }),
73 status: store::mfa_status_code({
74 let __inline = |value: &MfaStatus| -> ProtoMfaStatus {
75 match value {
76 MfaStatus::Enabled => ProtoMfaStatus::Enabled,
77 MfaStatus::Disabled => ProtoMfaStatus::Disabled,
78 MfaStatus::Pending => ProtoMfaStatus::Pending,
79 MfaStatus::Revoked => ProtoMfaStatus::Revoked,
80 }
81 };
82 __inline(&row.mfa_status)
83 }),
6484 name: row.name,
6585 has_secret: row.secret_ciphertext.is_some(),
6686 last_used_step: row.last_totp_step.unwrap_or(0),
@@ -112,8 +132,24 @@
112132 let expires_at = store::now() + chrono::Duration::days(RECOVERY_CODE_TTL_DAYS);
113133 let mut codes = Vec::with_capacity(count);
114134 for _ in 0..count {
115 let code = recovery_code();
116 let normalized = normalize_recovery_code(&code);
135 let code = {
136 let __inline = || -> String {
137 let left = crate::share::crypto::random_alnum(6);
138 let right = crate::share::crypto::random_alnum(6);
139 format!("{left}-{right}")
140 };
141 __inline()
142 };
143 let normalized = {
144 let __inline = |code: &str| -> String {
145 code.trim()
146 .to_uppercase()
147 .chars()
148 .filter(|value| *value != '-')
149 .collect()
150 };
151 __inline(&code)
152 };
117153 let code_hash = crate::share::crypto::sha256_hex(normalized.as_bytes());
118154 user_mfa_backup_codes::ActiveModel {
119155 id: Set(uuid::Uuid::new_v4()),
@@ -562,38 +598,6 @@
562598const RECOVERY_CODE_COUNT: usize = 10;
563599const RECOVERY_CODE_TTL_DAYS: i64 = 365;
564600const RECOVERY_CODE_HASH_KEY_ID: &str = "v1";
565
566fn normalize_recovery_code(code: &str) -> String {
567 code.trim()
568 .to_uppercase()
569 .chars()
570 .filter(|value| *value != '-')
571 .collect()
572}
573
574fn recovery_code() -> String {
575 let left = crate::share::crypto::random_alnum(6);
576 let right = crate::share::crypto::random_alnum(6);
577 format!("{left}-{right}")
578}
579
580fn proto_type(value: &MfaType) -> ProtoMfaType {
581 match value {
582 MfaType::Totp => ProtoMfaType::Totp,
583 MfaType::Webauthn => ProtoMfaType::Webauthn,
584 MfaType::Sms => ProtoMfaType::Sms,
585 MfaType::Email => ProtoMfaType::Email,
586 }
587}
588
589fn proto_status(value: &MfaStatus) -> ProtoMfaStatus {
590 match value {
591 MfaStatus::Enabled => ProtoMfaStatus::Enabled,
592 MfaStatus::Disabled => ProtoMfaStatus::Disabled,
593 MfaStatus::Pending => ProtoMfaStatus::Pending,
594 MfaStatus::Revoked => ProtoMfaStatus::Revoked,
595 }
596}
597601
598602#[tonic::async_trait]
599603impl MfaService for MfaApi {
Modified lib/auth/src/handlers/account/devices.rs lib/auth/src/handlers/account/devices.rs +48 −53 View lib/auth/src/handlers/account/devices.rs
@@ -55,56 +55,6 @@
5555 Some(name.to_string())
5656}
5757
58fn device_type_of(user_agent: &str) -> Option<String> {
59 let kind = if user_agent.contains("iPad") {
60 "tablet"
61 } else if user_agent.contains("Android") || user_agent.contains("iPhone") {
62 "mobile"
63 } else if user_agent.is_empty() {
64 "unknown"
65 } else {
66 "desktop"
67 };
68 Some(kind.to_string())
69}
70
71fn agent_of(user_agent: &str) -> Option<String> {
72 let name = if user_agent.contains("Edg/") {
73 "Edge"
74 } else if user_agent.contains("Firefox/") {
75 "Firefox"
76 } else if user_agent.contains("Chrome/") {
77 "Chrome"
78 } else if user_agent.contains("Safari/") {
79 "Safari"
80 } else if user_agent.contains("git/") {
81 "Git"
82 } else {
83 return None;
84 };
85 Some(name.to_string())
86}
87
88fn device_name_of(user_agent: &str) -> Option<String> {
89 let agent = agent_of(user_agent);
90 let os = os_of(user_agent);
91 match (agent, os) {
92 (Some(agent), Some(os)) => Some(format!("{agent} on {os}")),
93 (Some(agent), None) => Some(agent),
94 (None, Some(os)) => Some(os),
95 (None, None) => None,
96 }
97}
98
99fn stable_id(user_agent: &str) -> Uuid {
100 let digest = Sha256::digest(user_agent.as_bytes());
101 let mut bytes = [0u8; 16];
102 bytes.copy_from_slice(&digest[..16]);
103 bytes[6] = (bytes[6] & 0x0f) | 0x40;
104 bytes[8] = (bytes[8] & 0x3f) | 0x80;
105 Uuid::from_bytes(bytes)
106}
107
10858#[utoipa::path(
10959 get,
11060 path = "/me/devices",
@@ -147,7 +97,15 @@
14797 .last_seen_at
14898 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
14999 .unwrap_or(created_at);
150 let id = stable_id(&info.user_agent);
100 let id = {
101 let user_agent = &info.user_agent;
102 let digest = Sha256::digest(user_agent.as_bytes());
103 let mut bytes = [0u8; 16];
104 bytes.copy_from_slice(&digest[..16]);
105 bytes[6] = (bytes[6] & 0x0f) | 0x40;
106 bytes[8] = (bytes[8] & 0x3f) | 0x80;
107 Uuid::from_bytes(bytes)
108 };
151109 match rows.iter_mut().find(|row| row.id == id) {
152110 Some(row) => {
153111 row.first_seen_at = row.first_seen_at.min(created_at);
@@ -155,8 +113,45 @@
155113 }
156114 None => rows.push(DeviceRow {
157115 id,
158 device_name: device_name_of(&info.user_agent),
159 device_type: device_type_of(&info.user_agent),
116 device_name: {
117 let user_agent = &info.user_agent;
118 let agent = 'inline_agent_of: {
119 let name = if user_agent.contains("Edg/") {
120 "Edge"
121 } else if user_agent.contains("Firefox/") {
122 "Firefox"
123 } else if user_agent.contains("Chrome/") {
124 "Chrome"
125 } else if user_agent.contains("Safari/") {
126 "Safari"
127 } else if user_agent.contains("git/") {
128 "Git"
129 } else {
130 break 'inline_agent_of None;
131 };
132 Some(name.to_string())
133 };
134 let os = os_of(user_agent);
135 match (agent, os) {
136 (Some(agent), Some(os)) => Some(format!("{agent} on {os}")),
137 (Some(agent), None) => Some(agent),
138 (None, Some(os)) => Some(os),
139 (None, None) => None,
140 }
141 },
142 device_type: {
143 let user_agent = &info.user_agent;
144 let kind = if user_agent.contains("iPad") {
145 "tablet"
146 } else if user_agent.contains("Android") || user_agent.contains("iPhone") {
147 "mobile"
148 } else if user_agent.is_empty() {
149 "unknown"
150 } else {
151 "desktop"
152 };
153 Some(kind.to_string())
154 },
160155 os: os_of(&info.user_agent),
161156 first_seen_at: created_at,
162157 last_seen_at,
Modified lib/auth/src/handlers/identity/register.rs lib/auth/src/handlers/identity/register.rs +6 −8 View lib/auth/src/handlers/identity/register.rs
@@ -191,7 +191,12 @@
191191 self.ensure_email_available(&email).await?;
192192
193193 let inserted = users::ActiveModel {
194 id: Set(random_user_id()),
194 id: Set({
195 let mut rng = rand::rng();
196 let digits = rng.random_range(5u32..=10);
197 let upper = 10i64.pow(digits);
198 rng.random_range((upper / 10)..upper)
199 }),
195200 username: Set(username.clone()),
196201 lowername: Set(lowername.clone()),
197202 displayname: Set(displayname.clone()),
@@ -286,13 +291,6 @@
286291 }
287292 Ok(())
288293 }
289}
290
291fn random_user_id() -> i64 {
292 let mut rng = rand::rng();
293 let digits = rng.random_range(5u32..=10);
294 let upper = 10i64.pow(digits);
295 rng.random_range((upper / 10)..upper)
296294}
297295
298296#[utoipa::path(
Modified lib/auth/src/services/authority.rs lib/auth/src/services/authority.rs +10 −11 View lib/auth/src/services/authority.rs
@@ -15,16 +15,6 @@
1515 crypto::sha256_hex(id.to_string().as_bytes())
1616}
1717
18fn account_active(user: &users::Model) -> bool {
19 user.id > 0
20 && user.deleted_at.is_none()
21 && user.allow_use
22 && (!user.forbid_login
23 || user
24 .endtime_forbid_login_at
25 .is_none_or(|until| until > Utc::now()))
26}
27
2818impl AuthService {
2919 #[tracing::instrument(skip_all, fields(user_id = user.id, mfa = mfa, request_id = %request_id.unwrap_or_default()))]
3020 pub async fn bind_auth_session(
@@ -74,7 +64,16 @@
7464 .one(self.db().write())
7565 .await?
7666 .ok_or(AuthError::Unauthenticated)?;
77 if !account_active(&user) {
67 if !{
68 let user = &user;
69 user.id > 0
70 && user.deleted_at.is_none()
71 && user.allow_use
72 && (!user.forbid_login
73 || user
74 .endtime_forbid_login_at
75 .is_none_or(|until| until > Utc::now()))
76 } {
7877 return Err(AuthError::Unauthenticated);
7978 }
8079 if version <= 0 || version != user.security_version {
Modified lib/auth/src/share/password.rs lib/auth/src/share/password.rs +1 −5 View lib/auth/src/share/password.rs
@@ -98,7 +98,7 @@
9898}
9999
100100pub fn validate_new_password(value: &str, policy: &PasswordPolicy) -> AuthResult<()> {
101 if is_client_digest(value) {
101 if value.len() == CLIENT_DIGEST_LEN && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
102102 return if value.len() >= policy.min_length {
103103 Ok(())
104104 } else {
@@ -106,10 +106,6 @@
106106 };
107107 }
108108 validate_password(value, policy)
109}
110
111fn is_client_digest(value: &str) -> bool {
112 value.len() == CLIENT_DIGEST_LEN && value.bytes().all(|byte| byte.is_ascii_hexdigit())
113109}
114110
115111async fn blocking<T, F>(work: F) -> AuthResult<T>
Modified lib/auth/src/share/user.rs lib/auth/src/share/user.rs +6 −8 View lib/auth/src/share/user.rs
@@ -33,19 +33,17 @@
3333 reason: "account is not available".to_owned(),
3434 });
3535 }
36 if user.forbid_login && forbid_active(user) {
36 if user.forbid_login && {
37 match user.endtime_forbid_login_at {
38 Some(until) => until > Utc::now(),
39 None => true,
40 }
41 } {
3742 return Err(AuthError::LoginForbidden {
3843 reason: user.forbid_login_reason.clone(),
3944 });
4045 }
4146 Ok(())
42}
43
44fn forbid_active(user: &users::Model) -> bool {
45 match user.endtime_forbid_login_at {
46 Some(until) => until > Utc::now(),
47 None => true,
48 }
4947}
5048
5149pub fn ensure_security_version(user: &users::Model, expected: i32) -> AuthResult<()> {
Modified lib/auth/src/assets.rs lib/auth/src/assets.rs +25 −30 View lib/auth/src/assets.rs
@@ -17,19 +17,18 @@
1717/// 避免把已移除的旧路径回落到 index.html 变成 200。
1818const API_PREFIXES: [&str; 2] = ["api", "auth"];
1919
20fn looks_like_api(path: &str) -> bool {
21 if path.starts_with(".well-known/") {
22 return true;
23 }
24 let first = path.split('/').next().unwrap_or_default();
25 API_PREFIXES.contains(&first)
26}
27
2820pub async fn serve(request: HttpRequest) -> HttpResponse {
2921 let raw = request.path().trim_start_matches('/');
3022 let path = if raw.is_empty() { "index.html" } else { raw };
3123
32 if looks_like_api(path) {
24 let looks_like_api = 'inline_looks_like_api: {
25 if path.starts_with(".well-known/") {
26 break 'inline_looks_like_api true;
27 }
28 let first = path.split('/').next().unwrap_or_default();
29 API_PREFIXES.contains(&first)
30 };
31 if looks_like_api {
3332 return api_not_found();
3433 }
3534
@@ -46,8 +45,23 @@
4645 }
4746
4847 match lookup(path).or_else(|| lookup("index.html")) {
49 Some(asset) => respond(asset),
50 None => not_built(),
48 Some(asset) => {
49 let cache_control = if asset.path.starts_with("assets/") {
50 "public, max-age=31536000, immutable"
51 } else {
52 "no-cache"
53 };
54
55 HttpResponse::Ok()
56 .insert_header((CONTENT_TYPE, asset.content_type))
57 .insert_header((CACHE_CONTROL, cache_control))
58 .body(asset.bytes)
59 }
60 None => HttpResponse::ServiceUnavailable()
61 .insert_header((CONTENT_TYPE, "text/plain; charset=utf-8"))
62 .body(
63 "view/dist has no build output; run `just identity-web-build` and rebuild kenapi\n",
64 ),
5165 }
5266}
5367
@@ -55,28 +69,9 @@
5569 ASSETS.iter().find(|asset| asset.path == path)
5670}
5771
58fn respond(asset: &'static Asset) -> HttpResponse {
59 let cache_control = if asset.path.starts_with("assets/") {
60 "public, max-age=31536000, immutable"
61 } else {
62 "no-cache"
63 };
64
65 HttpResponse::Ok()
66 .insert_header((CONTENT_TYPE, asset.content_type))
67 .insert_header((CACHE_CONTROL, cache_control))
68 .body(asset.bytes)
69}
70
7172fn api_not_found() -> HttpResponse {
7273 HttpResponse::NotFound().json(crate::errors::ErrorBody {
7374 code: "not_found".to_owned(),
7475 message: "unknown api route".to_owned(),
7576 })
76}
77
78fn not_built() -> HttpResponse {
79 HttpResponse::ServiceUnavailable()
80 .insert_header((CONTENT_TYPE, "text/plain; charset=utf-8"))
81 .body("view/dist has no build output; run `just identity-web-build` and rebuild kenapi\n")
8277}
Modified lib/cache/src/error.rs lib/cache/src/error.rs +7 −9 View lib/cache/src/error.rs
@@ -76,18 +76,16 @@
7676
7777 pub fn is_retryable(&self) -> bool {
7878 match self {
79 Self::Backend(error) => retryable_backend(error),
79 Self::Backend(error) => {
80 error.is_io_error()
81 || error.is_timeout()
82 || error.is_connection_refusal()
83 || error.is_connection_dropped()
84 || error.is_cluster_error()
85 }
8086 _ => false,
8187 }
8288 }
83}
84
85fn retryable_backend(error: &RedisError) -> bool {
86 error.is_io_error()
87 || error.is_timeout()
88 || error.is_connection_refusal()
89 || error.is_connection_dropped()
90 || error.is_cluster_error()
9189}
9290
9391impl std::fmt::Display for CacheError {
Modified lib/cache/src/kv.rs lib/cache/src/kv.rs +7 −9 View lib/cache/src/kv.rs
@@ -192,7 +192,13 @@
192192 pub async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
193193 let mut conn = self.conn.clone();
194194 let seconds: i64 = conn.ttl(key).await.map_err(CacheError::from)?;
195 Ok(duration_from_seconds(seconds))
195 Ok({
196 if seconds < 0 {
197 None
198 } else {
199 Some(Duration::from_secs(seconds as u64))
200 }
201 })
196202 }
197203
198204 pub async fn delete(&self, key: &str) -> CacheResult<bool> {
@@ -262,13 +268,5 @@
262268 i64::MAX
263269 } else {
264270 millis as i64
265 }
266}
267
268fn duration_from_seconds(seconds: i64) -> Option<Duration> {
269 if seconds < 0 {
270 None
271 } else {
272 Some(Duration::from_secs(seconds as u64))
273271 }
274272}
Modified lib/cache/src/lock.rs lib/cache/src/lock.rs +11 −14 View lib/cache/src/lock.rs
@@ -28,7 +28,7 @@
2828
2929impl Cache {
3030 pub async fn try_lock(&self, key: &str, ttl: Duration) -> CacheResult<Option<CacheLock>> {
31 let token = new_token();
31 let token = { Uuid::now_v7().to_string() };
3232 if self.set_nx_ex(key, token.as_str(), ttl).await? {
3333 Ok(Some(CacheLock::new(self.clone(), key, token, ttl)))
3434 } else {
@@ -159,7 +159,16 @@
159159 if self.released {
160160 return;
161161 }
162 release_in_background(self.cache.clone(), self.key.clone(), self.token.clone());
162 {
163 let cache = self.cache.clone();
164 let key = self.key.clone();
165 let token = self.token.clone();
166 if let Ok(handle) = tokio::runtime::Handle::try_current() {
167 handle.spawn(async move {
168 let _ = cache.scripts().release_lock(&key, &token).await;
169 });
170 }
171 }
163172 }
164173}
165174
@@ -179,16 +188,4 @@
179188 });
180189 }
181190 }
182}
183
184fn release_in_background(cache: Cache, key: String, token: String) {
185 if let Ok(handle) = tokio::runtime::Handle::try_current() {
186 handle.spawn(async move {
187 let _ = cache.scripts().release_lock(&key, &token).await;
188 });
189 }
190}
191
192fn new_token() -> String {
193 Uuid::now_v7().to_string()
194191}
Modified lib/chat/src/services/attachments.rs lib/chat/src/services/attachments.rs +16 −15 View lib/chat/src/services/attachments.rs
@@ -137,20 +137,6 @@
137137 }
138138}
139139
140fn state_ready_key(state: &PreviewState) -> String {
141 match state {
142 PreviewState::Ready(ready) => ready.key.clone(),
143 PreviewState::Skipped(_) => String::new(),
144 }
145}
146
147fn state_ready_content_type(state: &PreviewState) -> String {
148 match state {
149 PreviewState::Ready(ready) => ready.content_type.clone(),
150 PreviewState::Skipped(_) => "application/octet-stream".to_string(),
151 }
152}
153
154140fn is_inline_renderable(content_type: &str) -> bool {
155141 let main = content_type.split(';').next().unwrap_or("").trim();
156142 main.starts_with("image/")
@@ -380,7 +366,22 @@
380366 {
381367 tracing::warn!(%error, attachment_id = %row.id, "failed to record attachment preview");
382368 }
383 (state_ready_key(&state), state_ready_content_type(&state))
369 (
370 {
371 let state = &state;
372 match state {
373 PreviewState::Ready(ready) => ready.key.clone(),
374 PreviewState::Skipped(_) => String::new(),
375 }
376 },
377 {
378 let state = &state;
379 match state {
380 PreviewState::Ready(ready) => ready.content_type.clone(),
381 PreviewState::Skipped(_) => "application/octet-stream".to_string(),
382 }
383 },
384 )
384385 }
385386 Some(PreviewState::Skipped(_)) | None => {
386387 (row.s3_key.clone(), attachment_stream_content_type(row))
Modified lib/chat/src/services/dms.rs lib/chat/src/services/dms.rs +20 −21 View lib/chat/src/services/dms.rs
@@ -38,25 +38,6 @@
3838 pub peer_user_ids: Option<Vec<i64>>,
3939}
4040
41fn dm_pair_key(a: i64, b: i64) -> String {
42 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
43 format!("dm:{lo}:{hi}")
44}
45
46fn group_dm_key(mut uids: Vec<i64>) -> String {
47 uids.sort();
48 let mut hasher = Sha256::new();
49 for uid in &uids {
50 hasher.update(uid.to_le_bytes());
51 }
52 let hex: String = hasher
53 .finalize()
54 .iter()
55 .map(|b| format!("{b:02x}"))
56 .collect();
57 format!("gdm:{hex}")
58}
59
6041impl ChatService {
6142 pub async fn open_dm(&self, caller: i64, cmd: OpenDmCmd) -> Result<DmDto, ChatError> {
6243 let mut peers: Vec<i64> = Vec::new();
@@ -119,7 +100,12 @@
119100 return Err(ChatError::NotFound);
120101 }
121102
122 let pair = dm_pair_key(caller, peer.user_id);
103 let pair = {
104 let a = caller;
105 let b = peer.user_id;
106 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
107 format!("dm:{lo}:{hi}")
108 };
123109 let existing = rooms::Entity::find()
124110 .filter(rooms::Column::LowerName.eq(&pair))
125111 .filter(rooms::Column::Kind.eq(RoomKind::DirectMessage))
@@ -195,7 +181,20 @@
195181
196182 let mut member_uids = peers.clone();
197183 member_uids.push(caller);
198 let key = group_dm_key(member_uids.clone());
184 let key = {
185 let mut uids = member_uids.clone();
186 uids.sort();
187 let mut hasher = Sha256::new();
188 for uid in &uids {
189 hasher.update(uid.to_le_bytes());
190 }
191 let hex: String = hasher
192 .finalize()
193 .iter()
194 .map(|b| format!("{b:02x}"))
195 .collect();
196 format!("gdm:{hex}")
197 };
199198
200199 let existing = rooms::Entity::find()
201200 .filter(rooms::Column::LowerName.eq(&key))
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +8 −10 View lib/chat/src/services/messages.rs
@@ -44,15 +44,6 @@
4444
4545const NONCE_CONFLICT: &str = "chat-nonce-conflict";
4646
47/// Which push copy a space-less room deserves; `None` for spaces and threads.
48fn direct_message_kind(room: &rooms::Model) -> Option<DirectMessageKind> {
49 match room.kind {
50 RoomKind::DirectMessage => Some(DirectMessageKind::Direct),
51 RoomKind::GroupDm => Some(DirectMessageKind::Group),
52 _ => None,
53 }
54}
55
5647#[derive(Clone, Copy)]
5748pub(crate) enum DirectMessageKind {
5849 Direct,
@@ -781,7 +772,14 @@
781772 // things that ask something of you, and a direct message is already
782773 // counted by the chat unread badge. Mute and notification level still
783774 // apply, so a muted conversation stays silent.
784 if let Some(kind) = direct_message_kind(&room) {
775 if let Some(kind) = {
776 let room = &room;
777 match room.kind {
778 RoomKind::DirectMessage => Some(DirectMessageKind::Direct),
779 RoomKind::GroupDm => Some(DirectMessageKind::Group),
780 _ => None,
781 }
782 } {
785783 let svc = self.clone();
786784 let recipients = crate::share::notify::dm_recipients(self, room_id, caller).await;
787785 let body = if content_for_email.chars().count() > 120 {
Modified lib/chat/src/services/outbound.rs lib/chat/src/services/outbound.rs +2 −4 View lib/chat/src/services/outbound.rs
@@ -361,6 +361,8 @@
361361 let mut mac =
362362 Hmac::<Sha256>::new_from_slice(webhook.secret.as_bytes()).map_err(|e| e.to_string())?;
363363 mac.update(&body);
364 let hex_lower =
365 |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() };
364366 let signature = format!("sha256={}", hex_lower(&mac.finalize().into_bytes()));
365367
366368 let mut client_builder = reqwest::Client::builder()
@@ -396,10 +398,6 @@
396398 Err(format!("endpoint returned {status}"))
397399 }
398400 }
399}
400
401fn hex_lower(bytes: &[u8]) -> String {
402 bytes.iter().map(|b| format!("{b:02x}")).collect()
403401}
404402
405403pub async fn enqueue_outbound_event(
Modified lib/chat/src/services/rooms.rs lib/chat/src/services/rooms.rs +10 −9 View lib/chat/src/services/rooms.rs
@@ -131,14 +131,6 @@
131131 pub revoked_at: Option<OffsetDateTime>,
132132}
133133
134fn random_accept_code() -> String {
135 const CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
136 let mut rng = rand::thread_rng();
137 (0..12)
138 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
139 .collect()
140}
141
142134impl ChatService {
143135 pub async fn list_space_rooms(
144136 &self,
@@ -758,7 +750,16 @@
758750 }
759751 let now = OffsetDateTime::now_utc();
760752 let id = Uuid::now_v7();
761 let accept_code = random_accept_code();
753 let accept_code = {
754 let __inline = || -> String {
755 const CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
756 let mut rng = rand::thread_rng();
757 (0..12)
758 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
759 .collect()
760 };
761 __inline()
762 };
762763 let expires_at = match cmd.expires_secs {
763764 Some(secs) if secs > 0 && secs <= 30 * 86_400 => {
764765 Some(now + time::Duration::seconds(secs))
Modified lib/chat/src/services/unfurl.rs lib/chat/src/services/unfurl.rs +28 −29 View lib/chat/src/services/unfurl.rs
@@ -34,37 +34,36 @@
3434 value[..end].to_string()
3535}
3636
37fn extract_urls(content: &str) -> Vec<String> {
38 let mut urls = Vec::new();
39 for token in content.split_whitespace() {
40 // 只裁掉 URL 前面的引用符号:括号可能是路径的一部分
41 // (`.../Rust_(programming_language)`),先剥掉会把链接截断。
42 let trimmed = token.trim_start_matches(|c: char| {
43 matches!(c, '(' | '[' | '{' | '<' | '"' | '\'' | '「' | '『' | '《')
44 });
45 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
46 continue;
47 }
48 if trimmed.len() < 12 || trimmed.len() > 2048 {
49 continue;
50 }
51 let cleaned = trim_trailing_punctuation(trimmed);
52 if cleaned.len() < 12 || cleaned.len() > 2048 {
53 continue;
54 }
55 if !urls.contains(&cleaned) {
56 urls.push(cleaned);
57 }
58 if urls.len() >= MAX_URLS_PER_MESSAGE {
59 break;
60 }
61 }
62 urls
63}
64
6537impl ChatService {
6638 pub async fn unfurl_message(&self, room_id: Uuid, message_id: Uuid, content: String) {
67 let urls = extract_urls(&content);
39 let urls = {
40 let content = &content;
41 let mut urls = Vec::new();
42 for token in content.split_whitespace() {
43 // 只裁掉 URL 前面的引用符号:括号可能是路径的一部分
44 // (`.../Rust_(programming_language)`),先剥掉会把链接截断。
45 let trimmed = token.trim_start_matches(|c: char| {
46 matches!(c, '(' | '[' | '{' | '<' | '"' | '\'' | '「' | '『' | '《')
47 });
48 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
49 continue;
50 }
51 if trimmed.len() < 12 || trimmed.len() > 2048 {
52 continue;
53 }
54 let cleaned = trim_trailing_punctuation(trimmed);
55 if cleaned.len() < 12 || cleaned.len() > 2048 {
56 continue;
57 }
58 if !urls.contains(&cleaned) {
59 urls.push(cleaned);
60 }
61 if urls.len() >= MAX_URLS_PER_MESSAGE {
62 break;
63 }
64 }
65 urls
66 };
6867 if urls.is_empty() {
6968 return;
7069 }
Modified lib/chat/src/services/voice.rs lib/chat/src/services/voice.rs +8 −14 View lib/chat/src/services/voice.rs
@@ -301,19 +301,6 @@
301301 ))
302302}
303303
304/// Grant strings must be the lowercase wire names the LiveKit server matches
305/// against (docs: camera | microphone | screen_share | screen_share_audio).
306/// The protobuf enum's `as_str_name()` is the uppercase constant name and does
307/// not match, which silently denies every publish with "no permission".
308fn publish_sources() -> Vec<String> {
309 vec![
310 "camera".to_string(),
311 "microphone".to_string(),
312 "screen_share".to_string(),
313 "screen_share_audio".to_string(),
314 ]
315}
316
317304/// 参与者 id 的解析:identity 是 uid 字符串。
318305pub fn identity_to_uid(identity: &str) -> Option<i64> {
319306 identity.parse::<i64>().ok()
@@ -632,7 +619,14 @@
632619 can_publish,
633620 can_publish_data: true,
634621 can_publish_sources: if can_publish {
635 publish_sources()
622 {
623 vec![
624 "camera".to_string(),
625 "microphone".to_string(),
626 "screen_share".to_string(),
627 "screen_share_audio".to_string(),
628 ]
629 }
636630 } else {
637631 Vec::new()
638632 },
Modified lib/chat/src/services/webhooks.rs lib/chat/src/services/webhooks.rs +11 −9 View lib/chat/src/services/webhooks.rs
@@ -56,14 +56,6 @@
5656 pub thread_id: Option<Uuid>,
5757}
5858
59fn webhook_token() -> String {
60 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
61 let mut rng = rand::thread_rng();
62 (0..48)
63 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
64 .collect()
65}
66
6759pub fn token_hash(token: &str) -> String {
6860 let mut hasher = Sha256::new();
6961 hasher.update(token.as_bytes());
@@ -126,7 +118,17 @@
126118
127119 let now = OffsetDateTime::now_utc();
128120 let id = Uuid::now_v7();
129 let token = webhook_token();
121 let token = {
122 let __inline = || -> String {
123 const CHARSET: &[u8] =
124 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
125 let mut rng = rand::thread_rng();
126 (0..48)
127 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
128 .collect()
129 };
130 __inline()
131 };
130132 let hash = token_hash(&token);
131133 room_webhooks::ActiveModel {
132134 id: Set(id),
Modified lib/chat/src/share/events.rs lib/chat/src/share/events.rs +6 −8 View lib/chat/src/share/events.rs
@@ -59,13 +59,6 @@
5959 }
6060}
6161
62fn room_thread_id(event: &RoomEvent) -> Option<Uuid> {
63 match event {
64 RoomEvent::ThreadUpdated { thread_id, .. } => Some(*thread_id),
65 _ => None,
66 }
67}
68
6962fn room_actor(event: &RoomEvent) -> Option<i64> {
7063 match event {
7164 RoomEvent::Typing { user_id, .. }
@@ -87,7 +80,12 @@
8780 if let Some(message_id) = room_message_id(event) {
8881 labels.insert("message_id", message_id);
8982 }
90 if let Some(thread_id) = room_thread_id(event) {
83 if let Some(thread_id) = {
84 match event {
85 RoomEvent::ThreadUpdated { thread_id, .. } => Some(*thread_id),
86 _ => None,
87 }
88 } {
9189 labels.insert("thread_id", thread_id);
9290 }
9391 let request_id = export::middleware::request_id();
Modified lib/config/src/database.rs lib/config/src/database.rs +13 −14 View lib/config/src/database.rs
@@ -63,7 +63,19 @@
6363
6464 let mut params: Vec<String> = Vec::new();
6565 if let Some(app) = self.application_name.as_deref().filter(|s| !s.is_empty()) {
66 params.push(format!("application_name={}", url_encode(app)));
66 params.push(format!("application_name={}", {
67 let value = app;
68 let mut out = String::with_capacity(value.len());
69 for b in value.as_bytes() {
70 match b {
71 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
72 out.push(*b as char);
73 }
74 _ => out.push_str(&format!("%{:02X}", b)),
75 }
76 }
77 out
78 }));
6779 }
6880 if let Some(mode) = self.ssl_mode {
6981 params.push(format!("sslmode={}", mode.as_query_value()));
@@ -237,17 +249,4 @@
237249 replica.apply_to(&mut opts)?;
238250 Ok(opts)
239251 }
240}
241
242fn url_encode(value: &str) -> String {
243 let mut out = String::with_capacity(value.len());
244 for b in value.as_bytes() {
245 match b {
246 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
247 out.push(*b as char);
248 }
249 _ => out.push_str(&format!("%{:02X}", b)),
250 }
251 }
252 out
253252}
Modified lib/crypt/src/envelope.rs lib/crypt/src/envelope.rs +6 −7 View lib/crypt/src/envelope.rs
@@ -82,9 +82,12 @@
8282
8383pub fn decode(envelope: &str) -> CryptResult<EnvelopeParts> {
8484 let trimmed = envelope.trim();
85 let body = trimmed
86 .strip_prefix(ENVELOPE_PREFIX)
87 .ok_or_else(|| CryptError::EnvelopePrefix(head_of(trimmed)))?;
85 let body = trimmed.strip_prefix(ENVELOPE_PREFIX).ok_or_else(|| {
86 CryptError::EnvelopePrefix({
87 let value = trimmed;
88 value.chars().take(16).collect()
89 })
90 })?;
8891 let raw = URL_SAFE_NO_PAD
8992 .decode(body)
9093 .map_err(|error| CryptError::Base64(error.to_string()))?;
@@ -145,10 +148,6 @@
145148 aad.push(ALGORITHM_RSA_OAEP_256_A256GCM);
146149 aad.extend_from_slice(key_id.as_bytes());
147150 aad
148}
149
150fn head_of(value: &str) -> String {
151 value.chars().take(16).collect()
152151}
153152
154153struct Cursor<'a> {
Modified lib/crypt/src/key.rs lib/crypt/src/key.rs +6 −8 View lib/crypt/src/key.rs
@@ -34,7 +34,12 @@
3434 }
3535
3636 pub fn from_private_pem(key_id: impl Into<String>, pem: &str) -> CryptResult<Self> {
37 let private = parse_private_pem(pem)?;
37 let private = {
38 let trimmed = pem.trim();
39 RsaPrivateKey::from_pkcs8_pem(trimmed)
40 .or_else(|_| RsaPrivateKey::from_pkcs1_pem(trimmed))
41 .map_err(|error| CryptError::Pem(error.to_string()))
42 }?;
3843 Ok(Self {
3944 key_id: key_id.into(),
4045 private,
@@ -210,13 +215,6 @@
210215 let mut bytes = vec![0u8; len];
211216 OsRng.fill_bytes(&mut bytes);
212217 bytes
213}
214
215fn parse_private_pem(pem: &str) -> CryptResult<RsaPrivateKey> {
216 let trimmed = pem.trim();
217 RsaPrivateKey::from_pkcs8_pem(trimmed)
218 .or_else(|_| RsaPrivateKey::from_pkcs1_pem(trimmed))
219 .map_err(|error| CryptError::Pem(error.to_string()))
220218}
221219
222220fn parse_public_pem(pem: &str) -> CryptResult<RsaPublicKey> {
Modified lib/deletion/src/lib.rs lib/deletion/src/lib.rs +9 −11 View lib/deletion/src/lib.rs
@@ -72,16 +72,6 @@
7272 .ok_or_else(|| DeletionError::Config("kenoauth is not configured".into()))
7373}
7474
75fn shell_client(state: &AppState) -> Result<KenShellClient, DeletionError> {
76 let cfg = &state.config.git;
77 KenShellClient::connect_with_key(
78 &cfg.kenshell_endpoint,
79 &cfg.kenshell_rpc_key,
80 Duration::from_secs(cfg.kenshell_timeout_secs.max(1)),
81 )
82 .map_err(ken_shell)
83}
84
8575fn now() -> OffsetDateTime {
8676 OffsetDateTime::now_utc()
8777}
@@ -291,7 +281,15 @@
291281 }
292282
293283 // 1. KenShell is the owner of repository/placement/LFS bytes.
294 let shell = shell_client(state)?;
284 let shell = {
285 let cfg = &state.config.git;
286 KenShellClient::connect_with_key(
287 &cfg.kenshell_endpoint,
288 &cfg.kenshell_rpc_key,
289 Duration::from_secs(cfg.kenshell_timeout_secs.max(1)),
290 )
291 .map_err(ken_shell)
292 }?;
295293 let repos = shell
296294 .list_repositories_for_org(&row.space_id.to_string())
297295 .await
Modified lib/envelope/src/cors.rs lib/envelope/src/cors.rs +16 −16 View lib/envelope/src/cors.rs
@@ -71,7 +71,14 @@
7171 .await
7272 .map(|response| response.map_into_left_body());
7373 };
74 if is_preflight(&request) {
74 let __inl_cond = {
75 let request = &request;
76 request.method() == Method::OPTIONS
77 && request
78 .headers()
79 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD)
80 };
81 if __inl_cond {
7582 let mut response = HttpResponse::NoContent().finish();
7683 policy.write_preflight(response.headers_mut(), &origin);
7784 let response = response.map_body(|_, body: BoxBody| EitherBody::<B>::right(body));
@@ -82,13 +89,6 @@
8289 Ok(response)
8390 })
8491 }
85}
86
87fn web_allows_value(allow_any: &bool, allow_origins: &[String], origin: &str) -> bool {
88 *allow_any
89 || allow_origins
90 .iter()
91 .any(|allowed| allowed.eq_ignore_ascii_case(origin))
9292}
9393
9494#[derive(Debug, Clone)]
@@ -119,7 +119,14 @@
119119 }
120120
121121 fn allows(&self, origin: &str) -> bool {
122 web_allows_value(&self.allow_any_origin, &self.allow_origins, origin)
122 {
123 let allow_any = &self.allow_any_origin;
124 let allow_origins = &self.allow_origins;
125 *allow_any
126 || allow_origins
127 .iter()
128 .any(|allowed| allowed.eq_ignore_ascii_case(origin))
129 }
123130 }
124131
125132 fn write_preflight(&self, headers: &mut HeaderMap, origin: &str) {
@@ -174,13 +181,6 @@
174181 .map(str::trim)
175182 .filter(|origin| !origin.is_empty())?;
176183 policy.allows(origin).then(|| origin.to_owned())
177}
178
179fn is_preflight(request: &ServiceRequest) -> bool {
180 request.method() == Method::OPTIONS
181 && request
182 .headers()
183 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD)
184184}
185185
186186fn joined(values: &[String]) -> HeaderValue {
Modified lib/envelope/src/middleware.rs lib/envelope/src/middleware.rs +29 −31 View lib/envelope/src/middleware.rs
@@ -52,7 +52,22 @@
5252 fn call(&self, request: ServiceRequest) -> Self::Future {
5353 let service = Rc::clone(&self.service);
5454 Box::pin(async move {
55 let request_id = request_id_for(&request);
55 let request_id = {
56 let request = &request;
57 request
58 .extensions()
59 .get::<RequestId>()
60 .map(|value| value.0.clone())
61 .or_else(|| {
62 validate_request_id(
63 request
64 .headers()
65 .get(REQUEST_ID_HEADER)
66 .and_then(|value| value.to_str().ok()),
67 )
68 })
69 .unwrap_or_else(|| Uuid::now_v7().to_string())
70 };
5671 request
5772 .extensions_mut()
5873 .insert(RequestId(request_id.clone()));
@@ -60,41 +75,24 @@
6075 context::with_request_id(request_id.clone(), async move {
6176 let mut response = service.call(request).await?;
6277 insert_header(response.headers_mut(), REQUEST_ID_HEADER, &request_id);
63 apply_security_headers(response.headers_mut());
78 {
79 let headers = response.headers_mut();
80 const X_CONTENT_TYPE_OPTIONS: HeaderName =
81 HeaderName::from_static("x-content-type-options");
82 const REFERRER_POLICY: HeaderName = HeaderName::from_static("referrer-policy");
83 const CSP: HeaderName = HeaderName::from_static("content-security-policy");
84 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
85 headers.insert(
86 REFERRER_POLICY,
87 HeaderValue::from_static("strict-origin-when-cross-origin"),
88 );
89 headers.insert(CSP, HeaderValue::from_static("frame-ancestors 'none'"));
90 }
6491 Ok(response)
6592 })
6693 .await
6794 })
6895 }
69}
70
71/// 全局安全响应头:禁止被嵌入 iframe、禁 MIME 嗅探、限制 referrer 泄露。
72fn apply_security_headers(headers: &mut HeaderMap) {
73 const X_CONTENT_TYPE_OPTIONS: HeaderName = HeaderName::from_static("x-content-type-options");
74 const REFERRER_POLICY: HeaderName = HeaderName::from_static("referrer-policy");
75 const CSP: HeaderName = HeaderName::from_static("content-security-policy");
76 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
77 headers.insert(
78 REFERRER_POLICY,
79 HeaderValue::from_static("strict-origin-when-cross-origin"),
80 );
81 headers.insert(CSP, HeaderValue::from_static("frame-ancestors 'none'"));
82}
83
84fn request_id_for(request: &ServiceRequest) -> String {
85 request
86 .extensions()
87 .get::<RequestId>()
88 .map(|value| value.0.clone())
89 .or_else(|| {
90 validate_request_id(
91 request
92 .headers()
93 .get(REQUEST_ID_HEADER)
94 .and_then(|value| value.to_str().ok()),
95 )
96 })
97 .unwrap_or_else(|| Uuid::now_v7().to_string())
9896}
9997
10098fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
Modified lib/events/src/outbox.rs lib/events/src/outbox.rs +4 −6 View lib/events/src/outbox.rs
@@ -335,11 +335,6 @@
335335 f64::from(2_u32.pow(attempts.clamp(1, 8) as u32))
336336}
337337
338/// identity 事件与 apihost 事件共用一张 outbox,按 kind 的域段决定投递管线。
339fn is_identity_kind(kind: &str) -> bool {
340 matches!(domain(kind), "auth" | "organization" | "email")
341}
342
343338async fn publish_identity(
344339 handler: Option<&EventHandler>,
345340 row: &PendingEvent,
@@ -420,7 +415,10 @@
420415 request_id = %request_id,
421416 );
422417 let result = async {
423 if is_identity_kind(&row.kind) {
418 if {
419 let kind = &row.kind;
420 matches!(domain(kind), "auth" | "organization" | "email")
421 } {
424422 publish_identity(identity, &row).await
425423 } else {
426424 publish_app(app, &row).await
Modified lib/export/src/http.rs lib/export/src/http.rs +8 −9 View lib/export/src/http.rs
@@ -453,7 +453,14 @@
453453 grouped.sort_by(|left, right| compare_specificity(&left.0, &right.0));
454454 for (path, guards, methods) in grouped {
455455 let resource = methods.apply(web::resource(path));
456 scope = register_resource(scope, resource, &guards);
456 scope = 'inline_register_resource: {
457 let guards = &guards;
458 let Some(guard) = guards.first().copied() else {
459 break 'inline_register_resource scope.service(resource);
460 };
461 let middleware = guard_middleware(guard);
462 scope.service(resource.wrap(actix_web::middleware::from_fn(middleware)))
463 };
457464 }
458465 for (prefix, child) in self.nests {
459466 scope = scope.service(child.into_scope(web::scope(&prefix)));
@@ -474,14 +481,6 @@
474481 }
475482 }
476483 right_segments.len().cmp(&left_segments.len())
477}
478
479fn register_resource(scope: Scope, resource: Resource, guards: &[Guard]) -> Scope {
480 let Some(guard) = guards.first().copied() else {
481 return scope.service(resource);
482 };
483 let middleware = guard_middleware(guard);
484 scope.service(resource.wrap(actix_web::middleware::from_fn(middleware)))
485484}
486485
487486fn guard_middleware(
Modified lib/export/src/redis.rs lib/export/src/redis.rs +33 −37 View lib/export/src/redis.rs
@@ -278,8 +278,39 @@
278278 .query_async(cluster)
279279 .await
280280 .map_err(ExportError::from)?;
281 let (host, port) = cluster_master_for_slot(&slots, slot)
282 .ok_or_else(|| ExportError::InvalidConfig(format!("no master owns slot {slot}")))?;
281 let (host, port) = 'inline_cluster_master_for_slot: {
282 let slots = &slots;
283 let Value::Array(groups) = slots else {
284 break 'inline_cluster_master_for_slot None;
285 };
286 for group in groups {
287 let Value::Array(fields) = group else {
288 continue;
289 };
290 let (Some(Value::Int(start)), Some(Value::Int(end))) =
291 (fields.first(), fields.get(1))
292 else {
293 continue;
294 };
295 if !(*start..=*end).contains(&(slot as i64)) {
296 continue;
297 }
298 let Some(Value::Array(nodes)) = fields.get(2) else {
299 continue;
300 };
301 let (Some(Value::BulkString(host)), Some(Value::Int(port))) =
302 (nodes.first(), nodes.get(1))
303 else {
304 continue;
305 };
306 break 'inline_cluster_master_for_slot Some((
307 String::from_utf8_lossy(host).into_owned(),
308 *port as u16,
309 ));
310 }
311 None
312 }
313 .ok_or_else(|| ExportError::InvalidConfig(format!("no master owns slot {slot}")))?;
283314 let addr = match node_info.addr() {
284315 ConnectionAddr::Tcp(_, _) => ConnectionAddr::Tcp(host, port),
285316 ConnectionAddr::TcpTls {
@@ -418,41 +449,6 @@
418449
419450 Ok(cluster_slot_ranges(&slots))
420451 }
421}
422
423/// Extracts the host/port of every master from a `CLUSTER SLOTS` response.
424///
425/// Each slot group is `[start, end, [ip, port, node_id, ...], replicas...]`,
426/// where the first entry of the node list is the master.
427/// Resolves the master node that owns `slot` straight from `CLUSTER SLOTS`.
428///
429/// Unlike [`cluster_master_addrs`] this needs no de-duplication: the range that
430/// contains the slot names its own master in the same group.
431fn cluster_master_for_slot(slots: &Value, slot: u16) -> Option<(String, u16)> {
432 let Value::Array(groups) = slots else {
433 return None;
434 };
435 for group in groups {
436 let Value::Array(fields) = group else {
437 continue;
438 };
439 let (Some(Value::Int(start)), Some(Value::Int(end))) = (fields.first(), fields.get(1))
440 else {
441 continue;
442 };
443 if !(*start..=*end).contains(&(slot as i64)) {
444 continue;
445 }
446 let Some(Value::Array(nodes)) = fields.get(2) else {
447 continue;
448 };
449 let (Some(Value::BulkString(host)), Some(Value::Int(port))) = (nodes.first(), nodes.get(1))
450 else {
451 continue;
452 };
453 return Some((String::from_utf8_lossy(host).into_owned(), *port as u16));
454 }
455 None
456452}
457453
458454/// Builds the connection info of the first configured node, used as a template
Modified lib/git/src/context/cache.rs lib/git/src/context/cache.rs +0 −6 View lib/git/src/context/cache.rs
@@ -1,12 +1,6 @@
11use crate::context::facade::GitContext;
22
33impl GitContext {
4 pub async fn cache_get<V>(&self, key: &str) -> Result<Option<V>, crate::share::errors::GitError>
5 where
6 V: serde::de::DeserializeOwned,
7 {
8 Ok(self.cache.get_json(key).await?)
9 }
104 pub async fn cache_remove(&self, key: &str) -> Result<(), crate::share::errors::GitError> {
115 let _ = self.cache.delete(key).await?;
126 Ok(())
Modified lib/git/src/handler/html/blob_content.rs lib/git/src/handler/html/blob_content.rs +13 −14 View lib/git/src/handler/html/blob_content.rs
@@ -75,7 +75,19 @@
7575 .next()
7676 .unwrap_or(&clean_path)
7777 .to_string();
78 let is_image = is_image_extension(&file_name);
78 let is_image = {
79 let filename = &file_name;
80 let lower = filename.to_ascii_lowercase();
81 lower.ends_with(".png")
82 || lower.ends_with(".jpg")
83 || lower.ends_with(".jpeg")
84 || lower.ends_with(".gif")
85 || lower.ends_with(".svg")
86 || lower.ends_with(".webp")
87 || lower.ends_with(".ico")
88 || lower.ends_with(".bmp")
89 || lower.ends_with(".avif")
90 };
7991
8092 let markup = html! {
8193 div class=(cls)
@@ -137,17 +149,4 @@
137149 }
138150 };
139151 response_html(markup, 30)
140}
141
142fn is_image_extension(filename: &str) -> bool {
143 let lower = filename.to_ascii_lowercase();
144 lower.ends_with(".png")
145 || lower.ends_with(".jpg")
146 || lower.ends_with(".jpeg")
147 || lower.ends_with(".gif")
148 || lower.ends_with(".svg")
149 || lower.ends_with(".webp")
150 || lower.ends_with(".ico")
151 || lower.ends_with(".bmp")
152 || lower.ends_with(".avif")
153152}
Modified lib/git/src/handler/html/commit_diff.rs lib/git/src/handler/html/commit_diff.rs +49 −48 View lib/git/src/handler/html/commit_diff.rs
@@ -124,16 +124,6 @@
124124 Ctx,
125125}
126126
127fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
128 let rest = line.strip_prefix("@@ ")?.split(" @@").next()?;
129 let mut parts = rest.split(' ');
130 let old = parts.next()?.trim_start_matches('-');
131 let new = parts.next()?.trim_start_matches('+');
132 let old_start = old.split(',').next()?.parse().ok()?;
133 let new_start = new.split(',').next()?.parse().ok()?;
134 Some((old_start, new_start))
135}
136
137127fn parse_patch_rows(patch: &str, cap: usize) -> (Vec<DiffRow>, bool) {
138128 let mut rows = Vec::new();
139129 let mut old_no = 0u32;
@@ -145,7 +135,18 @@
145135 break;
146136 }
147137 if line.starts_with("@@") {
148 if let Some((o, n)) = parse_hunk_header(line) {
138 if let Some((o, n)) = {
139 let __inline = |line: &str| -> Option<(u32, u32)> {
140 let rest = line.strip_prefix("@@ ")?.split(" @@").next()?;
141 let mut parts = rest.split(' ');
142 let old = parts.next()?.trim_start_matches('-');
143 let new = parts.next()?.trim_start_matches('+');
144 let old_start = old.split(',').next()?.parse().ok()?;
145 let new_start = new.split(',').next()?.parse().ok()?;
146 Some((old_start, new_start))
147 };
148 __inline(line)
149 } {
149150 old_no = o;
150151 new_no = n;
151152 }
@@ -193,19 +194,20 @@
193194 (rows, truncated)
194195}
195196
196fn status_badge(status: &str) -> (&'static str, &'static str) {
197 match status {
198 "Added" => ("A", "bg-emerald-100 text-emerald-700"),
199 "Deleted" => ("D", "bg-red-100 text-red-700"),
200 "Renamed" => ("R", "bg-sky-100 text-sky-700"),
201 "Copied" => ("C", "bg-sky-100 text-sky-700"),
202 "TypeChanged" => ("T", "bg-amber-100 text-amber-700"),
203 _ => ("M", "bg-zinc-100 text-zinc-700"),
204 }
205}
206
207197fn render_file(f: &ChangedPathDiffDto, style: &str) -> Markup {
208 let (badge, badge_cls) = status_badge(&f.path.status);
198 let (badge, badge_cls) = {
199 let __inline = |status: &str| -> (&'static str, &'static str) {
200 match status {
201 "Added" => ("A", "bg-emerald-100 text-emerald-700"),
202 "Deleted" => ("D", "bg-red-100 text-red-700"),
203 "Renamed" => ("R", "bg-sky-100 text-sky-700"),
204 "Copied" => ("C", "bg-sky-100 text-sky-700"),
205 "TypeChanged" => ("T", "bg-amber-100 text-amber-700"),
206 _ => ("M", "bg-zinc-100 text-zinc-700"),
207 }
208 };
209 __inline(&f.path.status)
210 };
209211 let display_path = match (&f.path.old_path, &f.path.new_path) {
210212 (Some(old), new) if old != new => format!("{old} → {new}"),
211213 (_, new) => new.clone(),
@@ -241,35 +243,34 @@
241243 } @else if style == "split" {
242244 (render_split(&rows))
243245 } @else {
244 (render_unified(&rows))
246 ({
247 let rows = &rows;
248 html! {
249 div class="overflow-x-auto" {
250 table class="w-full border-collapse text-xs font-mono" {
251 tbody {
252 @for r in rows {
253 @let (row_cls, marker) = match r.kind {
254 RowKind::Add => ("bg-emerald-50", "+"),
255 RowKind::Del => ("bg-red-50", "−"),
256 RowKind::Ctx => ("", " "),
257 };
258 tr class=(row_cls) {
259 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.old_no.map(|n| n.to_string()).unwrap_or_default()) }
260 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.new_no.map(|n| n.to_string()).unwrap_or_default()) }
261 td class="w-4 select-none text-center" { (marker) }
262 td class="whitespace-pre px-2 py-0 text-zinc-900" { (r.content) }
263 }
264 }
265 }
266 }
267 }
268 }
269 })
245270 }
246271 @if truncated {
247272 div class="border-t border-zinc-200 bg-zinc-50 px-3 py-1.5 text-center text-[11px] text-zinc-500" {
248273 "diff truncated"
249 }
250 }
251 }
252 }
253}
254
255fn render_unified(rows: &[DiffRow]) -> Markup {
256 html! {
257 div class="overflow-x-auto" {
258 table class="w-full border-collapse text-xs font-mono" {
259 tbody {
260 @for r in rows {
261 @let (row_cls, marker) = match r.kind {
262 RowKind::Add => ("bg-emerald-50", "+"),
263 RowKind::Del => ("bg-red-50", "−"),
264 RowKind::Ctx => ("", " "),
265 };
266 tr class=(row_cls) {
267 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.old_no.map(|n| n.to_string()).unwrap_or_default()) }
268 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.new_no.map(|n| n.to_string()).unwrap_or_default()) }
269 td class="w-4 select-none text-center" { (marker) }
270 td class="whitespace-pre px-2 py-0 text-zinc-900" { (r.content) }
271 }
272 }
273274 }
274275 }
275276 }
Modified lib/git/src/lfs/lock.rs lib/git/src/lfs/lock.rs +5 −6 View lib/git/src/lfs/lock.rs
@@ -110,7 +110,11 @@
110110 .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id))
111111 .order_by_asc(git_lfs_lock::Column::CreatedAt);
112112 if let Some(cursor) = cursor {
113 let at = parse_cursor(&cursor)?;
113 let at = {
114 let cursor = &cursor;
115 OffsetDateTime::parse(cursor, &time::format_description::well_known::Rfc3339)
116 .map_err(|e| GitError::InvalidArgument(format!("invalid cursor: {e}")))
117 }?;
114118 find = find.filter(git_lfs_lock::Column::CreatedAt.gt(at));
115119 }
116120 let mut rows = find.limit(limit + 1).all(svc.db_read()).await?;
@@ -188,11 +192,6 @@
188192fn format_time(at: OffsetDateTime) -> String {
189193 at.format(&time::format_description::well_known::Rfc3339)
190194 .unwrap_or_default()
191}
192
193fn parse_cursor(cursor: &str) -> Result<OffsetDateTime, GitError> {
194 OffsetDateTime::parse(cursor, &time::format_description::well_known::Rfc3339)
195 .map_err(|e| GitError::InvalidArgument(format!("invalid cursor: {e}")))
196195}
197196
198197fn normalize_limit(limit: u64) -> u64 {
Modified lib/git/src/rpcgit/api.rs lib/git/src/rpcgit/api.rs +26 −24 View lib/git/src/rpcgit/api.rs
@@ -139,17 +139,6 @@
139139 }
140140}
141141
142fn diff_status(text: &str) -> DiffStatus {
143 match text {
144 "A" | "added" | "Added" => DiffStatus::Added,
145 "D" | "deleted" | "Deleted" => DiffStatus::Deleted,
146 "T" | "type_changed" | "TypeChanged" => DiffStatus::TypeChanged,
147 "R" | "renamed" | "Renamed" => DiffStatus::Renamed,
148 "C" | "copied" | "Copied" => DiffStatus::Copied,
149 _ => DiffStatus::Modified,
150 }
151}
152
153142fn changed_path(view: pb::ChangedPathView) -> ChangedPath {
154143 ChangedPath {
155144 old_path: if view.old_path.is_empty() {
@@ -162,7 +151,19 @@
162151 new_mode: view.new_mode,
163152 old_oid: oid_opt(&view.old_oid),
164153 new_oid: oid_opt(&view.new_oid),
165 status: diff_status(&view.status),
154 status: {
155 let __inline = |text: &str| -> DiffStatus {
156 match text {
157 "A" | "added" | "Added" => DiffStatus::Added,
158 "D" | "deleted" | "Deleted" => DiffStatus::Deleted,
159 "T" | "type_changed" | "TypeChanged" => DiffStatus::TypeChanged,
160 "R" | "renamed" | "Renamed" => DiffStatus::Renamed,
161 "C" | "copied" | "Copied" => DiffStatus::Copied,
162 _ => DiffStatus::Modified,
163 }
164 };
165 __inline(&view.status)
166 },
166167 }
167168}
168169
@@ -1168,7 +1169,19 @@
11681169 new_mode: Some(mode_of(&format!("{:o}", record.head_mode))),
11691170 old_oid: oid_opt(&record.base_blob_id),
11701171 new_oid: oid_opt(&record.head_blob_id),
1171 status: tree_diff_status(&record.status),
1172 status: {
1173 let __inline = |text: &str| -> TreeDiffStatus {
1174 match text {
1175 "A" | "added" | "Added" => TreeDiffStatus::Added,
1176 "D" | "deleted" | "Deleted" => TreeDiffStatus::Deleted,
1177 "T" | "type_changed" | "TypeChanged" => TreeDiffStatus::TypeChanged,
1178 "R" | "renamed" | "Renamed" => TreeDiffStatus::Renamed,
1179 "C" | "copied" | "Copied" => TreeDiffStatus::Copied,
1180 _ => TreeDiffStatus::Modified,
1181 }
1182 };
1183 __inline(&record.status)
1184 },
11721185 })
11731186 .collect())
11741187 }
@@ -1446,17 +1459,6 @@
14461459 } else {
14471460 Some(tag.message)
14481461 },
1449 }
1450}
1451
1452fn tree_diff_status(text: &str) -> TreeDiffStatus {
1453 match text {
1454 "A" | "added" | "Added" => TreeDiffStatus::Added,
1455 "D" | "deleted" | "Deleted" => TreeDiffStatus::Deleted,
1456 "T" | "type_changed" | "TypeChanged" => TreeDiffStatus::TypeChanged,
1457 "R" | "renamed" | "Renamed" => TreeDiffStatus::Renamed,
1458 "C" | "copied" | "Copied" => TreeDiffStatus::Copied,
1459 _ => TreeDiffStatus::Modified,
14601462 }
14611463}
14621464
Modified lib/git/src/rpcgit/cmd_error.rs lib/git/src/rpcgit/cmd_error.rs +66 −68 View lib/git/src/rpcgit/cmd_error.rs
@@ -46,7 +46,72 @@
4646 code,
4747 stderr,
4848 } => {
49 let argv_summary = redact_argv(prog, args);
49 let argv_summary = {
50 let mut out = String::new();
51 out.push_str(prog);
52 let mut redact_next = false;
53 for arg in args {
54 out.push(' ');
55 if redact_next {
56 out.push_str(REDACTED);
57 redact_next = false;
58 continue;
59 }
60 // Flags whose secret value is the *next* argv element (e.g.
61 // `git -c http.extraheader=...`) must redact that following element.
62 if SEPARATE_VALUE_FLAGS.iter().any(|flag| *flag == arg) {
63 out.push_str(arg);
64 redact_next = true;
65 continue;
66 }
67 if SENSITIVE_FLAGS
68 .iter()
69 .any(|flag| arg == *flag || arg.starts_with(flag))
70 {
71 let label = if arg.contains('=') {
72 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
73 } else {
74 arg.as_str()
75 };
76 out.push_str(label);
77 out.push('=');
78 out.push_str(REDACTED);
79 continue;
80 }
81 if URL_VALUE_FLAGS
82 .iter()
83 .any(|flag| arg == *flag || arg.starts_with(flag))
84 {
85 let label = if arg.contains('=') {
86 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
87 } else {
88 arg.as_str()
89 };
90 out.push_str(label);
91 out.push_str(" <value>");
92 continue;
93 }
94 let __inl_cond = {
95 let s = arg;
96 if s.starts_with("http://")
97 || s.starts_with("https://")
98 || s.starts_with("ssh://")
99 || s.starts_with("git://")
100 || s.starts_with("file://")
101 {
102 true
103 } else {
104 s.contains("://") && s.contains('@')
105 }
106 };
107 if __inl_cond {
108 out.push_str(REDACTED);
109 continue;
110 }
111 out.push_str(arg);
112 }
113 out
114 };
50115 write!(
51116 f,
52117 "`{} {}` exited with code {}: {}",
@@ -96,73 +161,6 @@
96161pub type CmdResult<T> = std::result::Result<T, CmdError>;
97162
98163const REDACTED: &str = "***REDACTED***";
99
100fn redact_argv(prog: &str, args: &[String]) -> String {
101 let mut out = String::new();
102 out.push_str(prog);
103 let mut redact_next = false;
104 for arg in args {
105 out.push(' ');
106 if redact_next {
107 out.push_str(REDACTED);
108 redact_next = false;
109 continue;
110 }
111 // Flags whose secret value is the *next* argv element (e.g.
112 // `git -c http.extraheader=...`) must redact that following element.
113 if SEPARATE_VALUE_FLAGS.iter().any(|flag| *flag == arg) {
114 out.push_str(arg);
115 redact_next = true;
116 continue;
117 }
118 if SENSITIVE_FLAGS
119 .iter()
120 .any(|flag| arg == *flag || arg.starts_with(flag))
121 {
122 let label = if arg.contains('=') {
123 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
124 } else {
125 arg.as_str()
126 };
127 out.push_str(label);
128 out.push('=');
129 out.push_str(REDACTED);
130 continue;
131 }
132 if URL_VALUE_FLAGS
133 .iter()
134 .any(|flag| arg == *flag || arg.starts_with(flag))
135 {
136 let label = if arg.contains('=') {
137 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
138 } else {
139 arg.as_str()
140 };
141 out.push_str(label);
142 out.push_str(" <value>");
143 continue;
144 }
145 if looks_like_url(arg) {
146 out.push_str(REDACTED);
147 continue;
148 }
149 out.push_str(arg);
150 }
151 out
152}
153
154fn looks_like_url(s: &str) -> bool {
155 if s.starts_with("http://")
156 || s.starts_with("https://")
157 || s.starts_with("ssh://")
158 || s.starts_with("git://")
159 || s.starts_with("file://")
160 {
161 true
162 } else {
163 s.contains("://") && s.contains('@')
164 }
165}
166164
167165static SEPARATE_VALUE_FLAGS: &[&str] = &["-c"];
168166
Modified lib/git/src/service/path_log.rs lib/git/src/service/path_log.rs +17 −18 View lib/git/src/service/path_log.rs
@@ -41,23 +41,6 @@
4141 Ok(())
4242}
4343
44fn iso_date(t: &time::OffsetDateTime) -> String {
45 let secs = t.offset().whole_seconds();
46 let sign = if secs >= 0 { '+' } else { '-' };
47 let abs = secs.unsigned_abs();
48 format!(
49 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}",
50 t.year(),
51 u8::from(t.month()),
52 t.day(),
53 t.hour(),
54 t.minute(),
55 t.second(),
56 abs / 3600,
57 (abs % 3600) / 60,
58 )
59}
60
6144fn first_line(s: &str) -> String {
6245 s.trim_end()
6346 .lines()
@@ -129,7 +112,23 @@
129112 out.push(PathLogEntryDto {
130113 path: entry_path,
131114 short_sha,
132 date: iso_date(&commit.committer.time),
115 date: {
116 let t = &commit.committer.time;
117 let secs = t.offset().whole_seconds();
118 let sign = if secs >= 0 { '+' } else { '-' };
119 let abs = secs.unsigned_abs();
120 format!(
121 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}",
122 t.year(),
123 u8::from(t.month()),
124 t.day(),
125 t.hour(),
126 t.minute(),
127 t.second(),
128 abs / 3600,
129 (abs % 3600) / 60,
130 )
131 },
133132 author: commit.author.name.clone(),
134133 subject: first_line(&commit.message),
135134 });
Modified lib/git/src/service/pdf.rs lib/git/src/service/pdf.rs +30 −27 View lib/git/src/service/pdf.rs
@@ -75,12 +75,6 @@
7575 CACHE.get_or_init(|| Mutex::new(PageCache::default()))
7676}
7777
78fn cache_get(key: &str) -> Option<(Arc<Vec<u8>>, u16, u16)> {
79 let cache = page_cache().lock().ok()?;
80 let hit = cache.pages.get(key)?;
81 Some((hit.png.clone(), hit.width, hit.height))
82}
83
8478fn cache_put(key: String, png: Arc<Vec<u8>>, width: u16, height: u16) {
8579 if png.len() > CACHE_BUDGET_BYTES {
8680 return;
@@ -105,25 +99,6 @@
10599 cache.pages.insert(key, CachedPage { png, width, height });
106100}
107101
108/// KenShell resolves refs through its git backend, so an unknown revision surfaces as a
109/// non-zero exit rather than a typed error. On this read path the only thing
110/// that matters is the resulting status: an unresolvable ref is a 404, exactly
111/// like a missing path.
112fn is_missing_ref(error: &BabyError) -> bool {
113 match error {
114 BabyError::RevisionNotFound(_)
115 | BabyError::RevisionInvalid(_)
116 | BabyError::RefNotFound(_)
117 | BabyError::PathNotFound { .. } => true,
118 BabyError::Command(cmd) => has_missing_ref_text(&cmd.to_string()),
119 // KenShell only classifies the git message shapes it knows; anything
120 // else (such as `fatal: Not a valid object name`) lands here, so the
121 // stderr has to be inspected once more.
122 BabyError::Spawn { stderr, .. } => has_missing_ref_text(stderr),
123 _ => false,
124 }
125}
126
127102fn has_missing_ref_text(text: &str) -> bool {
128103 let text = text.to_ascii_lowercase();
129104 text.contains("not a valid object name")
@@ -166,7 +141,27 @@
166141 {
167142 Ok(Some(entry)) => entry,
168143 Ok(None) => return Err(GitError::NotFound),
169 Err(e) if is_missing_ref(&e) => return Err(GitError::NotFound),
144 Err(e)
145 if {
146 let __inline = |error: &BabyError| -> bool {
147 match error {
148 BabyError::RevisionNotFound(_)
149 | BabyError::RevisionInvalid(_)
150 | BabyError::RefNotFound(_)
151 | BabyError::PathNotFound { .. } => true,
152 BabyError::Command(cmd) => has_missing_ref_text(&cmd.to_string()),
153 // KenShell only classifies the git message shapes it knows; anything
154 // else (such as `fatal: Not a valid object name`) lands here, so the
155 // stderr has to be inspected once more.
156 BabyError::Spawn { stderr, .. } => has_missing_ref_text(stderr),
157 _ => false,
158 }
159 };
160 __inline(&e)
161 } =>
162 {
163 return Err(GitError::NotFound);
164 }
170165 Err(e) => return Err(map_baby(e)),
171166 };
172167 if !matches!(
@@ -300,7 +295,15 @@
300295 let interpreter = InterpreterSettings::default();
301296 for index in 0..total {
302297 let key = format!("{prefix}:{index}");
303 let (png, width, height) = match cache_get(&key) {
298 let __inl_cond = {
299 let __inline = |key: &str| -> Option<(Arc<Vec<u8>>, u16, u16)> {
300 let cache = page_cache().lock().ok()?;
301 let hit = cache.pages.get(key)?;
302 Some((hit.png.clone(), hit.width, hit.height))
303 };
304 __inline(&key)
305 };
306 let (png, width, height) = match __inl_cond {
304307 Some(hit) => hit,
305308 None => {
306309 let pixmap = render(&pages[index], &cache, &interpreter, &settings);
Modified lib/git/src/service/repos.rs lib/git/src/service/repos.rs +7 −8 View lib/git/src/service/repos.rs
@@ -35,13 +35,6 @@
3535 out
3636}
3737
38fn tab_order_from_json(value: Option<&serde_json::Value>) -> Vec<String> {
39 value
40 .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
41 .map(|order| normalize_repo_tab_order(&order))
42 .unwrap_or_default()
43}
44
4538#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
4639pub struct RepoDto {
4740 pub id: Uuid,
@@ -159,7 +152,13 @@
159152 http_clone_url: None,
160153 ssh_clone_url: None,
161154 allow_pull_requests: m.allow_pull_requests,
162 tab_order: tab_order_from_json(m.tab_order.as_ref()),
155 tab_order: {
156 let value = m.tab_order.as_ref();
157 value
158 .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
159 .map(|order| normalize_repo_tab_order(&order))
160 .unwrap_or_default()
161 },
163162 created_at: m.created_at,
164163 updated_at: m.updated_at,
165164 last_pushed_at: m.last_pushed_at,
Modified lib/git/src/service/trees.rs lib/git/src/service/trees.rs +19 −21 View lib/git/src/service/trees.rs
@@ -18,14 +18,6 @@
1818 pub commit_id: String,
1919}
2020
21fn entry_full_path(entry: &TreeEntry) -> String {
22 let mut s = entry.name.clone();
23 if entry.mode == TreeEntryMode::Tree {
24 s.push('/');
25 }
26 s
27}
28
2921impl GitService {
3022 pub async fn tree_read(
3123 &self,
@@ -158,8 +150,25 @@
158150 Ok(rows
159151 .into_iter()
160152 .map(|row| PathLastUpdated {
161 path: entry_full_path(&row.entry),
162 object_type: mode_to_i8(row.entry.mode),
153 path: {
154 let entry = &row.entry;
155 let mut s = entry.name.clone();
156 if entry.mode == TreeEntryMode::Tree {
157 s.push('/');
158 }
159 s
160 },
161 object_type: {
162 let mode = row.entry.mode;
163 match mode {
164 TreeEntryMode::Blob => 0,
165 TreeEntryMode::Exec => 1,
166 TreeEntryMode::Symlink => 2,
167 TreeEntryMode::Tree => 3,
168 TreeEntryMode::Submodule => 4,
169 TreeEntryMode::Commit => 5,
170 }
171 },
163172 last_updated: row.latest.author.time.unix_timestamp(),
164173 commit_id: row.latest.id.to_string(),
165174 })
@@ -198,16 +207,5 @@
198207 .map_err(map_baby)
199208 })
200209 .await
201 }
202}
203
204fn mode_to_i8(mode: TreeEntryMode) -> i8 {
205 match mode {
206 TreeEntryMode::Blob => 0,
207 TreeEntryMode::Exec => 1,
208 TreeEntryMode::Symlink => 2,
209 TreeEntryMode::Tree => 3,
210 TreeEntryMode::Submodule => 4,
211 TreeEntryMode::Commit => 5,
212210 }
213211}
Modified lib/git/src/routes.rs lib/git/src/routes.rs +299 −288 View lib/git/src/routes.rs
@@ -106,299 +106,310 @@
106106
107107pub fn router(_service: GitService) -> Router<GitService> {
108108 Router::new()
109 .nest("/repos", repos_router())
110 .nest("/topics", topics_router())
111 .nest("/licenses", licenses_router())
112}
109 .nest("/repos", {
110 let __inline = || -> Router<GitService> {
113111
114pub fn internal_router(_service: GitService) -> Router<GitService> {
115 Router::new()
116 .nest("/repos", repos_internal_router())
117 .merge(licenses_internal_router())
118}
112 Router::new()
113 .route("/{space_name}", post(repo_create).get(repo_list))
114 .route(
115 "/{space_name}/{repo_name}",
116 get(repo_get).patch(repo_update),
117 )
118 .route("/{space_name}/{repo_name}/archive", post(repo_archive))
119 .route("/{space_name}/{repo_name}/unarchive", post(unarchive_repo))
120 .route(
121 "/{space_name}/{repo_name}/branches",
122 get(branch_list).post(branch_create),
123 )
124 .route(
125 "/{space_name}/{repo_name}/branches/{name}",
126 get(branch_get).delete(branch_delete).patch(branch_rename),
127 )
128 .route(
129 "/{space_name}/{repo_name}/branches/{name}/default",
130 put(branch_set_default),
131 )
132 .route(
133 "/{space_name}/{repo_name}/tags",
134 get(tag_list).post(tag_create),
135 )
136 .route(
137 "/{space_name}/{repo_name}/tags/{name}",
138 get(tag_get).delete(tag_delete),
139 )
140 .route("/{space_name}/{repo_name}/commits", get(list_commits))
141 .route(
142 "/{space_name}/{repo_name}/commits/{revision}",
143 get(get_commit),
144 )
145 .route("/{space_name}/{repo_name}/commits/stats", get(commit_stats))
146 .route(
147 "/{space_name}/{repo_name}/commits/ancestor",
148 get(is_ancestor),
149 )
150 .route("/{space_name}/{repo_name}/rev/{revision}", get(rev_parse))
151 .route(
152 "/{space_name}/{repo_name}/commit-statuses",
153 post(set_commit_status),
154 )
155 .route(
156 "/{space_name}/{repo_name}/commit-statuses/{sha}",
157 get(list_commit_statuses),
158 )
159 .route(
160 "/{space_name}/{repo_name}/code-comments",
161 get(list_code_comments).post(create_code_comment),
162 )
163 .route(
164 "/{space_name}/{repo_name}/code-comments/{comment_id}",
165 export::http::routing::patch(update_code_comment).delete(delete_code_comment),
166 )
167 .route(
168 "/{space_name}/{repo_name}/code-comments/{comment_id}/resolve",
169 put(resolve_code_comment).delete(unresolve_code_comment),
170 )
171 .route(
172 "/{space_name}/{repo_name}/releases",
173 get(list_releases).post(create_release),
174 )
175 .route(
176 "/{space_name}/{repo_name}/releases/{tag}",
177 get(get_release)
178 .patch(update_release)
179 .delete(delete_release),
180 )
181 .route("/{space_name}/{repo_name}/tree/{revision}", get(get_tree))
182 .route("/{space_name}/{repo_name}/path-log", get(path_log))
183 .route(
184 "/{space_name}/{repo_name}/blobs/{oid}/metadata",
185 get(blob_metadata),
186 )
187 .route(
188 "/{space_name}/{repo_name}/blobs/{oid}/content",
189 get(blob_content),
190 )
191 .route(
192 "/{space_name}/{repo_name}/lfs-pointers",
193 get(list_lfs_pointers),
194 )
195 .route("/{space_name}/{repo_name}/blame", get(blame_handler))
196 .route("/{space_name}/{repo_name}/submodules", get(submodules_list))
197 .route(
198 "/{space_name}/{repo_name}/submodules/resolve",
199 get(submodules_resolve),
200 )
201 .route(
202 "/{space_name}/{repo_name}/signatures/commit/{sha}",
203 get(verify_commit),
204 )
205 .route(
206 "/{space_name}/{repo_name}/signatures/tag/{name}",
207 get(verify_tag),
208 )
209 .route("/{space_name}/{repo_name}/diff", get(get_diff))
210 .route("/{space_name}/{repo_name}/diff/raw", get(diff_raw))
211 .route(
212 "/{space_name}/{repo_name}/diff/for-commit/{commit}",
213 get(diff_for_commit),
214 )
215 .route("/{space_name}/{repo_name}/diff/range", get(range_diff))
216 .route(
217 "/{space_name}/{repo_name}/patch/generate",
218 get(patch_generate),
219 )
220 .route("/utils/patch/parse", post(patch_parse))
221 .route("/utils/commit-signature", post(caller_signature))
222 .route("/{space_name}/{repo_name}/conflicts", get(get_conflicts))
223 .route(
224 "/{space_name}/{repo_name}/conflicts/detail",
225 get(conflict_detail),
226 )
227 .route(
228 "/{space_name}/{repo_name}/remotes/import",
229 post(remote_clone_import),
230 )
231 .route(
232 "/{space_name}/{repo_name}/remotes/fetch",
233 post(remote_fetch),
234 )
235 .route("/{space_name}/{repo_name}/remotes/push", post(remote_push))
236 .route(
237 "/{space_name}/{repo_name}/remotes/{name}",
238 put(upsert_remote).delete(remote_remove),
239 )
240 .route("/{space_name}/{repo_name}/remotes", get(list_remotes))
241 .route(
242 "/{space_name}/{repo_name}/lfs/locks",
243 get(list_locks).post(lock).delete(unlock_by_path),
244 )
245 .route(
246 "/{space_name}/{repo_name}/lfs/locks/verify",
247 post(verify_locks),
248 )
249 .route(
250 "/{space_name}/{repo_name}/lfs/locks/{lock_id}",
251 delete(crate::handler::lfs::unlock_by_id::unlock_by_id),
252 )
253 .route(
254 "/{space_name}/{repo_name}/hooks/{script}",
255 post(execute_script),
256 )
257 .route(
258 "/{space_name}/{repo_name}/analysis/languages",
259 get(languages_read).post(languages),
260 )
261 .route(
262 "/{space_name}/{repo_name}/analysis/contributors",
263 get(contributors),
264 )
265 .route(
266 "/{space_name}/{repo_name}/analysis/code-activity",
267 get(code_activity),
268 )
269 .route("/{space_name}/{repo_name}/archives", post(create_archive))
270 .route(
271 "/{space_name}/{repo_name}/archives/{revision}",
272 get(get_archive),
273 )
274 .route(
275 "/{space_name}/{repo_name}/archives/bundle",
276 post(create_bundle),
277 )
278 .route(
279 "/{space_name}/{repo_name}/star",
280 put(star_repo).delete(unstar_repo),
281 )
282 .route("/{space_name}/{repo_name}/stargazers", get(list_stargazers))
283 .route("/{space_name}/{repo_name}/watch", put(upsert_watch))
284 .route("/{space_name}/{repo_name}/watchers", get(list_watchers))
285 .route("/{space_name}/{repo_name}/topics", get(list_repo_topics))
286 .route(
287 "/{space_name}/{repo_name}/topics/{topic_name}",
288 put(add_topic_to_repo).delete(remove_topic_from_repo),
289 )
290 .route(
291 "/{space_name}/{repo_name}/branch-protection",
292 get(crate::handler::branch_protection::list_policies::list_policies)
293 .post(crate::handler::branch_protection::create_policy::create_policy),
294 )
295 .route(
296 "/{space_name}/{repo_name}/branch-protection/{policy_id}",
297 get(crate::handler::branch_protection::get_policy::get_policy)
298 .patch(crate::handler::branch_protection::update_policy::update_policy)
299 .delete(crate::handler::branch_protection::delete_policy::delete_policy),
300 )
301 .route(
302 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists",
303 post(add_branch_whitelist).get(list_branch_whitelists),
304 )
305 .route(
306 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists/{whitelist_id}",
307 delete(remove_branch_whitelist),
308 )
309 .route(
310 "/{space_name}/{repo_name}/tag-protection",
311 get(crate::handler::tag_protection::list_policies::list_policies)
312 .post(crate::handler::tag_protection::create_policy::create_policy),
313 )
314 .route(
315 "/{space_name}/{repo_name}/tag-protection/{policy_id}",
316 get(crate::handler::tag_protection::get_policy::get_policy)
317 .patch(crate::handler::tag_protection::update_policy::update_policy)
318 .delete(crate::handler::tag_protection::delete_policy::delete_policy),
319 )
320 .route(
321 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists",
322 post(add_tag_whitelist).get(list_tag_whitelists),
323 )
324 .route(
325 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
326 delete(remove_tag_whitelist),
327 )
328 .nest(
329 "/{space_name}/{repo_name}/html",
330 crate::handler::html::router(),
331 )
119332
120fn repos_router() -> Router<GitService> {
121 Router::new()
122 .route("/{space_name}", post(repo_create).get(repo_list))
123 .route(
124 "/{space_name}/{repo_name}",
125 get(repo_get).patch(repo_update),
126 )
127 .route("/{space_name}/{repo_name}/archive", post(repo_archive))
128 .route("/{space_name}/{repo_name}/unarchive", post(unarchive_repo))
129 .route(
130 "/{space_name}/{repo_name}/branches",
131 get(branch_list).post(branch_create),
132 )
133 .route(
134 "/{space_name}/{repo_name}/branches/{name}",
135 get(branch_get).delete(branch_delete).patch(branch_rename),
136 )
137 .route(
138 "/{space_name}/{repo_name}/branches/{name}/default",
139 put(branch_set_default),
140 )
141 .route(
142 "/{space_name}/{repo_name}/tags",
143 get(tag_list).post(tag_create),
144 )
145 .route(
146 "/{space_name}/{repo_name}/tags/{name}",
147 get(tag_get).delete(tag_delete),
148 )
149 .route("/{space_name}/{repo_name}/commits", get(list_commits))
150 .route(
151 "/{space_name}/{repo_name}/commits/{revision}",
152 get(get_commit),
153 )
154 .route("/{space_name}/{repo_name}/commits/stats", get(commit_stats))
155 .route(
156 "/{space_name}/{repo_name}/commits/ancestor",
157 get(is_ancestor),
158 )
159 .route("/{space_name}/{repo_name}/rev/{revision}", get(rev_parse))
160 .route(
161 "/{space_name}/{repo_name}/commit-statuses",
162 post(set_commit_status),
163 )
164 .route(
165 "/{space_name}/{repo_name}/commit-statuses/{sha}",
166 get(list_commit_statuses),
167 )
168 .route(
169 "/{space_name}/{repo_name}/code-comments",
170 get(list_code_comments).post(create_code_comment),
171 )
172 .route(
173 "/{space_name}/{repo_name}/code-comments/{comment_id}",
174 export::http::routing::patch(update_code_comment).delete(delete_code_comment),
175 )
176 .route(
177 "/{space_name}/{repo_name}/code-comments/{comment_id}/resolve",
178 put(resolve_code_comment).delete(unresolve_code_comment),
179 )
180 .route(
181 "/{space_name}/{repo_name}/releases",
182 get(list_releases).post(create_release),
183 )
184 .route(
185 "/{space_name}/{repo_name}/releases/{tag}",
186 get(get_release)
187 .patch(update_release)
188 .delete(delete_release),
189 )
190 .route("/{space_name}/{repo_name}/tree/{revision}", get(get_tree))
191 .route("/{space_name}/{repo_name}/path-log", get(path_log))
192 .route(
193 "/{space_name}/{repo_name}/blobs/{oid}/metadata",
194 get(blob_metadata),
195 )
196 .route(
197 "/{space_name}/{repo_name}/blobs/{oid}/content",
198 get(blob_content),
199 )
200 .route(
201 "/{space_name}/{repo_name}/lfs-pointers",
202 get(list_lfs_pointers),
203 )
204 .route("/{space_name}/{repo_name}/blame", get(blame_handler))
205 .route("/{space_name}/{repo_name}/submodules", get(submodules_list))
206 .route(
207 "/{space_name}/{repo_name}/submodules/resolve",
208 get(submodules_resolve),
209 )
210 .route(
211 "/{space_name}/{repo_name}/signatures/commit/{sha}",
212 get(verify_commit),
213 )
214 .route(
215 "/{space_name}/{repo_name}/signatures/tag/{name}",
216 get(verify_tag),
217 )
218 .route("/{space_name}/{repo_name}/diff", get(get_diff))
219 .route("/{space_name}/{repo_name}/diff/raw", get(diff_raw))
220 .route(
221 "/{space_name}/{repo_name}/diff/for-commit/{commit}",
222 get(diff_for_commit),
223 )
224 .route("/{space_name}/{repo_name}/diff/range", get(range_diff))
225 .route(
226 "/{space_name}/{repo_name}/patch/generate",
227 get(patch_generate),
228 )
229 .route("/utils/patch/parse", post(patch_parse))
230 .route("/utils/commit-signature", post(caller_signature))
231 .route("/{space_name}/{repo_name}/conflicts", get(get_conflicts))
232 .route(
233 "/{space_name}/{repo_name}/conflicts/detail",
234 get(conflict_detail),
235 )
236 .route(
237 "/{space_name}/{repo_name}/remotes/import",
238 post(remote_clone_import),
239 )
240 .route(
241 "/{space_name}/{repo_name}/remotes/fetch",
242 post(remote_fetch),
243 )
244 .route("/{space_name}/{repo_name}/remotes/push", post(remote_push))
245 .route(
246 "/{space_name}/{repo_name}/remotes/{name}",
247 put(upsert_remote).delete(remote_remove),
248 )
249 .route("/{space_name}/{repo_name}/remotes", get(list_remotes))
250 .route(
251 "/{space_name}/{repo_name}/lfs/locks",
252 get(list_locks).post(lock).delete(unlock_by_path),
253 )
254 .route(
255 "/{space_name}/{repo_name}/lfs/locks/verify",
256 post(verify_locks),
257 )
258 .route(
259 "/{space_name}/{repo_name}/lfs/locks/{lock_id}",
260 delete(crate::handler::lfs::unlock_by_id::unlock_by_id),
261 )
262 .route(
263 "/{space_name}/{repo_name}/hooks/{script}",
264 post(execute_script),
265 )
266 .route(
267 "/{space_name}/{repo_name}/analysis/languages",
268 get(languages_read).post(languages),
269 )
270 .route(
271 "/{space_name}/{repo_name}/analysis/contributors",
272 get(contributors),
273 )
274 .route(
275 "/{space_name}/{repo_name}/analysis/code-activity",
276 get(code_activity),
277 )
278 .route("/{space_name}/{repo_name}/archives", post(create_archive))
279 .route(
280 "/{space_name}/{repo_name}/archives/{revision}",
281 get(get_archive),
282 )
283 .route(
284 "/{space_name}/{repo_name}/archives/bundle",
285 post(create_bundle),
286 )
287 .route(
288 "/{space_name}/{repo_name}/star",
289 put(star_repo).delete(unstar_repo),
290 )
291 .route("/{space_name}/{repo_name}/stargazers", get(list_stargazers))
292 .route("/{space_name}/{repo_name}/watch", put(upsert_watch))
293 .route("/{space_name}/{repo_name}/watchers", get(list_watchers))
294 .route("/{space_name}/{repo_name}/topics", get(list_repo_topics))
295 .route(
296 "/{space_name}/{repo_name}/topics/{topic_name}",
297 put(add_topic_to_repo).delete(remove_topic_from_repo),
298 )
299 .route(
300 "/{space_name}/{repo_name}/branch-protection",
301 get(crate::handler::branch_protection::list_policies::list_policies)
302 .post(crate::handler::branch_protection::create_policy::create_policy),
303 )
304 .route(
305 "/{space_name}/{repo_name}/branch-protection/{policy_id}",
306 get(crate::handler::branch_protection::get_policy::get_policy)
307 .patch(crate::handler::branch_protection::update_policy::update_policy)
308 .delete(crate::handler::branch_protection::delete_policy::delete_policy),
309 )
310 .route(
311 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists",
312 post(add_branch_whitelist).get(list_branch_whitelists),
313 )
314 .route(
315 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists/{whitelist_id}",
316 delete(remove_branch_whitelist),
317 )
318 .route(
319 "/{space_name}/{repo_name}/tag-protection",
320 get(crate::handler::tag_protection::list_policies::list_policies)
321 .post(crate::handler::tag_protection::create_policy::create_policy),
322 )
323 .route(
324 "/{space_name}/{repo_name}/tag-protection/{policy_id}",
325 get(crate::handler::tag_protection::get_policy::get_policy)
326 .patch(crate::handler::tag_protection::update_policy::update_policy)
327 .delete(crate::handler::tag_protection::delete_policy::delete_policy),
328 )
329 .route(
330 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists",
331 post(add_tag_whitelist).get(list_tag_whitelists),
332 )
333 .route(
334 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
335 delete(remove_tag_whitelist),
336 )
337 .nest(
338 "/{space_name}/{repo_name}/html",
339 crate::handler::html::router(),
340 )
341}
333 };
334 __inline()
335 })
336 .nest("/topics", {
337 let __inline = || -> Router<GitService> {
338
339 Router::new()
340 .route("/", post(create_topic).get(list_topics))
341 .route("/{name}", get(get_topic))
342 .route("/{name}/repos", get(list_repos_by_topic))
343
344 };
345 __inline()
346 })
347 .nest("/licenses", {
348 let __inline = || -> Router<GitService> {
342349
343fn repos_internal_router() -> Router<GitService> {
344 Router::new()
345 .route("/{space_name}/{repo_name}/refs", get(refs_list))
346 .route("/{space_name}/{repo_name}/refs/head", get(refs_head))
347 .route(
348 "/{space_name}/{repo_name}/refs/branches",
349 get(refs_branches),
350 )
351 .route("/{space_name}/{repo_name}/refs/filtered", get(filtered))
352 .route(
353 "/{space_name}/{repo_name}/refs/contains",
354 get(is_object_in_ref),
355 )
356 .route(
357 "/{space_name}/{repo_name}/refs/{name}",
358 get(refs_get).patch(refs_update).delete(refs_delete),
359 )
360 .route(
361 "/{space_name}/{repo_name}/refs/{name}/resolve",
362 get(refs_resolve),
363 )
364 .route(
365 "/{space_name}/{repo_name}/commits/{revision}/sync",
366 post(sync_commit),
367 )
368 .route("/{space_name}/{repo_name}/patch/apply", post(patch_apply))
369 .route("/{space_name}/{repo_name}/packs/stats", get(pack_stats))
370 .route("/{space_name}/{repo_name}/packs/ingest", post(pack_ingest))
371 .route("/{space_name}/{repo_name}/packs/repack", post(pack_repack))
372 .route("/{space_name}/{repo_name}/maintenance/prune", post(prune))
373 .route("/{space_name}/{repo_name}/maintenance/fsck", post(fsck))
374 .route(
375 "/{space_name}/{repo_name}/lfs/cleanup",
376 post(cleanup_unreferenced),
377 )
378 .route("/{space_name}/{repo_name}/contents/blobs", post(write_blob))
379 .route(
380 "/{space_name}/{repo_name}/contents/trees",
381 post(write_tree_from_files),
382 )
383 .route(
384 "/{space_name}/{repo_name}/contents/commits",
385 post(commit_object),
386 )
387}
350 Router::new()
351 .route("/", get(list_licenses))
352 .route("/{name}", get(get_license))
388353
389fn topics_router() -> Router<GitService> {
390 Router::new()
391 .route("/", post(create_topic).get(list_topics))
392 .route("/{name}", get(get_topic))
393 .route("/{name}/repos", get(list_repos_by_topic))
354 };
355 __inline()
356 })
394357}
395358
396fn licenses_router() -> Router<GitService> {
359pub fn internal_router(_service: GitService) -> Router<GitService> {
397360 Router::new()
398 .route("/", get(list_licenses))
399 .route("/{name}", get(get_license))
400}
401
402fn licenses_internal_router() -> Router<GitService> {
403 Router::new().route("/licenses/{name}", put(upsert_license))
361 .nest("/repos", {
362 let __inline = || -> Router<GitService> {
363 Router::new()
364 .route("/{space_name}/{repo_name}/refs", get(refs_list))
365 .route("/{space_name}/{repo_name}/refs/head", get(refs_head))
366 .route(
367 "/{space_name}/{repo_name}/refs/branches",
368 get(refs_branches),
369 )
370 .route("/{space_name}/{repo_name}/refs/filtered", get(filtered))
371 .route(
372 "/{space_name}/{repo_name}/refs/contains",
373 get(is_object_in_ref),
374 )
375 .route(
376 "/{space_name}/{repo_name}/refs/{name}",
377 get(refs_get).patch(refs_update).delete(refs_delete),
378 )
379 .route(
380 "/{space_name}/{repo_name}/refs/{name}/resolve",
381 get(refs_resolve),
382 )
383 .route(
384 "/{space_name}/{repo_name}/commits/{revision}/sync",
385 post(sync_commit),
386 )
387 .route("/{space_name}/{repo_name}/patch/apply", post(patch_apply))
388 .route("/{space_name}/{repo_name}/packs/stats", get(pack_stats))
389 .route("/{space_name}/{repo_name}/packs/ingest", post(pack_ingest))
390 .route("/{space_name}/{repo_name}/packs/repack", post(pack_repack))
391 .route("/{space_name}/{repo_name}/maintenance/prune", post(prune))
392 .route("/{space_name}/{repo_name}/maintenance/fsck", post(fsck))
393 .route(
394 "/{space_name}/{repo_name}/lfs/cleanup",
395 post(cleanup_unreferenced),
396 )
397 .route("/{space_name}/{repo_name}/contents/blobs", post(write_blob))
398 .route(
399 "/{space_name}/{repo_name}/contents/trees",
400 post(write_tree_from_files),
401 )
402 .route(
403 "/{space_name}/{repo_name}/contents/commits",
404 post(commit_object),
405 )
406 };
407 __inline()
408 })
409 .merge({
410 let __inline = || -> Router<GitService> {
411 Router::new().route("/licenses/{name}", put(upsert_license))
412 };
413 __inline()
414 })
404415}
Modified lib/imaging/src/lib.rs lib/imaging/src/lib.rs +46 −49 View lib/imaging/src/lib.rs
@@ -106,7 +106,23 @@
106106 if bytes.len() as u64 > preset.max_source_bytes() {
107107 return PreviewOutcome::Skipped(PreviewSkip::SourceTooLarge);
108108 }
109 if is_animated(bytes, format) {
109 let __inl_cond = {
110 let cursor = Cursor::new(bytes);
111 match format {
112 ImageFormat::Gif => GifDecoder::new(cursor)
113 .map(|decoder| decoder.into_frames().take(2).count() > 1)
114 .unwrap_or(false),
115 ImageFormat::WebP => WebPDecoder::new(cursor)
116 .map(|decoder| decoder.has_animation())
117 .unwrap_or(false),
118 ImageFormat::Png => PngDecoder::new(cursor)
119 .ok()
120 .and_then(|decoder| decoder.is_apng().ok())
121 .unwrap_or(false),
122 _ => false,
123 }
124 };
125 if __inl_cond {
110126 return PreviewOutcome::UseOriginal;
111127 }
112128
@@ -227,25 +243,6 @@
227243 Ok((out, "image/webp", "webp"))
228244}
229245
230/// Animated containers are handed back untouched: re-encoding them would either
231/// flatten the animation or explode the output size.
232fn is_animated(bytes: &[u8], format: ImageFormat) -> bool {
233 let cursor = Cursor::new(bytes);
234 match format {
235 ImageFormat::Gif => GifDecoder::new(cursor)
236 .map(|decoder| decoder.into_frames().take(2).count() > 1)
237 .unwrap_or(false),
238 ImageFormat::WebP => WebPDecoder::new(cursor)
239 .map(|decoder| decoder.has_animation())
240 .unwrap_or(false),
241 ImageFormat::Png => PngDecoder::new(cursor)
242 .ok()
243 .and_then(|decoder| decoder.is_apng().ok())
244 .unwrap_or(false),
245 _ => false,
246 }
247}
248
249246/// Whether a re-encoded derivative is actually worth storing. Requiring a
250247/// meaningful margin avoids keeping a "preview" that is byte-for-byte as heavy
251248/// as the original.
@@ -288,33 +285,6 @@
288285 out
289286 }
290287
291 fn png_with_alpha(width: u32, height: u32) -> Vec<u8> {
292 let image = image::RgbaImage::from_fn(width, height, |x, y| {
293 Rgba([(x % 200) as u8, (y % 200) as u8, 40, ((x + y) % 255) as u8])
294 });
295 let mut out = Vec::new();
296 image
297 .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
298 .expect("encode png fixture");
299 out
300 }
301
302 fn gif_two_frames(width: u32, height: u32) -> Vec<u8> {
303 let frame = image::RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
304 let mut out = Vec::new();
305 {
306 use image::codecs::gif::GifEncoder;
307 let mut encoder = GifEncoder::new(&mut out);
308 encoder
309 .encode_frame(image::Frame::new(frame.clone()))
310 .expect("gif frame 1");
311 encoder
312 .encode_frame(image::Frame::new(frame))
313 .expect("gif frame 2");
314 }
315 out
316 }
317
318288 #[test]
319289 fn downscales_to_preset_edge() {
320290 let bytes = jpeg_bytes(3000, 1500);
@@ -342,7 +312,18 @@
342312
343313 #[test]
344314 fn alpha_keeps_webp_alpha() {
345 let bytes = png_with_alpha(400, 300);
315 let bytes = {
316 let width = 400;
317 let height = 300;
318 let image = image::RgbaImage::from_fn(width, height, |x, y| {
319 Rgba([(x % 200) as u8, (y % 200) as u8, 40, ((x + y) % 255) as u8])
320 });
321 let mut out = Vec::new();
322 image
323 .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
324 .expect("encode png fixture");
325 out
326 };
346327 let PreviewOutcome::Preview(preview) =
347328 render_preview_sync(&bytes, "image/png", PreviewPreset::Avatar)
348329 else {
@@ -356,7 +337,23 @@
356337
357338 #[test]
358339 fn animated_gif_is_left_alone() {
359 let bytes = gif_two_frames(64, 64);
340 let bytes = {
341 let width = 64;
342 let height = 64;
343 let frame = image::RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
344 let mut out = Vec::new();
345 {
346 use image::codecs::gif::GifEncoder;
347 let mut encoder = GifEncoder::new(&mut out);
348 encoder
349 .encode_frame(image::Frame::new(frame.clone()))
350 .expect("gif frame 1");
351 encoder
352 .encode_frame(image::Frame::new(frame))
353 .expect("gif frame 2");
354 }
355 out
356 };
360357 let outcome = render_preview_sync(&bytes, "image/gif", PreviewPreset::Content);
361358 assert_eq!(outcome, PreviewOutcome::UseOriginal);
362359 }
Modified lib/issues/src/share/lookup.rs lib/issues/src/share/lookup.rs +11 −12 View lib/issues/src/share/lookup.rs
@@ -76,7 +76,17 @@
7676 base.filter(issue_item::Column::AutoincrementId.eq(number))
7777 .one(service.db().read())
7878 .await?
79 } else if let Some(number) = board_prefix_number(board.prefix.as_deref(), issue_ref) {
79 } else if let Some(number) = {
80 let __inline = |prefix: Option<&str>, issue_ref: &str| -> Option<i32> {
81 let prefix = prefix?;
82 let (head, tail) = issue_ref.rsplit_once('-')?;
83 if !head.eq_ignore_ascii_case(prefix) {
84 return None;
85 }
86 tail.parse::<i32>().ok()
87 };
88 __inline(board.prefix.as_deref(), issue_ref)
89 } {
8090 // `CAD-12` keeps pasted links readable; only this board's own prefix
8191 // resolves, so a foreign key never silently points at another board.
8292 base.filter(issue_item::Column::AutoincrementId.eq(number))
@@ -86,17 +96,6 @@
8696 None
8797 };
8898 item.ok_or(IssueError::NotFound)
89}
90
91/// `PREFIX-12` (case-insensitive) → `Some(12)` when the prefix matches the
92/// board's own; anything else is left to the plain-number path.
93fn board_prefix_number(prefix: Option<&str>, issue_ref: &str) -> Option<i32> {
94 let prefix = prefix?;
95 let (head, tail) = issue_ref.rsplit_once('-')?;
96 if !head.eq_ignore_ascii_case(prefix) {
97 return None;
98 }
99 tail.parse::<i32>().ok()
10099}
101100
102101pub async fn require_member(
Modified lib/oltp/src/config.rs lib/oltp/src/config.rs +5 −5 View lib/oltp/src/config.rs
@@ -244,7 +244,11 @@
244244 .unwrap_or(DEFAULT_TIMEOUT);
245245
246246 config.sampler = SamplerKind::parse(&env_var("OTEL_TRACES_SAMPLER").unwrap_or_default());
247 config.sample_ratio = env_f64("OTEL_TRACES_SAMPLER_ARG").unwrap_or(1.0);
247 config.sample_ratio = {
248 let name = "OTEL_TRACES_SAMPLER_ARG";
249 env_var(name).and_then(|value| value.parse().ok())
250 }
251 .unwrap_or(1.0);
248252
249253 config.traces =
250254 SignalConfig::from_env("TRACES", common_endpoint.as_deref(), &config.headers);
@@ -326,9 +330,5 @@
326330}
327331
328332fn env_u64(name: &str) -> Option<u64> {
329 env_var(name).and_then(|value| value.parse().ok())
330}
331
332fn env_f64(name: &str) -> Option<f64> {
333333 env_var(name).and_then(|value| value.parse().ok())
334334}
Modified lib/oltp/src/grpc.rs lib/oltp/src/grpc.rs +9 −10 View lib/oltp/src/grpc.rs
@@ -58,7 +58,15 @@
5858 }
5959
6060 fn call(&mut self, mut request: Request<ReqBody>) -> Self::Future {
61 let request_id = request_id_of(request.headers());
61 let request_id = {
62 let headers = request.headers();
63 headers
64 .get(REQUEST_ID_HEADER)
65 .or_else(|| headers.get(LEGACY_REQUEST_ID_HEADER))
66 .and_then(|value| value.to_str().ok())
67 .and_then(|value| session::validate_request_id(Some(value)))
68 .unwrap_or_else(|| Uuid::now_v7().to_string())
69 };
6270 if let Ok(value) = HeaderValue::from_str(&request_id) {
6371 request
6472 .headers_mut()
@@ -113,15 +121,6 @@
113121 .instrument(span),
114122 ))
115123 }
116}
117
118fn request_id_of(headers: &HeaderMap) -> String {
119 headers
120 .get(REQUEST_ID_HEADER)
121 .or_else(|| headers.get(LEGACY_REQUEST_ID_HEADER))
122 .and_then(|value| value.to_str().ok())
123 .and_then(|value| session::validate_request_id(Some(value)))
124 .unwrap_or_else(|| Uuid::now_v7().to_string())
125124}
126125
127126/// 一次调用的静态信息,在请求进栈时抓取。
Modified lib/oltp/src/resource.rs lib/oltp/src/resource.rs +10 −12 View lib/oltp/src/resource.rs
@@ -19,22 +19,20 @@
1919 }
2020 attributes.push(KeyValue::new(key.clone(), value.clone()));
2121 }
22 if let Some(host) = host_name() {
22 if let Some(host) = {
23 std::env::var("HOSTNAME")
24 .ok()
25 .filter(|value| !value.is_empty())
26 .or_else(|| {
27 std::env::var("COMPUTERNAME")
28 .ok()
29 .filter(|value| !value.is_empty())
30 })
31 } {
2332 attributes.push(KeyValue::new("host.name", host));
2433 }
2534
2635 Resource::builder_empty()
2736 .with_attributes(attributes)
2837 .build()
29}
30
31fn host_name() -> Option<String> {
32 std::env::var("HOSTNAME")
33 .ok()
34 .filter(|value| !value.is_empty())
35 .or_else(|| {
36 std::env::var("COMPUTERNAME")
37 .ok()
38 .filter(|value| !value.is_empty())
39 })
4038}
Modified lib/pulls/src/services/events.rs lib/pulls/src/services/events.rs +9 −8 View lib/pulls/src/services/events.rs
@@ -203,7 +203,15 @@
203203 .offset(offset)
204204 .all(self.db_read())
205205 .await?;
206 let actors = self.user_briefs(&unique_users(&rows)).await?;
206 let actors = self
207 .user_briefs(&{
208 let rows = &rows;
209 let mut ids: Vec<i64> = rows.iter().map(|row| row.user_id).collect();
210 ids.sort();
211 ids.dedup();
212 ids
213 })
214 .await?;
207215 let items = rows
208216 .into_iter()
209217 .map(|row| PullEventDto {
@@ -237,11 +245,4 @@
237245 .map(|(uid, user)| (uid, UserDto::from_public(&user)))
238246 .collect())
239247 }
240}
241
242fn unique_users(rows: &[pull_request_event_line::Model]) -> Vec<i64> {
243 let mut ids: Vec<i64> = rows.iter().map(|row| row.user_id).collect();
244 ids.sort();
245 ids.dedup();
246 ids
247248}
Modified lib/pulls/src/services/pulls.rs lib/pulls/src/services/pulls.rs +9 −11 View lib/pulls/src/services/pulls.rs
@@ -140,16 +140,6 @@
140140 pub issue_id: Option<Option<Uuid>>,
141141}
142142
143fn state_of(pull: &pull_requests::Model) -> PullState {
144 if pull.has_merged {
145 PullState::Merged
146 } else if pull.closed_at.is_some() {
147 PullState::Closed
148 } else {
149 PullState::Open
150 }
151}
152
153143impl PullService {
154144 pub(crate) fn dto(pull: &pull_requests::Model, users: &HashMap<i64, UserDto>) -> PullDto {
155145 PullDto {
@@ -158,7 +148,15 @@
158148 number: pull.autoincrement_id,
159149 title: pull.title.clone(),
160150 body: pull.body.clone(),
161 state: state_of(pull),
151 state: {
152 if pull.has_merged {
153 PullState::Merged
154 } else if pull.closed_at.is_some() {
155 PullState::Closed
156 } else {
157 PullState::Open
158 }
159 },
162160 is_draft: pull.is_draft,
163161 auto_merge: pull.auto_merge,
164162 locked: pull.is_locked,
Modified lib/session/src/middleware.rs lib/session/src/middleware.rs +43 −42 View lib/session/src/middleware.rs
@@ -3,7 +3,7 @@
33use actix_web::cookie::{Cookie, SameSite as CookieSameSite};
44use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready};
55use actix_web::http::header::{HeaderName, HeaderValue, USER_AGENT};
6use actix_web::{HttpMessage, HttpRequest, HttpResponse};
6use actix_web::{HttpMessage, HttpResponse};
77use config::session::{SameSite, SessionConfig};
88use std::fmt;
99use std::future::{Ready, ready};
@@ -108,8 +108,29 @@
108108 let previous_user_id = session.uid();
109109 req.extensions_mut().insert(session.clone());
110110
111 record_client(&session, req.request(), configuration.trust_proxy);
111 {
112 let session = &session;
113 let req = req.request();
114 let trust_proxy = configuration.trust_proxy;
115 let connection = req.connection_info();
116 let ip_addr = if trust_proxy {
117 connection.realip_remote_addr()
118 } else {
119 connection.peer_addr()
120 };
121 if let Some(ip_addr) = ip_addr {
122 let _ = session.set_client_ip(truncate(ip_addr, CLIENT_IP_MAX_LEN));
123 }
112124
125 let user_agent = req
126 .headers()
127 .get(USER_AGENT)
128 .and_then(|user_agent| user_agent.to_str().ok());
129 if let Some(user_agent) = user_agent {
130 let _ = session.set_user_agent(truncate(user_agent, USER_AGENT_MAX_LEN));
131 }
132 }
133
113134 let request_id = req
114135 .extensions()
115136 .get::<RequestId>()
@@ -139,11 +160,29 @@
139160
140161 let (status, state) = session.snapshot();
141162 let user_id = session.uid();
142 let ttl = Duration::seconds(session_ttl(&configuration, &session));
163 let ttl = Duration::seconds('inline_session_ttl: {
164 let configuration = &configuration;
165 let session = &session;
166 let max_age = configuration.max_age.max(1);
167 if matches!(session.get::<i64>(SESSION_USER_ID), Ok(Some(_))) {
168 break 'inline_session_ttl max_age;
169 }
170
171 configuration.anonymous_max_age.clamp(1, max_age)
172 });
143173
144174 match session_key {
145175 None => {
146 if has_identity(&state) {
176 let __inl_cond = {
177 let state = &state;
178 state.keys().any(|key| {
179 !matches!(
180 key.as_str(),
181 SESSION_CLIENT_ID | SESSION_CLIENT_IP | SESSION_USER_AGENT
182 )
183 })
184 };
185 if __inl_cond {
147186 let session_key = storage_backend.save(state, &ttl).await.map_err(e500)?;
148187 set_session_cookie(res.response_mut(), &configuration, session_key, ttl)?;
149188 } else if provided_key.is_some() {
@@ -223,44 +262,6 @@
223262 Ok(res)
224263 })
225264 }
226}
227
228fn session_ttl(configuration: &SessionConfig, session: &Session) -> i64 {
229 let max_age = configuration.max_age.max(1);
230 if matches!(session.get::<i64>(SESSION_USER_ID), Ok(Some(_))) {
231 return max_age;
232 }
233
234 configuration.anonymous_max_age.clamp(1, max_age)
235}
236
237fn record_client(session: &Session, req: &HttpRequest, trust_proxy: bool) {
238 let connection = req.connection_info();
239 let ip_addr = if trust_proxy {
240 connection.realip_remote_addr()
241 } else {
242 connection.peer_addr()
243 };
244 if let Some(ip_addr) = ip_addr {
245 let _ = session.set_client_ip(truncate(ip_addr, CLIENT_IP_MAX_LEN));
246 }
247
248 let user_agent = req
249 .headers()
250 .get(USER_AGENT)
251 .and_then(|user_agent| user_agent.to_str().ok());
252 if let Some(user_agent) = user_agent {
253 let _ = session.set_user_agent(truncate(user_agent, USER_AGENT_MAX_LEN));
254 }
255}
256
257fn has_identity(state: &SessionState) -> bool {
258 state.keys().any(|key| {
259 !matches!(
260 key.as_str(),
261 SESSION_CLIENT_ID | SESSION_CLIENT_IP | SESSION_USER_AGENT
262 )
263 })
264265}
265266
266267fn truncate(value: &str, max_len: usize) -> String {
Modified lib/session/src/redis.rs lib/session/src/redis.rs +21 −25 View lib/session/src/redis.rs
@@ -193,7 +193,9 @@
193193 }
194194
195195 fn bucket_key(&self, user_id: Option<i64>) -> String {
196 self.cache.key(&[USER_BUCKET_PREFIX, &bucket_user(user_id)])
196 self.cache.key(&[USER_BUCKET_PREFIX, &{
197 user_id.map(|id| id.to_string()).unwrap_or_default()
198 }])
197199 }
198200
199201 fn session_key_prefix(&self) -> String {
@@ -237,7 +239,18 @@
237239 if ttl <= 0 {
238240 return Ok(None);
239241 }
240 let Some(state) = state_from_entries(entries.to_vec()) else {
242 let Some(state) = ('inline_state_from_entries: {
243 let entries = entries.to_vec();
244 if entries.is_empty() {
245 break 'inline_state_from_entries None;
246 }
247 let mut state = SessionState::with_capacity(entries.len() / 2 + 1);
248 let mut entries = entries.into_iter();
249 while let (Some(field), Some(value)) = (entries.next(), entries.next()) {
250 state.insert(field, value);
251 }
252 Some(state)
253 }) else {
241254 return Ok(None);
242255 };
243256 Ok(Some((state, Duration::seconds(ttl))))
@@ -253,7 +266,12 @@
253266 "the session state to save cannot be empty",
254267 ));
255268 }
256 let user_id = user_id_of(&session_state);
269 let user_id = {
270 let state = &session_state;
271 state
272 .get(crate::state::SESSION_USER_ID)
273 .and_then(|value| value.parse::<i64>().ok())
274 };
257275 let bucket_ttl = ttl_seconds(ttl);
258276 for _ in 0..SAVE_ATTEMPTS {
259277 let session_key = generate_session_key();
@@ -399,28 +417,6 @@
399417 let revoked: usize = invocation.invoke_async(&mut connection).await?;
400418 Ok(revoked)
401419 }
402}
403
404fn bucket_user(user_id: Option<i64>) -> String {
405 user_id.map(|id| id.to_string()).unwrap_or_default()
406}
407
408fn user_id_of(state: &SessionState) -> Option<i64> {
409 state
410 .get(crate::state::SESSION_USER_ID)
411 .and_then(|value| value.parse::<i64>().ok())
412}
413
414fn state_from_entries(entries: Vec<String>) -> Option<SessionState> {
415 if entries.is_empty() {
416 return None;
417 }
418 let mut state = SessionState::with_capacity(entries.len() / 2 + 1);
419 let mut entries = entries.into_iter();
420 while let (Some(field), Some(value)) = (entries.next(), entries.next()) {
421 state.insert(field, value);
422 }
423 Some(state)
424420}
425421
426422fn append_diff_body(invocation: &mut ScriptInvocation<'_>, diff: &SessionDiff) {
Modified lib/social/src/parse/mod.rs lib/social/src/parse/mod.rs +12 −14 View lib/social/src/parse/mod.rs
@@ -211,19 +211,6 @@
211211 PLATFORMS.len()
212212}
213213
214fn target_of(url: &Url) -> String {
215 let mut target = url.path().to_string();
216 if let Some(query) = url.query() {
217 target.push('?');
218 target.push_str(query);
219 }
220 if let Some(fragment) = url.fragment() {
221 target.push('#');
222 target.push_str(fragment);
223 }
224 target
225}
226
227214fn pattern_regex(pattern: &'static str) -> Option<Regex> {
228215 static CACHE: OnceLock<RwLock<HashMap<&'static str, Regex>>> = OnceLock::new();
229216 let cache = CACHE.get_or_init(|| RwLock::new(HashMap::new()));
@@ -244,7 +231,18 @@
244231/// 按 `pattern` 从 `path?query#frag` 抽取捕获组。
245232pub fn capture_args(pattern: &'static str, url: &Url) -> Option<Vec<String>> {
246233 let regex = pattern_regex(pattern)?;
247 let target = target_of(url);
234 let target = {
235 let mut target = url.path().to_string();
236 if let Some(query) = url.query() {
237 target.push('?');
238 target.push_str(query);
239 }
240 if let Some(fragment) = url.fragment() {
241 target.push('#');
242 target.push_str(fragment);
243 }
244 target
245 };
248246 let captures = regex.captures(&target)?;
249247 Some(
250248 captures
Modified lib/social/src/resolvers/page.rs lib/social/src/resolvers/page.rs +40 −39 View lib/social/src/resolvers/page.rs
@@ -35,7 +35,33 @@
3535 {
3636 return Ok((
3737 final_url.clone(),
38 Some(media_embed(&final_url, kind, &content_type)),
38 Some({
39 let url = &final_url;
40 let content_type = &content_type;
41 let media = EmbedMedia {
42 url: Some(url.to_string()),
43 content_type: (!content_type.is_empty()).then(|| content_type.to_owned()),
44 ..EmbedMedia::default()
45 };
46 let filename = url
47 .path_segments()
48 .and_then(|mut segments| segments.next_back())
49 .filter(|value| !value.is_empty())
50 .map(str::to_owned);
51 let mut embed = MessageEmbed {
52 embed_type: kind.embed_type().to_owned(),
53 url: Some(url.to_string()),
54 title: filename,
55 image: (kind == MediaKind::Image).then(|| media.clone()),
56 video: (kind == MediaKind::Video).then(|| media.clone()),
57 audio: (kind == MediaKind::Audio).then(|| media.clone()),
58 image_url: (kind == MediaKind::Image).then(|| url.to_string()),
59 kind: Some(kind.embed_type().to_owned()),
60 ..MessageEmbed::default()
61 };
62 normalize::normalize(&mut embed);
63 embed
64 }),
3965 ));
4066 }
4167 if !content_type.is_empty() && !content_type.contains("html") && !content_type.contains("xml") {
@@ -105,7 +131,19 @@
105131 .and_then(|value| http::absolute(final_url, &value))
106132 .and_then(|value| frame_allowed(&value, final_url))
107133 });
108 let html = player_src.map(|src| safe_iframe(&src, oembed.width, oembed.height));
134 let html = player_src.map(|src| {
135 let src = &src;
136 let width = oembed.width;
137 let height = oembed.height;
138 let width = width.unwrap_or(640);
139 let height = height.unwrap_or(360);
140 format!(
141 "<iframe class=\"gd-embed gd-embed-frame\" src=\"{}\" width=\"{width}\" height=\"{height}\" \
142 loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen \
143 sandbox=\"allow-scripts allow-same-origin allow-popups allow-forms allow-presentation\"></iframe>",
144 crate::escape_html(src),
145 )
146 });
109147 let embed_type = if html.is_some() {
110148 "iframe"
111149 } else if video.is_some() {
@@ -183,41 +221,4 @@
183221 .iter()
184222 .any(|domain| host == *domain || host.ends_with(&format!(".{domain}")));
185223 (same_site || known).then(|| url.to_string())
186}
187
188fn media_embed(url: &Url, kind: MediaKind, content_type: &str) -> MessageEmbed {
189 let media = EmbedMedia {
190 url: Some(url.to_string()),
191 content_type: (!content_type.is_empty()).then(|| content_type.to_owned()),
192 ..EmbedMedia::default()
193 };
194 let filename = url
195 .path_segments()
196 .and_then(|mut segments| segments.next_back())
197 .filter(|value| !value.is_empty())
198 .map(str::to_owned);
199 let mut embed = MessageEmbed {
200 embed_type: kind.embed_type().to_owned(),
201 url: Some(url.to_string()),
202 title: filename,
203 image: (kind == MediaKind::Image).then(|| media.clone()),
204 video: (kind == MediaKind::Video).then(|| media.clone()),
205 audio: (kind == MediaKind::Audio).then(|| media.clone()),
206 image_url: (kind == MediaKind::Image).then(|| url.to_string()),
207 kind: Some(kind.embed_type().to_owned()),
208 ..MessageEmbed::default()
209 };
210 normalize::normalize(&mut embed);
211 embed
212}
213
214fn safe_iframe(src: &str, width: Option<u32>, height: Option<u32>) -> String {
215 let width = width.unwrap_or(640);
216 let height = height.unwrap_or(360);
217 format!(
218 "<iframe class=\"gd-embed gd-embed-frame\" src=\"{}\" width=\"{width}\" height=\"{height}\" \
219 loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen \
220 sandbox=\"allow-scripts allow-same-origin allow-popups allow-forms allow-presentation\"></iframe>",
221 crate::escape_html(src),
222 )
223224}
Modified lib/social/src/normalize.rs lib/social/src/normalize.rs +22 −27 View lib/social/src/normalize.rs
@@ -1,4 +1,4 @@
1use crate::types::{EmbedAuthor, EmbedField, EmbedMedia, EmbedProvider, MessageEmbed};
1use crate::types::{EmbedAuthor, EmbedField, EmbedProvider, MessageEmbed};
22
33const TITLE_MAX: usize = 256;
44const DESCRIPTION_MAX: usize = 4096;
@@ -13,10 +13,21 @@
1313 .title
1414 .as_deref()
1515 .map(|value| truncate(value, TITLE_MAX));
16 embed.description = embed
17 .description
18 .as_deref()
19 .map(|value| clamp_lines(&truncate(value, DESCRIPTION_MAX), DESCRIPTION_LINES));
16 embed.description = embed.description.as_deref().map(|value| {
17 let value = &truncate(value, DESCRIPTION_MAX);
18 let max_lines = DESCRIPTION_LINES;
19 let mut output = String::with_capacity(value.len());
20 for (index, line) in value.lines().enumerate() {
21 if index >= max_lines {
22 break;
23 }
24 if index > 0 {
25 output.push('\n');
26 }
27 output.push_str(line);
28 }
29 output
30 });
2031 embed.author = embed.author.take().and_then(normalize_author);
2132 embed.provider = embed.provider.take().and_then(normalize_provider);
2233 for media in [
@@ -28,7 +39,12 @@
2839 .into_iter()
2940 .flatten()
3041 {
31 normalize_media(media);
42 {
43 media.description = media
44 .description
45 .as_deref()
46 .map(|value| truncate(value, DESCRIPTION_MAX));
47 }
3248 }
3349 embed.html = embed.html.as_deref().map(|value| truncate(value, HTML_MAX));
3450 embed.fields = embed.fields.take().map(|fields| {
@@ -69,32 +85,11 @@
6985 (!provider.name.is_empty()).then_some(provider)
7086}
7187
72fn normalize_media(media: &mut EmbedMedia) {
73 media.description = media
74 .description
75 .as_deref()
76 .map(|value| truncate(value, DESCRIPTION_MAX));
77}
78
7988pub fn truncate(value: &str, max: usize) -> String {
8089 if value.chars().count() <= max {
8190 return value.to_owned();
8291 }
8392 let mut output: String = value.chars().take(max).collect();
8493 output.push('…');
85 output
86}
87
88fn clamp_lines(value: &str, max_lines: usize) -> String {
89 let mut output = String::with_capacity(value.len());
90 for (index, line) in value.lines().enumerate() {
91 if index >= max_lines {
92 break;
93 }
94 if index > 0 {
95 output.push('\n');
96 }
97 output.push_str(line);
98 }
9994 output
10095}
Modified lib/social/src/render.rs lib/social/src/render.rs +31 −46 View lib/social/src/render.rs
@@ -21,49 +21,6 @@
2121 out
2222}
2323
24fn host_of(url: &str) -> String {
25 url::Url::parse(url)
26 .ok()
27 .and_then(|parsed| parsed.host_str().map(str::to_string))
28 .unwrap_or_default()
29}
30
31/// 平台标识:`data-*` 上给前端留钩子。
32fn data_attrs(resolved: &Resolved) -> String {
33 format!(
34 " data-platform=\"{}\" data-category=\"{}\"",
35 escape_html(resolved.platform),
36 resolved.category.as_str(),
37 )
38}
39
40/// iframe 的尺寸。
41///
42/// 平台声明了固定像素高时用它(Spotify 单曲 152px、网易云外链 66px、
43/// Reddit 500px…),否则才按宽高比撑开。`background:#000` 只给视频类——
44/// 推文、音乐、地图这些是浅色卡片,套黑底会在加载前露出一块黑矩形。
45fn frame_style(resolved: &Resolved) -> String {
46 let base = "width:100%;max-width:640px;border:0;border-radius:12px;display:block";
47 let backdrop = if is_dark_player(resolved) {
48 ";background:#000"
49 } else {
50 ""
51 };
52 match resolved.height {
53 Some(height) => format!("{base};height:{height}px{backdrop}"),
54 None => format!("{base};aspect-ratio:{}{backdrop}", resolved.aspect.css()),
55 }
56}
57
58/// 视频类播放器才用黑底:其余嵌入是浅色卡片,不该有黑框。
59fn is_dark_player(resolved: &Resolved) -> bool {
60 matches!(resolved.kind, EmbedKind::Video | EmbedKind::Audio)
61 || matches!(
62 resolved.category,
63 crate::Category::Video | crate::Category::Live
64 )
65}
66
6724const MEDIA_STYLE: &str =
6825 "width:100%;max-width:640px;border-radius:12px;background:#000;display:block";
6926const IMAGE_STYLE: &str =
@@ -72,7 +29,13 @@
7229
7330/// 把一个解析结果渲染成可注入消息流的 HTML 片段。
7431pub fn render_html(resolved: &Resolved) -> String {
75 let attrs = data_attrs(resolved);
32 let attrs = {
33 format!(
34 " data-platform=\"{}\" data-category=\"{}\"",
35 escape_html(resolved.platform),
36 resolved.category.as_str(),
37 )
38 };
7639 let title = escape_html(&resolved.title);
7740 match resolved.kind {
7841 EmbedKind::Iframe => {
@@ -86,7 +49,23 @@
8649 sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation\" \
8750 style=\"{style}\"{attrs}></iframe>",
8851 src = escape_html(src),
89 style = frame_style(resolved),
52 style = {
53 let base =
54 "width:100%;max-width:640px;border:0;border-radius:12px;display:block";
55 let backdrop = if matches!(resolved.kind, EmbedKind::Video | EmbedKind::Audio)
56 || matches!(
57 resolved.category,
58 crate::Category::Video | crate::Category::Live
59 ) {
60 ";background:#000"
61 } else {
62 ""
63 };
64 match resolved.height {
65 Some(height) => format!("{base};height:{height}px{backdrop}"),
66 None => format!("{base};aspect-ratio:{}{backdrop}", resolved.aspect.css()),
67 }
68 },
9069 )
9170 }
9271 EmbedKind::Video => {
@@ -122,7 +101,13 @@
122101fn render_card(resolved: &Resolved, attrs: &str) -> String {
123102 let url = escape_html(&resolved.url);
124103 let name = escape_html(resolved.platform_name);
125 let host = escape_html(&host_of(&resolved.url));
104 let host = escape_html(&{
105 let url = &resolved.url;
106 url::Url::parse(url)
107 .ok()
108 .and_then(|parsed| parsed.host_str().map(str::to_string))
109 .unwrap_or_default()
110 });
126111 let raw_title = resolved.title.trim();
127112 let headline = if raw_title.is_empty() || raw_title == resolved.platform_name {
128113 name.clone()
Modified lib/social/src/resolve.rs lib/social/src/resolve.rs +33 −33 View lib/social/src/resolve.rs
@@ -106,44 +106,44 @@
106106 .then(|| candidate.to_string())
107107}
108108
109fn apply_enrichment(resolved: &mut Resolved, enrichment: &Enrichment, url: &Url) {
110 if let Some(title) = enrichment
111 .title
112 .as_deref()
113 .map(str::trim)
114 .filter(|value| !value.is_empty())
115 {
116 resolved.title = title.chars().take(200).collect();
117 }
118 if let Some(description) = enrichment
119 .description
120 .as_deref()
121 .map(str::trim)
122 .filter(|value| !value.is_empty())
123 {
124 resolved.description = Some(description.chars().take(300).collect());
125 }
126 if let Some(image) = enrichment.image_url.as_deref().and_then(absolute_http_url) {
127 resolved.image_url = Some(image.to_string());
128 }
129 if resolved.kind == EmbedKind::Link
130 && let Some(frame) = enrichment
131 .frame_src
132 .as_deref()
133 .and_then(|raw| owned_frame_src(raw, url))
134 {
135 resolved.kind = EmbedKind::Iframe;
136 resolved.src = Some(frame);
137 resolved.aspect = Aspect::Wide;
138 }
139}
140
141109/// 解析 + 合并联网补全(标题 / 简介 / 封面 / iframe)+ 渲染。
142110pub fn embed_json_enriched(raw: &str, enrichment: &Enrichment) -> Option<serde_json::Value> {
143111 let trimmed = raw.trim();
144112 let mut resolved = parse(trimmed)?;
145113 let url = Url::parse(trimmed).ok()?;
146 apply_enrichment(&mut resolved, enrichment, &url);
114 {
115 let resolved = &mut resolved;
116 let url = &url;
117 if let Some(title) = enrichment
118 .title
119 .as_deref()
120 .map(str::trim)
121 .filter(|value| !value.is_empty())
122 {
123 resolved.title = title.chars().take(200).collect();
124 }
125 if let Some(description) = enrichment
126 .description
127 .as_deref()
128 .map(str::trim)
129 .filter(|value| !value.is_empty())
130 {
131 resolved.description = Some(description.chars().take(300).collect());
132 }
133 if let Some(image) = enrichment.image_url.as_deref().and_then(absolute_http_url) {
134 resolved.image_url = Some(image.to_string());
135 }
136 if resolved.kind == EmbedKind::Link
137 && let Some(frame) = enrichment
138 .frame_src
139 .as_deref()
140 .and_then(|raw| owned_frame_src(raw, url))
141 {
142 resolved.kind = EmbedKind::Iframe;
143 resolved.src = Some(frame);
144 resolved.aspect = Aspect::Wide;
145 }
146 }
147147 let html = crate::render_html(&resolved);
148148 Some(Embed { resolved, html }.to_json())
149149}
Modified lib/space/src/services/images.rs lib/space/src/services/images.rs +9 −13 View lib/space/src/services/images.rs
@@ -101,18 +101,6 @@
101101 )
102102}
103103
104/// Content type to hand the preview generator for one stored object. The stored
105/// locator carries the uploaded extension; a wrong guess only costs a skipped
106/// render, because the decoder sniffs the real format from the bytes.
107fn space_image_content_type(original_key: &str) -> &'static str {
108 match original_key.rsplit('.').next().unwrap_or_default() {
109 "png" => "image/png",
110 "webp" => "image/webp",
111 "gif" => "image/gif",
112 _ => "image/jpeg",
113 }
114}
115
116104fn space_image_object_key(stored_url: &str) -> Option<String> {
117105 let key = stored_url.strip_prefix("storage://")?;
118106 if key.is_empty() {
@@ -267,7 +255,15 @@
267255 &self.state.redis,
268256 &key_base,
269257 &original_key,
270 space_image_content_type(&original_key),
258 {
259 let original_key = &original_key;
260 match original_key.rsplit('.').next().unwrap_or_default() {
261 "png" => "image/png",
262 "webp" => "image/webp",
263 "gif" => "image/gif",
264 _ => "image/jpeg",
265 }
266 },
271267 PreviewPreset::Content,
272268 recorded.as_ref(),
273269 )
Modified lib/space/src/services/rooms.rs lib/space/src/services/rooms.rs +7 −8 View lib/space/src/services/rooms.rs
@@ -75,13 +75,6 @@
7575 }
7676}
7777
78fn is_valid_lower_name(name: &str) -> bool {
79 !name.is_empty()
80 && name
81 .chars()
82 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
83}
84
8578impl SpaceService {
8679 #[tracing::instrument(name = "space.create_room", skip_all, fields(caller = %caller, space = %space_name))]
8780 pub async fn create_room(
@@ -131,7 +124,13 @@
131124 ));
132125 }
133126 let lower = name.to_lowercase();
134 if !is_valid_lower_name(&lower) {
127 if !{
128 let name = &lower;
129 !name.is_empty()
130 && name
131 .chars()
132 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
133 } {
135134 return Err(SpaceError::BadRequest(
136135 "invalid room name: must contain only lowercase letters, digits, and hyphens"
137136 .to_string(),
Modified lib/storage/src/s3.rs lib/storage/src/s3.rs +35 −39 View lib/storage/src/s3.rs
@@ -54,8 +54,41 @@
5454 pub fn new(config: &S3Config) -> Result<Self, StorageError> {
5555 let (bucket_name, region) = config.validate().map_err(StorageError::InvalidConfig)?;
5656
57 let region = build_region(config, region)?;
58 let credentials = build_credentials(config)?;
57 let region = {
58 match config.endpoint.as_deref().filter(|s| !s.is_empty()) {
59 Some(endpoint) => Ok(Region::Custom {
60 region: region.to_string(),
61 endpoint: endpoint.to_string(),
62 }),
63 None => region.parse::<Region>().map_err(|err| {
64 StorageError::InvalidConfig(format!("region {region:?}: {err}"))
65 }),
66 }
67 }?;
68 let credentials = {
69 match (
70 config.access_key_id.as_deref().filter(|s| !s.is_empty()),
71 config
72 .secret_access_key
73 .as_deref()
74 .filter(|s| !s.is_empty()),
75 ) {
76 (Some(access), Some(secret)) => Ok(Credentials::new(
77 Some(access),
78 Some(secret),
79 None,
80 None,
81 None,
82 )?),
83 (Some(_), None) => Err(StorageError::InvalidConfig(
84 "access_key_id provided without secret_access_key".into(),
85 )),
86 (None, Some(_)) => Err(StorageError::InvalidConfig(
87 "secret_access_key provided without access_key_id".into(),
88 )),
89 (None, None) => Ok(Credentials::default()?),
90 }
91 }?;
5992 let mut bucket = Bucket::new(bucket_name, region, credentials)?;
6093
6194 if config.use_path_style() {
@@ -369,43 +402,6 @@
369402 Ok(self
370403 .presign_bucket
371404 .presign_post(key, expiry_secs, post_policy)?)
372 }
373}
374
375fn build_region(config: &S3Config, region: &str) -> Result<Region, StorageError> {
376 match config.endpoint.as_deref().filter(|s| !s.is_empty()) {
377 Some(endpoint) => Ok(Region::Custom {
378 region: region.to_string(),
379 endpoint: endpoint.to_string(),
380 }),
381 None => region
382 .parse::<Region>()
383 .map_err(|err| StorageError::InvalidConfig(format!("region {region:?}: {err}"))),
384 }
385}
386
387fn build_credentials(config: &S3Config) -> Result<Credentials, StorageError> {
388 match (
389 config.access_key_id.as_deref().filter(|s| !s.is_empty()),
390 config
391 .secret_access_key
392 .as_deref()
393 .filter(|s| !s.is_empty()),
394 ) {
395 (Some(access), Some(secret)) => Ok(Credentials::new(
396 Some(access),
397 Some(secret),
398 None,
399 None,
400 None,
401 )?),
402 (Some(_), None) => Err(StorageError::InvalidConfig(
403 "access_key_id provided without secret_access_key".into(),
404 )),
405 (None, Some(_)) => Err(StorageError::InvalidConfig(
406 "secret_access_key provided without access_key_id".into(),
407 )),
408 (None, None) => Ok(Credentials::default()?),
409405 }
410406}
411407
Modified lib/user/src/services/avatar.rs lib/user/src/services/avatar.rs +9 −13 View lib/user/src/services/avatar.rs
@@ -30,18 +30,6 @@
3030 Some(key.to_string())
3131}
3232
33/// Content type to hand the preview generator for a stored avatar object. The
34/// stored locator carries the uploaded extension, which is authoritative enough
35/// for a decode attempt (and a mismatch only costs one skipped render).
36fn avatar_content_type(original_key: &str) -> &'static str {
37 match original_key.rsplit('.').next().unwrap_or_default() {
38 "png" => "image/png",
39 "webp" => "image/webp",
40 "gif" => "image/gif",
41 _ => "image/jpeg",
42 }
43}
44
4533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
4634#[serde(rename_all = "lowercase")]
4735pub enum AvatarFormat {
@@ -181,7 +169,15 @@
181169 &self.state.redis,
182170 &key_base,
183171 &original_key,
184 avatar_content_type(&original_key),
172 {
173 let original_key = &original_key;
174 match original_key.rsplit('.').next().unwrap_or_default() {
175 "png" => "image/png",
176 "webp" => "image/webp",
177 "gif" => "image/gif",
178 _ => "image/jpeg",
179 }
180 },
185181 PreviewPreset::Avatar,
186182 recorded.as_ref(),
187183 )
Modified lib/webapi/src/cleanup.rs lib/webapi/src/cleanup.rs +17 −17 View lib/webapi/src/cleanup.rs
@@ -155,22 +155,6 @@
155155 Ok(space)
156156}
157157
158fn room_kind_name(kind: rooms::RoomKind) -> &'static str {
159 use rooms::RoomKind;
160 match kind {
161 RoomKind::GuildRoom => "guild_room",
162 RoomKind::Text => "text",
163 RoomKind::Voice => "voice",
164 RoomKind::Stage => "stage",
165 RoomKind::Category => "category",
166 RoomKind::News => "news",
167 RoomKind::Thread => "thread",
168 RoomKind::GroupDm => "group_dm",
169 RoomKind::Dm => "dm",
170 RoomKind::DirectMessage => "direct_message",
171 }
172}
173
174158/// 目录路径:沿 `parent_dir_id` 收集名字再反转为 `a / b / c`。
175159fn dir_path(node: &dirs::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> String {
176160 let mut names = vec![node.name.clone()];
@@ -354,7 +338,23 @@
354338 for room in &room_rows {
355339 objects.push(CleanupObjectDto {
356340 id: room.id,
357 kind: room_kind_name(room.kind).to_string(),
341 kind: {
342 let kind = room.kind;
343 use rooms::RoomKind;
344 match kind {
345 RoomKind::GuildRoom => "guild_room",
346 RoomKind::Text => "text",
347 RoomKind::Voice => "voice",
348 RoomKind::Stage => "stage",
349 RoomKind::Category => "category",
350 RoomKind::News => "news",
351 RoomKind::Thread => "thread",
352 RoomKind::GroupDm => "group_dm",
353 RoomKind::Dm => "dm",
354 RoomKind::DirectMessage => "direct_message",
355 }
356 }
357 .to_string(),
358358 name: room.name.clone(),
359359 path: room_path(room, &room_index),
360360 is_private: Some(room.is_private),