Skip to content

Add C2PA Monitor experiment - #459

Open
lnispel wants to merge 38 commits into
WordPress:developfrom
OpenVerifiable:feature/c2pa-monitor
Open

Add C2PA Monitor experiment#459
lnispel wants to merge 38 commits into
WordPress:developfrom
OpenVerifiable:feature/c2pa-monitor

Conversation

@lnispel

@lnispel lnispel commented Apr 22, 2026

Copy link
Copy Markdown

Read-only feature that detects C2PA Content Credentials in uploaded JPEG/PNG/WebP images at the add_attachment hook, captures the raw manifest store to a sidecar file under wp-content/uploads/ai-c2pa/, and persists a structured _wpai_monitor_record postmeta entry for downstream consumers.

What?

Closes #421

Adds a new C2pa_Monitor experiment that:

  • Detects C2PA segments via streaming magic-byte/segment walks: JPEG APP11/JUMBF, PNG caBX, WebP RIFF C2PA. Hard byte caps throughout.
  • Reassembles JPEG manifests fragmented across multiple APP11 markers by tracking Box Instance Numbers (per ISO 19566-5). Continuation segments do not need the c2pa/jumb token in their first 64 bytes.
  • Streams raw manifest bytes to disk under uploads/ai-c2pa/<attachment_id>.<format>.c2pa, hashing in flight (SHA-256). Postmeta stores the hash, length, and relative sidecar path - not the bytes themselves.
  • Creates the sidecar directory on demand with .htaccess (Apache deny) and index.php hardening. nginx operators must add a deny rule manually (documented in the experiment README).
  • Wraps the entire capture path in a fail-open try / catch ( Throwable ) boundary: errors land in the record's errors[] array and the upload itself is never blocked.
  • Adds no external dependencies, no Composer additions, and no outbound HTTP. Pure PHP, compatible with the plugin's PHP 7.4 floor.
  • Surfaces a Content Credentials status column in the Media Library list view (sortable), a field in the Attachment Details panel / media modal, and a meta box on the Edit Media screen.

Why?

C2PA Content Credentials are increasingly embedded in images uploaded to WordPress sites (camera firmware, AI image generators, editorial signing tools), but WordPress's image processing pipeline destroys them - APP11 segments don't survive GD/Imagick re-encoding. Capturing the manifest at add_attachment, before subsize generation, is the only point in the WordPress lifecycle where the original bytes are still intact.

This PR establishes that capture as read-only infrastructure: the manifest data becomes available to downstream consumers (admin UI, REST endpoints, verification tooling) without each of them having to re-parse containers.

How?

Implementation lives entirely under includes/Experiments/C2pa_Monitor/ with one entry point:

  • C2pa_Monitor::capture_for_attachment() is hooked to add_attachment at priority 20, gated by MIME on image/jpeg, image/png, and image/webp.
  • Format_Detector walks containers and returns a location descriptor (segments + total length) without reading payload bytes.
  • Manifest_Reader consumes that descriptor, streams the bytes via fread into a hash context and an in-memory buffer, and produces an immutable Raw_Manifest value object.
  • Sidecar_Writer persists the bytes, ensuring the directory and hardening files exist exactly once.
  • Record normalizes the structured payload and persists it as JSON-encoded postmeta at _wpai_monitor_record.

The admin UI is entirely read-only and consists of three surfaces wired in register():

  • Media Library column (manage_media_columns / manage_media_custom_column): "Content Credentials" column showing ? Credentials, No credentials, or - (not scanned). Sortable via manage_upload_sortable_columns + pre_get_posts. Hover tooltip via inline CSS on admin_head-upload.php.
  • Attachment Details / media modal (attachment_fields_to_edit): same three-state badge with a visible help text paragraph. show_in_edit => false suppresses it on the Edit Media screen to avoid duplicating the meta box.
  • Edit Media meta box (add_meta_boxes_attachment): "Content Credentials" side meta box with the badge and a <p class="description"> help paragraph.

Reviewing this PR. The diff is large but cleanly partitioned. The work was developed in five layers and the integration branch's commits are organized that way:

  1. Register the experiment (scaffolding, no behavior).
  2. Define the record schema (Raw_Manifest, Record, tests).
  3. Detect image formats (Format_Detector, fixtures, tests).
  4. Read and persist manifests (Manifest_Reader, Sidecar_Writer, tests).
  5. Wire the intake hook (register() body, capture_for_attachment, end-to-end tests).
  6. Admin UI (Media Library column, sortable column, Attachment Details field, Edit Media meta box).

Each layer is independently coherent if you'd rather review incrementally; otherwise, the integrated diff stands on its own.

Use of AI Tools

AI assistance: Yes
Tool(s): Cursor, Claude
Model(s): Claude Sonnet 4.6
Used for: Architecture and PR-segmentation planning, initial code drafts, test scaffolding. Final implementation, all design decisions, and the byte-level format work were reviewed and edited by me.

Testing Instructions

Capture pipeline

  1. Enable the AI plugin globally and toggle on C2PA Monitor under experiments.
  2. Upload a JPEG with embedded C2PA credentials (e.g., from the C2PA public test files).
  3. Inspect the attachment's postmeta for _wpai_monitor_record - it should be JSON with c2pa.present: true, a SHA-256 hash, and a sidecar_path_relative value.
  4. Confirm the sidecar file exists at wp-content/uploads/ai-c2pa/<attachment_id>.jpeg.c2pa and that its bytes match manifest_sha256.
  5. Upload a JPEG without C2PA - the postmeta should record c2pa.present: false and no sidecar should be written.
  6. Upload a non-image (e.g., a .txt file) - no postmeta should be written at all.
  7. Disable the experiment and re-upload - no postmeta should be written.

Admin UI

  1. With the experiment enabled, open Media > Library in list view. A Content Credentials column should appear, showing ? Credentials for the image from step 2, and No credentials for the image from step 5. Hovering each cell should show a tooltip.
  2. Click the Content Credentials column header to sort - credentials-present images should appear first.
  3. In the Media Library, click an attachment to open the Attachment Details panel (modal). A Content Credentials field should appear with the status badge and a plain-text explanation beneath it.
  4. Navigate to wp-admin/upload.php?item=<id> for the same attachment. The same Content Credentials field should appear in the details form.
  5. Navigate to wp-admin/post.php?post=<id>&action=edit. A Content Credentials meta box should appear in the sidebar with the badge and help text. The field from step 11 should not appear in the main column (it is suppressed with show_in_edit => false to prevent duplication).

Verification

  1. Clicking ? Credentials opens verify.contentauthenticity.org in a new tab. Download the original image from your Media Library and drag it into the verify tool to run a full cryptographic check. (Direct URL pre-fill is not supported because WordPress admin URLs are not reachable by the verify tool's fetcher from outside the session.)

Automated coverage:

  • Format_Detector: magic bytes, single-segment APP11, multi-segment reassembly, interleaved APP0/APP1/APP2 around C2PA, generic JUMBF (non-C2PA) ignored, truncated input, JPEG_MAX_SEGMENTS cap (positive and negative), PNG/caBX, WebP simple + extended (VP8X) + odd-length padding.
  • Manifest_Reader: byte-exact roundtrip for JPEG/PNG/WebP, multi-segment reassembly, deterministic SHA-256, MAX_MANIFEST_BYTES rejection, missing file, empty segments, bad offsets.
  • Sidecar_Writer: write + roundtrip, hardening files, format sanitization, overwrite, multi-attachment coexistence, custom .htaccess preserved across ensure_dir().
  • Record: roundtrip, defaults on empty input, JSON-not-serialize storage format, null on corrupt JSON, null when absent.
  • C2pa_Monitor end-to-end: JPEG/PNG/WebP present, JPEG absent, unsupported MIME, fail-open on bogus ID, truncated JPEG, duration_ms recorded, add_attachment hook actually fires, file-deleted-on-disk produces errors[0].stage = 'resolve_path', column enabled/disabled, column cell states (present/absent/dash/malformed), tooltip present in column, tooltip absent on detail surfaces, show_in_edit false, helps key populated, meta box registration, meta box renders help paragraph, verify link has no source= query param, sort column registration and query modification.

Synthetic fixtures are generated at runtime so no binary blobs land in the repo and there is no third-party fixture licensing question.

Deferred (out of scope for this PR)

  • #953 - JUMBF box reader and CBOR decoder; populating c2pa.decoded claim summary (claim generator, digital source type, action history).
  • #954 - Cryptographic verification in-browser via the C2PA JS SDK (@contentauth/sdk, targeting 1.4.0) - would allow verification without leaving the admin and without relying on publicly reachable URLs.
  • #955 - Media Library grid view indicator. The C2PA CR trustmark is limited to products on the C2PA conforming products list and denotes validated credentials, so this ships as a neutral plugin-specific badge using "detected" language, with the CR icon revisited if WordPress joins the list.
  • #956 - Preserving manifests through WordPress's GD/Imagick subsize pipeline.
  • #957 - Pre-filling the CAI Verify tool with a publicly reachable attachment URL (?source=).

Screenshots or screencast

Demo video posted in thread: c2pa_monitor.mp4

Changelog Entry

Added - New Experiment: C2PA Monitor - read-only detection of C2PA Content Credentials in uploaded JPEG/PNG/WebP images. Captures the raw manifest store to a sidecar file under wp-content/uploads/ai-c2pa/ and stores a structured _wpai_monitor_record postmeta entry for downstream consumers. Adds a sortable Content Credentials column to the Media Library and surfaces the credential status on the Attachment Details and Edit Media admin screens. Fail-open and never blocks an upload. JUMBF/CBOR claim decoding and in-browser cryptographic verification deferred to follow-up PRs.

Open WordPress Playground Preview
@codecov

codecov Bot commented Apr 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.84180% with 132 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.43%. Comparing base (f6acb37) to head (57f3ef9).
⚠️ Report is 18 commits behind head on develop.

Files with missing lines Patch % Lines
...ludes/Experiments/C2pa_Monitor/Format_Detector.php 72.72% 60 Missing ⚠️
includes/Experiments/C2pa_Monitor/C2pa_Monitor.php 87.64% 32 Missing ⚠️
...cludes/Experiments/C2pa_Monitor/Sidecar_Writer.php 66.10% 20 Missing ⚠️
...ludes/Experiments/C2pa_Monitor/Manifest_Reader.php 80.35% 11 Missing ⚠️
includes/Admin/Uninstall.php 86.20% 4 Missing ⚠️
includes/Experiments/C2pa_Monitor/Record.php 93.10% 4 Missing ⚠️
includes/Experiments/C2pa_Monitor/Raw_Manifest.php 87.50% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #459      +/-   ##
=============================================
+ Coverage      74.42%   75.43%   +1.01%     
- Complexity      3116     3455     +339     
=============================================
  Files            132      139       +7     
  Lines          12179    13147     +968     
=============================================
+ Hits            9064     9918     +854     
- Misses          3115     3229     +114     
Flag Coverage Δ
unit 75.43% <80.84%> (+1.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
@jeffpaul jeffpaul added this to the 1.0.0 milestone Apr 22, 2026
@jeffpaul jeffpaul moved this to In progress in WordPress AI Roadmap Apr 22, 2026
@lnispel lnispel closed this Apr 26, 2026
@lnispel
lnispel force-pushed the feature/c2pa-monitor branch from ec97690 to 43d3bde Compare April 26, 2026 20:06
@github-project-automation github-project-automation Bot moved this from In progress to Done in WordPress AI Roadmap Apr 26, 2026
lnispel added 5 commits April 26, 2026 13:06
… tests

- JSON postmeta contract; @see DIF wpai-monitor-record schema
- Partial README (postmeta + constraints)

Made-with: Cursor
- Format_Detector for JPEG/PNG/WebP C2PA segments
- Synthetic fixtures and Format_DetectorTest

Made-with: Cursor
- Streaming Manifest_Reader and Sidecar_Writer
- README: sidecar layout, rationale, test fixtures

Made-with: Cursor
- capture_for_attachment, sidecar, Record persistence
- C2pa_MonitorTest; README: full flow, DIF schema cross-links, out of scope

Made-with: Cursor
lnispel added a commit to OpenVerifiable/ai that referenced this pull request Apr 26, 2026
@lnispel lnispel reopened this Apr 26, 2026
@lnispel
lnispel force-pushed the feature/c2pa-monitor branch 2 times, most recently from 1e33baa to 8257f84 Compare April 26, 2026 20:59
@lnispel
lnispel marked this pull request as ready for review April 26, 2026 21:12
@github-actions

github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @lukenispel.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: lukenispel.

Co-authored-by: lnispel <originvault@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
Co-authored-by: saarnilauri <laurisaarni@git.wordpress.org>
Co-authored-by: Zodiac1978 <zodiac1978@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

lnispel added 2 commits April 26, 2026 14:13
Synthetic fixtures are not valid renderable images. GD's WebP codec
fatals when create_upload_object triggers wp_generate_attachment_metadata.
Suppress intermediate_image_sizes_advanced in setUp/tearDown so GD is
never invoked on fixture bytes.

Made-with: Cursor
…kability

Adds CONTEXT_URL constant to C2pa_Monitor pointing at the wpai-monitor-record
context.json in DIF credential-schemas (interim raw.githubusercontent.com URL;
@todo migrate to https://w3id.org/openverifiable/v1 at Phase 3).

Every stored _wpai_monitor_record now includes:
  "@context": ["https://schema.org/", C2pa_Monitor::CONTEXT_URL]

This makes the record a self-describing JSON-LD document that can be lifted
directly into a credentialSubject without transformation. SCHEMA_VERSION stays
at 1 — the addition is backwards-compatible (unknown keys are ignored by
existing readers).

Updates Record::REQUIRED_KEYS and default_for() so normalize() always fills
@context even when callers omit it. Adds assertions to RecordTest and
C2pa_MonitorTest. Updates README example block.

Made-with: Cursor
@jeffpaul jeffpaul moved this from Done to Needs review in WordPress AI Roadmap May 7, 2026
@jeffpaul
jeffpaul requested review from dkotter and jeffpaul May 7, 2026 14:19
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/README.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread readme.txt
Comment thread includes/Experiments/C2pa_Monitor/Sidecar_Writer.php
@jeffpaul jeffpaul mentioned this pull request May 18, 2026
42 tasks
@dkotter dkotter modified the milestones: 1.0.0, 1.1.0 May 18, 2026
@dkotter dkotter removed this from the 1.0.1 milestone May 26, 2026
@jeffpaul

Copy link
Copy Markdown
Member

I grabbed test files from https://github.com/c2pa-org/public-testfiles. Some testing feedback:

  1. Are there any other variations shown in the Content Credentials column besides "✓ Credentials" and "No credentials"? We may want to consider the column naming and the names of the options displayed to make sure they're more clear to users new to the c2PA concept; nothing immediately different/better comes to mind, but something I'm trying to consider.
  2. When clicking "✓ Credentials" is there a way we can either pre-fill that page with the image URL (or the image itself if the site/image isn't publicly available via URL) or is there a more automated method that the image could be sent off for verification and then updating the status in that column?
  3. There's no Content Credentials shown on the Attachment details screen (e.g. https://example.com/wp-admin/upload.php?item=7) or Edit Media screen (e.g. https://example.com/wp-admin/post.php?post=7&action=edit), can we include on those views as well?
… media screens

- Add attachment_fields_to_edit filter (add_attachment_fields) to show a
  read-only 'Content Credentials' field in the media modal and on the
  upload.php?item=<id> Attachment Details screen.
- Add add_meta_boxes_attachment action (add_attachment_meta_box +
  render_attachment_meta_box) to show a 'Content Credentials' meta box in
  the side column of post.php?post=<id>&action=edit (Edit Media screen).
- Extract shared get_status_html() helper used by render_media_column(),
  add_attachment_fields(), and render_attachment_meta_box().
- Pass the attachment URL as a ?source= query param on the CAI verify link
  so the verify tool can pre-load the image for one-click cryptographic
  verification (best-effort; works for publicly reachable sites).
- Add tests: add_attachment_fields (enabled/disabled, all states),
  add_attachment_meta_box (registration enabled/disabled),
  render_attachment_meta_box (output states), verify link source param.
- Update docs/experiments/c2pa-monitor.md to document all three admin
  surfaces and the updated verify link behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jeffpaul jeffpaul mentioned this pull request Aug 10, 2026
48 tasks
@jeffpaul

Copy link
Copy Markdown
Member
  1. Same question as above.

  2. Testing via Playground likely means my test images aren't fully available on the internet to access via link so clicking the "✓ Credentials" link gets to something like https://verify.contentauthenticity.org/?source=https%3A%2F%2Fplayground.wordpress.net%2Fscope%3Acurious-chic-river%2Fwp-content%2Fuploads%2F2026%2F08%2Fa-bad-01-scaled.jpg which doesn't load the image into the inspector on the CAI verify site.

3a. Looks like on the "Edit Media" screen that the CC info is separate from the metabox set up for that info and the "No credentials" and "✓ Credentials" text don't include the on-hover helper text.

Screenshot 2026-08-13 at 1 59 27 PM

3b. On the "Attachment details" screen the on-hover text shows but gets overlapped by some element and isn't fully rendered

Screenshot 2026-08-13 at 2 01 27 PM Screenshot 2026-08-13 at 2 01 35 PM
…link

Verify link / source param:
- Revert the ?source= pre-fill. WordPress attachment URLs are not reachable
  by the CAI tool fetcher from outside the admin session (local, Playground,
  staging, auth-gated sites all fail silently). The link now points at the
  bare verify.contentauthenticity.org URL and help text tells the user to
  download and drag the file in. In-browser verification via @contentauth/sdk
  is deferred as a follow-up PR.

Tooltip vs visible help text:
- Add bool $with_tooltip param to get_status_html() (default true). CSS
  tooltip kept only in the compact Media Library column; detail screens use
  visible text instead.
- Add get_status_help_text() returning a per-state plain-English explanation.

Attachment details (media modal and upload.php?item=<id>):
- Set show_in_edit => false on the attachment_fields_to_edit field to suppress
  it on the Edit Media screen where the meta box already renders, fixing the
  duplicate display jeffpaul observed.
- Add helps key so WordPress renders a <p class="help"> beneath the badge,
  unaffected by the modal overflow that was clipping the CSS tooltip.

Edit Media meta box (post.php?post=<id>&action=edit):
- render_attachment_meta_box() now passes false to get_status_html() and
  appends a <p class="description"> help paragraph.

w3id.org tracking link:
- Correct CONTEXT_URL @see from pull/6007 to pull/6376 (the PR that merged).
- Same correction in docs/experiments/c2pa-monitor.md.

Tests:
- Rename test_verify_link_includes_source_param to
  test_verify_link_has_no_source_param and invert the assertion.
- Assert data-wpai-tooltip present in render_media_column output.
- Assert show_in_edit => false and helps key in add_attachment_fields tests.
- Assert tooltip absent and help text non-empty on both detail surfaces.
- Extend meta box test to cover all three states.

Docs:
- List all UI hooks under Key Hooks & Entry Points.
- Drop the source pre-fill claim; add verification note and SDK follow-up.
- Document show_in_edit rationale and visible help text for detail screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lnispel

lnispel commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks @jeffpaul, addressing all three items from your Aug 13 testing pass:

1. Column states

There are three: "? Credentials", "No credentials", and "-" (dash - no scan record, e.g. uploaded before the experiment was enabled or a non-image MIME type). The labels are intentionally minimal for now. Once we validate using the C2PA JS SDK and can surface decoded claim details (claim generator, digital source type, signing authority), we'll have the context needed to make the language more specific and can refine from there.

2. Verify link / ?source= pre-fill

Good catch - the ?source= parameter was a mistake and I've reverted it. WordPress attachment URLs are not publicly reachable from the CAI verify tool's servers regardless of whether the site is "live", because verification runs outside the admin session (local, Playground, staging, and any auth-gated site all fail the same way). The link now points at the bare verify.contentauthenticity.org URL, and a help text note below each credential badge tells the user to download the original image and drag it into the tool.

The right long-term fix is in-browser verification via @contentauth/sdk, which can fetch the attachment via the user's authenticated browser session and run the check inline - no redirect needed. I've noted that as a deferred follow-up in the PR description.

3a. Edit Media - duplicate display and missing tooltip

The CC info was appearing in both the main column (via attachment_fields_to_edit) and the sidebar meta box on post.php?post=<id>&action=edit. Fixed by setting 'show_in_edit' => false on the attachment field, which is the same approach Alt_Text_Generation uses in this codebase. The meta box is the only surface on Edit Media now.

The tooltip was absent because print_column_styles() only fires on admin_head-upload.php. Rather than extending the CSS tooltip to more screens, I replaced it with a visible <p class="description"> help paragraph on the meta box - more appropriate for that screen's layout anyway.

3b. Attachment details - tooltip clipping

The CSS tooltip's position: absolute; bottom: calc(100% + 6px) was getting clipped by the media modal's overflow: hidden parent. Fixed by using the 'helps' key on the attachment_fields_to_edit field instead, which WordPress renders natively as <p class="help"> beneath the field - no absolute positioning, nothing to clip.


Corrected w3id.org link (also in this commit): the CONTEXT_URL docblock referenced pull/6007 but the PR that actually merged was #6376 - fixed in both C2pa_Monitor.php and docs/experiments/c2pa-monitor.md.

PR description updated to reflect the current state: Closes #421, the three admin UI surfaces are documented in What/How/Testing Instructions/Changelog Entry, "No UI changes" is replaced with a link to the demo video, and the Deferred list now accurately reflects what remains (JUMBF/CBOR decoding, in-browser SDK verification, grid view badge).


Reply posted via the Cursor coding assistant on behalf of @lnispel.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jeffpaul
jeffpaul requested a review from dkotter August 18, 2026 16:49
@jeffpaul

Copy link
Copy Markdown
Member
  1. Ok, I think we'll want to look at iteration on all the copy shown but I prefer to get this experiment into the plugin and then based on community feedback can iterate on the copy in a subsequent release.

  2. Please open a new issue to document that long-term fix as something we can follow-up on in the 1.4.0 release. And it looks like there are 4 bullets in that "Deferred" section in the PR description, so would be great to have issues created for each of them so we can track improvements after getting the base experiment into the plugin.

Separately, for WordPress installs that do have publicly available image links, could those image links be more easily passed to the verification site/tool to avoid the WordPress site owner having to download/upload?

3a. That works, thanks!

3b. The "Content Credentials" label is misaligned and showing a row above the "✓ Credentials" value for that image, let's please ensure they're aligned on the same row

Screenshot 2026-08-18 at 12 06 42 PM
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/Manifest_Reader.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php
Comment thread includes/Experiments/C2pa_Monitor/Sidecar_Writer.php Outdated
Comment thread includes/Experiments/C2pa_Monitor/Format_Detector.php
Comment thread includes/Experiments/C2pa_Monitor/C2pa_Monitor.php Outdated
lnispel and others added 4 commits August 18, 2026 13:10
- Fix extract_jpeg_app11_jumbf_slice(): read 8-byte CI/En/Z prefix,
  parse Z as uint32 BE, dispatch on Z===1 (first segment, slice +8)
  vs Z>1 (continuation, slice +16), strict jumb/jumd/C2PA UUID
  validation on first segment, correct length guards.

- Fix sort_by_c2pa_column(): replace bare EXISTS meta_query clause
  with relation=>OR + EXISTS (named) + NOT EXISTS so WP uses a LEFT
  JOIN; unscanned attachments now appear at the bottom of DESC sorts
  rather than being silently dropped.

- Rewrite all four JPEG fixture writers in Fixtures.php to the
  spec-correct APP11 layout (pack N for Z, real LBox/TBox/jumd box
  carrying C2PA UUID). Upgrade write_jpeg_with_jumbf_non_c2pa() to
  emit a genuine jumb box with a non-C2PA type UUID.

- Vendor XCA.jpg and A.jpg from c2pa-node (MIT, Adobe) into
  tests/fixtures/c2pa/; add README.md with license notice and
  attribution in CREDITS.md.

- Add byte-exact tests: LBox arithmetic, seam regression, pinned
  sha256 (XCA.jpg), clean negative (A.jpg), continuation-too-short
  guard. Repair seven existing synthetic JPEG tests for new fixtures.
  Fix test_sort_includes_unscanned_attachments() to use OR relation.

- Remove 7 redundant is_enabled() guards from register()-hooked
  callbacks; drop disabled-branch test halves.

- Rename print_column_styles() to print_admin_styles(), hook to
  admin_head-upload.php and admin_head-post.php; add scoped
  .compat-field-wpai_c2pa CSS for label alignment.

- Accept dkotter's description fix: raw manifest -> sidecar file.
  File 5 tracking issues; update PR description and docs deferred
  section with issue links.
Replace the short ternary in the sort query with an explicit default,
invert the meta box help check into an early return, and promote the
C2PA box UUID to a class constant so its comparison is Yoda-compliant.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop esc_url() from the hardcoded verify tool link, explain the two
disabled PHPCS sniffs in Manifest_Reader and Sidecar_Writer, and repair
a mojibake character in the Format_Detector sniff comment.

Uninstall now clears both C2PA post meta keys and the sidecar directory.
Files the plugin did not write are preserved, and the directory is only
removed once it is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lnispel

lnispel commented Aug 21, 2026

Copy link
Copy Markdown
Author

Review round follow-up

@dkotter — I've replied inline on all 14 threads from your Aug 18 pass. Apologies for the silence: most of these were actually fixed on the 18th, but I never closed the loop on the threads themselves, so from your side the review looked ignored.

Fixed in b3a3403, the push right after your review:

  • Experiment description now says the manifest goes to a sidecar file, with only a structured record in postmeta.
  • All seven redundant is_enabled() guards removed — you were right that register() only runs when the experiment is enabled.
  • APP11 prefix corrected from 12 to 8 bytes, with proper LBox/TBox handling per ISO/IEC 19566-5 Annex B. That analysis you passed along was correct and it was a real bug: the reassembled sidecar would not have been a valid JUMBF store. You were also right that the fixtures encoded the same misreading, so the generator was fixed too.
  • Sortable column no longer drops unscanned attachments. It uses a named meta_query clause pairing EXISTS with NOT EXISTS under relation => OR, with a regression test seeding all three states.

Fixed in 1607ed9, just pushed:

  • Dropped esc_url() from the hardcoded verify link.
  • Documented why the two PHPCS sniffs are disabled in Manifest_Reader and Sidecar_Writer, matching what Format_Detector already explained.
  • Uninstall now clears _wpai_monitor_record, _wpai_c2pa_present, and the ai-c2pa sidecar directory, behind the existing wpai_remove_data_on_uninstall filter. Files the plugin did not write are preserved and the directory is only removed once empty. Three new UninstallTest cases cover removal, opt-out, and the foreign-file case.

Also cleared three PHPCS failures that were blocking CI (short ternary, early exit, Yoda condition) and merged develop, which had fallen 10 commits behind.


@jeffpaul — on your Aug 18 items:

  1. Agreed on landing the experiment first and iterating on copy from community feedback.
  2. Issues are open for every Deferred bullet: C2PA Monitor: JUMBF box reader and CBOR decoder for c2pa.decoded #953, C2PA Monitor: In-browser cryptographic verification via @contentauth/sdk #954, C2PA Monitor: Media Library grid view indicator #955, C2PA Monitor: Preserve C2PA manifests through WordPress GD/Imagick subsize pipeline #956, C2PA Monitor: Pre-fill CAI Verify tool with publicly reachable attachment URL (?source=) #957.

3b. Fixed. The Content Credentials label and its value now render on the same row; print_admin_styles() ships the alignment rule for the Attachment Details compat field on both upload.php and post.php.

On your separate question about public image links — yes, technically. The verify tool accepts a ?source=<url> parameter and fetches the image itself, so on a site whose uploads are publicly reachable that would remove the download/upload step entirely. The reason it is not in this PR is that we cannot tell from inside wp-admin whether a given install qualifies. Playground, local development, staging behind basic auth, and private or members-only sites all produce a link that silently fails, which is a worse experience than no link at all. That is tracked in #957.

The better long-term answer is #954: verifying in the browser with the C2PA JS SDK needs no publicly reachable URL, and behaves identically on a laptop and a production host. It would also let us replace the bare Credentials presence badge with an actual validation result, which should make the copy question in item 1 much easier to settle.

Separately, I'm revising #955. I had filed it as a CR icon badge, but the CR pin is a certification mark restricted to products on the C2PA conforming products list, so it is not ours to ship today. I'm rescoping that issue to a neutral, plugin-specific badge, with the trustmark revisited if WordPress joins the conforming products list.


Replied via Cursor on behalf of @lnispel.

Plugin Check rejects direct rmdir() calls, and the phpcs:ignore on that
line named the wrong sniff code so it never applied. Initialise
WP_Filesystem and use its delete() instead, degrading to leaving the
empty directory in place if the filesystem cannot be initialised.

Co-authored-by: Cursor <cursoragent@cursor.com>

@dkotter dkotter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for all the work here. I've put this through another round of review and testing and have found a few more things. In addition to what I've flagged, there's a couple other things:

  1. If I delete an image, the sidecar isn't deleted. I think ideally we hook into delete_attachment and clean that up
  2. We show the Content Credentials section even if the media item isn't an image, say an audio file or video file. Should we only show that section if it's an image?
  3. The UI in the modal still isn't great, which I see @jeffpaul flagged as well. Seems the content is vertically aligned in the container instead of top aligned, maybe?
Image Image
* @param \WP_Query $query The current query.
* @return void
*/
public function sort_by_c2pa_column( \WP_Query $query ): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sorting still doesn't work for me. If I sort by either ASC or DESC, I never see any files that have credentials, they all just disappear

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e18a1fc. The root cause was structural: the meta_query EXISTS + NOT EXISTS pattern with relation => OR emits an unconditional INNER JOIN for the EXISTS side, then reads ORDER BY wp_postmeta.meta_value from an arbitrary other meta row (e.g. a serialised _wp_attachment_metadata blob) for attachments that have no sort key — those strings sort above '1', so credentialed items get pushed to the back.

Replaced with a posts_clauses filter that injects a named LEFT JOIN ... ON ( post_id = ID AND meta_key = '_wpai_c2pa_present' ). One joined row per attachment, no DISTINCT, and COALESCE( meta_value + 0, -1 ) gives unscanned items a deterministic -1 so they sort last on DESC and first on ASC.

Also fixed the test that concealed the bug: the factory attachments had no other postmeta, so the ambiguous ORDER BY always hit the right value. The test now seeds _wp_attached_file and _wp_attachment_metadata on every fixture.

* @return void
*/
public function sort_by_c2pa_column( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() || 'wpai_c2pa' !== $query->get( 'orderby' ) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should likely also gate this to the attachment list screen

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e18a1fc. The posts_clauses handler now checks get_current_screen()->base === 'upload' and returns the clauses unchanged on any other screen.

*
* @return void
*/
public function print_admin_styles(): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the media modal can load in lots of different places besides just the Media Library or Edit Media screen. This may be fine for now but might need extended in the future

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the styles were limited to admin_head-upload.php and admin_head-post.php, so the block editor, customizer, and any third-party admin page that embeds the media frame would get no CSS. Changed in e18a1fc to admin_enqueue_scripts with wp_register_style( 'wpai-c2pa-monitor', false, array(), false ) + wp_add_inline_style(), which fires on every admin page.

Comment thread includes/Admin/Uninstall.php Outdated
* @since x.x.x
*/
private static function delete_c2pa_sidecars(): void {
$uploads = wp_upload_dir();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to pass false for the second param here so it won't create new directories on what is supposed to be a cleanup routine

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e18a1fc — the call is now wp_upload_dir( null, false ) so the cleanup path never creates directories.

/**
* Required top-level keys for a valid record.
*
* @var string[]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @var string[]
* @var list<string>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in e18a1fc.

Comment on lines +518 to +519
$writer = new Sidecar_Writer();
$rel = $writer->write( $attachment_id, $manifest );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If for some reason an image does have valid credentials but this write fails, the catch block fires and we lose the validation of the image. This may be expected but it will end up showing the image has not having valid credentials even though it did

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e18a1fc. $c2pa is now populated from the manifest facts (including present: true) before the sidecar write is attempted. If the write throws, the failure goes into errors[] with stage: 'sidecar_write' and the detection result survives. The outer RuntimeException catch (for scanner failures) is now labelled stage: 'scan' so the two paths are distinguishable in the stored record.

Sorting bug (second attempt):
Replace the meta_query EXISTS+NOT EXISTS approach with an explicit
LEFT JOIN via posts_clauses. The old approach emitted an unconditioned
INNER JOIN for the EXISTS side, which caused the ORDER BY to read
meta_value from an arbitrary other row (e.g. a serialised
_wp_attachment_metadata blob) for unscanned attachments, making
credentialed items sort behind that garbage and disappear off page one.
The clause injection uses COALESCE(meta_value + 0, -1) so unscanned
attachments sort deterministically last. Gated to the upload screen.
The test that concealed the bug (factory attachments had no other
postmeta) is rewritten to seed _wp_attached_file and
_wp_attachment_metadata on every fixture.

Sidecar write failure erasing a detection:
Populate $c2pa from the manifest before attempting the sidecar write.
A write failure (disk full, permissions) now lands in errors[] and the
detection result (present: true) is preserved.

Delete sidecar on attachment deletion:
Add delete_attachment handler + Sidecar_Writer::delete() to remove
ai-c2pa/<id>.*.c2pa when an attachment is deleted.

Image gating:
wp_attachment_is_image() guards render_media_column,
add_attachment_fields, and add_attachment_meta_box so audio/video
attachments show nothing.

Admin styles:
Move from admin_head-upload.php / admin_head-post.php to
admin_enqueue_scripts so styles load wherever the media modal
opens (block editor, customizer, third-party pages).

Small fixes:
- wp_upload_dir() in Uninstall passes null/false to avoid creating
  directories during cleanup.
- Record.php @var string[] -> @var list<string> per review suggestion.
- Outer RuntimeException catch stage corrected from sidecar_write to
  scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lnispel

lnispel commented Aug 31, 2026

Copy link
Copy Markdown
Author

Review round 3 follow-up — e18a1fc

@dkotter — replied inline on all six threads. Summary of the review-body items:

1. Sidecar not deleted when an image is deleted

Added a delete_attachment hook that calls a new Sidecar_Writer::delete() method. It globs �i-c2pa/..c2pa — the literal . after the ID prevents 12..c2pa from matching 123.jpeg.c2pa. Postmeta is already removed by WordPress core.

2. Content Credentials section shown for audio/video

All three entry points —
ender_media_column, �dd_attachment_fields, and �dd_attachment_meta_box — now bail early via wp_attachment_is_image(). Any image MIME type (including GIF, AVIF, etc.) shows the status; audio and video show nothing. The existing factory calls in the test file now create image/jpeg attachments so the assertions continue to pass.

3. Modal vertical alignment still wrong

The styles were hooked to �dmin_head-upload.php and �dmin_head-post.php only, so they were absent for the block editor, customizer, and any third-party page that opens the media frame. Moved to �dmin_enqueue_scripts via wp_register_style + wp_add_inline_style, which fires everywhere. The tooltip and compat-field alignment CSS are identical — the alignment issue in the screenshot was caused by the styles simply not being loaded on that screen, not by a wrong rule.


Replied via Cursor on behalf of @lnispel.

Verified the sorting fix against a real wp-env instance with realistic
attachment postmeta. Confirmed the old meta_query approach ordered by
CAST(wp_postmeta.meta_value AS CHAR) read from an unconditioned alias,
putting unscanned attachments ahead of credentialed ones on a DESC sort
- exactly the reported symptom.

While verifying, found that applying the posts_clauses filter twice
appends the LEFT JOIN twice, which MySQL rejects outright with
"Not unique table/alias" and empties the Media Library rather than
merely mis-sorting it. Guard against a duplicate alias and cover it
with a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lnispel

lnispel commented Aug 31, 2026

Copy link
Copy Markdown
Author

Verified against a live install — 897e194

I got a local wp-env instance running and reproduced all of this properly rather than reasoning about it, which turned up one more bug and one correction to what I said earlier.

Reproduced the sorting failure. Seeded four attachments carrying realistic postmeta (_wp_attached_file, _wp_attachment_metadata) — one with credentials, one scanned without, one never scanned, one audio file. The old meta_query approach produced:

ddd-audio(none) | ccc-never-scanned(none) | aaa-has-credentials(1) | bbb-no-credentials(0)

On a descending sort the unscanned items come first and the credentialed one lands third. That is exactly what you were seeing.

Correction to my earlier reply. I said WP_Query added DISTINCT for the OR relation. It did not — no DISTINCT appeared. The generated clause was:

ORDER BY CAST(wp_postmeta.meta_value AS CHAR) DESC

reading from the unconditioned wp_postmeta alias, so for attachments with no sort key it sorted on whatever other meta row it landed on (a path string, a serialised metadata blob), and those sort above '1' as CHAR. The mechanism I described was right, the DISTINCT detail was not.

After the fix, same data:

  • DESC -> aaa(1) | bbb(0) | ddd(none) | ccc(none)
  • ASC -> ddd(none) | ccc(none) | bbb(0) | aaa(1)
  • 4 of 4 rows returned, no DISTINCT

New bug found while verifying. Applying the posts_clauses filter twice appended the LEFT JOIN twice, and MySQL rejects that outright:

WordPress database error: [Not unique table/alias: 'wpai_c2pa_sort']

which returns zero rows — an empty Media Library, worse than a mis-sort. The filter is registered once so this would not fire in normal operation, but it is one stray apply_filters away from a blank screen. Guarded against the duplicate alias in 897e194 with a regression test.

Also confirmed on the live install:

  • Image gating — attachment 7 (audio/mpeg) renders an empty column; attachment 4 (image/jpeg) renders the verify link.
  • Sidecar cleanup — deleting attachment 5 removed 5.jpeg.c2pa and left 55.jpeg.c2pa untouched, so the ID prefix guard holds.

Posted via Cursor on behalf of @lnispel.

… rewrite

The previous commit added a wp_attachment_is_image() gate to the column,
attachment fields, and meta box, and moved sorting onto posts_clauses.
Both changes invalidated assumptions in the test file.

wp_attachment_is() returns false unless get_attached_file() resolves, so
attachments built with only a post_mime_type were no longer treated as
images and every UI assertion saw empty output. Adds a
create_image_attachment() helper that also seeds _wp_attached_file.

The sort tests stubbed get_current_screen() with an anonymous class, but
sort_by_c2pa_column() requires a real WP_Screen, so the filter silently
returned the clauses untouched and the assertions compared against empty
strings. Uses set_current_screen( 'upload' ) instead, and passes
wp_the_query so is_main_query() holds.

test_sort_includes_unscanned_attachments duplicated the production SQL
inline, which meant it could not catch a regression in the code it was
meant to guard. It now drives sort_by_c2pa_column() through a real
WP_Query; verified by mutation that changing the COALESCE default fails
the test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants