diff --git a/src/crates/contracts/product-domains/src/appearance_market.rs b/src/crates/contracts/product-domains/src/appearance_market.rs index a029fb5a0..ec9f4ae39 100644 --- a/src/crates/contracts/product-domains/src/appearance_market.rs +++ b/src/crates/contracts/product-domains/src/appearance_market.rs @@ -24,6 +24,14 @@ pub enum AppearanceMarketSubmissionStatus { Withdrawn, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppearanceMarketPublicationStatus { + Published, + Yanked, + Unpublished, +} + impl AppearanceMarketSubmissionStatus { pub fn can_transition_to(self, next: Self) -> bool { matches!( @@ -157,6 +165,8 @@ pub struct AppearanceMarketSubmission { pub repository_url: Option, pub status: AppearanceMarketSubmissionStatus, #[serde(skip_serializing_if = "Option::is_none")] + pub publication_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub package_sha256: Option, #[serde(skip_serializing_if = "Option::is_none")] pub package_size: Option, @@ -299,6 +309,18 @@ mod tests { ); } + #[test] + fn publication_status_serializes_as_a_stable_api_value() { + assert_eq!( + serde_json::to_string(&AppearanceMarketPublicationStatus::Yanked).unwrap(), + "\"yanked\"" + ); + assert_eq!( + serde_json::to_string(&AppearanceMarketPublicationStatus::Unpublished).unwrap(), + "\"unpublished\"" + ); + } + #[test] fn listing_detail_json_contract_matches_the_shared_typescript_fixture() { let fixture = include_str!( diff --git a/src/crates/services/services-integrations/src/appearance_market/submit.rs b/src/crates/services/services-integrations/src/appearance_market/submit.rs index 7004b9553..8bca78968 100644 --- a/src/crates/services/services-integrations/src/appearance_market/submit.rs +++ b/src/crates/services/services-integrations/src/appearance_market/submit.rs @@ -202,6 +202,7 @@ mod tests { }, repository_url: None, status, + publication_status: None, package_sha256: None, package_size: None, preview_url: None, diff --git a/src/crates/services/skin-market-service/src/lib.rs b/src/crates/services/skin-market-service/src/lib.rs index a27c26256..3b0efa361 100644 --- a/src/crates/services/skin-market-service/src/lib.rs +++ b/src/crates/services/skin-market-service/src/lib.rs @@ -217,6 +217,10 @@ mod tests { } fn appearance_package() -> Vec { + appearance_package_with_id("example.aurora") + } + + fn appearance_package_with_id(package_id: &str) -> Vec { let mut preview_output = Cursor::new(Vec::new()); image::DynamicImage::new_rgba8(8, 6) .write_to(&mut preview_output, image::ImageFormat::Png) @@ -225,7 +229,7 @@ mod tests { let manifest = serde_json::json!({ "schema": "bitfun.appearance", "schemaVersion": 1, - "id": "example.aurora", + "id": package_id, "name": "Aurora", "description": "A safe appearance", "version": "1.0.0", @@ -284,6 +288,75 @@ mod tests { .to_string() } + async fn publish_submission(app: &Router, slug: &str) -> (String, String, String) { + let submission_id = create_submission(app, slug).await; + let mut upload = request( + "PUT", + &format!("/skin/api/v1/submissions/{submission_id}/package"), + Some("owner-token"), + Body::from(appearance_package_with_id(&format!( + "example.{}", + slug.replace('-', ".") + ))), + ); + upload.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static( + bitfun_product_domains::appearance_market::APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + ), + ); + assert_eq!( + app.clone().oneshot(upload).await.unwrap().status(), + StatusCode::OK + ); + assert_eq!( + app.clone() + .oneshot(request( + "POST", + &format!("/skin/api/v1/submissions/{submission_id}/submit"), + Some("owner-token"), + Body::empty(), + )) + .await + .unwrap() + .status(), + StatusCode::OK + ); + let mut approve = request( + "POST", + &format!("/skin/api/v1/admin/submissions/{submission_id}/decision"), + Some("admin-token"), + Body::from(r#"{"decision":"approve","reason":""}"#), + ); + approve.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let response = app.clone().oneshot(approve).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let approved = json_body(response).await; + assert_eq!(approved["submission"]["publicationStatus"], "published"); + let listing_id = approved["submission"]["listingId"] + .as_str() + .unwrap() + .to_string(); + let detail = app + .clone() + .oneshot(request( + "GET", + &format!("/skin/api/v1/listings/{slug}"), + None, + Body::empty(), + )) + .await + .unwrap(); + let release_id = json_body(detail).await["releases"][0]["releaseId"] + .as_str() + .unwrap() + .to_string(); + (submission_id, listing_id, release_id) + } + #[tokio::test] async fn bearer_submission_review_and_public_download_flow() { let temporary = tempfile::tempdir().unwrap(); @@ -661,4 +734,69 @@ mod tests { } assert_eq!(seen.len(), 7); } + + #[tokio::test] + async fn submission_history_projects_release_and_listing_moderation() { + let temporary = tempfile::tempdir().unwrap(); + let app = test_router(&temporary).await; + let (yanked_submission_id, _, release_id) = publish_submission(&app, "yanked-skin").await; + let (unpublished_submission_id, listing_id, _) = + publish_submission(&app, "unpublished-skin").await; + + let mut yank = request( + "POST", + &format!("/skin/api/v1/admin/releases/{release_id}/yank"), + Some("admin-token"), + Body::from(r#"{"reason":"Unsafe package content"}"#), + ); + yank.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + assert_eq!( + app.clone().oneshot(yank).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + + let mut unpublish = request( + "POST", + &format!("/skin/api/v1/admin/listings/{listing_id}/unpublish"), + Some("admin-token"), + Body::from(r#"{"reason":"Policy violation"}"#), + ); + unpublish.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + assert_eq!( + app.clone().oneshot(unpublish).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + + let response = app + .clone() + .oneshot(request( + "GET", + "/skin/api/v1/submissions", + Some("owner-token"), + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let history = json_body(response).await; + let items = history["items"].as_array().unwrap(); + let yanked = items + .iter() + .find(|item| item["submissionId"] == yanked_submission_id) + .unwrap(); + assert_eq!(yanked["status"], "approved"); + assert_eq!(yanked["publicationStatus"], "yanked"); + let unpublished = items + .iter() + .find(|item| item["submissionId"] == unpublished_submission_id) + .unwrap(); + assert_eq!(unpublished["status"], "approved"); + assert_eq!(unpublished["publicationStatus"], "unpublished"); + } } diff --git a/src/crates/services/skin-market-service/src/routes.rs b/src/crates/services/skin-market-service/src/routes.rs index e1979e37c..1385d7831 100644 --- a/src/crates/services/skin-market-service/src/routes.rs +++ b/src/crates/services/skin-market-service/src/routes.rs @@ -15,12 +15,13 @@ use base64::Engine; use bitfun_product_domains::appearance_market::{ compute_appearance_review_bundle_hash, validate_appearance_market_slug, AppearanceAdminSubmissionDetail, AppearanceCursorPage, AppearanceMarketListingDetail, - AppearanceMarketListingSummary, AppearanceMarketPackageMeta, AppearanceMarketRelease, - AppearanceMarketSort, AppearanceMarketSubmission, AppearanceMarketSubmissionDraftRequest, - AppearanceMarketSubmissionStatus, AppearanceMarketUserSummary, AppearanceReviewDecision, - AppearanceReviewDecisionRequest, APPEARANCE_MARKET_API_VERSION, - APPEARANCE_MARKET_DEFAULT_PAGE_SIZE, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, - APPEARANCE_MARKET_MAX_PAGE_SIZE, APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + AppearanceMarketListingSummary, AppearanceMarketPackageMeta, AppearanceMarketPublicationStatus, + AppearanceMarketRelease, AppearanceMarketSort, AppearanceMarketSubmission, + AppearanceMarketSubmissionDraftRequest, AppearanceMarketSubmissionStatus, + AppearanceMarketUserSummary, AppearanceReviewDecision, AppearanceReviewDecisionRequest, + APPEARANCE_MARKET_API_VERSION, APPEARANCE_MARKET_DEFAULT_PAGE_SIZE, + APPEARANCE_MARKET_MAX_PACKAGE_BYTES, APPEARANCE_MARKET_MAX_PAGE_SIZE, + APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, }; use chrono::Utc; use hmac::{Hmac, Mac}; @@ -1088,7 +1089,7 @@ async fn yank_release( .begin() .await .map_err(SkinMarketError::internal)?; - let row = sqlx::query("SELECT listing_id, yanked_at FROM releases WHERE id = ?") + let row = sqlx::query("SELECT listing_id, submission_id, yanked_at FROM releases WHERE id = ?") .bind(&release_id) .fetch_optional(&mut *transaction) .await @@ -1105,8 +1106,10 @@ async fn yank_release( )); } let listing_id: String = row.get("listing_id"); + let submission_id: String = row.get("submission_id"); + let now = Utc::now().timestamp(); sqlx::query("UPDATE releases SET yanked_at = ?, yank_reason = ? WHERE id = ?") - .bind(Utc::now().timestamp()) + .bind(now) .bind(reason) .bind(&release_id) .execute(&mut *transaction) @@ -1126,11 +1129,17 @@ async fn yank_release( ) .bind(&replacement) .bind(i64::from(replacement.is_some())) - .bind(Utc::now().timestamp()) + .bind(now) .bind(&listing_id) .execute(&mut *transaction) .await .map_err(SkinMarketError::internal)?; + sqlx::query("UPDATE submissions SET updated_at = ? WHERE id = ? AND status = 'approved'") + .bind(now) + .bind(&submission_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; insert_audit( &mut *transaction, admin.user.internal_id, @@ -1164,11 +1173,12 @@ async fn unpublish_listing( .begin() .await .map_err(SkinMarketError::internal)?; + let now = Utc::now().timestamp(); let updated = sqlx::query( "UPDATE listings SET is_published = 0, updated_at = ? WHERE id = ? AND is_published = 1", ) - .bind(Utc::now().timestamp()) + .bind(now) .bind(&listing_id) .execute(&mut *transaction) .await @@ -1178,6 +1188,15 @@ async fn unpublish_listing( "Published appearance listing was not found.", )); } + sqlx::query( + "UPDATE submissions SET updated_at = ? + WHERE listing_id = ? AND status = 'approved'", + ) + .bind(now) + .bind(&listing_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; insert_audit( &mut *transaction, admin.user.internal_id, @@ -1313,29 +1332,34 @@ async fn list_submissions( .clamp(1, APPEARANCE_MARKET_MAX_PAGE_SIZE); let cursor = decode_submission_cursor(cursor)?; let mut builder = QueryBuilder::::new( - "SELECT id, listing_id, owner_user_id, slug, release_number, draft_json, - package_meta_json, status, package_sha256, package_size, preview_sha256, - rejection_reason, created_at, updated_at - FROM submissions WHERE 1 = 1", + "SELECT s.id, s.listing_id, s.owner_user_id, s.slug, s.release_number, s.draft_json, + s.package_meta_json, s.status, s.package_sha256, s.package_size, + s.preview_sha256, s.rejection_reason, s.created_at, s.updated_at, + l.is_published AS listing_is_published, + r.yanked_at AS release_yanked_at + FROM submissions s + LEFT JOIN listings l ON l.id = s.listing_id + LEFT JOIN releases r ON r.submission_id = s.id + WHERE 1 = 1", ); if let Some(owner_user_id) = owner_user_id { - builder.push(" AND owner_user_id = "); + builder.push(" AND s.owner_user_id = "); builder.push_bind(owner_user_id); } if let Some(status) = status { - builder.push(" AND status = "); + builder.push(" AND s.status = "); builder.push_bind(status_string(status)); } if let Some((updated_at, submission_id)) = cursor { - builder.push(" AND (updated_at < "); + builder.push(" AND (s.updated_at < "); builder.push_bind(updated_at); - builder.push(" OR (updated_at = "); + builder.push(" OR (s.updated_at = "); builder.push_bind(updated_at); - builder.push(" AND id < "); + builder.push(" AND s.id < "); builder.push_bind(submission_id); builder.push("))"); } - builder.push(" ORDER BY updated_at DESC, id DESC LIMIT "); + builder.push(" ORDER BY s.updated_at DESC, s.id DESC LIMIT "); builder.push_bind(i64::from(limit + 1)); let rows = builder .build() @@ -1363,14 +1387,19 @@ async fn submission_by_id( owner_user_id: Option, ) -> SkinMarketResult { let mut builder = QueryBuilder::::new( - "SELECT id, listing_id, owner_user_id, slug, release_number, draft_json, - package_meta_json, status, package_sha256, package_size, preview_sha256, - rejection_reason, created_at, updated_at - FROM submissions WHERE id = ", + "SELECT s.id, s.listing_id, s.owner_user_id, s.slug, s.release_number, s.draft_json, + s.package_meta_json, s.status, s.package_sha256, s.package_size, + s.preview_sha256, s.rejection_reason, s.created_at, s.updated_at, + l.is_published AS listing_is_published, + r.yanked_at AS release_yanked_at + FROM submissions s + LEFT JOIN listings l ON l.id = s.listing_id + LEFT JOIN releases r ON r.submission_id = s.id + WHERE s.id = ", ); builder.push_bind(submission_id); if let Some(owner_user_id) = owner_user_id { - builder.push(" AND owner_user_id = "); + builder.push(" AND s.owner_user_id = "); builder.push_bind(owner_user_id); } let row = builder @@ -1396,6 +1425,24 @@ fn submission_from_row( let preview_sha256 = row .try_get::, _>("preview_sha256") .map_err(SkinMarketError::internal)?; + let status = parse_submission_status(&row.get::("status"))?; + let publication_status = if status == AppearanceMarketSubmissionStatus::Approved { + let release_yanked_at = row + .try_get::, _>("release_yanked_at") + .map_err(SkinMarketError::internal)?; + let listing_is_published = row + .try_get::, _>("listing_is_published") + .map_err(SkinMarketError::internal)?; + Some(if release_yanked_at.is_some() { + AppearanceMarketPublicationStatus::Yanked + } else if listing_is_published == Some(1) { + AppearanceMarketPublicationStatus::Published + } else { + AppearanceMarketPublicationStatus::Unpublished + }) + } else { + None + }; Ok(AppearanceMarketSubmission { submission_id: row.get("id"), listing_id: row @@ -1416,7 +1463,8 @@ fn submission_from_row( changelog: draft.changelog, license: draft.license, repository_url: draft.repository_url, - status: parse_submission_status(&row.get::("status"))?, + status, + publication_status, package_sha256: row .try_get("package_sha256") .map_err(SkinMarketError::internal)?, diff --git a/src/skin-market-web/src/App.tsx b/src/skin-market-web/src/App.tsx index 097fefb0e..00c6a8f3a 100644 --- a/src/skin-market-web/src/App.tsx +++ b/src/skin-market-web/src/App.tsx @@ -220,6 +220,7 @@ export default function App() { ) : route.kind === 'detail' && route.slug ? ( ({ + detail: vi.fn(), + yankRelease: vi.fn(), + unpublishListing: vi.fn(), +})); + +vi.mock('./api', async (importOriginal) => ({ + ...await importOriginal(), + skinMarketApi: { + detail: mocks.detail, + yankRelease: mocks.yankRelease, + unpublishListing: mocks.unpublishListing, + }, +})); + +vi.mock('./PosterImage', () => ({ + PosterImage: ({ alt }: { alt: string }) =>
, +})); + +const detail: AppearanceListingDetail = { + ...listingFixture, + mode: 'dark', +}; + +const t = ((key: string) => key) as Translate; + +describe('DetailPage moderation', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.detail.mockReset().mockResolvedValue(detail); + mocks.yankRelease.mockReset().mockResolvedValue(undefined); + mocks.unpublishListing.mockReset().mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('lets an admin permanently remove a release after supplying a reason', async () => { + const onNavigate = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + const yankButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('yankRelease')); + const unpublishButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('unpublishListing')); + expect(yankButton?.disabled).toBe(true); + expect(unpublishButton).toBeDefined(); + + const reason = container.querySelector('#moderation-reason'); + await act(async () => { + if (!reason) throw new Error('moderation reason missing'); + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + valueSetter?.call(reason, 'Unsafe package content'); + reason.dispatchEvent(new Event('input', { bubbles: true })); + }); + + expect(yankButton?.disabled).toBe(false); + await act(async () => yankButton?.click()); + + await vi.waitFor(() => expect(mocks.yankRelease).toHaveBeenCalledWith( + 'appearance-release-fixture-2', + 'Unsafe package content', + )); + expect(window.confirm).toHaveBeenCalledWith('yankConfirm'); + expect(onNavigate).toHaveBeenCalledWith('/skin/'); + }); + + it('lets an admin unpublish the whole Skin listing after confirmation', async () => { + const onNavigate = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + const reason = container.querySelector('#moderation-reason'); + await act(async () => { + if (!reason) throw new Error('moderation reason missing'); + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + valueSetter?.call(reason, 'Repeated policy violations'); + reason.dispatchEvent(new Event('input', { bubbles: true })); + }); + + const unpublishButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('unpublishListing')); + expect(unpublishButton?.disabled).toBe(false); + await act(async () => unpublishButton?.click()); + + await vi.waitFor(() => expect(mocks.unpublishListing).toHaveBeenCalledWith( + 'appearance-listing-fixture-1', + 'Repeated policy violations', + )); + expect(window.confirm).toHaveBeenCalledWith('unpublishConfirm'); + expect(onNavigate).toHaveBeenCalledWith('/skin/?q=ocean'); + }); + + it('does not render moderation controls for a regular visitor', async () => { + await act(async () => { + root.render( + undefined} + slug="ocean-night" + t={t} + />, + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + expect(container.querySelector('#moderation-reason')).toBeNull(); + expect(container.textContent).not.toContain('yankRelease'); + expect(container.textContent).not.toContain('unpublishListing'); + }); +}); diff --git a/src/skin-market-web/src/DetailPage.tsx b/src/skin-market-web/src/DetailPage.tsx index f31fd1f28..203f4b95b 100644 --- a/src/skin-market-web/src/DetailPage.tsx +++ b/src/skin-market-web/src/DetailPage.tsx @@ -4,7 +4,9 @@ import { CheckCircle, Desktop, DownloadSimple, + Prohibit, ShieldCheck, + WarningCircle, } from '@phosphor-icons/react'; import { useEffect, useMemo, useState } from 'react'; import { downloadUrl, skinMarketApi, SkinMarketApiError } from './api'; @@ -24,6 +26,7 @@ import type { interface DetailPageProps { catalogSearch: string; + isAdmin: boolean; locale: Locale; onNavigate: (path: string) => void; slug: string; @@ -34,11 +37,14 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError'; } -export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: DetailPageProps) { +export function DetailPage({ catalogSearch, isAdmin, locale, onNavigate, slug, t }: DetailPageProps) { const [detail, setDetail] = useState(); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [retryKey, setRetryKey] = useState(0); + const [moderationReason, setModerationReason] = useState(''); + const [moderationBusy, setModerationBusy] = useState(); + const [moderationError, setModerationError] = useState(); const catalogPath = `/skin/${catalogSearch}`; useEffect(() => { @@ -107,6 +113,42 @@ export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: Detai const modeLabel = detail.mode === 'light' ? t('lightMode') : t('darkMode'); const customLicenseUrl = safeExternalUrl(detail.license.customUrl); const repositoryUrl = safeExternalUrl(detail.repositoryUrl); + const trimmedModerationReason = moderationReason.trim(); + + const yankRelease = async (release: AppearanceMarketRelease) => { + if (!trimmedModerationReason || moderationBusy) return; + if (!window.confirm(t('yankConfirm', { version: release.packageVersion }))) return; + setModerationBusy(release.releaseId); + setModerationError(undefined); + try { + await skinMarketApi.yankRelease(release.releaseId, trimmedModerationReason); + setModerationReason(''); + const remainingReleaseCount = detail.releases.filter((item) => ( + !item.yanked && item.releaseId !== release.releaseId + )).length; + if (remainingReleaseCount === 0) onNavigate(catalogPath); + else setRetryKey((value) => value + 1); + } catch (actionError) { + setModerationError(toError(actionError)); + } finally { + setModerationBusy(undefined); + } + }; + + const unpublishListing = async () => { + if (!trimmedModerationReason || moderationBusy) return; + if (!window.confirm(t('unpublishConfirm', { name: detail.name }))) return; + setModerationBusy(detail.listingId); + setModerationError(undefined); + try { + await skinMarketApi.unpublishListing(detail.listingId, trimmedModerationReason); + onNavigate(catalogPath); + } catch (actionError) { + setModerationError(toError(actionError)); + } finally { + setModerationBusy(undefined); + } + }; return (
@@ -166,7 +208,56 @@ export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: Detai

{detail.changelog || t('notDeclared')}

- + + + {isAdmin ? ( +
+
+
+