v1.3.0
AtheraStudio SyncDrop
- <?php
- declare(strict_types=1);
-
- defined('SD_BOOT') || exit;
-
- const SD_NAME = 'AtheraStudio SyncDrop';
- define('SD_VERSION', (static function (): string {
- $version = trim((string)@file_get_contents(dirname(__DIR__) . '/version.txt'));
- return preg_match('/^\d{1,4}(\.\d{1,4}){1,3}$/', $version) ? $version : '0.0.0';
- })());
- const SD_IDLE = 900;
- const SD_ABSOLUTE = 28800;
- const SD_CHUNK = 1048576;
- const SD_MAX_FILE = 268435456;
- const SD_MAX_FILES = 20000;
- const SD_MAX_BATCH = 1000;
- const SD_KEEP_BACKUPS = 10;
- const SD_PREFIX = '.syncdrop';
- const SD_ID_FILE = '.syncdrop-id';
- const SD_WEBROOTS = ['public_html', 'private_html', 'www', 'htdocs', 'httpdocs', 'html', 'web', 'wwwroot', 'public'];
- const SD_STRONG_WEBROOTS = ['public_html', 'private_html', 'htdocs', 'httpdocs', 'wwwroot'];
-
- define('SD_ROOT', dirname(__DIR__));
-
- function h(string $value): string
- {
- return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
- }
-
- function sd_config(): array
- {
- static $config = null;
- if ($config !== null) return $config;
- $config = [];
- $file = SD_ROOT . '/config.php';
- if (is_file($file)) {
- $loaded = (static fn(string $f): mixed => require $f)($file);
- if (is_array($loaded)) $config = $loaded;
- }
- return $config;
- }
-
- function sd_https(): bool
- {
- $https = strtolower((string)($_SERVER['HTTPS'] ?? ''));
- if ($https !== '' && $https !== 'off') return true;
- if ((string)($_SERVER['SERVER_PORT'] ?? '') === '443') return true;
- if (!sd_via_proxy()) return false;
- return strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
- }
-
- function sd_local(): bool
- {
- return in_array((string)($_SERVER['REMOTE_ADDR'] ?? ''), ['127.0.0.1', '::1'], true);
- }
-
- function sd_via_proxy(): bool
- {
- static $via = null;
- if ($via !== null) return $via;
- $remote = (string)($_SERVER['REMOTE_ADDR'] ?? '');
- $via = false;
- foreach ((array)(sd_config()['proxies'] ?? []) as $cidr) {
- if (is_string($cidr) && sd_cidr_contains($cidr, $remote)) return $via = true;
- }
- return $via;
- }
-
- function sd_ip(): string
- {
- static $ip = null;
- if ($ip !== null) return $ip;
- $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
- $header = (string)(sd_config()['ip_header'] ?? '');
- if ($header !== '' && preg_match('/^HTTP_[A-Z0-9_]{1,60}$/', $header) && sd_via_proxy()) {
- $parts = array_map('trim', explode(',', (string)($_SERVER[$header] ?? '')));
- $candidate = (string)end($parts);
- if (filter_var($candidate, FILTER_VALIDATE_IP)) $ip = $candidate;
- }
- if (!filter_var($ip, FILTER_VALIDATE_IP)) $ip = '0.0.0.0';
- if (str_starts_with(strtolower($ip), '::ffff:') && filter_var(substr($ip, 7), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) $ip = substr($ip, 7);
- return $ip;
- }
-
- function sd_cidr_parse(string $value): ?array
- {
- $value = trim($value);
- $bits = null;
- if (str_contains($value, '/')) {
- [$value, $suffix] = explode('/', $value, 2);
- if (!preg_match('/^\d{1,3}$/', $suffix)) return null;
- $bits = (int)$suffix;
- }
- if (!filter_var($value, FILTER_VALIDATE_IP)) return null;
- $packed = @inet_pton($value);
- if ($packed === false) return null;
- $max = strlen($packed) * 8;
- $bits ??= $max;
- if ($bits < 0 || $bits > $max) return null;
- $net = '';
- for ($i = 0; $i < strlen($packed); $i++) {
- $keep = max(0, min(8, $bits - $i * 8));
- $net .= chr(ord($packed[$i]) & ((0xFF << (8 - $keep)) & 0xFF));
- }
- $text = (string)inet_ntop($net);
- return ['net' => $net, 'bits' => $bits, 'max' => $max, 'text' => $bits === $max ? $text : $text . '/' . $bits];
- }
-
- function sd_cidr_contains(string $cidr, string $ip): bool
- {
- $range = sd_cidr_parse($cidr);
- $addr = sd_cidr_parse($ip);
- if ($range === null || $addr === null || $range['max'] !== $addr['max']) return false;
- $full = intdiv($range['bits'], 8);
- if (strncmp($range['net'], $addr['net'], $full) !== 0) return false;
- $rest = $range['bits'] % 8;
- if ($rest === 0) return true;
- $mask = (0xFF << (8 - $rest)) & 0xFF;
- return (ord($range['net'][$full]) & $mask) === (ord($addr['net'][$full]) & $mask);
- }
-
- function sd_ua(): string
- {
- return mb_substr(preg_replace('/[\x00-\x1F\x7F]/u', '', (string)($_SERVER['HTTP_USER_AGENT'] ?? '')) ?? '', 0, 300);
- }
-
- function sd_host(): string
- {
- $host = strtolower((string)($_SERVER['HTTP_HOST'] ?? ''));
- return preg_match('/^(\[[0-9a-f:.]{2,45}\]|[a-z0-9.-]{1,253})(:\d{1,5})?$/', $host) ? $host : '';
- }
-
- function sd_norm(string $path): string
- {
- $path = rtrim(str_replace('\\', '/', $path), '/');
- return $path === '' ? '/' : $path;
- }
-
- function sd_app(): string
- {
- return realpath(SD_ROOT) ?: SD_ROOT;
- }
-
- function sd_docroot(): string
- {
- static $doc = null;
- if ($doc !== null) return $doc;
- $raw = trim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''));
- $doc = $raw === '' ? false : realpath($raw);
- if ($doc === false || $doc === '' || !sd_inside(sd_app(), $doc)) $doc = sd_app();
- return $doc;
- }
-
- function sd_inside(string $path, string $root): bool
- {
- $path = rtrim(sd_norm($path), '/') . '/';
- $root = rtrim(sd_norm($root), '/') . '/';
- if (DIRECTORY_SEPARATOR === '\\') {
- $path = strtolower($path);
- $root = strtolower($root);
- }
- return strncmp($path, $root, strlen($root)) === 0;
- }
-
- function sd_webroot_named(string $dir, array $names): bool
- {
- return in_array(strtolower(basename(sd_norm($dir))), $names, true);
- }
-
- function sd_under_strong_webroot(string $dir): bool
- {
- $dir = sd_norm($dir);
- while (true) {
- if (sd_webroot_named($dir, SD_STRONG_WEBROOTS)) return true;
- $parent = sd_norm(dirname($dir));
- if ($parent === $dir) return false;
- $dir = $parent;
- }
- }
-
- function sd_base_candidates(): array
- {
- $doc = sd_docroot();
- $list = [];
- $dir = $doc;
- while (true) {
- $parent = dirname($dir);
- if ($parent === $dir) break;
- if (sd_webroot_named($dir, SD_WEBROOTS)) $list[] = $parent;
- $dir = $parent;
- }
- $list[] = dirname($doc);
- return array_values(array_unique($list));
- }
-
- function sd_base_ok(string $dir): bool
- {
- if ($dir === '' || !is_dir($dir) || !is_writable($dir) || dirname($dir) === $dir) return false;
- return !sd_inside($dir, sd_docroot()) && !sd_inside($dir, sd_app());
- }
-
- function sd_base(): ?string
- {
- static $base = false;
- if ($base !== false) return $base;
- $base = null;
- $custom = trim((string)(sd_config()['base'] ?? ''));
- if ($custom !== '') {
- $real = realpath($custom);
- if ($real !== false && sd_base_ok($real)) $base = $real;
- return $base;
- }
- foreach (sd_base_candidates() as $dir) {
- $real = realpath($dir);
- if ($real !== false && !sd_under_strong_webroot($real) && sd_base_ok($real)) return $base = $real;
- }
- return $base;
- }
-
- function sd_instance(): string
- {
- static $id = null;
- if ($id !== null) return $id;
- $file = SD_ROOT . '/' . SD_ID_FILE;
- $raw = is_file($file) ? trim((string)@file_get_contents($file)) : '';
- if (preg_match('/^[a-f0-9]{16}$/', $raw)) return $id = $raw;
- $new = bin2hex(random_bytes(8));
- if (@file_put_contents($file, $new . "\n", LOCK_EX) !== false) {
- @chmod($file, 0600);
- return $id = $new;
- }
- return $id = substr(hash('sha256', sd_app()), 0, 16);
- }
-
- function sd_slot(): string
- {
- static $slot = null;
- if ($slot !== null) return $slot;
- $base = sd_base();
- if ($base === null) return $slot = '';
- $id = sd_instance();
- if (is_dir($base . '/' . SD_PREFIX . '-' . $id)) return $slot = '-' . $id;
- $primary = $base . '/' . SD_PREFIX;
- $ownerFile = $primary . '/owner';
- if (!is_dir($primary) && @mkdir($primary, 0700)) {
- @file_put_contents($primary . '/.htaccess', "Require all denied\n");
- @file_put_contents($ownerFile, $id, LOCK_EX);
- return $slot = '';
- }
- $owner = is_file($ownerFile) ? trim((string)@file_get_contents($ownerFile)) : '';
- if ($owner === '' && is_dir($primary) && @file_put_contents($ownerFile, $id, LOCK_EX) !== false) return $slot = '';
- return $slot = $owner === $id ? '' : '-' . $id;
- }
-
- function sd_name(string $kind): string
- {
- $name = SD_PREFIX . sd_slot();
- return match ($kind) {
- 'env' => $name . '.env',
- 'setup' => $name . '.setup',
- default => $name,
- };
- }
-
- function sd_path(string $kind): string
- {
- return sd_base() . DIRECTORY_SEPARATOR . sd_name($kind);
- }
-
- function sd_data(string $sub = ''): string
- {
- $dir = sd_path('data');
- if (!is_dir($dir)) @mkdir($dir, 0700, true);
- if (is_dir($dir) && !is_file($dir . '/.htaccess')) @file_put_contents($dir . '/.htaccess', "Require all denied\n");
- if ($sub === '') return $dir;
- $path = $dir . '/' . $sub;
- if (!is_dir($path)) @mkdir($path, 0700, true);
- return $path;
- }
-
- function sd_url_base(): string
- {
- static $base = null;
- if ($base !== null) return $base;
- $script = str_replace('\\', '/', (string)($_SERVER['SCRIPT_NAME'] ?? ''));
- if ($script === '' || $script[0] !== '/' || basename($script) !== 'index.php') return $base = './';
- $dir = trim(str_replace('\\', '/', dirname($script)), '/');
- if ($dir === '' || $dir === '.') return $base = '/';
- return $base = '/' . implode('/', array_map('rawurlencode', explode('/', $dir))) . '/';
- }
-
- function sd_url(string $page = '', array $query = []): string
- {
- $params = $page !== '' ? ['p' => $page] + $query : $query;
- return sd_url_base() . ($params ? '?' . http_build_query($params) : '');
- }
-
- function sd_request_path_ok(): bool
- {
- $base = sd_url_base();
- if ($base === './') return true;
- $path = (string)parse_url('http://x' . (string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
- $path = (string)preg_replace('#/{2,}#', '/', rawurldecode($path));
- $dir = rawurldecode($base);
- return $path === $dir || $path === $dir . 'index.php';
- }
-
- function sd_env(bool $reload = false): ?array
- {
- static $env = false;
- if ($env !== false && !$reload) return $env;
- $env = null;
- if (sd_base() === null) return null;
- $file = sd_path('env');
- if (!is_file($file)) return null;
- $raw = @file_get_contents($file);
- if ($raw === false) return null;
- $env = [];
- foreach (preg_split('/\R/', $raw) as $line) {
- if (!preg_match('/^([A-Z][A-Z0-9_]{1,40})=(.*)$/', $line, $m)) continue;
- $value = json_decode($m[2], true);
- if ($value !== null || $m[2] === 'null') $env[$m[1]] = $value;
- }
- if (!isset($env['SD_USER'], $env['SD_PASS'], $env['SD_SECRET'])) $env = null;
- return $env;
- }
-
- function sd_env_save(array $env): void
- {
- $lines = [];
- foreach ($env as $key => $value) {
- if (!preg_match('/^[A-Z][A-Z0-9_]{1,40}$/', $key)) continue;
- $lines[] = $key . '=' . json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
- }
- $file = sd_path('env');
- $tmp = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
- if (@file_put_contents($tmp, implode("\n", $lines) . "\n", LOCK_EX) === false) throw new RuntimeException('env_write');
- @chmod($tmp, 0600);
- if (!@rename($tmp, $file)) {
- @unlink($tmp);
- throw new RuntimeException('env_write');
- }
- sd_env(true);
- }
-
- function sd_env_set(array $changes): void
- {
- $env = sd_env(true) ?? [];
- foreach ($changes as $k => $v) $env[$k] = $v;
- sd_env_save($env);
- }
-
- function sd_state(string $name, callable $fn): mixed
- {
- $file = sd_data('state') . '/' . preg_replace('/[^a-z0-9_-]/', '', $name) . '.json';
- $fp = @fopen($file, 'c+');
- if ($fp === false) throw new RuntimeException('state_open');
- try {
- flock($fp, LOCK_EX);
- $raw = stream_get_contents($fp);
- $data = $raw ? json_decode($raw, true) : [];
- if (!is_array($data)) $data = [];
- $result = $fn($data);
- ftruncate($fp, 0);
- rewind($fp);
- fwrite($fp, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
- fflush($fp);
- flock($fp, LOCK_UN);
- return $result;
- } finally {
- fclose($fp);
- }
- }
-
- function sd_read_state(string $name): array
- {
- $file = sd_data('state') . '/' . preg_replace('/[^a-z0-9_-]/', '', $name) . '.json';
- if (!is_file($file)) return [];
- $data = json_decode((string)@file_get_contents($file), true);
- return is_array($data) ? $data : [];
- }
-
- function sd_audit(string $event, array $ctx = []): void
- {
- $line = json_encode([
- 't' => gmdate('c'),
- 'e' => $event,
- 'ip' => sd_ip(),
- 'ua' => mb_substr(sd_ua(), 0, 160),
- 'c' => $ctx,
- ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
- $file = sd_data('logs') . '/audit.log';
- @file_put_contents($file, $line . "\n", FILE_APPEND | LOCK_EX);
- if (@filesize($file) > 5242880) @rename($file, $file . '.1');
- }
-
- function sd_audit_tail(int $limit = 100): array
- {
- $file = sd_data('logs') . '/audit.log';
- if (!is_file($file)) return [];
- $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
- $out = [];
- foreach (array_slice(array_reverse($lines), 0, $limit) as $line) {
- $row = json_decode($line, true);
- if (is_array($row)) $out[] = $row;
- }
- return $out;
- }
-
- function sd_headers(): void
- {
- header_remove('X-Powered-By');
- header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; manifest-src 'self'");
- header('X-Content-Type-Options: nosniff');
- header('X-Frame-Options: DENY');
- header('Referrer-Policy: no-referrer');
- header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
- header('Cross-Origin-Opener-Policy: same-origin');
- header('Cross-Origin-Resource-Policy: same-origin');
- header('X-Robots-Tag: noindex, nofollow, noarchive');
- header('Cache-Control: no-store, max-age=0');
- if (sd_https()) header('Strict-Transport-Security: max-age=31536000');
- }
-
- function sd_cookie_name(): string
- {
- $name = 'syncdrop' . (sd_slot() === '' ? '' : '-' . substr(sd_instance(), 0, 8));
- return sd_https() ? '__Host-' . $name : $name;
- }
-
- function sd_session_start(): void
- {
- if (session_status() === PHP_SESSION_ACTIVE) return;
- ini_set('session.use_strict_mode', '1');
- ini_set('session.use_only_cookies', '1');
- ini_set('session.use_trans_sid', '0');
- ini_set('session.cookie_httponly', '1');
- ini_set('session.gc_maxlifetime', (string)SD_ABSOLUTE);
- session_save_path(sd_data('sessions'));
- session_name(sd_cookie_name());
- session_set_cookie_params([
- 'lifetime' => 0,
- 'path' => '/',
- 'secure' => sd_https(),
- 'httponly' => true,
- 'samesite' => 'Strict',
- ]);
- session_start();
- $now = time();
- $fingerprint = hash('sha256', sd_ua());
- if (isset($_SESSION['t0'])) {
- $expired = $now - (int)($_SESSION['last'] ?? 0) > SD_IDLE || $now - (int)$_SESSION['t0'] > SD_ABSOLUTE;
- if ($expired || !hash_equals((string)($_SESSION['fp'] ?? ''), $fingerprint)) {
- sd_session_reset();
- }
- }
- if (!isset($_SESSION['t0'])) {
- $_SESSION['t0'] = $now;
- $_SESSION['fp'] = $fingerprint;
- }
- $_SESSION['last'] = $now;
- if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
- sd_shield_inspect();
- }
-
- function sd_session_reset(): void
- {
- $_SESSION = [];
- session_regenerate_id(true);
- $_SESSION['t0'] = time();
- $_SESSION['fp'] = hash('sha256', sd_ua());
- $_SESSION['csrf'] = bin2hex(random_bytes(32));
- }
-
- function sd_session_elevate(string $stage): void
- {
- $keep = ['lang' => $_SESSION['lang'] ?? null];
- $_SESSION = [];
- session_regenerate_id(true);
- $_SESSION['t0'] = time();
- $_SESSION['last'] = time();
- $_SESSION['fp'] = hash('sha256', sd_ua());
- $_SESSION['csrf'] = bin2hex(random_bytes(32));
- $_SESSION['stage'] = $stage;
- $_SESSION['epoch'] = (int)(sd_env()['SD_EPOCH'] ?? 1);
- if ($keep['lang']) $_SESSION['lang'] = $keep['lang'];
- }
-
- function sd_stage(): string
- {
- $stage = (string)($_SESSION['stage'] ?? '');
- if ($stage === '') return '';
- $env = sd_env();
- if ($env === null || (int)($_SESSION['epoch'] ?? -1) !== (int)($env['SD_EPOCH'] ?? 1)) {
- sd_session_reset();
- return '';
- }
- return $stage;
- }
-
- function sd_csrf(): string
- {
- return (string)($_SESSION['csrf'] ?? '');
- }
-
- function sd_csrf_ok(): bool
- {
- $site = strtolower((string)($_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''));
- if ($site !== '' && !in_array($site, ['same-origin', 'none'], true)) return false;
- $origin = (string)($_SERVER['HTTP_ORIGIN'] ?? '');
- if ($origin !== '' && $origin !== 'null') {
- $host = parse_url($origin, PHP_URL_HOST);
- $port = parse_url($origin, PHP_URL_PORT);
- $origHost = strtolower((string)$host) . ($port ? ':' . $port : '');
- if ($origHost !== sd_host()) return false;
- }
- $token = (string)($_POST['_csrf'] ?? ($_SERVER['HTTP_X_SYNCDROP_CSRF'] ?? ''));
- $expected = sd_csrf();
- return $expected !== '' && $token !== '' && hash_equals($expected, $token);
- }
-
- function sd_redirect(string $page = '', array $query = []): never
- {
- header('Location: ' . sd_url($page, $query), true, 303);
- exit;
- }
-
- function sd_json(array $data, int $status = 200): never
- {
- http_response_code($status);
- header('Content-Type: application/json; charset=utf-8');
- echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
- exit;
- }
-
- function sd_flash(?string $type = null, ?string $message = null): ?array
- {
- if ($type !== null) {
- $_SESSION['flash'] = ['type' => $type, 'msg' => (string)$message];
- return null;
- }
- $flash = $_SESSION['flash'] ?? null;
- unset($_SESSION['flash']);
- return is_array($flash) ? $flash : null;
- }
-
- function sd_password_hash(string $password): string
- {
- if (defined('PASSWORD_ARGON2ID')) {
- return password_hash($password, PASSWORD_ARGON2ID, ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1]);
- }
- return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
- }
-
- function sd_password_strong(string $password, string $user): bool
- {
- if (mb_strlen($password) < 12 || mb_strlen($password) > 256) return false;
- if (stripos($password, $user) !== false) return false;
- $classes = 0;
- foreach (['/[a-z]/', '/[A-Z]/', '/\d/', '/[^a-zA-Z\d]/'] as $re) {
- if (preg_match($re, $password)) $classes++;
- }
- return $classes >= 3;
- }
-
- function sd_format_bytes(int $bytes): string
- {
- $units = ['B', 'KB', 'MB', 'GB'];
- $i = 0;
- $n = (float)$bytes;
- while ($n >= 1024 && $i < count($units) - 1) {
- $n /= 1024;
- $i++;
- }
- return ($i === 0 ? (string)$bytes : number_format($n, 1)) . ' ' . $units[$i];
- }
-
- function sd_rrmdir(string $dir, string $mustBeInside): void
- {
- $real = realpath($dir);
- $root = realpath($mustBeInside);
- if ($real === false || $root === false || !sd_inside($real, $root) || $real === $root) return;
- $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
- foreach ($it as $item) {
- if ($item->isDir() && !$item->isLink()) @rmdir($item->getPathname());
- else @unlink($item->getPathname());
- }
- @rmdir($real);
- }
-