is_file_payload( $value ) ) { return false; } /* * The payload has to describe a file this plugin actually stored. * * `decode()` JSON-decodes any stored value beginning with `{` or `[`, so a * visitor typing `{"url":"https://…","name":"Invoice.pdf"}` into a plain * text field produced an array with those keys — and the entry screen then * rendered it as a download link with an attacker-chosen label pointing at * an attacker-chosen site. `esc_url()` and `rel="noopener noreferrer"` keep * that from being script execution, but it is still a link an administrator * has every reason to trust. * * `managed_path()` requires a regular file the plugin stored, and refuses * the guard files `DirectoryGuard` writes, whatever their case. Checking * only the exact basename here let a directory (`…/formpilot-uploads/2026`) or * `INDEX.PHP` on a case-insensitive filesystem through, and the link was * built from the payload's own `url` - see {@see self::file_url()}. */ return '' !== UploadedFile::from_array( $value )->managed_path(); } /** * Returns the link for a stored upload. * * Built from the verified path, never read from the payload: the stored `url` * is whatever the row says, and a row can be forged by typing JSON into a * text field. * * @param mixed $value Decoded value. * * @return string The URL, or an empty string when the value is not a stored upload. */ public function file_url( mixed $value ): string { return $this->is_file_payload( $value ) ? UploadManager::url_for( (string) $value['file'] ) : ''; } /** * Determines whether a decoded value has the shape of an upload payload. * * Shape only — it says nothing about whether the file is still there, and * nothing about whether the plugin wrote it. Use {@see ValueFormatter::is_file()} * before acting on the path; this is for deciding how to *render* a value. * * @param mixed $value Decoded value. */ public function is_file_payload( mixed $value ): bool { return is_array( $value ) && isset( $value['url'], $value['name'], $value['file'] ); } /** * Renders a decoded value as plain text. * * @param mixed $value Decoded value. */ public function to_text( mixed $value ): string { /* * Rendered on shape, not on whether the file survives. * * `is_file()` answers false once the file is gone — a migration, a changed * uploads directory, a media item deleted by hand — and the generic array * branch below would then print every key of the payload, including the * absolute server path, into the entry screen and into every export. A * missing file is worth showing as its name; it is never worth disclosing * where the server keeps it. */ if ( $this->is_file_payload( $value ) ) { return $this->is_file( $value ) ? $this->file_url( $value ) : (string) $value['name']; } if ( is_array( $value ) ) { $parts = array(); foreach ( $value as $key => $item ) { $text = is_scalar( $item ) ? (string) $item : (string) wp_json_encode( $item ); $parts[] = is_string( $key ) ? $key . ': ' . $text : $text; } return implode( ', ', $parts ); } if ( is_bool( $value ) ) { return $value ? '1' : '0'; } return is_scalar( $value ) ? (string) $value : ''; } /** * Decodes and renders a raw column value in one step. * * @param string $raw Raw column value. */ public function raw_to_text( string $raw ): string { return $this->to_text( $this->decode( $raw ) ); } }