diff --git a/.github/workflows/workout-tracker-validation.yml b/.github/workflows/workout-tracker-validation.yml index f46c0550..149eb784 100644 --- a/.github/workflows/workout-tracker-validation.yml +++ b/.github/workflows/workout-tracker-validation.yml @@ -1,10 +1,10 @@ # File Name: workout-tracker-validation.yml # Author: Jason Lamb (with help from ChatGPT) # Description: Validate PHP, JavaScript and preservation of Workout Tracker records. -# Revision: 1.5.0 +# Revision: 1.6.1 # Created Date: 2026-10-22 # Modified Date: 2026-10-27 -# Changelog: 1.5.0 - Validate seven-day and since-first attendance, weekday streaks, mobile card rendering. 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. +# Changelog: 1.6.1 - Execute multi-photo browser handler validation. 1.6.0 - Test multi-photo persistence and global remember-me session lifetime. 1.5.0 - Validate seven-day and since-first attendance, weekday streaks, mobile card rendering. 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. name: Workout Tracker validation on: pull_request: @@ -31,6 +31,11 @@ jobs: php -v php -l workout-tracker/index.php php -l workout-tracker/photo.php + php -l workout-tracker/photo-upload.php + php -l workout-tracker/tests/photo-upload.test.php + php -l 1-Framework/modules/Authentication/PasswordSession.php + php -l user-management/src/Auth.php + php -l user-management/index.php php -l workout-tracker/backup.php php -l workout-tracker/tests/photo-backup.test.php php -l workout-tracker/tests/heart-rate.test.php @@ -46,6 +51,7 @@ jobs: php workout-tracker/tests/edit.test.php php workout-tracker/tests/measurements.test.php php workout-tracker/tests/photo-backup.test.php + php workout-tracker/tests/photo-upload.test.php php workout-tracker/tests/heart-rate.test.php - name: Verify JavaScript syntax and Edit button behavior run: | @@ -57,8 +63,12 @@ jobs: node workout-tracker/tests/attendance-ui.test.cjs node --check workout-tracker/tests/workout-detail.test.cjs node workout-tracker/tests/workout-detail.test.cjs + node --check workout-tracker/tests/photo-ui.test.cjs + node workout-tracker/tests/photo-ui.test.cjs node --check workout-tracker/advanced.js node --check workout-tracker/tests/advanced.test.cjs node workout-tracker/tests/advanced.test.cjs node --check workout-tracker/tests/edit-button.test.cjs node workout-tracker/tests/edit-button.test.cjs + - name: Verify long-lived login is opt-in and revocable + run: node --test user-management/tests/remember.test.cjs diff --git a/1-Framework/modules/Authentication/PasswordSession.php b/1-Framework/modules/Authentication/PasswordSession.php index db718d14..c9e179e1 100644 --- a/1-Framework/modules/Authentication/PasswordSession.php +++ b/1-Framework/modules/Authentication/PasswordSession.php @@ -1,25 +1,78 @@ 30 * 86400) { + throw new \RuntimeException('Invalid identity session timeout.'); + } ini_set('session.use_strict_mode', '1'); + // PHP's default 24-minute garbage collection would otherwise invalidate + // a remembered browser session before its advertised expiry. + $gc = max((int)ini_get('session.gc_maxlifetime'),$timeout,$rememberSeconds); + ini_set('session.gc_maxlifetime',(string)$gc); session_name($name); - session_set_cookie_params(['httponly' => true, 'secure' => $secure, 'samesite' => 'Strict', 'path' => $path]); + session_set_cookie_params([ + 'httponly' => true,'secure' => $secure,'samesite' => 'Strict', + 'path' => $path,'lifetime' => 0 + ]); session_start(); - if (isset($_SESSION['last']) && time() - $_SESSION['last'] > $timeout) $_SESSION = []; - $_SESSION['last'] = time(); + $now = time(); + $until = isset($_SESSION['remember_until']) && is_int($_SESSION['remember_until']) + ? $_SESSION['remember_until'] : 0; + $idle = $until > 0 ? $rememberSeconds : $timeout; + if (($until > 0 && $until <= $now) + || (isset($_SESSION['last']) && $now - (int)$_SESSION['last'] > $idle)) { + $_SESSION = []; + session_regenerate_id(true); // Never reuse an expired identity's session ID. + // Replace any persistent cookie with a new session-only anonymous ID. + $this->writeCookie(false); + } elseif ($until > $now) { + // session_start() can resend a session-only cookie on some hosts. + // Repeat the SAME fixed expiry, never sliding it forward. + $this->writeCookie(true); + } + $_SESSION['last'] = $now; $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); } + + private function writeCookie(bool $remember): void + { + setcookie($this->name, session_id(), [ + 'expires'=>$remember ? (int)($_SESSION['remember_until'] ?? 0) : 0, + 'path'=>$this->path,'secure'=>$this->secure, + 'httponly'=>true,'samesite'=>'Strict' + ]); + } + + /** Only called by the central identity provider after successful password verification. */ + public function enableRemember(): void + { + $_SESSION['remember_until'] = time() + $this->rememberSeconds; + $this->writeCookie(true); + } + public function csrf(): string { return $_SESSION['csrf']; } - public function validCsrf(mixed $token): bool { return is_string($token) && hash_equals($this->csrf(), $token); } + public function validCsrf(mixed $token): bool { + return is_string($token) && hash_equals($this->csrf(), $token); + } public function loggedIn(): bool { return isset($_SESSION['user']); } public function login(string $user, string $password, string $expectedUser, string $hash, callable $allow): bool { @@ -36,5 +89,8 @@ public function logout(): void $_SESSION = []; session_regenerate_id(true); $_SESSION['csrf'] = bin2hex(random_bytes(32)); + // Removing the persistent expiry on the new session cookie signs out + // this browser across all connected projects without stale remember flags. + $this->writeCookie(false); } } diff --git a/user-management/README.md b/user-management/README.md index 7473cc88..39028cdd 100644 --- a/user-management/README.md +++ b/user-management/README.md @@ -2,12 +2,22 @@ Shared PHP/JSON accounts for projects in this repository. PHP 8.1+, no database or Composer dependencies. Uses the framework's `JsonStore` and `PasswordSession`; the framework exposes the configured provider through `SharedIdentity::connect()`. +## Optional remembered sign-in (2026-10-27) + +The central login now includes **Stay signed in for 7 days on this browser**. This is deliberately unchecked by default. Standard sign-in still expires after **30 minutes idle** and uses a session cookie. Choosing the checkbox issues a persistent browser cookie with an absolute seven-day maximum; on every request, the server checks the remembered session's expiry and identity version. Session rotation, CSRF verification, strict/HttpOnly/secure cookies, password resets, disabled-account invalidation and central logout remain enforced across all registered projects. + +The duration is configurable with `remember_seconds` in `config.local.php` (default 604800; limited to 30 days), while `timeout` remains 1800 seconds by default. Both are server settings; do not write secrets to this repository. **Existing sessions do not automatically become remembered.** Sign out and sign back in with the checkbox selected. The login checkbox is not recommended for shared devices. Hosts that purge PHP session data earlier than seven days require compatible server/session storage configuration. Test signed-in persistence and sign-out on the actual Hostinger installation. + +Validation: + + node --test user-management/tests/remember.test.cjs + ## Features - One same-host login/session for integrated projects. - Central account creation, disable/enable, temporary password reset and password change. - User, Admin and Super Admin account roles are separate from project scope. Any role can access multiple selected projects; all-project access is an explicit flag. Central directory administration is a separate permission. -- Server-side permission checks, CSRF tokens, secure cookies, session rotation, 30-minute idle expiry and persisted account/IP login limits. +- Server-side permission checks, CSRF tokens, secure cookies, session rotation, standard 30-minute idle expiry, **opt-in bounded seven-day remembered sessions**, and persisted account/IP login limits. - Password/account/access changes revoke the affected user's sessions; required password changes block project access. - Last active administrator protection. Users are disabled instead of deleted so project ownership remains stable. - Runtime files stay in ignored storage; normal Git updates do not overwrite accounts. diff --git a/user-management/bootstrap.php b/user-management/bootstrap.php index 8455dded..1200fc88 100644 --- a/user-management/bootstrap.php +++ b/user-management/bootstrap.php @@ -14,6 +14,12 @@ function jasr_users_config(): array $local = is_file($file) ? require $file : []; if (!is_array($local)) throw new RuntimeException('Invalid user management configuration.'); $config = array_replace($defaults, $local); + if (!is_int($config['timeout']) || $config['timeout'] < 60 + || !is_int($config['remember_seconds']) + || $config['remember_seconds'] < $config['timeout'] + || $config['remember_seconds'] > 30 * 86400) { + throw new RuntimeException('Invalid identity session lifetime configuration.'); + } if (!is_bool($config['debug']) || !is_array($config['rate_limit_exempt_ips'])) { throw new RuntimeException('Invalid development configuration.'); } diff --git a/user-management/config.local.example.php b/user-management/config.local.example.php index 079afe9b..15b49084 100644 --- a/user-management/config.local.example.php +++ b/user-management/config.local.example.php @@ -8,7 +8,8 @@ 'cookie_name' => 'JASR_IDENTITY', 'cookie_path' => '/', 'secure_cookie' => true, // HTTPS required. Set false ONLY for local HTTP tests. - 'timeout' => 1800, + 'timeout' => 1800, // Unchanged default: sign out after 30 minutes idle. + 'remember_seconds' => 7 * 86400, // Only for an explicitly checked login: max 7 days. // Development only. Keep false and empty on production hosting. 'debug' => false, 'rate_limit_exempt_ips' => [], diff --git a/user-management/index.php b/user-management/index.php index 0d3f5f2e..84e3c8d8 100644 --- a/user-management/index.php +++ b/user-management/index.php @@ -26,7 +26,7 @@ function field(array $input, string $key): string { return is_string($input[$key if (field($_POST, 'password') !== field($_POST, 'confirm_password')) throw new InvalidArgumentException('Passwords do not match.'); $directory->setup(field($_POST, 'username'), field($_POST, 'name'), field($_POST, 'password')); } elseif ($action === 'login') { - if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'))) { + if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'), field($_POST, 'remember') === 'yes')) { http_response_code(401); throw new InvalidArgumentException('Sign-in failed. Check your credentials or wait 15 minutes if attempts were limited.'); } } elseif ($action === 'logout') { @@ -95,7 +95,7 @@ function roleFields(array $account): void

Browser setup is disabled. Set a private setup key in config.local.php or run the CLI setup described in the README.

-

Sign in

Need a password reset? Ask your site administrator.

+

Sign in

Optional. Leave unchecked for the usual 30-minute inactivity timeout. Don't enable on a shared device.

Need a password reset? Ask your site administrator.

Signed in as

Change your temporary password before opening a project or managing users.

diff --git a/user-management/src/Auth.php b/user-management/src/Auth.php index c8081c06..18c4492e 100644 --- a/user-management/src/Auth.php +++ b/user-management/src/Auth.php @@ -6,6 +6,11 @@ use Jasr\Framework\PasswordSession; use Jasr\Framework\Response; +/* File Name: Auth.php +Revision: 1.1.0 +Modified Date: 2026-10-27 +Changelog: 1.1.0 - Offer opt-in seven-day shared login without changing default expiry. +*/ final class Auth { public readonly Directory $directory; @@ -16,7 +21,7 @@ public function __construct(private array $config) throw new \RuntimeException('HTTPS is required.'); } $this->directory = new Directory($config['data_path']); - $this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout']); + $this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout'], $config['remember_seconds']); header('Cache-Control: no-store'); } public function csrf(): string { return $this->session->csrf(); } @@ -29,7 +34,7 @@ public function user(): ?array if (!$user || !$user['active'] || $user['version'] !== ($_SESSION['identity_version'] ?? null)) return null; return Directory::publicUser($user); } - public function login(string $username, string $password): bool + public function login(string $username, string $password, bool $remember = false): bool { $username = strtolower(trim($username)); $remoteIp = (string) ($_SERVER['REMOTE_ADDR'] ?? 'cli'); @@ -59,6 +64,7 @@ public function login(string $username, string $password): bool $this->session->logout(); // Rotate ID/token before establishing the shared identity. $_SESSION['identity_id'] = $found['id']; $_SESSION['identity_version'] = $found['version']; + if ($remember) $this->session->enableRemember(); // Do not clear the peer-IP counter: it also limits password spraying. JsonStore::update($this->config['data_path'] . '/attempts.json', function (array $r) use ($keys): array { unset($r[$keys[0]]); diff --git a/user-management/tests/remember.test.cjs b/user-management/tests/remember.test.cjs new file mode 100644 index 00000000..45fbea4d --- /dev/null +++ b/user-management/tests/remember.test.cjs @@ -0,0 +1,123 @@ +/* +File Name: remember.test.cjs +Author: Jason Lamb (with help from ChatGPT) +Description: Isolated HTTP verification for opt-in seven-day central login, expiry and sign-out. +Revision: 1.0.1 +Created Date: 2026-10-27 +Modified Date: 2026-10-27 +Changelog: 1.0.1 - Check that remembered cookies retain fixed original expiry after a page load. 1.0.0 - Check session-only default, secure cookie flags, remembered expiry and revocation. +*/ +'use strict'; +const {test,after}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const os=require('node:os'); +const path=require('node:path'); +const {spawn}=require('node:child_process'); +const root=path.resolve(__dirname,'../..'); +const temp=fs.mkdtempSync(path.join(os.tmpdir(),'jasr-remember-')); +for(const name of ['1-Framework','user-management']) + fs.cpSync(path.join(root,name),path.join(temp,name),{recursive:true}); +const data=path.join(temp,'user-management/data'); +for(const name of fs.readdirSync(data))if(name!=='.htaccess') + fs.rmSync(path.join(data,name),{recursive:true,force:true}); +const key='isolated-remember-test-key-at-least-32-characters'; +fs.writeFileSync(path.join(temp,'user-management/config.local.php'), + "false,'setup_key'=>'"+key+"'];"); +fs.writeFileSync(path.join(temp,'expire.php'), + "user()) {http_response_code(401);exit;}\n"+ + "$_SESSION['remember_until']=time()-1;\n"+ + "echo 'test-only forced expiration';\n"); +const port=18963,host='http://127.0.0.1:'+port; +const server=spawn('php',['-S','127.0.0.1:'+port,'-t',temp],{stdio:['ignore','pipe','pipe']}); +let logs=''; +server.stderr.on('data',chunk=>logs+=String(chunk)); +after(()=>{server.kill();fs.rmSync(temp,{recursive:true,force:true});if(/Fatal error|Parse error/.test(logs))console.error(logs);}); + +class Browser{ + cookie='';csrf=''; + async request(url='/user-management/',post=null){ + const headers={Cookie:this.cookie}; + if(post)headers['Content-Type']='application/x-www-form-urlencoded'; + const options={headers,redirect:'manual'}; + if(post){options.method='POST';options.body=new URLSearchParams({csrf:this.csrf,...post});} + const res=await fetch(host+url,options); + const cookies=res.headers.getSetCookie?.()||[]; + if(cookies.length)this.cookie=cookies[cookies.length-1].split(';')[0]; + const text=await res.text(); + const match=text.match(/name="csrf" value="([a-f0-9]+)"/); + if(match)this.csrf=match[1]; + return {status:res.status,text,cookies}; + } + async post(action,details={}){ + const res=await this.request('/user-management/',{action,...details}); + if(res.status===303)await this.request(); + return res; + } +} +const password='example-password-for-tests-2026'; +test('default idle session, opt-in remembered login, and global logout',async()=>{ + let ready=false; + for(let i=0;i<100;i++){ + try{await fetch(host+'/user-management/');ready=true;break;} + catch{await new Promise(resolve=>setTimeout(resolve,80));} + } + assert.ok(ready,'isolated PHP HTTP server must start; logs: '+logs); + const browser=new Browser(); + let res=await browser.request(); + assert.match(res.text,/Create the first administrator/); + assert.equal((await browser.post('setup',{ + setup_key:key,username:'member',name:'Test Member', + password,confirm_password:password + })).status,303); + res=await browser.request(); + assert.match(res.text,/Stay signed in for 7 days/); + assert.match(res.text,/30-minute inactivity/); + + // Without the checkbox, browser closes end the session; no persistent Expires. + const normal=await browser.post('login',{username:'member',password}); + assert.equal(normal.status,303); + assert.ok(normal.cookies.length>0,'PHP rotates the cookie after login'); + assert.ok(normal.cookies.every(x=>!/;\s*expires=/i.test(x)), + 'default login sets session-only cookies'); + assert.match((await browser.request()).text,/Signed in as/); + + await browser.post('logout'); + assert.match((await browser.request()).text,/

Sign in<\/h2>/); + const remembered=await browser.post('login',{ + username:'member',password,remember:'yes' + }); + assert.equal(remembered.status,303); + const persistent=remembered.cookies.find(x=>/;\s*expires=/i.test(x)); + assert.ok(persistent,'explicit remember choice sets a persistent cookie'); + assert.match(persistent,/httponly/i); + assert.match(persistent,/samesite=strict/i); + assert.match(persistent,/path=\//i); + const match=persistent.match(/expires=([^;]+)/i); + assert.ok(match,'expiry field exists'); + const remaining=(Date.parse(match[1])-Date.now())/86400000; + assert.ok(remaining>6 && remaining<8,'remember expiry is bounded at seven days'); + const visited=await browser.request(); + assert.match(visited.text,/Signed in as/); + const refreshed=visited.cookies.find(x=>/;\s*expires=/i.test(x)); + if(refreshed){ + const expiry=refreshed.match(/expires=([^;]+)/i); + assert.ok(expiry,'subsequent authenticated request must keep a persistent cookie'); + assert.ok(Math.abs(Date.parse(expiry[1])-Date.parse(match[1]))<2000, + 'each visit retains the original fixed seven-day expiry rather than extending it'); + } + + // Expired remembered sessions cannot bypass server expiration even with cookie. + assert.equal((await browser.request('/expire.php')).status,200); + assert.match((await browser.request()).text,/

Sign in<\/h2>/, + 'expired remember token does not authenticate'); + assert.equal((await browser.post('login',{username:'member',password,remember:'yes'})).status,303); + assert.match((await browser.request()).text,/Signed in as/); + const loggedOut=await browser.post('logout'); + assert.equal(loggedOut.status,303); + assert.match((await browser.request()).text,/

Sign in<\/h2>/, + 'logging out revokes the remembered session'); + console.log('Passed: short default, opt-in seven-day cookie, absolute expiry and shared sign-out.'); +}); diff --git a/workout-tracker/CHANGELOG.md b/workout-tracker/CHANGELOG.md index 6b0b9add..21cae4c6 100644 --- a/workout-tracker/CHANGELOG.md +++ b/workout-tracker/CHANGELOG.md @@ -2,13 +2,22 @@ File Name: CHANGELOG.md Author: Jason Lamb (with help from ChatGPT) Description: Cumulative release history for private Workout Tracker. -Revision: 1.5.0 +Revision: 1.6.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.5.0 - Record v1.6.0 attendance dates, weekend-excluded percentages, and streaks. 1.4.1 - Document optional heart-rate BPM field and local parser in v1.5.1. 1.4.0 - Document v1.5.0 authenticated photos, workout details and ZIP backups. 1.3.0 - Document v1.4.0 calories, custom numeric metrics and per-set strength logging. 1.2.2 - Document workout-specific fields and iPhone-safe date/time layout in v1.3.2. 1.2.1 - Document v1.3.1 Edit button DOMException repair and regression. 1.2.0 - Document v1.3.0 non-destructive edits, tags, incline and regression coverage. 1.1.0 - Document v1.2.0 AI fallback, prepared screenshots and secrets convention. 1.0.0 - Initial release history including v1.0.0 and v1.1.0. +Changelog: 1.6.0 - Record v1.7.0 photo attachments, account links, and remembered login. 1.5.0 - Record v1.6.0 attendance dates, weekend-excluded percentages, and streaks. 1.4.1 - Document optional heart-rate BPM field and local parser in v1.5.1. 1.4.0 - Document v1.5.0 authenticated photos, workout details and ZIP backups. 1.3.0 - Document v1.4.0 calories, custom numeric metrics and per-set strength logging. 1.2.2 - Document workout-specific fields and iPhone-safe date/time layout in v1.3.2. 1.2.1 - Document v1.3.1 Edit button DOMException repair and regression. 1.2.0 - Document v1.3.0 non-destructive edits, tags, incline and regression coverage. 1.1.0 - Document v1.2.0 AI fallback, prepared screenshots and secrets convention. 1.0.0 - Initial release history including v1.0.0 and v1.1.0. --> # Workout Tracker changelog +## v1.7.0 — 2026-10-27 (Issue #135) + +- Allow adding **multiple private pictures to an already-saved workout** from the Edit screen using a mobile file picker (up to 5 files per request, 12 total, max 5 MB each); no older image is replaced. +- Retain legacy single `photo` and add optional append-only `photos` filename list with entry ID ownership, unique names, strict MIME/image validation, and stale-revision rejection. +- Display all saved pictures inside the authenticated Details gallery. Photo links accept only entry ID and zero-based index; private ZIP export includes both legacy and new images. +- Provide a responsive User Management link and CSRF-protected **Sign out** action through the existing shared portal. +- Add explicit **Stay signed in for 7 days** checkbox at central login, retaining normal 30-minute idle session by default. Bounded persistent cookie uses HttpOnly, SameSite Strict, secure HTTPS and server-side session expiry; logout still revokes access. +- Add synthetic regression tests for multiple media references and backups and an isolated HTTP login/expiry/logout test. No migration or direct live storage changes. + ## v1.6.0 — 2026-10-27 - Add four attendance metrics: 7-day calendar / weekday percentages and overall calendar / weekday percentages, each starting no earlier than the first workout date. diff --git a/workout-tracker/README.md b/workout-tracker/README.md index 18c13203..4fefce9a 100644 --- a/workout-tracker/README.md +++ b/workout-tracker/README.md @@ -2,10 +2,10 @@ File Name: README.md Author: Jason Lamb (with help from ChatGPT) Description: Setup, key-free quick entry, optional AI and screenshot import guidance for Workout Tracker. -Revision: 1.6.0 +Revision: 1.7.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.6.0 - Read-only workout-day attendance and weekday streak dashboard. 1.5.1 - Optional heart rate in BPM, including local parsing, charts and safe edits. 1.5.0 - Authenticated photo/detail view and per-user ZIP backup, preserving live data. 1.4.0 - Flexible exercise measurements, calories and variable weighted sets; safe legacy records. 1.3.2 - Responsive date/time layout and exercise-specific form measurements without losing hidden legacy fields. 1.3.1 - Fix Edit button form opening and add browser-compatible regression test. 1.3.0 - Safely edit saved workouts, add tracking tags and treadmill incline without migrating user data. 1.2.0 - Optional AI retry, exact-name config template and prepared screenshot import. 1.1.0 - Added local language parsing, batch entry, mobile-first review and tests. 1.0.0 - Initial release. +Changelog: 1.7.0 - Multiple photo uploads on saved workouts, central logout/navigation and opt-in seven-day login. 1.6.0 - Read-only workout-day attendance and weekday streak dashboard. 1.5.1 - Optional heart rate in BPM, including local parsing, charts and safe edits. 1.5.0 - Authenticated photo/detail view and per-user ZIP backup, preserving live data. 1.4.0 - Flexible exercise measurements, calories and variable weighted sets; safe legacy records. 1.3.2 - Responsive date/time layout and exercise-specific form measurements without losing hidden legacy fields. 1.3.1 - Fix Edit button form opening and add browser-compatible regression test. 1.3.0 - Safely edit saved workouts, add tracking tags and treadmill incline without migrating user data. 1.2.0 - Optional AI retry, exact-name config template and prepared screenshot import. 1.1.0 - Added local language parsing, batch entry, mobile-first review and tests. 1.0.0 - Initial release. --> # Workout Tracker @@ -21,6 +21,20 @@ Private PHP workout logger at https://jasr.me/github/workout-tracker/ using shar The parser is rule-based rather than a general LLM. It recognizes common exercises and explicit numbers but does **not guess missing values**. Unrecognized exercises use the collapsed manual entry form. It does not analyze photos. +## Multiple photos, account navigation and remembered login (v1.7.0; issue #135) + +**Photo uploads after saving:** Tap **Edit** on a previous workout, then scroll to **Add pictures to this workout**. Choose up to five JPEG, PNG or WebP images at once (max 5 MB each, 12 total per workout) using the phone's camera or photo library; tap **Add selected pictures**. Existing measurements and photos are not overwritten. The editor reports existing photo count and retains all earlier uploads. The upload is a separate CSRF-protected action, using the same authenticated user and revision-lock checks as other edits. + +**View multiple photos:** Tap **Details** from Workout history. The private gallery displays every photo attached to that record in order, including an original legacy `photo`. Each full-size link uses only an entry ID and numeric image index (`?photo=&image=0`); clients cannot supply filenames or paths. All images remain behind central authentication and owner checks. The protected data files are still ignored by Git and denied by the web-server rule. + +**How records remain safe:** Existing single-photo references `photo` remain unchanged. New filenames are appended to an optional `photos` array. The current `schemaVersion:1`, IDs, sessions, workout notes, dates and old entries are preserved. Uploads are checked (size, MIME, actual image data, path traversal and symlink rules), named uniquely, and attached atomically under `JsonStore::update`. If the entry has changed or a validation error occurs, newly staged uploads are removed and the old record is not modified. Photos are never automatically deleted. The account ZIP export includes every legacy and new image and fails if one is missing. + +**User Management and sign-out:** The top of Workout Tracker provides a **User Management** link and a **Sign out** button. The sign-out form submits with CSRF to the existing central account portal, signing out **all connected JASR projects in that browser**. This is not a local-only logoff. + +**Stay signed in:** The User Management login offers an optional **Stay signed in for 7 days on this browser** checkbox. **Unchecked remains the normal 30-minute inactivity timeout**, with a session-only cookie. Checked uses a seven-day, secure/HttpOnly/SameSite=Strict persistent session cookie with a fixed maximum expiry and server-side session lifetime. It is not enabled automatically for current sessions. After deploying, sign out and sign back in with the checkbox selected to activate it. Do not select it on shared devices. Logout, account disabling, password changes and permissions revocation still invalidate sessions. If hosting aggressively cleans PHP session files, server configuration may need adjustment; the new PHP session code raises the configured garbage-collection lifetime for remembered sessions. + +**Verification:** GitHub CI exercises synthetic multi-photo uploads, cross-account image denial, missing-file ZIP safety and the existing Edit button. It also runs an isolated User Management HTTP test for normal versus remembered cookies, expiry and logout. On Hostinger, verify the portal's remembered sign-in behavior, cross-account photo denial, private storage HTTP 403, ZIP completeness and multi-file iPhone upload. No hosted accounts/records are used in CI. The hosting webhook must never delete `workout-tracker/storage/` or `user-management/data/`. + ## Attendance and workout streaks (v1.6.0) The mobile dashboard uses only the **existing workout dates** in the authenticated user's saved records. A day counts at most once, even if multiple exercises were recorded that day. **No migration, backfill, or change to existing JSON** is needed. diff --git a/workout-tracker/app.css b/workout-tracker/app.css index 04f534fb..4566e4aa 100644 --- a/workout-tracker/app.css +++ b/workout-tracker/app.css @@ -2,10 +2,10 @@ File Name: app.css Author: Jason Lamb (with help from ChatGPT) Description: Calm, responsive workout dashboard with compact progressive-entry controls. -Revision: 1.6.0 +Revision: 1.7.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.6.0 - Mobile-first workout attendance cards, timeline and weekday/calendar streak grid. 1.5.0 - Add mobile details panel, secure photo preview and backup download controls. 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. +Changelog: 1.7.0 - Style signed-in navigation, multiselect photo attachment and private galleries for mobile. 1.6.0 - Mobile-first workout attendance cards, timeline and weekday/calendar streak grid. 1.5.0 - Add mobile details panel, secure photo preview and backup download controls. 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. */ :root{color-scheme:dark;font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#08141f;color:#e7f5f8} *{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 80% 0%,#173b44 0%,#0b1a29 50%,#08141f 100%);min-height:100vh} @@ -216,3 +216,35 @@ details>summary{min-height:44px;display:list-item;align-content:center;} .attendance-grid{grid-template-columns:minmax(0,1fr);} .attendance-streaks{grid-template-columns:minmax(0,1fr);} } + + +/* Connected account links and sign-out remain within thumb reach on phones. */ +.account-nav{display:flex;flex-direction:column;align-items:flex-end;gap:7px;flex-shrink:0;} +.account-link,.account-signout{display:flex;align-items:center;justify-content:center;min-height:44px;padding:8px 12px;border:1px solid #407481;border-radius:10px;background:#153542;color:#c6ecea;text-decoration:none;font-size:.82rem;white-space:nowrap;} +.account-signout{background:#172c3b;cursor:pointer;font-weight:650;} +.account-link:focus-visible,.account-signout:focus-visible{outline:2px solid #87efd8;outline-offset:2px;} +.logout-form{margin:0;} +.add-photos-form{border-top:1px solid #375969;padding-top:17px;margin-top:19px;} +.add-photos-form h3{margin-bottom:8px;} +.add-photos-form .muted{font-size:.87rem;} +.add-photos-form input[type="file"]{font-size:16px;min-height:50px;max-width:100%;padding:9px 8px;} +.add-photos-form button{min-height:48px;} +#existingPhotos{font-size:.86rem;margin:8px 0;} +#photoStatus{min-height:22px;overflow-wrap:anywhere;} +.detail-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px;} +.detail-photo{margin:0;background:#0c2130;border:1px solid #315467;border-radius:14px;padding:8px;min-width:0;} +.detail-photo img{display:block;width:100%;height:auto;max-height:320px;aspect-ratio:4/3;object-fit:contain;background:#071722;border-radius:10px;} +.detail-photo figcaption{min-width:0;} +.detail-photo figcaption a{display:flex;align-items:center;font-size:.83rem;min-height:44px;overflow-wrap:anywhere;} +@media(max-width:560px){ + .hero{flex-wrap:wrap;} + .hero>div:first-child{flex:1 1 100%;} + .account-nav{width:100%;flex-direction:row;justify-content:flex-start;align-items:center;flex-wrap:wrap;} + .account-link,.account-signout{font-size:.86rem;min-height:44px;} + .detail-gallery{gap:8px;} +} +@media(max-width:350px){ + .detail-gallery{grid-template-columns:minmax(0,1fr);} + .account-nav{align-items:stretch;} + .account-link,.account-signout{font-size:.84rem;} +} diff --git a/workout-tracker/app.js b/workout-tracker/app.js index 01f98e5a..4db2c3aa 100644 --- a/workout-tracker/app.js +++ b/workout-tracker/app.js @@ -1,10 +1,10 @@ /* File Name: app.js Author: Jason Lamb (with help from ChatGPT) Description: Workout quick entry, editable review, and per-exercise trends. -Revision: 1.6.0 +Revision: 1.7.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.6.0 - Read-only workout attendance, weekday percentages and calendar/weekday streaks. 1.5.1 - Display and edit heart rate BPM with cardio-focused fields and separate trends. 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ +Changelog: 1.7.0 - Multi-photo galleries and upload-after-save controls while keeping earlier uploads. 1.6.0 - Read-only workout attendance, weekday percentages and calendar/weekday streaks. 1.5.1 - Display and edit heart rate BPM with cardio-focused fields and separate trends. 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ 'use strict'; const $=selector=>document.querySelector(selector); const measures=[['distance_miles','Miles'],['duration_minutes','Minutes'],['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'],['calories_kcal','Calories (kcal)'],['heart_rate_bpm','Heart rate (BPM)'],['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)']]; @@ -222,6 +222,20 @@ function historyItems(){ .sort((a,b)=>(b.performed_at||b.date).localeCompare(a.performed_at||a.date)); } + +/** Existing single-photo records and new arrays coexist without migration. */ +function workoutPhotoCount(entry) { + return (typeof entry.photo==='string' && entry.photo!==''?1:0)+ + (Array.isArray(entry.photos)?entry.photos.filter(name=>typeof name==='string'&&name!=='').length:0); +} +function renderExistingPhotoCount(entry){ + const count=workoutPhotoCount(entry); + $('#existingPhotos').textContent=count+ + ' picture'+(count===1?'':'s')+' already saved. You can add '+Math.max(0,12-count)+' more.'; + const button=$('#addPhotosButton');button.disabled=count>=12; + $('#savedPhotos').disabled=count>=12; +} + /** Mobile-readable workout detail view. All user-provided content uses textContent. */ function openWorkoutDetails(id){ const entry=entries.find(row=>row.id===id); @@ -303,20 +317,31 @@ function openWorkoutDetails(id){ region.append(history); } const media=$('#detailImage');media.replaceChildren();media.hidden=true; - if(typeof entry.photo==='string' && entry.photo!==''){ - const image=document.createElement('img'); - const src='?photo='+encodeURIComponent(id); - image.src=src;image.alt='Private photo attached to '+entry.exercise+' workout'; - image.loading='lazy'; - const fallback=document.createElement('p');fallback.className='footnote';fallback.textContent= - 'Photo unavailable. Check that private storage is preserved on Hostinger.'; - fallback.hidden=true; - image.addEventListener('error',()=>{image.hidden=true;fallback.hidden=false;}); - const link=document.createElement('a');link.href=src;link.target='_blank'; - link.rel='noopener noreferrer';link.textContent='Open full-size photo'; - const caption=document.createElement('p');caption.className='footnote'; - caption.textContent='Only your authenticated account can view this image.'; - media.append(image,link,fallback,caption);media.hidden=false; + const photoCount=workoutPhotoCount(entry); + if(photoCount>0){ + const heading=document.createElement('h3'); + heading.textContent='Workout pictures ('+photoCount+')'; + const gallery=document.createElement('div');gallery.className='detail-gallery'; + for(let index=0;index{image.hidden=true;fallback.hidden=false;}); + const caption=document.createElement('figcaption'); + const link=document.createElement('a'); + link.href=src;link.target='_blank';link.rel='noopener noreferrer'; + link.textContent='Open picture '+(index+1)+' full size'; + caption.append(link); + item.append(image,fallback,caption);gallery.append(item); + } + const privacy=document.createElement('p');privacy.className='footnote'; + privacy.textContent='Pictures are visible only to your authenticated account.'; + media.append(heading,gallery,privacy);media.hidden=false; } $('#detailPanel').hidden=false; $('#detailPanel').scrollIntoView({behavior:'smooth',block:'start'}); @@ -330,7 +355,7 @@ function renderHistory(){ $('#recent').replaceChildren(...visible.map(e=>{ const li=document.createElement('li'),name=document.createElement('strong');name.textContent=titleCase(e.exercise); const day=document.createElement('span');day.textContent=niceDay(e.date); - const detail=document.createElement('small');detail.textContent=describe(e)+(e.tag?' · #'+e.tag:'')+(e.photo?' · Photo saved':''); + const detail=document.createElement('small');detail.textContent=describe(e)+(e.tag?' · #'+e.tag:'')+(workoutPhotoCount(e)?' · '+workoutPhotoCount(e)+' picture'+(workoutPhotoCount(e)===1?'':'s'):''); const actions=document.createElement('div');actions.className='history-actions'; const view=document.createElement('button');view.type='button';view.className='history-edit history-view'; view.textContent='Details';view.disabled=!e.id; @@ -436,12 +461,51 @@ function beginEdit(id){ timezone.value=match?match[2]:offset();grid.append(timezone); installExerciseVisibility(grid,exerciseField.children[1]); WorkoutAdvanced.attach(grid,entry,exerciseField.children[1]); + renderExistingPhotoCount(entry); + $('#savedPhotos').value=''; + $('#photoStatus').textContent=''; $('#editPanel').hidden=false;$('#editStatus').textContent='Original entry and changes are kept in your private workout history.'; $('#editPanel').scrollIntoView({behavior:'smooth',block:'nearest'}); } function closeEdit(){ editingId=null;editingRevision=null;$('#editPanel').hidden=true;$('#editFields').replaceChildren(); } +async function uploadSavedPhotos(){ + const fileInput=$('#savedPhotos'),files=Array.from(fileInput.files||[]); + if(!editingId||!editingRevision){$('#photoStatus').textContent='Open a saved workout to attach pictures.';return;} + if(!files.length){$('#photoStatus').textContent='Choose pictures to add.';return;} + if(files.length>5){$('#photoStatus').textContent='Select up to five pictures at a time.';return;} + if(files.some(file=>file.size===0||file.size>5*1024*1024)){ + $('#photoStatus').textContent='Each picture must be between 1 byte and 5 MB.';return; + } + const existing=entries.find(e=>e.id===editingId); + if(!existing||workoutPhotoCount(existing)+files.length>12){ + $('#photoStatus').textContent='This workout has a limit of 12 pictures.';return; + } + const btn=$('#addPhotosButton'),csrf=payload('append_photos'); + csrf.set('entry_id',editingId);csrf.set('expected_revision',editingRevision); + files.forEach(file=>csrf.append('photos[]',file,file.name)); + btn.disabled=true;btn.textContent='Uploading pictures…'; + try{ + const result=await api(csrf); + editingRevision=result.entry._revision; + fileInput.value=''; + $('#photoStatus').textContent=result.attached+' picture'+(result.attached===1?'':'s')+' saved.'; + await load(); + renderExistingPhotoCount(result.entry); + message('Pictures attached to the selected workout. No existing pictures were replaced.'); + }catch(err){ + $('#photoStatus').textContent=err.message; + if(/changed elsewhere|edited elsewhere|reload/i.test(err.message)){ + message('Workout changed since the editor opened. Refresh and try again.',true); + } + }finally{ + btn.textContent='Add selected pictures'; + const current=entries.find(e=>e.id===editingId); + btn.disabled=!current||workoutPhotoCount(current)>=12; + } +} + async function submitEdit(){ if(!editingId||!editingRevision)return; const button=$('#confirmEdit'),row={}; @@ -518,6 +582,12 @@ $('#toggleHistory').addEventListener('click',()=>{showAllHistory=!showAllHistory $('#recent').addEventListener('click',e=>{const button=e.target.closest('[data-edit-id]');if(button)beginEdit(button.dataset.editId);}); $('#editForm').addEventListener('submit',e=>{e.preventDefault();submitEdit();}); $('#cancelEdit').addEventListener('click',closeEdit); +$('#photosForm').addEventListener('submit',event=>{event.preventDefault();uploadSavedPhotos();}); +$('#savedPhotos').addEventListener('change',()=>{ + const current=entries.find(e=>e.id===editingId); + const files=$('#savedPhotos').files||[]; + $('#addPhotosButton').disabled=!files.length||!current||workoutPhotoCount(current)+files.length>12; +}); document.addEventListener('visibilitychange',()=>{if(!document.hidden && entries.length)refreshAttendance();}); $('#closeDetail').addEventListener('click',closeWorkoutDetails); $('#editFromDetail').addEventListener('click',()=>{if(detailEntryId)beginEdit(detailEntryId);}); diff --git a/workout-tracker/index.php b/workout-tracker/index.php index e03587da..0f14fad0 100644 --- a/workout-tracker/index.php +++ b/workout-tracker/index.php @@ -2,10 +2,10 @@ /* File Name: index.php Author: Jason Lamb (with help from ChatGPT) Description: Authenticated workout dashboard and JSON API. -Revision: 1.6.0 +Revision: 1.7.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.6.0 - Add rolling and since-first attendance with weekday-only rates and workout streaks. 1.5.1 - Add optional heart rate (BPM) to parsing, manual entry, editing, AI fallback and trends. 1.5.0 - Authenticated workout detail/photo viewing and read-only user backup ZIP. 1.4.0 - Backward-compatible calories, configurable metrics and detailed weightlifting sets; closes #128. 1.3.2 - Mobile input layout and context-sensitive exercise measurements (display only, no storage changes). 1.3.1 - Bust cached JavaScript and mark Edit button UI repair. 1.3.0 - Edit prior entries safely, optional tracking tags, treadmill incline and retained edit history. 1.2.0 - Add explicit optional AI retry with private config and multi-exercise validation. 1.1.0 - No-key text parsing and atomic multi-exercise logging. 1.0.1 - Use framework JsonStore for private entries. 1.0.0 - Initial release. */ +Changelog: 1.7.0 - Multiple photos on saved workouts, secure User Management navigation and shared sign-out. 1.6.0 - Add rolling and since-first attendance with weekday-only rates and workout streaks. 1.5.1 - Add optional heart rate (BPM) to parsing, manual entry, editing, AI fallback and trends. 1.5.0 - Authenticated workout detail/photo viewing and read-only user backup ZIP. 1.4.0 - Backward-compatible calories, configurable metrics and detailed weightlifting sets; closes #128. 1.3.2 - Mobile input layout and context-sensitive exercise measurements (display only, no storage changes). 1.3.1 - Bust cached JavaScript and mark Edit button UI repair. 1.3.0 - Edit prior entries safely, optional tracking tags, treadmill incline and retained edit history. 1.2.0 - Add explicit optional AI retry with private config and multi-exercise validation. 1.1.0 - No-key text parsing and atomic multi-exercise logging. 1.0.1 - Use framework JsonStore for private entries. 1.0.0 - Initial release. */ declare(strict_types=1); require_once dirname(__DIR__) . '/1-Framework/bootstrap.php'; require_once __DIR__ . '/parse.php'; @@ -13,6 +13,7 @@ require_once __DIR__ . '/measurements.php'; require_once __DIR__ . '/photo.php'; require_once __DIR__ . '/backup.php'; +require_once __DIR__ . '/photo-upload.php'; $auth = \Jasr\Framework\SharedIdentity::connect(); $user = $auth->requireProject('workout-tracker', $_SERVER['REQUEST_METHOD'] === 'POST' ? 'member' : 'viewer', isset($_GET['api'])); $uid = (string)$user['id']; @@ -49,8 +50,11 @@ function workoutAiConfig(): ?array { // Private photo stream. The client supplies only an entry ID; never a path. if (isset($_GET['photo'])) { $entryId = $_GET['photo']; - $photo = is_string($entryId) - ? workout_owned_photo(\Jasr\Framework\JsonStore::read($path)['records'], $entryId, $root, $uid) + $imageIndex = $_GET['image'] ?? '0'; + $imageNumber = is_string($imageIndex) && preg_match('/^(0|[1-9][0-9]?)$/D', $imageIndex) === 1 + ? (int)$imageIndex : -1; + $photo = is_string($entryId) && $imageNumber >= 0 + ? workout_owned_photo(\Jasr\Framework\JsonStore::read($path)['records'], $entryId, $root, $uid, $imageNumber) : null; header('Cache-Control: private, no-store, max-age=0'); header('X-Content-Type-Options: nosniff'); @@ -98,6 +102,58 @@ function workoutAiConfig(): ?array { exit; } + if ($action === 'append_photos') { + $id=$_POST['entry_id']??null; + $revision=$_POST['expected_revision']??null; + if (!is_string($id) || !is_string($revision) || !isset($_FILES['photos'])) { + reply(422,['error'=>'Choose one or more pictures from your device.']); + } + if (!preg_match('/^[a-f0-9]{24}$/D',$id) + || !preg_match('/^[a-f0-9]{64}$/D',$revision)) { + reply(422,['error'=>'Invalid workout or revision.']); + } + // Cheap preflight avoids staging files for an obviously stale entry. + $current=null; + foreach (\Jasr\Framework\JsonStore::read($path)['records'] as $record) { + if (is_array($record) && ($record['id']??null)===$id) { + if ($current!==null)reply(409,['error'=>'Duplicate workout identifiers.']); + $current=$record; + } + } + if ($current===null)reply(404,['error'=>'Workout entry not found.']); + if (!hash_equals(workout_record_revision($current),$revision)) { + reply(409,['error'=>'Workout was edited elsewhere. Reload before uploading pictures.']); + } + if (count(workout_photo_names($current))>=12) { + reply(422,['error'=>'This workout already has the maximum of 12 photos.']); + } + $staged=[]; + try { + if (!is_array($_FILES['photos']))throw new InvalidArgumentException('Invalid upload.'); + $staged=workout_stage_photo_uploads($_FILES['photos'],$root,$uid,$id); + $names=array_column($staged,'filename'); + $all=\Jasr\Framework\JsonStore::update($path, + static fn(array $records):array=>workout_apply_photo_append($records,$id,$revision,$names)); + } catch (WorkoutEditConflict $e) { + foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']); + reply(409,['error'=>$e->getMessage()]); + } catch (WorkoutEntryNotFound $e) { + foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']); + reply(404,['error'=>$e->getMessage()]); + } catch (InvalidArgumentException $e) { + foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']); + reply(422,['error'=>$e->getMessage()]); + } catch (Throwable $e) { + foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']); + throw $e; + } + foreach($all as $record)if(is_array($record) && ($record['id']??null)===$id){ + $record['_revision']=workout_record_revision($record); + reply(200,['entry'=>$record,'attached'=>count($staged)]); + } + reply(500,['error'=>'The workout could not be read after adding its pictures.']); + } + if ($action === 'edit_entry') { $id=$_POST['entry_id']??null; $revision=$_POST['expected_revision']??null; @@ -296,19 +352,27 @@ static function(array $records) use($id,$revision,$changes):array { Workout Tracker · Keep moving - + - +
Personal fitness · Your pace

Keep moving.

Every session counts. Progress happens one workout at a time.

-

Workout Tracker v1.6.0 · Updated October 27, 2026

+

Workout Tracker v1.7.0 · Updated October 27, 2026

+
Sessions—
@@ -445,7 +509,7 @@ static function(array $records) use($id,$revision,$changes):array {

-
+

Keep a private backup

diff --git a/workout-tracker/photo-upload.php b/workout-tracker/photo-upload.php new file mode 100644 index 00000000..493f92dd --- /dev/null +++ b/workout-tracker/photo-upload.php @@ -0,0 +1,150 @@ + 5) { + throw new InvalidArgumentException('Upload 1 to 5 photos at a time.'); + } + foreach (['name','tmp_name','size','error'] as $key) { + if (count($files[$key]) !== $count) throw new InvalidArgumentException('Invalid photo upload.'); + } + $items = []; + foreach ($files['name'] as $i => $name) { + if (!is_string($name) || !is_string($files['tmp_name'][$i]) + || !is_int($files['error'][$i]) || !is_int($files['size'][$i])) { + throw new InvalidArgumentException('Invalid photo upload.'); + } + if ($files['error'][$i] !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException('A photo did not upload successfully. Try smaller files.'); + } + if ($files['size'][$i] < 1 || $files['size'][$i] > 5 * 1024 * 1024) { + throw new InvalidArgumentException('Each photo must be JPEG, PNG or WebP and no larger than 5 MB.'); + } + $items[] = ['tmp_name'=>$files['tmp_name'][$i], 'size'=>$files['size'][$i]]; + } + return $items; +} + +function workout_photo_upload_mime(string $path): string +{ + if (!is_file($path) || !is_readable($path)) { + throw new InvalidArgumentException('Cannot read an uploaded photo.'); + } + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($path); + $extension = [ + 'image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp' + ][$mime] ?? null; + if ($extension === null || @getimagesize($path) === false) { + throw new InvalidArgumentException('Only valid JPEG, PNG or WebP images can be attached.'); + } + return $extension; +} + +/** + * Stage new immutable image files only in this user's directory. + * The callback exists for isolated tests; production always uses + * move_uploaded_file, which rejects client-supplied local filesystem paths. + */ +function workout_stage_photo_uploads( + array $files, string $storageRoot, string $userId, string $entryId, + ?callable $mover = null +): array { + if (preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) { + throw new InvalidArgumentException('Invalid workout entry.'); + } + $items=workout_photo_upload_items($files); + $root=realpath($storageRoot); + if ($root===false || !is_dir($root)) throw new RuntimeException('Private storage is unavailable.'); + $photosRoot=$root.'/photos'; + if (is_link($photosRoot)) throw new RuntimeException('Unsafe photo storage.'); + if (!is_dir($photosRoot) && !mkdir($photosRoot,0700) && !is_dir($photosRoot)) { + throw new RuntimeException('Cannot create private photo storage.'); + } + $folder=$photosRoot.'/'.hash('sha256',$userId); + if (is_link($folder)) throw new RuntimeException('Unsafe photo folder.'); + if (!is_dir($folder) && !mkdir($folder,0700) && !is_dir($folder)) { + throw new RuntimeException('Cannot create the user photo folder.'); + } + if (realpath($folder)!==$folder || dirname(realpath($folder))!==$photosRoot) { + throw new RuntimeException('Invalid photo folder path.'); + } + $mover ??= static fn(string $tmp, string $destination):bool => + move_uploaded_file($tmp,$destination); + $saved = []; + try { + foreach ($items as $item) { + $extension=workout_photo_upload_mime($item['tmp_name']); + $filename=$entryId.'-'.bin2hex(random_bytes(12)).'.'.$extension; + $destination=$folder.'/'.$filename; + if (file_exists($destination) || is_link($destination) + || !$mover($item['tmp_name'],$destination)) { + throw new RuntimeException('Unable to save one of the new photos.'); + } + @chmod($destination,0600); + $saved[]=['filename'=>$filename,'path'=>$destination]; + } + return $saved; + } catch (Throwable $e) { + foreach ($saved as $photo) if (is_file($photo['path'])) unlink($photo['path']); + throw $e; + } +} + +/** + * Revision checked update under JsonStore's exclusive lock. + * Legacy photo fields are never replaced, and all other records are unchanged. + */ +function workout_apply_photo_append(array $records, string $id, string $revision, array $filenames): array +{ + if (preg_match('/\A[a-f0-9]{24}\z/D',$id)!==1 + || preg_match('/\A[a-f0-9]{64}\z/D',$revision)!==1 + || !$filenames || count($filenames)>5) { + throw new InvalidArgumentException('Invalid photo attachment request.'); + } + foreach ($filenames as $name) { + if (!workout_photo_name_is_safe($name)) throw new InvalidArgumentException('Invalid saved photo name.'); + } + if (count(array_unique($filenames))!==count($filenames)) { + throw new InvalidArgumentException('Duplicate new photo.'); + } + $matches=0; + foreach ($records as $i => $record) { + if (!is_array($record) || ($record['id']??null)!==$id)continue; + $matches++; + if ($matches!==1) throw new RuntimeException('Duplicate workout IDs.'); + if (!hash_equals(workout_record_revision($record),$revision)) { + throw new WorkoutEditConflict('This workout changed elsewhere. Reload before uploading photos.'); + } + $existing=workout_photo_names($record); + if (count($existing)+count($filenames)>12) { + throw new InvalidArgumentException('A workout can have at most 12 photos.'); + } + if (array_intersect($existing,$filenames))throw new InvalidArgumentException('Photo already attached.'); + $previous=$record; + unset($previous['edit_history']); + $history=$record['edit_history']??[]; + if (!is_array($history))throw new RuntimeException('Invalid edit history.'); + $history[]=['edited_at'=>gmdate('c'),'previous'=>$previous]; + $record['photos']=array_merge(is_array($record['photos']??null)?$record['photos']:[],$filenames); + $record['edit_history']=$history; + $record['updated_at']=gmdate('c'); + $records[$i]=$record; + } + if ($matches!==1)throw new WorkoutEntryNotFound('Workout entry not found.'); + return $records; +} diff --git a/workout-tracker/photo.php b/workout-tracker/photo.php index 747964ec..3b9925fb 100644 --- a/workout-tracker/photo.php +++ b/workout-tracker/photo.php @@ -2,10 +2,10 @@ /* File Name: photo.php Author: Jason Lamb (with help from ChatGPT) Description: Read-only, per-user photo lookup for authenticated Workout Tracker media. -Revision: 1.0.0 +Revision: 1.1.0 Created Date: 2026-10-27 Modified Date: 2026-10-27 -Changelog: 1.0.0 - Resolve only owned referenced images within protected user storage. */ +Changelog: 1.1.0 - Support indexed legacy and multi-photo references with strict per-user isolation. 1.0.0 - Resolve only owned referenced images within protected user storage. */ declare(strict_types=1); /** Do not accept filenames containing path separators, escape sequences or other file types. */ @@ -15,14 +15,28 @@ function workout_photo_name_is_safe(mixed $filename): bool && preg_match('/\A[a-zA-Z0-9_-]{1,100}\.(?:jpg|jpeg|png|webp)\z/D', $filename) === 1; } +/** Keep legacy photo intact; all new attachments use the optional photos array. */ +function workout_photo_names(array $record): array +{ + $names = []; + if (($record['photo'] ?? null) !== null && ($record['photo'] ?? '') !== '') { + $names[] = $record['photo']; + } + $additional = $record['photos'] ?? []; + if (!is_array($additional) || !array_is_list($additional)) return $names; + foreach ($additional as $filename) $names[] = $filename; + return $names; +} + /** * Return a verified image path for a photo referenced by this account's own * saved workout record. Never accept a client-supplied path or arbitrary name. * Null means no safe/readable photo. This function has NO write side effects. */ -function workout_photo_for_record(array $record, string $storageRoot, string $userId): ?array +function workout_photo_for_record(array $record, string $storageRoot, string $userId, int $index = 0): ?array { - $name = $record['photo'] ?? null; + $names = workout_photo_names($record); + $name = $index >= 0 && $index < count($names) ? $names[$index] : null; if (!workout_photo_name_is_safe($name)) return null; $rootReal = realpath($storageRoot); @@ -52,9 +66,9 @@ function workout_photo_for_record(array $record, string $storageRoot, string $us } /** Identify a photo strictly by an entry ID already in the authenticated user's JSON file. */ -function workout_owned_photo(array $records, string $entryId, string $storageRoot, string $userId): ?array +function workout_owned_photo(array $records, string $entryId, string $storageRoot, string $userId, int $index = 0): ?array { - if (preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) return null; + if ($index < 0 || $index > 32 || preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) return null; $record = null; foreach ($records as $item) { if (!is_array($item) || ($item['id'] ?? null) !== $entryId) continue; @@ -62,7 +76,7 @@ function workout_owned_photo(array $records, string $entryId, string $storageRoo $record = $item; } if ($record === null) return null; - return workout_photo_for_record($record, $storageRoot, $userId); + return workout_photo_for_record($record, $storageRoot, $userId, $index); } /** @@ -74,19 +88,25 @@ function workout_owned_photo_manifest(array $records, string $storageRoot, strin $found = []; $manifest = []; foreach ($records as $record) { - if (!is_array($record) || empty($record['photo'])) continue; + if (!is_array($record)) continue; + $names = workout_photo_names($record); + if (!$names) continue; $entryId = $record['id'] ?? null; if (!is_string($entryId) || preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) { throw new RuntimeException('A saved photo has an invalid exercise reference.'); } - $photo = workout_owned_photo($records, $entryId, $storageRoot, $userId); - if ($photo === null) throw new RuntimeException('A saved workout photo is missing or invalid; backup would be incomplete.'); - // Different entry IDs must not silently reuse a single unsafe archive name. - if (isset($found[$photo['filename']]) && $found[$photo['filename']] !== $entryId) { - throw new RuntimeException('Duplicate personal photo reference.'); + if (count($names) > 33) throw new RuntimeException('A workout has too many photo references.'); + foreach ($names as $index => $name) { + if (!workout_photo_name_is_safe($name) || isset($found[$name])) { + throw new RuntimeException('A duplicate or unsafe workout photo was referenced.'); + } + $photo = workout_owned_photo($records, $entryId, $storageRoot, $userId, $index); + if ($photo === null) { + throw new RuntimeException('A saved workout photo is missing or invalid; backup would be incomplete.'); + } + $found[$name] = true; + $manifest[] = $photo; } - $found[$photo['filename']] = $entryId; - $manifest[$photo['filename']] = $photo; } - return array_values($manifest); + return $manifest; } diff --git a/workout-tracker/tests/edit-button.test.cjs b/workout-tracker/tests/edit-button.test.cjs index fba330ba..71e57185 100644 --- a/workout-tracker/tests/edit-button.test.cjs +++ b/workout-tracker/tests/edit-button.test.cjs @@ -2,10 +2,10 @@ File Name: edit-button.test.cjs Author: Jason Lamb (with help from ChatGPT) Description: Regression test for Edit button opening saved workouts without a DOMException. -Revision: 1.1.4 +Revision: 1.1.5 Created Date: 2026-10-27 Modified Date: 2026-10-27 -Changelog: 1.1.4 - Validate cardio heart rate visibility and editing without hiding saved BPM values. 1.1.3 - Isolate previous edit button regression from advanced measurement editor. 1.1.2 - Verify mobile touch targets, optional fields location, and hidden-value indicator. 1.1.1 - Check hidden historical rep/set/weight values remain intact and manual entry adapts. 1.1.0 - Verify exercise-specific field visibility and safe retention of hidden measurements. 1.0.0 - Exercise the actual event handler and editing form against DOM class-token validation. +Changelog: 1.1.5 - Verify saved-photo count and upload picker on Edit without altering prior records. 1.1.4 - Validate cardio heart rate visibility and editing without hiding saved BPM values. 1.1.3 - Isolate previous edit button regression from advanced measurement editor. 1.1.2 - Verify mobile touch targets, optional fields location, and hidden-value indicator. 1.1.1 - Check hidden historical rep/set/weight values remain intact and manual entry adapts. 1.1.0 - Verify exercise-specific field visibility and safe retention of hidden measurements. 1.0.0 - Exercise the actual event handler and editing form against DOM class-token validation. */ 'use strict'; const assert = require('node:assert/strict'); @@ -50,7 +50,11 @@ const dom = new Map([ ['#recent',element('ul')], ['#editFields',element('div')], ['#editPanel',element('section')], - ['#editStatus',element('p')] + ['#editStatus',element('p')], + ['#existingPhotos',element('p')], + ['#addPhotosButton',element('button')], + ['#savedPhotos',element('input')], + ['#photoStatus',element('p')] ]); dom.get('#editPanel').hidden=true; const original={ @@ -82,6 +86,7 @@ const context={ const declarations = functionSection('function fieldInput(', 'function describe(') + functionSection('function visibleFieldsForExercise(', 'function renderReview(suggestions){') + + functionSection('function workoutPhotoCount(entry) {', '/** Mobile-readable workout detail view.') + functionSection('function editField(', 'function closeEdit('); vm.runInNewContext(declarations, context, {filename:'workout-tracker/app.js'}); const clickListener=code.split('\n').find(line=>line.includes("$('#recent').addEventListener('click'")); @@ -98,6 +103,9 @@ assert.equal(dom.get('#editPanel').hidden,false,'Edit panel should open after ta assert.equal(dom.get('#editPanel').scrolled,true,'Edit panel should scroll into view.'); assert.equal(context.editingId,original.id,'Correct saved record selected.'); assert.equal(context.editingRevision,original._revision,'Existing revision used for safe editing.'); +assert.match(dom.get('#existingPhotos').textContent,/1 picture already saved/, + 'Legacy attached photo is retained in edit page.'); +assert.equal(dom.get('#savedPhotos').value,'', 'New picture upload input starts empty.'); const fields=dom.get('#editFields').children .flatMap(wrapper=>(wrapper.children||[]).filter(child=>child.dataset?.field)); const byName=name=>fields.find(input=>input.dataset.field===name); diff --git a/workout-tracker/tests/photo-ui.test.cjs b/workout-tracker/tests/photo-ui.test.cjs new file mode 100644 index 00000000..4d2dc76b --- /dev/null +++ b/workout-tracker/tests/photo-ui.test.cjs @@ -0,0 +1,89 @@ +/* +File Name: photo-ui.test.cjs +Author: Jason Lamb (with help from ChatGPT) +Description: Exercise the actual saved-workout photo upload handler without network or user records. +Revision: 1.0.0 +Created Date: 2026-10-27 +Modified Date: 2026-10-27 +Changelog: 1.0.0 - Verify scoped ID/revision, multiple files, stale errors and unchanged original data. +*/ +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const root=path.join(__dirname,'..'); +const script=fs.readFileSync(path.join(root,'app.js'),'utf8'); +const html=fs.readFileSync(path.join(root,'index.php'),'utf8'); +const begin=script.indexOf('async function uploadSavedPhotos(){'); +const end=script.indexOf('async function submitEdit(){',begin); +assert.ok(begin>=0&&end>begin,'saved-workout picture handler present'); +const countStart=script.indexOf('function workoutPhotoCount(entry) {'); +const countEnd=script.indexOf('/** Mobile-readable workout detail view.',countStart); +assert.ok(countStart>=0&&countEnd>countStart,'photo counting logic present'); +const original={ + id:'aaaaaaaaaaaaaaaaaaaaaaaa', + _revision:'a'.repeat(64),date:'2026-10-22',exercise:'treadmill', + photo:'legacy.jpg',notes:'Do not replace me',distance_miles:2.06 +}; +const before=JSON.stringify(original); +const dom=new Map([ + ['#savedPhotos',{files:[{name:'new-1.png',size:1024},{name:'new-2.png',size:2048}],value:'selected'}], + ['#photoStatus',{textContent:''}], + ['#addPhotosButton',{disabled:false,textContent:'Add selected pictures'}], + ['#existingPhotos',{textContent:''}] +]); +let requests=[],errors=[]; +class Data{ + entries=[]; + set(k,v){this.entries.push(['set',k,v]);} + append(k,v,name){this.entries.push(['append',k,v,name]);} +} +const updated={...original,photos:['new-1.png','new-2.png'],_revision:'b'.repeat(64)}; +const ctx={ + entries:[original],editingId:original.id,editingRevision:original._revision, + $:selector=>{assert.ok(dom.has(selector),'known selector '+selector);return dom.get(selector);}, + payload:action=>{assert.equal(action,'append_photos');return new Data();}, + api:async data=>{requests.push(data.entries);return {entry:updated,attached:2};}, + load:async()=>{ctx.entries=[updated];}, + message:msg=>errors.push(msg), + Math, +}; +vm.runInNewContext(script.slice(countStart,countEnd)+script.slice(begin,end),ctx,{filename:'app.js'}); +(async()=>{ + await ctx.uploadSavedPhotos(); + assert.equal(requests.length,1,'exactly one upload request'); + const set=requests[0].filter(item=>item[0]==='set'); + assert.ok(set.some(item=>item[1]==='entry_id'&&item[2]===original.id), + 'request targets original workout ID'); + assert.ok(set.some(item=>item[1]==='expected_revision'&&item[2]===original._revision), + 'request uses original revision token'); + const appended=requests[0].filter(item=>item[0]==='append'); + assert.equal(appended.length,2,'both pictures are uploaded together'); + assert.ok(appended.every(item=>item[1]==='photos[]'),'multipart names match backend'); + assert.equal(ctx.editingRevision,updated._revision,'editor revision updates after successful attach'); + assert.equal(dom.get('#photoStatus').textContent,'2 pictures saved.'); + assert.match(dom.get('#existingPhotos').textContent,/3 pictures already saved/, + 'original and two additions are counted'); + assert.equal(JSON.stringify(original),before,'source saved entry is not changed by view code'); + assert.equal(dom.get('#savedPhotos').value,'','picker reset on success'); + + requests=[]; + dom.get('#savedPhotos').files=[{name:'big.jpg',size:6*1024*1024}]; + await ctx.uploadSavedPhotos(); + assert.equal(requests.length,0,'oversized photo blocked before API call'); + assert.match(dom.get('#photoStatus').textContent,/5 MB/); + + dom.get('#savedPhotos').files=Array.from({length:6},(_,i)=>({name:i+'.jpg',size:1000})); + await ctx.uploadSavedPhotos(); + assert.equal(requests.length,0,'more than five pictures blocked'); + + assert.match(html,/id="photosForm"/); + assert.match(html,/id="savedPhotos"[^>]*multiple/,'mobile image input allows multiple'); + assert.match(html,/name="action" value="logout"/,'central logout uses POST form'); + assert.match(html,/User Management/,'portal navigation displayed'); + const css=fs.readFileSync(path.join(root,'app.css'),'utf8'); + assert.match(css,/\.detail-gallery\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); + assert.match(css,/\.account-signout\{/, 'touch-friendly sign-out action'); + console.log('Passed: append-only mobile uploads, correct revision/owner, limits, gallery and account controls.'); +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/workout-tracker/tests/photo-upload.test.php b/workout-tracker/tests/photo-upload.test.php new file mode 100644 index 00000000..8c7ea536 --- /dev/null +++ b/workout-tracker/tests/photo-upload.test.php @@ -0,0 +1,130 @@ +$id,'exercise'=>'treadmill','date'=>'2026-10-21', + 'distance_miles'=>1.16,'photo'=>$id.'.png','notes'=>'Existing saved workout', + 'created_at'=>'2026-10-21T14:00:00Z','session_id'=>'original-session', + 'legacy_metadata'=>['preserve'=>true] + ]; + $otherRecord=['id'=>$secondId,'exercise'=>'push-ups','date'=>'2026-10-22','reps'=>20]; + JsonStore::update($json,static fn(array $r):array=>[$beforeRecord,$otherRecord]); + $before=JsonStore::read($json)['records']; + $uploads=[ + 'name'=>['new-1.png','new-2.png'], + 'tmp_name'=>[$root.'/new-1.png',$root.'/new-2.png'], + 'size'=>[strlen($fixture),strlen($fixture)], + 'error'=>[UPLOAD_ERR_OK,UPLOAD_ERR_OK] + ]; + photoAssert(count(workout_photo_upload_items($uploads))===2,'two new valid uploads detected'); + photoRejects(fn()=>workout_photo_upload_items([ + 'name'=>array_fill(0,6,'x.png'),'tmp_name'=>array_fill(0,6,'temp'), + 'size'=>array_fill(0,6,100),'error'=>array_fill(0,6,UPLOAD_ERR_OK) + ]),'reject more than five photos'); + photoRejects(fn()=>workout_photo_upload_items([ + 'name'=>['bad.png'],'tmp_name'=>['dummy'], + 'size'=>[100],'error'=>[UPLOAD_ERR_INI_SIZE] + ]),'upload error'); + file_put_contents($root.'/fake.svg',''); + photoRejects(fn()=>workout_photo_upload_mime($root.'/fake.svg'),'unsupported unsafe media'); + + $staged=workout_stage_photo_uploads( + $uploads,$root,$user,$id,static fn(string $src,string $dst):bool=>copy($src,$dst) + ); + photoAssert(count($staged)===2,'two new immutable files staged'); + photoAssert($staged[0]['filename']!==$staged[1]['filename'],'unique photo names generated'); + photoAssert(str_starts_with($staged[0]['filename'],$id.'-'),'entry ID bound filename'); + photoAssert(file_get_contents($folder.'/'.$id.'.png')===$fixture,'old photo bytes unchanged'); + + $rev=workout_record_revision($before[0]); + $names=array_column($staged,'filename'); + $updated=JsonStore::update($json,static fn(array $r):array=> + workout_apply_photo_append($r,$id,$rev,$names)); + photoAssert(count($updated)===2,'photos do not create new workout entries'); + photoAssert($updated[1]===$otherRecord,'unrelated workout untouched'); + photoAssert($updated[0]['photo']===$beforeRecord['photo'],'original single-photo reference remains'); + photoAssert($updated[0]['photos']===$names,'new images appended to optional array'); + photoAssert($updated[0]['created_at']===$beforeRecord['created_at'] && + $updated[0]['session_id']===$beforeRecord['session_id'] && + $updated[0]['legacy_metadata']===$beforeRecord['legacy_metadata'], + 'all historical metadata remains'); + photoAssert($updated[0]['edit_history'][0]['previous']===$before[0], + 'audit snapshot preserves previous original record'); + photoAssert(count(workout_photo_names($updated[0]))===3,'legacy and new photos coexist'); + + for($i=0;$i<3;$i++){ + $item=workout_owned_photo($updated,$id,$root,$user,$i); + photoAssert($item!==null&&is_file($item['path']),'each numbered private photo resolves'); + photoAssert(workout_owned_photo($updated,$id,$root,$other,$i)===null, + 'other account never sees photo at index '.$i); + } + photoAssert(workout_owned_photo($updated,$id,$root,$user,3)===null,'nonexistent index denied'); + photoAssert(workout_owned_photo($updated,'../'.$id,$root,$user)===null,'photo traversal denied'); + $manifest=workout_owned_photo_manifest($updated,$root,$user); + photoAssert(count($manifest)===3,'backup manifest includes legacy and both new photos'); + $stale=false; + try{ + JsonStore::update($json,static fn(array $r):array=> + workout_apply_photo_append($r,$id,$rev,$names)); + }catch(WorkoutEditConflict $e){$stale=true;} + photoAssert($stale,'stale upload cannot overwrite concurrent entry changes'); + photoAssert(count(JsonStore::read($json)['records'][0]['photos'])===2,'failed update did not duplicate images'); + photoRejects(fn()=>workout_apply_photo_append( + $updated,$id,workout_record_revision($updated[0]),array_fill(0,5,$names[0])), + 'duplicate attachments refused'); + if(class_exists(ZipArchive::class)){ + $zipPath=workout_prepare_backup($json,$root,$user); + try{ + $zip=new ZipArchive();photoAssert($zip->open($zipPath)===true,'ZIP created'); + photoAssert(count(json_decode((string)$zip->getFromName('workouts.json'),true)['records'])===2, + 'complete historical workouts backed up'); + foreach(array_merge([$id.'.png'],$names) as $name) + photoAssert($zip->getFromName('photos/'.$name)===$fixture, + 'legacy/new photo appears with exact original bytes'); + $zip->close(); + }finally{if(is_file($zipPath))unlink($zipPath);} + } + photoAssert(JsonStore::read($json)['records'][1]===$otherRecord,'other record unchanged after backup'); + echo "Passed: multi-photo appends, indexed image access, backup coverage, revision safety and legacy data."; +} finally { + foreach(glob($folder.'/*')?:[] as $file)unlink($file); + if(is_dir($folder))rmdir($folder); + if(is_dir($root.'/photos'))rmdir($root.'/photos'); + foreach(glob($root.'/*')?:[] as $file)if(is_file($file))unlink($file); + if(is_dir($root))rmdir($root); +} diff --git a/workout-tracker/tests/workout-detail.test.cjs b/workout-tracker/tests/workout-detail.test.cjs index e0434098..237cd3bc 100644 --- a/workout-tracker/tests/workout-detail.test.cjs +++ b/workout-tracker/tests/workout-detail.test.cjs @@ -2,10 +2,10 @@ File Name: workout-detail.test.cjs Author: Jason Lamb (with help from ChatGPT) Description: DOM regression for mobile workout detail and private photo viewing. -Revision: 1.0.1 +Revision: 1.1.0 Created Date: 2026-10-27 Modified Date: 2026-10-27 -Changelog: 1.0.1 - Verify saved BPM appears in authenticated mobile workout details. 1.0.0 - Verify safe UI rendering, set details, photo ID URL, and old entries without photos. +Changelog: 1.1.0 - Verify indexed legacy and multiple new workout images in private detail gallery. 1.0.1 - Verify saved BPM appears in authenticated mobile workout details. 1.0.0 - Verify safe UI rendering, set details, photo ID URL, and old entries without photos. */ 'use strict'; const assert=require('node:assert/strict'); @@ -13,7 +13,7 @@ const fs=require('node:fs'); const path=require('node:path'); const vm=require('node:vm'); const script=fs.readFileSync(path.join(__dirname,'..','app.js'),'utf8'); -const start=script.indexOf('function openWorkoutDetails(id){'); +const start=script.indexOf('function workoutPhotoCount(entry) {'); const end=script.indexOf('function renderHistory(){',start); assert.ok(start>=0 && end>start,'Detail-view implementation is present'); @@ -36,7 +36,7 @@ const old='bbbbbbbbbbbbbbbbbbbbbbbb'; const first={ id,exercise:'bench press',date:'2026-10-22',performed_at:'2026-10-22T08:37:00-04:00', notes:'Warmup ',tag:'Strength',heart_rate_bpm:148, - photo:id+'.png',sets_detail:[{weight_lb:95,reps:12},{weight_lb:115,reps:10},{weight_lb:135,reps:8}], + photo:id+'.png',photos:[id+'-new1.png',id+'-new2.png'],sets_detail:[{weight_lb:95,reps:12},{weight_lb:115,reps:10},{weight_lb:135,reps:8}], custom_metrics:[{name:'Tempo