developer docs

CMS

WordPress

A plugin that saves every file tinypica finishes to the Media Library.

For a site that publishes on WordPress: a plugin of one file that listens for export.ready, and saves each file tinypica finishes to the Media Library. It was run on a WordPress site as it is shown here.

The plugin

wp-content/plugins/tinypica/tinypica.php
<?php
/**
 * Plugin Name: tinypica
 * Description: Saves every file tinypica finishes to the Media Library, as its webhooks announce them.
 * Version: 1.0.0
 * Requires PHP: 8.1
 */

// In wp-config.php:
//
//   define('TINYPICA_API_KEY', 'tp_…');
//   define('TINYPICA_WEBHOOK_SECRET', 'whsec_…');
//
// Then, on tinypica's API screen, an endpoint with export.ready ticked:
//
//   https://your-site.example/wp-json/tinypica/v1/webhook

defined('ABSPATH') || exit;

const TINYPICA_API = 'https://tinypica.com/api/v1/';

add_action('rest_api_init', function () {
    register_rest_route('tinypica/v1', '/webhook', [
        'methods' => 'POST',
        'callback' => 'tinypica_receive',
        // The signature is the permission: only tinypica can make one.
        'permission_callback' => 'tinypica_signed',
    ]);
});

// Standard Webhooks: an HMAC-SHA256 of "id.timestamp.body", keyed with the secret.
function tinypica_signed(WP_REST_Request $request): bool
{
    $id = (string) $request->get_header('webhook-id');
    $timestamp = (string) $request->get_header('webhook-timestamp');
    // Five minutes either way, so an old delivery cannot be replayed.
    if ($id === '' || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $key = base64_decode(substr(TINYPICA_WEBHOOK_SECRET, strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp." . $request->get_body(), $key, true));
    foreach (explode(' ', (string) $request->get_header('webhook-signature')) as $signature) {
        if (hash_equals($expected, preg_replace('/^v1,/', '', $signature))) {
            return true;
        }
    }
    return false;
}

function tinypica_receive(WP_REST_Request $request): WP_REST_Response
{
    // An event is sent again until it is answered, and an answer can be lost
    // on the way back: each one is taken once, and remembered for a week.
    $seen = 'tinypica_' . md5($request->get_header('webhook-id'));
    if (!get_transient($seen)) {
        set_transient($seen, 1, WEEK_IN_SECONDS);
        $event = $request->get_json_params();
        if ($event['type'] === 'export.ready') {
            // Downloaded by WP-Cron once this is answered: tinypica waits
            // 10 seconds for the answer, and a chapter takes longer to fetch.
            wp_schedule_single_event(time(), 'tinypica_save_export', [$event['data']]);
        }
    }
    return new WP_REST_Response(null, 204);
}

add_action('tinypica_save_export', 'tinypica_save_export');

function tinypica_save_export(array $file): void
{
    // Your key goes to tinypica and nowhere else.
    if (!str_starts_with($file['downloadUrl'], TINYPICA_API)) {
        return;
    }
    require_once ABSPATH . 'wp-admin/includes/file.php';
    require_once ABSPATH . 'wp-admin/includes/media.php';
    require_once ABSPATH . 'wp-admin/includes/image.php';

    $tmp = wp_tempnam($file['filename']);
    $response = wp_remote_get($file['downloadUrl'], [
        'headers' => ['Authorization' => 'Bearer ' . TINYPICA_API_KEY],
        'timeout' => 300,
        'stream' => true,
        'filename' => $tmp,
    ]);
    if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
        @unlink($tmp);
        error_log('tinypica: could not download ' . $file['filename']);
        return;
    }
    $attachment = media_handle_sideload(['name' => $file['filename'], 'tmp_name' => $tmp], 0);
    if (is_wp_error($attachment)) {
        @unlink($tmp);
        error_log('tinypica: ' . $attachment->get_error_message());
        return;
    }
    // Where a theme or another plugin takes over: a manga reader's chapter
    // post, a download link, a message to your team.
    do_action('tinypica_export_saved', $attachment, $file);
}

Install it

  1. Save the file as wp-content/plugins/tinypica/tinypica.php, and activate tinypica under Plugins.
  2. Add the key and the signing secret to wp-config.php, above the line that says to stop editing:
    wp-config.php
    define('TINYPICA_API_KEY', 'tp_…');
    define('TINYPICA_WEBHOOK_SECRET', 'whsec_…');
  3. On the API screen, add an endpoint at your site's address, with export.ready ticked:
    Endpoint URL
    https://your-site.example/wp-json/tinypica/v1/webhook
    The Webhooks card just after Add endpoint: the signing secret with Copy and Done, and the site's endpoint with export.ready ticked
  4. Press Test. A delivered ping means the site answers and the secret matches.

From then on, the file of every chapter that finishes lands in Media, under Library.

How it works

  • The signature check is the route's permission: a delivery without a good one is a 401, and nothing else runs.
  • Each event is taken once. Its webhook-id is remembered for a week, so a repeat is answered and ignored.
  • The download happens after the answer, in a WP-Cron event: tinypica waits ten seconds for an answer, and a chapter can take longer to fetch.
  • The key is only ever sent to tinypica: a downloadUrl anywhere else is skipped.

Taking it further

Once a file is saved, the plugin fires tinypica_export_saved with the attachment's id and the event's data. That is the place to hand the chapter to a manga reader theme, or anything else your site does with it:

PHP
// In your theme's functions.php, or a plugin of your own: a draft post for
// every file, with a link to it, to be finished and published by hand.
add_action('tinypica_export_saved', function (int $attachment, array $file) {
    wp_insert_post([
        'post_title' => "{$file['filename']} ({$file['language']})",
        'post_content' => wp_get_attachment_link($attachment),
        'post_status' => 'draft',
    ]);
}, 10, 2);

On a quiet site

WP-Cron runs when someone visits the site, so on one with few visitors a file can wait. Run it from the system's cron instead, every minute, with WP-CLI:

wp-config.php
define('DISABLE_WP_CRON', true);
crontab
* * * * * cd /var/www/html && wp cron event run --due-now

How deliveries are signed, retried and ordered is in webhooks.