Просмотр исходного кода

Persist checklists offline, protect admin writes, harden config and links

Four fixes, all exercised against a throwaway copy of the production
database.

Checklist state and offline support
  Ticks were DOM-only, so a reload or an evicted tab lost the lot -- on
  the one screen people use over hours. They are now stored per list
  hash in localStorage and restored on load, with ids for items no
  longer on the list pruned. Every storage access is guarded, so a
  private window degrades to not remembering rather than breaking. A
  Reset button appears once something is ticked.

  sw.js caches the shell and every list already opened, which is what
  makes an installed PackIt useful where there is no signal.
  Navigations are network-first so an online visit still reflects the
  latest admin changes; redirected responses are not cached, as a
  replayed redirect breaks navigation. The admin panel is never cached.

CSRF, session and login throttling
  Every admin POST acted on $_POST['action'] with no token check: any
  page an admin visited while logged in could delete the item
  catalogue. All 16 POST forms now carry a per-session token and all 6
  handlers verify it, login.php included. The session cookie is
  HttpOnly, SameSite=Strict, scoped to /admin/ and Secure over HTTPS.

  The old sleep(1) is replaced by a logged attempt table: 10 failures
  from one IP in 15 minutes locks that IP out. Failures against a
  username buy a growing delay instead of a lockout, because locking
  the username would let anyone shut the real admin out of the panel.

Error exposure and credentials
  display_errors was forced on and db.php printed the raw PDO message,
  so a dropped connection showed visitors the host, database and user.
  db.php is now the bootstrap: $appEnv drives error display, with
  fail_hard() and an exception handler logging the cause and showing a
  generic message. Anything but the exact string 'development' is
  production, so a typo fails closed.

  config.php is untracked and git-ignored, with config.sample.php as
  the template. The password in it is still in git history and needs
  rotating separately.

Collision-safe short links
  A 5-character SHA-256 prefix collides in the low thousands of lists,
  and the UNIQUE column turned that into an uncaught PDOException
  instead of a list. allocate_selection_hash() walks the prefix from 5
  to 16 characters, reusing the length already ours and stepping up
  only on a real collision; the insert race is caught and re-checked.
  The column drops from CHAR(64) to VARCHAR(16), left over from the old
  long hashes, and activity_ids gets the index every list creation
  needs.

Existing databases need migrations/001_hardening.sql; it deletes rows
still holding a 64-character hash, which packing.php has rejected since
links were shortened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sebastian 3 недель назад
Родитель
Сommit
7e2c7d34b6
20 измененных файлов с 551 добавлено и 55 удалено
  1. 1 0
      .gitignore
  2. 14 0
      .htaccess
  3. 41 8
      README.md
  4. 4 0
      admin/activities.php
  5. 4 0
      admin/activity_groups.php
  6. 48 0
      admin/auth.php
  7. 4 0
      admin/item_groups.php
  8. 4 0
      admin/items.php
  9. 42 6
      admin/login.php
  10. 4 0
      admin/mappings.php
  11. 0 13
      config.php
  12. 19 0
      config.sample.php
  13. 56 6
      db.php
  14. 6 1
      index.php
  15. 58 13
      list.php
  16. 31 0
      migrations/001_hardening.sql
  17. 32 0
      offline.html
  18. 67 5
      packing.php
  19. 17 3
      schema.sql
  20. 99 0
      sw.js

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 deploy.sh
+config.php

+ 14 - 0
.htaccess

@@ -5,3 +5,17 @@ AddType application/manifest+json .webmanifest
 <Files "config.php">
     Require all denied
 </Files>
+
+# The service worker must never be served from a stale browser cache, or an
+# update to it can take days to reach an installed app.
+<IfModule mod_headers.c>
+    <Files "sw.js">
+        Header set Cache-Control "no-cache, max-age=0, must-revalidate"
+    </Files>
+</IfModule>
+
+# Neither the config template nor the migrations belong in the web root, but
+# deny them too in case they are copied there by accident.
+<FilesMatch "^(config\.sample\.php|.*\.sql)$">
+    Require all denied
+</FilesMatch>

+ 41 - 8
README.md

@@ -17,7 +17,7 @@ PackIt is a smart packing list generator for outdoor activities. Select the acti
 
 1. **Choose your activities** — Pick one or more activities from the activity selection page. Activities are organised into groups (e.g. Overnight, Water Activities, Winter Sports).
 2. **Generate your list** — PackIt merges all item requirements across your selected activities, removing duplicates, and groups the result by category (Clothing, Shelter, Navigation, etc.). When the same item is mapped to multiple activities with different quantities, the highest quantity wins.
-3. **Pack and check off** — Work through the interactive checklist, ticking items as you pack them. A progress bar tracks how far along you are.
+3. **Pack and check off** — Work through the interactive checklist, ticking items as you pack them. A progress bar tracks how far along you are, and your ticks are remembered on that device, so packing can be spread over several sittings.
 4. **Share or print** — Every list gets a permanent, shareable short URL. You can also print the list directly from the browser.
 
 ## Features
@@ -25,9 +25,11 @@ PackIt is a smart packing list generator for outdoor activities. Select the acti
 - **Activity-based list generation** — Items are mapped to activities in the database. Selecting multiple activities produces a single merged list with no duplicate entries.
 - **Per-mapping quantities** — Each activity-to-item mapping carries a quantity. Items needed in multiples (e.g. 2× Water bottle) are rendered with a leading multiplier, and merging across activities keeps the largest required quantity.
 - **Grouped items** — Items are organised into logical categories (Clothing, Shelter, Navigation, Medical, Water & Food, Safety Gear) for easy scanning.
-- **Interactive checklist** — Click any item to mark it as packed. Checked items are visually struck through and a progress bar updates in real time.
+- **Interactive checklist** — Click any item to mark it as packed. Checked items are visually struck through and a progress bar updates in real time. Ticks are stored in the browser under the list's hash and restored on the next visit; a **Reset** button appears once something is ticked. Nothing is sent to the server, so each device keeps its own progress, and a browser with site data blocked simply falls back to not remembering.
 - **Shareable short URLs** — Each unique activity selection is stored under a deterministic 5-character hash, producing a permanent link short enough to share comfortably over chat or read out loud.
 - **Installable PWA** — A web app manifest, theme color, and maskable icons let PackIt be installed to the home screen on mobile or as a standalone app on desktop.
+- **Works offline** — A service worker (`sw.js`) caches the app shell and every list you have opened, which is what makes an installed PackIt useful at the trailhead. Pages are network-first, so an online visit always reflects the latest admin changes and falls back to the cached copy only when the network is gone. A list never opened on the device shows `offline.html` instead. The admin panel is deliberately never cached.
+- **Collision-safe short links** — Links are a 5-character SHA-256 prefix. If a prefix is ever claimed by a different selection, that selection transparently gets a 6-character link (then 7, …) rather than failing, so links stay short without ever pointing at the wrong list.
 - **Print support** — The packing list page has a print stylesheet that removes UI chrome and renders a clean black-and-white checklist.
 - **Admin panel** — A password-protected admin interface at `/admin/` lets you manage all content:
   - Activity groups and activities
@@ -35,6 +37,9 @@ PackIt is a smart packing list generator for outdoor activities. Select the acti
   - Activity-to-item mappings (with quantities)
   - Inline editing on every list page — edit fields render in place within the table column, so the page no longer jumps to the top on each edit
   - Dashboard with counts of all entities
+  - CSRF-protected: every form carries a per-session token that the matching handler verifies, so a page an admin merely visits cannot post changes on their behalf
+  - Session cookie is `HttpOnly`, `SameSite=Strict`, scoped to `/admin/`, and marked `Secure` over HTTPS
+  - Failed logins are logged and throttled (see below)
 
 ## Tech stack
 
@@ -49,10 +54,30 @@ PackIt is a smart packing list generator for outdoor activities. Select the acti
    ```
    mysql -u <user> -p < schema.sql
    ```
-2. Copy `config.php` and set your database credentials.
-3. Serve the project with any PHP-capable web server (Apache, Nginx, PHP built-in server). The included `.htaccess` ensures the `.webmanifest` MIME type is served correctly and denies HTTP access to `config.php`.
+2. Copy the config template and fill in your database credentials:
+   ```
+   cp config.sample.php config.php
+   ```
+   `config.php` is git-ignored and excluded from `deploy.sh` — it never belongs in the repository. On a real server, place it one directory above the web root.
+3. Serve the project with any PHP-capable web server (Apache, Nginx, PHP built-in server). The included `.htaccess` ensures the `.webmanifest` MIME type is served correctly, denies HTTP access to `config.php`, `config.sample.php` and `.sql` files, and keeps `sw.js` out of the browser cache.
 4. Open the site in a browser and start selecting activities. On mobile, use the browser's "Add to Home Screen" option to install PackIt as an app.
 
+Offline support needs a secure context: browsers only register a service worker over HTTPS (or on `localhost`). Everything else works over plain HTTP.
+
+### Error reporting
+
+`config.php` sets `$appEnv`. Leave it at `production` on a live site: errors are written to the PHP error log and visitors see a generic message. Set it to `development` to get the detail on screen instead. Anything other than the exact string `development` is treated as production, so a typo fails closed.
+
+### Upgrading an existing install
+
+Databases created before this change need one migration:
+
+```
+mysql -u <user> -p <your-database> < migrations/001_hardening.sql
+```
+
+It adds the `login_attempts` table, narrows `selection_hashes.hash` to `VARCHAR(16)`, and indexes `activity_ids`. It also deletes any rows still holding a 64-character hash from the original link scheme — those links have been unreachable since links were shortened to 5 characters, and the table is only a cache of selection → hash, so anything deleted is recreated by re-picking the activities.
+
 ### Admin password
 
 Admin credentials live in the `admin_users` table, not in `config.php`. The `password_hash` column stores a bcrypt hash produced by PHP's `password_hash()`; `admin/login.php` verifies the submitted password against it with `password_verify()`.
@@ -75,17 +100,25 @@ SET password_hash = '<paste-hash-here>'
 WHERE username = 'admin';
 ```
 
+### Login throttling
+
+Failed logins are recorded in `login_attempts`. After **10 failures from one IP within 15 minutes** that IP is refused until the window passes. Repeated failures against a *username* add a growing delay (up to 5 seconds) instead of a lockout — locking the username would let anyone shut the real admin out of their own panel. A successful login clears that IP's history, and rows older than a day are pruned on each write.
+
 ## Project structure
 
 ```
 index.php              Activity selection page
 list.php               Processes selection, stores hash, redirects
 packing.php            Displays the shareable packing list
-db.php                 PDO connection helper
-config.php             Database credentials
+db.php                 Bootstrap: error handling + PDO connection helper
+config.php             Database credentials (git-ignored, create from the sample)
+config.sample.php      Documented config template
 schema.sql             Database schema and sample data
+migrations/            One-off SQL upgrades for existing databases
+sw.js                  Service worker (offline support)
+offline.html           Shown when an unvisited list is opened offline
 manifest.webmanifest   PWA manifest (installable web app)
-.htaccess              MIME type for manifest, blocks config.php over HTTP
+.htaccess              Manifest MIME type, blocks config/SQL, no-cache for sw.js
 img/                   App icons (192/512, plus maskable variants)
 admin/
   index.php            Dashboard
@@ -95,5 +128,5 @@ admin/
   item_groups.php      Manage item groups
   mappings.php         Manage activity-to-item mappings (with quantities)
   layout.php           Shared admin layout
-  login.php / auth.php Authentication
+  login.php / auth.php Authentication, CSRF helpers, login throttling
 ```

+ 4 - 0
admin/activities.php

@@ -6,6 +6,7 @@ require_admin();
 $pdo = get_pdo();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $action = $_POST['action'] ?? '';
 
     if ($action === 'add') {
@@ -61,6 +62,7 @@ show_alerts();
 <div class="card">
   <h2>Add New Activity</h2>
   <form method="POST">
+    <?= csrf_field() ?>
     <input type="hidden" name="action" value="add">
     <div class="form-row">
       <div>
@@ -89,6 +91,7 @@ show_alerts();
 
 <?php foreach ($activities as $a): ?>
 <form id="edit-form-<?= $a['activityID'] ?>" method="POST">
+  <?= csrf_field() ?>
   <input type="hidden" name="action" value="edit">
   <input type="hidden" name="activityID" value="<?= $a['activityID'] ?>">
 </form>
@@ -131,6 +134,7 @@ show_alerts();
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
         <form method="POST" onsubmit="return confirm('Delete this activity?')">
+          <?= csrf_field() ?>
           <input type="hidden" name="action" value="delete">
           <input type="hidden" name="activityID" value="<?= $id ?>">
           <button class="btn btn-sm btn-danger">Delete</button>

+ 4 - 0
admin/activity_groups.php

@@ -6,6 +6,7 @@ require_admin();
 $pdo = get_pdo();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $action = $_POST['action'] ?? '';
 
     if ($action === 'add') {
@@ -53,6 +54,7 @@ show_alerts();
 <div class="card">
   <h2>Add New Group</h2>
   <form method="POST">
+    <?= csrf_field() ?>
     <input type="hidden" name="action" value="add">
     <div class="form-row">
       <div>
@@ -72,6 +74,7 @@ show_alerts();
 
 <?php foreach ($groups as $g): ?>
 <form id="edit-form-<?= $g['groupID'] ?>" method="POST">
+  <?= csrf_field() ?>
   <input type="hidden" name="action" value="edit">
   <input type="hidden" name="groupID" value="<?= $g['groupID'] ?>">
 </form>
@@ -108,6 +111,7 @@ show_alerts();
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
         <form method="POST" onsubmit="return confirm('Delete this group and ALL its activities?')">
+          <?= csrf_field() ?>
           <input type="hidden" name="action" value="delete">
           <input type="hidden" name="groupID" value="<?= $id ?>">
           <button type="submit" class="btn btn-sm btn-danger">Delete</button>

+ 48 - 0
admin/auth.php

@@ -5,6 +5,18 @@
 require_once __DIR__ . '/../db.php';
 
 session_name('packit_admin');
+
+// Scope the cookie to the admin directory, keep it off JavaScript, and never
+// let it travel cross-site (which is what makes the CSRF tokens below the
+// second line of defence rather than the only one).
+$adminPath = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/x.php')), '/') . '/';
+session_set_cookie_params([
+    'lifetime' => 0,
+    'path'     => $adminPath,
+    'httponly' => true,
+    'secure'   => request_is_https(),
+    'samesite' => 'Strict',
+]);
 session_start();
 
 define('ADMIN_LOGGED_IN', isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true);
@@ -22,7 +34,43 @@ function admin_url(string $page): string {
 
 function admin_logout(): void {
     $_SESSION = [];
+    if (ini_get('session.use_cookies')) {
+        $p = session_get_cookie_params();
+        setcookie(session_name(), '', time() - 42000,
+                  $p['path'], $p['domain'], $p['secure'], $p['httponly']);
+    }
     session_destroy();
     header('Location: login.php');
     exit;
 }
+
+// ---------------------------------------------------------------------------
+// CSRF
+// ---------------------------------------------------------------------------
+
+/** Per-session token, minted on first use and reused for the session's life. */
+function csrf_token(): string {
+    if (empty($_SESSION['csrf_token'])) {
+        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+    }
+    return $_SESSION['csrf_token'];
+}
+
+/** Hidden field to drop into every POST form. */
+function csrf_field(): string {
+    return '<input type="hidden" name="csrf_token" value="'
+           . htmlspecialchars(csrf_token(), ENT_QUOTES) . '">';
+}
+
+/**
+ * Guard for every state-changing POST handler. Without this, any page an
+ * admin visits while logged in could post to us on their behalf.
+ */
+function require_csrf(): void {
+    $sent = $_POST['csrf_token'] ?? '';
+    if (!is_string($sent) || $sent === '' || !hash_equals(csrf_token(), $sent)) {
+        http_response_code(403);
+        exit('<p style="font-family:system-ui,sans-serif">Invalid or expired form token. '
+             . 'Go back, reload the page and try again.</p>');
+    }
+}

+ 4 - 0
admin/item_groups.php

@@ -6,6 +6,7 @@ require_admin();
 $pdo = get_pdo();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $action = $_POST['action'] ?? '';
     if ($action === 'add') {
         $name  = trim($_POST['groupName'] ?? '');
@@ -42,6 +43,7 @@ show_alerts();
 <div class="card">
   <h2>Add New Group</h2>
   <form method="POST">
+    <?= csrf_field() ?>
     <input type="hidden" name="action" value="add">
     <div class="form-row">
       <div>
@@ -61,6 +63,7 @@ show_alerts();
 
 <?php foreach ($groups as $g): ?>
 <form id="edit-form-<?= $g['groupID'] ?>" method="POST">
+  <?= csrf_field() ?>
   <input type="hidden" name="action" value="edit">
   <input type="hidden" name="groupID" value="<?= $g['groupID'] ?>">
 </form>
@@ -97,6 +100,7 @@ show_alerts();
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
         <form method="POST" onsubmit="return confirm('Delete group and ALL its items?')">
+          <?= csrf_field() ?>
           <input type="hidden" name="action" value="delete">
           <input type="hidden" name="groupID" value="<?= $id ?>">
           <button class="btn btn-sm btn-danger">Delete</button>

+ 4 - 0
admin/items.php

@@ -6,6 +6,7 @@ require_admin();
 $pdo = get_pdo();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $action = $_POST['action'] ?? '';
     if ($action === 'add') {
         $name  = trim($_POST['itemName'] ?? '');
@@ -70,6 +71,7 @@ show_alerts();
 <div class="card">
   <h2>Add New Item</h2>
   <form method="POST">
+    <?= csrf_field() ?>
     <input type="hidden" name="action" value="add">
     <div class="form-row">
       <div>
@@ -99,6 +101,7 @@ show_alerts();
 
 <?php foreach ($items as $item): ?>
 <form id="edit-form-<?= $item['itemID'] ?>" method="POST">
+  <?= csrf_field() ?>
   <input type="hidden" name="action" value="edit">
   <input type="hidden" name="itemID" value="<?= $item['itemID'] ?>">
 </form>
@@ -148,6 +151,7 @@ show_alerts();
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
         <form method="POST" onsubmit="return confirm('Delete this item?')">
+          <?= csrf_field() ?>
           <input type="hidden" name="action" value="delete">
           <input type="hidden" name="itemID" value="<?= $id ?>">
           <button class="btn btn-sm btn-danger">Delete</button>

+ 42 - 6
admin/login.php

@@ -6,16 +6,46 @@ if (ADMIN_LOGGED_IN) {
     exit;
 }
 
+// Brute-force throttling.
+//
+// A hard lock is applied per client IP only. Locking the *username* instead
+// would let anyone shut the admin out of their own panel by guessing at it,
+// so repeated failures against a username buy a growing delay rather than a
+// closed door -- enough to make distributed guessing impractical.
+const LOGIN_MAX_PER_IP  = 10;   // failures per IP within the window
+const LOGIN_WINDOW_MIN  = 15;   // rolling window, minutes
+const LOGIN_MAX_DELAY   = 5;    // seconds, cap on the per-username slowdown
+
+function login_failures(PDO $pdo, string $column, string $value): int {
+    $col = $column === 'ip' ? 'ip' : 'username';   // never interpolate raw input
+    $stmt = $pdo->prepare(
+        "SELECT COUNT(*) FROM login_attempts
+          WHERE $col = ? AND attempted_at > (NOW() - INTERVAL " . LOGIN_WINDOW_MIN . " MINUTE)");
+    $stmt->execute([$value]);
+    return (int)$stmt->fetchColumn();
+}
+
+function record_login_failure(PDO $pdo, string $ip, string $user): void {
+    $pdo->prepare("INSERT INTO login_attempts (ip, username) VALUES (?,?)")
+        ->execute([$ip, substr($user, 0, 50)]);
+    // Opportunistic cleanup so the table cannot grow without bound.
+    $pdo->query("DELETE FROM login_attempts WHERE attempted_at < (NOW() - INTERVAL 1 DAY)");
+}
+
 $error = '';
+$ip    = substr($_SERVER['REMOTE_ADDR'] ?? 'unknown', 0, 45);
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $user = trim($_POST['username'] ?? '');
     $pass = $_POST['password'] ?? '';
+    $pdo  = get_pdo();
 
-    if ($user === '' || $pass === '') {
+    if (login_failures($pdo, 'ip', $ip) >= LOGIN_MAX_PER_IP) {
+        $error = 'Too many failed attempts. Try again in ' . LOGIN_WINDOW_MIN . ' minutes.';
+    } elseif ($user === '' || $pass === '') {
         $error = 'Please enter username and password.';
     } else {
-        $pdo = get_pdo();
         $stmt = $pdo->prepare("SELECT password_hash FROM admin_users WHERE username = ?");
         $stmt->execute([$user]);
         $row = $stmt->fetch();
@@ -24,13 +54,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
             session_regenerate_id(true);
             $_SESSION['admin_logged_in'] = true;
             $_SESSION['admin_user']      = $user;
+            // A fresh token for the new session id.
+            unset($_SESSION['csrf_token']);
+            // Clear this client's failure history.
+            $pdo->prepare("DELETE FROM login_attempts WHERE ip = ?")->execute([$ip]);
             header('Location: index.php');
             exit;
-        } else {
-            $error = 'Invalid username or password.';
-            // Slow down brute-force attempts
-            sleep(1);
         }
+
+        record_login_failure($pdo, $ip, $user);
+        $error = 'Invalid username or password.';
+        // Slow the next guess down in proportion to how many came before it.
+        sleep(min(LOGIN_MAX_DELAY, 1 + login_failures($pdo, 'username', $user)));
     }
 }
 ?>
@@ -67,6 +102,7 @@ input:focus{border-color:var(--accent2);}
   <div class="error-msg"><?= htmlspecialchars($error) ?></div>
   <?php endif; ?>
   <form method="POST">
+    <?= csrf_field() ?>
     <label for="username">Username</label>
     <input type="text" id="username" name="username"
            value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"

+ 4 - 0
admin/mappings.php

@@ -7,6 +7,7 @@ $pdo = get_pdo();
 
 // Handle add/remove mapping
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    require_csrf();
     $action = $_POST['action'] ?? '';
     $aid    = (int)($_POST['activityID'] ?? 0);
     $iid    = (int)($_POST['itemID']     ?? 0);
@@ -140,6 +141,7 @@ show_alerts();
 
       <!-- Add new mapping -->
       <form method="POST" style="display:flex;gap:.5rem;align-items:flex-end;margin-bottom:1.25rem;flex-wrap:wrap;">
+        <?= csrf_field() ?>
         <input type="hidden" name="action" value="add">
         <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
         <div style="flex:1;min-width:200px;">
@@ -193,6 +195,7 @@ show_alerts();
           <td><span class="badge badge-group"><?= htmlspecialchars($it['groupName']) ?></span></td>
           <td>
             <form method="POST" style="display:flex;gap:.3rem;align-items:center;">
+              <?= csrf_field() ?>
               <input type="hidden" name="action"     value="update">
               <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
               <input type="hidden" name="itemID"     value="<?= $it['itemID'] ?>">
@@ -202,6 +205,7 @@ show_alerts();
           </td>
           <td style="text-align:right">
             <form method="POST" onsubmit="return confirm('Remove this mapping?')">
+              <?= csrf_field() ?>
               <input type="hidden" name="action"     value="remove">
               <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
               <input type="hidden" name="itemID"     value="<?= $it['itemID'] ?>">

+ 0 - 13
config.php

@@ -1,13 +0,0 @@
-<?php
-// PackIt – database configuration
-// Place this file ONE directory ABOVE the web-root (e.g. /var/www/config.php)
-// so it is never served directly by the web server.
-
-// sample configuration
-$host     = 'localhost';
-$dbname   = 'packit';
-$username = 'packer';
-$password = 'DEbWX9AHkzAgaNdLC0Qj605grG';
-$charset  = 'utf8mb4';
-
-?>

+ 19 - 0
config.sample.php

@@ -0,0 +1,19 @@
+<?php
+// PackIt – database configuration (SAMPLE)
+//
+// Copy this file to config.php and fill in your own credentials:
+//     cp config.sample.php config.php
+//
+// config.php is git-ignored and excluded from deploy.sh. On a real server,
+// place it ONE directory ABOVE the web root (e.g. /var/www/config.php) so it
+// is never served directly; .htaccess denies it as a second line of defence.
+
+// 'production' (default) hides all error detail from visitors and writes it to
+// the PHP error log instead. Set to 'development' for on-screen stack traces.
+$appEnv = 'production';
+
+$host     = 'localhost';
+$dbname   = 'packit';
+$username = 'packit';
+$password = 'change-me';
+$charset  = 'utf8mb4';

+ 56 - 6
db.php

@@ -1,9 +1,60 @@
 <?php
-// db.php – shared PDO helper
-// Included by every script that needs a database connection.
-ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
+// db.php – shared bootstrap: error handling + PDO helper.
+// Included by every script that needs a database connection, so the error
+// configuration below applies site-wide.
 require_once __DIR__ . '/config.php';
 
+// $appEnv comes from config.php. Anything other than an explicit
+// 'development' is treated as production, so a missing or misspelt value
+// fails closed and never leaks internals to visitors.
+define('APP_DEBUG', (isset($appEnv) ? $appEnv : 'production') === 'development');
+
+error_reporting(E_ALL);
+ini_set('display_errors',         APP_DEBUG ? '1' : '0');
+ini_set('display_startup_errors', APP_DEBUG ? '1' : '0');
+ini_set('log_errors', '1');
+
+/**
+ * Log the real reason, show the visitor something harmless, stop.
+ * Detail only reaches the browser in development.
+ */
+function fail_hard(string $logMessage): void {
+    error_log('PackIt: ' . $logMessage);
+    if (!headers_sent()) {
+        http_response_code(500);
+        header('Content-Type: text/html; charset=utf-8');
+    }
+    if (APP_DEBUG) {
+        echo '<p style="font-family:monospace;color:#c00">'
+             . htmlspecialchars($logMessage) . '</p>';
+    } else {
+        echo '<p style="font-family:system-ui,sans-serif">Something went wrong on our side. '
+             . 'Please try again in a moment.</p>';
+    }
+    exit;
+}
+
+// Uncaught exceptions (a dropped connection mid-query, a constraint we did not
+// anticipate) would otherwise print file paths and SQL to the page.
+set_exception_handler(function (Throwable $e): void {
+    fail_hard(get_class($e) . ': ' . $e->getMessage()
+              . ' in ' . $e->getFile() . ':' . $e->getLine());
+});
+
+/**
+ * True when the visitor reached us over TLS, including through a reverse proxy
+ * that terminates it and forwards X-Forwarded-Proto.
+ */
+function request_is_https(): bool {
+    if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
+        return true;
+    }
+    if (strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
+        return true;
+    }
+    return (int)($_SERVER['SERVER_PORT'] ?? 0) === 443;
+}
+
 function get_pdo(): PDO {
     global $host, $dbname, $username, $password, $charset;
     static $pdo = null;
@@ -17,9 +68,8 @@ function get_pdo(): PDO {
         try {
             $pdo = new PDO($dsn, $username, $password, $options);
         } catch (PDOException $e) {
-            http_response_code(500);
-            die('<p style="font-family:monospace;color:#c00">Database connection failed: '
-                . htmlspecialchars($e->getMessage()) . '</p>');
+            // The message names the host, database and user – log only.
+            fail_hard('Database connection failed: ' . $e->getMessage());
         }
     }
     return $pdo;

+ 6 - 1
index.php

@@ -1,5 +1,4 @@
 <?php
-ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
 // index.php – Activity selection page
 require_once __DIR__ . '/db.php';
 
@@ -318,6 +317,12 @@ function toggleAll(on) {
   document.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = on);
   updateCount();
 }
+
+// Offline support: installs the worker that keeps visited lists readable
+// without signal.
+if ('serviceWorker' in navigator) {
+  window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
+}
 </script>
 </body>
 </html>

+ 58 - 13
list.php

@@ -2,8 +2,57 @@
 // list.php – receives selected activity IDs, creates/reuses a hash, redirects to packing.php
 require_once __DIR__ . '/db.php';
 
+// Shortest link we hand out. 5 hex characters is ~1M values, so by the
+// birthday bound collisions start showing up in the low thousands of stored
+// lists; allocate_selection_hash() below grows the prefix when one happens.
+const HASH_MIN_LEN = 5;
+const HASH_MAX_LEN = 16;   // must fit selection_hashes.hash
+
 $pdo = get_pdo();
 
+/**
+ * Deterministic short hash for a selection, guaranteed unique.
+ *
+ * Walks the SHA-256 prefix from HASH_MIN_LEN upwards until it finds a length
+ * that is either already ours or free, so a collision costs one extra
+ * character instead of a fatal duplicate-key error.
+ */
+function allocate_selection_hash(PDO $pdo, string $idsStr): string {
+    $full = hash('sha256', $idsStr);
+
+    for ($len = HASH_MIN_LEN; $len <= HASH_MAX_LEN; $len++) {
+        $candidate = substr($full, 0, $len);
+
+        $lookup = $pdo->prepare("SELECT activity_ids FROM selection_hashes WHERE hash = ?");
+        $lookup->execute([$candidate]);
+        $owner = $lookup->fetchColumn();
+
+        if ($owner === false) {
+            try {
+                $pdo->prepare("INSERT INTO selection_hashes (hash, activity_ids) VALUES (?, ?)")
+                    ->execute([$candidate, $idsStr]);
+                return $candidate;
+            } catch (PDOException $e) {
+                // 23000 = integrity constraint: a concurrent request claimed
+                // this candidate between the SELECT and the INSERT. Re-read
+                // and fall through to the ownership check below.
+                if ($e->getCode() !== '23000') {
+                    throw $e;
+                }
+                $lookup->execute([$candidate]);
+                $owner = $lookup->fetchColumn();
+            }
+        }
+
+        if ($owner === $idsStr) {
+            return $candidate;      // this selection already lives here
+        }
+        // Taken by a different selection – try one character longer.
+    }
+
+    throw new RuntimeException("Could not allocate a hash for selection [$idsStr]");
+}
+
 // Sanitize input
 $raw = isset($_GET['aID']) && is_array($_GET['aID']) ? $_GET['aID'] : [];
 $ids = [];
@@ -32,24 +81,20 @@ if (empty($validIds)) {
 sort($validIds);
 $idsStr = implode(',', $validIds);
 
-// Generate deterministic 5-character hash from the sorted activity IDs
-$hash = substr(hash('sha256', $idsStr), 0, 5);
-
-// Check if this exact selection already has a hash stored
+// Reuse the stored hash when this selection already has a usable one.
 $stmt = $pdo->prepare("SELECT hash FROM selection_hashes WHERE activity_ids = ?");
 $stmt->execute([$idsStr]);
 $existing = $stmt->fetchColumn();
 
-if ($existing) {
-    if ($existing !== $hash) {
-        // Overwrite old (long) hash with the new short hash
-        $stmt = $pdo->prepare("UPDATE selection_hashes SET hash = ? WHERE activity_ids = ?");
-        $stmt->execute([$hash, $idsStr]);
-    }
+if ($existing !== false && preg_match('/^[a-f0-9]{' . HASH_MIN_LEN . ',' . HASH_MAX_LEN . '}$/', $existing)) {
+    $hash = $existing;
 } else {
-    // Store new hash
-    $stmt = $pdo->prepare("INSERT INTO selection_hashes (hash, activity_ids) VALUES (?, ?)");
-    $stmt->execute([$hash, $idsStr]);
+    if ($existing !== false) {
+        // A leftover 64-character hash from before short links; packing.php no
+        // longer accepts it, so drop the row and allocate a fresh short one.
+        $pdo->prepare("DELETE FROM selection_hashes WHERE activity_ids = ?")->execute([$idsStr]);
+    }
+    $hash = allocate_selection_hash($pdo, $idsStr);
 }
 
 // Redirect to the shareable packing list URL

+ 31 - 0
migrations/001_hardening.sql

@@ -0,0 +1,31 @@
+-- Migration 001 – brute-force throttling + collision-safe short links.
+-- Run once against an existing PackIt database:
+--     mysql -u <user> -p <your-database> < migrations/001_hardening.sql
+-- New installs get all of this from schema.sql and can skip it.
+
+-- 1. Failed-login log backing the throttle in admin/login.php.
+CREATE TABLE IF NOT EXISTS login_attempts (
+    attemptID    INT AUTO_INCREMENT PRIMARY KEY,
+    ip           VARCHAR(45) NOT NULL,
+    username     VARCHAR(50) NOT NULL,
+    attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_ip_time   (ip, attempted_at),
+    INDEX idx_user_time (username, attempted_at)
+);
+
+-- 2. Short links.
+--
+-- Rows whose hash is longer than 16 characters are leftovers from the original
+-- 64-character scheme. packing.php has rejected those links since the switch to
+-- 5-character hashes, so they are already dead; deleting them lets the column
+-- shrink. The table is only a cache of selection -> hash: any such link is
+-- recreated by re-picking the activities.
+DELETE FROM selection_hashes WHERE CHAR_LENGTH(hash) > 16;
+
+ALTER TABLE selection_hashes
+    MODIFY hash         VARCHAR(16)  NOT NULL,
+    MODIFY activity_ids VARCHAR(500) NOT NULL;
+
+-- Every new list looks its selection up by activity_ids; without this it is a
+-- full table scan.
+ALTER TABLE selection_hashes ADD INDEX idx_activity_ids (activity_ids);

+ 32 - 0
offline.html

@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PackIt – Offline</title>
+<meta name="theme-color" content="#e8c547">
+<link rel="icon" type="image/png" sizes="192x192" href="/img/icon-192.png">
+<style>
+  :root { --bg:#0f1117; --surface:#181c27; --border:#2a2f3f; --accent:#e8c547; --text:#e8eaf0; --muted:#7a8099; }
+  *,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
+  body { background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,'DM Sans',sans-serif;
+         min-height:100vh; display:flex; align-items:center; justify-content:center; padding:1.5rem; }
+  .box { background:var(--surface); border:1px solid var(--border); border-radius:10px;
+         padding:2.5rem 2rem; max-width:420px; text-align:center; }
+  .logo { font-size:2.2rem; font-weight:700; letter-spacing:.04em; color:var(--accent); margin-bottom:.5rem; }
+  h1 { font-size:1.05rem; font-weight:600; margin-bottom:.75rem; }
+  p { font-size:.9rem; color:var(--muted); line-height:1.55; margin-bottom:1.5rem; }
+  a { display:inline-block; background:var(--accent); color:#0f1117; text-decoration:none;
+      font-weight:600; font-size:.9rem; padding:.7rem 1.6rem; border-radius:10px; }
+</style>
+</head>
+<body>
+<div class="box">
+  <div class="logo">PackIt</div>
+  <h1>You're offline</h1>
+  <p>This list hasn't been opened on this device yet, so there's no copy to show.
+     Lists you've already opened stay available offline, ticks and all.</p>
+  <a href="/index.php">Try again</a>
+</div>
+</body>
+</html>

+ 67 - 5
packing.php

@@ -1,12 +1,13 @@
 <?php
-ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
 // packing.php – displays the packing list for a given hash
 require_once __DIR__ . '/db.php';
 
 $pdo = get_pdo();
 
+// 5 characters normally; list.php lengthens a link only when a shorter one
+// would collide with a different selection.
 $hash = isset($_GET['h']) ? trim($_GET['h']) : '';
-if (!preg_match('/^[a-f0-9]{5}$/', $hash)) {
+if (!preg_match('/^[a-f0-9]{5,16}$/', $hash)) {
     http_response_code(400);
     die('<p>Invalid or missing list hash.</p>');
 }
@@ -367,6 +368,7 @@ $totalItems = count($allItems);
   </div>
   <div class="btn-row">
     <a href="index.php" class="btn btn-outline">← New list</a>
+    <button class="btn btn-outline" id="resetBtn" onclick="resetList()" hidden>↺ Reset</button>
     <button class="btn btn-teal" onclick="window.print()">🖨 Print</button>
     <button class="btn btn-accent" onclick="copyLink()">🔗 Copy link</button>
   </div>
@@ -399,7 +401,7 @@ $totalItems = count($allItems);
     <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
     <div class="item-list">
       <?php foreach ($group['items'] as $item): ?>
-      <div class="item-row" onclick="toggleItem(this)">
+      <div class="item-row" data-item-id="<?= (int)$item['id'] ?>" onclick="toggleItem(this)">
         <div class="item-check">
           <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
             <path d="M1 4l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -416,17 +418,72 @@ $totalItems = count($allItems);
 <script>
 const total = <?= $totalItems ?>;
 
+// Packing happens over hours, not in one sitting: a reload, a rotated phone or
+// an evicted tab must not lose the ticks. They live per list hash, in this
+// browser only – no account, no server round-trip.
+const STORE_KEY = 'packit:checked:<?= htmlspecialchars($hash, ENT_QUOTES) ?>';
+
+// Site data can be blocked (private windows, strict settings). Every access is
+// guarded so the list still works, just without remembering anything.
+function loadChecked() {
+  try {
+    const raw = localStorage.getItem(STORE_KEY);
+    return new Set(raw ? JSON.parse(raw) : []);
+  } catch (e) {
+    return new Set();
+  }
+}
+
+function saveChecked(set) {
+  try {
+    localStorage.setItem(STORE_KEY, JSON.stringify(Array.from(set)));
+  } catch (e) {
+    /* full, blocked, or private mode – ticks just won't survive the reload */
+  }
+}
+
 function toggleItem(el) {
-  el.classList.toggle('checked');
+  const nowChecked = el.classList.toggle('checked');
+  const set = loadChecked();
+  if (nowChecked) {
+    set.add(el.dataset.itemId);
+  } else {
+    set.delete(el.dataset.itemId);
+  }
+  saveChecked(set);
+  updateProgress();
+}
+
+function resetList() {
+  if (!confirm('Uncheck every item on this list?')) return;
+  document.querySelectorAll('.item-row.checked').forEach(row => row.classList.remove('checked'));
+  try { localStorage.removeItem(STORE_KEY); } catch (e) {}
   updateProgress();
 }
 
 function updateProgress() {
   const checked = document.querySelectorAll('.item-row.checked').length;
   document.getElementById('checkedCnt').textContent = checked;
-  document.getElementById('progressFill').style.width = (checked / total * 100) + '%';
+  document.getElementById('progressFill').style.width = (total ? checked / total * 100 : 0) + '%';
+  document.getElementById('resetBtn').hidden = checked === 0;
 }
 
+function restoreChecked() {
+  const stored = loadChecked();
+  const alive = new Set();
+  document.querySelectorAll('.item-row').forEach(row => {
+    if (stored.has(row.dataset.itemId)) {
+      row.classList.add('checked');
+      alive.add(row.dataset.itemId);
+    }
+  });
+  // Items dropped from the list in the meantime would otherwise linger forever.
+  if (alive.size !== stored.size) saveChecked(alive);
+  updateProgress();
+}
+
+restoreChecked();
+
 function copyLink() {
   const url = document.getElementById('shareUrl').textContent;
   navigator.clipboard.writeText(url).then(() => {
@@ -443,6 +500,11 @@ function copyLink() {
     document.body.removeChild(ta);
   });
 }
+
+// Offline support: a list you have opened stays readable without signal.
+if ('serviceWorker' in navigator) {
+  window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
+}
 </script>
 </body>
 </html>

+ 17 - 3
schema.sql

@@ -47,11 +47,14 @@ CREATE TABLE IF NOT EXISTS activity_item_map (
 );
 
 -- Stored selection hashes (for shareable URLs)
+-- hash is a 5-character SHA-256 prefix, grown one character at a time by
+-- list.php when a shorter prefix is already taken by a different selection.
 CREATE TABLE IF NOT EXISTS selection_hashes (
     hashID       INT AUTO_INCREMENT PRIMARY KEY,
-    hash         CHAR(64) NOT NULL UNIQUE,
-    activity_ids TEXT NOT NULL,
-    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+    hash         VARCHAR(16) NOT NULL UNIQUE,
+    activity_ids VARCHAR(500) NOT NULL,
+    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_activity_ids (activity_ids)
 );
 
 -- Admin users
@@ -61,6 +64,17 @@ CREATE TABLE IF NOT EXISTS admin_users (
     password_hash VARCHAR(255) NOT NULL
 );
 
+-- Failed admin logins, used by admin/login.php to throttle brute-force
+-- attempts. Rows older than a day are pruned opportunistically on each write.
+CREATE TABLE IF NOT EXISTS login_attempts (
+    attemptID    INT AUTO_INCREMENT PRIMARY KEY,
+    ip           VARCHAR(45) NOT NULL,
+    username     VARCHAR(50) NOT NULL,
+    attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_ip_time   (ip, attempted_at),
+    INDEX idx_user_time (username, attempted_at)
+);
+
 -- -----------------------------------------------
 -- Sample data
 -- -----------------------------------------------

+ 99 - 0
sw.js

@@ -0,0 +1,99 @@
+// PackIt service worker.
+//
+// A packing list is read where there is no signal, so anything already visited
+// has to keep working offline. Navigations are network-first (the list must
+// reflect the latest admin changes when we can reach the server) with the
+// cached copy as the fallback; static assets are cache-first.
+//
+// Bump CACHE_VERSION to retire every previously cached response.
+const CACHE_VERSION = 'packit-v1';
+const OFFLINE_URL   = '/offline.html';
+
+// Small and always needed. index.php is added opportunistically because it
+// depends on the database and must not be allowed to fail the install.
+const PRECACHE = [
+  OFFLINE_URL,
+  '/manifest.webmanifest',
+  '/img/icon-192.png',
+  '/img/icon-512.png',
+];
+
+self.addEventListener('install', (event) => {
+  event.waitUntil((async () => {
+    const cache = await caches.open(CACHE_VERSION);
+    await cache.addAll(PRECACHE);
+    await cache.add('/index.php').catch(() => {});
+    self.skipWaiting();
+  })());
+});
+
+self.addEventListener('activate', (event) => {
+  event.waitUntil((async () => {
+    const keys = await caches.keys();
+    await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)));
+    await self.clients.claim();
+  })());
+});
+
+async function networkFirst(request) {
+  const cache = await caches.open(CACHE_VERSION);
+  try {
+    const response = await fetch(request);
+    // A redirected response (list.php -> packing.php) cannot be replayed from
+    // the cache for a navigation, so only store final ones.
+    if (response.ok && !response.redirected) {
+      cache.put(request, response.clone());
+    }
+    return response;
+  } catch (err) {
+    const cached = await cache.match(request);
+    return cached || (await cache.match(OFFLINE_URL)) || Response.error();
+  }
+}
+
+async function cacheFirst(request) {
+  const cache = await caches.open(CACHE_VERSION);
+  const cached = await cache.match(request);
+  if (cached) return cached;
+  const response = await fetch(request);
+  if (response.ok || response.type === 'opaque') {
+    cache.put(request, response.clone());
+  }
+  return response;
+}
+
+async function staleWhileRevalidate(request) {
+  const cache = await caches.open(CACHE_VERSION);
+  const cached = await cache.match(request);
+  const network = fetch(request).then((response) => {
+    if (response.ok || response.type === 'opaque') cache.put(request, response.clone());
+    return response;
+  }).catch(() => cached);
+  return cached || network;
+}
+
+self.addEventListener('fetch', (event) => {
+  const request = event.request;
+  if (request.method !== 'GET') return;
+
+  const url = new URL(request.url);
+
+  // Fonts live on Google's CDN; keep them usable offline.
+  if (url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com') {
+    event.respondWith(staleWhileRevalidate(request));
+    return;
+  }
+
+  if (url.origin !== self.location.origin) return;
+
+  // The admin panel is session-protected and always changing – never cache it,
+  // and never serve a stale page to someone editing content.
+  if (url.pathname.startsWith('/admin/')) return;
+
+  if (request.mode === 'navigate') {
+    event.respondWith(networkFirst(request));
+    return;
+  }
+
+  event.respondWith(cacheFirst(request));
+});