WebOrbiton
v1.0.0.0

StocketBase

284 lines · 15.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/activity-log.php';
  10. require_once __DIR__ . '/includes/digital-files.php';
  11. ​
  12. Auth::boot();
  13. Auth::requireRoleAtLeast(Auth::ROLE_STORE_MANAGER);
  14. ​
  15. $db = Database::site();
  16. $flashMessage = null;
  17. $flashType = 'success';
  18. ​
  19. $findLiveProduct = static function (int $productId) use ($db): ?array {
  20. $statement = $db->prepare('SELECT id, title FROM products WHERE id = :id AND deleted_at IS NULL LIMIT 1');
  21. $statement->execute(['id' => $productId]);
  22. ​
  23. return $statement->fetch() ?: null;
  24. };
  25. ​
  26. if (isset($_GET['download'])) {
  27. $previewFile = DigitalFiles::find((int) $_GET['download']);
  28. $previewPath = $previewFile !== null ? DigitalFiles::resolvePath($previewFile) : null;
  29. if ($previewFile === null || $previewPath === null) {
  30. http_response_code(404);
  31. exit('File not found.');
  32. }
  33. DigitalFiles::stream($previewFile, $previewPath);
  34. }
  35. ​
  36. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  37. if (empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
  38. $flashMessage = 'The upload is larger than this server allows (post_max_size = ' . ini_get('post_max_size') . ', upload_max_filesize = ' . ini_get('upload_max_filesize') . ').';
  39. $flashType = 'error';
  40. } elseif (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  41. $flashMessage = 'Security check failed, please try again.';
  42. $flashType = 'error';
  43. } else {
  44. $action = (string) ($_POST['action'] ?? '');
  45. $fileId = (int) ($_POST['file_id'] ?? 0);
  46. $file = $fileId > 0 ? DigitalFiles::find($fileId) : null;
  47. ​
  48. if ($action === 'upload_file') {
  49. $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0));
  50. if ($targetProduct === null) {
  51. $flashMessage = 'Choose a product for the file.';
  52. $flashType = 'error';
  53. } else {
  54. [$stored, $flashMessage] = DigitalFiles::store((int) $targetProduct['id'], $_FILES['product_file'] ?? null);
  55. $flashType = $stored ? 'success' : 'error';
  56. if ($stored) {
  57. ActivityLog::record('product.file_upload', 'product', (int) $targetProduct['id'], mb_substr((string) ($_FILES['product_file']['name'] ?? ''), 0, 120));
  58. }
  59. }
  60. } elseif (in_array($action, ['rename_file', 'move_file', 'delete_file'], true) && $file === null) {
  61. $flashMessage = 'File not found.';
  62. $flashType = 'error';
  63. } elseif ($action === 'rename_file') {
  64. $newName = trim((string) ($_POST['name'] ?? ''));
  65. if ($newName === '') {
  66. $flashMessage = 'Enter a file name.';
  67. $flashType = 'error';
  68. } else {
  69. DigitalFiles::rename($fileId, $newName);
  70. DigitalFiles::setButtonLabel($fileId, (string) ($_POST['button_label'] ?? ''));
  71. ActivityLog::record('product.file_rename', 'product', (int) $file['product_id'], mb_substr($newName, 0, 120));
  72. $flashMessage = 'File updated.';
  73. }
  74. } elseif ($action === 'move_file') {
  75. $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0));
  76. if ($targetProduct === null) {
  77. $flashMessage = 'Choose a product to move the file to.';
  78. $flashType = 'error';
  79. } elseif ((int) $targetProduct['id'] === (int) $file['product_id'] && ($file['detached_at'] ?? null) === null) {
  80. $flashMessage = 'The file is already attached to that product.';
  81. $flashType = 'error';
  82. } else {
  83. DigitalFiles::move($fileId, (int) $targetProduct['id']);
  84. ActivityLog::record('product.file_move', 'product', (int) $targetProduct['id'], mb_substr((string) $file['original_name'], 0, 120));
  85. $flashMessage = 'File moved to "' . $targetProduct['title'] . '".';
  86. }
  87. } elseif ($action === 'delete_file') {
  88. DigitalFiles::delete($fileId);
  89. ActivityLog::record('product.file_delete', 'product', (int) $file['product_id'], mb_substr((string) $file['original_name'], 0, 120));
  90. $flashMessage = 'File deleted. Customers can no longer download it.';
  91. }
  92. ​
  93. if ($flashType === 'success' && $flashMessage !== null) {
  94. $_SESSION['files_flash'] = $flashMessage;
  95. header('Location: files.php' . (isset($_GET['filter']) || isset($_GET['q']) ? '?' . http_build_query(['filter' => (string) ($_GET['filter'] ?? 'all'), 'q' => (string) ($_GET['q'] ?? '')]) : ''));
  96. exit;
  97. }
  98. }
  99. }
  100. ​
  101. if (isset($_SESSION['files_flash'])) {
  102. $flashMessage = (string) $_SESSION['files_flash'];
  103. $flashType = 'success';
  104. unset($_SESSION['files_flash']);
  105. }
  106. ​
  107. $filter = in_array($_GET['filter'] ?? 'all', ['all', 'attached', 'detached'], true) ? (string) ($_GET['filter'] ?? 'all') : 'all';
  108. $search = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
  109. ​
  110. $files = DigitalFiles::allFiles($filter, $search);
  111. $linkCounts = DigitalFiles::linkDownloadCounts();
  112. $storage = DigitalFiles::storageInfo();
  113. $liveProducts = $db->query('SELECT id, title FROM products WHERE deleted_at IS NULL ORDER BY title ASC')->fetchAll();
  114. $totalBytes = array_sum(array_map(static fn(array $file): int => (int) $file['size_bytes'], $files));
  115. $totalDownloads = array_sum(array_map(static fn(array $file): int => (int) ($file['download_count'] ?? 0), $files));
  116. ​
  117. $dashActivePage = 'files';
  118. $dashPageTitle = 'Files';
  119. ​
  120. require __DIR__ . '/includes/dash-header.php';
  121. ​
  122. ?>
  123. ​
  124. <?php if ($flashMessage !== null): ?>
  125. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  126. <?php endif; ?>
  127. ​
  128. <h1 class="dash-title"><?= Icons::icon('download', 'icon icon-lg') ?>Files</h1>
  129. ​
  130. <div class="dash-cards">
  131. <div class="dash-card">
  132. <?= Icons::icon('layers') ?>
  133. <div class="dash-card-value"><?= count($files) ?></div>
  134. <div class="dash-card-label"><?= $filter === 'all' && $search === '' ? 'Files' : 'Files shown' ?></div>
  135. </div>
  136. <div class="dash-card">
  137. <?= Icons::icon('download') ?>
  138. <div class="dash-card-value"><?= number_format($totalDownloads) ?></div>
  139. <div class="dash-card-label">Downloads</div>
  140. </div>
  141. <div class="dash-card">
  142. <?= Icons::icon('save') ?>
  143. <div class="dash-card-value"><?= htmlspecialchars(DigitalFiles::formatSize($totalBytes)) ?></div>
  144. <div class="dash-card-label">Total size</div>
  145. </div>
  146. <div class="dash-card">
  147. <?= Icons::icon('shield') ?>
  148. <div class="dash-card-value" style="font-size:14px;"><?= $storage['path'] === null ? 'Not writable' : ($storage['outside_web_root'] ? 'Outside web root' : 'private-downloads/') ?></div>
  149. <div class="dash-card-label">Storage<?= $storage['free_bytes'] !== null ? ' · ' . htmlspecialchars(DigitalFiles::formatSize((int) $storage['free_bytes'])) . ' free' : '' ?></div>
  150. </div>
  151. </div>
  152. ​
  153. <?php if ($storage['path'] === null): ?>
  154. <div class="dash-flash dash-flash-error"><?= Icons::icon('info', 'icon icon-sm') ?>No writable folder for downloads. Create "stocketbase-downloads" next to the site folder (recommended) or "private-downloads" inside it, and make it writable by PHP.</div>
  155. <?php elseif (!$storage['outside_web_root']): ?>
  156. <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Files are stored in private-downloads/ inside the site folder, protected by .htaccess. On Nginx, block web access to this folder, or create "stocketbase-downloads" next to the site folder so files are kept outside the web root.</p>
  157. <?php endif; ?>
  158. ​
  159. <h2 class="dash-subtitle"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload a file</h2>
  160. <form method="post" enctype="multipart/form-data" class="files-toolbar">
  161. <?= Csrf::field() ?>
  162. <input type="hidden" name="action" value="upload_file">
  163. <div>
  164. <label>Product</label>
  165. <select name="product_id" required>
  166. <option value="">— choose a product —</option>
  167. <?php foreach ($liveProducts as $liveProduct): ?>
  168. <option value="<?= (int) $liveProduct['id'] ?>"><?= htmlspecialchars($liveProduct['title']) ?></option>
  169. <?php endforeach; ?>
  170. </select>
  171. </div>
  172. <div>
  173. <label>File (max <?= htmlspecialchars((string) ini_get('upload_max_filesize')) ?>)</label>
  174. <input type="file" name="product_file" required>
  175. </div>
  176. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload</button>
  177. </form>
  178. ​
  179. <h2 class="dash-subtitle"><?= Icons::icon('list', 'icon icon-sm') ?>All files</h2>
  180. <form method="get" class="files-toolbar">
  181. <div>
  182. <label>Search</label>
  183. <input type="text" name="q" value="<?= htmlspecialchars($search) ?>" placeholder="File or product name">
  184. </div>
  185. <div>
  186. <label>Show</label>
  187. <select name="filter" onchange="this.form.submit()">
  188. <option value="all" <?= $filter === 'all' ? 'selected' : '' ?>>All files</option>
  189. <option value="attached" <?= $filter === 'attached' ? 'selected' : '' ?>>Attached to a product</option>
  190. <option value="detached" <?= $filter === 'detached' ? 'selected' : '' ?>>From deleted products</option>
  191. </select>
  192. </div>
  193. <button type="submit" class="dash-btn"><?= Icons::icon('search', 'icon icon-sm') ?>Filter</button>
  194. </form>
  195. ​
  196. <table class="dash-table">
  197. <thead>
  198. <tr>
  199. <th>File</th>
  200. <th>Product</th>
  201. <th>Access</th>
  202. <th>Downloads</th>
  203. <th>Uploaded</th>
  204. <th></th>
  205. </tr>
  206. </thead>
  207. <tbody>
  208. <?php foreach ($files as $file): ?>
  209. <?php
  210. $isDetached = ($file['detached_at'] ?? null) !== null || $file['current_product_title'] === null;
  211. $isTrashed = !$isDetached && $file['product_deleted_at'] !== null;
  212. $isFreeFile = !$isDetached && !$isTrashed && $file['product_status'] === 'published'
  213. && DigitalFiles::isFree(['id' => (int) $file['product_id'], 'price_cents' => $file['product_price_cents']]);
  214. $counts = $linkCounts[(int) $file['product_id']] ?? null;
  215. ?>
  216. <tr>
  217. <td class="files-name-cell">
  218. <?= htmlspecialchars($file['original_name']) ?>
  219. <small><?= htmlspecialchars(DigitalFiles::formatSize((int) $file['size_bytes'])) ?> · <?= htmlspecialchars((string) $file['mime_type']) ?><?= trim((string) ($file['button_label'] ?? '')) !== '' ? ' · button: “' . htmlspecialchars((string) $file['button_label']) . '”' : '' ?></small>
  220. <form method="post" class="files-inline-form" style="margin-top:6px;">
  221. <?= Csrf::field() ?>
  222. <input type="hidden" name="action" value="rename_file">
  223. <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
  224. <input type="text" name="name" value="<?= htmlspecialchars($file['original_name']) ?>" maxlength="180" aria-label="File name" title="File name">
  225. <input type="text" name="button_label" value="<?= htmlspecialchars((string) ($file['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional)" aria-label="Button text" title="Button text shown to customers instead of the file name">
  226. <button type="submit" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Save</button>
  227. </form>
  228. </td>
  229. <td>
  230. <?php if ($isDetached): ?>
  231. <span class="status-pill status-archived">Deleted product</span><br>
  232. <small><?= htmlspecialchars((string) ($file['product_title'] ?? '')) ?></small>
  233. <?php else: ?>
  234. <a href="dashboard.php?view=product-edit&amp;id=<?= (int) $file['product_id'] ?>"><?= htmlspecialchars((string) $file['current_product_title']) ?></a>
  235. <?php if ($isTrashed): ?><br><span class="status-pill status-rejected">In trash</span><?php endif; ?>
  236. <?php endif; ?>
  237. </td>
  238. <td>
  239. <?php if ($isFreeFile): ?>
  240. <span class="status-pill status-published">Free</span>
  241. <?php elseif ($isDetached): ?>
  242. <span class="status-pill status-scheduled">Past buyers only</span>
  243. <?php else: ?>
  244. <span class="status-pill status-paid">Buyers</span>
  245. <?php endif; ?>
  246. </td>
  247. <td>
  248. <?= number_format((int) ($file['download_count'] ?? 0)) ?>
  249. <?php if ($counts !== null): ?>
  250. <br><small style="color:var(--muted);"><?= (int) $counts['links'] ?> email link<?= (int) $counts['links'] === 1 ? '' : 's' ?> issued</small>
  251. <?php endif; ?>
  252. </td>
  253. <td><?= htmlspecialchars(substr((string) $file['created_at'], 0, 16)) ?></td>
  254. <td class="dash-table-actions">
  255. <a href="files.php?download=<?= (int) $file['id'] ?>" class="dash-btn-small"><?= Icons::icon('download', 'icon icon-sm') ?>Download</a>
  256. <form method="post" class="files-inline-form" onsubmit="return confirm('Move this file? Customers who bought the current product will lose access to it, and buyers of the new product will get access.');">
  257. <?= Csrf::field() ?>
  258. <input type="hidden" name="action" value="move_file">
  259. <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
  260. <select name="product_id" required aria-label="Move to product">
  261. <option value="">Move to…</option>
  262. <?php foreach ($liveProducts as $liveProduct): ?>
  263. <option value="<?= (int) $liveProduct['id'] ?>"><?= htmlspecialchars($liveProduct['title']) ?></option>
  264. <?php endforeach; ?>
  265. </select>
  266. <button type="submit" class="dash-btn-small">Move</button>
  267. </form>
  268. <form method="post" style="display:inline;" onsubmit="return confirm('Delete this file permanently? Every customer loses access to it. This cannot be undone.');">
  269. <?= Csrf::field() ?>
  270. <input type="hidden" name="action" value="delete_file">
  271. <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
  272. <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button>
  273. </form>
  274. </td>
  275. </tr>
  276. <?php endforeach; ?>
  277. <?php if (empty($files)): ?>
  278. <tr><td colspan="6"><?= $filter === 'all' && $search === '' ? 'No files yet. Upload one above or from a product\'s edit page.' : 'No files match your filter.' ?></td></tr>
  279. <?php endif; ?>
  280. </tbody>
  281. </table>
  282. ​
  283. <?php require __DIR__ . '/includes/dash-footer.php'; ?>
  284. ​