Duplicate Post Prevention (API)

Every content-creating webhook now checks for existing posts with the same title or slug before inserting a new record. The Post::checkDuplicate() method queries against both fields, excluding trashed posts. When a duplicate is detected, the API returns HTTP 409 with the existing post's details — ID, title, slug, status, and URL — so the calling system can decide how to proceed.

Five webhook endpoints are protected: webhookPublish, webhookSchedule, webhookDraft, webhookContentGenerate, and webhookBulkPublish. The first four return a 409 error; bulk publish silently skips duplicates and reports a skipped count in the response. All endpoints respect force_duplicate: true in the request body to bypass the check when intentional duplicates are needed.

// API 409 Response
{
 "success": false,
 "error": "duplicate_post",
 "existing": {
 "id": 142,
 "title": "Post Title",
 "slug": "post-title",
 "status": "published",
 "url": "https://site.com/post-title"
 }
}

Duplicate Finder (Admin Panel)

A new "Duplicates" button appears on the Posts page. Clicking it scans all published posts for slug patterns ending with -N (where N is 1 through 10) and checks whether the base slug also exists as a separate post. Results appear in a modal with side-by-side comparison of the duplicate and original, linking to each post's editor. Individual posts can be trashed one by one, or all detected duplicates can be removed in a single action.

Featured Image Thumbnail Fallback

The get_featured_image() function now handles missing thumbnail files gracefully. When a sized variant (thumbnail, medium, or card) doesn't exist on disk, the function falls back to the built-in image proxy with appropriate width and height parameters instead of serving the full-size original or returning a 404.

Size dimensions: thumbnail (400x400), card (480x300), medium (800x500), large (1600x1000). The proxy generates the resized version on the fly and caches it for subsequent requests.

Deployment Scope

All changes were applied simultaneously to 14 targets: 11 active sites, the _template directory, and both the root and native JekCMS installations. Each target received 5 file updates — no database migration required.

How checkDuplicate() Works Internally

The method runs two SQL queries in sequence. The first checks for an exact slug match using WHERE slug = ? AND status != 'trashed'. If no match is found, a second query performs a case-insensitive title comparison with WHERE LOWER(title) = LOWER(?) AND status != 'trashed'. This two-step approach catches both exact reposts and content that was republished with a slightly different slug.

Performance Considerations

Both queries use indexed columns. On a site with 12,000 published posts, the combined execution time for both queries averages 1.2ms. The check adds negligible overhead to the publish workflow but prevents the far more expensive problem of manually identifying and cleaning up duplicate content after the fact.

// Post.php - checkDuplicate method
public function checkDuplicate(string $title, string $slug): ?array
{
 // Check slug first (faster, indexed)
 $existing = $this->db->fetch(
 "SELECT id, title, slug, status FROM posts
 WHERE slug = ? AND status != 'trashed' LIMIT 1",
 [$slug]
 );
 if ($existing) return $existing;

 // Then check title (case-insensitive)
 return $this->db->fetch(
 "SELECT id, title, slug, status FROM posts
 WHERE LOWER(title) = LOWER(?) AND status != 'trashed' LIMIT 1",
 [$title]
 );
}

Duplicate Finder: Detection Algorithm

The admin-side duplicate finder uses a different approach than the API check. Instead of comparing against incoming content, it scans the entire posts table for slug patterns that suggest accidental duplication. The algorithm identifies slugs ending with -1 through -10 and looks up the base slug (without the numeric suffix) in the same table.

What Gets Flagged

  • Posts where both example-post and example-post-1 exist
  • Multiple numbered variants: example-post-1, example-post-2, etc.
  • Only posts with status published or draft are checked; trashed posts are excluded
  • The scanner runs as an AJAX request and returns results in under 500ms for most installations

Image Proxy Fallback: Technical Details

When get_featured_image() detects a missing thumbnail variant, it constructs an image proxy URL with explicit dimensions. The proxy endpoint (/includes/image-proxy.php) accepts path, w (width), and h (height) parameters. It reads the original full-size image, resizes it using GD, caches the result as a WebP file in cache/images/, and serves the cached version for all subsequent requests.

Cache Behaviour

Cached proxy images are stored with a filename hash derived from the source path and requested dimensions. Files expire after 7 days or when manually cleared through the admin panel's cache management. The garbage collector runs probabilistically (1% chance per request) to avoid directory bloat.

// Fallback URL construction
$proxyUrl = site_url('/includes/image-proxy.php?path='
 . urlencode($post['featured_image'])
 . '&w=' . $width
 . '&h=' . $height
);

Simultaneous Deployment Across 14 Targets

Deploying to 14 targets required a structured approach. Each target received the same 5 files:

  • classes/Post.php — checkDuplicate() method added
  • api/v1/index.php — 409 checks added to 5 webhook handlers
  • admin/ajax/find-duplicates.php — new file for duplicate scanning
  • admin/posts.php — UI button, modal, and JavaScript for duplicate finder
  • includes/helpers.php — get_featured_image() fallback logic

The deployment was file-only — no SQL migrations, no configuration changes, no service restarts. This made rollback straightforward: replacing any of the five files with its previous version would revert that specific change without affecting the others.