6 Angajamente e083222c44 ... 7e2c7d34b6

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  sebastian 7e2c7d34b6 Persist checklists offline, protect admin writes, harden config and links 3 săptămâni în urmă
  sebastian 8361662f70 Group activities by category in the mappings selector 3 săptămâni în urmă
  sebastian 9f70eb8799 Keep admin sidebar fixed to the window height 3 săptămâni în urmă
  sebastian 3e3e354276 Warn about similar items when adding or renaming an item 3 săptămâni în urmă
  sebastian f97f2e4917 Fix tick boxes printing as solid black rectangles 3 săptămâni în urmă
  sebastian 3454183bf9 Replace item dropdown on mappings page with a filtering search field 3 săptămâni în urmă
21 a modificat fișierele cu 923 adăugiri și 88 ștergeri
  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. 176 2
      admin/items.php
  9. 1 1
      admin/layout.php
  10. 42 6
      admin/login.php
  11. 187 28
      admin/mappings.php
  12. 0 13
      config.php
  13. 19 0
      config.sample.php
  14. 56 6
      db.php
  15. 6 1
      index.php
  16. 58 13
      list.php
  17. 31 0
      migrations/001_hardening.sql
  18. 32 0
      offline.html
  19. 83 7
      packing.php
  20. 17 3
      schema.sql
  21. 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>

+ 176 - 2
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'] ?? '');
@@ -47,14 +48,36 @@ show_alerts();
 ?>
 <h1>Items</h1>
 
+<style>
+/* Advisory duplicate hint under an item-name field. Never blocks a save. */
+.dup-hint{margin:-.55rem 0 .9rem;border:1.5px solid var(--border);border-radius:var(--radius);
+          background:var(--bg);padding:.4rem 0;font-size:.85rem;}
+.dup-hint-head{padding:.15rem .8rem .3rem;font-size:.72rem;font-weight:600;letter-spacing:.06em;
+               text-transform:uppercase;color:var(--muted);}
+.dup-hint.exact{border-color:rgba(232,197,71,.45);}
+.dup-hint.exact .dup-hint-head{color:var(--accent);}
+.dup-row{display:flex;gap:.5rem;align-items:center;padding:.25rem .8rem;}
+.dup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.dup-id{color:var(--muted);font-size:.78rem;}
+/* In the table the hint floats, so a row does not jump in height while editing.
+   The cell's own overflow:hidden would clip it, hence the override. */
+.tbl-fixed tr[data-editing="true"] td{overflow:visible;}
+.dup-anchor{position:relative;}
+.dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
+                min-width:100%;width:max-content;max-width:min(420px,60vw);
+                background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
+</style>
+
 <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>
         <label>Item Name</label>
-        <input type="text" name="itemName" placeholder="e.g. Rain jacket" required>
+        <input type="text" name="itemName" id="new-item-name" autocomplete="off" placeholder="e.g. Rain jacket" required>
+        <div class="dup-hint" id="dup-hint" hidden></div>
       </div>
       <div>
         <label>Group</label>
@@ -78,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>
@@ -100,7 +124,14 @@ show_alerts();
     <td style="color:var(--muted)"><?= $id ?></td>
     <td>
       <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
-      <div class="edit"><input type="text" name="itemName" form="edit-form-<?= $id ?>" value="<?= htmlspecialchars($item['itemName']) ?>" required></div>
+      <div class="edit">
+        <div class="dup-anchor">
+          <input type="text" name="itemName" form="edit-form-<?= $id ?>" autocomplete="off"
+                 value="<?= htmlspecialchars($item['itemName']) ?>"
+                 data-dup-hint="dup-hint-<?= $id ?>" data-item-id="<?= $id ?>" required>
+          <div class="dup-hint dup-hint-float" id="dup-hint-<?= $id ?>" hidden></div>
+        </div>
+      </div>
     </td>
     <td>
       <div class="view"><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></div>
@@ -120,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>
@@ -147,4 +179,146 @@ function cancelEdit(id) {
 }
 </script>
 
+<script>
+// Duplicate hints: purely advisory. They never block a save - they only show
+// which existing items resemble the name being typed, both on the add form and
+// on every inline rename.
+(function () {
+  var ITEMS = <?= json_encode(array_map(function ($i) {
+      return ['id' => (int)$i['itemID'], 'name' => $i['itemName'], 'group' => $i['groupName']];
+  }, $items), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
+
+  var MAX_HITS = 8;
+
+  // Lower-cased, stripped of diacritics and punctuation, so "Rain-Jacket" and
+  // "rain jacket" collapse onto the same key.
+  function norm(s) {
+    return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+            .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
+  }
+  // Crude singular, enough to make "socks" and "sock" match.
+  function singular(t) {
+    return t.length > 3 && t.slice(-1) === 's' ? t.slice(0, -1) : t;
+  }
+  function tokens(s) {
+    return norm(s).split(' ').filter(Boolean).map(singular);
+  }
+  function key(s) {
+    return tokens(s).join(' ');
+  }
+
+  ITEMS.forEach(function (it) {
+    it.key    = key(it.name);
+    it.tokens = it.key.split(' ').filter(Boolean);
+  });
+
+  // Levenshtein distance, abandoned as soon as it exceeds `max`.
+  function lev(a, b, max) {
+    var prev = [], cur = [], i, j;
+    for (j = 0; j <= b.length; j++) prev[j] = j;
+    for (i = 1; i <= a.length; i++) {
+      cur = [i];
+      var best = i;
+      for (j = 1; j <= b.length; j++) {
+        cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
+                          prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
+        if (cur[j] < best) best = cur[j];
+      }
+      if (best > max) return max + 1;
+      prev = cur;
+    }
+    return prev[b.length];
+  }
+
+  // Higher is more similar; 0 means "not worth showing".
+  function score(qTokens, qKey, it) {
+    if (!qKey) return 0;
+    if (it.key === qKey) return 1000;                        // same item, different spelling
+    if (it.key.indexOf(qKey) === 0) return 500;              // typed name is a prefix
+    if (it.key.indexOf(qKey) !== -1) return 400;             // ...or appears inside
+    var hits = qTokens.filter(function (q) {
+      return it.tokens.some(function (t) {
+        // German compounds mean one word often sits inside another ("Socken"
+        // in "Skisocken"), so substrings count - but only between tokens of
+        // real length, else the "T" of "T-Shirt" matches half the table.
+        return t === q || (q.length >= 3 && t.length >= 3 &&
+                           (t.indexOf(q) !== -1 || q.indexOf(t) !== -1));
+      });
+    }).length;
+    // All words matched, just in another order ("jacket rain" -> "Rain jacket").
+    if (hits) return hits === qTokens.length ? 300 + hits : 100 * hits;
+    // Typos are the usual way a duplicate sneaks in ("sonnenbrile").
+    var slack = qKey.length >= 8 ? 2 : 1;
+    if (Math.abs(it.key.length - qKey.length) <= slack && lev(it.key, qKey, slack) <= slack) {
+      return 200;
+    }
+    return 0;
+  }
+
+  function esc(s) {
+    return s.replace(/[&<>"]/g, function (c) {
+      return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c];
+    });
+  }
+
+  // Keeps `hint` in sync with what is typed into `input`. A rename passes its
+  // own itemID as `selfId` so the row never flags itself, and stays quiet until
+  // the name actually differs from the one it started with.
+  function attach(input, hint, selfId) {
+    if (!input || !hint) return;
+    var baseline  = key(input.defaultValue || '');
+    var baseClass = hint.className;
+
+    function render() {
+      var qKey = key(input.value.trim());
+      if (qKey.length < 2 || (baseline && qKey === baseline)) { hint.hidden = true; return; }
+
+      var qTokens = qKey.split(' ');
+      var hits = ITEMS.filter(function (it) { return it.id !== selfId; })
+                      .map(function (it) { return {it: it, s: score(qTokens, qKey, it)}; })
+                      .filter(function (h) { return h.s > 0; })
+                      .sort(function (a, b) {
+                        return b.s - a.s || a.it.name.localeCompare(b.it.name);
+                      });
+
+      if (!hits.length) { hint.hidden = true; return; }
+
+      var exact = hits[0].s === 1000;
+      var shown = hits.slice(0, MAX_HITS);
+      var head  = exact
+        ? '⚠ “' + esc(hits[0].it.name) + '” already exists'
+        : shown.length + ' similar item' + (shown.length === 1 ? '' : 's') + ' already in the database';
+
+      var html = '<div class="dup-hint-head">' + head + '</div>';
+      shown.forEach(function (h) {
+        html += '<div class="dup-row">'
+              +   '<span class="dup-id">#' + h.it.id + '</span>'
+              +   '<span class="dup-name">' + esc(h.it.name) + '</span>'
+              +   '<span class="badge badge-group">' + esc(h.it.group) + '</span>'
+              + '</div>';
+      });
+      if (hits.length > shown.length) {
+        html += '<div class="dup-row dup-id">+ ' + (hits.length - shown.length) + ' more</div>';
+      }
+
+      hint.className = baseClass + (exact ? ' exact' : '');
+      hint.innerHTML = html;
+      hint.hidden = false;
+    }
+
+    input.addEventListener('input', render);
+    input.addEventListener('focus', render);
+    render();
+  }
+
+  attach(document.getElementById('new-item-name'), document.getElementById('dup-hint'), 0);
+
+  Array.prototype.forEach.call(
+    document.querySelectorAll('input[data-dup-hint]'),
+    function (input) {
+      attach(input, document.getElementById(input.dataset.dupHint), +input.dataset.itemId);
+    });
+})();
+</script>
+
 <?php admin_foot(); ?>

+ 1 - 1
admin/layout.php

@@ -26,7 +26,7 @@ function admin_head(string $title, string $active = ''): void {
 *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
 body{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px;display:flex;min-height:100vh;}
 /* sidebar */
-.sidebar{width:200px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:1.5rem 0;}
+.sidebar{width:200px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:1.5rem 0;position:sticky;top:0;align-self:flex-start;height:100vh;height:100dvh;overflow-y:auto;}
 .sidebar-logo{font-family:'Bebas Neue',sans-serif;font-size:1.8rem;color:var(--accent);letter-spacing:.05em;padding:0 1.25rem .5rem;}
 .sidebar-sub{font-size:.7rem;color:var(--muted);letter-spacing:.1em;text-transform:uppercase;padding:0 1.25rem 1.5rem;}
 .nav-link{display:block;padding:.55rem 1.25rem;color:var(--muted);text-decoration:none;font-size:.85rem;font-weight:500;border-left:3px solid transparent;transition:color .15s,border-color .15s,background .15s;}

+ 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'] ?? '') ?>"

+ 187 - 28
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);
@@ -80,20 +81,51 @@ show_alerts();
 ?>
 <h1>Activity ↔ Item Mappings</h1>
 
+<style>
+/* Searchable item picker (replaces the unwieldy 100+ entry select) */
+.item-picker{position:relative;}
+.item-list{position:absolute;z-index:20;top:calc(100% + 4px);left:0;right:0;max-height:280px;overflow-y:auto;
+           background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius);
+           box-shadow:0 8px 24px rgba(0,0,0,.45);padding:.25rem 0;}
+.item-group-label{padding:.4rem .8rem .2rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
+                  text-transform:uppercase;color:var(--accent2);}
+.item-option{padding:.35rem .8rem .35rem 1.3rem;font-size:.88rem;cursor:pointer;}
+.item-option:hover,.item-option.active{background:rgba(232,197,71,.12);color:var(--accent);}
+.item-empty{padding:.5rem .8rem;font-size:.85rem;color:var(--muted);}
+
+/* Activity selector: one heading per group, activities listed below it, so a long
+   activity name can wrap without colliding with a per-row category label. */
+.activity-picker{flex:0 0 260px;min-width:260px;}
+.activity-group{margin-bottom:1rem;}
+.activity-group:last-child{margin-bottom:0;}
+.activity-group-label{padding:0 .15rem .3rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
+                      text-transform:uppercase;color:var(--accent2);}
+.activity-link{display:block;padding:.4rem .6rem;margin-bottom:.2rem;border-radius:6px;
+               border:1px solid transparent;font-size:.85rem;line-height:1.35;text-decoration:none;
+               color:var(--muted);overflow-wrap:anywhere;
+               transition:color .15s,background .15s,border-color .15s;}
+.activity-link:hover{color:var(--text);background:rgba(255,255,255,.03);}
+.activity-link.active{background:rgba(232,197,71,.1);color:var(--accent);border-color:rgba(232,197,71,.3);}
+</style>
+
 <div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
 
   <!-- Activity selector -->
-  <div class="card" style="min-width:220px;flex:0 0 220px;">
+  <div class="card activity-picker">
     <h2>Select Activity</h2>
-    <?php foreach ($activities as $a): ?>
-    <a href="?aid=<?= $a['activityID'] ?>"
-       style="display:block;padding:.45rem .6rem;border-radius:6px;margin-bottom:.25rem;font-size:.85rem;text-decoration:none;
-              background:<?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.1)' : 'transparent' ?>;
-              color:<?= $selectedAid==$a['activityID'] ? 'var(--accent)' : 'var(--muted)' ?>;
-              border:1px solid <?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.3)' : 'transparent' ?>;">
-      <?= htmlspecialchars($a['activityName']) ?>
-      <span style="float:right;font-size:.72rem;color:var(--muted)"><?= htmlspecialchars($a['groupName']) ?></span>
-    </a>
+    <?php
+    // The query already orders by group, so grouping keeps the intended sort order.
+    $activityGroups = [];
+    foreach ($activities as $a) { $activityGroups[$a['groupName']][] = $a; }
+    foreach ($activityGroups as $grpName => $grpActivities):
+    ?>
+    <div class="activity-group">
+      <div class="activity-group-label"><?= htmlspecialchars($grpName) ?></div>
+      <?php foreach ($grpActivities as $a): ?>
+      <a href="?aid=<?= $a['activityID'] ?>"
+         class="activity-link<?= $selectedAid==$a['activityID'] ? ' active' : '' ?>"><?= htmlspecialchars($a['activityName']) ?></a>
+      <?php endforeach; ?>
+    </div>
     <?php endforeach; ?>
   </div>
 
@@ -109,28 +141,38 @@ 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;">
-          <label>Add Item</label>
-          <select name="itemID" required style="margin-bottom:0;">
-            <option value="">– choose item –</option>
-            <?php
-            // Group items in optgroups
-            $optGroups = [];
-            foreach ($allItems as $it) {
-                if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
-                $optGroups[$it['groupName']][] = $it;
-            }
-            foreach ($optGroups as $grpName => $grpItems):
-            ?>
-            <optgroup label="<?= htmlspecialchars($grpName) ?>">
-              <?php foreach ($grpItems as $it): ?>
-              <option value="<?= $it['itemID'] ?>"><?= htmlspecialchars($it['itemName']) ?></option>
+          <label for="item-search">Add Item</label>
+          <div class="item-picker" id="item-picker">
+            <input type="hidden" name="itemID" id="item-id">
+            <input type="text" id="item-search" autocomplete="off" role="combobox"
+                   aria-autocomplete="list" aria-expanded="false" aria-controls="item-list"
+                   placeholder="Type to search items…" style="margin-bottom:0;">
+            <div class="item-list" id="item-list" role="listbox" hidden>
+              <?php
+              // Group the still-unmapped items; the list is filtered client-side.
+              $optGroups = [];
+              foreach ($allItems as $it) {
+                  if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
+                  $optGroups[$it['groupName']][] = $it;
+              }
+              foreach ($optGroups as $grpName => $grpItems):
+              ?>
+              <div class="item-group">
+                <div class="item-group-label"><?= htmlspecialchars($grpName) ?></div>
+                <?php foreach ($grpItems as $it): ?>
+                <div class="item-option" role="option" tabindex="-1"
+                     data-id="<?= $it['itemID'] ?>"
+                     data-name="<?= htmlspecialchars($it['itemName']) ?>"><?= htmlspecialchars($it['itemName']) ?></div>
+                <?php endforeach; ?>
+              </div>
               <?php endforeach; ?>
-            </optgroup>
-            <?php endforeach; ?>
-          </select>
+              <div class="item-empty" hidden>No matching items.</div>
+            </div>
+          </div>
         </div>
         <div style="width:90px;">
           <label>Qty</label>
@@ -153,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'] ?>">
@@ -162,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'] ?>">
@@ -178,4 +222,119 @@ show_alerts();
   </div>
 
 </div>
+
+<script>
+(function () {
+  var picker = document.getElementById('item-picker');
+  if (!picker) return;
+  var search  = document.getElementById('item-search');
+  var hidden  = document.getElementById('item-id');
+  var list    = document.getElementById('item-list');
+  var empty   = list.querySelector('.item-empty');
+  var groups  = Array.prototype.slice.call(list.querySelectorAll('.item-group'));
+  var options = Array.prototype.slice.call(list.querySelectorAll('.item-option'));
+  var active  = null;
+
+  // Lower-cased and stripped of diacritics, so "muhe" also finds "Mühe".
+  function norm(s) {
+    return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
+  }
+  options.forEach(function (o) { o.dataset.search = norm(o.dataset.name); });
+
+  function setActive(el) {
+    if (active) active.classList.remove('active');
+    active = el;
+    if (active) {
+      active.classList.add('active');
+      active.scrollIntoView({block: 'nearest'});
+    }
+  }
+
+  function visibleOptions() {
+    return options.filter(function (o) { return !o.hidden; });
+  }
+
+  function filter(showAll) {
+    var q = showAll ? '' : norm(search.value.trim());
+    options.forEach(function (o) {
+      o.hidden = q !== '' && o.dataset.search.indexOf(q) === -1;
+    });
+    // Hide a group entirely when none of its items matched.
+    groups.forEach(function (g) {
+      var items = Array.prototype.slice.call(g.querySelectorAll('.item-option'));
+      g.hidden = !items.some(function (o) { return !o.hidden; });
+    });
+    empty.hidden = visibleOptions().length > 0;
+    setActive(visibleOptions()[0] || null);
+  }
+
+  function open() {
+    list.hidden = false;
+    search.setAttribute('aria-expanded', 'true');
+  }
+
+  function close() {
+    list.hidden = true;
+    search.setAttribute('aria-expanded', 'false');
+    setActive(null);
+  }
+
+  function choose(option) {
+    if (!option) return;
+    hidden.value = option.dataset.id;
+    search.value = option.dataset.name;
+    filter();
+    close();
+  }
+
+  search.addEventListener('input', function () {
+    hidden.value = ''; // typing invalidates a previous pick
+    filter();
+    open();
+  });
+  search.addEventListener('focus', function () {
+    // After a pick the field holds the full item name; offer the whole list again.
+    var picked = !!hidden.value;
+    if (picked) search.select();
+    filter(picked);
+    open();
+  });
+  search.addEventListener('keydown', function (e) {
+    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+      e.preventDefault();
+      if (list.hidden) { filter(!!hidden.value); open(); return; }
+      var vis = visibleOptions();
+      if (!vis.length) return;
+      var i = vis.indexOf(active);
+      i = e.key === 'ArrowDown' ? (i + 1) % vis.length : (i <= 0 ? vis.length : i) - 1;
+      setActive(vis[i]);
+    } else if (e.key === 'Enter') {
+      if (!list.hidden && active) { e.preventDefault(); choose(active); }
+    } else if (e.key === 'Escape') {
+      close();
+    }
+  });
+
+  list.addEventListener('mousedown', function (e) {
+    var option = e.target.closest('.item-option');
+    if (option) { e.preventDefault(); choose(option); }
+  });
+
+  document.addEventListener('click', function (e) {
+    if (!picker.contains(e.target)) close();
+  });
+
+  search.form.addEventListener('submit', function (e) {
+    if (!hidden.value) {
+      e.preventDefault();
+      search.focus();
+      filter();
+      open();
+    }
+  });
+
+  filter();
+})();
+</script>
+
 <?php admin_foot(); ?>

+ 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>

+ 83 - 7
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>');
 }
@@ -292,6 +293,7 @@ $totalItems = count($allItems);
     flex-shrink: 0;
     display: flex; align-items: center; justify-content: center;
     background: var(--bg);
+    color: #fff;
     transition: all .15s;
   }
   .item-row.checked .item-check {
@@ -335,9 +337,22 @@ $totalItems = count($allItems);
   @media print {
     header .btn-row, .share-box, .progress-bar-wrap { display: none !important; }
     body { background: #fff; color: #000; }
-    .item-row { border-color: #ccc; background: none; }
+    .item-row { border-color: #ccc; background: none; break-inside: avoid; }
     .section-title { color: #333; }
     .pill { background: #f5f5f5; color: #333; border-color: #ccc; }
+    /* The tick boxes carry the dark screen background. Browsers that print
+       background graphics (Safari, and any dialog with "Background graphics"
+       ticked – common when saving as PDF) then render them as solid black
+       rectangles, leaving nothing to cross by hand. Keep them blank on paper. */
+    .item-check,
+    .item-row.checked .item-check {
+      background: #fff;
+      border-color: #555;
+      color: #000;
+    }
+    /* Checked items also need to survive without background colours. */
+    .item-row.checked { background: none; border-color: #ccc; }
+    .item-row.checked .item-name { color: #000; }
   }
 </style>
 </head>
@@ -353,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>
@@ -385,10 +401,10 @@ $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="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+            <path d="M1 4l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
           </svg>
         </div>
         <span class="item-name"><?php if ($item['quantity'] > 1): ?><?= (int)$item['quantity'] ?>&times; <?php endif; ?><?= htmlspecialchars($item['name']) ?></span>
@@ -402,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(() => {
@@ -429,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));
+});