Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions php/class-media.php
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,27 @@ function_exists( 'wp_get_original_image_path' )
return $file_size;
}

/**
* Get the local file path used to upload an attachment.
*
* Mirrors the file resolution in Connect\Api::upload(): the unscaled original when
* `cloudinary_use_original_image` allows it, the attached file otherwise -- e.g. the
* `-scaled` copy WordPress creates for images over `big_image_size_threshold`.
*
* @param int $attachment_id The attachment ID.
*
* @return string|false
*/
public function get_upload_file_path( $attachment_id ) {
/** This filter is documented in php/connect/class-api.php */
$use_original = apply_filters( 'cloudinary_use_original_image', true, $attachment_id );
if ( $use_original && function_exists( 'wp_get_original_image_path' ) && wp_attachment_is_image( $attachment_id ) ) {
return wp_get_original_image_path( $attachment_id );
}

return get_attached_file( $attachment_id );
}

/**
* Get the Cloudinary delivery type.
*
Expand Down
7 changes: 1 addition & 6 deletions php/connect/class-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -560,12 +560,7 @@ public function upload( $attachment_id, $args, $headers = array(), $try_remote =
} else {
// We should have the file in args at this point, but if the transient was set, it will be defaulting here.
if ( empty( $args['file'] ) ) {
if ( wp_attachment_is_image( $attachment_id ) ) {
$get_path_func = $use_original && function_exists( 'wp_get_original_image_path' ) ? 'wp_get_original_image_path' : 'get_attached_file';
$args['file'] = call_user_func( $get_path_func, $attachment_id );
} else {
$args['file'] = get_attached_file( $attachment_id );
}
$args['file'] = $this->media->get_upload_file_path( $attachment_id );
}
// Headers indicate chunked upload.
if ( empty( $headers ) && file_exists( $args['file'] ) ) {
Expand Down
87 changes: 84 additions & 3 deletions php/sync/class-upload-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,14 @@ function ( $is_synced, $post_id ) use ( $attachment_id ) {

// Check that this wasn't an existing.
if ( ! empty( $result['existing'] ) ) {
// If no public_id is recorded in WordPress, this asset in Cloudinary is from a
// failed previous upload. Overwrite it instead of creating a suffixed duplicate.
if ( empty( $suffix ) && ! $this->media->get_post_meta( $attachment_id, Sync::META_KEYS['public_id'], true ) ) {
// A missing public_id in WordPress isn't enough on its own to prove the conflicting
// Cloudinary asset is an orphan of this attachment's own failed upload -- any never
// synced attachment also has no public_id. Only treat it as our own orphan, safe to
// overwrite, when the existing asset's file size also matches the local file.
if ( empty( $suffix )
&& ! $this->media->get_post_meta( $attachment_id, Sync::META_KEYS['public_id'], true )
&& $this->is_matching_existing_asset( $attachment_id, $result )
) {
return $this->upload_asset( $attachment_id, $type, null, true );
}
// Add a suffix and try again.
Expand Down Expand Up @@ -382,6 +387,82 @@ function ( $is_synced, $post_id ) use ( $attachment_id ) {
return $result;
}

/**
* Check whether a Cloudinary "existing" asset is likely this attachment's own local file.
*
* Used to tell apart an orphan left by this same attachment's previously interrupted upload
* of the default (non "folder"/"cloud_name") sync type (safe to overwrite) from an unrelated
* asset that happens to share the same derived public ID, e.g. WordPress reusing a filename
* across months (must not be overwritten). Only called once a public_id is unrecorded, so in
* practice this only ever runs for that default sync type; the other types always have one.
*
* @internal Reachable for testing; not intended to be called from outside this class.
*
* @param int $attachment_id The attachment ID.
* @param array $result The Cloudinary upload result.
*
* @return bool
*/
public function is_matching_existing_asset( $attachment_id, $result ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: public for a method with a single internal caller. Reasonable trade to make it reachable from the test, but it is now plugin API surface that has to keep its signature. An @internal note in the docblock would set the expectation.

Also worth narrowing the docblock's claim of generality: the folder and cloud_name sync types route to Api::copy(), which uploads from a Cloudinary URL, not a local file, and under offload=cld there may be no local file at all. In practice those types only run once a public_id is recorded, so the second clause of the condition short-circuits first and this is never reached, but the docblock reads as though it applies to any upload.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both addressed in 9187312: added an @internal note, and narrowed the docblock to note this only actually runs for the default sync type in practice, since folder/cloud_name route through Api::copy() and, as you noted, only ever get here once a public_id already exists (short-circuiting the first clause). Left it public per your call.

if ( empty( $result['bytes'] ) ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: the fallback direction is right, safe over destructive. But it makes the #1182 fix depend on an undocumented field of the existing response. If that field is ever trimmed, the recovery path dies silently, with no error and no log, and suffixed duplicates quietly come back.

A sync note or debug trace when the check bails out on a missing field would save the next person from bisecting two PRs to work out why.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added — 9187312 logs via Utils::log() (the existing debug-report mechanism, see class-delivery.php/class-responsive-breakpoints.php for the same pattern) when the check bails out for a missing bytes field, so a future response shape change shows up in the debug log instead of silently reverting to the old duplicate-per-cycle behavior.

Utils::log(
sprintf( 'Cloudinary upload result for attachment %d has no "bytes" field; treating as a non-matching asset.', $attachment_id ),
'upload-sync-existing-asset-check'
);

return false;
}
// Byte-identical content between two unrelated attachments isn't proof of ownership: the
// second overwrite would still clobber the first's context and advance its version. Only
// proceed if no other attachment already claims this public ID.
if ( ! $this->is_solely_linked_to( $attachment_id, empty( $result['public_id'] ) ? null : $result['public_id'] ) ) {
return false;
}
$file = $this->media->get_upload_file_path( $attachment_id );
if ( empty( $file ) || ! file_exists( $file ) ) {
return false;
}
if ( (int) filesize( $file ) !== (int) $result['bytes'] ) {
return false;
}
// Hashing a vip:// stream wrapper path pulls the whole object over the network; a failed
// read returns false rather than throwing, which would wrongly read as a mismatch. Bytes
// alone is the safer signal to rely on there.
if ( false !== strpos( $file, 'vip://' ) ) {
return true;
}

// Bytes alone can coincide between unrelated files; confirm with the content hash when available.
return empty( $result['etag'] ) || md5_file( $file ) === $result['etag'];
}

/**
* Check that no other attachment is already tracked as linked to a public ID.
*
* Mirrors the ownership guard Delete_Sync::delete_asset() uses before destroying an asset.
*
* @param int $attachment_id The attachment ID.
* @param string|null $public_id The public ID to check.
*
* @return bool
*/
protected function is_solely_linked_to( $attachment_id, $public_id ) {
if ( empty( $public_id ) ) {
return false;
}
$linked = $this->media->get_linked_attachments( $public_id );
if ( count( $linked ) > 1 ) {
// More than one attachment already shares this public ID.
return false;
}
if ( 1 === count( $linked ) && (int) $attachment_id !== (int) $linked[0] ) {
// Exactly one other attachment is already linked to it.
return false;
}

return true;
}

/**
* Update an assets context..
*
Expand Down
Loading
Loading