diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index c1e5c97..9c7b184 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -74,9 +74,22 @@ export function deleteFile(id: string): Promise { return del(`/api/files/${id}`); } -export function getDownloadUrl(id: string, version: 'signed' | 'original' = 'signed'): string { +export async function downloadFile(id: string, version: 'signed' | 'original' = 'signed', filename?: string): Promise { + const { getToken } = await import('./client'); const base = import.meta.env.VITE_API_URL || ''; - return `${base}/api/files/${id}/download?version=${version}`; + const res = await fetch(`${base}/api/files/${id}/download?version=${version}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!res.ok) throw new Error('Download failed'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || 'download'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } export async function verifyUploadedFile(file: File): Promise { diff --git a/frontend/src/components/verification/CredentialSummary.tsx b/frontend/src/components/verification/CredentialSummary.tsx new file mode 100644 index 0000000..b7910f6 --- /dev/null +++ b/frontend/src/components/verification/CredentialSummary.tsx @@ -0,0 +1,408 @@ +import { Shield, Cpu, User, FileText, Link2, ChevronDown, ChevronRight, AlertTriangle, CheckCircle2, Info, Layers } from 'lucide-react'; +import { useState } from 'react'; + +interface ManifestData { + manifests?: Record; + active_manifest?: string; + validation_status?: Array<{ code: string; explanation?: string }>; + validation_results?: { + activeManifest?: { + success?: Array<{ code: string; explanation?: string }>; + failure?: Array<{ code: string; explanation?: string }>; + informational?: Array<{ code: string; explanation?: string }>; + }; + }; +} + +interface Manifest { + label?: string; + title?: string; + assertions?: Array<{ label: string; data: unknown }>; + ingredients?: Array<{ + title?: string; + format?: string; + relationship?: string; + manifest_data?: { label?: string }; + }>; + signature_info?: { + alg?: string; + issuer?: string; + common_name?: string; + time?: string; + cert_serial_number?: string; + }; + claim_generator_info?: Array<{ name?: string; version?: string }>; + metadata?: Array<{ key: string; value: string }>; +} + +interface CredentialSummaryProps { + manifestData: unknown; + isValid: boolean; +} + +function parseActions(manifest: Manifest) { + const actions: Array<{ action: string; description?: string; softwareAgent?: { name?: string; version?: string }; digitalSourceType?: string }> = []; + for (const assertion of (manifest.assertions || [])) { + if (assertion.label?.includes('c2pa.actions')) { + const data = assertion.data as { actions?: Array> }; + if (data?.actions) { + for (const a of data.actions) { + actions.push(a as typeof actions[0]); + } + } + } + } + return actions; +} + +function parseOmgMetadata(manifest: Manifest): Record { + const meta: Record = {}; + // From manifest metadata array + if (manifest.metadata) { + for (const m of manifest.metadata) { + if (m.key.startsWith('omg:')) { + meta[m.key.replace('omg:', '')] = m.value; + } + } + } + // Also try to parse from action description + const actions = parseActions(manifest); + for (const a of actions) { + if (a.description) { + const parts = a.description.split(' | '); + for (const part of parts) { + const [key, ...valueParts] = part.split(': '); + const value = valueParts.join(': '); + if (key === 'Job') meta.jobNumber = value; + if (key === 'Platform') meta.platform = value; + if (key === 'AI Model') meta.aiModel = value; + if (key === 'Stamped by') meta.stampedBy = value; + if (key === 'Stamped at') meta.stampedAt = value; + } + } + } + return meta; +} + +function getActionLabel(action: string): string { + const labels: Record = { + 'c2pa.created': 'Created', + 'c2pa.opened': 'Opened', + 'c2pa.edited': 'Edited', + 'c2pa.converted': 'Converted asset', + 'c2pa.cropped': 'Cropped', + 'c2pa.resized': 'Resized', + 'c2pa.filtered': 'Filtered', + 'c2pa.color_adjustments': 'Colour adjustments', + 'c2pa.placed': 'Placed', + 'c2pa.published': 'Published', + 'c2pa.transcoded': 'Transcoded', + 'c2pa.unknown': 'Other edits', + }; + return labels[action] || action.replace('c2pa.', '').replace(/_/g, ' '); +} + +function getActionDescription(action: string): string { + const desc: Record = { + 'c2pa.created': 'This asset was created', + 'c2pa.opened': 'Opened a pre-existing file', + 'c2pa.edited': 'The asset was edited', + 'c2pa.converted': 'The format of the asset was changed', + 'c2pa.cropped': 'The asset was cropped', + 'c2pa.resized': 'The asset was resized', + 'c2pa.unknown': 'Made other changes', + }; + return desc[action] || ''; +} + +function getSourceTypeLabel(sourceType: string): string { + if (sourceType.includes('trainedAlgorithmicMedia')) return 'This image was generated with an AI tool.'; + if (sourceType.includes('compositeWithTrainedAlgorithmicMedia')) return 'This image contains AI-generated content.'; + if (sourceType.includes('algorithmicMedia')) return 'This was created by an algorithm.'; + if (sourceType.includes('digitalCapture')) return 'This was captured by a digital device.'; + return ''; +} + +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} + +function SectionHeader({ title, icon: Icon, children, defaultOpen = true }: { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +export function CredentialSummary({ manifestData, isValid }: CredentialSummaryProps) { + const data = manifestData as ManifestData; + if (!data?.manifests || !data.active_manifest) return null; + + const activeManifest = data.manifests[data.active_manifest]; + if (!activeManifest) return null; + + const actions = parseActions(activeManifest); + const omgMeta = parseOmgMetadata(activeManifest); + const sigInfo = activeManifest.signature_info; + const claimGen = activeManifest.claim_generator_info?.[0]; + const ingredients = activeManifest.ingredients || []; + const validationResults = data.validation_results?.activeManifest; + const successCount = validationResults?.success?.length || 0; + const failureCount = validationResults?.failure?.length || 0; + + // Determine content summary + let contentSummary = ''; + for (const a of actions) { + if (a.digitalSourceType) { + contentSummary = getSourceTypeLabel(a.digitalSourceType); + } + } + + return ( +
+ {/* Header bar */} +
+
+ {isValid ? : } +
+
+

+ {activeManifest.title || 'Untitled asset'} +

+

+ {sigInfo?.issuer ? `Issued by ${sigInfo.issuer}` : ''} + {sigInfo?.time ? ` on ${formatDate(sigInfo.time)}` : ''} +

+
+ + {isValid ? 'Valid Credential' : 'Invalid'} + +
+ + {/* Content Summary */} + {contentSummary && ( +
+

Content Summary

+
+ +

{contentSummary}

+
+
+ )} + + {/* OMG Metadata */} + {Object.keys(omgMeta).length > 0 && ( + +
+ {omgMeta.jobNumber && ( + <> + Job Number + {omgMeta.jobNumber} + + )} + {omgMeta.platform && ( + <> + Platform + {omgMeta.platform} + + )} + {omgMeta.aiModel && ( + <> + AI Model + {omgMeta.aiModel} + + )} + {omgMeta.stampedBy && ( + <> + Stamped By + {omgMeta.stampedBy} + + )} + {omgMeta.stampedAt && ( + <> + Stamped At + {formatDate(omgMeta.stampedAt)} + + )} +
+
+ )} + + {/* Process */} + +

+ The app or device used to produce this content recorded the following info: +

+ + {/* Software agent / claim generator */} + {(claimGen || actions[0]?.softwareAgent) && ( +
+

App or device used

+

+ {actions[0]?.softwareAgent?.name || claimGen?.name || 'Unknown'} + {(actions[0]?.softwareAgent?.version || claimGen?.version) && ( + v{actions[0]?.softwareAgent?.version || claimGen?.version} + )} +

+
+ )} + + {/* Actions */} + {actions.length > 0 && ( +
+

Actions

+
+ {actions.map((a, i) => ( +
+
+ +
+
+

{getActionLabel(a.action)}

+

{getActionDescription(a.action)}

+
+
+ ))} +
+
+ )} +
+ + {/* Ingredients */} + {ingredients.length > 0 && ( + +
+ {ingredients.map((ing, i) => ( +
+
+ +
+
+

{ing.title || 'Untitled asset'}

+

+ {ing.format || ''} + {ing.relationship ? ` — ${ing.relationship}` : ''} +

+
+
+ ))} +
+
+ )} + + {/* About this Content Credential */} + +
+ {sigInfo?.issuer && ( +
+

Issued by

+

{sigInfo.issuer}

+
+ )} + {sigInfo?.common_name && ( +
+

Common Name

+

{sigInfo.common_name}

+
+ )} + {sigInfo?.time && ( +
+

Issued on

+

{formatDate(sigInfo.time)}

+
+ )} + {sigInfo?.alg && ( +
+

Algorithm

+

{sigInfo.alg}

+
+ )} + {sigInfo?.cert_serial_number && ( +
+

Certificate Serial

+

{sigInfo.cert_serial_number}

+
+ )} +
+
+ + {/* Validation Results */} + +
+

+ {successCount} passed, {failureCount} {failureCount === 1 ? 'notice' : 'notices'} +

+ {validationResults?.success?.map((s, i) => ( +
+ + {s.explanation || s.code} +
+ ))} + {validationResults?.failure?.map((f, i) => ( +
+ + {f.explanation || f.code} +
+ ))} +
+
+ + {/* Claim Generator */} + +
+ {(activeManifest.claim_generator_info || []).map((cg, i) => ( +
+ {cg.name} + {cg.version && v{cg.version}} +
+ ))} +
+
+ + {/* Provenance Chain */} + {Object.keys(data.manifests).length > 1 && ( + +
+ {Object.values(data.manifests).map((m, i) => ( +
+
+
+

{m.title || m.label}

+

+ {m.signature_info?.issuer || 'Unknown issuer'} + {m.signature_info?.time ? ` — ${formatDate(m.signature_info.time)}` : ''} +

+
+
+ ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/FileDetailPage.tsx b/frontend/src/pages/FileDetailPage.tsx index 5edf99f..8b849fc 100644 --- a/frontend/src/pages/FileDetailPage.tsx +++ b/frontend/src/pages/FileDetailPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getFile, verifyFile, deleteFile, getDownloadUrl, type FileRecord, type VerifyResponse } from '../api/files'; +import { getFile, verifyFile, deleteFile, downloadFile, type FileRecord, type VerifyResponse } from '../api/files'; import { useAuth } from '../contexts/AuthContext'; import { SignDialog } from '../components/files/SignDialog'; +import { CredentialSummary } from '../components/verification/CredentialSummary'; import { ManifestTree, ValidationStatus } from '../components/verification/ManifestTree'; import { formatFileSize, formatDate, getStatusColor, cn } from '../lib/utils'; -import { ArrowLeft, Download, Trash2, Shield, Pen, Loader2 } from 'lucide-react'; +import { ArrowLeft, Download, Trash2, Shield, Pen, Loader2, ChevronDown, ChevronRight } from 'lucide-react'; export function FileDetailPage() { const { id } = useParams<{ id: string }>(); @@ -16,6 +17,8 @@ export function FileDetailPage() { const [verifying, setVerifying] = useState(false); const [verifyResult, setVerifyResult] = useState(null); const [error, setError] = useState(null); + const [showRawData, setShowRawData] = useState(false); + const [downloading, setDownloading] = useState(null); useEffect(() => { if (id) getFile(id).then(setFile).catch(() => setError('File not found')); @@ -36,6 +39,19 @@ export function FileDetailPage() { } }; + const handleDownload = async (version: 'signed' | 'original') => { + if (!file) return; + setDownloading(version); + try { + const filename = version === 'signed' ? `signed_${file.originalFilename}` : file.originalFilename; + await downloadFile(file.id, version, filename); + } catch { + setError('Download failed'); + } finally { + setDownloading(null); + } + }; + const handleDelete = async () => { if (!id || !confirm('Are you sure you want to delete this file?')) return; await deleteFile(id); @@ -62,6 +78,10 @@ export function FileDetailPage() { const isVideo = file.mimeType.startsWith('video/'); const isAudio = file.mimeType.startsWith('audio/'); + // Determine which manifest data to display + const displayManifest = verifyResult?.manifest || file.manifestData; + const displayIsValid = verifyResult ? verifyResult.isValid : (file.c2paStatus === 'signed' || file.c2paStatus === 'verified'); + return (
{/* Header */} @@ -101,21 +121,23 @@ export function FileDetailPage() { Verify {file.signedPath && ( - handleDownload('signed')} + disabled={downloading === 'signed'} + className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50" > - + {downloading === 'signed' ? : } Download Signed - + )} - handleDownload('original')} + disabled={downloading === 'original'} + className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50" > - + {downloading === 'original' ? : } Download Original - + {hasRole('admin') && ( + {showRawData && ( +
+ } /> +
+ )}
) : null} diff --git a/frontend/src/pages/VerifyPage.tsx b/frontend/src/pages/VerifyPage.tsx index c5c033a..e21f70a 100644 --- a/frontend/src/pages/VerifyPage.tsx +++ b/frontend/src/pages/VerifyPage.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { verifyUploadedFile, type VerifyResponse } from '../api/files'; +import { CredentialSummary } from '../components/verification/CredentialSummary'; import { ManifestTree, ValidationStatus } from '../components/verification/ManifestTree'; import { useDropzone } from 'react-dropzone'; -import { Shield, Loader2 } from 'lucide-react'; +import { Shield, Loader2, ChevronDown, ChevronRight } from 'lucide-react'; import { cn } from '../lib/utils'; export function VerifyPage() { @@ -12,6 +13,7 @@ export function VerifyPage() { const [previewUrl, setPreviewUrl] = useState(null); const [previewType, setPreviewType] = useState(''); const [error, setError] = useState(null); + const [showRaw, setShowRaw] = useState(false); const onDrop = async (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) return; @@ -20,8 +22,8 @@ export function VerifyPage() { setVerifying(true); setError(null); setResult(null); + setShowRaw(false); - // Create local preview if (file.type.startsWith('image/')) { setPreviewUrl(URL.createObjectURL(file)); setPreviewType('image'); @@ -88,7 +90,7 @@ export function VerifyPage() { {/* Results */} {result && ( -
+

Results for {fileName}

{result.isValid ? ( @@ -100,23 +102,55 @@ export function VerifyPage() { )}
- {/* File preview */} - {previewUrl && ( -
-

File Preview

- {previewType === 'image' && ( - {fileName} +
+ {/* Left: Preview */} +
+ {previewUrl && ( +
+

File Preview

+ {previewType === 'image' && ( + {fileName} + )} + {previewType === 'video' && ( +
)} - {previewType === 'video' && ( -
+ + {/* Right: Credential Summary */} +
+ {result.manifest ? ( + + ) : ( +
+ +

No C2PA content credentials found in this file.

+
)}
- )} - - +
+ {/* Raw data toggle */} {result.manifest ? ( - } /> +
+ + {showRaw && ( +
+ } /> +
+ )} +
) : null}
)}