1use 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 #[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 let user_uuid = Uuid::parse_str(&user_id)
62 .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
63
64 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 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 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 state
103 .store
104 .record_audit_event(
105 Uuid::nil(),
106 Some(user_uuid),
107 AuditEventType::AdminImpersonation, 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
128pub 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
186pub 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
264pub 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: 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 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 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 if plugin.verified_at.is_some() {
356 badges.push("verified".to_string());
357 }
358
359 if plugin.downloads_total >= 1000 {
361 badges.push("popular".to_string());
362 }
363
364 if plugin.rating_avg >= rust_decimal::Decimal::new(45, 1) && plugin.rating_count >= 10 {
366 badges.push("highly-rated".to_string());
367 }
368
369 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 if plugin.downloads_total > 100 {
378 badges.push("trending".to_string());
379 }
380
381 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 let user_uuid = Uuid::parse_str(&user_id)
429 .map_err(|_| ApiError::InvalidRequest("Invalid user ID".to_string()))?;
430
431 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 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}