v1.0.0.0
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/site-front.php';
-
- final class DigitalFiles
- {
- public const MAX_FILE_BYTES = 1073741824;
- public const MAX_FILES_PER_PRODUCT = 20;
- public const PAID_ORDER_STATUSES = ['paid', 'processing', 'fulfilled', 'shipped', 'completed'];
-
- private static bool $usersSchemaChecked = false;
-
- public static function storageCandidates(): array
- {
- return [
- dirname(__DIR__, 2) . '/stocketbase-downloads',
- dirname(__DIR__) . '/private-downloads',
- ];
- }
-
- public static function storageDir(): ?string
- {
- foreach (self::storageCandidates() as $directory) {
- if (!is_dir($directory)) {
- @mkdir($directory, 0750, true);
- }
- if (is_dir($directory) && is_writable($directory)) {
- self::ensureGuards($directory);
-
- return $directory;
- }
- }
-
- return null;
- }
-
- private static function ensureGuards(string $directory): void
- {
- $guards = [
- $directory . '/.htaccess' => "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n",
- $directory . '/index.html' => '',
- ];
- foreach ($guards as $path => $content) {
- if (!is_file($path)) {
- @file_put_contents($path, $content);
- }
- }
- }
-
- public static function resolvePath(array $file): ?string
- {
- $storedName = (string) ($file['stored_name'] ?? '');
- if (preg_match('/^[a-f0-9]{32}$/', $storedName) !== 1) {
- return null;
- }
-
- foreach (self::storageCandidates() as $directory) {
- $path = $directory . '/' . $storedName;
- if (is_file($path)) {
- return $path;
- }
- }
-
- return null;
- }
-
- public static function filesForProduct(int $productId): array
- {
- try {
- $statement = Database::site()->prepare('SELECT * FROM product_files WHERE product_id = :id ORDER BY sort_order ASC, id ASC');
- $statement->execute(['id' => $productId]);
-
- return $statement->fetchAll();
- } catch (PDOException $exception) {
- return [];
- }
- }
-
- public static function find(int $fileId): ?array
- {
- if ($fileId <= 0) {
- return null;
- }
-
- $statement = Database::site()->prepare('SELECT * FROM product_files WHERE id = :id LIMIT 1');
- $statement->execute(['id' => $fileId]);
-
- return $statement->fetch() ?: null;
- }
-
- public static function uploadErrorMessage(int $code): string
- {
- return match ($code) {
- UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is larger than the upload limit of this server (upload_max_filesize / post_max_size).',
- UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded. Please try again.',
- UPLOAD_ERR_NO_FILE => 'Choose a file to upload.',
- UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'The server could not store the uploaded file.',
- default => 'The upload failed.',
- };
- }
-
- public static function store(int $productId, ?array $upload): array
- {
- if (!is_array($upload) || !isset($upload['error'])) {
- return [false, 'Choose a file to upload.'];
- }
- if ((int) $upload['error'] !== UPLOAD_ERR_OK) {
- return [false, self::uploadErrorMessage((int) $upload['error'])];
- }
- if (!is_uploaded_file((string) $upload['tmp_name'])) {
- return [false, 'The upload failed.'];
- }
-
- $size = (int) filesize((string) $upload['tmp_name']);
- if ($size <= 0) {
- return [false, 'The file is empty.'];
- }
- if ($size > self::MAX_FILE_BYTES) {
- return [false, 'The file is too large (max 1 GB).'];
- }
- if (count(self::filesForProduct($productId)) >= self::MAX_FILES_PER_PRODUCT) {
- return [false, 'A product can have at most ' . self::MAX_FILES_PER_PRODUCT . ' files.'];
- }
-
- $directory = self::storageDir();
- if ($directory === null) {
- return [false, 'No writable storage folder for downloads. Create "stocketbase-downloads" next to the site folder (or "private-downloads" inside it) and make it writable.'];
- }
-
- $originalName = self::sanitizeFileName((string) ($upload['name'] ?? ''));
- $mimeType = 'application/octet-stream';
- if (function_exists('finfo_open')) {
- $finfo = finfo_open(FILEINFO_MIME_TYPE);
- $detected = $finfo ? finfo_file($finfo, (string) $upload['tmp_name']) : false;
- if (is_string($detected) && $detected !== '') {
- $mimeType = mb_substr($detected, 0, 120);
- }
- }
-
- $storedName = bin2hex(random_bytes(16));
- $target = $directory . '/' . $storedName;
-
- if (!move_uploaded_file((string) $upload['tmp_name'], $target)) {
- return [false, 'The file could not be saved.'];
- }
- @chmod($target, 0640);
-
- try {
- Database::site()->prepare(
- 'INSERT INTO product_files (product_id, original_name, stored_name, mime_type, size_bytes, sha256, sort_order)
- VALUES (:product_id, :original_name, :stored_name, :mime_type, :size_bytes, :sha256, :sort_order)'
- )->execute([
- 'product_id' => $productId,
- 'original_name' => $originalName,
- 'stored_name' => $storedName,
- 'mime_type' => $mimeType,
- 'size_bytes' => $size,
- 'sha256' => (string) hash_file('sha256', $target),
- 'sort_order' => count(self::filesForProduct($productId)),
- ]);
- } catch (PDOException $exception) {
- @unlink($target);
- error_log('StocketBase: could not save product file: ' . $exception->getMessage());
-
- return [false, 'The file could not be saved.'];
- }
-
- return [true, 'File "' . $originalName . '" uploaded.'];
- }
-
- public static function delete(int $fileId): void
- {
- $file = self::find($fileId);
- if ($file === null) {
- return;
- }
-
- $path = self::resolvePath($file);
- if ($path !== null) {
- @unlink($path);
- }
-
- Database::site()->prepare('DELETE FROM product_files WHERE id = :id')->execute(['id' => $fileId]);
- }
-
- public static function deleteForProduct(int $productId): void
- {
- foreach (self::filesForProduct($productId) as $file) {
- self::delete((int) $file['id']);
- }
- self::revokeGrantsForProduct($productId);
- }
-
- public static function detachForProduct(int $productId, string $productTitle): void
- {
- Database::site()->prepare('UPDATE product_files SET product_title = :title, detached_at = NOW() WHERE product_id = :id')
- ->execute(['title' => mb_substr($productTitle, 0, 255), 'id' => $productId]);
- }
-
- public static function rename(int $fileId, string $name): void
- {
- Database::site()->prepare('UPDATE product_files SET original_name = :name WHERE id = :id')
- ->execute(['name' => self::sanitizeFileName($name), 'id' => $fileId]);
- }
-
- public static function recordDownload(int $fileId): void
- {
- try {
- Database::site()->prepare('UPDATE product_files SET download_count = download_count + 1 WHERE id = :id')
- ->execute(['id' => $fileId]);
- } catch (PDOException $exception) {
- error_log('StocketBase: could not count a download: ' . $exception->getMessage());
- }
- }
-
- public static function setButtonLabel(int $fileId, string $label): void
- {
- $label = strip_tags($label);
- $label = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $label);
- $label = trim((string) preg_replace('/\s+/u', ' ', $label));
- $label = mb_substr($label, 0, 120);
-
- Database::site()->prepare('UPDATE product_files SET button_label = :label WHERE id = :id')
- ->execute(['label' => $label !== '' ? $label : null, 'id' => $fileId]);
- }
-
- public static function displayName(array $file): string
- {
- $label = trim((string) ($file['button_label'] ?? ''));
-
- return $label !== '' ? $label : (string) $file['original_name'];
- }
-
- public static function move(int $fileId, int $productId): void
- {
- Database::site()->prepare('UPDATE product_files SET product_id = :product_id, product_title = NULL, detached_at = NULL WHERE id = :id')
- ->execute(['product_id' => $productId, 'id' => $fileId]);
- }
-
- public static function allFiles(string $filter = 'all', string $search = ''): array
- {
- $conditions = [];
- $params = [];
- if ($filter === 'detached') {
- $conditions[] = 'f.detached_at IS NOT NULL';
- } elseif ($filter === 'attached') {
- $conditions[] = 'f.detached_at IS NULL';
- }
- if ($search !== '') {
- $conditions[] = '(f.original_name LIKE :search OR p.title LIKE :search2 OR f.product_title LIKE :search3)';
- $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%';
- $params = ['search' => $like, 'search2' => $like, 'search3' => $like];
- }
-
- $statement = Database::site()->prepare(
- 'SELECT f.*, p.title AS current_product_title, p.status AS product_status, p.deleted_at AS product_deleted_at, p.price_cents AS product_price_cents
- FROM product_files f LEFT JOIN products p ON p.id = f.product_id'
- . (empty($conditions) ? '' : ' WHERE ' . implode(' AND ', $conditions))
- . ' ORDER BY f.created_at DESC, f.id DESC LIMIT 500'
- );
- $statement->execute($params);
-
- return $statement->fetchAll();
- }
-
- public static function linkDownloadCounts(): array
- {
- $usersDb = Database::users();
- self::ensureUsersSchema($usersDb);
-
- $counts = [];
- foreach ($usersDb->query('SELECT product_id, SUM(download_count) AS downloads, COUNT(*) AS links FROM download_grants WHERE revoked_at IS NULL GROUP BY product_id')->fetchAll() as $row) {
- $counts[(int) $row['product_id']] = ['downloads' => (int) $row['downloads'], 'links' => (int) $row['links']];
- }
-
- return $counts;
- }
-
- public static function storageInfo(): array
- {
- $directory = self::storageDir();
- $external = $directory !== null && realpath($directory) === realpath(self::storageCandidates()[0]);
-
- return [
- 'path' => $directory,
- 'outside_web_root' => $external,
- 'free_bytes' => $directory !== null ? (@disk_free_space($directory) ?: null) : null,
- ];
- }
-
- public static function revokeGrantsForProduct(int $productId): void
- {
- $usersDb = Database::users();
- self::ensureUsersSchema($usersDb);
- $usersDb->prepare('UPDATE download_grants SET revoked_at = NOW() WHERE product_id = :product_id AND revoked_at IS NULL')
- ->execute(['product_id' => $productId]);
- }
-
- public static function stream(array $file, string $path): never
- {
- if (session_status() === PHP_SESSION_ACTIVE) {
- session_write_close();
- }
- while (ob_get_level() > 0) {
- ob_end_clean();
- }
- @set_time_limit(0);
-
- $downloadName = (string) $file['original_name'];
- $asciiName = (string) preg_replace('/[^A-Za-z0-9._ -]/', '_', $downloadName);
-
- header('Content-Type: application/octet-stream');
- header('Content-Disposition: attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($downloadName));
- header('Content-Length: ' . (int) filesize($path));
- header('Content-Transfer-Encoding: binary');
- header('X-Content-Type-Options: nosniff');
- header('Content-Security-Policy: default-src \'none\'; sandbox');
- header('Cache-Control: private, no-store, max-age=0');
- header('X-Robots-Tag: noindex, nofollow');
-
- $handle = fopen($path, 'rb');
- if ($handle !== false) {
- while (!feof($handle)) {
- echo fread($handle, 1048576);
- flush();
- if (connection_aborted()) {
- break;
- }
- }
- fclose($handle);
- }
- exit;
- }
-
- public static function sanitizeFileName(string $name): string
- {
- $name = str_replace(['\\', '/'], '_', $name);
- $name = (string) preg_replace('/[\x00-\x1F\x7F"<>|:*?]+/u', '', $name);
- $name = trim($name, " .\t");
- $name = mb_substr($name, 0, 180);
-
- return $name !== '' ? $name : 'download';
- }
-
- public static function formatSize(int $bytes): string
- {
- $units = ['B', 'KB', 'MB', 'GB'];
- $value = (float) $bytes;
- $unit = 0;
- while ($value >= 1024 && $unit < count($units) - 1) {
- $value /= 1024;
- $unit++;
- }
-
- return ($unit === 0 ? (string) $bytes : number_format($value, 1)) . ' ' . $units[$unit];
- }
-
- public static function isFree(array $product, ?array $variants = null): bool
- {
- if ((int) ($product['price_cents'] ?? 0) > 0) {
- return false;
- }
-
- if ($variants === null) {
- $statement = Database::site()->prepare('SELECT price_cents FROM product_variants WHERE product_id = :id');
- $statement->execute(['id' => (int) $product['id']]);
- $variants = $statement->fetchAll();
- }
-
- foreach ($variants as $variant) {
- if ($variant['price_cents'] !== null && (int) $variant['price_cents'] > 0) {
- return false;
- }
- }
-
- return true;
- }
-
- public static function customerOwnsProduct(int $userAccountId, int $productId): bool
- {
- $usersDb = Database::users();
- $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
-
- $orderStatement = $usersDb->prepare(
- "SELECT 1 FROM orders o JOIN order_items oi ON oi.order_id = o.id
- WHERE o.user_account_id = ? AND oi.product_id = ? AND o.status IN ({$placeholders}) LIMIT 1"
- );
- $orderStatement->execute(array_merge([$userAccountId, $productId], self::PAID_ORDER_STATUSES));
- if ($orderStatement->fetchColumn()) {
- return true;
- }
-
- $subscriptionStatement = $usersDb->prepare(
- "SELECT 1 FROM subscriptions
- WHERE user_account_id = :user_id AND product_id = :product_id AND status IN ('active', 'trialing')
- AND (current_period_end IS NULL OR current_period_end > NOW()) LIMIT 1"
- );
- $subscriptionStatement->execute(['user_id' => $userAccountId, 'product_id' => $productId]);
-
- return (bool) $subscriptionStatement->fetchColumn();
- }
-
- public static function ownedProductIds(int $userAccountId): array
- {
- $usersDb = Database::users();
- $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
-
- $orderStatement = $usersDb->prepare(
- "SELECT DISTINCT oi.product_id FROM orders o JOIN order_items oi ON oi.order_id = o.id
- WHERE o.user_account_id = ? AND o.status IN ({$placeholders})"
- );
- $orderStatement->execute(array_merge([$userAccountId], self::PAID_ORDER_STATUSES));
- $ids = array_map('intval', $orderStatement->fetchAll(PDO::FETCH_COLUMN));
-
- $subscriptionStatement = $usersDb->prepare(
- "SELECT DISTINCT product_id FROM subscriptions
- WHERE user_account_id = :user_id AND product_id IS NOT NULL AND status IN ('active', 'trialing')
- AND (current_period_end IS NULL OR current_period_end > NOW())"
- );
- $subscriptionStatement->execute(['user_id' => $userAccountId]);
-
- return array_values(array_unique(array_merge($ids, array_map('intval', $subscriptionStatement->fetchAll(PDO::FETCH_COLUMN)))));
- }
-
- public static function ensureUsersSchema(PDO $usersDb): void
- {
- if (self::$usersSchemaChecked) {
- return;
- }
- self::$usersSchemaChecked = true;
-
- try {
- $usersDb->exec(
- "CREATE TABLE IF NOT EXISTS download_grants (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- token CHAR(64) NOT NULL,
- product_id INT UNSIGNED NOT NULL,
- order_id INT UNSIGNED NULL,
- user_account_id INT UNSIGNED NULL,
- email VARCHAR(190) NULL,
- download_count INT UNSIGNED NOT NULL DEFAULT 0,
- max_downloads INT UNSIGNED NULL,
- expires_at DATETIME NULL,
- revoked_at DATETIME NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_download_token (token),
- KEY idx_download_order (order_id),
- KEY idx_download_product (product_id)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
- );
- } catch (PDOException $exception) {
- error_log('StocketBase: could not create download_grants: ' . $exception->getMessage());
- }
- }
-
- public static function baseUrl(): string
- {
- return rtrim((string) Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', '');
- }
-
- public static function downloadUrl(int $fileId, ?string $token = null, bool $absolute = false): string
- {
- $url = 'download.php?file=' . $fileId . ($token !== null && $token !== '' ? '&token=' . rawurlencode($token) : '');
-
- return $absolute ? self::baseUrl() . '/' . $url : $url;
- }
-
- public static function grantsForOrder(PDO $usersDb, array $order, array $items): array
- {
- self::ensureUsersSchema($usersDb);
-
- $settings = SiteFront::settings();
- $expiryDays = max(0, (int) ($settings['download_link_expiry_days'] ?? 30));
- $maxDownloads = max(0, (int) ($settings['download_link_max_downloads'] ?? 10));
- $email = (string) ($order['guest_email'] ?? '');
-
- $links = [];
- $seenProducts = [];
-
- foreach ($items as $item) {
- $productId = (int) $item['product_id'];
- if (isset($seenProducts[$productId])) {
- continue;
- }
- $seenProducts[$productId] = true;
-
- $files = self::filesForProduct($productId);
- if (empty($files)) {
- continue;
- }
-
- $existing = $usersDb->prepare('SELECT token FROM download_grants WHERE order_id = :order_id AND product_id = :product_id AND revoked_at IS NULL LIMIT 1');
- $existing->execute(['order_id' => (int) $order['id'], 'product_id' => $productId]);
- $token = (string) $existing->fetchColumn();
-
- if ($token === '') {
- $token = bin2hex(random_bytes(32));
- $usersDb->prepare(
- 'INSERT INTO download_grants (token, product_id, order_id, user_account_id, email, max_downloads, expires_at)
- VALUES (:token, :product_id, :order_id, :user_account_id, :email, :max_downloads, :expires_at)'
- )->execute([
- 'token' => $token,
- 'product_id' => $productId,
- 'order_id' => (int) $order['id'],
- 'user_account_id' => $order['user_account_id'] !== null ? (int) $order['user_account_id'] : null,
- 'email' => $email !== '' ? $email : null,
- 'max_downloads' => $maxDownloads > 0 ? $maxDownloads : null,
- 'expires_at' => $expiryDays > 0 ? date('Y-m-d H:i:s', time() + $expiryDays * 86400) : null,
- ]);
- }
-
- foreach ($files as $file) {
- $links[] = [
- 'name' => self::displayName($file),
- 'url' => self::downloadUrl((int) $file['id'], $token, true),
- ];
- }
- }
-
- return $links;
- }
-
- public static function findValidGrant(string $token, int $productId): ?array
- {
- if (preg_match('/^[a-f0-9]{64}$/', $token) !== 1) {
- return null;
- }
-
- $usersDb = Database::users();
- self::ensureUsersSchema($usersDb);
-
- $statement = $usersDb->prepare(
- 'SELECT * FROM download_grants WHERE token = :token AND product_id = :product_id AND revoked_at IS NULL
- AND (expires_at IS NULL OR expires_at > NOW())
- AND (max_downloads IS NULL OR download_count < max_downloads) LIMIT 1'
- );
- $statement->execute(['token' => $token, 'product_id' => $productId]);
- $grant = $statement->fetch();
- if (!$grant) {
- return null;
- }
-
- if ($grant['order_id'] !== null) {
- $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
- $orderStatement = $usersDb->prepare("SELECT 1 FROM orders WHERE id = ? AND status IN ({$placeholders}) LIMIT 1");
- $orderStatement->execute(array_merge([(int) $grant['order_id']], self::PAID_ORDER_STATUSES));
- if (!$orderStatement->fetchColumn()) {
- return null;
- }
- }
-
- return $grant;
- }
-
- public static function consumeGrant(int $grantId): bool
- {
- $statement = Database::users()->prepare(
- 'UPDATE download_grants SET download_count = download_count + 1
- WHERE id = :id AND revoked_at IS NULL AND (max_downloads IS NULL OR download_count < max_downloads)'
- );
- $statement->execute(['id' => $grantId]);
-
- return $statement->rowCount() > 0;
- }
-
- public static function revokeGrantsForOrder(int $orderId): void
- {
- $usersDb = Database::users();
- self::ensureUsersSchema($usersDb);
- $usersDb->prepare('UPDATE download_grants SET revoked_at = NOW() WHERE order_id = :order_id AND revoked_at IS NULL')
- ->execute(['order_id' => $orderId]);
- }
-
- public static function tokensForOrder(int $orderId): array
- {
- $usersDb = Database::users();
- self::ensureUsersSchema($usersDb);
- $statement = $usersDb->prepare('SELECT product_id, token FROM download_grants WHERE order_id = :order_id AND revoked_at IS NULL');
- $statement->execute(['order_id' => $orderId]);
-
- $tokens = [];
- foreach ($statement->fetchAll() as $row) {
- $tokens[(int) $row['product_id']] = (string) $row['token'];
- }
-
- return $tokens;
- }
- }
-