# Depolama Modülü Yazma

https://dev.wisecp.com/tr/depolama-modulu-yazma

Altı aktarım metodu uygulayarak yeni bir yedek hedefi ekleyin. Operatör o hedefte istediği kadar hesap kaydedebilir.

## Genel Bakış

Depolama modülü, yedek arşivlerinin gittiği yerdir. Küresel bir seçim değildir: operatör hedefleri satır satır kaydeder. Kimlik bilgileri `config.php` içinde durmaz; hedef başına, çözülmüş gelir.

İki taban sınıfı vardır. `StorageModule` operatörün yazdığı kimlik bilgileriyle bağlanır; `CloudStorageModule` OAuth yetkilendirme kodu akışını ekler.

Altı modül gelir. Başarısızlık bir `StorageException` fırlatılarak bildirilir; başarı hiçbir şey döndürmez.

## Ön Koşullar

- Büyük dosya saklayan, geri okuyan, dizin listeleyen ve bayt boyutu bildiren bir hedef.
- Bulut sağlayıcı için client id, secret ve yönlendirme adresi.
- [Modül Anatomisi](https://dev.wisecp.com/tr/modul-anatomisi) ve [Modül Yapılandırması](https://dev.wisecp.com/tr/modul-yapilandirmasi).

## Yapı

```bash
coremio/modules/Storage/AcmeVault/
├── AcmeVault.php     class AcmeVault extends StorageModule       (ya da CloudStorageModule)
├── config.php        ['meta' => [...], 'defaults' => [...]]      kimlik bilgisi burada tutulmaz
└── lang/
    ├── en.php
    └── tr.php
```

- **StorageModule**: Düz taban: beş soyut metot ve geçersiz kılınabilir varsayılanlar.
- **CloudStorageModule**: Üç soyut OAuth metodu ve bir geri çağrı işleyicisi ekler.
- **StorageException**: Hata kanalı; `StorageConnectionException` ve `StorageAuthException` daraltır.
- **Backup::buildStorage()**: Hedef satırını örneğinize çevirir.
- **Backup::decodeStorageConfig()**: `encryptedFields()` ile adlandırılan alanları çözer; `encodeStorageConfig()` kayıtta şifreler.
- **coremio/modules/Storage**: Klasörünüz buraya girer.

## Adım Adım

### 1. Taban Sınıfı Seçin

1. Operatörün yazdığı kimlik bilgileri varsa `StorageModule` genişletin, FTP gibi.
2. Operatör oturum açıyor ve siz yenileme jetonu tutuyorsanız `CloudStorageModule` genişletin, GoogleDrive gibi.
3. Bağlan butonu yalnız bulut alt sınıfında görünür.

### 2. Formu ve Sırları Bildirin

1. `configuration()` uygulayın. Her alan adı config dizisinin anahtarı olur.
2. Statik `encryptedFields()` uygulayın ve her sırrı adlandırın. Unuttuğunuz alan düz metin saklanır.
3. Sır olmayan varsayılanları `config.php` içindeki `defaults` altına koyun; satır üzerine birleştirilir.

### 3. Aktarım Metotlarını Uygulayın

1. `test()` yazılabilirliği kanıtlamalı: yoklama dosyası yükleyin, boyutunu okuyun, silin.
2. `upload()`, `download()`, `delete()`, `list()`, `ensureDirectory()` yazın. Hepsi void döner, hatada fırlatır.
3. Sağlayıcınızda ucuz bir boyut çağrısı varsa `remoteSize()` geçersiz kılın; varsayılan onu `list()` üzerinden türetir.
4. Uzun aktarımlara ilerleme geri çağrısını bağlayın.

### 4. OAuth Yarısını Ekleyin

1. `authorizationUrl()`, `exchangeCode()`, `refreshToken()` ve `revokeTokens()` uygulayın.
2. Geri çağrı rotası yazmayın. Ortak işleyici imzalı durumu doğrular, `exchangeCode()` çağırır ve sonucu geri gönderir.
3. Jeton alanlarını `configuration()` içinde gizli kayıt olarak bildirin: erişim jetonu, yenileme jetonu, bitiş zamanı, hesap.
4. Tembel yenileyin: her çağrıdan önce bitiş zamanına bakın.

## Referans

### StorageModule

```php
// Config hedef başına gelir: varsayılanlar, satırın çözülmüş kimlik bilgileriyle birleşmiş hâlde.
public function __construct(array $storageConfig = []);

// ── soyut: beşini de uygulamak zorundasınız ────────────────────────────
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;

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

// ── sanal: makul varsayılanlar, daha iyisini yapabiliyorsanız ezin ─────
public function remoteSize(string $remoteName): ?int;                       // list() üzerinden türetilir
public function downloadStream(string $remoteName, $outputStream): void;    // geçici dosya üzerinden
public function getPresignedDownloadUrl(string $remoteName, int $ttlSeconds = 300): ?string;  // null
public static function encryptedFields(): array;                            // []
public function configuration(): array;                                     // []
public function controller_test(): array;                                   // test() metodunu try/catch içine sarar

// ── devralınan tesisat, yeniden uygulamayın ────────────────────────────
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() null döndürmesi**: "Sağlayıcı yanıtlayamadı" demektir, başarısızlık değil. Yedek doğrulanmamış saklanır; sıfır doğrulamayı düşürür.
- **getPresignedDownloadUrl()**: Kısa ömürlü doğrudan adres; indirme oraya yönlenir. Karşılığı yoksa null kalır.
- **downloadStream()**: İstemciniz akış yapabiliyorsa geçersiz kılın; aksi hâlde arşiv geçici dosyaya yazılır.
- **controller_test()**: Zaten yazılmış: `test()` çağırıp formun beklediği biçimi döndürür.

### CloudStorageModule

```php
// $state değerini generateSignedState() üretir ve sağlayıcıya aynen iletilmelidir.
abstract public function authorizationUrl(string $state): string;

// Şunu döndürür: ['access_token' => string, 'refresh_token' => string,
//          'expires_at' => int, 'account_email' => ?string]
// Hatada StorageAuthException fırlatır.
abstract public function exchangeCode(string $code): array;

// $this->config['access_token'] ve ['expires_at'] değerlerini yerinde değiştirir.
abstract public function refreshToken(): void;

// Varsayılanı boş işlemdir. Sağlayıcının iptal ucuna gitmek için ezin.
public function revokeTokens(string $refreshToken): void;

// Zaten uygulanmış: durumu doğrular, exchangeCode() çağırır, popup yükünü kurar.
public function callback_handle(): array;

// Geçici kimlik bilgilerini taşıyan HMAC imzalı durum; böylece geri çağrı ne oturuma
// ne de veritabanı sorgusuna ihtiyaç duyar. Varsayılan ömrü on dakikadır.
public static function generateSignedState(array $cfg = [], int $ttlSeconds = 600): string;
public static function decodeSignedState(string $state): array|false;
```

Geri çağrı rotası `coremio/modules/Storage/router.php` içindedir; slug'ı modül adına eşler. Yeni sağlayıcı için bir kayıt yeter:

```php
// 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, ...}
// Sonuç, popup'ı açan pencereye geri gönderilir.
```

### configuration() Tanımı

Düz bir dizi listesi; yalnız `type` ve `name` zorunludur.

| Anahtar | Kabul ettiği | Ne yapar |
| --- | --- | --- |
| `type` | `text`, `number`, `password`, `checkbox`, `select`, `hidden`, `section`, `redirect_uri`, `oauth_connect` | Kontrolü seçer. Son üçü yerleşimdir: başlık, adres, oturum butonu. |
| `name` | dize | Config anahtarı; `$this->config['name']` olarak okunur. |
| `label` | dize | Görünen etiket; `$this->lang` üzerinden düz bir yedekle okuyun. |
| `width` | 1 ile 12 arası | Izgara sütunu. |
| `required` | bool | Alanı zorunlu işaretler. |
| `encrypted` | bool | Arayüz için sır işaretidir. Hiçbir şeyi **şifrelemez**: şifreleme `encryptedFields()` ile olur, ikisi uyuşmalıdır. |
| `value`, `checked`, `placeholder`, `description` | karışık | Başlangıç değeri, işaret durumu, ipucu, yardım metni. |
| `step`, `doc_url`, `doc_label` | int, dize, dize | `section` kaydında: numarası ve konsol bağlantısı. |

### Kimlik Bilgileri Nerede Durur

- **backup_storage satırı**: Hedef başına bir satır: ad, `type`, şifreli JSON `config`.
- **yapıcıdaki birleştirme**: `meta` ve `defaults` okur, sonra geçirilen config'i üzerine `$this->config` içinde birleştirir.
- **fabrikanın üçüncü argümanı**: `Modules::getInstance("Storage", $type, [$config])`. Sarmalama yapıcının argüman listesidir.
- **maske**: Şifreli alanlar beş yıldız olarak geri okunur; o değeri göndermek "değişmedi" demektir.
- **kayıtta yeniden doğrulama**: Güncelleme `test()` metodunu yalnız `folder_name`, `folder_path`, `base_path` ya da `remote_directory` değiştiyse tekrar çalıştırır.

### Yükleme Doğrulaması

Yedek motoru `upload()` metodundan temiz dönmeye güvenmez.

```php
$module = Backup::buildStorage($storageId);          // satır -> çöz -> getInstance

// Kalp atışı: yoksa yavaş bir yükleme kuyruğun bayatlama penceresini aşar ve sonlandırılır.
$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);

// Sonra: baytların indiğini kanıtlayın. Temiz dönüş kanıt değildir; kesilen bir aktarım
// doğru adlı ama kırpılmış bir dosya bırakabilir ve bu ancak birinin yedeğe ihtiyacı
// olduğunda fark edilir. null "yanıtlayamadım" demektir ve başarısız değil doğrulanmamış kaydedilir.
$remoteSize = $module->remoteSize($finalName);
if ($remoteSize !== null && $remoteSize !== $localSize)
    throw new Exception("Upload verification failed: the destination holds {$remoteSize} bytes.");
```

## Örnek

Düz bir hedef modülü, sonra onu geri okuyan iki taraf.

```php
<?php
namespace WISECP\Modules\Storage;

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

class AcmeVault extends StorageModule
{
    // Burada adlandırılır, sınıfın dışında hedef satırına girerken şifrelenir.
    // Bu listede olmayan bir sır düz metin olarak saklanır.
    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],

            // Bir dizin alanına yol benzeri adlardan birini vermek, o alandaki düzenlemenin
            // kayıtta test() metodunu yeniden koşturmasını sağlar. Diğerleri yeniden doğrulanmadan kaydedilir.
            ['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('');

        // Erişilebilirlik yazılabilirlik değildir. Bir yoklama yazın, boyutunu geri okuyun, silin.
        $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 sağlayıcının yanıtlayamadığı anlamına gelir, bu da hiçbir yönde kanıt değildir.
            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(),
        ]);

        // Bu olmadan gözcü hiç etkinlik görmez ve yavaş bir yüklemeyi bayat sayıp sonlandırabilir.
        $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;
    }

    // Dizin listesinin tamamını okuyan devralınan sürümden daha ucuz.
    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
    {
        // Anahtar çözülmüş olarak gelir: çözme, satır okunurken yapıldı.
        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
<?php
return [
    'meta' => [
        'name'        => 'AcmeVault',
        'version'     => '1.0',
        'description' => 'Object storage over HTTPS',
        'icon'        => 'bi bi-hdd-rack',
    ],
    // Hedef satırının ALTINA birleşir, yani satırdaki değer her zaman kazanır. Kimlik
    // bilgileri burada asla yer almaz: onlar modüle değil hedefe aittir.
    'defaults' => [
        'region'           => 'eu-central',
        'remote_directory' => '',
    ],
];
```

```php
// 1. Hedefler ekranı sağlayıcı listesini ve formunuzu modülün kendisinden kurar.
$module = Modules::getInstance("Storage", $name, [[]]);   // boş config: yalnız üst veri
$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. Kayıt tam olarak encryptedFields() metodunun adlandırdığı alanları şifreler, sonra JSON'u saklar.
WDB::insert("backup_storage", [
    'name'   => $displayName,
    'type'   => $name,
    'config' => Backup::encodeStorageConfig($name, $config),
    'status' => 'enabled',
]);

// 3. Yedek koşusu ters yöne gider: satırı oku, çöz, örneği kur, aktar.
$module = Backup::buildStorage($storageId);
$module->upload($finalArchive, $finalName);
```

## Tuzaklar

> **encryptedFields() dışındaki sır okunabilir kalır**
> 
> Form alanındaki `encrypted` yalnız görünümü belirler; şifrelemeyi statik liste yürütür. Arıza sessizdir: değer veritabanında okunabilir durur.

> **Ölçemiyorsanız asla sıfır bildirmeyin**
> 
> `remoteSize()` "yanıtlayamadım" (null) ile "dosya boş" (sıfır) arasını ayırır. Yanıtlanamayan kontrole sıfır döndürmek sağlam bir arşivi çöpe atar.

> **Sessiz yükleme bayat sayılır**
> 
> Yüklemeler bayatlama penceresi olan kuyruk işlerinde çalışır; büyük aktarım pencereyi aşabilir. İlerleme geri çağrısı olmazsa iş yarıda kesilir.

> **Fabrika config'i bir seviye sarmalı alır**
> 
> `Modules::getInstance("Storage", $type, [$config])` üçüncü argümanı yapıcının argüman listesidir; sarmalanmamış config değerlere dağılır.

> **Bağlantı kesilirken jetonu iptal edin**
> 
> Bir bulut hedefini silmek önce `revokeTokens()` çağırır; satır her hâlükârda kaldırılır. Uygulamazsanız sağlayıcıda çalışan bir yenileme jetonu kalır.

## İlgili Makaleler

- [Modül Anatomisi](https://dev.wisecp.com/tr/modul-anatomisi)
- [Modül Yapılandırması](https://dev.wisecp.com/tr/modul-yapilandirmasi)
- [Zamanlanmış Görev Ekleme](https://dev.wisecp.com/tr/zamanlanmis-gorev-ekleme)
- [Hata Yönetimi](https://dev.wisecp.com/tr/hata-yonetimi)
- [Sosyal Giriş Sağlayıcısı Yazma](https://dev.wisecp.com/tr/sosyal-giris-saglayicisi-yazma)
- [Güvenlik Pratikleri](https://dev.wisecp.com/tr/guvenlik-pratikleri)
