WebOrbiton
v1.0.0.1

StocketBase

592 lines · 22.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/site-front.php';
  7. ​
  8. final class DigitalFiles
  9. {
  10. public const MAX_FILE_BYTES = 1073741824;
  11. public const MAX_FILES_PER_PRODUCT = 20;
  12. public const PAID_ORDER_STATUSES = ['paid', 'processing', 'fulfilled', 'shipped', 'completed'];
  13. ​
  14. private static bool $usersSchemaChecked = false;
  15. ​
  16. public static function storageCandidates(): array
  17. {
  18. return [
  19. dirname(__DIR__, 2) . '/stocketbase-downloads',
  20. dirname(__DIR__) . '/private-downloads',
  21. ];
  22. }
  23. ​
  24. public static function storageDir(): ?string
  25. {
  26. foreach (self::storageCandidates() as $directory) {
  27. if (!is_dir($directory)) {
  28. @mkdir($directory, 0750, true);
  29. }
  30. if (is_dir($directory) && is_writable($directory)) {
  31. self::ensureGuards($directory);
  32. ​
  33. return $directory;
  34. }
  35. }
  36. ​
  37. return null;
  38. }
  39. ​
  40. private static function ensureGuards(string $directory): void
  41. {
  42. $guards = [
  43. $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",
  44. $directory . '/index.html' => '',
  45. ];
  46. foreach ($guards as $path => $content) {
  47. if (!is_file($path)) {
  48. @file_put_contents($path, $content);
  49. }
  50. }
  51. }
  52. ​
  53. public static function resolvePath(array $file): ?string
  54. {
  55. $storedName = (string) ($file['stored_name'] ?? '');
  56. if (preg_match('/^[a-f0-9]{32}$/', $storedName) !== 1) {
  57. return null;
  58. }
  59. ​
  60. foreach (self::storageCandidates() as $directory) {
  61. $path = $directory . '/' . $storedName;
  62. if (is_file($path)) {
  63. return $path;
  64. }
  65. }
  66. ​
  67. return null;
  68. }
  69. ​
  70. public static function filesForProduct(int $productId): array
  71. {
  72. try {
  73. $statement = Database::site()->prepare('SELECT * FROM product_files WHERE product_id = :id ORDER BY sort_order ASC, id ASC');
  74. $statement->execute(['id' => $productId]);
  75. ​
  76. return $statement->fetchAll();
  77. } catch (PDOException $exception) {
  78. return [];
  79. }
  80. }
  81. ​
  82. public static function find(int $fileId): ?array
  83. {
  84. if ($fileId <= 0) {
  85. return null;
  86. }
  87. ​
  88. $statement = Database::site()->prepare('SELECT * FROM product_files WHERE id = :id LIMIT 1');
  89. $statement->execute(['id' => $fileId]);
  90. ​
  91. return $statement->fetch() ?: null;
  92. }
  93. ​
  94. public static function uploadErrorMessage(int $code): string
  95. {
  96. return match ($code) {
  97. 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).',
  98. UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded. Please try again.',
  99. UPLOAD_ERR_NO_FILE => 'Choose a file to upload.',
  100. UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'The server could not store the uploaded file.',
  101. default => 'The upload failed.',
  102. };
  103. }
  104. ​
  105. public static function store(int $productId, ?array $upload): array
  106. {
  107. if (!is_array($upload) || !isset($upload['error'])) {
  108. return [false, 'Choose a file to upload.'];
  109. }
  110. if ((int) $upload['error'] !== UPLOAD_ERR_OK) {
  111. return [false, self::uploadErrorMessage((int) $upload['error'])];
  112. }
  113. if (!is_uploaded_file((string) $upload['tmp_name'])) {
  114. return [false, 'The upload failed.'];
  115. }
  116. ​
  117. $size = (int) filesize((string) $upload['tmp_name']);
  118. if ($size <= 0) {
  119. return [false, 'The file is empty.'];
  120. }
  121. if ($size > self::MAX_FILE_BYTES) {
  122. return [false, 'The file is too large (max 1 GB).'];
  123. }
  124. if (count(self::filesForProduct($productId)) >= self::MAX_FILES_PER_PRODUCT) {
  125. return [false, 'A product can have at most ' . self::MAX_FILES_PER_PRODUCT . ' files.'];
  126. }
  127. ​
  128. $directory = self::storageDir();
  129. if ($directory === null) {
  130. 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.'];
  131. }
  132. ​
  133. $originalName = self::sanitizeFileName((string) ($upload['name'] ?? ''));
  134. $mimeType = 'application/octet-stream';
  135. if (function_exists('finfo_open')) {
  136. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  137. $detected = $finfo ? finfo_file($finfo, (string) $upload['tmp_name']) : false;
  138. if (is_string($detected) && $detected !== '') {
  139. $mimeType = mb_substr($detected, 0, 120);
  140. }
  141. }
  142. ​
  143. $storedName = bin2hex(random_bytes(16));
  144. $target = $directory . '/' . $storedName;
  145. ​
  146. if (!move_uploaded_file((string) $upload['tmp_name'], $target)) {
  147. return [false, 'The file could not be saved.'];
  148. }
  149. @chmod($target, 0640);
  150. ​
  151. try {
  152. Database::site()->prepare(
  153. 'INSERT INTO product_files (product_id, original_name, stored_name, mime_type, size_bytes, sha256, sort_order)
  154. VALUES (:product_id, :original_name, :stored_name, :mime_type, :size_bytes, :sha256, :sort_order)'
  155. )->execute([
  156. 'product_id' => $productId,
  157. 'original_name' => $originalName,
  158. 'stored_name' => $storedName,
  159. 'mime_type' => $mimeType,
  160. 'size_bytes' => $size,
  161. 'sha256' => (string) hash_file('sha256', $target),
  162. 'sort_order' => count(self::filesForProduct($productId)),
  163. ]);
  164. } catch (PDOException $exception) {
  165. @unlink($target);
  166. error_log('StocketBase: could not save product file: ' . $exception->getMessage());
  167. ​
  168. return [false, 'The file could not be saved.'];
  169. }
  170. ​
  171. return [true, 'File "' . $originalName . '" uploaded.'];
  172. }
  173. ​
  174. public static function delete(int $fileId): void
  175. {
  176. $file = self::find($fileId);
  177. if ($file === null) {
  178. return;
  179. }
  180. ​
  181. $path = self::resolvePath($file);
  182. if ($path !== null) {
  183. @unlink($path);
  184. }
  185. ​
  186. Database::site()->prepare('DELETE FROM product_files WHERE id = :id')->execute(['id' => $fileId]);
  187. }
  188. ​
  189. public static function deleteForProduct(int $productId): void
  190. {
  191. foreach (self::filesForProduct($productId) as $file) {
  192. self::delete((int) $file['id']);
  193. }
  194. self::revokeGrantsForProduct($productId);
  195. }
  196. ​
  197. public static function detachForProduct(int $productId, string $productTitle): void
  198. {
  199. Database::site()->prepare('UPDATE product_files SET product_title = :title, detached_at = NOW() WHERE product_id = :id')
  200. ->execute(['title' => mb_substr($productTitle, 0, 255), 'id' => $productId]);
  201. }
  202. ​
  203. public static function rename(int $fileId, string $name): void
  204. {
  205. Database::site()->prepare('UPDATE product_files SET original_name = :name WHERE id = :id')
  206. ->execute(['name' => self::sanitizeFileName($name), 'id' => $fileId]);
  207. }
  208. ​
  209. public static function recordDownload(int $fileId): void
  210. {
  211. try {
  212. Database::site()->prepare('UPDATE product_files SET download_count = download_count + 1 WHERE id = :id')
  213. ->execute(['id' => $fileId]);
  214. } catch (PDOException $exception) {
  215. error_log('StocketBase: could not count a download: ' . $exception->getMessage());
  216. }
  217. }
  218. ​
  219. public static function setButtonLabel(int $fileId, string $label): void
  220. {
  221. $label = strip_tags($label);
  222. $label = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $label);
  223. $label = trim((string) preg_replace('/\s+/u', ' ', $label));
  224. $label = mb_substr($label, 0, 120);
  225. ​
  226. Database::site()->prepare('UPDATE product_files SET button_label = :label WHERE id = :id')
  227. ->execute(['label' => $label !== '' ? $label : null, 'id' => $fileId]);
  228. }
  229. ​
  230. public static function displayName(array $file): string
  231. {
  232. $label = trim((string) ($file['button_label'] ?? ''));
  233. ​
  234. return $label !== '' ? $label : (string) $file['original_name'];
  235. }
  236. ​
  237. public static function move(int $fileId, int $productId): void
  238. {
  239. Database::site()->prepare('UPDATE product_files SET product_id = :product_id, product_title = NULL, detached_at = NULL WHERE id = :id')
  240. ->execute(['product_id' => $productId, 'id' => $fileId]);
  241. }
  242. ​
  243. public static function allFiles(string $filter = 'all', string $search = ''): array
  244. {
  245. $conditions = [];
  246. $params = [];
  247. if ($filter === 'detached') {
  248. $conditions[] = 'f.detached_at IS NOT NULL';
  249. } elseif ($filter === 'attached') {
  250. $conditions[] = 'f.detached_at IS NULL';
  251. }
  252. if ($search !== '') {
  253. $conditions[] = '(f.original_name LIKE :search OR p.title LIKE :search2 OR f.product_title LIKE :search3)';
  254. $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%';
  255. $params = ['search' => $like, 'search2' => $like, 'search3' => $like];
  256. }
  257. ​
  258. $statement = Database::site()->prepare(
  259. '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
  260. FROM product_files f LEFT JOIN products p ON p.id = f.product_id'
  261. . (empty($conditions) ? '' : ' WHERE ' . implode(' AND ', $conditions))
  262. . ' ORDER BY f.created_at DESC, f.id DESC LIMIT 500'
  263. );
  264. $statement->execute($params);
  265. ​
  266. return $statement->fetchAll();
  267. }
  268. ​
  269. public static function linkDownloadCounts(): array
  270. {
  271. $usersDb = Database::users();
  272. self::ensureUsersSchema($usersDb);
  273. ​
  274. $counts = [];
  275. 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) {
  276. $counts[(int) $row['product_id']] = ['downloads' => (int) $row['downloads'], 'links' => (int) $row['links']];
  277. }
  278. ​
  279. return $counts;
  280. }
  281. ​
  282. public static function storageInfo(): array
  283. {
  284. $directory = self::storageDir();
  285. $external = $directory !== null && realpath($directory) === realpath(self::storageCandidates()[0]);
  286. ​
  287. return [
  288. 'path' => $directory,
  289. 'outside_web_root' => $external,
  290. 'free_bytes' => $directory !== null ? (@disk_free_space($directory) ?: null) : null,
  291. ];
  292. }
  293. ​
  294. public static function revokeGrantsForProduct(int $productId): void
  295. {
  296. $usersDb = Database::users();
  297. self::ensureUsersSchema($usersDb);
  298. $usersDb->prepare('UPDATE download_grants SET revoked_at = NOW() WHERE product_id = :product_id AND revoked_at IS NULL')
  299. ->execute(['product_id' => $productId]);
  300. }
  301. ​
  302. public static function stream(array $file, string $path): never
  303. {
  304. if (session_status() === PHP_SESSION_ACTIVE) {
  305. session_write_close();
  306. }
  307. while (ob_get_level() > 0) {
  308. ob_end_clean();
  309. }
  310. @set_time_limit(0);
  311. ​
  312. $downloadName = (string) $file['original_name'];
  313. $asciiName = (string) preg_replace('/[^A-Za-z0-9._ -]/', '_', $downloadName);
  314. ​
  315. header('Content-Type: application/octet-stream');
  316. header('Content-Disposition: attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($downloadName));
  317. header('Content-Length: ' . (int) filesize($path));
  318. header('Content-Transfer-Encoding: binary');
  319. header('X-Content-Type-Options: nosniff');
  320. header('Content-Security-Policy: default-src \'none\'; sandbox');
  321. header('Cache-Control: private, no-store, max-age=0');
  322. header('X-Robots-Tag: noindex, nofollow');
  323. ​
  324. $handle = fopen($path, 'rb');
  325. if ($handle !== false) {
  326. while (!feof($handle)) {
  327. echo fread($handle, 1048576);
  328. flush();
  329. if (connection_aborted()) {
  330. break;
  331. }
  332. }
  333. fclose($handle);
  334. }
  335. exit;
  336. }
  337. ​
  338. public static function sanitizeFileName(string $name): string
  339. {
  340. $name = str_replace(['\\', '/'], '_', $name);
  341. $name = (string) preg_replace('/[\x00-\x1F\x7F"<>|:*?]+/u', '', $name);
  342. $name = trim($name, " .\t");
  343. $name = mb_substr($name, 0, 180);
  344. ​
  345. return $name !== '' ? $name : 'download';
  346. }
  347. ​
  348. public static function formatSize(int $bytes): string
  349. {
  350. $units = ['B', 'KB', 'MB', 'GB'];
  351. $value = (float) $bytes;
  352. $unit = 0;
  353. while ($value >= 1024 && $unit < count($units) - 1) {
  354. $value /= 1024;
  355. $unit++;
  356. }
  357. ​
  358. return ($unit === 0 ? (string) $bytes : number_format($value, 1)) . ' ' . $units[$unit];
  359. }
  360. ​
  361. public static function isFree(array $product, ?array $variants = null): bool
  362. {
  363. if ((int) ($product['price_cents'] ?? 0) > 0) {
  364. return false;
  365. }
  366. ​
  367. if ($variants === null) {
  368. $statement = Database::site()->prepare('SELECT price_cents FROM product_variants WHERE product_id = :id');
  369. $statement->execute(['id' => (int) $product['id']]);
  370. $variants = $statement->fetchAll();
  371. }
  372. ​
  373. foreach ($variants as $variant) {
  374. if ($variant['price_cents'] !== null && (int) $variant['price_cents'] > 0) {
  375. return false;
  376. }
  377. }
  378. ​
  379. return true;
  380. }
  381. ​
  382. public static function customerOwnsProduct(int $userAccountId, int $productId): bool
  383. {
  384. $usersDb = Database::users();
  385. $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
  386. ​
  387. $orderStatement = $usersDb->prepare(
  388. "SELECT 1 FROM orders o JOIN order_items oi ON oi.order_id = o.id
  389. WHERE o.user_account_id = ? AND oi.product_id = ? AND o.status IN ({$placeholders}) LIMIT 1"
  390. );
  391. $orderStatement->execute(array_merge([$userAccountId, $productId], self::PAID_ORDER_STATUSES));
  392. if ($orderStatement->fetchColumn()) {
  393. return true;
  394. }
  395. ​
  396. $subscriptionStatement = $usersDb->prepare(
  397. "SELECT 1 FROM subscriptions
  398. WHERE user_account_id = :user_id AND product_id = :product_id AND status IN ('active', 'trialing')
  399. AND (current_period_end IS NULL OR current_period_end > NOW()) LIMIT 1"
  400. );
  401. $subscriptionStatement->execute(['user_id' => $userAccountId, 'product_id' => $productId]);
  402. ​
  403. return (bool) $subscriptionStatement->fetchColumn();
  404. }
  405. ​
  406. public static function ownedProductIds(int $userAccountId): array
  407. {
  408. $usersDb = Database::users();
  409. $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
  410. ​
  411. $orderStatement = $usersDb->prepare(
  412. "SELECT DISTINCT oi.product_id FROM orders o JOIN order_items oi ON oi.order_id = o.id
  413. WHERE o.user_account_id = ? AND o.status IN ({$placeholders})"
  414. );
  415. $orderStatement->execute(array_merge([$userAccountId], self::PAID_ORDER_STATUSES));
  416. $ids = array_map('intval', $orderStatement->fetchAll(PDO::FETCH_COLUMN));
  417. ​
  418. $subscriptionStatement = $usersDb->prepare(
  419. "SELECT DISTINCT product_id FROM subscriptions
  420. WHERE user_account_id = :user_id AND product_id IS NOT NULL AND status IN ('active', 'trialing')
  421. AND (current_period_end IS NULL OR current_period_end > NOW())"
  422. );
  423. $subscriptionStatement->execute(['user_id' => $userAccountId]);
  424. ​
  425. return array_values(array_unique(array_merge($ids, array_map('intval', $subscriptionStatement->fetchAll(PDO::FETCH_COLUMN)))));
  426. }
  427. ​
  428. public static function ensureUsersSchema(PDO $usersDb): void
  429. {
  430. if (self::$usersSchemaChecked) {
  431. return;
  432. }
  433. self::$usersSchemaChecked = true;
  434. ​
  435. try {
  436. $usersDb->exec(
  437. "CREATE TABLE IF NOT EXISTS download_grants (
  438. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  439. token CHAR(64) NOT NULL,
  440. product_id INT UNSIGNED NOT NULL,
  441. order_id INT UNSIGNED NULL,
  442. user_account_id INT UNSIGNED NULL,
  443. email VARCHAR(190) NULL,
  444. download_count INT UNSIGNED NOT NULL DEFAULT 0,
  445. max_downloads INT UNSIGNED NULL,
  446. expires_at DATETIME NULL,
  447. revoked_at DATETIME NULL,
  448. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  449. UNIQUE KEY uq_download_token (token),
  450. KEY idx_download_order (order_id),
  451. KEY idx_download_product (product_id)
  452. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
  453. );
  454. } catch (PDOException $exception) {
  455. error_log('StocketBase: could not create download_grants: ' . $exception->getMessage());
  456. }
  457. }
  458. ​
  459. public static function baseUrl(): string
  460. {
  461. return rtrim((string) Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', '');
  462. }
  463. ​
  464. public static function downloadUrl(int $fileId, ?string $token = null, bool $absolute = false): string
  465. {
  466. $url = 'download.php?file=' . $fileId . ($token !== null && $token !== '' ? '&token=' . rawurlencode($token) : '');
  467. ​
  468. return $absolute ? self::baseUrl() . '/' . $url : $url;
  469. }
  470. ​
  471. public static function grantsForOrder(PDO $usersDb, array $order, array $items): array
  472. {
  473. self::ensureUsersSchema($usersDb);
  474. ​
  475. $settings = SiteFront::settings();
  476. $expiryDays = max(0, (int) ($settings['download_link_expiry_days'] ?? 30));
  477. $maxDownloads = max(0, (int) ($settings['download_link_max_downloads'] ?? 10));
  478. $email = (string) ($order['guest_email'] ?? '');
  479. ​
  480. $links = [];
  481. $seenProducts = [];
  482. ​
  483. foreach ($items as $item) {
  484. $productId = (int) $item['product_id'];
  485. if (isset($seenProducts[$productId])) {
  486. continue;
  487. }
  488. $seenProducts[$productId] = true;
  489. ​
  490. $files = self::filesForProduct($productId);
  491. if (empty($files)) {
  492. continue;
  493. }
  494. ​
  495. $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');
  496. $existing->execute(['order_id' => (int) $order['id'], 'product_id' => $productId]);
  497. $token = (string) $existing->fetchColumn();
  498. ​
  499. if ($token === '') {
  500. $token = bin2hex(random_bytes(32));
  501. $usersDb->prepare(
  502. 'INSERT INTO download_grants (token, product_id, order_id, user_account_id, email, max_downloads, expires_at)
  503. VALUES (:token, :product_id, :order_id, :user_account_id, :email, :max_downloads, :expires_at)'
  504. )->execute([
  505. 'token' => $token,
  506. 'product_id' => $productId,
  507. 'order_id' => (int) $order['id'],
  508. 'user_account_id' => $order['user_account_id'] !== null ? (int) $order['user_account_id'] : null,
  509. 'email' => $email !== '' ? $email : null,
  510. 'max_downloads' => $maxDownloads > 0 ? $maxDownloads : null,
  511. 'expires_at' => $expiryDays > 0 ? date('Y-m-d H:i:s', time() + $expiryDays * 86400) : null,
  512. ]);
  513. }
  514. ​
  515. foreach ($files as $file) {
  516. $links[] = [
  517. 'name' => self::displayName($file),
  518. 'url' => self::downloadUrl((int) $file['id'], $token, true),
  519. ];
  520. }
  521. }
  522. ​
  523. return $links;
  524. }
  525. ​
  526. public static function findValidGrant(string $token, int $productId): ?array
  527. {
  528. if (preg_match('/^[a-f0-9]{64}$/', $token) !== 1) {
  529. return null;
  530. }
  531. ​
  532. $usersDb = Database::users();
  533. self::ensureUsersSchema($usersDb);
  534. ​
  535. $statement = $usersDb->prepare(
  536. 'SELECT * FROM download_grants WHERE token = :token AND product_id = :product_id AND revoked_at IS NULL
  537. AND (expires_at IS NULL OR expires_at > NOW())
  538. AND (max_downloads IS NULL OR download_count < max_downloads) LIMIT 1'
  539. );
  540. $statement->execute(['token' => $token, 'product_id' => $productId]);
  541. $grant = $statement->fetch();
  542. if (!$grant) {
  543. return null;
  544. }
  545. ​
  546. if ($grant['order_id'] !== null) {
  547. $placeholders = implode(',', array_fill(0, count(self::PAID_ORDER_STATUSES), '?'));
  548. $orderStatement = $usersDb->prepare("SELECT 1 FROM orders WHERE id = ? AND status IN ({$placeholders}) LIMIT 1");
  549. $orderStatement->execute(array_merge([(int) $grant['order_id']], self::PAID_ORDER_STATUSES));
  550. if (!$orderStatement->fetchColumn()) {
  551. return null;
  552. }
  553. }
  554. ​
  555. return $grant;
  556. }
  557. ​
  558. public static function consumeGrant(int $grantId): bool
  559. {
  560. $statement = Database::users()->prepare(
  561. 'UPDATE download_grants SET download_count = download_count + 1
  562. WHERE id = :id AND revoked_at IS NULL AND (max_downloads IS NULL OR download_count < max_downloads)'
  563. );
  564. $statement->execute(['id' => $grantId]);
  565. ​
  566. return $statement->rowCount() > 0;
  567. }
  568. ​
  569. public static function revokeGrantsForOrder(int $orderId): void
  570. {
  571. $usersDb = Database::users();
  572. self::ensureUsersSchema($usersDb);
  573. $usersDb->prepare('UPDATE download_grants SET revoked_at = NOW() WHERE order_id = :order_id AND revoked_at IS NULL')
  574. ->execute(['order_id' => $orderId]);
  575. }
  576. ​
  577. public static function tokensForOrder(int $orderId): array
  578. {
  579. $usersDb = Database::users();
  580. self::ensureUsersSchema($usersDb);
  581. $statement = $usersDb->prepare('SELECT product_id, token FROM download_grants WHERE order_id = :order_id AND revoked_at IS NULL');
  582. $statement->execute(['order_id' => $orderId]);
  583. ​
  584. $tokens = [];
  585. foreach ($statement->fetchAll() as $row) {
  586. $tokens[(int) $row['product_id']] = (string) $row['token'];
  587. }
  588. ​
  589. return $tokens;
  590. }
  591. }
  592. ​