Writing a Storage Module
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
coremio/modules/Storage/AcmeVault/
├── AcmeVault.php class AcmeVault extends StorageModule (or CloudStorageModule)
├── config.php ['meta' => [...], 'defaults' => [...]] no credentials here
└── lang/
├── en.php
└── tr.php
StorageConnectionException and StorageAuthException.
encryptedFields() named; encodeStorageConfig() encrypts on save.
Walkthrough
1. Pick the Base Class
- Credentials the operator types: extend
StorageModule, like FTP. - The operator signs in and you hold a refresh token: extend
CloudStorageModule, like GoogleDrive. - The destinations screen shows the connect button only for a cloud subclass.
2. Declare Form and Secrets
- Implement
configuration(). Each field name becomes a key of the config array you receive later. - Implement the static
encryptedFields()and name every secret. A forgotten field is stored in clear text. - Put non-secret defaults in
config.phpunderdefaults; the row is merged on top.
3. The Transfer Methods
- Write
test()so it proves writability: upload a probe, read its size, delete it. - Write
upload(),download(),delete(),list(),ensureDirectory(). All return void, throw on failure. - Override
remoteSize()if your provider has a cheap size call; the default derives it fromlist(). - Attach the progress callback in long transfers, or the watchdog reaps the job.
4. Add the OAuth Half
- Implement
authorizationUrl(),exchangeCode(),refreshToken()andrevokeTokens(). - Do not write a callback route. The shared one validates the state, calls
exchangeCode()and posts the result back. - Declare the token fields as hidden entries in
configuration(): access token, refresh token, expiry, account. - Refresh lazily: check the expiry before each API call.
Reference
StorageModule
// 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;
test(), returns what the form expects.
CloudStorageModule
// $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:
// 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.
| Key | Accepts | What it does |
|---|---|---|
type | text, number, password, checkbox, select, hidden, section, redirect_uri, oauth_connect | The control. The last three are layout: heading, address, sign-in button. |
name | string | The config key, read as $this->config['name']. |
label | string | Visible label, from $this->lang with a literal fallback. |
width | 1 to 12 | Grid columns. |
required | bool | Marks the field mandatory. |
encrypted | bool | Marks a secret for the interface. It does not encrypt: encryptedFields() does, and both must agree. |
value, checked, placeholder, description | mixed | Initial value, tick state, hint, help. |
step, doc_url, doc_label | int, string, string | On a section: its number and a console link. |
Where Credentials Live
type, config as encrypted JSON.
meta and defaults, then merges the passed config over them into $this->config.
Modules::getInstance("Storage", $type, [$config]). The nesting is the constructor argument list.
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().
$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.
<?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;
}
}
<?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' => '',
],
];
// 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
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.
remoteSize() separates "could not answer" (null) from "the file is empty" (zero). Zero for an unanswerable check discards a good archive.
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 third argument of Modules::getInstance("Storage", $type, [$config]) is the constructor's argument list; an unwrapped config spreads across parameters.
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.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.