2323/**
2424 * C2PA Monitor experiment class.
2525 *
26- * Layer 1: experiment metadata and registration only; no capture hook.
26+ * Hooks into add_attachment and captures a structured `_wpai_monitor_record`
27+ * for every uploaded image. The capture is read-only, fail-open, and never
28+ * blocks the upload pipeline.
2729 *
2830 * @since 0.7.0
2931 */
@@ -78,7 +80,142 @@ protected function load_metadata(): array {
7880 * {@inheritDoc}
7981 */
8082 public function register (): void {
81- // Intake hook is registered in a later layer.
83+ add_action ( 'add_attachment ' , array ( $ this , 'capture_for_attachment ' ), 20 , 1 );
84+ }
85+
86+ /**
87+ * Captures C2PA presence and raw manifest for a freshly created attachment.
88+ *
89+ * Wrapped in a fail-open boundary: issues are recorded in the `errors`
90+ * array inside the persisted postmeta (when this experiment applies to the
91+ * attachment) alongside whatever partial data was collected. This handler
92+ * never throws, never returns an error, and never blocks the upload.
93+ * Unsupported MIME types are left untouched: no postmeta is written.
94+ *
95+ * @since 0.7.0
96+ *
97+ * @param int $attachment_id The newly created attachment ID.
98+ * @return void
99+ */
100+ public function capture_for_attachment ( int $ attachment_id ): void {
101+ $ started_at = microtime ( true );
102+ $ should_persist = true ;
103+ $ errors = array ();
104+ $ source = array (
105+ 'attachment_id ' => $ attachment_id ,
106+ 'original_path_relative ' => '' ,
107+ 'size_bytes ' => 0 ,
108+ 'mime ' => '' ,
109+ );
110+ $ c2pa = array (
111+ 'present ' => false ,
112+ 'format ' => null ,
113+ );
114+
115+ try {
116+ $ mime = (string ) get_post_mime_type ( $ attachment_id );
117+ $ source ['mime ' ] = $ mime ;
118+
119+ if ( ! self ::is_supported_mime ( $ mime ) ) {
120+ $ should_persist = false ;
121+ return ;
122+ }
123+
124+ $ path = self ::get_original_path ( $ attachment_id );
125+ if ( '' === $ path || ! is_readable ( $ path ) ) {
126+ $ errors [] = array (
127+ 'stage ' => 'resolve_path ' ,
128+ 'message ' => 'Attachment file is not readable. ' ,
129+ );
130+ return ;
131+ }
132+
133+ $ size = filesize ( $ path );
134+ if ( false === $ size ) {
135+ $ errors [] = array (
136+ 'stage ' => 'stat ' ,
137+ 'message ' => 'filesize() returned false. ' ,
138+ );
139+ return ;
140+ }
141+
142+ $ source ['size_bytes ' ] = (int ) $ size ;
143+ $ source ['original_path_relative ' ] = self ::relative_to_uploads ( $ path );
144+
145+ if ( $ size > self ::MAX_SCAN_BYTES ) {
146+ $ errors [] = array (
147+ 'stage ' => 'size_cap ' ,
148+ 'message ' => sprintf ( 'File exceeds MAX_SCAN_BYTES (%d). ' , self ::MAX_SCAN_BYTES ),
149+ );
150+ return ;
151+ }
152+
153+ $ detector = new Format_Detector ();
154+ $ format = $ detector ->detect_format ( $ path );
155+ $ c2pa ['format ' ] = $ format ;
156+
157+ if ( null === $ format ) {
158+ return ;
159+ }
160+
161+ $ location = $ detector ->find_manifest_location ( $ path , $ format );
162+ if ( null === $ location ) {
163+ return ;
164+ }
165+
166+ $ reader = new Manifest_Reader ();
167+ $ manifest = $ reader ->read ( $ path , $ location );
168+ if ( null === $ manifest ) {
169+ $ errors [] = array (
170+ 'stage ' => 'read_manifest ' ,
171+ 'message ' => 'Manifest_Reader returned null. ' ,
172+ );
173+ return ;
174+ }
175+
176+ $ writer = new Sidecar_Writer ();
177+ $ rel = $ writer ->write ( $ attachment_id , $ manifest );
178+
179+ $ c2pa = array (
180+ 'present ' => true ,
181+ 'format ' => $ manifest ->format ,
182+ 'container ' => $ manifest ->container ,
183+ 'manifest_sha256 ' => $ manifest ->sha256 ,
184+ 'manifest_length ' => $ manifest ->bytes_length ,
185+ 'sidecar_path_relative ' => $ rel ,
186+ 'decoded ' => null ,
187+ );
188+ } catch ( \RuntimeException $ e ) {
189+ $ errors [] = array (
190+ 'stage ' => 'sidecar_write ' ,
191+ 'message ' => $ e ->getMessage (),
192+ );
193+ } catch ( \Throwable $ e ) {
194+ $ errors [] = array (
195+ 'stage ' => 'unexpected ' ,
196+ 'message ' => $ e ->getMessage (),
197+ );
198+ } finally {
199+ if ( $ should_persist ) {
200+ $ duration_ms = (int ) round ( ( microtime ( true ) - $ started_at ) * 1000 );
201+ Record::store (
202+ $ attachment_id ,
203+ array (
204+ 'schema_version ' => self ::SCHEMA_VERSION ,
205+ 'captured_at ' => gmdate ( 'Y-m-d\TH:i:s\Z ' ),
206+ 'duration_ms ' => $ duration_ms ,
207+ 'source ' => $ source ,
208+ 'traditional ' => array (
209+ 'exif ' => array (),
210+ 'iptc ' => array (),
211+ 'xmp ' => array (),
212+ ),
213+ 'c2pa ' => $ c2pa ,
214+ 'errors ' => $ errors ,
215+ )
216+ );
217+ }
218+ }
82219 }
83220
84221 /**
@@ -96,4 +233,50 @@ public static function is_supported_mime( string $mime ): bool {
96233 true
97234 );
98235 }
236+
237+ /**
238+ * Resolves the absolute path to the original uploaded file.
239+ *
240+ * Falls back to get_attached_file() when wp_get_original_image_path() does
241+ * not return a usable path (non-image attachments, edited media, etc.).
242+ *
243+ * @since 0.7.0
244+ *
245+ * @param int $attachment_id Attachment ID.
246+ * @return string Absolute filesystem path, or empty string when unresolved.
247+ */
248+ private static function get_original_path ( int $ attachment_id ): string {
249+ if ( function_exists ( 'wp_get_original_image_path ' ) ) {
250+ $ path = wp_get_original_image_path ( $ attachment_id );
251+ if ( is_string ( $ path ) && '' !== $ path ) {
252+ return $ path ;
253+ }
254+ }
255+
256+ $ path = get_attached_file ( $ attachment_id );
257+ return is_string ( $ path ) ? $ path : '' ;
258+ }
259+
260+ /**
261+ * Returns the path relative to the uploads basedir, or the absolute path
262+ * if it lives outside uploads.
263+ *
264+ * @since 0.7.0
265+ *
266+ * @param string $absolute Absolute path.
267+ * @return string Relative path or original absolute path.
268+ */
269+ private static function relative_to_uploads ( string $ absolute ): string {
270+ $ uploads = wp_upload_dir ( null , false );
271+ if ( ! is_array ( $ uploads ) || empty ( $ uploads ['basedir ' ] ) ) {
272+ return $ absolute ;
273+ }
274+
275+ $ basedir = trailingslashit ( (string ) $ uploads ['basedir ' ] );
276+ if ( 0 === strpos ( $ absolute , $ basedir ) ) {
277+ return substr ( $ absolute , strlen ( $ basedir ) );
278+ }
279+
280+ return $ absolute ;
281+ }
99282}
0 commit comments