v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/includes/config.php';
- require_once __DIR__ . '/includes/database.php';
- require_once __DIR__ . '/includes/auth.php';
- require_once __DIR__ . '/includes/csrf.php';
- require_once __DIR__ . '/includes/activity-log.php';
- require_once __DIR__ . '/includes/digital-files.php';
-
- Auth::boot();
- Auth::requireRoleAtLeast(Auth::ROLE_STORE_MANAGER);
-
- $db = Database::site();
- $flashMessage = null;
- $flashType = 'success';
-
- $findLiveProduct = static function (int $productId) use ($db): ?array {
- $statement = $db->prepare('SELECT id, title FROM products WHERE id = :id AND deleted_at IS NULL LIMIT 1');
- $statement->execute(['id' => $productId]);
-
- return $statement->fetch() ?: null;
- };
-
- if (isset($_GET['download'])) {
- $previewFile = DigitalFiles::find((int) $_GET['download']);
- $previewPath = $previewFile !== null ? DigitalFiles::resolvePath($previewFile) : null;
- if ($previewFile === null || $previewPath === null) {
- http_response_code(404);
- exit('File not found.');
- }
- DigitalFiles::stream($previewFile, $previewPath);
- }
-
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- if (empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
- $flashMessage = 'That file is too big for your server. The limit is ' . ini_get('upload_max_filesize') . ', ask your host if you need more.';
- $flashType = 'error';
- } elseif (!Csrf::verify($_POST['csrf_token'] ?? null)) {
- $flashMessage = 'Your session expired. Please try again.';
- $flashType = 'error';
- } else {
- $action = (string) ($_POST['action'] ?? '');
- $fileId = (int) ($_POST['file_id'] ?? 0);
- $file = $fileId > 0 ? DigitalFiles::find($fileId) : null;
-
- if ($action === 'upload_file') {
- $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0));
- if ($targetProduct === null) {
- $flashMessage = 'Pick which product this file belongs to.';
- $flashType = 'error';
- } else {
- [$stored, $flashMessage] = DigitalFiles::store((int) $targetProduct['id'], $_FILES['product_file'] ?? null);
- $flashType = $stored ? 'success' : 'error';
- if ($stored) {
- ActivityLog::record('product.file_upload', 'product', (int) $targetProduct['id'], mb_substr((string) ($_FILES['product_file']['name'] ?? ''), 0, 120));
- }
- }
- } elseif (in_array($action, ['rename_file', 'move_file', 'delete_file'], true) && $file === null) {
- $flashMessage = 'File not found.';
- $flashType = 'error';
- } elseif ($action === 'rename_file') {
- $newName = trim((string) ($_POST['name'] ?? ''));
- if ($newName === '') {
- $flashMessage = 'Please give the file a name.';
- $flashType = 'error';
- } else {
- DigitalFiles::rename($fileId, $newName);
- DigitalFiles::setButtonLabel($fileId, (string) ($_POST['button_label'] ?? ''));
- ActivityLog::record('product.file_rename', 'product', (int) $file['product_id'], mb_substr($newName, 0, 120));
- $flashMessage = 'Changes saved.';
- }
- } elseif ($action === 'move_file') {
- $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0));
- if ($targetProduct === null) {
- $flashMessage = 'Pick the product to move the file to.';
- $flashType = 'error';
- } elseif ((int) $targetProduct['id'] === (int) $file['product_id'] && ($file['detached_at'] ?? null) === null) {
- $flashMessage = 'The file already belongs to that product.';
- $flashType = 'error';
- } else {
- DigitalFiles::move($fileId, (int) $targetProduct['id']);
- ActivityLog::record('product.file_move', 'product', (int) $targetProduct['id'], mb_substr((string) $file['original_name'], 0, 120));
- $flashMessage = 'File moved to "' . $targetProduct['title'] . '".';
- }
- } elseif ($action === 'delete_file') {
- DigitalFiles::delete($fileId);
- ActivityLog::record('product.file_delete', 'product', (int) $file['product_id'], mb_substr((string) $file['original_name'], 0, 120));
- $flashMessage = 'File deleted. Nobody can download it anymore.';
- }
-
- if ($flashType === 'success' && $flashMessage !== null) {
- $_SESSION['files_flash'] = $flashMessage;
- header('Location: files.php' . (isset($_GET['filter']) || isset($_GET['q']) ? '?' . http_build_query(['filter' => (string) ($_GET['filter'] ?? 'all'), 'q' => (string) ($_GET['q'] ?? '')]) : ''));
- exit;
- }
- }
- }
-
- if (isset($_SESSION['files_flash'])) {
- $flashMessage = (string) $_SESSION['files_flash'];
- $flashType = 'success';
- unset($_SESSION['files_flash']);
- }
-
- $filter = in_array($_GET['filter'] ?? 'all', ['all', 'attached', 'detached'], true) ? (string) ($_GET['filter'] ?? 'all') : 'all';
- $search = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
-
- $files = DigitalFiles::allFiles($filter, $search);
- $linkCounts = DigitalFiles::linkDownloadCounts();
- $storage = DigitalFiles::storageInfo();
- $liveProducts = $db->query('SELECT id, title FROM products WHERE deleted_at IS NULL ORDER BY title ASC')->fetchAll();
- $totalBytes = array_sum(array_map(static fn(array $file): int => (int) $file['size_bytes'], $files));
- $totalDownloads = array_sum(array_map(static fn(array $file): int => (int) ($file['download_count'] ?? 0), $files));
-
- $dashActivePage = 'files';
- $dashPageTitle = 'Files';
-
- require __DIR__ . '/includes/dash-header.php';
-
- ?>
-
- <?php if ($flashMessage !== null): ?>
- <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
- <?php endif; ?>
-
- <h1 class="dash-title"><?= Icons::icon('download', 'icon icon-lg') ?>Files</h1>
-
- <div class="dash-cards">
- <div class="dash-card">
- <?= Icons::icon('layers') ?>
- <div class="dash-card-value"><?= count($files) ?></div>
- <div class="dash-card-label"><?= $filter === 'all' && $search === '' ? 'Files' : 'Matching files' ?></div>
- </div>
- <div class="dash-card">
- <?= Icons::icon('download') ?>
- <div class="dash-card-value"><?= number_format($totalDownloads) ?></div>
- <div class="dash-card-label">Downloads</div>
- </div>
- <div class="dash-card">
- <?= Icons::icon('save') ?>
- <div class="dash-card-value"><?= htmlspecialchars(DigitalFiles::formatSize($totalBytes)) ?></div>
- <div class="dash-card-label">Total size</div>
- </div>
- <div class="dash-card">
- <?= Icons::icon('shield') ?>
- <div class="dash-card-value" style="font-size:14px;"><?= $storage['path'] === null ? 'Not set up' : ($storage['outside_web_root'] ? 'Safe, outside your site' : 'Private folder') ?></div>
- <div class="dash-card-label">Storage<?= $storage['free_bytes'] !== null ? ' · ' . htmlspecialchars(DigitalFiles::formatSize((int) $storage['free_bytes'])) . ' free' : '' ?></div>
- </div>
- </div>
-
- <?php if ($storage['path'] === null): ?>
- <div class="dash-flash dash-flash-error"><?= Icons::icon('info', 'icon icon-sm') ?>There's nowhere to keep files yet. Create a folder called "stocketbase-downloads" next to your site folder (best) or "private-downloads" inside it, and let the site write to it. Your host can help with this.</div>
- <?php elseif (!$storage['outside_web_root']): ?>
- <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Files are kept in a private folder inside your site. On most hosts that's safe as it is. If your server uses Nginx, create a "stocketbase-downloads" folder next to your site folder instead so nobody can open the files directly.</p>
- <?php endif; ?>
-
- <h2 class="dash-subtitle"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload a file</h2>
- <form method="post" enctype="multipart/form-data" class="files-toolbar">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="upload_file">
- <div>
- <label>Product</label>
- <select name="product_id" required>
- <option value="">Pick a product</option>
- <?php foreach ($liveProducts as $liveProduct): ?>
- <option value="<?= (int) $liveProduct['id'] ?>"><?= htmlspecialchars($liveProduct['title']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- <div>
- <label>File (max <?= htmlspecialchars((string) ini_get('upload_max_filesize')) ?>)</label>
- <input type="file" name="product_file" required>
- </div>
- <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload</button>
- </form>
-
- <h2 class="dash-subtitle"><?= Icons::icon('list', 'icon icon-sm') ?>All files</h2>
- <form method="get" class="files-toolbar">
- <div>
- <label>Search</label>
- <input type="text" name="q" value="<?= htmlspecialchars($search) ?>" placeholder="File or product name">
- </div>
- <div>
- <label>Show</label>
- <select name="filter" onchange="this.form.submit()">
- <option value="all" <?= $filter === 'all' ? 'selected' : '' ?>>All files</option>
- <option value="attached" <?= $filter === 'attached' ? 'selected' : '' ?>>Attached to a product</option>
- <option value="detached" <?= $filter === 'detached' ? 'selected' : '' ?>>Left over from deleted products</option>
- </select>
- </div>
- <button type="submit" class="dash-btn"><?= Icons::icon('search', 'icon icon-sm') ?>Filter</button>
- </form>
-
- <table class="dash-table">
- <thead>
- <tr>
- <th>File</th>
- <th>Product</th>
- <th>Who can download</th>
- <th>Downloads</th>
- <th>Uploaded</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- <?php foreach ($files as $file): ?>
- <?php
- $isDetached = ($file['detached_at'] ?? null) !== null || $file['current_product_title'] === null;
- $isTrashed = !$isDetached && $file['product_deleted_at'] !== null;
- $isFreeFile = !$isDetached && !$isTrashed && $file['product_status'] === 'published'
- && DigitalFiles::isFree(['id' => (int) $file['product_id'], 'price_cents' => $file['product_price_cents']]);
- $counts = $linkCounts[(int) $file['product_id']] ?? null;
- ?>
- <tr>
- <td class="files-name-cell">
- <?= htmlspecialchars($file['original_name']) ?>
- <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>
- <form method="post" class="files-inline-form" style="margin-top:6px;">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="rename_file">
- <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
- <input type="text" name="name" value="<?= htmlspecialchars($file['original_name']) ?>" maxlength="180" aria-label="File name" title="File name">
- <input type="text" name="button_label" value="<?= htmlspecialchars((string) ($file['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional)" aria-label="Button text" title="Customers see this on the button instead of the file name">
- <button type="submit" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Save</button>
- </form>
- </td>
- <td>
- <?php if ($isDetached): ?>
- <span class="status-pill status-archived">Deleted product</span><br>
- <small><?= htmlspecialchars((string) ($file['product_title'] ?? '')) ?></small>
- <?php else: ?>
- <a href="dashboard.php?view=product-edit&id=<?= (int) $file['product_id'] ?>"><?= htmlspecialchars((string) $file['current_product_title']) ?></a>
- <?php if ($isTrashed): ?><br><span class="status-pill status-rejected">In trash</span><?php endif; ?>
- <?php endif; ?>
- </td>
- <td>
- <?php if ($isFreeFile): ?>
- <span class="status-pill status-published">Free</span>
- <?php elseif ($isDetached): ?>
- <span class="status-pill status-scheduled">Only past buyers</span>
- <?php else: ?>
- <span class="status-pill status-paid">Buyers only</span>
- <?php endif; ?>
- </td>
- <td>
- <?= number_format((int) ($file['download_count'] ?? 0)) ?>
- <?php if ($counts !== null): ?>
- <br><small style="color:var(--muted);"><?= (int) $counts['links'] ?> email link<?= (int) $counts['links'] === 1 ? '' : 's' ?> sent</small>
- <?php endif; ?>
- </td>
- <td><?= htmlspecialchars(substr((string) $file['created_at'], 0, 16)) ?></td>
- <td class="dash-table-actions">
- <a href="files.php?download=<?= (int) $file['id'] ?>" class="dash-btn-small"><?= Icons::icon('download', 'icon icon-sm') ?>Download</a>
- <form method="post" class="files-inline-form" onsubmit="return confirm('Move this file? People who bought the current product lose access, and buyers of the new one get it.');">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="move_file">
- <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
- <select name="product_id" required aria-label="Move to product">
- <option value="">Move to…</option>
- <?php foreach ($liveProducts as $liveProduct): ?>
- <option value="<?= (int) $liveProduct['id'] ?>"><?= htmlspecialchars($liveProduct['title']) ?></option>
- <?php endforeach; ?>
- </select>
- <button type="submit" class="dash-btn-small">Move</button>
- </form>
- <form method="post" style="display:inline;" onsubmit="return confirm('Delete this file for good? Nobody will be able to download it anymore.');">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="delete_file">
- <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>">
- <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button>
- </form>
- </td>
- </tr>
- <?php endforeach; ?>
- <?php if (empty($files)): ?>
- <tr><td colspan="6"><?= $filter === 'all' && $search === '' ? 'No files yet. Upload one above, or from a product\'s page.' : 'Nothing matches your search.' ?></td></tr>
- <?php endif; ?>
- </tbody>
- </table>
-
- <?php require __DIR__ . '/includes/dash-footer.php'; ?>
-