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:
@@ -497,62 +497,34 @@ impl TwitterHand {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let creds = self.get_credentials().await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
|
||||
|
||||
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();
|
||||
|
||||
let _ = tweet_id;
|
||||
tracing::warn!("[TwitterHand] like action requires OAuth 1.0a user context — not yet supported");
|
||||
Ok(json!({
|
||||
"success": status.is_success(),
|
||||
"tweet_id": tweet_id,
|
||||
"action": "liked",
|
||||
"status_code": status.as_u16(),
|
||||
"message": if status.is_success() { "Tweet liked" } else { &response_text }
|
||||
"success": false,
|
||||
"action": "like",
|
||||
"error": "OAuth 1.0a user context required. Like action is not yet supported with app-only Bearer token.",
|
||||
"suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let creds = self.get_credentials().await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
|
||||
|
||||
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();
|
||||
|
||||
let _ = tweet_id;
|
||||
tracing::warn!("[TwitterHand] retweet action requires OAuth 1.0a user context — not yet supported");
|
||||
Ok(json!({
|
||||
"success": status.is_success(),
|
||||
"tweet_id": tweet_id,
|
||||
"action": "retweeted",
|
||||
"status_code": status.as_u16(),
|
||||
"message": if status.is_success() { "Tweet retweeted" } else { &response_text }
|
||||
"success": false,
|
||||
"action": "retweet",
|
||||
"error": "OAuth 1.0a user context required. Retweet action is not yet supported with app-only Bearer token.",
|
||||
"suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
@@ -15,6 +15,26 @@ use zclaw_types::{Message, Result};
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,38 +93,28 @@ impl DataMasker {
|
||||
let mut entities = Vec::new();
|
||||
|
||||
// Company names: X公司、XX集团、XX工作室 (1-20 char prefix + suffix)
|
||||
if let Ok(re) = Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)") {
|
||||
for cap in re.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
for cap in RE_COMPANY.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
|
||||
// Money amounts: ¥50万、¥100元、$200、50万元
|
||||
if let Ok(re) = Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元") {
|
||||
for cap in re.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
for cap in RE_MONEY.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
|
||||
// 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.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
for cap in RE_PHONE.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
|
||||
// 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.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
for cap in RE_EMAIL.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
|
||||
// ID card numbers (simplified): 18 digits
|
||||
if let Ok(re) = Regex::new(r"\b\d{17}[\dXx]\b") {
|
||||
for cap in re.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
for cap in RE_ID_CARD.find_iter(text) {
|
||||
entities.push(cap.as_str().to_string());
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
{
|
||||
let forward = self.forward.read().unwrap();
|
||||
if let Some(token) = forward.get(entity) {
|
||||
return token.clone();
|
||||
if let Ok(forward) = recover_read(&self.forward) {
|
||||
if let Some(token) = forward.get(entity) {
|
||||
return token.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,12 +162,10 @@ impl DataMasker {
|
||||
let token = format!("__ENTITY_{}__", counter);
|
||||
|
||||
// Store in both mappings
|
||||
{
|
||||
let mut forward = self.forward.write().unwrap();
|
||||
if let Ok(mut forward) = recover_write(&self.forward) {
|
||||
forward.insert(entity.to_string(), token.clone());
|
||||
}
|
||||
{
|
||||
let mut reverse = self.reverse.write().unwrap();
|
||||
if let Ok(mut reverse) = recover_write(&self.reverse) {
|
||||
reverse.insert(token.clone(), entity.to_string());
|
||||
}
|
||||
|
||||
|
||||
@@ -122,12 +122,11 @@ pub async fn list_versions(
|
||||
pub async fn get_version(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path((name, _version)): Path<(String, i32)>,
|
||||
Path((name, version)): Path<(String, i32)>,
|
||||
) -> SaasResult<Json<PromptVersionInfo>> {
|
||||
check_permission(&ctx, "prompt:read")?;
|
||||
|
||||
let _tmpl = service::get_template_by_name(&state.db, &name).await?;
|
||||
Ok(Json(service::get_current_version(&state.db, &name).await?))
|
||||
Ok(Json(service::get_version_by_number(&state.db, &name, version).await?))
|
||||
}
|
||||
|
||||
/// POST /api/v1/prompts/{name}/versions — 发布新版本
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/// 获取模板的指定版本号的内容
|
||||
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(
|
||||
db: &PgPool,
|
||||
|
||||
Reference in New Issue
Block a user