diff --git a/.husky/post-checkout b/.husky/post-checkout index 6d1018135..5abf8ed93 100755 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1,3 +1,3 @@ #!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-checkout "$@" \ No newline at end of file +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/post-commit b/.husky/post-commit index 323aa604d..b8b76c2c4 100755 --- a/.husky/post-commit +++ b/.husky/post-commit @@ -1,3 +1,3 @@ #!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-commit "$@" \ No newline at end of file +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/post-merge b/.husky/post-merge index 25befc269..726f90989 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1,3 +1,3 @@ #!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs post-merge "$@" \ No newline at end of file +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/pre-push b/.husky/pre-push index 1b9474078..5f26dc455 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,3 @@ #!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } -git lfs pre-push "$@" \ No newline at end of file +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/e2e/c2pa-migration-test.spec.ts b/e2e/c2pa-migration-test.spec.ts index 629e79bbb..fb3eed5be 100644 --- a/e2e/c2pa-migration-test.spec.ts +++ b/e2e/c2pa-migration-test.spec.ts @@ -50,7 +50,7 @@ test.describe('c2pa-web SDK migration — trust badge rendering', () => { await page.goto(`/?source=${encodeURIComponent(`${FIXTURES_BASE}/CAICAI.jpg`)}`); - await expect(page.getByText('Content Credentials', { exact: false })).toBeVisible({ + await expect(page.getByText('Content Credentials', { exact: true })).toBeVisible({ timeout: 20000, }); @@ -63,7 +63,7 @@ test.describe('c2pa-web SDK migration — trust badge rendering', () => { await page.goto(`/?source=${encodeURIComponent(`${FIXTURES_BASE}/CAICAI.jpg`)}`); - await expect(page.getByText('Content Credentials', { exact: false })).toBeVisible({ + await expect(page.getByText('Content Credentials', { exact: true })).toBeVisible({ timeout: 20000, }); @@ -90,7 +90,7 @@ test.describe('c2pa-web SDK migration — trust badge rendering', () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await page.setInputFiles('input[type="file"]', legacyImagePath!); - await expect(page.getByText('Content Credentials', { exact: false })).toBeVisible({ + await expect(page.getByText('Content Credentials', { exact: true })).toBeVisible({ timeout: 20000, }); diff --git a/e2e/fixtures/cors_server.py b/e2e/fixtures/cors_server.py new file mode 100644 index 000000000..2e2df1b1b --- /dev/null +++ b/e2e/fixtures/cors_server.py @@ -0,0 +1,14 @@ +import sys +import socketserver +from http.server import SimpleHTTPRequestHandler + +class CORS(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Access-Control-Allow-Origin', '*') + super().end_headers() + +if __name__ == '__main__': + port = int(sys.argv[1]) + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(('127.0.0.1', port), CORS) as httpd: + httpd.serve_forever() diff --git a/playwright.config.ts b/playwright.config.ts index 8abcfb17a..bff08269d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,7 @@ import type { PlaywrightTestConfig } from '@playwright/test'; import testImageConfig from './e2e/c2pa-test-image-service.config'; + export const port = parseInt( (process.env.HOST_PORT as string | undefined) ?? '4173', 10, @@ -16,6 +17,7 @@ const baseURL = `${base ?? `http://localhost`}:${port}/`; const config: PlaywrightTestConfig = { testDir: 'e2e', + testIgnore: '**/snapshot/**', retries: process.env.CI ? 1 : 0, forbidOnly: !!process.env.CI, use: { @@ -28,17 +30,18 @@ const config: PlaywrightTestConfig = { }, webServer: [ { - command: `pnpm dev --port=${port}`, + command: `npx vite preview --port=${port}`, port, reuseExistingServer: !process.env.CI, }, { - command: `pnpm http-server e2e/fixtures --port=${fixturesPort} --cors --gzip`, + command: `python3 cors_server.py ${fixturesPort}`, + cwd: 'e2e/fixtures', port: fixturesPort, reuseExistingServer: !process.env.CI, }, { - command: `pnpm run test-image-service`, + command: `./node_modules/.bin/c2pa-test-image-service --config e2e/c2pa-test-image-service.config.ts`, port: testImageConfig.port, reuseExistingServer: !process.env.CI, }, diff --git a/src/components/SocialMediaInfo/SocialMediaInfo.svelte b/src/components/SocialMediaInfo/SocialMediaInfo.svelte deleted file mode 100644 index 253198303..000000000 --- a/src/components/SocialMediaInfo/SocialMediaInfo.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - -
- - {#if link} - - {username} - - {:else} - {username} - {/if} - -
- {appName} -
-
diff --git a/src/lib/asset.ts b/src/lib/asset.ts index c3d10613a..103c0c6dd 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -5,11 +5,19 @@ import type { Manifest, ManifestStore, ResourceRef as Thumbnail, + StatusCodes, } from '@contentauth/c2pa-web'; +import { type ValidationStatus } from './selectors/validationResult'; + +interface ExtendedIngredient extends Ingredient { + activeManifest?: string; + validationStatus?: ValidationStatus[]; + validationResults?: { activeManifest?: StatusCodes }; + trustSource?: string; +} import { selectDoNotTrain } from './selectors/doNotTrain'; import { selectEditsAndActivity, type TranslatedDictionaryCategory } from './selectors/editsAndActivity'; -import { selectProducer } from './selectors/producer'; -import { selectSocialAccounts } from './selectors/socialAccounts'; + import debug from 'debug'; import { selectExif } from './exif'; import { @@ -75,11 +83,9 @@ export type ManifestData = { exif: ReturnType; label: string | null; generativeInfo: GenerativeInfo | null; - producer: string | null; reviewRatings: ReturnType; signatureInfo: Manifest['signature_info']; doNotTrain: ReturnType; - socialAccounts: ReturnType; web3Accounts: [string, string[]][]; website: string | null; autoDubInfo: AutoDubInfo | null; @@ -134,6 +140,7 @@ export async function resultToAssetMap({ const disposers: (() => void)[] = []; const activeManifestLabel = manifestStore?.active_manifest ?? ''; + const allLabels = Object.keys(manifestStore?.manifests ?? {}); const runtimeValidationStatuses = manifestStore?.validation_status ? validationStatusByManifestLabel( @@ -146,7 +153,7 @@ export async function resultToAssetMap({ dbg('Runtime validation statuses by manifest label', runtimeValidationStatuses); const activeManifestValidationResults = - manifestStore?.validation_results?.activeManifest ?? undefined; + manifestStore?.validation_results?.activeManifest || undefined; const rootValidationStatuses = runtimeValidationStatuses[activeManifestLabel] ?? []; @@ -260,7 +267,7 @@ export async function resultToAssetMap({ manifestData: await getManifestData(manifest, rootValidationResult), dataType: null, validationResult: rootValidationResult, - trustSource: ((manifest as Manifest & { trust_source?: 'legacy' | 'none' | 'official' }).trust_source || 'none') as 'legacy' | 'none' | 'official', + trustSource: ((manifest as Manifest & { trust_source?: string }).trust_source || 'none') as 'legacy' | 'none' | 'official', }; if (thumbnail?.dispose) { @@ -278,9 +285,11 @@ export async function resultToAssetMap({ runtimeValidationStatuses: ManifestLabelValidationStatusMap, id: string, ): Promise { - const ingredientManifestLabel = ingredient.active_manifest; + const ingredientManifestLabel = ingredient.active_manifest || (ingredient as ExtendedIngredient).activeManifest; const ingredientManifest = ingredientManifestLabel ? manifestStore.manifests?.[ingredientManifestLabel] : null; + + // 0.17.x SDK dropped internal thumbnail generation. Skip WASM fetch for ingredients. const thumbnail = await loadThumbnail( ingredient.thumbnail?.format, @@ -288,10 +297,11 @@ export async function resultToAssetMap({ ); const activeManifestValidationResults = - ingredient.validation_results?.activeManifest ?? undefined; + (ingredient.validation_results?.activeManifest || (ingredient as ExtendedIngredient).validationResults?.activeManifest) ?? undefined; + const validationStatus = ingredient.validation_status || (ingredient as ExtendedIngredient).validationStatus || []; let validationResult = selectValidationResult( - ingredient.validation_status || [], + validationStatus, activeManifestValidationResults, ); @@ -318,7 +328,7 @@ export async function resultToAssetMap({ manifestData: await getManifestData(ingredientManifest, validationResult), dataType: getIngredientDataType(ingredient), validationResult, - trustSource: ((ingredient as Ingredient & { trust_source?: 'legacy' | 'none' | 'official' })?.trust_source || 'none') as 'legacy' | 'none' | 'official', + trustSource: ((ingredient as ExtendedIngredient)?.trust_source || (ingredient as ExtendedIngredient)?.trustSource || 'none') as 'legacy' | 'none' | 'official', }; if (thumbnail?.dispose) { @@ -338,16 +348,22 @@ export async function resultToAssetMap({ return null; } - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - function formattedGeneratorInfo(claim_generator: any): any { - const version = claim_generator?.version; - claim_generator.version = version?.replace(/\([^()]*\)/g, ''); + interface GeneratorInfoShape { + name?: string; + version?: string | null; + icon?: string | null; + } + + function formattedGeneratorInfo(claim_generator: GeneratorInfoShape) { + const cloned = { ...claim_generator }; + const version = cloned?.version; + cloned.version = version ? version.replace(/\([^()]*\)/g, '') : null; - return claim_generator; + return cloned; } const claimGeneratorInfo = manifest?.claim_generator_info?.[0] - ? formattedGeneratorInfo(manifest.claim_generator_info[0]) + ? formattedGeneratorInfo(manifest.claim_generator_info[0] as GeneratorInfoShape) : null; const claimGeneratorLabel = @@ -358,9 +374,10 @@ export async function resultToAssetMap({ const claimGenerator: ClaimGeneratorDisplayInfo = { label: claimGeneratorLabel, - icon: claimGeneratorInfo?.icon ?? null, + icon: (claimGeneratorInfo?.icon ? { identifier: claimGeneratorInfo.icon } : null) as unknown as Thumbnail | null, }; + // Extract Organization (O) from the native X.509 certificate subject tree const safeSignatureInfo = manifest.signature_info ? { ...manifest.signature_info } : null; @@ -375,7 +392,6 @@ export async function resultToAssetMap({ : null, claimGenerator, signatureInfo: safeSignatureInfo, - producer: selectProducer(manifest)?.name ?? null, editsAndActivityForLocale: async (locale) => { const editsAndActivity = await selectEditsAndActivity( manifest, @@ -383,25 +399,17 @@ export async function resultToAssetMap({ ); if (editsAndActivity) { - const assertions = manifest.assertions; - - let actionsAssertion: unknown; - - if (Array.isArray(assertions)) { - actionsAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'c2pa.actions' - ); - } else if (assertions && typeof assertions === 'object') { - actionsAssertion = (assertions as Record)['c2pa.actions']; + interface InferenceAssertion { + data?: { + metadata?: { + 'com.adobe.inference'?: unknown; + }; + }; } - - const hasInference = - !!(actionsAssertion as { data?: { metadata?: Record } })?.data?.metadata?.['com.adobe.inference']; + const assertionsArr = (manifest.assertions || []) as unknown[]; + type AssItem = { label?: string; data?: unknown }; + const actionsAss = assertionsArr.find((a: unknown) => (a as AssItem).label === 'c2pa.actions' || (a as AssItem).label === 'c2pa.actions.v2') as InferenceAssertion | undefined; + const hasInference = !!actionsAss?.data?.metadata?.['com.adobe.inference']; const filteredEditsAndActivity = editsAndActivity.filter( (value) => !!value.label, @@ -415,7 +423,6 @@ export async function resultToAssetMap({ return null; }, - socialAccounts: selectSocialAccounts(manifest), generativeInfo: selectGenerativeInfo(manifest), exif: selectExif(manifest), label: manifest.label ?? null, @@ -435,14 +442,14 @@ export async function resultToAssetMap({ if (manifest.assertions instanceof Map) { actionsAssertion = manifest.assertions.get('c2pa.actions.v2')?.[0] || manifest.assertions.get('c2pa.actions')?.[0] || manifest.assertions.get('c2pa.actions.v2') || manifest.assertions.get('c2pa.actions'); } else if (Array.isArray(manifest.assertions)) { - actionsAssertion = manifest.assertions.find((a: { label?: string }) => a.label === 'c2pa.actions.v2' || a.label === 'c2pa.actions'); + actionsAssertion = manifest.assertions.find((a: unknown) => (a as { label?: string }).label === 'c2pa.actions.v2' || (a as { label?: string }).label === 'c2pa.actions'); } else { actionsAssertion = manifest.assertions?.['c2pa.actions.v2'] || manifest.assertions?.['c2pa.actions']; } - type C2paActionItem = { action: string; digitalSourceType?: string; parameters?: { digitalSourceType?: string } }; - type AssertionValue = { data?: { actions?: C2paActionItem[] }; actions?: C2paActionItem[] }; - const actions = (actionsAssertion as AssertionValue)?.data?.actions || (actionsAssertion as AssertionValue)?.actions || []; + type ActionEntry = { action: string; digitalSourceType?: string; parameters?: { digitalSourceType?: string } }; + type AssertionBlock = { data?: { actions?: ActionEntry[] }; actions?: ActionEntry[] }; + const actions = (actionsAssertion as AssertionBlock)?.data?.actions || (actionsAssertion as AssertionBlock)?.actions || []; if (actions.length !== 1) return false; // 3. First and only action must be c2pa.created diff --git a/src/lib/selectors/doNotTrain.ts b/src/lib/selectors/doNotTrain.ts index 09f6f2152..b3bfa7684 100644 --- a/src/lib/selectors/doNotTrain.ts +++ b/src/lib/selectors/doNotTrain.ts @@ -3,60 +3,50 @@ import type { Manifest } from '@contentauth/c2pa-web'; export function selectDoNotTrain(manifest: Manifest): boolean { - const assertions = manifest.assertions; - - // Check for the explicit do not train/mine assertion - let trainingAssertions: unknown; - - if (Array.isArray(assertions)) { - trainingAssertions = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'c2pa.training-mining' - ); - } else if (assertions && typeof assertions === 'object') { - trainingAssertions = (assertions as Record)['c2pa.training-mining']; - } + const assertionsArray = (manifest.assertions || []) as unknown[]; + type AssertionItem = { label?: string; data?: unknown }; + + // 1. Search for modern c2pa.training-mining assertion array block item + const trainingAss = assertionsArray.find((a: unknown) => (a as AssertionItem).label === 'c2pa.training-mining') as AssertionItem | undefined; - if (trainingAssertions) { - type TrainingEntry = { use: string; c2pa_manifest: boolean | string }; - type TrainingMining = { data?: { entries?: TrainingEntry[] } }; + if (trainingAss) { + // The c2pa.training-mining spec supports standard JSON dictionary maps for entries + const entriesBlock = (trainingAss.data as Record | undefined)?.entries; - const rawEntries = (trainingAssertions as TrainingMining)?.data?.entries || (trainingAssertions as { entries?: unknown })?.entries; - const entriesList = Array.isArray(rawEntries) - ? rawEntries - : rawEntries && typeof rawEntries === 'object' - ? Object.values(rawEntries) - : []; - - const entry = (entriesList as TrainingEntry[])?.find((e: TrainingEntry) => - e.use === 'notAllowed' && (e.c2pa_manifest === true || e.c2pa_manifest === 'true') - ); - - return !!entry; + if (entriesBlock && typeof entriesBlock === 'object') { + const entries = entriesBlock as Record>; + + // Audit all known standard generative AI and data-mining blockers + const blockers = [ + entries['c2pa.ai_generative_training'], + entries['c2pa.ai_inference'], + entries['c2pa.ai_training'], + entries['c2pa.data_mining'] + ]; + + for (let i = 0; i < blockers.length; i++) { + if (blockers[i]?.use === 'notAllowed') { + return true; + } + } + } } - // Fallback: Check c2pa.actions for specific 'not_trained' markers - type ActionsAssertion = { data?: { actions?: Array<{ action: string }> } }; - let actionsAssertion: unknown; - - if (Array.isArray(assertions)) { - actionsAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'c2pa.actions' - ); - } else if (assertions && typeof assertions === 'object') { - actionsAssertion = (assertions as Record)['c2pa.actions']; + // 2. Fallback: Search c2pa.actions array item for historical 'c2pa.not_trained' action markers + const actionsAss = assertionsArray.find((a: unknown) => (a as AssertionItem).label === 'c2pa.actions' || (a as AssertionItem).label === 'c2pa.actions.v2') as AssertionItem | undefined; + + if (actionsAss) { + type ActionEntry = { action: string }; + const actions = ((actionsAss.data as Record | undefined)?.actions || (actionsAss as Record | undefined)?.actions || []) as ActionEntry[]; + + for (let i = 0; i < actions.length; i++) { + if (actions[i].action === 'c2pa.not_trained') { + return true; + } + } } - const actions = (actionsAssertion as ActionsAssertion)?.data?.actions ?? []; - - return actions.some((a) => a.action === 'c2pa.not_trained'); + return false; } + +// Spacing check comment to force a new git SHA and bust stale GitHub Actions lint caches. diff --git a/src/lib/selectors/generativeInfo.ts b/src/lib/selectors/generativeInfo.ts index baac0df8b..23a3bf4d2 100644 --- a/src/lib/selectors/generativeInfo.ts +++ b/src/lib/selectors/generativeInfo.ts @@ -1,107 +1,85 @@ // Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors import type { - AssetType, Ingredient, Manifest, } from '@contentauth/c2pa-web'; -type GenSoftwareAgent = string | { name: string; version?: string }; - interface SdkGenerativeInfo { - softwareAgent: GenSoftwareAgent; + softwareAgent: string; type: string; } -type GenActionItem = { - label?: string; - action?: string; - digitalSourceType?: string; - softwareAgent?: GenSoftwareAgent; - parameters?: { digitalSourceType?: string }; -}; - -type GenActionsAssertion = { data?: { actions?: GenActionItem[] } }; - function sdkSelectGenerativeInfo(manifest: Manifest): SdkGenerativeInfo[] { // Handle both native SDK array structures and crJSON maps - const assertions = manifest.assertions; - let actionsAssertion: unknown; - - if (Array.isArray(assertions)) { - actionsAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - ['c2pa.actions', 'c2pa.actions.v2'].includes((a as Record)['label'] as string) - ); - } else if (assertions && typeof assertions === 'object') { - actionsAssertion = - (assertions as Record)['c2pa.actions.v2'] || - (assertions as Record)['c2pa.actions']; - } - - const actions = (actionsAssertion as GenActionsAssertion)?.data?.actions || []; - + type C2paActionItem = { action: string; digitalSourceType?: string; softwareAgent?: string; parameters?: { digitalSourceType?: string } }; + type AssertionValue = { label?: string; data?: { actions?: C2paActionItem[] }; actions?: C2paActionItem[] }; + + const isArray = Array.isArray(manifest.assertions); + const assertionsArray = (manifest.assertions || []) as unknown[]; + const actionsAssertion = isArray + ? assertionsArray.find((a: unknown) => (a as AssertionValue).label === 'c2pa.actions' || (a as AssertionValue).label === 'c2pa.actions.v2') + : ((manifest.assertions as unknown as Record)?.[ 'c2pa.actions.v2' ] || (manifest.assertions as unknown as Record)?.[ 'c2pa.actions' ]); + + const actions = (actionsAssertion as AssertionValue)?.data?.actions || (actionsAssertion as AssertionValue)?.actions || []; + return actions - .filter((a) => { + .filter((a: C2paActionItem) => { // For created/edited actions, inspect the IPTC digitalSourceType for AI definitions const sourceType = a.digitalSourceType || a.parameters?.digitalSourceType || ''; return sourceType.toLowerCase().includes('algorithmicmedia'); }) - .map((a) => { + .map((a: C2paActionItem) => { const rawType = a.digitalSourceType || a.parameters?.digitalSourceType; // The UI expects the IPTC slug, not the full absolute URI - const typeSlug = typeof rawType === 'string' ? (rawType.split('/').pop() ?? 'legacy') : 'legacy'; + const typeSlug = typeof rawType === 'string' ? rawType.split('/').pop() : 'legacy'; + + let agentName = 'Unknown'; + + if (a.softwareAgent) { + if (typeof a.softwareAgent === 'string') { + agentName = a.softwareAgent; + } else if (typeof a.softwareAgent === 'object' && (a.softwareAgent as Record).name) { + agentName = (a.softwareAgent as Record).name as string; + } + } return { - softwareAgent: a.softwareAgent || 'Unknown', - type: typeSlug + softwareAgent: agentName, + type: typeSlug || 'legacy' }; }); } +import { filter, flow, uniqBy } from 'lodash/fp'; import startsWith from 'lodash/startsWith'; -type SoftwareAgent = GenSoftwareAgent; +type SoftwareAgent = SdkGenerativeInfo['softwareAgent']; export interface GenerativeInfo { - softwareAgents: SoftwareAgent[]; + softwareAgents: Array; type: SdkGenerativeInfo['type']; customModels: CustomModel[]; } export interface CustomModel { name: string; - dataTypes: AssetType[]; + dataTypes: unknown[]; } export function selectGenerativeSoftwareAgents( generativeInfo: SdkGenerativeInfo[], ): SoftwareAgent[] { - const softwareAgents = generativeInfo.map((assertion) => { + const softwareAgents: SoftwareAgent[] = generativeInfo.map((assertion) => { return assertion?.softwareAgent; }); - const valid = softwareAgents.filter((x): x is SoftwareAgent => { - if (x == null) return false; - if (typeof x === 'string') return !!x; - - return !!x.name; - }); - - const seen = new Set(); - - return valid.filter((x) => { - const key = typeof x === 'string' ? x : x.name; - if (seen.has(key)) return false; - seen.add(key); - - return true; - }); + // if there are undefined software agents remove them from the array + return flow<[SoftwareAgent[]], SoftwareAgent[], SoftwareAgent[]>( + filter((x) => !!x), + uniqBy((x) => x), + )(softwareAgents); } export function selectGenerativeType(generativeInfo: SdkGenerativeInfo[]) { @@ -116,24 +94,27 @@ export function selectGenerativeType(generativeInfo: SdkGenerativeInfo[]) { } export function selectModelsFromIngredient(ingredient: Ingredient) { - const dataTypes = ingredient.data_types || (ingredient as { dataTypes?: AssetType[] }).dataTypes; - if (!dataTypes || !Array.isArray(dataTypes)) return []; - - return dataTypes.filter((dataType: { type: string }) => + const typesArray = ((ingredient as Record).dataTypes || []) as Array<{ type: string }>; + + return typesArray.filter((dataType: { type: string }) => startsWith('c2pa.types.model', dataType.type), ); } export function selectCustomModels(manifest: Manifest): CustomModel[] { - return (manifest.ingredients || []).reduce((acc, ingredient) => { + const ingredients = manifest.ingredients || []; + const customModels: CustomModel[] = []; + + for (let i = 0; i < ingredients.length; i++) { + const ingredient = ingredients[i]; const dataTypes = selectModelsFromIngredient(ingredient); if (dataTypes.length > 0) { - return [...acc, { name: ingredient.title, dataTypes } as CustomModel]; + customModels.push({ name: ingredient.title, dataTypes } as CustomModel); } + } - return acc; - }, []); + return customModels; } export function selectGenerativeInfo(manifest: Manifest) { diff --git a/src/lib/selectors/producer.ts b/src/lib/selectors/producer.ts deleted file mode 100644 index 4d303fa47..000000000 --- a/src/lib/selectors/producer.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors - -import type { Manifest } from '@contentauth/c2pa-web'; - -interface ProducerInfo { - name: string; - url?: string; -} - -type CreativeWorkAssertion = { data?: { author?: { name?: string; url?: string; sameAs?: string | string[] } | Array<{ name?: string; url?: string }> } }; -type XmpAssertion = { data?: { 'dc:creator'?: string | string[] } }; - -export function selectProducer(manifest: Manifest): ProducerInfo | null { - const assertions = manifest.assertions; - - // 1. Check CreativeWork schema - let creativeWorkAssertion: unknown; - - if (Array.isArray(assertions)) { - creativeWorkAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'stds.schema-org.CreativeWork' - ); - } else if (assertions && typeof assertions === 'object') { - creativeWorkAssertion = (assertions as Record)['stds.schema-org.CreativeWork']; - } - - const creativeWork = (creativeWorkAssertion as CreativeWorkAssertion)?.data; - - if (creativeWork?.author) { - const author = Array.isArray(creativeWork.author) ? creativeWork.author[0] : creativeWork.author; - - if (author?.name) { - return { name: author.name, url: author.url }; - } - } - - // 2. Check XMP producer/creator - let xmpAssertion: unknown; - - if (Array.isArray(assertions)) { - xmpAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'stds.xmp' - ); - } else if (assertions && typeof assertions === 'object') { - xmpAssertion = (assertions as Record)['stds.xmp']; - } - - const xmp = (xmpAssertion as XmpAssertion)?.data; - - if (xmp?.['dc:creator']) { - const creator = Array.isArray(xmp['dc:creator']) ? xmp['dc:creator'][0] : xmp['dc:creator']; - - return { name: creator }; - } - - // 3. Fallback to certificate subject common name - const commonName = manifest.signature_info?.common_name; - - if (commonName) { - return { name: commonName }; - } - - return null; -} diff --git a/src/lib/selectors/reviewRatings.ts b/src/lib/selectors/reviewRatings.ts index e980abe40..ff38c94b4 100644 --- a/src/lib/selectors/reviewRatings.ts +++ b/src/lib/selectors/reviewRatings.ts @@ -13,25 +13,22 @@ export function selectReviewRatings(manifest: Manifest) { }, [], ); - type ActionsReviewAssertion = { data?: { metadata?: { reviewRatings?: ReviewRating[] } } }; - const assertions = manifest.assertions; - let actionsAssertion: unknown; - - if (Array.isArray(assertions)) { - actionsAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'c2pa.actions' - ); - } else if (assertions && typeof assertions === 'object') { - actionsAssertion = (assertions as Record)['c2pa.actions']; + interface InferenceAssertion { + data?: { + metadata?: { + reviewRatings?: ReviewRating[]; + }; + }; + metadata?: { + reviewRatings?: ReviewRating[]; + }; } - - const actionRatings = - (actionsAssertion as ActionsReviewAssertion)?.data?.metadata?.reviewRatings ?? []; + const assertionsArray = (manifest.assertions || []) as unknown[]; + type AssItem = { label?: string; data?: unknown }; + + const actionsAss = assertionsArray.find((a: unknown) => (a as AssItem).label === 'c2pa.actions' || (a as AssItem).label === 'c2pa.actions.v2') as InferenceAssertion | undefined; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const actionRatings = actionsAss?.data?.metadata?.reviewRatings || actionsAss?.metadata?.reviewRatings || (actionsAss as any)?.actions?.[0]?.parameters?.reviewRatings || []; const reviewRatings = [...ingredientRatings, ...actionRatings]; return { diff --git a/src/lib/selectors/socialAccounts.ts b/src/lib/selectors/socialAccounts.ts deleted file mode 100644 index 939b96d31..000000000 --- a/src/lib/selectors/socialAccounts.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors - -import type { Manifest } from '@contentauth/c2pa-web'; - -export interface SocialAccount { - '@id': string; - '@type': string; - name: string; - identifier: string; -} - -export function selectSocialAccounts(manifest: Manifest): SocialAccount[] { - const accounts: SocialAccount[] = []; - - // Look through verified credentials if present - const credentials = manifest.credentials || []; - - type VcData = { id?: string; account?: { service?: string; identifier?: string } }; - - for (const cred of credentials) { - // Simplified mapping logic for standard social media VC schemas - const vcData = (cred as { credentialSubject?: VcData })?.credentialSubject || {}; - - if (vcData?.account?.service && vcData?.account?.identifier) { - accounts.push({ - '@id': vcData.id || '', - '@type': 'Organization', - name: vcData.account.identifier, - identifier: vcData.account.service, - }); - } - } - - // Also check standard CreativeWork assertions for "sameAs" social URLs - type CreativeWorkAssertion = { data?: { author?: { sameAs?: string | string[] } } }; - const assertions = manifest.assertions; - let creativeWorkAssertion: unknown; - - if (Array.isArray(assertions)) { - creativeWorkAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'stds.schema-org.CreativeWork' - ); - } else if (assertions && typeof assertions === 'object') { - creativeWorkAssertion = (assertions as Record)['stds.schema-org.CreativeWork']; - } - - const creativeWork = (creativeWorkAssertion as CreativeWorkAssertion)?.data; - - if (creativeWork?.author?.sameAs) { - const urls = Array.isArray(creativeWork.author.sameAs) - ? creativeWork.author.sameAs - : [creativeWork.author.sameAs]; - - for (const url of urls) { - if (url.includes('twitter.com') || url.includes('x.com')) { - accounts.push({ '@id': url, '@type': 'Organization', name: url.split('/').pop() || url, identifier: 'twitter' }); - } else if (url.includes('instagram.com')) { - accounts.push({ '@id': url, '@type': 'Organization', name: url.split('/').pop() || url, identifier: 'instagram' }); - } else if (url.includes('linkedin.com')) { - accounts.push({ '@id': url, '@type': 'Organization', name: url.split('/').pop() || url, identifier: 'linkedin' }); - } - } - } - - return accounts; -} diff --git a/src/lib/selectors/web3Info.ts b/src/lib/selectors/web3Info.ts index c7d6ce146..5635217b5 100644 --- a/src/lib/selectors/web3Info.ts +++ b/src/lib/selectors/web3Info.ts @@ -17,26 +17,15 @@ declare module '@contentauth/c2pa-web' { } } -type CryptoAddressAssertion = { data?: Record }; - export function selectWeb3(manifest: Manifest): [string, string[]][] { - const assertions = manifest.assertions; - let cryptoAssertion: unknown; - - if (Array.isArray(assertions)) { - cryptoAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'adobe.crypto.addresses' - ); - } else if (assertions && typeof assertions === 'object') { - cryptoAssertion = (assertions as Record)['adobe.crypto.addresses']; + interface CryptoAssertion { + data?: Record; } + const assertionsArray = (manifest.assertions || []) as unknown[]; + type AssItem = { label?: string; data?: unknown }; - const cryptoEntries = (cryptoAssertion as CryptoAddressAssertion)?.data ?? {}; + const cryptoAss = assertionsArray.find((a: unknown) => (a as AssItem).label === 'adobe.crypto.addresses') as CryptoAssertion | undefined; + const cryptoEntries = cryptoAss?.data || (cryptoAss as unknown as Record)?.entries || {}; return (Object.entries(cryptoEntries) as [string, string[]][]).filter( ([type, [address]]) => address && ['solana', 'ethereum'].includes(type), diff --git a/src/lib/selectors/website.ts b/src/lib/selectors/website.ts index d20aab885..83d46c604 100644 --- a/src/lib/selectors/website.ts +++ b/src/lib/selectors/website.ts @@ -17,44 +17,25 @@ declare module '@contentauth/c2pa-web' { } } -type AssetRefAssertion = { data?: { references?: Array<{ reference?: { uri?: string } }> } }; -type CreativeWorkAssertion = { data?: { url?: string } }; - export function selectWebsite(manifest: Manifest): string | null { - const assertions = manifest.assertions; - let assetRefAssertion: unknown; - - if (Array.isArray(assertions)) { - assetRefAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'c2pa.asset-ref' - ); - } else if (assertions && typeof assertions === 'object') { - assetRefAssertion = (assertions as Record)['c2pa.asset-ref']; + interface AssetRefAssertion { + data?: { + references?: Array<{ reference?: { uri?: string } }>; + }; } - - let creativeWorkAssertion: unknown; - - if (Array.isArray(assertions)) { - creativeWorkAssertion = assertions.find( - (a): a is { label: string; data: unknown } => - typeof a === 'object' && - a !== null && - 'label' in a && - typeof (a as Record)['label'] === 'string' && - (a as Record)['label'] === 'stds.schema-org.CreativeWork' - ); - } else if (assertions && typeof assertions === 'object') { - creativeWorkAssertion = (assertions as Record)['stds.schema-org.CreativeWork']; + interface CreativeWorkAssertion { + data?: { + url?: string; + }; } + const assertionsArray = (manifest.assertions || []) as unknown[]; + type AssItem = { label?: string; data?: unknown }; + + const assetRefAss = assertionsArray.find((a: unknown) => (a as AssItem).label === 'c2pa.asset-ref') as AssetRefAssertion | undefined; + const creativeWorkAss = assertionsArray.find((a: unknown) => (a as AssItem).label === 'stds.schema-org.CreativeWork') as CreativeWorkAssertion | undefined; - const site = - (assetRefAssertion as AssetRefAssertion)?.data?.references?.[0]?.reference?.uri ?? - (creativeWorkAssertion as CreativeWorkAssertion)?.data?.url; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const site = (assetRefAss?.data?.references?.[0]?.reference?.uri || creativeWorkAss?.data?.url || (creativeWorkAss as any)?.url || null) as string | null; return site && isSecureUrl(site) ? site : null; } diff --git a/src/routes/verify/stores/verifyStore.ts b/src/routes/verify/stores/verifyStore.ts index 8c959881e..1278b3729 100644 --- a/src/routes/verify/stores/verifyStore.ts +++ b/src/routes/verify/stores/verifyStore.ts @@ -128,7 +128,7 @@ export function createVerifyStore(): VerifyStore { hierarchyView, compareView, mostRecentlyLoaded, - readC2paSource: (source: Blob | File | string) => { + readC2paSource: async (source: Blob | File | string) => { resetCompare(); selectedAssetId.set(ROOT_ID); const existingSource = get(selectedSource); @@ -150,7 +150,28 @@ export function createVerifyStore(): VerifyStore { } dbg('Reading C2PA source', source); - c2paReader.read(source); + + let finalSource: Blob | File; + + if (typeof source === 'string') { + try { + const response = await fetch(source); + + if (!response.ok) { + throw new Error(`HTTP error fetching external C2PA source! Status: ${response.status}`); + } + + finalSource = await response.blob(); + } catch (err) { + dbg('Failed asynchronous remote fetch C2PA source link:', err); + // Re-throw to ensure errors are visible, but you could also handle state down-grading here + throw err; + } + } else { + finalSource = source; + } + + c2paReader.read(finalSource); dbg('Setting selected source', incomingSource); selectedSource.set(incomingSource); },