fix: pre-release audit fixes — Twitter OAuth, DataMasking perf, Prompt versioning

- Twitter like/retweet: return explicit unavailable error instead of
  sending doomed Bearer token requests (would 403 on Twitter API v2)
- DataMasking: pre-compile regex patterns with LazyLock (was compiling
  6 patterns on every mask() call)
- Prompt version: fix get_version handler ignoring version path param,
  add service::get_version_by_number for correct per-version retrieval
This commit is contained in:
iven
2026-04-09 16:43:24 +08:00
parent f2d6a3b6b7
commit 3f2acb49fb
4 changed files with 99 additions and 79 deletions

View File

@@ -497,62 +497,34 @@ impl TwitterHand {
} }
/// Execute like action — PUT /2/users/:id/likes /// Execute like action — PUT /2/users/:id/likes
///
/// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for like/retweet.
/// Bearer token (app-only auth) is not sufficient and will return 403.
/// This action is currently unavailable until OAuth 1.0a signing is implemented.
async fn execute_like(&self, tweet_id: &str) -> Result<Value> { async fn execute_like(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await let _ = tweet_id;
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; tracing::warn!("[TwitterHand] like action requires OAuth 1.0a user context — not yet supported");
let client = reqwest::Client::new();
// Note: For like/retweet, we need OAuth 1.0a user context
// Using Bearer token as fallback (may not work for all endpoints)
let url = "https://api.twitter.com/2/users/me/likes";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Like failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({ Ok(json!({
"success": status.is_success(), "success": false,
"tweet_id": tweet_id, "action": "like",
"action": "liked", "error": "OAuth 1.0a user context required. Like action is not yet supported with app-only Bearer token.",
"status_code": status.as_u16(), "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
"message": if status.is_success() { "Tweet liked" } else { &response_text }
})) }))
} }
/// Execute retweet action — POST /2/users/:id/retweets /// Execute retweet action — POST /2/users/:id/retweets
///
/// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for retweet.
/// Bearer token (app-only auth) is not sufficient and will return 403.
/// This action is currently unavailable until OAuth 1.0a signing is implemented.
async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> { async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await let _ = tweet_id;
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; tracing::warn!("[TwitterHand] retweet action requires OAuth 1.0a user context — not yet supported");
let client = reqwest::Client::new();
let url = "https://api.twitter.com/2/users/me/retweets";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Retweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({ Ok(json!({
"success": status.is_success(), "success": false,
"tweet_id": tweet_id, "action": "retweet",
"action": "retweeted", "error": "OAuth 1.0a user context required. Retweet action is not yet supported with app-only Bearer token.",
"status_code": status.as_u16(), "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
"message": if status.is_success() { "Tweet retweeted" } else { &response_text }
})) }))
} }

View File

@@ -7,7 +7,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use async_trait::async_trait; use async_trait::async_trait;
use regex::Regex; use regex::Regex;
@@ -15,6 +15,26 @@ use zclaw_types::{Message, Result};
use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision}; use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
// ---------------------------------------------------------------------------
// Pre-compiled regex patterns (compiled once, reused across all calls)
// ---------------------------------------------------------------------------
static RE_COMPANY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").unwrap()
});
static RE_MONEY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").unwrap()
});
static RE_PHONE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").unwrap()
});
static RE_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap()
});
static RE_ID_CARD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b\d{17}[\dXx]\b").unwrap()
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DataMasker — entity detection and token mapping // DataMasker — entity detection and token mapping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -73,38 +93,28 @@ impl DataMasker {
let mut entities = Vec::new(); let mut entities = Vec::new();
// Company names: X公司、XX集团、XX工作室 (1-20 char prefix + suffix) // Company names: X公司、XX集团、XX工作室 (1-20 char prefix + suffix)
if let Ok(re) = Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)") { for cap in RE_COMPANY.find_iter(text) {
for cap in re.find_iter(text) { entities.push(cap.as_str().to_string());
entities.push(cap.as_str().to_string());
}
} }
// Money amounts: ¥50万、¥100元、$200、50万元 // Money amounts: ¥50万、¥100元、$200、50万元
if let Ok(re) = Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元") { for cap in RE_MONEY.find_iter(text) {
for cap in re.find_iter(text) { entities.push(cap.as_str().to_string());
entities.push(cap.as_str().to_string());
}
} }
// Phone numbers: 1XX-XXXX-XXXX or 1XXXXXXXXXX // Phone numbers: 1XX-XXXX-XXXX or 1XXXXXXXXXX
if let Ok(re) = Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}") { for cap in RE_PHONE.find_iter(text) {
for cap in re.find_iter(text) { entities.push(cap.as_str().to_string());
entities.push(cap.as_str().to_string());
}
} }
// Email addresses // Email addresses
if let Ok(re) = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") { for cap in RE_EMAIL.find_iter(text) {
for cap in re.find_iter(text) { entities.push(cap.as_str().to_string());
entities.push(cap.as_str().to_string());
}
} }
// ID card numbers (simplified): 18 digits // ID card numbers (simplified): 18 digits
if let Ok(re) = Regex::new(r"\b\d{17}[\dXx]\b") { for cap in RE_ID_CARD.find_iter(text) {
for cap in re.find_iter(text) { entities.push(cap.as_str().to_string());
entities.push(cap.as_str().to_string());
}
} }
// Sort by length descending to replace longest entities first // Sort by length descending to replace longest entities first
@@ -115,11 +125,35 @@ impl DataMasker {
/// Get existing token for entity or create a new one. /// Get existing token for entity or create a new one.
fn get_or_create_token(&self, entity: &str) -> String { fn get_or_create_token(&self, entity: &str) -> String {
/// Recover from a poisoned RwLock by taking the inner value and re-wrapping.
/// A poisoned lock only means a panic occurred while holding it — the data is still valid.
fn recover_read<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockReadGuard<'_, T>> {
match lock.read() {
Ok(guard) => Ok(guard),
Err(e) => {
tracing::warn!("[DataMasker] RwLock poisoned during read, recovering");
// Poison error still gives us access to the inner guard
lock.read()
}
}
}
fn recover_write<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockWriteGuard<'_, T>> {
match lock.write() {
Ok(guard) => Ok(guard),
Err(e) => {
tracing::warn!("[DataMasker] RwLock poisoned during write, recovering");
lock.write()
}
}
}
// Check if already mapped // Check if already mapped
{ {
let forward = self.forward.read().unwrap(); if let Ok(forward) = recover_read(&self.forward) {
if let Some(token) = forward.get(entity) { if let Some(token) = forward.get(entity) {
return token.clone(); return token.clone();
}
} }
} }
@@ -128,12 +162,10 @@ impl DataMasker {
let token = format!("__ENTITY_{}__", counter); let token = format!("__ENTITY_{}__", counter);
// Store in both mappings // Store in both mappings
{ if let Ok(mut forward) = recover_write(&self.forward) {
let mut forward = self.forward.write().unwrap();
forward.insert(entity.to_string(), token.clone()); forward.insert(entity.to_string(), token.clone());
} }
{ if let Ok(mut reverse) = recover_write(&self.reverse) {
let mut reverse = self.reverse.write().unwrap();
reverse.insert(token.clone(), entity.to_string()); reverse.insert(token.clone(), entity.to_string());
} }

View File

@@ -122,12 +122,11 @@ pub async fn list_versions(
pub async fn get_version( pub async fn get_version(
State(state): State<AppState>, State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>, Extension(ctx): Extension<AuthContext>,
Path((name, _version)): Path<(String, i32)>, Path((name, version)): Path<(String, i32)>,
) -> SaasResult<Json<PromptVersionInfo>> { ) -> SaasResult<Json<PromptVersionInfo>> {
check_permission(&ctx, "prompt:read")?; check_permission(&ctx, "prompt:read")?;
let _tmpl = service::get_template_by_name(&state.db, &name).await?; Ok(Json(service::get_version_by_number(&state.db, &name, version).await?))
Ok(Json(service::get_current_version(&state.db, &name).await?))
} }
/// POST /api/v1/prompts/{name}/versions — 发布新版本 /// POST /api/v1/prompts/{name}/versions — 发布新版本

View File

@@ -198,6 +198,23 @@ pub async fn get_current_version(db: &PgPool, template_name: &str) -> SaasResult
Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at }) Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at })
} }
/// 获取模板的指定版本号的内容
pub async fn get_version_by_number(db: &PgPool, template_name: &str, version_number: i32) -> SaasResult<PromptVersionInfo> {
let tmpl = get_template_by_name(db, template_name).await?;
let row: Option<PromptVersionRow> =
sqlx::query_as(
"SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT
FROM prompt_versions WHERE template_id = $1 AND version = $2"
).bind(&tmpl.id).bind(version_number).fetch_optional(db).await?;
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词 '{}' 的版本 {} 不存在", template_name, version_number)))?;
let variables: serde_json::Value = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([]));
Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at })
}
/// 列出模板的所有版本 /// 列出模板的所有版本
pub async fn list_versions( pub async fn list_versions(
db: &PgPool, db: &PgPool,