Skip to main content

mockforge_registry_server/handlers/
admin.rs

1//! Admin handlers
2
3use axum::{
4    extract::{Path, State},
5    Extension, Json,
6};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::{
12    error::{ApiError, ApiResult},
13    models::{AuditEventType, Plugin},
14    AppState,
15};
16
17#[derive(Debug, Deserialize)]
18pub struct VerifyPluginRequest {
19    pub verified: bool,
20}
21
22#[derive(Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct TakedownPluginRequest {
25    /// Optional reason shown on the admin detail view. Stored on the
26    /// plugin row so admins reviewing past moderation see why.
27    #[serde(default)]
28    pub reason: Option<String>,
29}
30
31#[derive(Debug, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct TakedownPluginResponse {
34    pub success: bool,
35    pub plugin_name: String,
36    pub taken_down: bool,
37    pub taken_down_at: Option<String>,
38    pub reason: Option<String>,
39    pub message: String,
40}
41
42#[derive(Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct VerifyPluginResponse {
45    pub success: bool,
46    pub plugin_name: String,
47    pub verified: bool,
48    pub verified_at: Option<String>,
49    pub message: String,
50}
51
52pub async fn verify_plugin(
53    State(state): State<AppState>,
54    Path(name): Path<String>,
55    Extension(user_id): Extension<String>,
56    Json(request): Json<VerifyPluginRequest>,
57) -> ApiResult<Json<VerifyPluginResponse>> {
58    let pool = state.db.pool();
59
60    // Parse user_id
61    let user_uuid = Uuid::parse_str(&user_id)
62        .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
63
64    // Check if user is admin
65    let user = sqlx::query_as::<_, (bool,)>("SELECT is_admin FROM users WHERE id = $1")
66        .bind(user_uuid)
67        .fetch_one(pool)
68        .await
69        .map_err(ApiError::Database)?;
70
71    if !user.0 {
72        return Err(ApiError::PermissionDenied);
73    }
74
75    // Get plugin
76    let plugin = Plugin::find_by_name(pool, &name)
77        .await
78        .map_err(ApiError::Database)?
79        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
80
81    // Update verification status
82    let verified_at = if request.verified {
83        Some(Utc::now())
84    } else {
85        None
86    };
87
88    sqlx::query("UPDATE plugins SET verified_at = $1 WHERE id = $2")
89        .bind(verified_at)
90        .bind(plugin.id)
91        .execute(pool)
92        .await
93        .map_err(ApiError::Database)?;
94
95    let message = if request.verified {
96        format!("Plugin '{}' has been verified", name)
97    } else {
98        format!("Plugin '{}' verification has been removed", name)
99    };
100
101    // Record audit event for admin verification action
102    state
103        .store
104        .record_audit_event(
105            Uuid::nil(),
106            Some(user_uuid),
107            AuditEventType::AdminImpersonation, // Reusing admin action type for verification
108            message.clone(),
109            Some(serde_json::json!({
110                "plugin_name": name,
111                "verified": request.verified,
112                "action": "verify_plugin",
113            })),
114            None,
115            None,
116        )
117        .await;
118
119    Ok(Json(VerifyPluginResponse {
120        success: true,
121        plugin_name: name,
122        verified: request.verified,
123        verified_at: verified_at.map(|dt| dt.to_rfc3339()),
124        message,
125    }))
126}
127
128/// Soft-delete a plugin from the public catalog. Reversible via
129/// `restore_plugin` — installed copies keep working because we only flip
130/// flags; we don't drop rows.
131pub async fn takedown_plugin(
132    State(state): State<AppState>,
133    Path(name): Path<String>,
134    Extension(user_id): Extension<String>,
135    Json(request): Json<TakedownPluginRequest>,
136) -> ApiResult<Json<TakedownPluginResponse>> {
137    let pool = state.db.pool();
138
139    let user_uuid = Uuid::parse_str(&user_id)
140        .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
141
142    let user = sqlx::query_as::<_, (bool,)>("SELECT is_admin FROM users WHERE id = $1")
143        .bind(user_uuid)
144        .fetch_one(pool)
145        .await
146        .map_err(ApiError::Database)?;
147    if !user.0 {
148        return Err(ApiError::PermissionDenied);
149    }
150
151    let plugin = Plugin::find_by_name(pool, &name)
152        .await
153        .map_err(ApiError::Database)?
154        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
155
156    let reason = request.reason.as_deref().map(str::trim).filter(|s| !s.is_empty());
157    state.store.take_down_plugin(plugin.id, reason).await?;
158
159    let message = format!("Plugin '{}' has been taken down", name);
160    state
161        .store
162        .record_audit_event(
163            Uuid::nil(),
164            Some(user_uuid),
165            AuditEventType::PluginTakenDown,
166            message.clone(),
167            Some(serde_json::json!({
168                "plugin_name": name,
169                "reason": reason,
170            })),
171            None,
172            None,
173        )
174        .await;
175
176    Ok(Json(TakedownPluginResponse {
177        success: true,
178        plugin_name: name,
179        taken_down: true,
180        taken_down_at: Some(Utc::now().to_rfc3339()),
181        reason: reason.map(str::to_string),
182        message,
183    }))
184}
185
186/// Reverse a takedown — clears both the timestamp and the stored reason.
187pub async fn restore_plugin(
188    State(state): State<AppState>,
189    Path(name): Path<String>,
190    Extension(user_id): Extension<String>,
191) -> ApiResult<Json<TakedownPluginResponse>> {
192    let pool = state.db.pool();
193
194    let user_uuid = Uuid::parse_str(&user_id)
195        .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
196
197    let user = sqlx::query_as::<_, (bool,)>("SELECT is_admin FROM users WHERE id = $1")
198        .bind(user_uuid)
199        .fetch_one(pool)
200        .await
201        .map_err(ApiError::Database)?;
202    if !user.0 {
203        return Err(ApiError::PermissionDenied);
204    }
205
206    let plugin = Plugin::find_by_name(pool, &name)
207        .await
208        .map_err(ApiError::Database)?
209        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
210
211    state.store.restore_plugin(plugin.id).await?;
212
213    let message = format!("Plugin '{}' has been restored", name);
214    state
215        .store
216        .record_audit_event(
217            Uuid::nil(),
218            Some(user_uuid),
219            AuditEventType::PluginRestored,
220            message.clone(),
221            Some(serde_json::json!({ "plugin_name": name })),
222            None,
223            None,
224        )
225        .await;
226
227    Ok(Json(TakedownPluginResponse {
228        success: true,
229        plugin_name: name,
230        taken_down: false,
231        taken_down_at: None,
232        reason: None,
233        message,
234    }))
235}
236
237#[derive(Debug, Serialize)]
238#[serde(rename_all = "camelCase")]
239pub struct TakenDownPluginEntry {
240    pub name: String,
241    pub description: String,
242    pub category: String,
243    pub current_version: String,
244    pub author: TakenDownAuthorInfo,
245    pub taken_down_at: String,
246    pub reason: Option<String>,
247}
248
249#[derive(Debug, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct TakenDownAuthorInfo {
252    pub id: String,
253    pub username: String,
254    pub email: Option<String>,
255}
256
257#[derive(Debug, Serialize)]
258#[serde(rename_all = "camelCase")]
259pub struct ListTakenDownResponse {
260    pub plugins: Vec<TakenDownPluginEntry>,
261    pub total: usize,
262}
263
264/// Admin moderation: list every plugin that's currently taken-down.
265/// `Plugin::search` filters these out, so this is the only programmatic
266/// path for the moderation UI to find them after the post-takedown
267/// snackbar window closes.
268pub async fn list_taken_down_plugins(
269    State(state): State<AppState>,
270    Extension(user_id): Extension<String>,
271) -> ApiResult<Json<ListTakenDownResponse>> {
272    let pool = state.db.pool();
273
274    let user_uuid = Uuid::parse_str(&user_id)
275        .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
276
277    let user = sqlx::query_as::<_, (bool,)>("SELECT is_admin FROM users WHERE id = $1")
278        .bind(user_uuid)
279        .fetch_one(pool)
280        .await
281        .map_err(ApiError::Database)?;
282    if !user.0 {
283        return Err(ApiError::PermissionDenied);
284    }
285
286    let plugins = state.store.list_taken_down_plugins().await?;
287    let mut entries = Vec::with_capacity(plugins.len());
288    for plugin in plugins {
289        let author = state
290            .store
291            .find_user_by_id(plugin.author_id)
292            .await?
293            .unwrap_or_else(|| crate::models::User::placeholder(plugin.author_id));
294        entries.push(TakenDownPluginEntry {
295            name: plugin.name,
296            description: plugin.description,
297            category: plugin.category,
298            current_version: plugin.current_version,
299            author: TakenDownAuthorInfo {
300                id: author.id.to_string(),
301                username: author.username,
302                email: Some(author.email),
303            },
304            // taken_down_at is guaranteed Some by the SQL filter, but
305            // keep a defensive fallback so a clock-skew or column
306            // regression can't crash the page.
307            taken_down_at: plugin.taken_down_at.map(|dt| dt.to_rfc3339()).unwrap_or_default(),
308            reason: plugin.taken_down_reason,
309        });
310    }
311
312    let total = entries.len();
313    Ok(Json(ListTakenDownResponse {
314        plugins: entries,
315        total,
316    }))
317}
318
319#[derive(Debug, Serialize)]
320pub struct PluginWithBadges {
321    pub name: String,
322    pub version: String,
323    pub badges: Vec<String>,
324}
325
326pub async fn get_plugin_badges(
327    State(state): State<AppState>,
328    Path(name): Path<String>,
329) -> ApiResult<Json<PluginWithBadges>> {
330    let pool = state.db.pool();
331
332    // Get plugin
333    let plugin = Plugin::find_by_name(pool, &name)
334        .await
335        .map_err(ApiError::Database)?
336        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
337
338    let mut badges = Vec::new();
339
340    // Check for "Official" badge (created by admin user)
341    // ADMIN_USER_ID: UUID of the admin user for official plugins
342    // Default: "00000000-0000-0000-0000-000000000001"
343    let admin_id = std::env::var("ADMIN_USER_ID")
344        .ok()
345        .and_then(|s| Uuid::parse_str(&s).ok())
346        .unwrap_or_else(|| {
347            Uuid::parse_str("00000000-0000-0000-0000-000000000001")
348                .expect("default admin UUID is valid")
349        });
350    if plugin.author_id == admin_id {
351        badges.push("official".to_string());
352    }
353
354    // Check for "Verified" badge
355    if plugin.verified_at.is_some() {
356        badges.push("verified".to_string());
357    }
358
359    // Check for "Popular" badge (1000+ downloads)
360    if plugin.downloads_total >= 1000 {
361        badges.push("popular".to_string());
362    }
363
364    // Check for "Highly Rated" badge (4.5+ stars with 10+ reviews)
365    if plugin.rating_avg >= rust_decimal::Decimal::new(45, 1) && plugin.rating_count >= 10 {
366        badges.push("highly-rated".to_string());
367    }
368
369    // Check for "Maintained" badge (updated within last 90 days)
370    let ninety_days_ago = Utc::now() - chrono::Duration::days(90);
371    if plugin.updated_at > ninety_days_ago {
372        badges.push("maintained".to_string());
373    }
374
375    // Check for "Trending" badge (check downloads in last week)
376    // For MVP, we'll use a simple heuristic
377    if plugin.downloads_total > 100 {
378        badges.push("trending".to_string());
379    }
380
381    // "Signed" badge — set when the plugin's current version was
382    // published with a verified Ed25519 SBOM attestation. The scanner
383    // also surfaces this inside security findings, but the badge here
384    // makes it visible at the marketplace card level so users can spot
385    // signed plugins without opening the security tab.
386    let signed: Option<bool> = sqlx::query_scalar(
387        r#"
388        SELECT (sbom_signed_key_id IS NOT NULL) AS signed
389        FROM plugin_versions
390        WHERE plugin_id = $1 AND version = $2
391        LIMIT 1
392        "#,
393    )
394    .bind(plugin.id)
395    .bind(&plugin.current_version)
396    .fetch_optional(pool)
397    .await
398    .map_err(ApiError::Database)?;
399    if matches!(signed, Some(true)) {
400        badges.push("signed".to_string());
401    }
402
403    Ok(Json(PluginWithBadges {
404        name: plugin.name,
405        version: plugin.current_version,
406        badges,
407    }))
408}
409
410#[derive(Debug, Serialize)]
411#[serde(rename_all = "camelCase")]
412pub struct StatsResponse {
413    pub total_plugins: i64,
414    pub total_downloads: i64,
415    pub total_users: i64,
416    pub verified_plugins: i64,
417    pub total_reviews: i64,
418    pub average_rating: f64,
419}
420
421pub async fn get_admin_stats(
422    State(state): State<AppState>,
423    Extension(user_id): Extension<String>,
424) -> ApiResult<Json<StatsResponse>> {
425    let pool = state.db.pool();
426
427    // Parse user_id
428    let user_uuid = Uuid::parse_str(&user_id)
429        .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
430
431    // Check if user is admin
432    let user = sqlx::query_as::<_, (bool,)>("SELECT is_admin FROM users WHERE id = $1")
433        .bind(user_uuid)
434        .fetch_one(pool)
435        .await
436        .map_err(ApiError::Database)?;
437
438    if !user.0 {
439        return Err(ApiError::PermissionDenied);
440    }
441
442    // Get stats
443    let plugin_stats = sqlx::query_as::<_, (i64, i64, i64)>(
444        "SELECT COUNT(*), SUM(downloads_total), COUNT(*) FILTER (WHERE verified_at IS NOT NULL) FROM plugins"
445    )
446    .fetch_one(pool)
447    .await
448    .map_err(ApiError::Database)?;
449
450    let user_count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users")
451        .fetch_one(pool)
452        .await
453        .map_err(ApiError::Database)?;
454
455    let review_stats = sqlx::query_as::<_, (i64, f64)>(
456        "SELECT COUNT(*), COALESCE(AVG(rating), 0.0)::float8 FROM reviews",
457    )
458    .fetch_one(pool)
459    .await
460    .map_err(ApiError::Database)?;
461
462    Ok(Json(StatsResponse {
463        total_plugins: plugin_stats.0,
464        total_downloads: plugin_stats.1,
465        verified_plugins: plugin_stats.2,
466        total_users: user_count.0,
467        total_reviews: review_stats.0,
468        average_rating: review_stats.1,
469    }))
470}