Writing a Storage Module

5 vues Markdown

Add a backup destination by implementing six transfer methods. The operator can register as many accounts on it as they like.

Overview

A Storage module is where backup archives go. It is not a global choice: an operator registers destinations as rows. Credentials do not live in config.php; they arrive in the constructor, per destination, decrypted.

There are two base classes. StorageModule connects with credentials the operator typed; CloudStorageModule adds the OAuth authorization code flow.

Six modules ship. Failure is a thrown StorageException; success returns nothing.

Prerequisites

  • A destination that can store a large file, read it back, list a directory and report a byte size.
  • For a cloud provider, a client id and secret plus the redirect address.
  • Module Anatomy and Module Configuration.

Structure

file layout
coremio/modules/Storage/AcmeVault/
├── AcmeVault.php     class AcmeVault extends StorageModule       (or CloudStorageModule)
├── config.php        ['meta' => [...], 'defaults' => [...]]      no credentials here
└── lang/
    ├── en.php
    └── tr.php
StorageModule The plain base: five abstract transfer methods plus overridable defaults.
CloudStorageModule Adds three abstract OAuth methods, a callback handler and state helpers.
StorageException The failure channel. Narrowed by StorageConnectionException and StorageAuthException.
Backup::buildStorage() Destination row to instance.
Backup::decodeStorageConfig() Decrypts the fields encryptedFields() named; encodeStorageConfig() encrypts on save.
coremio/modules/Storage Your folder goes here.

Walkthrough

1. Pick the Base Class

  1. Credentials the operator types: extend StorageModule, like FTP.
  2. The operator signs in and you hold a refresh token: extend CloudStorageModule, like GoogleDrive.
  3. The destinations screen shows the connect button only for a cloud subclass.

2. Declare Form and Secrets

  1. Implement configuration(). Each field name becomes a key of the config array you receive later.
  2. Implement the static encryptedFields() and name every secret. A forgotten field is stored in clear text.
  3. Put non-secret defaults in config.php under defaults; the row is merged on top.

3. The Transfer Methods

  1. Write test() so it proves writability: upload a probe, read its size, delete it.
  2. Write upload(), download(), delete(), list(), ensureDirectory(). All return void, throw on failure.
  3. Override remoteSize() if your provider has a cheap size call; the default derives it from list().
  4. Attach the progress callback in long transfers, or the watchdog reaps the job.

4. Add the OAuth Half

  1. Implement authorizationUrl(), exchangeCode(), refreshToken() and revokeTokens().
  2. Do not write a callback route. The shared one validates the state, calls exchangeCode() and posts the result back.
  3. Declare the token fields as hidden entries in configuration(): access token, refresh token, expiry, account.
  4. Refresh lazily: check the expiry before each API call.

Reference

StorageModule

signatures
// Config arrives per destination: defaults merged with the row's decrypted credentials.
public function __construct(array $storageConfig = []);

// ── abstract: you must implement all five ──────────────────────────────
abstract public function test(): void;
abstract public function upload(string $localPath, string $remoteName): void;
abstract public function download(string $remoteName, string $localPath): void;
abstract public function delete(string $remoteName): void;
abstract public function ensureDirectory(string $path): void;

// Each entry: ['name' => string, 'size' => int, 'mtime' => int|null]
abstract public function list(string $prefix = ''): array;

// ── virtual: sensible defaults, override when you can do better ────────
public function remoteSize(string $remoteName): ?int;                       // derived from list()
public function downloadStream(string $remoteName, $outputStream): void;    // via a temp file
public function getPresignedDownloadUrl(string $remoteName, int $ttlSeconds = 300): ?string;  // null
public static function encryptedFields(): array;                            // []
public function configuration(): array;                                     // []
public function controller_test(): array;                                   // wraps test() in try/catch

// ── inherited plumbing, do not reimplement ─────────────────────────────
public function setProgressCallback(?callable $cb): void;
public function getProgressCallback(): ?callable;
protected function attachProgressCallback(\CurlHandle $ch, ?int $intervalSec = null): void;

const DEFAULT_FOLDER_PATH   = '/wisecp-backup';
const HEARTBEAT_INTERVAL_SEC = 5;
remoteSize() returning null Means "the provider could not answer", not a failure. The backup is kept as unverified; zero would fail verification.
getPresignedDownloadUrl() A short-lived direct URL; the download redirects to it, so the archive never transits this installation. Null when unavailable.
downloadStream() Override when your client can stream; otherwise the archive is written to a temp file first.
controller_test() Already written: calls test(), returns what the form expects.

CloudStorageModule

signatures
// $state is produced by generateSignedState() and must be echoed to the provider.
abstract public function authorizationUrl(string $state): string;

// Returns ['access_token' => string, 'refresh_token' => string,
//          'expires_at' => int, 'account_email' => ?string]
// Throws StorageAuthException on failure.
abstract public function exchangeCode(string $code): array;

// Mutates $this->config['access_token'] and ['expires_at'] in place.
abstract public function refreshToken(): void;

// Default is a no-op. Override to hit the provider's revoke endpoint.
public function revokeTokens(string $refreshToken): void;

// Already implemented: validates state, calls exchangeCode(), builds the popup payload.
public function callback_handle(): array;

// HMAC-signed state that carries the provisional credentials, so the callback needs no
// session and no database lookup. Default lifetime is ten minutes.
public static function generateSignedState(array $cfg = [], int $ttlSeconds = 600): string;
public static function decodeSignedState(string $state): array|false;

The callback route exists at coremio/modules/Storage/router.php. It maps a slug onto a module name, so a new provider needs one entry:

shared callback
// api/system/backup/{slug}/callback
$providers = ['google-drive' => 'GoogleDrive', 'onedrive' => 'OneDrive', 'yandex-disk' => 'YandexDisk'];

$module = Modules::getInstance("Storage", $providers[$slug], [[]]);
$result = $module->callback_handle();   // {status: connected|error, access_token, refresh_token, ...}
// The result is posted back to the window that opened the popup.

configuration() Descriptor

A flat list of arrays; only type and name are required.

KeyAcceptsWhat it does
typetext, number, password, checkbox, select, hidden, section, redirect_uri, oauth_connectThe control. The last three are layout: heading, address, sign-in button.
namestringThe config key, read as $this->config['name'].
labelstringVisible label, from $this->lang with a literal fallback.
width1 to 12Grid columns.
requiredboolMarks the field mandatory.
encryptedboolMarks a secret for the interface. It does not encrypt: encryptedFields() does, and both must agree.
value, checked, placeholder, descriptionmixedInitial value, tick state, hint, help.
step, doc_url, doc_labelint, string, stringOn a section: its number and a console link.

Where Credentials Live

backup_storage row One row per destination: name, module name as type, config as encrypted JSON.
the constructor merge Reads meta and defaults, then merges the passed config over them into $this->config.
the third factory argument Modules::getInstance("Storage", $type, [$config]). The nesting is the constructor argument list.
the mask Encrypted fields read back as five asterisks; posting that means "unchanged".
revalidation on save An update re-runs test() only when folder_name, folder_path, base_path or remote_directory changed.

Upload Verification

The engine does not trust a clean return from upload().

helpers/Backup.php
$module = Backup::buildStorage($storageId);          // row -> decrypt -> getInstance

// Heartbeat: a slow upload otherwise exceeds the queue's stale window and gets reaped.
$module->setProgressCallback(function ($bytes = 0, $total = 0) use ($queueId, $backupId, $row): void {
    if ($queueId > 0) CronJobQueue::touch($queueId);
    $tot = (int) ($total > 0 ? $total : ($row['file_size'] ?? 0));
    $pct = $tot > 0 ? (int) min(100, round(((int) $bytes) * 100 / $tot)) : 0;
    Backup::updateProgress($backupId, 'upload', $pct, (int) $bytes, $tot);
});

$module->upload($finalArchive, $finalName);

// Then: prove the bytes landed. A clean return is not proof; an interrupted transfer can
// leave a correctly named but truncated file, and that is discovered only when someone
// needs the backup. null means "could not answer" and is recorded as unverified, not failed.
$remoteSize = $module->remoteSize($finalName);
if ($remoteSize !== null && $remoteSize !== $localSize)
    throw new Exception("Upload verification failed: the destination holds {$remoteSize} bytes.");

Example

A plain module, then the two sides that read it back.

AcmeVault.php
<?php
namespace WISECP\Modules\Storage;

use StorageModule;
use StorageException;
use StorageConnectionException;
use Utility;

class AcmeVault extends StorageModule
{
    // Named here, encrypted outside the class on the way into the destination row.
    // A secret missing from this list is stored in clear text.
    public static function encryptedFields(): array
    {
        return ['api_key'];
    }

    public function configuration(): array
    {
        return [
            ['type' => 'text',     'name' => 'bucket',  'width' => 8, 'label' => $this->lang['field-bucket'] ?? 'Bucket', 'required' => true],
            ['type' => 'text',     'name' => 'region',  'width' => 4, 'label' => $this->lang['field-region'] ?? 'Region', 'value' => 'eu-central'],
            ['type' => 'password', 'name' => 'api_key', 'width' => 12, 'label' => $this->lang['field-api-key'] ?? 'API Key', 'required' => true, 'encrypted' => true],

            // Naming a directory field one of the path-like names makes an edit to it
            // re-run test() on save. Anything else is saved without revalidating.
            ['type' => 'text', 'name' => 'remote_directory', 'width' => 12,
             'label' => $this->lang['field-remote-directory'] ?? 'Remote directory',
             'value' => self::DEFAULT_FOLDER_PATH, 'placeholder' => self::DEFAULT_FOLDER_PATH],
        ];
    }

    public function test(): void
    {
        $this->ensureDirectory('');

        // Reachability is not writability. Write a probe, read its size back, remove it.
        $name  = '.wisecp-storage-test-' . bin2hex(random_bytes(4));
        $probe = 'wisecp';
        $tmp   = tempnam(sys_get_temp_dir(), 'wcp-acme-');
        file_put_contents($tmp, $probe);

        try {
            $this->upload($tmp, $name);

            $size = $this->remoteSize($name);
            $this->delete($name);

            // null means the provider could not answer, which proves nothing either way.
            if ($size !== null && $size !== strlen($probe))
                throw new StorageException(sprintf(
                    $this->lang['error-upload-incomplete'] ?? 'Upload incomplete: the server stored %s of %s bytes',
                    $size, strlen($probe)
                ));
        }
        finally {
            @unlink($tmp);
        }
    }

    public function upload(string $localPath, string $remoteName): void
    {
        if (!is_file($localPath)) throw new StorageException("Local file not found: {$localPath}");

        $handle = fopen($localPath, 'rb');
        if (!$handle) throw new StorageException("Local file not readable: {$localPath}");

        $ch = curl_init($this->endpoint($remoteName));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_UPLOAD         => true,
            CURLOPT_INFILE         => $handle,
            CURLOPT_INFILESIZE     => filesize($localPath),
            CURLOPT_HTTPHEADER     => $this->headers(),
        ]);

        // Without this the watchdog sees no activity and can reap a slow upload as stale.
        $this->attachProgressCallback($ch);

        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);
        fclose($handle);

        if ($err !== '')            throw new StorageConnectionException($err);
        if ($code < 200 || $code > 299) throw new StorageException("Upload failed (HTTP {$code}): " . (string) $body);
    }

    public function download(string $remoteName, string $localPath): void
    {
        $out = fopen($localPath, 'wb');
        if (!$out) throw new StorageException("Cannot write to {$localPath}");

        try     { $this->downloadStream($remoteName, $out); }
        finally { fclose($out); }
    }

    public function delete(string $remoteName): void
    {
        $this->request('DELETE', $remoteName);
    }

    public function list(string $prefix = ''): array
    {
        $data = Utility::jdecode($this->request('GET', $prefix), true);

        $out = [];
        foreach (($data['objects'] ?? []) as $item)
            $out[] = [
                'name'  => (string) ($item['key'] ?? ''),
                'size'  => (int) ($item['bytes'] ?? 0),
                'mtime' => isset($item['modified']) ? (int) strtotime((string) $item['modified']) : null,
            ];

        return $out;
    }

    // Cheaper than the inherited version, which reads a whole directory listing.
    public function remoteSize(string $remoteName): ?int
    {
        try     { $head = Utility::jdecode($this->request('HEAD', $remoteName), true); }
        catch   (\Throwable $e) { return null; }

        return isset($head['bytes']) ? (int) $head['bytes'] : null;
    }

    public function ensureDirectory(string $path): void
    {
        $this->request('MKCOL', $path);
    }

    private function endpoint(string $name): string
    {
        $base = trim((string) ($this->config['remote_directory'] ?? self::DEFAULT_FOLDER_PATH), '/');

        return 'https://' . rawurlencode((string) ($this->config['region'] ?? '')) . '.acmevault.example/'
             . rawurlencode((string) ($this->config['bucket'] ?? '')) . '/' . trim($base . '/' . $name, '/');
    }

    private function headers(): array
    {
        // The key arrives already decrypted: decoding happened when the row was read.
        return ['X-Api-Key: ' . (string) ($this->config['api_key'] ?? '')];
    }

    private function request(string $method, string $name): string
    {
        $ch = curl_init($this->endpoint($name));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HTTPHEADER     => $this->headers(),
            CURLOPT_TIMEOUT        => 30,
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);

        if ($err !== '')            throw new StorageConnectionException($err);
        if ($code < 200 || $code > 299) throw new StorageException("{$method} failed (HTTP {$code})");

        return (string) $body;
    }
}
config.php
<?php
return [
    'meta' => [
        'name'        => 'AcmeVault',
        'version'     => '1.0',
        'description' => 'Object storage over HTTPS',
        'icon'        => 'bi bi-hdd-rack',
    ],
    // Merged UNDER the destination row, so a row value always wins. Credentials never
    // appear here: they belong to the destination, not to the module.
    'defaults' => [
        'region'           => 'eu-central',
        'remote_directory' => '',
    ],
];
core side, do not copy
// 1. The destinations screen builds the provider list and your form from the module itself.
$module = Modules::getInstance("Storage", $name, [[]]);   // empty config: metadata only
$entry  = [
    'key'         => $name,
    'name'        => $module->lang['name'] ?? $name,
    'description' => $module->lang['description'] ?? ($module->meta['description'] ?? ''),
    'oauth'       => is_subclass_of("WISECP\\Modules\\Storage\\{$name}", "CloudStorageModule"),
    'fields'      => $module->configuration(),
];

// 2. Saving encrypts exactly the fields encryptedFields() named, then stores the JSON.
WDB::insert("backup_storage", [
    'name'   => $displayName,
    'type'   => $name,
    'config' => Backup::encodeStorageConfig($name, $config),
    'status' => 'enabled',
]);

// 3. Running a backup goes the other way: read the row, decrypt, instantiate, transfer.
$module = Backup::buildStorage($storageId);
$module->upload($finalArchive, $finalName);

Pitfalls

A secret missing from encryptedFields() is readable

The encrypted flag on a form field only styles the input; encryption is driven by the static list. The failure is silent: the value sits readable in the database.

Never report zero when you cannot measure

remoteSize() separates "could not answer" (null) from "the file is empty" (zero). Zero for an unanswerable check discards a good archive.

A silent upload is reaped as stale

Uploads run inside a queued job with a stale window that a large transfer can outlast. Without the progress callback the job is killed mid-flight.

The factory nests the config one level deeper

The third argument of Modules::getInstance("Storage", $type, [$config]) is the constructor's argument list; an unwrapped config spreads across parameters.

Revoke on disconnect

Deleting a cloud destination calls revokeTokens() first, best effort: the row is removed either way. Without it a removed destination leaves a live refresh token.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.