| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/includes/ |
Upload File : |
<?php
declare(strict_types=1);
/** POLANA V18.2 — encrypted, CLI-scheduled backups; no external PHP dependencies. */
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/polana_backup_sql_v182.php';
function pbk_root(): string { return dirname(__DIR__); }
/** V18.3: dedicated private location file stores only a path, never keys or SQL. */
function pbk_location_file(): string { return __DIR__ . '/polana_backup_location_v183.php'; }
function pbk_safe_private_target(string $requested): string {
if ($requested === '' || str_contains($requested, "\0") || $requested[0] !== '/') {
throw new RuntimeException('Podaj bezwzględną ścieżkę linuksową zaczynającą się od /.');
}
$requested = rtrim($requested, '/');
if (basename($requested) !== 'polana-private-backups') {
throw new RuntimeException('Katalog musi kończyć się nazwą polana-private-backups.');
}
$parent = realpath(dirname($requested));
if ($parent === false || !is_dir($parent)) throw new RuntimeException('Katalog nadrzędny nie istnieje — utwórz go w panelu hostingu.');
$target = $parent . '/polana-private-backups';
$resolved = is_dir($target) ? realpath($target) : $target;
if (!is_string($resolved) || $resolved === '') throw new RuntimeException('Nie udało się ustalić lokalizacji.');
$root = realpath(pbk_root());
$documentRoot = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? ''));
foreach ([$root, $documentRoot] as $public) {
if ($public !== false && $public !== null &&
($resolved === $public || str_starts_with($resolved . '/', $public . '/'))) {
throw new RuntimeException('Lokalizacja jest wewnątrz katalogu WWW — odmowa zapisu.');
}
}
// Detect standard public document roots even in CLI, where DOCUMENT_ROOT is unavailable.
if (preg_match('~/(public_html|httpdocs|htdocs|webroot|www)(/|$)~i', $resolved)) {
throw new RuntimeException('Ścieżka prowadzi przez publiczny katalog WWW.');
}
if (is_link($target)) throw new RuntimeException('Katalog backupów nie może być dowiązaniem symbolicznym.');
return $target;
}
function pbk_prepare_private(string $path): string {
$path = pbk_safe_private_target($path);
if (!is_dir($path) && !@mkdir($path, 0700)) {
throw new RuntimeException('PHP nie może utworzyć katalogu poza WWW. Utwórz go w panelu hostingu i nadaj użytkownikowi PHP prawo zapisu.');
}
if (!is_dir($path) || !is_writable($path)) throw new RuntimeException('Katalog istnieje, lecz PHP nie ma do niego prawa zapisu.');
@chmod($path, 0700);
// Test an actual write; is_writable() alone can be misleading with host ACLs.
$probe = $path . '/.writetest-' . bin2hex(random_bytes(8));
$handle = @fopen($probe, 'x+b');
if ($handle === false) throw new RuntimeException('Nie można zapisać pliku testowego w wybranym katalogu.');
fclose($handle); @unlink($probe);
foreach (['daily', 'weekly', 'tmp'] as $name) {
$sub = $path . '/' . $name;
if (!is_dir($sub) && !@mkdir($sub, 0700)) throw new RuntimeException('Nie można utworzyć prywatnego podkatalogu ' . $name);
if (!is_dir($sub) || !is_writable($sub)) throw new RuntimeException('Brak zapisu w prywatnym podkatalogu ' . $name);
@chmod($sub, 0700);
}
return $path;
}
function pbk_private(): string {
$location = pbk_location_file();
if (is_file($location)) {
$selected = require $location;
if (!is_string($selected)) throw new RuntimeException('Nieprawidłowa konfiguracja lokalizacji V18.3.');
return pbk_prepare_private($selected);
}
$root = realpath(pbk_root());
if ($root === false) throw new RuntimeException('Nie znaleziono katalogu witryny.');
// Prefer a sibling of DOCUMENT_ROOT, then a sibling of the project directory.
$doc = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? ''));
$bases = [];
if ($doc !== false) $bases[] = dirname($doc);
$bases[] = dirname($root);
$bases[] = dirname(dirname($root));
$candidates = [];
foreach (array_unique($bases) as $base) {
$base = realpath($base);
if ($base === false || $base === '/' || !is_dir($base)) continue;
try { $candidates[] = pbk_safe_private_target($base . '/polana-private-backups'); }
catch (Throwable $ignored) { }
}
// Locate existing keys/archives first. Never accidentally start a new key elsewhere.
usort($candidates, static function (string $x, string $y): int {
$hasData = static function (string $p): bool {
return is_file($p . '/backup.key') || is_file($p . '/settings.json') || (bool)(glob($p . '/daily/*.pbackup') ?: []);
};
return ((int)$hasData($y)) <=> ((int)$hasData($x));
});
foreach ($candidates as $target) {
try { return pbk_prepare_private($target); }
catch (Throwable $ignored) { /* Next safe candidate. */ }
}
throw new RuntimeException('Brak zapisywalnego katalogu poza WWW. Otwórz /admin/polana-v183-naprawa.php i wskaż prywatną ścieżkę.');
}
function pbk_cfg(): array {
$file = pbk_private() . '/settings.json';
if (!is_file($file)) return ['enabled' => false, 'email' => '', 'email_enabled' => false, 'drive_enabled' => false, 'drive_client_id' => '', 'drive_client_secret' => '', 'drive_refresh_token' => '', 'drive_folder_id' => '', 'key_exported' => false];
$json = json_decode((string)file_get_contents($file), true);
if (!is_array($json)) throw new RuntimeException('Nieprawidłowa konfiguracja backupu.');
return $json;
}
function pbk_save_cfg(array $cfg): void {
$dir = pbk_private();
$tmp = $dir . '/settings.' . bin2hex(random_bytes(6)) . '.tmp';
$json = json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($tmp, $json, LOCK_EX) === false) throw new RuntimeException('Błąd zapisu ustawień.');
@chmod($tmp, 0600);
if (!rename($tmp, $dir . '/settings.json')) { @unlink($tmp); throw new RuntimeException('Błąd zatwierdzania ustawień.'); }
}
function pbk_key(): string {
$file = pbk_private() . '/backup.key';
if (!is_file($file)) {
$key = random_bytes(32);
$handle = @fopen($file, 'x+b');
if ($handle === false) {
if (!is_file($file)) throw new RuntimeException('Nie można utworzyć klucza szyfrowania.');
} else {
@chmod($file, 0600);
if (fwrite($handle, $key) !== 32) throw new RuntimeException('Nie zapisano całego klucza.');
fclose($handle);
}
}
$key = file_get_contents($file);
if (!is_string($key) || strlen($key) !== 32) throw new RuntimeException('Uszkodzony klucz szyfrowania backupów.');
return $key;
}
function pbk_secret_encrypt(string $plaintext): string {
$nonce = random_bytes(12); $tag = '';
$encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', pbk_key(), OPENSSL_RAW_DATA, $nonce, $tag, 'POLANA-V18.2-SECRET');
if (!is_string($encrypted)) throw new RuntimeException('Błąd szyfrowania sekretu.');
return 'gcm:' . base64_encode($nonce . $tag . $encrypted);
}
function pbk_secret_decrypt(string $value): string {
if (!str_starts_with($value, 'gcm:')) return '';
$raw = base64_decode(substr($value, 4), true);
if (!is_string($raw) || strlen($raw) < 29) return '';
$plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', pbk_key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), 'POLANA-V18.2-SECRET');
return is_string($plain) ? $plain : '';
}
/** Authenticated, chunked AES-256-GCM: magic + frames(length:uint32, nonce:12, tag:16, ciphertext) + authenticated EOF. */
function pbk_encrypt_stream($source, string $destination): array {
$out = @fopen($destination, 'x+b');
if ($out === false) throw new RuntimeException('Nie można utworzyć archiwum zaszyfrowanego.');
@chmod($destination, 0600);
$key = pbk_key(); $index = 0; $total = 0;
try {
if (fwrite($out, "POLANABK1\n") !== 10) throw new RuntimeException('Błąd nagłówka.');
while (!feof($source)) {
$plain = fread($source, 1024 * 1024);
if ($plain === false) throw new RuntimeException('Błąd odczytu źródła.');
if ($plain === '') { if (!feof($source)) throw new RuntimeException('Pusty odczyt źródła.'); break; }
$nonce = random_bytes(12); $tag = '';
$aad = "POLANABK1\n" . pack('N', $index);
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if (!is_string($cipher)) throw new RuntimeException('Błąd szyfrowania danych.');
$frame = pack('N', strlen($cipher)) . $nonce . $tag . $cipher;
if (fwrite($out, $frame) !== strlen($frame)) throw new RuntimeException('Niepełny zapis archiwum.');
$total += strlen($plain); $index++;
}
$nonce = random_bytes(12); $tag = '';
$aad = "POLANABK1\n" . pack('N', $index) . 'EOF';
$final = openssl_encrypt('', 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if (!is_string($final) || fwrite($out, pack('N', 0) . $nonce . $tag) !== 32) throw new RuntimeException('Błąd znacznika końca archiwum.');
fflush($out);
} catch (Throwable $e) { fclose($out); @unlink($destination); throw $e; }
fclose($out);
return ['bytes' => $total, 'encrypted_bytes' => filesize($destination), 'sha256' => hash_file('sha256', $destination)];
}
function pbk_read_exact($handle, int $size): string {
$result = '';
while (strlen($result) < $size) {
$part = fread($handle, $size - strlen($result));
if (!is_string($part) || $part === '') throw new RuntimeException('Archiwum niekompletne.');
$result .= $part;
}
return $result;
}
function pbk_decrypt_file(string $encryptedPath, string $target, string $key): void {
if (strlen($key) !== 32) throw new RuntimeException('Klucz musi mieć 32 bajty.');
$in = fopen($encryptedPath, 'rb');
$out = @fopen($target, 'x+b');
if ($out === false) throw new RuntimeException('Plik wyjściowy już istnieje lub nie można go utworzyć.');
@chmod($target, 0600);
try {
if (pbk_read_exact($in, 10) !== "POLANABK1\n") throw new RuntimeException('Nieprawidłowy format archiwum.');
$index = 0;
while (true) {
$length = unpack('N', pbk_read_exact($in, 4))[1];
if ($length > 1024 * 1024) throw new RuntimeException('Nieprawidłowy rozmiar bloku.');
$nonce = pbk_read_exact($in, 12); $tag = pbk_read_exact($in, 16);
$cipher = pbk_read_exact($in, $length);
$aad = "POLANABK1\n" . pack('N', $index) . ($length === 0 ? 'EOF' : '');
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if (!is_string($plain)) throw new RuntimeException('Błędny klucz lub uszkodzony blok.');
if ($length === 0) { if (!feof($in) && fread($in, 1) !== '') throw new RuntimeException('Dodatkowe dane po EOF.'); break; }
if (fwrite($out, $plain) !== strlen($plain)) throw new RuntimeException('Błąd zapisu odszyfrowanego pliku.');
$index++;
}
} catch (Throwable $e) { fclose($out); fclose($in); @unlink($target); throw $e; }
fclose($out); fclose($in);
}
function pbk_verify_encrypted(string $path): bool {
$in = fopen($path, 'rb');
if (!$in) return false;
try {
if (pbk_read_exact($in, 10) !== "POLANABK1\n") return false;
$key = pbk_key(); $index = 0;
while (true) {
$length = unpack('N', pbk_read_exact($in, 4))[1];
if ($length > 1024 * 1024) return false;
$nonce = pbk_read_exact($in, 12); $tag = pbk_read_exact($in, 16);
$cipher = pbk_read_exact($in, $length);
$aad = "POLANABK1\n" . pack('N', $index) . ($length === 0 ? 'EOF' : '');
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if (!is_string($plain)) return false;
if ($length === 0) return fread($in, 1) === '';
$index++;
}
} catch (Throwable $e) { return false; }
finally { fclose($in); }
}
/** V18.3: bounded-memory, streaming gzip -> AES-GCM, no plaintext dump on disk. */
class PbkEncryptedGzipFilter extends php_user_filter {
private $gzip;
private string $buffer = '';
private string $key = '';
private int $frameNo = 0;
private bool $started = false;
public function onCreate(): bool {
$this->gzip = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 6]);
$this->key = pbk_key();
return $this->gzip !== false && strlen($this->key) === 32;
}
private function emit($out, string $frame): void {
$bucket = stream_bucket_new($this->stream, $frame);
stream_bucket_append($out, $bucket);
}
private function cipherFrame(string $plaintext, bool $isEnd = false): string {
$nonce = random_bytes(12); $tag = '';
$aad = "POLANABK1\n" . pack('N', $this->frameNo) . ($isEnd ? 'EOF' : '');
$cipher = openssl_encrypt($plaintext, 'aes-256-gcm', $this->key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if (!is_string($cipher)) throw new RuntimeException('Błąd szyfrowania bloku SQL.');
$this->frameNo++;
return pack('N', strlen($cipher)) . $nonce . $tag . $cipher;
}
private function flushFullBlocks($out): void {
while (strlen($this->buffer) >= 1048576) {
$chunk = substr($this->buffer, 0, 1048576);
$this->buffer = substr($this->buffer, 1048576);
$this->emit($out, $this->cipherFrame($chunk));
}
}
public function filter($in, $out, &$consumed, bool $closing): int {
try {
if (!$this->started) { $this->emit($out, "POLANABK1\n"); $this->started = true; }
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += strlen($bucket->data);
$compressed = deflate_add($this->gzip, $bucket->data, ZLIB_NO_FLUSH);
if (!is_string($compressed)) return PSFS_ERR_FATAL;
$this->buffer .= $compressed;
$this->flushFullBlocks($out);
}
if ($closing) {
$final = deflate_add($this->gzip, '', ZLIB_FINISH);
if (!is_string($final)) return PSFS_ERR_FATAL;
$this->buffer .= $final;
$this->flushFullBlocks($out);
if ($this->buffer !== '') $this->emit($out, $this->cipherFrame($this->buffer));
$this->buffer = '';
$this->emit($out, $this->cipherFrame('', true));
}
return PSFS_PASS_ON;
} catch (Throwable $e) {
error_log('[POLANA V18.3] SQL encryption stream failed: ' . $e->getMessage());
return PSFS_ERR_FATAL;
}
}
}
function pbk_sql_daily(string $date): string {
$file = pbk_private() . '/daily/polana-sql-' . $date . '.sql.gz.pbackup';
if (is_file($file)) {
if (pbk_verify_encrypted($file)) return $file;
// Preserve failed archive for forensics, then allow a fresh attempt that day.
$quarantine = $file . '.corrupt-' . date('His') . '-' . bin2hex(random_bytes(3));
if (!@rename($file, $quarantine)) throw new RuntimeException('Błędnej kopii SQL nie można zachować pod nową nazwą. Nie nadpisano jej.');
$statusOld = pbk_status_path($file);
if (is_file($statusOld)) @rename($statusOld, $quarantine . '.json');
pbk_log('integrity', 'WARN', 'Zachowano uszkodzony SQL do analizy: ' . basename($quarantine));
}
if (!in_array('pbk.encrypted_gzip.v183', stream_get_filters(), true)) {
if (!stream_filter_register('pbk.encrypted_gzip.v183', PbkEncryptedGzipFilter::class)) throw new RuntimeException('Brak filtra szyfrowania.');
}
$tmp = pbk_private() . '/tmp/sql-' . bin2hex(random_bytes(12)) . '.pbackup.part';
$fp = @fopen($tmp, 'x+b');
if ($fp === false) throw new RuntimeException('Nie można utworzyć tymczasowego zaszyfrowanego archiwum SQL.');
@chmod($tmp, 0600);
try {
if (!stream_filter_append($fp, 'pbk.encrypted_gzip.v183', STREAM_FILTER_WRITE)) throw new RuntimeException('Nie udało się dodać filtra szyfrowania.');
pbk_sql_build(db(), $fp, false);
if (!fclose($fp)) throw new RuntimeException('Nie zamknięto archiwum SQL.');
$fp = null;
if (!pbk_verify_encrypted($tmp)) throw new RuntimeException('Nieudana weryfikacja integralności SQL.');
if (is_file($file)) throw new RuntimeException('Kopia z dzisiejszą datą już istnieje; nie nadpisano jej.');
if (!rename($tmp, $file)) throw new RuntimeException('Nie można zatwierdzić kopii SQL.');
@chmod($file, 0600);
} catch (Throwable $e) {
if (is_resource($fp)) fclose($fp);
@unlink($tmp);
throw $e;
}
return $file;
}
function pbk_site_weekly(string $date): string {
$file = pbk_private() . '/weekly/polana-strona-' . $date . '.tar.pbackup';
if (is_file($file)) {
if (pbk_verify_encrypted($file)) return $file;
$quarantine = $file . '.corrupt-' . date('His') . '-' . bin2hex(random_bytes(3));
if (!@rename($file, $quarantine)) throw new RuntimeException('Błędnej kopii strony nie można zachować pod nową nazwą.');
$statusOld = pbk_status_path($file);
if (is_file($statusOld)) @rename($statusOld, $quarantine . '.json');
pbk_log('integrity', 'WARN', 'Zachowano uszkodzony TAR do analizy: ' . basename($quarantine));
}
if (!class_exists('PharData')) throw new RuntimeException('Brak PharData w PHP (nie można spakować witryny).');
$tmp = pbk_private() . '/tmp/site-' . bin2hex(random_bytes(8)) . '.tar';
$root = pbk_root(); $count = 0;
try {
$archive = new PharData($tmp);
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $entry) {
if (!$entry->isFile() || $entry->isLink()) continue;
$rel = str_replace('\\', '/', substr($entry->getPathname(), strlen($root) + 1));
if (preg_match('~(^|/)(\.git|node_modules|vendor-cache|cache|tmp|sessions|\.polana_v174_backups|polana-v15-backups|polana-i18n-v96-backup)(/|$)~i', $rel)) continue;
if (preg_match('~(\.log$|\.bak$|\.tmp$|\.zip$|\.tar$|\.sql$|\.pbackup$|^admin/database_z_zip\.sql$)~i', $rel)) continue;
$archive->addFile($entry->getPathname(), $rel); $count++;
if ($count > 150000) throw new RuntimeException('Zbyt wiele plików do archiwizacji.');
}
unset($archive);
if ($count < 15 || filesize($tmp) < 1024) throw new RuntimeException('Archiwum strony jest podejrzanie małe.');
$stream = fopen($tmp, 'rb');
try { pbk_encrypt_stream($stream, $file); } finally { fclose($stream); }
if (!pbk_verify_encrypted($file)) { @unlink($file); throw new RuntimeException('Nieudana weryfikacja integralności kopii strony.'); }
} finally { @unlink($tmp); }
return $file;
}
function pbk_http(string $url, string $method = 'GET', array $headers = [], string $body = ''): array {
if (!str_starts_with($url, 'https://')) throw new RuntimeException('Wymagany HTTPS.');
$headerString = implode("\r\n", $headers);
$opts = ['http' => ['method' => $method, 'header' => $headerString, 'content' => $body, 'timeout' => 45, 'ignore_errors' => true, 'follow_location' => 0], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]];
$context = stream_context_create($opts);
$result = @file_get_contents($url, false, $context);
$respHeaders = $http_response_header ?? [];
$code = 0;
foreach ($respHeaders as $line) if (preg_match('~^HTTP/\S+\s+(\d+)~i', $line, $m)) $code = (int)$m[1];
if ($code === 0) throw new RuntimeException('Nie można połączyć się z usługą HTTPS.');
return [$code, (string)$result, $respHeaders];
}
function pbk_oauth_redirect_uri(): string { return 'https://mojapolanasmakow.pl/admin/polana-backup-automat.php'; }
function pbk_drive_access_token(array $cfg): string {
$refresh = pbk_secret_decrypt((string)($cfg['drive_refresh_token'] ?? ''));
$secret = pbk_secret_decrypt((string)($cfg['drive_client_secret'] ?? ''));
if ($refresh === '' || $secret === '') throw new RuntimeException('Niepołączone konto Google Drive.');
[$code, $body] = pbk_http('https://oauth2.googleapis.com/token', 'POST', ['Content-Type: application/x-www-form-urlencoded'], http_build_query([
'client_id' => $cfg['drive_client_id'], 'client_secret' => $secret, 'refresh_token' => $refresh, 'grant_type' => 'refresh_token'
]));
$json = json_decode($body, true);
if ($code !== 200 || empty($json['access_token'])) throw new RuntimeException('Google Drive: nie udało się odświeżyć tokenu (HTTP ' . $code . ').');
return (string)$json['access_token'];
}
function pbk_drive_folder(array &$cfg, string $token): string {
if (!empty($cfg['drive_folder_id'])) return (string)$cfg['drive_folder_id'];
[$code, $body] = pbk_http('https://www.googleapis.com/drive/v3/files?fields=id', 'POST', ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], json_encode(['name' => 'POLANA — kopie zapasowe', 'mimeType' => 'application/vnd.google-apps.folder'], JSON_THROW_ON_ERROR));
$json = json_decode($body, true);
if ($code !== 200 && $code !== 201 || empty($json['id'])) throw new RuntimeException('Google Drive: błąd tworzenia folderu (HTTP ' . $code . ').');
$cfg['drive_folder_id'] = (string)$json['id']; pbk_save_cfg($cfg);
return (string)$json['id'];
}
function pbk_drive_upload(string $file, array &$cfg): string {
$token = pbk_drive_access_token($cfg);
$folder = pbk_drive_folder($cfg, $token);
$name = basename($file);
$size = filesize($file);
if (!is_int($size) || $size < 50) throw new RuntimeException('Archiwum jest puste.');
[$code, $body, $headers] = pbk_http('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name,size', 'POST', [
'Authorization: Bearer ' . $token, 'Content-Type: application/json; charset=UTF-8', 'X-Upload-Content-Type: application/octet-stream', 'X-Upload-Content-Length: ' . $size
], json_encode(['name' => $name, 'parents' => [$folder], 'mimeType' => 'application/octet-stream'], JSON_THROW_ON_ERROR));
$session = '';
foreach ($headers as $line) if (stripos($line, 'Location:') === 0) $session = trim(substr($line, 9));
if ($code !== 200 || !str_starts_with($session, 'https://www.googleapis.com/')) throw new RuntimeException('Google Drive: nie udało się rozpocząć wysyłki (HTTP ' . $code . ').');
$in = fopen($file, 'rb'); $offset = 0; $resultId = '';
try {
while ($offset < $size) {
$chunk = fread($in, min(4 * 1024 * 1024, $size - $offset));
if (!is_string($chunk) || $chunk === '') throw new RuntimeException('Błąd odczytu archiwum.');
$last = $offset + strlen($chunk) - 1;
[$http, $response] = pbk_http($session, 'PUT', [
'Authorization: Bearer ' . $token, 'Content-Type: application/octet-stream',
'Content-Length: ' . strlen($chunk), 'Content-Range: bytes ' . $offset . '-' . $last . '/' . $size
], $chunk);
if ($last + 1 < $size && $http !== 308) throw new RuntimeException('Google Drive: niepełna wysyłka (HTTP ' . $http . ').');
if ($last + 1 === $size) {
$json = json_decode($response, true);
if (!in_array($http, [200, 201], true) || empty($json['id'])) throw new RuntimeException('Google Drive: brak potwierdzenia pliku (HTTP ' . $http . ').');
$resultId = (string)$json['id'];
}
$offset = $last + 1;
}
} finally { fclose($in); }
return $resultId;
}
function pbk_send_attachment(string $to, string $file): void {
if (!is_file($file) || filesize($file) > 12 * 1024 * 1024) throw new RuntimeException('Archiwum przekracza limit e-mail 12 MB.');
if (setting('smtp_enabled', '0') !== '1') throw new RuntimeException('SMTP wyłączone.');
$host = trim(setting('smtp_host')); $port = (int)setting('smtp_port', '587');
$security = setting('smtp_security', 'starttls');
$user = trim(setting('smtp_username')); $pass = smtp_password();
$from = smtp_clean_email(filter_var($user, FILTER_VALIDATE_EMAIL) ? $user : setting('smtp_from_email')); $to = smtp_clean_email($to);
$fromName = setting('smtp_from_name', 'Moja Polana Smaków');
$ctx = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false, 'SNI_enabled' => true]]);
$errno = 0; $errstr = '';
$socket = @stream_socket_client(($security === 'ssl' ? 'ssl' : 'tcp') . '://' . $host . ':' . $port, $errno, $errstr, 20, STREAM_CLIENT_CONNECT, $ctx);
if (!$socket) throw new RuntimeException('SMTP: brak połączenia.');
stream_set_timeout($socket, 30);
$helo = 'mojapolanasmakow.pl';
try {
smtp_expect($socket, [220]); smtp_command($socket, 'EHLO ' . $helo, [250]);
if ($security === 'starttls') {
smtp_command($socket, 'STARTTLS', [220]);
if (@stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT) !== true) throw new RuntimeException('SMTP: błąd TLS.');
smtp_command($socket, 'EHLO ' . $helo, [250]);
}
if ($user !== '') { smtp_command($socket, 'AUTH LOGIN', [334]); smtp_command($socket, base64_encode($user), [334]); smtp_command($socket, base64_encode($pass), [235]); }
smtp_command($socket, 'MAIL FROM:<' . $from . '>', [250]);
smtp_command($socket, 'RCPT TO:<' . $to . '>', [250, 251]);
smtp_command($socket, 'DATA', [354]);
$boundary = 'polana-' . bin2hex(random_bytes(12));
$headers = [
'Date: ' . date(DATE_RFC2822), 'From: ' . smtp_header_encode($fromName) . ' <' . $from . '>',
'To: <' . $to . '>', 'Subject: ' . smtp_header_encode('POLANA — zaszyfrowany backup SQL ' . date('Y-m-d')),
'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'
];
$intro = implode("\r\n", $headers) . "\r\n\r\n--$boundary\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" .
"Zaszyfrowana kopia SQL Moja Polana Smaków. Klucz odszyfrowania przechowuj osobno.\r\n" .
"\r\n--$boundary\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"" . basename($file) . "\"\r\n\r\n";
if (fwrite($socket, $intro) !== strlen($intro)) throw new RuntimeException('SMTP: niepełne nagłówki.');
$in = fopen($file, 'rb');
try {
while (!feof($in)) {
$chunk = fread($in, 57 * 500);
if ($chunk === false) throw new RuntimeException('Błąd odczytu załącznika.');
if ($chunk === '') break;
$encoded = chunk_split(base64_encode($chunk), 76, "\r\n");
if (fwrite($socket, $encoded) !== strlen($encoded)) throw new RuntimeException('SMTP: błąd transmisji załącznika.');
}
} finally { fclose($in); }
$tail = "\r\n--$boundary--\r\n.\r\n";
if (fwrite($socket, $tail) !== strlen($tail)) throw new RuntimeException('SMTP: błąd zakończenia wiadomości.');
smtp_expect($socket, [250]);
@smtp_command($socket, 'QUIT', [221]);
} finally { fclose($socket); }
}
function pbk_status_path(string $file): string { return $file . '.json'; }
function pbk_status(string $file): array {
$path = pbk_status_path($file);
if (!is_file($path)) return ['mail' => false, 'drive' => false, 'created' => date(DATE_ATOM), 'sha256' => hash_file('sha256', $file)];
$data = json_decode((string)file_get_contents($path), true);
return is_array($data) ? $data : [];
}
function pbk_save_status(string $file, array $state): void {
$path = pbk_status_path($file);
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($tmp, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), LOCK_EX);
@chmod($tmp, 0600); rename($tmp, $path);
}
function pbk_log(string $kind, string $result, string $message): void {
$line = json_encode(['time' => date(DATE_ATOM), 'kind' => $kind, 'status' => $result, 'message' => substr($message, 0, 400)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$path = pbk_private() . '/history.log';
file_put_contents($path, $line . "\n", FILE_APPEND | LOCK_EX); @chmod($path, 0600);
if (filesize($path) > 1048576) {
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
file_put_contents($path, implode("\n", array_slice($lines, -500)) . "\n", LOCK_EX);
}
}
function pbk_retention(string $kind, int $keep, array $cfg): void {
$files = glob(pbk_private() . '/' . $kind . '/*.pbackup') ?: [];
rsort($files, SORT_STRING);
foreach (array_slice($files, $keep) as $file) {
$state = pbk_status($file);
if (!empty($cfg['drive_enabled']) && empty($state['drive'])) continue;
if ($kind === 'daily' && !empty($cfg['email_enabled']) && empty($state['mail'])) continue;
@unlink($file); @unlink(pbk_status_path($file));
}
}
function pbk_run(string $mode = 'auto'): array {
$cfg = pbk_cfg();
if (empty($cfg['enabled'])) throw new RuntimeException('Automatyczne kopie są wyłączone.');
if (empty($cfg['key_exported'])) throw new RuntimeException('Najpierw pobierz i zachowaj klucz odzyskiwania.');
$lock = fopen(pbk_private() . '/backup.lock', 'c+');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('Backup już jest uruchomiony.');
$summary = []; $hasErrors = false;
try {
@set_time_limit(0);
$today = date('Y-m-d');
if (in_array($mode, ['auto', 'sql'], true)) {
$sql = pbk_sql_daily($today); $summary[] = 'SQL: ' . basename($sql);
$state = pbk_status($sql);
if (!empty($cfg['email_enabled']) && empty($state['mail'])) {
try { pbk_send_attachment((string)$cfg['email'], $sql); $state['mail'] = true; pbk_save_status($sql, $state); pbk_log('sql-mail', 'OK', basename($sql)); }
catch (Throwable $e) { pbk_log('sql-mail', 'ERROR', $e->getMessage()); $summary[] = 'E-mail: błąd (szczegóły w historii)'; $hasErrors = true; }
}
}
if ($mode === 'weekly' || ($mode === 'auto' && date('N') === '7')) {
$weekly = pbk_site_weekly($today); $summary[] = 'Strona: ' . basename($weekly);
}
if (!empty($cfg['drive_enabled'])) {
foreach (['daily', 'weekly'] as $dir) {
foreach (glob(pbk_private() . '/' . $dir . '/*.pbackup') ?: [] as $file) {
$state = pbk_status($file);
if (!pbk_verify_encrypted($file)) { pbk_log('integrity', 'ERROR', 'Archiwum nie przeszło kontroli: ' . basename($file)); $hasErrors = true; continue; }
if (!empty($state['drive'])) continue;
try {
$id = pbk_drive_upload($file, $cfg); $state['drive'] = true; $state['drive_file_id'] = $id;
pbk_save_status($file, $state); pbk_log('drive', 'OK', basename($file));
} catch (Throwable $e) { pbk_log('drive', 'ERROR', $e->getMessage()); $summary[] = 'Drive: błąd wysyłki ' . basename($file); $hasErrors = true; }
}
}
}
pbk_retention('daily', 14, $cfg); pbk_retention('weekly', 4, $cfg);
pbk_log('backup', $hasErrors ? 'WARN' : 'OK', implode('; ', $summary));
if ($hasErrors) throw new RuntimeException('Kopia powstała, ale część wysyłek nie powiodła się. Sprawdź historię.');
return $summary;
} catch (Throwable $e) { pbk_log('backup', 'ERROR', $e->getMessage()); throw $e; }
finally { flock($lock, LOCK_UN); fclose($lock); }
}