Browse Source

adding click tracking to gallery and download all

Medowar 6 hours ago
parent
commit
60ea471469
6 changed files with 113 additions and 6 deletions
  1. 12 2
      admin/galleries.php
  2. 7 0
      admin/index.php
  3. 6 2
      app/archive.php
  4. 74 2
      app/storage.php
  5. 7 0
      gallery/download.php
  6. 7 0
      gallery/index.php

+ 12 - 2
admin/galleries.php

@@ -77,11 +77,21 @@ flash_render();
     <p class="help">No galleries yet.</p>
     <p class="help">No galleries yet.</p>
 <?php else: ?>
 <?php else: ?>
 <div class="card"><table>
 <div class="card"><table>
-    <tr><th>Title</th><th>Images</th><th>Attributes</th><th>Expires</th><th>Created</th><th style="width:200px">Actions</th></tr>
-    <?php foreach ($galleries as $g): $expired = gallery_is_expired($g); ?>
+    <tr><th>Title</th><th>Images</th><th>Views</th><th>Downloads</th><th>Attributes</th><th>Expires</th><th>Created</th><th style="width:200px">Actions</th></tr>
+    <?php foreach ($galleries as $g):
+        $expired = gallery_is_expired($g);
+        $stats = gallery_stats($g['slug']);
+    ?>
     <tr>
     <tr>
         <td><?= e($g['title']) ?></td>
         <td><?= e($g['title']) ?></td>
         <td><?= count($g['images'] ?? []) ?></td>
         <td><?= count($g['images'] ?? []) ?></td>
+        <td title="<?= $stats['last_viewed_at'] ? 'Last opened ' . e($stats['last_viewed_at']) : 'Never opened' ?>">
+            <?= $stats['views'] ?>
+        </td>
+        <td title="<?= $stats['last_download_at'] ? 'Last download ' . e($stats['last_download_at']) : 'Never downloaded' ?>">
+            <?php /* "—" only while downloads never ran: a count survives them being switched off. */ ?>
+            <?= empty($g['downloads_enabled']) && $stats['downloads'] === 0 ? '—' : $stats['downloads'] ?>
+        </td>
         <td>
         <td>
             <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
             <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
             <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>
             <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>

+ 7 - 0
admin/index.php

@@ -5,6 +5,9 @@ auth_require();
 $site = site_get();
 $site = site_get();
 $galleries = galleries_all();
 $galleries = galleries_all();
 $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
 $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
+$stats = array_map(fn($g) => gallery_stats($g['slug']), $galleries);
+$views = array_sum(array_column($stats, 'views'));
+$downloads = array_sum(array_column($stats, 'downloads'));
 
 
 admin_header('Dashboard');
 admin_header('Dashboard');
 flash_render();
 flash_render();
@@ -16,6 +19,10 @@ flash_render();
             <td><a href="showreel.php">Manage →</a></td></tr>
             <td><a href="showreel.php">Manage →</a></td></tr>
         <tr><td>Galleries</td><td><?= count($galleries) ?> (<?= $active ?> active)</td>
         <tr><td>Galleries</td><td><?= count($galleries) ?> (<?= $active ?> active)</td>
             <td><a href="galleries.php">Manage →</a></td></tr>
             <td><a href="galleries.php">Manage →</a></td></tr>
+        <tr><td>Gallery views</td><td><?= $views ?></td>
+            <td><a href="galleries.php">Per gallery →</a></td></tr>
+        <tr><td>Archive downloads</td><td><?= $downloads ?></td>
+            <td><a href="galleries.php">Per gallery →</a></td></tr>
         <tr><td>Front page</td><td><?= e($site['intro_title']) ?></td>
         <tr><td>Front page</td><td><?= e($site['intro_title']) ?></td>
             <td><a href="frontpage.php">Edit →</a></td></tr>
             <td><a href="frontpage.php">Edit →</a></td></tr>
     </table>
     </table>

+ 6 - 2
app/archive.php

@@ -593,7 +593,10 @@ function archive_worker_url(): ?string
  * sets ignore_user_abort(), so it runs its slice regardless.
  * sets ignore_user_abort(), so it runs its slice regardless.
  *
  *
  * A timeout is the expected, successful outcome here — it means the request was
  * A timeout is the expected, successful outcome here — it means the request was
- * delivered and the worker is busy with it.
+ * delivered and the worker is busy with it. A *completed* response is only a
+ * success if it is the worker's own 204; anything else (a 404 from a wrong key
+ * or a misconfigured base URL) means nothing is running, and saying so lets the
+ * caller fall back to doing the work inline instead of silently stalling.
  */
  */
 function archive_dispatch(int $timeoutMs = 1000): bool
 function archive_dispatch(int $timeoutMs = 1000): bool
 {
 {
@@ -610,7 +613,8 @@ function archive_dispatch(int $timeoutMs = 1000): bool
     ]);
     ]);
     curl_exec($ch);
     curl_exec($ch);
     $errno = curl_errno($ch);
     $errno = curl_errno($ch);
-    return $errno === 0 || $errno === CURLE_OPERATION_TIMEOUTED;
+    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+    return $errno === CURLE_OPERATION_TIMEOUTED || ($errno === 0 && $status === 204);
 }
 }
 
 
 /**
 /**

+ 74 - 2
app/storage.php

@@ -238,6 +238,17 @@ function gallery_archive_buffer(string $slug): string
     return gallery_path($slug, '.archive.buf');
     return gallery_path($slug, '.archive.buf');
 }
 }
 
 
+/**
+ * The gallery's visit counters. Deliberately a sidecar rather than fields in
+ * the gallery file: every visitor writes it, and the gallery file — hundreds of
+ * image entries — would be rewritten in full on each page view, in contention
+ * with uploads and archive builds.
+ */
+function gallery_stats_file(string $slug): string
+{
+    return gallery_path($slug, '.stats.json');
+}
+
 function gallery_load(string $slug): ?array
 function gallery_load(string $slug): ?array
 {
 {
     try {
     try {
@@ -268,6 +279,8 @@ function gallery_delete(string $slug): void
     }
     }
     @unlink($file . '.lock');
     @unlink($file . '.lock');
     @unlink(gallery_archive_file($slug) . '.lock');
     @unlink(gallery_archive_file($slug) . '.lock');
+    @unlink(gallery_stats_file($slug));
+    @unlink(gallery_stats_file($slug) . '.lock');
 }
 }
 
 
 /**
 /**
@@ -296,13 +309,72 @@ function gallery_append_image(string $slug, array $image): ?int
     return count($gallery['images'] ?? []);
     return count($gallery['images'] ?? []);
 }
 }
 
 
+/** Counter name => the timestamp field recording when it last moved. */
+const GALLERY_COUNTERS = [
+    'views'     => 'last_viewed_at',
+    'downloads' => 'last_download_at',
+];
+
+/**
+ * Count one event on a gallery. Silently does nothing for an unknown slug or
+ * counter, so a stray link cannot litter the data directory with stats for
+ * galleries that never existed.
+ */
+function gallery_record_hit(string $slug, string $counter): void
+{
+    try {
+        if (!isset(GALLERY_COUNTERS[$counter]) || !is_file(gallery_file($slug))) {
+            return;
+        }
+    } catch (InvalidArgumentException) {
+        return;
+    }
+    json_update(gallery_stats_file($slug), function (array $stats) use ($counter) {
+        $stats[$counter] = (int)($stats[$counter] ?? 0) + 1;
+        $stats[GALLERY_COUNTERS[$counter]] = date('Y-m-d H:i:s');
+        return $stats;
+    });
+}
+
+/** One gallery opened by a visitor. */
+function gallery_record_view(string $slug): void
+{
+    gallery_record_hit($slug, 'views');
+}
+
+/** One ZIP archive handed out to a visitor. */
+function gallery_record_download(string $slug): void
+{
+    gallery_record_hit($slug, 'downloads');
+}
+
+/**
+ * A gallery's stats, with every counter present. Galleries that were never
+ * opened simply read as zero, with null timestamps.
+ */
+function gallery_stats(string $slug): array
+{
+    try {
+        $stored = json_read(gallery_stats_file($slug));
+    } catch (InvalidArgumentException) {
+        $stored = [];
+    }
+    $out = [];
+    foreach (GALLERY_COUNTERS as $counter => $timestamp) {
+        $out[$counter]  = (int)($stored[$counter] ?? 0);
+        $out[$timestamp] = $stored[$timestamp] ?? null;
+    }
+    return $out;
+}
+
 /** All galleries, newest first. */
 /** All galleries, newest first. */
 function galleries_all(): array
 function galleries_all(): array
 {
 {
     $out = [];
     $out = [];
     foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) {
     foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) {
-        // Skip the archive-build sidecars that live in the same directory.
-        if (str_ends_with($file, '.archive.json')) {
+        // Skip the sidecars (archive build state, visit counter) that live in
+        // the same directory and match the same glob.
+        if (str_ends_with($file, '.archive.json') || str_ends_with($file, '.stats.json')) {
             continue;
             continue;
         }
         }
         $g = json_read($file);
         $g = json_read($file);

+ 7 - 0
gallery/download.php

@@ -40,6 +40,13 @@ if (!$allowed) {
     exit;
     exit;
 }
 }
 
 
+// Counted here rather than on S3, which is the only place the transfer itself
+// is visible: this records that a visitor asked for the archive, not that the
+// download finished. The admin's own downloads stay out of the client's numbers.
+if (!auth_check()) {
+    gallery_record_download($slug);
+}
+
 // Short TTL on purpose: the browser only needs the URL long enough to start the
 // Short TTL on purpose: the browser only needs the URL long enough to start the
 // transfer. A GET already in flight is not cut off when the signature expires.
 // transfer. A GET already in flight is not cut off when the signature expires.
 redirect(s3_presign_get((string)$gallery['archive']['key'], 900));
 redirect(s3_presign_get((string)$gallery['archive']['key'], 900));

+ 7 - 0
gallery/index.php

@@ -59,6 +59,13 @@ if ($needsPassword && !$unlocked) {
     exit;
     exit;
 }
 }
 
 
+// One visit = one open of the gallery itself. Counted only once the gallery is
+// actually shown (past the password gate), and never for the logged-in admin,
+// so checking your own galleries does not inflate the client's numbers.
+if (!auth_check()) {
+    gallery_record_view($slug);
+}
+
 public_header(e($gallery['title']));
 public_header(e($gallery['title']));
 ?>
 ?>
 <main class="page">
 <main class="page">