diff --git a/.env.example b/.env.example index b15236c1..810083cb 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,17 @@ JWT_SECRET=change-me-to-a-long-random-secret # default; public-internet-facing installs should leave this off. # ALLOW_PRIVATE_WEBHOOK_URLS=1 +# Importing a recipe from a link, and smart import's AI address when an +# account sets its own, fetch from this server. An admin may reach addresses +# on the server's own network; other accounts may not, so they can't read +# services only the server can see. Set these to let every account (and a +# single-user install, which has no sign-in). Cloud-metadata addresses stay +# blocked. +# ALLOW_PRIVATE_RECIPE_URLS=1 +# ALLOW_PRIVATE_AI_URLS=1 +# Behind HTTP_PROXY/HTTPS_PROXY these checks still run, but the proxy makes +# the connection, so what it can reach (its own network) is up to the proxy. + # ── Update checks ───────────────────────────────────────────────────────── # Off until you turn them on: setup asks, and Settings, Updates can change it. # When on, the server asks github.com for the latest release every few hours diff --git a/CHANGELOG.md b/CHANGELOG.md index 8838b3b3..a3ffe8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,157 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.4.1] - 2026-10-22 + +Patch release. Android sync that keeps your edits, earlier versions of a recipe when two devices disagree, imports from Tandoor and Paprika that work, single sign-on fixes, share links that open for everyone, and a round of security fixes (two rated critical). + +> **Upgrade note.** Importing a recipe from an address on your own network (a LAN Mealie, say) is now for admins only; set `ALLOW_PRIVATE_RECIPE_URLS=1` to open it to every account. An AI address an account sets for smart import can't point inside your network unless `ALLOW_PRIVATE_AI_URLS=1`. Password reset, invite and sharing emails build their links from `PUBLIC_URL`, so set it to the address people open. Update the Android app too: the sync fixes are in the app as well as the server. + +### Added + +- **Earlier versions of a recipe.** When edits from two devices change the same part of a recipe, the newer one stays, and the recipe page says so and lists the other, with what differs and a preview. Restore puts it back, and a restore can be undone. A kitchen member sees the versions that were their own edits. +- **Imports say what they skipped** ([#72](https://github.com/TraceApps/cooktrace/issues/72), thanks @joeShuff). A bulk import reads "Imported 119 of 121 Recipes" and lists each recipe skipped as a duplicate, linked to the one you have. + +### Changed + +- **A sync keeps the newer edit, field by field.** An edit made offline in the Android app or the browser no longer overwrites a newer change from another device, even when a phone's clock is off. Changes to different fields are both kept, an amount and its unit stay together, and an item deleted on one device stays deleted. Older versions of the app sync as before. +- **Shared recipe links are a plain address** (`/r/...`) instead of a `#/` one, so they open for people without an account. Links copied before this still open. +- **Sheets work from the keyboard**: focus moves into a sheet and stays there, Escape closes it, and focus goes back where it was. +- **Trace's attach button offers Camera or Gallery on phones and in the Android app.** On a computer it opens the file picker. + +### Fixed + +- **A Tandoor export imports** ([#72](https://github.com/TraceApps/cooktrace/issues/72), thanks @joeShuff), each recipe with its picture. Every Tandoor export failed with "No JSON files found". +- **Importing a recipe you already have opens it**, from a link, pasted text, a file or a Paprika archive, instead of an empty page or counting it as imported. +- **Smart cookbooks show their recipes** ([#76](https://github.com/TraceApps/cooktrace/issues/76), thanks @herver1971): the right count instead of "0 recipes", their recipes in the Android app, and the right ones when filtered by category, on the phone and the server. Renaming one in the Android app no longer empties it, and a plain cookbook no longer counts deleted recipes. +- **The Android app syncs everything it creates, edits and deletes**: categories, custom units, Trace chat, recipes added to or taken out of a cookbook, and the right category and pantry links for recipes and pantry items. An edit made offline reaches your other devices, a recipe made on the phone is made once, and photos go up as files instead of inside the rows. +- **The Android app after signing in or connecting**: no blank screen after signing in on the setup screen, the recipe list, pantry, shopping list and diary fill in after the first sync, Upload matches the categories an account already has, and Disconnect keeps your settings. +- **Cookbook covers change in the Android app** and are kept for offline use. +- **Single sign-on**: an email that already has an account no longer gets a second, empty one when the provider doesn't mark it verified (Authentik's default since 2025.10); Authelia 4.39 and later work out of the box; the callback works at `/api/oidc/callback` and without a provider number; and `OIDC_ENABLE_EMAIL_PASSWORD_LOGIN` works for providers added in Settings. +- **Signing in on a plain-HTTP address says what's wrong** instead of looping back to the sign-in page, and links to the fix (`INSECURE_COOKIES=1`). +- **The app no longer loads behind the sign-in screen**, and your custom and hidden units load after signing in without a reload. +- **Share links from the Android app point at your server**, subpath included. +- **The installed app works when CookTrace is served from a subpath** (`BASE_URL`). Thanks @kgenerozov for the fix in LiftTrace and NutriTrace. +- **Trace with OpenAI-compatible servers**: endpoints that stream unless told not to work ([nutritrace#258](https://github.com/TraceApps/nutritrace/issues/258), thanks @jsapede), smart import works with one set on the server or without an API key (Ollama), and Trace set up through environment variables shows as set up in the Android app. +- **Trace shows your newest messages when you open it**, not the oldest 100. +- **Reordering recipe or pantry categories on the web keeps the new order**, and the Recipes, Shared and Cookbooks switch highlights the tab you're on. + +### Security + +- **Another account signing in on the same phone never sees or sends the last one's data.** Signing back in keeps everything, another account starts from its own data, and you're asked before unsent changes are discarded. The server also takes the session the app sends over an older sign-in cookie. +- **A link can only point at your own pantry items, recipes, categories and cookbooks**, from the web or the Android app. +- **The Android app's SSO sign-in no longer passes the session token through the `cooktrace://` link**, which another app could intercept; it carries a single-use code only the app that started the sign-in can redeem. +- **A password reset link can no longer be pointed at someone else's site**, and names in emails can't carry markup. +- **Recipe import and smart import can't be used to read services on your own network** (see the upgrade note), and push notifications and the NutriTrace link never reach cloud-metadata addresses. +- **The recipe share card can't run script or leak a private recipe**, the image proxy passes images only, and shared recipe pages are kept out of search engines. +- **Share links and uploaded file names come from a secure random source**, and a removed share link stays removed. +- **@capacitor/android** and **@capacitor/core** bumped to 8.5.3, closes [GHSA-rvm3-566m-v7fv](https://github.com/advisories/GHSA-rvm3-566m-v7fv) (critical: a crafted link to the app's own address could load any page as the app). CookTrace was exposed through recipe source and video links; no longer possible. +- **proxy-addr** bumped 2.0.7 to 2.0.8, closes [GHSA-jqcg-44mw-7w3h](https://github.com/advisories/GHSA-jqcg-44mw-7w3h) (critical). CookTrace never sets a trusted proxy, so it wasn't exposed. +- **@modelcontextprotocol/sdk** bumped 1.30.0 to 1.32.1, closes [GHSA-6qxp-vccf-f47h](https://github.com/advisories/GHSA-6qxp-vccf-f47h) (high), [GHSA-22jm-h49p-29qw](https://github.com/advisories/GHSA-22jm-h49p-29qw) (high) and [GHSA-6prh-2h8m-c8cw](https://github.com/advisories/GHSA-6prh-2h8m-c8cw) (moderate). CookTrace doesn't use the affected parts. +- **source-map-js** bumped 1.2.1 to 1.2.2, closes [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) (high). Build tooling only. +- **vite** bumped 7.3.6 to 7.3.7, closes [GHSA-587h-7wmj-h5pm](https://github.com/advisories/GHSA-587h-7wmj-h5pm) (high), [GHSA-698v-4r3x-3vcv](https://github.com/advisories/GHSA-698v-4r3x-3vcv) and [GHSA-r825-w8mm-5pxw](https://github.com/advisories/GHSA-r825-w8mm-5pxw). Only the development server (`npm run dev`) was affected; the app and server people install never include it. +- **hono** bumped 4.13.7 to 4.13.13 on the server (through the MCP library), closes [GHSA-5r4p-p66f-jhc7](https://github.com/advisories/GHSA-5r4p-p66f-jhc7) (moderate). CookTrace never uses the affected `serveStatic`, so it wasn't exposed. +- `npm audit` reports 0 vulnerabilities for the app and the server. + +--- + +## [1.4.1-dev03] - 2026-10-09 (pre-release) + +Security fixes (two rated critical), Android sync that no longer loses edits, and smart cookbooks that show their recipes. + +### Added + +- **Earlier versions of a recipe.** When edits from two devices change the same part of a recipe, the newer one stays, and the recipe page says so and lists the other, with what differs and a preview. Restore puts it back, and a restore can be undone. A kitchen member sees the versions that were their own edits. + +### Changed + +- **A sync keeps the newer edit, field by field.** An edit made offline in the Android app or in the browser no longer overwrites a newer change made on another device, even when a phone's clock is off. Changes to different fields are both kept (an item checked off on the phone and renamed on the web keeps both), an amount and its unit stay together, and an item deleted on one device stays deleted. Older versions of the app sync as before. +- **Sheets work from the keyboard.** Focus moves into a sheet when it opens and stays there, Escape closes it, and focus goes back where it was. +- **Trace's attach button offers Camera or Gallery on phones and in the Android app**, the same as the other Trace apps. On a computer it opens the file picker. + +### Fixed + +- **Smart cookbooks show how many recipes they hold** ([#76](https://github.com/TraceApps/cooktrace/issues/76), thanks @herver1971). Every smart cookbook said "0 recipes", and a plain cookbook still counted recipes you had deleted. +- **Smart cookbooks open with their recipes in the Android app** ([#76](https://github.com/TraceApps/cooktrace/issues/76)). They opened empty, and renaming one there turned it into an empty plain cookbook. +- **Smart cookbooks filtered by category show the right recipes on the phone and the server**, whichever one they were made on. +- **The Android app syncs what it creates, edits and deletes.** A recipe or pantry item could land in the wrong category on the server, and categories, custom units and Trace chat made on the phone never reached it. +- **Deletes sync both ways in the Android app**: categories, custom units, Trace chat, and recipes taken out of a cookbook. A recipe added to a cookbook on the phone no longer drops off at the next sync. +- **An edit made offline reaches your other devices.** One that synced after another device's last sync was skipped there; after this update every device catches up once. +- **A recipe made on the phone is made once.** Two syncs starting at once could make it twice on the server and three times on the phone. +- **A recipe's ingredients made or edited in the Android app link the right pantry items on the server**, and a recipe from the web links the right ones on the phone. +- **Photos synced from the Android app are stored as files on the server.** They were kept inside the recipe, pantry item, cookbook or diary entry, so every list carried them and share cards couldn't show them. Photos already stored that way become files when the server starts. +- **The recipe list fills in after the first sync** instead of saying "No Recipes Yet" until you leave the page. The pantry, shopping list and cook diary refresh the same way. +- **Upload from the phone to a server matches categories the account already has** instead of making a second copy of each. +- **The Android app no longer opens on a blank screen after signing in on the setup screen.** +- **Disconnect keeps your settings on the phone** and no longer shows the welcome screen. +- **Changing a cookbook's cover in the Android app works**, and covers are kept for offline use. +- **Reordering recipe or pantry categories on the web keeps the new order.** +- **The Recipes, Shared and Cookbooks switch highlights the tab you're on.** +- **The installed app works when CookTrace is served from a subpath** (`BASE_URL`). Every visit after the first sent the app's requests to the site root. Thanks @kgenerozov for the fix in LiftTrace and NutriTrace. +- **Trace works with OpenAI-compatible endpoints that stream unless told not to** ([nutritrace#258](https://github.com/TraceApps/nutritrace/issues/258), reported by @jsapede). Chat and smart recipe import failed with "Unexpected non-whitespace character after JSON". +- **Smart recipe import works with an OpenAI-compatible endpoint set on the server, or one without an API key** (such as Ollama). It failed with "Unknown AI provider" or counted as not set up. +- **Trace shows your newest messages when you open it.** It loaded the oldest 100, so a longer chat never showed its latest messages. + +### Security + +- **Another account signing in on the same phone never sees or sends the last one's data.** Signing back in to the same account keeps everything, another account starts from its own data, and you're asked before unsent changes from the last account are discarded. +- **The server takes the session the app sends over an older sign-in cookie.** On Android, a cookie from an earlier account could be answered as that account. +- **A link can only point at your own pantry items, recipes, categories and cookbooks**, from the web or the Android app. One could point at another account's row, showing its name and aisle or adding your recipes to their cookbook. +- **The recipe share card can't run script or leak a private recipe.** It escapes what it shows, isn't cached, blocks script, and follows the recipe's own sharing. +- **A password reset link can no longer be pointed at someone else's site.** It took its address from the request, so a forged host could receive another person's reset token. Reset, invite and sharing emails now use `PUBLIC_URL`, or an address an admin uses, and keep the `BASE_URL` subpath. +- **Names in emails can no longer carry markup.** Everything an email shows is escaped. +- **Accounts can't use recipe import to read services on your own network.** Only an admin can import from your network; `ALLOW_PRIVATE_RECIPE_URLS=1` opens it to every account. +- **An AI address an account sets is checked before smart import uses it.** Other accounts can't point it inside your network unless `ALLOW_PRIVATE_AI_URLS=1`. +- **Push notifications and the NutriTrace link never reach cloud-metadata addresses** and follow a redirect only on the same server; the open `/api/proxy` checks every redirect. +- **The image proxy passes images only**, and images can't act as a page. +- **@capacitor/android** and **@capacitor/core** bumped to 8.5.3, closes [GHSA-rvm3-566m-v7fv](https://github.com/advisories/GHSA-rvm3-566m-v7fv) (critical: a crafted link to the app's own address could load any page as the app, with its sign-in and access to the phone). CookTrace was exposed through recipe source and video links; no longer possible. +- **proxy-addr** bumped 2.0.7 to 2.0.8, closes [GHSA-jqcg-44mw-7w3h](https://github.com/advisories/GHSA-jqcg-44mw-7w3h) (critical). CookTrace never sets a trusted proxy, so it wasn't exposed. +- **@modelcontextprotocol/sdk** bumped 1.30.0 to 1.32.1, closes [GHSA-6qxp-vccf-f47h](https://github.com/advisories/GHSA-6qxp-vccf-f47h) (high). CookTrace doesn't use the affected OAuth client. +- **source-map-js** bumped 1.2.1 to 1.2.2, closes [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) (high). Build tooling only. + +--- + +## [1.4.1-dev02] - 2026-10-04 (pre-release) + +Imports now say what they skipped, and importing a recipe you already have opens it. + +### Fixed + +- **Imports say what they skipped** ([#72](https://github.com/TraceApps/cooktrace/issues/72), thanks @joeShuff). After a bulk import, the summary reads "Imported 119 of 121 Recipes" and lists each recipe skipped as a duplicate, linked to the one you already have, apart from any that failed. +- **Importing a recipe you already have opens it.** Importing it again from a link, pasted text or a file showed a "no cooking steps" warning and opened an empty page. It now says the recipe already exists and opens yours. +- **A Paprika archive no longer counts recipes you already have as imported.** + +--- + +## [1.4.1-dev01] - 2026-10-03 (pre-release) + +A hotfix: Tandoor exports import again, single sign-on fixes, and share links that open for everyone. + +### Fixed + +- **A Tandoor export imports** ([#72](https://github.com/TraceApps/cooktrace/issues/72), thanks @joeShuff). Tandoor packs each recipe in its own zip inside the export, which the importer never opened, so every Tandoor export failed with "No JSON files found". Each recipe now comes in with its own picture. +- **Signing in through SSO with an email that already has an account no longer creates a second account.** When the identity provider doesn't mark the email verified (Authentik's default since 2025.10), the sign-in is refused with a pointer to link the provider from your profile, instead of landing you in a new, empty account. +- **SSO works with Authelia 4.39 and later out of the box.** Email, username and groups are read from the provider's userinfo when the ID token leaves them out. +- **The SSO callback also works without the provider number, and at `/api/oidc/callback`**, the address older docs gave. Both used to end on a blank page. +- **`OIDC_ENABLE_EMAIL_PASSWORD_LOGIN` works for providers added in Settings.** It was ignored unless a provider was also defined through env vars. +- **Signing in on a plain-HTTP address says what's wrong instead of looping.** The sign-in cookie only works over HTTPS unless `INSECURE_COOKIES=1` is set, so signing in from an `http://` address dropped you back on the login page with no error. The sign-in and setup screens now explain it and link to the fix, the app no longer flashes before sending you back, and the server log says so too. +- **Shared recipe links open for people without an account.** On a server with accounts, the link showed the sign-in screen instead of the recipe. Links are now a plain address (`/r/...`) instead of a `#/` one; links copied before this still open. +- **Share links from the Android app point at your server.** They used the app's own internal address, which opened nothing for anyone else, and ignored a server set up under a subpath. +- **The app no longer loads behind the sign-in screen.** Opened signed out, it asked the server for your data and was refused before the sign-in screen replaced it. It now waits to learn who is signed in. +- **Trace set up through the server's environment variables shows as set up in the Android app.** The app asked without its sign-in, so the server refused and Trace looked unconfigured. +- **Your custom and hidden units load after signing in on the web** without a reload. Opened signed out, the app tried to load them before you signed in and then gave up. + +### Security + +- **The Android app's SSO sign-in no longer passes the session token through the `cooktrace://` link**, which another app could intercept. The link carries a single-use code that only the app that started the sign-in can redeem. +- **Share link tokens come from a secure random source.** They were made with `Math.random()`, which can be predicted from its own output. +- **A removed share link stays removed.** A phone still holding the old link could bring it back with its next edit to the recipe, and a device could set a link of its own choosing. The link is now only set by creating or removing it. +- **Shared recipe pages are kept out of search engines** and don't pass their address on to sites the recipe links to. +- **Uploaded files get unguessable names.** The random part of the name was made with `Math.random()`, which can be predicted from its own output. +- `npm audit` reports 0 vulnerabilities for the app and the server. + +--- + ## [1.4.0] - 2026-10-02 CookTrace on your wrist, offline mode for a shop with no signal, kitchen roles for a shared household library, cooking more than one dish at once, preliminary foldable support, and Spanish. diff --git a/DEPLOY.md b/DEPLOY.md index 0d73efaa..60d7a190 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -20,20 +20,19 @@ is a discoverability mirror. Both are first-class; pick whichever fits. - **`ghcr.io/traceapps/cooktrace`** (primary) - **`traceapps/cooktrace`** on [Docker Hub](https://hub.docker.com/r/traceapps/cooktrace) (mirror) -Pin to whatever risk level fits (examples below use GHCR; swap the prefix -for Docker Hub if preferred): +Two tags are published (examples below use GHCR; swap the prefix for +Docker Hub if preferred): | Tag | Updates when | Use case | |-----|--------------|----------| -| `ghcr.io/traceapps/cooktrace:1.0.0` | Never (pinned exact) | Reproducible pin to a specific version | -| `ghcr.io/traceapps/cooktrace:1.0` | Any 1.0.x patch release | Auto-receive bug fixes, no new features | -| `ghcr.io/traceapps/cooktrace:1` | Any 1.x.y minor release | Auto-minor within a major, no breaking | -| `ghcr.io/traceapps/cooktrace:latest` | Every stable release | Absolute latest stable | +| `ghcr.io/traceapps/cooktrace:latest` | Every stable release | Running the app | | `ghcr.io/traceapps/cooktrace:dev` | Every push to `dev` branch | Leading edge, not for production | -Legacy `1.0.0-rc.N` tags from before the semver switch remain published -indefinitely on GHCR; anyone pinned to a specific rc release is unaffected. -Docker Hub mirroring started post-1.0, so it only carries stable-era tags. +There are no version-number tags (`:1`, `:1.4`, `:1.4.0`); the ones published +for the first releases stopped updating and were retired. To hold a server on +one exact build, pin it by digest +(`ghcr.io/traceapps/cooktrace@sha256:...`; `docker inspect --format +'{{index .RepoDigests 0}}' ghcr.io/traceapps/cooktrace:latest` prints the one you run). ## Testing pre-release builds @@ -45,9 +44,9 @@ Every dev-worthy build refreshes the [`dev-latest`](https://github.com/traceapps ### Milestone `v-devNN` (occasional, pinnable) -When a specific feature or fix is worth its own tester milestone (a new AI capability, a wearable integration, a big backup change), a numbered pre-release gets cut: `v1.0.4-dev01`, `v1.1.0-dev01`, etc. These get their own permanent GH release, their own tester-facing notes, and their own Docker tag (`ghcr.io/traceapps/cooktrace:1.0.4-dev01`) alongside `:dev`. `dev-latest` is refreshed to point at the same commit. +When a specific feature or fix is worth its own tester milestone (a new AI capability, a wearable integration, a big backup change), a numbered pre-release gets cut: `v1.0.4-dev01`, `v1.1.0-dev01`, etc. These get their own permanent GH release, their own tester-facing notes, but no Docker tag of their own: the server side of a milestone is the `:dev` image. `dev-latest` is refreshed to point at the same commit. -Iteration numbers are zero-padded two digits for 1 through 9 (`dev01`, `dev02`, …, `dev09`) and natural two digits from 10 onward (`dev10`, `dev11`, …). No dot between `dev` and the number. That keeps the identifier inside SemVer 2.0.0 §9 and gives correct lex ordering everywhere (GitHub Tags, `gh release list`, Docker Hub). +Iteration numbers are zero-padded two digits for 1 through 9 (`dev01`, `dev02`, …, `dev09`) and natural two digits from 10 onward (`dev10`, `dev11`, …). No dot between `dev` and the number. That keeps the identifier inside SemVer 2.0.0 §9 and gives correct lex ordering everywhere (GitHub Tags, `gh release list`). Use numbered dev builds when reporting bugs ("I saw this on `v1.1.0-dev02`") or if you want to install a specific milestone and stay on it. Everyone else, `dev-latest` covers you. diff --git a/README.md b/README.md index 024d7220..320703d0 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Third app in the Trace family alongside [NutriTrace](https://github.com/traceapp ### AI + Federation - **Trace AI.** Reads your recipes, pantry, diary, and cookbooks; can log a cook, plan a meal, add to shopping, or import a recipe from a URL, all conversationally. 19 tools total. Multi-provider (Claude / OpenAI / Gemini / any OpenAI-compatible endpoint). Smart Log voice, image attach, cook-mode voice control. → [full guide](https://traceapps.github.io/docs/cooktrace/trace/) -- **NutriTrace federation.** Pull food data per-user with a Bearer token; log cooks back to the NT diary. → [full guide](https://traceapps.github.io/docs/cooktrace/nt-federation/) +- **NutriTrace federation.** Pull food data from NutriTrace into your pantry, per user, with a Bearer token. → [full guide](https://traceapps.github.io/docs/cooktrace/nt-federation/) - **Model Context Protocol (MCP).** Expose your recipes, pantry, shopping list, and cook diary to external AI agents (Claude Desktop, Cursor, Codex) via the standard MCP Streamable HTTP transport. Off by default; opt in with `MCP_ENABLED=1`. Read, write, and destructive tool tiers, each independently gated by its own env flag and token scope. → [full guide](https://traceapps.github.io/docs/cooktrace/mcp/) ### Accounts + platforms @@ -139,6 +139,7 @@ The most-asked knobs. Full list at [docs/self-hosting/env-vars](https://traceapp | `UPLOADS_PATH` | `/data/uploads` | Uploaded images and server-side backups. | | `PORT` | `3003` | Port the server listens on inside the container (3001 before 1.3.0). | | `BASE_URL` | - | Mount at a subpath, e.g. `/cooktrace`. | +| `PUBLIC_URL` | - | The full address people open the app at, subpath included (e.g. `https://recipes.example.com`). Links in emails (password reset, invites, sharing) use it. Unset, they use an address an admin has opened the app at. | | `LOG_LEVEL` | `info` | `error` \| `warn` \| `info` \| `debug`. | | `INSECURE_COOKIES` | unset | Set to `1` on plain-HTTP LAN deployments so the auth cookie isn't dropped. See [LAN-HTTP notes](https://traceapps.github.io/docs/getting-started/lan-http/). | | `MAX_SESSION_HOURS` | `8760` | Session-length cap in hours. Lower for shared / kiosk machines. | diff --git a/android/app/build.gradle b/android/app/build.gradle index 64021d25..c3b69f6d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,8 +19,8 @@ android { applicationId "com.cooktrace.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 105 - versionName "1.4.0" + versionCode 106 + versionName "1.4.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d4332038..312a16c4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..a86d4229 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/android/wear/build.gradle b/android/wear/build.gradle index ab668d62..37ac43ca 100644 --- a/android/wear/build.gradle +++ b/android/wear/build.gradle @@ -17,8 +17,8 @@ android { minSdkVersion 30 targetSdkVersion rootProject.ext.targetSdkVersion // Its own numbering; the name tracks the phone app it pairs with. - versionCode 2 - versionName "1.4.0" + versionCode 3 + versionName "1.4.1" } buildFeatures { diff --git a/index.html b/index.html index 33cf0576..54c238ab 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,22 @@ + + diff --git a/package-lock.json b/package-lock.json index 2a343777..a423de5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cooktrace", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cooktrace", - "version": "1.4.0", + "version": "1.4.1", "hasInstallScript": true, "dependencies": { "@aparajita/capacitor-biometric-auth": "^10.0.0", @@ -1659,12 +1659,12 @@ } }, "node_modules/@capacitor/android": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.0.tgz", - "integrity": "sha512-EQy6ByUuKayQBJmMm/e0byJiHavqsQHrvW23BuT2GNVQvenAvipqwaePiJHzrv2PZr7A0T0+se4kgDCeROj0mQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.3.tgz", + "integrity": "sha512-ilH4gSSz3DrketMkzDVAvY9JsWJ//rCQnECLTdcFRO7xXDogwAbUvQAnIAbiqefwV/D1xfjEL4Gw1MJXEcaA9g==", "license": "MIT", "peerDependencies": { - "@capacitor/core": "^8.3.0" + "@capacitor/core": "^8.5.0" } }, "node_modules/@capacitor/app": { @@ -1766,9 +1766,9 @@ } }, "node_modules/@capacitor/core": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.0.tgz", - "integrity": "sha512-LrS1xPIrqLtJABBIPDGXxxKmI9OyesrzWw8DiHbxhSC9JoiLUleUAJlX1a0LWIVLRbuY4Szgf9huFeRqYH2SAQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.3.tgz", + "integrity": "sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==", "license": "MIT", "dependencies": { "tslib": "^2.1.0" @@ -1796,12 +1796,12 @@ } }, "node_modules/@capacitor/ios": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.0.tgz", - "integrity": "sha512-tnwstEdbTJ2nHAfoAwnurXgYRscWeLY+IIGdz69o24gN2Crfj9Xc0TWo8L5uFLF1LmpbUywH1IT0U1oHV8c+CA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.3.tgz", + "integrity": "sha512-HnnV92Jl8+3x0JUloXUMjFcUsQI8wHFVsKwkLn72DfRZXYGZW11tQbmNOWOnRTljaU18FdI1b9ipT09SzzPDNw==", "license": "MIT", "peerDependencies": { - "@capacitor/core": "^8.4.0" + "@capacitor/core": "^8.5.0" } }, "node_modules/@capacitor/keyboard": { @@ -2673,9 +2673,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2689,9 +2686,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2705,9 +2699,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2721,9 +2712,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6126,9 +6114,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7318,9 +7306,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9eabd200..eedbded9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cooktrace", - "version": "1.4.0", + "version": "1.4.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/ai-history-newest.test.js b/scripts/ai-history-newest.test.js new file mode 100644 index 00000000..58434b6e --- /dev/null +++ b/scripts/ai-history-newest.test.js @@ -0,0 +1,70 @@ +/** + * Trace's chat history shows the newest messages, in the order they were + * written. + * + * GET /api/ai/history returned the OLDEST 100 messages, so once an account + * had more than 100, reopening Trace never showed the newest ones, on the + * web or in the app. Picking the newest keeps a question and its answer + * saved in the same second in the order they were written (id breaks ties). + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +// server/db.js opens DB_PATH when imported: a scratch database. +let db = null, dir = null, server = null, base = null; +try { + const req = createRequire(new URL('../server/package.json', import.meta.url)); + req('better-sqlite3'); + dir = mkdtempSync(join(tmpdir(), 'ai-history-')); + process.env.DB_PATH = join(dir, 'test.db'); + db = (await import('../server/db.js')).default; + const express = req('express'); + const { default: aiRoutes } = await import('../server/routes/ai.js'); + const app = express(); + app.use(express.json()); + // what requireAuth leaves behind: the signed-in account, picked per request here + app.use((r, _res, next) => { r.user = { id: Number(r.headers['x-user']), role: 'admin' }; next(); }); + app.use('/api/ai', aiRoutes); + server = app.listen(0); + base = `http://127.0.0.1:${server.address().port}`; +} catch { /* better-sqlite3 not built for this Node: the cases skip */ } +test.after(() => { server?.close(); try { db?.close(); } catch {} if (dir) rmSync(dir, { recursive: true, force: true }); }); + +const user = (n) => Number(db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(n + Math.random(), 'x').lastInsertRowid); +const say = (u, role, content, at) => db.prepare('INSERT INTO ai_chat_history (user_id, role, content, created_at) VALUES (?, ?, ?, ?)').run(u, role, content, at); +const history = async (u) => (await fetch(`${base}/api/ai/history`, { headers: { 'x-user': String(u) } })).json(); +const at = (s) => new Date(Date.UTC(2026, 9, 1, 8, 0, 0) + s * 1000).toISOString().replace('T', ' ').slice(0, 19); + +test('history is the newest 100 messages, oldest first', async (t) => { + if (!db) return t.skip('better-sqlite3 is not built for this Node'); + const me = user('me'); + for (let i = 1; i <= 150; i++) say(me, i % 2 ? 'user' : 'assistant', `message ${i}`, at(i)); + const rows = await history(me); + assert.equal(rows.length, 100); + assert.equal(rows[0].content, 'message 51'); + assert.equal(rows[99].content, 'message 150', 'the newest message is shown'); + assert.deepEqual(rows.map(r => r.content), Array.from({ length: 100 }, (_, i) => `message ${i + 51}`)); +}); + +test('a question and its answer saved in the same second keep their order', async (t) => { + if (!db) return t.skip('better-sqlite3 is not built for this Node'); + const me = user('me'); + for (let i = 0; i < 20; i++) { + say(me, 'user', `question ${i}`, at(1000 + i)); + say(me, 'assistant', `answer ${i}`, at(1000 + i)); + } + const rows = await history(me); + assert.deepEqual(rows.map(r => r.content), Array.from({ length: 20 }, (_, i) => [`question ${i}`, `answer ${i}`]).flat()); +}); + +test("another account's chat never shows", async (t) => { + if (!db) return t.skip('better-sqlite3 is not built for this Node'); + const me = user('me'), other = user('other'); + say(me, 'user', 'mine', at(5000)); + say(other, 'user', 'theirs', at(5001)); + assert.deepEqual((await history(me)).map(r => r.content), ['mine']); +}); diff --git a/scripts/android-schema.test.js b/scripts/android-schema.test.js new file mode 100644 index 00000000..d7daef02 --- /dev/null +++ b/scripts/android-schema.test.js @@ -0,0 +1,85 @@ +/** + * The phone's database, built as the Android SQLite plugin runs a script: + * split on ";\n" only, each piece's first statement run and the rest of + * it silently dropped (scripts/android-sync/sqlite.mjs). Every table, + * index and trigger the app's scripts make must be there, and clearing an + * account's copy must leave none of its rows. + */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import * as nodeModule from 'node:module'; +import test from 'node:test'; + +const root = new URL('../', import.meta.url); +let skip = false; +try { + const Database = createRequire(new URL('../server/package.json', import.meta.url))('better-sqlite3'); + new Database(':memory:').close(); + createRequire(new URL('../package.json', import.meta.url)).resolve('svelte/store'); + if (typeof nodeModule.register !== 'function') skip = 'needs module hooks'; +} catch { skip = 'needs the server and app dependencies, built for this Node'; } + +function onPhone(code) { + const r = spawnSync(process.execPath, ['--import', './scripts/android-sync/register.mjs', '--input-type=module', '-e', ` + globalThis.window = globalThis; globalThis.addEventListener ??= () => {}; globalThis.dispatchEvent ??= () => true; + const store = new Map(); + Object.defineProperty(globalThis, 'localStorage', { value: { getItem: k => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), key: i => [...store.keys()][i] ?? null, get length() { return store.size; } }, configurable: true }); + const dbn = await import('./src/lib/db-native.js'); + await dbn.dbInit(); + const db = await dbn.getDb(); + ${code} + `], { cwd: root, encoding: 'utf8', env: { ...process.env, CT_SERVER: '' } }); + const line = (r.stdout || '').split('\n').find(l => l.startsWith('RESULT ')); + if (!line) throw new Error(`phone run failed:\n${r.stdout}\n${r.stderr}`); + return JSON.parse(line.slice(7)); +} + +test('the schema and its triggers are all there, run as the Android plugin runs a script', { skip }, () => { + const src = readFileSync(new URL('../src/lib/db-native.js', import.meta.url), 'utf8'); + const schema = src.slice(src.indexOf('const SCHEMA = `'), src.indexOf('`;', src.indexOf('const SCHEMA = `'))); + const code = schema.split('\n').map(l => l.replace(/--.*$/, '')).join('\n'); + const want = [...code.matchAll(/CREATE (?:UNIQUE )?(?:TABLE|INDEX) IF NOT EXISTS (\w+)/g)].map(m => m[1]); + assert.ok(want.length > 15, 'read the schema'); + const got = onPhone(` + const names = (await db.query("SELECT name FROM sqlite_master", [])).values.map(r => r.name); + console.log('RESULT ' + JSON.stringify(names)); + `); + for (const n of want) assert.ok(got.includes(n), `${n} made`); + for (const t of ['recipes', 'pantry_items', 'cook_diary', 'shopping_list', 'recipe_categories', 'pantry_categories', 'custom_units', 'cookbooks', 'recipe_comments', 'ai_chat_history']) { + assert.ok(got.includes(`trg_${t}_edit_clock_upd`), `${t}: edit clock trigger (update)`); + assert.ok(got.includes(`trg_${t}_edit_clock_ins`), `${t}: edit clock trigger (insert)`); + } +}); + +test("clearing an account's copy leaves none of its rows, every table", { skip }, () => { + const r = onPhone(` + const { CtApiNative: api } = await import('./src/lib/api-native.js'); + const r1 = await api.createRecipe({ name: 'Soup', ingredients: [], steps: [] }); + await api.createPantryItem({ name: 'Rice' }); + await api.addShoppingItem({ name: 'Milk' }); + await api.createRecipeCategory({ name: 'Mains' }); + await api.createCookbook({ name: 'Weeknights' }); + await db.run("INSERT INTO sync_deletes (table_name, server_id) VALUES ('recipes', 9)", []); + await db.run("INSERT INTO user_settings (key, value) VALUES ('x', '1')", []); + await db.run("INSERT INTO sync_meta (key, value) VALUES ('last_pull_at', '2026-01-01')", []); + const tables = ['recipes', 'pantry_items', 'shopping_list', 'recipe_categories', 'cookbooks', 'sync_deletes', 'user_settings']; + const count = async () => Object.fromEntries(await Promise.all(tables.map(async t => [t, (await db.query('SELECT COUNT(*) AS n FROM ' + t, [])).values[0].n]))); + const before = await count(); + await dbn.dbClearUserData(); + console.log('RESULT ' + JSON.stringify({ before, after: await count(), cursor: (await db.query("SELECT value FROM sync_meta WHERE key = 'last_pull_at'", [])).values.length })); + `); + for (const [t, n] of Object.entries(r.before)) assert.ok(n > 0, `${t} had rows`); + assert.deepEqual(Object.values(r.after), Object.values(r.after).map(() => 0), JSON.stringify(r.after)); + assert.equal(r.cursor, 0); +}); + +test('a script with two statements on a line runs only the first on the phone, as on Android', { skip }, () => { + const r = onPhone(` + await db.execute("CREATE TABLE a (x); CREATE TABLE b (x);\\nCREATE TABLE c (x);\\n"); + const names = (await db.query("SELECT name FROM sqlite_master WHERE name IN ('a','b','c')", [])).values.map(r => r.name).sort(); + console.log('RESULT ' + JSON.stringify(names)); + `); + assert.deepEqual(r, ['a', 'c'], 'the stand-in drops what the plugin drops'); +}); diff --git a/scripts/android-sync.test.js b/scripts/android-sync.test.js new file mode 100644 index 00000000..377804a3 --- /dev/null +++ b/scripts/android-sync.test.js @@ -0,0 +1,478 @@ +/** + * The Android app and the web editing the same recipes. + * + * Runs the app's own sync code (src/lib/sync.js, db-native.js, + * api-native.js; Capacitor swapped for scripts/android-sync/) against a real + * server started from server/index.js, one fresh server per scenario, with + * the web saving through the REST API as the web app does. What it holds to: + * + * - the newer of two edits stays, whatever the phone's clock says, and the + * other is kept as an earlier version; the phone ends up with what stayed; + * - an edit made on the copy still on the server always goes in, and + * nothing is kept when nothing conflicted; + * - a rating, a favorite or a cook logged on the phone doesn't put back + * content, a rating or a visibility changed on the web since, and cook + * counts follow the diary; + * - a delete is an edit at its time: an older edit doesn't bring a deleted + * recipe back, and is kept; + * - the phone's own push coming back doesn't undo a newer edit, with a + * slow clock and a slow network; + * - a phone takes the server's copy as its own only once it has it. + */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createServer } from 'node:net'; +import { createRequire } from 'node:module'; +import * as nodeModule from 'node:module'; +import test from 'node:test'; + +const root = new URL('../', import.meta.url); +let Database = null; +try { + Database = createRequire(new URL('../server/package.json', import.meta.url))('better-sqlite3'); + new Database(':memory:').close(); // a build for another Node fails here + createRequire(new URL('../package.json', import.meta.url)).resolve('svelte/store'); +} catch { Database = null; } +const skip = Database && typeof nodeModule.register === 'function' ? false : 'needs the server and app dependencies, built for this Node'; + +const freePort = () => new Promise((res, rej) => { + const s = createServer(); + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => res(port)); }); + s.on('error', rej); +}); + +async function startServer() { + const dir = mkdtempSync(join(tmpdir(), 'ct-android-sync-')); + const port = await freePort(); + const proc = spawn(process.execPath, ['index.js'], { + cwd: new URL('server/', root), + env: { ...process.env, PORT: String(port), DB_PATH: join(dir, 'app.db'), UPLOADS_PATH: join(dir, 'uploads'), + JWT_SECRET: 'android-sync-test-secret-0123456789abcdef', INSECURE_COOKIES: '1', NODE_ENV: 'test' }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let errors = ''; + proc.stderr.on('data', d => { errors += d; }); + const base = `http://127.0.0.1:${port}`; + for (let i = 0; ; i++) { + try { await fetch(base + '/api/auth/me'); break; } catch { await new Promise(r => setTimeout(r, 100)); } + if (i > 150) { proc.kill(); throw new Error('server did not start: ' + errors.slice(-500)); } + } + const reg = await fetch(base + '/api/auth/register', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'cook', password: 'Str0ng-Pass-88!y' }), + }); + const token = reg.headers.getSetCookie().map(c => c.split(';')[0].split('=')[1]).find(v => v?.startsWith('eyJ')); + assert.ok(token, 'signed in'); + // A second account, Bob, made by the admin. Sign-in is rate limited, so + // his session is made the way the server makes one (signToken). + const made = await fetch(base + '/api/auth/register', { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ username: 'bob', password: 'Str0ng-Pass-99!z' }), + }).then(r => r.json()); + const jwt = createRequire(new URL('../server/package.json', import.meta.url))('jsonwebtoken'); + const tokenB = jwt.sign({ id: made.user?.id ?? made.id, username: 'bob', role: 'user', csrf: 'test' }, 'android-sync-test-secret-0123456789abcdef', { expiresIn: '1h' }); + return { base, token, tokenB, dbPath: join(dir, 'app.db'), stop: () => { proc.kill(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* closing */ } } }; +} + +// A stuck scenario fails rather than holding up the run. +const PHONE_TIMEOUT_MS = 60_000; +const TEST_TIMEOUT_MS = 120_000; + +function phone(srv, scenario, env = {}) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(`${scenario} took longer than ${PHONE_TIMEOUT_MS / 1000}s`)); }, PHONE_TIMEOUT_MS); + const p = spawn(process.execPath, ['--import', './scripts/android-sync/register.mjs', 'scripts/android-sync/phone.mjs', scenario], { + cwd: root, env: { ...process.env, CT_SERVER: srv.base, CT_TOKEN: srv.token, CT_TOKEN_B: srv.tokenB, ...env }, stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = '', err = ''; + p.stdout.on('data', d => { out += d; }); + p.stderr.on('data', d => { err += d; }); + p.on('close', code => { + clearTimeout(timer); + const at = out.lastIndexOf('\n@@'); + if (code !== 0 || at < 0) return reject(new Error(`${scenario} exited ${code}: ${err.slice(-800)}`)); + resolve(JSON.parse(out.slice(at + 3))); + }); + }); +} + +async function run(scenario, env) { + const srv = await startServer(); + try { return await phone(srv, scenario, env); } finally { srv.stop(); } +} + +const MIN = 60_000; + +for (const [clock, skew] of [['right', 0], ['10 minutes slow', -10 * MIN], ['10 minutes fast', 10 * MIN]]) { + test(`an older phone edit doesn't replace a newer web edit (clock ${clock})`, { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('olderPhone', { PHONE_SKEW_MS: String(skew) }); + assert.equal(r.server.name, 'Soup (web)'); + assert.equal(r.phone.name, 'Soup (web)', 'the phone has what stayed, in the same sync'); + assert.equal(r.phone.pending, false); + assert.deepEqual(r.versions, ['Soup (phone)'], "the phone's edit is kept"); + }); + test(`a newer phone edit replaces an older web edit (clock ${clock})`, { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('newerPhone', { PHONE_SKEW_MS: String(skew) }); + assert.equal(r.server.name, 'Soup (phone)'); + assert.equal(r.phone.name, 'Soup (phone)'); + assert.deepEqual(r.versions, ['Soup (web)'], "the web's edit is kept"); + }); +} + +test('edits only on the phone, or on a copy that has the web edit, keep nothing', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('noConflict', { PHONE_SKEW_MS: String(-10 * MIN) }); + assert.equal(r.server.name, 'Soup (phone4)', 'goes in whatever the clock says'); + assert.deepEqual(r.versions, []); +}); + +test('a rating, a favorite and a cook on the phone keep the web content', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('metaOnly'); + assert.deepEqual(r.server, { name: 'Soup (web)', rating: 4, favorite: true, visibility: 'private', cook_count: 1, servings: null }); + assert.equal(r.phone.name, 'Soup (web)'); + assert.equal(r.phone.rating, 4); + assert.equal(r.phone.cook_count, 1); + assert.deepEqual(r.versions, [], 'nothing conflicted'); +}); + +test("a cook on the phone keeps the web's rating, favorite, visibility and cooks", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('cookAfterWeb'); + assert.equal(r.server.rating, 5); + assert.equal(r.server.favorite, true); + assert.equal(r.server.visibility, 'group'); + assert.equal(r.server.cook_count, 3, 'two cooks on the web and one on the phone'); + assert.equal(r.phone.cook_count, 3); + assert.equal(r.phone.rating, 5); + assert.deepEqual(r.versions, []); +}); + +test("an older phone edit doesn't bring back a recipe the web deleted later, and is kept", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + try { + const r = await phone(srv, 'webDeletesLater'); + assert.equal(r.server, 'gone 404'); + assert.equal(r.phone.deleted, true, 'the phone drops it too'); + const db = new Database(srv.dbPath, { readonly: true }); + const kept = db.prepare('SELECT data FROM recipe_versions WHERE recipe_id = ?').all(r.id).map(v => JSON.parse(v.data).name); + db.close(); + assert.deepEqual(kept, ['Soup (phone)']); + } finally { srv.stop(); } +}); + +test('a phone edit made after the web deleted the recipe brings it back', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('phoneEditsAfterDelete'); + assert.equal(r.server.name, 'Soup (phone)'); + assert.equal(r.phone.deleted, false); +}); + +for (const [label, env] of [ + ['slow clock', { PHONE_SKEW_MS: String(-10 * MIN) }], + ['slow clock and a slow network', { PHONE_SKEW_MS: String(-10 * MIN), PHONE_DELAY_MS: '1500' }], + ['fast clock and a slow network', { PHONE_SKEW_MS: String(10 * MIN), PHONE_DELAY_MS: '1500' }], +]) { + test(`the phone's own push coming back doesn't undo a newer edit (${label})`, { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('echoCategory', env); + assert.deepEqual({ server: r.server, phone: r.phone }, { server: 'Beta', phone: 'Beta' }); + }); +} + +test('other rows merge field by field: checked off on the phone, renamed on the web', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('shoppingFields'); + assert.deepEqual(r.server, { name: 'Oat Milk', checked: true }); + assert.deepEqual(r.phone, { name: 'Oat Milk', checked: true }); +}); + +test('an edit keeps the time it was made, even if the clock is set right before the sync', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('clockFixedBeforePush', { PHONE_SKEW_MS: String(-10 * MIN) }); + assert.equal(r.server.name, 'Soup (phone)', "the phone's edit, made after the web's, stays"); + assert.deepEqual(r.versions, ['Soup (web)']); +}); + +test("the phone takes the server's copy as its own only once it has it", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('repullFails'); + assert.equal(r.firstSync, 'failed'); + assert.equal(r.stampKept, true, 'the failed pull leaves the stamp of the copy it has'); + assert.equal(r.phoneAfterFail, 'Soup (phone)'); + assert.equal(r.phoneAfter, 'Soup (web)', 'the next sync brings it down'); + assert.equal(r.stampMoved, true); + assert.equal(r.server, 'Soup (web)'); +}); + +// ── Accounts on one phone, and syncs that run at once ──────────────────── +const ownersOf = (srv, name) => { + const db = new Database(srv.dbPath, { readonly: true }); + try { return db.prepare(`SELECT u.username FROM recipes r JOIN users u ON u.id = r.user_id WHERE r.name = ? AND r.deleted_at IS NULL`).all(name).map(x => x.username); } + finally { db.close(); } +}; + +test("another account signing in neither sees nor sends the last one's data; saying no keeps it for them", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + try { + const r = await phone(srv, 'accountSwitch'); + assert.equal(r.early, 'other_account', 'no sync before the app has checked'); + assert.equal(r.no, false, 'saying no undoes the sign-in'); + assert.equal(r.asked, 1, 'asked, with the count of changes waiting'); + assert.deepEqual(ownersOf(srv, 'Alice Waiting'), ['cook'], "Alice's change went up as hers, once"); + assert.equal(r.yes, true); + assert.equal(r.leftAfterClear, 0, "nothing of Alice's left on the phone"); + assert.deepEqual(r.bobPhone, ['Bob Web Soup'], 'Bob sees his own, and only his'); + assert.deepEqual(ownersOf(srv, 'Alice Discarded'), [], 'discarded only after saying so, never sent as Bob'); + assert.deepEqual(r.same, true); + assert.equal(r.askedBob, false, 'the same account back: nothing asked'); + assert.deepEqual(ownersOf(srv, 'Bob Waiting'), ['bob']); + } finally { srv.stop(); } +}); + +test('a sync running when another account signs in stops, and writes nothing after the clear', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('switchDuringSync'); + assert.equal(r.yes, true); + assert.equal(r.run, 'stopped'); + assert.equal(r.leftAfterClear, 0); + assert.deepEqual(r.bobPhone, []); +}); + +test("signing out sends what's waiting with its own session, and never under the next account's", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('signOutMidPush'); + assert.ok(r.took < 5500, `sign-out waited ${r.took} ms`); + assert.deepEqual(r.pushes, ['alice (cut off)'], 'one push, as Alice, cut off when sign-out stopped waiting'); + assert.equal(r.bobSync, 'other_account', "Bob's session sends nothing before the check"); + assert.equal(r.aliceSoupOnServer, 0, 'what was cut off stays on the phone for Alice'); +}); + +test('every sync trigger at once runs one sync: a new recipe is made once, and an edit made meanwhile goes up after', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('syncTriggersAtOnce'); + assert.equal(r.overlapping, false, 'no two pushes or pulls at once'); + assert.deepEqual(r.server, ['edited while syncing'], 'one recipe on the server, with the edit'); + assert.equal(r.phone, 1, 'one on the phone'); + assert.ok(r.pushes <= 2, `${r.pushes} pushes`); +}); + +test('a push whose answer was lost sends the new recipe again, and it is still one recipe', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('lostAnswer'); + assert.equal(r.first, false); + assert.equal(r.server, 1); + assert.deepEqual(r.phone, [true]); +}); + +test("if the phone can't tell whose data it holds, it shows none of it, and Retry works", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('gateError'); + assert.deepEqual({ first: r.first, state: r.state, shown: r.shown }, { first: false, state: 'error', shown: false }); + assert.deepEqual({ second: r.second, shownAfter: r.shownAfter }, { second: true, shownAfter: true }); + assert.deepEqual(r.together, [true, true]); +}); + +test('Disconnect, use the phone alone, connect as another account with Upload: no question, every row once', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('disconnectUpload'); + assert.deepEqual(r.errors, []); + assert.equal(r.ok, true); + assert.equal(r.asked, null, 'Upload made the copy this account\'s'); + assert.equal(r.madeAtKept, true, "a pull keeps when the phone made its row: it's part of the row's key"); + assert.deepEqual(r.bobServer, ['Alice Synced', 'Made Alone'], 'each once, even after the upload ran twice'); + assert.deepEqual(r.bobPhone, ['Alice Synced', 'Made Alone']); + assert.deepEqual(r.serverCatDupes, [], "the account's categories are matched, not made twice"); + assert.deepEqual(r.phoneCats, r.serverCats, 'the phone has the same categories, and its pulls go through'); +}); + +test('the same server at another address is the same account; one that cannot tell is asked', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('otherAddress'); + assert.equal(r.sameServer, true); + assert.equal(r.askedSame, false, 'the server named itself: nothing to ask'); + assert.deepEqual(r.keptSame, ['Kept Soup']); + assert.equal(r.saidYes, true); + assert.equal(r.keptYes, 1, '"Same Server" keeps the data'); + assert.equal(r.saidNo, true); + assert.equal(r.keptNo, 0, '"Different Server" treats it as another account\'s'); + assert.equal(r.questions, 2); +}); + +test('the first sync tells the pages it brought data down, so the list shows it at once', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('firstSyncTellsPages'); + assert.equal(r.told.length, 2); + assert.ok(r.told[0].ok && r.told[0].changed > 0, JSON.stringify(r.told)); + assert.equal(r.told[1].changed, 0, 'nothing new, nothing to read again'); +}); + +test("settings are kept per account and server: another account never reads the last one's", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('settingsPerAccount'); + assert.equal(r.bobReads, null); + assert.equal(r.aliceReads, 'alice-key'); + assert.equal(r.keys.length, 1); + assert.match(r.keys[0], /^wl_u\d+@127\.0\.0\.1-\d+_aiApiKey$/); +}); + +test("the phone's cookie jar never speaks for the previous account", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('cookieJar'); + assert.equal(typeof r.jarAfterLogin[r.host].ct_token, 'string', 'the sign-in cookie is in the jar, as on Android'); + assert.equal(typeof r.jarAfterLogin['app.cooktrace.local'].ct_token, 'string', "and its copy at the app's own address"); + assert.equal(r.serverSays, 'bob', "Bob's session, with Alice's cookie still sent alongside, is Bob"); + assert.equal(r.yes, true); + assert.deepEqual(r.jarAfterSwitch, { [r.host]: { authelia_session: 'gate' }, 'app.cooktrace.local': {}, 'gate.example.com': { CF_Authorization: 'gate' } }, + "only CookTrace's cookies go, at the server and at the app's own address; a gate's stay"); + assert.deepEqual(r.bobPhone, ['Bob Only']); +}); + +test("our bearer token decides the session, never another account's cookie; anyone else's bearer leaves the web on its cookie", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + try { + const jwt = createRequire(new URL('../server/package.json', import.meta.url))('jsonwebtoken'); + const secret = 'android-sync-test-secret-0123456789abcdef'; + const expired = jwt.sign({ id: 2, username: 'bob', role: 'user', csrf: 'x', exp: Math.floor(Date.now() / 1000) - 60 }, secret); + const idp = jwt.sign({ sub: 'someone', email: 'a@example.com' }, 'the-identity-providers-own-key'); + const cookie = `ct_token=${srv.token}`; + const me = async headers => (await (await fetch(srv.base + '/api/auth/me', { headers })).json()).user?.username ?? null; + assert.equal(await me({ Authorization: `Bearer ${srv.tokenB}`, Cookie: cookie }), 'bob', "our bearer's account, not the cookie's"); + assert.equal(await me({ Authorization: `Bearer ${expired}`, Cookie: cookie }), null, 'our expired bearer is no session, never the cookie'); + assert.equal(await me({ Authorization: `Bearer ${idp}`, Cookie: cookie }), 'cook', "a proxy's or identity provider's bearer: the web's cookie"); + assert.equal(await me({ Authorization: 'Bearer opaque-proxy-token', Cookie: cookie }), 'cook'); + assert.equal(await me({ Cookie: cookie }), 'cook', 'the web signs in with its cookie alone'); + assert.equal(await me({ Authorization: `Bearer ${idp}` }), null); + // Changes: the cookie session needs its CSRF token, whatever bearer comes along. + const csrf = (await (await fetch(srv.base + '/api/auth/me', { headers: { Cookie: cookie } })).json()).csrf; + const post = headers => fetch(srv.base + '/api/shopping', { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify({ name: 'Milk' }) }).then(r => r.status); + assert.equal(await post({ Authorization: `Bearer ${idp}`, Cookie: cookie }), 403, "someone else's bearer doesn't skip the cookie's CSRF check"); + assert.equal(await post({ Authorization: 'Bearer opaque-proxy-token', Cookie: cookie }), 403); + assert.equal(await post({ Authorization: `Bearer ${idp}`, Cookie: cookie, 'X-CSRF-Token': csrf }), 201); + assert.equal(await post({ Cookie: cookie }), 403, 'the web without its CSRF token, as before'); + assert.equal(await post({ Cookie: cookie, 'X-CSRF-Token': csrf }), 201, 'the web with it'); + assert.equal(await post({ Authorization: `Bearer ${srv.tokenB}`, Cookie: cookie }), 201, 'our bearer needs none'); + // The API signs in by its own API token: never stopped for CSRF, as before. + const api = await fetch(srv.base + '/api/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ct_not_a_real_token', Cookie: cookie }, body: '{}' }); + assert.notEqual(api.status, 403, 'the API answers by its own token, not the CSRF check'); + } finally { srv.stop(); } +}); + +test('an account cached before a Disconnect is never taken for the one now signed in', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('cachedAccount'); + assert.notEqual(r.atOnce, 'cook', "Alice's cached account isn't checked against the phone's copy"); + assert.equal(r.afterServer, 'bob'); +}); + +// ── Review round: restored copies, links, waiting settings, names ─────── +test('a phone restored from another phone\'s backup gets its own install id: rows with the same local id stay two rows', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + const dir = mkdtempSync(join(tmpdir(), 'ct-clone-')); + try { + const A = { PHONE_DB: join(dir, 'a'), PHONE_MARKER: join(dir, 'a.marker') }; + const B = { PHONE_DB: join(dir, 'b'), PHONE_MARKER: join(dir, 'b.marker') }; + const a1 = await phone(srv, 'cloneMake', { ...A, RECIPE_NAME: 'Before Backup' }); + // Phone B: phone A's database, put back from a backup (the marker isn't in it). + copyFileSync(join(dir, 'a-cooktrace_local.db'), join(dir, 'b-cooktrace_local.db')); + const a2 = await phone(srv, 'cloneMake', { ...A, RECIPE_NAME: 'Phone A Soup' }); + const b1 = await phone(srv, 'cloneMake', { ...B, RECIPE_NAME: 'Phone B Stew' }); + assert.equal(b1.localId, a2.localId, 'both made a row with the same local id'); + const db = new Database(srv.dbPath, { readonly: true }); + const names = db.prepare(`SELECT name FROM recipes WHERE name IN ('Phone A Soup', 'Phone B Stew') ORDER BY name`).all().map(r => r.name); + db.close(); + assert.deepEqual(names, ['Phone A Soup', 'Phone B Stew'], 'both rows on the server, neither merged into the other'); + assert.ok(a2.serverId && b1.serverId && a2.serverId !== b1.serverId, 'each phone has its own'); + assert.equal(a2.install, a1.install, 'the same phone keeps its id across runs'); + assert.notEqual(b1.install, a1.install, 'the restored copy takes a new one'); + } finally { srv.stop(); rmSync(dir, { recursive: true, force: true }); } +}); + +test('Upload sends every link as the server\'s id, and the phone keeps its links after the first pull', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('uploadLinks'); + assert.deepEqual(r.errors, []); + assert.deepEqual(r.server, { variantParentIsMilk: true, sourceIsOat: true, shoppingIsOat: true, ingredientIsOat: true }); + assert.deepEqual(r.phone, { variantParentIsMilk: true, sourceIsOat: true, shoppingIsOat: true, counts: { milk: 1, oat: 1 } }); +}); + +test("another account's pantry item, recipe or category is never linked, nor read through a link", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + try { + const call = async (tok, method, path, body) => { + const r = await fetch(srv.base + path, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, body: body ? JSON.stringify(body) : undefined }); + const t = await r.text(); + return { status: r.status, body: t ? JSON.parse(t) : null }; + }; + const A = srv.token, B = srv.tokenB; + const cat = (await call(A, 'POST', '/api/pantry/categories', { name: 'Alice Aisle Cat', default_aisle: 'Alice Secret Aisle' })).body; + const item = (await call(A, 'POST', '/api/pantry', { name: 'Alice Truffle', category_id: cat.id })).body; + const rcat = (await call(A, 'POST', '/api/recipes/categories', { name: 'Alice Only' })).body; + const recipe = (await call(A, 'POST', '/api/recipes', { name: 'Alice Private', ingredients: [], steps: [] })).body; + const shop = (await call(B, 'POST', '/api/shopping', { name: 'Truffle', pantry_id: item.id, recipe_id: recipe.id })).body; + assert.equal(shop.pantry_id, null, "another account's pantry item is no link"); + assert.equal(shop.recipe_id, null, "nor another account's recipe"); + assert.notEqual(shop.aisle, 'Alice Secret Aisle', "and its aisle isn't read"); + const own = (await call(B, 'POST', '/api/pantry', { name: 'Bob Item' })).body; + const put = await call(B, 'PUT', `/api/shopping/${shop.id}`, { pantry_id: item.id }); + assert.equal(put.body.pantry_id, null); + const variant = (await call(B, 'POST', '/api/pantry', { name: 'Bob Variant', generic_parent_id: item.id, nutrition_source_variant_id: item.id, category_id: cat.id })).body; + assert.deepEqual([variant.generic_parent_id, variant.nutrition_source_variant_id, variant.category_id], [null, null, null]); + assert.equal((await call(B, 'PUT', `/api/pantry/${own.id}`, { generic_parent_id: item.id })).status, 400, "another account's item can't be a parent"); + assert.equal((await call(B, 'POST', '/api/cook-diary', { recipe_id: recipe.id, date: '2026-10-07', kind: 'cooked' })).status, 404, "nor a recipe to log a cook of"); + const bobRecipe = (await call(B, 'POST', '/api/recipes', { name: 'Bob Soup', category_id: rcat.id, ingredients: [], steps: [] })).body; + assert.equal(bobRecipe.category_id ?? null, null, "nor a recipe category"); + // A link stored before this check (an older upload) reads nothing of Alice's. + const db = new Database(srv.dbPath); + db.prepare(`UPDATE shopping_list SET pantry_id = ?, recipe_id = ? WHERE id = ?`).run(item.id, recipe.id, shop.id); + db.close(); + const list = (await call(B, 'GET', '/api/shopping')).body; + const row = (Array.isArray(list) ? list : list.items).find(x => x.id === shop.id); + assert.equal(row.pantry_name ?? null, null, "the list doesn't show another account's pantry item"); + assert.equal(row.recipe_name ?? null, null, "or recipe"); + // Its own links still work. + const ok = (await call(B, 'POST', '/api/shopping', { name: 'Bob Item', pantry_id: own.id })).body; + assert.equal(ok.pantry_id, own.id); + } finally { srv.stop(); } +}); + +test('a setting changed here and not yet sent counts as a change waiting when another account signs in', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('settingWaits'); + assert.deepEqual(r, { ok: false, asked: 1, ownOk: true, askedAgain: null }, "the last account's setting counts; the new account's own start-up settings don't"); +}); + +test('a category edited here whose name another device moved to another category: pulls go through, the edit goes up', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('categoryNameMoved'); + assert.deepEqual(r.syncs, ['ok', 'ok']); + assert.deepEqual(r.server, [{ slug: 'soups', color: null }, { slug: 'stews', color: '#ff0000' }], 'the edit went up to the renamed category'); + assert.deepEqual(r.phone, [{ slug: 'soups', color: null, sync_status: 'synced' }, { slug: 'stews', color: '#ff0000', sync_status: 'synced' }]); +}); + +test('switching accounts while pictures download does not wait for them', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('switchDuringImages'); + assert.equal(r.yes, true); + assert.ok(r.switchMs < 3000, `the switch took ${r.switchMs} ms`); + assert.equal(r.run, 'stopped', JSON.stringify(r.sent)); +}); + +test('started offline with a cached account the session is not for: the sign-in screen', { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('cachedAccountOffline'); + assert.deepEqual(r, { user: null, userMgmt: true }); +}); + +test("the pictures of the account's recipes are found for keeping offline", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('imagesFound'); + assert.ok(r.total >= 1, `found ${r.total}`); +}); + +test("an ingredient's pantry link is the server's id on the server and the phone's own on the phone", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const r = await run('ingredientLinks'); + assert.equal(r.idsDiffer, true, "the item's id differs on each side"); + assert.deepEqual(r.made.server, r.made.want, 'a recipe made on the phone links the right items on the server, a new item included'); + assert.equal(r.edited.server, r.edited.want, 'an offline edit of the link too'); + assert.deepEqual(r.phoneAfter.got, r.phoneAfter.want, 'the phone keeps its own ids after the sync'); + assert.deepEqual(r.fromWeb.got, r.fromWeb.want, "a recipe from the web links the phone's own item"); +}); + +test("a recipe's ingredient can't link another account's pantry item", { skip, timeout: TEST_TIMEOUT_MS }, async () => { + const srv = await startServer(); + try { + const call = async (tok, method, path, body) => { + const r = await fetch(srv.base + path, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, body: body ? JSON.stringify(body) : undefined }); + return r.json(); + }; + const theirs = await call(srv.token, 'POST', '/api/pantry', { name: 'Alice Saffron' }); + const mine = await call(srv.tokenB, 'POST', '/api/pantry', { name: 'Bob Rice' }); + const link = r => r.ingredients[0].items.map(i => i.pantry_item_id ?? null); + const made = await call(srv.tokenB, 'POST', '/api/recipes', { name: 'Paella', ingredients: [{ items: [{ name: 'saffron', pantry_item_id: theirs.id }, { name: 'rice', pantry_item_id: mine.id }] }], steps: ['Cook'] }); + assert.deepEqual(link(made), [null, mine.id], "another account's item is no link; your own stays"); + const edited = await call(srv.tokenB, 'PUT', `/api/recipes/${made.id}`, { ...made, ingredients: [{ items: [{ name: 'saffron', pantry_item_id: theirs.id }] }] }); + assert.deepEqual(link(edited), [null]); + const pushed = await call(srv.tokenB, 'POST', '/api/sync/push', { fk_ids: 'server', client_now: new Date().toISOString(), tables: { recipes: [{ client_id: 1, server_id: null, name: 'Pushed', ingredients: JSON.stringify([{ items: [{ name: 'saffron', pantry_item_id: theirs.id }] }]), steps: '[]', tags: '[]', tools: '[]', nutrition: '{}', visibility: 'private', cook_count: 0, favorite: 0, servings: 2, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19), edit_clock: 'server' }] } }); + const got = await call(srv.tokenB, 'GET', `/api/recipes/${pushed.tables.recipes[0].server_id}`); + assert.deepEqual(link(got), [null], 'nor through a sync'); + } finally { srv.stop(); } +}); diff --git a/scripts/android-sync/capacitor.mjs b/scripts/android-sync/capacitor.mjs new file mode 100644 index 00000000..d808263c --- /dev/null +++ b/scripts/android-sync/capacitor.mjs @@ -0,0 +1,42 @@ +// The Capacitor plugins the sync code touches. +export const Filesystem = { readdir: async () => ({ files: [] }), stat: async () => { throw new Error('none'); }, readFile: async () => { throw new Error('none'); }, writeFile: async () => ({}), mkdir: async () => ({}), deleteFile: async () => ({}) }; +export const Directory = { Data: 'DATA', Cache: 'CACHE', Documents: 'DOCUMENTS' }; +export const Capacitor = { isNativePlatform: () => true, convertFileSrc: x => x, getPlatform: () => 'android', isPluginAvailable: () => false }; +export const Network = { getStatus: async () => ({ connected: true, connectionType: 'wifi' }), addListener: async () => ({ remove() {} }) }; +import { readFileSync, writeFileSync } from 'node:fs'; +// InstallMarkerPlugin: kept in PHONE_MARKER, a file a backup never copies +// (a copied phone starts without one); in memory when none is given. +let _marker = null; +const InstallMarker = { + async get() { if (process.env.PHONE_MARKER) { try { return { value: readFileSync(process.env.PHONE_MARKER, 'utf8') || null }; } catch { return { value: null }; } } return { value: _marker }; }, + async set({ value }) { if (process.env.PHONE_MARKER) writeFileSync(process.env.PHONE_MARKER, String(value)); else _marker = value; }, +}; +export const registerPlugin = name => (name === 'InstallMarker' ? InstallMarker : new Proxy({}, { get: () => async () => ({}) })); +export const App = { addListener: async () => ({ remove() {} }) }; +export const Preferences = { get: async () => ({ value: null }), set: async () => {}, remove: async () => {} }; +// CapacitorHttp (lib/migrate.js uploads with it): the same request, by fetch. +export const CapacitorHttp = { + async request({ method = 'GET', url, headers = {}, data }) { + const r = await fetch(url, { method, headers, body: method === 'GET' ? undefined : JSON.stringify(data ?? {}) }); + const t = await r.text(); + let body = t; try { body = JSON.parse(t); } catch { /* text */ } + return { status: r.status, data: body }; + }, + get(o) { return this.request({ ...o, method: 'GET' }); }, + post(o) { return this.request({ ...o, method: 'POST' }); }, +}; +// The phone's cookie jar (phone.mjs keeps it: every request sends it, as +// CapacitorHttp does on Android, and every answer's cookies go in it). +// Kept per host ({ host: Map(name -> value) }), as Android keeps them. +globalThis.__cookieJar ??= new Map(); +const _host = url => { try { return new URL(url).host; } catch { return ''; } }; +const _at = host => { const j = globalThis.__cookieJar; if (!j.has(host)) j.set(host, new Map()); return j.get(host); }; +export const CapacitorCookies = { + clearAllCookies: async () => { globalThis.__cookieJar.clear(); }, + getCookies: async ({ url } = {}) => Object.fromEntries(globalThis.__cookieJar.get(_host(url)) || []), + setCookie: async ({ url, key, value }) => { _at(_host(url)).set(key, value); }, + deleteCookie: async ({ url, key }) => { globalThis.__cookieJar.get(_host(url))?.delete(key); }, + // Every host's cookies (a test's view of the whole jar). + _all: async () => Object.fromEntries([...globalThis.__cookieJar].map(([h, m]) => [h, Object.fromEntries(m)])), +}; +export default {}; diff --git a/scripts/android-sync/clock.mjs b/scripts/android-sync/clock.mjs new file mode 100644 index 00000000..a8a1a483 --- /dev/null +++ b/scripts/android-sync/clock.mjs @@ -0,0 +1,15 @@ +// A phone whose clock is off: PHONE_SKEW_MS moves JavaScript's clock here +// and SQLite's datetime('now') in sqlite.mjs. The server keeps its own. A +// scenario can set the clock right later (setSkew), as a phone does when +// it fixes its time. +export const SKEW = Number(process.env.PHONE_SKEW_MS || 0); +let skew = SKEW; +export function setSkew(ms) { skew = ms; } +if (SKEW) { + const RealDate = Date; + class SkewDate extends RealDate { + constructor(...a) { if (a.length === 0) super(RealDate.now() + skew); else super(...a); } + static now() { return RealDate.now() + skew; } + } + globalThis.Date = SkewDate; +} diff --git a/scripts/android-sync/hooks.mjs b/scripts/android-sync/hooks.mjs new file mode 100644 index 00000000..611f22c4 --- /dev/null +++ b/scripts/android-sync/hooks.mjs @@ -0,0 +1,19 @@ +// Module hooks for register.mjs: the SQLite plugin, platform.js and the +// other Capacitor plugins resolve to the shims here. +const here = new URL('./', import.meta.url); +const src = new URL('../../src/', import.meta.url).href; +export async function resolve(spec, ctx, next) { + if (spec === '@capacitor-community/sqlite') return { url: new URL('sqlite.mjs', here).href, shortCircuit: true }; + if (/(^|\/)platform\.js$/.test(spec) && ctx.parentURL?.startsWith(src)) return { url: new URL('platform.mjs', here).href, shortCircuit: true }; + if (spec.startsWith('@capacitor/')) return { url: new URL('capacitor.mjs', here).href, shortCircuit: true }; + return next(spec, ctx); +} +// Vite's import.meta.env, as a production build has it. +export async function load(url, ctx, next) { + const r = await next(url, ctx); + if (url.startsWith(src) && url.endsWith('.js') && r.source) { + const text = String(r.source); + if (text.includes('import.meta.env')) return { ...r, source: text.replace(/import\.meta\.env/g, "({ DEV: false, PROD: true, MODE: 'production' })") }; + } + return r; +} diff --git a/scripts/android-sync/phone.mjs b/scripts/android-sync/phone.mjs new file mode 100644 index 00000000..41a3ca4d --- /dev/null +++ b/scripts/android-sync/phone.mjs @@ -0,0 +1,727 @@ +// One phone, running the app's own sync code (src/lib/sync.js, +// db-native.js, api-native.js) against a real CookTrace server that +// android-sync.test.js starts, through one scenario, while "the web" edits +// the same recipes through the REST API as the web app does. Prints its +// findings as JSON on the last line, after @@. +// +// CT_SERVER=http://127.0.0.1: CT_TOKEN= [PHONE_SKEW_MS=] [PHONE_DELAY_MS=] +// node --import ./scripts/android-sync/register.mjs scripts/android-sync/phone.mjs + +const realFetch = globalThis.fetch; +const server = process.env.CT_SERVER; +const token = process.env.CT_TOKEN; +const skew = Number(process.env.PHONE_SKEW_MS || 0); +const delayMax = Number(process.env.PHONE_DELAY_MS || 0); +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// The phone's network: as slow as asked (a different delay each time), +// able to fail one request on purpose, to hold requests (holdFor: ms, by +// url), or to lose one answer after the server has the request +// (dropAnswer). Every request is noted, with the session it carried. +let failNext = null; +let holdFor = () => 0; +let dropAnswer = null; +const sent = []; +const authOf = init => { const h = init?.headers || {}; return String(h.Authorization || h.authorization || '').replace(/^Bearer /, ''); }; +globalThis.fetch = async (url, init) => { + if (failNext && failNext(String(url), init)) { failNext = null; throw new TypeError('Failed to fetch'); } + if (delayMax) await sleep(Math.floor(Math.random() * delayMax)); + // The cookie jar (per host) goes with every request, as on Android. + const jarAll = globalThis.__cookieJar ??= new Map(); + let reqHost = ''; try { reqHost = new URL(String(url)).host; } catch { /* relative */ } + const jar = jarAll.get(reqHost) || new Map(); + if (jar.size) { + init = { ...(init || {}), headers: { ...(init?.headers || {}), Cookie: [...jar].map(([k, v]) => `${k}=${v}`).join('; ') } }; + } + const entry = { url: String(url).replace(server, ''), method: init?.method || 'GET', token: authOf(init), cookie: !!jar.size, at: Date.now() }; + sent.push(entry); + const hold = holdFor(String(url), init); + // Held as a slow server would: an abort ends the wait at once. + if (hold) entry.held = true; + if (hold) await new Promise(r => { const t = setTimeout(r, hold); init?.signal?.addEventListener?.('abort', () => { clearTimeout(t); r(); }); }); + if (init?.signal?.aborted) { entry.aborted = true; throw new DOMException('aborted', 'AbortError'); } + const res = await realFetch(url, init); + // Capacitor keeps what a native request receives at the request's host + // and again at the app's own address (CapacitorCookieManager.put). + for (const c of res.headers.getSetCookie?.() || []) { + const [kv] = c.split(';'); const i = kv.indexOf('='); + const k = kv.slice(0, i).trim(), v = kv.slice(i + 1).trim(); + for (const h of [reqHost, new URL(globalThis.location.origin).host]) { + if (!jarAll.has(h)) jarAll.set(h, new Map()); + if (!v || /max-age=0|expires=thu, 01 jan 1970/i.test(c)) jarAll.get(h).delete(k); else jarAll.get(h).set(k, v); + } + } + if (dropAnswer && dropAnswer(String(url), init)) { dropAnswer = null; await res.text(); throw new TypeError('Failed to fetch'); } + return res; +}; +globalThis.window = globalThis; +// The app's own address (Capacitor's local hostname). +globalThis.location ??= { origin: 'https://app.cooktrace.local', href: 'https://app.cooktrace.local/', hash: '' }; +globalThis.addEventListener ??= () => {}; +globalThis.removeEventListener ??= () => {}; +// What the app tells the pages (ct:sync-complete), noted. +const events = []; +globalThis.CustomEvent ??= class { constructor(type, o) { this.type = type; this.detail = o?.detail; } }; +globalThis.dispatchEvent = e => { events.push({ type: e?.type, detail: e?.detail }); return true; }; +globalThis.document ??= { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible' }; +const store = new Map(); +Object.defineProperty(globalThis, 'localStorage', { value: { getItem: k => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), key: i => [...store.keys()][i] ?? null, get length() { return store.size; } }, configurable: true }); + +// The web: plain requests, on the server's clock. +async function web(method, path, body, tok = token) { + const r = await realFetch(server + path, { + method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, + body: body ? JSON.stringify(body) : undefined, + }); + const t = await r.text(); + if (!r.ok) { const e = new Error(`${method} ${path} ${r.status} ${t.slice(0, 160)}`); e.status = r.status; throw e; } + return t ? JSON.parse(t) : null; +} +// A save as the web app makes it: with the copy the page had. +async function webSave(id, change) { + const r = await web('GET', `/api/recipes/${id}`); + const now = new Date(Date.now() - skew).toISOString(); + return web('PUT', `/api/recipes/${id}`, { ...r, ...change, _sync: { base_synced_at: r.synced_at, changed: Object.keys(change), edited_at: now, client_now: now } }); +} + +const dbn = await import('../../src/lib/db-native.js'); +const { dbInit, getDb } = dbn; +await dbInit(); +const { fullSync } = await import('../../src/lib/sync.js'); +const { CtApiNative: api } = await import('../../src/lib/api-native.js'); +const db = await getDb(); +const sync = async () => { const r = await fullSync({ silent: true }, true); if (!r.ok) throw new Error('sync ' + JSON.stringify(r)); return r; }; +const local = async sid => (await db.query(`SELECT * FROM recipes WHERE server_id = ?`, [sid])).values[0]; +const localRecipe = async sid => api.getRecipe((await local(sid)).id); +const phoneEdit = async (sid, tag) => { const x = await localRecipe(sid); await api.updateRecipe(x.id, { ...x, name: `Soup (${tag})`, notes: `${tag} notes` }); }; +const webEdit = (sid, tag) => webSave(sid, { name: `Soup (${tag})`, notes: `${tag} notes` }); +const versions = async sid => (await web('GET', `/api/recipes/${sid}/versions`).catch(() => [])).map(v => v.data.name); +const newRecipe = async (extra = {}) => (await web('POST', '/api/recipes', { name: 'Soup', ingredients: [{ items: [{ name: 'water' }] }], steps: ['Boil'], notes: 'original', ...extra })).id; +const both = async sid => { + const s = await web('GET', `/api/recipes/${sid}`).catch(e => ({ gone: e.status })); + const p = await local(sid); + return { + server: s.gone ? `gone ${s.gone}` : { name: s.name, rating: s.rating, favorite: !!s.favorite, visibility: s.visibility, cook_count: s.cook_count, servings: s.servings }, + phone: p ? { name: p.name, rating: p.rating, favorite: !!p.favorite, cook_count: p.cook_count, deleted: p.deleted_at != null, pending: p.sync_status === 'pending' } : null, + }; +}; +const out = {}; +// Who the server says a session is. +const me = async tok => (await web('GET', '/api/auth/me', null, tok)).user; +// Signing in or out on the phone: the session the app sends from now on. +const signIn = tok => { if (tok == null) delete process.env.CT_TOKEN; else process.env.CT_TOKEN = tok; }; +// Rows of the account's tables still on the phone. +const ACCOUNT_TABLES = ['recipes', 'pantry_items', 'cook_diary', 'shopping_list', 'recipe_categories', 'pantry_categories', 'custom_units', 'cookbooks', 'recipe_comments', 'ai_chat_history', 'recipe_cookbook_links', 'user_settings', 'sync_deletes']; +const rowsHere = async () => { + let n = 0; + for (const t of ACCOUNT_TABLES) n += (await db.query(`SELECT COUNT(*) AS n FROM ${t}`)).values[0].n; + return n; +}; + +const scenarios = { + // An edit made offline on the phone, older than one made on the web since. + async olderPhone() { + const id = await newRecipe(); await sync(); + await phoneEdit(id, 'phone'); await sleep(3100); await webEdit(id, 'web'); + await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + async newerPhone() { + const id = await newRecipe(); await sync(); + await webEdit(id, 'web'); await sleep(3100); await phoneEdit(id, 'phone'); + await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + // Only the phone edits, over several syncs, then edits the web's copy. + async noConflict() { + const id = await newRecipe(); await sync(); + for (const n of [1, 2, 3]) { await phoneEdit(id, `phone${n}`); await sync(); await sleep(1100); } + await webEdit(id, 'web'); await sync(); await sleep(1100); + await phoneEdit(id, 'phone4'); await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + // The web changes the content; the phone then rates, favorites and logs a + // cook (no servings set and no picture: values a save used to rewrite). + async metaOnly() { + const id = await newRecipe({ servings: null }); await sync(); + await webEdit(id, 'web'); await sleep(1100); + let x = await localRecipe(id); await api.updateRecipe(x.id, { ...x, rating: 4 }); + x = await localRecipe(id); await api.updateRecipe(x.id, { ...x, favorite: true }); + x = await localRecipe(id); await api.markCooked(x.id, { date: '2026-10-06' }); + await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + // Rating, favorite and visibility set on the web, two cooks logged there; + // a cook logged on the phone later doesn't undo any of it. + async cookAfterWeb() { + const id = await newRecipe(); await sync(); + await webSave(id, { rating: 5, favorite: true, visibility: 'group' }); + await web('POST', `/api/recipes/${id}/cooked`, { date: '2026-10-04' }); + await web('POST', `/api/recipes/${id}/cooked`, { date: '2026-10-05' }); + await sleep(1100); + const x = await localRecipe(id); await api.markCooked(x.id, { date: '2026-10-06' }); + await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + // The phone edits offline; the web deletes the recipe later. + async webDeletesLater() { + const id = await newRecipe(); await sync(); + await phoneEdit(id, 'phone'); await sleep(3100); + await web('DELETE', `/api/recipes/${id}`); + await sync(); + Object.assign(out, await both(id), { id }); + }, + // The web deletes the recipe; the phone edits it later, offline. + async phoneEditsAfterDelete() { + const id = await newRecipe(); await sync(); + await web('DELETE', `/api/recipes/${id}`); await sleep(3100); + await phoneEdit(id, 'phone'); + await sync(); + Object.assign(out, await both(id)); + }, + // A category changed twice on the phone, the second time before the + // first push comes back in a pull (with whatever clock and delay). + async echoCategory() { + const id = await newRecipe(); + const cats = {}; + for (const n of ['Alpha', 'Beta']) cats[n] = (await web('POST', '/api/recipes/categories', { name: n })).id; + await sync(); + const localCat = async n => (await db.query(`SELECT id FROM recipe_categories WHERE server_id = ?`, [cats[n]])).values[0].id; + const setCat = async n => { const x = await localRecipe(id); await api.updateRecipe(x.id, { ...x, category_id: await localCat(n) }); }; + await setCat('Alpha'); await sync(); + await sleep(1100); await setCat('Beta'); + await sync(); await sync(); + const s = await web('GET', `/api/recipes/${id}`); + const p = await local(id); + out.server = s.category?.name ?? null; + out.phone = (await db.query(`SELECT name FROM recipe_categories WHERE id = ?`, [p.category_id])).values[0]?.name ?? null; + }, + // An item checked off on the phone while the web renamed it: both stay. + async shoppingFields() { + const it = await web('POST', '/api/shopping', { name: 'Milk' }); await sync(); + const localItem = async () => (await db.query(`SELECT * FROM shopping_list WHERE server_id = ?`, [it.id])).values[0]; + await api.toggleShoppingChecked((await localItem()).id, true); + await sleep(1100); + const cur = await web('GET', '/api/shopping').then(l => (Array.isArray(l) ? l : l.items || []).find(x => x.id === it.id)); + await web('PUT', `/api/shopping/${it.id}`, { ...cur, name: 'Oat Milk' }); + await sync(); + const s = await web('GET', '/api/shopping').then(l => (Array.isArray(l) ? l : l.items || []).find(x => x.id === it.id)); + const p = await localItem(); + out.server = { name: s.name, checked: !!s.checked }; + out.phone = { name: p.name, checked: !!p.checked }; + }, + // The phone's clock is 10 minutes slow when it edits, offline, a recipe + // the web edited a few seconds before; it is set right before the sync. + // Its edit is the newer one. + async clockFixedBeforePush() { + const id = await newRecipe(); await sync(); + const x0 = await localRecipe(id); await api.updateRecipe(x0.id, { ...x0, rating: 1 }); await sync(); // the server says the offset + await webEdit(id, 'web'); await sleep(3100); + await phoneEdit(id, 'phone'); + (await import('./clock.mjs')).setSkew(0); + await sync(); + Object.assign(out, await both(id), { versions: await versions(id) }); + }, + // The server keeps its own copy, and the pull that would bring it down + // fails: the phone mustn't take that copy as its own until it has it. + async repullFails() { + const id = await newRecipe(); await sync(); + const before = (await local(id)).server_synced_at; + await phoneEdit(id, 'phone'); await sleep(3100); await webEdit(id, 'web'); + let pulls = 0; + failNext = url => url.includes('/api/sync/pull') && ++pulls === 2; + const failed = await fullSync({ silent: true }, true); + out.firstSync = failed.ok ? 'ok' : 'failed'; + const mid = await local(id); + out.stampKept = mid.server_synced_at === before; + out.phoneAfterFail = mid.name; + await sync(); + const end = await local(id); + out.phoneAfter = end.name; + out.stampMoved = end.server_synced_at !== before; + out.server = (await web('GET', `/api/recipes/${id}`)).name; + }, + + // ── Accounts on one phone (lib/local-account.js) ────────────────────── + // Alice (CT_TOKEN) leaves a change that never went up; Bob (CT_TOKEN_B) + // signs in on the same phone. + async accountSwitch() { + const la = await import('../../src/lib/local-account.js'); + const B_TOKEN = process.env.CT_TOKEN_B; + const A = await me(token), B = await me(B_TOKEN); + await web('POST', '/api/recipes', { name: 'Bob Web Soup', ingredients: [], steps: [] }, B_TOKEN); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + await api.createRecipe({ name: 'Alice Waiting', ingredients: [], steps: [] }); + // Bob signs in. Before the app has checked, a sync is refused. + signIn(B_TOKEN); + out.early = (await fullSync({ silent: true }, true)).reason; + // He says no: nothing changes, and Alice's change goes up as hers. + let asked = null; + out.no = await la.prepareLocalAccount(B, { confirm: async n => { asked = n; return false; } }); + out.asked = asked; + signIn(token); + await la.prepareLocalAccount(A, { confirm: async () => { throw new Error('asked Alice'); } }); + await sync(); + // Alice leaves another; Bob says yes this time. + await api.createRecipe({ name: 'Alice Discarded', ingredients: [], steps: [] }); + signIn(B_TOKEN); + out.yes = await la.prepareLocalAccount(B, { confirm: async () => true }); + out.leftAfterClear = await rowsHere(); + await sync(); + out.bobPhone = (await api.getRecipes()).map(r => r.name).sort(); + out.bobTokenSentAliceRows = sent.filter(x => x.token === B_TOKEN && x.url.startsWith('/api/sync/push')).length; + // Bob signs out with a change waiting and back in: nothing asked or lost. + await api.createRecipe({ name: 'Bob Waiting', ingredients: [], steps: [] }); + signIn(null); signIn(B_TOKEN); + let askedBob = false; + out.same = await la.prepareLocalAccount(B, { confirm: async () => { askedBob = true; return true; } }); + out.askedBob = askedBob; + await sync(); + }, + + // A sync is running for Alice (its download held up) when Bob signs in + // and says yes: the copy changes hands only once the sync has stopped, + // and nothing of Alice's lands after the clear. + async switchDuringSync() { + const la = await import('../../src/lib/local-account.js'); + const B_TOKEN = process.env.CT_TOKEN_B; + const A = await me(token), B = await me(B_TOKEN); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + await web('POST', '/api/recipes', { name: 'Alice Late Soup', ingredients: [], steps: [] }); + holdFor = url => (url.includes('/api/sync/pull') ? 2500 : 0); + const running = fullSync({ silent: true }, true); + await sleep(300); + signIn(B_TOKEN); + out.yes = await la.prepareLocalAccount(B, { confirm: async () => true }); + out.run = (await running).reason || 'ok'; + holdFor = () => 0; + await sleep(3000); + out.leftAfterClear = await rowsHere(); + await sync(); + out.bobPhone = (await api.getRecipes()).map(r => r.name); + }, + + // Signing out pushes what's waiting with the session signing out, for a + // few seconds at most; a push held longer is cut off, and nothing goes + // up afterwards under the next account's session. + async signOutMidPush() { + const la = await import('../../src/lib/local-account.js'); + const { pushBeforeSignOut } = await import('../../src/lib/sync.js'); + const B_TOKEN = process.env.CT_TOKEN_B; + const A = await me(token); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + await api.createRecipe({ name: 'Alice Slow', ingredients: [], steps: [] }); + holdFor = url => (url.includes('/api/sync/push') ? 6000 : 0); + const t0 = Date.now(); + await pushBeforeSignOut(); + out.took = Date.now() - t0; + la.bumpAccountGeneration(); + signIn(B_TOKEN); + // Bob's app starts syncing before the account check has run: refused. + out.bobSync = (await fullSync({ silent: true }, true)).reason; + await sleep(7000); + holdFor = () => 0; + out.aliceSoupOnServer = (await web('GET', '/api/recipes')).filter(r => r.name === 'Alice Slow').length; + out.pushes = sent.filter(x => x.url.startsWith('/api/sync/push')).map(x => (x.token === token ? 'alice' : x.token === B_TOKEN ? 'bob' : 'other') + (x.aborted ? ' (cut off)' : '')); + }, + + // Every trigger at once (app start, coming back, the timer, Sync Now): + // one sync runs, the new recipe is made once, and an edit made while it + // runs goes up right after it. + async syncTriggersAtOnce() { + await sync(); + await api.createRecipe({ name: 'Kill Test Recipe', ingredients: [], steps: [] }); + holdFor = url => (url.includes('/api/sync/push') ? 800 : 0); + const runs = [fullSync(true), fullSync(true), fullSync({ silent: true }, true), fullSync(false, true, true)]; + await sleep(200); + const x = (await api.getRecipes()).find(r => r.name === 'Kill Test Recipe'); + await api.updateRecipe(x.id, { ...(await api.getRecipe(x.id)), notes: 'edited while syncing' }); + await Promise.all(runs); + holdFor = () => 0; + await sleep(2500); + out.pushes = sent.filter(x => x.url.startsWith('/api/sync/push')).length; + out.overlapping = sent.filter(x => x.url.startsWith('/api/sync/pu')).some((x, i, a) => i > 0 && x.at - a[i - 1].at < 50 && x.url === a[i - 1].url); + out.server = (await web('GET', '/api/recipes')).filter(r => r.name === 'Kill Test Recipe').map(r => r.notes); + out.phone = (await api.getRecipes()).filter(r => r.name === 'Kill Test Recipe').length; + }, + + // The answer to a push is lost after the server made the new recipe: + // the next sync sends it again, and it is still one recipe. + async lostAnswer() { + await sync(); + await api.createRecipe({ name: 'Lost Answer Soup', ingredients: [], steps: [] }); + dropAnswer = url => url.includes('/api/sync/push'); + out.first = (await fullSync({ silent: true }, true)).ok; + await sync(); + out.server = (await web('GET', '/api/recipes')).filter(r => r.name === 'Lost Answer Soup').length; + out.phone = (await db.query(`SELECT server_id FROM recipes WHERE name = 'Lost Answer Soup'`)).values.map(r => !!r.server_id); + }, + + // The phone can't read whose data it holds: it shows none of it, says + // so, and Retry works once it can. + async gateError() { + const la = await import('../../src/lib/local-account.js'); + const { get } = await import('svelte/store'); + const A = await me(token); + await db.execute('ALTER TABLE sync_meta RENAME TO sync_meta_away'); + out.first = await la.ensureLocalAccount(A, { confirm: async () => true }); + const g = get(la.accountGate); + out.state = g.state; + out.shown = la.accountReadyFor(g, A.id); + await db.execute('ALTER TABLE sync_meta_away RENAME TO sync_meta'); + out.second = await la.ensureLocalAccount(A, { confirm: async () => true }); + out.shownAfter = la.accountReadyFor(get(la.accountGate), A.id); + // Asked twice at once for the same account: one check, one answer. + la.resetAccountGate(); + const both = await Promise.all([la.ensureLocalAccount(A), la.ensureLocalAccount(A)]); + out.together = both; + }, + + // Disconnect, use the phone on its own, then connect as Bob and choose + // Upload: no question at sign-in, every row on Bob's account once. + async disconnectUpload() { + const la = await import('../../src/lib/local-account.js'); + const B_TOKEN = process.env.CT_TOKEN_B; + const A = await me(token), B = await me(B_TOKEN); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await newRecipe({ name: 'Alice Synced' }); + await sync(); + // Settings > Disconnect. + await la.setLocalOwner(); + const url = server; + process.env.CT_SERVER = ''; signIn(null); + await api.createRecipe({ name: 'Made Alone', ingredients: [], steps: [] }); + // Made a while before it goes up, as a recipe made on the phone is: the + // server's own time for the row differs from the phone's. + await db.run(`UPDATE recipes SET created_at = datetime('now', '-1 hour') WHERE name = 'Made Alone'`); + const madeAt = (await db.query(`SELECT created_at FROM recipes WHERE name = 'Made Alone'`)).values[0].created_at; + // Settings > Connect as Bob > Upload, as SettingsServerConnection does: + // signed in, the upload sent to the address given, and the app switched + // to that server only once the copy is claimed. + signIn(B_TOKEN); + const { uploadLocalToServer } = await import('../../src/lib/migrate.js'); + const summary = await uploadLocalToServer({ serverUrl: url, authToken: B_TOKEN }); + out.errors = summary.errors; + await la.claimForServer(url, B.id, { uploaded: summary.uploaded, created: B.created_at }); + process.env.CT_SERVER = url; + let asked = null; + out.ok = await la.prepareLocalAccount(B, { confirm: async n => { asked = n; return false; } }); + out.asked = asked; + await sync(); await sync(); + out.madeAtKept = (await db.query(`SELECT created_at FROM recipes WHERE name = 'Made Alone'`)).values[0].created_at === madeAt; + // The same upload again (an answer lost): still once. + await uploadLocalToServer({ serverUrl: url, authToken: B_TOKEN }).catch(() => {}); + const bob = (await web('GET', '/api/recipes', null, B_TOKEN)).map(r => r.name).sort(); + out.bobServer = bob; + out.bobPhone = (await api.getRecipes()).map(r => r.name).sort(); + // Categories: the account's own, matched, never a second copy of each. + const serverCats = (await web('GET', '/api/recipes/categories', null, B_TOKEN)).map(c => c.slug).sort(); + out.serverCatDupes = serverCats.filter(x => /-\d+$/.test(x)); + out.phoneCats = (await db.query(`SELECT slug FROM recipe_categories ORDER BY slug`)).values.map(r => r.slug); + out.serverCats = serverCats; + }, + + // The same server at another address (its LAN IP and its name): the + // same account, nothing cleared. An address whose server can't be told + // apart: asked, and nothing goes up unless the answer is the same server. + async otherAddress() { + const la = await import('../../src/lib/local-account.js'); + const A = await me(token); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await newRecipe({ name: 'Kept Soup' }); + await sync(); + const url = server; + process.env.CT_SERVER = url.replace('127.0.0.1', 'localhost'); + let asked = false; + out.sameServer = await la.prepareLocalAccount(A, { confirm: async () => { asked = true; return true; }, sameServer: async () => { asked = true; return false; } }); + out.askedSame = asked; + out.keptSame = (await api.getRecipes()).map(r => r.name); + // Now an address whose server never named itself: the tag has no id. + const owner = JSON.parse((await db.query(`SELECT value FROM sync_meta WHERE key = 'account'`)).values[0].value); + await db.run(`UPDATE sync_meta SET value = ? WHERE key = 'account'`, [JSON.stringify({ s: 'http://old-name.example', u: owner.u, c: owner.c })]); + let question = 0; + out.saidYes = await la.prepareLocalAccount(A, { confirm: async () => true, sameServer: async () => { question++; return true; } }); + out.keptYes = (await api.getRecipes()).length; + await db.run(`UPDATE sync_meta SET value = ? WHERE key = 'account'`, [JSON.stringify({ s: 'http://old-name.example', u: owner.u, c: owner.c })]); + out.saidNo = await la.prepareLocalAccount(A, { confirm: async () => true, sameServer: async () => { question++; return false; } }); + out.keptNo = (await api.getRecipes()).length; + out.questions = question; + process.env.CT_SERVER = url; + }, + + // The first sync after signing in tells the pages it brought recipes + // down, so the list shows them at once; a sync with nothing new doesn't. + async firstSyncTellsPages() { + const la = await import('../../src/lib/local-account.js'); + const A = await me(token); + await newRecipe({ name: 'Waiting On The Server' }); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + await sync(); + out.told = events.filter(e => e.type === 'ct:sync-complete').map(e => ({ ok: e.detail?.ok, changed: e.detail?.changed })); + }, + + // The phone's cookie jar still holds Alice's sign-in when Bob signs in: + // the server answers Bob's requests as Bob, and the jar is emptied when + // the phone changes hands. + async cookieJar() { + const la = await import('../../src/lib/local-account.js'); + const { CapacitorHttp } = await import('@capacitor/core'); + const B_TOKEN = process.env.CT_TOKEN_B; + const A = await me(token), B = await me(B_TOKEN); + const { CapacitorCookies } = await import('@capacitor/core'); + await CapacitorHttp.post({ url: `${server}/api/auth/login`, headers: { 'Content-Type': 'application/json' }, data: { username: 'cook', password: 'Str0ng-Pass-88!y' } }); + // A sign-in gate's cookies, in front of the server and on its own host. + await CapacitorCookies.setCookie({ url: server, key: 'authelia_session', value: 'gate' }); + await CapacitorCookies.setCookie({ url: 'https://gate.example.com', key: 'CF_Authorization', value: 'gate' }); + out.jarAfterLogin = await CapacitorCookies._all(); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + signIn(B_TOKEN); + out.serverSays = (await (await fetch(`${server}/api/auth/me`, { headers: { Authorization: `Bearer ${B_TOKEN}` } })).json()).user?.username; + out.yes = await la.prepareLocalAccount(B, { confirm: async () => true }); + out.jarAfterSwitch = await CapacitorCookies._all(); + out.host = new URL(server).host; + await web('POST', '/api/recipes', { name: 'Bob Only', ingredients: [], steps: [] }, B_TOKEN); + await sync(); + out.bobPhone = (await api.getRecipes()).map(r => r.name); + }, + + // An account cached by an earlier sign-in (before a Disconnect and a + // connect as someone else) is never taken for the one signed in now. + async cachedAccount() { + const A = await me(token), B_TOKEN = process.env.CT_TOKEN_B; + localStorage.setItem('ct:serverUrl', server); + localStorage.setItem('ct:cachedUser', JSON.stringify(A)); + signIn(B_TOKEN); + const { currentUser, loadAuthState } = await import('../../src/stores/auth.js'); + const { get } = await import('svelte/store'); + await loadAuthState(); + out.atOnce = get(currentUser)?.username ?? null; + await sleep(1500); + out.afterServer = get(currentUser)?.username ?? null; + }, + + // Settings are kept per account and server: another account never reads + // the last one's. + async settingsPerAccount() { + const { DB } = await import('../../src/lib/db.js'); + const A = await me(token), B = await me(process.env.CT_TOKEN_B); + localStorage.setItem('ct:serverUrl', server); + localStorage.setItem('wl:userId', String(A.id)); + DB.setSetting('aiApiKey', 'alice-key'); + localStorage.setItem('wl:userId', String(B.id)); + out.bobReads = DB.getSetting('aiApiKey', null); + localStorage.setItem('wl:userId', String(A.id)); + out.aliceReads = DB.getSetting('aiApiKey', null); + out.keys = [...store.keys()].filter(k => k.includes('aiApiKey')); + }, + + // One phone (PHONE_DB, PHONE_MARKER), across runs: makes the recipe in + // RECIPE_NAME and syncs it. Run on a copy of another phone's database + // (a backup put back on a second phone), it must not send that phone's + // keys. + async cloneMake() { + // Made before this run's first sync, as on a phone that was offline. + const made = await api.createRecipe({ name: process.env.RECIPE_NAME, ingredients: [], steps: [] }); + const { dbInstallId } = await import('../../src/lib/db-native.js'); + out.install = await dbInstallId(); + out.localId = made?.id ?? (await db.query(`SELECT id FROM recipes WHERE name = ?`, [process.env.RECIPE_NAME])).values[0]?.id; + await sync(); + out.serverId = (await db.query(`SELECT server_id FROM recipes WHERE id = ?`, [out.localId])).values[0]?.server_id ?? null; + }, + + // Upload with links: a variant under its item, the item's nutrition + // source, a shopping item and an ingredient linked to the variant. The + // server gets its own ids for every link, and the phone keeps the same + // links after the first pull. + async uploadLinks() { + const la = await import('../../src/lib/local-account.js'); + const B_TOKEN = process.env.CT_TOKEN_B; + const B = await me(B_TOKEN); + // Bob's server already has items, so ids on the phone and there differ. + for (const n of ['Bob Salt', 'Bob Pepper', 'Bob Oil']) await web('POST', '/api/pantry', { name: n }, B_TOKEN); + const url = server; + process.env.CT_SERVER = ''; signIn(null); + await la.setLocalOwner(); + const milk = await api.createPantryItem({ name: 'Milk' }); + const oat = await api.createPantryItem({ name: 'Oat Milk', generic_parent_id: milk.id }); + await api.updatePantryItem(milk.id, { nutrition_source_variant_id: oat.id }); + await api.addShoppingItem({ name: 'Oat Milk', pantry_id: oat.id }); + await api.createRecipe({ name: 'Porridge', ingredients: [{ items: [{ name: 'oat milk', pantry_item_id: oat.id }] }], steps: ['Stir'] }); + signIn(B_TOKEN); + const { uploadLocalToServer } = await import('../../src/lib/migrate.js'); + const summary = await uploadLocalToServer({ serverUrl: url, authToken: B_TOKEN }); + out.errors = summary.errors; + await la.claimForServer(url, B.id, { uploaded: summary.uploaded, created: B.created_at }); + process.env.CT_SERVER = url; + await la.prepareLocalAccount(B, { confirm: async () => true }); + await sync(); await sync(); + const sp = await web('GET', '/api/pantry', null, B_TOKEN); + const sId = n => sp.find(p => p.name === n)?.id; + const sRow = n => sp.find(p => p.name === n); + const sShop = (await web('GET', '/api/shopping', null, B_TOKEN)); + const shopRow = (Array.isArray(sShop) ? sShop : sShop.items).find(x => x.name === 'Oat Milk'); + const sRecipe = (await web('GET', '/api/recipes', null, B_TOKEN)).find(r => r.name === 'Porridge'); + const full = await web('GET', `/api/recipes/${sRecipe.id}`, null, B_TOKEN); + out.server = { + variantParentIsMilk: sRow('Oat Milk')?.generic_parent_id === sId('Milk'), + sourceIsOat: sRow('Milk')?.nutrition_source_variant_id === sId('Oat Milk'), + shoppingIsOat: shopRow?.pantry_id === sId('Oat Milk'), + ingredientIsOat: full.ingredients?.[0]?.items?.[0]?.pantry_item_id === sId('Oat Milk'), + }; + const lp = (await db.query(`SELECT id, name, generic_parent_id, nutrition_source_variant_id FROM pantry_items WHERE deleted_at IS NULL`)).values; + const lId = n => lp.find(p => p.name === n)?.id; + const lShop = (await db.query(`SELECT pantry_id FROM shopping_list WHERE name = 'Oat Milk' AND deleted_at IS NULL`)).values; + out.phone = { + variantParentIsMilk: lp.find(p => p.name === 'Oat Milk')?.generic_parent_id === lId('Milk'), + sourceIsOat: lp.find(p => p.name === 'Milk')?.nutrition_source_variant_id === lId('Oat Milk'), + shoppingIsOat: lShop.length === 1 && lShop[0].pantry_id === lId('Oat Milk'), + counts: { milk: lp.filter(p => p.name === 'Milk').length, oat: lp.filter(p => p.name === 'Oat Milk').length }, + }; + }, + + // A setting changed here that never went up counts as a change waiting: + // another account signing in is asked before it's dropped. + async settingWaits() { + const la = await import('../../src/lib/local-account.js'); + const A = await me(token), B = await me(process.env.CT_TOKEN_B); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + await dbn.dbUpsertSetting('measurementSystem', 'metric'); + signIn(process.env.CT_TOKEN_B); + // Bob's own settings, written as his app starts, before the check. + const { mirrorSetting } = await import('../../src/stores/settings.js'); + await mirrorSetting('timezone', 'Europe/Oslo'); + let asked = null; + out.ok = await la.prepareLocalAccount(B, { confirm: async n => { asked = n; return false; } }); + out.asked = asked; + // Nothing of Alice's waiting: Bob's own start-up settings ask nothing. + signIn(token); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + signIn(process.env.CT_TOKEN_B); + await mirrorSetting('timezone', 'Europe/Oslo'); + // Signed out, the app starting on the sign-in screen sets a default. + signIn(null); + await mirrorSetting('timezone', 'America/New_York'); + signIn(process.env.CT_TOKEN_B); + let askedAgain = null; + out.ownOk = await la.prepareLocalAccount(B, { confirm: async n => { askedAgain = n; return false; } }); + out.askedAgain = askedAgain; + }, + + // A category with an edit waiting here, whose name another device moved + // to another category: the pull still goes through, the edit still goes + // up, and both categories are on the phone. + async categoryNameMoved() { + const cat = await web('POST', '/api/recipes/categories', { name: 'Soups' }); + await sync(); + const local = (await db.query(`SELECT * FROM recipe_categories WHERE server_id = ?`, [cat.id])).values[0]; + await api.updateRecipeCategory(local.id, { name: local.name, color: '#ff0000' }); + // Another device renames it (and its slug) and makes a new "Soups". + await sleep(1100); + const copy = (await web('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).tables.recipe_categories.find(c => c.id === cat.id); + const row = { ...copy, client_id: 999, server_id: cat.id, server_synced_at: copy.synced_at, name: 'Stews', slug: 'stews', updated_at: new Date(Date.now() - skew).toISOString().replace('T', ' ').slice(0, 19), edit_clock: 'server', changed: ['name', 'slug'] }; + delete row.id; delete row.synced_at; + const now = new Date(Date.now() - skew).toISOString(); + await web('POST', '/api/sync/push', { fk_ids: 'server', client_now: now, tables: { recipe_categories: [row] } }); + await web('POST', '/api/recipes/categories', { name: 'Soups' }); + const r1 = await fullSync({ silent: true }, true); + const r2 = await fullSync({ silent: true }, true); + out.syncs = [r1.ok ? 'ok' : r1.error, r2.ok ? 'ok' : r2.error]; + const server = await web('GET', '/api/recipes/categories'); + out.server = server.filter(c => ['soups', 'stews'].includes(c.slug)).map(c => ({ slug: c.slug, color: c.color })).sort((a, b) => a.slug.localeCompare(b.slug)); + out.phone = (await db.query(`SELECT slug, color, sync_status FROM recipe_categories WHERE slug IN ('soups', 'stews') ORDER BY slug`)).values; + }, + + // A switch while the sync is downloading pictures: the switch doesn't + // wait for them. + async switchDuringImages() { + const la = await import('../../src/lib/local-account.js'); + const A = await me(token), B = await me(process.env.CT_TOKEN_B); + await la.prepareLocalAccount(A, { confirm: async () => true }); + await sync(); + holdFor = (url, init) => (/\/api\/recipes$/.test(url) && (init?.method || 'GET') === 'GET' ? 8000 : 0); + const running = fullSync({ silent: true }, true); + await sleep(1500); + signIn(process.env.CT_TOKEN_B); + const t0 = Date.now(); + out.yes = await la.prepareLocalAccount(B, { confirm: async () => true }); + out.switchMs = Date.now() - t0; + out.run = (await running).reason || 'ok'; + out.sent = sent.map(x => (x.at - t0) + ' ' + x.url.slice(0, 30) + (x.aborted ? ' (aborted)' : '') + (x.held ? ' held' : '')); + holdFor = () => 0; + }, + + // The pictures of the account's recipes are found for keeping offline. + async imagesFound() { + await web('POST', '/api/recipes', { name: 'Pictured', img_url: '/uploads/pictured.jpg', ingredients: [], steps: [] }); + await sync(); + const { cacheAllImages } = await import('../../src/lib/image-cache.js'); + out.total = (await cacheAllImages()).total; + }, + + // Started offline with a cached account the session isn't for: the + // sign-in screen, never the copy with nobody signed in. + async cachedAccountOffline() { + const A = await me(token), B_TOKEN = process.env.CT_TOKEN_B; + process.env.CT_SERVER = 'http://127.0.0.1:9'; + localStorage.setItem('ct:serverUrl', process.env.CT_SERVER); + localStorage.setItem('ct:cachedUser', JSON.stringify(A)); + localStorage.setItem('ct:cachedUserMgmt', '1'); + signIn(B_TOKEN); + const { currentUser, userMgmtActive, loadAuthState } = await import('../../src/stores/auth.js'); + const { get } = await import('svelte/store'); + await loadAuthState(); + await sleep(500); + out.user = get(currentUser)?.username ?? null; + out.userMgmt = get(userMgmtActive); + process.env.CT_SERVER = server; + }, + + // An ingredient linked to a pantry item, on the phone in server mode, + // where the item's id here differs from its id on the server: the link is + // the right one on both sides, made new, edited offline, and from the web. + async ingredientLinks() { + for (const n of ['Salt', 'Pepper', 'Oil', 'Vinegar']) await web('POST', '/api/pantry', { name: n }); + // The phone makes its own items first, so its ids run ahead of the server's. + await api.createPantryItem({ name: 'Flour' }); + await api.createPantryItem({ name: 'Sugar' }); + await sync(); + const here = async n => (await db.query(`SELECT id, server_id FROM pantry_items WHERE name = ?`, [n])).values[0]; + const flour = await here('Flour'), sugar = await here('Sugar'), oil = await here('Oil'); + out.idsDiffer = flour.id !== flour.server_id && sugar.id !== sugar.server_id; + // A new item and a recipe that links it, both made before a sync. + const butter = await api.createPantryItem({ name: 'Butter' }); + const r = await api.createRecipe({ name: 'Cake', ingredients: [{ items: [{ name: 'flour', pantry_item_id: flour.id }, { name: 'butter', pantry_item_id: butter.id }] }], steps: ['Bake'] }); + await sync(); + const linkOnServer = async (rid, i) => (await web('GET', `/api/recipes/${rid}`)).ingredients[0].items[i]?.pantry_item_id ?? null; + const sid = (await db.query(`SELECT server_id FROM recipes WHERE id = ?`, [r.id])).values[0].server_id; + const butterHere = await here('Butter'); + out.made = { server: [await linkOnServer(sid, 0), await linkOnServer(sid, 1)], want: [flour.server_id, butterHere.server_id] }; + // Edited offline: the butter link swapped for sugar. + const x = await api.getRecipe(r.id); + const g = x.ingredients; + g[0].items[1] = { name: 'sugar', pantry_item_id: sugar.id }; + await api.updateRecipe(r.id, { ...x, ingredients: g }); + await sync(); + out.edited = { server: await linkOnServer(sid, 1), want: sugar.server_id }; + const phoneLinks = async rid => JSON.parse((await db.query(`SELECT ingredients FROM recipes WHERE id = ?`, [rid])).values[0].ingredients)[0].items.map(i => i.pantry_item_id ?? null); + out.phoneAfter = { got: await phoneLinks(r.id), want: [flour.id, sugar.id] }; + // A recipe made on the web, linked to an item by its server id. + const w = await web('POST', '/api/recipes', { name: 'Dressing', ingredients: [{ items: [{ name: 'oil', pantry_item_id: oil.server_id }] }], steps: ['Shake'] }); + await sync(); + const wl = (await db.query(`SELECT id FROM recipes WHERE server_id = ?`, [w.id])).values[0].id; + out.fromWeb = { got: await phoneLinks(wl), want: [oil.id] }; + }, +}; + +const name = process.argv[2]; +if (!scenarios[name]) throw new Error('no scenario ' + name); +await scenarios[name](); +process.stdout.write('\n@@' + JSON.stringify(out) + '\n'); +process.exit(0); diff --git a/scripts/android-sync/platform.mjs b/scripts/android-sync/platform.mjs new file mode 100644 index 00000000..1185b797 --- /dev/null +++ b/scripts/android-sync/platform.mjs @@ -0,0 +1,15 @@ +// platform.js for a phone connected to the server in CT_SERVER as CT_TOKEN. +// Signing in or out (setAuthToken) changes CT_TOKEN, as the app's own does. +const server = () => process.env.CT_SERVER || null; +export const isNative = true; +export const getServerUrl = server; +export const getAuthToken = () => process.env.CT_TOKEN || null; +export const setAuthToken = t => { if (t == null) delete process.env.CT_TOKEN; else process.env.CT_TOKEN = t; }; +export const apiUrl = p => (server() || '') + p; +export const resolveAssetUrl = x => x; +export const getNativeMode = () => (server() ? 'server' : 'local'); +export const setNativeMode = () => {}; export const setServerUrl = () => {}; +export const needsNativeSetup = () => false; export const iconUrl = x => x; +export const loadImageMap = async () => ({}); export const setImageMap = () => {}; +export const explainConnectError = e => String(e); +export const publicRecipeUrl = t => t; export const publicRecipeToken = () => null; diff --git a/scripts/android-sync/register.mjs b/scripts/android-sync/register.mjs new file mode 100644 index 00000000..fd267a27 --- /dev/null +++ b/scripts/android-sync/register.mjs @@ -0,0 +1,6 @@ +// node --import ./scripts/android-sync/register.mjs: run the Android app's own +// sync modules (src/lib/sync.js, api-native.js, db-native.js) in Node, with +// Capacitor swapped for the shims next to this file. +import './clock.mjs'; +import { register } from 'node:module'; +register('./hooks.mjs', import.meta.url); diff --git a/scripts/android-sync/sqlite.mjs b/scripts/android-sync/sqlite.mjs new file mode 100644 index 00000000..1ea7201c --- /dev/null +++ b/scripts/android-sync/sqlite.mjs @@ -0,0 +1,86 @@ +// @capacitor-community/sqlite on better-sqlite3 (the server's driver), one +// in-memory database per connection name, on the phone's clock. +import { createRequire } from 'node:module'; +import { SKEW } from './clock.mjs'; + +const Database = createRequire(new URL('../../server/package.json', import.meta.url))('better-sqlite3'); +const plain = new Database(':memory:'); +const dbs = new Map(); +const norm = p => (p || []).map(v => (v === undefined ? null : typeof v === 'boolean' ? (v ? 1 : 0) : v)); + +function skewed(db) { + if (!SKEW) return; + const now = () => new Date(Date.now()).toISOString().replace('T', ' ').replace('Z', ''); + for (const f of ['datetime', 'date', 'time', 'julianday']) { + db.function(f, { varargs: true, deterministic: false }, + (...a) => plain.prepare(`SELECT ${f}(${a.map(() => '?').join(',')}) AS v`).get(...a.map(v => (v === 'now' ? now() : v))).v); + } + db.function('strftime', { varargs: true, deterministic: false }, + (...a) => plain.prepare(`SELECT strftime(${a.map(() => '?').join(',')}) AS v`).get(a[0], ...a.slice(1).map(v => (v === 'now' ? now() : v))).v); +} + +// The first whole statement of `sql`, as SQLite's compiler takes it (the +// rest is ignored, as execSQL does): the shortest text up to a ";" that +// is a whole statement. A trigger's body has ";" inside it, so a cut +// there is still incomplete. Any other error is the statement's own, and +// shows when it runs. +function firstStatement(db, sql) { + let at = sql.indexOf(';'); + while (at > -1) { + const head = sql.slice(0, at + 1); + try { db.prepare(head); return head; } catch (e) { + if (!/incomplete input/i.test(e.message)) return head; + } + at = sql.indexOf(';', at + 1); + } + return sql; +} + +function conn(name) { + // PHONE_DB: a file, so a later run is the same phone (or a copy of it). + if (!dbs.has(name)) { const d = new Database(process.env.PHONE_DB ? `${process.env.PHONE_DB}-${name}.db` : ':memory:'); skewed(d); dbs.set(name, d); } + const db = dbs.get(name); + return { + async open() {}, async close() {}, + // As the Android plugin runs a script (UtilsSQLite.getStatementsArray + // and Database.execute): split on ";\n" only, a piece that is just + // "END" joined to the one before (a trigger's body), lines joined with + // "--" comments dropped, and each piece handed to execSQL, which runs + // only its first statement. Two statements on one line: the second + // silently never runs. + async execute(sql) { + const before = db.prepare('SELECT total_changes() AS n').get().n; + let pieces = String(sql).replace(/end;/g, 'END;').split(';\n').map(x => x.trim()); + for (let i = pieces.indexOf('END'); i > 0; i = pieces.indexOf('END')) pieces.splice(i - 1, 2, pieces[i - 1] + '; END'); + pieces = pieces.map(x => x.split('\n').map(l => { const k = l.indexOf('--'); return (k > -1 ? l.slice(0, k) : l).trim(); }).filter(Boolean).join(' ')); + for (const piece of pieces) { + if (!piece) continue; + const st = db.prepare(firstStatement(db, piece.endsWith(';') ? piece : piece + ';')); + if (st.reader) st.all(); else st.run(); + } + return { changes: { changes: db.prepare('SELECT total_changes() AS n').get().n - before } }; + }, + async query(sql, params) { + const st = db.prepare(sql); + if (!st.reader) { st.run(...norm(params)); return { values: [] }; } + return { values: st.all(...norm(params)) }; + }, + // Each write waits a turn, as a trip across the native bridge does, so + // other work (a sign-in, another sync) can run while one is writing. + async run(sql, params) { + await new Promise(r => setImmediate(r)); + const r = db.prepare(sql).run(...norm(params)); + return { changes: { changes: Number(r.changes), lastId: Number(r.lastInsertRowid) } }; + }, + }; +} + +export const CapacitorSQLite = {}; +export class SQLiteConnection { + async checkConnectionsConsistency() { return { result: false }; } + async isConnection() { return { result: false }; } + async closeConnection() {} + async closeAllConnections() {} + async retrieveConnection(n) { return conn(n); } + async createConnection(n) { return conn(n); } +} diff --git a/scripts/attach-image.test.js b/scripts/attach-image.test.js new file mode 100644 index 00000000..a7fa28f4 --- /dev/null +++ b/scripts/attach-image.test.js @@ -0,0 +1,40 @@ +/** + * Trace's attach button is one shared component, identical in every Trace + * app: Camera or Gallery on a phone, a tablet or the Android app; straight to + * the file picker on a computer, where browsers ignore the camera hint. The + * Android app goes straight to the camera or the system photo picker. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); +const button = read('../src/components/ai/AttachImageButton.svelte'); +const trace = read('../src/components/ai/Trace.svelte'); +const app = JSON.parse(read('../package.json')).name; + +test('Trace attaches images only through the shared button', () => { + assert.match(trace, /import AttachImageButton from '\.\/AttachImageButton\.svelte';/); + assert.match(trace, /]*on:files=\{e => [A-Za-z_]+\(e\.detail\)\}/); + assert.doesNotMatch(trace, /type="file"/, 'no file inputs of its own left in Trace'); + assert.doesNotMatch(trace, /CameraSource/, 'no camera calls of its own left in Trace'); + // LiftTrace's coach compares progress photos, so it takes several at once. + const multiple = / { + assert.match(button, /const touch = isNative\s*\n\s*\|\| \(typeof window !== 'undefined' && !!window\.matchMedia\?\.\('\(pointer: coarse\)'\)\.matches\);/); + assert.match(button, /if \(!touch\) \{ fileInput\?\.click\(\); return; \}/); + assert.match(button, /source: fromCamera \? CameraSource\.Camera : CameraSource\.Photos,/, 'Android goes straight to the camera or the photo picker'); + assert.doesNotMatch(button, /CameraSource\.Prompt/, 'never Android\'s generic chooser'); + assert.match(button, /capture="environment"/, 'the web Camera choice asks for the camera'); + assert.match(button, /on:pointerdown=\{onWindowPointer\} on:keydown=\{onWindowKey\}/, 'the menu closes on a tap outside or Escape'); +}); + +test('the menu has its words', () => { + const en = JSON.parse(read('../src/i18n/en.json')); + assert.deepEqual(Object.keys(en.attach_image).sort(), ['button', 'camera', 'gallery']); + assert.match(button, /\$_\('attach_image\.camera'\)/); + assert.match(button, /\$_\('attach_image\.gallery'\)/); +}); diff --git a/scripts/cookie-check.test.js b/scripts/cookie-check.test.js new file mode 100644 index 00000000..66113967 --- /dev/null +++ b/scripts/cookie-check.test.js @@ -0,0 +1,67 @@ +/** + * Signing in from a plain-HTTP page. The sign-in cookie is HTTPS-only unless + * INSECURE_COOKIES=1, so the browser drops it and the user loops back to the + * login page with no error (NutriTrace #20, #41, #43, #195 were all this). The login page and the + * setup wizard now say why, before and after signing in, and the server log + * says so too. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { cookieBlockedByHttp, droppedCookieReason, COOKIE_HELP_URLS } from '../src/lib/cookie-check.js'; + +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); + +test('blocked only when the cookie is HTTPS-only and the page is not secure', () => { + const secureCookie = { secure_cookies: true }, lanCookie = { secure_cookies: false }; + assert.equal(cookieBlockedByHttp(secureCookie, { secureContext: false }), true, 'plain HTTP, HTTPS-only cookie'); + assert.equal(cookieBlockedByHttp(secureCookie, { secureContext: true }), false, 'HTTPS or localhost'); + assert.equal(cookieBlockedByHttp(lanCookie, { secureContext: false }), false, 'INSECURE_COOKIES=1 on a LAN'); + assert.equal(cookieBlockedByHttp(secureCookie, { secureContext: false, native: true }), false, 'the Android app signs in with a token'); + assert.equal(cookieBlockedByHttp(null, { secureContext: false }), false, 'no answer from the server: claim nothing'); + assert.equal(cookieBlockedByHttp({}, { secureContext: false }), false, 'an older server that does not say'); +}); + +test('a sign-in that did not stick is put down to HTTP only when it was HTTP', () => { + assert.equal(droppedCookieReason({ secure_cookies: true }, { secureContext: false }), 'http'); + assert.equal(droppedCookieReason({ secure_cookies: true }, { secureContext: true }), 'dropped'); + assert.equal(droppedCookieReason(null, { secureContext: false }), 'dropped'); +}); + +test('the server reports the cookie setting and logs a plain-HTTP sign-in', () => { + const auth = read('../server/routes/auth.js'); + assert.match(auth, /secure_cookies: !_insecureCookies,/, '/status says whether the cookie is HTTPS-only'); + const login = auth.slice(auth.indexOf("router.post('/login'"), auth.indexOf("router.post('/logout'")); + assert.match(login, /warnIfPlainHttp\(req\);\s*\n\s*res\.cookie\('ct_token'/, 'logged when signing in'); + const register = auth.slice(auth.indexOf("router.post('/register'"), auth.indexOf("res.json({ user: safeUser(user) });", auth.indexOf("router.post('/register'"))); + assert.match(register, /warnIfPlainHttp\(req\);\s*\n\s*res\.cookie\('ct_token'/, 'and when the first account signs in'); + assert.match(auth, /if \(_insecureCookies\) return;/, 'never on a server set up for plain HTTP'); +}); + +test('the login page and the wizard show it, before and after signing in', () => { + const login = read('../src/routes/Login.svelte'); + assert.match(login, /if \(cookieBlockedByHttp\(data, \{ native: isNative \}\)\) signInProblem\.set\('http'\);/); + assert.match(login, /if \(!isNative && !get\(currentUser\)\) \{\s*\n\s*signInProblem\.set\(droppedCookieReason/, + 'checked with get(): setting the user tears the page down, and its $store stops updating'); + assert.match(login, /\{#if \$signInProblem\}\s*\n\s*/); + const wizard = read('../src/routes/Wizard.svelte'); + assert.match(wizard, /if \(cookieBlockedByHttp\(d\)\) signInProblem\.set\('http'\)/); + assert.match(wizard, /if \(_isPwa && !get\(currentUser\)\) \{\s*\n\s*signInProblem\.set\(droppedCookieReason\(_authStatus\)\);/); + assert.match(wizard, //); + assert.match(read('../src/stores/auth.js'), /export const signInProblem = writable\(null\);/); + // No flash: on the web the user is only set once the server confirms the + // session (loadAuthState), never straight from the login reply. + assert.match(login, /if \(isNative\) currentUser\.set\(data\.user\);/); + assert.doesNotMatch(login, /\n\s*currentUser\.set\(data\.user\);/); +}); + +test('the warning has its words and points at the fix', () => { + const en = JSON.parse(read('../src/i18n/en.json')); + for (const k of ['http_title', 'http_body', 'dropped_title', 'dropped_body', 'fix_link']) { + assert.ok(en.login.cookie_warning[k], k); + } + assert.match(en.login.cookie_warning.http_body, /INSECURE_COOKIES=1/); + assert.equal(COOKIE_HELP_URLS.http, 'https://traceapps.github.io/docs/getting-started/lan-http/'); + assert.equal(COOKIE_HELP_URLS.dropped, 'https://traceapps.github.io/docs/getting-started/reverse-proxy/'); + assert.match(read('../src/components/ui/CookieWarning.svelte'), /href=\{COOKIE_HELP_URLS\[reason\] \|\| COOKIE_HELP_URLS\.http\}/); +}); diff --git a/scripts/email-escape.test.js b/scripts/email-escape.test.js new file mode 100644 index 00000000..290d88be --- /dev/null +++ b/scripts/email-escape.test.js @@ -0,0 +1,72 @@ +/** + * Names, titles and links are escaped in every email's HTML. + * + * A user's name went into the greeting and the invite as-is, and in the + * sharing emails a food, meal or recipe name (another user's) did too, so + * "" or a link in a name became real markup in + * someone's inbox. The helpers that take plain text now escape it + * themselves. Verified by sending every email through a real SMTP catcher + * before and after; ordinary names render byte for byte as before. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const src = readFileSync(new URL('../server/email.js', import.meta.url), 'utf8'); +const H = 'Eve & click'; + +// The helpers are private to the module (which opens the database when +// imported), so they are read out of the source and run on their own. +const fnSrc = (name) => { + const at = src.indexOf(`function ${name}(`); + if (at < 0) return ''; + const end = src.indexOf('\n}\n', at); + return src.slice(at, end + 2); +}; +const helpers = ['_escapeHtml', 'greeting', 'ctaButton', 'fallbackUrl', '_sectionHeader'] + .map(fnSrc).filter(Boolean).join('\n'); +const FONT = (src.match(/const _FONT = `[^`]*`;/) || [''])[0]; +const h = new Function(`${FONT}\n${helpers}\nreturn { greeting, ctaButton, fallbackUrl, _escapeHtml };`)(); + +const noMarkup = (html) => { + assert.doesNotMatch(html, / { + const html = h.greeting(H); + noMarkup(html); + assert.match(html, /Eve & <img src=x onerror=alert\(1\)>/); + assert.doesNotMatch(h.greeting('Tom & Jerry'), /&amp;/, 'escaped once, not twice'); +}); + +test('links and button labels are escaped too', () => { + const href = 'http://host.test/#/x?a=1&b="2"'; + const btn = h.ctaButton(href, H); + noMarkup(btn); + assert.match(btn, /href="http:\/\/host\.test\/#\/x\?a=1&b="2""/); + noMarkup(h.fallbackUrl('http://host.test/">')); +}); + +test('the inviter name is escaped wherever it appears in the HTML', () => { + const invite = fnSrc('sendInvite'); + assert.match(invite, /_escapeHtml\(inviterName\)/); + assert.doesNotMatch(invite.replace(/text: [^\n]*/, ''), /\$\{inviterName\}/, 'no raw ${inviterName} outside the plain-text part'); +}); + +test('the old raw spots are gone', () => { + assert.doesNotMatch(src, /' \+ name \+ '/, 'greeting'); + assert.doesNotMatch(src, / { + const body = fnSrc('sendRecipeShared'); + assert.match(body, /const safeRecipe = _escapeHtml\(recipeText\);/); + assert.match(body, /subject: `\$\{sharerText\} shared "\$\{recipeText\}" with you`/); + assert.match(body, /text: `\$\{sharerText\} shared "\$\{recipeText\}"/); +}); + +test('weekly summary rows escape recipe names', () => { + assert.match(fnSrc('_statRow'), /\$\{_escapeHtml\(value\)\}/); +}); diff --git a/scripts/import-duplicates.test.js b/scripts/import-duplicates.test.js new file mode 100644 index 00000000..0366cdc6 --- /dev/null +++ b/scripts/import-duplicates.test.js @@ -0,0 +1,52 @@ +/** + * Imports skip a recipe that already exists (same name or source link) by + * default. The skip used to vanish: the bulk summary counted only imports + * and errors (#72, 121 found, 119 imported, no word on the other 2), a + * single paste or URL import of a duplicate opened /recipes/undefined, and + * a Paprika archive counted its duplicates as imported. Every path now + * reports what it skipped and which recipe it matched. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8'); +const routes = read('server/routes/recipes.js'); +const nativeApi = read('src/lib/api-native.js'); +const api = read('src/lib/api.js'); + +test('server: every duplicate answer names the recipe it matched', () => { + const answers = routes.match(/skipped: true, reason: 'duplicate'[^}]*\}/g) || []; + assert.equal(answers.length, 2, '/scrape and /import single both answer a duplicate'); + for (const a of answers) assert.match(a, /existing_id/); + assert.match(routes, /skippedDup\.push\(\{ idx: i, .*existing_id: targetRecipeId \}\)/, 'zip commit lists the match too'); +}); + +test('server: a Paprika archive never counts a skipped duplicate as imported', () => { + assert.doesNotMatch(routes, /const row = _saveImportedRecipe\(u, r,[^\n]*\n\s*created\.push\(row\);/); + assert.match(routes, /if \(row\) created\.push\(row\);\s*else skipped\.push/); +}); + +test('local mode answers duplicates the same way as the server', () => { + assert.match(nativeApi, /return \{ skipped: true, reason: 'duplicate', existing_id:/); + assert.match(nativeApi, /skipped\.push\(\{ name: entry\.recipe\?\.name \|\| '\?', existing_id:/); +}); + +test('api.js passes a duplicate answer through instead of treating it as a recipe', () => { + assert.equal((api.match(/res\?\.skipped \? res : this\._imgFromApi\(res\)/g) || []).length, 3); +}); + +test('both single-import screens open the existing recipe on a duplicate', () => { + for (const f of ['src/routes/Recipes.svelte', 'src/components/recipe/ImportUrlDialog.svelte']) { + const src = read(f); + assert.match(src, /\.skipped\)/, `${f} checks for a skipped answer`); + assert.match(src, /push\(`\/recipes\/\$\{(result|created)\.existing_id\}`\)/, `${f} opens the match`); + } +}); + +test('the bulk summary lists skipped duplicates by name', () => { + const src = read('src/components/settings/SettingsImport.svelte'); + assert.match(src, /skipped: \[\{name, existing_id\}\]/); + assert.match(src, /\{#each summary\.skipped as d\}/); + assert.match(src, /settings_import_ct\.summary_partial/); +}); diff --git a/scripts/list-reload.test.js b/scripts/list-reload.test.js new file mode 100644 index 00000000..ecdfe5f4 --- /dev/null +++ b/scripts/list-reload.test.js @@ -0,0 +1,29 @@ +/** + * The list pages read the phone's copy again when a sync brings changes + * (lib/sync-refresh.js). Two reads can overlap (the page opening while a + * sync lands): only the newest one sets what's shown, the loading state + * and an error; a quiet read after a sync never shows an error toast. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +for (const page of ['Recipes', 'Pantry', 'Shopping', 'CookDiary']) { + test(`${page}: only the newest read sets the loading state; a quiet one never toasts`, () => { + const src = readFileSync(new URL(`../src/routes/${page}.svelte`, import.meta.url), 'utf8'); + const body = src.slice(src.indexOf('async function load({ quiet'), src.indexOf('\n }\n', src.indexOf('async function load({ quiet'))); + assert.match(body, /const seq = \+\+_loadSeq;/); + assert.match(body, /if \(!quiet\) \{ loading = true; loadError = null; \}/); + assert.match(body, /finally \{\s*if \(seq === _loadSeq\) loading = false;/); + assert.match(body, /if \(seq === _loadSeq && !quiet\) \{[^}]*showError\(loadError\)/s, 'a quiet read keeps quiet, and an older read never speaks'); + assert.doesNotMatch(body.replace(/if \(seq === _loadSeq && !quiet\) \{[^}]*\}/s, ''), /showError\(/, 'no other toast'); + assert.match(src, /onSyncChanges\(\(\) => \{?\s*load\(\{ quiet: true \}\)/); + }); +} + +test('the phone keeps the same name groups the server merges by', async () => { + const native = readFileSync(new URL('../src/lib/db-native.js', import.meta.url), 'utf8'); + const groups = JSON.parse(native.match(/const NAME_GROUPS = (\{[^\n]*\});/)[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"')); + const { SYNC_GROUPS } = await import('../server/lib/sync-fields.js'); + for (const [t, g] of Object.entries(groups)) assert.ok(SYNC_GROUPS[t].some(x => JSON.stringify(x) === JSON.stringify(g)), t); +}); diff --git a/scripts/native-boot.test.js b/scripts/native-boot.test.js new file mode 100644 index 00000000..309ade23 --- /dev/null +++ b/scripts/native-boot.test.js @@ -0,0 +1,18 @@ +/** + * The Android app's first native call after it reloads itself can lose its + * answer (Capacitor's bridge runs the call before it starts answering the + * new page). The database's first call must never be that call: a harmless + * one, given up on after a moment, goes first. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('the app primes the native bridge before the database opens', () => { + const main = readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); + const prime = main.indexOf('await primeNativeBridge();'); + const db = main.indexOf("await import('./lib/db-native.js')"); + assert.ok(prime > -1 && db > prime, 'primed first'); + assert.match(main, /Promise\.race\(\[\s*CapacitorSQLite\.echo\(/, 'its answer may be lost, so it is given up on'); + assert.match(main, /setTimeout\(resolve, 1000\)/); +}); diff --git a/scripts/offline-wiring.test.js b/scripts/offline-wiring.test.js index 64db236d..7024e9ba 100644 --- a/scripts/offline-wiring.test.js +++ b/scripts/offline-wiring.test.js @@ -38,7 +38,8 @@ test('the status survives the error, so a hiccup is told from a refusal', () => }); test('the queue is replayed as the requests the app made', () => { - assert.match(offline, /await _http\._fetch\(op\.method, path, body\)/); + // Same route and body; a recipe save also says which copy it was made on. + assert.match(offline, /await _http\._fetch\(op\.method, path, withSaveBase\(body, op\.sync\)\)/); assert.ok(!/sync\/push/.test(offline), 'no second merge path'); }); diff --git a/scripts/oidc-fixes.test.js b/scripts/oidc-fixes.test.js new file mode 100644 index 00000000..fd657c4f --- /dev/null +++ b/scripts/oidc-fixes.test.js @@ -0,0 +1,61 @@ +/** + * OIDC fixes, verified end to end against a mock IdP before they went in; + * these checks keep them from quietly coming undone. + * + * - An email that matches an existing account but can't be linked (auto-link + * off, or email_verified not true) is refused, never given a second account. + * - The callback works with or without the provider ID, and at the old + * documented /api/oidc/callback address. + * - OIDC_ENABLE_EMAIL_PASSWORD_LOGIN applies even with no env-defined provider. + * - Userinfo fills in claims the ID token lacks (Authelia 4.39+), and is read + * as plain JSON unless a real signing algorithm is configured. + * - The Android deep link carries a single-use code bound to the app's + * secret, not the session token. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8'); +const lib = read('server/lib/oidc.js'); +const routes = read('server/routes/oidc.js'); +const env = read('server/lib/oidc-env.js'); +const index = read('server/index.js'); +const app = read('src/App.svelte'); +const handoff = read('src/lib/oidc-app-handoff.js'); + +test('an unlinkable matching email is refused before auto-register', () => { + assert.match(lib, /if \(collision && \(!autoLink \|\| !emailVerified\)\) \{\s*throw new Error/); + assert.ok(lib.indexOf('collision && (!autoLink || !emailVerified)') < lib.indexOf('if (autoCreate)')); +}); + +test('callback works with or without the provider ID, and at the old address', () => { + assert.match(routes, /router\.get\('\/callback\/:providerId', wrap\(handleCallback\)\)/); + assert.match(routes, /router\.get\('\/callback', wrap\(handleCallback\)\)/); + assert.match(routes, /const providerId = req\.params\.providerId \?\? stored\.providerId;/); + assert.match(index, /router\.get\('\/api\/oidc\/callback'[\s\S]{0,200}redirect\(307, `\$\{BASE_URL\}\/api\/auth\/oidc\/callback/); + const gate = index.indexOf("error: 'Setup required'"); + if (gate >= 0) { + assert.ok(index.indexOf("router.get('/api/oidc/callback'") < gate, 'the old address must be forwarded before the setup gate'); + } +}); + +test('the password-login env flag is seeded before the no-env-provider early return', () => { + const fn = env.slice(env.indexOf('export function seedOidcFromEnv')); + assert.ok(fn.indexOf('_seedPasswordLoginFromEnv();') < fn.indexOf('if (!prefixes.length) return;')); +}); + +test('userinfo fills in missing claims, ID token wins, same subject only', () => { + assert.match(routes, /client\.userinfo\(tokenSet\.access_token\)/); + assert.match(routes, /if \(info && info\.sub === claims\.sub\) claims = \{ \.\.\.info, \.\.\.claims \};/); + assert.match(lib, /userinfo_signed_response_alg !== 'none'/); +}); + +test('the Android deep link carries a single-use code bound to the app secret', () => { + assert.match(routes, /oidc-callback\/\?code=\$\{encodeURIComponent\(code\)\}/); + assert.match(routes, /router\.post\('\/handoff'/); + assert.match(lib, /createHash\('sha256'\)\.update\(verifier\)\.digest\('base64url'\)/); + assert.match(lib, /DELETE FROM oauth_state WHERE state = \?`\)\.run\(code\)/); + assert.match(handoff, /crypto\.subtle\.digest\('SHA-256'/); + assert.match(app, /redeemHandoff\(code\)/); +}); diff --git a/scripts/openai-chat-params.test.js b/scripts/openai-chat-params.test.js index b166a9ae..6ef6da11 100644 --- a/scripts/openai-chat-params.test.js +++ b/scripts/openai-chat-params.test.js @@ -17,6 +17,7 @@ for (const [name, getParams] of implementations) { hasTools: true, }), { + stream: false, max_completion_tokens: 4096, reasoning_effort: 'none', }, @@ -30,7 +31,7 @@ for (const [name, getParams] of implementations) { model: 'gpt-5.6-luna', hasTools: false, }), - { max_completion_tokens: 4096 }, + { stream: false, max_completion_tokens: 4096 }, ); }); @@ -42,7 +43,7 @@ for (const [name, getParams] of implementations) { hasTools: true, maxTokens: 1024, }), - { max_completion_tokens: 1024 }, + { stream: false, max_completion_tokens: 1024 }, ); }); @@ -53,7 +54,13 @@ for (const [name, getParams] of implementations) { model: 'gpt-5.6-luna', hasTools: true, }), - { max_tokens: 4096 }, + { stream: false, max_tokens: 4096 }, ); }); } + +test('client and server copies are identical', async () => { + const { readFileSync } = await import('node:fs'); + const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); + assert.equal(read('../src/lib/openai-chat-params.js'), read('../server/lib/openai-chat-params.js')); +}); diff --git a/scripts/proxy-content.test.js b/scripts/proxy-content.test.js new file mode 100644 index 00000000..f3a40f09 --- /dev/null +++ b/scripts/proxy-content.test.js @@ -0,0 +1,27 @@ +/** + * The image proxy only passes images, and sends them so they can't act as a + * page. It answers from the app's own origin before sign-in, and for the + * image hosts it passed through whatever came back: an HTML page from one of + * them was served as text/html (reproduced with i.imgur.com), and an SVG with + * no protection. Photos and SVGs still show in an . + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const src = readFileSync(new URL('../server/routes/proxy.js', import.meta.url), 'utf8'); + +test('only an image is passed through, never a page from an image host', () => { + assert.match(src, /if \(contentType\.startsWith\('image\/'\) \|\| \(isImgHost && !contentType\)\) \{/); + assert.doesNotMatch(src, /contentType\.startsWith\('image\/'\) \|\| isImgHost\)/); + assert.match(src, /if \(isImgHost\) \{[\s\S]{0,160}return res\.status\(502\)\.json\(\{ error: 'That address is not an image' \}\);/); +}); + +test('the type is read in lower case, so IMAGE/JPEG is still an image', () => { + assert.match(src, /const contentType = \(response\.headers\.get\('content-type'\) \|\| ''\)\.toLowerCase\(\);/); +}); + +test('an image is sent with nosniff and a sandbox policy', () => { + assert.match(src, /res\.set\('X-Content-Type-Options', 'nosniff'\);/); + assert.match(src, /res\.set\('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"\);/); +}); diff --git a/scripts/public-recipe-meta.test.js b/scripts/public-recipe-meta.test.js new file mode 100644 index 00000000..04787b20 --- /dev/null +++ b/scripts/public-recipe-meta.test.js @@ -0,0 +1,51 @@ +/** + * The tags a public recipe link sends to chat apps and social sites. + * Everything here comes from the recipe, so it all has to be escaped. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { escapeHtml, publicRecipeDescription, publicRecipeHead, injectPublicHead, publicPageHtml } from '../server/lib/public-recipe-meta.js'; + +const ctx = { origin: 'https://cook.example.com', basePath: '/ct', pageUrl: 'https://cook.example.com/ct/r/abc' }; + +test('escapes everything that could close an attribute or open a tag', () => { + assert.equal(escapeHtml(`">', description: '' }, ctx); + assert.ok(!head.includes(''; + const page = publicPageHtml(html, '/ct'); + assert.ok(page.indexOf('') < page.indexOf('./assets/')); + const out = injectPublicHead(page, publicRecipeHead({ name: 'Rice' }, ctx)); + assert.equal((out.match(//g) || []).length, 1); + assert.match(out, /<title>Rice<\/title>/); +}); diff --git a/scripts/public-url.test.js b/scripts/public-url.test.js new file mode 100644 index 00000000..6aa985e1 --- /dev/null +++ b/scripts/public-url.test.js @@ -0,0 +1,135 @@ +/** + * Emailed links go to an address an admin uses, never to whatever host a + * request claims. + * + * A password reset for someone else's account, asked for with + * X-Forwarded-Host set to your own domain, sent them a real reset email + * whose link (token included) pointed at you; invites and "shared with you" + * emails trusted the same header. server/lib/public-url.js now picks the + * address. Verified against each app's server with a mail catcher: before, + * the links pointed at the forged host; after, at the admin's address. + */ +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), 'public-url-')), 'test.db'); +const { createPublicUrl, requestOrigin } = await import('../server/lib/public-url.js'); +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); + +const mem = () => { const m = new Map(); return { get: (k) => m.get(k) ?? null, set: (k, v) => m.set(k, v), m }; }; +const req = (host, { proto = 'http', browser = true, admin = false, xfh = true } = {}) => ({ + protocol: 'http', + user: admin ? { role: 'admin' } : null, + headers: { ...(xfh ? { 'x-forwarded-host': host, 'x-forwarded-proto': proto } : { host }), ...(browser ? { 'sec-fetch-site': 'same-origin' } : {}) }, +}); +const make = (env = {}, isAdmin = (r) => !!r?.user) => { const store = mem(); return { store, pu: createPublicUrl(store, { env, isAdmin }) }; }; + +test('a forged host gets the address an admin uses, not its own', () => { + const { pu } = make(); + pu.remember(req('lift.example.com', { proto: 'https', admin: true })); + assert.equal(pu.linkBase(req('attacker.example')), 'https://lift.example.com'); +}); + +test('an address an admin also uses is kept for the person asking from it', () => { + const { pu } = make(); + pu.remember(req('lift.lan:3002', { admin: true })); + pu.remember(req('lift.example.com', { proto: 'https', admin: true })); + assert.equal(pu.linkBase(req('lift.lan:3002')), 'http://lift.lan:3002'); + assert.equal(pu.linkBase(req('elsewhere.test')), 'https://lift.example.com', 'anything else gets the latest'); +}); + +test('with no address known yet, only an admin request may use its own', () => { + const { pu } = make(); + assert.equal(pu.linkBase(req('attacker.example')), '', 'no email for anyone else'); + assert.equal(pu.linkBase(req('lift.example.com', { admin: true })), 'http://lift.example.com'); + assert.equal(pu.linkBase(null), '', 'the scheduler has no request'); +}); + +test('PUBLIC_URL wins, as given', () => { + const { pu } = make({ PUBLIC_URL: 'https://example.com/lifttrace/' }); + pu.remember(req('lift.lan', { admin: true })); + assert.equal(pu.linkBase(req('attacker.example')), 'https://example.com/lifttrace'); + assert.equal(pu.linkBase(null), 'https://example.com/lifttrace'); +}); + +test('the BASE_URL subpath is part of a remembered address', () => { + const was = process.env.BASE_URL; process.env.BASE_URL = '/lift/'; + try { + const { pu } = make(); + pu.remember(req('lift.lan', { admin: true })); + assert.equal(pu.linkBase(req('attacker.example')), 'http://lift.lan/lift'); + } finally { if (was === undefined) delete process.env.BASE_URL; else process.env.BASE_URL = was; } +}); + +test('only a browser or the app is remembered, and only a real host', () => { + const { pu, store } = make(); + pu.remember(req('localhost:3002', { browser: false, admin: true })); + assert.equal(store.get('known_origins'), null, 'a health check or script'); + pu.remember(req('evil"><img src=x>', { admin: true })); + assert.equal(store.get('known_origins'), null, 'not a host'); + for (let i = 0; i < 15; i++) pu.remember(req(`h${i}.test`, { admin: true })); + const known = JSON.parse(store.get('known_origins')); + assert.equal(known.length, 10); + assert.equal(known.at(-1), 'http://h14.test'); +}); + +test('a list of forwarded hosts reads the first', () => { + assert.equal(requestOrigin({ headers: { 'x-forwarded-host': 'a.test, b.test', 'x-forwarded-proto': 'https, http' } }), 'https://a.test'); + assert.equal(requestOrigin({ protocol: 'http', headers: { host: 'Lift.Example.COM' } }), 'http://lift.example.com'); +}); + +test('the real store keeps what an admin used', async () => { + const { rememberAdminOrigin, linkBase } = await import('../server/lib/public-url.js'); + // No accounts yet is single-user mode: the one operator is the admin, and + // with no accounts there is no reset or share email to take over. + assert.equal(linkBase(req('lift.lan')), 'http://lift.lan'); + rememberAdminOrigin(req('lift.example.com', { proto: 'https' })); + assert.equal(linkBase(req('attacker.example')), 'https://lift.example.com'); +}); + +test('reset, invite, test and sharing emails all take their address from it', () => { + const auth = read('../server/routes/auth.js'); + assert.doesNotMatch(auth, /x-forwarded-host/); + assert.equal(auth.match(/const baseUrl = linkBase\(req\);/g)?.length, 2, 'reset and invite'); + assert.match(auth, /if \(baseUrl\) await sendPasswordReset\(/, 'no reset email without a trusted address'); + assert.doesNotMatch(read('../server/routes/app-config.js'), /x-forwarded-host/); + const index = read('../server/index.js'); + assert.match(index, /router\.use\(authenticate\);[^\n]*\n\/\/ Remember the address an admin uses[\s\S]{0,300}rememberAdminOrigin\(req\)/); + for (const [file, guard] of APP_SHARES) { + const src = read(`../server/routes/${file}`); + assert.doesNotMatch(src.slice(0, src.length), /x-forwarded-host'\]\s+\|\| req\.headers\.host \|\| '';\n[^\n]*\n?[^\n]*viewUrl/, file); + assert.match(src, guard, file); + } +}); + +test('localhost is passed over for a real address when one is known', () => { + const { pu } = make(); + pu.remember(req('lift.example.com', { proto: 'https', admin: true })); + pu.remember(req('localhost:3002', { admin: true })); + assert.equal(pu.linkBase(req('attacker.example')), 'https://lift.example.com'); + assert.equal(pu.linkBase(req('localhost:3002')), 'http://localhost:3002', 'still kept for whoever asks from it'); +}); + +test('a host with an underscore, as Docker service names have, is a real host', () => { + assert.equal(requestOrigin({ headers: { host: 'cook_app:3003' }, protocol: 'http' }), 'http://cook_app:3003'); +}); + +test('a restore that rewrites the stored list is read straight away', () => { + const { pu, store } = make(); + pu.remember(req('old.example', { admin: true })); + store.set('known_origins', JSON.stringify(['https://restored.example'])); + assert.equal(pu.linkBase(req('attacker.example')), 'https://restored.example'); +}); + +test('only a signed-in admin is remembered, never the zero-account window', () => { + assert.match(read('../server/index.js'), /\n if \(req\.user\?\.role === 'admin'\) rememberAdminOrigin\(req\);\n/); +}); + +test('an invite with no known address is refused, not sent with a broken link', () => { + assert.match(read('../server/routes/auth.js'), /const baseUrl = linkBase\(req\);\n\s*if \(!baseUrl\) return res\.status\(500\)\.json\(\{ error: 'The app\\'s address is unknown\. Set PUBLIC_URL, then try again\.' \}\);\n\s*const inviteUrl = /); +}); + +const APP_SHARES = [['recipes.js', /if \(!row\.email \|\| !baseUrl\) continue;/]]; diff --git a/scripts/pwa-base-path.test.js b/scripts/pwa-base-path.test.js new file mode 100644 index 00000000..72748e71 --- /dev/null +++ b/scripts/pwa-base-path.test.js @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import test from 'node:test'; + +const read = path => readFileSync(new URL(path, import.meta.url), 'utf8'); +const html = read('../index.html'); +const vite = read('../vite.config.js'); +const startTag = '<script data-base-path-fallback>'; +const start = html.indexOf(startTag); +const end = html.indexOf('</script>', start); +const bootstrap = start < 0 || end < 0 ? undefined : html.slice(start + startTag.length, end); + +function resolveRawShellConfig(scriptURL, existingConfig) { + const window = {}; + if (existingConfig) window.__CT_CONFIG__ = existingConfig; + runInNewContext(bootstrap, { + URL, + window, + navigator: { serviceWorker: { controller: scriptURL ? { scriptURL } : null } }, + }); + return window.__CT_CONFIG__?.basePath; +} + +test('the runtime config fallback is synchronous and precedes the app module', () => { + assert.ok(bootstrap, 'index.html has a marked base-path bootstrap'); + assert.ok(html.indexOf('data-base-path-fallback') < html.indexOf('type="module"')); +}); + +test('a controlled raw app shell derives root and subpath from its active worker', () => { + assert.equal(resolveRawShellConfig('https://example.test/sw.js'), ''); + assert.equal(resolveRawShellConfig('https://example.test/cooktrace/sw.js'), '/cooktrace'); + assert.equal(resolveRawShellConfig('https://example.test/apps/cooktrace/sw.js'), '/apps/cooktrace'); +}); + +test('server-injected config wins and an uncontrolled page does not invent one', () => { + const serverConfig = { basePath: '/server-configured' }; + assert.equal(resolveRawShellConfig('https://example.test/cooktrace/sw.js', serverConfig), serverConfig.basePath); + assert.equal(resolveRawShellConfig(null), undefined); +}); + +test('the offline app shell and its bundled assets remain precached', () => { + assert.ok(vite.includes("globPatterns: ['**/*.{js,mjs,css,html,woff2,woff,ttf,")); +}); diff --git a/scripts/recipe-ai-fallback.test.js b/scripts/recipe-ai-fallback.test.js new file mode 100644 index 00000000..f4f26b66 --- /dev/null +++ b/scripts/recipe-ai-fallback.test.js @@ -0,0 +1,124 @@ +// Smart import's AI: which one it uses, and what it sends +// (TraceApps/nutritrace#258). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import http from 'node:http'; +import { aiExtractRecipe, importAiConfig } from '../server/lib/recipe-ai-fallback.js'; + +// The chosen AI without where it came from, to compare with how it was picked before. +const picked = (cfg) => { if (!cfg) return cfg; const { source: _source, ...rest } = cfg; return rest; }; + +// How 1.4.0-dev04 picked, before keyless endpoints counted. +function before(user, env) { + const { provider, apiKey, model, baseUrl } = user; + if (provider && apiKey) return { provider, apiKey, model: model || '', baseUrl: baseUrl || '' }; + if (env.AI_API_KEY) return { provider: env.AI_PROVIDER || 'claude', apiKey: env.AI_API_KEY, model: env.AI_MODEL || '', baseUrl: env.AI_BASE_URL || '' }; + return null; +} + +const users = [ + {}, + { provider: 'claude', apiKey: 'sk-user' }, + { provider: 'custom', apiKey: 'k', baseUrl: 'http://lan:11434', model: 'llama3' }, + { provider: 'custom', baseUrl: 'http://localhost:11434', model: 'llama3' }, + { provider: 'openai' }, +]; +const envs = [ + {}, + { AI_PROVIDER: 'claude', AI_API_KEY: 'sk-env', AI_MODEL: 'm' }, + { AI_PROVIDER: 'oai-compat', AI_API_KEY: 'k', AI_BASE_URL: 'http://gw:4000', AI_MODEL: 'm' }, + { AI_PROVIDER: 'oai-compat', AI_BASE_URL: 'http://ollama:11434', AI_MODEL: 'm' }, +]; + +test('every setup that had an AI before keeps the same one', () => { + for (const u of users) for (const e of envs) { + const was = before(u, e); + if (was) assert.deepEqual(picked(importAiConfig(u, e)), was, JSON.stringify({ u, e })); + } +}); + +test("the server's AI stays ahead of a keyless endpoint set in the app", () => { + const u = { provider: 'custom', baseUrl: 'http://localhost:11434', model: 'llama3' }; + assert.equal(importAiConfig(u, { AI_API_KEY: 'sk-env' }).apiKey, 'sk-env'); +}); + +test('keyless OpenAI-compatible endpoints now count', () => { + assert.deepEqual(importAiConfig({}, { AI_PROVIDER: 'oai-compat', AI_BASE_URL: 'http://ollama:11434', AI_MODEL: 'm' }), + { provider: 'oai-compat', apiKey: '', model: 'm', baseUrl: 'http://ollama:11434', source: 'server' }); + assert.deepEqual(importAiConfig({ provider: 'custom', baseUrl: 'http://lan:11434', model: 'llama3' }, {}), + { provider: 'custom', apiKey: '', model: 'llama3', baseUrl: 'http://lan:11434', source: 'user' }); + assert.equal(importAiConfig({ provider: 'claude', apiKey: 'k' }, {}).source, 'user'); + assert.equal(importAiConfig({ provider: 'openai' }, {}), null, 'a cloud provider still needs a key'); +}); + +async function sent(cfg) { + const real = globalThis.fetch; + let req; + globalThis.fetch = async (url, opts) => { + req = { url: String(url), headers: opts.headers, body: JSON.parse(opts.body) }; + return new Response(JSON.stringify({ choices: [{ message: { content: '{"name":"Tomato Soup","steps":["Simmer"]}' } }] }), { status: 200 }); + }; + try { return { recipe: await aiExtractRecipe('<h1>Soup</h1>', 'https://example.com/soup', cfg), req }; } + finally { globalThis.fetch = real; } +} + +test('an OpenAI-compatible endpoint is asked for one JSON answer', async () => { + for (const provider of ['custom', 'oai-compat']) { + const { recipe, req } = await sent({ provider, apiKey: '', model: 'm', baseUrl: 'http://ollama:11434/' }); + assert.equal(recipe.name, 'Tomato Soup'); + assert.equal(req.url, 'http://ollama:11434/v1/chat/completions'); + assert.equal(req.body.stream, false); + assert.equal(req.headers.accept, 'application/json'); + assert.equal(req.headers.Authorization, undefined, 'no placeholder key sent'); + } + const { req } = await sent({ provider: 'oai-compat', apiKey: 'k', model: 'm', baseUrl: 'http://gw:4000' }); + assert.equal(req.headers.Authorization, 'Bearer k'); +}); + +// A local stand-in for an AI endpoint: answers like OpenAI, or like an +// internal service that refuses (with a body that must not reach the user). +async function endpoint(handler) { + const server = http.createServer((req, res) => { + let raw = ''; req.on('data', c => raw += c); req.on('end', () => handler(req, res, raw)); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }; +} + +test("a user's own AI address on the server's network: the owner may, others may not", async () => { + const ep = await endpoint((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ choices: [{ message: { content: '{"name":"Soup"}' } }] })); + }); + try { + const cfg = { provider: 'custom', apiKey: '', model: 'm', baseUrl: ep.base, source: 'user' }; + // Refused with the same words as an address that doesn't answer, so a + // refusal can't tell an account which names exist on the network. + await assert.rejects(aiExtractRecipe('<h1>x</h1>', 'https://example.com', { ...cfg, allowPrivate: false }), /^Error: Could not reach the AI address$/); + assert.equal((await aiExtractRecipe('<h1>x</h1>', 'https://example.com', { ...cfg, allowPrivate: true })).name, 'Soup'); + // The server's own AI (AI_* env vars) is the owner's: no address check. + assert.equal((await aiExtractRecipe('<h1>x</h1>', 'https://example.com', { ...cfg, source: 'server' })).name, 'Soup'); + } finally { ep.close(); } +}); + +test("a provider's refusal shows its status and message, never the raw reply", async () => { + let body = 'INTERNAL ADMIN DATA: db_password=hunter2'; + const ep = await endpoint((req, res) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(body); }); + try { + const cfg = { provider: 'custom', apiKey: '', model: 'm', baseUrl: ep.base, source: 'user', allowPrivate: true }; + await assert.rejects(aiExtractRecipe('<h1>x</h1>', 'https://example.com', cfg), (e) => { + assert.equal(e.message, 'OpenAI returned 500'); + return true; + }); + body = JSON.stringify({ error: { message: 'model "m" not found' } }); + await assert.rejects(aiExtractRecipe('<h1>x</h1>', 'https://example.com', cfg), /OpenAI returned 500: model "m" not found/); + } finally { ep.close(); } +}); + +test("a user's AI address can't redirect to cloud metadata", async () => { + const ep = await endpoint((req, res) => { res.writeHead(307, { location: 'http://169.254.169.254/' }); res.end(); }); + try { + const cfg = { provider: 'custom', apiKey: '', model: 'm', baseUrl: ep.base, source: 'user', allowPrivate: true }; + await assert.rejects(aiExtractRecipe('<h1>x</h1>', 'https://example.com', cfg), /cloud-metadata/); + } finally { ep.close(); } +}); diff --git a/scripts/recipe-card.test.js b/scripts/recipe-card.test.js new file mode 100644 index 00000000..709dfb5b --- /dev/null +++ b/scripts/recipe-card.test.js @@ -0,0 +1,48 @@ +/** + * The recipe share card (GET /api/recipes/:id/card.png, an SVG). + * + * Three problems, each reproduced against the server before the fix: an + * image link with a quote in it ran script as the app when the card was + * opened (stored XSS, the card is served from this origin); a private + * recipe's card was sent Cache-Control: public, so a shared cache could keep + * it for others; and an app image's link took its host from the request. + * A name was also stripped of & and < instead of escaped. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const src = readFileSync(new URL('../server/routes/recipes.js', import.meta.url), 'utf8'); +const route = src.slice(src.indexOf("router.get('/:id/card.png'"), src.indexOf('function _textWrap')); +const fn = (name) => src.slice(src.indexOf(`function ${name}(`), src.indexOf('\n}\n', src.indexOf(`function ${name}(`)) + 2); +const helpers = (base = '') => new Function(`const _BASE_PATH = ${JSON.stringify(base)};\n${fn('_escapeXml')}\n${fn('_absoluteUrl')}\n${fn('_textWrap')}\nreturn { _escapeXml, _absoluteUrl, _textWrap };`)(); + +test('names and links are escaped, not stripped', () => { + const { _escapeXml, _textWrap } = helpers(); + assert.equal(_textWrap('Mac & Cheese <Deluxe>', 22), 'Mac & Cheese <Deluxe>'); + assert.equal(_escapeXml('x.jpg" onerror="alert(1)'), 'x.jpg" onerror="alert(1)'); + assert.match(route, /<image href="\$\{_escapeXml\(heroUrl\)\}"/); +}); + +test('an image link is a web address or an app path on this origin, nothing else', () => { + const h = helpers(); + assert.equal(h._absoluteUrl({}, 'https://img.test/a.jpg'), 'https://img.test/a.jpg'); + assert.equal(h._absoluteUrl({}, '/uploads/a.jpg'), '/uploads/a.jpg'); + assert.equal(h._absoluteUrl({}, 'javascript:alert(1)'), ''); + assert.equal(h._absoluteUrl({}, '//evil.test/a.jpg'), ''); + assert.equal(helpers('/cook')._absoluteUrl({}, '/uploads/a.jpg'), '/cook/uploads/a.jpg', 'under BASE_URL'); + assert.equal(helpers('/cook')._absoluteUrl({}, '/cook/uploads/a.jpg'), '/cook/uploads/a.jpg', 'not twice'); + assert.doesNotMatch(fn('_absoluteUrl'), /x-forwarded-host|headers\.host/); +}); + +test('who may see the card is who may see the recipe', () => { + assert.match(route, /const isOwner = \(u == null && recipe\.user_id == null\) \|\| recipe\.user_id === u;/); + assert.match(route, /SELECT 1 FROM recipe_shares WHERE recipe_id = \? AND grantee_id = \?/); + assert.match(route, /if \(!isOwner && !isShared && recipe\.visibility !== 'group'\)/); +}); + +test('the card is private to caches and cannot run script', () => { + assert.match(route, /res\.setHeader\('Cache-Control', 'private, no-store'\);/); + assert.match(route, /Content-Security-Policy', "default-src 'none'; img-src 'self' https: http: data:; style-src 'unsafe-inline'; sandbox"/); + assert.doesNotMatch(route, /'public, max-age/); +}); diff --git a/scripts/share-link-smoke.mjs b/scripts/share-link-smoke.mjs new file mode 100644 index 00000000..7b7f22d5 --- /dev/null +++ b/scripts/share-link-smoke.mjs @@ -0,0 +1,73 @@ +/** + * share-link-smoke.mjs: public recipe links end to end. + * + * Run ONLY against a throwaway instance: it registers accounts and creates + * recipes. + * + * docker run -d -p 3003:3003 -e JWT_SECRET=... <image> + * COOKTRACE_URL=http://localhost:3003 node scripts/share-link-smoke.mjs + */ +const B = process.env.COOKTRACE_URL || 'http://localhost:3003'; +let token = ''; +let failures = 0; +const ok = (cond, msg) => { if (cond) console.log(' ok ', msg); else { failures++; console.log(' FAIL', msg); } }; +async function api(method, path, body, tok = token) { + const res = await fetch(B + path, { + method, + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; try { json = JSON.parse(text); } catch { json = text; } + return { status: res.status, json }; +} + +await api('POST', '/api/auth/register', { username: 'admin', password: 'Phase1!Test', full_name: 'Admin' }); +token = (await api('POST', '/api/auth/login', { username: 'admin', password: 'Phase1!Test' })).json.token; +ok(!!token, 'login'); +const inv = (await api('POST', '/api/auth/invite', { role: 'user' })).json; +const invToken = new URL(inv.inviteUrl.replace('/#/', '/')).searchParams.get('token'); +await api('POST', '/api/auth/accept-invite', { token: invToken, username: 'sam', password: 'Share!Test9', full_name: 'Sam' }); +const sam = (await api('POST', '/api/auth/login', { username: 'sam', password: 'Share!Test9' })).json.token; +ok(!!sam, 'second account signs in'); + +console.log('public link'); +const r = (await api('POST', '/api/recipes', { name: 'Lime rice', description: 'Bright jasmine rice.', servings: 4, img_url: '/uploads/lime.jpg', + ingredients: [{ name: 'Jasmine rice', qty: 2, unit: 'cup' }], steps: ['Rinse', 'Simmer 15 minutes'] })).json; +ok((await api('POST', `/api/recipes/${r.id}/share`, null, sam)).status === 403, 'another account cannot make a link'); +const first = (await api('POST', `/api/recipes/${r.id}/share`)).json.share_token; +ok(/^[A-Za-z0-9_-]{22}$/.test(first || ''), 'owner makes a link'); +const pub = await api('GET', `/api/r/${first}`, null, ''); +ok(pub.status === 200 && pub.json.name === 'Lime rice', 'anyone with the link reads the recipe without signing in'); +const leaked = ['user_id', 'visibility', 'share_token', 'favorite'].filter(k => k in pub.json); +ok(leaked.length === 0, `public view leaks no account fields (leaked: ${leaked.join() || 'none'})`); +ok((await api('GET', '/api/r/short', null, '')).status === 400 && (await api('GET', `/api/r/${first.slice(0, -1)}x`, null, '')).status === 404, 'a wrong token reads nothing'); + +const page = await fetch(`${B}/r/${first}`); +const html = await page.text(); +ok(page.status === 200 && html.includes('<meta property="og:title" content="Lime rice" />') && html.includes('og:image" content="http'), 'the link page carries the preview tags'); +ok(html.includes('<base href="/" />') && page.headers.get('referrer-policy') === 'no-referrer' && /noindex/.test(page.headers.get('x-robots-tag') || ''), 'the page loads from the root and keeps the token private'); +ok((await fetch(`${B}/r/AAAAAAAAAAAAAAAAAAAAAA`)).status === 404, 'an unknown link page is a 404'); + +const xss = (await api('POST', '/api/recipes', { name: '"><script>alert(1)</script>', description: '<img src=x onerror=alert(1)>' })).json; +const xssTok = (await api('POST', `/api/recipes/${xss.id}/share`)).json.share_token; +const xssHtml = await (await fetch(`${B}/r/${xssTok}`)).text(); +ok(!xssHtml.includes('<script>alert(1)') && !xssHtml.includes('<img src=x'), 'preview tags are escaped'); + +// A device still holding the link after it was removed must not bring it back. +await api('DELETE', `/api/recipes/${r.id}/share`); +ok((await api('GET', `/api/r/${first}`, null, '')).status === 404, 'removing the link stops it at once'); +const push = await api('POST', '/api/sync/push', { tables: { recipes: [{ client_id: 1, server_id: r.id, name: 'Lime rice', share_token: first, updated_at: '2099-01-01 00:00:00' }] } }); +ok(push.status === 200 && (await api('GET', `/api/r/${first}`, null, '')).status === 404, 'a stale device cannot revive a removed link'); +const chosen = await api('POST', '/api/sync/push', { tables: { recipes: [{ client_id: 2, name: 'Pushed', share_token: 'chosen-by-device-1', updated_at: '2099-01-01 00:00:00' }] } }); +ok(chosen.status === 200 && (await api('GET', '/api/r/chosen-by-device-1', null, '')).status === 404, 'a device cannot choose a link'); +const second = (await api('POST', `/api/recipes/${r.id}/share`)).json.share_token; +const pull = (await api('GET', '/api/sync/pull?since=1970-01-01')).json; +ok(pull.tables.recipes.find(x => x.id === r.id)?.share_token === second, 'devices still learn the link through sync'); +ok(second !== first && (await api('GET', `/api/r/${first}`, null, '')).status === 404, 'a new link never revives the old one'); + +await api('DELETE', `/api/recipes/${r.id}`); +ok((await api('GET', `/api/r/${second}`, null, '')).status === 404, 'a deleted recipe is not public'); + +console.log(failures ? `\n${failures} FAILED` : '\nall passed'); +process.exit(failures ? 1 : 0); diff --git a/scripts/smart-cookbook.test.js b/scripts/smart-cookbook.test.js new file mode 100644 index 00000000..3952e21f --- /dev/null +++ b/scripts/smart-cookbook.test.js @@ -0,0 +1,99 @@ +/** + * Smart cookbooks (#76). + * + * The count a cookbook shows in the list must be what opening it shows: + * for a smart cookbook, what its filter matches (it has no links, so it + * read 0), and for a plain one, links to recipes that still exist + * (deleting a recipe keeps its link). The Android app's own database + * works a smart cookbook out by the same rules as the server, and editing + * one there keeps it smart. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { matchesSmartFilter, parseSmartFilter } from '../src/lib/smart-cookbook.js'; + +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); +const serverJs = read('../server/routes/cookbooks.js'); +const nativeJs = read('../src/lib/api-native.js'); + +const recipe = (o = {}) => ({ + id: 1, deleted_at: null, category_id: null, favorite: 0, rating: null, + prep_minutes: null, cook_minutes: null, tags: '[]', ...o, +}); + +test('the server and the app share one copy of the rules', () => { + assert.equal(read('../src/lib/smart-cookbook.js'), read('../server/lib/smart-cookbook.js')); +}); + +test('an empty filter holds every recipe that is not deleted', () => { + assert.equal(matchesSmartFilter(recipe(), {}), true); + assert.equal(matchesSmartFilter(recipe({ deleted_at: '2026-10-05' }), {}), false); + assert.equal(matchesSmartFilter(recipe(), null), false); + assert.equal(matchesSmartFilter(recipe(), 5), false); +}); + +test('tags match without regard to case, and every tag is needed', () => { + const f = { tags: ['quick', 'Veg'] }; + assert.equal(matchesSmartFilter(recipe({ tags: '["Quick","veg","kids"]' }), f), true); + assert.equal(matchesSmartFilter(recipe({ tags: ['QUICK', 'VEG'] }), f), true, 'tags already parsed'); + assert.equal(matchesSmartFilter(recipe({ tags: '["quick"]' }), f), false); + assert.equal(matchesSmartFilter(recipe({ tags: 'not json' }), f), false); + assert.equal(matchesSmartFilter(recipe({ tags: null }), f), false); +}); + +test('category, favorites, rating and time match like the old SQL', () => { + assert.equal(matchesSmartFilter(recipe({ category_id: 3 }), { category_id: 3 }), true); + assert.equal(matchesSmartFilter(recipe({ category_id: 4 }), { category_id: 3 }), false); + assert.equal(matchesSmartFilter(recipe(), { category_id: 3 }), false, 'no category never matches one'); + assert.equal(matchesSmartFilter(recipe({ favorite: 1 }), { favorites_only: true }), true); + assert.equal(matchesSmartFilter(recipe({ favorite: 0 }), { favorites_only: true }), false); + assert.equal(matchesSmartFilter(recipe({ rating: 4 }), { min_rating: 4 }), true); + assert.equal(matchesSmartFilter(recipe({ rating: 3 }), { min_rating: 4 }), false); + assert.equal(matchesSmartFilter(recipe({ rating: null }), { min_rating: 0 }), false, 'unrated is not rated 0'); + assert.equal(matchesSmartFilter(recipe({ prep_minutes: 10, cook_minutes: 20 }), { max_total_minutes: 30 }), true); + assert.equal(matchesSmartFilter(recipe({ prep_minutes: 10, cook_minutes: 21 }), { max_total_minutes: 30 }), false); + assert.equal(matchesSmartFilter(recipe({ prep_minutes: null, cook_minutes: null }), { max_total_minutes: 0 }), true, 'missing times count as 0'); +}); + +test('a saved filter is read from the row', () => { + assert.deepEqual(parseSmartFilter('{"tags":["quick"]}'), { tags: ['quick'] }); + assert.equal(parseSmartFilter(null), null); + assert.equal(parseSmartFilter(''), null); + assert.equal(parseSmartFilter('{broken'), null); +}); + +test('the server counts what opening the cookbook shows', () => { + assert.doesNotMatch(serverJs, /COUNT\(\*\) FROM recipe_cookbook_links l\s+WHERE l\.cookbook_id = c\.id/, + 'counting links alone gives a smart cookbook 0 and counts deleted recipes'); + const count = serverJs.slice(serverJs.indexOf('function _recipeCount')); + const body = count.slice(0, count.indexOf('\n}')); + assert.match(body, /if \(cb\.is_smart\)/); + assert.match(body, /matchesSmartFilter/); + assert.match(body, /JOIN recipes r ON r\.id = l\.recipe_id[\s\S]*r\.deleted_at IS NULL/); + for (const route of ["router.get('/',", "router.get('/shared-with-me',", "router.post('/',", "router.put('/:id',"]) { + const r = serverJs.slice(serverJs.indexOf(route)); + assert.match(r.slice(0, r.indexOf('\n}));')), /_recipeCount\(/, `${route} counts with _recipeCount`); + } + const open = serverJs.slice(serverJs.indexOf("router.get('/:id',")); + assert.match(open.slice(0, open.indexOf('\n}));')), /_evalSmartFilter\(cb\.user_id, parseSmartFilter\(cb\.smart_filter_json\) \|\| \{\}\)/); +}); + +test('the Android app opens and counts a smart cookbook by its filter', () => { + assert.match(nativeJs, /import \{[^}]*\bmatchesSmartFilter\b[^}]*\} from '\.\/smart-cookbook\.js'/); + assert.doesNotMatch(nativeJs, /SELECT COUNT\(\*\) FROM recipe_cookbook_links l WHERE l\.cookbook_id = c\.id/); + const open = nativeJs.slice(nativeJs.indexOf('async getCookbook(id)')); + assert.match(open.slice(0, open.indexOf('\n },')), /out\.is_smart\s*\? await _smartCookbookRecipes\(out\.smart_filter \|\| \{\}\)/); + const list = nativeJs.slice(nativeJs.indexOf('async getCookbooks()')); + assert.match(list.slice(0, list.indexOf('\n },')), /recipe_count = await _cookbookCount\(cb, cache\)/); +}); + +test('editing a cookbook in the Android app keeps what was not sent', () => { + const upd = nativeJs.slice(nativeJs.indexOf('async updateCookbook(id, d)')); + const body = upd.slice(0, upd.indexOf('\n },')); + assert.match(body, /d\.name != null \? [^:]+: existing\.name/); + assert.match(body, /d\.cover_image_url !== undefined \? [^:]+: existing\.cover_image_url/); + assert.match(body, /d\.is_smart !== undefined \? _bool\(d\.is_smart\) : existing\.is_smart/); + assert.match(body, /let filterJson = existing\.smart_filter_json/); + assert.doesNotMatch(body, /\[d\.name, d\.description \|\| null/, 'a rename blanked the filter and a cover change blanked the name'); +}); diff --git a/scripts/ssrf-fetch-checked.test.js b/scripts/ssrf-fetch-checked.test.js new file mode 100644 index 00000000..cccbead4 --- /dev/null +++ b/scripts/ssrf-fetch-checked.test.js @@ -0,0 +1,155 @@ +// fetchChecked(): the one way the server fetches an address a user chose. +// The same test in all four Trace apps, like server/lib/ssrf-guard.js. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import http from 'node:http'; +import dns from 'node:dns/promises'; +import { fetchChecked, serviceBase, isLinkLocalOrCloudMeta, isPrivateOrLoopback, isSameServer, readBody } from '../server/lib/ssrf-guard.js'; + +async function server(handler) { + const s = http.createServer(handler); + await new Promise(r => s.listen(0, '127.0.0.1', r)); + return { port: s.address().port, close: () => s.close() }; +} + +test('connects only to the address it checked, never a second lookup', async () => { + // The check sees 127.0.0.1 for a name the real resolver doesn't know. + // Reaching the local server proves the connection used that answer + // instead of resolving the name again (where an attacker's DNS could + // answer differently). + const s = await server((req, res) => { res.writeHead(200); res.end('pinned'); }); + const real = dns.lookup; + dns.lookup = async (host, opts) => host === 'pinned.invalid' + ? (opts?.all ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 }) + : real(host, opts); + try { + const res = await fetchChecked(`http://pinned.invalid:${s.port}/`, {}, { allowPrivate: true }); + assert.equal(await res.text(), 'pinned'); + } finally { dns.lookup = real; s.close(); } +}); + +test('refuses the home network unless allowed, and cloud metadata always', async () => { + const s = await server((req, res) => { res.writeHead(200); res.end('inside'); }); + try { + await assert.rejects(fetchChecked(`http://127.0.0.1:${s.port}/`, {}, { allowPrivateEnvHint: 'ALLOW_PRIVATE_TEST' }), /ALLOW_PRIVATE_TEST/); + assert.equal(await (await fetchChecked(`http://127.0.0.1:${s.port}/`, {}, { allowPrivate: true })).text(), 'inside'); + await assert.rejects(fetchChecked('http://169.254.169.254/latest/meta-data/', {}, { allowPrivate: true }), /cloud-metadata/); + } finally { s.close(); } +}); + +test('checks every redirect hop, and follows none unless asked', async () => { + const inside = await server((req, res) => { res.writeHead(200); res.end('inside'); }); + const hop = await server((req, res) => { + const to = req.url === '/meta' ? 'http://169.254.169.254/' : `http://127.0.0.1:${inside.port}/`; + res.writeHead(302, { location: to }); res.end(); + }); + try { + // No redirects asked for: the 3xx comes back as it is. + assert.equal((await fetchChecked(`http://127.0.0.1:${hop.port}/`, {}, { allowPrivate: true })).status, 302); + // Followed, each hop checked: allowed inside, cloud metadata never. + const ok = await fetchChecked(`http://127.0.0.1:${hop.port}/`, {}, { allowPrivate: true, maxRedirects: 3 }); + assert.equal(await ok.text(), 'inside'); + await assert.rejects(fetchChecked(`http://127.0.0.1:${hop.port}/meta`, {}, { allowPrivate: true, maxRedirects: 3 }), /cloud-metadata/); + } finally { hop.close(); inside.close(); } +}); + +test('stops after the redirect limit', async () => { + let n = 0; + const loop = await server((req, res) => { n++; res.writeHead(302, { location: `/again${n}` }); res.end(); }); + try { + const res = await fetchChecked(`http://127.0.0.1:${loop.port}/`, {}, { allowPrivate: true, maxRedirects: 2 }); + assert.equal(res.status, 302); + assert.equal(n, 3); + } finally { loop.close(); } +}); + +test('redirects keep or drop the request body as fetch does', async () => { + const seen = []; + const s = await server((req, res) => { + let raw = ''; req.on('data', c => raw += c); req.on('end', () => { + seen.push(`${req.method} ${req.url} ${raw}`); + if (req.url === '/307') { res.writeHead(307, { location: '/end' }); return res.end(); } + if (req.url === '/302') { res.writeHead(302, { location: '/end' }); return res.end(); } + res.writeHead(200); res.end('ok'); + }); + }); + try { + const post = { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"a":1}' }; + await fetchChecked(`http://127.0.0.1:${s.port}/307`, post, { allowPrivate: true, maxRedirects: 3 }); + await fetchChecked(`http://127.0.0.1:${s.port}/302`, post, { allowPrivate: true, maxRedirects: 3 }); + assert.deepEqual(seen, ['POST /307 {"a":1}', 'POST /end {"a":1}', 'POST /302 {"a":1}', 'GET /end ']); + } finally { s.close(); } +}); + +test('credentials stay with their origin on a redirect', async () => { + const seen = []; + const other = await server((req, res) => { seen.push(`other auth=${req.headers.authorization || '-'} token=${req.headers['x-emby-token'] || '-'}`); res.writeHead(200); res.end('other'); }); + const first = await server((req, res) => { + seen.push(`first auth=${req.headers.authorization || '-'}`); + // localhost vs 127.0.0.1: same machine, different origin. + res.writeHead(307, { location: `http://localhost:${other.port}/` }); res.end(); + }); + try { + const headers = { authorization: 'Bearer secret', 'x-emby-token': 'tok' }; + const res = await fetchChecked(`http://127.0.0.1:${first.port}/`, { headers }, { allowPrivate: true, maxRedirects: 3 }); + assert.equal(await res.text(), 'other'); + // Authorization dropped across origins; a custom header is the caller's to protect... + assert.deepEqual(seen, ['first auth=Bearer secret', 'other auth=- token=tok']); + // ...which sameOrigin does, by not following at all. + const stopped = await fetchChecked(`http://127.0.0.1:${first.port}/`, { headers }, { allowPrivate: true, maxRedirects: 3, sameOrigin: true }); + assert.equal(stopped.status, 307); + assert.equal(seen.length, 3); + } finally { first.close(); other.close(); } +}); + +test('bracketed IPv6 addresses work when allowed', async () => { + const s = http.createServer((req, res) => { res.writeHead(200); res.end('v6'); }); + await new Promise(r => s.listen(0, '::1', r)); + try { + assert.equal(await (await fetchChecked(`http://[::1]:${s.address().port}/`, {}, { allowPrivate: true })).text(), 'v6'); + await assert.rejects(fetchChecked(`http://[::1]:${s.address().port}/`), /Private/); + } finally { s.close(); } +}); + +test('every cloud metadata address is always refused', () => { + for (const ip of ['169.254.169.254', '100.100.100.200', '168.63.129.16', 'fd00:ec2::254', '::ffff:169.254.169.254', '64:ff9b::a9fe:a9fe', '64:ff9b::169.254.169.254']) { + assert.equal(isLinkLocalOrCloudMeta(ip), true, ip); + } + for (const ip of ['64:ff9b::7f00:1', '::ffff:10.0.0.1', '192.0.0.8', '224.0.0.1', '255.255.255.255', 'ff02::1']) { + assert.equal(isPrivateOrLoopback(ip), true, ip); + } + assert.equal(isPrivateOrLoopback('64:ff9b::808:808'), false, 'NAT64 of a public address'); + // Fake-IP DNS (Clash, sing-box) answers every public name with 198.18.x.x. + assert.equal(isPrivateOrLoopback('198.18.0.7'), false); +}); + +test('a service address keeps only its origin and path', () => { + assert.equal(serviceBase('http://ntfy.lan:8080/'), 'http://ntfy.lan:8080'); + assert.equal(serviceBase('http://docker:2375/containers/json?x='), 'http://docker:2375/containers/json'); + assert.equal(serviceBase('https://jelly.example/jf#frag'), 'https://jelly.example/jf'); + assert.equal(serviceBase('ftp://x'), null); + assert.equal(serviceBase('not a url'), null); +}); + +test('a redirect stays on the same server only for its origin or an https upgrade', () => { + assert.equal(isSameServer('http://music.lan/rest/ping', 'http://music.lan/rest/x'), true); + assert.equal(isSameServer('http://music.lan/rest/ping', 'https://music.lan/rest/ping'), true); + assert.equal(isSameServer('http://music.lan:4533/', 'https://music.lan/'), false, 'a port changes the server'); + assert.equal(isSameServer('https://music.lan/', 'http://music.lan/'), false, 'never a downgrade'); + assert.equal(isSameServer('http://music.lan/', 'https://evil.example/'), false); + assert.equal(isSameServer('http://127.0.0.1:1/', 'http://localhost:1/'), false); +}); + +test('control characters in an address are refused before parsing', async () => { + // A tab, CR or LF would vanish in parsing and turn ".\t." into "..". + for (const u of ['http://127.0.0.1/api/.\t./admin', 'http://127.0.0.1/a\nb', 'http://127.0.0.1/a\rb', 'http://127.0.0.1/\u0000']) + await assert.rejects(fetchChecked(u, {}, { allowPrivate: true }), /Invalid URL/, JSON.stringify(u)); +}); + +test('a reply body is read up to a cap', async () => { + const s = await server((req, res) => { res.writeHead(200); res.end('x'.repeat(5000)); }); + try { + assert.equal((await readBody(await fetchChecked(`http://127.0.0.1:${s.port}/`, {}, { allowPrivate: true }), 10000)).length, 5000); + await assert.rejects(async () => readBody(await fetchChecked(`http://127.0.0.1:${s.port}/`, {}, { allowPrivate: true }), 1000), /too large/); + } finally { s.close(); } +}); diff --git a/scripts/sync-conflict.harness.mjs b/scripts/sync-conflict.harness.mjs new file mode 100644 index 00000000..b2c7f84e --- /dev/null +++ b/scripts/sync-conflict.harness.mjs @@ -0,0 +1,753 @@ +// Run by scripts/sync-conflict.test.js in a child process, with DB_PATH set +// to a scratch database: the server's real sync and recipe routes on its real +// schema (single-user mode, so no sign-in), driven over HTTP the way the +// Android app and the web app drive them. Prints what it saw as JSON. +import { createRequire } from 'node:module'; + +const require = createRequire(new URL('../server/package.json', import.meta.url)); +const express = require('express'); +const { default: db } = await import('../server/db.js'); +const { default: syncRoutes } = await import('../server/routes/sync.js'); +const { default: recipeRoutes } = await import('../server/routes/recipes.js'); +const { default: pantryRoutes } = await import('../server/routes/pantry.js'); +const { default: shoppingRoutes } = await import('../server/routes/shopping.js'); +const { default: unitRoutes } = await import('../server/routes/units.js'); +const { default: cookbookRoutes } = await import('../server/routes/cookbooks.js'); + +const app = express(); +app.use(express.json({ limit: '5mb' })); +app.use('/api/sync', syncRoutes); +app.use('/api/recipes', recipeRoutes); +app.use('/api/pantry', pantryRoutes); +app.use('/api/shopping', shoppingRoutes); +app.use('/api/units', unitRoutes); +app.use('/api/cookbooks', cookbookRoutes); +const server = app.listen(0); +await new Promise(r => server.once('listening', r)); +const base = `http://127.0.0.1:${server.address().port}`; +const raw = async (method, path, body) => { + const r = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); + const t = await r.text(); + return { status: r.status, body: t ? JSON.parse(t) : null }; +}; +// Recipe saves as the web app makes them: with the stamp of the copy the +// page had and the fields the save changes on it (offline-edits.js), unless +// marked _plain (an older page). +const { recipeChangedFields, recipeBaseOf, recipeSaveBase, withSaveBase, collapseOps, answerWithOps, saveBase, stampedRows, readTable } = await import('../src/lib/offline-edits.js'); +const { stampFields } = await import('../server/lib/field-stamps.js'); +const j = async (method, path, body) => { + if (method === 'PUT' && /^\/api\/recipes\/\d+$/.test(path) && body && !body._sync) { + if (body._plain) { const { _plain, ...rest } = body; return raw(method, path, rest); } + const page = (await raw('GET', path)).body; + const now = new Date().toISOString(); + return raw(method, path, { ...body, _sync: { base_synced_at: page.synced_at, changed: recipeChangedFields(body, recipeBaseOf(page)), edited_at: now, client_now: now } }); + } + return raw(method, path, body); +}; +const sql = (q, ...a) => db.prepare(q).get(...a); +const fmt = ms => new Date(ms).toISOString().replace('T', ' ').slice(0, 19); +const ago = min => fmt(Date.now() - min * 60000); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const out = {}; + +// Rows made a day ago, so edit times in the scenarios (minutes ago) come +// after them, as real ones do. +const backdate = (table, id) => db.prepare(`UPDATE ${table} SET created_at = datetime('now', '-1 day') WHERE id = ?`).run(id); +async function newRecipe(name) { + const r = await j('POST', '/api/recipes', { name, ingredients: [{ items: [{ name: 'water' }] }], steps: ['Boil'], notes: 'web' }); + backdate('recipes', r.body.id); + return r.body.id; +} +// What the phone holds for a recipe: its row as the pull hands it over. +async function pulled(id) { + const p = await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1'); + return p.body.tables.recipes.find(r => r.id === id); +} +// One row as the phone pushes it. +function phoneRow(copy, changes, { base = copy.synced_at } = {}) { + const row = { ...copy, ...changes, client_id: 900 + copy.id, server_id: copy.id }; + delete row.id; delete row.synced_at; + if (base != null) row.server_synced_at = base; + return row; +} +const push = (rows, extra = {}) => j('POST', '/api/sync/push', { tables: { recipes: rows }, fk_ids: 'server', ...extra }); +const now = () => ({ client_now: new Date().toISOString() }); +const versions = async id => (await j('GET', `/api/recipes/${id}/versions`)).body; + +// The pull only sends the stamp to apps that ask for it. +{ + const id = await newRecipe('Plain'); + const old = await j('GET', '/api/sync/pull?since=1970-01-01'); + out.pullWithoutFlag = Object.keys(old.body.tables.recipes.find(r => r.id === id)); + out.pullWithFlag = Object.keys(await pulled(id)); +} + +// 1. An offline phone edit, older than a web edit made since: the web's stays. +{ + const id = await newRecipe('Soup'); + const copy = await pulled(id); + await sleep(20); + await j('PUT', `/api/recipes/${id}`, { name: 'Soup (web)', ingredients: [{ items: [{ name: 'water' }] }], steps: ['Boil'], notes: 'web edit' }); + const stampBefore = sql('SELECT synced_at FROM recipes WHERE id = ?', id).synced_at; + await sleep(20); + const res = await push([phoneRow(copy, { name: 'Soup (phone)', notes: 'phone edit', updated_at: ago(60) })], now()); + const after = sql('SELECT name, notes, synced_at FROM recipes WHERE id = ?', id); + out.olderPhone = { + result: res.body.tables.recipes[0], + server: after.name, restamped: after.synced_at > stampBefore, + versions: (await versions(id)).map(v => ({ reason: v.reason, seen: v.seen, name: v.data.name, notes: v.data.notes })), + }; +} + +// 2. The reverse: the phone's edit is newer, and the web's copy it replaces is kept. +{ + const id = await newRecipe('Stew'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Stew (web)', ingredients: [], steps: ['Simmer'], notes: 'web' }); + const res = await push([phoneRow(copy, { name: 'Stew (phone)', updated_at: fmt(Date.now()) })], now()); + out.newerPhone = { + result: res.body.tables.recipes[0], + server: sql('SELECT name FROM recipes WHERE id = ?', id).name, + versions: (await versions(id)).map(v => v.data.name), + }; +} + +// 3. No conflict: an edit on the copy the server still has goes in, however +// old its clock says it is, and nothing is kept. +{ + const id = await newRecipe('Bread'); + const copy = await pulled(id); + const res = await push([phoneRow(copy, { name: 'Bread (phone)', updated_at: ago(600) })], now()); + const second = await push([phoneRow({ ...copy, synced_at: res.body.tables.recipes[0].synced_at }, { name: 'Bread (phone again)', updated_at: ago(599) })], now()); + out.noConflict = { + result: res.body.tables.recipes[0], second: second.body.tables.recipes[0], + server: sql('SELECT name FROM recipes WHERE id = ?', id).name, versions: (await versions(id)).length, + }; +} + +// 4. A phone clock ten minutes slow or fast still picks the right winner. +for (const [label, skewMin, editAgoMin, want] of [ + ['slowNewer', -10, -0.1, 'phone'], // edited after the web, clock 10 minutes behind + ['fastOlder', 10, 2, 'web'], // edited 2 minutes before the web, clock 10 minutes ahead +]) { + const id = await newRecipe('Pie'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Pie (web)', ingredients: [], steps: [], notes: '' }); + const phoneNow = Date.now() + skewMin * 60000; + const res = await push([phoneRow(copy, { name: 'Pie (phone)', updated_at: fmt(phoneNow - editAgoMin * 60000) })], { client_now: new Date(phoneNow).toISOString() }); + out[label] = { want, got: sql('SELECT name FROM recipes WHERE id = ?', id).name === 'Pie (phone)' ? 'phone' : 'web', offset: res.body.clock_offset_ms }; +} + +// 5. client_now: missing or unreadable means an older app; any readable +// one is corrected, however far off (40 days slow here). +for (const [label, clientNow] of [['missing', undefined], ['garbage', 'yesterday'], ['farOff', new Date(Date.now() - 40 * 86400000).toISOString()]]) { + const res = await j('POST', '/api/sync/push', { tables: {}, client_now: clientNow }); + out[`offset_${label}`] = res.body.clock_offset_ms; +} + +// 6. An older app: no client_now, no stamp. Its edits go in, as before +// (it can't be judged), and each copy they replace is kept quietly. +{ + const id = await newRecipe('Rice'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Rice (web)', ingredients: [], steps: [], notes: '' }); + const first = await push([phoneRow(copy, { name: 'Rice (old app)', updated_at: ago(30) }, { base: null })]); + const second = await push([phoneRow(copy, { name: 'Rice (old app, newer)', updated_at: fmt(Date.now()) }, { base: null })]); + out.oldApp = { + first: first.body.tables.recipes[0], second: second.body.tables.recipes[0], + server: sql('SELECT name FROM recipes WHERE id = ?', id).name, + versions: (await versions(id)).map(v => `${v.reason}:${v.data.name}:${v.seen}`), + }; + // A pantry item edited on the web after an older app's offline edit: the + // older app's edit goes in, as it always has. + const p = (await j('POST', '/api/pantry', { name: 'Oats', notes: 'web' })).body; + db.prepare(`UPDATE pantry_items SET notes = 'web later', updated_at = ? WHERE id = ?`).run(fmt(Date.now()), p.id); + await j('POST', '/api/sync/push', { tables: { pantry_items: [{ client_id: 4, server_id: p.id, name: 'Oats', in_stock: 1, notes: 'old app', updated_at: ago(5) }] } }); + out.oldAppPantry = sql('SELECT notes FROM pantry_items WHERE id = ?', p.id).notes; +} + +// 7. Other tables, from apps that say their clock: newer edit wins, nothing kept. +{ + const vBefore = sql('SELECT COUNT(*) n FROM recipe_versions').n; + const p = (await j('POST', '/api/pantry', { name: 'Flour', notes: 'web' })).body; + backdate('pantry_items', p.id); + db.prepare(`UPDATE pantry_items SET notes = 'web later', updated_at = ? WHERE id = ?`).run(fmt(Date.now()), p.id); + stampFields('pantry_items', p.id, ['notes'], fmt(Date.now())); + const res = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [{ client_id: 5, server_id: p.id, name: 'Flour', notes: 'phone older', in_stock: 1, updated_at: ago(5) }] } }); + out.pantry = { result: res.body.tables.pantry_items[0], notes: sql('SELECT notes FROM pantry_items WHERE id = ?', p.id).notes, newVersions: sql('SELECT COUNT(*) n FROM recipe_versions').n - vBefore }; +} + +// 8. Restore, undo, seen, the cap, and the cascade. +{ + const id = await newRecipe('Curry'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Curry (web)', ingredients: [], steps: ['Web step'], notes: 'web' }); + await push([phoneRow(copy, { name: 'Curry (phone)', steps: JSON.stringify(['Phone step']), updated_at: ago(60) })], now()); + const [v] = await versions(id); + const unseenBefore = (await versions(id)).filter(x => !x.seen).length; + await j('POST', `/api/recipes/${id}/versions/seen`); + const unseenAfter = (await versions(id)).filter(x => !x.seen).length; + const stampBefore = sql('SELECT synced_at, updated_at FROM recipes WHERE id = ?', id); + await sleep(1100); + const restored = await j('POST', `/api/recipes/${id}/versions/${v.id}/restore`); + const stampAfter = sql('SELECT synced_at, updated_at FROM recipes WHERE id = ?', id); + const backup = (await versions(id)).find(x => x.reason === 'restore'); + const undone = await j('POST', `/api/recipes/${id}/versions/${backup.id}/restore`); + const other = await newRecipe('Other'); + out.restore = { + restored: { name: restored.body.name, steps: restored.body.steps }, + syncsOut: stampAfter.synced_at > stampBefore.synced_at && stampAfter.updated_at > stampBefore.updated_at, + backup: { name: backup.data.name, seen: backup.seen }, + undone: { name: undone.body.name, steps: undone.body.steps }, + unseenBefore, unseenAfter, + wrongRecipe: (await j('POST', `/api/recipes/${other}/versions/${v.id}/restore`)).status, + missing: (await j('GET', '/api/recipes/99999/versions')).status, + }; + const { saveRecipeVersion } = await import('../server/lib/recipe-versions.js'); + for (let i = 0; i < 25; i++) saveRecipeVersion(id, null, { name: `kept ${i}` }, { reason: i < 3 ? 'conflict' : 'replaced' }); + saveRecipeVersion(id, null, { name: 'newest' }); + const kept = await versions(id); + out.cap = { count: kept.length, newest: kept[0].data.name, conflictsKept: kept.filter(x => /^kept [012]$/.test(x.data.name)).length }; + db.prepare('DELETE FROM recipes WHERE id = ?').run(id); + out.cascade = sql('SELECT COUNT(*) n FROM recipe_versions WHERE recipe_id = ?', id).n; +} + +// 9. A delete on the phone leaves updated_at where it was: the delete is +// the edit, at the time it was made, and goes in after a web edit made +// before it. +{ + const p = (await j('POST', '/api/pantry', { name: 'Salt' })).body; + backdate('pantry_items', p.id); + db.prepare(`UPDATE pantry_items SET notes = 'web', updated_at = ? WHERE id = ?`).run(ago(60), p.id); + stampFields('pantry_items', p.id, ['notes'], ago(60)); + const res = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [{ client_id: 6, server_id: p.id, name: 'Salt', in_stock: 1, updated_at: ago(120), deleted_at: fmt(Date.now()) }] } }); + out.phoneDelete = { result: res.body.tables.pantry_items[0], deleted: sql('SELECT deleted_at FROM pantry_items WHERE id = ?', p.id).deleted_at != null }; +} + +// 10. A cook logged or a share link made on the web after an offline phone +// edit isn't an edit of the recipe: the phone's edit goes in, nothing kept. +{ + const id = await newRecipe('Chili'); + const copy = await pulled(id); + const edited = fmt(Date.now()); + await sleep(3100); + const cooked = await j('POST', `/api/recipes/${id}/cooked`, { date: '2026-10-06' }); + await j('POST', `/api/recipes/${id}/share`); + const res = await push([phoneRow(copy, { name: 'Chili (phone)', updated_at: edited, changed: ['name'] })], now()); + out.cookLogged = { cookStatus: cooked.status, result: res.body.tables.recipes[0], server: sql('SELECT name FROM recipes WHERE id = ?', id).name, versions: (await versions(id)).length }; +} + +// 11. Three devices. The web edits at 10:00; phone A's offline edit from +// 09:00 arrives and loses; phone B's offline edit from 10:30 arrives after +// and wins. A's lost push moves only the pull cursor, never the edit time. +{ + const id = await newRecipe('Tart'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Tart (web)', ingredients: [], steps: [], notes: '' }); + const web = ago(60); + db.prepare('UPDATE recipes SET updated_at = ? WHERE id = ?').run(web, id); + stampFields('recipes', id, ['name', 'notes', 'ingredients', 'steps'], web); + const a = await push([phoneRow(copy, { name: 'Tart (phone A)', updated_at: ago(120) })], now()); + const afterA = sql(`SELECT name, updated_at, json_extract(field_stamps, '$.name[1]') AS name_at FROM recipes WHERE id = ?`, id); + const bAt = ago(30); + const b = await push([phoneRow({ ...copy, client_id: 1 }, { name: 'Tart (phone B)', updated_at: bAt })], now()); + const afterB = sql('SELECT name, updated_at FROM recipes WHERE id = ?', id); + const p = (await j('POST', '/api/pantry', { name: 'Eggs' })).body; + backdate('pantry_items', p.id); + db.prepare(`UPDATE pantry_items SET notes = 'web', updated_at = ? WHERE id = ?`).run(web, p.id); + stampFields('pantry_items', p.id, ['notes'], web); + const pa = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [{ client_id: 8, server_id: p.id, name: 'Eggs', in_stock: 1, notes: 'phone A', updated_at: ago(120) }] } }); + const pAfterA = sql('SELECT notes, updated_at FROM pantry_items WHERE id = ?', p.id); + await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [{ client_id: 9, server_id: p.id, name: 'Eggs', in_stock: 1, notes: 'phone B', updated_at: bAt }] } }); + out.threeDevices = { + web, bAt, + a: a.body.tables.recipes[0].kept, afterA, b: b.body.tables.recipes[0].kept ?? 'in', afterB, + versions: (await versions(id)).map(v => v.data.name), + pantryA: pa.body.tables.pantry_items[0].kept, pAfterA, pantryB: sql('SELECT notes, updated_at FROM pantry_items WHERE id = ?', p.id), + }; +} + +// 12. What a push says it changed: only that goes in. A cook logged (no +// fields changed) after a web content edit, and a rating changed on the +// phone with the content changed on the web. +{ + const id = await newRecipe('Dal'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { name: 'Dal (web)', ingredients: [], steps: ['Web'], notes: 'web' }); + const res = await push([phoneRow(copy, { cook_count: 1, last_cooked_at: '2026-10-06', updated_at: fmt(Date.now()), changed: [] })], now()); + const older = await newRecipe('Dal 2'); + const copy2 = await pulled(older); + await j('PUT', `/api/recipes/${older}`, { name: 'Dal 2 (web)', ingredients: [], steps: [], notes: '' }); + const meta = { rating: copy2.rating, favorite: copy2.favorite, visibility: copy2.visibility }; + const res2 = await push([phoneRow(copy2, { rating: 3, updated_at: ago(60), changed: ['rating'], base_meta: meta })], now()); + out.changedOnly = { + result: res.body.tables.recipes[0].kept, row: sql('SELECT name, steps, cook_count FROM recipes WHERE id = ?', id), versions: (await versions(id)).length, + ratingResult: res2.body.tables.recipes[0].kept, ratingRow: sql('SELECT name, rating FROM recipes WHERE id = ?', older), ratingVersions: (await versions(older)).length, + }; +} + +// 13. The web app's saves say which copy they were made on (_sync), also +// when replayed after time with no connection. +{ + const page = async id => { const r = (await raw('GET', `/api/recipes/${id}`)).body; return { r, sync: { base_synced_at: r.synced_at } }; }; + const sent = (p, change, editedAt) => ({ ...p.r, ...change, _sync: { ...p.sync, changed: Object.keys(change), edited_at: editedAt, client_now: new Date().toISOString() } }); + // A save queued offline an hour ago meets a newer phone edit: the phone's stays. + const a = await newRecipe('Cake'); + const pa = await page(a); + const copyA = await pulled(a); + await push([phoneRow(copyA, { name: 'Cake (phone)', updated_at: ago(10) })], now()); + const lost = await raw('PUT', `/api/recipes/${a}`, sent(pa, { name: 'Cake (web, offline)' }, new Date(Date.now() - 3600000).toISOString())); + // A newer save replaces the phone's older one, which is kept. + const b = await newRecipe('Pasta'); + const pb = await page(b); + const copyB = await pulled(b); + await push([phoneRow(copyB, { name: 'Pasta (phone)', updated_at: ago(10) })], now()); + const won = await raw('PUT', `/api/recipes/${b}`, sent(pb, { name: 'Pasta (web)' }, new Date().toISOString())); + // A rating from a page opened before the phone's edit: the content stays the phone's. + const c = await newRecipe('Salad'); + const pc = await page(c); + const copyC = await pulled(c); + await push([phoneRow(copyC, { name: 'Salad (phone)', updated_at: ago(1) })], now()); + const rated = await raw('PUT', `/api/recipes/${c}`, sent(pc, { rating: 5 }, new Date().toISOString())); + // An editor's save leaves the favorite alone (it doesn't send one). + const f = await newRecipe('Fav'); + const pf0 = await page(f); + await raw('PUT', `/api/recipes/${f}`, sent(pf0, { favorite: true }, new Date().toISOString())); + const pf = await page(f); + const { favorite: _f, visibility: _v, ...editorBody } = pf.r; + await raw('PUT', `/api/recipes/${f}`, { ...editorBody, name: 'Fav (edited)', _sync: { ...pf.sync, changed: recipeChangedFields({ ...editorBody, name: 'Fav (edited)' }, recipeBaseOf(pf.r)), edited_at: new Date().toISOString(), client_now: new Date().toISOString() } }); + // A save without it (older pages) writes as before. + const d = await newRecipe('Toast'); + const plain = await j('PUT', `/api/recipes/${d}`, { name: 'Toast (plain)', ingredients: [], steps: [], _plain: true }); + out.webSave = { + lost: { kept: lost.body.kept, answer: lost.body.name, server: sql('SELECT name FROM recipes WHERE id = ?', a).name, versions: (await versions(a)).map(v => v.data.name) }, + won: { kept: won.body.kept ?? null, server: sql('SELECT name FROM recipes WHERE id = ?', b).name, versions: (await versions(b)).map(v => v.data.name) }, + rated: { kept: rated.body.kept ?? null, row: sql('SELECT name, rating FROM recipes WHERE id = ?', c), versions: (await versions(c)).length }, + editorKeepsFavorite: sql('SELECT name, favorite FROM recipes WHERE id = ?', f), + plain: plain.body.name, plainKept: (await versions(d)).map(v => `${v.reason}:${v.data.name}:${v.seen}`), + }; +} + +// 14. Two saves made offline in the browser, collapsed into one by the +// queue: a rename then a rating, and a rename then another edit. Both edits +// stay (the combined save says the first copy and every changed field). +for (const [label, second] of [['renameThenRate', { rating: 4 }], ['renameThenEdit', { notes: 'second edit' }]]) { + const id = await newRecipe('Pie'); + const kept = (await raw('GET', `/api/recipes/${id}`)).body; + const ops = []; + const at = new Date().toISOString(); + const body1 = { ...kept, name: 'Pie (renamed offline)', _base: recipeBaseOf(kept) }; + delete body1.synced_at; + ops.push({ seq: 1, kind: 'recipe-update', key: `recipe:${id}`, id, method: 'PUT', path: `/api/recipes/${id}`, body: withSaveBase(body1, null), sync: recipeSaveBase(body1, kept, at) }); + const shown = answerWithOps(`/api/recipes/${id}`, kept, ops); + const body2 = { ...shown, ...second, _base: recipeBaseOf(shown), ...(second.rating ? { _changed: ['rating'] } : {}) }; + ops.push({ seq: 2, kind: 'recipe-update', key: `recipe:${id}`, id, method: 'PUT', path: `/api/recipes/${id}`, body: withSaveBase(body2, null), sync: recipeSaveBase(body2, kept, at) }); + const sent = collapseOps(ops); + for (const op of sent) await raw('PUT', op.path, withSaveBase(op.body, op.sync)); + out[label] = { sent: sent.length, changed: sent[0].sync.changed.sort(), row: sql('SELECT name, rating, notes FROM recipes WHERE id = ?', id), versions: (await versions(id)).length }; +} + +// 15. A favorite from the Recipes list, whose copy is older than an edit +// made on another device since: only the favorite changes. +{ + const id = await newRecipe('Stew'); + const listRow = (await raw('GET', '/api/recipes')).body.find(x => x.id === id); + await sleep(1100); + await j('PUT', `/api/recipes/${id}`, { ...(await raw('GET', `/api/recipes/${id}`)).body, name: 'Stew (other device)', notes: 'new notes' }); + const body = { ...listRow, favorite: true, _base: recipeBaseOf(listRow), _changed: ['favorite'] }; + await raw('PUT', `/api/recipes/${id}`, withSaveBase(body, recipeSaveBase(body, null, new Date().toISOString()))); + out.listFavorite = { row: sql('SELECT name, notes, favorite FROM recipes WHERE id = ?', id), versions: (await versions(id)).length }; +} + +// 16. An edit pending on a phone across the app update (no stamp, no +// fields): it goes in when newer, and what it replaces is kept, quietly. +// A forged stamp from the future counts as none. +for (const [label, base] of [['noStamp', null], ['forgedStamp', '9999-01-01 00:00:00.000']]) { + const id = await newRecipe('Rice'); + const copy = await pulled(id); + await sleep(1100); + await j('PUT', `/api/recipes/${id}`, { ...(await raw('GET', `/api/recipes/${id}`)).body, name: 'Rice (web)', notes: 'web notes' }); + await sleep(1100); + const res = await push([phoneRow(copy, { rating: 5, updated_at: fmt(Date.now()) }, { base })], now()); + out[label] = { result: res.body.tables.recipes[0].kept ?? 'in', row: sql('SELECT name, rating FROM recipes WHERE id = ?', id), versions: (await versions(id)).map(v => `${v.reason}:${v.data.name}`) }; +} + +// 17. A reorder isn't an edit: updated_at stays; only sort_order is new. +{ + const a = (await raw('POST', '/api/recipes/categories', { name: 'A' })).body; + const b = (await raw('POST', '/api/recipes/categories', { name: 'B' })).body; + db.prepare(`UPDATE recipe_categories SET updated_at = '2020-01-01 00:00:00' WHERE id IN (?, ?)`).run(a.id, b.id); + const nameBefore = sql(`SELECT json_extract(field_stamps, '$.name') AS n FROM recipe_categories WHERE id = ?`, a.id).n; + const status = (await raw('PUT', '/api/recipes/categories/order', { ids: [b.id, a.id] })).status; + const row = sql(`SELECT updated_at, sort_order, json_extract(field_stamps, '$.sort_order[1]') AS sort_at, json_extract(field_stamps, '$.name') AS n FROM recipe_categories WHERE id = ?`, a.id); + out.reorder = { status, updated_at: row.updated_at, sort_order: row.sort_order, sortStampedNow: row.sort_at > '2026-01-01', nameUntouched: row.n === nameBefore }; +} + +// 18. Other tables merge field by field too: an item checked off on a phone +// and renamed on the web since are both kept. +{ + const it = (await raw('POST', '/api/shopping', { name: 'Milk' })).body; + backdate('shopping_list', it.id); + const copy = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.shopping_list.find(r => r.id === it.id); + await sleep(1100); + await raw('PUT', `/api/shopping/${it.id}`, { ...copy, name: 'Oat milk' }); + const res = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { shopping_list: [{ ...copy, id: undefined, client_id: 3, server_id: it.id, server_synced_at: copy.synced_at, checked: 1, changed: ['checked'], updated_at: ago(0.5) }] } }); + out.shoppingFields = { result: res.body.tables.shopping_list[0].kept ?? 'in', row: sql('SELECT name, checked FROM shopping_list WHERE id = ?', it.id) }; +} + +// 19. updated_at never goes back: an older save's rating goes in, its +// content loses to a newer edit, and the row keeps the newer time. +{ + const id = await newRecipe('Pasta'); + const s0 = (await raw('GET', `/api/recipes/${id}`)).body; + const old = new Date(Date.now() - 60_000).toISOString(); + await sleep(1100); + await j('PUT', `/api/recipes/${id}`, { ...s0, notes: 'newer' }); + const before = sql('SELECT updated_at FROM recipes WHERE id = ?', id).updated_at; + await raw('PUT', `/api/recipes/${id}`, { ...s0, notes: 'older', rating: 3, _sync: { base_synced_at: s0.synced_at, changed: ['notes', 'rating'], edited_at: old, client_now: new Date().toISOString() } }); + out.notBackwards = { before, after: sql('SELECT updated_at, notes, rating FROM recipes WHERE id = ?', id), versions: (await versions(id)).map(v => v.data.notes) }; +} + +// 20. The other way round, quietly: the server's copy wins one field, the +// edit's other field still goes in, and nothing is kept when nothing of +// the edit's content was lost. +{ + const id = await newRecipe('Bun'); + const copy = await pulled(id); + await sleep(1100); + await j('PUT', `/api/recipes/${id}`, { ...(await raw('GET', `/api/recipes/${id}`)).body, rating: 5 }); + const res = await push([phoneRow(copy, { rating: 2, notes: 'phone notes', updated_at: ago(30), changed: ['rating', 'notes'] })], now()); + out.symmetric = { result: res.body.tables.recipes[0].kept ?? 'in', row: sql('SELECT notes, rating FROM recipes WHERE id = ?', id), versions: (await versions(id)).length }; +} + +// 21. A device clock that jumped to the year 9999 after the edit: moved by +// that, the edit would be centuries old. It can't be judged, so it goes in +// as made now rather than being dropped, and for a recipe the copy it +// replaced is kept. +{ + const p = (await raw('POST', '/api/pantry', { name: 'Flour' })).body; + const res = await j('POST', '/api/sync/push', { fk_ids: 'server', client_now: '9999-01-01T00:00:00Z', tables: { pantry_items: [{ client_id: 9, server_id: p.id, name: 'Flour (phone)', changed: ['name'], updated_at: ago(1) }] } }); + const id = await newRecipe('Loaf'); + const copy = await pulled(id); + await j('PUT', `/api/recipes/${id}`, { ...(await raw('GET', `/api/recipes/${id}`)).body, name: 'Loaf (web)' }); + const r = await push([phoneRow(copy, { name: 'Loaf (phone)', updated_at: ago(1), changed: ['name'] })], { client_now: '9999-01-01T00:00:00Z' }); + out.absurdClock = { + pantry: { result: res.body.tables.pantry_items[0].kept ?? 'in', name: sql('SELECT name, updated_at FROM pantry_items WHERE id = ?', p.id) }, + recipe: { result: r.body.tables.recipes[0].kept ?? 'in', name: sql('SELECT name FROM recipes WHERE id = ?', id).name, versions: (await versions(id)).map(v => `${v.reason}:${v.data.name}`) }, + }; +} + +// 22. Rows from before the field stamps, in every table: a field the phone +// changed offline goes in after the web changed another field of the row +// (or only touched the row), and nothing is kept. The stamps start from a +// baseline (db.js), never from the row's latest write. +{ + const r0 = await newRecipe('Base'); + const t = { + recipes: { id: r0, phone: ['notes', 'phone notes'], web: ['name', 'Base (web)'] }, + pantry_items: { sql: `INSERT INTO pantry_items (name) VALUES ('Oil')`, phone: ['notes', 'phone'], web: ['name', 'Olive oil'] }, + shopping_list: { sql: `INSERT INTO shopping_list (name) VALUES ('Milk')`, phone: ['checked', 1], web: ['name', 'Oat milk'] }, + cook_diary: { sql: `INSERT INTO cook_diary (date) VALUES ('2026-10-01')`, phone: ['notes', 'phone'], web: ['servings', 3] }, + recipe_categories: { sql: `INSERT INTO recipe_categories (name, slug) VALUES ('Soups', 'soups-legacy')`, phone: ['color', '#ff0000'], web: ['name', 'Stews'] }, + pantry_categories: { sql: `INSERT INTO pantry_categories (name, slug) VALUES ('Dry', 'dry-legacy')`, phone: ['icon', 'grain'], web: ['name', 'Dry goods'] }, + custom_units: { sql: `INSERT INTO custom_units (abbr, full_name) VALUES ('pn', 'pinch')`, phone: ['sort_order', 5], web: ['full_name', 'small pinch'] }, + cookbooks: { sql: `INSERT INTO cookbooks (name, slug) VALUES ('Fall', 'fall-legacy')`, phone: ['description', 'phone'], web: ['name', 'Autumn'] }, + recipe_comments: { sql: `INSERT INTO recipe_comments (recipe_id, body) VALUES (${r0}, 'nice')`, phone: ['body', 'very nice'], web: null }, + ai_chat_history: { sql: `INSERT INTO ai_chat_history (role, content) VALUES ('user', 'hi')`, phone: ['content', 'hello'], web: null }, + }; + for (const [table, c] of Object.entries(t)) { + c.id ??= db.prepare(c.sql).run().lastInsertRowid; + db.prepare(`UPDATE ${table} SET created_at = datetime('now', '-2 days') WHERE id = ?`).run(c.id); + } + // As before the upgrade: no stamps. Then the server starts again (db.js + // runs its migrations on this database) and fills in the baselines. + for (const [table, c] of Object.entries(t)) db.prepare(`UPDATE ${table} SET field_stamps = NULL WHERE id = ?`).run(c.id); + await import(`../server/db.js?again=${Date.now()}`); + out.legacy = {}; + const pull = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables; + await sleep(1100); + const phoneAt = fmt(Date.now()); + await sleep(1100); + for (const [table, c] of Object.entries(t)) { + const baseline = sql(`SELECT json_extract(field_stamps, '$._base') AS b FROM ${table} WHERE id = ?`, c.id).b; + // The web changes another field, or only touches the row. + if (c.web) db.prepare(`UPDATE ${table} SET ${c.web[0]} = ?, updated_at = datetime('now') WHERE id = ?`).run(c.web[1], c.id); + else db.prepare(`UPDATE ${table} SET updated_at = datetime('now') WHERE id = ?`).run(c.id); + const copy = pull[table].find(r => r.id === c.id); + const row = { ...copy, client_id: 70, server_id: c.id, server_synced_at: copy.synced_at, [c.phone[0]]: c.phone[1], changed: [c.phone[0]], updated_at: phoneAt, edit_clock: 'server' }; + delete row.id; delete row.synced_at; + const res = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { [table]: [row] } }); + const after = sql(`SELECT * FROM ${table} WHERE id = ?`, c.id); + out.legacy[table] = { + baseline: !!baseline, + phone: String(after[c.phone[0]]) === String(c.phone[1]), + web: c.web ? String(after[c.web[0]]) === String(c.web[1]) : true, + result: res.body.tables[table][0]?.kept ?? 'in', + }; + } + out.legacy.versions = (await versions(r0)).length; +} + +// 23. Fields that belong together merge as one: on one device 2 lb became +// 500 g; an older copy elsewhere changed the amount to 3 (still lb). +{ + const p = (await raw('POST', '/api/pantry', { name: 'Flour', quantity: 2, unit: 'lb', in_stock: 1 })).body; + backdate('pantry_items', p.id); + const copy = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.pantry_items.find(r => r.id === p.id); + const push1 = (row) => j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [row] } }); + const base = { ...copy, client_id: 71, server_id: p.id, server_synced_at: copy.synced_at, edit_clock: 'server' }; + delete base.id; delete base.synced_at; + await sleep(1100); + const tA = fmt(Date.now()); await sleep(1100); const tB = fmt(Date.now()); + await push1({ ...base, quantity: 500, unit: 'g', updated_at: tA, changed: ['quantity', 'unit'] }); + await push1({ ...base, quantity: 3, updated_at: tB, changed: ['quantity'] }); + out.groups = sql('SELECT quantity, unit FROM pantry_items WHERE id = ?', p.id); +} + +// 24. A reorder doesn't bring back an item deleted offline. +{ + const a = (await raw('POST', '/api/shopping', { name: 'Eggs' })).body; + const b = (await raw('POST', '/api/shopping', { name: 'Bread' })).body; + backdate('shopping_list', a.id); + const copy = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.shopping_list.find(r => r.id === a.id); + await sleep(1100); + const del = fmt(Date.now()); + await sleep(1100); + await raw('POST', '/api/shopping/reorder', { items: [{ id: b.id, sort_order: 0 }, { id: a.id, sort_order: 1 }] }); + const row = { ...copy, client_id: 72, server_id: a.id, server_synced_at: copy.synced_at, deleted_at: del, updated_at: del, edit_clock: 'server', changed: ['deleted_at'] }; + delete row.id; delete row.synced_at; + await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { shopping_list: [row] } }); + out.reorderDelete = sql('SELECT deleted_at IS NOT NULL AS deleted, sort_order FROM shopping_list WHERE id = ?', a.id); +} + +// 25. Saves through the REST API with _sync, every table: a save made on an +// older copy changes only its field; a field changed elsewhere since stays. +{ + out.rest = {}; + const sync = (copy, changed) => ({ base_synced_at: copy.synced_at, changed, edited_at: new Date().toISOString(), client_now: new Date().toISOString() }); + const item = (await raw('POST', '/api/shopping', { name: 'Tea' })).body; + const itemCopy = (await raw('GET', '/api/shopping')).body; + const tea = (Array.isArray(itemCopy) ? itemCopy : itemCopy.items).find(x => x.id === item.id); + await sleep(1100); + await raw('PUT', `/api/shopping/${item.id}`, { name: 'Green tea' }); + await raw('PUT', `/api/shopping/${item.id}`, { ...tea, checked: true, _sync: sync(tea, ['checked']) }); + out.rest.shopping = sql('SELECT name, checked FROM shopping_list WHERE id = ?', item.id); + const pi = (await raw('POST', '/api/pantry', { name: 'Rice', quantity: 1, unit: 'kg' })).body; + const piCopy = (await raw('GET', `/api/pantry/${pi.id}`)).body; + await sleep(1100); + await raw('PUT', `/api/pantry/${pi.id}`, { name: 'Basmati rice' }); + await raw('PUT', `/api/pantry/${pi.id}`, { ...piCopy, notes: 'for pilaf', _sync: sync(piCopy, ['notes']) }); + out.rest.pantry = sql('SELECT name, notes, quantity, unit FROM pantry_items WHERE id = ?', pi.id); + const toggled = await raw('PATCH', `/api/pantry/${pi.id}/stock`, { in_stock: false, _sync: { changed: ['in_stock'], edited_at: new Date().toISOString(), client_now: new Date().toISOString() } }); + out.rest.stock = { status: toggled.status, in_stock: sql('SELECT in_stock FROM pantry_items WHERE id = ?', pi.id).in_stock }; + const cat = (await raw('POST', '/api/recipes/categories', { name: 'Bakes' })).body; + const catCopy = { ...sql('SELECT * FROM recipe_categories WHERE id = ?', cat.id) }; + await sleep(1100); + await raw('PUT', `/api/recipes/categories/${cat.id}`, { name: 'Baking' }); + await raw('PUT', `/api/recipes/categories/${cat.id}`, { ...catCopy, color: '#00ff00', _sync: sync(catCopy, ['color']) }); + out.rest.category = sql('SELECT name, color FROM recipe_categories WHERE id = ?', cat.id); +} + +// 26. Two devices' variant links, each fine alone: the phone makes V a +// variant of P (and P's nutrition source), the web made P a variant of Q +// since. The newer link stays and the tree stays valid. +{ + const mk = async n => { const x = (await raw('POST', '/api/pantry', { name: n })).body; backdate('pantry_items', x.id); return x; }; + const Q = await mk('Milk'), P = await mk('Whole milk'), V = await mk('Brand milk'); + const pulled = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.pantry_items; + const bp = pulled.find(r => r.id === P.id), bv = pulled.find(r => r.id === V.id); + await sleep(1100); const tP = fmt(Date.now()); await sleep(2100); + const web = await raw('PUT', `/api/pantry/${P.id}`, { generic_parent_id: Q.id }); + const rowOf = (copy, cid, sid, ch) => { const r = { ...copy, client_id: cid, server_id: sid, server_synced_at: copy.synced_at, updated_at: tP, edit_clock: 'server', ...ch, changed: Object.keys(ch) }; delete r.id; delete r.synced_at; return r; }; + await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [ + rowOf(bv, 81, V.id, { generic_parent_id: P.id }), + rowOf(bp, 82, P.id, { nutrition_source_variant_id: V.id }), + ] } }); + const rows = Object.fromEntries([Q, P, V].map(x => [x.name, sql('SELECT generic_parent_id AS parent, nutrition_source_variant_id AS source FROM pantry_items WHERE id = ?', x.id)])); + // Every rule PUT /api/pantry/:id holds. + const all = db.prepare('SELECT id, generic_parent_id, nutrition_source_variant_id FROM pantry_items WHERE deleted_at IS NULL').all(); + const byId = new Map(all.map(r => [r.id, r])); + const broken = all.filter(r => (r.generic_parent_id != null && (byId.get(r.generic_parent_id)?.generic_parent_id != null || all.some(k => k.generic_parent_id === r.id))) + || (r.nutrition_source_variant_id != null && (r.generic_parent_id != null || byId.get(r.nutrition_source_variant_id)?.generic_parent_id !== r.id))); + out.variants = { webStatus: web.status, rows, ids: { Q: Q.id, P: P.id, V: V.id }, broken: broken.length, stamps: [P.id, V.id].map(i => sql(`SELECT json_extract(field_stamps, '$.generic_parent_id') AS g FROM pantry_items WHERE id = ?`, i).g), tP }; +} + +// 27. The managers' lists (recipe and pantry categories, custom units, +// cookbooks) carry the server's stamp, so a save from one says which copy +// it was made on, as every other save does. The manager's page (or the +// offline copy it came from) was read before another device's change; +// its save changes only its own field, and the other change stays. +{ + out.managers = {}; + const listRows = (path, body) => (path === '/api/units' ? body.custom : body); + const cases = [ + { table: 'recipe_categories', list: '/api/recipes/categories', make: () => raw('POST', '/api/recipes/categories', { name: 'Sides' }), + put: id => `/api/recipes/categories/${id}`, other: r => ({ name: 'Side dishes', color: r.color }), mine: r => ({ name: r.name, color: '#ff8800' }), check: 'SELECT name, color FROM recipe_categories WHERE id = ?' }, + { table: 'pantry_categories', list: '/api/pantry/categories', make: () => raw('POST', '/api/pantry/categories', { name: 'Tins' }), + put: id => `/api/pantry/categories/${id}`, other: r => ({ name: 'Canned goods', icon: r.icon, default_aisle: r.default_aisle }), mine: r => ({ name: r.name, icon: r.icon, default_aisle: 'Aisle 4' }), check: 'SELECT name, default_aisle FROM pantry_categories WHERE id = ?' }, + { table: 'custom_units', list: '/api/units', make: () => raw('POST', '/api/units', { abbr: 'pinch', full_name: 'pinch', category: 'volume' }), + put: id => `/api/units/${id}`, other: null, mine: r => ({ abbr: r.abbr, full_name: 'small pinch', category: r.category }), check: 'SELECT abbr, full_name, sort_order FROM custom_units WHERE id = ?' }, + { table: 'cookbooks', list: '/api/cookbooks', make: () => raw('POST', '/api/cookbooks', { name: 'Weeknights' }), + put: id => `/api/cookbooks/${id}`, other: r => ({ name: 'Weeknight dinners', description: r.description, cover_image_url: r.cover_image_url }), mine: r => ({ name: r.name, description: 'Fast ones', cover_image_url: r.cover_image_url }), check: 'SELECT name, description FROM cookbooks WHERE id = ?' }, + ]; + for (const c of cases) { + const made = (await c.make()).body; + backdate(c.table, made.id); + // The manager's page: the list as read (and as the offline copy holds it). + const listed = (await raw('GET', c.list)).body; + const page = listRows(c.list, listed).find(r => r.id === made.id); + const shown = stampedRows(listed).some(r => r.id === made.id) && readTable(c.list) === c.table; + await sleep(1100); + // Another device changes something else. + if (c.other) { + const fresh = listRows(c.list, (await raw('GET', c.list)).body).find(r => r.id === made.id); + const body = { ...c.other(fresh), _base: fresh }; + await raw('PUT', c.put(made.id), withSaveBase(body, saveBase('PUT', c.put(made.id), body, null, new Date().toISOString()))); + } else { + // A phone moves the unit in its list. + const copy = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.custom_units.find(r => r.id === made.id); + const row = { ...copy, client_id: 90, server_id: made.id, server_synced_at: copy.synced_at, sort_order: 7, updated_at: fmt(Date.now()), edit_clock: 'server', changed: ['sort_order'] }; + delete row.id; delete row.synced_at; + await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { custom_units: [row] } }); + } + await sleep(1100); + // The manager saves on the copy it read. + const body = { ...c.mine(page), _base: page }; + const sync = saveBase('PUT', c.put(made.id), body, null, new Date().toISOString()); + const res = await raw('PUT', c.put(made.id), withSaveBase(body, sync)); + out.managers[c.table] = { + listed: typeof page.synced_at === 'string', shown, + base: sync?.base_synced_at === page.synced_at, changed: sync?.changed, + answered: typeof res.body?.synced_at === 'string' && res.body.synced_at > page.synced_at, + row: sql(c.check, made.id), + }; + } +} + +// 28. A new row sent twice (two syncs at once, a retry, a lost answer) is +// made once; a name the account has already is that row; the same lost +// copy kept twice is one version; an upload sent twice makes one row. +{ + out.once = {}; + const row = (extra = {}) => ({ client_id: 5, server_id: null, client_key: 'phone-1:recipes:5', name: 'Kill Test Recipe', ingredients: '[]', steps: '[]', tags: '[]', tools: '[]', nutrition: '{}', visibility: 'private', cook_count: 0, favorite: 0, servings: 2, updated_at: fmt(Date.now()), edit_clock: 'server', ...extra }); + const a = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { recipes: [row()] } }); + const b = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { recipes: [row({ notes: 'second send, edited' })] } }); + if (!Array.isArray(a.body?.tables?.recipes)) throw new Error('push: ' + JSON.stringify(a.body).slice(0, 300)); + out.once.ids = [a.body.tables.recipes[0].server_id, b.body.tables.recipes[0].server_id]; + out.once.rows = db.prepare(`SELECT notes FROM recipes WHERE name = 'Kill Test Recipe'`).all().map(r => r.notes); + const other = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { recipes: [row({ client_id: 6, client_key: 'phone-1:recipes:6', name: 'Another One' })] } }); + out.once.otherKey = other.body.tables.recipes[0].server_id !== a.body.tables.recipes[0].server_id; + // The pull hands the key back, so the phone knows the row as its own. + const pulledRow = (await j('GET', '/api/sync/pull?since=1970-01-01&synced_at=1')).body.tables.recipes.find(r => r.name === 'Kill Test Recipe'); + out.once.pulledKey = pulledRow?.client_key ?? null; + const oldPull = (await j('GET', '/api/sync/pull?since=1970-01-01')).body.tables.recipes.find(r => r.name === 'Kill Test Recipe'); + out.once.oldAppKey = oldPull && 'client_key' in oldPull; + // A category the account has (made on the web) pushed as new: the same one. + const webCat = (await raw('POST', '/api/recipes/categories', { name: 'Soups' })).body; + const catPush = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { recipe_categories: [{ client_id: 9, server_id: null, name: 'Soups', slug: webCat.slug, color: '#123456', sort_order: 0, updated_at: fmt(Date.now()), edit_clock: 'server' }] } }); + out.once.category = { error: catPush.body.tables.recipe_categories.error || null, same: catPush.body.tables.recipe_categories[0]?.server_id === webCat.id, count: sql(`SELECT COUNT(*) AS n FROM recipe_categories WHERE slug = ?`, webCat.slug).n }; + // The same conflicting phone edit pushed twice: one version. + const rid = await newRecipe('Twice Soup'); + const copy = await pulled(rid); + await sleep(1100); + await j('PUT', `/api/recipes/${rid}`, { name: 'Twice Soup (web)' }); + const older = phoneRow(copy, { name: 'Twice Soup (phone)', updated_at: ago(0.5) }); + await push([older], now()); + await push([older], now()); + out.once.versions = (await versions(rid)).map(v => v.data.name); + // An upload sent twice with its keys: one row each. + for (let n = 0; n < 2; n++) await raw('POST', '/api/recipes', { name: 'Uploaded Twice', ingredients: [], steps: [], client_key: 'phone-1:recipes:77' }); + for (let n = 0; n < 2; n++) await raw('POST', '/api/pantry', { name: 'Pantry Twice', client_key: 'phone-1:pantry_items:7' }); + for (let n = 0; n < 2; n++) await raw('POST', '/api/shopping', { name: 'Shopping Twice', client_key: 'phone-1:shopping_list:7' }); + out.once.uploads = ['recipes|Uploaded Twice', 'pantry_items|Pantry Twice', 'shopping_list|Shopping Twice'].map(x => { const [t, nm] = x.split('|'); return sql(`SELECT COUNT(*) AS n FROM ${t} WHERE name = ?`, nm).n; }); +} + +// 29. The same copy kept again moves to newest, with the new reason: a +// restore's undo point is never left marked 'replaced', nor trimmed first. +{ + const { saveRecipeVersion } = await import('../server/lib/recipe-versions.js'); + const rid = await newRecipe('Kept Twice'); + const base = sql('SELECT * FROM recipes WHERE id = ?', rid); + const copy = n => ({ ...base, name: n }); + saveRecipeVersion(rid, base.user_id, copy('Copy V'), { reason: 'replaced' }); + for (let n = 0; n < 3; n++) saveRecipeVersion(rid, base.user_id, copy(`Other ${n}`), { reason: 'conflict' }); + saveRecipeVersion(rid, base.user_id, copy('Copy V'), { reason: 'restore' }); + out.keptAgain = db.prepare('SELECT data, reason FROM recipe_versions WHERE recipe_id = ? ORDER BY id DESC').all(rid) + .map(v => `${JSON.parse(v.data).name}:${v.reason}`); +} + +// 30. A photo taken with no connection comes embedded in its row (a data: +// URL): the push stores it as a file, and the startup repair does the same +// for photos stored embedded before. +{ + const fs = await import('node:fs'); + const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const files = () => { try { return fs.readdirSync(process.env.UPLOADS_PATH).length; } catch { return 0; } }; + out.photos = {}; + const base = { ingredients: '[]', steps: '[]', tags: '[]', tools: '[]', nutrition: '{}', visibility: 'private', cook_count: 0, favorite: 0, servings: 2, edit_clock: 'server' }; + // A new recipe with its photo. + const made = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { recipes: [{ ...base, client_id: 31, server_id: null, name: 'Photo Soup', img_url: PNG, updated_at: fmt(Date.now()) }] } }); + const rid = made.body.tables.recipes[0].server_id; + out.photos.made = sql('SELECT img_url FROM recipes WHERE id = ?', rid).img_url; + backdate('recipes', rid); + // The photo changed on the phone, on the copy the server has: one file, no version, nothing to pull back. + const copy = await pulled(rid); + await sleep(1100); + const before = files(); + const edit = await push([phoneRow(copy, { img_url: PNG, updated_at: fmt(Date.now()), changed: ['img_url'], edit_clock: 'server' })], now()); + out.photos.edited = sql('SELECT img_url FROM recipes WHERE id = ?', rid).img_url; + out.photos.editResult = edit.body.tables.recipes[0].kept ?? null; + out.photos.filesAdded = files() - before; + out.photos.versions = (await versions(rid)).length; + // Another field changed, with the photo still embedded on the phone: no file, the photo stays. + const copy2 = await pulled(rid); + await sleep(1100); + const before2 = files(); + await push([phoneRow(copy2, { name: 'Photo Soup (renamed)', img_url: PNG, updated_at: fmt(Date.now()), changed: ['name'], edit_clock: 'server' })], now()); + out.photos.otherField = { files: files() - before2, img: sql('SELECT img_url, name FROM recipes WHERE id = ?', rid) }; + // A diary entry's photos list, and a cookbook cover. + const cook = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { cook_diary: [{ client_id: 32, server_id: null, recipe_id: rid, date: '2026-10-09', kind: 'cooked', photo_url: PNG, photos: JSON.stringify([PNG, '/uploads/kept.jpg']), updated_at: fmt(Date.now()), edit_clock: 'server' }] } }); + out.photos.cook = sql('SELECT photo_url, photos FROM cook_diary WHERE id = ?', cook.body.tables.cook_diary[0].server_id); + const cb = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { cookbooks: [{ client_id: 33, server_id: null, name: 'Covers', slug: 'covers', cover_image_url: PNG, is_smart: 0, sort_order: 0, updated_at: fmt(Date.now()), edit_clock: 'server' }] } }); + out.photos.cover = sql('SELECT cover_image_url FROM cookbooks WHERE id = ?', cb.body.tables.cookbooks[0].server_id).cover_image_url; + const pi = await j('POST', '/api/sync/push', { fk_ids: 'server', ...now(), tables: { pantry_items: [{ client_id: 34, server_id: null, name: 'Photo Flour', in_stock: 1, img_url: PNG, updated_at: fmt(Date.now()), edit_clock: 'server' }] } }); + out.photos.pantry = sql('SELECT img_url FROM pantry_items WHERE id = ?', pi.body.tables.pantry_items[0].server_id).img_url; + + // The startup repair. + const { repairInlinePhotos } = await import('../server/lib/inline-photos.js'); + const ins = (name, img, extra = '') => db.prepare(`INSERT INTO recipes (user_id, name, img_url, ingredients, steps, tags, tools, nutrition${extra ? ', deleted_at' : ''}) VALUES (?, ?, ?, '[]', '[]', '[]', '[]', '{}'${extra ? ', ?' : ''})`).run(...[null, name, img, ...(extra ? [extra] : [])]).lastInsertRowid; + const owner = sql('SELECT user_id FROM recipes WHERE id = ?', rid).user_id; + const insOwned = (name, img, deleted = null) => { const id = ins(name, img, deleted || ''); db.prepare('UPDATE recipes SET user_id = ? WHERE id = ?').run(owner, id); return id; }; + const stored = insOwned('Stored Inline', PNG); + db.prepare(`UPDATE recipes SET updated_at = '2026-01-02 03:04:05' WHERE id = ?`).run(stored); + const gone = insOwned('Deleted Inline', PNG, '2026-01-01 00:00:00'); + const editedMeanwhile = insOwned('Edited Meanwhile', PNG); + const broken = insOwned('Broken Inline', 'data:image/png;base64,bm90IGFuIGltYWdl'); + const external = insOwned('External', 'https://example.com/a.jpg'); + const stamps = id => sql('SELECT updated_at, field_stamps, synced_at FROM recipes WHERE id = ?', id); + const s0 = stamps(stored); + const vBefore = db.prepare('SELECT COUNT(*) AS n FROM recipe_versions').get().n; + await sleep(20); + // An edit of one row lands while the repair is running. + const run1 = repairInlinePhotos(); + db.prepare('UPDATE recipes SET img_url = ? WHERE id = ?').run('/uploads/edited-meanwhile.jpg', editedMeanwhile); + const r1 = await run1; + const s1 = stamps(stored); + out.photos.repair = { + result: r1, + stored: sql('SELECT img_url FROM recipes WHERE id = ?', stored).img_url, + updatedKept: s1.updated_at === s0.updated_at, + stampsKept: s1.field_stamps === s0.field_stamps, + syncMoved: s1.synced_at > s0.synced_at, + deleted: sql('SELECT img_url FROM recipes WHERE id = ?', gone).img_url.slice(0, 5), + meanwhile: sql('SELECT img_url FROM recipes WHERE id = ?', editedMeanwhile).img_url, + broken: sql('SELECT img_url FROM recipes WHERE id = ?', broken).img_url.slice(0, 5), + external: sql('SELECT img_url FROM recipes WHERE id = ?', external).img_url, + versions: db.prepare('SELECT COUNT(*) AS n FROM recipe_versions').get().n - vBefore, + }; + out.photos.retry = await repairInlinePhotos(); +} + +server.close(); +process.stdout.write('\n@@' + JSON.stringify(out)); +process.exit(0); diff --git a/scripts/sync-conflict.test.js b/scripts/sync-conflict.test.js new file mode 100644 index 00000000..c5bd8386 --- /dev/null +++ b/scripts/sync-conflict.test.js @@ -0,0 +1,391 @@ +/** + * Two edits of one row: the newer edit stays, and a recipe edit that + * doesn't stay is kept as an earlier version the owner can restore. + * + * A phone that was offline used to overwrite whatever the server had, + * however much newer. Now apps that say what time it is on them (client_now) + * are judged: edit times are moved onto the server's clock and the newer + * edit stays; an edit made on the copy the server still has always goes + * in; for recipes, only the fields an edit changed count. Older apps and + * pages keep their old behavior, and what they replace is kept quietly. + * The real-schema part runs the server's sync and recipe routes on a + * scratch database (scripts/sync-conflict.harness.mjs); the Android app's + * own sync code runs against a real server in android-sync.test.js. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { utcMs, clockOffset, editTime, latestTime, serverIsNewer } from '../server/lib/sync-clock.js'; + +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); + +test('edit times are read the same in every format either side writes', () => { + const t = Date.UTC(2026, 9, 6, 12, 30, 5); + assert.equal(utcMs('2026-10-06 12:30:05'), t, "SQLite's datetime('now') is UTC"); + assert.equal(utcMs('2026-10-06 12:30:05.250'), t + 250); + assert.equal(utcMs('2026-10-06T12:30:05Z'), t); + assert.equal(utcMs('2026-10-06T12:30:05.000Z'), t); + assert.equal(utcMs('2026-10-06T14:30:05+02:00'), t); + assert.equal(utcMs('2026-10-06'), Date.UTC(2026, 9, 6)); + assert.ok(Number.isNaN(utcMs(''))); + assert.ok(Number.isNaN(utcMs(null))); + assert.ok(Number.isNaN(utcMs('soon'))); +}); + +test("a device's clock is corrected however far off it is", () => { + const near = clockOffset(new Date(Date.now() - 10 * 60000).toISOString()); + assert.ok(Math.abs(near - 10 * 60000) < 5000, `ten minutes slow, got ${near}`); + const far = clockOffset(new Date(Date.now() - 40 * 86400000).toISOString()); + assert.ok(Math.abs(far - 40 * 86400000) < 5000, 'a clock reset weeks back is still the same device, editing now'); + assert.equal(clockOffset(undefined), 0, 'older apps send none'); + assert.equal(clockOffset('later'), 0); + assert.equal(clockOffset(12345), 0); + assert.equal(clockOffset('2026-01-06T12:00:00.000Z', Date.UTC(2026, 0, 6, 12, 0, 3)), 3000, 'measured from when the push arrived'); +}); + +test("an edit time moves onto the server's clock and compares by time, not text", () => { + assert.equal(editTime('2026-01-06 12:00:00', 10 * 60000), '2026-01-06 12:10:00'); + assert.equal(editTime('2026-01-06T12:00:00.000Z', 0), '2026-01-06 12:00:00'); + assert.equal(editTime('not a time', 5000), 'not a time', 'kept as it came'); + assert.match(editTime(null, 0), /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/); + assert.ok(editTime(new Date(Date.now() + 3600000).toISOString(), 0) <= editTime(null, 0), 'no edit is from the future'); + assert.equal(serverIsNewer('2026-10-06 12:00:05', '2026-10-06 12:00:00'), true); + assert.equal(serverIsNewer('2026-10-06 12:00:00', '2026-10-06 12:00:00'), false, 'a tie goes to the edit arriving'); + assert.equal(serverIsNewer('2026-01-06 12:00:00', editTime('2026-01-06 12:00:00', -300)), false, 'rounding is a tie'); + // As text, "2026-10-06T..." sorts after "2026-10-06 ..." whatever the time. + assert.equal(serverIsNewer('2026-10-06T09:00:00Z', '2026-10-06 12:00:00'), false); + assert.equal(serverIsNewer('garbage', '2026-10-06 12:00:00'), false); + assert.equal(latestTime('2026-10-06 12:00:00', '2026-10-07 08:00:00'), '2026-10-07 08:00:00'); + assert.equal(latestTime('2026-10-06 12:00:00', null), '2026-10-06 12:00:00'); + assert.equal(latestTime(null, '2026-10-06 12:00:00'), '2026-10-06 12:00:00'); +}); + +test('the app sends its clock, its copy and what it changed, and asks for the stamp', () => { + const sync = read('../src/lib/sync.js'); + const dbNative = read('../src/lib/db-native.js'); + assert.match(sync, /client_now: new Date\(\)\.toISOString\(\)/); + assert.match(sync, /\/api\/sync\/pull\?since=\$\{encodeURIComponent\(since\)\}&synced_at=1/); + assert.match(sync, /const changed = rowChanges\(table, r\);/, 'every table says which fields it changed'); + assert.match(sync, /if \(!r\.kept && r\.client_id\)/, "the server's copy is the phone's only once a pull stored it"); + assert.match(dbNative, /ADD COLUMN server_synced_at TEXT/); + assert.match(dbNative, /translated\.server_synced_at = translated\.synced_at/); + assert.match(dbNative, /delete translated\.synced_at/, 'never written into a table that lacks the column'); + assert.match(dbNative, /FROM sync_echoes WHERE table_name = \? AND row_id = \?/, "the phone knows its own push's echo"); + assert.match(dbNative, /edit_clock = 'server'/, 'edits are stamped on the server clock as they are made'); +}); + +test('the phone, the web app and the server merge the same fields', async () => { + const server = await import('../server/lib/sync-fields.js'); + const client = await import('../src/lib/sync-fields.js'); + assert.deepEqual(client.SYNC_FIELDS, server.SYNC_FIELDS); + assert.match(read('../src/lib/db-native.js'), /export \{ SYNC_FIELDS \} from '\.\/sync-fields\.js'/); + for (const [t, groups] of Object.entries(server.SYNC_GROUPS)) { + for (const g of groups) for (const f of g) assert.ok(server.SYNC_FIELDS[t].includes(f), `${t}: ${f} in a group is a synced field`); + } +}); + +test('only apps that ask get the stamp in a pull (older apps write every key)', () => { + const route = read('../server/routes/sync.js'); + assert.match(route, /const withSyncedAt = req\.query\.synced_at === '1'/); + assert.match(route, /if \(withSyncedAt\) cols\.push\('synced_at'(, 'client_key')?\)/); +}); + +test("the web app's recipe saves say which copy they were made on, and which fields they change", async () => { + const { recipeSaveBase, withSaveBase, recipeBaseOf, recipeChangedFields, collapseOps } = await import('../src/lib/offline-edits.js'); + const page = { id: 4, name: 'A', notes: 'n', rating: 3, favorite: false, imgUrl: '/uploads/a.jpg', servings: null, synced_at: '2026-10-06 10:00:00.000' }; + const pageBase = recipeBaseOf(page); + assert.equal(pageBase.synced_at, '2026-10-06 10:00:00.000'); + assert.equal(pageBase.img_url, '/uploads/a.jpg', 'the picture as a save sends it'); + assert.deepEqual(recipeSaveBase({ name: 'B' }, null, 'x').changed, ['name'], 'a save of a few fields changes those'); + assert.equal(recipeSaveBase({ id: 4, name: 'B', notes: 'n' }, null, 'x'), null, "a whole row with no copy's stamp can't say: saved as before"); + const b = recipeSaveBase({ ...page, name: 'B', _base: pageBase }, null, '2026-10-06T10:05:00.000Z'); + assert.equal(b.base_synced_at, '2026-10-06 10:00:00.000'); + assert.deepEqual(b.changed, ['name'], 'only what differs from the copy the page had'); + assert.equal(b.edited_at, '2026-10-06T10:05:00.000Z', 'when the save was made, not when it goes up'); + assert.deepEqual(recipeSaveBase({ ...page, favorite: true, _base: pageBase, _changed: ['favorite'] }, null, 'x').changed, ['favorite'], 'a toggle says its one field'); + assert.deepEqual(recipeChangedFields({ ...page, img_url: '/uploads/a.jpg', servings: '' }, pageBase), [], 'the same value written another way is no change'); + // Another tab read a newer copy since: this tab's save is still about its own. + const newer = { ...page, name: 'Other tab', synced_at: '2026-10-06 10:09:00.000' }; + assert.equal(recipeSaveBase({ ...page, rating: 5, _base: pageBase }, newer, 'x').base_synced_at, '2026-10-06 10:00:00.000'); + assert.equal(recipeSaveBase({ ...page, rating: 5 }, newer, 'x').changed, null, "the kept copy isn't this page's: it can't say"); + assert.deepEqual(recipeSaveBase({ ...page, rating: 5 }, { ...page }, 'x').changed, ['rating'], 'the kept copy is this one'); + // Two saves queued for one recipe go up as one: the first copy, both changes. + const op = (seq, sync) => ({ seq, kind: 'recipe-update', key: 'recipe:4', id: 4, body: {}, sync }); + const [one] = collapseOps([op(1, { base_synced_at: 'A', changed: ['name'], edited_at: '1' }), op(2, { base_synced_at: 'B', changed: ['rating'], edited_at: '2' })]); + assert.deepEqual(one.sync, { base_synced_at: 'A', changed: ['name', 'rating'], edited_at: '2' }); + const sent = withSaveBase({ name: 'B', _base: pageBase, _changed: ['name'], _sync: { stale: true } }, b, 'NOW'); + assert.equal(sent._sync.client_now, 'NOW'); + assert.equal(sent._sync.stale, undefined); + assert.equal(sent._base, undefined); + assert.equal(sent._changed, undefined); + const api = read('../src/lib/offline-api.js'); + assert.match(api, /_http\._fetch\(op\.method, path, withSaveBase\(body, op\.sync\)\)/, 'a replayed save carries its copy and the time'); + assert.match(read('../src/routes/RecipeView.svelte'), /addEventListener\('ct:offline-synced', _onOfflineSynced\)/, 'the page shows the copy that stayed'); + assert.match(read('../src/routes/RecipeView.svelte'), /rating: next, _base: recipeBaseOf\(recipe\), _changed: \['rating'\]/); + assert.match(read('../src/routes/Recipes.svelte'), /favorite: !r\.favorite, _base: recipeBaseOf\(r\), _changed: \['favorite'\]/, 'the list says its toggle too'); + assert.match(read('../src/routes/RecipeEditor.svelte'), /_base: loadedBase/); +}); + +test('every web save of a synced row says its copy and what it changed', async () => { + const { saveBase, saveTarget, readTable, stampedRows, collapseOps } = await import('../src/lib/offline-edits.js'); + assert.deepEqual(saveTarget('PUT', '/api/shopping/7'), { table: 'shopping_list', id: 7, toggle: false }); + assert.deepEqual(saveTarget('PATCH', '/api/pantry/3/stock'), { table: 'pantry_items', id: 3, toggle: true }); + assert.deepEqual(saveTarget('PUT', '/api/recipes/categories/2'), { table: 'recipe_categories', id: 2, toggle: false }); + assert.deepEqual(saveTarget('PUT', '/api/recipes/5/cooks/9'), { table: 'cook_diary', id: 9, toggle: false }); + for (const [m, u] of [['PUT', '/api/pantry/categories/1'], ['PUT', '/api/cookbooks/1'], ['PUT', '/api/units/1'], ['PUT', '/api/cook-diary/1'], ['PATCH', '/api/shopping/1/check']]) { + assert.ok(saveTarget(m, u), `${m} ${u} says its copy`); + } + assert.equal(saveTarget('POST', '/api/shopping'), null, 'creates have no copy'); + assert.equal(readTable('/api/shopping'), 'shopping_list'); + assert.equal(readTable('/api/units'), 'custom_units'); + assert.equal(stampedRows({ builtin: [], custom: [{ id: 1, synced_at: 's' }] }).length, 1); + // The row a page showed: only what the save changed on it. + const shown = { id: 7, name: 'Milk', quantity: 1, unit: 'l', checked: 0, synced_at: 'S1' }; + const seen = (t, id, st) => (t === 'shopping_list' && id === 7 && st === 'S1' ? shown : null); + assert.deepEqual(saveBase('PUT', '/api/shopping/7', { ...shown, checked: true }, seen, 'E'), { base_synced_at: 'S1', changed: ['checked'], edited_at: 'E' }); + assert.equal(saveBase('PUT', '/api/shopping/7', { ...shown, synced_at: 'S2', checked: true }, seen, 'E').changed, null, 'a copy not shown here: it can\'t say'); + assert.deepEqual(saveBase('PATCH', '/api/shopping/7/check', { checked: true }, seen, 'E').changed, ['checked'], 'a toggle changes what it sends'); + assert.deepEqual(saveBase('PUT', '/api/shopping/7', { aisle: 'Dairy' }, seen, 'E'), { base_synced_at: null, changed: ['aisle'], edited_at: 'E' }, 'a save of a few fields changes those'); + assert.deepEqual(saveBase('PUT', '/api/shopping/7', { name: 'x', unit: 'l', _base: { name: 'Milk', unit: 'l' } }, seen, 'E').changed, ['name'], "a form with the row it opened, that row has no stamp: what differs from it"); + assert.equal(saveBase('PUT', '/api/shopping/7', { id: 7, name: 'x', unit: 'l' }, seen, 'E'), null, "a whole row with no copy's stamp: saved as before"); + // Two queued saves of one item: the first copy, every changed field. + const op = (seq, sync) => ({ seq, kind: 'shopping-update', key: 'shopping:7', id: 7, body: {}, sync }); + const [one] = collapseOps([op(1, { base_synced_at: 'S1', changed: ['name'], edited_at: '1' }), op(2, { base_synced_at: 'S2', changed: ['quantity'], edited_at: '2' })]); + assert.deepEqual(one.sync, { base_synced_at: 'S1', changed: ['name', 'quantity'], edited_at: '2' }); + const api = read('../src/lib/offline-api.js'); + assert.match(api, /_noteShown\(path, answer\)/, 'rows read are remembered by their stamp'); + assert.match(api, /return _straight\(http, m, target, withSaveBase\(body, sync\)\)/, 'saves that go straight up say their copy too'); +}); + +test('earlier versions are a server-only call on Android, empty on a phone alone', () => { + const api = read('../src/lib/api.js'); + const native = read('../src/lib/api-native.js'); + const block = api.slice(api.indexOf('const SERVER_ONLY_METHODS'), api.indexOf(']);', api.indexOf('const SERVER_ONLY_METHODS'))); + for (const m of ['getRecipeVersions', 'markRecipeVersionsSeen', 'restoreRecipeVersion']) { + assert.ok(block.includes(`'${m}'`), `${m} goes to the server`); + assert.match(native, new RegExp(`async ${m}\\(`), `${m} has a local stub`); + } +}); + +test('server: newer edit wins, versions kept, restore and undo (real schema)', (t) => { + try { + createRequire(new URL('../server/package.json', import.meta.url))('better-sqlite3'); + } catch { + t.skip('better-sqlite3 is not built for this Node'); + return; + } + const dir = mkdtempSync(join(tmpdir(), 'ct-conflict-')); + try { + const res = spawnSync(process.execPath, [new URL('./sync-conflict.harness.mjs', import.meta.url).pathname], { + env: { ...process.env, DB_PATH: join(dir, 'test.db'), UPLOADS_PATH: join(dir, 'uploads'), JWT_SECRET: 'test-secret-for-sync-conflict' }, encoding: 'utf8', + }); + assert.equal(res.status, 0, res.stderr); + const out = JSON.parse(res.stdout.slice(res.stdout.lastIndexOf('\n@@') + 3)); + + assert.ok(!out.pullWithoutFlag.includes('synced_at'), 'older apps never see a key they have no column for'); + assert.ok(out.pullWithFlag.includes('synced_at')); + + assert.equal(out.olderPhone.result.kept, 'server', 'the phone is told its row is in, and that the server kept its own'); + assert.equal(out.olderPhone.server, 'Soup (web)', "the phone's older edit doesn't overwrite the web's"); + assert.ok(out.olderPhone.restamped, 'stamped again so the phone pulls the winning copy'); + assert.deepEqual(out.olderPhone.versions, [{ reason: 'conflict', seen: false, name: 'Soup (phone)', notes: 'phone edit' }]); + + assert.equal(out.newerPhone.server, 'Stew (phone)'); + assert.deepEqual(out.newerPhone.versions, ['Stew (web)'], "the web's overwritten copy is kept"); + assert.match(out.newerPhone.result.synced_at, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}$/); + assert.match(out.newerPhone.result.updated_at, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/, 'the edit time stored, to know the echo'); + + assert.equal(out.noConflict.server, 'Bread (phone again)', 'an edit on the current copy goes in, whatever the clock says'); + assert.equal(out.noConflict.versions, 0, 'no conflict, nothing kept (twice in a row either)'); + + assert.equal(out.slowNewer.got, out.slowNewer.want, 'clock 10 minutes slow'); + assert.equal(out.fastOlder.got, out.fastOlder.want, 'clock 10 minutes fast'); + assert.ok(Math.abs(out.slowNewer.offset - 600000) < 5000); + assert.equal(out.offset_missing, 0); + assert.equal(out.offset_garbage, 0); + assert.ok(Math.abs(out.offset_farOff - 40 * 86400000) < 5000, 'a clock 40 days off is corrected'); + + assert.equal(out.oldApp.first.kept, undefined, "older apps' edits go in, as before"); + assert.equal(out.oldApp.server, 'Rice (old app, newer)'); + assert.deepEqual(out.oldApp.versions, ['replaced:Rice (old app):true', 'replaced:Rice (web):true'], 'each copy they replace is kept, quietly'); + assert.equal(out.oldAppPantry, 'old app', 'other rows from older apps too, as before'); + + assert.equal(out.pantry.result.kept, 'server'); + assert.equal(out.pantry.notes, 'web later'); + assert.equal(out.pantry.newVersions, 0, 'only recipes keep versions'); + + assert.deepEqual(out.restore.restored, { name: 'Curry (phone)', steps: ['Phone step'] }); + assert.ok(out.restore.syncsOut, 'a restore gets a new edit time and stamp, so it syncs out'); + assert.deepEqual(out.restore.backup, { name: 'Curry (web)', seen: true }, 'the replaced copy is kept first'); + assert.deepEqual(out.restore.undone, { name: 'Curry (web)', steps: ['Web step'] }, 'and restoring it undoes the restore'); + assert.equal(out.restore.unseenBefore, 1); + assert.equal(out.restore.unseenAfter, 0); + assert.equal(out.restore.wrongRecipe, 404, "another recipe's version can't be restored onto this one"); + assert.equal(out.restore.missing, 404); + + assert.equal(out.cap.count, 20, 'the newest 20 are kept'); + assert.equal(out.cap.newest, 'newest'); + assert.equal(out.cap.conflictsKept, 3, 'copies kept only to be safe go first'); + assert.equal(out.cascade, 0, "a recipe's versions go with it"); + + assert.ok(!out.phoneDelete.result.kept, 'a delete made after the web edit goes in'); + assert.ok(out.phoneDelete.deleted); + + assert.ok(out.cookLogged.cookStatus < 300, `cook logged (${out.cookLogged.cookStatus})`); + assert.equal(out.cookLogged.server, 'Chili (phone)', "a cook or a share link doesn't beat an edit of the recipe"); + assert.equal(out.cookLogged.versions, 0, 'and nothing is kept: the content never changed on the server'); + + const t3 = out.threeDevices; + assert.equal(t3.a, 'server', "phone A's 09:00 edit loses to the web's 10:00"); + assert.deepEqual(t3.afterA, { name: 'Tart (web)', updated_at: t3.web, name_at: t3.web }, "A's lost push leaves the edit times alone"); + assert.equal(t3.b, 'in', "phone B's 10:30 edit still beats the web's"); + assert.deepEqual(t3.afterB, { name: 'Tart (phone B)', updated_at: t3.bAt }); + assert.deepEqual(t3.versions, ['Tart (web)', 'Tart (phone A)'], 'both copies that did not stay are kept'); + assert.equal(t3.pantryA, 'server'); + assert.deepEqual(t3.pAfterA, { notes: 'web', updated_at: t3.web }); + assert.deepEqual(t3.pantryB, { notes: 'phone B', updated_at: t3.bAt }); + + assert.equal(out.changedOnly.result, 'server', 'the phone pulls the content back'); + assert.deepEqual(out.changedOnly.row, { name: 'Dal (web)', steps: '["Web"]', cook_count: 0 }, "a push that changed nothing doesn't put back old content (counts follow the diary)"); + assert.equal(out.changedOnly.versions, 0, 'and nothing is kept'); + assert.deepEqual(out.changedOnly.ratingRow, { name: 'Dal 2 (web)', rating: 3 }, 'a rating changed on the phone goes in, the content stays'); + assert.equal(out.changedOnly.ratingVersions, 0); + + assert.equal(out.webSave.lost.kept, 'server', 'a web save replayed late is told the server kept its own'); + assert.equal(out.webSave.lost.answer, 'Cake (phone)', 'and gets the copy that stayed'); + assert.equal(out.webSave.lost.server, 'Cake (phone)'); + assert.deepEqual(out.webSave.lost.versions, ['Cake (web, offline)']); + assert.equal(out.webSave.won.kept, null); + assert.equal(out.webSave.won.server, 'Pasta (web)'); + assert.deepEqual(out.webSave.won.versions, ['Pasta (phone)']); + assert.deepEqual(out.webSave.rated.row, { name: 'Salad (phone)', rating: 5 }, "a rating from an old page doesn't put back old content"); + assert.equal(out.webSave.rated.versions, 0); + assert.deepEqual(out.webSave.editorKeepsFavorite, { name: 'Fav (edited)', favorite: 1 }, "an editor's save doesn't clear the favorite"); + assert.equal(out.webSave.plain, 'Toast (plain)', 'a save from an older page goes in, as before'); + assert.deepEqual(out.webSave.plainKept, ['replaced:Toast:true'], 'and what it replaced is kept, quietly'); + + for (const k of ['renameThenRate', 'renameThenEdit']) { + assert.equal(out[k].sent, 1, `${k}: the queue sends one save`); + assert.equal(out[k].row.name, 'Pie (renamed offline)', `${k}: the first offline edit isn't dropped`); + assert.equal(out[k].versions, 0); + } + assert.deepEqual(out.renameThenRate.changed, ['name', 'rating']); + assert.equal(out.renameThenRate.row.rating, 4); + assert.equal(out.renameThenEdit.row.notes, 'second edit'); + + assert.deepEqual(out.listFavorite.row, { name: 'Stew (other device)', notes: 'new notes', favorite: 1 }, "a favorite from an older list doesn't put back its content"); + assert.equal(out.listFavorite.versions, 0); + + assert.equal(out.noStamp.result, 'in', 'an edit with no stamp goes in when it is newer'); + assert.equal(out.noStamp.row.rating, 5); + assert.deepEqual(out.noStamp.versions, ['replaced:Rice (web)'], 'and the copy it replaced is kept, quietly'); + assert.deepEqual(out.forgedStamp.versions, ['replaced:Rice (web)'], 'a stamp from the future counts as none'); + + assert.equal(out.reorder.status, 200, 'the reorder route is reached (it was shadowed by /categories/:id)'); + assert.equal(out.reorder.updated_at, '2020-01-01 00:00:00', "a reorder doesn't move updated_at"); + assert.equal(out.reorder.sort_order, 1); + assert.ok(out.reorder.sortStampedNow, 'sort_order is stamped as changed now'); + assert.ok(out.reorder.nameUntouched); + + assert.deepEqual(out.shoppingFields.row, { name: 'Oat milk', checked: 1 }, 'checked off on the phone, renamed on the web: both kept'); + assert.equal(out.shoppingFields.result, 'server', 'the phone pulls the new name'); + + assert.equal(out.notBackwards.after.updated_at, out.notBackwards.before, "updated_at doesn't go back"); + assert.equal(out.notBackwards.after.notes, 'newer'); + assert.equal(out.notBackwards.after.rating, 3, 'the rating it changed still goes in'); + assert.deepEqual(out.notBackwards.versions, ['older'], 'its lost notes are kept'); + + assert.deepEqual(out.symmetric.row, { notes: 'phone notes', rating: 5 }, "the server's newer rating stays, the phone's notes go in"); + assert.equal(out.symmetric.versions, 0, 'no content was lost: nothing kept'); + + assert.equal(out.absurdClock.pantry.name.name, 'Flour (phone)', 'an edit from a clock that cannot be right goes in'); + assert.ok(out.absurdClock.pantry.name.updated_at.startsWith('20'), 'taken as made now'); + assert.equal(out.absurdClock.recipe.name, 'Loaf (phone)'); + assert.deepEqual(out.absurdClock.recipe.versions, ['conflict:Loaf (web)'], 'and the copy it replaced is kept'); + + for (const [table, r] of Object.entries(out.legacy)) { + if (table === 'versions') continue; + assert.ok(r.baseline, `${table}: a row from before the stamps gets a baseline`); + assert.ok(r.phone, `${table}: the phone's offline edit goes in`); + assert.ok(r.web, `${table}: the web's change stays`); + } + assert.equal(out.legacy.versions, 0, 'nothing was lost: no version, no notice'); + + assert.deepEqual(out.groups, { quantity: 3, unit: 'lb' }, 'an amount and its unit stay together (never 3 g)'); + assert.equal(out.reorderDelete.deleted, 1, "a reorder doesn't bring back an item deleted offline"); + + assert.deepEqual(out.rest.shopping, { name: 'Green tea', checked: 1 }, 'a web save on an older copy changes only its field'); + assert.deepEqual(out.rest.pantry, { name: 'Basmati rice', notes: 'for pilaf', quantity: 1, unit: 'kg' }); + assert.deepEqual(out.rest.stock, { status: 200, in_stock: 0 }); + assert.deepEqual(out.rest.category, { name: 'Baking', color: '#00ff00' }); + + assert.equal(out.variants.broken, 0, 'the variant tree keeps every rule the pantry route enforces'); + assert.equal(out.variants.rows['Whole milk'].parent, out.variants.ids.Q, "the web's newer link stays"); + assert.equal(out.variants.rows['Brand milk'].parent, null, "the phone's older link that broke a rule is taken out"); + assert.equal(out.variants.rows['Whole milk'].source, null, 'a variant has no nutrition source'); + + // A new row sent twice is made once (server/lib/create-keys.js). + assert.equal(out.once.ids[0], out.once.ids[1], 'the second send is the row the first made'); + assert.deepEqual(out.once.rows, ['second send, edited'], 'one row, with what the second send changed'); + assert.ok(out.once.otherKey, 'another key is another row'); + assert.equal(out.once.pulledKey, 'phone-1:recipes:5', 'the pull hands the key back to the app that made it'); + assert.equal(out.once.oldAppKey, false, 'older apps never see a key they have no column for'); + assert.deepEqual(out.once.category, { error: null, same: true, count: 1 }, 'a name the account has is that row, not a second that stops the push'); + assert.deepEqual(out.once.versions, ['Twice Soup (phone)'], 'the same lost copy kept once'); + assert.deepEqual(out.once.uploads, [1, 1, 1], 'an upload sent twice makes each row once'); + + // Photos taken with no connection are stored as files, from the push and by the startup repair. + const file = /^\/uploads\/[\w-]+\.png$/; + assert.match(out.photos.made, file, 'a new recipe pushed with an embedded photo stores a file'); + assert.match(out.photos.edited, file, 'a photo changed on the phone too'); + assert.equal(out.photos.editResult, null, "the phone's edit stands: nothing to pull back"); + assert.equal(out.photos.filesAdded, 1); + assert.equal(out.photos.versions, 0, 'storing a photo as a file makes no version'); + assert.equal(out.photos.otherField.files, 0, "a photo the push didn't change isn't stored again"); + assert.equal(out.photos.otherField.img.img_url, out.photos.edited, 'and stays as it was'); + assert.equal(out.photos.otherField.img.name, 'Photo Soup (renamed)'); + assert.match(out.photos.cook.photo_url, file); + const list = JSON.parse(out.photos.cook.photos); + assert.equal(list.length, 2); assert.match(list[0], file); assert.equal(list[1], '/uploads/kept.jpg'); + assert.match(out.photos.cover, file); + assert.match(out.photos.pantry, file); + const r = out.photos.repair; + assert.match(r.stored, file, 'a photo stored embedded becomes a file at startup'); + assert.equal(r.updatedKept, true, 'its edit time stays'); + assert.equal(r.stampsKept, true, 'and its field stamps'); + assert.equal(r.syncMoved, true, 'the sync stamp moves, so phones pull the path'); + assert.equal(r.deleted, 'data:', 'deleted rows are left alone'); + assert.equal(r.meanwhile, '/uploads/edited-meanwhile.jpg', 'an edit made meanwhile stays'); + assert.equal(r.broken, 'data:', "what can't be stored is left for the next startup"); + assert.equal(r.external, 'https://example.com/a.jpg', 'other addresses are never touched'); + assert.equal(r.versions, 0); + assert.ok(r.result.failed >= 1); + assert.equal(out.photos.retry.failed, r.result.failed, 'and tried again'); + assert.equal(out.photos.retry.repaired, 0); + + assert.deepEqual(out.keptAgain, ['Copy V:restore', 'Other 2:conflict', 'Other 1:conflict', 'Other 0:conflict'], 'kept once, as the newest, for the new reason'); + + // The managers' saves say their copy, as every other save does. + const m = out.managers; + for (const t of ['recipe_categories', 'pantry_categories', 'custom_units', 'cookbooks']) { + assert.ok(m[t].listed, `${t}: the list carries the server's stamp`); + assert.ok(m[t].shown, `${t}: the browser notes the row it showed`); + assert.ok(m[t].base, `${t}: the save sends the stamp of the copy it was made on`); + assert.ok(m[t].answered, `${t}: the answer carries the new stamp, for the next save`); + } + assert.deepEqual(m.recipe_categories.changed, ['color']); + assert.deepEqual(m.recipe_categories.row, { name: 'Side dishes', color: '#ff8800' }, "another device's rename stays, the manager's color goes in"); + assert.deepEqual(m.pantry_categories.changed, ['default_aisle']); + assert.deepEqual(m.pantry_categories.row, { name: 'Canned goods', default_aisle: 'Aisle 4' }); + assert.deepEqual(m.custom_units.changed, ['full_name']); + assert.deepEqual(m.custom_units.row, { abbr: 'pinch', full_name: 'small pinch', sort_order: 7 }, "a phone's move stays"); + assert.deepEqual(m.cookbooks.changed, ['description']); + assert.deepEqual(m.cookbooks.row, { name: 'Weeknight dinners', description: 'Fast ones' }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/sync-ids.test.js b/scripts/sync-ids.test.js new file mode 100644 index 00000000..2ca0dc53 --- /dev/null +++ b/scripts/sync-ids.test.js @@ -0,0 +1,171 @@ +/** + * Android sync: ids, the pull cursor and deletes. + * + * Ids differ between a phone and the server. The phone sends the server's + * id in every id column, listing a parent made in the same push in + * _local_fks, and the server keeps a pushed id only when the row it names + * belongs to the account. The pull picks rows by synced_at, the server's + * time of the write, so an edit made offline still reaches other devices. + * Rows deleted outright (categories, units, chat) and links taken out of + * a cookbook sync both ways. + * + * The end-to-end check (the app's db-native.js and sync.js against a real + * server) needs node:sqlite, which Node 20 lacks; these tests pin the + * pieces, and run the server's real schema when better-sqlite3 loads. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mapSmartFilterCategory, cleanSmartFilter } from '../src/lib/smart-cookbook.js'; + +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); +const serverSync = read('../server/routes/sync.js'); +const clientSync = read('../src/lib/sync.js'); +const dbNative = read('../src/lib/db-native.js'); +const apiNative = read('../src/lib/api-native.js'); + +// The server's TABLES[...].parents, read from the source. +function serverParents() { + const out = {}; + const re = /^ (\w+): \{\n cols:[\s\S]*?\n parents: (\{[\s\S]*?\}),\n/gm; + for (const m of serverSync.matchAll(re)) { + const body = m[2].replace(/\/\/.*$/gm, ''); + const map = {}; + for (const p of body.matchAll(/(\w+): '(\w+)'/g)) map[p[1]] = p[2]; + if (Object.keys(map).length) out[m[1]] = map; + } + return out; +} + +test('the phone translates the same id columns the server declares', () => { + const block = dbNative.slice(dbNative.indexOf('export const SYNC_PARENTS = {')); + const client = {}; + for (const m of block.slice(0, block.indexOf('\n};')).matchAll(/^ (\w+): \{([^}]*)\}/gm)) { + client[m[1]] = Object.fromEntries([...m[2].matchAll(/(\w+): '(\w+)'/g)].map(x => [x[1], x[2]])); + } + assert.deepEqual(client, serverParents()); + assert.ok(client.pantry_items.category_id === 'pantry_categories', "a pantry item's category is a pantry category"); +}); + +test('a pull maps each id through its own parent table', () => { + const pull = dbNative.slice(dbNative.indexOf('export async function dbApplyPull')); + assert.match(pull, /const parents = SYNC_PARENTS\[table\] \|\| \{\}/); + assert.doesNotMatch(pull, /category_id: \['recipe_categories', 'pantry_categories'\]/); +}); + +test('a push sends server ids, and lists parents from the same push', () => { + const push = clientSync.slice(clientSync.indexOf('async function _toServerIds')); + assert.match(push, /if \(!map\.has\(row\[fk\]\)\) row\[fk\] = null/); + assert.match(push, /else localFks\.push\(fk\)/); + assert.match(push, /row\._local_fks = localFks/); + assert.match(clientSync, /fk_ids: 'server'/); + assert.match(clientSync, /\{ cookbook_id: 'cookbooks', recipe_id: 'recipes' \}/, 'cookbook links go up as server ids too'); +}); + +test('the server keeps a pushed id only for a row the account may use', () => { + const fn = serverSync.slice(serverSync.indexOf('function _translateParents(')); + const body = fn.slice(0, fn.indexOf('\n}\n')); + assert.match(body, /if \(local\.has\(fk\)\) \{\s*if \(!mapped\) return null;/); + assert.match(body, /if \(!serverIds && mapped\)/); + assert.match(body, /if \(!ok\) out\[fk\] = null/); + assert.match(serverSync, /function _recipeLinkable[\s\S]*?recipe_shares/); + assert.match(serverSync, /_translateParents\(\{ \.\.\.r \}, linkSpec, idMaps, u, serverIds, \{ recipes: _recipeLinkable, cookbooks: _ownsRow \}\)/, + 'a cookbook link only goes into the account\'s own cookbook'); +}); + +test('tables without deleted_at take a push', () => { + assert.doesNotMatch(serverSync, /spec\.softDelete \? \(translated\.deleted_at \?\? null\) : null/, + 'an extra deleted_at value made every category, unit and chat push fail'); + assert.equal((serverSync.match(/\.\.\.\(spec\.softDelete \? \[translated\.deleted_at \?\? null\] : \[\]\)/g) || []).length, 2); +}); + +test('the pull picks rows by the server time of the write', () => { + const pull = serverSync.slice(serverSync.indexOf("router.get('/pull'")); + assert.match(pull, /synced_at >= \?\$\{orderBy\}/); + assert.match(pull, /AND synced_at >= \?`/); + assert.doesNotMatch(pull, /updated_at > \?/); + assert.match(pull, /FROM sync_deletions/); +}); + +test('deletes and removed links go up from the phone', () => { + for (const fn of ['deleteRecipeCategory', 'deletePantryCategory', 'deleteCustomUnit']) { + const body = apiNative.slice(apiNative.indexOf(`async ${fn}(`)).split('\n },')[0]; + assert.match(body, /_noteDelete\(/, `${fn} notes the delete`); + } + assert.match(apiNative.slice(apiNative.indexOf('async clearAiChat(')).split('\n },')[0], /INSERT INTO sync_deletes/); + assert.match(apiNative.slice(apiNative.indexOf('async removeRecipeFromCookbook(')).split('\n },')[0], /'recipe_cookbook_links'/); + assert.match(clientSync, /JSON\.stringify\(\{ tables: tablesToSend, settings, deletes, fk_ids: 'server', client_now: new Date\(\)\.toISOString\(\) \}\)/); + assert.match(clientSync, /if \(deleteIds\.length && body\.deleted( && live\(\))?\)/, 'kept until a server that knows deletes takes them'); +}); + +test("a pull keeps the phone's unsent links and drops what the server deleted", () => { + const pull = dbNative.slice(dbNative.indexOf('export async function dbApplyPull')); + assert.match(pull, /COALESCE\(sync_status, 'synced'\) != 'pending'/); + assert.match(pull, /const known = \[\.\.\.cookbookMap\.values\(\)\]/, 'every cookbook the server has, even one left with no links'); + assert.match(pull, /payload\.tables\.deletions/); + assert.match(dbNative, /WHERE sync_status = 'pending'`/, 'only links added or reordered here go up'); +}); + +test("a smart cookbook's category is translated both ways", () => { + const m = new Map([[3, 41]]); + assert.equal(mapSmartFilterCategory('{"category_id":3,"tags":["a"]}', id => m.get(id)), '{"category_id":41,"tags":["a"]}'); + assert.equal(mapSmartFilterCategory('{"category_id":9}', id => m.get(id)), '{"category_id":9}', 'unknown ids stay'); + assert.equal(mapSmartFilterCategory('{"tags":["a"]}', id => m.get(id)), '{"tags":["a"]}'); + assert.equal(mapSmartFilterCategory(null, id => m.get(id)), null); + assert.equal(mapSmartFilterCategory('{bad', id => m.get(id)), '{bad'); + assert.deepEqual(cleanSmartFilter({ category_id: '7', tags: [' a ', ''], favorites_only: 'yes', min_rating: '4', junk: 1 }), + { category_id: 7, tags: ['a'], min_rating: 4 }); + assert.match(dbNative, /mapSmartFilterCategory\(translated\.smart_filter_json, id => cats\.get\(id\)\)/); + assert.match(clientSync, /row\._local_filter_category = true/); +}); + +// The real schema: open server/db.js on a scratch database in a child +// process (it opens DB_PATH when imported) and check the triggers. +test('server schema: writes are stamped, deletes are recorded', (t) => { + try { + createRequire(new URL('../server/package.json', import.meta.url))('better-sqlite3'); + } catch { + t.skip('better-sqlite3 is not built for this Node'); + return; + } + const dir = mkdtempSync(join(tmpdir(), 'ct-sync-')); + try { + const script = ` + const { default: db } = await import(${JSON.stringify(new URL('../server/db.js', import.meta.url).href)}); + const u = db.prepare("INSERT INTO users (username, password_hash) VALUES ('t', 'x')").run().lastInsertRowid; + const cat = db.prepare("INSERT INTO recipe_categories (user_id, name, slug) VALUES (?, 'C', 'c')").run(u).lastInsertRowid; + const r = db.prepare("INSERT INTO recipes (user_id, name, category_id, updated_at) VALUES (?, 'R', ?, '2020-01-01 00:00:00')").run(u, cat).lastInsertRowid; + const before = db.prepare('SELECT synced_at FROM recipes WHERE id = ?').get(r).synced_at; + await new Promise(res => setTimeout(res, 15)); + db.prepare("UPDATE recipes SET name = 'R2', updated_at = '2020-01-02 00:00:00' WHERE id = ?").run(r); + const after = db.prepare('SELECT synced_at, updated_at FROM recipes WHERE id = ?').get(r); + db.prepare('DELETE FROM recipe_categories WHERE id = ?').run(cat); + const del = db.prepare("SELECT table_name, row_id, user_id FROM sync_deletions").all(); + const recat = db.prepare('SELECT category_id FROM recipes WHERE id = ?').get(r).category_id; + const chat = db.prepare("INSERT INTO ai_chat_history (user_id, role, content, updated_at) VALUES (?, 'user', 'hi', '2020-01-01 00:00:00')").run(u).lastInsertRowid; + db.prepare("UPDATE ai_chat_history SET content = 'yo', updated_at = '2020-01-03 00:00:00' WHERE id = ?").run(chat); + const chatRow = db.prepare('SELECT updated_at, synced_at FROM ai_chat_history WHERE id = ?').get(chat); + db.prepare("UPDATE ai_chat_history SET content = 'no time' WHERE id = ?").run(chat); + const chatBumped = db.prepare('SELECT updated_at FROM ai_chat_history WHERE id = ?').get(chat).updated_at; + process.stdout.write(JSON.stringify({ before, after, del, recat, chatRow, chatBumped, cat: Number(cat), u: Number(u) })); + `; + const res = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, DB_PATH: join(dir, 'test.db') }, encoding: 'utf8', + }); + assert.equal(res.status, 0, res.stderr); + const out = JSON.parse(res.stdout.slice(res.stdout.indexOf('{"before"'))); + assert.match(out.before, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}$/, 'an insert is stamped with the server time'); + assert.ok(out.after.synced_at > out.before, 'an update moves the stamp'); + assert.equal(out.after.updated_at, '2020-01-02 00:00:00', "the device's edit time is kept"); + assert.deepEqual(out.del, [{ table_name: 'recipe_categories', row_id: out.cat, user_id: out.u }]); + assert.equal(out.recat, null, "a deleted category's recipes stay, without it"); + assert.equal(out.chatRow.updated_at, '2020-01-03 00:00:00', "the stamp doesn't reset a chat row's edit time"); + assert.notEqual(out.chatBumped, '2020-01-03 00:00:00', 'an edit without a time still gets one'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/tandoor-zip-import.test.js b/scripts/tandoor-zip-import.test.js new file mode 100644 index 00000000..83269317 --- /dev/null +++ b/scripts/tandoor-zip-import.test.js @@ -0,0 +1,77 @@ +/** + * Tandoor's export is a zip of zips (cookbook/integration/default.py): + * export.zip holds one <id>.zip per recipe, each with recipe.json and, when + * the recipe has one, image.<ext>. The bulk importer only looked at the + * outer zip's .json files, so every Tandoor export failed with "No JSON + * files found" (#72). Inner zips are now unpacked under <id>/, so each + * recipe keeps its own picture. Archives without inner zips are untouched. + * + * The recipe.json below is trimmed from a real export made with Tandoor's + * own exporter (2026-10, vabene1111/recipes:latest). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { loadRecipeZip, scanLoadedZip, readImageFromLoadedZip } from '../server/lib/recipe-importers.js'; + +const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex'); +const tandoorRecipe = (name, foods) => ({ + name, + description: '', + keywords: [{ name: 'Dinner' }], + steps: [{ + name: '', instruction: 'Cook it.', + ingredients: foods.map((f, i) => ({ food: { name: f }, unit: { name: 'g' }, amount: 100, note: '', order: i, is_header: false, no_amount: false })), + }], + working_time: 10, waiting_time: 0, internal: true, servings: 2, +}); + +async function tandoorExport(recipes) { + const outer = new JSZip(); + for (const [id, recipe, withImage] of recipes) { + const inner = new JSZip(); + inner.file('recipe.json', JSON.stringify(recipe)); + if (withImage) inner.file('image.png', PNG); + outer.file(`${id}.zip`, await inner.generateAsync({ type: 'nodebuffer' })); + } + return outer.generateAsync({ type: 'nodebuffer' }); +} + +test('a Tandoor export (zip of zips) imports every recipe with its own picture', async () => { + const buf = await tandoorExport([ + [7, tandoorRecipe('Crème brûlée', ['crème', 'sucre']), true], + [8, tandoorRecipe('Toast', ['bread']), false], + [9, tandoorRecipe('Pancakes', ['flour', 'egg', 'milk']), true], + ]); + const zip = await loadRecipeZip(buf); + const out = await scanLoadedZip(zip); + assert.deepEqual(out.map(o => o.recipe.name).sort(), ['Crème brûlée', 'Pancakes', 'Toast']); + assert.ok(out.every(o => o.source === 'tandoor')); + const byName = Object.fromEntries(out.map(o => [o.recipe.name, o])); + assert.equal(byName['Crème brûlée'].imageEntryName, '7/image.png'); + assert.equal(byName['Pancakes'].imageEntryName, '9/image.png'); + assert.equal(byName['Toast'].imageEntryName, null, 'a recipe without a picture must not borrow another one'); + const img = await readImageFromLoadedZip(zip, byName['Pancakes'].imageEntryName); + assert.equal(img.ext, 'png'); + assert.equal(byName['Pancakes'].recipe.ingredients.flatMap(g => g.items).length, 3); +}); + +test('a zip without inner zips scans exactly as before', async () => { + const z = new JSZip(); + z.file('data/recipes/pasta/recipe.json', JSON.stringify({ name: 'Pasta', recipe_ingredient: [{ note: '200 g pasta' }], recipe_instructions: [{ text: 'Boil.' }] })); + z.file('data/recipes/pasta/images/original.webp', PNG); + const zip = await loadRecipeZip(await z.generateAsync({ type: 'nodebuffer' })); + const names = Object.keys(zip.files).sort(); + const out = await scanLoadedZip(zip); + assert.deepEqual(Object.keys(zip.files).sort(), names); + assert.equal(out.length, 1); + assert.equal(out[0].imageEntryName, 'data/recipes/pasta/images/original.webp'); +}); + +test('a file named .zip that is not a zip is skipped, not fatal', async () => { + const z = new JSZip(); + z.file('notes.zip', 'not really a zip'); + z.file('r/recipe.json', JSON.stringify({ name: 'Salad', ingredients: [{ name: '', items: [{ qty: '1', unit: '', name: 'lettuce' }] }], steps: [{ text: 'Chop.' }] })); + const out = await scanLoadedZip(await loadRecipeZip(await z.generateAsync({ type: 'nodebuffer' }))); + assert.deepEqual(out.map(o => o.recipe.name), ['Salad']); +}); diff --git a/scripts/webhooks-wiring.test.js b/scripts/webhooks-wiring.test.js index a4a9910b..7e4074a0 100644 --- a/scripts/webhooks-wiring.test.js +++ b/scripts/webhooks-wiring.test.js @@ -151,7 +151,7 @@ test('the Android sync upload fires the same webhook events as the REST routes', // sent after the response, so a rolled-back push never announces itself. assert.match(syncJs, /txn\(\);\s*webhookEvents\.push\(\.\.\.tableEvents\)/); const sendAt = syncJs.indexOf('for (const [event, data] of webhookEvents)'); - const respondAt = syncJs.indexOf('res.json({ tables: results });'); + const respondAt = syncJs.indexOf('res.json({ tables: results'); assert.ok(respondAt > 0 && sendAt > respondAt, 'webhooks should be dispatched after the push response is built'); assert.match(syncJs, /catch \(e\) \{ \/\* never let a webhook failure block the save \*\/ \}/); }); diff --git a/server/db.js b/server/db.js index c765da0b..9a61c8c6 100644 --- a/server/db.js +++ b/server/db.js @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import { foldText } from './lib/search-text.js'; +import { SYNC_FIELDS } from './lib/sync-fields.js'; const dbPath = process.env.DB_PATH || './cooktrace.db'; fs.mkdirSync(path.dirname(dbPath), { recursive: true }); @@ -853,6 +854,139 @@ db.exec(` } } +// ── Sync cursor ──────────────────────────────────────────────────────────── +// synced_at is the SERVER's clock time of the last write to a row, stamped +// by triggers so every write path (REST routes, sync push, restore) is +// covered. /api/sync/pull filters on it. updated_at can't serve as the +// pull cursor: a device stamps updated_at when the user edits, which may +// be long before the edit reaches the server, so an offline edit could +// land behind another device's last pull and never be sent to it. +// Rows that predate the column get the time it was added, so every +// device pulls everything once and picks up edits it missed before. +{ + const STAMP = `strftime('%Y-%m-%d %H:%M:%f', 'now')`; + for (const t of ['recipe_categories', 'pantry_categories', 'custom_units', 'cookbooks', 'recipes', + 'pantry_items', 'cook_diary', 'shopping_list', 'recipe_comments', 'ai_chat_history']) { + if (!columnExists(t, 'synced_at')) { + db.exec(`ALTER TABLE ${t} ADD COLUMN synced_at TEXT`); + db.exec(`UPDATE ${t} SET synced_at = ${STAMP} WHERE synced_at IS NULL`); + } + db.exec(`CREATE INDEX IF NOT EXISTS idx_${t}_synced ON ${t}(synced_at)`); + db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_${t}_synced_ins AFTER INSERT ON ${t} + BEGIN UPDATE ${t} SET synced_at = ${STAMP} WHERE id = NEW.id; END; + CREATE TRIGGER IF NOT EXISTS trg_${t}_synced_upd AFTER UPDATE ON ${t} + BEGIN UPDATE ${t} SET synced_at = ${STAMP} WHERE id = NEW.id; END; + `); + } + if (!columnExists('user_settings', 'synced_at')) { + db.exec(`ALTER TABLE user_settings ADD COLUMN synced_at TEXT`); + db.exec(`UPDATE user_settings SET synced_at = ${STAMP} WHERE synced_at IS NULL`); + } + db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_user_settings_synced_ins AFTER INSERT ON user_settings + BEGIN UPDATE user_settings SET synced_at = ${STAMP} WHERE user_id IS NEW.user_id AND key = NEW.key; END; + CREATE TRIGGER IF NOT EXISTS trg_user_settings_synced_upd AFTER UPDATE ON user_settings + BEGIN UPDATE user_settings SET synced_at = ${STAMP} WHERE user_id IS NEW.user_id AND key = NEW.key; END; + `); + // Tables without a deleted_at column lose rows outright. Each delete + // is noted here so /api/sync/pull can tell devices to drop the row too. + db.exec(` + CREATE TABLE IF NOT EXISTS sync_deletions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + table_name TEXT NOT NULL, + row_id INTEGER NOT NULL, + synced_at TEXT NOT NULL DEFAULT (${STAMP}) + ); + CREATE INDEX IF NOT EXISTS idx_sync_deletions_user ON sync_deletions(user_id, synced_at); + `); + // Only while the account is there: deleting an account removes its + // rows too, and there's no device left to tell. + for (const t of ['recipe_categories', 'pantry_categories', 'custom_units', 'ai_chat_history']) { + db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_${t}_sync_del AFTER DELETE ON ${t} + FOR EACH ROW WHEN OLD.user_id IS NULL OR EXISTS (SELECT 1 FROM users WHERE id = OLD.user_id) + BEGIN INSERT INTO sync_deletions (user_id, table_name, row_id) VALUES (OLD.user_id, '${t}', OLD.id); END; + `); + } + // A year is far longer than any device stays offline between syncs. + db.exec(`DELETE FROM sync_deletions WHERE synced_at < strftime('%Y-%m-%d %H:%M:%f', 'now', '-365 days')`); + + // The chat's updated_at trigger above fires on any update that leaves + // updated_at alone, which the stamp itself is; it would overwrite a + // device's edit time with the server's. Only a write to the message + // itself counts. + db.exec(` + DROP TRIGGER IF EXISTS trg_ai_chat_history_updated_at_upd; + CREATE TRIGGER trg_ai_chat_history_updated_at_upd + AFTER UPDATE OF role, content ON ai_chat_history + FOR EACH ROW WHEN NEW.updated_at IS OLD.updated_at + BEGIN + UPDATE ai_chat_history SET updated_at = datetime('now') WHERE id = NEW.id; + END; + `); +} + +// ── Earlier versions of a recipe ────────────────────────────────────────── +// When a sync meets two edits of one recipe, the newer stays and the other +// is kept here (server/lib/recipe-versions.js), as is the copy a restore +// replaces. `seen` is whether the owner has looked since it was kept; +// `edited_by` whose edit it was (the owner, or a kitchen member). +db.exec(` + CREATE TABLE IF NOT EXISTS recipe_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT 'conflict', + created_at TEXT DEFAULT (datetime('now')), + seen INTEGER NOT NULL DEFAULT 0, + edited_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_recipe_versions_recipe ON recipe_versions(recipe_id, id); +`); + +// When each synced field of a row last changed here: field_stamps holds +// { field: [synced_at, updated_at] } of the write that changed it. Two +// edits of a row meet field by field (lib/field-merge.js): a field changed +// here since a device's copy is one the device's edit competes with, and +// one that wasn't is simply the device's to change. Logging a cook or making +// a share link changes none of a recipe's fields an edit competes with, and +// reordering a list changes only sort_order. Kept by triggers, so every +// write path counts. Rows from before have none: every field then counts as +// changed at the row's own times. +{ + const STAMP = `strftime('%Y-%m-%d %H:%M:%f', 'now')`; + for (const [t, fields] of Object.entries(SYNC_FIELDS)) { + if (!columnExists(t, 'field_stamps')) db.exec(`ALTER TABLE ${t} ADD COLUMN field_stamps TEXT`); + // Rows from before the stamps: a baseline, the last write known now, for + // every field without a stamp of its own (lib/field-merge.js fieldStamps). + db.exec(`UPDATE ${t} SET field_stamps = json_object('_base', json_array(COALESCE(synced_at, ''), COALESCE(updated_at, ''))) WHERE field_stamps IS NULL`); + const all = fields.map(f => `'${f}', json_array(${STAMP}, NEW.updated_at)`).join(', '); + db.exec(` + DROP TRIGGER IF EXISTS trg_${t}_fields_ins; + CREATE TRIGGER trg_${t}_fields_ins AFTER INSERT ON ${t} + BEGIN UPDATE ${t} SET field_stamps = json_object(${all}) WHERE id = NEW.id; END; + `); + for (const f of fields) { + db.exec(` + DROP TRIGGER IF EXISTS trg_${t}_field_${f}; + CREATE TRIGGER trg_${t}_field_${f} AFTER UPDATE OF ${f} ON ${t} + FOR EACH ROW WHEN OLD.${f} IS NOT NEW.${f} + BEGIN UPDATE ${t} SET field_stamps = json_set(COALESCE(field_stamps, json_object('_base', json_array(COALESCE(OLD.synced_at, ''), COALESCE(OLD.updated_at, '')))), '$.${f}', json_array(${STAMP}, NEW.updated_at)) WHERE id = NEW.id; END; + `); + } + } +} + +// The Android app's key for a row it made (lib/create-keys.js), so the same +// create sent twice (a retry, two syncs at once, an answer lost) makes one row. +for (const t of Object.keys(SYNC_FIELDS)) { + if (!columnExists(t, 'client_key')) db.exec(`ALTER TABLE ${t} ADD COLUMN client_key TEXT DEFAULT NULL`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_${t}_client_key ON ${t}(user_id, client_key)`); +} + // ── Seed default app_config rows ─────────────────────────────────────────── { const seeds = [ diff --git a/server/email.js b/server/email.js index 2df872f3..bdf0dc11 100644 --- a/server/email.js +++ b/server/email.js @@ -126,8 +126,16 @@ export function isEmailConfigured() { const _FONT = `-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif`; +// Every name, title and link that goes into an email's HTML is escaped +// here, so a name like "<b>Eve</b>" reads as typed instead of as markup. +function _escapeHtml(str) { + return String(str) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + function emailWrapper(origin, bodyHtml, footerNote, preheaderText) { - const logoUrl = `${origin}/icons/logo-email.png`; + const logoUrl = `${_escapeHtml(origin)}/icons/logo-email.png`; const year = new Date().getFullYear(); const preheader = preheaderText ? `<div style="display:none;font-size:1px;color:#0A0B0F;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${preheaderText}</div>` @@ -213,7 +221,7 @@ ${preheader} function greeting(name) { return `<p class="nt-body-txt" style="margin:0 0 20px;font-size:15px;color:#8A93A8;line-height:1.7;"> - Hi${name ? ' <strong style="color:#FFFFFF;">' + name + '</strong>' : ''}, + Hi${name ? ' <strong style="color:#FFFFFF;">' + _escapeHtml(name) + '</strong>' : ''}, </p>`; } @@ -222,9 +230,9 @@ function ctaButton(href, label) { <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:0 auto;"> <tr> <td align="center" style="border-radius:10px;background-color:#00C47A;"> - <a href="${href}" + <a href="${_escapeHtml(href)}" style="display:inline-block;padding:14px 36px;font-family:${_FONT};font-size:15px;font-weight:700;color:#FFFFFF;text-decoration:none;border-radius:10px;letter-spacing:0.01em;"> - ${label} + ${_escapeHtml(label)} </a> </td> </tr> @@ -234,7 +242,7 @@ function ctaButton(href, label) { function fallbackUrl(url) { return `<p class="nt-expiry" style="margin:24px 0 0;font-family:${_FONT};font-size:12px;color:#4A5268;text-align:center;line-height:1.6;"> Button not working? Copy this link into your browser:<br/> - <a class="nt-fb-url" href="${url}" style="color:#00C47A;word-break:break-all;font-size:11px;">${url}</a> + <a class="nt-fb-url" href="${_escapeHtml(url)}" style="color:#00C47A;word-break:break-all;font-size:11px;">${_escapeHtml(url)}</a> </p>`; } @@ -269,7 +277,7 @@ export async function sendPasswordReset(email, resetUrl) { export async function sendInvite(email, inviteUrl, inviterName) { const origin = new URL(inviteUrl).origin; const sender = inviterName - ? `<strong style="color:#FFFFFF;">${inviterName}</strong> has invited you to join` + ? `<strong style="color:#FFFFFF;">${_escapeHtml(inviterName)}</strong> has invited you to join` : `You’ve been invited to join`; const body = ` @@ -295,7 +303,7 @@ export async function sendInvite(email, inviteUrl, inviterName) { await sendMail({ to: email, subject: `You've been invited to CookTrace`, - html: emailWrapper(origin, body, null, `${inviterName || 'Someone'} invited you to CookTrace — accept within 7 days.`), + html: emailWrapper(origin, body, null, `${_escapeHtml(inviterName || 'Someone')} invited you to CookTrace — accept within 7 days.`), text: `${inviterName ? inviterName + ' has invited you' : "You've been invited"} to join CookTrace.\n\nAccept your invitation:\n${inviteUrl}\n\nThis invite expires in 7 days.`, }); } @@ -307,8 +315,10 @@ export async function sendInvite(email, inviteUrl, inviterName) { export async function sendRecipeShared(email, viewUrl, recipeName, sharerName) { if (!email) return; const origin = new URL(viewUrl).origin; - const safeRecipe = String(recipeName || 'a recipe'); - const safeSharer = String(sharerName || 'Someone'); + const recipeText = String(recipeName || 'a recipe'); + const sharerText = String(sharerName || 'Someone'); + const safeRecipe = _escapeHtml(recipeText); + const safeSharer = _escapeHtml(sharerText); const body = ` ${greeting(null)} <p class="nt-heading" style="margin:0 0 10px;font-size:20px;font-weight:700;color:#FFFFFF;line-height:1.3;"> @@ -323,9 +333,9 @@ export async function sendRecipeShared(email, viewUrl, recipeName, sharerName) { await sendMail({ to: email, - subject: `${safeSharer} shared "${safeRecipe}" with you`, + subject: `${sharerText} shared "${recipeText}" with you`, html: emailWrapper(origin, body, null, `${safeSharer} shared "${safeRecipe}" with you on CookTrace.`), - text: `${safeSharer} shared "${safeRecipe}" with you on CookTrace.\n\nOpen it: ${viewUrl}`, + text: `${sharerText} shared "${recipeText}" with you on CookTrace.\n\nOpen it: ${viewUrl}`, }); } @@ -335,13 +345,13 @@ function _statRow(label, value, unit = '') { if (value == null) return ''; return ` <tr> - <td class="nt-stat-lbl nt-stat-div" style="padding:8px 0;font-family:${_FONT};font-size:14px;color:#8A93A8;border-bottom:1px solid #1E2330;">${label}</td> - <td class="nt-stat-val nt-stat-div" style="padding:8px 0;font-family:${_FONT};font-size:14px;font-weight:600;color:#FFFFFF;text-align:right;border-bottom:1px solid #1E2330;">${value}${unit ? ' <span style="font-weight:400;color:#5A6278;">' + unit + '</span>' : ''}</td> + <td class="nt-stat-lbl nt-stat-div" style="padding:8px 0;font-family:${_FONT};font-size:14px;color:#8A93A8;border-bottom:1px solid #1E2330;">${_escapeHtml(label)}</td> + <td class="nt-stat-val nt-stat-div" style="padding:8px 0;font-family:${_FONT};font-size:14px;font-weight:600;color:#FFFFFF;text-align:right;border-bottom:1px solid #1E2330;">${_escapeHtml(value)}${unit ? ' <span style="font-weight:400;color:#5A6278;">' + _escapeHtml(unit) + '</span>' : ''}</td> </tr>`; } function _sectionHeader(label) { - return `<p class="nt-section" style="margin:24px 0 8px;font-family:${_FONT};font-size:13px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#00C47A;">${label}</p>`; + return `<p class="nt-section" style="margin:24px 0 8px;font-family:${_FONT};font-size:13px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#00C47A;">${_escapeHtml(label)}</p>`; } // CookTrace weekly summary: cooks this week, top dish, new recipes, diff --git a/server/index.js b/server/index.js index d3a97b43..624449fb 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,5 @@ import 'dotenv/config'; +import { rememberAdminOrigin } from './lib/public-url.js'; // Forward-proxy support (#177). Self-installs an undici // EnvHttpProxyAgent as the global fetch dispatcher when // HTTP_PROXY / HTTPS_PROXY / NO_PROXY (or lowercase equivalents) @@ -30,7 +31,7 @@ import ntFederationRoutes from './routes/nt-federation.js'; import notifyRoutes from './routes/notify.js'; import unitsRoutes from './routes/units.js'; import cookbooksRoutes from './routes/cookbooks.js'; -import shareRoutes from './routes/share.js'; +import shareRoutes, { getPublicRecipe } from './routes/share.js'; import kitchensRoutes from './routes/kitchens.js'; import updatesRoutes from './routes/updates.js'; import apiTokensRoutes from './routes/api-tokens.js'; @@ -48,6 +49,7 @@ import { seedOidcFromEnv } from './lib/oidc-env.js'; // Initialise DB (runs schema) import db from './db.js'; import { isPrivateUploadPath, UPLOAD_RESPONSE_HEADERS } from './lib/upload-paths.js'; +import { publicPageHtml } from './lib/public-recipe-meta.js'; // Seed config from env vars if provided (env vars take priority over UI) seedSmtpFromEnv(); @@ -81,6 +83,9 @@ const router = express.Router(); // global parser so they win — by the time the global parser runs, req.body // is already populated and it short-circuits. router.use('/api/data/import', express.json({ limit: '25mb' })); +// The push's clock correction is measured from when the request arrived, +// before its body uploads, so a slow upload doesn't read as a slow clock. +router.use('/api/sync/push', (req, res, next) => { req.receivedAt = Date.now(); next(); }); router.use('/api/sync/push', express.json({ limit: '25mb' })); // Global cap: 1 MB. Prevents a single authed user from filling memory with // repeated large requests. Anything above belongs on a per-route opt-in. @@ -150,6 +155,12 @@ router.use('/uploads', express.static(uploadsPath, { router.use('/api/proxy', proxyRoutes); router.use(authenticate); // attach req.user on every request +// Remember the address an admin uses, so emailed links (password reset, +// invite, sharing) go there instead of to whatever Host a request claims. +router.use((req, res, next) => { + if (req.user?.role === 'admin') rememberAdminOrigin(req); + next(); +}); router.use(csrfProtect); // CSRF protection for cookie-based sessions // ── Request logging ──────────────────────────────────────────────────────── @@ -166,6 +177,15 @@ router.use((req, res, next) => { // Prevent browser/proxy caching of all API responses router.use('/api', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); }); +// Older docs gave the OIDC callback as /api/oidc/callback. An IdP set up from +// them sends people here; forward to the real callback (which finds the +// provider from the sign-in state) instead of letting the SPA fallback show +// a blank page. Before the setup gate so OIDC-first installs work too. +router.get('/api/oidc/callback', (req, res) => { + const q = req.originalUrl.indexOf('?'); + res.redirect(307, `${BASE_URL}/api/auth/oidc/callback${q >= 0 ? req.originalUrl.slice(q) : ''}`); +}); + // Setup enforcement — block data APIs until the first user account is created. // /api/auth/* is allowed so the client can register the admin. /api/r/* // (public share-link reads) is also allowed so a recipient with a @@ -252,6 +272,19 @@ try { logger.warn(`[server] could not pre-template dist/index.html: ${e.message}`); } +// A public recipe link: the app page, which opens the recipe without signing +// in. An unknown or removed token still gets the page (it says the link is +// gone) but with a 404. +router.get('/r/:token', (req, res) => { + res.set('Cache-Control', 'no-store'); + res.set('Referrer-Policy', 'no-referrer'); + res.set('X-Robots-Tag', 'noindex, nofollow'); + if (!_indexHtmlTemplated) return res.sendFile(_indexHtmlPath); + const html = publicPageHtml(_indexHtmlTemplated, BASE_URL); + const recipe = getPublicRecipe(req.params.token); + res.status(recipe ? 200 : 404).set('Content-Type', 'text/html').send(html); +}); + // SPA fallback — serves the templated index.html for any route under BASE_URL. // Express 5 / path-to-regexp 8 requires named splat syntax for catch-alls; // `/{*splat}` matches every path including the root, so the SPA HTML is @@ -303,6 +336,13 @@ app.listen(PORT, async () => { logger.warn(`[claim] orphan repair skipped: ${e.message}`); } + // Photos stored embedded in their rows (pushed by the Android app before + // the push turned them into files) become files. After listening, so it + // never holds up startup; it tries again on every start until all are done. + import('./lib/inline-photos.js').then(({ repairInlinePhotos }) => repairInlinePhotos()).catch(e => { + logger.warn(`[inline-photos] repair skipped: ${e.message}`); + }); + // Start the notification + sync scheduler import('./lib/scheduler.js').then(({ startScheduler }) => startScheduler()).catch(e => { logger.warn(`[scheduler] failed to start: ${e.message}`); diff --git a/server/lib/claim-anonymous-data.js b/server/lib/claim-anonymous-data.js index 71d9a477..c7169199 100644 --- a/server/lib/claim-anonymous-data.js +++ b/server/lib/claim-anonymous-data.js @@ -25,6 +25,10 @@ export const CLAIM_NULL = [ 'recipes', 'pantry_items', 'cook_diary', 'shopping_list', 'recipe_categories', 'pantry_categories', 'custom_units', 'disabled_units', 'cookbooks', 'ai_chat_history', 'notification_log', + // Deletes waiting for devices to pull: they follow the rows they removed. + 'sync_deletions', + // Earlier versions of recipes, kept with the recipes they belong to. + 'recipe_versions', ]; const ORPHAN_EXTRA_COUNTS = []; diff --git a/server/lib/create-keys.js b/server/lib/create-keys.js new file mode 100644 index 00000000..7a78fe27 --- /dev/null +++ b/server/lib/create-keys.js @@ -0,0 +1,25 @@ +/** + * create-keys.js: a new row sent twice is made once. + * + * The Android app gives every row it makes a stable key (its install and + * its own row id), sent with the create: Connect > Upload, and the sync + * push for rows the server hasn't seen. A retry, two syncs at once, or an + * answer lost on the way back sends the same key again, and gets the row + * made the first time instead of a second one. Kept in client_key (db.js), + * per account. + */ +import db from '../db.js'; + +export function cleanCreateKey(v) { + return typeof v === 'string' && v.trim() ? v.trim().slice(0, 128) : null; +} + +/** The account's row made with this key before, or null. */ +export function findByCreateKey(table, userId, key) { + if (!key) return null; + return db.prepare(`SELECT * FROM ${table} WHERE client_key = ? AND user_id IS ?`).get(key, userId ?? null) || null; +} + +export function setCreateKey(table, id, key) { + if (key) db.prepare(`UPDATE ${table} SET client_key = ? WHERE id = ?`).run(key, id); +} diff --git a/server/lib/field-merge.js b/server/lib/field-merge.js new file mode 100644 index 00000000..1e81fe85 --- /dev/null +++ b/server/lib/field-merge.js @@ -0,0 +1,151 @@ +/** + * One edit of a synced row meeting the copy here, field by field. + * + * An edit says which copy it was made on (base: the server's synced_at of + * that copy) and which fields it changed. db.js keeps, per row, when each + * field last changed here (field_stamps: { field: [synced_at, edited_at] }). + * So for each field the edit changed: + * - unchanged here since the edit's copy: the edit's value goes in; + * - changed here too: the newer of the two stays, by edit time on the + * server's clock, and the other is lost for that field. + * Fields the edit didn't change stay as they are here. A delete and an + * edit meet as a whole: the newer stays. With no usable base (an edit made + * before the app kept one, or a stamp from the future), every field counts + * as changed here, and the times decide. An edit time that can't be right + * (before the row existed, after now) can't be judged: the edit goes in, + * and what it replaces is reported as lost so it can be kept. + */ +import { serverIsNewer, utcMs } from './sync-clock.js'; +import { normaliseIngredientGroups } from './recipe-hydrate.js'; + +const BOOLEAN_FIELDS = new Set(['favorite', 'in_stock', 'checked', 'is_smart']); + +function _stable(v) { + if (Array.isArray(v)) return `[${v.map(_stable).join(',')}]`; + if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${_stable(v[k])}`).join(',')}}`; + return JSON.stringify(v ?? null); +} + +/** A field's value as compared: the same value written another way is equal. */ +export function normValue(f, v) { + if (v === undefined || v === '') v = null; + if (f === 'deleted_at') return v == null ? 0 : 1; + if (BOOLEAN_FIELDS.has(f)) return v === true || v === 1 || v === '1' || v === 'true' ? 1 : 0; + if (f === 'visibility') return v || 'private'; + if (typeof v === 'string' && /^\s*[[{]/.test(v)) { try { v = JSON.parse(v); } catch { /* text */ } } + if (f === 'ingredients' && Array.isArray(v)) v = normaliseIngredientGroups(v); + if (Array.isArray(v) && v.length === 0) return null; + if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) return null; + if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return Number(v); + return v; +} + +export function sameValue(f, a, b) { + return _stable(normValue(f, a)) === _stable(normValue(f, b)); +} + +/** + * When each field last changed here: [server stamp, edit time]. A field + * with no stamp of its own (a row from before the stamps, see db.js) takes + * the row's baseline, the last write known when the stamps began, never + * the row's latest write: a later edit of one field doesn't make the + * others look changed. + */ +export function fieldStamps(existing) { + let s = {}; + try { s = existing?.field_stamps ? JSON.parse(existing.field_stamps) : {}; } catch { s = {}; } + const baseline = Array.isArray(s._base) ? s._base : ['', '']; + return f => (Array.isArray(s[f]) ? s[f] : baseline); +} + +// Not an edit of an item's content: a new place in a list. It never stands +// against a delete, either way. +const ORDER_FIELDS = new Set(['sort_order']); + +/** + * existing: the row here. incoming: the edit's values (as stored; a field + * it doesn't send is undefined). fields: the fields that merge. groups: + * lists of fields that merge as one (lib/sync-fields.js SYNC_GROUPS). + * Options: base, changed (a Set, or null when the edit can't say: every + * field it sends counts), editedAt (server clock). + * + * Returns { row: every field's value afterwards, applied, lostIncoming, + * lostExisting: field lists, updatedAt (never earlier than here), fieldAt + * (the edit time of the fields that went in), write, devicePulls (the + * device's copy isn't what stays), validBase, plausible }. + */ +export function mergeFields(existing, incoming, { fields, groups = [], base, changed = null, editedAt, now = Date.now() }) { + const validBase = typeof base === 'string' && base !== '' && existing.synced_at != null && base <= existing.synced_at; + const stampOf = fieldStamps(existing); + const changedHere = f => !validBase || String(stampOf(f)[0] ?? '') > base; + const sent = f => incoming[f] !== undefined; + const has = f => sent(f) && (changed ? changed.has(f) : true); + const differs = f => has(f) && !sameValue(f, incoming[f], existing[f]); + const editMs = utcMs(editedAt); + const createdMs = utcMs(existing.created_at); + const plausible = Number.isFinite(editMs) && editMs <= now + 5000 + && (!Number.isFinite(createdMs) || editMs >= createdMs - 60_000); + // Whether the copy here, as of `at`, is newer than the edit. + const hereNewer = at => plausible && serverIsNewer(at, editedAt); + const latest = list => list.map(f => stampOf(f)[1]).filter(Boolean).sort((a, b) => utcMs(b) - utcMs(a))[0]; + + const row = {}; + const applied = [], lostIncoming = [], lostExisting = []; + const softDelete = fields.includes('deleted_at'); + const content = fields.filter(f => f !== 'deleted_at' && !ORDER_FIELDS.has(f)); + const editFields = content.filter(differs); + + let editLost = false; + row.deleted_at = softDelete ? (existing.deleted_at ?? null) : undefined; + if (softDelete && has('deleted_at') && !sameValue('deleted_at', incoming.deleted_at, existing.deleted_at)) { + if (incoming.deleted_at != null) { + // A delete: it loses to an edit of the item's content here since the + // device's copy that's newer than it (a new place in a list isn't one). + const latestHere = latest(content.filter(changedHere)); + if (latestHere && hereNewer(latestHere)) lostIncoming.push('deleted_at'); + else { row.deleted_at = incoming.deleted_at; applied.push('deleted_at'); } + } else { + row.deleted_at = null; applied.push('deleted_at'); + } + } else if (softDelete && existing.deleted_at != null && editFields.length && changedHere('deleted_at')) { + // An edit of the content meets a delete made here since the device's copy. + if (hereNewer(stampOf('deleted_at')[1])) editLost = true; + else { row.deleted_at = null; applied.push('deleted_at'); } + } + + // The fields that merge as one, then each of the rest on its own. + const inGroup = new Set(); + const units = []; + for (const g of groups) { + const u = g.filter(f => fields.includes(f) && f !== 'deleted_at'); + if (u.length) { units.push(u); u.forEach(f => inGroup.add(f)); } + } + for (const f of fields) if (f !== 'deleted_at' && !inGroup.has(f)) units.push([f]); + + for (const unit of units) { + for (const f of unit) row[f] = existing[f]; + const touched = unit.filter(differs); + if (!touched.length) continue; + if (editLost) { lostIncoming.push(...touched); continue; } + const take = () => { + // The edit's whole group (what it sends of it), so it stays whole. + for (const f of unit) { + if (sent(f) && (has(f) || unit.length > 1) && !sameValue(f, incoming[f], existing[f])) { row[f] = incoming[f]; applied.push(f); } + } + }; + const here = unit.filter(changedHere); + if (!here.length) { take(); continue; } + if (hereNewer(latest(here))) { lostIncoming.push(...touched); continue; } + lostExisting.push(...unit.filter(f => sent(f) && !sameValue(f, incoming[f], existing[f]))); + take(); + } + + const write = applied.length > 0; + // The time of what went in: the edit's, or now when it can't be right. + const fieldAt = plausible ? editedAt : new Date(now).toISOString().replace('T', ' ').slice(0, 19); + // A row's edit time never goes back. + const updatedAt = write && !(utcMs(existing.updated_at) >= utcMs(fieldAt)) ? fieldAt : existing.updated_at; + const devicePulls = fields.some(f => f !== 'deleted_at' && sent(f) && !sameValue(f, row[f], incoming[f])) + || (softDelete && sent('deleted_at') && !sameValue('deleted_at', row.deleted_at, incoming.deleted_at)); + return { row, applied, lostIncoming, lostExisting, updatedAt, fieldAt, write, devicePulls, validBase, plausible }; +} diff --git a/server/lib/field-stamps.js b/server/lib/field-stamps.js new file mode 100644 index 00000000..41429fed --- /dev/null +++ b/server/lib/field-stamps.js @@ -0,0 +1,14 @@ +/** + * Setting when fields of a row were edited, where a write's own updated_at + * isn't that time: a merged edit's fields went in at the edit's time, and + * a reorder moves sort_order now without being an edit of the row (its + * updated_at stays). See the field_stamps triggers in db.js. + */ +import db from '../db.js'; + +export function stampFields(table, id, fields, at = new Date().toISOString().replace('T', ' ').slice(0, 19)) { + if (!fields.length) return; + db.prepare( + `UPDATE ${table} SET field_stamps = json_set(COALESCE(field_stamps, '{}'), ${fields.map(() => `?, json_array(json_extract(COALESCE(field_stamps, '{}'), ?), ?)`).join(', ')}) WHERE id = ?` + ).run(...fields.flatMap(f => [`$.${f}`, `$.${f}[0]`, at]), id); +} diff --git a/server/lib/inline-photos.js b/server/lib/inline-photos.js new file mode 100644 index 00000000..8585daa2 --- /dev/null +++ b/server/lib/inline-photos.js @@ -0,0 +1,121 @@ +/** + * inline-photos.js: a photo that arrives embedded in a row (a data: URL) + * becomes a file under /uploads/, wherever the row comes from. + * + * The Android app keeps a photo taken with no connection embedded in its + * row, and the sync push sent it on as it was: every list and pull then + * carried the whole picture, and anything that needs a real address (the + * share card, a link preview, NutriTrace's CookTrace import) couldn't use + * it. The REST routes already turned these into files + * (lib/image-localizer.js); the push now does the same, and a repair at + * every startup turns those already stored into files. + */ +import db from '../db.js'; +import { logger } from '../logger.js'; +import { localizeDataUrl } from './image-localizer.js'; + +// The columns that hold a photo, per synced table. cook_diary.photos is a +// JSON list of them. +export const PHOTO_COLUMNS = { + recipes: ['img_url'], + pantry_items: ['img_url'], + cookbooks: ['cover_image_url'], + cook_diary: ['photo_url', 'photos'], +}; + +const _isData = v => typeof v === 'string' && /^data:/i.test(v); + +/** + * One column's value with every embedded photo turned into a file, in the + * same shape it came (text, a JSON list as text, or a list). A photo that + * can't be stored (not an image, too big, the disk refused) is dropped, as + * the REST routes do. Returns { value, failed }. + */ +export function localizePhotoValue(value) { + if (_isData(value)) { + const out = localizeDataUrl(value); + return { value: out, failed: out == null ? 1 : 0 }; + } + let list = value, asText = false; + if (typeof value === 'string' && value.trim().startsWith('[')) { + try { list = JSON.parse(value); asText = true; } catch { return { value, failed: 0 }; } + } + if (!Array.isArray(list) || !list.some(_isData)) return { value, failed: 0 }; + let failed = 0; + const out = []; + for (const p of list) { + if (!_isData(p)) { out.push(p); continue; } + const file = localizeDataUrl(p); + if (file) out.push(file); else failed++; + } + return { value: asText ? JSON.stringify(out) : out, failed }; +} + +/** A pushed row of `table` with its embedded photos as files (a copy). + * Only the columns the push says it changed (all of them for an app that + * doesn't say): a photo it didn't change isn't stored again, and isn't + * taken from it either (lib/field-merge.js). The two photo columns of a + * diary entry merge as one, so both go when either does. */ +export function localizeRowPhotos(table, row) { + const cols = PHOTO_COLUMNS[table]; + if (!cols || !row || typeof row !== 'object') return row; + // Columns that merge as one (a diary entry's photo and its photos) go + // in together, so they're stored together. + const said = Array.isArray(row.changed) ? new Set(row.changed) : null; + if (said && cols.some(c => said.has(c))) cols.forEach(c => said.add(c)); + let copy = row; + for (const c of cols) { + if (row[c] == null || (said && !said.has(c))) continue; + const { value } = localizePhotoValue(row[c]); + if (value !== row[c]) { if (copy === row) copy = { ...row }; copy[c] = value; } + } + return copy; +} + +/** + * At every startup: photos already stored embedded become files. Rows that + * aren't deleted only (nobody sees a deleted row's photo, and changing it + * would send it to every phone again for nothing). A row is changed only + * if the value is still the one read, so an edit made meanwhile stays. + * Its edit time and field stamps stay as they were (it isn't an edit, so it + * never stands against one); the sync stamp moves, so phones pull the path. + * What can't be stored is counted, logged and tried again next startup. + * Addresses that aren't embedded photos are never touched. + */ +let _running = null; +export function repairInlinePhotos() { + if (!_running) _running = _repair().finally(() => { _running = null; }); + return _running; +} + +async function _repair() { + let repaired = 0, failed = 0; + for (const [table, cols] of Object.entries(PHOTO_COLUMNS)) { + for (const col of cols) { + const rows = db.prepare( + `SELECT id, ${col} AS value, field_stamps FROM ${table} WHERE deleted_at IS NULL AND ${col} LIKE '%data:%'` + ).all(); + const update = db.prepare(`UPDATE ${table} SET ${col} = ? WHERE id = ? AND ${col} = ?`); + const keepStamps = db.prepare(`UPDATE ${table} SET field_stamps = ? WHERE id = ?`); + for (const row of rows) { + // One at a time, so the server keeps answering meanwhile. + await new Promise(r => setImmediate(r)); + const { value, failed: bad } = localizePhotoValue(row.value); + if (!bad && value === row.value) continue; // no embedded photo after all + if (bad) { + failed += bad; + logger.warn(`[inline-photos] ${table} id=${row.id} ${col}: could not store the photo; will try again next startup`); + continue; + } + const changed = db.transaction(() => { + const n = update.run(value, row.id, row.value).changes; + if (n) keepStamps.run(row.field_stamps, row.id); + return n; + })(); + repaired += changed; + } + } + } + if (repaired || failed) logger.info(`[inline-photos] stored ${repaired} embedded photo(s) as files, ${failed} failed`); + return { repaired, failed }; +} diff --git a/server/lib/link-checks.js b/server/lib/link-checks.js new file mode 100644 index 00000000..2e172dd0 --- /dev/null +++ b/server/lib/link-checks.js @@ -0,0 +1,58 @@ +/** + * link-checks.js: an id a request (or an upload) sends for another row + * points only at a row the account may point at, or at nothing. + * + * An id from somewhere else (the Android app's own numbering, sent by an + * older upload, or another account's row) never links across accounts and + * never reads another account's row (a pantry item's aisle, a recipe's + * name). Used by the REST routes; the sync push has the same rule + * (routes/sync.js _translateParents). + */ +import db from '../db.js'; + +const _mine = (row, u) => !!row && ((u == null && row.user_id == null) || (u != null && row.user_id === u)); + +/** The id, when that row of `table` is the account's (and not deleted; + * `softDelete: false` for a table without deleted_at); else null. */ +export function ownId(table, raw, u, { softDelete = true } = {}) { + const id = raw == null || raw === '' ? NaN : Number(raw); + if (!Number.isInteger(id)) return null; + const row = db.prepare(`SELECT user_id${softDelete ? ', deleted_at' : ''} FROM ${table} WHERE id = ?`).get(id); + if (!_mine(row, u)) return null; + if (softDelete && row.deleted_at != null) return null; + return id; +} + +/** A recipe the account owns or was shared (a shopping item or a cook can be about a shared one). */ +export function linkableRecipeId(raw, u) { + const own = ownId('recipes', raw, u); + if (own != null) return own; + const id = Number(raw); + if (u == null || !Number.isInteger(id)) return null; + return db.prepare(`SELECT 1 FROM recipe_shares WHERE recipe_id = ? AND grantee_id = ?`).get(id, u) ? id : null; +} + +/** + * A recipe's ingredients (JSON text or a list of groups) with every pantry + * link that isn't one of `ownerId`'s own pantry items taken out: a link + * holds the server's id of the recipe owner's item, never another + * account's (or an id from a phone's own numbering). Same type back. + */ +export function ownIngredientLinks(ingredients, ownerId) { + const asText = typeof ingredients === 'string'; + let groups = ingredients; + if (asText) { try { groups = JSON.parse(ingredients); } catch { return ingredients; } } + if (!Array.isArray(groups)) return ingredients; + let changed = false; + const items = g => (g && Array.isArray(g.items) ? g.items : null); + const fix = it => { + if (!it || typeof it !== 'object' || it.pantry_item_id == null) return it; + if (ownId('pantry_items', it.pantry_item_id, ownerId) != null) return it; + changed = true; + const { pantry_item_id, ...rest } = it; + return rest; + }; + const out = groups.map(g => (items(g) ? { ...g, items: g.items.map(fix) } : fix(g))); + if (!changed) return ingredients; + return asText ? JSON.stringify(out) : out; +} diff --git a/server/lib/oidc-env.js b/server/lib/oidc-env.js index 18441410..2ede078b 100644 --- a/server/lib/oidc-env.js +++ b/server/lib/oidc-env.js @@ -126,8 +126,27 @@ function _findDefinedPrefixes() { // ── Boot-time seeder ─────────────────────────────────────────────────────── +// Seed the SSO-only flag from env: OIDC_ENABLE_EMAIL_PASSWORD_LOGIN. +// Accepts truthy forms (1|true|yes|on) to enable password login (default), +// falsy forms (0|false|no|off) to disable it. When unset, the module-level +// env-lock flag stays false so admin UI keeps control. Runs whether or not +// any provider is defined through env: it used to sit after the "no env +// providers" early return, so it was silently ignored for providers added +// in Settings. +function _seedPasswordLoginFromEnv() { + _passwordLoginEnvLocked = false; + const rawFlag = process.env.OIDC_ENABLE_EMAIL_PASSWORD_LOGIN; + if (rawFlag != null && rawFlag !== '') { + const enabled = /^(1|true|yes|on)$/i.test(String(rawFlag).trim()); + setPasswordLoginEnabled(enabled); + _passwordLoginEnvLocked = true; + logger.info(`[oidc-env] Password login ${enabled ? 'enabled' : 'disabled'} via OIDC_ENABLE_EMAIL_PASSWORD_LOGIN`); + } +} + export function seedOidcFromEnv() { _envLockedIds.clear(); + _seedPasswordLoginFromEnv(); const prefixes = _findDefinedPrefixes(); if (!prefixes.length) return; @@ -211,18 +230,4 @@ export function seedOidcFromEnv() { if (count) { logger.info(`[oidc-env] Loaded ${count} OIDC provider${count === 1 ? '' : 's'} from environment (IDs: ${getEnvLockedProviderIds().join(', ')})`); } - - // Seed the SSO-only flag from env: OIDC_ENABLE_EMAIL_PASSWORD_LOGIN. - // Accepts truthy forms (1|true|yes|on) to enable password login (default), - // falsy forms (0|false|no|off) to disable it. When unset, the module-level - // env-lock flag stays false so admin UI keeps control. Cross-app fix - // mirrored from NutriTrace. - _passwordLoginEnvLocked = false; - const rawFlag = process.env.OIDC_ENABLE_EMAIL_PASSWORD_LOGIN; - if (rawFlag != null && rawFlag !== '') { - const enabled = /^(1|true|yes|on)$/i.test(String(rawFlag).trim()); - setPasswordLoginEnabled(enabled); - _passwordLoginEnvLocked = true; - logger.info(`[oidc-env] Password login ${enabled ? 'enabled' : 'disabled'} via OIDC_ENABLE_EMAIL_PASSWORD_LOGIN`); - } } diff --git a/server/lib/oidc.js b/server/lib/oidc.js index 7591aaa6..af3ec98d 100644 --- a/server/lib/oidc.js +++ b/server/lib/oidc.js @@ -21,6 +21,7 @@ import { Issuer, generators, custom } from 'openid-client'; // to recover without silently failing the auth flow. Cross-app fix mirrored // from NutriTrace. custom.setHttpOptionsDefaults({ timeout: 10000 }); +import crypto from 'crypto'; import db from '../db.js'; import { encrypt, decrypt } from './token-crypto.js'; import { logger } from '../logger.js'; @@ -100,7 +101,11 @@ export async function getClient(providerId) { response_types: _safeJsonArray(provider.response_types), token_endpoint_auth_method: provider.token_endpoint_auth_method, id_token_signed_response_alg: provider.id_token_signed_response_alg, - userinfo_signed_response_alg: provider.userinfo_signed_response_alg, + // Providers are stored with 'none' here, meaning a plain JSON userinfo + // response. openid-client reads any value as "expect a signed JWT", so + // only pass a real algorithm through. + ...(provider.userinfo_signed_response_alg && provider.userinfo_signed_response_alg !== 'none' + ? { userinfo_signed_response_alg: provider.userinfo_signed_response_alg } : {}), }); } @@ -111,11 +116,12 @@ export function invalidateDiscovery(providerId) { // ── PKCE / state persistence (reuses oauth_state) ───────────────────────── -export function persistState({ providerId, redirectUri, returnPath, codeVerifier, state, nonce, mobile, linkUserId }) { +export function persistState({ providerId, redirectUri, returnPath, codeVerifier, state, nonce, mobile, linkUserId, appChallenge }) { const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); const data = { providerId, redirectUri, returnPath, codeVerifier, nonce }; if (mobile) data.mobile = true; if (linkUserId) data.linkUserId = linkUserId; + if (appChallenge) data.appChallenge = appChallenge; db.prepare( `INSERT OR REPLACE INTO oauth_state (state, user_id, provider, data, expires_at) VALUES (?, ?, ?, ?, ?)` @@ -141,7 +147,46 @@ export function consumeState(state) { } export function purgeExpiredStates() { - db.prepare(`DELETE FROM oauth_state WHERE expires_at < datetime('now') AND provider LIKE 'oidc:%'`).run(); + db.prepare(`DELETE FROM oauth_state WHERE expires_at < datetime('now') AND (provider LIKE 'oidc:%' OR provider = 'oidc-handoff')`).run(); +} + +// ── Native app hand-off ─────────────────────────────────────────────────── +// The Android app finishes SSO through a cooktrace:// deep link, which any +// installed app can register for. Instead of the session token, that link +// carries a short-lived single-use code, and only the app that started the +// sign-in can swap it for the token: it sent the SHA-256 of a secret +// (app_challenge) when it opened the login URL, and must present the secret +// itself (verifier) with the code. Same idea as PKCE, one hop further. + +const HANDOFF_TTL_MS = 2 * 60 * 1000; +const APP_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/; // base64url SHA-256 + +export function isValidAppChallenge(v) { + return typeof v === 'string' && APP_CHALLENGE_RE.test(v); +} + +export function persistHandoff({ appChallenge, token, idTokenHint, providerId }) { + const code = crypto.randomBytes(32).toString('base64url'); + const expiresAt = new Date(Date.now() + HANDOFF_TTL_MS).toISOString(); + db.prepare( + `INSERT INTO oauth_state (state, user_id, provider, data, expires_at) VALUES (?, NULL, 'oidc-handoff', ?, ?)` + ).run(code, JSON.stringify({ appChallenge, token, idTokenHint: idTokenHint || null, providerId }), expiresAt); + return code; +} + +/** Single use: the row is deleted whether or not the verifier matches. */ +export function consumeHandoff(code, verifier) { + if (typeof code !== 'string' || typeof verifier !== 'string' || !code || !verifier) return null; + const row = db.prepare(`SELECT * FROM oauth_state WHERE state = ? AND provider = 'oidc-handoff'`).get(code); + if (!row) return null; + db.prepare(`DELETE FROM oauth_state WHERE state = ?`).run(code); + if (new Date(row.expires_at).getTime() < Date.now()) return null; + let data; + try { data = JSON.parse(row.data); } catch { return null; } + const expected = Buffer.from(String(data.appChallenge || ''), 'utf8'); + const actual = Buffer.from(crypto.createHash('sha256').update(verifier).digest('base64url'), 'utf8'); + if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) return null; + return data; } // ── Auth flow helpers ───────────────────────────────────────────────────── @@ -160,8 +205,9 @@ export function generateAuthChecks() { /** * Map OIDC claims to a CookTrace user. Implements the resolved - * email-collision policy: auto-link only when email_verified === true - * AND the provider has auto_register=1. + * email-collision policy: link to an existing account by email only when + * the IdP says email_verified === true and auto-link is on; a matching email + * that can't be linked is refused (never given a second account). * * Returns: { user, created, linked } — or throws with a user-facing message. */ @@ -208,12 +254,17 @@ export function resolveUser(provider, claims) { return { user: localByEmail, created: false, linked: true }; } } - // 2b. Email collision but auto-link is off → reject so the user can link - // via Profile after a password login. + // 2b. Email matches an existing account but we may not link it + // automatically (auto-link off, or the IdP didn't vouch for the email). + // Refuse and point at the manual link, instead of falling through to + // auto-register, which would create a second account with the same + // email and sign the person into that one instead of their own. + // Authentik >= 2025.10 sends email_verified=false by default, so this + // is the common case there. if (email) { const collision = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email); - if (collision && !autoLink) { - throw new Error(`Your ${provider.display_name || 'OIDC'} account matches an existing CookTrace user. Sign in once with your password and link this provider from Profile → Linked accounts. After that, SSO will sign you in directly.`); + if (collision && (!autoLink || !emailVerified)) { + throw new Error(`Your ${provider.display_name || 'OIDC'} account matches an existing CookTrace user. Sign in the usual way once, then link this provider from Profile → Linked Accounts. After that, SSO will sign you in directly.`); } } diff --git a/server/lib/openai-chat-params.js b/server/lib/openai-chat-params.js index fc4cb739..6e3b455e 100644 --- a/server/lib/openai-chat-params.js +++ b/server/lib/openai-chat-params.js @@ -8,12 +8,16 @@ const GPT_56_MODEL = /^gpt-5\.6(?:-|$)/; * OpenAI's current API uses max_completion_tokens. Keep max_tokens for * third-party OpenAI-compatible endpoints, many of which have not adopted * the newer field. + * + * `stream: false` is OpenAI's default, but some compatible gateways stream + * when the field is missing, and the reply then fails to parse as JSON + * (TraceApps/nutritrace#258). Every caller reads one JSON reply, so say so. */ export function getOpenAIChatParams({ baseUrl, model, hasTools, maxTokens = 4096 }) { const isOfficialOpenAI = baseUrl?.replace(/\/+$/, '') === OPENAI_API_ORIGIN; - if (!isOfficialOpenAI) return { max_tokens: maxTokens }; + if (!isOfficialOpenAI) return { stream: false, max_tokens: maxTokens }; - const params = { max_completion_tokens: maxTokens }; + const params = { stream: false, max_completion_tokens: maxTokens }; // GPT-5.6 Chat Completions requires effective reasoning "none" when // function tools are present. This does not disable tools: the supplied // tools remain available and tool_choice keeps its default "auto" behavior. diff --git a/server/lib/outbound-policy.js b/server/lib/outbound-policy.js new file mode 100644 index 00000000..45325e35 --- /dev/null +++ b/server/lib/outbound-policy.js @@ -0,0 +1,18 @@ +// Who may reach the server's own network when a feature fetches an +// address a user chose. The same file in all four Trace apps. +// +// Features made for services on the home network (push, Mealie, CookTrace, +// NutriTrace, Navidrome) allow it for everyone and don't ask here. +// Features made for the public web ask ownerOrOptIn(): an admin may; other +// accounts only when the owner sets that feature's ALLOW_PRIVATE_* variable. +// A single-user install has no sign-in, so every visitor would count as +// the owner; there only the variable opens it. +import { userMgmtActive } from '../middleware/auth.js'; + +export function envOn(value) { + return /^(1|true|yes|on)$/i.test(String(value ?? '').trim()); +} + +export function ownerOrOptIn(req, envVar) { + return (userMgmtActive() && req?.user?.role === 'admin') || envOn(process.env[envVar]); +} diff --git a/server/lib/pantry-tree.js b/server/lib/pantry-tree.js new file mode 100644 index 00000000..cc457949 --- /dev/null +++ b/server/lib/pantry-tree.js @@ -0,0 +1,68 @@ +/** + * Keeping the pantry's variant tree in the shape PUT /api/pantry/:id allows, + * after edits from two devices were merged (each fine alone, not together): + * - a variant's generic item is not itself a variant (one level only); + * - an item with variants is not a variant; + * - an item's nutrition source is one of its own variants, and a variant + * has no nutrition source. + * Where two links break a rule, the newer edit stays and the older link is + * taken out, so the tree is never left invalid. + */ +import db from '../db.js'; +import { fieldStamps } from './field-merge.js'; +import { utcMs } from './sync-clock.js'; +import { stampFields } from './field-stamps.js'; + +const _get = id => (id == null ? null : db.prepare(`SELECT * FROM pantry_items WHERE id = ? AND deleted_at IS NULL`).get(id)); +const _at = (row, f) => utcMs(fieldStamps(row)(f)[1]) || 0; +function _drop(id, f) { + db.prepare(`UPDATE pantry_items SET ${f} = NULL WHERE id = ?`).run(id); + stampFields('pantry_items', id, [f]); +} + +/** Check the rows `ids` touch (and their generics and variants); returns the links taken out. */ +export function repairVariantTree(ids) { + const dropped = []; + const queue = [...new Set(ids)]; + for (let n = 0; queue.length && n < 500; n++) { + const r = _get(queue.shift()); + if (!r) continue; + if (r.generic_parent_id != null) { + const p = _get(r.generic_parent_id); + // Its generic is a variant itself (or it is its own): the newer link stays. + if (p && (p.id === r.id || p.generic_parent_id != null)) { + if (p.id !== r.id && _at(r, 'generic_parent_id') >= _at(p, 'generic_parent_id')) { + _drop(p.id, 'generic_parent_id'); dropped.push([p.id, 'generic_parent_id']); queue.push(p.id, r.id); + } else { + _drop(r.id, 'generic_parent_id'); dropped.push([r.id, 'generic_parent_id']); queue.push(r.id); + } + continue; + } + // It is a variant with variants of its own: the newer link stays. + const kids = db.prepare(`SELECT * FROM pantry_items WHERE generic_parent_id = ? AND deleted_at IS NULL`).all(r.id); + if (kids.length) { + if (Math.max(...kids.map(k => _at(k, 'generic_parent_id'))) > _at(r, 'generic_parent_id')) { + _drop(r.id, 'generic_parent_id'); dropped.push([r.id, 'generic_parent_id']); queue.push(r.id); + continue; + } + for (const kid of kids) { _drop(kid.id, 'generic_parent_id'); dropped.push([kid.id, 'generic_parent_id']); } + } + } + const fresh = _get(r.id); + if (fresh?.nutrition_source_variant_id != null) { + const v = _get(fresh.nutrition_source_variant_id); + if (fresh.generic_parent_id != null || !v || v.generic_parent_id !== fresh.id) { + _drop(fresh.id, 'nutrition_source_variant_id'); dropped.push([fresh.id, 'nutrition_source_variant_id']); + } + } + // Its own generic may now own a source that is no longer its variant. + if (r.generic_parent_id != null && !queue.includes(r.generic_parent_id)) { + const p = _get(r.generic_parent_id); + if (p?.nutrition_source_variant_id != null) { + const v = _get(p.nutrition_source_variant_id); + if (!v || v.generic_parent_id !== p.id) { _drop(p.id, 'nutrition_source_variant_id'); dropped.push([p.id, 'nutrition_source_variant_id']); } + } + } + } + return dropped; +} diff --git a/server/lib/public-recipe-meta.js b/server/lib/public-recipe-meta.js new file mode 100644 index 00000000..b2568b5b --- /dev/null +++ b/server/lib/public-recipe-meta.js @@ -0,0 +1,92 @@ +/** + * public-recipe-meta.js: the <head> tags for a public recipe link, so a + * chat app or social site that fetches the link shows the recipe's name, a + * line about it, and its photo. Crawlers don't run the app, so these go + * into the HTML the server sends. Pure, so it's testable without a + * database. Same shape as NoteTrace's public-note-meta.js. + */ + +const DESCRIPTION_CHARS = 200; + +/** Escape for an HTML attribute value or text node. */ +export function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** One plain-text line for og:description: the description, else the ingredients. */ +export function publicRecipeDescription(recipe) { + let text = String(recipe.description || '').replace(/\s+/g, ' ').trim(); + if (!text) { + const names = (recipe.ingredients || []) + .flatMap(g => g.items || []) + .map(i => String(i?.name || '').trim()) + .filter(Boolean); + if (names.length) text = names.join(', '); + } + if (text.length > DESCRIPTION_CHARS) text = text.slice(0, DESCRIPTION_CHARS).replace(/\s+\S*$/, '') + '…'; + return text; +} + +/** An absolute URL for the photo, or null when it isn't one a crawler can fetch. */ +function absoluteUrl(url, origin, basePath) { + if (!url) return null; + if (/^https?:\/\//i.test(url)) return url; + if (url.startsWith('/')) return `${origin}${basePath}${url}`; + return null; +} + +/** + * The tags to put in <head>, as one string. + * recipe what getPublicRecipe returned + * origin scheme and host the link was opened on (no trailing slash) + * basePath BASE_URL ('' at the root) + * pageUrl the full public link + */ +export function publicRecipeHead(recipe, { origin, basePath = '', pageUrl }) { + const title = (recipe.name || '').trim() || 'Recipe'; + const description = publicRecipeDescription(recipe); + const image = absoluteUrl(recipe.img_url, origin, basePath); + const icon = `${origin}${basePath}/icons/icon-512.png`; + const tags = [ + `<title>${escapeHtml(title)}`, + // Unlisted: the link works for whoever has it, but search engines skip it, + // and following a link out of the recipe doesn't hand its token to that site. + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]; + if (description) { + tags.push( + ``, + ``, + ``, + ); + } + return tags.join('\n '); +} + +/** + * The app's index.html as served at /r/. The build links its scripts + * and styles relative to the page (./assets/...), which from /r/ would point + * at /r/assets/, so a sends them back to the app's root. + */ +export function publicPageHtml(html, basePath = '') { + return html.replace(//i, `\n `); +} + +/** The page with its swapped for the recipe's tags. */ +export function injectPublicHead(html, head) { + const swapped = html.replace(/<title>[^<]*<\/title>/, ''); + return swapped.replace('</head>', ` ${head}\n</head>`); +} diff --git a/server/lib/public-url.js b/server/lib/public-url.js new file mode 100644 index 00000000..0631a05f --- /dev/null +++ b/server/lib/public-url.js @@ -0,0 +1,111 @@ +/** + * public-url.js: the address put in links that leave the app, such as the + * link in a password reset, an invite or a "shared with you" email. + * + * Those links used to be built from the request's Host and X-Forwarded-Host + * headers, which the sender of the request controls. Asking for a reset of + * someone else's account with X-Forwarded-Host set to your own domain sent + * the victim a real reset email whose link, token and all, pointed at you. + * + * Where the address comes from now, in order: + * 1. PUBLIC_URL, when the server sets it: the full address the app is + * opened at, subpath included (https://example.com/lifttrace). + * 2. An address an admin has used. Every signed-in admin request records + * the address it came in on, so a link goes back to the address it + * was asked from when an admin uses that address too. Anything else, + * a forged header included, gets the admin's latest address, passing + * over localhost when a real one is known. Only a signed-in admin is + * recorded: before the first account exists anyone counts as the + * operator, and could otherwise plant an address for later. + * 3. With nothing recorded yet (an upgrade, before an admin has opened + * the app), the request's own address, but only when an admin asked: + * for anyone else the result is '' and the caller sends no email, + * since that address is exactly what an attacker would forge. + * + * This file is identical in all four Trace apps. + */ +import db from '../db.js'; +import { logger } from '../logger.js'; +import { userMgmtActive } from '../middleware/auth.js'; + +const KNOWN_KEY = 'known_origins'; // JSON list, newest last +const LATEST_KEY = 'app_url'; // the admin's latest address +const MAX_KNOWN = 10; +const ORIGIN_RE = /^https?:\/\/[a-z0-9._\-]+(:\d{1,5})?$|^https?:\/\/\[[0-9a-f:.]+\](:\d{1,5})?$/; +const LOOPBACK_RE = /^https?:\/\/(localhost|127(\.\d{1,3}){3}|\[::1\])(:\d+)?$/; + +const _basePath = () => (process.env.BASE_URL || '').replace(/\/$/, ''); + +/** The origin a request came in on, from the headers (untrusted). */ +export function requestOrigin(req) { + if (!req?.headers) return null; + const first = (v) => String(v || '').split(',')[0].trim(); + const proto = (first(req.headers['x-forwarded-proto']) || req.protocol || 'http').toLowerCase(); + const host = (first(req.headers['x-forwarded-host']) || first(req.headers.host)).toLowerCase(); + const origin = `${proto}://${host}`; + return ORIGIN_RE.test(origin) ? origin : null; +} + +/** The logic, apart from where it is stored (tests pass their own store). */ +export function createPublicUrl(store, { env = process.env, warn = () => {}, isAdmin = () => false } = {}) { + // Read fresh each time (one small row): a backup restore rewrites it. + const load = () => { + let list = []; + try { list = JSON.parse(store.get(KNOWN_KEY) || '[]'); } catch { list = []; } + return Array.isArray(list) ? list.filter(o => ORIGIN_RE.test(o)) : []; + }; + const latest = () => { + const l = load(); + if (l.length) return [...l].reverse().find(o => !LOOPBACK_RE.test(o)) || l[l.length - 1]; + // LiftTrace stored one address before this file existed; use it until + // an admin has been seen. + const saved = store.get(LATEST_KEY); + return saved && ORIGIN_RE.test(saved) ? saved : null; + }; + + return { + /** Record the address of a request an admin made. Cheap when nothing changed. */ + remember(req) { + // Only a browser or the app: a health check or script hitting the + // server sends none of these, and shouldn't decide the address. + const h = req?.headers || {}; + if (!h['sec-fetch-site'] && !h.origin && !h.referer) return; + const origin = requestOrigin(req); + if (!origin) return; + const l = load(); + if (l[l.length - 1] === origin) return; + const known = l.filter(o => o !== origin).concat(origin).slice(-MAX_KNOWN); + store.set(KNOWN_KEY, JSON.stringify(known)); + store.set(LATEST_KEY, origin); + }, + /** + * Base for an emailed link: scheme, host and subpath, no trailing slash. + * `req` is the request asking for the email, or null (the scheduler). + */ + linkBase(req) { + const pub = String(env.PUBLIC_URL || '').trim().replace(/\/+$/, ''); + if (/^https?:\/\/[^\s/]+/i.test(pub)) return pub; + const base = _basePath(); + const asked = requestOrigin(req); + const l = load(); + if (asked && l.includes(asked)) return asked + base; + const last = latest(); + if (last) return last + base; + if (asked && isAdmin(req)) return asked + base; + if (req) warn('[public-url] no email sent: no admin address is known yet. Set PUBLIC_URL, or open the app once as an admin.'); + return ''; + }, + }; +} + +const _store = { + get: (key) => db.prepare('SELECT value FROM app_config WHERE key = ?').get(key)?.value ?? null, + set: (key, value) => db.prepare('INSERT OR REPLACE INTO app_config (key, value) VALUES (?, ?)').run(key, value), +}; +const _publicUrl = createPublicUrl(_store, { + warn: (m) => logger.warn(m), + isAdmin: (req) => !userMgmtActive() || req?.user?.role === 'admin', +}); + +export const rememberAdminOrigin = (req) => { try { _publicUrl.remember(req); } catch (e) { logger.warn(`[public-url] ${e.message}`); } }; +export const linkBase = (req) => _publicUrl.linkBase(req); diff --git a/server/lib/push-notify.js b/server/lib/push-notify.js index 0bf52f57..652550d7 100644 --- a/server/lib/push-notify.js +++ b/server/lib/push-notify.js @@ -13,6 +13,17 @@ import db from '../db.js'; import { logger } from '../logger.js'; +import { fetchChecked, serviceBase } from './ssrf-guard.js'; + +// Push services (Gotify, ntfy, Apprise) usually live on the home network, +// so that's allowed for every account; the address is still checked on +// every redirect hop, a redirect only followed on the same server (never +// cloud metadata), and the connection pinned. +async function _send(url, init) { + const res = await fetchChecked(url, init, { allowPrivate: true, maxRedirects: 3, sameOrigin: true }); + try { await res.body?.cancel(); } catch {} + return res; +} // Cleanup stale dedup keys older than 7 days (runs once at module load). try { @@ -70,7 +81,7 @@ async function _pushGotify(userId, title, message, priority) { const url = _getUserSetting(userId, 'gotifyUrl'); const token = _getUserSetting(userId, 'gotifyToken'); if (!url || !token) return; - const res = await fetch(`${url.replace(/\/+$/, '')}/message?token=${encodeURIComponent(token)}`, { + const res = await _send(`${serviceBase(url) ?? url}/message?token=${encodeURIComponent(token)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `CookTrace — ${title}`, message, priority }), @@ -86,7 +97,7 @@ async function _pushNtfy(userId, title, message, priority) { if (!topic) return; const headers = { 'Title': _encodeHeaderValue(`CookTrace — ${title}`), 'Priority': String(Math.min(5, priority)) }; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`${url.replace(/\/+$/, '')}/${encodeURIComponent(topic)}`, { + const res = await _send(`${serviceBase(url) ?? url}/${encodeURIComponent(topic)}`, { method: 'POST', headers, body: message, }); if (!res.ok) throw new Error(`ntfy ${res.status}`); @@ -99,7 +110,7 @@ async function _pushApprise(userId, title, message, priority) { if (!url) return; const body = { title: `CookTrace — ${title}`, body: message, type: priority >= 7 ? 'warning' : 'info' }; if (tag) body.tag = tag; - const res = await fetch(`${url.replace(/\/+$/, '')}/notify`, { + const res = await _send(`${serviceBase(url) ?? url}/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/server/lib/recipe-ai-fallback.js b/server/lib/recipe-ai-fallback.js index 4ed6ee26..1a93c747 100644 --- a/server/lib/recipe-ai-fallback.js +++ b/server/lib/recipe-ai-fallback.js @@ -10,6 +10,8 @@ * sending so we don't blow through token limits on long pages. */ import * as cheerio from 'cheerio'; +import { logger } from '../logger.js'; +import { fetchChecked, serviceBase } from './ssrf-guard.js'; // Cap the HTML payload sent to the LLM. Recipes virtually never need // more than this; oversized pages get truncated. @@ -90,7 +92,7 @@ function _condenseHtml(html, sourceUrl) { * aiCfg shape: { provider, apiKey, model, baseUrl } */ export async function aiExtractRecipe(html, sourceUrl, aiCfg) { - if (!aiCfg?.provider || !aiCfg?.apiKey) { + if (!aiCfg?.provider || !(aiCfg.apiKey || (isCompatibleProvider(aiCfg.provider) && aiCfg.baseUrl))) { throw new Error('AI not configured'); } const condensed = _condenseHtml(html, sourceUrl); @@ -161,10 +163,70 @@ async function _callProvider(cfg, systemPrompt, userText) { case 'claude': return _callClaude(apiKey, model, systemPrompt, userText); case 'openai': return _callOpenAI(apiKey, model, systemPrompt, userText); case 'gemini': return _callGemini(apiKey, model, systemPrompt, userText); - case 'custom': return _callOpenAI(apiKey || 'no-key', model, systemPrompt, userText, (cfg.baseUrl || '').replace(/\/+$/, '')); + // 'custom' is the app's name for an OpenAI-compatible endpoint, + // 'oai-compat' the server's (AI_PROVIDER). + case 'custom': + case 'oai-compat': return _callOpenAI(apiKey || 'no-key', model, systemPrompt, userText, (cfg.baseUrl || '').replace(/\/+$/, ''), cfg); default: throw new Error(`Unknown AI provider: ${provider}`); } } + +// A user's own AI address, through the address check. The owner sees why +// an address was refused; anyone else the same message as for one that +// doesn't answer, so it can't map which names exist on the network. +function _sendToUserEndpoint(cfg) { + return async (url, init) => { + try { + return await fetchChecked(url, init, { allowPrivate: !!cfg.allowPrivate, allowPrivateEnvHint: 'ALLOW_PRIVATE_AI_URLS', maxRedirects: 3 }); + } catch (e) { + if (cfg.allowPrivate || /cloud-metadata/.test(e.message)) throw e; + if (/addresses are|resolve host/.test(e.message)) throw new Error('Could not reach the AI address'); + throw e; + } + }; +} + +// A provider's refusal for the user: its status and its own short error +// message ("model not found"), never the raw reply, which goes to the log. +async function _providerError(name, res) { + const raw = await res.text().catch(() => ''); + logger.warn(`[recipe-ai] ${name} returned ${res.status}: ${raw.slice(0, 500)}`); + let message = ''; + try { + const e = JSON.parse(raw)?.error; + message = typeof e === 'string' ? e : (typeof e?.message === 'string' ? e.message : ''); + } catch {} + return new Error(`${name} returned ${res.status}${message ? `: ${message.slice(0, 200)}` : ''}`); +} + +// OpenAI-compatible endpoints (Ollama, LM Studio...) often need no API key. +export function isCompatibleProvider(provider) { + return provider === 'custom' || provider === 'oai-compat'; +} + +/** + * The AI an import uses, in this order: the user's own provider with a key; + * else the server's (AI_* env vars, keyless when it is an OpenAI-compatible + * endpoint); else the user's own keyless OpenAI-compatible endpoint. The + * server's AI stays ahead of a keyless user endpoint, which may only be + * reachable from the user's browser (http://localhost:11434). Null when + * there is none. + */ +export function importAiConfig(user, env) { + const { provider, apiKey, model, baseUrl } = user || {}; + if (provider && apiKey) return { provider, apiKey, model: model || '', baseUrl: baseUrl || '', source: 'user' }; + if (env.AI_API_KEY || (isCompatibleProvider(env.AI_PROVIDER) && env.AI_BASE_URL)) { + return { + provider: env.AI_PROVIDER || 'claude', + apiKey: env.AI_API_KEY || '', + model: env.AI_MODEL || '', + baseUrl: env.AI_BASE_URL || '', + source: 'server', + }; + } + if (isCompatibleProvider(provider) && baseUrl) return { provider, apiKey: '', model: model || '', baseUrl, source: 'user' }; + return null; +} function _defaultModel(provider) { return ({ claude: 'claude-haiku-4-5-20251001', @@ -189,27 +251,32 @@ async function _callClaude(apiKey, model, systemPrompt, userText) { messages: [{ role: 'user', content: userText }], }), }); - if (!res.ok) throw new Error(`Claude returned ${res.status}: ${await res.text().catch(() => '')}`); + if (!res.ok) throw await _providerError('Claude', res); const data = await res.json(); return data.content?.[0]?.text || ''; } -async function _callOpenAI(apiKey, model, systemPrompt, userText, baseUrl = 'https://api.openai.com') { - const res = await fetch(`${baseUrl}/v1/chat/completions`, { +async function _callOpenAI(apiKey, model, systemPrompt, userText, baseUrl = 'https://api.openai.com', cfg = {}) { + const headers = { 'content-type': 'application/json', 'accept': 'application/json' }; + // Some self-hosted endpoints (Ollama in particular) reject a placeholder key. + if (apiKey && apiKey !== 'no-key') headers['Authorization'] = `Bearer ${apiKey}`; + // An endpoint a user typed in goes through the address check, on every + // redirect hop too; the server's own (AI_* env vars) is the owner's. + const send = cfg.source === 'user' ? _sendToUserEndpoint(cfg) : fetch; + const base = cfg.source === 'user' ? (serviceBase(baseUrl) ?? baseUrl) : baseUrl; + const res = await send(`${base}/v1/chat/completions`, { method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'content-type': 'application/json', - }, + headers, body: JSON.stringify({ model, + stream: false, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userText }, ], }), }); - if (!res.ok) throw new Error(`OpenAI returned ${res.status}: ${await res.text().catch(() => '')}`); + if (!res.ok) throw await _providerError('OpenAI', res); const data = await res.json(); return data.choices?.[0]?.message?.content || ''; } @@ -223,7 +290,7 @@ async function _callGemini(apiKey, model, systemPrompt, userText) { contents: [{ role: 'user', parts: [{ text: userText }] }], }), }); - if (!res.ok) throw new Error(`Gemini returned ${res.status}: ${await res.text().catch(() => '')}`); + if (!res.ok) throw await _providerError('Gemini', res); const data = await res.json(); return data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } diff --git a/server/lib/recipe-importers.js b/server/lib/recipe-importers.js index d7e38869..47395f2e 100644 --- a/server/lib/recipe-importers.js +++ b/server/lib/recipe-importers.js @@ -373,7 +373,9 @@ export async function importPaprikaArchive(buffer) { // image, which on a 500 MB Mealie backup with 100 recipes was 100x // the necessary work. export async function loadRecipeZip(buffer) { - return JSZip.loadAsync(buffer); + // Unpacked here too, not only in scanLoadedZip: callers that load once to + // read pictures and scan separately get the same <id>/image paths. + return _expandNestedZips(await JSZip.loadAsync(buffer)); } export async function readImageFromLoadedZip(zip, entryName) { if (!entryName) return null; @@ -391,7 +393,38 @@ export async function scanRecipeZip(buffer) { const zip = await JSZip.loadAsync(buffer); return scanLoadedZip(zip); } +// ── Zips inside the zip ──────────────────────────────────────────────────── +// Tandoor's export is a zip of zips: export.zip holds one <id>.zip per +// recipe, each with recipe.json and image.<ext> (cookbook/integration/ +// default.py). Unpack every inner zip in place, under a folder named after +// it (<id>/recipe.json beside <id>/image.png), so the walker below finds +// each recipe with its own picture, the way other exports lay them out. +// One level deep, and capped, so a zip of zips can't balloon in memory. +const NESTED_ZIP_MAX_BYTES = 1024 * 1024 * 1024; +async function _expandNestedZips(zip) { + const inner = Object.values(zip.files).filter(e => + !e.dir && /\.zip$/i.test(e.name) && !/^__MACOSX\//.test(e.name) + ); + let total = 0; + for (const entry of inner) { + let sub; + try { sub = await JSZip.loadAsync(await entry.async('nodebuffer')); } + catch { continue; } // not a zip after all: leave it + const base = entry.name.replace(/\.zip$/i, ''); + for (const f of Object.values(sub.files)) { + if (f.dir || /^__MACOSX\//.test(f.name)) continue; + const bytes = await f.async('nodebuffer'); + total += bytes.length; + if (total > NESTED_ZIP_MAX_BYTES) throw new Error('The zips inside this archive unpack to more than 1 GB.'); + zip.file(`${base}/${f.name}`, bytes); + } + zip.remove(entry.name); + } + return zip; +} + export async function scanLoadedZip(zip) { + await _expandNestedZips(zip); const out = []; // Paprika first — distinct extension makes this an unambiguous match. diff --git a/server/lib/recipe-scraper.js b/server/lib/recipe-scraper.js index 539024b0..ceafba5a 100644 --- a/server/lib/recipe-scraper.js +++ b/server/lib/recipe-scraper.js @@ -4,33 +4,26 @@ * * Defenses: * - http/https only - * - blocks private/loopback IPs (basic SSRF guard — node will reject - * localhost/private subnets too via their public-DNS resolution path) + * - every address the host resolves to is checked, the connection goes + * only to those addresses, and every redirect hop is checked the same + * way (ssrf-guard.js fetchChecked). Cloud-metadata and link-local + * addresses are always refused; private and loopback ones unless the + * caller allows them (the owner, or ALLOW_PRIVATE_RECIPE_URLS=1) * - 8s timeout * - 5MB max response size * - User-Agent header so blogs don't 403 us */ import * as cheerio from 'cheerio'; +import { fetchChecked } from './ssrf-guard.js'; const MAX_BYTES = 5 * 1024 * 1024; const TIMEOUT_MS = 8000; const UA = 'CookTrace/1.0 (+https://github.com/traceapps/cooktrace; recipe scraper)'; -const PRIVATE_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']); -const PRIVATE_PREFIXES = ['10.', '127.', '192.168.', '169.254.']; - -function _isPrivateHost(host) { - if (!host) return true; - if (PRIVATE_HOSTS.has(host)) return true; - if (PRIVATE_PREFIXES.some(p => host.startsWith(p))) return true; - // 172.16.0.0 – 172.31.255.255 - const m = host.match(/^172\.(\d+)\./); - if (m && +m[1] >= 16 && +m[1] <= 31) return true; - return false; -} +const MAX_REDIRECTS = 5; -export async function scrapeRecipe(rawUrl) { - const { html, finalUrl } = await fetchRecipeHtml(rawUrl); +export async function scrapeRecipe(rawUrl, opts = {}) { + const { html, finalUrl } = await fetchRecipeHtml(rawUrl, opts); const recipe = _extractRecipe(html, finalUrl); if (!recipe) throw new Error('No schema.org/Recipe data found on that page'); return recipe; @@ -41,29 +34,30 @@ export async function scrapeRecipe(rawUrl) { * scrapeRecipe but return the raw HTML so callers can re-parse it * with a different strategy (recipe-scrapers sidecar, AI fallback). */ -export async function fetchRecipeHtml(rawUrl) { +export async function fetchRecipeHtml(rawUrl, { allowPrivate = false } = {}) { let url; try { url = new URL(rawUrl); } catch { throw new Error('Not a valid URL'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('Only http(s) URLs are supported'); } - if (_isPrivateHost(url.hostname)) { - throw new Error('Refused: private / loopback hostnames are not allowed'); - } const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); let res; try { - res = await fetch(url, { + res = await fetchChecked(url.toString(), { headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' }, signal: ctrl.signal, - redirect: 'follow', - }); + }, { allowPrivate, allowPrivateEnvHint: 'ALLOW_PRIVATE_RECIPE_URLS', maxRedirects: MAX_REDIRECTS }); } catch (e) { clearTimeout(t); if (e.name === 'AbortError') throw new Error('Request timed out'); + // The owner sees why an address was refused. Anyone else gets the same + // message as for an address that doesn't answer, so the refusal can't + // be used to learn which names exist on the server's network. + if (allowPrivate && /addresses are|resolve host/.test(e.message)) throw e; + if (/http and https|Invalid URL|cloud-metadata/.test(e.message)) throw e; throw new Error('Could not fetch URL'); } clearTimeout(t); diff --git a/server/lib/recipe-versions.js b/server/lib/recipe-versions.js new file mode 100644 index 00000000..d4c43a04 --- /dev/null +++ b/server/lib/recipe-versions.js @@ -0,0 +1,132 @@ +/** + * Earlier versions of a recipe. + * + * A device that syncs an edit made against an older copy of a recipe meets + * a newer copy on the server, and only one of the two can stay. The other + * one is kept here, so nothing typed on either side is lost: the recipe page + * offers it back. Restoring a version keeps the copy it replaces the same + * way, so a restore can be undone. + */ +import db from '../db.js'; +import { mergeFields } from './field-merge.js'; +import { SYNC_GROUPS } from './sync-fields.js'; +import { normaliseIngredientGroups } from './recipe-hydrate.js'; + +// What a version holds: the recipe as someone wrote it. Rating, favorite, +// cook counts and sharing are about the recipe, not its content. +export const VERSION_FIELDS = [ + 'name', 'description', 'ingredients', 'steps', 'tags', 'tools', 'notes', + 'servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', + 'nutrition', 'category_id', 'img_url', 'source_url', 'video_url', 'yield_text', +]; +// About the recipe rather than its content: each is merged like the rest, +// and never kept as a version. +export const META_FIELDS = ['rating', 'favorite', 'visibility']; +// The fields an edit of a recipe is merged over. +export const RECIPE_MERGE_FIELDS = [...VERSION_FIELDS, ...META_FIELDS, 'deleted_at']; + +const JSON_FIELDS = { ingredients: [], steps: [], tags: [], tools: [], nutrition: {} }; +const NUMBER_FIELDS = new Set(['servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', 'category_id']); + +// Versions kept per recipe. +export const VERSIONS_KEPT = 20; + +/** A recipe row (stored or pushed) as plain content, comparable either way. */ +export function recipeContent(row) { + const out = {}; + for (const f of VERSION_FIELDS) { + let v = row?.[f]; + if (f in JSON_FIELDS) { + if (typeof v === 'string') { try { v = JSON.parse(v); } catch { /* kept as text */ } } + if (v == null || v === '') v = JSON_FIELDS[f]; + // Grouped or the older flat list: the same ingredients. + if (f === 'ingredients' && Array.isArray(v)) v = normaliseIngredientGroups(v); + } else if (v === undefined || v === '') { + v = null; + } else if (NUMBER_FIELDS.has(f) && typeof v === 'string' && Number.isFinite(Number(v))) { + v = Number(v); + } + out[f] = v; + } + return out; +} + +// Key order doesn't make two copies different. +function _stable(v) { + if (Array.isArray(v)) return `[${v.map(_stable).join(',')}]`; + if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${_stable(v[k])}`).join(',')}}`; + return JSON.stringify(v ?? null); +} + +export function sameContent(a, b) { + return _stable(recipeContent(a)) === _stable(recipeContent(b)); +} + +/** + * Keep `row`'s content as a version of the recipe. reason: 'conflict' (two + * edits met), 'restore' (the copy a restore replaced), or 'replaced' (the + * copy a save that can't say what it was made on replaced: an older app or + * page; kept only to be safe, so not pointed at). editedBy: whose edit the + * copy was. The newest VERSIONS_KEPT per recipe stay, whoever made them; + * over that, the oldest 'replaced' copies go first. + */ +export function saveRecipeVersion(recipeId, userId, row, { reason = 'conflict', seen = reason !== 'conflict', editedBy = userId } = {}) { + // The same copy kept already (the same edit sent twice, two syncs at + // once, a restore of a copy kept before): kept once, as the newest, for + // this reason. The older entry goes, so it's never the first trimmed and + // a restore's undo point never stays marked 'replaced'. + const data = JSON.stringify(recipeContent(row)); + const key = _stable(recipeContent(row)); + for (const v of db.prepare(`SELECT id, data FROM recipe_versions WHERE recipe_id = ?`).all(recipeId)) { + let same = false; + try { same = _stable(recipeContent(JSON.parse(v.data))) === key; } catch { same = false; } + if (same) db.prepare(`DELETE FROM recipe_versions WHERE id = ?`).run(v.id); + } + db.prepare( + `INSERT INTO recipe_versions (recipe_id, user_id, data, reason, seen, edited_by) VALUES (?, ?, ?, ?, ?, ?)` + ).run(recipeId, userId ?? null, data, reason, seen ? 1 : 0, editedBy ?? null); + db.prepare( + `DELETE FROM recipe_versions WHERE recipe_id = ? AND id NOT IN ( + SELECT id FROM recipe_versions WHERE recipe_id = ? + ORDER BY (reason = 'replaced') ASC, id DESC LIMIT ?)` + ).run(recipeId, recipeId, VERSIONS_KEPT); +} + +/** + * One edit of a recipe meeting the copy here (lib/field-merge.js, over its + * content, rating, favorite, visibility and deleted_at; cook counts are the + * server's, counted from the diary), and which copy is kept as a version: + * only when content was lost. The edit's copy when some of its content + * didn't go in (it never saw the newer change here). The copy here when + * the edit replaced content changed here since: pointed at when the edit + * said which copy it was made on, or its time can't be judged; kept quietly + * ('replaced') when it couldn't say. + * + * Returns mergeFields' result plus keep: [{ row, reason, side }]. + */ +export function mergeRecipe(existing, incoming, opts) { + const m = mergeFields(existing, incoming, { ...opts, fields: RECIPE_MERGE_FIELDS, groups: SYNC_GROUPS.recipes }); + const keep = []; + const lost = list => list.some(f => VERSION_FIELDS.includes(f)); + if (lost(m.lostIncoming) && incoming.deleted_at == null) { + const edited = { ...existing }; + for (const f of VERSION_FIELDS) if (m.lostIncoming.includes(f) || m.applied.includes(f)) edited[f] = incoming[f]; + if (!sameContent(edited, existing)) keep.push({ side: 'incoming', row: edited, reason: 'conflict' }); + } + if (lost(m.lostExisting) && existing.deleted_at == null) { + keep.push({ side: 'existing', row: existing, reason: m.validBase || !m.plausible ? 'conflict' : 'replaced' }); + } + return { ...m, keep }; +} + +/** The columns and values that put a version's content back on the recipe. */ +export function versionColumns(data) { + const c = recipeContent(data); + const cols = []; + const vals = []; + for (const f of VERSION_FIELDS) { + cols.push(f); + vals.push(f in JSON_FIELDS && typeof c[f] !== 'string' ? JSON.stringify(c[f]) : c[f]); + } + return { cols, vals }; +} diff --git a/server/lib/rest-merge.js b/server/lib/rest-merge.js new file mode 100644 index 00000000..11c66515 --- /dev/null +++ b/server/lib/rest-merge.js @@ -0,0 +1,45 @@ +/** + * A save through the REST API (the web app, also replaying saves made with + * no connection) that says which copy it was made on and what it changed + * (_sync: { base_synced_at, changed, edited_at, client_now }) merges with + * the row as a phone's sync does (lib/field-merge.js): what it didn't + * change stays, and of what it did, the newer of it and a change here + * since stays. A save that can't say (an older page) writes as before. + */ +import db from '../db.js'; +import { mergeFields } from './field-merge.js'; +import { SYNC_FIELDS, SYNC_GROUPS } from './sync-fields.js'; +import { clockOffset, editTime } from './sync-clock.js'; +import { stampFields } from './field-stamps.js'; + +const _now = () => new Date().toISOString().replace('T', ' ').slice(0, 19); + +/** + * Write a save to one row. `next`: the columns the route would write, with + * the values it worked out (from the body, or the row's own where the body + * leaves them out). Returns { wrote, kept }: kept when what stays isn't all + * the save sent, so the caller answers with the row as it now is. + */ +export function saveRow(table, id, existing, next, sync) { + const cols = Object.keys(next); + if (!sync || typeof sync !== 'object') { + db.prepare(`UPDATE ${table} SET ${cols.map(c => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`) + .run(...cols.map(c => next[c]), _now(), id); + return { wrote: true, kept: false }; + } + const fields = (SYNC_FIELDS[table] || []).filter(f => f !== 'deleted_at'); + const incoming = {}; + for (const f of fields) if (f in next) incoming[f] = next[f]; + const said = Array.isArray(sync.changed) ? new Set(sync.changed) : null; + const changed = new Set(Object.keys(incoming).filter(f => !said || said.has(f))); + const m = mergeFields(existing, incoming, { + fields, groups: SYNC_GROUPS[table], changed, + base: typeof sync.base_synced_at === 'string' ? sync.base_synced_at : null, + editedAt: editTime(typeof sync.edited_at === 'string' ? sync.edited_at : null, clockOffset(sync.client_now)), + }); + if (!m.write) return { wrote: false, kept: m.devicePulls }; + db.prepare(`UPDATE ${table} SET ${cols.map(c => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`) + .run(...cols.map(c => (c in m.row ? m.row[c] : next[c])), m.updatedAt, id); + stampFields(table, id, m.applied, m.fieldAt); + return { wrote: true, kept: m.devicePulls }; +} diff --git a/server/lib/scheduler.js b/server/lib/scheduler.js index 707ae9bb..0650eac3 100644 --- a/server/lib/scheduler.js +++ b/server/lib/scheduler.js @@ -17,6 +17,7 @@ * dedup log keeps us safe across server restarts. */ import db from '../db.js'; +import { linkBase } from './public-url.js'; import { logger } from '../logger.js'; import { notifyCookDay, notifyShoppingNudge, notifyExpiryDigest } from './push-notify.js'; import { sendWeeklySummaryEmail } from '../email.js'; @@ -182,7 +183,7 @@ async function _checkWeeklySummary(userId, today) { function _appOrigin() { // Best-effort: use APP_ORIGIN env var if set; otherwise null and the // email helper substitutes a sensible default in its CTA buttons. - return process.env.APP_ORIGIN || process.env.PUBLIC_URL || null; + return process.env.APP_ORIGIN || linkBase(null) || null; } // Pantry expiration digest. Fires once per day at the user's picked diff --git a/server/lib/shopping-items.js b/server/lib/shopping-items.js index dfcb7b24..a291292a 100644 --- a/server/lib/shopping-items.js +++ b/server/lib/shopping-items.js @@ -44,14 +44,16 @@ export function titleCaseName(raw) { // falling back to the category name, then null. Used by every insert path // (single-item POST, /from-recipe, /from-plan, the sister-app API) so the // shopping list groups itself without the user having to tag each row. -export function aisleForPantry(pantryId) { +// Only the account's own pantry item says where it is (`userId`): an id +// from anywhere else never reads another account's aisle. +export function aisleForPantry(pantryId, userId) { if (!pantryId) return null; const cat = db.prepare( `SELECT c.default_aisle, c.name FROM pantry_items p - LEFT JOIN pantry_categories c ON c.id = p.category_id - WHERE p.id = ?` - ).get(pantryId); + LEFT JOIN pantry_categories c ON c.id = p.category_id AND c.user_id IS p.user_id + WHERE p.id = ? AND p.user_id IS ?` + ).get(pantryId, userId ?? null); if (!cat) return null; return (cat.default_aisle && cat.default_aisle.trim()) || cat.name || null; } @@ -76,6 +78,7 @@ export function listShoppingItems(userId, { includeChecked = true } = {}) { r.name AS recipe_name, s.sort_order, s.updated_at FROM shopping_list s LEFT JOIN recipes r ON r.id = s.recipe_id AND r.deleted_at IS NULL + AND (r.user_id IS s.user_id OR EXISTS (SELECT 1 FROM recipe_shares rs WHERE rs.recipe_id = r.id AND rs.grantee_id = s.user_id)) WHERE ${userClause(userId, 's.user_id')} AND s.deleted_at IS NULL${includeChecked ? '' : ' AND s.checked = 0'} ORDER BY s.checked ASC, COALESCE(s.aisle, 'zzz') ASC, @@ -108,7 +111,7 @@ export const addShoppingItems = db.transaction((userId, items) => { const open = findOpen.get(...userArgs(userId), name); if (open) { skipped.push({ name, existing_id: open.id }); continue; } const pantryId = pantryIdForName(userId, name); - const aisle = raw.aisle || aisleForPantry(pantryId); + const aisle = raw.aisle || aisleForPantry(pantryId, userId); const info = insert.run(userId, name, raw.quantity ?? null, raw.unit || null, aisle || null, pantryId); added.push({ id: Number(info.lastInsertRowid), name, quantity: raw.quantity ?? null, unit: raw.unit || null, aisle: aisle || null, pantry_id: pantryId }); } diff --git a/server/lib/smart-cookbook.js b/server/lib/smart-cookbook.js new file mode 100644 index 00000000..682380bb --- /dev/null +++ b/server/lib/smart-cookbook.js @@ -0,0 +1,75 @@ +/** + * Which recipes a smart cookbook holds, from its saved filter. + * + * Identical copies live at server/lib/smart-cookbook.js (the server) and + * src/lib/smart-cookbook.js (the Android app's own database), so a smart + * cookbook holds the same recipes on both. scripts/smart-cookbook.test.js + * keeps the two the same. + * + * Supported criteria, every one that is set must match: + * { category_id, tags: [], favorites_only, min_rating, max_total_minutes } + * Tags match without regard to case, and a recipe needs every listed tag. + */ + +/** A filter as saved: known criteria only, numbers as numbers. */ +export function cleanSmartFilter(f) { + const clean = {}; + if (!f || typeof f !== 'object') return clean; + if (Number.isFinite(parseInt(f.category_id, 10))) clean.category_id = parseInt(f.category_id, 10); + if (Array.isArray(f.tags)) clean.tags = f.tags.map(s => String(s).trim()).filter(Boolean); + if (f.favorites_only === true) clean.favorites_only = true; + if (Number.isFinite(parseInt(f.min_rating, 10))) clean.min_rating = parseInt(f.min_rating, 10); + if (Number.isFinite(parseInt(f.max_total_minutes, 10))) clean.max_total_minutes = parseInt(f.max_total_minutes, 10); + return clean; +} + +/** The saved filter from a cookbook row, or null when it has none. */ +export function parseSmartFilter(json) { + if (json == null || json === '') return null; + if (typeof json === 'object') return json; + try { return JSON.parse(json); } catch { return null; } +} + +function _tagsOf(raw) { + let arr = raw; + if (typeof raw === 'string') { + try { arr = JSON.parse(raw || '[]'); } catch { arr = []; } + } + return new Set((Array.isArray(arr) ? arr : []).map(t => String(t).toLowerCase())); +} + +/** True when a recipe row (not deleted) matches the filter. */ +export function matchesSmartFilter(recipe, filter) { + if (!recipe || !filter || typeof filter !== 'object') return false; + if (recipe.deleted_at != null) return false; + if (Number.isFinite(filter.category_id)) { + if (recipe.category_id == null || Number(recipe.category_id) !== filter.category_id) return false; + } + if (filter.favorites_only && Number(recipe.favorite) !== 1) return false; + if (Number.isFinite(filter.min_rating)) { + if (recipe.rating == null || !(Number(recipe.rating) >= filter.min_rating)) return false; + } + if (Number.isFinite(filter.max_total_minutes)) { + const total = (Number(recipe.prep_minutes) || 0) + (Number(recipe.cook_minutes) || 0); + if (!(total <= filter.max_total_minutes)) return false; + } + const want = Array.isArray(filter.tags) ? filter.tags.map(s => String(s).toLowerCase()) : []; + if (want.length > 0) { + const have = _tagsOf(recipe.tags); + if (!want.every(t => have.has(t))) return false; + } + return true; +} + +/** A saved filter (JSON) with its category id put through `map` (an id + * in, an id or undefined out). Undefined leaves it as is. Category ids + * differ between a phone and the server, so sync translates them. */ +export function mapSmartFilterCategory(json, map) { + if (!json) return json; + let f; + try { f = typeof json === 'string' ? JSON.parse(json) : json; } catch { return json; } + if (!f || typeof f !== 'object' || f.category_id == null) return json; + const to = map(f.category_id); + if (to == null) return json; + return JSON.stringify({ ...f, category_id: to }); +} diff --git a/server/lib/ssrf-guard.js b/server/lib/ssrf-guard.js index 725fda34..51e2c57e 100644 --- a/server/lib/ssrf-guard.js +++ b/server/lib/ssrf-guard.js @@ -1,45 +1,52 @@ /** * server/lib/ssrf-guard.js * - * Shared SSRF guard for any code path that fetches a user-supplied URL - * server-side. Extracted from server/lib/image-localizer.js's - * `_isPrivateIP`/`_hostnameResolvesPrivate` (the only such guard that - * existed in this codebase; server/lib/recipe-scraper.js does its own - * unguarded fetch of a user-supplied recipe URL with no equivalent - * check at all, a pre-existing gap out of scope for this change, left - * untouched rather than silently altering its already-shipped behavior). + * The guard for every server-side request to an address a user can + * influence. The same file in all four Trace apps: change one, change all. * - * image-localizer.js keeps its own DNS-resolve-then-check flow (it - * checks every resolved address, not just one, and silently falls back - * to the original URL plus a log line on rejection rather than - * throwing), but now delegates the actual IP-range classification to - * the shared functions here instead of its own copy, so the range - * logic has one place to fix if a bypass is ever found. Its observable - * behavior is unchanged: everything image-localizer.js used to block - * (including the CGNAT range and the full 0.0.0.0/8 block) is still - * blocked, since isPrivateOrLoopback below is a superset covering both - * its old ranges and LiftTrace's/NutriTrace's. + * Address classes: + * - Link-local / IPv6 link-local / cloud metadata (169.254.169.254): + * always refused. Never legitimate for any caller. + * - Loopback, RFC1918, CGNAT, IPv6 ULA (the server's own network): + * refused unless the caller allows them. Callers decide by purpose: + * features made for services on the home network (push, Mealie, + * CookTrace, NutriTrace, Navidrome) allow them for everyone; features + * made for the public web (recipe URLs, link previews, radio streams, + * a user's own AI address) allow them for the owner only, unless the + * owner opts everyone in with that feature's ALLOW_PRIVATE_* variable. * - * `assertSafeUrl` is new: a throwing guard for outgoing webhooks that - * requires every resolved address to pass, parameterized so a self-hoster can opt a specific - * feature into allowing private/loopback targets (a same-Docker-network - * Home Assistant instance) without loosening image-localizer.js's own, - * always-strict guard. - * - * Tiered defaults: - * - Link-local / IPv6 link-local / cloud-metadata (169.254.169.254): - * ALWAYS blocked. Never legitimate for any caller. - * - Loopback + RFC1918 + CGNAT + IPv6 ULA: blocked by default; a - * caller passes its own `allowPrivate` flag (webhooks: - * ALLOW_PRIVATE_WEBHOOK_URLS) to opt in independently per feature. + * fetchChecked() is the way to make such a request: it checks every + * address the host resolves to, connects only to those addresses, and + * checks every redirect hop the same way. assertSafeUrl() only checks; a + * plain fetch after it resolves again, so prefer fetchChecked(). */ import dns from 'dns/promises'; import net from 'net'; +import { Agent, fetch as undiciFetch } from 'undici'; + +const _CLOUD_META = new Set(['100.100.100.200', '168.63.129.16']); + +// The IPv4 address inside an IPv4-mapped (::ffff:a.b.c.d) or NAT64 +// (64:ff9b::a.b.c.d) IPv6 address, or null. +function _embeddedV4(ip) { + const lo = ip.toLowerCase(); + const m = lo.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/); + if (m) return m[1]; + const h = lo.match(/^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (h) { + const a = parseInt(h[1], 16), b = parseInt(h[2], 16); + return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`; + } + return null; +} export function isLinkLocalOrCloudMeta(ip) { - if (net.isIPv4(ip)) return ip.startsWith('169.254.'); + if (net.isIPv4(ip)) return ip.startsWith('169.254.') || _CLOUD_META.has(ip); if (net.isIPv6(ip)) { + const v4 = _embeddedV4(ip); + if (v4) return isLinkLocalOrCloudMeta(v4); const lo = ip.toLowerCase(); + if (lo === 'fd00:ec2::254') return true; // fe80::/10 = fe80..febf in the first hextet if (/^fe[89ab]/.test(lo)) return true; // IPv4-mapped link-local (e.g. ::ffff:169.254.169.254) @@ -57,10 +64,15 @@ export function isPrivateOrLoopback(ip) { if (o[0] === 192 && o[1] === 168) return true; // 192.168.0.0/16 if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16.0.0/12 if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return true; // 100.64.0.0/10 CGNAT + if (o[0] === 192 && o[1] === 0 && o[2] === 0) return true; // 192.0.0.0/24 IETF special use + if (o[0] >= 224) return true; // multicast, reserved, broadcast return false; } if (net.isIPv6(ip)) { + const v4 = _embeddedV4(ip); + if (v4) return isPrivateOrLoopback(v4); const lo = ip.toLowerCase(); + if (/^ff/.test(lo)) return true; // multicast if (lo === '::1' || lo === '::' || lo === '0:0:0:0:0:0:0:1') return true; // Unique-local addresses fc00::/7 = fc00..fdff if (/^f[cd]/.test(lo)) return true; @@ -81,14 +93,19 @@ export function isPrivateOrLoopback(ip) { * `allowPrivateEnvHint` is used only in the error message so a self- * hoster knows which env var to set: callers pass their own var name. * - * Note on DNS rebinding: this function resolves once. A determined - * attacker could DNS-rebind between this lookup and the fetch that - * follows it. Accepted as residual risk here; webhook delivery - * re-validates via this same function immediately before every send - * attempt (not just once at creation time), which narrows but does - * not eliminate the window. + * Note on DNS rebinding: this function resolves once, and a plain fetch + * after it resolves again, so a host could answer the two lookups + * differently. fetchChecked() connects to the checked addresses instead. */ export async function assertSafeUrl(url, { allowPrivate = false, allowPrivateEnvHint = 'ALLOW_PRIVATE_URLS' } = {}) { + return (await _checkedTarget(url, { allowPrivate, allowPrivateEnvHint })).parsed; +} + +// The URL parsed, plus every address its host resolves to, all checked. +async function _checkedTarget(url, { allowPrivate = false, allowPrivateEnvHint = 'ALLOW_PRIVATE_URLS' } = {}) { + // URL parsing silently drops tabs and line breaks, so ".\t." would pass a + // caller's path check and then become "..": refuse control characters. + if (/[\u0000-\u001f\u007f]/.test(String(url))) throw new Error('Invalid URL'); let parsed; try { parsed = new URL(url); } catch { throw new Error('Invalid URL'); } @@ -99,19 +116,125 @@ export async function assertSafeUrl(url, { allowPrivate = false, allowPrivateEnv // one: the fetch that follows resolves independently and may pick any // of them, so a host publishing one public and one private record // would otherwise sail through this check and then connect privately. - let addresses; + let resolved; try { - const r = await dns.lookup(parsed.hostname, { all: true }); - addresses = (Array.isArray(r) ? r : [r]).map(a => a.address).filter(Boolean); + const r = await dns.lookup(parsed.hostname.replace(/^\[(.*)\]$/, '$1'), { all: true }); + resolved = (Array.isArray(r) ? r : [r]).filter(a => a?.address); } catch { throw new Error('Could not resolve host'); } - if (addresses.length === 0) throw new Error('Could not resolve host'); + if (resolved.length === 0) throw new Error('Could not resolve host'); + const addresses = resolved.map(a => a.address); if (addresses.some(isLinkLocalOrCloudMeta)) { throw new Error('Link-local / cloud-metadata addresses are not allowed'); } if (!allowPrivate && addresses.some(isPrivateOrLoopback)) { throw new Error(`Private / loopback addresses are blocked. Set ${allowPrivateEnvHint}=1 to enable LAN targets.`); } - return parsed; + return { parsed, resolved }; +} + +/** + * The base address of a service a user configured (push server, NutriTrace, + * music server): origin and path only, without a trailing slash. A query or + * fragment is dropped, so a path the app appends stays a path. Null when it + * isn't an http(s) address. + */ +export function serviceBase(url) { + let u; + try { u = new URL(String(url ?? '').trim()); } catch { return null; } + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; + return (u.origin + u.pathname).replace(/\/+$/, ''); +} + +/** + * Whether a redirect from `from` to `to` stays on the same server: the + * same origin, or http to https on the same host and default ports (a + * reverse proxy's upgrade). + */ +export function isSameServer(from, to) { + const a = new URL(from), b = new URL(to); + if (a.origin === b.origin) return true; + return a.protocol === 'http:' && b.protocol === 'https:' && a.hostname === b.hostname && !a.port && !b.port; +} + +/** + * A reply's body, at most `maxBytes` (a larger one throws), so an address + * that answers with an endless stream can't fill the server's memory. + */ +export async function readBody(res, maxBytes) { + const reader = res.body?.getReader(); + if (!reader) return Buffer.alloc(0); + const chunks = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > maxBytes) { + try { await reader.cancel(); } catch {} + throw new Error('Reply too large'); + } + chunks.push(value); + } + return Buffer.concat(chunks.map(c => Buffer.from(c))); +} + +const _proxied = !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy); + +/** + * fetch() a user-supplied URL through the guard. Unlike assertSafeUrl + * followed by a plain fetch, it connects only to the addresses it + * checked, so a host can't answer the check with a public address and + * the connection with a private one (DNS rebinding), and it checks every + * redirect hop the same way (at most `maxRedirects`, 0 = none: a 3xx is + * returned as is). A redirect to another origin drops Authorization, + * Cookie and Proxy-Authorization, as browsers do; `sameOrigin: true` + * refuses such a redirect (the 3xx is returned), for callers that send + * credentials in headers of their own (X-Plex-Token, X-Emby-Token). + * Behind a forward proxy (HTTP_PROXY/HTTPS_PROXY) the proxy connects, so + * only the check applies. + */ +export async function fetchChecked(url, init = {}, { allowPrivate = false, allowPrivateEnvHint = 'ALLOW_PRIVATE_URLS', maxRedirects = 0, sameOrigin = false } = {}) { + let target = url; + let request = { ...init }; + for (let hop = 0; ; hop++) { + const { parsed, resolved } = await _checkedTarget(target, { allowPrivate, allowPrivateEnvHint }); + const options = { ...request, redirect: 'manual' }; + if (!_proxied) options.dispatcher = _pinnedAgent(resolved); + const res = await (_proxied ? globalThis.fetch : undiciFetch)(parsed, options); + const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null; + if (!location || hop >= maxRedirects) return res; + const next = new URL(location, parsed); + const crossOrigin = !isSameServer(parsed, next); + if (crossOrigin && sameOrigin) return res; + try { await res.body?.cancel(); } catch {} + target = next.toString(); + if (crossOrigin) { + const headers = new Headers(request.headers || {}); + for (const h of ['authorization', 'cookie', 'proxy-authorization']) headers.delete(h); + request = { ...request, headers }; + } + // As fetch does: a 303 (except after GET or HEAD), or a 301/302 after a + // POST, continues as a GET without the body; 307/308 repeat it as it was. + const method = String(request.method || 'GET').toUpperCase(); + if ((res.status === 303 && method !== 'GET' && method !== 'HEAD') || ((res.status === 301 || res.status === 302) && method === 'POST')) { + const headers = new Headers(request.headers || {}); + headers.delete('content-type'); + headers.delete('content-length'); + request = { ...request, method: 'GET', body: undefined, headers }; + } + } +} + +// An HTTP agent whose every connection goes to the given, already +// checked, addresses. TLS still verifies the certificate for the +// original hostname. +function _pinnedAgent(resolved) { + const lookup = (_hostname, opts, cb) => { + if (typeof opts === 'function') { cb = opts; opts = {}; } + if (opts?.all) cb(null, resolved.map(a => ({ address: a.address, family: a.family }))); + else cb(null, resolved[0].address, resolved[0].family); + }; + return new Agent({ connect: { lookup }, keepAliveTimeout: 1000 }); } diff --git a/server/lib/sync-clock.js b/server/lib/sync-clock.js new file mode 100644 index 00000000..1c1d78ed --- /dev/null +++ b/server/lib/sync-clock.js @@ -0,0 +1,70 @@ +/** + * Edit times from devices, on the server's clock. + * + * A device stamps an edit with its own clock when the edit is made, which + * may be days before it syncs, and that clock may be minutes off. The push + * says what time the device thinks it is (client_now), so its edit times + * can be moved onto the server's clock before two edits of one row are + * compared: the newer one stays. + */ + +// A time as stored here or sent by a device: SQLite's datetime('now') +// ("YYYY-MM-DD HH:MM:SS", UTC with no zone), with or without fractions, +// or ISO 8601. NaN when it isn't one. +export function utcMs(v) { + if (v == null || v === '') return NaN; + let s = String(v).trim(); + if (/^\d{4}-\d\d-\d\d$/.test(s)) s += 'T00:00:00'; + s = s.replace(/^(\d{4}-\d\d-\d\d) /, '$1T'); + if (/^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(s)) s += 'Z'; + return Date.parse(s); +} + +// How far the device's clock is behind the server's: the server's time when +// the push arrived (`receivedAt`, taken before its body was read, so an +// upload's time doesn't count) less the device's time when it sent it. A +// clock any distance off is corrected: one reset to years ago is still the +// same device, editing now. 0 when the device doesn't say (older apps). +export function clockOffset(clientNow, receivedAt = Date.now()) { + if (typeof clientNow !== 'string') return 0; + const t = utcMs(clientNow); + if (!Number.isFinite(t)) return 0; + return (Number.isFinite(receivedAt) ? receivedAt : Date.now()) - t; +} + +const _fmt = ms => new Date(ms).toISOString().replace('T', ' ').slice(0, 19); + +// A device's edit time on the server's clock, stored as the server stores +// its own. One that can't be read is kept as it came. No edit arriving now +// was made later than now: a clock running fast would otherwise leave a +// time no other device's edit can beat. +export function editTime(updatedAt, offsetMs) { + const now = Date.now(); + if (updatedAt == null || updatedAt === '') return _fmt(now); + const t = utcMs(updatedAt); + if (!Number.isFinite(t)) return updatedAt; + // A correction that lands before 2000 came from a clock that can't be + // right (a device set to the year 9999): the edit is taken as made now. + const c = t + offsetMs; + if (!Number.isFinite(c) || c < Date.UTC(2000, 0, 1)) return _fmt(now); + return _fmt(Math.min(c, now)); +} + +/** The later of two times, either one possibly missing. */ +export function latestTime(a, b) { + const x = utcMs(a), y = utcMs(b); + if (!Number.isFinite(y)) return a; + if (!Number.isFinite(x)) return b; + return y > x ? b : a; +} + +// Edit times are whole seconds, so two times this close are a tie. +export const TIE_MS = 1000; + +// Whether the server's copy of a row is newer than an edit arriving now +// (already on the server's clock). A tie goes to the arriving edit. +export function serverIsNewer(serverUpdatedAt, editedAt) { + const a = utcMs(serverUpdatedAt); + const b = utcMs(editedAt); + return Number.isFinite(a) && Number.isFinite(b) && a > b + TIE_MS; +} diff --git a/server/lib/sync-fields.js b/server/lib/sync-fields.js new file mode 100644 index 00000000..f81dcb70 --- /dev/null +++ b/server/lib/sync-fields.js @@ -0,0 +1,65 @@ +/** + * The fields of each synced table, as two edits of a row meet them. + * + * The same columns a device writes through /api/sync/push (TABLES[t].cols + * in routes/sync.js, which checks it matches at load), plus deleted_at where + * rows are soft-deleted: a delete is an edit too. db.js keeps, per row, when + * each of these last changed (field_stamps); lib/field-merge.js uses that to + * tell which fields changed here since a device's copy. The phone keeps the + * same list (src/lib/db-native.js SYNC_FIELDS). + */ +export const SYNC_FIELDS = { + recipe_categories: ['name', 'slug', 'color', 'sort_order'], + pantry_categories: ['name', 'slug', 'icon', 'color', 'sort_order', 'default_aisle'], + custom_units: ['abbr', 'full_name', 'category', 'sort_order'], + cookbooks: ['name', 'slug', 'description', 'cover_image_url', 'is_smart', 'smart_filter_json', 'sort_order', 'deleted_at'], + recipes: [ + 'name', 'description', 'img_url', 'servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', + 'ingredients', 'steps', 'tags', 'tools', 'source_url', 'video_url', 'notes', + 'visibility', 'rating', 'yield_text', 'last_cooked_at', 'cook_count', + 'nutrition', 'favorite', 'category_id', 'deleted_at', + ], + pantry_items: [ + 'name', 'brand', 'barcode', 'in_stock', 'quantity', 'unit', 'expires_on', + 'nt_food_id', 'img_url', 'notes', 'category', 'category_id', + 'serving_size', 'serving_unit', 'serving_label', 'nutrition', 'g_per_cup', + 'generic_parent_id', 'nutrition_source_variant_id', 'deleted_at', + ], + cook_diary: ['recipe_id', 'date', 'kind', 'servings', 'notes', 'photo_url', 'photos', 'meal_type', 'rating', 'deleted_at'], + shopping_list: ['name', 'quantity', 'unit', 'aisle', 'checked', 'pantry_id', 'recipe_id', 'sort_order', 'deleted_at'], + recipe_comments: ['recipe_id', 'parent_id', 'body', 'deleted_at'], + ai_chat_history: ['role', 'content'], +}; + +/** + * Fields whose meaning depends on each other, merged as one: an edit that + * changed any of them competes with a change here to any of them, and the + * one that stays brings the whole group. Merged one by one, a quantity from + * one side and a unit from the other could make 3 g out of 2 lb and 500 g. + * Fields not listed merge on their own. + */ +export const SYNC_GROUPS = { + recipe_categories: [['name', 'slug']], + pantry_categories: [['name', 'slug']], + custom_units: [['abbr', 'full_name', 'category']], + cookbooks: [['name', 'slug'], ['is_smart', 'smart_filter_json']], + recipes: [ + // Steps point at ingredients by id; servings, yield and per-serving + // nutrition go together; the total time can be worked out from the rest. + ['ingredients', 'steps'], + ['servings', 'yield_text', 'nutrition'], + ['prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes'], + ], + pantry_items: [ + ['quantity', 'unit'], + ['serving_size', 'serving_unit', 'serving_label', 'nutrition', 'g_per_cup', 'nt_food_id'], + ['category', 'category_id'], + // Which item this is a variant of, and which variant its nutrition comes + // from: the shape of the variant tree. + ['generic_parent_id', 'nutrition_source_variant_id'], + ], + cook_diary: [['photos', 'photo_url'], ['date', 'meal_type', 'kind']], + shopping_list: [['quantity', 'unit', 'pantry_id']], + recipe_comments: [['recipe_id', 'parent_id']], + ai_chat_history: [['role', 'content']], +}; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 6fa5ebb3..eab446cf 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -47,20 +47,33 @@ export function sessionMaxAge() { return hours * 60 * 60 * 1000; } -/** Attach req.user if a valid JWT cookie is present (non-blocking) */ -export function authenticate(req, res, next) { - // Accept token from cookie OR Authorization: Bearer header (mobile apps use the header) - let token = req.cookies?.ct_token; - if (!token) { - const auth = req.headers.authorization; - if (auth?.startsWith('Bearer ')) token = auth.slice(7); - } - if (!token) { req.user = null; return next(); } - try { - req.user = jwt.verify(token, JWT_SECRET); - } catch { - req.user = null; +// A bearer token is ours when its signature verifies with our secret, +// expired or not: { user } (null once expired). Anything else (an API +// token, a reverse proxy's or identity provider's token) isn't: null. +// jsonwebtoken checks the signature before the times, so an expiry error +// means the signature held. +function _ourBearer(token) { + try { return { user: jwt.verify(token, JWT_SECRET) }; } catch (e) { + return e?.name === 'TokenExpiredError' || e?.name === 'NotBeforeError' ? { user: null } : null; } +} + +/** Attach req.user from our bearer token or the session cookie (non-blocking). + * req.authVia says which one decided: 'bearer', 'cookie' or null. */ +export function authenticate(req, res, next) { + // Our token in the Authorization header decides: the Android app sends + // the signed-in account's token there, and its HTTP layer can also carry + // a cookie an earlier account's sign-in left behind. Expired, it is no + // session, never the cookie's. A bearer that isn't ours (a reverse proxy + // in front of the web app can add its own) is left alone, and the web + // signs in with its cookie as before. + const auth = req.headers.authorization; + const ours = auth?.startsWith('Bearer ') ? _ourBearer(auth.slice(7)) : null; + if (ours) { req.user = ours.user; req.authVia = 'bearer'; return next(); } + const cookie = req.cookies?.ct_token; + req.user = null; + if (cookie) { try { req.user = jwt.verify(cookie, JWT_SECRET); } catch { /* no session */ } } + req.authVia = req.user ? 'cookie' : null; next(); } diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js index 0b000c26..62339130 100644 --- a/server/middleware/csrf.js +++ b/server/middleware/csrf.js @@ -7,12 +7,14 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); // their own state token (CSRF-equivalent) via the oauth_state DB table. const SKIP_PREFIXES = ['/api/auth/']; const SKIP_SUFFIXES = ['/callback']; +const BEARER_ONLY = ['/api/v1', '/api/mcp']; /** * CSRF protection for cookie-based (PWA) sessions. * * Strategy: synchronizer token embedded in the JWT. - * - Bearer token requests are inherently CSRF-safe (attacker can't set headers via HTML form/img). + * - Requests signed in by our bearer token are CSRF-safe (attacker can't set headers via HTML form/img). + * A cookie session is checked even when the request also carries someone else's bearer. * - Single-user mode has no auth cookies, so CSRF isn't relevant. * - Old JWTs without a csrf field are let through for a seamless migration window. * New sessions (issued after this change) are fully protected. @@ -24,7 +26,13 @@ export function csrfProtect(req, res, next) { // their own state tokens, and the cross-origin redirect can't carry our CSRF header. if (SKIP_SUFFIXES.some(s => req.path.endsWith(s))) return next(); if (!userMgmtActive()) return next(); // single-user mode - if (req.headers.authorization?.startsWith('Bearer ')) return next(); // Bearer = CSRF-safe + // Signed in by our bearer token: CSRF-safe (a page can't set the header). + // A session from the cookie is checked even when the request carries + // someone else's bearer (a reverse proxy can add one). + if (req.authVia === 'bearer') return next(); + // The API (/api/v1, /api/mcp) signs in by its own API token only, never + // the cookie, and its clients send no CSRF token. + if (BEARER_ONLY.some(p => req.path === p || req.path.startsWith(p + '/')) && req.headers.authorization?.startsWith('Bearer ')) return next(); if (!req.user?.csrf) return next(); // old token — skip for now const header = req.headers['x-csrf-token']; diff --git a/server/package-lock.json b/server/package-lock.json index 4f1b807e..11b20975 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -42,9 +42,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -185,9 +185,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -204,9 +201,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -223,9 +217,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -242,9 +233,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -261,9 +249,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1393,9 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2079,9 +2064,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -2089,6 +2074,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { diff --git a/server/routes/ai.js b/server/routes/ai.js index bd9ecd2a..cb86d029 100644 --- a/server/routes/ai.js +++ b/server/routes/ai.js @@ -15,9 +15,11 @@ const MAX_HISTORY = 200; // rows kept per user // ── GET /api/ai/history ─────────────────────────────────────────────────────── router.get('/history', requireAuth, wrap((req, res) => { const u = uid(req); + // The newest 100, oldest first. id breaks ties: a question and its + // answer are often saved in the same second. const rows = u == null - ? db.prepare(`SELECT role, content, created_at FROM ai_chat_history WHERE user_id IS NULL ORDER BY created_at ASC LIMIT 100`).all() - : db.prepare(`SELECT role, content, created_at FROM ai_chat_history WHERE user_id = ? ORDER BY created_at ASC LIMIT 100`).all(u); + ? db.prepare(`SELECT role, content, created_at FROM (SELECT id, role, content, created_at FROM ai_chat_history WHERE user_id IS NULL ORDER BY created_at DESC, id DESC LIMIT 100) ORDER BY created_at ASC, id ASC`).all() + : db.prepare(`SELECT role, content, created_at FROM (SELECT id, role, content, created_at FROM ai_chat_history WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT 100) ORDER BY created_at ASC, id ASC`).all(u); res.json(rows); })); @@ -165,6 +167,7 @@ async function _callOpenAI(apiKey, model, messages, systemPrompt, baseUrl = 'htt method: 'POST', headers: { 'Content-Type': 'application/json', + 'Accept': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ diff --git a/server/routes/app-config.js b/server/routes/app-config.js index 12522d85..2d39df9d 100644 --- a/server/routes/app-config.js +++ b/server/routes/app-config.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { linkBase } from '../lib/public-url.js'; import db from '../db.js'; import { wrap } from '../logger.js'; import { requireAuth, requireAdmin, userMgmtActive } from '../middleware/auth.js'; @@ -120,8 +121,7 @@ router.post('/test-email', requireAuth, requireAdmin, wrap(async (req, res) => { const to = (typeof body.to === 'string' && body.to.trim()) || req.user?.email || undefined; // Origin lets the email template load the app logo. Recipient name // personalizes the greeting. - const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); - const origin = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; + const origin = linkBase(req); const recipientName = req.user?.full_name || req.user?.nickname || req.user?.username || null; try { const result = await testSmtp({ overrides, to, origin, recipientName }); diff --git a/server/routes/auth.js b/server/routes/auth.js index e54d18d6..c7f401b3 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { linkBase } from '../lib/public-url.js'; import { localizeDataUrl } from '../lib/image-localizer.js'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; @@ -83,6 +84,24 @@ const COOKIE_OPTS = { secure: !_insecureCookies, }; +// A browser drops a Secure cookie on a plain-HTTP page (localhost aside), so +// signing in from one succeeds and then lands back on the login page with +// nothing in the log to say why. The browser's Origin header shows the page +// it was on, whatever proxy sits in between. Said once an hour at most. The +// login page shows the same thing on screen. +let _plainHttpWarnedAt = 0; +function warnIfPlainHttp(req) { + if (_insecureCookies) return; + const origin = String(req.get('origin') || req.get('referer') || ''); + if (!/^http:\/\//i.test(origin)) return; + if (/^http:\/\/(localhost|127\.\d+\.\d+\.\d+|\[::1\])(:\d+)?(\/|$)/i.test(origin)) return; + if (Date.now() - _plainHttpWarnedAt < 60 * 60 * 1000) return; + _plainHttpWarnedAt = Date.now(); + let page = origin; + try { page = new URL(origin).origin; } catch {} + console.warn(`[WARN] Sign-in from a plain-HTTP page (${page}). The sign-in cookie only works over HTTPS, so the browser drops it and the user lands back on the login page. Serve CookTrace over HTTPS, or set INSECURE_COOKIES=1 on a trusted LAN. See https://traceapps.github.io/docs/getting-started/lan-http/`); +} + function safeUser(u) { const { password_hash, ...rest } = u; // Linked OIDC providers + password-set flag — surfaced so the Profile page @@ -93,6 +112,15 @@ function safeUser(u) { } // ── Status: is user management active? ──────────────────────────────────── +// This server's random id (see /status), made once and kept in app_config. +// Made again if a restore emptied app_config. +function serverInstanceId() { + const row = db.prepare(`SELECT value FROM app_config WHERE key = 'instance_id'`).get(); + if (row?.value) return row.value; + db.prepare(`INSERT OR IGNORE INTO app_config (key, value) VALUES ('instance_id', ?)`).run(crypto.randomUUID()); + return db.prepare(`SELECT value FROM app_config WHERE key = 'instance_id'`).get()?.value || null; +} + router.get('/status', wrap((req, res) => { const active = userMgmtActive(); // setup_required distinguishes a fresh install (no users, setup needed) @@ -112,6 +140,15 @@ router.get('/status', wrap((req, res) => { oidc: { providers, enable_email_password_login: isPasswordLoginEnabled() }, password_policy: policy, // 'standard' | 'strong' password_min_score: policy === 'strong' ? STRONG_MIN_SCORE : 0, + // Whether the sign-in cookie is HTTPS-only, so the login page can say up + // front that signing in from a plain-HTTP page will not stick. + secure_cookies: !_insecureCookies, + // A random id for this server, so the Android app can tell the same + // server at another address (LAN IP, domain) from a different one. + instance_id: serverInstanceId(), + // What the Android sync speaks. 2: edits merge field by field, and a + // row the app makes carries a key, so sending it twice makes it once. + sync_version: 2, }); })); @@ -137,6 +174,7 @@ router.post('/login', rateLimitLogin, wrap((req, res) => { const token = signToken(user); const cookieOpts = { ...COOKIE_OPTS, maxAge: sessionMaxAge() }; + warnIfPlainHttp(req); res.cookie('ct_token', token, cookieOpts); res.json({ user: safeUser(user), token }); })); @@ -178,6 +216,7 @@ router.post('/register', wrap((req, res) => { // existed (user_id IS NULL). Stays scoped to CookTrace's own tables. if (isFirst) { claimAnonymousData(user.id); + warnIfPlainHttp(req); res.cookie('ct_token', signToken(user), COOKIE_OPTS); } @@ -399,10 +438,9 @@ router.post('/forgot-password', rateLimitLogin, wrap(async (req, res) => { const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour db.prepare('INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, user.id, expires); - const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); - const baseUrl = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; + const baseUrl = linkBase(req); try { - await sendPasswordReset(user.email, `${baseUrl}/#/reset-password?token=${token}`); + if (baseUrl) await sendPasswordReset(user.email, `${baseUrl}/#/reset-password?token=${token}`); } catch (e) { // Don't surface email-send errors to the client — it would leak existence. // The token is still issued; the admin can investigate the email backend. @@ -449,8 +487,8 @@ router.post('/invite', requireAuth, requireAdmin, wrap(async (req, res) => { db.prepare('INSERT INTO invite_tokens (token, email, role, created_by, expires_at) VALUES (?, ?, ?, ?, ?)') .run(token, email ? email.trim().toLowerCase() : null, role, req.user.id, expires); - const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); - const baseUrl = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; + const baseUrl = linkBase(req); + if (!baseUrl) return res.status(500).json({ error: 'The app\'s address is unknown. Set PUBLIC_URL, then try again.' }); const inviteUrl = `${baseUrl}/#/accept-invite?token=${token}`; if (email && isEmailConfigured()) { diff --git a/server/routes/cook-diary.js b/server/routes/cook-diary.js index 23d3d164..84df8263 100644 --- a/server/routes/cook-diary.js +++ b/server/routes/cook-diary.js @@ -15,9 +15,12 @@ import { Router } from 'express'; import { localizeDataUrl } from '../lib/image-localizer.js'; import db from '../db.js'; +import { saveRow } from '../lib/rest-merge.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { dispatchWebhookEvent } from '../lib/webhooks.js'; +import { cleanCreateKey, findByCreateKey, setCreateKey } from '../lib/create-keys.js'; +import { linkableRecipeId } from '../lib/link-checks.js'; const router = Router(); router.use(requireAuth); @@ -160,13 +163,21 @@ router.get('/', wrap((req, res) => { // ── POST / — create a planned cook ───────────────────────────────────── router.post('/', wrap((req, res) => { const u = uid(req); + // Sent before (Connect > Upload again, or an answer lost): the row made + // then, not a second one (lib/create-keys.js). + const createKey = cleanCreateKey(req.body?.client_key); + const made = findByCreateKey('cook_diary', u, createKey); + if (made) return res.json(made); const body = req.body || {}; const recipeId = parseInt(body.recipe_id, 10); if (!Number.isFinite(recipeId)) return res.status(400).json({ error: 'recipe_id required' }); const date = (typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date)) ? body.date : null; if (!date) return res.status(400).json({ error: 'date (YYYY-MM-DD) required' }); - const recipe = db.prepare(`SELECT * FROM recipes WHERE id = ? AND deleted_at IS NULL`).get(recipeId); + // The account's own recipe, or one shared with it (lib/link-checks.js): + // another account's recipe is not found, and its cook count untouched. + const recipe = linkableRecipeId(recipeId, u) != null + ? db.prepare(`SELECT * FROM recipes WHERE id = ? AND deleted_at IS NULL`).get(recipeId) : null; if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); const kind = body.kind === 'cooked' ? 'cooked' : 'planned'; @@ -183,6 +194,7 @@ router.post('/', wrap((req, res) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run(u, recipeId, date, kind, servings, notes, photoUrl, mealType, rating); + setCreateKey('cook_diary', result.lastInsertRowid, createKey); if (kind === 'cooked') recomputeRecipeAggregates(recipeId); const row = db.prepare(`SELECT * FROM cook_diary WHERE id = ?`).get(result.lastInsertRowid); @@ -210,18 +222,17 @@ router.put('/:id', wrap((req, res) => { } const body = req.body || {}; const date = (typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date)) ? body.date : existing.date; - const kind = body.kind === 'cooked' || body.kind === 'planned' ? body.kind : existing.kind; + const kindIn = body.kind === 'cooked' || body.kind === 'planned' ? body.kind : existing.kind; const notes = body.notes !== undefined ? (body.notes ? String(body.notes).trim() || null : null) : existing.notes; const photoUrl = body.photo_url !== undefined ? localizeDataUrl(body.photo_url ?? null) : existing.photo_url; const servings = body.servings !== undefined ? (body.servings === '' || body.servings == null ? null : Number(body.servings)) : existing.servings; const mealType = body.meal_type !== undefined ? _coerceMealType(body.meal_type) : existing.meal_type; const rating = body.rating !== undefined ? _coerceRating(body.rating) : existing.rating; - db.prepare( - `UPDATE cook_diary SET date = ?, kind = ?, notes = ?, photo_url = ?, servings = ?, - meal_type = ?, rating = ?, updated_at = datetime('now') - WHERE id = ?` - ).run(date, kind, notes, photoUrl, servings, mealType, rating, id); + // With _sync, merged with changes made elsewhere (lib/rest-merge.js): + // what stays may not be all of this save. + saveRow('cook_diary', id, existing, { date, kind: kindIn, notes, photo_url: photoUrl, servings, meal_type: mealType, rating }, body._sync); + const kind = db.prepare(`SELECT kind FROM cook_diary WHERE id = ?`).get(id).kind; // Recompute aggregates if cooked-state or recipe changed. if (existing.kind !== kind || existing.recipe_id) recomputeRecipeAggregates(existing.recipe_id); diff --git a/server/routes/cookbooks.js b/server/routes/cookbooks.js index 732773b0..3333d310 100644 --- a/server/routes/cookbooks.js +++ b/server/routes/cookbooks.js @@ -8,6 +8,8 @@ import { Router } from 'express'; import { localizeDataUrl } from '../lib/image-localizer.js'; import db from '../db.js'; +import { stampFields } from '../lib/field-stamps.js'; +import { saveRow } from '../lib/rest-merge.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; // Same recipe-card hydration the Recipes tab uses (category, tags, @@ -22,6 +24,7 @@ import { buildStockSet, buildCategoryMap, } from '../lib/recipe-hydrate.js'; +import { cleanSmartFilter, matchesSmartFilter, parseSmartFilter } from '../lib/smart-cookbook.js'; const router = Router(); router.use(requireAuth); @@ -49,6 +52,8 @@ function _hydrate(row, recipeCount = null) { slug: row.slug, description: row.description, cover_image_url: row.cover_image_url, + // The server's stamp of this copy: a save made on it says so. + synced_at: row.synced_at, is_smart: !!row.is_smart, smart_filter, sort_order: row.sort_order, @@ -59,49 +64,51 @@ function _hydrate(row, recipeCount = null) { } // Evaluate a smart-filter JSON against the user's recipes and return -// the matching rows. Supported criteria: -// { category_id, tags: [], favorites_only, min_rating, max_total_minutes } -// Tags use AND matching (every listed tag must be present). -function _evalSmartFilter(userId, filter) { +// the matching rows, newest first. The criteria themselves live in +// lib/smart-cookbook.js, shared with the Android app's own database. +// Just the columns the filter reads. +const SMART_COUNT_COLS = 'r.id, r.deleted_at, r.category_id, r.favorite, r.rating, r.prep_minutes, r.cook_minutes, r.tags'; + +function _evalSmartFilter(userId, filter, { full = true } = {}) { if (!filter || typeof filter !== 'object') return []; - const where = [`r.deleted_at IS NULL`]; - const args = []; - if (userId == null) where.push(`r.user_id IS NULL`); - else { where.push(`r.user_id = ?`); args.push(userId); } - if (Number.isFinite(filter.category_id)) { - where.push(`r.category_id = ?`); args.push(filter.category_id); - } - if (filter.favorites_only) where.push(`r.favorite = 1`); - if (Number.isFinite(filter.min_rating)) { - where.push(`r.rating >= ?`); args.push(filter.min_rating); - } - if (Number.isFinite(filter.max_total_minutes)) { - where.push(`COALESCE(r.prep_minutes, 0) + COALESCE(r.cook_minutes, 0) <= ?`); - args.push(filter.max_total_minutes); + const matches = db.prepare( + `SELECT ${SMART_COUNT_COLS} + FROM recipes r + WHERE ${userId == null ? 'r.user_id IS NULL' : 'r.user_id = ?'} AND r.deleted_at IS NULL + ORDER BY r.updated_at DESC, r.id DESC` + ).all(...(userId == null ? [] : [userId])).filter(r => matchesSmartFilter(r, filter)); + if (!full) return matches; + // Then the full rows of just the matches (not a narrow column list): + // the caller runs every row through hydrateRecipe/matchSummary just + // like the non-smart branch, so a smart cookbook's cards get the same + // category/tags/rating/pantry_match as everywhere else recipe cards + // render. + const byId = new Map(); + const get = db.prepare(`SELECT r.* FROM recipes r WHERE r.id IN (SELECT value FROM json_each(?))`); + for (let i = 0; i < matches.length; i += 500) { + for (const r of get.all(JSON.stringify(matches.slice(i, i + 500).map(m => m.id)))) byId.set(r.id, r); } - // Full row (not a narrow column list) — the caller runs every row - // through hydrateRecipe/matchSummary just like the non-smart branch, - // so a smart cookbook's cards get the same category/tags/rating/ - // pantry_match as everywhere else recipe cards render. - const sql = ` - SELECT r.* - FROM recipes r - WHERE ${where.join(' AND ')} - ORDER BY r.updated_at DESC - `; - let rows = db.prepare(sql).all(...args); - // Tag AND-match runs in JS (recipes.tags is JSON-encoded). Cheap - // enough for any household-scale library. - const tagFilter = Array.isArray(filter.tags) ? filter.tags.map(s => String(s).toLowerCase()) : []; - if (tagFilter.length > 0) { - rows = rows.filter(r => { - let arr; - try { arr = JSON.parse(r.tags || '[]'); } catch { arr = []; } - const have = new Set((Array.isArray(arr) ? arr : []).map(t => String(t).toLowerCase())); - return tagFilter.every(t => have.has(t)); - }); + return matches.map(m => byId.get(m.id)).filter(Boolean); +} + +// How many recipes opening the cookbook shows (GET /:id): what a smart +// cookbook's filter matches, or the manual links to recipes that still +// exist. A deleted recipe keeps its link (deletes are soft), so links +// alone overcount. `cache` keeps one owner's recipes across a list. +function _recipeCount(cb, cache = new Map()) { + if (cb.is_smart) { + const owner = cb.user_id ?? null; + if (!cache.has(owner)) cache.set(owner, _evalSmartFilter(owner, {}, { full: false })); + const filter = parseSmartFilter(cb.smart_filter_json) || {}; + if (typeof filter !== 'object') return 0; + return cache.get(owner).filter(r => matchesSmartFilter(r, filter)).length; } - return rows; + return db.prepare( + `SELECT COUNT(*) AS n + FROM recipe_cookbook_links l + JOIN recipes r ON r.id = l.recipe_id + WHERE l.cookbook_id = ? AND r.deleted_at IS NULL` + ).get(cb.id).n; } // userClause variant for an aliased table (the JOIN paths below). @@ -111,15 +118,13 @@ const userClauseAliased = (u, alias) => u == null ? `${alias}.user_id IS NULL` : router.get('/', wrap((req, res) => { const u = uid(req); const rows = db.prepare( - `SELECT c.*, ( - SELECT COUNT(*) FROM recipe_cookbook_links l - WHERE l.cookbook_id = c.id - ) AS recipe_count + `SELECT c.* FROM cookbooks c WHERE ${userClauseAliased(u, 'c')} AND c.deleted_at IS NULL ORDER BY c.sort_order ASC, c.name ASC` ).all(...userArgs(u)); - res.json(rows.map(r => _hydrate(r, r.recipe_count))); + const cache = new Map(); + res.json(rows.map(r => _hydrate(r, _recipeCount(r, cache)))); })); // ── PUT /order — rewrite the global cookbook display order ───────────── @@ -132,11 +137,13 @@ router.put('/order', wrap((req, res) => { : []; if (ids.length === 0) return res.status(400).json({ error: 'cookbook_ids required' }); const own = db.prepare(`SELECT id FROM cookbooks WHERE id = ? AND ${userClause(u)} AND deleted_at IS NULL`); - const upd = db.prepare(`UPDATE cookbooks SET sort_order = ?, updated_at = datetime('now') WHERE id = ?`); + // A new order isn't an edit of the cookbooks: only sort_order is stamped + // as changed now, and updated_at stays (lib/field-merge.js). + const upd = db.prepare(`UPDATE cookbooks SET sort_order = ? WHERE id = ? AND sort_order IS NOT ?`); const tx = db.transaction(() => { ids.forEach((cid, i) => { const r = own.get(cid, ...userArgs(u)); - if (r) upd.run(i, cid); + if (r && upd.run(i, cid, i).changes) stampFields('cookbooks', cid, ['sort_order']); }); }); tx(); @@ -153,7 +160,6 @@ router.get('/shared-with-me', wrap((req, res) => { if (u == null) return res.json([]); const rows = db.prepare( `SELECT c.*, - (SELECT COUNT(*) FROM recipe_cookbook_links l WHERE l.cookbook_id = c.id) AS recipe_count, gu.username AS shared_by_username, s.via_kitchen_id, k.name AS via_kitchen_name @@ -164,8 +170,11 @@ router.get('/shared-with-me', wrap((req, res) => { WHERE s.grantee_id = ? AND c.deleted_at IS NULL ORDER BY s.granted_at DESC` ).all(u); + // Counted the way the owner sees it, which is also what opening it + // shows (recipes this account can't open come up locked). + const cache = new Map(); res.json(rows.map(r => ({ - ..._hydrate(r, r.recipe_count), + ..._hydrate(r, _recipeCount(r, cache)), shared_with_me: true, shared_by: r.shared_by_username || null, via_kitchen_id: r.via_kitchen_id ?? null, @@ -229,9 +238,7 @@ router.get('/:id', wrap((req, res) => { // always target the OWNER's recipes — the reader sees exactly // what the owner sees. Recipes the reader can't independently // access get locked below. - let filter = null; - try { filter = cb.smart_filter_json ? JSON.parse(cb.smart_filter_json) : null; } catch {} - recipes = _evalSmartFilter(cb.user_id, filter || {}); + recipes = _evalSmartFilter(cb.user_id, parseSmartFilter(cb.smart_filter_json) || {}); } else { // Full row (not the old narrow column list) so hydrateRecipe below // can resolve category / tags / ingredients the same as every @@ -323,14 +330,7 @@ router.post('/', wrap((req, res) => { // we store stays clean across schema iterations. let smart_filter_json = null; if (is_smart && req.body?.smart_filter && typeof req.body.smart_filter === 'object') { - const f = req.body.smart_filter; - const clean = {}; - if (Number.isFinite(parseInt(f.category_id, 10))) clean.category_id = parseInt(f.category_id, 10); - if (Array.isArray(f.tags)) clean.tags = f.tags.map(s => String(s).trim()).filter(Boolean); - if (f.favorites_only === true) clean.favorites_only = true; - if (Number.isFinite(parseInt(f.min_rating, 10))) clean.min_rating = parseInt(f.min_rating, 10); - if (Number.isFinite(parseInt(f.max_total_minutes, 10))) clean.max_total_minutes = parseInt(f.max_total_minutes, 10); - smart_filter_json = JSON.stringify(clean); + smart_filter_json = JSON.stringify(cleanSmartFilter(req.body.smart_filter)); } let slug = _slugify(name); let n = 2; @@ -347,7 +347,7 @@ router.post('/', wrap((req, res) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ).run(u, name, slug, description, cover_image_url, is_smart, smart_filter_json, maxOrder + 1); const row = db.prepare(`SELECT * FROM cookbooks WHERE id = ?`).get(result.lastInsertRowid); - res.status(201).json(_hydrate(row, 0)); + res.status(201).json(_hydrate(row, _recipeCount(row))); })); // ── PUT /:id — update ─────────────────────────────────────────────────── @@ -380,27 +380,13 @@ router.put('/:id', wrap((req, res) => { let nextFilterJson = existing.smart_filter_json; if (req.body?.is_smart !== undefined) nextIsSmart = req.body.is_smart ? 1 : 0; if (nextIsSmart && req.body?.smart_filter && typeof req.body.smart_filter === 'object') { - const f = req.body.smart_filter; - const clean = {}; - if (Number.isFinite(parseInt(f.category_id, 10))) clean.category_id = parseInt(f.category_id, 10); - if (Array.isArray(f.tags)) clean.tags = f.tags.map(s => String(s).trim()).filter(Boolean); - if (f.favorites_only === true) clean.favorites_only = true; - if (Number.isFinite(parseInt(f.min_rating, 10))) clean.min_rating = parseInt(f.min_rating, 10); - if (Number.isFinite(parseInt(f.max_total_minutes, 10))) clean.max_total_minutes = parseInt(f.max_total_minutes, 10); - nextFilterJson = JSON.stringify(clean); + nextFilterJson = JSON.stringify(cleanSmartFilter(req.body.smart_filter)); } if (!nextIsSmart) nextFilterJson = null; - db.prepare( - `UPDATE cookbooks - SET name = ?, description = ?, cover_image_url = ?, sort_order = ?, - is_smart = ?, smart_filter_json = ?, - updated_at = datetime('now') - WHERE id = ?` - ).run(name, description, cover_image_url, sort_order, nextIsSmart, nextFilterJson, id); + saveRow('cookbooks', id, existing, { name, description, cover_image_url, sort_order, is_smart: nextIsSmart, smart_filter_json: nextFilterJson }, req.body?._sync); const row = db.prepare(`SELECT * FROM cookbooks WHERE id = ?`).get(id); - const cnt = db.prepare(`SELECT COUNT(*) AS n FROM recipe_cookbook_links WHERE cookbook_id = ?`).get(id).n; - res.json(_hydrate(row, cnt)); + res.json(_hydrate(row, _recipeCount(row))); })); // ── DELETE /:id — soft delete ─────────────────────────────────────────── diff --git a/server/routes/data.js b/server/routes/data.js index d44d17f4..9f6d4c17 100644 --- a/server/routes/data.js +++ b/server/routes/data.js @@ -27,6 +27,8 @@ const TABLES_HARD = [ 'pantry_categories', 'custom_units', 'disabled_units', + // Earlier versions of the user's recipes. + 'recipe_versions', ]; router.delete('/', wrap((req, res) => { diff --git a/server/routes/full-backup.js b/server/routes/full-backup.js index d93d5623..38561327 100644 --- a/server/routes/full-backup.js +++ b/server/routes/full-backup.js @@ -203,6 +203,7 @@ function dumpDatabase() { disabled_units: _selectIfExists('disabled_units'), cookbooks: _selectIfExists('cookbooks'), recipe_cookbook_links: _selectIfExists('recipe_cookbook_links'), + recipe_versions: _selectIfExists('recipe_versions'), recipe_shares: _selectIfExists('recipe_shares'), cookbook_shares: _selectIfExists('cookbook_shares'), kitchens: _selectIfExists('kitchens'), @@ -334,6 +335,7 @@ function restoreFromZip(zip) { _restoreTable('recipe_shares', data.recipe_shares); _restoreTable('cookbook_shares', data.cookbook_shares); _restoreTable('recipe_comments', data.recipe_comments); + _restoreTable('recipe_versions', data.recipe_versions); // See TODO(review) in dumpDatabase — pending invitations only. _restoreTable('invite_tokens', data.invite_tokens); diff --git a/server/routes/nt-federation.js b/server/routes/nt-federation.js index d06f47b0..48a857c7 100644 --- a/server/routes/nt-federation.js +++ b/server/routes/nt-federation.js @@ -15,6 +15,7 @@ import { Router } from 'express'; import db from '../db.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; +import { fetchChecked, serviceBase } from '../lib/ssrf-guard.js'; const router = Router(); router.use(requireAuth); @@ -34,8 +35,9 @@ function _config(userId) { const token = _getSetting(userId, 'ntInstanceToken'); const enabled = _getSetting(userId, 'ntFederationEnabled'); if (!url || !token) return null; - if (!/^https?:\/\//.test(url)) return null; - return { url: url.replace(/\/$/, ''), token, enabled: !!enabled }; + const base = serviceBase(url); + if (!base) return null; + return { url: base, token, enabled: !!enabled }; } // Resolve a possibly-relative image path against the NT origin so the @@ -48,11 +50,50 @@ function _absoluteUrl(origin, urlPath) { return origin.replace(/\/$/, '') + (urlPath.startsWith('/') ? urlPath : '/' + urlPath); } +const _str = v => (typeof v === 'string' && v ? v.slice(0, 2000) : null); +const _num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +// A NutriTrace food (its /api/v1/foods shape), field by field. +function _ntFood(f) { + const nutrition = {}; + if (f.nutrition && typeof f.nutrition === 'object') { + for (const [k, v] of Object.entries(f.nutrition)) { + if (typeof v === 'number' && Number.isFinite(v) && /^[a-z0-9_-]{1,40}$/i.test(k)) nutrition[k] = v; + } + } + return { + id: _num(f.id) ?? _str(f.id), + name: _str(f.name), + brand: _str(f.brand), + category: _str(f.category), + barcode: _str(f.barcode), + portion: _num(f.portion), + unit: _str(f.unit), + notes: _str(f.notes), + nutrition, + created_at: _str(f.created_at), + updated_at: _str(f.updated_at), + }; +} + +// What went wrong, without the other server's words: a refused address +// says so, anything else is a failed connection. +function _linkError(e) { + const m = String(e?.message || ''); + if (/addresses are not allowed|addresses are blocked/.test(m)) return m; + if (e?.name === 'AbortError' || /aborted/i.test(m)) return 'NutriTrace didn\'t answer in time'; + return 'Could not reach NutriTrace'; +} + async function _ntFetch(cfg, path, opts = {}) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 8000); try { - const res = await fetch(cfg.url + path, { + // NutriTrace usually lives on the home network, so that's allowed for + // every account; the address is still checked (never cloud metadata), + // on every redirect hop (only followed on the same server), and the + // connection pinned to it. + const res = await fetchChecked(cfg.url + path, { ...opts, headers: { 'Authorization': `Bearer ${cfg.token}`, @@ -60,7 +101,7 @@ async function _ntFetch(cfg, path, opts = {}) { ...(opts.headers || {}), }, signal: ctrl.signal, - }); + }, { allowPrivate: true, maxRedirects: 3, sameOrigin: true }); return res; } finally { clearTimeout(t); } } @@ -68,17 +109,23 @@ async function _ntFetch(cfg, path, opts = {}) { router.post('/test', wrap(async (req, res) => { const u = uid(req); // Allow inline override so the Settings page can test before saving. - const url = (req.body?.url || _getSetting(u, 'ntInstanceUrl') || '').replace(/\/$/, ''); + const raw = req.body?.url || _getSetting(u, 'ntInstanceUrl') || ''; const token = req.body?.token || _getSetting(u, 'ntInstanceToken'); - if (!url || !token) return res.status(400).json({ ok: false, error: 'URL and token required' }); - if (!/^https?:\/\//.test(url)) return res.status(400).json({ ok: false, error: 'URL must start with http(s)://' }); + if (!raw || !token) return res.status(400).json({ ok: false, error: 'URL and token required' }); + const url = serviceBase(raw); + if (!url) return res.status(400).json({ ok: false, error: 'URL must start with http(s)://' }); try { const ntRes = await _ntFetch({ url, token }, '/api/auth/me'); if (!ntRes.ok) return res.json({ ok: false, error: `NutriTrace returned ${ntRes.status}` }); const body = await ntRes.json().catch(() => ({})); - return res.json({ ok: true, user: body.user || null }); + // Only the name the Settings page shows, not whatever the address answered. + const who = body?.user; + const user = who && typeof who === 'object' + ? { username: typeof who.username === 'string' ? who.username : null, name: typeof who.name === 'string' ? who.name : null } + : null; + return res.json({ ok: true, user }); } catch (e) { - return res.json({ ok: false, error: e.message || 'Connection failed' }); + return res.json({ ok: false, error: _linkError(e) }); } })); @@ -93,11 +140,7 @@ router.get('/foods', wrap(async (req, res) => { // 401's any bearer token regardless of validity. const ntRes = await _ntFetch(cfg, '/api/v1/foods' + (q ? '?q=' + encodeURIComponent(q) : '')); if (!ntRes.ok) { - const text = await ntRes.text().catch(() => ''); - return res.status(502).json({ - error: `NutriTrace returned ${ntRes.status} ${ntRes.statusText || ''}`.trim() - + (text && text.length < 240 ? `: ${text}` : ''), - }); + return res.status(502).json({ error: `NutriTrace returned ${ntRes.status} ${ntRes.statusText || ''}`.trim() }); } const body = await ntRes.json(); // v1 wire shape: { items: [...], total, limit, offset } with @@ -106,16 +149,21 @@ router.get('/foods', wrap(async (req, res) => { // already consume so neither needs to know about wire-version // differences. const items = Array.isArray(body?.items) ? body.items : (Array.isArray(body) ? body : []); - res.json(items.map(f => ({ - ...f, - serving_size: f.serving_size != null ? f.serving_size : f.portion, - serving_unit: f.serving_unit || f.unit || null, - // Rewrite NT's relative `/uploads/...` image paths to absolute - // URLs against the NT origin. Without this the picker's <img> - // resolves them against cooktrace's own origin and 404's. - img_url: _absoluteUrl(cfg.url, f.img_url || f.image_url || null), - }))); - } catch (e) { res.status(502).json({ error: e.message || 'Federation request failed' }); } + // Only NutriTrace's food fields go back, never whatever else the + // address answered: it may point anywhere on the home network. + res.json(items.filter(f => f && typeof f === 'object').map(f => { + const food = _ntFood(f); + return { + ...food, + serving_size: _num(f.serving_size) ?? food.portion, + serving_unit: _str(f.serving_unit) || food.unit, + // Rewrite NT's relative `/uploads/...` image paths to absolute + // URLs against the NT origin. Without this the picker's <img> + // resolves them against cooktrace's own origin and 404's. + img_url: _absoluteUrl(cfg.url, _str(f.img_url) || _str(f.image_url) || _str(f.imgUrl)), + }; + })); + } catch (e) { res.status(502).json({ error: _linkError(e) }); } })); // ── POST /import-foods — bulk-import NT foods into the pantry ───────── @@ -268,7 +316,7 @@ router.post('/log-meal', wrap(async (req, res) => { }); if (!ntRes.ok) return res.status(502).json({ error: `NutriTrace returned ${ntRes.status}` }); res.json(await ntRes.json()); - } catch (e) { res.status(502).json({ error: e.message || 'Federation request failed' }); } + } catch (e) { res.status(502).json({ error: _linkError(e) }); } })); export default router; diff --git a/server/routes/oidc.js b/server/routes/oidc.js index 24015645..92315729 100644 --- a/server/routes/oidc.js +++ b/server/routes/oidc.js @@ -12,7 +12,7 @@ import { listProviders, publicProvider, getProvider, getClient, generateAuthChecks, persistState, consumeState, resolveUser, applyAdminMapping, linkUser, unlinkUser, listUserLinks, - isPasswordLoginEnabled, + isPasswordLoginEnabled, isValidAppChallenge, persistHandoff, consumeHandoff, } from '../lib/oidc.js'; import db from '../db.js'; import { claimAnonymousData } from '../lib/claim-anonymous-data.js'; @@ -77,6 +77,9 @@ router.get('/login/:providerId', wrap(async (req, res) => { const checks = generateAuthChecks(); const linkMode = req.query.link === '1' && !!req.user; const isMobile = req.query.mobile === '1'; + // Newer Android builds send app_challenge so the deep link at the end can + // carry a single-use code instead of the session token (see /handoff). + const appChallenge = isMobile && isValidAppChallenge(req.query.app_challenge) ? req.query.app_challenge : null; const returnPath = typeof req.query.return === 'string' ? req.query.return.slice(0, 256) : ''; persistState({ providerId: provider.id, @@ -87,6 +90,7 @@ router.get('/login/:providerId', wrap(async (req, res) => { nonce: checks.nonce, mobile: isMobile, linkUserId: linkMode ? req.user.id : null, + appChallenge, }); const url = client.authorizationUrl({ @@ -102,21 +106,27 @@ router.get('/login/:providerId', wrap(async (req, res) => { /** * GET /api/auth/oidc/callback/:providerId + * GET /api/auth/oidc/callback * Validates state/nonce, exchanges code for tokens, mints ct_token, redirects. + * + * The provider ID in the path is optional: the state row already records + * which provider the sign-in started with. Older docs gave the callback + * without the ID (and at /api/oidc/callback, which index.js forwards here), + * so registrations copied from them keep working. */ -router.get('/callback/:providerId', wrap(async (req, res) => { - const provider = getProvider(req.params.providerId); - if (!provider) return res.status(404).send('Provider not found'); - +async function handleCallback(req, res) { const params = new URLSearchParams(req.url.split('?')[1] || ''); const state = params.get('state'); if (!state) return res.status(400).send('Missing state'); const stored = consumeState(state); if (!stored) return res.status(400).send('Invalid or expired state'); - if (Number(stored.providerId) !== Number(provider.id)) { + const providerId = req.params.providerId ?? stored.providerId; + if (Number(stored.providerId) !== Number(providerId)) { return res.status(400).send('State / provider mismatch'); } + const provider = getProvider(providerId); + if (!provider) return res.status(404).send('Provider not found'); let tokenSet, claims; try { @@ -128,6 +138,18 @@ router.get('/callback/:providerId', wrap(async (req, res) => { code_verifier: stored.codeVerifier, }); claims = tokenSet.claims(); + // Some IdPs only put email, name, username and groups in the userinfo + // response (Authelia 4.39+ by default). Fill in what the ID token lacks; + // the ID token wins where both have a value, and userinfo is ignored + // unless it names the same subject. + if (tokenSet.access_token && client.issuer?.metadata?.userinfo_endpoint) { + try { + const info = await client.userinfo(tokenSet.access_token); + if (info && info.sub === claims.sub) claims = { ...info, ...claims }; + } catch (e) { + logger.warn(`[oidc] userinfo failed for provider ${provider.id}: ${e?.message || e}`); + } + } } catch (e) { logger.warn(`[oidc] callback failed for provider ${provider.id}: ${e?.message || e}`); if (stored.mobile) return res.redirect(`cooktrace://oidc-callback/?error=callback_failed`); @@ -180,6 +202,17 @@ router.get('/callback/:providerId', wrap(async (req, res) => { // locally for RP-initiated logout later. The cookie path used by PWA // doesn't reach the WebView's separate cookie jar, so the client has // to remember these itself. + if (stored.appChallenge) { + // Single-use code the app swaps for the token at POST /handoff. + const code = persistHandoff({ + appChallenge: stored.appChallenge, + token, + idTokenHint: tokenSet?.id_token, + providerId: provider.id, + }); + return res.redirect(`cooktrace://oidc-callback/?code=${encodeURIComponent(code)}`); + } + // Older app builds (no app_challenge) still get the token in the link. let deepLink = `cooktrace://oidc-callback/?token=${encodeURIComponent(token)}`; if (tokenSet?.id_token) { deepLink += `&id_token_hint=${encodeURIComponent(tokenSet.id_token)}`; @@ -198,6 +231,20 @@ router.get('/callback/:providerId', wrap(async (req, res) => { res.clearCookie(LOGOUT_COOKIE); } return _redirectToLogin(res, stored.returnPath, null, 'ok'); +} + +router.get('/callback/:providerId', wrap(handleCallback)); +router.get('/callback', wrap(handleCallback)); + +/** + * POST /api/auth/oidc/handoff { code, verifier } + * The Android app swaps the single-use code from the deep link, plus the + * secret whose hash it sent as app_challenge, for its session token. + */ +router.post('/handoff', wrap((req, res) => { + const data = consumeHandoff(req.body?.code, req.body?.verifier); + if (!data) return res.status(400).json({ error: 'Sign-in expired. Please try again.' }); + res.json({ token: data.token, id_token_hint: data.idTokenHint, provider_id: data.providerId }); })); function _redirectToLogin(res, returnPath, error, ok) { diff --git a/server/routes/pantry.js b/server/routes/pantry.js index 4d6ea23e..08d6ce98 100644 --- a/server/routes/pantry.js +++ b/server/routes/pantry.js @@ -13,11 +13,16 @@ import { Router } from 'express'; import { localizeDataUrl } from '../lib/image-localizer.js'; import db from '../db.js'; +import { stampFields } from '../lib/field-stamps.js'; +import { saveRow } from '../lib/rest-merge.js'; +import { repairVariantTree } from '../lib/pantry-tree.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { deriveSodiumSalt } from '../lib/nutrition-derive.js'; import { dispatchWebhookEvent } from '../lib/webhooks.js'; import { foldText } from '../lib/search-text.js'; +import { cleanCreateKey, findByCreateKey, setCreateKey } from '../lib/create-keys.js'; +import { ownId } from '../lib/link-checks.js'; const router = Router(); router.use(requireAuth); @@ -59,8 +64,8 @@ function _hydrate(row, categoryMap = null) { // pantry_categories for the user; unknown slug → null (cleanly drops) function _resolveCategoryId(u, body) { if (body.category_id != null && body.category_id !== '') { - const n = parseInt(body.category_id, 10); - return Number.isFinite(n) ? n : null; + // Only the account's own category (lib/link-checks.js). + return ownId('pantry_categories', body.category_id, u, { softDelete: false }); } if (typeof body.category === 'string' && body.category.trim()) { const row = db.prepare( @@ -101,7 +106,7 @@ router.get('/categories', wrap((req, res) => { // Include the count of non-deleted pantry items that point at each // category so the Manage hub can surface a usage pill. const rows = db.prepare( - `SELECT c.id, c.name, c.slug, c.icon, c.color, c.sort_order, c.default_aisle, + `SELECT c.id, c.name, c.slug, c.icon, c.color, c.sort_order, c.default_aisle, c.synced_at, (SELECT COUNT(*) FROM pantry_items p WHERE p.category_id = c.id AND p.deleted_at IS NULL AND ${userClause(u).replace(/user_id/g, 'p.user_id')}) AS pantry_count @@ -135,11 +140,31 @@ router.post('/categories', wrap((req, res) => { VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(u, name, slug, icon, color, maxOrder + 1, defaultAisle); const row = db.prepare( - `SELECT id, name, slug, icon, color, sort_order, default_aisle FROM pantry_categories WHERE id = ?` + `SELECT id, name, slug, icon, color, sort_order, default_aisle, synced_at FROM pantry_categories WHERE id = ?` ).get(result.lastInsertRowid); res.status(201).json(row); })); +// Before '/categories/:id', which would otherwise take 'order' for an id +// (and answer 400): the new order never got saved. +router.put('/categories/order', wrap((req, res) => { + const u = uid(req); + const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : null; + if (!ids) return res.status(400).json({ error: 'ids array required' }); + // A new order isn't an edit of the categories: updated_at stays, and + // only sort_order is stamped as changed now (lib/field-merge.js). + const upd = db.prepare( + `UPDATE pantry_categories + SET sort_order = ? + WHERE id = ? AND ${userClause(u)} AND sort_order IS NOT ?` + ); + const tx = db.transaction(() => { + ids.forEach((id, idx) => { if (upd.run(idx, id, ...userArgs(u), idx).changes) stampFields('pantry_categories', id, ['sort_order']); }); + }); + tx(); + res.json({ ok: true }); +})); + router.put('/categories/:id', wrap((req, res) => { const u = uid(req); const id = parseInt(req.params.id, 10); @@ -160,34 +185,15 @@ router.put('/categories/:id', wrap((req, res) => { ? String(req.body.default_aisle).trim().slice(0, 40) : null) : existing.default_aisle; - db.prepare( - `UPDATE pantry_categories - SET name = ?, icon = ?, color = ?, sort_order = ?, default_aisle = ?, updated_at = datetime('now') - WHERE id = ?` - ).run(name, icon, color, sort, defaultAisle, id); + saveRow('pantry_categories', id, existing, { name, icon, color, sort_order: sort, default_aisle: defaultAisle }, req.body?._sync); const row = db.prepare( - `SELECT id, name, slug, icon, color, sort_order, default_aisle FROM pantry_categories WHERE id = ?` + `SELECT id, name, slug, icon, color, sort_order, default_aisle, synced_at FROM pantry_categories WHERE id = ?` ).get(id); res.json(row); })); // ── PUT /categories/order — bulk reorder ────────────────────────────── // Same shape as the recipe-categories order endpoint. -router.put('/categories/order', wrap((req, res) => { - const u = uid(req); - const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : null; - if (!ids) return res.status(400).json({ error: 'ids array required' }); - const upd = db.prepare( - `UPDATE pantry_categories - SET sort_order = ?, updated_at = datetime('now') - WHERE id = ? AND ${userClause(u)}` - ); - const tx = db.transaction(() => { - ids.forEach((id, idx) => upd.run(idx, id, ...userArgs(u))); - }); - tx(); - res.json({ ok: true }); -})); router.delete('/categories/:id', wrap((req, res) => { const u = uid(req); @@ -312,6 +318,11 @@ router.get('/:id', wrap((req, res) => { // ── POST / — create ───────────────────────────────────────────────────── router.post('/', wrap((req, res) => { const u = uid(req); + // Sent before (Connect > Upload again, or an answer lost): the row made + // then, not a second one (lib/create-keys.js). + const createKey = cleanCreateKey(req.body?.client_key); + const made = findByCreateKey('pantry_items', u, createKey); + if (made) return res.json(_hydrate(made)); const body = req.body || {}; const name = (body.name || '').toString().trim(); if (!name) return res.status(400).json({ error: 'Name is required' }); @@ -348,10 +359,13 @@ router.post('/', wrap((req, res) => { // both stay in range is done at update / promote time; create-time // values are trusted (caller is the editor flow which already picked // valid IDs). - const genericParentId = body.generic_parent_id != null && body.generic_parent_id !== '' - ? parseInt(body.generic_parent_id, 10) : null; - const nutritionSourceVariantId = body.nutrition_source_variant_id != null && body.nutrition_source_variant_id !== '' - ? parseInt(body.nutrition_source_variant_id, 10) : null; + // Only the account's own top-level item can be the parent. A new item + // has no variants yet, so it has no nutrition source either: one is + // set once its variants exist (PUT, which checks it is one of them). + const parentId = ownId('pantry_items', body.generic_parent_id, u); + const parentRow = parentId != null ? db.prepare(`SELECT generic_parent_id FROM pantry_items WHERE id = ?`).get(parentId) : null; + const genericParentId = parentRow && parentRow.generic_parent_id == null ? parentId : null; + const nutritionSourceVariantId = null; const result = db.prepare( `INSERT INTO pantry_items (user_id, name, brand, barcode, in_stock, quantity, unit, expires_on, nt_food_id, @@ -380,6 +394,7 @@ router.post('/', wrap((req, res) => { Number.isFinite(genericParentId) ? genericParentId : null, Number.isFinite(nutritionSourceVariantId) ? nutritionSourceVariantId : null, ); + setCreateKey('pantry_items', result.lastInsertRowid, createKey); const row = db.prepare(`SELECT * FROM pantry_items WHERE id = ?`).get(result.lastInsertRowid); res.status(201).json(_hydrate(row)); })); @@ -424,7 +439,7 @@ router.put('/:id', wrap((req, res) => { const candidate = parseInt(body.generic_parent_id, 10); if (!Number.isFinite(candidate)) return res.status(400).json({ error: 'generic_parent_id must be a number or null' }); if (candidate === id) return res.status(400).json({ error: "An item can't be its own generic parent" }); - const parent = db.prepare( + const parent = ownId('pantry_items', candidate, u) != null && db.prepare( `SELECT id, generic_parent_id FROM pantry_items WHERE id = ? AND deleted_at IS NULL` ).get(candidate); if (!parent) return res.status(400).json({ error: 'Generic parent not found' }); @@ -444,7 +459,7 @@ router.put('/:id', wrap((req, res) => { } else { const candidate = parseInt(body.nutrition_source_variant_id, 10); if (!Number.isFinite(candidate)) return res.status(400).json({ error: 'nutrition_source_variant_id must be a number or null' }); - const source = db.prepare( + const source = ownId('pantry_items', candidate, u) != null && db.prepare( `SELECT id, generic_parent_id FROM pantry_items WHERE id = ? AND deleted_at IS NULL` ).get(candidate); if (!source) return res.status(400).json({ error: 'Nutrition source variant not found' }); @@ -457,41 +472,34 @@ router.put('/:id', wrap((req, res) => { const nextInStock = body.in_stock != null ? (body.in_stock ? 1 : 0) : existing.in_stock; - db.prepare( - `UPDATE pantry_items SET - name = ?, brand = ?, barcode = ?, in_stock = ?, quantity = ?, unit = ?, expires_on = ?, - nt_food_id = ?, img_url = ?, notes = ?, - category = ?, category_id = ?, serving_size = ?, serving_unit = ?, serving_label = ?, - nutrition = ?, g_per_cup = ?, - generic_parent_id = ?, nutrition_source_variant_id = ?, - updated_at = datetime('now') - WHERE id = ?` - ).run( + // With _sync, merged with changes made elsewhere (lib/rest-merge.js). + const { kept } = saveRow('pantry_items', id, existing, { name, - body.brand !== undefined ? (body.brand?.toString().trim() || null) : existing.brand, - body.barcode !== undefined ? (body.barcode?.toString().trim() || null) : existing.barcode, - nextInStock, + brand: body.brand !== undefined ? (body.brand?.toString().trim() || null) : existing.brand, + barcode: body.barcode !== undefined ? (body.barcode?.toString().trim() || null) : existing.barcode, + in_stock: nextInStock, // An explicit null or '' clears the quantity; only an absent key keeps // the stored one. Sending null used to fall through to "keep", so // marking an item back in stock left quantity 0, which still reads as // out of stock everywhere quantity is the source of truth. - body.quantity !== undefined ? (body.quantity === '' || body.quantity === null ? null : Number(body.quantity)) : existing.quantity, - body.unit !== undefined ? (body.unit || null) : existing.unit, - body.expires_on !== undefined ? (body.expires_on || null) : existing.expires_on, - body.nt_food_id !== undefined ? (body.nt_food_id || null) : existing.nt_food_id, - body.img_url !== undefined ? localizeDataUrl(body.img_url || body.imgUrl || null) : existing.img_url, - body.notes !== undefined ? (body.notes || null) : existing.notes, - nextCategorySlug, - nextCategoryId, - body.serving_size !== undefined ? (body.serving_size === '' || body.serving_size == null ? null : Number(body.serving_size)) : existing.serving_size, - body.serving_unit !== undefined ? (body.serving_unit || null) : existing.serving_unit, - body.serving_label !== undefined ? (body.serving_label || null) : existing.serving_label, - body.nutrition !== undefined ? _stringifyNutrition(body.nutrition) : existing.nutrition, - body.g_per_cup !== undefined ? (body.g_per_cup === '' || body.g_per_cup == null ? null : Number(body.g_per_cup)) : existing.g_per_cup, - nextGenericParentId, - nextNutritionSourceVariantId, - id, - ); + quantity: body.quantity !== undefined ? (body.quantity === '' || body.quantity === null ? null : Number(body.quantity)) : existing.quantity, + unit: body.unit !== undefined ? (body.unit || null) : existing.unit, + expires_on: body.expires_on !== undefined ? (body.expires_on || null) : existing.expires_on, + nt_food_id: body.nt_food_id !== undefined ? (body.nt_food_id || null) : existing.nt_food_id, + img_url: body.img_url !== undefined ? localizeDataUrl(body.img_url || body.imgUrl || null) : existing.img_url, + notes: body.notes !== undefined ? (body.notes || null) : existing.notes, + category: nextCategorySlug, + category_id: nextCategoryId, + serving_size: body.serving_size !== undefined ? (body.serving_size === '' || body.serving_size == null ? null : Number(body.serving_size)) : existing.serving_size, + serving_unit: body.serving_unit !== undefined ? (body.serving_unit || null) : existing.serving_unit, + serving_label: body.serving_label !== undefined ? (body.serving_label || null) : existing.serving_label, + nutrition: body.nutrition !== undefined ? _stringifyNutrition(body.nutrition) : existing.nutrition, + g_per_cup: body.g_per_cup !== undefined ? (body.g_per_cup === '' || body.g_per_cup == null ? null : Number(body.g_per_cup)) : existing.g_per_cup, + generic_parent_id: nextGenericParentId, + nutrition_source_variant_id: nextNutritionSourceVariantId, + }, body._sync); + // A merge can't leave the variant tree in a shape the checks above refuse. + if (body._sync) repairVariantTree([id]); if (existing.in_stock === 1 && nextInStock === 0) { try { @@ -500,7 +508,7 @@ router.put('/:id', wrap((req, res) => { } const row = db.prepare(`SELECT * FROM pantry_items WHERE id = ?`).get(id); - res.json(_hydrate(row)); + res.json({ ..._hydrate(row), ...(kept ? { kept: 'server' } : {}) }); })); // ── DELETE /:id — soft delete ─────────────────────────────────────────── @@ -560,8 +568,8 @@ router.patch('/:id/stock', wrap((req, res) => { if ((u == null && existing.user_id != null) || (u != null && existing.user_id !== u)) { return res.status(403).json({ error: 'Forbidden' }); } - const next = req.body?.in_stock ? 1 : 0; - db.prepare(`UPDATE pantry_items SET in_stock = ?, updated_at = datetime('now') WHERE id = ?`).run(next, id); + saveRow('pantry_items', id, existing, { in_stock: req.body?.in_stock ? 1 : 0 }, req.body?._sync); + const next = db.prepare(`SELECT in_stock FROM pantry_items WHERE id = ?`).get(id).in_stock; if (existing.in_stock === 1 && next === 0) { try { diff --git a/server/routes/proxy.js b/server/routes/proxy.js index 01fbd861..f82a6922 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import { logger } from '../logger.js'; +import { fetchChecked, readBody } from '../lib/ssrf-guard.js'; import { makeRateLimiter } from '../middleware/rate-limit.js'; const router = Router(); @@ -35,32 +36,46 @@ router.get('/', async (req, res) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15000); - const response = await fetch(url, { + // Only the allowed public hosts, and a redirect may not lead into the + // server's own network or to cloud metadata. + const response = await fetchChecked(url, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CookTrace/1.0)' }, - }); - clearTimeout(timer); + }, { maxRedirects: 3 }); if (!response.ok) { logger.warn(`[proxy] upstream ${response.status} for ${url}`); return res.status(response.status).json({ error: `Upstream ${response.status}` }); } - const contentType = response.headers.get('content-type') || ''; + const contentType = (response.headers.get('content-type') || '').toLowerCase(); - // Image response: pipe binary data with proper content-type - if (contentType.startsWith('image/') || isImgHost) { - const buffer = Buffer.from(await response.arrayBuffer()); + // Image response: only an image, sent so it can't act as a page. This + // answers from the app's own origin, before sign-in, so an image host's + // HTML or SVG passed through as-is could have run script as the app. + // (An image host that names no type gets image/jpeg, as before.) + if (contentType.startsWith('image/') || (isImgHost && !contentType)) { + const buffer = await readBody(response, 10 * 1024 * 1024); + clearTimeout(timer); res.set('Content-Type', contentType || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); return res.send(buffer); } + if (isImgHost) { + clearTimeout(timer); + try { await response.body?.cancel(); } catch {} + return res.status(502).json({ error: 'That address is not an image' }); + } // JSON API response - res.json(await response.json()); + const body = JSON.parse((await readBody(response, 5 * 1024 * 1024)).toString('utf8')); + clearTimeout(timer); + res.json(body); } catch(e) { logger.error('[proxy] fetch error:', e.message, 'url:', url); - res.status(503).json({ error: e.message }); + res.status(503).json({ error: 'Could not reach that service' }); } }); diff --git a/server/routes/recipes.js b/server/routes/recipes.js index e410150b..b79ad090 100644 --- a/server/routes/recipes.js +++ b/server/routes/recipes.js @@ -9,17 +9,23 @@ * management) leaves user_id NULL. */ import { Router } from 'express'; +import { linkBase } from '../lib/public-url.js'; import db from '../db.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { dispatchWebhookEvent } from '../lib/webhooks.js'; import { ensurePantryItems } from './pantry.js'; import { scrapeRecipe, fetchRecipeHtml, extractFromHtml } from '../lib/recipe-scraper.js'; -import { aiExtractRecipe } from '../lib/recipe-ai-fallback.js'; +import { aiExtractRecipe, importAiConfig } from '../lib/recipe-ai-fallback.js'; +import { ownerOrOptIn } from '../lib/outbound-policy.js'; import { scrapeWithRecipeScrapers, isRecipeScrapersAvailable } from '../lib/recipe-scrapers-bridge.js'; import { importRecipeFromText, importPaprikaArchive, scanRecipeZip, scanLoadedZip, loadRecipeZip, readImageFromLoadedZip, readZipImageBytes, mealieEventImagePaths } from '../lib/recipe-importers.js'; import { extractText, detectFileType } from '../lib/text-extractors.js'; import { guardNestedRecipeFields } from '../lib/recipe-guards.js'; +import { saveRecipeVersion, versionColumns, mergeRecipe, sameContent, VERSION_FIELDS, META_FIELDS, RECIPE_MERGE_FIELDS } from '../lib/recipe-versions.js'; +import { clockOffset, editTime } from '../lib/sync-clock.js'; +import { stampFields } from '../lib/field-stamps.js'; +import { saveRow } from '../lib/rest-merge.js'; import { parseRecipeText, HIGH_CONFIDENCE_THRESHOLD } from '../lib/heuristic-recipe-parser.js'; import JSZip from 'jszip'; import path from 'node:path'; @@ -50,6 +56,8 @@ function _userArgs(u) { // helper and silently dropped every mobile-created recipe out of // auto-share (root cause of the "member sees nothing" bug). import { autoShareNewRecipe as _autoShareNewRecipe } from '../lib/auto-share.js'; +import { cleanCreateKey, findByCreateKey, setCreateKey } from '../lib/create-keys.js'; +import { ownId, ownIngredientLinks } from '../lib/link-checks.js'; // Recipe row -> API-shape hydration lives in server/lib/recipe-hydrate.js // so cookbooks.js can hydrate cookbook recipe cards identically (they @@ -204,7 +212,7 @@ router.get('/categories', wrap((req, res) => { // pill in the Manage hub so users can see at a glance which // taxonomies are stale. const rows = db.prepare( - `SELECT c.id, c.name, c.slug, c.color, c.sort_order, + `SELECT c.id, c.name, c.slug, c.color, c.sort_order, c.synced_at, (SELECT COUNT(*) FROM recipes r WHERE r.category_id = c.id AND r.deleted_at IS NULL AND ${_whereUser(u).replace(/user_id/g, 'r.user_id')}) AS recipe_count @@ -233,10 +241,31 @@ router.post('/categories', wrap((req, res) => { const result = db.prepare( `INSERT INTO recipe_categories (user_id, name, slug, color, sort_order) VALUES (?, ?, ?, ?, ?)` ).run(u, name, slug, color, maxOrder + 1); - const row = db.prepare(`SELECT id, name, slug, color, sort_order FROM recipe_categories WHERE id = ?`).get(result.lastInsertRowid); + const row = db.prepare(`SELECT id, name, slug, color, sort_order, synced_at FROM recipe_categories WHERE id = ?`).get(result.lastInsertRowid); res.status(201).json(row); })); +// Before '/categories/:id', which would otherwise take 'order' for an id +// (and answer 400): the new order never got saved. +router.put('/categories/order', wrap((req, res) => { + const u = uid(req); + const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : null; + if (!ids) return res.status(400).json({ error: 'ids array required' }); + // A new order isn't an edit of the categories: updated_at stays, and + // only sort_order is stamped as changed now (two edits of a category + // meet field by field, see lib/field-merge.js). + const upd = db.prepare( + `UPDATE recipe_categories + SET sort_order = ? + WHERE id = ? AND ${_whereUser(u)} AND sort_order IS NOT ?` + ); + const tx = db.transaction(() => { + ids.forEach((id, idx) => { if (upd.run(idx, id, ..._userArgs(u), idx).changes) stampFields('recipe_categories', id, ['sort_order']); }); + }); + tx(); + res.json({ ok: true }); +})); + router.put('/categories/:id', wrap((req, res) => { const u = uid(req); const id = parseInt(req.params.id, 10); @@ -254,12 +283,8 @@ router.put('/categories/:id', wrap((req, res) => { ? parseInt(req.body.sort_order, 10) : existing.sort_order; - db.prepare( - `UPDATE recipe_categories - SET name = ?, color = ?, sort_order = ?, updated_at = datetime('now') - WHERE id = ?` - ).run(name, color, sort, id); - const row = db.prepare(`SELECT id, name, slug, color, sort_order FROM recipe_categories WHERE id = ?`).get(id); + saveRow('recipe_categories', id, existing, { name, color, sort_order: sort }, req.body?._sync); + const row = db.prepare(`SELECT id, name, slug, color, sort_order, synced_at FROM recipe_categories WHERE id = ?`).get(id); res.json(row); })); @@ -267,21 +292,6 @@ router.put('/categories/:id', wrap((req, res) => { // Body: { ids: [42, 17, 9, ...] } — applies sort_order = index to each // id in the given sequence. Used by the drag-and-drop reorder in the // Manage hub; mirrors the same pattern as cookbooks order. -router.put('/categories/order', wrap((req, res) => { - const u = uid(req); - const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : null; - if (!ids) return res.status(400).json({ error: 'ids array required' }); - const upd = db.prepare( - `UPDATE recipe_categories - SET sort_order = ?, updated_at = datetime('now') - WHERE id = ? AND ${_whereUser(u)}` - ); - const tx = db.transaction(() => { - ids.forEach((id, idx) => upd.run(idx, id, ..._userArgs(u))); - }); - tx(); - res.json({ ok: true }); -})); router.delete('/categories/:id', wrap((req, res) => { const u = uid(req); @@ -526,6 +536,11 @@ router.get('/:id', wrap((req, res) => { // ── POST / — create ───────────────────────────────────────────────────── router.post('/', wrap((req, res) => { const u = uid(req); + // Sent before (Connect > Upload again, or an answer lost): the row made + // then, not a second one (lib/create-keys.js). + const createKey = cleanCreateKey(req.body?.client_key); + const made = findByCreateKey('recipes', u, createKey); + if (made) return res.json(_withCreatorAvatar(_hydrate(made), made)); // Resolve / auto-create pantry items BEFORE serializing to JSON, // but only when the user opts in via the autoCreatePantryFromRecipes // setting. Default off — typing "flour" in a recipe editor doesn't @@ -536,6 +551,9 @@ router.post('/', wrap((req, res) => { body.ingredients = _linkIngredientsToPantry(u, body.ingredients); } const data = _toStorage(body); + // Only the account's own category and pantry items (lib/link-checks.js). + if (data.category_id != null) data.category_id = ownId('recipe_categories', data.category_id, u, { softDelete: false }); + data.ingredients = ownIngredientLinks(data.ingredients, u); if (!data.name) return res.status(400).json({ error: 'Name is required' }); // Capture creator's username on insert (denormalized for display speed). @@ -554,6 +572,7 @@ router.post('/', wrap((req, res) => { data.ingredients, data.steps, data.tags, data.tools, data.nutrition, data.source_url, data.notes, data.visibility, creatorUsername, data.category_id, data.video_url, ); + setCreateKey('recipes', result.lastInsertRowid, createKey); _autoShareNewRecipe(u, result.lastInsertRowid); const row = db.prepare(`SELECT * FROM recipes WHERE id = ?`).get(result.lastInsertRowid); res.status(201).json(_withCreatorAvatar(_hydrate(row), row)); @@ -596,6 +615,10 @@ router.put('/:id', wrap((req, res) => { data.rating = existing.rating; data.favorite = existing.favorite; } + // Only a category of the recipe's owner (lib/link-checks.js). + else if (data.category_id != null) data.category_id = ownId('recipe_categories', data.category_id, existing.user_id, { softDelete: false }); + // Ingredients link only to the recipe owner's pantry items. + data.ingredients = ownIngredientLinks(data.ingredients, existing.user_id); // Option E guard (2026-08-11): if any of the nested JSON fields // (ingredients / steps / tags / tools / nutrition) is empty on the @@ -610,6 +633,50 @@ router.put('/:id', wrap((req, res) => { // these fields, promote CT to full Option C (per-uuid merge). const guarded = guardNestedRecipeFields({ existing, incoming: data }); + // A save that says which copy it was made on (_sync: the web app sends + // it, also when it replays saves made with no connection, and so does the + // Android app for a recipe shared with it) follows the same rule as a + // phone's sync: the newer edit stays, the copy that doesn't is kept as an + // earlier version, and what the save didn't change stays as it is here. + const sync = body._sync && typeof body._sync === 'object' ? body._sync : null; + let editedAt = null; + let keptServer = false; + let applied = null; + if (sync) { + // What this save changed: what it says (`changed`, from the copy the + // page had), else every field it sends. A Sous Chef doesn't change the + // owner's category, rating, favorite or visibility. + const sent = new Set(RECIPE_MERGE_FIELDS.filter(f => f !== 'deleted_at' + && (f === 'img_url' ? ('img_url' in body || 'imgUrl' in body) : f in body))); + const said = Array.isArray(sync.changed) ? sync.changed.filter(f => sent.has(f)) : [...sent]; + const changed = new Set(said.filter(f => !(isKitchenEditor && ['category_id', 'rating', 'favorite', 'visibility'].includes(f)))); + const incoming = {}; + for (const f of RECIPE_MERGE_FIELDS) if (f !== 'deleted_at' && sent.has(f)) incoming[f] = guarded[f]; + const m = mergeRecipe(existing, incoming, { + base: typeof sync.base_synced_at === 'string' ? sync.base_synced_at : null, + changed, + editedAt: editTime(typeof sync.edited_at === 'string' ? sync.edited_at : null, clockOffset(sync.client_now)), + }); + for (const k of m.keep) { + saveRecipeVersion(id, existing.user_id, k.row, { + reason: k.reason, + editedBy: k.side === 'incoming' ? (u ?? existing.user_id) : (existing.last_edited_by ?? existing.user_id), + }); + } + if (!m.write) { + return res.json({ ..._withCreatorAvatar(_hydrate(existing), existing), ...(m.devicePulls ? { kept: 'server' } : {}) }); + } + for (const f of [...VERSION_FIELDS, ...META_FIELDS]) { data[f] = m.row[f]; guarded[f] = m.row[f]; } + editedAt = m.updatedAt; + keptServer = m.devicePulls; + applied = { fields: m.applied, at: m.fieldAt }; + } else if (!sameContent(guarded, existing)) { + // A save that can't say which copy it was made on (an older page, an + // API client): it goes in as before, and the copy it replaces is kept, + // without pointing at it, in case it was someone else's newer edit. + saveRecipeVersion(id, existing.user_id, existing, { reason: 'replaced', editedBy: existing.last_edited_by ?? existing.user_id }); + } + db.prepare( `UPDATE recipes SET name = ?, description = ?, img_url = ?, servings = ?, yield_text = ?, @@ -617,18 +684,104 @@ router.put('/:id', wrap((req, res) => { ingredients = ?, steps = ?, tags = ?, tools = ?, nutrition = ?, source_url = ?, notes = ?, visibility = ?, category_id = ?, video_url = ?, last_edited_by = ?, - updated_at = datetime('now') + updated_at = COALESCE(?, datetime('now')) WHERE id = ?` ).run( data.name, data.description, data.img_url, data.servings, data.yield_text, data.prep_minutes, data.cook_minutes, data.total_minutes, data.rest_minutes, data.rating, data.favorite, guarded.ingredients, guarded.steps, guarded.tags, guarded.tools, guarded.nutrition, data.source_url, data.notes, data.visibility, data.category_id, data.video_url, - // Only worth recording when someone other than the owner saved. - isOwner ? null : u, + // Only worth recording when someone other than the owner saved, and + // only for content of theirs that went in: otherwise it stays whose it was. + applied && !applied.fields.some(f => VERSION_FIELDS.includes(f)) ? existing.last_edited_by : (isOwner ? null : u), + editedAt, id, ); + if (applied) stampFields('recipes', id, applied.fields, applied.at); const row = db.prepare(`SELECT * FROM recipes WHERE id = ?`).get(id); + res.json({ ..._withCreatorAvatar(_hydrate(row), row), ...(keptServer ? { kept: 'server' } : {}) }); +})); + + +// ── Earlier versions ────────────────────────────────────────────────── +// Copies of the recipe a sync didn't keep (an edit from another device +// that was older, or the copy a newer one replaced), and the copy each +// restore or older save replaced. The owner sees them all; someone else +// who may edit it (an admin, a kitchen Sous Chef) sees their own edits. +function _versionAccess(req, res) { + const u = uid(req); + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid id' }); return null; } + const recipe = db.prepare(`SELECT * FROM recipes WHERE id = ? AND deleted_at IS NULL`).get(id); + if (!recipe) { res.status(404).json({ error: 'Not found' }); return null; } + const isOwner = (u == null && recipe.user_id == null) || recipe.user_id === u; + const isAdmin = req.user?.role === 'admin'; + if (!isOwner && !isAdmin && !_canEditViaKitchen(id, u)) { res.status(403).json({ error: 'Forbidden' }); return null; } + return { + recipe, isOwner, u, isKitchenEditor: !isOwner && !isAdmin, + // Which versions: all of them, or this person's own edits. + mine: isOwner ? '' : ' AND v.edited_by = ?', mineArgs: isOwner ? [] : [u], + }; +} + +// GET /:id/versions: newest first, each with the content it holds and whose +// edit it was. +router.get('/:id/versions', wrap((req, res) => { + const a = _versionAccess(req, res); + if (!a) return; + const rows = db.prepare( + `SELECT v.id, v.reason, v.created_at, v.seen, v.data, v.edited_by, + COALESCE(us.full_name, us.username) AS edited_by_name + FROM recipe_versions v LEFT JOIN users us ON us.id = v.edited_by + WHERE v.recipe_id = ?${a.mine} ORDER BY v.id DESC` + ).all(a.recipe.id, ...a.mineArgs); + res.json(rows.map(r => { + const data = _safeJson(r.data, {}); + data.ingredients = _normaliseIngredientGroups(data.ingredients); + return { ...r, seen: !!r.seen, data }; + })); +})); + +// POST /:id/versions/seen: they've looked, so the recipe page stops +// pointing at them. +router.post('/:id/versions/seen', wrap((req, res) => { + const a = _versionAccess(req, res); + if (!a) return; + db.prepare(`UPDATE recipe_versions AS v SET seen = 1 WHERE v.recipe_id = ? AND v.seen = 0${a.mine}`).run(a.recipe.id, ...a.mineArgs); + res.json({ ok: true }); +})); + +// POST /:id/versions/:vid/restore: the version becomes the recipe. The +// copy it replaces is kept as a version first, so a restore can be undone. +// A new edit time, so every device picks it up. +router.post('/:id/versions/:vid/restore', wrap((req, res) => { + const a = _versionAccess(req, res); + if (!a) return; + const { recipe } = a; + const vid = parseInt(req.params.vid, 10); + const version = Number.isFinite(vid) + ? db.prepare(`SELECT v.* FROM recipe_versions v WHERE v.id = ? AND v.recipe_id = ?${a.mine}`).get(vid, recipe.id, ...a.mineArgs) + : null; + if (!version) return res.status(404).json({ error: 'Not found' }); + const data = _safeJson(version.data, {}); + // A version's name and category may no longer fit: a recipe needs a + // name, and a category deleted since stays the current one. A Sous Chef + // doesn't move the owner's recipe between categories. + if (!String(data.name ?? '').trim()) data.name = recipe.name; + if (a.isKitchenEditor) data.category_id = recipe.category_id; + else if (data.category_id != null) { + const cat = db.prepare(`SELECT user_id FROM recipe_categories WHERE id = ?`).get(data.category_id); + const ownCat = cat && ((recipe.user_id == null && cat.user_id == null) || cat.user_id === recipe.user_id); + if (!ownCat) data.category_id = recipe.category_id; + } + const { cols, vals } = versionColumns(data); + db.transaction(() => { + saveRecipeVersion(recipe.id, recipe.user_id, recipe, { reason: 'restore', editedBy: recipe.last_edited_by ?? recipe.user_id }); + db.prepare( + `UPDATE recipes SET ${cols.map(c => `${c} = ?`).join(', ')}, last_edited_by = ?, updated_at = datetime('now') WHERE id = ?` + ).run(...vals, a.isOwner ? null : a.u, recipe.id); + })(); + const row = db.prepare(`SELECT * FROM recipes WHERE id = ?`).get(recipe.id); res.json(_withCreatorAvatar(_hydrate(row), row)); })); @@ -809,11 +962,7 @@ router.put('/:id/cooks/:cookId', wrap((req, res) => { const photoUrl = nextPhotos[0] || null; const photosJson = nextPhotos.length ? JSON.stringify(nextPhotos) : null; - db.prepare( - `UPDATE cook_diary - SET date = ?, notes = ?, photo_url = ?, photos = ?, updated_at = datetime('now') - WHERE id = ?` - ).run(date, notes, photoUrl, photosJson, cookId); + saveRow('cook_diary', cookId, existing, { date, notes, photo_url: photoUrl, photos: photosJson }, req.body?._sync); _recomputeCookAggregates(id); res.json({ ok: true }); @@ -877,15 +1026,13 @@ router.post('/:id/shares', wrap((req, res) => { // configured or any individual send throws. if (newGrantees.length > 0 && isEmailConfigured()) { const sharerName = req.user?.full_name || req.user?.username || null; - const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; - const host = req.headers['x-forwarded-host'] || req.headers.host || ''; - const baseUrl = `${proto}://${host}`; + const baseUrl = linkBase(req); const viewUrl = `${baseUrl}/#/recipes/${id}`; const granteeEmails = db.prepare( `SELECT id, email FROM users WHERE id IN (${newGrantees.map(() => '?').join(',')})` ).all(...newGrantees); for (const row of granteeEmails) { - if (!row.email) continue; + if (!row.email || !baseUrl) continue; sendRecipeShared(row.email, viewUrl, recipe.name, sharerName) .catch(e => logger.debug?.(`[share] email to ${row.email} failed: ${e.message}`)); } @@ -942,13 +1089,10 @@ router.delete('/:id/share', wrap((req, res) => { })); function _randomToken(byteLen) { - // Base64url encoding via Buffer (Node 18+). - const buf = Buffer.alloc(byteLen); - for (let i = 0; i < byteLen; i++) buf[i] = Math.floor(Math.random() * 256); - return buf.toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + // From the system's secure random source. Math.random() is predictable + // from its own output, so a token made with it could be worked out from + // links the person had already seen. + return crypto.randomBytes(byteLen).toString('base64url'); } // ── DELETE /:id/cooks/:cookId — soft-delete a cook entry ─────────────── @@ -1005,7 +1149,7 @@ function _recomputeCookAggregates(recipeId) { async function _scrapeUrlOnly(url, opts = {}) { const errors = []; let html, finalUrl; - try { ({ html, finalUrl } = await fetchRecipeHtml(url)); } + try { ({ html, finalUrl } = await fetchRecipeHtml(url, { allowPrivate: !!opts.allowPrivate })); } catch (e) { return { parsed: null, usedTier: null, errors: [e.message] }; } const engine = opts.engine || 'standard'; @@ -1077,10 +1221,10 @@ router.post('/scrape', wrap(async (req, res) => { // tier is unavailable for this request (e.g. local-Android client). const engine = _userSetting(u, 'urlImportEngine') || 'standard'; const fallback = _userSetting(u, 'urlImportFallback') || 'standard'; - const aiCfg = _aiConfigForUser(u); + const aiCfg = _aiConfigForUser(u, req); let html, finalUrl; - try { ({ html, finalUrl } = await fetchRecipeHtml(url)); } + try { ({ html, finalUrl } = await fetchRecipeHtml(url, { allowPrivate: ownerOrOptIn(req, 'ALLOW_PRIVATE_RECIPE_URLS') })); } catch (e) { return res.status(400).json({ error: e.message }); } let parsed = null; @@ -1146,7 +1290,8 @@ router.post('/scrape', wrap(async (req, res) => { const row = _saveImportedRecipe(u, parsed, { addToPantry, applyTags, importCategories, dedup, creatorUsername: req.user?.username || null }); if (!row) { - return res.status(200).json({ skipped: true, reason: 'duplicate', _import_tier: usedTier }); + const existing = _findImportDuplicate(u, parsed); + return res.status(200).json({ skipped: true, reason: 'duplicate', existing_id: existing?.id || null, name: parsed.name, _import_tier: usedTier }); } res.status(201).json({ ...row, _import_tier: usedTier }); })); @@ -1182,8 +1327,8 @@ router.post('/batch-scrape', wrap(async (req, res) => { const engine = _userSetting(u, 'urlImportEngine') || 'standard'; const fallback = _userSetting(u, 'urlImportFallback') || 'standard'; - const aiCfg = _aiConfigForUser(u); - const scrapeOpts = { engine, fallback, aiCfg }; + const aiCfg = _aiConfigForUser(u, req); + const scrapeOpts = { engine, fallback, aiCfg, allowPrivate: ownerOrOptIn(req, 'ALLOW_PRIVATE_RECIPE_URLS') }; // Small concurrent pool. Workers pull from a shared index so we don't // need a queue library for a one-shot batch. @@ -1284,22 +1429,36 @@ function _userSetting(u, key) { // Build an AI config object for a user from their stored settings, or // fall back to the server-side AI env config when they have nothing // of their own. Returns null when no usable config exists. -function _aiConfigForUser(u) { - const provider = _userSetting(u, 'aiProvider'); - const apiKey = _userSetting(u, 'aiApiKey'); - const model = _userSetting(u, 'aiModel'); - const baseUrl = _userSetting(u, 'aiBaseUrl'); - if (provider && apiKey) return { provider, apiKey, model: model || '', baseUrl: baseUrl || '' }; - // Fall back to env-configured AI (same key as /api/ai/chat uses). - if (process.env.AI_API_KEY) { - return { - provider: process.env.AI_PROVIDER || 'claude', - apiKey: process.env.AI_API_KEY, - model: process.env.AI_MODEL || '', - baseUrl: process.env.AI_BASE_URL || '', - }; +function _aiConfigForUser(u, req) { + const cfg = importAiConfig({ + provider: _userSetting(u, 'aiProvider'), + apiKey: _userSetting(u, 'aiApiKey'), + model: _userSetting(u, 'aiModel'), + baseUrl: _userSetting(u, 'aiBaseUrl'), + }, process.env); + // A user's own endpoint is reached through the address check; the + // server's (AI_* env vars) is the owner's and is trusted as is. + if (cfg?.source === 'user') cfg.allowPrivate = ownerOrOptIn(req, 'ALLOW_PRIVATE_AI_URLS'); + return cfg; +} + +// The recipe an import would duplicate: case-insensitive name first, +// then source_url. Returns { id } or null. _saveImportedRecipe skips on +// a match (dedup 'skip'), and the routes report the match's id so the +// client can open it instead of a recipe that was never written. +function _findImportDuplicate(u, parsed) { + const lower = String(parsed?.name || '').toLowerCase().trim(); + let existing = lower ? db.prepare( + `SELECT id FROM recipes + WHERE ${_whereUser(u)} AND deleted_at IS NULL AND lower(name) = ? LIMIT 1` + ).get(...[..._userArgs(u), lower]) : null; + if (!existing && parsed?.source_url) { + existing = db.prepare( + `SELECT id FROM recipes + WHERE ${_whereUser(u)} AND deleted_at IS NULL AND source_url = ? LIMIT 1` + ).get(...[..._userArgs(u), parsed.source_url]); } - return null; + return existing || null; } // Shared writer used by /scrape, /import, and /import-file. Takes a @@ -1321,17 +1480,7 @@ function _saveImportedRecipe(u, parsed, opts = {}) { dedup = 'skip', } = opts; if (dedup !== 'force' && parsed?.name) { - const lower = String(parsed.name).toLowerCase().trim(); - let existing = db.prepare( - `SELECT id FROM recipes - WHERE ${_whereUser(u)} AND deleted_at IS NULL AND lower(name) = ? LIMIT 1` - ).get(...[..._userArgs(u), lower]); - if (!existing && parsed.source_url) { - existing = db.prepare( - `SELECT id FROM recipes - WHERE ${_whereUser(u)} AND deleted_at IS NULL AND source_url = ? LIMIT 1` - ).get(...[..._userArgs(u), parsed.source_url]); - } + const existing = _findImportDuplicate(u, parsed); if (existing) { if (dedup === 'skip') return null; // dedup === 'replace' falls through and gets re-inserted; the @@ -1375,6 +1524,7 @@ function _saveImportedRecipe(u, parsed, opts = {}) { } } const data = _toStorage(body); + data.ingredients = ownIngredientLinks(data.ingredients, u); if (!data.name) throw Object.assign(new Error('No recipe name found'), { status: 400 }); const result = db.prepare( @@ -1564,7 +1714,7 @@ function _saveZipImage(bytes, ext) { try { fs.mkdirSync(_UPLOADS_DIR, { recursive: true }); const safeExt = /^[a-z0-9]{2,5}$/.test(ext) ? ext : 'jpg'; - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${safeExt}`; + const filename = `${Date.now()}-${crypto.randomBytes(12).toString('hex')}.${safeExt}`; const filePath = path.join(_UPLOADS_DIR, filename); fs.writeFileSync(filePath, bytes); return `/uploads/${filename}`; @@ -1711,18 +1861,9 @@ router.post('/import-zip/commit', _zipImportUpload.single('file'), wrap(async (r // so timeline events still land on the existing recipe. let targetRecipeId = row?.id || null; if (!row) { - skippedDup.push({ idx: i, name: recipe?.name || `(item ${i})` }); - const lower = String(recipe?.name || '').toLowerCase().trim(); - if (lower) { - const existing = db.prepare( - `SELECT id FROM recipes - WHERE ${_whereUser(u)} AND deleted_at IS NULL AND lower(name) = ? LIMIT 1` - ).get(...[..._userArgs(u), lower]) || (recipe.source_url ? db.prepare( - `SELECT id FROM recipes - WHERE ${_whereUser(u)} AND deleted_at IS NULL AND source_url = ? LIMIT 1` - ).get(...[..._userArgs(u), recipe.source_url]) : null); - targetRecipeId = existing?.id || null; - } + const existing = _findImportDuplicate(u, recipe); + targetRecipeId = existing?.id || null; + skippedDup.push({ idx: i, name: recipe?.name || `(item ${i})`, existing_id: targetRecipeId }); } else { created.push(row); } @@ -1845,16 +1986,19 @@ router.post('/import', _importUpload.single('file'), wrap(async (req, res) => { try { recipes = await importPaprikaArchive(f.buffer); } catch (e) { return res.status(400).json({ error: e.message }); } const created = []; + const skipped = []; for (const r of recipes) { try { const row = _saveImportedRecipe(u, r, { addToPantry, applyTags, importCategories, creatorUsername }); - created.push(row); + // null = a duplicate that was skipped, not a recipe written. + if (row) created.push(row); + else skipped.push({ name: r?.name || '', existing_id: _findImportDuplicate(u, r)?.id || null }); } catch (e) { // Continue on per-recipe failure — most archives have many. } } - if (created.length === 0) return res.status(400).json({ error: 'No recipes could be imported' }); - return res.status(201).json({ recipes: created, count: created.length }); + if (created.length === 0 && skipped.length === 0) return res.status(400).json({ error: 'No recipes could be imported' }); + return res.status(created.length ? 201 : 200).json({ recipes: created, count: created.length, skipped }); } // Single recipe — text body or single-file upload. @@ -1874,6 +2018,10 @@ router.post('/import', _importUpload.single('file'), wrap(async (req, res) => { try { const row = _saveImportedRecipe(u, parsed, { addToPantry, applyTags, importCategories, creatorUsername }); + if (!row) { + const existing = _findImportDuplicate(u, parsed); + return res.status(200).json({ skipped: true, reason: 'duplicate', existing_id: existing?.id || null, name: parsed?.name || '' }); + } return res.status(201).json(row); } catch (e) { return res.status(e.status || 500).json({ error: e.message || 'Import failed' }); @@ -2211,8 +2359,15 @@ router.get('/:id/card.png', wrap(async (req, res) => { if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); const recipe = db.prepare(`SELECT * FROM recipes WHERE id = ? AND deleted_at IS NULL`).get(id); if (!recipe) return res.status(404).json({ error: 'Not found' }); - // Allow public access to group-shared recipes; require auth for private. - if (recipe.visibility !== 'group' && (!req.user || (recipe.user_id != null && recipe.user_id !== req.user.id))) { + // Who may see the card is who may see the recipe (GET /:id): its owner, + // anyone it is shared with, and everyone when it is group-visible. This + // used to differ: a shared recipe's card was refused, and a recipe with + // no owner gave its card to anyone signed in. + const u = uid(req); + const isOwner = (u == null && recipe.user_id == null) || recipe.user_id === u; + const isShared = !isOwner && u != null + && !!db.prepare(`SELECT 1 FROM recipe_shares WHERE recipe_id = ? AND grantee_id = ?`).get(id, u); + if (!isOwner && !isShared && recipe.visibility !== 'group') { return res.status(403).json({ error: 'Forbidden' }); } @@ -2227,10 +2382,15 @@ router.get('/:id/card.png', wrap(async (req, res) => { // SVG card — 600 × 800 portrait. Hero image embedded via <image> if we // can resolve to an absolute URL, else pure typography. - const safeName = String(recipe.name || 'Recipe').replace(/[<&]/g, ''); - const safeSub = subtitle.replace(/[<&]/g, ''); - const heroSection = recipe.img_url ? ` - <image href="${_absoluteUrl(req, recipe.img_url)}" x="0" y="0" width="600" height="500" preserveAspectRatio="xMidYMid slice" /> + // Escaped, not stripped: "Mac & Cheese" stays as typed, and nothing in a + // name or an image link can become markup. The card is an SVG served from + // this origin, so an unescaped quote in img_url used to add an event + // handler that ran as the app when the card was opened. + const name = String(recipe.name || 'Recipe'); + const safeSub = _escapeXml(subtitle); + const heroUrl = _absoluteUrl(req, recipe.img_url); + const heroSection = heroUrl ? ` + <image href="${_escapeXml(heroUrl)}" x="0" y="0" width="600" height="500" preserveAspectRatio="xMidYMid slice" /> <rect x="0" y="380" width="600" height="120" fill="url(#grad)" /> ` : ` <rect x="0" y="0" width="600" height="500" fill="#0A0B0F" /> @@ -2247,22 +2407,38 @@ router.get('/:id/card.png', wrap(async (req, res) => { </defs> <rect width="600" height="800" fill="#0A0B0F" /> ${heroSection} - <text x="40" y="560" font-family="system-ui, sans-serif" font-size="40" font-weight="800" fill="#FFFFFF">${_textWrap(safeName, 22)}</text> + <text x="40" y="560" font-family="system-ui, sans-serif" font-size="40" font-weight="800" fill="#FFFFFF">${_textWrap(name, 22)}</text> ${safeSub ? `<text x="40" y="660" font-family="system-ui, sans-serif" font-size="20" fill="#A1A8B8">${safeSub}</text>` : ''} <text x="40" y="760" font-family="system-ui, sans-serif" font-size="14" fill="#4FFFB0" font-weight="700" letter-spacing="2">COOKTRACE</text> </svg>`; res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=300'); + // The card can be someone's private recipe: no cache keeps it, as for the + // rest of /api. The policy stops any script in the SVG, as a backstop. + res.setHeader('Cache-Control', 'private, no-store'); + res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' https: http: data:; style-src 'unsafe-inline'; sandbox"); + res.setHeader('X-Content-Type-Options', 'nosniff'); res.send(svg); })); +// An image link the card may show: a web address, or one of this app's own +// paths, kept on this origin (the card is only served to someone signed in, +// so it is always opened from here). It used to be made absolute with the +// request's Host, which the sender controls. Anything else (javascript:, +// data:, //host) leaves the card without its picture. +const _BASE_PATH = (process.env.BASE_URL || '').replace(/\/$/, ''); function _absoluteUrl(req, path) { if (!path) return ''; - if (/^https?:\/\//.test(path)) return path; - const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; - const host = req.headers['x-forwarded-host'] || req.headers.host || ''; - return `${proto}://${host}${path.startsWith('/') ? '' : '/'}${path}`; + const p = String(path).trim(); + if (/^https?:\/\//i.test(p)) return p; + if (!p.startsWith('/') || p.startsWith('//')) return ''; + return _BASE_PATH && !p.startsWith(_BASE_PATH + '/') ? `${_BASE_PATH}${p}` : p; +} + +function _escapeXml(str) { + return String(str) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); } function _textWrap(text, perLine) { @@ -2278,7 +2454,7 @@ function _textWrap(text, perLine) { } if (cur) lines.push(cur); if (lines.length > 3) lines.length = 3; - return lines.map((l, i) => i === 0 ? l : `<tspan x="40" dy="48">${l}</tspan>`).join(''); + return lines.map((l, i) => i === 0 ? _escapeXml(l) : `<tspan x="40" dy="48">${_escapeXml(l)}</tspan>`).join(''); } // ── Comments ──────────────────────────────────────────────────────────── @@ -2407,9 +2583,7 @@ router.put('/:id/comments/:commentId', wrap((req, res) => { if (!body) return res.status(400).json({ error: 'body required' }); if (body.length > 4000) return res.status(400).json({ error: 'comment too long (4000 char max)' }); - db.prepare( - `UPDATE recipe_comments SET body = ?, updated_at = datetime('now') WHERE id = ?` - ).run(body, cid); + saveRow('recipe_comments', cid, c, { body }, req.body?._sync); const row = _selectCommentJoined(cid); res.json(_commentRow(row)); })); diff --git a/server/routes/settings.js b/server/routes/settings.js index 6c493ff2..9d402839 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -3,6 +3,25 @@ import db from '../db.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { isServerOnlyKey } from '../lib/server-only-keys.js'; +import { fetchChecked, serviceBase } from '../lib/ssrf-guard.js'; + +// Push services usually live on the home network, so that's allowed for +// every account; the address is still checked, and a redirect only +// followed on the same server. +const _sendPush = (url, init) => fetchChecked(url, init, { allowPrivate: true, maxRedirects: 3, sameOrigin: true }); + +// The service's own short error ("unauthorized"), never its raw reply. +async function _pushErrorMessage(resp) { + const raw = await resp.text().catch(() => ''); + let message = ''; + try { + const j = JSON.parse(raw); + for (const v of [j?.errorDescription, j?.error?.message, j?.error, j?.message]) { + if (typeof v === 'string' && v) { message = v; break; } + } + } catch {} + return message ? `: ${message.slice(0, 100)}` : ''; +} const router = Router(); router.use(requireAuth); @@ -116,7 +135,7 @@ async function _pushTestHandler(req, res) { const url = _s('gotifyUrl'); const token = _s('gotifyToken'); if (!url || !token) return res.status(400).json({ error: 'Gotify URL and token required — save settings first' }); - resp = await fetch(`${url.replace(/\/+$/, '')}/message?token=${encodeURIComponent(token)}`, { + resp = await _sendPush(`${serviceBase(url) ?? url}/message?token=${encodeURIComponent(token)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title || 'CookTrace', message: message || 'Test notification — connected!', priority: priority || 5 }), }); @@ -127,7 +146,7 @@ async function _pushTestHandler(req, res) { if (!topic) return res.status(400).json({ error: 'ntfy topic required' }); const headers = { 'Title': title || 'CookTrace', 'Priority': String(Math.min(5, priority || 5)) }; if (token) headers['Authorization'] = `Bearer ${token}`; - resp = await fetch(`${url.replace(/\/+$/, '')}/${encodeURIComponent(topic)}`, { + resp = await _sendPush(`${serviceBase(url) ?? url}/${encodeURIComponent(topic)}`, { method: 'POST', headers, body: message || 'Test notification — connected!', }); } else if (svc === 'apprise') { @@ -136,7 +155,7 @@ async function _pushTestHandler(req, res) { if (!url) return res.status(400).json({ error: 'Apprise URL required' }); const body = { title: title || 'CookTrace', body: message || 'Test notification — connected!', type: 'info' }; if (tag) body.tag = tag; - resp = await fetch(`${url.replace(/\/+$/, '')}/notify`, { + resp = await _sendPush(`${serviceBase(url) ?? url}/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } else { @@ -144,9 +163,9 @@ async function _pushTestHandler(req, res) { } if (!resp.ok) { - const body = await resp.text().catch(() => ''); - return res.status(resp.status).json({ error: `${svc} ${resp.status}: ${body.slice(0, 100)}` }); + return res.status(resp.status).json({ error: `${svc} ${resp.status}${await _pushErrorMessage(resp)}` }); } + try { await resp.body?.cancel(); } catch {} res.json({ ok: true }); } catch (e) { res.status(502).json({ error: `${svc}: ${e.message}` }); diff --git a/server/routes/share.js b/server/routes/share.js index d1b1d566..4bd4996a 100644 --- a/server/routes/share.js +++ b/server/routes/share.js @@ -11,11 +11,15 @@ import { wrap } from '../logger.js'; const router = Router(); -router.get('/:token', wrap((req, res) => { - const token = String(req.params.token || '').trim(); - if (!token || token.length < 8 || token.length > 64) { - return res.status(400).json({ error: 'Invalid token' }); - } +/** + * The recipe behind a public token, projected to a clean read-only view + * (no user_id, visibility, or anything else that could leak account + * details). Null when the token is unknown or removed, or the recipe was + * deleted. Also used for the link-preview tags on /r/<token>. + */ +export function getPublicRecipe(token) { + token = String(token || '').trim(); + if (!token || token.length < 8 || token.length > 64) return null; // Lookup is index-backed (idx_recipes_share_token). const row = db.prepare( `SELECT r.*, rc.name AS category_name, rc.slug AS category_slug, rc.color AS category_color, @@ -25,10 +29,8 @@ router.get('/:token', wrap((req, res) => { LEFT JOIN users u ON u.id = r.user_id WHERE r.share_token = ? AND r.deleted_at IS NULL` ).get(token); - if (!row) return res.status(404).json({ error: 'Not found' }); + if (!row) return null; - // Project a clean read-only view. We strip user_id, visibility, and - // anything else that could leak account details. const _safeJson = (s, fallback) => { if (!s) return fallback; try { return JSON.parse(s); } catch { return fallback; } @@ -47,7 +49,7 @@ router.get('/:token', wrap((req, res) => { return [{ name: '', items: ingredients }]; })(); - res.json({ + return { id: row.id, name: row.name, description: row.description, @@ -73,7 +75,18 @@ router.get('/:token', wrap((req, res) => { category: row.category_name ? { name: row.category_name, slug: row.category_slug, color: row.category_color, } : null, - }); + }; +} + +router.get('/:token', wrap((req, res) => { + res.set('X-Robots-Tag', 'noindex, nofollow'); + const token = String(req.params.token || '').trim(); + if (!token || token.length < 8 || token.length > 64) { + return res.status(400).json({ error: 'Invalid token' }); + } + const recipe = getPublicRecipe(token); + if (!recipe) return res.status(404).json({ error: 'Not found' }); + res.json(recipe); })); export default router; diff --git a/server/routes/shopping.js b/server/routes/shopping.js index c9299d8c..edfb48d8 100644 --- a/server/routes/shopping.js +++ b/server/routes/shopping.js @@ -8,10 +8,14 @@ */ import { Router } from 'express'; import db from '../db.js'; +import { stampFields } from '../lib/field-stamps.js'; +import { saveRow } from '../lib/rest-merge.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { dispatchWebhookEvent } from '../lib/webhooks.js'; import { titleCaseName as _titleCaseName, aisleForPantry as _aisleForPantry } from '../lib/shopping-items.js'; +import { ownId, linkableRecipeId } from '../lib/link-checks.js'; +import { cleanCreateKey, findByCreateKey, setCreateKey } from '../lib/create-keys.js'; const router = Router(); router.use(requireAuth); @@ -32,8 +36,9 @@ router.get('/', wrap((req, res) => { `SELECT s.*, p.name AS pantry_name, p.img_url AS pantry_img_url, r.name AS recipe_name FROM shopping_list s - LEFT JOIN pantry_items p ON p.id = s.pantry_id + LEFT JOIN pantry_items p ON p.id = s.pantry_id AND p.user_id IS s.user_id LEFT JOIN recipes r ON r.id = s.recipe_id AND r.deleted_at IS NULL + AND (r.user_id IS s.user_id OR EXISTS (SELECT 1 FROM recipe_shares rs WHERE rs.recipe_id = r.id AND rs.grantee_id = s.user_id)) WHERE ${userClause(u).replace(/user_id/g, 's.user_id')} AND s.deleted_at IS NULL ORDER BY s.checked ASC, COALESCE(s.aisle, 'zzz') ASC, @@ -47,6 +52,11 @@ router.get('/', wrap((req, res) => { // ── POST / — add an item ─────────────────────────────────────────────── router.post('/', wrap((req, res) => { const u = uid(req); + // Sent before (Connect > Upload again, or an answer lost): the row made + // then, not a second one (lib/create-keys.js). + const createKey = cleanCreateKey(req.body?.client_key); + const made = findByCreateKey('shopping_list', u, createKey); + if (made) return res.json(_hydrate(made)); const body = req.body || {}; const name = (body.name || '').toString().trim(); if (!name) return res.status(400).json({ error: 'Name is required' }); @@ -55,7 +65,11 @@ router.post('/', wrap((req, res) => { // a pantry item. Explicit body.aisle always wins so callers can force // a specific value; see _aisleForPantry for the fallback chain. let aisle = body.aisle && String(body.aisle).trim() ? String(body.aisle).trim() : null; - if (aisle == null && body.pantry_id) aisle = _aisleForPantry(body.pantry_id); + // Links only to the account's own pantry item, and its own or a shared + // recipe (lib/link-checks.js): anything else is no link. + const pantryId = ownId('pantry_items', body.pantry_id, u); + const recipeId = linkableRecipeId(body.recipe_id, u); + if (aisle == null && pantryId) aisle = _aisleForPantry(pantryId, u); const result = db.prepare( `INSERT INTO shopping_list (user_id, name, quantity, unit, aisle, checked, pantry_id, recipe_id) @@ -66,9 +80,10 @@ router.post('/', wrap((req, res) => { body.unit || null, aisle, body.checked ? 1 : 0, - body.pantry_id || null, - body.recipe_id || null, + pantryId, + recipeId, ); + setCreateKey('shopping_list', result.lastInsertRowid, createKey); const row = db.prepare(`SELECT * FROM shopping_list WHERE id = ?`).get(result.lastInsertRowid); res.status(201).json(_hydrate(row)); })); @@ -84,23 +99,18 @@ router.put('/:id', wrap((req, res) => { return res.status(403).json({ error: 'Forbidden' }); } const body = req.body || {}; - db.prepare( - `UPDATE shopping_list SET - name = ?, quantity = ?, unit = ?, aisle = ?, checked = ?, pantry_id = ?, sort_order = ?, - updated_at = datetime('now') - WHERE id = ?` - ).run( - body.name != null ? String(body.name).trim() || existing.name : existing.name, - body.quantity !== undefined ? (body.quantity === '' || body.quantity == null ? null : Number(body.quantity)) : existing.quantity, - body.unit !== undefined ? (body.unit || null) : existing.unit, - body.aisle !== undefined ? (body.aisle && String(body.aisle).trim() ? String(body.aisle).trim() : null) : existing.aisle, - body.checked !== undefined ? (body.checked ? 1 : 0) : existing.checked, - body.pantry_id !== undefined ? (body.pantry_id || null) : existing.pantry_id, - body.sort_order !== undefined ? (body.sort_order == null ? null : Number(body.sort_order)) : existing.sort_order, - id, - ); + // With _sync, merged with changes made elsewhere (lib/rest-merge.js). + const { kept } = saveRow('shopping_list', id, existing, { + name: body.name != null ? String(body.name).trim() || existing.name : existing.name, + quantity: body.quantity !== undefined ? (body.quantity === '' || body.quantity == null ? null : Number(body.quantity)) : existing.quantity, + unit: body.unit !== undefined ? (body.unit || null) : existing.unit, + aisle: body.aisle !== undefined ? (body.aisle && String(body.aisle).trim() ? String(body.aisle).trim() : null) : existing.aisle, + checked: body.checked !== undefined ? (body.checked ? 1 : 0) : existing.checked, + pantry_id: body.pantry_id !== undefined ? ownId('pantry_items', body.pantry_id, u) : existing.pantry_id, + sort_order: body.sort_order !== undefined ? (body.sort_order == null ? null : Number(body.sort_order)) : existing.sort_order, + }, body._sync); const row = db.prepare(`SELECT * FROM shopping_list WHERE id = ?`).get(id); - res.json(_hydrate(row)); + res.json({ ..._hydrate(row), ...(kept ? { kept: 'server' } : {}) }); })); // ── DELETE /checked — clear all checked items at once ────────────────── @@ -154,11 +164,18 @@ router.post('/reorder', wrap((req, res) => { const nextAisle = it.aisle !== undefined ? (it.aisle && String(it.aisle).trim() ? String(it.aisle).trim() : null) : existing.aisle; + // Moving an item is an edit only when its aisle changes: a new place + // in the list moves sort_order alone, stamped as changed now, and + // leaves updated_at (lib/field-merge.js merges field by field). + const aisleMoved = (existing.aisle ?? null) !== (nextAisle ?? null); + const sortMoved = (existing.sort_order ?? null) !== (nextSort ?? null); + if (!aisleMoved && !sortMoved) { n++; continue; } db.prepare( `UPDATE shopping_list - SET sort_order = ?, aisle = ?, updated_at = datetime('now') + SET sort_order = ?, aisle = ?${aisleMoved ? `, updated_at = datetime('now')` : ''} WHERE id = ?` ).run(nextSort, nextAisle, id); + if (sortMoved && !aisleMoved) stampFields('shopping_list', id, ['sort_order']); n++; } return n; @@ -191,8 +208,8 @@ router.patch('/:id/check', wrap((req, res) => { if ((u == null && existing.user_id != null) || (u != null && existing.user_id !== u)) { return res.status(403).json({ error: 'Forbidden' }); } - const next = req.body?.checked ? 1 : 0; - db.prepare(`UPDATE shopping_list SET checked = ?, updated_at = datetime('now') WHERE id = ?`).run(next, id); + saveRow('shopping_list', id, existing, { checked: req.body?.checked ? 1 : 0 }, req.body?._sync); + const next = db.prepare(`SELECT checked FROM shopping_list WHERE id = ?`).get(id).checked; // Only a genuine 0-to-1 transition can newly complete the list; a // redundant re-check of an already-checked item (double-click, retry, @@ -313,8 +330,10 @@ router.post('/from-plan', wrap((req, res) => { let added = 0; const tx = db.transaction(() => { for (const row of merged.values()) { - const aisle = _aisleForPantry(row.pantry_id); - insert.run(u, _titleCaseName(row.name), row.qty, row.unit, aisle, row.pantry_id, row.recipe_id); + // A shared recipe's ingredients point at its owner's pantry: no link. + const pantryId = ownId('pantry_items', row.pantry_id, u); + const aisle = _aisleForPantry(pantryId, u); + insert.run(u, _titleCaseName(row.name), row.qty, row.unit, aisle, pantryId, row.recipe_id); added++; } }); @@ -355,8 +374,10 @@ router.post('/from-recipe/:id', wrap((req, res) => { for (const it of flat) { if (!it.name) continue; if (onlyMissing && it.pantry_item_id && stockSet.has(it.pantry_item_id)) continue; - const aisle = _aisleForPantry(it.pantry_item_id); - insert.run(u, _titleCaseName(it.name), it.qty || null, it.unit || null, aisle, it.pantry_item_id || null, recipeId); + // A shared recipe's ingredients point at its owner's pantry: no link. + const pantryId = ownId('pantry_items', it.pantry_item_id, u); + const aisle = _aisleForPantry(pantryId, u); + insert.run(u, _titleCaseName(it.name), it.qty || null, it.unit || null, aisle, pantryId, recipeId); added++; } }); diff --git a/server/routes/sync.js b/server/routes/sync.js index 4c7db0e3..224cca26 100644 --- a/server/routes/sync.js +++ b/server/routes/sync.js @@ -11,13 +11,21 @@ * ports without surgery): * * POST /api/sync/push - * body: { tables: { [name]: [row, ...] }, settings: [{ key, value, updated_at }] } - * row shape: { client_id, server_id?, ...table-columns, updated_at, deleted_at } - * response: { tables: { [name]: [{ client_id, server_id }] } } + * body: { tables: { [name]: [row, ...] }, settings: [{ key, value, updated_at }], + * client_now?: ISO } + * row shape: { client_id, server_id?, ...table-columns, updated_at, deleted_at, + * recipes also: server_synced_at?, changed?: [field], base_meta? } + * response: { tables: { [name]: [{ client_id, server_id, synced_at?, updated_at?, kept? }] }, + * clock_offset_ms } * - * GET /api/sync/pull?since=<ISO> + * GET /api/sync/pull?since=<ISO>[&synced_at=1] * response: { now: 'ISO', tables: { [name]: [{ id, ...cols, updated_at, deleted_at }] } } * + * Two edits of one row: apps that send client_now are judged, and the newer + * edit stays (lib/sync-clock.js; recipes field by field, with the other + * copy kept as an earlier version: lib/recipe-versions.js). Older apps' + * edits go in as they always have. + * * Tables handled: recipes, pantry_items, cook_diary, shopping_list, * recipe_categories, pantry_categories, custom_units, cookbooks, * recipe_comments, ai_chat_history (structural) plus disabled_units + @@ -37,6 +45,19 @@ import { requireAuth, userMgmtActive } from '../middleware/auth.js'; import { isEmptyForGuard } from '../lib/recipe-guards.js'; import { autoShareNewRecipe } from '../lib/auto-share.js'; import { dispatchWebhookEvent } from '../lib/webhooks.js'; +import { mapSmartFilterCategory } from '../lib/smart-cookbook.js'; +import { saveRecipeVersion, mergeRecipe, sameContent, VERSION_FIELDS } from '../lib/recipe-versions.js'; +import { mergeFields } from '../lib/field-merge.js'; +import { SYNC_FIELDS, SYNC_GROUPS } from '../lib/sync-fields.js'; +import { stampFields } from '../lib/field-stamps.js'; +import { repairVariantTree } from '../lib/pantry-tree.js'; +import { clockOffset, editTime, latestTime, utcMs } from '../lib/sync-clock.js'; +import { cleanCreateKey, findByCreateKey, setCreateKey } from '../lib/create-keys.js'; +import { ownIngredientLinks } from '../lib/link-checks.js'; +import { localizeRowPhotos } from '../lib/inline-photos.js'; + +// Unique per account (server/db.js), so one made on two devices is one row. +const NATURAL_KEYS = { recipe_categories: 'slug', pantry_categories: 'slug', cookbooks: 'slug', custom_units: 'abbr' }; // Option E guard (2026-08-11): the recipe UPDATE path replaces nested // JSON fields (ingredients/steps/tags/tools/nutrition) wholesale. A @@ -108,8 +129,12 @@ const TABLES = { 'name', 'description', 'img_url', 'servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', 'ingredients', 'steps', 'tags', 'tools', 'source_url', 'video_url', 'notes', 'visibility', 'rating', 'yield_text', 'last_cooked_at', 'cook_count', - 'nutrition', 'favorite', 'category_id', 'share_token', + 'nutrition', 'favorite', 'category_id', ], + // Sent to devices so they know a public link exists, never taken from + // them: only POST/DELETE /api/recipes/:id/share set it. A device still + // holding a removed link would otherwise bring it back with its next edit. + pullCols: ['share_token'], parents: { category_id: 'recipe_categories' }, softDelete: true, }, @@ -143,7 +168,7 @@ const TABLES = { }, recipe_comments: { cols: ['recipe_id', 'parent_id', 'body'], - parents: { recipe_id: 'recipes' }, + parents: { recipe_id: 'recipes', parent_id: 'recipe_comments' }, softDelete: true, }, ai_chat_history: { @@ -153,6 +178,17 @@ const TABLES = { }, }; +// The fields lib/field-merge.js merges are exactly the columns a device +// writes (plus deleted_at): a column added to one and not the other fails +// here, at start, rather than merging the wrong fields. +for (const [t, spec] of Object.entries(TABLES)) { + const want = [...spec.cols, ...(spec.softDelete ? ['deleted_at'] : [])].join(','); + if ((SYNC_FIELDS[t] || []).join(',') !== want) throw new Error(`lib/sync-fields.js is out of step with routes/sync.js for ${t}`); +} + +// Tables a device deletes rows from outright (no deleted_at column). +const DELETABLE = ['recipe_categories', 'pantry_categories', 'custom_units', 'ai_chat_history']; + // Process tables in dependency order so parents land first within a // single push and child FKs can resolve against the freshly-minted ids. const PUSH_ORDER = [ @@ -166,6 +202,20 @@ const PUSH_ORDER = [ router.post('/push', wrap((req, res) => { const u = uid(req); const tables = req.body?.tables || {}; + // Apps that send fk_ids: 'server' put the server's own ids in id + // columns, apart from those listed in a row's _local_fks. Older apps + // send the phone's ids. + const serverIds = req.body?.fk_ids === 'server'; + // How far the device's clock is behind the server's, so its edit times + // compare with the server's. Apps that say what time it is on them can be + // judged: the newer edit stays. Older apps' edits go in as before. + const offsetMs = clockOffset(req.body?.client_now, req.receivedAt); + const modern = typeof req.body?.client_now === 'string' && Number.isFinite(utcMs(req.body.client_now)); + // Recipes whose cooks changed in this push: their cook counts are + // counted again from the diary afterwards, as the REST routes do. + const cooksChanged = new Set(); + // Pantry items written in this push: their variant tree is checked after. + const pantryTouched = new Set(); const idMaps = {}; // tableName → { client_id: server_id } const results = {}; @@ -178,8 +228,13 @@ router.post('/push', wrap((req, res) => { for (const name of PUSH_ORDER) { if (!Array.isArray(tables[name])) { results[name] = []; continue; } const spec = TABLES[name]; - const rows = tables[name]; + // A photo taken with no connection comes embedded in its row: it + // becomes a file first, as the REST routes do (lib/inline-photos.js), + // so what's stored and merged is its path. + const rows = tables[name].map(r => localizeRowPhotos(name, r)); idMaps[name] = idMaps[name] || {}; + // A table that fails rolls back, and so do the ids it handed out. + const idsBefore = { ...idMaps[name] }; results[name] = []; const insertSql = _buildInsertSql(name, spec); @@ -189,10 +244,34 @@ router.post('/push', wrap((req, res) => { const txn = db.transaction(() => { for (const row of rows) { - const translated = _translateParents(row, spec, idMaps); + const translated = _translateParents(row, spec, idMaps, u, serverIds); + if (!translated) continue; // its parent didn't go in; the app sends it again next sync + if (name === 'cookbooks') _translateFilterCategory(translated, idMaps, serverIds); let values = spec.cols.map(c => _coerce(translated[c])); + // An ingredient links only to the account's own pantry items + // (lib/link-checks.js): the app sends the server's ids for them. + if (name === 'recipes') { + const at = spec.cols.indexOf('ingredients'); + if (at > -1 && values[at] != null) values[at] = ownIngredientLinks(values[at], u); + } const val = (col) => values[spec.cols.indexOf(col)]; const deleted = spec.softDelete && translated.deleted_at != null; + // A row the app made, sent before (two syncs at once, a retry, an + // answer lost on the way back): the one made then, as an edit of it. + const createKey = row.server_id ? null : cleanCreateKey(row.client_key); + if (createKey) { + const made = findByCreateKey(name, u, createKey); + if (made) row.server_id = made.id; + } + // A name the account already has (a category, cookbook or unit made + // on two devices, or kept on the phone through a reconnect): that + // one, as an edit of it. A second would break the unique name and + // stop every push of the table. + const natural = NATURAL_KEYS[name]; + if (!row.server_id && natural && translated[natural] != null) { + const same = db.prepare(`SELECT id FROM ${name} WHERE ${userClause(u)} AND ${natural} = ?`).get(...userArgs(u), translated[natural]); + if (same) row.server_id = same.id; + } if (row.server_id) { // Fetch enough of the existing row to (a) authorize the write @@ -205,13 +284,88 @@ router.post('/push', wrap((req, res) => { if (name === 'recipes') { values = _guardRecipeValuesForUpdate(values, spec, existing); } + // The edit's time on the server's clock. Apps that stamp edits with + // the server's clock as they're made say so (edit_clock); older + // rows are moved by the offset measured on this push. A delete on + // an older phone doesn't move updated_at: the delete is the edit. + const corrected = row.edit_clock === 'server'; + const editedAt = editTime( + !corrected && translated.deleted_at != null && existing.deleted_at == null + ? latestTime(translated.updated_at, translated.deleted_at) : translated.updated_at, + corrected ? 0 : offsetMs); + if (modern) { + // Field by field (lib/field-merge.js): what the app says it + // changed on the copy it had (changed, server_synced_at). + const incoming = Object.fromEntries(spec.cols.map((c, i) => [c, values[i]])); + if (spec.softDelete) incoming.deleted_at = translated.deleted_at ?? null; + const opts = { + base: row.server_synced_at, + changed: Array.isArray(row.changed) ? new Set(row.changed) : null, + editedAt, + }; + const m = name === 'recipes' + ? mergeRecipe(existing, incoming, opts) + : mergeFields(existing, incoming, { ...opts, fields: SYNC_FIELDS[name], groups: SYNC_GROUPS[name] }); + for (const k of m.keep || []) { + saveRecipeVersion(row.server_id, existing.user_id, k.row, { + reason: k.reason, + editedBy: k.side === 'incoming' ? (u ?? existing.user_id) : (existing.last_edited_by ?? existing.user_id), + }); + } + if (m.write) { + const cols = m.applied; + // The owner's own edit: content that goes in is theirs. + const byOwner = name === 'recipes' && cols.some(c => VERSION_FIELDS.includes(c)); + db.prepare( + `UPDATE ${name} SET ${cols.map(c => `${c} = ?`).join(', ')}, updated_at = ?${byOwner ? ', last_edited_by = NULL' : ''} WHERE id = ?` + ).run(...cols.map(c => m.row[c] ?? null), m.updatedAt, row.server_id); + stampFields(name, row.server_id, cols, m.fieldAt); + } else if (m.devicePulls) { + // Nothing of the edit stays: the copy here is stamped again so + // the device's next pull brings it down. Its edit times stay. + db.prepare(`UPDATE ${name} SET synced_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?`).run(row.server_id); + } + results[name].push({ + client_id: row.client_id, server_id: row.server_id, + ...(m.write ? { ..._syncedAt(name, row.server_id), updated_at: m.updatedAt } : {}), + // What the device has isn't what stays here: it pulls it down. + ...(m.devicePulls ? { kept: 'server' } : {}), + }); + idMaps[name][row.client_id] = row.server_id; + if (m.write && !deleted) { + if (name === 'cook_diary' && existing.kind !== 'cooked' && m.row.kind === 'cooked') { + tableEvents.push(_mealCookedEvent(c => (c in m.row ? m.row[c] : existing[c]))); + } else if (name === 'pantry_items' && _on(existing.in_stock) && !_on(m.row.in_stock)) { + tableEvents.push(['pantry.out_of_stock', { pantry_item_id: row.server_id, name: m.row.name ?? existing.name }]); + } else if (name === 'shopping_list' && !_on(existing.checked) && _on(m.row.checked)) { + tableNewlyChecked = true; + } + } + if (name === 'cook_diary' && m.write) { cooksChanged.add(existing.recipe_id); cooksChanged.add(m.row.recipe_id); } + if (name === 'pantry_items' && m.write) pantryTouched.add(row.server_id); + continue; + } + // Older apps can't say what their copy was or when, by the + // server's clock: their edit goes in, as before. For a recipe, + // the copy it replaces is kept, without pointing at it, in case + // it was someone else's newer edit. + if (name === 'recipes' && existing.deleted_at == null && translated.deleted_at == null) { + const incoming = Object.fromEntries(spec.cols.map((c, i) => [c, values[i]])); + incoming.category_id ??= existing.category_id; // as the UPDATE's COALESCE + if (!sameContent(incoming, existing)) { + saveRecipeVersion(row.server_id, existing.user_id, existing, { reason: 'replaced', editedBy: existing.last_edited_by ?? existing.user_id }); + } + } + // deleted_at only on tables that have one (categories and + // units don't), or the statement has one value too many. db.prepare(updateSql).run( ...values, - translated.updated_at || _now(), - spec.softDelete ? (translated.deleted_at ?? null) : null, + editedAt, + ...(spec.softDelete ? [translated.deleted_at ?? null] : []), row.server_id ); - results[name].push({ client_id: row.client_id, server_id: row.server_id }); + // The edit time stored, for the device to know this row's echo. + results[name].push({ client_id: row.client_id, server_id: row.server_id, ..._syncedAt(name, row.server_id), updated_at: editedAt }); idMaps[name][row.client_id] = row.server_id; // Same transitions the REST routes fire on. The Android app // saves through this push, so without these its changes never @@ -225,15 +379,20 @@ router.post('/push', wrap((req, res) => { tableNewlyChecked = true; } } + if (name === 'cook_diary') { cooksChanged.add(existing.recipe_id); cooksChanged.add(val('recipe_id')); } } else { + const insertedAt = editTime(translated.updated_at, row.edit_clock === 'server' ? 0 : offsetMs); const info = db.prepare(insertSql).run( u, ...values, - translated.updated_at || _now(), - spec.softDelete ? (translated.deleted_at ?? null) : null + insertedAt, + ...(spec.softDelete ? [translated.deleted_at ?? null] : []) ); const serverId = info.lastInsertRowid; - results[name].push({ client_id: row.client_id, server_id: serverId }); + setCreateKey(name, serverId, createKey); + results[name].push({ client_id: row.client_id, server_id: serverId, ..._syncedAt(name, serverId), updated_at: insertedAt }); + if (name === 'cook_diary') cooksChanged.add(val('recipe_id')); + if (name === 'pantry_items' && modern) pantryTouched.add(serverId); idMaps[name][row.client_id] = serverId; // Auto-share fan-out for native-created recipes. The REST // POST /api/recipes route calls this same helper; without @@ -255,9 +414,28 @@ router.post('/push', wrap((req, res) => { webhookEvents.push(...tableEvents); if (tableNewlyChecked) shoppingNewlyChecked = true; } - catch (e) { results[name] = { error: e.message || 'push failed' }; } + catch (e) { results[name] = { error: e.message || 'push failed' }; idMaps[name] = idsBefore; } } + // Two devices' variant links, each fine alone, can break the tree's rules + // together: the newer stays (lib/pantry-tree.js). + if (pantryTouched.size) { + try { db.transaction(() => repairVariantTree([...pantryTouched]))(); } catch (e) { console.warn('[sync] variant tree:', e?.message); } + } + + // Cook counts follow the diary, as when cooks are logged on the web: a + // count sent by a device could be older than cooks logged since. + try { + const own = db.prepare(`SELECT 1 FROM recipes WHERE id = ? AND ${userClause(u)}`); + const stats = db.prepare(`SELECT COUNT(*) AS n, MAX(date) AS last FROM cook_diary WHERE recipe_id = ? AND deleted_at IS NULL AND kind = 'cooked'`); + const set = db.prepare(`UPDATE recipes SET cook_count = ?, last_cooked_at = ? WHERE id = ? AND (cook_count IS NOT ? OR last_cooked_at IS NOT ?)`); + for (const id of cooksChanged) { + if (id == null || !own.get(id, ...userArgs(u))) continue; + const st = stats.get(id); + set.run(st.n || 0, st.last || null, id, st.n || 0, st.last || null); + } + } catch (e) { console.warn('[sync] cook counts:', e?.message); } + // ── disabled_units: replace-by-set ──────────────────────────────── // // Option E guard: only DELETE-then-INSERT when the client has actually @@ -274,13 +452,47 @@ router.post('/push', wrap((req, res) => { try { txn(); } catch {} } + // ── Deletes from the device ───────────────────────────────────────── + // Rows the device removed outright, by server id: categories, units + // and chat (no deleted_at), and links taken out of a cookbook (the + // link push only adds). Applied before the links below, so a link + // taken out and put back in on the phone ends up in. Same effect as + // the REST deletes: a + // category's recipes and pantry items keep going, without it. + const deletes = req.body?.deletes; + const deleted = {}; + if (serverIds && deletes && typeof deletes === 'object') { + const ids = v => (Array.isArray(v) ? v : []).map(n => parseInt(n, 10)).filter(Number.isFinite); + const txn = db.transaction(() => { + for (const t of DELETABLE) { + const list = ids(deletes[t]); + const del = db.prepare(`DELETE FROM ${t} WHERE id = ? AND ${userClause(u)}`); + for (const id of list) del.run(id, ...userArgs(u)); + if (list.length) deleted[t] = list.length; + } + const links = Array.isArray(deletes.recipe_cookbook_links) ? deletes.recipe_cookbook_links : []; + const own = db.prepare(`SELECT 1 FROM cookbooks WHERE id = ? AND ${userClause(u)}`); + const del = db.prepare(`DELETE FROM recipe_cookbook_links WHERE cookbook_id = ? AND recipe_id = ?`); + for (const l of links) { + const cb = parseInt(l?.cookbook_id, 10), r = parseInt(l?.recipe_id, 10); + if (Number.isFinite(cb) && Number.isFinite(r) && own.get(cb, ...userArgs(u))) del.run(cb, r); + } + if (links.length) deleted.recipe_cookbook_links = links.length; + }); + // Kept by the device until the server answers `deleted`. + try { txn(); } catch (e) { for (const k of Object.keys(deleted)) delete deleted[k]; console.warn('[sync] deletes failed:', e?.message); } + } + // ── recipe_cookbook_links: translate FKs, replace per cookbook ──── if (Array.isArray(tables.recipe_cookbook_links)) { - const translated = tables.recipe_cookbook_links.map(r => ({ - cookbook_id: idMaps.cookbooks?.[r.cookbook_id] || r.cookbook_id, - recipe_id: idMaps.recipes?.[r.recipe_id] || r.recipe_id, - sort_order: r.sort_order ?? 0, - })).filter(r => r.cookbook_id && r.recipe_id); + // Same rules as the rows above: ids are mapped, and a link only goes + // into this account's own cookbook, to a recipe it may add there + // (its own, or one shared with it, as POST /api/cookbooks/:id/recipes). + const linkSpec = { parents: { cookbook_id: 'cookbooks', recipe_id: 'recipes' } }; + const translated = tables.recipe_cookbook_links + .map(r => _translateParents({ ...r }, linkSpec, idMaps, u, serverIds, { recipes: _recipeLinkable, cookbooks: _ownsRow })) + .filter(r => r && r.cookbook_id && r.recipe_id) + .map(r => ({ cookbook_id: r.cookbook_id, recipe_id: r.recipe_id, sort_order: r.sort_order ?? 0 })); // Option E guard (2026-08-11): additive-only. Prior behavior // DELETE-then-INSERTed all links for every cookbook in the // payload, so a stale client whose local cache had fewer links @@ -290,8 +502,12 @@ router.post('/push', wrap((req, res) => { // sync push only adds. Insertion order per link is preserved via // sort_order on the row. const txn = db.transaction(() => { - const ins = db.prepare( - `INSERT OR IGNORE INTO recipe_cookbook_links (cookbook_id, recipe_id, sort_order) VALUES (?, ?, ?)` + // A link already there takes the device's order: devices that send + // fk_ids send only links added or reordered on them. + const ins = db.prepare(serverIds + ? `INSERT INTO recipe_cookbook_links (cookbook_id, recipe_id, sort_order) VALUES (?, ?, ?) + ON CONFLICT(cookbook_id, recipe_id) DO UPDATE SET sort_order = excluded.sort_order` + : `INSERT OR IGNORE INTO recipe_cookbook_links (cookbook_id, recipe_id, sort_order) VALUES (?, ?, ?)` ); for (const r of translated) ins.run(r.cookbook_id, r.recipe_id, r.sort_order); }); @@ -315,7 +531,7 @@ router.post('/push', wrap((req, res) => { try { txn(); } catch {} } - res.json({ tables: results }); + res.json({ tables: results, deleted, clock_offset_ms: offsetMs }); try { for (const [event, data] of webhookEvents) dispatchWebhookEvent(u, event, data); @@ -353,7 +569,15 @@ function _mealCookedEvent(val) { router.get('/pull', wrap((req, res) => { const u = uid(req); const since = (typeof req.query.since === 'string' && req.query.since) || '1970-01-01T00:00:00'; - const now = _now(); + // Apps that keep which copy of each row they have ask for its stamp. + // Older apps write every key of a row into their own table, so it's + // only sent when asked for. + const withSyncedAt = req.query.synced_at === '1'; + // Server time, taken before the queries, so a write racing this pull + // is picked up by the next one (>= below makes an overlap harmless: + // pulls are upserts). Rows are picked by synced_at, the server's + // time of the write, not updated_at, the device's time of the edit. + const now = new Date().toISOString().replace('T', ' ').replace('Z', ''); const out = {}; for (const [name, spec] of Object.entries(TABLES)) { @@ -362,8 +586,11 @@ router.get('/pull', wrap((req, res) => { // column and SQLite's local `DEFAULT (datetime('now'))` stamps // every synced row with the pull-time clock — every recipe ends // up looking like it was created on first-connect day. - const cols = ['id', ...spec.cols, 'created_at', 'updated_at']; + const cols = ['id', ...spec.cols, ...(spec.pullCols || []), 'created_at', 'updated_at']; if (spec.softDelete) cols.push('deleted_at'); + // And the key the app made a row with (lib/create-keys.js), so a row + // whose push answer was lost is known as its own when it comes down. + if (withSyncedAt) cols.push('synced_at', 'client_key'); // Sort self-referencing tables so parents come before children in // the pull payload. The client's dbApplyPull scans server_id → // local_id fresh for each row, so a parent that arrives before its @@ -378,7 +605,7 @@ router.get('/pull', wrap((req, res) => { const orderBy = selfRef ? ` ORDER BY ${selfRef[0]} ASC, id ASC` : ''; out[name] = db.prepare( `SELECT ${cols.join(', ')} FROM ${name} - WHERE ${userClause(u)} AND updated_at > ?${orderBy}` + WHERE ${userClause(u)} AND synced_at >= ?${orderBy}` ).all(...userArgs(u), since); } @@ -393,10 +620,22 @@ router.get('/pull', wrap((req, res) => { WHERE ${userClause(u).replace(/user_id/g, 'c.user_id')}` ).all(...userArgs(u)); + // Rows deleted outright since the last pull, for the device to drop. + out.deletions = {}; + // Ids never come back in normal use (AUTOINCREMENT); one that exists + // again was put back by a restore, so it isn't gone. + for (const r of db.prepare( + `SELECT table_name, row_id FROM sync_deletions WHERE ${userClause(u)} AND synced_at >= ?` + ).all(...userArgs(u), since)) { + if (!DELETABLE.includes(r.table_name)) continue; + if (db.prepare(`SELECT 1 FROM ${r.table_name} WHERE id = ?`).get(r.row_id)) continue; + (out.deletions[r.table_name] ||= []).push(r.row_id); + } + // Settings: only the keys that changed since the last pull. out.settings = db.prepare( `SELECT key, value, updated_at FROM user_settings - WHERE ${userClause(u)} AND updated_at > ?` + WHERE ${userClause(u)} AND synced_at >= ?` ).all(...userArgs(u), since); res.json({ now, tables: out }); @@ -406,28 +645,70 @@ router.get('/pull', wrap((req, res) => { function _now() { return new Date().toISOString().replace('T', ' ').slice(0, 19); } +// The server's stamp of a row just written, for the device to keep as the +// copy it has. +function _syncedAt(name, id) { + const r = db.prepare(`SELECT synced_at FROM ${name} WHERE id = ?`).get(id); + return r?.synced_at ? { synced_at: r.synced_at } : {}; +} + + function _coerce(v) { if (v === undefined) return null; if (typeof v === 'object' && v !== null) return JSON.stringify(v); return v; } -function _translateParents(row, spec, idMaps) { +// Whether account u may point a row at this parent. +function _ownsRow(table, id, u) { + const p = db.prepare(`SELECT user_id FROM ${table} WHERE id = ?`).get(id); + return !!p && ((u == null && p.user_id == null) || (u != null && p.user_id === u)); +} +// A recipe: its own, or one shared with it (comments, cooked entries and +// shopping items can be about a shared recipe). +function _recipeLinkable(table, id, u) { + if (_ownsRow(table, id, u)) return true; + return u != null && !!db.prepare( + `SELECT 1 FROM recipe_shares WHERE recipe_id = ? AND grantee_id = ?` + ).get(id, u); +} +const PARENT_CHECKS = { recipes: _recipeLinkable }; + +// Id columns in a pushed row, as the server's ids. A parent created in +// the same push is mapped from the app's id. Any other id must be a row +// this account may point at, or it's dropped: an id from the phone's own +// numbering (older apps) or another account's row never links across. +// Returns null when a parent created in this push didn't go in, so the +// row waits for the next sync. +function _translateParents(row, spec, idMaps, u, serverIds, checks = PARENT_CHECKS) { if (!spec.parents) return row; const out = { ...row }; + const local = new Set(Array.isArray(row._local_fks) ? row._local_fks : []); for (const [fk, parentTable] of Object.entries(spec.parents)) { const raw = out[fk]; if (raw == null) continue; - const map = idMaps[parentTable]; - if (map && map[raw]) out[fk] = map[raw]; - // else: leave as-is. If the FK matches an existing server row it'll - // resolve; otherwise the column either accepts NULL via ON DELETE - // SET NULL semantics or surfaces a constraint error the client - // retries on next sync. + const mapped = idMaps[parentTable]?.[raw]; + if (local.has(fk)) { + if (!mapped) return null; + out[fk] = mapped; + continue; + } + // Older apps: a parent from this same push is still found by its + // app id, as before. + if (!serverIds && mapped) { out[fk] = mapped; continue; } + const ok = (checks[parentTable] || _ownsRow)(parentTable, raw, u); + if (!ok) out[fk] = null; } return out; } +// A smart cookbook's category, sent as the app's id when the category +// went up in this same push. +function _translateFilterCategory(row, idMaps, serverIds) { + if (!row._local_filter_category && serverIds) return; + row.smart_filter_json = mapSmartFilterCategory(row.smart_filter_json, id => idMaps.recipe_categories?.[id]); +} + function _buildInsertSql(table, spec) { const cols = ['user_id', ...spec.cols, 'updated_at']; if (spec.softDelete) cols.push('deleted_at'); diff --git a/server/routes/units.js b/server/routes/units.js index 55bb01ae..d0e9f918 100644 --- a/server/routes/units.js +++ b/server/routes/units.js @@ -10,6 +10,7 @@ */ import { Router } from 'express'; import db from '../db.js'; +import { saveRow } from '../lib/rest-merge.js'; import { wrap } from '../logger.js'; import { requireAuth, userMgmtActive } from '../middleware/auth.js'; @@ -28,7 +29,7 @@ router.get('/', wrap((req, res) => { `SELECT abbr FROM disabled_units WHERE ${userClause(u)}` ).all(...userArgs(u)).map(r => r.abbr); const custom = db.prepare( - `SELECT id, abbr, full_name, category, sort_order + `SELECT id, abbr, full_name, category, sort_order, synced_at FROM custom_units WHERE ${userClause(u)} ORDER BY sort_order ASC, abbr ASC` @@ -108,7 +109,7 @@ router.post('/', wrap((req, res) => { VALUES (?, ?, ?, ?, ?)` ).run(u, abbr, full_name, category, maxOrder + 1); const row = db.prepare( - `SELECT id, abbr, full_name, category, sort_order FROM custom_units WHERE id = ?` + `SELECT id, abbr, full_name, category, sort_order, synced_at FROM custom_units WHERE id = ?` ).get(result.lastInsertRowid); res.status(201).json(row); })); @@ -129,13 +130,9 @@ router.put('/:id', wrap((req, res) => { ? (req.body.category ? String(req.body.category).trim() : null) : existing.category; - db.prepare( - `UPDATE custom_units - SET abbr = ?, full_name = ?, category = ?, updated_at = datetime('now') - WHERE id = ?` - ).run(abbr, full_name, category, id); + saveRow('custom_units', id, existing, { abbr, full_name, category }, req.body?._sync); const row = db.prepare( - `SELECT id, abbr, full_name, category, sort_order FROM custom_units WHERE id = ?` + `SELECT id, abbr, full_name, category, sort_order, synced_at FROM custom_units WHERE id = ?` ).get(id); res.json(row); })); diff --git a/server/routes/upload.js b/server/routes/upload.js index c78620c8..97b4ecec 100644 --- a/server/routes/upload.js +++ b/server/routes/upload.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import path from 'path'; +import crypto from 'crypto'; import fs from 'fs'; import { safeUploadExtension } from '../lib/upload-paths.js'; import { requireAuth } from '../middleware/auth.js'; @@ -17,7 +18,7 @@ const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsPath), filename: (req, file, cb) => { const ext = safeUploadExtension(file.mimetype, file.originalname); - cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`); + cb(null, `${Date.now()}-${crypto.randomBytes(12).toString('hex')}${ext}`); }, }); diff --git a/src/App.svelte b/src/App.svelte index 142c127c..77886622 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,12 +18,13 @@ import Toast from './components/ui/Toast.svelte'; import ConfirmDialogMount from './components/ui/ConfirmDialogMount.svelte'; import { DB } from './lib/db.js'; - import { navStyle, applyAccentColor, accentColor, applyAppearance, appearance, disableAnimations, sidebarPersistent, language, pageBanners, bannerStyle, bannerAnimation, forceMobileLayout } from './stores/settings.js'; + import { navStyle, applyAccentColor, accentColor, applyAppearance, appearance, disableAnimations, sidebarPersistent, language, pageBanners, bannerStyle, bannerAnimation, forceMobileLayout, reloadSettingStores } from './stores/settings.js'; import { _, locale } from 'svelte-i18n'; import { currentUser, userMgmtActive, setupRequired, loadAuthState, handleOidcCallback } from './stores/auth.js'; - import { needsNativeSetup, isNative, getNativeMode, getServerUrl, apiUrl } from './lib/platform.js'; - import { writable } from 'svelte/store'; + import { needsNativeSetup, isNative, getNativeMode, getServerUrl, apiUrl, getAuthToken } from './lib/platform.js'; + import { writable, get as getStore } from 'svelte/store'; import { describeConnectionIssue } from './lib/connection-message.js'; + import { accountGate, ensureLocalAccount, accountReadyFor } from './lib/local-account.js'; // Sync state — mirrored from the real sync store (dynamically imported). // Includes the smart-banner fields (connectionIssue, showErrorBanner) so @@ -423,7 +424,8 @@ } } }); - // Deep link callbacks: cooktrace://oidc-callback?token=… + // Deep link callbacks: cooktrace://oidc-callback?code=… (or ?token=… + // from servers older than the single-use code hand-off) App.addListener('appUrlOpen', async ({ url }) => { console.log('[app] deep link received:', url); try { @@ -433,9 +435,22 @@ if (host === 'oidc-callback') { const errMsg = params.get('error'); const linked = params.get('linked'); - const token = params.get('token'); - const idTokenHint = params.get('id_token_hint'); - const providerId = params.get('provider_id'); + let token = params.get('token'); + let idTokenHint = params.get('id_token_hint'); + let providerId = params.get('provider_id'); + const code = params.get('code'); + if (code && !errMsg) { + try { + const { redeemHandoff } = await import('./lib/oidc-app-handoff.js'); + const data = await redeemHandoff(code); + token = data.token; + idTokenHint = data.id_token_hint || null; + providerId = data.provider_id != null ? String(data.provider_id) : null; + } catch (e) { + import('./stores/toast.js').then(({ showError }) => showError(e?.message || 'Sign-in failed')); + return; + } + } if (errMsg) { import('./stores/toast.js').then(({ showError }) => showError(decodeURIComponent(errMsg))); } else if (linked) { @@ -478,22 +493,14 @@ }); } - await loadAuthState(); + try { await loadAuthState(); } finally { authLoaded = true; } await handleOidcCallback(); // Env-lock state for AI / SMTP / OIDC. Fetched globally so the Trace // FAB knows about env-set AI_ENABLED without waiting for Settings to - // load. Mirrors NutriTrace #36. - if (!isNative || getServerUrl()) { - fetch(apiUrl('/api/app-config/env-locks'), { credentials: 'include' }) - .then(r => r.ok ? r.json() : null) - .then(async d => { - if (!d) return; - const { envLocks } = await import('./stores/settings.js'); - envLocks.set(d); - }) - .catch(() => {}); - } + // load. Mirrors NutriTrace #36. Signed out it would only be refused; + // signing in loads it (see _wasNeedsLogin below). + if (!($userMgmtActive && !$currentUser)) loadEnvLocks(); // Wizard gate. The web "no user + no user management" case is fully // covered by $setupRequired (the server distinguishes a fresh install @@ -565,14 +572,63 @@ } }); + /** + * Which sections the server holds by environment variable, Trace included. + * Sends the Android app's token: cookies alone were refused there, which + * left Trace looking unconfigured even with AI_* set on the server. + */ + async function loadEnvLocks() { + if (isNative && !getServerUrl()) return; + try { + const headers = {}; + const token = isNative ? getAuthToken() : null; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(apiUrl('/api/app-config/env-locks'), { credentials: 'include', headers }); + if (!res.ok) return; + const { envLocks } = await import('./stores/settings.js'); + envLocks.set(await res.json()); + } catch { /* defaults stay: Trace waits for a key in Settings */ } + } + const AUTH_BYPASS = ['/forgot-password', '/reset-password', '/accept-invite']; + // The web app learns who is signed in from the server. Until it knows, + // nothing renders: the app used to load first and fire its requests + // signed out, before the sign-in screen replaced it. Android starts from + // the account it cached, so it never waits. + let authLoaded = isNative; $: needsLogin = $userMgmtActive && !$currentUser && !AUTH_BYPASS.includes($location); + // Android, server mode: the phone's copy of the data must be this + // account's before the app shows any of it (lib/local-account.js). A + // different account than last time clears the copy; if that account + // left changes that never went up, the person is asked first, and + // saying no signs them back out. Every sign-in path ends by setting + // $currentUser, so this is the one place that sees them all. Keyed on + // the id, so a refreshed user object doesn't ask again; the gate itself + // runs one check per account at a time. + // Every platform: the setting stores follow the account signed in + // (stores/settings.js), so another account never sees or saves the last + // one's values. + $: _settingsFor = $currentUser?.id ?? null; + $: _settingsFor, reloadSettingStores(); + $: _accountId = isNative && getNativeMode() === 'server' && $currentUser?.id != null ? $currentUser.id : null; + $: if (_accountId != null) _checkAccount(); + $: accountReady = _accountId == null || accountReadyFor($accountGate, _accountId); + async function _checkAccount() { + const user = getStore(currentUser); + if (!user || user.id == null) return; + const ok = await ensureLocalAccount(user, { + signOut: async () => { const { logout } = await import('./stores/auth.js'); await logout(); }, + }); + if (ok) import('./lib/sync.js').then(m => m.fullSync()).catch(() => {}); + } + let _wasNeedsLogin = needsLogin; $: { if (_wasNeedsLogin && !needsLogin && $currentUser) { _wasNeedsLogin = false; import('./stores/settings.js').then(({ loadServerSettings }) => loadServerSettings()).catch(() => {}); + loadEnvLocks(); } else if (needsLogin) { _wasNeedsLogin = true; } @@ -590,8 +646,28 @@ <NativeSetup /> <Toast /> +{:else if !authLoaded} + <!-- Asking the server who is signed in: a blank page, never the app. --> {:else if needsLogin} <Login /> +{:else if !accountReady} + <!-- Checking the phone's copy is this account's: never another account's data (the confirm dialog is mounted below). --> + {#if $accountGate.state === 'error'} + <div class="account-check-error" role="alert"> + <span class="material-symbols-rounded" aria-hidden="true">error</span> + <h2>{$_('sync.account_check_failed_title')}</h2> + <p>{$_('sync.account_check_failed')}</p> + <div class="account-check-actions"> + <button class="btn btn-primary" on:click={_checkAccount}>{$_('sync.retry')}</button> + <button class="btn btn-ghost" on:click={async () => { const { logout } = await import('./stores/auth.js'); await logout(); }}>{$_('common.sign_out')}</button> + </div> + </div> + {:else} + <div class="account-check-wait" role="status" aria-label={$_('updates.checking')}> + <span class="material-symbols-rounded account-check-spin" aria-hidden="true">progress_activity</span> + </div> + {/if} + <Toast /> {:else} <Sidebar bind:open={sidebarOpen} persistent={sidebarPinned} on:close={() => { if (!sidebarPinned) sidebarOpen = false; }} /> @@ -707,6 +783,23 @@ <ConfirmDialogMount /> <style> + .account-check-error { + min-height: 100vh; min-height: 100dvh; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 8px; padding: 24px 16px; text-align: center; + background: var(--bg); color: var(--text-1); + } + .account-check-error .material-symbols-rounded { font-size: 40px; color: var(--danger, #d33); } + .account-check-error h2 { margin: 0; font-size: 18px; } + .account-check-error p { margin: 0; max-width: 360px; color: var(--text-3); font-size: 14px; line-height: 1.5; } + .account-check-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; justify-content: center; } + .account-check-wait { + min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; justify-content: center; + background: var(--bg); color: var(--accent); + } + .account-check-spin { font-size: 36px; animation: account-check-spin 1s linear infinite; } + @keyframes account-check-spin { to { transform: rotate(360deg); } } + :global(body) { overflow-x: hidden; } :global(.no-animations *) { diff --git a/src/components/ai/AttachImageButton.svelte b/src/components/ai/AttachImageButton.svelte new file mode 100644 index 00000000..c5d7d73f --- /dev/null +++ b/src/components/ai/AttachImageButton.svelte @@ -0,0 +1,157 @@ +<script> + // Trace's attach button. The same file in every Trace app, so keep the + // copies identical: change one, change all four. + // + // On a phone, a tablet or the Android app it offers Camera or Gallery. On a + // computer it opens the file picker straight away: desktop browsers ignore + // the "open the camera" hint, so both choices would do the same thing. + // In the Android app each choice goes straight to the camera or the system + // photo picker instead of Android's generic chooser. + // + // Whatever the source, the parent gets the same thing: a `files` event + // with an array of image Files. + import { createEventDispatcher } from 'svelte'; + import { fade } from 'svelte/transition'; + import { _ } from 'svelte-i18n'; + import { isNative } from '../../lib/platform.js'; + + /** Let the user pick several photos at once from the gallery. */ + export let multiple = false; + export let disabled = false; + /** Tooltip and accessible name, from the parent's own translations. */ + export let title = ''; + + const dispatch = createEventDispatcher(); + let menuOpen = false; + let wrap; + let fileInput; + let cameraInput; + + // A touch screen is where "Camera" means something; a mouse is not. + const touch = isNative + || (typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches); + + function onButton() { + if (!touch) { fileInput?.click(); return; } + menuOpen = !menuOpen; + } + + function emit(files) { + const images = files.filter(f => f && /^image\//.test(f.type)); + if (images.length) dispatch('files', images); + } + + function onPicked(e) { + emit(Array.from(e.target.files || [])); + e.target.value = ''; + } + + // A photo from the native picker, as a File like any other. + async function toFile(webPath, format) { + const blob = await (await fetch(webPath)).blob(); + const type = blob.type && blob.type.startsWith('image/') ? blob.type : `image/${format || 'jpeg'}`; + return new File([blob], `photo.${(type.split('/')[1] || 'jpeg').replace('jpeg', 'jpg')}`, { type }); + } + + async function pickNative(fromCamera) { + try { + const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera'); + if (!fromCamera && multiple) { + const { photos } = await Camera.pickImages({ quality: 80, width: 1024 }); + emit(await Promise.all((photos || []).map(p => toFile(p.webPath, p.format)))); + return; + } + const photo = await Camera.getPhoto({ + quality: 80, + width: 1024, + resultType: CameraResultType.Uri, + source: fromCamera ? CameraSource.Camera : CameraSource.Photos, + }); + emit([await toFile(photo.webPath, photo.format)]); + } catch { + // Closing the camera or the picker leaves things as they were. + } + } + + function choose(fromCamera) { + menuOpen = false; + if (isNative) pickNative(fromCamera); + else (fromCamera ? cameraInput : fileInput)?.click(); + } + + function onWindowPointer(e) { + if (menuOpen && wrap && !wrap.contains(e.target)) menuOpen = false; + } + function onWindowKey(e) { + if (menuOpen && e.key === 'Escape') { menuOpen = false; e.preventDefault(); } + } +</script> + +<svelte:window on:pointerdown={onWindowPointer} on:keydown={onWindowKey} /> + +<div class="attach" bind:this={wrap}> + <button type="button" class="attach-image-btn" on:click={onButton} {disabled} + {title} aria-label={title} aria-haspopup={touch ? 'menu' : undefined} aria-expanded={touch ? menuOpen : undefined}> + <span class="material-symbols-rounded" aria-hidden="true">add_photo_alternate</span> + </button> + {#if menuOpen} + <div class="attach-image-menu" role="menu" transition:fade={{ duration: 120 }}> + <button type="button" role="menuitem" class="attach-image-option" on:click={() => choose(true)}> + <span class="material-symbols-rounded" aria-hidden="true">photo_camera</span> + {$_('attach_image.camera')} + </button> + <button type="button" role="menuitem" class="attach-image-option" on:click={() => choose(false)}> + <span class="material-symbols-rounded" aria-hidden="true">photo_library</span> + {$_('attach_image.gallery')} + </button> + </div> + {/if} + <input bind:this={fileInput} type="file" accept="image/*" {multiple} hidden on:change={onPicked} /> + <input bind:this={cameraInput} type="file" accept="image/*" capture="environment" hidden on:change={onPicked} /> +</div> + +<style> + .attach { position: relative; flex-shrink: 0; } + .attach-image-btn { + width: 40px; height: 40px; + border-radius: 50%; + background: none; + color: var(--text-3); + border: 1px solid var(--border); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: color var(--dur-fast), border-color var(--dur-fast); + } + .attach-image-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); } + .attach-image-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .attach-image-btn:disabled { opacity: 0.4; cursor: default; } + .attach-image-btn .material-symbols-rounded { font-size: 20px; } + .attach-image-menu { + position: absolute; + bottom: 48px; + left: 0; + z-index: 10; + min-width: 160px; + overflow: hidden; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } + .attach-image-option { + display: flex; align-items: center; gap: 10px; + width: 100%; + min-height: 48px; + padding: 10px 14px; + background: none; + border: none; + color: var(--text-1); + font-size: 14px; + text-align: left; + cursor: pointer; + } + .attach-image-option .material-symbols-rounded { font-size: 20px; } + .attach-image-option:hover { background: var(--surface-2); } + .attach-image-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + .attach-image-option + .attach-image-option { border-top: 1px solid var(--border); } +</style> diff --git a/src/components/ai/Trace.svelte b/src/components/ai/Trace.svelte index a833a123..f40ac6d9 100644 --- a/src/components/ai/Trace.svelte +++ b/src/components/ai/Trace.svelte @@ -16,6 +16,7 @@ import { tick, onMount } from 'svelte'; import { fly, fade } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; + import AttachImageButton from './AttachImageButton.svelte'; import { closeOnBack } from '../../lib/back-stack.js'; import { _ } from 'svelte-i18n'; import TraceFace from './TraceFace.svelte'; @@ -900,10 +901,9 @@ When you write to the user's data, summarise what you did briefly and concretely } let _voicePrefix = null; - let _imageInput; - function _attachClick() { _imageInput?.click(); } - function _onImagePicked(e) { - const f = e.target.files?.[0]; + // From the shared attach button: an array of image Files. Trace takes one. + function _useImageFiles(files) { + const f = files?.[0]; if (!f) return; if (!/^image\//.test(f.type)) { showError($_('trace_ai_ct.toast.pick_image')); return; } const reader = new FileReader(); @@ -913,7 +913,6 @@ When you write to the user's data, summarise what you did briefly and concretely attachedImage = { base64, mimeType: f.type || 'image/jpeg', preview: dataUrl }; }; reader.readAsDataURL(f); - e.target.value = ''; } function _clearImage() { attachedImage = null; } @@ -1147,11 +1146,8 @@ When you write to the user's data, summarise what you did briefly and concretely {/if} <footer class="panel-input"> - <input type="file" accept="image/*" bind:this={_imageInput} on:change={_onImagePicked} style="display:none" /> - <button class="attach-btn" on:click={_attachClick} disabled={busy} - aria-label="Attach Image" title="Attach Image"> - <span class="material-symbols-rounded">photo_camera</span> - </button> + <AttachImageButton disabled={busy} title={$_('attach_image.button')} + on:files={e => _useImageFiles(e.detail)} /> <!-- In-chat voice button removed for parity with NutriTrace + LiftTrace. Voice input lives only on the FAB hold gesture (Smart Log) and on dedicated voice surfaces (Smart Log @@ -1613,20 +1609,6 @@ When you write to the user's data, summarise what you did briefly and concretely flex-shrink: 0; } /* Attach (photo_camera) — round, border-only, accent on hover. */ - .attach-btn { - background: none; - border: 1px solid var(--border); - border-radius: 50%; - width: 40px; height: 40px; - cursor: pointer; - color: var(--text-3); - display: flex; align-items: center; justify-content: center; - flex-shrink: 0; - transition: color var(--dur-fast), border-color var(--dur-fast); - } - .attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); } - .attach-btn:disabled { opacity: 0.4; cursor: not-allowed; } - .attach-btn .material-symbols-rounded { font-size: 20px; } .mic-btn.recording { background: color-mix(in srgb, var(--danger) 18%, transparent); border-color: var(--danger); diff --git a/src/components/manage/ManageCookbooks.svelte b/src/components/manage/ManageCookbooks.svelte index a711fd50..bfed3b3e 100644 --- a/src/components/manage/ManageCookbooks.svelte +++ b/src/components/manage/ManageCookbooks.svelte @@ -84,7 +84,7 @@ payload.smart_filter = filter; } const cb = await NtApi.createCookbook(payload); - cookbooks = [...cookbooks, { ...cb, recipe_count: 0 }]; + cookbooks = [...cookbooks, { ...cb, recipe_count: cb.recipe_count ?? 0 }]; newName = ''; newDesc = ''; newSmart = false; @@ -114,6 +114,7 @@ name, description: editDesc.trim() || null, cover_image_url: editCover || null, + _base: cb, }); cookbooks = cookbooks.map(x => x.id === cb.id ? { ...x, ...updated } : x); cancelEdit(); diff --git a/src/components/manage/ManagePantryCategories.svelte b/src/components/manage/ManagePantryCategories.svelte index 517e6bbe..f1ab2903 100644 --- a/src/components/manage/ManagePantryCategories.svelte +++ b/src/components/manage/ManagePantryCategories.svelte @@ -58,6 +58,7 @@ name, icon: editIcon || null, default_aisle: editDefaultAisle.trim() || null, + _base: c, }); categories = categories.map(x => x.id === c.id ? updated : x); cancelEdit(); diff --git a/src/components/manage/ManageRecipeCategories.svelte b/src/components/manage/ManageRecipeCategories.svelte index bc8320f8..77e6ab25 100644 --- a/src/components/manage/ManageRecipeCategories.svelte +++ b/src/components/manage/ManageRecipeCategories.svelte @@ -51,7 +51,7 @@ const name = editName.trim(); if (!name) return; try { - const updated = await NtApi.updateRecipeCategory(c.id, { name, color: editColor }); + const updated = await NtApi.updateRecipeCategory(c.id, { name, color: editColor, _base: c }); categories = categories.map(x => x.id === c.id ? updated : x); cancelEdit(); showSuccess($_('manage_recipe_cats.toast.saved')); diff --git a/src/components/manage/ManageTaxonomyList.svelte b/src/components/manage/ManageTaxonomyList.svelte index 0a610f81..a646358b 100644 --- a/src/components/manage/ManageTaxonomyList.svelte +++ b/src/components/manage/ManageTaxonomyList.svelte @@ -164,7 +164,7 @@ <input class="input edit-name" bind:value={editText} on:keydown={(e) => { if (e.key === 'Enter') saveEdit(it); - if (e.key === 'Escape') cancelEdit(); + if (e.key === 'Escape') { cancelEdit(); e.preventDefault(); } }} autofocus /> <div class="row-actions"> diff --git a/src/components/manage/ManageUnits.svelte b/src/components/manage/ManageUnits.svelte index ccdcde03..787c46cb 100644 --- a/src/components/manage/ManageUnits.svelte +++ b/src/components/manage/ManageUnits.svelte @@ -139,7 +139,7 @@ if (!abbr) return; try { const updated = await NtApi.updateCustomUnit(u.id, { - abbr, full_name: full, category: editCategory, + abbr, full_name: full, category: editCategory, _base: u, }); custom = custom.map(x => x.id === u.id ? updated : x); cancelEdit(); diff --git a/src/components/pantry/PantryItemSheet.svelte b/src/components/pantry/PantryItemSheet.svelte index 98512d64..2c816dec 100644 --- a/src/components/pantry/PantryItemSheet.svelte +++ b/src/components/pantry/PantryItemSheet.svelte @@ -754,7 +754,8 @@ dispatch('created', finalRow); open = false; } else { - await NtApi.updatePantryItem(itemId, payload); + // With the item as opened: only what the form changed goes in. + await NtApi.updatePantryItem(itemId, { ...payload, _base: item }); showSuccess($_('pantry_sheet_extra.toast.saved')); item = { ...item, ...payload, id: itemId }; dispatch('changed', { ...item }); diff --git a/src/components/recipe/CookLogDialog.svelte b/src/components/recipe/CookLogDialog.svelte index 8e08861d..9178e4ff 100644 --- a/src/components/recipe/CookLogDialog.svelte +++ b/src/components/recipe/CookLogDialog.svelte @@ -115,7 +115,7 @@ function lightboxNext() { if (lightboxIndex < photos.length - 1) lightboxIndex += 1; } function onLightboxKey(e) { if (lightboxIndex < 0) return; - if (e.key === 'Escape') closeLightbox(); + if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); } else if (e.key === 'ArrowLeft') lightboxPrev(); else if (e.key === 'ArrowRight') lightboxNext(); } diff --git a/src/components/recipe/ImportUrlDialog.svelte b/src/components/recipe/ImportUrlDialog.svelte index c1f2fa85..4991f17f 100644 --- a/src/components/recipe/ImportUrlDialog.svelte +++ b/src/components/recipe/ImportUrlDialog.svelte @@ -22,7 +22,7 @@ import { fade } from 'svelte/transition'; import { push } from 'svelte-spa-router'; import { NtApi, mutatingAuthHeaders } from '../../lib/api.js'; - import { showSuccess, showError } from '../../stores/toast.js'; + import { showSuccess, showError, showInfo } from '../../stores/toast.js'; import { portal } from '../../lib/portal.js'; export let open = false; @@ -105,9 +105,22 @@ applyTags, importCategories, }); + if (created?.skipped) { + // Already in the catalog: nothing was written, so open the + // recipe it matched instead of a new one. + open = false; + dispatch('close'); + if (created.existing_id) { + showInfo($_('import_url_dialog.toast.already_exists')); + push(`/recipes/${created.existing_id}`); + } else { + showInfo($_('import_url_dialog.toast.already_exists_short')); + } + return; + } const stepCount = (created.steps || []).reduce((n, s) => n + (s?.text ? 1 : 0), 0); if (stepCount === 0) { - showError("Imported, but the source page didn't include cooking steps. Open the recipe to add them manually."); + showError($_('import_url_dialog.toast.imported_no_steps')); } else { showSuccess($_('import_url_dialog.toast.recipe_imported')); } diff --git a/src/components/recipe/IngredientNameField.svelte b/src/components/recipe/IngredientNameField.svelte index 830cf430..b991d92d 100644 --- a/src/components/recipe/IngredientNameField.svelte +++ b/src/components/recipe/IngredientNameField.svelte @@ -73,7 +73,7 @@ if (highlight >= 0 && filtered[highlight]) { pick(filtered[highlight]); e.preventDefault(); } else { open = false; } } - else if (e.key === 'Escape') { open = false; highlight = -1; } + else if (e.key === 'Escape') { if (open) e.preventDefault(); open = false; highlight = -1; } } function onBlur() { diff --git a/src/components/settings/SettingsBackup.svelte b/src/components/settings/SettingsBackup.svelte index 05d43eac..267be7db 100644 --- a/src/components/settings/SettingsBackup.svelte +++ b/src/components/settings/SettingsBackup.svelte @@ -307,8 +307,8 @@ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); // Wipe per-user prefixed localStorage so client-side stores reset // to defaults on reload. Mirrors NT's prefix logic verbatim. - const userId = localStorage.getItem('wl:userId'); - const prefix = userId ? `wl_u${userId}_` : 'wl_'; + const { settingPrefix } = await import('../../lib/setting-key.js'); + const prefix = settingPrefix(); const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); diff --git a/src/components/settings/SettingsEmail.svelte b/src/components/settings/SettingsEmail.svelte index 58439149..6a109279 100644 --- a/src/components/settings/SettingsEmail.svelte +++ b/src/components/settings/SettingsEmail.svelte @@ -278,7 +278,7 @@ {#if showTestDialog} <div class="test-dialog-overlay" on:click={closeTestDialog} use:closeOnBack={closeTestDialog} - on:keydown={(e) => e.key === 'Escape' && closeTestDialog()}> + on:keydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); closeTestDialog(); } }}> <div class="test-dialog" role="dialog" aria-labelledby="test-dialog-title" on:click|stopPropagation> <h3 id="test-dialog-title">{$_('settings_email_ct.send_test_email')}</h3> diff --git a/src/components/settings/SettingsImport.svelte b/src/components/settings/SettingsImport.svelte index a7617a77..8ff1833b 100644 --- a/src/components/settings/SettingsImport.svelte +++ b/src/components/settings/SettingsImport.svelte @@ -32,7 +32,7 @@ let manifest = null; // { count, recipes: [...] } from /scan let scanning = false; let committing = false; - let summary = null; // { imported, failed: [{name, error}] } + let summary = null; // { imported, total, skipped: [{name, existing_id}], failed: [{name, error}] } // Phase + progress for the XHR-driven upload. `phase`: // 'idle' — nothing in flight @@ -176,15 +176,19 @@ if (p.uploaded) phase = 'importing'; }, }); + const skipped = Array.isArray(res.skipped) ? res.skipped : []; + const failed = Array.isArray(res.failed) ? res.failed : []; summary = { imported: res.count || 0, - failed: Array.isArray(res.failed) ? res.failed : [], + total: (res.count || 0) + skipped.length + failed.length, + skipped, + failed, }; if (summary.imported > 0) { - showSuccess(`Imported ${summary.imported} ${summary.imported === 1 ? 'recipe' : 'recipes'}`); + showSuccess($_('settings_import_ct.toast_imported', { values: { count: summary.imported } })); } if (summary.failed.length > 0) { - showError(`${summary.failed.length} ${summary.failed.length === 1 ? 'recipe' : 'recipes'} could not be imported`); + showError($_('settings_import_ct.summary_failed', { values: { count: summary.failed.length } })); } } catch (err) { showError(err.message || 'Import failed'); @@ -344,9 +348,9 @@ </label> {/if} <div class="opt opt-row"> - <span class="opt-label">If a recipe already exists:</span> - <select class="select sel-sm" bind:value={dedup}> - <option value="skip">Skip it (default)</option> + <span class="opt-label">{$_('settings_import_ct.dup_label')}</span> + <select class="select sel-sm" bind:value={dedup} aria-label={$_('settings_import_ct.dup_label')}> + <option value="skip">{$_('settings_import_ct.opt_skip')}</option> <option value="replace">{$_('settings_import_ct.opt_replace')}</option> <option value="force">{$_('settings_import_ct.opt_force')}</option> </select> @@ -404,24 +408,51 @@ <span class="material-symbols-rounded summary-icon">check_circle</span> <div> <div class="summary-title"> - Imported {summary.imported} {summary.imported === 1 ? 'recipe' : 'recipes'} + {#if summary.imported === summary.total} + {$_('settings_import_ct.summary_all', { values: { count: summary.imported } })} + {:else} + {$_('settings_import_ct.summary_partial', { values: { imported: summary.imported, total: summary.total } })} + {/if} </div> + {#if summary.skipped.length > 0} + <div class="summary-sub">{$_('settings_import_ct.summary_dupes', { values: { count: summary.skipped.length } })}</div> + {/if} {#if summary.failed.length > 0} - <div class="summary-sub error"> - {summary.failed.length} skipped because of errors - </div> - {:else} - <div class="summary-sub">All set. Find them on the Recipes tab.</div> + <div class="summary-sub error">{$_('settings_import_ct.summary_failed', { values: { count: summary.failed.length } })}</div> + {/if} + {#if summary.imported > 0 && summary.skipped.length === 0 && summary.failed.length === 0} + <div class="summary-sub">{$_('settings_import_ct.summary_find')}</div> {/if} </div> </div> + {#if summary.skipped.length > 0} + <div class="summary-group"> + <div class="summary-group-title">{$_('settings_import_ct.dupes_heading')}</div> + <p class="summary-group-hint">{$_('settings_import_ct.dupes_hint')}</p> + <ul class="skipped-list"> + {#each summary.skipped as d} + <li> + {#if d.existing_id} + <a href={`#/recipes/${d.existing_id}`}>{d.name}</a> + {:else} + {d.name} + {/if} + </li> + {/each} + </ul> + </div> + {/if} + {#if summary.failed.length > 0} - <ul class="failed"> - {#each summary.failed as f} - <li><strong>{f.name}</strong> — <span class="muted">{f.error}</span></li> - {/each} - </ul> + <div class="summary-group"> + <div class="summary-group-title">{$_('settings_import_ct.failed_heading')}</div> + <ul class="failed"> + {#each summary.failed as f} + <li><strong>{f.name}</strong>: <span class="muted">{f.error}</span></li> + {/each} + </ul> + </div> {/if} <div class="actions"> @@ -649,6 +680,38 @@ } .failed li { padding: 3px 0; line-height: 1.4; } + /* Duplicates skipped on purpose: listed plainly, not as errors. */ + .summary-group { margin: 0 0 12px; } + .summary-group-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-2); + margin-bottom: 6px; + } + .summary-group-hint { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.45; + color: var(--text-2); + } + .summary-group .failed { margin-bottom: 0; } + .skipped-list { + list-style: none; + margin: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2); + font-size: 13px; + max-height: 180px; + overflow-y: auto; + } + .skipped-list li { padding: 3px 0; line-height: 1.4; } + .skipped-list a { color: var(--text-1); text-decoration: underline; text-decoration-color: var(--border-strong); } + .skipped-list a:hover { color: var(--accent); } + /* Upload + scan progress strip. The track is a thin pill; the fill animates width during upload, runs an indeterminate sweep when waiting on the server. */ diff --git a/src/components/settings/SettingsServerConnection.svelte b/src/components/settings/SettingsServerConnection.svelte index 38db3779..eba18766 100644 --- a/src/components/settings/SettingsServerConnection.svelte +++ b/src/components/settings/SettingsServerConnection.svelte @@ -48,6 +48,9 @@ let mergeProgressPct = 0; let mergeStage = ''; let _pendingServerUrl = ''; + let _pendingUserId = null; // the account signed in to, for lib/local-account.js + let _pendingUserCreated = null; + let _connectMode = null; // 'upload' | 'download' | 'merge' | null (no local data) let localCounts = null; let migrationSummary = null; @@ -84,6 +87,9 @@ if (login.status < 200 || login.status >= 300) throw new Error(body?.error || 'Login failed'); _pendingServerUrl = url; + _pendingUserId = body.user?.id ?? null; + _pendingUserCreated = body.user?.created_at ?? null; + _connectMode = null; if (body.token) setAuthToken(body.token); // Count local data so the merge dialog can show what's about to @@ -112,6 +118,7 @@ // recipes/diary/pantry because those tables don't have a // natural unique key on the server. async function _mergeAndConnect(modeChoice) { + _connectMode = modeChoice; mergeStep = 'syncing'; mergeProgress = ''; mergeProgressPct = 0; @@ -164,7 +171,23 @@ } } - function _finalizeConnect() { + async function _finalizeConnect() { + // The phone's data is this account's from now on, as chosen above: + // Download replaces it; after an upload, what went up takes the + // server's ids and the rest goes up with the next sync. Signing in + // after the reload then neither asks nor clears. + if (_pendingUserId != null) { + const clear = _connectMode === 'download'; + const uploaded = (_connectMode === 'upload' || _connectMode === 'merge') ? (migrationSummary?.uploaded || {}) : {}; + try { + const { claimForServer } = await import('../../lib/local-account.js'); + await claimForServer(_pendingServerUrl, _pendingUserId, { clear, uploaded, created: _pendingUserCreated }); + } catch (e) { + console.warn('[account] could not mark the data as this account\'s:', e?.message || e); + } + } + // The account just signed in to, not one cached by an earlier sign-in. + try { localStorage.removeItem('ct:cachedUser'); localStorage.removeItem('wl:userId'); } catch {} setServerUrl(_pendingServerUrl); setNativeMode('server'); mode = 'server'; @@ -239,9 +262,25 @@ dangerous: true, }); if (!ok) return; + // The data on the phone is the phone's own now (local mode), and goes + // to whichever account it's connected to next. + try { + const { setLocalOwner } = await import('../../lib/local-account.js'); + await setLocalOwner(); + } catch {} + // The account's settings stay with the phone too: kept per account and + // server, they'd otherwise vanish in local mode (lib/setting-key.js), + // and the first-run welcome would show as if the app were new. + const { settingPrefix, copySettingScope } = await import('../../lib/setting-key.js'); + const accountScope = settingPrefix(); setServerUrl(null); setAuthToken(null); setNativeMode('local'); + try { localStorage.removeItem('ct:cachedUser'); } catch {} + try { + copySettingScope(accountScope, settingPrefix('1')); + localStorage.setItem(settingPrefix('1') + 'setupComplete', 'true'); + } catch { /* storage unavailable */ } showSuccess($_('settings_server_conn.toast.disconnected_reloading')); setTimeout(() => window.location.reload(), 300); } diff --git a/src/components/ui/Combobox.svelte b/src/components/ui/Combobox.svelte index f025b74d..2f220525 100644 --- a/src/components/ui/Combobox.svelte +++ b/src/components/ui/Combobox.svelte @@ -199,7 +199,7 @@ e.preventDefault(); return; } - if (e.key === 'Escape') { closePopover(); return; } + if (e.key === 'Escape') { if (open) e.preventDefault(); closePopover(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); highlightIndex = Math.min(rowCount - 1, highlightIndex + 1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); highlightIndex = Math.max(0, highlightIndex - 1); return; } if (e.key === 'Enter') { diff --git a/src/components/ui/CookieWarning.svelte b/src/components/ui/CookieWarning.svelte new file mode 100644 index 00000000..610e0527 --- /dev/null +++ b/src/components/ui/CookieWarning.svelte @@ -0,0 +1,39 @@ +<script> + // Why signing in won't stick, shown on the login page and the setup wizard. + // reason: 'http' (plain-HTTP page, HTTPS-only cookie) or 'dropped' (signed + // in, but the browser did not keep the cookie for another reason). + import { _ } from 'svelte-i18n'; + import { COOKIE_HELP_URLS } from '../../lib/cookie-check.js'; + export let reason = 'http'; +</script> + +<div class="cookie-warning" role="alert"> + <span class="material-symbols-rounded cw-icon" aria-hidden="true">warning</span> + <div class="cw-text"> + <p class="cw-title">{$_(`login.cookie_warning.${reason}_title`)}</p> + <p>{$_(`login.cookie_warning.${reason}_body`)}</p> + <a href={COOKIE_HELP_URLS[reason] || COOKIE_HELP_URLS.http} target="_blank" rel="noopener noreferrer">{$_('login.cookie_warning.fix_link')}</a> + </div> +</div> + +<style> + .cookie-warning { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 14px; + margin-bottom: 16px; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent); + background: color-mix(in srgb, var(--warning, #f59e0b) 10%, var(--surface-2)); + font-size: 13px; + line-height: 1.45; + color: var(--text-1); + text-align: left; + } + .cw-icon { font-size: 20px; color: var(--warning, #f59e0b); flex: none; margin-top: 1px; } + .cw-text { display: flex; flex-direction: column; gap: 6px; min-width: 0; } + .cw-text p { margin: 0; } + .cw-title { font-weight: 600; font-size: 14px; } + .cw-text a { color: var(--accent, #4fffb0); font-weight: 600; align-self: flex-start; } +</style> diff --git a/src/components/ui/Sheet.svelte b/src/components/ui/Sheet.svelte index 3ae794a9..c2b0b36d 100644 --- a/src/components/ui/Sheet.svelte +++ b/src/components/ui/Sheet.svelte @@ -1,5 +1,13 @@ +<script context="module"> + // Open sheets, the newest last: only the top one answers Escape and + // keeps keyboard focus (a sheet can open over another). + const _open = []; +</script> + <script> import { closeOnBack } from '../../lib/back-stack.js'; + import { get } from 'svelte/store'; + import { confirmRequest } from '../../stores/confirmDialog.js'; import { fly, fade } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; import { createEventDispatcher } from 'svelte'; @@ -24,6 +32,45 @@ dispatch('close'); } + // A dialog for the keyboard: focus moves into the sheet when it opens, + // Tab stays inside it, Escape closes it (not while a confirmation is open + // over it, nor when something inside used it), and focus goes back where + // it was when it closes. + const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + function dialog(node) { + const before = document.activeElement; + _open.push(node); + queueMicrotask(() => { if (!node.contains(document.activeElement)) node.focus({ preventScroll: true }); }); + const onKey = (e) => { + if (_open[_open.length - 1] !== node) return; + if (e.key === 'Escape') { + // After everything else had the key: a picker or a dialog inside + // the sheet that used Escape to close itself marks it handled. + setTimeout(() => { if (!e.defaultPrevented && _open[_open.length - 1] === node && !get(confirmRequest)) close(); }); + return; + } + if (e.key !== 'Tab') return; + const items = [...node.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null || el === document.activeElement); + if (!items.length) { e.preventDefault(); node.focus(); return; } + const first = items[0], last = items[items.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === node)) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }; + window.addEventListener('keydown', onKey); + return { + destroy() { + window.removeEventListener('keydown', onKey); + const i = _open.indexOf(node); + if (i >= 0) _open.splice(i, 1); + // Back where it was, except into a text field on a touch screen, + // where that would open the keyboard again. + const typing = before && (before.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(before.tagName)); + const touch = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; + if (before && typeof before.focus === 'function' && document.contains(before) && !(typing && touch)) before.focus({ preventScroll: true }); + }, + }; + } + function onBackdropClick(e) { if (_locked) return; if (e.target === e.currentTarget) close(); @@ -36,6 +83,8 @@ <div use:portal class="sheet-backdrop" on:click={onBackdropClick} use:closeOnBack={close} in:fade={{ duration: 200 }} out:fade={{ duration: 160 }}> <div + use:dialog + tabindex="-1" class="sheet-panel" class:sheet-full={height === 'full'} style={height !== 'auto' && height !== 'full' ? `height:${height}` : ''} @@ -140,4 +189,6 @@ padding: 0 20px 20px; } .sheet-body.no-title { padding-top: 16px; } + /* Focused to take the keyboard in, not to be pointed at. */ + .sheet-panel:focus { outline: none; } </style> diff --git a/src/components/ui/UnitPicker.svelte b/src/components/ui/UnitPicker.svelte index 704715bc..75ade01b 100644 --- a/src/components/ui/UnitPicker.svelte +++ b/src/components/ui/UnitPicker.svelte @@ -93,7 +93,7 @@ if (highlight >= 0 && flatList[highlight]) { pick(flatList[highlight]); e.preventDefault(); } else { open = false; } } - else if (e.key === 'Escape') { open = false; highlight = -1; } + else if (e.key === 'Escape') { if (open) e.preventDefault(); open = false; highlight = -1; } else if (e.key === 'Tab') { open = false; } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 099e8570..fbbacfd7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -15,6 +15,15 @@ "sign_out_waiting_title": "Changes are still waiting", "sign_out_waiting": "{count, plural, one {# change has not reached your server yet. Signing out now discards it. Connect and try again to keep it.} other {# changes have not reached your server yet. Signing out now discards them. Connect and try again to keep them.}}", "sign_out_anyway": "Sign Out Anyway", + "switch_account_waiting_title": "Another Account's Changes Are Waiting", + "switch_account_waiting": "{count, plural, one {# change made on this device by the account signed in before has not reached the server. Signing in now discards it. To keep it, sign in as that account first.} other {# changes made on this device by the account signed in before have not reached the server. Signing in now discards them. To keep them, sign in as that account first.}}", + "switch_account_anyway": "Sign In Anyway", + "same_server_title": "Is This the Same Server?", + "same_server": "This server is at a different address than the one this device used before, and it can't tell whether it's the same server. If it is, your data here stays and syncs with it. If not, it's treated as another account's.", + "same_server_yes": "Same Server", + "same_server_no": "Different Server", + "account_check_failed_title": "Couldn't Open Your Data", + "account_check_failed": "This device couldn't check whose data it holds, so nothing is shown yet. Try again, or sign out.", "sync_offline": "Offline", "sync_failing": "Can't reach the server", "not_synced": "Not synced yet", @@ -305,6 +314,11 @@ }, "server": { "section": "Server Connection" } }, + "attach_image": { + "button": "Attach Image", + "camera": "Camera", + "gallery": "Gallery" + }, "login": { "subtitle": "Sign in to your account", "username": "Username", @@ -321,6 +335,13 @@ "errors": { "failed": "Login failed" }, + "cookie_warning": { + "http_title": "Signing in won't work on this address", + "http_body": "This page uses plain HTTP, but the server only sends its sign-in cookie over HTTPS, so your browser throws it away and signing in sends you back to the sign-in page. Open CookTrace over HTTPS, or, on a trusted home network, set INSECURE_COOKIES=1 on the server and restart it.", + "dropped_title": "Your browser didn't keep you signed in", + "dropped_body": "Your password was right, but the browser didn't keep the sign-in cookie. Check that cookies are allowed for this site, and if CookTrace is behind a reverse proxy, that the proxy passes cookies through.", + "fix_link": "How to fix it" + }, "recovery": { "confirm": "This will delete all user accounts. Your food & diary data will be kept. Continue?", "explainer": "If you never set up user accounts intentionally, you can disable user management. <strong>This will delete all user accounts.</strong> Your food & diary data will be kept.", @@ -1037,7 +1058,18 @@ "opt_replace": "Replace with the Imported Version", "opt_force": "Allow Duplicates", "cancel": "Cancel", - "import_another": "Import Another" + "import_another": "Import Another", + "dup_label": "If a Recipe Already Exists", + "opt_skip": "Skip It", + "summary_all": "Imported {count, plural, one {# Recipe} other {# Recipes}}", + "summary_partial": "Imported {imported} of {total} Recipes", + "summary_find": "Find them on the Recipes tab.", + "summary_dupes": "{count, plural, one {# duplicate skipped} other {# duplicates skipped}}", + "summary_failed": "{count, plural, one {# recipe couldn't be imported} other {# recipes couldn't be imported}}", + "dupes_heading": "Duplicates Skipped", + "dupes_hint": "A recipe with the same name or source link already exists. To import these anyway, set If a Recipe Already Exists to Allow Duplicates and import again.", + "failed_heading": "Not Imported", + "toast_imported": "Imported {count, plural, one {# recipe} other {# recipes}}" }, "settings_federation_ct": { "toast": { @@ -1247,7 +1279,10 @@ "toast": { "recipe_imported": "Recipe imported", "url_batch_limit": "Up to 100 URLs per batch. Split into smaller batches.", - "pick_one_url": "Pick at least one URL to save." + "pick_one_url": "Pick at least one URL to save.", + "already_exists": "This recipe already exists. Opening it.", + "already_exists_short": "This recipe already exists.", + "imported_no_steps": "Recipe imported, but the source had no cooking steps. Add them in the editor." }, "title": "Import from URL", "link_to_pantry": "Link Ingredients to Pantry", @@ -1330,6 +1365,12 @@ "toast": { "paste_or_pick": "Paste a recipe or pick a file first", "recipe_imported": "Recipe imported", + "already_exists": "This recipe already exists. Opening it.", + "already_exists_short": "This recipe already exists.", + "imported_no_steps": "Recipe imported, but the source had no cooking steps. Add them in the editor.", + "archive_imported": "Imported {count, plural, one {# recipe} other {# recipes}}", + "archive_imported_skipped": "Imported {count, plural, one {# recipe} other {# recipes}}; {skipped, plural, one {# duplicate skipped} other {# duplicates skipped}}", + "archive_all_duplicates": "Nothing new to import: {skipped, plural, one {# recipe already exists} other {all # recipes already exist}}", "new_link_generated": "New link generated", "link_revoked": "Link revoked", "copied": "Copied to clipboard", @@ -1404,7 +1445,40 @@ "tags": "Tags", "step": "Step", "notes": "Notes", - "cancel": "Cancel" + "cancel": "Cancel", + "versions": { + "title": "Earlier Versions", + "notice": "Two devices edited this recipe. The older edit is kept as an earlier version.", + "view": "View", + "dismiss": "Dismiss", + "intro": "When two devices edit this recipe, the newer edit stays and the other is kept here. Restore one to make it the recipe again.", + "reason_conflict": "Kept from a sync", + "reason_restore": "Kept before a restore", + "reason_replaced": "Kept before a later save", + "reason_conflict_by": "{name}'s edit, kept from a sync", + "reason_restore_by": "{name}'s edit, kept before a restore", + "reason_replaced_by": "{name}'s edit, kept before a later save", + "differs": "Differs in", + "part_title": "Title", + "part_ingredients": "Ingredients", + "part_steps": "Steps", + "part_notes": "Notes", + "part_category": "Category", + "part_photo": "Photo", + "part_nutrition": "Nutrition", + "part_details": "Details", + "preview": "Preview", + "hide_preview": "Hide Preview", + "restore": "Restore", + "confirm_title": "Restore This Version?", + "confirm_message": "The recipe will change to this version. The current recipe is kept as an earlier version, so you can undo this.", + "restored": "Recipe restored", + "restore_failed": "Couldn't restore the recipe", + "ingredients": "Ingredients", + "steps": "Steps", + "no_ingredients": "No ingredients", + "no_steps": "No steps" + } }, "cookbook_view_ct": { "toast": { @@ -1434,7 +1508,11 @@ "steps": "Steps", "no_steps": "No steps yet.", "notes": "Notes", - "powered_by_html": "Powered by <strong>CookTrace</strong>." + "powered_by_html": "Powered by <strong>CookTrace</strong>.", + "gone": "This share link no longer works.", + "gone_hint": "Ask the person who shared it for a new link.", + "load_failed": "Couldn't load the recipe. Check your connection and try again.", + "retry": "Try Again" }, "barcode_scanner": { "toast": { @@ -1824,7 +1902,7 @@ "copied": "Copied to clipboard.", "copy_failed": "Copy failed. Copy the command manually.", "view_notes": "View Release Notes", - "channel_note": "To switch server channels, change your docker-compose.yml image tag (:latest, :1.0, :1, :dev) and re-run docker-compose pull && docker-compose up -d." + "channel_note": "To switch server channels, change your docker-compose.yml image tag (:latest, :dev) and re-run docker-compose pull && docker-compose up -d." } } } diff --git a/src/i18n/es.json b/src/i18n/es.json index c293170a..2b1887ea 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -1097,7 +1097,18 @@ "opt_replace": "Reemplazar por la versión importada", "opt_force": "Permitir duplicados", "cancel": "Cancelar", - "import_another": "Importar otro" + "import_another": "Importar otro", + "dup_label": "Si la receta ya existe", + "opt_skip": "Omitirla", + "summary_all": "{count, plural, one {Se importó # receta} other {Se importaron # recetas}}", + "summary_partial": "Se importaron {imported} de {total} recetas", + "summary_find": "Las encontrarás en la pestaña Recetas.", + "summary_dupes": "{count, plural, one {Se omitió # duplicado} other {Se omitieron # duplicados}}", + "summary_failed": "{count, plural, one {No se pudo importar # receta} other {No se pudieron importar # recetas}}", + "dupes_heading": "Duplicados omitidos", + "dupes_hint": "Ya existe una receta con el mismo nombre o enlace de origen. Para importarlas de todos modos, elige Permitir duplicados en Si la receta ya existe e importa de nuevo.", + "failed_heading": "No importadas", + "toast_imported": "{count, plural, one {Se importó # receta} other {Se importaron # recetas}}" }, "settings_federation_ct": { "toast": { @@ -1307,7 +1318,10 @@ "toast": { "recipe_imported": "Receta importada", "url_batch_limit": "Hasta 100 URL por tanda. Divídelas en tandas más pequeñas.", - "pick_one_url": "Elige al menos una URL para guardar." + "pick_one_url": "Elige al menos una URL para guardar.", + "already_exists": "Esta receta ya existe. Abriéndola.", + "already_exists_short": "Esta receta ya existe.", + "imported_no_steps": "Receta importada, pero el origen no tenía pasos de preparación. Agrégalos en el editor." }, "title": "Importar desde una URL", "link_to_pantry": "Vincular los ingredientes con la despensa", @@ -1390,6 +1404,12 @@ "toast": { "paste_or_pick": "Primero pega una receta o elige un archivo", "recipe_imported": "Receta importada", + "already_exists": "Esta receta ya existe. Abriéndola.", + "already_exists_short": "Esta receta ya existe.", + "imported_no_steps": "Receta importada, pero el origen no tenía pasos de preparación. Agrégalos en el editor.", + "archive_imported": "{count, plural, one {Se importó # receta} other {Se importaron # recetas}}", + "archive_imported_skipped": "{count, plural, one {Se importó # receta} other {Se importaron # recetas}}; {skipped, plural, one {se omitió # duplicado} other {se omitieron # duplicados}}", + "archive_all_duplicates": "Nada nuevo que importar: {skipped, plural, one {# receta ya existe} other {las # recetas ya existen}}", "new_link_generated": "Se generó un enlace nuevo", "link_revoked": "Enlace revocado", "copied": "Copiado al portapapeles", @@ -1884,7 +1904,7 @@ "copied": "Copiado al portapapeles.", "copy_failed": "No se pudo copiar. Copia el comando a mano.", "view_notes": "Ver notas de la versión", - "channel_note": "Para cambiar de canal en el servidor, cambia la etiqueta de la imagen en tu docker-compose.yml (:latest, :1.0, :1, :dev) y vuelve a ejecutar docker-compose pull && docker-compose up -d." + "channel_note": "Para cambiar de canal en el servidor, cambia la etiqueta de la imagen en tu docker-compose.yml (:latest, :dev) y vuelve a ejecutar docker-compose pull && docker-compose up -d." } } } diff --git a/src/i18n/fr.json b/src/i18n/fr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/i18n/fr.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/it.json b/src/i18n/it.json new file mode 100644 index 00000000..3ff4fe40 --- /dev/null +++ b/src/i18n/it.json @@ -0,0 +1,33 @@ +{ + "nav": { + "recipes": "Ricette", + "pantry": "Dispensa", + "shopping": "Spesa", + "settings": "Impostazioni" + }, + "sync": { + "synced": "Sincronizzato", + "not_synced": "Non ancora sincronizzato", + "retry": "Riprova", + "retrying": "Riprovando…", + "error_title": "Sincronizzazione fallita", + "connection": { + "no_network_title": "Nessuna connessione alla rete" + } + }, + "routes": { + "pantry": { + "empty_title": "La dispensa è vuota" + }, + "diary": { + "no_matches_title": "Nessuna corrispondenza" + }, + "shopping": { + "title": "Spesa", + "empty_title": "La lista è vuota", + "group_by_recipe": "Per ricetta", + "uncategorized": "Non categorizzato", + "other": "Altro" + } + } +} diff --git a/src/lib/aiChat.js b/src/lib/aiChat.js index da8b5b3f..f94efc45 100644 --- a/src/lib/aiChat.js +++ b/src/lib/aiChat.js @@ -439,7 +439,7 @@ async function _callOpenAIWithTools(apiKey, model, messages, systemPrompt, tools }), }; if (openaiTools.length) body.tools = openaiTools; - const headers = { 'Content-Type': 'application/json' }; + const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (apiKey && apiKey !== 'no-key') headers['Authorization'] = `Bearer ${apiKey}`; const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: 'POST', diff --git a/src/lib/api-cached.js b/src/lib/api-cached.js index 1f85d190..a672cd9a 100644 --- a/src/lib/api-cached.js +++ b/src/lib/api-cached.js @@ -14,6 +14,7 @@ import { CtApiNative } from './api-native.js'; import { fullSync, startSyncLoop } from './sync.js'; +import { recipeSaveBase, withSaveBase } from './offline-edits.js'; // Cheap debounce so we don't fire 50 push attempts during a bulk // import. Last write wins; 600ms after the last write triggers a sync. @@ -119,8 +120,12 @@ wrapped.updateRecipe = async function (id, data) { const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; } + // Says which copy it was made on, as the web app does: the newer of two + // edits stays and the other is kept as an earlier version. + const now = new Date().toISOString(); + const body = withSaveBase(data, recipeSaveBase(data, null, now), now); const res = await fetch(apiUrl(`/api/recipes/${id}`), { - method: 'PUT', headers, credentials: 'include', body: JSON.stringify(data), + method: 'PUT', headers, credentials: 'include', body: JSON.stringify(body), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/src/lib/api-native.js b/src/lib/api-native.js index ae686a10..c9e749d3 100644 --- a/src/lib/api-native.js +++ b/src/lib/api-native.js @@ -16,6 +16,7 @@ import { getDb, LOCAL_USER_ID } from './db-native.js'; import { resolveAssetUrl } from './platform.js'; +import { cleanSmartFilter, matchesSmartFilter } from './smart-cookbook.js'; import { Filesystem, Directory } from '@capacitor/filesystem'; // ── Small utilities ────────────────────────────────────────────────── @@ -177,6 +178,54 @@ function _cookbookFromRow(row) { return out; } +// Note a row deleted outright here so the next sync deletes it on the +// server too. Only rows the server has (a server_id); one made here and +// never synced just goes. +async function _noteDelete(table, id) { + const r = (await _query(`SELECT server_id FROM ${table} WHERE id = ?`, [id]))[0]; + if (r?.server_id) { + await _run(`INSERT INTO sync_deletes (table_name, server_id) VALUES (?, ?)`, [table, r.server_id]); + } +} + +// A smart cookbook's recipes, newest first, by the same rules as the +// server (smart-cookbook.js), so it holds the same recipes offline. +async function _smartCookbookRecipes(filter, cols = '*') { + if (!filter || typeof filter !== 'object') return []; + const rows = await _query( + `SELECT ${cols} FROM recipes + WHERE user_id = ? AND deleted_at IS NULL + ORDER BY updated_at DESC, id DESC`, + [LOCAL_USER_ID] + ); + return rows.filter(r => matchesSmartFilter(r, filter)); +} + +// How many recipes opening the cookbook shows: what a smart cookbook's +// filter matches, or the links to recipes that still exist (a deleted +// recipe keeps its link). `cache` keeps the recipes and the link counts +// across a list, so the whole list costs two queries. +async function _cookbookCount(cb, cache = {}) { + if (cb.is_smart) { + const filter = cb.smart_filter || {}; + if (typeof filter !== 'object') return 0; + if (!cache.rows) cache.rows = await _smartCookbookRecipes({}, + 'id, deleted_at, category_id, favorite, rating, prep_minutes, cook_minutes, tags'); + return cache.rows.filter(r => matchesSmartFilter(r, filter)).length; + } + if (!cache.links) { + const rows = await _query( + `SELECT l.cookbook_id, COUNT(*) AS n + FROM recipe_cookbook_links l + JOIN recipes r ON r.id = l.recipe_id AND r.deleted_at IS NULL + GROUP BY l.cookbook_id`, + [] + ); + cache.links = new Map(rows.map(r => [r.cookbook_id, r.n])); + } + return cache.links.get(cb.id) ?? 0; +} + // ── NtApi native implementation ────────────────────────────────────── export const CtApiNative = { @@ -245,7 +294,8 @@ export const CtApiNative = { const existing = (await _query(`SELECT * FROM recipes WHERE id = ?`, [id]))[0]; if (!existing) throw new Error('Recipe not found'); const d = { ...existing, ...data }; - const img = d.img_url ?? d.imgUrl ?? existing.img_url ?? null; + // No picture is null, as the server keeps it: '' would read as a change. + const img = (d.img_url ?? d.imgUrl ?? existing.img_url) || null; await _run( `UPDATE recipes SET name = ?, description = ?, img_url = ?, servings = ?, @@ -259,7 +309,9 @@ export const CtApiNative = { d.name, d.description ?? null, img, - d.servings ?? 2, + // Not given (a rating, a favorite): left as it was, so no servings + // stays none rather than becoming 2. + d.servings ?? null, d.prep_minutes ?? null, d.cook_minutes ?? null, d.total_minutes ?? null, @@ -419,11 +471,13 @@ export const CtApiNative = { try { const recipes = await importPaprikaArchive(blob); const created = []; + const skipped = []; for (const r of recipes) { const saved = await _saveImportedRecipeLocal(this, r, { addToPantry, applyTags }); if (saved) created.push(saved); + else if (r?.name) skipped.push({ name: r.name, existing_id: (await _findImportDuplicateLocal(r))?.id || null }); } - return { recipes: created, count: created.length }; + return { recipes: created, count: created.length, skipped }; } catch (e) { if (!/no \.paprikarecipe/i.test(e.message || '')) throw e; // Not a Paprika archive — fall through to generic ZIP scan. @@ -435,7 +489,13 @@ export const CtApiNative = { } else { throw new Error('importRecipe requires `text` or `file`'); } - return _saveImportedRecipeLocal(this, recipe, { addToPantry, applyTags }); + const saved = await _saveImportedRecipeLocal(this, recipe, { addToPantry, applyTags }); + if (!saved && recipe?.name) { + // Same shape as the server's duplicate answer. + const existing = await _findImportDuplicateLocal(recipe); + return { skipped: true, reason: 'duplicate', existing_id: existing?.id || null, name: recipe.name }; + } + return saved; }, async scanRecipeZip(file) { @@ -503,23 +563,11 @@ export const CtApiNative = { const saved = await _saveImportedRecipeLocal(this, entry.recipe, { addToPantry, applyTags, dedup }); let targetRecipeId = saved?.id; if (!saved) { - skipped.push({ name: entry.recipe?.name || '?' }); // Re-resolve the existing recipe so timeline events still - // land on it. Match the same way _saveImportedRecipeLocal - // does (case-insensitive name, then source_url). - if (entry.recipe?.name) { - const lower = entry.recipe.name.toLowerCase().trim(); - const existing = (await _query( - `SELECT id FROM recipes - WHERE user_id = ? AND deleted_at IS NULL AND LOWER(name) = ? LIMIT 1`, - [LOCAL_USER_ID, lower] - ))[0] || (entry.recipe.source_url ? (await _query( - `SELECT id FROM recipes - WHERE user_id = ? AND deleted_at IS NULL AND source_url = ? LIMIT 1`, - [LOCAL_USER_ID, entry.recipe.source_url] - ))[0] : null); - targetRecipeId = existing?.id || null; - } + // land on it, and so the summary can link to it. + const existing = await _findImportDuplicateLocal(entry.recipe); + targetRecipeId = existing?.id || null; + skipped.push({ name: entry.recipe?.name || '?', existing_id: targetRecipeId }); } else { imported.push(saved); } @@ -1166,6 +1214,7 @@ export const CtApiNative = { return (await _query(`SELECT * FROM recipe_categories WHERE id = ?`, [id]))[0]; }, async deleteRecipeCategory(id) { + await _noteDelete('recipe_categories', id); await _run(`UPDATE recipes SET category_id = NULL WHERE category_id = ?`, [id]); await _run(`DELETE FROM recipe_categories WHERE id = ?`, [id]); return { ok: true }; @@ -1226,6 +1275,7 @@ export const CtApiNative = { return (await _query(`SELECT * FROM pantry_categories WHERE id = ?`, [id]))[0]; }, async deletePantryCategory(id) { + await _noteDelete('pantry_categories', id); await _run(`UPDATE pantry_items SET category_id = NULL WHERE category_id = ?`, [id]); await _run(`DELETE FROM pantry_categories WHERE id = ?`, [id]); return { ok: true }; @@ -1288,6 +1338,7 @@ export const CtApiNative = { return (await _query(`SELECT * FROM custom_units WHERE id = ?`, [id]))[0]; }, async deleteCustomUnit(id) { + await _noteDelete('custom_units', id); await _run(`DELETE FROM custom_units WHERE id = ?`, [id]); return { ok: true }; }, @@ -1296,28 +1347,39 @@ export const CtApiNative = { async getCookbooks() { const rows = await _query( - `SELECT c.*, - (SELECT COUNT(*) FROM recipe_cookbook_links l WHERE l.cookbook_id = c.id) AS recipe_count + `SELECT c.* FROM cookbooks c WHERE c.user_id = ? AND c.deleted_at IS NULL ORDER BY c.sort_order ASC, c.name COLLATE NOCASE ASC`, [LOCAL_USER_ID] ); - return rows.map(_cookbookFromRow); + const cache = {}; + const out = []; + for (const row of rows) { + const cb = _cookbookFromRow(row); + cb.recipe_count = await _cookbookCount(cb, cache); + out.push(cb); + } + return out; }, async getCookbook(id) { const cb = (await _query(`SELECT * FROM cookbooks WHERE id = ? AND deleted_at IS NULL`, [id]))[0]; if (!cb) return null; const out = _cookbookFromRow(cb); - const recipes = await _query( - `SELECT r.*, l.sort_order AS link_sort - FROM recipe_cookbook_links l - JOIN recipes r ON r.id = l.recipe_id AND r.deleted_at IS NULL - WHERE l.cookbook_id = ? - ORDER BY l.sort_order ASC, r.name COLLATE NOCASE ASC`, - [id] - ); + // A smart cookbook holds whatever its filter matches, worked out on + // every read like the server does; its links are not used. + const recipes = out.is_smart + ? await _smartCookbookRecipes(out.smart_filter || {}) + : await _query( + `SELECT r.*, l.sort_order AS link_sort + FROM recipe_cookbook_links l + JOIN recipes r ON r.id = l.recipe_id AND r.deleted_at IS NULL + WHERE l.cookbook_id = ? + ORDER BY l.sort_order ASC, r.name COLLATE NOCASE ASC`, + [id] + ); out.recipes = recipes.map(_recipeFromRow); + out.recipe_count = out.recipes.length; return out; }, async createCookbook(d) { @@ -1329,16 +1391,34 @@ export const CtApiNative = { const id = await _runInsert( `INSERT INTO cookbooks (user_id, name, slug, description, cover_image_url, is_smart, smart_filter_json, sort_order, sync_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, - [LOCAL_USER_ID, d.name, slug, d.description || null, d.cover_image_url || null, _bool(d.is_smart), _stringify(d.smart_filter), max + 1] + // The filter is kept as the server keeps it (smart-cookbook.js). + [LOCAL_USER_ID, d.name, slug, d.description || null, d.cover_image_url || null, _bool(d.is_smart), + d.is_smart && d.smart_filter && typeof d.smart_filter === 'object' ? JSON.stringify(cleanSmartFilter(d.smart_filter)) : null, max + 1] ); - return _cookbookFromRow((await _query(`SELECT * FROM cookbooks WHERE id = ?`, [id]))[0]); + const cb = _cookbookFromRow((await _query(`SELECT * FROM cookbooks WHERE id = ?`, [id]))[0]); + cb.recipe_count = await _cookbookCount(cb); + return cb; }, async updateCookbook(id, d) { + // Only what the caller sends changes, as on the server (PUT + // /api/cookbooks/:id): a rename or a new cover must not turn a smart + // cookbook into an empty plain one, or blank its name. + const existing = (await _query(`SELECT * FROM cookbooks WHERE id = ? AND deleted_at IS NULL`, [id]))[0]; + if (!existing) return null; + const name = d.name != null ? (String(d.name).trim() || existing.name) : existing.name; + const description = d.description !== undefined ? (d.description ? String(d.description).trim() : null) : existing.description; + const cover = d.cover_image_url !== undefined ? (d.cover_image_url || null) : existing.cover_image_url; + const isSmart = d.is_smart !== undefined ? _bool(d.is_smart) : existing.is_smart; + let filterJson = existing.smart_filter_json; + if (isSmart && d.smart_filter && typeof d.smart_filter === 'object') filterJson = JSON.stringify(cleanSmartFilter(d.smart_filter)); + if (!isSmart) filterJson = null; await _run( `UPDATE cookbooks SET name = ?, description = ?, cover_image_url = ?, is_smart = ?, smart_filter_json = ?, updated_at = datetime('now'), sync_status = 'pending' WHERE id = ?`, - [d.name, d.description || null, d.cover_image_url || null, _bool(d.is_smart), _stringify(d.smart_filter), id] + [name, description, cover, isSmart, filterJson, id] ); - return _cookbookFromRow((await _query(`SELECT * FROM cookbooks WHERE id = ?`, [id]))[0]); + const cb = _cookbookFromRow((await _query(`SELECT * FROM cookbooks WHERE id = ?`, [id]))[0]); + if (cb) cb.recipe_count = await _cookbookCount(cb); + return cb; }, async deleteCookbook(id) { await _run(`UPDATE cookbooks SET deleted_at = datetime('now'), sync_status = 'pending' WHERE id = ?`, [id]); @@ -1355,10 +1435,27 @@ export const CtApiNative = { [id, recipeIds[i], max + 1 + i] ); if ((r?.changes?.changes ?? r?.changes ?? 0) > 0) added++; + // Put back before the removal went up: the removal is off. + await _run( + `DELETE FROM sync_deletes WHERE table_name = 'recipe_cookbook_links' + AND server_id = (SELECT server_id FROM cookbooks WHERE id = ?) + AND recipe_server_id = (SELECT server_id FROM recipes WHERE id = ?)`, + [id, recipeIds[i]] + ); } return { ok: true, added }; }, async removeRecipeFromCookbook(id, recipeId) { + // The server only hears of a removal through sync_deletes; a link + // push only adds. + const cb = (await _query(`SELECT server_id FROM cookbooks WHERE id = ?`, [id]))[0]; + const r = (await _query(`SELECT server_id FROM recipes WHERE id = ?`, [recipeId]))[0]; + if (cb?.server_id && r?.server_id) { + await _run( + `INSERT INTO sync_deletes (table_name, server_id, recipe_server_id) VALUES ('recipe_cookbook_links', ?, ?)`, + [cb.server_id, r.server_id] + ); + } await _run(`DELETE FROM recipe_cookbook_links WHERE cookbook_id = ? AND recipe_id = ?`, [id, recipeId]); return { ok: true }; }, @@ -1374,7 +1471,7 @@ export const CtApiNative = { async reorderCookbookRecipes(id, recipeIds) { for (let i = 0; i < recipeIds.length; i++) { await _run( - `UPDATE recipe_cookbook_links SET sort_order = ? WHERE cookbook_id = ? AND recipe_id = ?`, + `UPDATE recipe_cookbook_links SET sort_order = ?, sync_status = 'pending' WHERE cookbook_id = ? AND recipe_id = ?`, [i, id, recipeIds[i]] ); } @@ -1454,6 +1551,11 @@ export const CtApiNative = { return { ok: true }; }, async clearAiChat() { + await _run( + `INSERT INTO sync_deletes (table_name, server_id) + SELECT 'ai_chat_history', server_id FROM ai_chat_history WHERE user_id = ? AND server_id IS NOT NULL`, + [LOCAL_USER_ID] + ); await _run(`DELETE FROM ai_chat_history WHERE user_id = ?`, [LOCAL_USER_ID]); return { ok: true }; }, @@ -1472,6 +1574,11 @@ export const CtApiNative = { async mintRecipeShareToken() { throw new Error('Public share links require a server connection.'); }, async revokeRecipeShareToken() { return { ok: true }; }, async getRecipesSharedWithMe() { return []; }, + // Earlier versions come from syncing with a server; a phone on its own + // has none. + async getRecipeVersions() { return []; }, + async markRecipeVersionsSeen() { return { ok: true }; }, + async restoreRecipeVersion() { throw new Error('Earlier versions require a server connection.'); }, // Kitchens (household / group sharing) — single-user local mode has // no concept of kitchens. Returns empty / no-op shapes that match @@ -1553,6 +1660,25 @@ export const NtApiNative = CtApiNative; * dedup 'skip' (default) | 'force' | 'replace' * @returns {object|null} the saved recipe row, or null when skipped */ +async function _findImportDuplicateLocal(r) { + // The recipe an import would duplicate: case-insensitive name first, + // then source_url. Mirrors the server's _findImportDuplicate. + const lower = String(r?.name || '').toLowerCase().trim(); + let existing = lower ? (await _query( + `SELECT id, name, source_url FROM recipes + WHERE user_id = ? AND deleted_at IS NULL AND LOWER(name) = ? LIMIT 1`, + [LOCAL_USER_ID, lower] + ))[0] : null; + if (!existing && r?.source_url) { + existing = (await _query( + `SELECT id, name, source_url FROM recipes + WHERE user_id = ? AND deleted_at IS NULL AND source_url = ? LIMIT 1`, + [LOCAL_USER_ID, r.source_url] + ))[0]; + } + return existing || null; +} + async function _saveImportedRecipeLocal(api, r, opts = {}) { const { addToPantry = true, dedup = 'skip' } = opts; if (!r?.name) return null; @@ -1562,20 +1688,7 @@ async function _saveImportedRecipeLocal(api, r, opts = {}) { // existing recipe alone. 'replace' updates the existing row. // 'force' creates a duplicate (the pre-dedup behaviour). if (dedup !== 'force') { - const lower = r.name.toLowerCase().trim(); - const existingByName = (await _query( - `SELECT id, name, source_url FROM recipes - WHERE user_id = ? AND deleted_at IS NULL AND LOWER(name) = ? LIMIT 1`, - [LOCAL_USER_ID, lower] - ))[0]; - let existing = existingByName; - if (!existing && r.source_url) { - existing = (await _query( - `SELECT id, name, source_url FROM recipes - WHERE user_id = ? AND deleted_at IS NULL AND source_url = ? LIMIT 1`, - [LOCAL_USER_ID, r.source_url] - ))[0]; - } + const existing = await _findImportDuplicateLocal(r); if (existing) { if (dedup === 'skip') return null; if (dedup === 'replace') { diff --git a/src/lib/api.js b/src/lib/api.js index 2b41128c..748112d7 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -176,7 +176,9 @@ const _CtApiHttp = { applyTags: !!opts.applyTags, importCategories: opts.importCategories !== false, }; - return this._imgFromApi(await this.post('/api/recipes/scrape', body)); + const res = await this.post('/api/recipes/scrape', body); + // { skipped, existing_id } when the recipe is already in the catalog. + return res?.skipped ? res : this._imgFromApi(res); }, async importRecipe({ text, file, addToPantry = true, applyTags = false, importCategories = true } = {}) { if (file) { @@ -186,14 +188,15 @@ const _CtApiHttp = { form.append('applyTags', applyTags ? 'true' : 'false'); form.append('importCategories', importCategories ? 'true' : 'false'); const res = await this._fetch('POST', '/api/recipes/import', form, true); - // Paprika archive returns { recipes, count }; single returns a row. - if (res?.recipes) return { recipes: res.recipes.map(r => this._imgFromApi(r)), count: res.count }; - return this._imgFromApi(res); + // Paprika archive returns { recipes, count, skipped }; single returns + // a row, or { skipped, existing_id } for a duplicate. + if (res?.recipes) return { recipes: res.recipes.map(r => this._imgFromApi(r)), count: res.count, skipped: res.skipped || [] }; + return res?.skipped ? res : this._imgFromApi(res); } const body = { text, addToPantry, applyTags, importCategories }; const res = await this.post('/api/recipes/import', body); - if (res?.recipes) return { recipes: res.recipes.map(r => this._imgFromApi(r)), count: res.count }; - return this._imgFromApi(res); + if (res?.recipes) return { recipes: res.recipes.map(r => this._imgFromApi(r)), count: res.count, skipped: res.skipped || [] }; + return res?.skipped ? res : this._imgFromApi(res); }, // Bulk zip import (Mealie / Tandoor / Paprika backups). Two-step: // scan returns a manifest the UI renders as a picker; commit writes @@ -256,6 +259,12 @@ const _CtApiHttp = { mintRecipeShareToken(id) { return this.post(`/api/recipes/${id}/share`); }, revokeRecipeShareToken(id) { return this.del(`/api/recipes/${id}/share`); }, + // Earlier versions: copies of a recipe a sync didn't keep, and the copy + // each restore replaced. Held by the server, so the Android app asks it. + getRecipeVersions(id) { return this.get(`/api/recipes/${id}/versions`); }, + markRecipeVersionsSeen(id) { return this.post(`/api/recipes/${id}/versions/seen`); }, + async restoreRecipeVersion(id, versionId) { return this._imgFromApi(await this.post(`/api/recipes/${id}/versions/${versionId}/restore`)); }, + // Recipe sharing — per-user grants getSharePeers() { return this.get('/api/recipes/peers'); }, async getRecipesSharedWithMe() { @@ -452,6 +461,8 @@ const SERVER_ONLY_METHODS = new Set([ 'getSharePeers', 'getRecipeShares', 'shareRecipeWithUsers', 'unshareRecipeWithUser', 'mintRecipeShareToken', 'revokeRecipeShareToken', 'getRecipesSharedWithMe', + // A recipe's earlier versions live on the server (sync keeps them there). + 'getRecipeVersions', 'markRecipeVersionsSeen', 'restoreRecipeVersion', 'getUsersList', 'getAppConfig', 'scanRecipeZip', 'commitRecipeZip', // Low-level HTTP primitives — used by components that don't have a @@ -480,6 +491,11 @@ async function _uploadImageConnected(file) { } // Dynamic proxy — picks the right impl per call based on platform mode. +const _withoutSaveHints = fn => (...args) => fn(...args.map(a => ( + a && typeof a === 'object' && !Array.isArray(a) && ('_base' in a || '_changed' in a) + ? (({ _base, _changed, ...rest }) => rest)(a) : a +))); + export const NtApi = new Proxy({}, { get(_, prop) { // Special-case uploadImage in native connected mode: server-first @@ -494,7 +510,12 @@ export const NtApi = new Proxy({}, { else if (!getServerUrl()) impl = CtApiNative; else if (SERVER_ONLY_METHODS.has(prop)) impl = _CtApiHttp; else impl = CtApiCached; - return typeof impl[prop] === 'function' ? impl[prop].bind(impl) : impl[prop]; + const fn = typeof impl[prop] === 'function' ? impl[prop].bind(impl) : impl[prop]; + // What a web save says about its copy (_base, _changed) means nothing + // to the phone's own database, which tracks its edits itself. A recipe + // save keeps it: one shared with the phone goes to the server. + if (isNative && impl !== _CtApiHttp && typeof fn === 'function' && prop !== 'updateRecipe') return _withoutSaveHints(fn); + return fn; }, }); diff --git a/src/lib/cookie-check.js b/src/lib/cookie-check.js new file mode 100644 index 00000000..0337bb69 --- /dev/null +++ b/src/lib/cookie-check.js @@ -0,0 +1,31 @@ +/** + * cookie-check.js: will the browser keep the sign-in cookie? + * + * The server marks its session cookie Secure unless INSECURE_COOKIES=1, and a + * browser only keeps a Secure cookie on a secure page (HTTPS, or localhost). + * From a plain-HTTP address the sign-in succeeds, the cookie is thrown away, + * and the next request lands back on the login page with no error (NutriTrace + * #20, #41, #43 and #195 were all this). The login page and the setup + * wizard check this up front and say why, instead of looping. + * + * `status` is the server's /api/auth/status reply. The Android app is never + * affected: it signs in with a token, not the cookie. + */ +export function cookieBlockedByHttp(status, { native = false, secureContext } = {}) { + if (native) return false; + const secure = secureContext ?? (typeof window !== 'undefined' ? window.isSecureContext : true); + return status?.secure_cookies === true && secure === false; +} + +/** Why a sign-in that succeeded did not stick: plain HTTP, or something else + * (blocked cookies, a proxy dropping them). Drives the wording on screen. */ +export function droppedCookieReason(status, opts = {}) { + return cookieBlockedByHttp(status, opts) ? 'http' : 'dropped'; +} + +// Where "How to fix it" goes for each reason: the plain-HTTP page, or the +// reverse-proxy guide when the page was secure and something else dropped it. +export const COOKIE_HELP_URLS = { + http: 'https://traceapps.github.io/docs/getting-started/lan-http/', + dropped: 'https://traceapps.github.io/docs/getting-started/reverse-proxy/', +}; diff --git a/src/lib/db-native.js b/src/lib/db-native.js index b1f78cc8..6b69e7f2 100644 --- a/src/lib/db-native.js +++ b/src/lib/db-native.js @@ -19,6 +19,7 @@ import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'; import { isNative } from './platform.js'; +import { mapSmartFilterCategory } from './smart-cookbook.js'; export const LOCAL_USER_ID = 1; const DB_NAME = 'cooktrace_local'; @@ -67,7 +68,9 @@ const SCHEMA = ` created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), deleted_at TEXT DEFAULT NULL, - sync_status TEXT DEFAULT 'synced' + sync_status TEXT DEFAULT 'synced', + server_synced_at TEXT, + server_base TEXT ); CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); CREATE INDEX IF NOT EXISTS idx_recipes_updated ON recipes(updated_at); @@ -293,6 +296,24 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_chat_user ON ai_chat_history(user_id, created_at); -- Sync infrastructure tables — not mirrored on the server side. + -- Rows deleted outright on this device that the server still has, by + -- server id, sent with the next push. A cookbook link carries both ids. + CREATE TABLE IF NOT EXISTS sync_deletes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + server_id INTEGER NOT NULL, + recipe_server_id INTEGER + ); + + -- The edit time the server stored for this phone's own last push of a + -- row, so the copy of it that comes back in a pull is known as its own. + CREATE TABLE IF NOT EXISTS sync_echoes ( + table_name TEXT NOT NULL, + row_id INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (table_name, row_id) + ); + CREATE TABLE IF NOT EXISTS sync_meta ( key TEXT PRIMARY KEY, value TEXT @@ -356,8 +377,61 @@ export async function dbInit() { await _migrateAiChatUpdatedAt(); await _migratePantryVariantColumns(); await _migrateRecipeTotalMinutes(); + await _migrateServerCopy(); await _migrateShoppingAisle(); await _backfillShoppingNames(); + await _migrateIngredientLinks(); +} + +/** + * A recipe's ingredients (JSON text) with each pantry link (pantry_item_id) + * put through `map(id)`: the id it should hold, or null to take the link + * out, or undefined when it can't be told yet. The phone keeps its own ids + * in these links, as everywhere else; the server keeps its ids. Returns + * { json, unknown }: unknown counts links `map` couldn't tell. + */ +export function mapIngredientLinks(json, map) { + let groups; + try { groups = typeof json === 'string' ? JSON.parse(json) : json; } catch { return { json, unknown: 0 }; } + if (!Array.isArray(groups)) return { json, unknown: 0 }; + let changed = false, unknown = 0; + const fix = it => { + if (!it || typeof it !== 'object' || it.pantry_item_id == null) return it; + const to = map(Number(it.pantry_item_id)); + if (to === undefined) { unknown++; return it; } + if (to === Number(it.pantry_item_id)) return it; + changed = true; + if (to == null) { const { pantry_item_id, ...rest } = it; return rest; } + return { ...it, pantry_item_id: to }; + }; + const out = groups.map(g => (g && Array.isArray(g.items) ? { ...g, items: g.items.map(fix) } : fix(g))); + return { json: changed ? (typeof json === 'string' ? JSON.stringify(out) : out) : json, unknown }; +} + +// One time: ingredient links pulled before the phone translated them held +// the server's ids. A link that is the server id of a pantry item here +// becomes that item's own id, except in a recipe edited here that links an +// item not sent yet; anything else stays as it was. +async function _migrateIngredientLinks() { + try { + const db = await getDb(); + const done = (await db.query(`SELECT value FROM sync_meta WHERE key = 'ingredient_links_local'`, []))?.values?.[0]; + if (done) return; + const pantry = (await db.query(`SELECT id, server_id FROM pantry_items`, []))?.values || []; + const byServer = new Map(pantry.filter(p => p.server_id != null).map(p => [Number(p.server_id), p.id])); + const unsent = new Set(pantry.filter(p => p.server_id == null).map(p => p.id)); + if (byServer.size) { + const recipes = (await db.query(`SELECT id, ingredients, sync_status FROM recipes`, []))?.values || []; + for (const r of recipes) { + // A recipe edited here may hold a link picked here (this phone's id, + // to an item not sent yet); one that isn't holds the server's copy. + const edited = r.sync_status === 'pending'; + const { json } = mapIngredientLinks(r.ingredients, id => (edited && unsent.has(id) ? id : byServer.has(id) ? byServer.get(id) : id)); + if (json !== r.ingredients) await db.run(`UPDATE recipes SET ingredients = ? WHERE id = ?`, [json, r.id]); + } + } + await db.run(`INSERT INTO sync_meta (key, value) VALUES ('ingredient_links_local', '1') ON CONFLICT(key) DO NOTHING`, []); + } catch (e) { console.warn('[db-native] ingredient links:', e?.message); } } // Shopping-list drag-to-reorder + pantry-category per-category @@ -396,6 +470,55 @@ async function _migrateRecipeTotalMinutes() { } catch { /* best-effort */ } } +// Per synced row: server_synced_at, the server's stamp of the copy this +// phone has, sent back with an edit so the server can tell what changed +// there since; server_base, that copy field by field, in short, so a push +// can say which fields this phone changed (rowBase / rowChanges); and +// edit_clock, 'server' when the edit time was stamped on the server's clock +// as the edit was made (the triggers below), so a clock fixed or broken +// before the push doesn't move it. Rows pulled before the columns existed +// have no stamp, so the next pull starts over once and brings them down. +async function _migrateServerCopy() { + try { + const db = await getDb(); + let reset = false; + for (const table of SYNC_TABLES) { + const info = await db.query(`PRAGMA table_info(${table})`); + const cols = new Set((info?.values || []).map(c => c.name)); + for (const col of ['server_base', 'edit_clock']) { + if (!cols.has(col)) await db.run(`ALTER TABLE ${table} ADD COLUMN ${col} TEXT`); + } + if (!cols.has('server_synced_at')) { + await db.run(`ALTER TABLE ${table} ADD COLUMN server_synced_at TEXT`); + reset = true; + } + // An edit made here (the row goes pending with a new time, or is + // deleted) is stamped with the server's clock, by the offset the + // server last measured, when there is one. + const soft = (SYNC_FIELDS[table] || []).includes('deleted_at'); + const at = soft ? `CASE WHEN NEW.deleted_at IS NOT OLD.deleted_at AND NEW.deleted_at IS NOT NULL THEN NEW.deleted_at ELSE NEW.updated_at END` : 'NEW.updated_at'; + const shift = `printf('%+.3f seconds', CAST((SELECT value FROM sync_meta WHERE key = 'clock_offset_ms') AS REAL) / 1000.0)`; + const has = `EXISTS (SELECT 1 FROM sync_meta WHERE key = 'clock_offset_ms')`; + await db.execute(` + DROP TRIGGER IF EXISTS trg_${table}_edit_clock_upd; + CREATE TRIGGER trg_${table}_edit_clock_upd AFTER UPDATE ON ${table} + FOR EACH ROW WHEN NEW.sync_status = 'pending' AND ${has} + AND (NEW.updated_at IS NOT OLD.updated_at${soft ? ' OR NEW.deleted_at IS NOT OLD.deleted_at' : ''}) + BEGIN + UPDATE ${table} SET updated_at = strftime('%Y-%m-%d %H:%M:%S', ${at}, ${shift}), edit_clock = 'server' WHERE id = NEW.id; + END; + DROP TRIGGER IF EXISTS trg_${table}_edit_clock_ins; + CREATE TRIGGER trg_${table}_edit_clock_ins AFTER INSERT ON ${table} + FOR EACH ROW WHEN NEW.sync_status = 'pending' AND ${has} + BEGIN + UPDATE ${table} SET updated_at = strftime('%Y-%m-%d %H:%M:%S', COALESCE(NEW.updated_at, datetime('now')), ${shift}), edit_clock = 'server' WHERE id = NEW.id; + END; + `); + } + if (reset) await db.run(`DELETE FROM sync_meta WHERE key = 'last_pull_at'`); + } catch (e) { console.warn('[db-native] server copy columns:', e?.message); } +} + // Mirror of the server migration: pantry_items gains generic_parent_id // and nutrition_source_variant_id (Issue #4 / variants). Older local // DBs are missing them — ALTER ADD here so dbApplyPull doesn't choke @@ -602,6 +725,83 @@ const SYNC_TABLES = [ 'ai_chat_history', ]; +/** Which local row each synced id column points at, per table. Mirrors + * TABLES[...].parents in server/routes/sync.js. Ids differ between the + * phone and the server, so these columns are translated both ways: on + * pull (server id to local id) and on push (local id to server id). */ +// Unique per account, so one made on two devices is one row. +const NATURAL_KEYS = { recipe_categories: 'slug', pantry_categories: 'slug', cookbooks: 'slug', custom_units: 'abbr' }; +// The fields that go with each one (server/lib/sync-fields.js SYNC_GROUPS). +const NAME_GROUPS = { recipe_categories: ['name', 'slug'], pantry_categories: ['name', 'slug'], cookbooks: ['name', 'slug'], custom_units: ['abbr', 'full_name', 'category'] }; +// Timestamps from either side, comparable as text. +const _ts = v => String(v || '').replace('T', ' ').replace('Z', '').replace(/\.\d+$/, ''); +// The same, as a time: SQLite's datetime('now') is UTC with no zone. +const _utcMs = v => Date.parse(_ts(v).replace(' ', 'T') + 'Z'); +// Whether the server's copy is newer than an edit made here. The server +// keeps edit times on its own clock and says how far this phone's is +// behind (clockOffsetMs); a server that doesn't say keeps this phone's +// times as sent, so they compare as they are. +function _serverNewer(serverAt, localAt, clockOffsetMs = 0) { + const a = _utcMs(serverAt), b = _utcMs(localAt); + if (!Number.isFinite(a) || !Number.isFinite(b)) return _ts(serverAt) > _ts(localAt); + return a > b + clockOffsetMs; +} + +export const SYNC_DELETABLE = ['recipe_categories', 'pantry_categories', 'custom_units', 'ai_chat_history']; + +// The fields of each synced table (sync-fields.js). A push says which of +// them this phone changed on the copy it had. +export { SYNC_FIELDS } from './sync-fields.js'; +import { SYNC_FIELDS } from './sync-fields.js'; +const _BOOLEAN_FIELDS = new Set(['favorite', 'in_stock', 'checked', 'is_smart']); +function _stableJson(v) { + if (Array.isArray(v)) return `[${v.map(_stableJson).join(',')}]`; + if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${_stableJson(v[k])}`).join(',')}}`; + return JSON.stringify(v ?? null); +} +// One field as a short key. The same value written another way (null or +// '', a number as text, JSON re-serialized, true or 1) gives the same key, +// so saving a rating, which writes every field back, changes nothing else. +function _fieldKey(f, v) { + if (v === undefined || v === '') v = null; + if (f === 'deleted_at') v = v == null ? 0 : 1; + else if (_BOOLEAN_FIELDS.has(f)) v = v === true || v === 1 || v === '1' || v === 'true' ? 1 : 0; + else if (f === 'visibility') v = v || 'private'; + else { + if (typeof v === 'string' && /^\s*[[{]/.test(v)) { try { v = JSON.parse(v); } catch { /* text */ } } + if (Array.isArray(v) && v.length === 0) v = null; + else if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) v = null; + else if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) v = Number(v); + } + const s = _stableJson(v); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } + return `${s.length}:${(h >>> 0).toString(36)}`; +} +/** A row's fields, as keys: kept for the copy the server has. */ +export function rowBase(table, row) { + return JSON.stringify(Object.fromEntries((SYNC_FIELDS[table] || []).map(f => [f, _fieldKey(f, row?.[f])]))); +} +/** The fields a row changed since the copy the server has; null if unknown. */ +export function rowChanges(table, row) { + let base = null; + try { base = row?.server_base ? JSON.parse(row.server_base) : null; } catch { base = null; } + if (!base) return null; + return (SYNC_FIELDS[table] || []).filter(f => base[f] !== _fieldKey(f, row[f])); +} + +export const SYNC_PARENTS = { + recipes: { category_id: 'recipe_categories' }, + pantry_items: { + category_id: 'pantry_categories', + generic_parent_id: 'pantry_items', + nutrition_source_variant_id: 'pantry_items', + }, + cook_diary: { recipe_id: 'recipes' }, + shopping_list: { pantry_id: 'pantry_items', recipe_id: 'recipes' }, + recipe_comments: { recipe_id: 'recipes', parent_id: 'recipe_comments' }, +}; + /** All rows with sync_status='pending' grouped by table. */ export async function dbGetPendingChanges() { if (!isNative) return {}; @@ -620,9 +820,12 @@ export async function dbGetPendingChanges() { [LOCAL_USER_ID] ); out.disabled_units = dis?.values || []; - // recipe_cookbook_links: composite key. Push the full set; cheap. - const links = await db.query(`SELECT cookbook_id, recipe_id, sort_order FROM recipe_cookbook_links`, []); + // recipe_cookbook_links: the links added or reordered here since the + // last push. The server keeps the rest. + const links = await db.query(`SELECT cookbook_id, recipe_id, sort_order FROM recipe_cookbook_links WHERE sync_status = 'pending'`, []); out.recipe_cookbook_links = links?.values || []; + const dels = await db.query(`SELECT * FROM sync_deletes ORDER BY id`, []); + out.deletes = dels?.values || []; return out; } @@ -676,22 +879,23 @@ export async function dbSetServerId(table, clientId, serverId, snapshotUpdatedAt * are translated from server ids to local ids via the per-table * server_id index. */ -export async function dbApplyPull(payload) { - if (!isNative || !payload?.tables) return; +/** The edit time the server stored for a row this phone pushed. */ +export async function dbRememberEcho(table, rowId, updatedAt) { + if (!isNative) return; const db = await getDb(); - const parents = { - category_id: ['recipe_categories', 'pantry_categories'], - recipe_id: ['recipes'], - pantry_id: ['pantry_items'], - cookbook_id: ['cookbooks'], - parent_id: ['recipe_comments'], - // Variant feature (Issue #4). Both FKs point back at pantry_items; - // translate server ids to local ids on pull so a parent or - // nutrition-source variant referenced before its own row arrives - // resolves correctly across the same pull payload. - generic_parent_id: ['pantry_items'], - nutrition_source_variant_id: ['pantry_items'], - }; + await db.run( + `INSERT INTO sync_echoes (table_name, row_id, updated_at) VALUES (?, ?, ?) + ON CONFLICT(table_name, row_id) DO UPDATE SET updated_at = excluded.updated_at`, + [table, rowId, updatedAt] + ); +} + +export async function dbApplyPull(payload, { clockOffsetMs = 0, live = () => true } = {}) { + // live(): false once the copy is changing hands (another account signing + // in, lib/local-account.js): nothing more of this pull is written. + if (!isNative || !payload?.tables) return true; + const db = await getDb(); + const install = await dbInstallId(); // Build a per-table { server_id → local_id } map by scanning the // local server_id column once. Re-scanned per pull so it picks up @@ -703,32 +907,83 @@ export async function dbApplyPull(payload) { return m; } - async function translateFK(value, candidates) { + async function translateFK(value, table) { if (value == null) return null; - for (const t of candidates) { - const m = await mapFor(t); - if (m.has(value)) return m.get(value); - } - return null; + const m = await mapFor(table); + return m.has(value) ? m.get(value) : null; } - for (const [table, rows] of Object.entries(payload.tables)) { + // Pantry items before recipes, so an ingredient's link to an item in the + // same pull finds it. + const order = t => (t === 'pantry_items' ? -1 : 0); + const tablesInOrder = Object.entries(payload.tables).sort((x, y) => order(x[0]) - order(y[0])); + for (const [table, rows] of tablesInOrder) { if (!Array.isArray(rows)) continue; if (table === 'disabled_units' || table === 'recipe_cookbook_links' || table === 'settings') continue; for (const row of rows) { - const existing = (await db.query( - `SELECT id, sync_status FROM ${table} WHERE server_id = ? LIMIT 1`, + if (!live()) return false; + let existing = (await db.query( + `SELECT * FROM ${table} WHERE server_id = ? LIMIT 1`, [row.id] ))?.values?.[0]; + // One made here before it synced, with the same name (slug) as one + // made elsewhere: they're the same, and inserting a second would + // break the unique name and stop every sync. Join them. + const key = NATURAL_KEYS[table]; + if (!existing && key && row[key] != null) { + existing = (await db.query( + `SELECT * FROM ${table} WHERE user_id = ? AND ${key} = ? AND server_id IS NULL LIMIT 1`, + [LOCAL_USER_ID, row[key]] + ))?.values?.[0]; + if (existing) await db.run(`UPDATE ${table} SET server_id = ? WHERE id = ?`, [row.id, existing.id]); + } - // Translate FK columns from server ids to local ids. + // One this phone made, whose push went in but whose answer was lost + // on the way back (it carries this install's key for that row): the + // same row, not a second one. + const made = typeof row.client_key === 'string' ? row.client_key : null; + if (!existing && made && install && made.startsWith(`${install}:${table}:`)) { + const localId = Number(made.slice(`${install}:${table}:`.length).split('@')[0]); + const mine = Number.isFinite(localId) ? (await db.query( + `SELECT * FROM ${table} WHERE id = ? AND server_id IS NULL LIMIT 1`, [localId] + ))?.values?.[0] : undefined; + // The same row only if the key is the one it would be sent with now. + if (mine && createKeyOf(install, table, mine) === made) { + existing = mine; + await db.run(`UPDATE ${table} SET server_id = ? WHERE id = ?`, [row.id, existing.id]); + } + } + + // Translate FK columns from server ids to local ids, each through + // its own parent table (a pantry item's category_id is a pantry + // category, never a recipe category). Parents in the same pull + // resolve too: parent tables come first, and self-references + // (variants, replies) arrive parent first. + const parents = SYNC_PARENTS[table] || {}; const translated = { ...row }; - for (const [fk, candidates] of Object.entries(parents)) { + delete translated.client_key; + // The server's stamp of this copy. + if ('synced_at' in translated) { + translated.server_synced_at = translated.synced_at; + delete translated.synced_at; + } + for (const [fk, parentTable] of Object.entries(parents)) { if (fk in translated && translated[fk] != null) { - translated[fk] = await translateFK(translated[fk], candidates); + translated[fk] = await translateFK(translated[fk], parentTable); } } + // An ingredient's pantry link: the server's id there, this phone's + // here. One to an item the phone doesn't have is taken out. + if (table === 'recipes' && translated.ingredients != null) { + const pantryHere = await mapFor('pantry_items'); + translated.ingredients = mapIngredientLinks(translated.ingredients, id => (pantryHere.has(id) ? pantryHere.get(id) : null)).json; + } + // A smart cookbook's category is an id too. + if (table === 'cookbooks' && translated.smart_filter_json) { + const cats = await mapFor('recipe_categories'); + translated.smart_filter_json = mapSmartFilterCategory(translated.smart_filter_json, id => cats.get(id)); + } // Local pending edits shouldn't be overwritten by the server's // pre-edit snapshot for user-editable columns. But STRUCTURAL FK @@ -739,8 +994,18 @@ export async function dbApplyPull(payload) { // Milk sits in the pull payload would leave Greenwise stuck at // its stale generic_parent_id until the next push cleared the // pending flag AND another server-side change bumped updated_at. + // Only where the server's copy is newer than the edit waiting here, + // or this side has none: every push comes back in the next pull, and + // that echo of an earlier push must not undo a newer edit. The + // stamp and fields of the copy this phone has stay too: the edit + // waiting here was made on it. if (existing && existing.sync_status === 'pending') { - const fkKeys = Object.keys(parents).filter(k => k in translated); + // This phone's own earlier push coming back is never newer than an + // edit made here since. Anything else compares by time, on one clock. + const echo = (await db.query(`SELECT updated_at FROM sync_echoes WHERE table_name = ? AND row_id = ?`, [table, existing.id]))?.values?.[0]; + const serverNewer = echo?.updated_at !== translated.updated_at + && _serverNewer(translated.updated_at, existing.updated_at, existing.edit_clock === 'server' ? 0 : clockOffsetMs); + const fkKeys = Object.keys(parents).filter(k => k in translated && (serverNewer || existing[k] == null)); if (fkKeys.length) { const setClause = fkKeys.map(k => `${k} = ?`).join(', '); const setValues = fkKeys.map(k => translated[k]); @@ -749,6 +1014,24 @@ export async function dbApplyPull(payload) { [...setValues, existing.id] ); } + // Its name (slug) on the server, when the edit waiting here didn't + // change it: the copy here keeps up, and frees the old name for a + // row that has it now (the server's names are unique). It doesn't + // count as changed here, so the edit still sends only its own fields. + if (key && translated[key] != null && translated[key] !== existing[key]) { + const group = NAME_GROUPS[table] || [key]; + const changedHere = rowChanges(table, existing) || []; + if (!group.some(f => changedHere.includes(f))) { + const fields = group.filter(f => f in translated); + let base = {}; + try { base = existing.server_base ? JSON.parse(existing.server_base) : {}; } catch { base = {}; } + for (const f of fields) base[f] = _fieldKey(f, translated[f]); + await db.run( + `UPDATE ${table} SET ${fields.map(f => `${f} = ?`).join(', ')}, server_base = ? WHERE id = ?`, + [...fields.map(f => translated[f]), existing.server_base ? JSON.stringify(base) : null, existing.id] + ); + } + } continue; } @@ -761,11 +1044,38 @@ export async function dbApplyPull(payload) { return v; }); + // The name (slug) is unique here as on the server. A row here that + // has it but is another server row is a stale copy of that row (its + // name changed there; its new copy comes in this pull): it steps + // aside rather than stop every pull on the unique name. + if (key && translated[key] != null) { + await db.run( + `UPDATE ${table} SET ${key} = ${key} || '~' || id WHERE user_id = ? AND ${key} = ? AND server_id IS NOT NULL AND server_id != ? AND id != ? AND COALESCE(sync_status, 'synced') != 'pending'`, + [LOCAL_USER_ID, translated[key], row.id, existing?.id ?? -1] + ); + // Held by an edit waiting here that gave a row this name: the row + // coming down keeps it under a name of its own until that edit has + // gone up and the server has settled the two, rather than stop + // every pull on the unique name. + const held = (await db.query( + `SELECT id FROM ${table} WHERE user_id = ? AND ${key} = ? AND id != ? LIMIT 1`, + [LOCAL_USER_ID, translated[key], existing?.id ?? -1] + ))?.values?.[0]; + if (held) { + const i = cols.indexOf(key); + if (i > -1) values[i] = `${translated[key]}~s${row.id}`; + } + } if (existing) { - const set = cols.map(c => `${c} = ?`).join(', '); + // When a row here was made stays as it is: for a row this phone + // made, the server's is when it got there. It's part of the key + // the row is sent with (createKeyOf), which must never change, or + // the same row sent again (Upload a second time) makes a second. + const keep = c => c === 'created_at' && existing.created_at != null; + const set = cols.map(c => (keep(c) ? null : `${c} = ?`)).filter(Boolean).join(', '); await db.run( `UPDATE ${table} SET ${set}, sync_status = 'synced' WHERE id = ?`, - [...values, existing.id] + [...values.filter((v, i) => !keep(cols[i])), existing.id] ); } else { await db.run( @@ -774,9 +1084,33 @@ export async function dbApplyPull(payload) { [row.id, LOCAL_USER_ID, ...values] ); } + // The copy this phone now has from the server, as it reads it back. + if (SYNC_FIELDS[table]) { + const here = (await db.query(`SELECT * FROM ${table} WHERE server_id = ? LIMIT 1`, [row.id]))?.values?.[0]; + if (here) await db.run(`UPDATE ${table} SET server_base = ?, edit_clock = NULL WHERE id = ?`, [rowBase(table, here), here.id]); + } + if (existing) await db.run(`DELETE FROM sync_echoes WHERE table_name = ? AND row_id = ?`, [table, existing.id]); } } + if (!live()) return false; + // Rows the server deleted outright: drop them here too, like the REST + // deletes do (a category's recipes and pantry items stay, without it). + const gone = payload.tables.deletions; + if (gone && typeof gone === 'object') { + const children = { recipe_categories: 'recipes', pantry_categories: 'pantry_items' }; + for (const [table, ids] of Object.entries(gone)) { + if (!SYNC_DELETABLE.includes(table) || !Array.isArray(ids)) continue; + for (const sid of ids) { + const local = (await db.query(`SELECT id FROM ${table} WHERE server_id = ?`, [sid]))?.values?.[0]; + if (!local) continue; + if (children[table]) await db.run(`UPDATE ${children[table]} SET category_id = NULL WHERE category_id = ?`, [local.id]); + await db.run(`DELETE FROM ${table} WHERE id = ?`, [local.id]); + } + } + } + + if (!live()) return false; // disabled_units: replace local set with server set. if (Array.isArray(payload.tables.disabled_units)) { await db.run(`DELETE FROM disabled_units WHERE user_id = ?`, [LOCAL_USER_ID]); @@ -789,21 +1123,33 @@ export async function dbApplyPull(payload) { } // recipe_cookbook_links: translate FKs server→local, then replace - // local set for each cookbook present in the payload. + // local set for every cookbook the server has. Its links here become + // the server's, apart from links added here that haven't gone up yet + // and links taken out here that the server hasn't heard about yet. A + // cookbook whose last link went on another device ends up empty. + if (!live()) return false; if (Array.isArray(payload.tables.recipe_cookbook_links)) { const cookbookMap = await mapFor('cookbooks'); const recipeMap = await mapFor('recipes'); + const removed = new Set(((await db.query( + `SELECT server_id, recipe_server_id FROM sync_deletes WHERE table_name = 'recipe_cookbook_links'`, [] + ))?.values || []).map(d => `${d.server_id}:${d.recipe_server_id}`)); const links = payload.tables.recipe_cookbook_links + .filter(l => !removed.has(`${l.cookbook_id}:${l.recipe_id}`)) .map(l => ({ cookbook_id: cookbookMap.get(l.cookbook_id), recipe_id: recipeMap.get(l.recipe_id), sort_order: l.sort_order ?? 0, })) .filter(l => l.cookbook_id && l.recipe_id); - const cookbookIds = [...new Set(links.map(l => l.cookbook_id))]; - if (cookbookIds.length) { - const ph = cookbookIds.map(() => '?').join(','); - await db.run(`DELETE FROM recipe_cookbook_links WHERE cookbook_id IN (${ph})`, cookbookIds); + const known = [...cookbookMap.values()]; + if (known.length) { + const ph = known.map(() => '?').join(','); + await db.run( + `DELETE FROM recipe_cookbook_links + WHERE cookbook_id IN (${ph}) AND COALESCE(sync_status, 'synced') != 'pending'`, + known + ); } for (const l of links) { await db.run( @@ -819,8 +1165,10 @@ export async function dbApplyPull(payload) { // back into the next push. Skip keys the user has a local pending // edit for — the pull would otherwise clobber the fresh value with // the server's pre-edit copy, same shape as the per-table guard above. + if (!live()) return false; if (Array.isArray(payload.tables.settings)) { for (const s of payload.tables.settings) { + if (!live()) return false; const localRow = (await db.query( `SELECT sync_status FROM user_settings WHERE user_id = ? AND key = ? LIMIT 1`, [LOCAL_USER_ID, s.key] @@ -839,6 +1187,7 @@ export async function dbApplyPull(payload) { ); } } + return true; } /** @@ -857,3 +1206,117 @@ export async function dbMarkTableSynced(table, rows) { ); } } + +// ── Whose copy (lib/local-account.js) ──────────────────────────────────── + +/** Changes made here that haven't reached the server: rows waiting to go + * up, cookbook links, deletes, and settings. */ +export async function dbCountUnsynced() { + if (!isNative) return 0; + const db = await getDb(); + const n = async sql => Number((await db.query(sql, []))?.values?.[0]?.n || 0); + let total = 0; + for (const t of SYNC_TABLES) total += await n(`SELECT COUNT(*) AS n FROM ${t} WHERE sync_status = 'pending'`); + total += await n(`SELECT COUNT(*) AS n FROM recipe_cookbook_links WHERE sync_status = 'pending'`); + total += await n(`SELECT COUNT(*) AS n FROM sync_deletes`); + total += await n(`SELECT COUNT(*) AS n FROM user_settings WHERE sync_status = 'pending'`); + return total; +} + +// Every account row the phone mirrors, and what the sync knows about the +// copy (the pull cursor, the echoes of its pushes, deletes waiting): gone, +// so the next sync fills it from the account now signed in. One-time +// markers, this install's id and the server's clock in sync_meta stay. +const _ACCOUNT_TABLES = [...SYNC_TABLES, 'recipe_cookbook_links', 'disabled_units', 'user_settings', 'sync_deletes', 'sync_echoes', 'sync_log']; +export async function dbClearUserData() { + if (!isNative) return; + const db = await getDb(); + // One statement per call: the Android plugin's execute() runs only the + // first statement of each line it splits a script into, so a script of + // deletes silently left most tables as they were. + for (const t of _ACCOUNT_TABLES) await db.run(`DELETE FROM ${t}`, []); + await db.run(`DELETE FROM sync_meta WHERE key = 'last_pull_at'`, []); + // Read back: a copy that still holds anything is never shown as cleared + // (lib/local-account.js shows the error screen instead). + for (const t of _ACCOUNT_TABLES) { + const left = Number((await db.query(`SELECT COUNT(*) AS n FROM ${t}`, []))?.values?.[0]?.n || 0); + if (left) throw new Error(`could not clear ${t}`); + } + if ((await db.query(`SELECT 1 FROM sync_meta WHERE key = 'last_pull_at'`, []))?.values?.length) throw new Error('could not clear the pull cursor'); +} + +/** + * Connecting to a server after an upload from Settings (lib/migrate.js): + * every row is this account's now, and none has an id on its server yet, + * except the rows that went up, which take the ids the server gave them + * (`uploaded`: { table: [[localId, serverId], ...] }) and come down from it + * on the next pull. The rest goes up with the next sync as new rows, with + * what points at the uploaded ones translated to the server's ids. Deletes + * meant for another server go, and the pull starts over. + */ +export async function dbKeepForNewServer(uploaded = {}) { + if (!isNative) return; + const db = await getDb(); + for (const t of SYNC_TABLES) { + if (t !== 'ai_chat_history') await db.run(`DELETE FROM ${t} WHERE deleted_at IS NOT NULL`, []); + await db.run(`UPDATE ${t} SET server_id = NULL, server_synced_at = NULL, server_base = NULL, sync_status = 'pending'`, []); + for (const pair of (Array.isArray(uploaded[t]) ? uploaded[t] : [])) { + const [localId, serverId] = (pair || []).map(Number); + if (!Number.isFinite(localId) || !Number.isFinite(serverId)) continue; + await db.run(`UPDATE ${t} SET server_id = ?, sync_status = 'synced' WHERE id = ?`, [serverId, localId]); + } + } + // One statement per call (see dbClearUserData). + await db.run(`UPDATE recipe_cookbook_links SET sync_status = 'pending'`, []); + await db.run(`DELETE FROM sync_deletes`, []); + await db.run(`DELETE FROM sync_echoes`, []); + await db.run(`DELETE FROM sync_meta WHERE key = 'last_pull_at'`, []); +} + +/** This install's id: with a row's own id and when it was made, the key + * the server knows a create by, so one sent twice is made once + * (server/lib/create-keys.js). + * + * The id is kept in this database, which Android backs up and puts back + * on a new phone (or a second one), and also outside it, in a marker that + * backups leave out (InstallMarkerPlugin). When the two differ, or the + * marker is missing, this database came from elsewhere (or from before + * the marker existed): this install takes a new id, so two phones never + * send the same key for different rows. A new id is always safe; the cost + * is that a create sent just before, whose answer never came, can be made + * a second time, once. */ +let _installId = null; +let _installIdPromise = null; +let _markerPlugin = null; +async function _installMarker() { + try { + if (!_markerPlugin) { const { registerPlugin } = await import('@capacitor/core'); _markerPlugin = registerPlugin('InstallMarker'); } + const got = await _markerPlugin.get(); + return { kept: got?.value ?? null, set: value => _markerPlugin.set({ value }) }; + } catch { return null; } // a shell without the plugin: the database's id stands +} +export function dbInstallId() { + if (_installId) return Promise.resolve(_installId); + if (!isNative) return Promise.resolve(null); + _installIdPromise ||= (async () => { + const db = await getDb(); + let id = (await db.query(`SELECT value FROM sync_meta WHERE key = 'install_id'`, []))?.values?.[0]?.value || null; + const marker = await _installMarker(); + if (!id || (marker && marker.kept !== id)) { + id = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + await db.run(`INSERT INTO sync_meta (key, value) VALUES ('install_id', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [id]); + if (marker) await marker.set(id); + } + _installId = id; + return id; + })().finally(() => { _installIdPromise = null; }); + return _installIdPromise; +} + +/** The key a row this install made is sent with: the install, the table, + * the row's own id, and when it was made (a second guard, should two + * copies of one database ever share an install id). */ +export function createKeyOf(install, table, row) { + if (!install || row?.id == null) return undefined; + return `${install}:${table}:${row.id}${row.created_at ? `@${row.created_at}` : ''}`; +} diff --git a/src/lib/db.js b/src/lib/db.js index c8f81a28..819f475b 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -3,6 +3,7 @@ * Per-user-scoped settings live in localStorage; entity data (recipes, * pantry, cook_diary, shopping_list) lives in IndexedDB stores. */ +import { settingPrefix } from './setting-key.js'; const DB = (() => { const DB_NAME = 'cooktrace'; const DB_VERSION = 1; @@ -126,8 +127,8 @@ const DB = (() => { // ── Settings (localStorage, per-user-scoped) ──────────────────────────── _settingKey(key) { - const userId = localStorage.getItem('wl:userId'); - return userId ? `wl_u${userId}_${key}` : `wl_${key}`; + // Per account, and per server in the Android app (lib/setting-key.js). + return settingPrefix() + key; }, getSetting(key, def) { const raw = localStorage.getItem(this._settingKey(key)); @@ -143,7 +144,7 @@ const DB = (() => { window.dispatchEvent(new CustomEvent('wl:setting', { detail: { key } })); }, getAllSettings() { - const prefix = this._settingKey('').replace(/[^_]*$/, ''); + const prefix = settingPrefix(); // e.g. 'wl_u3_', 'wl_u3@host_' or 'wl_' const s = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); @@ -161,15 +162,15 @@ const DB = (() => { /** Move every setting from one user-prefix to another. Used when * enable/disable user-management toggles change the key prefix. */ migrateSettingsPrefix(fromUserId, toUserId) { - const fromPrefix = fromUserId == null ? 'wl_' : `wl_u${fromUserId}_`; - const toPrefix = toUserId == null ? 'wl_' : `wl_u${toUserId}_`; + const fromPrefix = settingPrefix(fromUserId == null ? null : String(fromUserId)); + const toPrefix = settingPrefix(toUserId == null ? null : String(toUserId)); if (fromPrefix === toPrefix) return 0; let moved = 0; const orphans = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k || !k.startsWith(fromPrefix)) continue; - if (fromPrefix === 'wl_' && /^wl_u\d+_/.test(k)) continue; + if (fromPrefix === 'wl_' && /^wl_u\d+(@[^_]*)?_/.test(k)) continue; orphans.push(k); } for (const fromKey of orphans) { diff --git a/src/lib/image-cache.js b/src/lib/image-cache.js index 6961411b..80940f3f 100644 --- a/src/lib/image-cache.js +++ b/src/lib/image-cache.js @@ -147,12 +147,15 @@ async function _saveImageMap(map) { * (not the local cache) so we have the complete set before going * offline. onProgress(downloaded, total) fires for UI updates. */ -export async function cacheAllImages(onProgress) { +// `token`: the sync's session. `signal` / `live()`: a change of account +// stops it between requests (sync.js stopSync), rather than the next +// account waiting for every picture of the last one. +export async function cacheAllImages(onProgress, { token = getAuthToken(), signal = null, live = () => true } = {}) { const serverUrl = getServerUrl(); if (!serverUrl) return { total: 0, downloaded: 0, failed: 0 }; + const stopped = () => !live() || !!signal?.aborted; const imageMap = await _loadImageMap(); - const token = getAuthToken(); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; const urlPairs = []; // [{ relative, full }] @@ -183,7 +186,7 @@ export async function cacheAllImages(onProgress) { // photos also show up in the diary list below). Server returns // hydrated recipes from /api/recipes. try { - const recipes = await fetch(`${serverUrl}/api/recipes`, { headers }).then(r => r.json()); + const recipes = await (stopped() ? Promise.reject(new Error('stopped')) : fetch(`${serverUrl}/api/recipes`, { headers, signal })).then(r => r.json()); if (Array.isArray(recipes)) { for (const r of recipes) { addUrl(r.img_url); @@ -194,13 +197,13 @@ export async function cacheAllImages(onProgress) { // Pantry items — img_url on each row. try { - const items = await fetch(`${serverUrl}/api/pantry`, { headers }).then(r => r.json()); + const items = await (stopped() ? Promise.reject(new Error('stopped')) : fetch(`${serverUrl}/api/pantry`, { headers, signal })).then(r => r.json()); if (Array.isArray(items)) for (const p of items) addUrl(p.img_url); } catch {} // Cook diary entries — photo_url + photos[] JSON array on each row. try { - const diary = await fetch(`${serverUrl}/api/cook-diary`, { headers }).then(r => r.json()); + const diary = await (stopped() ? Promise.reject(new Error('stopped')) : fetch(`${serverUrl}/api/cook-diary`, { headers, signal })).then(r => r.json()); if (Array.isArray(diary)) { for (const d of diary) { addUrl(d.photo_url); @@ -213,7 +216,7 @@ export async function cacheAllImages(onProgress) { // Cookbooks — cover images. try { - const cookbooks = await fetch(`${serverUrl}/api/recipes/cookbooks`, { headers }).then(r => r.json()); + const cookbooks = await (stopped() ? Promise.reject(new Error('stopped')) : fetch(`${serverUrl}/api/cookbooks`, { headers, signal })).then(r => r.json()); if (Array.isArray(cookbooks)) for (const c of cookbooks) addUrl(c.cover_image_url); } catch {} @@ -231,6 +234,7 @@ export async function cacheAllImages(onProgress) { if (onProgress) onProgress(0, total); for (const pair of toDownload) { + if (stopped()) return { total, downloaded, failed, stopped: true }; const localUri = await _downloadImage(pair.full); if (localUri) { // Store both relative and full URL as keys so lookup works no diff --git a/src/lib/local-account.js b/src/lib/local-account.js new file mode 100644 index 00000000..f92d1bb5 --- /dev/null +++ b/src/lib/local-account.js @@ -0,0 +1,359 @@ +/** + * local-account.js: whose data the phone's local database holds. + * + * Native server mode keeps a full copy of the account in SQLite, and one + * phone can sign in to more than one account (or server). The copy is + * tagged with the account it belongs to (sync_meta 'account', JSON: the + * server's instance id "i", its address "s", the user id "u", and when + * the account was made "c"; or { local: true } for the phone's own data), + * so that: + * - signing in as someone else never shows the previous account's + * recipes, pantry, diary, shopping list or settings: the copy is + * cleared and filled from the new account on the next sync; + * - the previous account's changes that never reached the server are + * never pushed under the new one. If there are any, the person signing + * in is asked first; saying no undoes the sign-in, and the changes go + * up the next time that account signs in here; + * - signing out and back in to the same account keeps everything, + * including changes still waiting. + * + * Same account means the same user id, made at the same time, on the same + * server. The same user id at the same address is the same account unless + * it was made at another time (a server rebuilt with a fresh database). At + * another address, the server is known by the random id it reports + * (/api/auth/status instance_id), so the same server at a LAN IP and at + * its domain is one server, and two different ids are two servers. When + * either id is unknown (a server too old to report one, or one that can't + * be reached), it can't be decided here: the person is asked whether it's + * the same server, and saying no treats the copy as another account's. + * + * Copies made before this tag existed, and copies in local mode (no + * server, or after Disconnect), go to the first account that signs in, as + * they always did. Connecting to a server from Settings decides for itself + * (claimForServer), since the person chose there what happens to the data. + */ +import { writable, get } from 'svelte/store'; +import { getServerUrl, getAuthToken } from './platform.js'; +import { dbGetMeta as dbGetSyncMeta, dbSetMeta as dbSetSyncMeta, dbCountUnsynced, dbClearUserData, dbKeepForNewServer } from './db-native.js'; +import { resetUserState } from './user-state.js'; + +const META_KEY = 'account'; + +// Moves on every change of account (another one signing in, connecting to +// a server, going local, signing out). A sync started before it writes +// nothing after it (sync.js checks before every write). +let _generation = 0; +export const accountGeneration = () => _generation; +export function bumpAccountGeneration() { _generation++; } + +function _server(url = getServerUrl()) { + return String(url || '').trim().replace(/\/+$/, '').toLowerCase(); +} + +// The user id inside the session token (the server signs { id, ... }). +export function tokenUserId(token = getAuthToken()) { + try { + const part = String(token || '').split('.')[1]; + if (!part) return null; + const json = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))); + return json?.id ?? null; + } catch { + return null; + } +} + +// What the server reports about itself (/api/auth/status): its instance +// id, and the sync version it speaks (sync_version; 1 for servers before +// it existed). Asked once per address per app run, and kept (sync_meta) +// for when it can't be reached. +const _instances = new Map(); // address -> instance id +const _versions = new Map(); // address -> sync version +async function _serverInfo(serverUrl = getServerUrl(), { tries = 1 } = {}) { + const url = _server(serverUrl); + if (!url) return { i: null, v: 1 }; + if (_instances.has(url) && _versions.has(url)) return { i: _instances.get(url), v: _versions.get(url) }; + for (let n = 0; n < tries; n++) { + if (n) await new Promise(r => setTimeout(r, 1000)); + const got = await _askServer(serverUrl, url); + if (got) return got; + } + const v = Number(await dbGetSyncMeta(`syncver@${url}`)); + return { i: (await dbGetSyncMeta(`instance@${url}`)) || null, v: Number.isInteger(v) && v > 0 ? v : 1 }; +} +// One /api/auth/status: { i, v }, or null when it couldn't be reached. +async function _askServer(serverUrl, url) { + try { + const res = await fetch(`${String(serverUrl).trim().replace(/\/+$/, '')}/api/auth/status`, { + credentials: 'include', signal: AbortSignal.timeout(4000), + }); + if (res.ok) { + const body = await res.json().catch(() => ({})); + const i = typeof body?.instance_id === 'string' && body.instance_id ? body.instance_id : null; + const v = Number.isInteger(body?.sync_version) ? body.sync_version : 1; + if (i) await dbSetSyncMeta(`instance@${url}`, i); + await dbSetSyncMeta(`syncver@${url}`, String(v)); + _instances.set(url, i); + _versions.set(url, v); + return { i, v }; + } + } catch { /* not reached */ } + return null; +} +export async function serverInstanceId(serverUrl = getServerUrl(), opts = undefined) { + return (await _serverInfo(serverUrl, opts)).i; +} + +// The instance id last seen at this address, without asking the server. +async function _knownInstance(serverUrl = getServerUrl()) { + const url = _server(serverUrl); + if (!url) return null; + if (_instances.has(url)) return _instances.get(url); + return (await dbGetSyncMeta(`instance@${url}`)) || null; +} + +async function _readOwner() { + const raw = await dbGetSyncMeta(META_KEY); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} +const _unowned = owner => !owner || owner.local || owner.u == null; +async function _current(userId, serverUrl = getServerUrl()) { + return { i: await serverInstanceId(serverUrl), s: _server(serverUrl), u: userId }; +} +async function _setOwner(cur) { + await dbSetSyncMeta(META_KEY, JSON.stringify(cur)); +} +// The same account at a new address of its server: its settings, kept +// per server (lib/setting-key.js), come along. +async function _retag(owner, tag) { + if (owner && !_unowned(owner) && owner.s && tag.s && owner.s !== tag.s) { + try { (await import('./setting-key.js')).moveSettingScope(tag.u, owner.s); } catch {} + } + await _setOwner(tag); +} + +/** + * Whether a copy tagged `owner` is user `userId`'s on this server: + * { same, tag }, `tag` being the tag to write when it should change + * (claimed, or the same server at a new address). Only asks the server + * for its id when the address differs. + */ +export async function matchOwner(owner, userId, serverUrl = getServerUrl(), created = null, { tries = 1 } = {}) { + const s = _server(serverUrl); + const c = created || null; + if (_unowned(owner)) return { same: true, tag: { i: await _knownInstance(serverUrl), s, u: userId, c } }; + if (String(owner.u) !== String(userId)) return { same: false }; + // The same id on a server rebuilt with a fresh database is someone + // else: the account was made at another time (a restore keeps it). + if (owner.c && c && owner.c !== c) return { same: false }; + const fill = !owner.c && c; + if (owner.s === s) return { same: true, ...(fill ? { tag: { ...owner, c } } : {}) }; + // Another address: only the servers' own ids can tell. User ids repeat + // across servers (the first admin is 1 on every one), so without both + // ids it can't be decided here: the person is asked (`ambiguous`). + const i = await serverInstanceId(serverUrl, { tries }); + const tag = { i: i || owner.i || null, s, u: userId, c: owner.c || c }; + if (!owner.i || !i) return { same: false, ambiguous: true, tag }; + if (owner.i !== i) return { same: false }; + return { same: true, tag }; +} + +/** + * Sync gate: false only when the local data is known to be another + * account's than the one whose token the request carries (`token`, the + * one a push or pull started with). A copy with no owner yet is claimed + * for it. A token with no user id in it (single-user servers) doesn't + * block. Online, it also fills in the server's id on the tag. + */ +export async function localDataIsThisAccount(token = getAuthToken()) { + const id = tokenUserId(token); + if (id == null) return true; + const owner = await _readOwner(); + const m = await matchOwner(owner, id); + if (!m.same) return false; + if (m.tag) await _retag(owner, { ...m.tag, c: m.tag.c ?? owner?.c ?? null }); + else if (!owner.i) { + const i = await serverInstanceId(); + if (i) await _setOwner({ ...owner, i }); + } + return true; +} + +// The server's sign-in cookie from an earlier sign-in, which CapacitorHttp +// sends with every request on Android: gone at every change of account +// (sign-in as someone else, Connect, Disconnect, sign-out, a lost +// session), so it can never speak for the previous account. Only +// CookTrace's own cookies go, at every server address the phone knows (the +// one in use, the one its data came from, `urls`): a sign-in gate in front +// of the server (Cloudflare Access, Authelia) keeps its cookie, on that +// host or any other. Capacitor also keeps a copy of what a native request +// receives at the app's own address. +const SERVER_COOKIES = ['ct_token', 'ct_oidc_logout']; +export async function forgetServerCookies(...urls) { + try { + const { CapacitorCookies, Capacitor } = await import('@capacitor/core'); + if (!Capacitor?.isNativePlatform?.()) return; + let tagged = null; + try { tagged = (await _readOwner())?.s || null; } catch { /* no tag */ } + const at = new Set(); + for (const u of [...urls, getServerUrl(), tagged, globalThis.location?.origin]) { + try { if (u) at.add(new URL(u).origin + '/'); } catch { /* not an address */ } + } + for (const url of at) { + for (const key of SERVER_COOKIES) { + try { await CapacitorCookies.deleteCookie({ url, key }); } catch { /* none there */ } + } + } + } catch { /* none kept */ } +} + +/** Local mode (Disconnect, or chose local at setup): the data is this + * phone's own now, and goes to whichever account it's connected to next. */ +export async function setLocalOwner() { + resetAccountGate(); + await _syncIdle(); + await forgetServerCookies(); + await dbSetSyncMeta(META_KEY, JSON.stringify({ local: true })); +} + +/** + * Connecting to a server from Settings, after the person chose what + * happens to the phone's data. `clear` (Download) empties the copy, so it + * fills from that account. `uploaded` (Upload or Merge, lib/migrate.js): + * the rows that went up take the server's ids, so the next sync neither + * sends them again nor loses what points at them; the rest goes up with + * the next sync, as new rows. Either way it's that account's now, so + * signing in afterwards neither asks nor clears. + */ +export async function claimForServer(serverUrl, userId, { clear = false, uploaded = null, created = null } = {}) { + resetAccountGate(); + await _syncIdle(); + await forgetServerCookies(serverUrl); + await resetUserState(); + if (clear) { await dbClearUserData(); _forgetDeviceCooks(); } + else await dbKeepForNewServer(uploaded || {}); + await _setOwner({ ...(await _current(userId, serverUrl)), c: created }); +} + +// A sync still running carries the session it started with and writes +// into the copy until it ends. The account generation moves first, so it +// writes nothing more; its requests are aborted, and the copy changes +// hands only once it has actually stopped. +async function _syncIdle() { + _generation++; + try { + const { stopSync } = await import('./sync.js'); + await stopSync(); + } catch { /* nothing running */ } +} + +async function _askSameServer() { + const { confirmDialog } = await import('../stores/confirmDialog.js'); + const { _ } = await import('svelte-i18n'); + const say = get(_); + return confirmDialog({ + title: say('sync.same_server_title'), + message: say('sync.same_server'), + confirmText: say('sync.same_server_yes'), + cancelText: say('sync.same_server_no'), + }); +} + +async function _askToDiscard(count) { + const { confirmDialog } = await import('../stores/confirmDialog.js'); + const { _ } = await import('svelte-i18n'); + const say = get(_); + return confirmDialog({ + title: say('sync.switch_account_waiting_title'), + message: say('sync.switch_account_waiting', { values: { count } }), + confirmText: say('sync.switch_account_anyway'), + dangerous: true, + }); +} + +/** + * Make the copy this account's. Returns false when the person chose to + * keep the previous account's unsent changes: the caller then undoes the + * sign-in. `confirm(count)` replaces the dialog (tests). + */ +export async function prepareLocalAccount(user, { confirm = _askToDiscard, sameServer = _askSameServer } = {}) { + if (!user || user.id == null) return true; + const owner = await _readOwner(); + // A server that didn't answer is asked again before anyone is asked. + const m = await matchOwner(owner, user.id, getServerUrl(), user.created_at || null, { tries: 3 }); + if (m.same || (m.ambiguous && await sameServer())) { + if (m.tag) await _retag(owner, m.tag); + return true; + } + await _syncIdle(); + const waiting = await dbCountUnsynced(); + if (waiting > 0 && !(await confirm(waiting))) return false; + await forgetServerCookies(); + await resetUserState(); + await dbClearUserData(); + _forgetDeviceCooks(); + await _setOwner({ i: await _knownInstance(), s: _server(), u: user.id, c: user.created_at || null }); + return true; +} + +// What this phone has on the go for the last account (cooks underway and +// their timers) points at its recipes, by name: gone with its copy. +function _forgetDeviceCooks() { + try { localStorage.removeItem('ct:cooks'); localStorage.removeItem('ct:timers'); } catch {} + import('../stores/cooks.js').then(m => m.activeCooks?.set({})).catch(() => {}); + import('../stores/cookTimers.js').then(m => m.cookTimers?.set([])).catch(() => {}); +} + +// ── The gate App.svelte shows the app behind ───────────────────────────── +// One check at a time, and one per account: asking again for the account +// being checked (Svelte re-running its reactive block, a refreshed user +// object) gets the same answer, never a second dialog. A check that ends +// in signing out stays the running one until the sign-out has finished. +// state: 'idle' | 'checking' | 'ready' | 'signing_out' | 'error' +export const accountGate = writable({ state: 'idle', key: null, error: null }); +let _running = null; // { key, promise } +const _gateKey = userId => `${_server()}#${userId}`; + +export function resetAccountGate() { + if (!_running) accountGate.set({ state: 'idle', key: null, error: null }); +} + +export function ensureLocalAccount(user, { confirm, signOut, sameServer } = {}) { + if (!user || user.id == null) return Promise.resolve(true); + const key = _gateKey(user.id); + const now = get(accountGate); + if (now.state === 'ready' && now.key === key && !_running) return Promise.resolve(true); + if (_running?.key === key) return _running.promise; + const before = _running?.promise || Promise.resolve(); + const promise = before.catch(() => {}).then(() => _check(user, key, { confirm, signOut, sameServer })); + const run = { key, promise }; + _running = run; + promise.finally(() => { if (_running === run) _running = null; }); + return promise; +} + +async function _check(user, key, { confirm, signOut, sameServer }) { + accountGate.set({ state: 'checking', key, error: null }); + let ok; + try { + ok = await prepareLocalAccount(user, { ...(confirm ? { confirm } : {}), ...(sameServer ? { sameServer } : {}) }); + } catch (e) { + // Not knowing whose data this is: show nothing of it, say so, and + // offer to try again or sign out. + accountGate.set({ state: 'error', key, error: e?.message || String(e) }); + return false; + } + if (!ok) { + accountGate.set({ state: 'signing_out', key, error: null }); + try { await signOut?.(); } catch { /* the sign-in is undone either way */ } + accountGate.set({ state: 'idle', key: null, error: null }); + return false; + } + accountGate.set({ state: 'ready', key, error: null }); + return true; +} + +/** Whether the app may show the data for this user now. */ +export function accountReadyFor(gate, userId) { + return gate?.state === 'ready' && gate.key === _gateKey(userId); +} diff --git a/src/lib/local-backup.js b/src/lib/local-backup.js index 40f1203a..987f31fe 100644 --- a/src/lib/local-backup.js +++ b/src/lib/local-backup.js @@ -233,6 +233,11 @@ export async function importLocalSnapshot(snapshot) { } catch {} } } + // The snapshot replaces what's here, so deletes noted since don't apply, + // and its cookbook links go up again (a sync only keeps links the + // server has, or ones waiting to go up). + await db.run(`DELETE FROM sync_deletes`, []); + await db.run(`UPDATE recipe_cookbook_links SET sync_status = 'pending'`, []); // Images — write each base64 blob back to Data/uploads. if (snapshot.images && typeof snapshot.images === 'object') { diff --git a/src/lib/migrate.js b/src/lib/migrate.js index c45d71d8..1f09a40b 100644 --- a/src/lib/migrate.js +++ b/src/lib/migrate.js @@ -97,7 +97,19 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = errors: [], total: 0, totalSuccess: 0, + // The rows that went up, by table: [local id, server id]. They take + // the server's ids when the phone connects (lib/local-account.js + // claimForServer), so the next sync doesn't send them again. + uploaded: {}, }; + const went = (table, localId, created) => { + if (localId != null && created?.id != null) (summary.uploaded[table] ||= []).push([localId, created.id]); + }; + // A key per row (this install and the row's own id): an upload sent + // twice, or one whose answer was lost, makes each row once on the server. + let install = null, createKeyOf = () => undefined; + try { ({ createKeyOf } = await import('./db-native.js')); install = await (await import('./db-native.js')).dbInstallId(); } catch { install = null; } + const key = (table, row) => createKeyOf(install, table, row); const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, @@ -106,6 +118,18 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = // ── Categories first so recipes/pantry items can reference them ───────── // Map local category_id → newly-created server category_id so the // recipes + pantry uploads that follow can rewrite their FK fields. + // The account's categories on the server, by slug or name: a category + // the phone has that the account has too is matched, never made twice. + const _byKey = async path => { + const list = await _request('GET', `${serverUrl}${path}`, headers).catch(() => []); + const m = new Map(); + for (const x of Array.isArray(list) ? list : []) { + if (x?.slug) m.set(`s:${x.slug}`, x); + if (x?.name) m.set(`n:${String(x.name).trim().toLowerCase()}`, x); + } + return c => m.get(`s:${c.slug}`) || m.get(`n:${String(c.name || '').trim().toLowerCase()}`) || null; + }; + const theirs = { recipe: await _byKey('/api/recipes/categories'), pantry: await _byKey('/api/pantry/categories') }; const recipeCatIdMap = new Map(); try { const localCats = NtApi.getRecipeCategories ? await NtApi.getRecipeCategories().catch(() => []) : []; @@ -113,10 +137,13 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = onProgress?.('categories', i, localCats.length); const c = localCats[i]; try { - const created = await _post(`${serverUrl}/api/recipes/categories`, headers, { + // One the account has already (the default ones every account + // starts with, or an upload sent before): that one, not a second. + const created = theirs.recipe(c) || await _post(`${serverUrl}/api/recipes/categories`, headers, { name: c.name, slug: c.slug || null, color: c.color || null, }); if (created?.id) recipeCatIdMap.set(c.id, created.id); + went('recipe_categories', c.id, created); summary.success.categories++; } catch (e) { summary.errors.push({ stage: 'categories', name: c.name || `cat #${c.id}`, message: e.message }); @@ -134,10 +161,11 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = onProgress?.('categories', i, localPCats.length); const c = localPCats[i]; try { - const created = await _post(`${serverUrl}/api/pantry/categories`, headers, { + const created = theirs.pantry(c) || await _post(`${serverUrl}/api/pantry/categories`, headers, { name: c.name, slug: c.slug || null, color: c.color || null, }); if (created?.id) pantryCatIdMap.set(c.id, created.id); + went('pantry_categories', c.id, created); summary.success.categories++; } catch (e) { summary.errors.push({ stage: 'categories', name: c.name || `pcat #${c.id}`, message: e.message }); @@ -146,7 +174,50 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = } } catch { /* non-fatal */ } - // ── Recipes ───────────────────────────────────────────────────────────── + // ── Pantry items ──────────────────────────────────────────────────────── + // Items first (recipes, the shopping list and other items point at them), + // and every link sent as the server's id: an id from this phone's own + // numbering would point at whatever has that id on the server. Items + // that aren't variants go first, then their variants, then each item's + // nutrition source, once the variant it points at is there. + const localPantry = await NtApi.getPantry().catch(() => []); + const pantryIdMap = new Map(); // local id -> server id + // An ingredient's link holds this phone's id for the item (db-native.js + // keeps them so); the server gets the id it gave that item. + const pantryLink = raw => (raw == null || raw === '' ? null : (pantryIdMap.get(Number(raw)) ?? null)); + const pantryOrder = [ + ...localPantry.filter(p => p.generic_parent_id == null), + ...localPantry.filter(p => p.generic_parent_id != null), + ]; + for (let i = 0; i < pantryOrder.length; i++) { + onProgress?.('pantry', i, pantryOrder.length); + const p = pantryOrder[i]; + try { + const { id, user_id, sync_status, server_id, created_at, updated_at, deleted_at, + category_id, generic_parent_id, nutrition_source_variant_id, + server_synced_at, server_base, edit_clock, ...rest } = p; + const created = await _post(`${serverUrl}/api/pantry`, headers, { + ...rest, + client_key: key('pantry_items', p), + category_id: category_id != null ? (pantryCatIdMap.get(category_id) ?? null) : null, + generic_parent_id: generic_parent_id != null ? (pantryIdMap.get(generic_parent_id) ?? null) : null, + }); + if (created?.id) pantryIdMap.set(id, created.id); + went('pantry_items', id, created); + summary.success.pantry++; + } catch (e) { + summary.errors.push({ stage: 'pantry', name: p.name || `pantry #${p.id}`, message: e.message }); + } + } + for (const p of localPantry) { + const own = pantryIdMap.get(p.id); + const source = p.nutrition_source_variant_id != null ? pantryIdMap.get(p.nutrition_source_variant_id) : null; + if (!own || !source) continue; + try { await _request('PUT', `${serverUrl}/api/pantry/${own}`, headers, { nutrition_source_variant_id: source }); } + catch (e) { summary.errors.push({ stage: 'pantry', name: p.name || `pantry #${p.id}`, message: e.message }); } + } + + // ── Recipes ─────────────────────────────────────────────────────────────── const localRecipes = await NtApi.getRecipes().catch(() => []); const recipeIdMap = new Map(); // local id → server id, for diary + shopping rewire for (let i = 0; i < localRecipes.length; i++) { @@ -156,35 +227,26 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = const { id, user_id, sync_status, server_id, created_at, updated_at, deleted_at, cook_count, last_cooked_at, share_token, category_id, ...rest } = r; + // Ingredients linked to pantry items: the server's ids for them. + const ingredients = Array.isArray(rest.ingredients) + ? rest.ingredients.map(g => (g && Array.isArray(g.items) + ? { ...g, items: g.items.map(it => (it && it.pantry_item_id != null ? { ...it, pantry_item_id: pantryLink(it.pantry_item_id) } : it)) } + : g)) + : rest.ingredients; const created = await _post(`${serverUrl}/api/recipes`, headers, { ...rest, + ingredients, + client_key: key('recipes', r), category_id: category_id != null ? (recipeCatIdMap.get(category_id) ?? null) : null, }); if (created?.id) recipeIdMap.set(id, created.id); + went('recipes', id, created); summary.success.recipes++; } catch (e) { summary.errors.push({ stage: 'recipes', name: r.name || `recipe #${r.id}`, message: e.message }); } } - // ── Pantry items ──────────────────────────────────────────────────────── - const localPantry = await NtApi.getPantry().catch(() => []); - for (let i = 0; i < localPantry.length; i++) { - onProgress?.('pantry', i, localPantry.length); - const p = localPantry[i]; - try { - const { id, user_id, sync_status, server_id, created_at, updated_at, deleted_at, - category_id, ...rest } = p; - await _post(`${serverUrl}/api/pantry`, headers, { - ...rest, - category_id: category_id != null ? (pantryCatIdMap.get(category_id) ?? null) : null, - }); - summary.success.pantry++; - } catch (e) { - summary.errors.push({ stage: 'pantry', name: p.name || `pantry #${p.id}`, message: e.message }); - } - } - // ── Cook diary (rewire recipe_id to its new server id) ────────────────── const localDiary = await NtApi.getCookDiary().catch(() => []); for (let i = 0; i < localDiary.length; i++) { @@ -197,10 +259,12 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = summary.errors.push({ stage: 'diary', name: d.date || `diary #${d.id}`, message: 'recipe not uploaded — skipping' }); continue; } - await _post(`${serverUrl}/api/cook-diary`, headers, { + const created = await _post(`${serverUrl}/api/cook-diary`, headers, { ...rest, + client_key: key('cook_diary', d), recipe_id: remappedRecipeId, }); + went('cook_diary', id, created); summary.success.diary++; } catch (e) { summary.errors.push({ stage: 'diary', name: d.date || `diary #${d.id}`, message: e.message }); @@ -215,10 +279,13 @@ export async function uploadLocalToServer({ serverUrl, authToken, onProgress } = try { const { id, user_id, sync_status, server_id, created_at, updated_at, deleted_at, ...rest } = s; const remappedRecipeId = s.recipe_id != null ? (recipeIdMap.get(s.recipe_id) ?? null) : null; - await _post(`${serverUrl}/api/shopping`, headers, { + const created = await _post(`${serverUrl}/api/shopping`, headers, { ...rest, + client_key: key('shopping_list', s), recipe_id: remappedRecipeId, + pantry_id: s.pantry_id != null ? (pantryIdMap.get(s.pantry_id) ?? null) : null, }); + went('shopping_list', id, created); summary.success.shopping++; } catch (e) { summary.errors.push({ stage: 'shopping', name: s.name || `shop #${s.id}`, message: e.message }); @@ -272,6 +339,8 @@ export async function wipeLocalData() { 'recipes', 'pantry_items', 'cook_diary', 'shopping_list', 'recipe_categories', 'pantry_categories', 'cookbooks', 'recipe_cookbook_links', 'recipe_comments', 'custom_units', 'disabled_units', + // Deletes noted for the old server must not reach the one pulled next. + 'sync_deletes', ]; let touched = 0; for (const t of tables) { diff --git a/src/lib/off.js b/src/lib/off.js index 3db2b5b3..c2e69baa 100644 --- a/src/lib/off.js +++ b/src/lib/off.js @@ -17,6 +17,7 @@ */ import { apiUrl, isNative, getServerUrl } from './platform.js'; import { deriveSodiumSalt } from './nutriments.js'; +import { settingPrefix } from './setting-key.js'; const OFF_BASE = 'https://world.openfoodfacts.org'; @@ -26,8 +27,7 @@ const OFF_BASE = 'https://world.openfoodfacts.org'; function _getOffSearchCountry() { try { if (typeof localStorage === 'undefined') return null; - const userId = localStorage.getItem('wl:userId'); - const setKey = userId ? `wl_u${userId}_offSearchCountry` : 'wl_offSearchCountry'; + const setKey = settingPrefix() + 'offSearchCountry'; const raw = localStorage.getItem(setKey); if (!raw) return null; const country = JSON.parse(raw); @@ -43,8 +43,7 @@ function _getOffSearchCountry() { function _getOffSearchLanguage() { try { if (typeof localStorage === 'undefined') return 'en'; - const userId = localStorage.getItem('wl:userId'); - const setKey = userId ? `wl_u${userId}_offSearchLanguage` : 'wl_offSearchLanguage'; + const setKey = settingPrefix() + 'offSearchLanguage'; const raw = localStorage.getItem(setKey); if (!raw) return 'en'; const lang = JSON.parse(raw); diff --git a/src/lib/offline-api.js b/src/lib/offline-api.js index f80cb710..524fbe81 100644 --- a/src/lib/offline-api.js +++ b/src/lib/offline-api.js @@ -23,7 +23,7 @@ import { writable } from 'svelte/store'; import { isOfflineError, isMirroredGet, mirrorKey, pathOf, writeOp, collapseOps, sentSeqs, answerWithOps, newTempId, createdId, remapIds, remapPath, describeOp, shouldRetryStatus, - MAKES_A_ROW, staleAnswerKeys, queuedReply, + MAKES_A_ROW, staleAnswerKeys, queuedReply, saveBase, saveTarget, withSaveBase, readTable, stampedRows, } from './offline-edits.js'; // Loaded with everything else, never fetched on demand: a picture is kept // exactly when there is no connection to fetch a separate file with, and a @@ -335,7 +335,7 @@ async function _flushOnce() { const body = op.body == null ? undefined : remapIds(op.body, map); let answer; try { - answer = await _http._fetch(op.method, path, body); + answer = await _http._fetch(op.method, path, withSaveBase(body, op.sync)); } catch (err) { if (isOfflineError(err)) { stopped = { offline: true }; break; } // A server that is struggling, or a session that needs signing in @@ -513,6 +513,23 @@ async function _straight(http, method, path, body, isUpload) { } } +// The rows this page has shown, by table, id and the server's stamp of +// that copy: a save of one says what it changed on the copy it was made on +// (saveBase), not on a newer one another tab has read since. +const _shown = new Map(); +const SHOWN_MAX = 3000; +function _noteShown(url, answer) { + const table = readTable(url); + if (!table) return; + for (const r of stampedRows(answer)) { + const key = `${table}:${r.id}:${r.synced_at}`; + _shown.delete(key); + _shown.set(key, r); + } + while (_shown.size > SHOWN_MAX) _shown.delete(_shown.keys().next().value); +} +const _seen = (table, id, stamp) => _shown.get(`${table}:${id}:${stamp}`) || null; + /** One call, with the copy and the queue behind it. */ export async function offlineFetch(http, method, path, body) { _http = http; @@ -522,6 +539,7 @@ export async function offlineFetch(http, method, path, body) { try { const answer = await http._fetch(m, path, body); if (isMirroredGet(path)) await _remember(mirrorKey(path), answer); + _noteShown(path, answer); _publish({ online: true }); const ops = await _loadOps(); return ops.length ? answerWithOps(path, answer, ops) : answer; @@ -530,6 +548,7 @@ export async function offlineFetch(http, method, path, body) { _publish({ online: false }); if (!isMirroredGet(path)) throw _offlineError(); const mirrored = await _recall(path); + _noteShown(path, mirrored); // Even with no copy of this call, what is queued for it may be the // whole answer: a shopping list written from scratch in a shop. const answer = answerWithOps(path, mirrored, await _loadOps()); @@ -542,17 +561,23 @@ export async function offlineFetch(http, method, path, body) { } const target = remapPath(String(path), _swapped); + // A save of a row says which copy it was made on and what it changed, so + // an edit made since on another device isn't overwritten by an older one, + // and what this one didn't change stays as it is there (saveBase). + const sync = saveTarget(m, target) ? saveBase(m, target, body, _seen, new Date().toISOString()) : null; + if (saveTarget(m, target)) body = withSaveBase(body, null); const op = writeOp(m, target, body); if (!op) { // Sharing, kitchens, imports, AI, admin: still the server's job. - return _straight(http, m, target, body); + // (Categories, cookbooks and units go straight up too, saying their copy.) + return _straight(http, m, target, withSaveBase(body, sync)); } _changedSomething = true; const queued = await _loadOps(); if (_online() && !queued.length) { try { - const answer = await http._fetch(m, target, body); + const answer = await http._fetch(m, target, withSaveBase(body, sync)); // Whatever this change makes stale goes, the same as after a replay. // Without this, something deleted while online was still in the copy // held here, and came back the moment the connection did not: the @@ -573,6 +598,7 @@ export async function offlineFetch(http, method, path, body) { body, at: Date.now(), ...op, + ...(sync ? { sync } : {}), ...(tempId != null ? { tempId, id: tempId, key: `${op.kind.replace('-create', '')}:${tempId}` } : {}), }); if (!stored) throw _offlineError(); diff --git a/src/lib/offline-edits.js b/src/lib/offline-edits.js index ecb2ec3f..941030c0 100644 --- a/src/lib/offline-edits.js +++ b/src/lib/offline-edits.js @@ -17,6 +17,8 @@ * built for: ticking things off a list has to work. */ +import { SYNC_FIELDS } from './sync-fields.js'; + /** Was this the server being unreachable, rather than a real answer? */ export function isOfflineError(err) { if (!err) return false; @@ -39,7 +41,7 @@ export const mirrorKey = (url) => String(url).replace(/^https?:\/\/[^/]+/, ''); export const MIRRORED_GETS = [ /^\/api\/recipes$/, /^\/api\/recipes\/\d+$/, - /^\/api\/recipes\/\d+\/(comments|cooks|tools|tags)$/, + /^\/api\/recipes\/\d+\/(comments|cooks|tools|tags|versions)$/, /^\/api\/recipes\/categories$/, /^\/api\/cookbooks$/, /^\/api\/cookbooks\/\d+$/, @@ -124,6 +126,168 @@ export function writeOp(method, url, body) { return null; } +// The fields of a recipe a save may change, as the server compares two +// copies (server/lib/recipe-versions.js VERSION_FIELDS and META_FIELDS). +const RECIPE_FIELDS = [ + 'name', 'description', 'ingredients', 'steps', 'tags', 'tools', 'notes', + 'servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', + 'nutrition', 'category_id', 'img_url', 'source_url', 'video_url', 'yield_text', + 'rating', 'favorite', 'visibility', +]; +const FIELDS = { ...SYNC_FIELDS, recipes: RECIPE_FIELDS }; + +/** + * The copy of a recipe a page shows, as a save made on it describes it: + * the server's stamp of that copy and its fields, the picture as a save + * sends it. Pages pass it with a save as `_base`. + */ +export function recipeBaseOf(recipe) { + if (!recipe || typeof recipe !== 'object') return null; + const out = { synced_at: typeof recipe.synced_at === 'string' ? recipe.synced_at : null }; + for (const f of RECIPE_FIELDS) out[f] = recipe[f] ?? null; + out.img_url = recipe.imgUrl || recipe.img_url || null; + return out; +} + +// A field's value as two copies on a page compare: the same value written +// another way is the same. +function _norm(v) { + if (v === undefined || v === '' || v === false) v = v === false ? 0 : null; + if (v === true) v = 1; + if (Array.isArray(v) && v.length === 0) v = null; + if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) v = null; + if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) v = Number(v); + return v; +} +function _stable(v) { + v = _norm(v); + if (Array.isArray(v)) return `[${v.map(_stable).join(',')}]`; + if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${_stable(v[k])}`).join(',')}}`; + return JSON.stringify(v ?? null); +} +// Ingredients grouped or as the older flat list: the same ingredients. +function _groups(v) { + if (!Array.isArray(v) || !v.length) return v; + if (v.every(g => g && typeof g === 'object' && !Array.isArray(g.items))) return [{ name: '', items: v }]; + return v.map(g => ({ name: g?.name || '', items: Array.isArray(g?.items) ? g.items : [] })); +} +const _val = (o, f) => { + const v = f === 'img_url' ? (o?.imgUrl ?? o?.img_url) : o?.[f]; + return f === 'ingredients' ? _groups(v) : v; +}; + +/** The fields a save changes on its page's copy (only those it sends). */ +export function changedFields(table, body, base) { + return (FIELDS[table] || []).filter(f => f !== 'deleted_at' && _val(body, f) !== undefined && _stable(_val(body, f)) !== _stable(_val(base, f))); +} +export const recipeChangedFields = (body, base) => changedFields('recipes', body, base); + +/** + * Which row a save is to, by its address: { table, id }, or null for a + * request that isn't one. + */ +export function saveTarget(method, url) { + const m = (method || '').toUpperCase(); + const path = pathOf(url); + const rules = [ + ['PUT', /^\/api\/recipes\/categories\/(\d+)$/, 'recipe_categories'], + ['PUT', /^\/api\/recipes\/\d+\/cooks\/(\d+)$/, 'cook_diary'], + ['PUT', /^\/api\/recipes\/\d+\/comments\/(\d+)$/, 'recipe_comments'], + ['PUT', /^\/api\/recipes\/(\d+)$/, 'recipes'], + ['PUT', /^\/api\/pantry\/categories\/(\d+)$/, 'pantry_categories'], + ['PUT', /^\/api\/pantry\/(\d+)$/, 'pantry_items'], + ['PATCH', /^\/api\/pantry\/(\d+)\/stock$/, 'pantry_items'], + ['PUT', /^\/api\/shopping\/(\d+)$/, 'shopping_list'], + ['PATCH', /^\/api\/shopping\/(\d+)\/check$/, 'shopping_list'], + ['PUT', /^\/api\/cook-diary\/(\d+)$/, 'cook_diary'], + ['PUT', /^\/api\/cookbooks\/(\d+)$/, 'cookbooks'], + ['PUT', /^\/api\/units\/(\d+)$/, 'custom_units'], + ]; + for (const [rm, re, table] of rules) { + const hit = m === rm && path.match(re); + if (hit) return { table, id: Number(hit[1]), toggle: rm === 'PATCH' }; + } + return null; +} + +/** Which table the rows of a read belong to, by its address. */ +export function readTable(url) { + const path = pathOf(url); + if (/^\/api\/recipes\/categories$/.test(path)) return 'recipe_categories'; + if (/^\/api\/recipes\/\d+\/cooks$/.test(path)) return 'cook_diary'; + if (/^\/api\/recipes\/\d+\/comments$/.test(path)) return 'recipe_comments'; + if (/^\/api\/recipes(\/\d+)?$/.test(path)) return 'recipes'; + if (/^\/api\/pantry\/categories$/.test(path)) return 'pantry_categories'; + if (/^\/api\/pantry(\/\d+)?$/.test(path)) return 'pantry_items'; + if (/^\/api\/shopping$/.test(path)) return 'shopping_list'; + if (/^\/api\/cook-diary(\/|$)/.test(path)) return 'cook_diary'; + if (/^\/api\/cookbooks(\/\d+)?$/.test(path)) return 'cookbooks'; + if (/^\/api\/units$/.test(path)) return 'custom_units'; + return null; +} + +/** The rows in an answer that carry the server's stamp: its own, a list, or `items` / `custom`. */ +export function stampedRows(answer) { + const out = []; + const take = r => { if (r && typeof r === 'object' && r.id != null && typeof r.synced_at === 'string') out.push(r); }; + if (Array.isArray(answer)) answer.forEach(take); + else if (answer && typeof answer === 'object') { + take(answer); + for (const k of ['items', 'custom', 'entries']) if (Array.isArray(answer[k])) answer[k].forEach(take); + } + return out; +} + +/** + * What a save says about the copy it was made on, for the server to keep + * the newer of two edits (and, for a recipe, the other as an earlier + * version) and to leave alone what the save didn't change: the server's + * stamp of the page's copy, and the fields the save changes. The page's + * copy is `_base`, or the row this browser showed with the stamp the body + * carries (seen(table, id, stamp)); a toggle (PATCH) changes the fields it + * sends. Null when the save can't say (sent as before). + */ +export function saveBase(method, url, body, seen, editedAt) { + const t = saveTarget(method, url); + if (!t || !body || typeof body !== 'object') return null; + if (t.toggle) { + const changed = Object.keys(body).filter(f => !f.startsWith('_') && (FIELDS[t.table] || []).includes(f)); + return { base_synced_at: null, changed, edited_at: editedAt }; + } + const fields = FIELDS[t.table] || []; + const page = body._base && typeof body._base === 'object' ? body._base : null; + const stamp = page?.synced_at ?? body.synced_at; + const said = Array.isArray(body._changed) ? body._changed.filter(f => fields.includes(f)) : null; + if (typeof stamp !== 'string' || !stamp) { + // No copy's stamp: a save of a few fields (not a whole row) changes the + // fields it sends, or what it says; one with a page's copy but no stamp + // changes what differs from it. Otherwise it can't say. + const sent = Object.keys(body).filter(f => !f.startsWith('_') && fields.includes(f)); + if (said) return { base_synced_at: null, changed: said, edited_at: editedAt }; + if (page) return { base_synced_at: null, changed: changedFields(t.table, body, page), edited_at: editedAt }; + if (body.id == null && sent.length) return { base_synced_at: null, changed: sent, edited_at: editedAt }; + return null; + } + const copy = page || (typeof seen === 'function' ? seen(t.table, t.id, stamp) : null); + let changed = null; + if (said) changed = said; + else if (copy) changed = changedFields(t.table, body, copy); + return { base_synced_at: stamp, changed, edited_at: editedAt }; +} + +/** A recipe save's base, the kept copy standing in when it is the same copy. */ +export function recipeSaveBase(body, kept, editedAt) { + return saveBase('PUT', `/api/recipes/${body?.id ?? 0}`, body, + (table, id, stamp) => (kept && typeof kept === 'object' && kept.synced_at === stamp ? recipeBaseOf(kept) : null), editedAt); +} + +/** A save's body with what it says about its copy, and this device's time. */ +export function withSaveBase(body, sync, now = new Date().toISOString()) { + if (!body || typeof body !== 'object') return body; + const { _sync, _base, _changed, ...rest } = body; + return sync ? { ...rest, _sync: { ...sync, client_now: now } } : rest; +} + /** Creates that make a row, so they need a temporary id to hold on to. */ export const MAKES_A_ROW = ['shopping-create', 'pantry-create', 'diary-create', 'recipe-create', 'comment-create']; @@ -147,11 +311,21 @@ export function collapseOps(ops) { continue; } } - byKey.set(op.key, op); + // Two saves of one recipe go up as one, the latest body: it says the + // first one's copy, and every field either changed, or the second + // save's copy (which already showed the first) would hide the first. + byKey.set(op.key, prev?.sync && op.sync ? { ...op, sync: combineSaveBases(prev.sync, op.sync) } : op); } return [...byKey.values(), ...out].sort((a, b) => (a.seq || 0) - (b.seq || 0)); } +/** Two saves of one recipe as one: the first copy, every field changed. */ +export function combineSaveBases(first, second) { + const changed = Array.isArray(first.changed) && Array.isArray(second.changed) + ? [...new Set([...first.changed, ...second.changed])] : null; + return { ...second, base_synced_at: first.base_synced_at ?? second.base_synced_at, changed }; +} + /** * Which queued requests a replay accounted for: the ones whose key went up * successfully, plus any that cancelled each other out and were never sent. diff --git a/src/lib/oidc-app-handoff.js b/src/lib/oidc-app-handoff.js new file mode 100644 index 00000000..970128ef --- /dev/null +++ b/src/lib/oidc-app-handoff.js @@ -0,0 +1,47 @@ +/** + * Native SSO hand-off. + * + * The Android app finishes SSO through a cooktrace://oidc-callback deep link, + * which any installed app can register for. So the link no longer carries the + * session token: before opening the sign-in page the app makes a random + * secret, sends only its SHA-256 (app_challenge), and the link comes back + * with a single-use code. The app then swaps code + secret for the token at + * POST /api/auth/oidc/handoff; another app that caught the link has the code + * but not the secret. + */ +import { apiUrl } from './platform.js'; + +const KEY = 'ct:oidc_app_verifier'; + +function _b64url(bytes) { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Query-string fragment to append to the native login URL. */ +export async function appChallengeParam() { + // Without WebCrypto (not a secure context) fall back to the old link that + // carries the token, rather than not signing in at all. + if (!globalThis.crypto?.subtle) return ''; + const raw = new Uint8Array(32); + crypto.getRandomValues(raw); + const verifier = _b64url(raw); + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); + try { localStorage.setItem(KEY, verifier); } catch {} + return `&app_challenge=${_b64url(digest)}`; +} + +/** Swap the deep link's code for { token, id_token_hint, provider_id }. */ +export async function redeemHandoff(code) { + let verifier = null; + try { verifier = localStorage.getItem(KEY); localStorage.removeItem(KEY); } catch {} + const res = await fetch(apiUrl('/api/auth/oidc/handoff'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, verifier }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.token) throw new Error(data.error || 'Sign-in failed'); + return data; +} diff --git a/src/lib/openai-chat-params.js b/src/lib/openai-chat-params.js index fc4cb739..6e3b455e 100644 --- a/src/lib/openai-chat-params.js +++ b/src/lib/openai-chat-params.js @@ -8,12 +8,16 @@ const GPT_56_MODEL = /^gpt-5\.6(?:-|$)/; * OpenAI's current API uses max_completion_tokens. Keep max_tokens for * third-party OpenAI-compatible endpoints, many of which have not adopted * the newer field. + * + * `stream: false` is OpenAI's default, but some compatible gateways stream + * when the field is missing, and the reply then fails to parse as JSON + * (TraceApps/nutritrace#258). Every caller reads one JSON reply, so say so. */ export function getOpenAIChatParams({ baseUrl, model, hasTools, maxTokens = 4096 }) { const isOfficialOpenAI = baseUrl?.replace(/\/+$/, '') === OPENAI_API_ORIGIN; - if (!isOfficialOpenAI) return { max_tokens: maxTokens }; + if (!isOfficialOpenAI) return { stream: false, max_tokens: maxTokens }; - const params = { max_completion_tokens: maxTokens }; + const params = { stream: false, max_completion_tokens: maxTokens }; // GPT-5.6 Chat Completions requires effective reasoning "none" when // function tools are present. This does not disable tools: the supplied // tools remain available and tool_choice keeps its default "auto" behavior. diff --git a/src/lib/platform.js b/src/lib/platform.js index 9c88cb3e..02d7c6f9 100644 --- a/src/lib/platform.js +++ b/src/lib/platform.js @@ -239,3 +239,25 @@ export function apiUrl(path) { } return _basePath + path; } + +/** + * The address of a recipe's public link, on the server this app talks to. + * A real path, not a #/ route: a server never sees what follows the #, so + * only a path lets chat apps show a preview of the recipe. + */ +export function publicRecipeUrl(token) { + const root = isNative ? String(getServerUrl() || '').replace(/\/+$/, '') : `${window.location.origin}${_basePath}`; + return `${root}/r/${token}`; +} + +/** + * The token when this page is a public recipe link, else null: /r/<token>, + * or #/r/<token> from links copied before they had a real path. + */ +export function publicRecipeToken() { + if (isNative || typeof window === 'undefined') return null; + let path = window.location.pathname; + if (_basePath && path.startsWith(_basePath)) path = path.slice(_basePath.length); + const m = /^\/r\/([A-Za-z0-9_-]{8,64})\/?$/.exec(path) || /^#\/r\/([A-Za-z0-9_-]{8,64})\/?$/.exec(window.location.hash); + return m ? m[1] : null; +} diff --git a/src/lib/recipe-importers-client.js b/src/lib/recipe-importers-client.js index 8f4810bb..c10d252d 100644 --- a/src/lib/recipe-importers-client.js +++ b/src/lib/recipe-importers-client.js @@ -86,7 +86,9 @@ export async function importPaprikaArchive(blob) { } export async function loadRecipeZip(blob) { - return JSZip.loadAsync(blob); + // Unpacked here too, not only in scanLoadedZip: callers that load once to + // read pictures and scan separately get the same <id>/image paths. + return _expandNestedZips(await JSZip.loadAsync(blob)); } export async function readImageFromLoadedZip(zip, entryName) { @@ -107,7 +109,38 @@ export async function scanRecipeZip(blob) { return scanLoadedZip(zip); } +// ── Zips inside the zip ──────────────────────────────────────────────────── +// Tandoor's export is a zip of zips: export.zip holds one <id>.zip per +// recipe, each with recipe.json and image.<ext> (cookbook/integration/ +// default.py). Unpack every inner zip in place, under a folder named after +// it (<id>/recipe.json beside <id>/image.png), so the walker below finds +// each recipe with its own picture, the way other exports lay them out. +// One level deep, and capped, so a zip of zips can't balloon in memory. +const NESTED_ZIP_MAX_BYTES = 1024 * 1024 * 1024; +async function _expandNestedZips(zip) { + const inner = Object.values(zip.files).filter(e => + !e.dir && /\.zip$/i.test(e.name) && !/^__MACOSX\//.test(e.name) + ); + let total = 0; + for (const entry of inner) { + let sub; + try { sub = await JSZip.loadAsync(await entry.async('uint8array')); } + catch { continue; } // not a zip after all: leave it + const base = entry.name.replace(/\.zip$/i, ''); + for (const f of Object.values(sub.files)) { + if (f.dir || /^__MACOSX\//.test(f.name)) continue; + const bytes = await f.async('uint8array'); + total += bytes.length; + if (total > NESTED_ZIP_MAX_BYTES) throw new Error('The zips inside this archive unpack to more than 1 GB.'); + zip.file(`${base}/${f.name}`, bytes); + } + zip.remove(entry.name); + } + return zip; +} + export async function scanLoadedZip(zip) { + await _expandNestedZips(zip); const out = []; const paprikaEntries = Object.values(zip.files).filter(e => !e.dir && e.name.endsWith('.paprikarecipe')); diff --git a/src/lib/setting-key.js b/src/lib/setting-key.js new file mode 100644 index 00000000..fe549bfb --- /dev/null +++ b/src/lib/setting-key.js @@ -0,0 +1,90 @@ +/** + * setting-key.js: where an account's settings live in this app's storage. + * + * `wl_u<id>_<key>` per account, and in the Android app connected to a + * server, `wl_u<id>@<server>_<key>`: the same user id on another server + * is another account, and must never see or save these values. With no + * account (single-user mode) it's `wl_<key>`. The web app runs on its own + * server's address, so its storage is already that server's alone. + * No imports, so anything can read a setting without pulling in the + * stores (notifications, the API layer, db.js). + */ +export function settingScope() { + try { + const srv = String(localStorage.getItem('ct:serverUrl') || '').trim().replace(/\/+$/, '').toLowerCase(); + if (!srv) return ''; + return '@' + srv.replace(/^https?:\/\//, '').replace(/[^a-z0-9.-]/g, '-'); + } catch { + return ''; + } +} + +export function settingPrefix(userId) { + let id = userId; + if (id === undefined) { try { id = localStorage.getItem('wl:userId'); } catch { id = null; } } + return id ? `wl_u${id}${settingScope()}_` : 'wl_'; +} + +/** One time: settings kept before they carried the server move to the + * server this app is connected to now (the one they were made on). */ +export function migrateSettingScope() { + try { + if (localStorage.getItem('ct:settings:scoped') === '1') return; + const scope = settingScope(); + if (scope) { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && /^wl_u\d+_/.test(k)) keys.push(k); + } + for (const k of keys) { + const to = k.replace(/^(wl_u\d+)_/, `$1${scope}_`); + if (localStorage.getItem(to) === null) localStorage.setItem(to, localStorage.getItem(k)); + localStorage.removeItem(k); + } + } + localStorage.setItem('ct:settings:scoped', '1'); + } catch { /* storage unavailable */ } +} + +/** The same account at another address of the same server: its settings + * move with it (lib/local-account.js). */ +export function moveSettingScope(userId, fromServerUrl) { + try { + const from = String(fromServerUrl || '').trim().replace(/\/+$/, '').toLowerCase(); + const fromPrefix = `wl_u${userId}${from ? '@' + from.replace(/^https?:\/\//, '').replace(/[^a-z0-9.-]/g, '-') : ''}_`; + const toPrefix = settingPrefix(String(userId)); + if (fromPrefix === toPrefix) return; + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(fromPrefix)) keys.push(k); + } + for (const k of keys) { + const to = toPrefix + k.slice(fromPrefix.length); + if (localStorage.getItem(to) === null) localStorage.setItem(to, localStorage.getItem(k)); + localStorage.removeItem(k); + } + } catch { /* storage unavailable */ } +} + +/** Settings kept under one prefix copied to another, where that one has + * none of its own for a key (Disconnect: the account's settings become + * the phone's own, as its data does). */ +export function copySettingScope(fromPrefix, toPrefix) { + try { + if (!fromPrefix || !toPrefix || fromPrefix === toPrefix) return; + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(fromPrefix)) keys.push(k); + } + for (const k of keys) { + const to = toPrefix + k.slice(fromPrefix.length); + if (localStorage.getItem(to) === null) localStorage.setItem(to, localStorage.getItem(k)); + } + } catch { /* storage unavailable */ } +} + +// Before anything reads a setting. +migrateSettingScope(); diff --git a/src/lib/smart-cookbook.js b/src/lib/smart-cookbook.js new file mode 100644 index 00000000..682380bb --- /dev/null +++ b/src/lib/smart-cookbook.js @@ -0,0 +1,75 @@ +/** + * Which recipes a smart cookbook holds, from its saved filter. + * + * Identical copies live at server/lib/smart-cookbook.js (the server) and + * src/lib/smart-cookbook.js (the Android app's own database), so a smart + * cookbook holds the same recipes on both. scripts/smart-cookbook.test.js + * keeps the two the same. + * + * Supported criteria, every one that is set must match: + * { category_id, tags: [], favorites_only, min_rating, max_total_minutes } + * Tags match without regard to case, and a recipe needs every listed tag. + */ + +/** A filter as saved: known criteria only, numbers as numbers. */ +export function cleanSmartFilter(f) { + const clean = {}; + if (!f || typeof f !== 'object') return clean; + if (Number.isFinite(parseInt(f.category_id, 10))) clean.category_id = parseInt(f.category_id, 10); + if (Array.isArray(f.tags)) clean.tags = f.tags.map(s => String(s).trim()).filter(Boolean); + if (f.favorites_only === true) clean.favorites_only = true; + if (Number.isFinite(parseInt(f.min_rating, 10))) clean.min_rating = parseInt(f.min_rating, 10); + if (Number.isFinite(parseInt(f.max_total_minutes, 10))) clean.max_total_minutes = parseInt(f.max_total_minutes, 10); + return clean; +} + +/** The saved filter from a cookbook row, or null when it has none. */ +export function parseSmartFilter(json) { + if (json == null || json === '') return null; + if (typeof json === 'object') return json; + try { return JSON.parse(json); } catch { return null; } +} + +function _tagsOf(raw) { + let arr = raw; + if (typeof raw === 'string') { + try { arr = JSON.parse(raw || '[]'); } catch { arr = []; } + } + return new Set((Array.isArray(arr) ? arr : []).map(t => String(t).toLowerCase())); +} + +/** True when a recipe row (not deleted) matches the filter. */ +export function matchesSmartFilter(recipe, filter) { + if (!recipe || !filter || typeof filter !== 'object') return false; + if (recipe.deleted_at != null) return false; + if (Number.isFinite(filter.category_id)) { + if (recipe.category_id == null || Number(recipe.category_id) !== filter.category_id) return false; + } + if (filter.favorites_only && Number(recipe.favorite) !== 1) return false; + if (Number.isFinite(filter.min_rating)) { + if (recipe.rating == null || !(Number(recipe.rating) >= filter.min_rating)) return false; + } + if (Number.isFinite(filter.max_total_minutes)) { + const total = (Number(recipe.prep_minutes) || 0) + (Number(recipe.cook_minutes) || 0); + if (!(total <= filter.max_total_minutes)) return false; + } + const want = Array.isArray(filter.tags) ? filter.tags.map(s => String(s).toLowerCase()) : []; + if (want.length > 0) { + const have = _tagsOf(recipe.tags); + if (!want.every(t => have.has(t))) return false; + } + return true; +} + +/** A saved filter (JSON) with its category id put through `map` (an id + * in, an id or undefined out). Undefined leaves it as is. Category ids + * differ between a phone and the server, so sync translates them. */ +export function mapSmartFilterCategory(json, map) { + if (!json) return json; + let f; + try { f = typeof json === 'string' ? JSON.parse(json) : json; } catch { return json; } + if (!f || typeof f !== 'object' || f.category_id == null) return json; + const to = map(f.category_id); + if (to == null) return json; + return JSON.stringify({ ...f, category_id: to }); +} diff --git a/src/lib/sync-fields.js b/src/lib/sync-fields.js new file mode 100644 index 00000000..d2bd2371 --- /dev/null +++ b/src/lib/sync-fields.js @@ -0,0 +1,27 @@ +/** + * The fields of each synced table, as the server merges two edits of a row + * (server/lib/sync-fields.js, kept the same by a test). The phone and the + * web app both say which of them an edit changed. + */ +export const SYNC_FIELDS = { + recipe_categories: ['name', 'slug', 'color', 'sort_order'], + pantry_categories: ['name', 'slug', 'icon', 'color', 'sort_order', 'default_aisle'], + custom_units: ['abbr', 'full_name', 'category', 'sort_order'], + cookbooks: ['name', 'slug', 'description', 'cover_image_url', 'is_smart', 'smart_filter_json', 'sort_order', 'deleted_at'], + recipes: [ + 'name', 'description', 'img_url', 'servings', 'prep_minutes', 'cook_minutes', 'total_minutes', 'rest_minutes', + 'ingredients', 'steps', 'tags', 'tools', 'source_url', 'video_url', 'notes', + 'visibility', 'rating', 'yield_text', 'last_cooked_at', 'cook_count', + 'nutrition', 'favorite', 'category_id', 'deleted_at', + ], + pantry_items: [ + 'name', 'brand', 'barcode', 'in_stock', 'quantity', 'unit', 'expires_on', + 'nt_food_id', 'img_url', 'notes', 'category', 'category_id', + 'serving_size', 'serving_unit', 'serving_label', 'nutrition', 'g_per_cup', + 'generic_parent_id', 'nutrition_source_variant_id', 'deleted_at', + ], + cook_diary: ['recipe_id', 'date', 'kind', 'servings', 'notes', 'photo_url', 'photos', 'meal_type', 'rating', 'deleted_at'], + shopping_list: ['name', 'quantity', 'unit', 'aisle', 'checked', 'pantry_id', 'recipe_id', 'sort_order', 'deleted_at'], + recipe_comments: ['recipe_id', 'parent_id', 'body', 'deleted_at'], + ai_chat_history: ['role', 'content'], +}; diff --git a/src/lib/sync-refresh.js b/src/lib/sync-refresh.js new file mode 100644 index 00000000..f52cf083 --- /dev/null +++ b/src/lib/sync-refresh.js @@ -0,0 +1,19 @@ +/** + * sync-refresh.js: a page reads its data again when a sync brings changes + * down (Android, server mode). Pages read the phone's copy once, when they + * open; without this, the first sync after signing in filled the copy + * while the page went on showing it empty ("No Recipes Yet") until it was + * opened again. Changes are rows that came down or went (sync.js + * `changed`), not the small sets sent whole every time. + * + * Returns the remover, so a page can hand it to onMount. + */ +export function onSyncChanges(fn) { + if (typeof window === 'undefined') return () => {}; + const handler = e => { + const d = e?.detail; + if (d?.ok && Number(d.changed) > 0) fn(d); + }; + window.addEventListener('ct:sync-complete', handler); + return () => window.removeEventListener('ct:sync-complete', handler); +} diff --git a/src/lib/sync.js b/src/lib/sync.js index 3ffe321d..4b2a599d 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -18,12 +18,22 @@ import { isNative, getServerUrl, getAuthToken, apiUrl } from './platform.js'; import { dbGetPendingChanges, dbGetPendingSettingsForPush, dbSetServerId, dbApplyPull, - dbGetMeta, dbSetMeta, dbMarkSettingsSynced, dbMarkTableSynced, + dbGetMeta, dbSetMeta, dbMarkSettingsSynced, dbMarkTableSynced, dbRememberEcho, + SYNC_PARENTS, SYNC_FIELDS, rowBase, rowChanges, dbInstallId, dbCountUnsynced, createKeyOf, mapIngredientLinks, } from './db-native.js'; +import { mapSmartFilterCategory } from './smart-cookbook.js'; +import { localDataIsThisAccount, accountGeneration } from './local-account.js'; let _syncInFlight = null; +// A sync asked for while one runs (an edit saved, the app coming back): +// one more runs after it, so what it was asked for isn't left waiting. +let _syncAgain = false; let _interval = null; const LAST_PULL_KEY = 'last_pull_at'; +// How far this phone's clock is behind the server's, as the server last +// said. It keeps edit times on its own clock, and a pull compares them +// with edits waiting here. +const CLOCK_OFFSET_KEY = 'clock_offset_ms'; /** Public store the UI binds against — Settings + the connect dialog * read `online`, `syncing`, `lastSync`, `error` to render their state. @@ -182,13 +192,23 @@ async function _errBody(res) { } } -function _headers() { +// `tok`: the session a sync started with. Read once per run, so signing +// out (or in as someone else) partway through never sends the rest of one +// account's rows with another account's session. +function _headers(tok = getAuthToken()) { const h = { 'Content-Type': 'application/json' }; - const tok = getAuthToken(); if (tok) h['Authorization'] = `Bearer ${tok}`; return h; } +// One request's deadline, cut short too when `signal` aborts (a change of +// account stops the sync, stopSync). +function _deadline(signal, ms = 30000) { + const t = AbortSignal.timeout(ms); + if (!signal) return t; + return typeof AbortSignal.any === 'function' ? AbortSignal.any([t, signal]) : signal; +} + /** * Handle a 401 from any sync endpoint by clearing local auth state so * App.svelte's reactive gate sends the user to Login. Without this, @@ -198,6 +218,8 @@ function _headers() { * cycle. Mirrors the same fix in NT sync.js (commit d1e8217). */ async function _handleSyncAuthError() { + // The server's cookie can't stand in for the session that ended. + try { await (await import('./local-account.js')).forgetServerCookies(); } catch { /* none */ } console.warn('[sync] received 401 — clearing local auth so the user can re-sign-in'); try { const { setAuthToken } = await import('./platform.js'); @@ -288,7 +310,7 @@ async function _readFileAsBlob(fileUri) { return null; } } -async function _uploadFromFileUri(fileUri) { +async function _uploadFromFileUri(fileUri, token = getAuthToken()) { const blob = await _readFileAsBlob(fileUri); if (!blob) return null; const nameMatch = fileUri.match(/[^/]+$/); @@ -302,7 +324,6 @@ async function _uploadFromFileUri(fileUri) { const form = new FormData(); form.append('file', file); const headers = {}; - const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(apiUrl('/api/upload'), { method: 'POST', headers, credentials: 'include', body: form, @@ -314,7 +335,7 @@ async function _uploadFromFileUri(fileUri) { return null; } } -async function _reconcileLocalPhotoUrls(onProgress) { +async function _reconcileLocalPhotoUrls(onProgress, { token = getAuthToken(), live = () => true } = {}) { const { getDb } = await import('./db-native.js'); const db = await getDb(); // Collect candidates from the tables that carry user photos. Field @@ -381,9 +402,10 @@ async function _reconcileLocalPhotoUrls(onProgress) { } } async function _resolveUrl(url) { + if (!live()) return { status: 'retry' }; const fileUri = _capacitorUrlToFileUri(url); if (fileUri && await _fileExistsAtUri(fileUri)) { - const uploaded_ = await _uploadFromFileUri(fileUri); + const uploaded_ = await _uploadFromFileUri(fileUri, token); if (uploaded_) return { status: 'uploaded', url: uploaded_ }; return { status: 'retry' }; // upload attempt failed; try again next sync } @@ -395,6 +417,7 @@ async function _reconcileLocalPhotoUrls(onProgress) { if (r.status === 'retry') { progressDone++; if (onProgress) onProgress(progressDone, jobs.length); continue; } const newUrl = r.status === 'uploaded' ? r.url : ''; if (r.status === 'uploaded') uploaded++; else cleared++; + if (!live()) break; try { await db.run( `UPDATE ${j.table} SET ${j.field} = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), sync_status = 'pending' WHERE id = ?`, @@ -426,7 +449,7 @@ async function _reconcileLocalPhotoUrls(onProgress) { progressDone++; if (onProgress) onProgress(progressDone, jobs.length); } - if (dirty) { + if (dirty && live()) { const finalArr = arr.filter(u => u != null); try { await db.run( @@ -442,47 +465,139 @@ async function _reconcileLocalPhotoUrls(onProgress) { return { total: jobs.length, uploaded, cleared }; } -async function pushChanges() { +// Local id → server id for one table, read once per push. +async function _serverIdMap(db, table, cache) { + if (!cache[table]) { + const r = await db.query(`SELECT id, server_id FROM ${table}`, []); + cache[table] = new Map((r?.values || []).map(x => [x.id, x.server_id || null])); + } + return cache[table]; +} + +// Ids the phone stores are its own; the server's differ. Each id column +// goes up as the server's id. A parent the server hasn't seen yet is +// pending in this same push, so its local id goes up and is listed in +// _local_fks for the server to map once that parent is in. +async function _toServerIds(db, row, fks, cache) { + const localFks = []; + for (const [fk, parentTable] of Object.entries(fks)) { + if (row[fk] == null) continue; + const map = await _serverIdMap(db, parentTable, cache); + if (!map.has(row[fk])) row[fk] = null; // the parent is gone from the phone + else if (map.get(row[fk])) row[fk] = map.get(row[fk]); + else localFks.push(fk); + } + if (localFks.length) row._local_fks = localFks; + return row; +} + +async function pushChanges({ token = getAuthToken(), signal = null, gen = accountGeneration() } = {}) { + // False once another account's sign-in has begun (lib/local-account.js + // accountGeneration): nothing more is written for this push after that. + const live = () => gen === accountGeneration(); + // Another account's data still on the phone (a sign-in that hasn't been + // through prepareLocalAccount yet) never goes up under this one. + if (!live() || !(await localDataIsThisAccount(token))) return { pushed: 0, stopped: true }; const pending = await dbGetPendingChanges(); const settings = await dbGetPendingSettingsForPush(); + const { getDb } = await import('./db-native.js'); + const db = await getDb(); + const ids = {}; + // A row the server hasn't seen goes with a stable key (this install and + // its own id here), so a retry, two syncs at once or a lost answer never + // makes it twice (server/lib/create-keys.js). + const install = await dbInstallId(); const tablesToSend = {}; let total = 0; + let deferred = 0; for (const [table, rows] of Object.entries(pending)) { if (!Array.isArray(rows) || rows.length === 0) continue; - if (table === 'disabled_units' || table === 'recipe_cookbook_links') continue; - tablesToSend[table] = rows.map(r => { - const out = { ...r, client_id: r.id, server_id: r.server_id || null }; - delete out.id; - delete out.sync_status; - return out; - }); - total += rows.length; + if (table === 'disabled_units' || table === 'recipe_cookbook_links' || table === 'deletes') continue; + const out = []; + for (const r of rows) { + const row = { ...r, client_id: r.id, server_id: r.server_id || null }; + if (!row.server_id && install) row.client_key = createKeyOf(install, table, r); + delete row.id; + delete row.sync_status; + // Which fields this phone changed on the copy it had: only those + // compete with changes made elsewhere, so a cook logged, a rating or + // an item checked off here doesn't put back anything else. + const changed = rowChanges(table, r); + if (changed) row.changed = changed; + delete row.server_base; + await _toServerIds(db, row, SYNC_PARENTS[table] || {}, ids); + // An ingredient's pantry link holds this phone's id; the server's id + // goes up. A link to an item that hasn't gone up yet holds the recipe + // back to the push right after this one, once the item has its id. + if (table === 'recipes' && row.ingredients != null) { + const pantryIds = await _serverIdMap(db, 'pantry_items', ids); + const { json, unknown } = mapIngredientLinks(row.ingredients, id => (!pantryIds.has(id) ? null : pantryIds.get(id) || undefined)); + if (unknown) { deferred++; continue; } + row.ingredients = json; + } + if (table === 'cookbooks' && row.smart_filter_json) { + const cats = await _serverIdMap(db, 'recipe_categories', ids); + let local = false; + row.smart_filter_json = mapSmartFilterCategory(row.smart_filter_json, id => { + if (cats.get(id)) return cats.get(id); + local = true; + }); + if (local) row._local_filter_category = true; + } + out.push(row); + } + tablesToSend[table] = out; + total += out.length; } if (Array.isArray(pending.disabled_units) && pending.disabled_units.length) { tablesToSend.disabled_units = pending.disabled_units.map(r => ({ abbr: r.abbr })); } if (Array.isArray(pending.recipe_cookbook_links) && pending.recipe_cookbook_links.length) { - tablesToSend.recipe_cookbook_links = pending.recipe_cookbook_links.map(r => ({ - cookbook_id: r.cookbook_id, recipe_id: r.recipe_id, sort_order: r.sort_order ?? 0, - })); + const links = []; + for (const r of pending.recipe_cookbook_links) { + links.push(await _toServerIds(db, { + cookbook_id: r.cookbook_id, recipe_id: r.recipe_id, sort_order: r.sort_order ?? 0, + }, { cookbook_id: 'cookbooks', recipe_id: 'recipes' }, ids)); + } + tablesToSend.recipe_cookbook_links = links; + } + // Rows deleted outright here, by server id. + const deletes = {}; + for (const d of pending.deletes || []) { + if (d.table_name === 'recipe_cookbook_links') { + (deletes.recipe_cookbook_links ||= []).push({ cookbook_id: d.server_id, recipe_id: d.recipe_server_id }); + } else { + (deletes[d.table_name] ||= []).push(d.server_id); + } } + const deleteIds = (pending.deletes || []).map(d => d.id); - if (total === 0 && settings.length === 0 + if (total === 0 && settings.length === 0 && deleteIds.length === 0 && !tablesToSend.disabled_units && !tablesToSend.recipe_cookbook_links) { - return { pushed: 0 }; + return { pushed: 0, deferred }; } + if (!live()) return { pushed: 0, stopped: true }; const res = await fetch(apiUrl('/api/sync/push'), { method: 'POST', - headers: _headers(), - body: JSON.stringify({ tables: tablesToSend, settings }), + headers: _headers(token), + signal: _deadline(signal), + // fk_ids: 'server' tells the server the id columns already hold its + // own ids, apart from those listed in _local_fks. client_now lets it + // put this phone's edit times on its own clock before it compares + // them with edits made elsewhere: the newer edit stays. + body: JSON.stringify({ tables: tablesToSend, settings, deletes, fk_ids: 'server', client_now: new Date().toISOString() }), }); if (!res.ok) { - if (res.status === 401) await _handleSyncAuthError(); + if (res.status === 401 && live() && getAuthToken() === token) await _handleSyncAuthError(); throw new Error(`Sync push failed: ${res.status} ${await _errBody(res)}`); } const body = await res.json(); + // Another account's sign-in began while this was out, and the phone's + // copy is (or is becoming) theirs: these ids belong to rows that go. + if (!live() || !(await localDataIsThisAccount(token))) return { pushed: 0, stopped: true }; + if (Number.isFinite(body.clock_offset_ms)) await dbSetMeta(CLOCK_OFFSET_KEY, String(body.clock_offset_ms)); // Build a lookup of {table → {clientId → snapshotUpdatedAt}} so the // mark-synced steps below can check the push snapshot against the @@ -499,50 +614,139 @@ async function pushChanges() { if (m.size) snapshotByTable[table] = m; } + // The edit time the row had when it went up, if the row is still as it + // went up; otherwise one that never matches, so it stays pending and + // goes up again. Edit times count whole seconds, so an edit made in the + // same second as the push (while it was out) has the same time: the + // fields tell it apart. + const NEVER = '\u0000edited since'; + async function snapFor(table, clientId) { + const sentRow = (pending[table] || []).find(p => p.id === clientId); + const at = snapshotByTable[table]?.get(clientId); + if (!sentRow || !at) return at || null; + if (!SYNC_FIELDS[table]) return at; + const cur = (await db.query(`SELECT * FROM ${table} WHERE id = ?`, [clientId]))?.values?.[0]; + if (!cur) return at; + return cur.updated_at === at && rowBase(table, cur) === rowBase(table, sentRow) ? at : NEVER; + } + + // Rows where the server kept something other than what went up: the pull + // after this push brings it down over the copy here. + let serverKept = 0; for (const [table, results] of Object.entries(body.tables || {})) { if (!Array.isArray(results)) continue; - const snap = snapshotByTable[table]; const updatedRowsForBulkMark = []; for (const r of results) { + if (!live()) return { pushed: 0, stopped: true }; + if (r.kept === 'server') serverKept++; + if (!r.kept && r.client_id) { + // The copy the server now has is the one that went up: an edit made + // from here on is made on it. When the server kept something else, + // that copy becomes this phone's only once a pull has stored it. + if (typeof r.synced_at === 'string') { + const sent = (pending[table] || []).find(p => p.id === r.client_id); + await db.run(`UPDATE ${table} SET server_synced_at = ?, server_base = ? WHERE id = ?`, + [r.synced_at, sent ? rowBase(table, sent) : null, r.client_id]); + } + // The edit time the server stored for it, to know its echo in a pull. + if (typeof r.updated_at === 'string') await dbRememberEcho(table, r.client_id, r.updated_at); + } if (r.client_id && r.server_id) { // Newly-created row: server assigned a server_id. Stamp it + // mark synced gated on updated_at. - await dbSetServerId(table, r.client_id, r.server_id, snap?.get(r.client_id) || null); + await dbSetServerId(table, r.client_id, r.server_id, await snapFor(table, r.client_id)); } else if (r.client_id) { // Update of an existing server_id row: server didn't need to // assign anything. Still mark synced gated on updated_at. - const updatedAt = snap?.get(r.client_id); - if (updatedAt) updatedRowsForBulkMark.push({ id: r.client_id, updated_at: updatedAt }); + const updatedAt = await snapFor(table, r.client_id); + if (updatedAt && updatedAt !== NEVER) updatedRowsForBulkMark.push({ id: r.client_id, updated_at: updatedAt }); } } if (updatedRowsForBulkMark.length) { await dbMarkTableSynced(table, updatedRowsForBulkMark); } } + if (!live()) return { pushed: 0, stopped: true }; if (settings.length) { await dbMarkSettingsSynced(settings.map(s => ({ key: s.key, updated_at: s.updated_at }))); } - return { pushed: total }; + // Links that went up: synced once the cookbook and the recipe both + // have a server id (a parent that failed to go in keeps it pending). + // Same sort_order as sent, so a reorder made meanwhile goes up next. + for (const l of pending.recipe_cookbook_links || []) { + await db.run( + `UPDATE recipe_cookbook_links SET sync_status = 'synced' + WHERE cookbook_id = ? AND recipe_id = ? AND sort_order IS ? + AND EXISTS (SELECT 1 FROM cookbooks WHERE id = ? AND server_id IS NOT NULL) + AND EXISTS (SELECT 1 FROM recipes WHERE id = ? AND server_id IS NOT NULL)`, + [l.cookbook_id, l.recipe_id, l.sort_order, l.cookbook_id, l.recipe_id] + ); + } + // Deletes the server took. An older server doesn't answer `deleted`; + // they wait for it to update. + if (deleteIds.length && body.deleted && live()) { + await db.run(`DELETE FROM sync_deletes WHERE id IN (${deleteIds.map(() => '?').join(',')})`, deleteIds); + } + return { pushed: total, serverKept, deferred }; } -async function pullChanges() { +async function pullChanges({ token = getAuthToken(), signal = null, gen = accountGeneration() } = {}) { + // False once another account's sign-in has begun: checked before every + // write (lib/local-account.js accountGeneration, dbApplyPull's live()). + const live = () => gen === accountGeneration(); + if (!live() || !(await localDataIsThisAccount(token))) return { pulled: 0, changed: 0, stopped: true }; const since = (await dbGetMeta(LAST_PULL_KEY)) || '1970-01-01T00:00:00'; - const res = await fetch(apiUrl(`/api/sync/pull?since=${encodeURIComponent(since)}`), { + // synced_at=1: each row comes with the server's stamp of that copy. + const res = await fetch(apiUrl(`/api/sync/pull?since=${encodeURIComponent(since)}&synced_at=1`), { method: 'GET', - headers: _headers(), + headers: _headers(token), + signal: _deadline(signal), }); if (!res.ok) { - if (res.status === 401) await _handleSyncAuthError(); + if (res.status === 401 && live() && getAuthToken() === token) await _handleSyncAuthError(); throw new Error(`Sync pull failed: ${res.status} ${await _errBody(res)}`); } const body = await res.json(); - await dbApplyPull(body); + if (!live() || !(await localDataIsThisAccount(token))) return { pulled: 0, changed: 0, stopped: true }; + const clockOffsetMs = Number(await dbGetMeta(CLOCK_OFFSET_KEY)) || 0; + const done = await dbApplyPull(body, { clockOffsetMs, live }); + if (done === false || !live()) return { pulled: 0, changed: 0, stopped: true }; await dbSetMeta(LAST_PULL_KEY, body.now || new Date().toISOString()); let pulled = 0; - for (const arr of Object.values(body.tables || {})) { - if (Array.isArray(arr)) pulled += arr.length; + // Rows that came down (the replace-sets, sent whole every time, aside): + // what the pages on show read again (ct:sync-complete). + let changed = 0; + for (const [name, arr] of Object.entries(body.tables || {})) { + if (Array.isArray(arr)) { + pulled += arr.length; + if (!['disabled_units', 'recipe_cookbook_links'].includes(name)) changed += arr.length; + } } - return { pulled }; + for (const ids of Object.values(body.tables?.deletions || {})) if (Array.isArray(ids)) changed += ids.length; + return { pulled, changed }; +} + +// What's running now (full syncs and the sign-out push), so a change of +// account can stop it: every run's requests are aborted, and the account's +// copy changes hands only once they have all ended (stopSync). Each run +// also stops writing as soon as the account generation moves +// (lib/local-account.js). +const _runs = new Set(); // { ctl, signal, promise } +function _startRun() { + const ctl = new AbortController(); + const run = { ctl, signal: ctl.signal, promise: null }; + _runs.add(run); + return run; +} +function _endRun(run, promise) { + run.promise = promise; + Promise.resolve(promise).catch(() => {}).finally(() => _runs.delete(run)); +} +export async function stopSync() { + _syncAgain = false; + const running = [..._runs]; + for (const r of running) r.ctl.abort(); + await Promise.allSettled(running.map(r => r.promise).filter(Boolean)); } /** @@ -551,17 +755,40 @@ async function pullChanges() { * the user edited locally before the server's last change would push * the stale full-row payload and silently clobber the server (issue * surfaced as variant relationships not appearing on mobile even - * though the PWA had set them — local row was still pre-attach, the + * though the PWA had set them: local row was still pre-attach, the * push sent generic_parent_id=NULL, and the server's value was lost). - * Concurrent callers share the in-flight promise so a manual "Sync - * now" tap mid-background round doesn't double-fire. + * + * One at a time, whatever asks (app start, coming back to the app, the + * connection returning, the timer, an edit, Sync Now): a call while one + * runs gets that run, and one more runs after it if anything is still + * waiting to go up, so an edit made meanwhile doesn't wait for the timer. + * Two at once sent the same new rows twice. */ -export async function fullSync(silentOrOpts = false, forceCheck = false, showFailureBanner = false) { +export function fullSync(silentOrOpts = false, forceCheck = false, showFailureBanner = false) { // Backwards-compatible: old callers pass a bool for `silent`; the // pull-to-refresh + Retry banner paths pass all three positional args. const silent = typeof silentOrOpts === 'object' ? !!silentOrOpts.silent : !!silentOrOpts; - if (!_shouldRun()) return { ok: false, reason: 'not-server-mode' }; - if (_syncInFlight) return _syncInFlight; + if (!_shouldRun()) return Promise.resolve({ ok: false, reason: 'not-server-mode' }); + if (_syncInFlight) { _syncAgain = true; return _syncInFlight; } + const token = getAuthToken(); + if (!token) return Promise.resolve({ ok: false, reason: 'not_authenticated' }); + const run = _startRun(); + const promise = _fullSync({ silent, forceCheck, showFailureBanner, token, signal: run.signal, gen: accountGeneration() }) + .finally(() => { + if (_syncInFlight === promise) _syncInFlight = null; + if (_syncAgain) { + _syncAgain = false; + // Asked for while this one ran (an edit saved meanwhile): once more, + // after it, when anything is still waiting to go up. + promise.then(async r => { if (r?.ok && (await dbCountUnsynced()) > 0) setTimeout(() => fullSync(true), 0); }).catch(() => {}); + } + }); + _syncInFlight = promise; + _endRun(run, promise); + return promise; +} + +async function _fullSync({ silent, forceCheck, showFailureBanner, token, signal, gen }) { // Server-reachability probe before the heavy pull/push. Skipping this // when the circuit breaker says "known offline" avoids re-triggering // the 3s health-check on every ticked poll. `showFailureBanner` opts @@ -569,73 +796,139 @@ export async function fullSync(silentOrOpts = false, forceCheck = false, showFai // the compact cloud badge. const online = await checkOnline(forceCheck, showFailureBanner); if (!online) return { ok: false, reason: 'offline' }; + // The phone still holds another account's data (a sign-in the app + // hasn't finished checking): nothing is read, sent or fetched until + // App.svelte has run prepareLocalAccount. + if (!(await localDataIsThisAccount(token))) return { ok: false, reason: 'other_account' }; + const live = () => gen === accountGeneration(); + if (!live()) return { ok: false, reason: 'stopped' }; + const stopped = () => Object.assign(new Error('stopped'), { stopped: true }); syncState.update(s => ({ ...s, syncing: true, phase: 'pull', error: null })); - _syncInFlight = (async () => { + try { + const pull = await pullChanges({ token, signal, gen }); + if (pull.stopped) throw stopped(); + // Between pull and push: reconcile any phone-local photo URLs + // (Capacitor's https://<host>/_capacitor_file_/... scheme) that + // ended up in server-tracked entities. These are always non-portable: + // they point at a file inside THIS install's private Filesystem. If + // the file still exists on disk, re-upload to /api/upload and rewrite + // the URL to a portable /uploads/... one. If it doesn't (fresh install + // syncing entries from another device, uninstall-reinstall), clear the + // URL so downstream renderers show the placeholder instead of a broken + // image. Runs before push so the rewritten URLs sync out in the same + // pass. try { - const pull = await pullChanges(); - // Between pull and push: reconcile any phone-local photo URLs - // (Capacitor's https://<host>/_capacitor_file_/... scheme) that - // ended up in server-tracked entities. These are always non- - // portable — they point at a file inside THIS install's private - // Filesystem. If the file still exists on disk, re-upload to - // /api/upload and rewrite the URL to a portable /uploads/... one. - // If it doesn't (fresh install syncing entries from another - // device, uninstall-reinstall), clear the URL so downstream - // renderers show the placeholder instead of a broken image. - // Runs before push so the rewritten URLs sync out in the same - // pass. - try { - await _reconcileLocalPhotoUrls((done, total) => { - if (total > 0) { - syncState.update(s => ({ ...s, phase: 'photos', progress: `Uploading local photos… ${done}/${total}` })); - } - }); - } catch (e) { - console.warn('[sync] local-photo reconcile failed:', e?.message); - } - syncState.update(s => ({ ...s, phase: 'push' })); - const push = await pushChanges(); - // After the data pull, walk the server's image URLs and download - // any that aren't already in the local cache so recipe/pantry/ - // diary thumbnails render offline. Best-effort: a failure here - // doesn't fail the sync — the user just sees broken-image - // placeholders for fresh entries until the next pass. Same - // ordering as NutriTrace's sync.js. - try { + await _reconcileLocalPhotoUrls((done, total) => { + if (total > 0) { + syncState.update(s => ({ ...s, phase: 'photos', progress: `Uploading local photos… ${done}/${total}` })); + } + }, { token, live }); + } catch (e) { + console.warn('[sync] local-photo reconcile failed:', e?.message); + } + syncState.update(s => ({ ...s, phase: 'push' })); + const push = await pushChanges({ token, signal, gen }); + if (push.stopped) throw stopped(); + // Recipes held back for a pantry item that went up in this push: now. + if (push.deferred && push.pushed) { + const more = await pushChanges({ token, signal, gen }); + if (more.stopped) throw stopped(); + push.pushed += more.pushed || 0; + push.serverKept = (push.serverKept || 0) + (more.serverKept || 0); + } + // The server kept something other than what went up: bring it down + // now rather than on the next round. + if (push.serverKept) { + const again = await pullChanges({ token, signal, gen }); + if (again.stopped) throw stopped(); + pull.pulled += again.pulled; + pull.changed += again.changed; + } + // After the data pull, walk the server's image URLs and download + // any that aren't already in the local cache so recipe/pantry/ + // diary thumbnails render offline. Best-effort: a failure here + // doesn't fail the sync; the user just sees broken-image + // placeholders for fresh entries until the next pass. Same + // ordering as NutriTrace's sync.js. + try { + if (live()) { const { cacheAllImages } = await import('./image-cache.js'); await cacheAllImages((done, total) => { if (total > 0) { syncState.update(s => ({ ...s, phase: 'images', progress: `Caching images… ${done}/${total}` })); } - }); - } catch (e) { - console.warn('[sync] image-cache pass failed:', e?.message); + }, { token, signal, live }); } - const result = { ok: true, ...push, ...pull }; - const ts = new Date().toISOString(); - // Clear connectionIssue + showErrorBanner + error on success so a - // stale banner from a prior 401 / timeout doesn't linger forever - // once the underlying issue is resolved. - syncState.update(s => ({ - ...s, syncing: false, phase: '', progress: '', - lastSync: ts, error: null, online: true, - connectionIssue: null, showErrorBanner: false, - })); - _notify(result); - return result; } catch (e) { - const err = e.message || String(e); - syncState.update(s => ({ - ...s, syncing: false, phase: '', error: err, - ...(showFailureBanner ? { showErrorBanner: true } : {}), - })); - if (!silent) _notify({ ok: false, error: err }); - return { ok: false, error: err }; - } finally { - _syncInFlight = null; + console.warn('[sync] image-cache pass failed:', e?.message); + } + if (!live()) throw stopped(); + const result = { ok: true, ...push, ...pull }; + console.info(`[sync] done: ${result.pushed || 0} sent, ${result.changed || 0} changed here`); + const ts = new Date().toISOString(); + // Clear connectionIssue + showErrorBanner + error on success so a + // stale banner from a prior 401 / timeout doesn't linger forever + // once the underlying issue is resolved. + syncState.update(s => ({ + ...s, syncing: false, phase: '', progress: '', + lastSync: ts, error: null, online: true, + connectionIssue: null, showErrorBanner: false, + })); + _notify(result); + return result; + } catch (e) { + if (e?.stopped || !live()) { + // Stopped for a change of account: not an error to show. + syncState.update(s => ({ ...s, syncing: false, phase: '', progress: '' })); + return { ok: false, reason: 'stopped' }; + } + const err = e.message || String(e); + syncState.update(s => ({ + ...s, syncing: false, phase: '', error: err, + ...(showFailureBanner ? { showErrorBanner: true } : {}), + })); + if (!silent) _notify({ ok: false, error: err }); + return { ok: false, error: err }; + } +} + +/** + * Signing out: send what's waiting while this account's session still + * works. Push only (no pull, photos or images), and never longer than + * `timeoutMs`: sign-out goes ahead regardless, and anything left stays for + * this account (lib/local-account.js keeps it from going up under anyone + * else). The push carries this session's token throughout, and is cut off + * (aborted) when the time is up. A sync already running is waited for + * within the same time instead, and it already pushes what's waiting. + */ +export function pushBeforeSignOut(timeoutMs = 4000) { + const token = getAuthToken(); + if (!_shouldRun() || !token) return Promise.resolve(false); + let timer; + const timeout = new Promise(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); }); + if (_syncInFlight) { + _syncAgain = false; + return Promise.race([_syncInFlight.then(r => !!r?.ok), timeout]).finally(() => clearTimeout(timer)); + } + const r = _startRun(); + const gen = accountGeneration(); + const run = (async () => { + try { + if (!(await checkOnline(true))) return false; + const res = await pushChanges({ token, signal: r.signal, gen }); + return !res.stopped; + } catch (e) { + console.warn('[sync] push before sign-out failed:', e?.message || e); + return false; } })(); - return _syncInFlight; + // A sync asked for meanwhile joins this push rather than starting another. + const mine = run.then(ok => ({ ok })); + _syncInFlight = mine; + mine.finally(() => { if (_syncInFlight === mine) _syncInFlight = null; }); + _endRun(r, run); + const cut = timeout.then(v => { r.ctl.abort(); return v; }); + return Promise.race([run, cut]).finally(() => clearTimeout(timer)); } /** Start the background sync loop. Idempotent. */ diff --git a/src/lib/user-state.js b/src/lib/user-state.js new file mode 100644 index 00000000..4c6228df --- /dev/null +++ b/src/lib/user-state.js @@ -0,0 +1,26 @@ +/** + * user-state.js: what the app keeps in memory for the account signed in. + * + * Module stores outlive a sign-out, so the next account would see, and + * could change or save, the last one's: + * - every setting store (stores/settings.js): units, AI keys, the + * kitchen and the rest are kept per account in storage, but each store + * holds the value it read last; a change still waiting to be sent is + * dropped; + * - the units overlay (stores/unitsOverlay.js): the account's custom and + * turned-off units; + * - what one page hands an editor (stores/editorState.js). + * Cleared when the account changes and on sign-out, before anything is + * shown. Pages keep the rest in their own state, which goes with them when + * the sign-in screen (or the account check) replaces the app. Server-wide + * state (user management on, feature flags, update checks) stays. Cooks + * underway and their timers are this phone's, and go only when another + * account's sign-in clears the copy (lib/local-account.js). + */ +export async function resetUserState() { + await Promise.allSettled([ + import('../stores/settings.js').then(m => m.reloadSettingStores?.({ force: true })), + import('../stores/unitsOverlay.js').then(m => m.resetUnitsOverlay?.()), + import('../stores/editorState.js').then(m => { m.clearFoodEditorState?.(); m.clearMealEditorState?.(); }), + ]); +} diff --git a/src/lib/version.js b/src/lib/version.js index 4e33f914..27bfea71 100644 --- a/src/lib/version.js +++ b/src/lib/version.js @@ -1 +1 @@ -export const APP_VERSION = 'v1.4.0'; +export const APP_VERSION = 'v1.4.1'; diff --git a/src/main.js b/src/main.js index 3be4c1d9..9ee3c700 100644 --- a/src/main.js +++ b/src/main.js @@ -13,9 +13,10 @@ import './styles/buttons.css'; import './styles/forms.css'; // Last, so its rules win over the layouts they move off a crease. import './styles/fold.css'; -import App from './App.svelte'; import { DB } from './lib/db.js'; +import { waitLocale } from 'svelte-i18n'; import { initI18n } from './i18n/index.js'; +import { publicRecipeToken } from './lib/platform.js'; // Pick browser-detected locale for first paint; the App-level subscription to // the `language` store flips it to the user's saved preference once that loads. @@ -31,35 +32,71 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e = } }); -// Boot -DB.init() - .then(async () => { - // Local SQLite layer: open the connection + run the schema before the - // app mounts so the first NtApi call has a working DB underneath it. - // No-op on web. Lazy on subsequent calls because getDb() memoises. - const { isNative } = await import('./lib/platform.js'); - if (isNative) { - const { dbInit } = await import('./lib/db-native.js'); - await dbInit(); - const { loadImageMap } = await import('./lib/platform.js'); - await loadImageMap(); - } - new App({ target: document.getElementById('app') }); - }) - .catch(err => { - console.error('DB init failed:', err); - document.getElementById('app').innerHTML = ` - <div style="display:flex;flex-direction:column;align-items:center;justify-content:center; - height:100dvh;padding:32px;text-align:center;gap:16px;font-family:sans-serif;"> - <span style="font-size:48px">⚠️</span> - <h2 style="color:#F0F2F8">Database Error</h2> - <p style="color:rgba(240,242,248,0.6);max-width:300px"> - Could not open the local database. Try closing other tabs or clearing site data. - </p> - <button onclick="location.reload()" - style="padding:12px 24px;border-radius:12px;background:#4FFFB0; - color:#0A0B0F;font-weight:600;border:none;cursor:pointer;font-size:15px;"> - Retry - </button> - </div>`; +// A public recipe link (/r/<token>) opens on its own: no sign-in, no local +// database, no sync, just the recipe. Inside the app the login screen stood +// in front of it, so nobody without an account could open a shared link. +const publicToken = publicRecipeToken(); +if (publicToken) { + // The locale files load on demand, and the page has no app to wait for them. + Promise.all([import('./routes/PublicRecipePage.svelte'), waitLocale()]).then(([{ default: PublicRecipePage }]) => { + new PublicRecipePage({ target: document.getElementById('app'), props: { token: publicToken } }); }); +} else { + boot(); +} + +// A harmless native call, given up on after a moment: its answer may go +// to the page before a reload, and every call after it is answered here. +async function primeNativeBridge() { + try { + const { CapacitorSQLite } = await import('@capacitor-community/sqlite'); + await Promise.race([ + CapacitorSQLite.echo({ value: 'ready' }).catch(() => {}), + new Promise(resolve => setTimeout(resolve, 1000)), + ]); + } catch { /* nothing to prime */ } +} + +function boot() { + DB.init() + .then(async () => { + // Local SQLite layer: open the connection + run the schema before the + // app mounts so the first NtApi call has a working DB underneath it. + // No-op on web. Lazy on subsequent calls because getDb() memoises. + const { isNative } = await import('./lib/platform.js'); + if (isNative) { + // The first native call a page makes after the app reloads itself + // (setup's sign-in, Connect, Disconnect, sign-out) can lose its + // answer: Capacitor's Android bridge runs the call before it starts + // answering this page, and a quick call answers the page that's + // gone. The database's first call then never returned and the app + // stayed blank. A first call whose answer can be lost goes first. + await primeNativeBridge(); + const { dbInit } = await import('./lib/db-native.js'); + await dbInit(); + const { loadImageMap } = await import('./lib/platform.js'); + await loadImageMap(); + } + // Loaded here rather than at the top so a public recipe link never pulls + // in the app, whose stores start fetching account data as they load. + const { default: App } = await import('./App.svelte'); + new App({ target: document.getElementById('app') }); + }) + .catch(err => { + console.error('DB init failed:', err); + document.getElementById('app').innerHTML = ` + <div style="display:flex;flex-direction:column;align-items:center;justify-content:center; + height:100dvh;padding:32px;text-align:center;gap:16px;font-family:sans-serif;"> + <span style="font-size:48px">⚠️</span> + <h2 style="color:#F0F2F8">Database Error</h2> + <p style="color:rgba(240,242,248,0.6);max-width:300px"> + Could not open the local database. Try closing other tabs or clearing site data. + </p> + <button onclick="location.reload()" + style="padding:12px 24px;border-radius:12px;background:#4FFFB0; + color:#0A0B0F;font-weight:600;border:none;cursor:pointer;font-size:15px;"> + Retry + </button> + </div>`; + }); +} diff --git a/src/routes/CookDiary.svelte b/src/routes/CookDiary.svelte index 1b6fa9de..18137f67 100644 --- a/src/routes/CookDiary.svelte +++ b/src/routes/CookDiary.svelte @@ -1,5 +1,6 @@ <script> import { closeOnBack } from '../lib/back-stack.js'; + import { onSyncChanges } from '../lib/sync-refresh.js'; import { onMount } from 'svelte'; import { push } from 'svelte-spa-router'; import { fade, slide } from 'svelte/transition'; @@ -198,24 +199,35 @@ return `${y}-${m}-${dd}`; } - async function load() { - loading = true; - loadError = null; + // Two reads at once (the page opening while a sync lands): the later + // one's answer stands, never an older one finishing last. + let _loadSeq = 0; + // quiet: read again after a sync, without the loading state. + async function load({ quiet = false } = {}) { + const seq = ++_loadSeq; + if (!quiet) { loading = true; loadError = null; } try { // Pull a wide window so both list + month view share data. const monthFrom = _isoDate(new Date(monthAnchor.getFullYear(), monthAnchor.getMonth() - 1, 1)); const monthTo = _isoDate(new Date(monthAnchor.getFullYear(), monthAnchor.getMonth() + 2, 0)); const from = view === 'month' ? monthFrom : rangeFrom; const to = view === 'month' ? monthTo : rangeTo; - entries = await NtApi.getCookDiary({ from, to }); + const got = await NtApi.getCookDiary({ from, to }); + if (seq === _loadSeq) { entries = got; loadError = null; } } catch (e) { - loadError = e.message || 'Could not load diary'; - showError(loadError); + // Only the newest read speaks; a quiet one after a sync that fails + // keeps what's shown, unsaid. + if (seq === _loadSeq && !quiet) { + loadError = e.message || 'Could not load diary'; + showError(loadError); + } } finally { - loading = false; + if (seq === _loadSeq) loading = false; } } onMount(() => { load(); loadStats(); loadHeatmap(); }); + // A sync that brought changes down shows them here at once. + onMount(() => onSyncChanges(() => { load({ quiet: true }); loadStats(); loadHeatmap(); })); $: if (monthAnchor || view) { /* trigger reload on view change */ load(); } // Group list-view entries by date (descending — future first, then past). diff --git a/src/routes/Login.svelte b/src/routes/Login.svelte index 19da7781..06f9336d 100644 --- a/src/routes/Login.svelte +++ b/src/routes/Login.svelte @@ -1,6 +1,8 @@ <script> import { onMount } from 'svelte'; - import { currentUser, userMgmtActive, loadAuthState } from '../stores/auth.js'; + import { currentUser, userMgmtActive, loadAuthState, signInProblem } from '../stores/auth.js'; + import { cookieBlockedByHttp, droppedCookieReason } from '../lib/cookie-check.js'; + import CookieWarning from '../components/ui/CookieWarning.svelte'; import { loadServerSettings } from '../stores/settings.js'; import { showError, showSuccess } from '../stores/toast.js'; import { push } from 'svelte-spa-router'; @@ -57,6 +59,7 @@ // since there's no server to talk to. let oidcProviders = []; let passwordLoginEnabled = true; + let authStatus = null; onMount(async () => { if (isNative && !getServerUrl()) return; // standalone — skip // Probe biometric concurrently with the auth-status fetch. @@ -71,6 +74,10 @@ const r = await fetch(apiUrl('/api/auth/status'), { credentials: 'include' }); if (r.ok) { const data = await r.json(); + authStatus = data; + // Plain HTTP with an HTTPS-only cookie: say so before anyone signs + // in (lib/cookie-check.js). + if (cookieBlockedByHttp(data, { native: isNative })) signInProblem.set('http'); if (data?.oidc) { oidcProviders = Array.isArray(data.oidc.providers) ? data.oidc.providers : []; passwordLoginEnabled = data.oidc.enable_email_password_login !== false; @@ -86,8 +93,9 @@ // the server to redirect via the cooktrace://oidc-callback deep // link instead of setting an HttpOnly cookie + SPA hash redirect. const { Browser } = await import('@capacitor/browser'); + const { appChallengeParam } = await import('../lib/oidc-app-handoff.js'); await Browser.open({ - url: apiUrl(`/api/auth/oidc/login/${providerId}?mobile=1&return=${ret}`), + url: apiUrl(`/api/auth/oidc/login/${providerId}?mobile=1&return=${ret}${await appChallengeParam()}`), presentationStyle: 'popover', }); return; @@ -123,12 +131,24 @@ localStorage.setItem('wl:userId', String(data.user.id)); localStorage.setItem('ct:cachedUser', JSON.stringify(data.user)); localStorage.setItem('ct:cachedUserMgmt', '1'); - currentUser.set(data.user); + // Web: don't set the user here. loadAuthState() below reads it back + // from the server, so the app only appears once the session cookie has + // actually stuck; setting it early flashed the app for a moment before + // a dropped cookie sent the user back. Native signs in with a token. + if (isNative) currentUser.set(data.user); // Refresh CSRF token from /api/auth/me before any state-changing request // can fire (otherwise reactive settings saves would use a stale csrf from // a previous session and 403). loadAuthState() handles the fetch and // populates localStorage.nt:csrf as a side effect. await loadAuthState(); + // The password was right but the session didn't stick: the browser + // dropped the cookie. Say why here instead of looping back to sign-in. + // get(), not $currentUser: this page can be torn down meanwhile. + if (!isNative && !get(currentUser)) { + signInProblem.set(droppedCookieReason(authStatus, { native: isNative })); + return; + } + signInProblem.set(null); await loadServerSettings(); push('/'); } catch(e) { @@ -171,6 +191,10 @@ <p class="text-3 text-sm">{$_('login.subtitle')}</p> </div> + {#if $signInProblem} + <CookieWarning reason={$signInProblem} /> + {/if} + {#if !recoveryDone} {#if oidcProviders.length} <div class="sso-row"> diff --git a/src/routes/NativeSetup.svelte b/src/routes/NativeSetup.svelte index db038271..a238405b 100644 --- a/src/routes/NativeSetup.svelte +++ b/src/routes/NativeSetup.svelte @@ -29,6 +29,11 @@ async function chooseLocal() { setNativeMode('local'); setServerUrl(null); + // Data made from here on is the phone's own (lib/local-account.js). + try { + const { setLocalOwner } = await import('../lib/local-account.js'); + await setLocalOwner(); + } catch {} // Reload — SQLite will initialize naturally when CtApiNative is first called window.location.reload(); } @@ -119,8 +124,9 @@ try { const ret = encodeURIComponent('#/'); const { Browser } = await import('@capacitor/browser'); + const { appChallengeParam } = await import('../lib/oidc-app-handoff.js'); await Browser.open({ - url: `${validatedUrl}/api/auth/oidc/login/${providerId}?mobile=1&return=${ret}`, + url: `${validatedUrl}/api/auth/oidc/login/${providerId}?mobile=1&return=${ret}${await appChallengeParam()}`, presentationStyle: 'popover', }); // Deep-link callback (cooktrace://oidc-callback?token=…) handled by diff --git a/src/routes/Pantry.svelte b/src/routes/Pantry.svelte index a518f8c9..a2233ec5 100644 --- a/src/routes/Pantry.svelte +++ b/src/routes/Pantry.svelte @@ -1,5 +1,6 @@ <script> import { closeOnBack } from '../lib/back-stack.js'; + import { onSyncChanges } from '../lib/sync-refresh.js'; import { onMount } from 'svelte'; import { fade, fly } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; @@ -690,24 +691,36 @@ return c?.icon || categoryIcon(slug) || 'kitchen'; } - async function load() { - loading = true; - loadError = null; + // Two reads at once (the page opening while a sync lands): the later + // one's answer stands, never an older one finishing last. + let _loadSeq = 0; + // quiet: read again after a sync, without the loading state. + async function load({ quiet = false } = {}) { + const seq = ++_loadSeq; + if (!quiet) { loading = true; loadError = null; } try { const [pantryRes, catsRes] = await Promise.all([ NtApi.getPantry(), NtApi.getPantryCategories().catch(() => []), ]); + if (seq !== _loadSeq) return; + loadError = null; items = pantryRes; pantryCategories = catsRes || []; } catch (e) { - loadError = e.message || 'Could not load pantry'; - showError(loadError); + // Only the newest read speaks; a quiet one after a sync that fails + // keeps what's shown, unsaid. + if (seq === _loadSeq && !quiet) { + loadError = e.message || 'Could not load pantry'; + showError(loadError); + } } finally { - loading = false; + if (seq === _loadSeq) loading = false; } } - onMount(load); + onMount(() => { load(); }); + // A sync that brought changes down shows them here at once. + onMount(() => onSyncChanges(() => load({ quiet: true }))); // Variant feature announcement (Issue #4 commit 4). One-shot banner // at the top of the Pantry page introducing the new feature. Dismiss diff --git a/src/routes/PantryEditor.svelte b/src/routes/PantryEditor.svelte index b15da680..4cd60f38 100644 --- a/src/routes/PantryEditor.svelte +++ b/src/routes/PantryEditor.svelte @@ -32,6 +32,8 @@ $: id = params.id ? parseInt(params.id, 10) : null; $: isEdit = Number.isFinite(id); + // The item as opened, sent with the save: only what the form changed goes in. + let loadedBase = null; let item = _blank(); let loading = isEdit; @@ -319,6 +321,7 @@ loading = true; try { const row = await NtApi.getPantryItem(id); + loadedBase = row; item = { ..._blank(), ...row, @@ -373,7 +376,7 @@ nutrition: item.nutrition && Object.keys(item.nutrition).length ? item.nutrition : null, }; if (isEdit) { - await NtApi.updatePantryItem(id, payload); + await NtApi.updatePantryItem(id, { ...payload, _base: loadedBase }); showSuccess($_('pantry_editor_ct.toast.saved')); } else { await NtApi.createPantryItem(payload); diff --git a/src/routes/PublicRecipe.svelte b/src/routes/PublicRecipe.svelte index 4d3a5811..3fdf3d40 100644 --- a/src/routes/PublicRecipe.svelte +++ b/src/routes/PublicRecipe.svelte @@ -39,13 +39,13 @@ cache: 'no-store', }); if (!res.ok) { - if (res.status === 404) loadError = 'This share link is no longer valid.'; - else loadError = `Server error (${res.status})`; + loadError = res.status === 404 || res.status === 400 ? 'gone' : 'failed'; return; } recipe = await res.json(); - } catch (e) { - loadError = e.message || 'Could not load recipe'; + document.title = recipe.name || document.title; + } catch { + loadError = 'failed'; } finally { loading = false; } @@ -73,9 +73,14 @@ </div> {:else if loadError} <div class="state error"> - <span class="material-symbols-rounded">error</span> - <h2>{loadError}</h2> - <p>Ask the person who shared this for an updated link.</p> + <span class="material-symbols-rounded">{loadError === 'gone' ? 'link_off' : 'cloud_off'}</span> + {#if loadError === 'gone'} + <h2>{$_('public_recipe_ct.gone')}</h2> + <p>{$_('public_recipe_ct.gone_hint')}</p> + {:else} + <h2>{$_('public_recipe_ct.load_failed')}</h2> + <button class="btn btn-primary" on:click={load}>{$_('public_recipe_ct.retry')}</button> + {/if} </div> {:else if recipe} {#if recipe.img_url} diff --git a/src/routes/PublicRecipePage.svelte b/src/routes/PublicRecipePage.svelte new file mode 100644 index 00000000..3c17c83a --- /dev/null +++ b/src/routes/PublicRecipePage.svelte @@ -0,0 +1,14 @@ +<script> + /** + * A public recipe link opened on its own (main.js mounts this instead of + * the app): no sign-in, no local database, no sync. Just the recipe, and + * the timer pill so its step timers can be seen and stopped. + */ + import PublicRecipe from './PublicRecipe.svelte'; + import TopTimerPill from '../components/recipe/TopTimerPill.svelte'; + + export let token; +</script> + +<TopTimerPill /> +<PublicRecipe params={{ token }} /> diff --git a/src/routes/RecipeEditor.svelte b/src/routes/RecipeEditor.svelte index 10234112..513841c5 100644 --- a/src/routes/RecipeEditor.svelte +++ b/src/routes/RecipeEditor.svelte @@ -6,6 +6,7 @@ import { fade } from 'svelte/transition'; import { _ } from 'svelte-i18n'; import { NtApi } from '../lib/api.js'; + import { recipeBaseOf } from '../lib/offline-edits.js'; import { showError, showSuccess } from '../stores/toast.js'; import { resolveAssetUrl } from '../lib/platform.js'; import { defaultServings } from '../stores/settings.js'; @@ -328,10 +329,15 @@ return { id: _newIngId(), qty: '', unit: '', name: '', note: '' }; } + // The copy this editor opened, sent with the save: the server keeps the + // newer of this edit and one made elsewhere since, and the other as an + // earlier version, and leaves alone what this save didn't change. + let loadedBase = null; async function load() { if (!isEdit) return; try { const r = await NtApi.getRecipe(id); + loadedBase = recipeBaseOf(r); name = r.name || ''; description = r.description || ''; imgUrl = r.imgUrl || ''; @@ -935,6 +941,7 @@ source_url: sourceUrl.trim() || null, video_url: videoUrl.trim() || null, notes: notes.trim() || null, + ...(isEdit && loadedBase ? { _base: loadedBase } : {}), }; const saved = isEdit diff --git a/src/routes/RecipeView.svelte b/src/routes/RecipeView.svelte index 5f659361..1e7fb7f7 100644 --- a/src/routes/RecipeView.svelte +++ b/src/routes/RecipeView.svelte @@ -14,9 +14,10 @@ push('/recipes'); } import { fade } from 'svelte/transition'; - import { _ } from 'svelte-i18n'; + import { _, locale } from 'svelte-i18n'; import { formatDuration } from '../lib/duration.js'; import { NtApi } from '../lib/api.js'; + import { recipeBaseOf } from '../lib/offline-edits.js'; import { showError, showSuccess } from '../stores/toast.js'; import { confirmDialog } from '../stores/confirmDialog.js'; import StarRating from '../components/ui/StarRating.svelte'; @@ -25,10 +26,10 @@ import CookLogDialog from '../components/recipe/CookLogDialog.svelte'; import { relativeTime, shortDate } from '../lib/relative-time.js'; import { formatDate, formatUpdated, domainFromUrl } from '../lib/format.js'; - import { dateFormat } from '../stores/settings.js'; + import { dateFormat, timeFormat } from '../stores/settings.js'; import { scaleQty, displayQty, displayQtyParts, parseQty } from '../lib/qty.js'; import { convertWithinFamily, convertQty, unitFamily } from '../lib/recipe-nutrition.js'; - import { resolveAssetUrl, isNative, getServerUrl } from '../lib/platform.js'; + import { resolveAssetUrl, isNative, getServerUrl, publicRecipeUrl } from '../lib/platform.js'; import { publishCooks as _publishCooks, readCooks as _readCooks } from '../lib/wear-pairing.js'; import { portal } from '../lib/portal.js'; import RecipeComments from '../components/recipe/RecipeComments.svelte'; @@ -44,6 +45,7 @@ import { activeCooks, startCook, endCook, isCooking, describeCook, cookList } from '../stores/cooks.js'; import { computeRecipeNutrition, computeRecipeMass, lookupCommonDensity } from '../lib/recipe-nutrition.js'; import ActionSheet from '../components/ui/ActionSheet.svelte'; + import Sheet from '../components/ui/Sheet.svelte'; import { buildRecipeCardPages, buildRecipeShareText } from '../lib/recipe-card.js'; import { svgToPngBlob, shareBlobs } from '../lib/shopping-card.js'; @@ -232,7 +234,7 @@ function lightboxNext() { if (lightboxIndex < lightboxPhotos.length - 1) lightboxIndex += 1; } function onLightboxKey(e) { if (lightboxIndex < 0) return; - if (e.key === 'Escape') closeLightbox(); + if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); } else if (e.key === 'ArrowLeft') lightboxPrev(); else if (e.key === 'ArrowRight') lightboxNext(); } @@ -555,6 +557,7 @@ // of knowing unless it is told. Pressing Cook is not the only moment that // matters, since cook mode survives closing the app. if (recipe) { describeCook(id, { name: recipe.name, img: recipe.imgUrl || '', serverId: _watchRecipeId() }); _tellWatch({ now: true }); } + loadVersions(); // And the watch may have ticked something off while the phone was shut. _hearWatch(); // Kick off the pantry load so the FDA box can render "~Xg per @@ -648,7 +651,7 @@ token = res.share_token; recipe = { ...recipe, share_token: token }; } - const url = `${window.location.origin}/#/r/${token}`; + const url = publicRecipeUrl(token); if (navigator.share) { try { await navigator.share({ title: recipe.name, url }); @@ -669,6 +672,161 @@ } } + // ── Earlier versions ───────────────────────────────────── + // Copies of this recipe a sync didn't keep (an edit from another device + // that was older, or the copy a newer edit replaced), and the copy each + // restore replaced. The server holds them, so the Android app asks it + // with the recipe's server id; a phone on its own has none. The owner + // sees them all; someone who may edit a recipe shared with them sees + // their own edits. Anything else answers with an error: none here. + let versions = []; + let versionsOpen = false; + let previewVersionId = null; + let restoringVersion = false; + function _versionsRecipeId() { + if (!recipe || Number(recipe.id) !== id) return 0; + // (Asked of the recipe itself: canEdit only catches up after a tick.) + const editable = recipe.can_edit === true || recipe.user_id == null + || recipe.user_id === $currentUser?.id || $currentUser?.role === 'admin'; + if (!isNative) return editable ? id : 0; + return getServerUrl() ? Number(recipe.server_id) || 0 : 0; + } + async function loadVersions() { + const rid = _versionsRecipeId(); + if (!rid) { versions = []; return; } + try { + const rows = await NtApi.getRecipeVersions(rid); + if (_versionsRecipeId() === rid) versions = Array.isArray(rows) ? rows : []; + } catch { versions = []; } + } + $: unseenVersions = versions.filter(v => v.reason === 'conflict' && !v.seen).length; + // Saves made here with no connection went up just now: the recipe on + // the server may be another device's newer edit, with this one kept as + // an earlier version. Show what the server has. + const _onOfflineSynced = async () => { + if (!recipe || isNative) return; + try { const fresh = await NtApi.getRecipe(id); if (fresh && Number(fresh.id) === id) recipe = fresh; } catch { /* shown on the next visit */ } + loadVersions(); + }; + if (typeof window !== 'undefined') { + window.addEventListener('ct:offline-synced', _onOfflineSynced); + onDestroy(() => window.removeEventListener('ct:offline-synced', _onOfflineSynced)); + } + async function _markVersionsSeen() { + if (!unseenVersions) return; + versions = versions.map(v => ({ ...v, seen: true })); + try { await NtApi.markRecipeVersionsSeen(_versionsRecipeId()); } catch { /* shown again next time */ } + } + function openVersions() { + previewVersionId = null; + _loadVersionCategory(); + versionsOpen = true; + _markVersionsSeen(); + } + function _versionTime(v) { + const raw = String(v.created_at || ''); + const d = new Date(raw.replace(' ', 'T') + (/[zZ]$|[+-]\d\d:?\d\d$/.test(raw) ? '' : 'Z')); + if (Number.isNaN(d.getTime())) return ''; + const time = d.toLocaleTimeString($locale || undefined, { hour: 'numeric', minute: '2-digit', hour12: $timeFormat !== '24h' }); + return `${formatDate(d, $dateFormat)}, ${time}`; + } + const _stepText = st => (typeof st === 'string' ? st : `${st?.title || ''}\n${st?.text || ''}`).trim(); + const _ingText = groups => JSON.stringify((Array.isArray(groups) ? groups : []).map(g => [ + g?.name || '', (g?.items || []).map(i => [String(i?.qty ?? ''), i?.unit || '', (i?.name || '').trim()]), + ])); + const _same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + // A picture's address, the same however it's written: on its own, with + // the server in front, under a subpath, or with a query. + const _imgKey = u => { + if (!u) return ''; + const s = String(u); + const at = s.indexOf('/uploads/'); + if (at >= 0) return s.slice(at).split(/[?#]/)[0]; + try { return new URL(s, 'http://local').pathname; } catch { return s; } + }; + const _sortKeys = o => Object.fromEntries(Object.entries(o || {}) + .filter(([k, val]) => k !== '_derived' && val != null && val !== '').sort(([a], [b]) => a.localeCompare(b))); + // The server's id of this recipe's category, which is what a version + // holds. On the phone the recipe has its own id for it. + let versionCategoryId = null; + async function _loadVersionCategory() { + versionCategoryId = null; + if (!isNative || recipe?.category_id == null) { versionCategoryId = recipe?.category_id ?? null; return; } + try { + const cats = await NtApi.getRecipeCategories(); + versionCategoryId = Number(cats.find(c => c.id === recipe.category_id)?.server_id) || null; + } catch { versionCategoryId = null; } + } + const VERSION_REASONS = { + conflict: 'recipe_view_ct.versions.reason_conflict', + restore: 'recipe_view_ct.versions.reason_restore', + replaced: 'recipe_view_ct.versions.reason_replaced', + }; + // Someone else's edit: whose, in a whole sentence each. + const VERSION_REASONS_BY = { + conflict: 'recipe_view_ct.versions.reason_conflict_by', + restore: 'recipe_view_ct.versions.reason_restore_by', + replaced: 'recipe_view_ct.versions.reason_replaced_by', + }; + const VERSION_PARTS = { + title: 'recipe_view_ct.versions.part_title', + ingredients: 'recipe_view_ct.versions.part_ingredients', + steps: 'recipe_view_ct.versions.part_steps', + notes: 'recipe_view_ct.versions.part_notes', + category: 'recipe_view_ct.versions.part_category', + photo: 'recipe_view_ct.versions.part_photo', + nutrition: 'recipe_view_ct.versions.part_nutrition', + details: 'recipe_view_ct.versions.part_details', + }; + // What a version has that the recipe doesn't, as the parts of the page. + // Every field a restore writes is under one of them. + function versionDiffers(v, catId = versionCategoryId) { + const d = v.data || {}; + const out = []; + if ((d.name || '').trim() !== (recipe?.name || '').trim()) out.push('title'); + if (_ingText(d.ingredients) !== _ingText(recipe?.ingredients)) out.push('ingredients'); + if (!_same((d.steps || []).map(_stepText), (recipe?.steps || []).map(_stepText))) out.push('steps'); + if ((d.notes || '') !== (recipe?.notes || '')) out.push('notes'); + if ((d.category_id ?? null) !== (catId ?? null)) out.push('category'); + if (_imgKey(d.img_url) !== _imgKey(recipe?.img_url ?? recipe?.imgUrl)) out.push('photo'); + if (!_same(_sortKeys(d.nutrition), _sortKeys(recipe?.nutrition))) out.push('nutrition'); + const details = ['description', 'servings', 'prep_minutes', 'cook_minutes', 'rest_minutes', 'total_minutes', + 'yield_text', 'source_url', 'video_url']; + if (details.some(k => (d[k] ?? '') !== (recipe?.[k] ?? '')) || !_same(d.tags || [], recipe?.tags || []) + || !_same(d.tools || [], recipe?.tools || [])) out.push('details'); + return out; + } + async function restoreVersion(v) { + const ok = await confirmDialog({ + title: $_('recipe_view_ct.versions.confirm_title'), + message: $_('recipe_view_ct.versions.confirm_message'), + confirmText: $_('recipe_view_ct.versions.restore'), + }); + if (!ok) return; + restoringVersion = true; + try { + const restored = await NtApi.restoreRecipeVersion(_versionsRecipeId(), v.id); + if (isNative && getServerUrl()) { + // The phone's own copy comes from the server with a pull. A round + // already under way pulled before the restore, so wait for it and + // pull again. + const { fullSync } = await import('../lib/sync.js'); + await fullSync({ silent: true }); + await fullSync({ silent: true }); + recipe = await NtApi.getRecipe(id); + } else { + recipe = await NtApi.getRecipe(id).catch(() => restored); + } + versionsOpen = false; + showSuccess($_('recipe_view_ct.versions.restored')); + await loadVersions(); + } catch (e) { + showError(e.message || $_('recipe_view_ct.versions.restore_failed')); + } finally { + restoringVersion = false; + } + } + function openCookLog(cook = null) { editingCook = cook; cookDialogOpen = true; @@ -678,7 +836,7 @@ cookBusy = true; try { if (editingCook) { - await NtApi.updateCook(recipe.id, editingCook.id, e.detail); + await NtApi.updateCook(recipe.id, editingCook.id, { ...e.detail, _base: editingCook }); showSuccess($_('recipe_view_ct.toast.diary_updated')); } else { recipe = await NtApi.markCooked(recipe.id, e.detail); @@ -718,7 +876,7 @@ async function setRating(e) { const next = e.detail; try { - const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, rating: next }); + const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, rating: next, _base: recipeBaseOf(recipe), _changed: ['rating'] }); recipe = updated; } catch (err) { showError(err.message || 'Could not save rating'); @@ -732,7 +890,7 @@ const next = !recipe.favorite; favPopping = true; try { - const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, favorite: next }); + const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, favorite: next, _base: recipeBaseOf(recipe), _changed: ['favorite'] }); recipe = updated; } catch (err) { showError(err.message || 'Could not update favorite'); @@ -818,7 +976,7 @@ for (const [k, v] of Object.entries(recomputeResult.nutrition)) { perServing[k] = Math.round((v / div) * 100) / 100; } - const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, nutrition: perServing }); + const updated = await NtApi.updateRecipe(recipe.id, { ...recipe, nutrition: perServing, _base: recipeBaseOf(recipe), _changed: ['nutrition'] }); recipe = updated; recomputeResult = null; showSuccess(`Saved auto-calc — ${recomputeResult?.used ?? '?'} of ${recomputeResult?.total ?? '?'} ingredients`); @@ -937,6 +1095,17 @@ </div> {:else if recipe} <div class="body"> + {#if unseenVersions && !cookMode} + <div class="versions-notice" role="status"> + <span class="material-symbols-rounded versions-notice-icon" aria-hidden="true">history</span> + <p class="versions-notice-text">{$_('recipe_view_ct.versions.notice')}</p> + <button class="btn btn-ghost versions-notice-view" on:click={openVersions}>{$_('recipe_view_ct.versions.view')}</button> + <button class="btn-icon versions-notice-close" on:click={_markVersionsSeen} + aria-label={$_('recipe_view_ct.versions.dismiss')} title={$_('recipe_view_ct.versions.dismiss')}> + <span class="material-symbols-rounded">close</span> + </button> + </div> + {/if} <!-- Recipe header — side-by-side on desktop. Hero on the left, title / description / byline / rating + actions / meta / tags on the right so the screen real estate stays used and @@ -966,7 +1135,7 @@ cook history all on one row. Replaces the dedicated Source section + the standalone "Last Cooked" line below the meta strip — same data, less vertical real estate. --> - {#if recipe.created_by_full_name || recipe.created_by_username || recipe.created_at || recipe.source_url || recipe.last_cooked_at || recipe.cook_count > 0} + {#if recipe.created_by_full_name || recipe.created_by_username || recipe.created_at || recipe.source_url || recipe.last_cooked_at || recipe.cook_count > 0 || versions.length} <p class="byline"> {#if recipe.created_by_full_name || recipe.created_by_username} {@const creatorDisplay = recipe.created_by_full_name || recipe.created_by_username} @@ -1009,6 +1178,15 @@ <span class="dot">·</span> <span>Cooked {recipe.cook_count} {recipe.cook_count === 1 ? 'Time' : 'Times'}</span> {/if} + <!-- With the rest of the recipe's history rather than a fifth + header icon: they're rare, and the header has no room. --> + {#if versions.length} + <span class="byline-versions-wrap"> + <button type="button" class="byline-versions" on:click={openVersions}> + <span class="material-symbols-rounded" aria-hidden="true">history</span>{$_('recipe_view_ct.versions.title')} + </button> + </span> + {/if} </p> {/if} @@ -1519,6 +1697,79 @@ /> <!-- Share menu — Share Card (PNG) or Share Link (public URL). --> +<!-- Earlier versions: when each was kept, what differs, a look, Restore. --> +<Sheet bind:open={versionsOpen} title={$_('recipe_view_ct.versions.title')}> + <p class="versions-intro">{$_('recipe_view_ct.versions.intro')}</p> + <ul class="versions-list"> + {#each versions as v (v.id)} + {@const parts = recipe ? versionDiffers(v, versionCategoryId) : []} + <li class="version"> + <div class="version-head"> + <span class="version-time">{_versionTime(v)}</span> + <span class="version-reason"> + {#if v.edited_by != null && v.edited_by !== $currentUser?.id && v.edited_by_name} + {$_(VERSION_REASONS_BY[v.reason] || VERSION_REASONS_BY.conflict, { values: { name: v.edited_by_name } })} + {:else} + {$_(VERSION_REASONS[v.reason] || VERSION_REASONS.conflict)} + {/if} + </span> + </div> + <p class="version-name">{v.data?.name || ''}</p> + {#if parts.length} + <div class="version-parts"> + <span class="version-parts-label">{$_('recipe_view_ct.versions.differs')}</span> + {#each parts as part} + <span class="version-chip">{$_(VERSION_PARTS[part])}</span> + {/each} + </div> + {/if} + <div class="version-actions"> + <button class="btn btn-secondary version-btn" on:click={() => previewVersionId = previewVersionId === v.id ? null : v.id} + aria-expanded={previewVersionId === v.id}> + <span class="material-symbols-rounded">{previewVersionId === v.id ? 'visibility_off' : 'visibility'}</span> + {$_(previewVersionId === v.id ? 'recipe_view_ct.versions.hide_preview' : 'recipe_view_ct.versions.preview')} + </button> + <button class="btn btn-primary version-btn" on:click={() => restoreVersion(v)} disabled={restoringVersion}> + <span class="material-symbols-rounded">restore</span> + {$_('recipe_view_ct.versions.restore')} + </button> + </div> + {#if previewVersionId === v.id} + <div class="version-preview"> + <h4>{v.data?.name || ''}</h4> + {#if v.data?.description}<p class="version-desc">{v.data.description}</p>{/if} + <h5>{$_('recipe_view_ct.versions.ingredients')}</h5> + {#if (v.data?.ingredients || []).some(g => g.items?.length)} + {#each v.data.ingredients as g} + {#if g.name}<p class="version-group">{g.name}</p>{/if} + <ul class="version-ings"> + {#each g.items || [] as it} + <li>{[displayQty(it.qty, it.unit), it.unit, it.name].filter(Boolean).join(' ')}</li> + {/each} + </ul> + {/each} + {:else} + <p class="version-empty">{$_('recipe_view_ct.versions.no_ingredients')}</p> + {/if} + <h5>{$_('recipe_view_ct.versions.steps')}</h5> + {#if v.data?.steps?.length} + <ol class="version-steps"> + {#each v.data.steps as st}<li>{_stepText(st)}</li>{/each} + </ol> + {:else} + <p class="version-empty">{$_('recipe_view_ct.versions.no_steps')}</p> + {/if} + {#if v.data?.notes} + <h5>{$_('recipe_view_ct.notes')}</h5> + <div class="version-notes">{@html sanitizeRichText(v.data.notes)}</div> + {/if} + </div> + {/if} + </li> + {/each} + </ul> +</Sheet> + <ActionSheet bind:open={shareSheetOpen} title="Share Recipe" @@ -1569,6 +1820,54 @@ background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); } + /* Earlier versions: the notice above the recipe, and the sheet. */ + .versions-notice { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 8px 8px 8px 14px; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--info) 35%, transparent); + background: color-mix(in srgb, var(--info) 10%, var(--surface-1)); + color: var(--text-1); + } + .versions-notice-icon { color: var(--info); font-size: 20px; flex-shrink: 0; } + .versions-notice-text { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 1.4; } + .versions-notice-view { height: 36px; padding: 0 12px; flex-shrink: 0; } + .versions-notice-close { width: 36px; height: 36px; flex-shrink: 0; background: transparent; border-color: transparent; color: var(--text-2); } + .versions-intro { margin: 0 0 12px; color: var(--text-2); font-size: 14px; line-height: 1.45; } + .versions-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } + .version { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2); + padding: 12px 14px; + } + .version-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; } + .version-time { font-weight: 600; font-size: 14px; color: var(--text-1); } + .version-reason { font-size: 13px; color: var(--text-2); } + .version-name { margin: 6px 0 0; font-size: 15px; color: var(--text-1); overflow-wrap: anywhere; } + .version-parts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; } + .version-parts-label { font-size: 13px; color: var(--text-2); } + .version-chip { + font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; + background: var(--accent-dim); color: var(--text-1); + } + .version-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + .version-btn { height: 38px; padding: 0 14px; font-size: 14px; } + .version-btn .material-symbols-rounded { font-size: 18px; } + .version-preview { + margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); + font-size: 14px; line-height: 1.5; color: var(--text-1); overflow-wrap: anywhere; + } + .version-preview h4 { margin: 0 0 4px; font-size: 16px; } + .version-preview h5 { margin: 12px 0 4px; font-size: 13px; color: var(--text-2); } + .version-desc { margin: 0; color: var(--text-2); } + .version-group { margin: 6px 0 2px; font-weight: 600; } + .version-ings, .version-steps { margin: 0; padding-left: 20px; } + .version-empty { margin: 0; color: var(--text-3); } + .version-notes :global(p) { margin: 0 0 6px; } .btn-icon.close-btn:hover { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); @@ -1841,6 +2140,14 @@ line-height: 1.4; } .byline strong { color: var(--text-2); font-weight: 700; } + .byline-versions-wrap { display: inline-flex; align-items: center; white-space: nowrap; margin-left: 4px; } + .byline-versions { + display: inline-flex; align-items: center; gap: 3px; + padding: 2px 0; background: none; border: 0; cursor: pointer; + color: var(--accent); font: inherit; font-weight: 600; + } + .byline-versions .material-symbols-rounded { font-size: 16px; } + .byline-versions:hover { text-decoration: underline; } .byline .dot { opacity: 0.6; } .byline-author { display: inline-flex; diff --git a/src/routes/Recipes.svelte b/src/routes/Recipes.svelte index 0dee7371..9291e13f 100644 --- a/src/routes/Recipes.svelte +++ b/src/routes/Recipes.svelte @@ -1,5 +1,6 @@ <script> import { closeOnBack } from '../lib/back-stack.js'; + import { onSyncChanges } from '../lib/sync-refresh.js'; import { onMount, tick } from 'svelte'; import { fold } from '../lib/fold.js'; import { columnsAcrossFold, gridTemplateAcrossFold, columnForIndex } from '../lib/fold-core.js'; @@ -8,7 +9,8 @@ import { _ } from 'svelte-i18n'; import { formatDuration } from '../lib/duration.js'; import { NtApi } from '../lib/api.js'; - import { showError, showSuccess } from '../stores/toast.js'; + import { recipeBaseOf } from '../lib/offline-edits.js'; + import { showError, showSuccess, showInfo } from '../stores/toast.js'; import { pageBanners, bannerStyle, aiEnabled, aiKeyVerified, recipesSort, mixSharedIntoRecipes } from '../stores/settings.js'; import ActionSheet from '../components/ui/ActionSheet.svelte'; import ImportFromFileDialog from '../components/recipe/ImportFromFileDialog.svelte'; @@ -19,7 +21,7 @@ import { confirmDialog } from '../stores/confirmDialog.js'; import { buildRecipeCardPages, buildRecipeShareText } from '../lib/recipe-card.js'; import { svgToPngBlob, shareBlobs } from '../lib/shopping-card.js'; - import { isNative, getServerUrl } from '../lib/platform.js'; + import { isNative, getServerUrl, publicRecipeUrl } from '../lib/platform.js'; import { foldText } from '../lib/search-text.js'; @@ -111,13 +113,30 @@ pasteText = ''; clearPasteFile(); if (result?.recipes && Array.isArray(result.recipes)) { - showSuccess(`Imported ${result.count} recipes`); + const count = result.count || 0; + const skipped = Array.isArray(result.skipped) ? result.skipped.length : 0; + if (count === 0) { + showInfo($_('recipes_page.toast.archive_all_duplicates', { values: { skipped } })); + } else if (skipped > 0) { + showSuccess($_('recipes_page.toast.archive_imported_skipped', { values: { count, skipped } })); + } else { + showSuccess($_('recipes_page.toast.archive_imported', { values: { count } })); + } await load(); + } else if (result?.skipped) { + // Already in the catalog: nothing was written, so open the + // recipe it matched instead of a new one. + if (result.existing_id) { + showInfo($_('recipes_page.toast.already_exists')); + push(`/recipes/${result.existing_id}`); + } else { + showInfo($_('recipes_page.toast.already_exists_short')); + } } else { const created = result; const stepCount = (created.steps || []).reduce((n, s) => n + (s?.text ? 1 : 0), 0); if (stepCount === 0) { - showError("Imported — but the source had no cooking steps. Open it to add them manually."); + showError($_('recipes_page.toast.imported_no_steps')); } else { showSuccess($_('recipes_page.toast.recipe_imported')); } @@ -360,7 +379,7 @@ } function shareLinkUrl(token) { if (typeof window === 'undefined' || !token) return ''; - return `${window.location.origin}/#/r/${token}`; + return publicRecipeUrl(token); } async function openShareDialog(recipe) { shareDialogRecipe = recipe; @@ -501,7 +520,9 @@ } else if (v === 'favorite') { try { - const updated = await NtApi.updateRecipe(r.id, { ...r, favorite: !r.favorite }); + // Says it changes the favorite only: the list's copy may be older + // than an edit made elsewhere since, which the save mustn't undo. + const updated = await NtApi.updateRecipe(r.id, { ...r, favorite: !r.favorite, _base: recipeBaseOf(r), _changed: ['favorite'] }); recipes = recipes.map(x => x.id === r.id ? updated : x); } catch (err) { showError(err.message || 'Update failed'); } } @@ -606,8 +627,24 @@ $: if (viewMode || sharedRecipes.length || cookbooks.length || recipes.length) { if (typeof window !== 'undefined') tick().then(_measureVtPill); } - if (typeof window !== 'undefined') { - window.addEventListener('resize', () => tick().then(_measureVtPill)); + // The buttons change width after that first measure: the icon font + // loads (until then an icon is its name, "auto_stories", in text) and + // the counts arrive. Measure again whenever a button resizes, so the + // pill sits on the active button rather than where it was. + let vtResize = null; + onMount(() => { + const remeasure = () => tick().then(_measureVtPill); + window.addEventListener('resize', remeasure); + if (typeof ResizeObserver !== 'undefined') vtResize = new ResizeObserver(() => _measureVtPill()); + document.fonts?.ready?.then(remeasure); + return () => { + window.removeEventListener('resize', remeasure); + vtResize?.disconnect(); + vtResize = null; + }; + }); + $: if (vtResize) { + for (const el of [vtContainer, vtBtnRecipes, vtBtnShared, vtBtnCookbooks]) if (el) vtResize.observe(el); } // Category filter — driven by a clickable chip row OR a `?category=<slug>` @@ -760,9 +797,13 @@ return arr; } - async function load() { - loading = true; - loadError = null; + // Two reads at once (the page opening while a sync lands): the later + // one's answer stands, never an older one finishing last. + let _loadSeq = 0; + // quiet: read again after a sync, without the loading state. + async function load({ quiet = false } = {}) { + const seq = ++_loadSeq; + if (!quiet) { loading = true; loadError = null; } try { const [recipesRes, catsRes, cookbooksRes, sharedRes, sharedCbRes] = await Promise.all([ NtApi.getRecipes(), @@ -777,6 +818,8 @@ return []; }), ]); + if (seq !== _loadSeq) return; + loadError = null; recipes = recipesRes; categories = catsRes || []; cookbooks = cookbooksRes || []; @@ -792,13 +835,20 @@ // active button on cold start. tick().then(() => requestAnimationFrame(_measureVtPill)); } catch (e) { - loadError = e.message || 'Could not load recipes'; - showError(loadError); + // Only the newest read speaks; a quiet one after a sync that fails + // keeps what's shown, unsaid. + if (seq === _loadSeq && !quiet) { + loadError = e.message || 'Could not load recipes'; + showError(loadError); + } } finally { - loading = false; + if (seq === _loadSeq) loading = false; } } - onMount(load); + onMount(() => { load(); }); + // A sync that brought changes down (the first one after signing in + // included) shows them here at once. + onMount(() => onSyncChanges(() => load({ quiet: true }))); function totalMinutes(r) { if (r?.total_minutes != null) return r.total_minutes; diff --git a/src/routes/Shopping.svelte b/src/routes/Shopping.svelte index b6ef16c3..e9687275 100644 --- a/src/routes/Shopping.svelte +++ b/src/routes/Shopping.svelte @@ -1,5 +1,6 @@ <script> import { closeOnBack } from '../lib/back-stack.js'; + import { onSyncChanges } from '../lib/sync-refresh.js'; import { onMount } from 'svelte'; import { fade, slide } from 'svelte/transition'; import { push } from 'svelte-spa-router'; @@ -194,12 +195,19 @@ return String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' }); } - async function load() { - loading = true; - loadError = null; - try { items = await NtApi.getShoppingList(); } - catch (e) { loadError = e.message || 'Could not load list'; showError(loadError); } - finally { loading = false; } + // Two reads at once (the page opening while a sync lands): the later + // one's answer stands, never an older one finishing last. + let _loadSeq = 0; + // quiet: read again after a sync, without the loading state. + async function load({ quiet = false } = {}) { + const seq = ++_loadSeq; + if (!quiet) { loading = true; loadError = null; } + try { const list = await NtApi.getShoppingList(); if (seq === _loadSeq) { items = list; loadError = null; } } + catch (e) { + // A quiet read after a sync that fails keeps what's shown, unsaid. + if (seq === _loadSeq && !quiet) { loadError = e.message || 'Could not load list'; showError(loadError); } + } + finally { if (seq === _loadSeq) loading = false; } } async function loadPantry() { try { @@ -214,6 +222,8 @@ catch { categories = []; } } onMount(() => { load(); loadPantry(); loadCategories(); }); + // A sync that brought changes down shows them here at once. + onMount(() => onSyncChanges(() => { load({ quiet: true }); loadPantry(); loadCategories(); })); // Known-aisle list = defaults from categories (default_aisle if set, // else the category name) + whatever aisles are currently in the @@ -511,14 +521,14 @@ try { if (members.length === 1) { items = items.map(i => i.id === it.id ? { ...i, ...payload } : i); - await NtApi.updateShoppingItem(it.id, payload); + await NtApi.updateShoppingItem(it.id, { ...payload, _base: it }); } else if (fold) { const [keep, ...rest] = members; const restIds = new Set(rest.map(m => m.id)); items = items .filter(i => !restIds.has(i.id)) .map(i => i.id === keep.id ? { ...i, ...payload } : i); - await NtApi.updateShoppingItem(keep.id, payload); + await NtApi.updateShoppingItem(keep.id, { ...payload, _base: keep }); for (const id of restIds) await NtApi.deleteShoppingItem(id); } else { const nameUnit = { name: payload.name, unit: payload.unit }; diff --git a/src/routes/Wizard.svelte b/src/routes/Wizard.svelte index 1a2db4f3..c3227f32 100644 --- a/src/routes/Wizard.svelte +++ b/src/routes/Wizard.svelte @@ -5,7 +5,10 @@ import { cubicOut } from 'svelte/easing'; import { DB } from '../lib/db.js'; import { bulkSet } from '../stores/settings.js'; - import { currentUser, userMgmtActive, setupRequired, loadAuthState } from '../stores/auth.js'; + import { currentUser, userMgmtActive, setupRequired, loadAuthState, signInProblem } from '../stores/auth.js'; + import { get } from 'svelte/store'; + import { cookieBlockedByHttp, droppedCookieReason } from '../lib/cookie-check.js'; + import CookieWarning from '../components/ui/CookieWarning.svelte'; import { validatePassword, passwordStrength } from '../lib/validation.js'; import { showError } from '../stores/toast.js'; import { isNative, getServerUrl, apiUrl } from '../lib/platform.js'; @@ -19,6 +22,16 @@ const _isPwa = !isNative; const _forceAccountCreation = _isPwa && $setupRequired; + // A new account signs straight in, so a plain-HTTP page with an HTTPS-only + // cookie would loop here too. Say so on the account step (cookie-check.js). + let _authStatus = null; + if (_isPwa) { + fetch(apiUrl('/api/auth/status'), { credentials: 'include' }) + .then(r => (r.ok ? r.json() : null)) + .then(d => { _authStatus = d; if (cookieBlockedByHttp(d)) signInProblem.set('http'); }) + .catch(() => {}); + } + let step = 0; let dir = 1; @@ -103,6 +116,12 @@ throw new Error(e.error || `Registration failed (${res.status})`); } await loadAuthState(); + // The account exists, but the browser didn't keep the sign-in cookie, + // so every later step would fail. Stop and say why. + if (_isPwa && !get(currentUser)) { + signInProblem.set(droppedCookieReason(_authStatus)); + return; + } next(); } catch (e) { umError = e.message; @@ -226,6 +245,9 @@ {#if enableUserMgmt} <div class="um-form" transition:fly={{ y: 10, duration: 200 }}> + {#if $signInProblem} + <CookieWarning reason={$signInProblem} /> + {/if} <p class="um-section-label">{$_('wizard_ct.admin_account')}</p> <div class="form-row-2"> diff --git a/src/stores/auth.js b/src/stores/auth.js index e7f0f92f..c9fe8773 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -1,4 +1,4 @@ -import { writable } from 'svelte/store'; +import { writable, get as getStore } from 'svelte/store'; import { loadServerSettings } from './settings.js'; import { isNative, getServerUrl, getAuthToken, apiUrl as _apiUrl } from '../lib/platform.js'; @@ -19,6 +19,11 @@ export const userMgmtActive = writable(false); /** True when the server has no users yet — PWA must show setup screen */ export const setupRequired = writable(false); +// Why the last sign-in didn't stick, if it didn't ('http' | 'dropped'), so +// the login page can say so. A store, not page state, because the login page +// is torn down and rebuilt when the user flips on and back off. See +// lib/cookie-check.js. +export const signInProblem = writable(null); // Synthetic local user for native standalone mode (no server configured). // full_name is overridden at load time from the localUserName setting (set @@ -86,11 +91,26 @@ export async function loadAuthState() { if (cached) { try { const user = JSON.parse(cached); + // Only the account the session is for: one cached by an earlier + // sign-in (before a Disconnect and a connect as someone else) + // would be checked against the phone's copy as if signed in. + const { tokenUserId } = await import('../lib/local-account.js'); + const sid = tokenUserId(getAuthToken()); + if (sid != null && String(sid) !== String(user?.id)) throw new Error('another account'); currentUser.set(user); userMgmtActive.set(localStorage.getItem('ct:cachedUserMgmt') === '1'); localStorage.setItem('wl:userId', String(user.id)); - } catch {} + } catch { + // Not this session's account: the sign-in screen until the server + // says who is signed in, never the phone's copy with nobody. + localStorage.removeItem('ct:cachedUser'); + localStorage.removeItem('wl:userId'); + } } + // No account to show yet on a server with accounts: sign-in first + // (offline included), never the copy with no account. A server + // without accounts (single-user) said so before, and shows the app. + if (getStore(currentUser) == null && localStorage.getItem('ct:cachedUserMgmt') !== '0') userMgmtActive.set(true); // Refresh auth from server in the background (non-blocking) _refreshAuthFromServer(); return; @@ -264,6 +284,17 @@ export async function logout() { // post_logout_redirect_uri so the Capacitor browser can route back // into the app after the IdP destroys the session. let logoutUrl = null; + // Android: the phone keeps its copy of the account, so signing back in to + // the same account picks up where it left off. Send what's waiting first, + // while the session still works: a push only, cut off after a few + // seconds. What can't go now stays for this account (lib/local-account.js + // keeps it from going up under anyone else). + if (isNative && getServerUrl()) { + try { + const { pushBeforeSignOut } = await import('../lib/sync.js'); + await pushBeforeSignOut(); + } catch { /* never block sign-out */ } + } try { const logoutPath = isNative ? '/api/auth/oidc/logout?mobile=1' @@ -281,6 +312,8 @@ export async function logout() { const oidcRes = await fetch(_apiUrl(logoutPath), { method: 'POST', credentials: 'include', + // Android: a server that doesn't answer never holds up signing out. + ...(isNative ? { signal: AbortSignal.timeout(5000) } : {}), headers: { ..._authHeaders(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); @@ -313,11 +346,13 @@ export async function logout() { await clearOffline(); } catch { /* nothing queued, or no database */ } } - try { await fetch(_apiUrl('/api/auth/logout'), { method: 'POST', credentials: 'include', headers: _authHeaders() }); } catch {} + try { await fetch(_apiUrl('/api/auth/logout'), { method: 'POST', credentials: 'include', headers: _authHeaders(), ...(isNative ? { signal: AbortSignal.timeout(5000) } : {}) }); } catch {} // Clear auth state — but keep cached data (foods, images, server URL) if (isNative) { const { setAuthToken } = await import('../lib/platform.js'); setAuthToken(null); + // The server's cookie from this sign-in can't stand in for the next. + try { await (await import('../lib/local-account.js')).forgetServerCookies(); } catch { /* none */ } // Wipe biometric-cached JWT too, otherwise the next launch could bypass // the password gate after the user explicitly signed out. try { @@ -328,6 +363,18 @@ export async function logout() { localStorage.removeItem('wl:userId'); localStorage.removeItem('ct:cachedUser'); localStorage.removeItem('ct:csrf'); + // What the app holds in memory is this account's: gone before anyone + // else signs in (lib/user-state.js). A sync still running writes nothing + // more (lib/local-account.js). + try { + const { resetUserState } = await import('../lib/user-state.js'); + await resetUserState(); + if (isNative) { + const la = await import('../lib/local-account.js'); + la.bumpAccountGeneration(); + la.resetAccountGate(); + } + } catch { /* never block sign-out */ } currentUser.set(null); // The watch should not keep a working token for an account that signed out. if (isNative) import('../lib/wear-pairing.js').then(({ unpairWatch }) => unpairWatch()).catch(() => {}); diff --git a/src/stores/settings.js b/src/stores/settings.js index 37922321..07cae7bf 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -1,5 +1,8 @@ import { writable, get, derived } from 'svelte/store'; import { DB } from '../lib/db.js'; +import { settingPrefix } from '../lib/setting-key.js'; +// Whose settings: the account (and server) they're kept for. +const _userKey = () => settingPrefix(); const _dlog = import.meta.env.DEV ? console.log @@ -126,7 +129,11 @@ export function scheduleSave(key, value) { if (!SERVER_SETTINGS.has(key)) return; if (_suppressSync) return; clearTimeout(_saveQueue[key]); + // The account the change was made for: if another one is signed in by + // the time it goes, it doesn't go (it would land in that account). + const forUser = _userKey(); _saveQueue[key] = setTimeout(async () => { + if (_userKey() !== forUser) return; if (!_shouldSyncToServer()) return; try { const url = _settingsUrl(); @@ -151,6 +158,26 @@ export function scheduleSave(key, value) { }, 600); } +/** + * A setting changed here, into the phone's copy (Android), where the sync + * sends it and an account switch counts it as a change waiting. Only into + * a copy that is this account's: the account signing in writes its own + * settings as the app starts, before the app has checked whose copy the + * phone holds (lib/local-account.js), and they'd count as the last + * account's changes. Nor while signed out. They still go straight to the + * server (scheduleSave). + */ +export async function mirrorSetting(key, value) { + if (!isNative) return; + // Signed out on a server: nobody's change (the app sets defaults as it + // starts on the sign-in screen). + if (getServerUrl() && !getAuthToken()) return; + const { localDataIsThisAccount } = await import('../lib/local-account.js'); + if (!(await localDataIsThisAccount().catch(() => false))) return; + const { dbUpsertSetting } = await import('../lib/db-native.js'); + await dbUpsertSetting(key, value); +} + export async function bulkSet(settingsObj) { if (!settingsObj || typeof settingsObj !== 'object') return; const entries = Object.entries(settingsObj); @@ -164,8 +191,7 @@ export async function bulkSet(settingsObj) { if (isNative && userPrefEntries.length > 0) { try { - const { dbUpsertSetting } = await import('../lib/db-native.js'); - for (const [key, value] of userPrefEntries) await dbUpsertSetting(key, value); + for (const [key, value] of userPrefEntries) await mirrorSetting(key, value); } catch (e) { console.warn('[settings] bulk native upsert failed:', e.message); } @@ -212,7 +238,11 @@ export async function loadServerSettings() { if (DEVICE_PREFS.has(key)) continue; DB.setSetting(key, value, true); } - if (isNative) { + // Only into a copy that is this account's: signing in as someone else + // loads their settings before the app has checked whose copy the phone + // holds (lib/local-account.js), and they'd land in the last account's. + const ownCopy = isNative && await import('../lib/local-account.js').then(m => m.localDataIsThisAccount()).catch(() => false); + if (ownCopy) { try { const { dbUpsertSetting, dbMarkSettingsSynced } = await import('../lib/db-native.js'); const keys = []; @@ -250,15 +280,37 @@ if (typeof window !== 'undefined') { if (_suppressSync) return; const value = DB.getSetting(key, undefined); _recentlyChanged.set(key, Date.now()); - if (isNative) { - import('../lib/db-native.js').then(({ dbUpsertSetting }) => dbUpsertSetting(key, value)).catch(() => {}); - } + if (isNative) mirrorSetting(key, value).catch(() => {}); scheduleSave(key, value); }); } +// Every setting store, so they can all be read again for another account +// (reloadSettingStores). Settings are kept per account in this app's +// storage (lib/db.js, lib/setting-key.js), but a store holds the value it +// read when the app started; without this, the next account to sign in saw +// the last one's values (units, AI keys, kitchen...) and could save them +// as its own. +const _settingStores = []; +let _storesUser = _userKey(); + +/** Read every setting store again if the signed-in account changed (or + * `force`), and drop the last account's changes still waiting to go to + * the server. Values are set straight into the stores: nothing is + * written or sent. */ +export function reloadSettingStores({ force = false } = {}) { + const now = _userKey(); + if (!force && now === _storesUser) return false; + _storesUser = now; + for (const k of Object.keys(_saveQueue)) { clearTimeout(_saveQueue[k]); delete _saveQueue[k]; } + _recentlyChanged.clear(); + for (const { key, defaultValue, store } of _settingStores) store.set(DB.getSetting(key, defaultValue)); + return true; +} + function createSettingStore(key, defaultValue) { const store = writable(DB.getSetting(key, defaultValue)); + _settingStores.push({ key, defaultValue, store }); window.addEventListener('wl:setting', (e) => { if (e.detail && e.detail.key === key) { @@ -280,9 +332,7 @@ function createSettingStore(key, defaultValue) { store.set(value); if (_suppressSync) return; _recentlyChanged.set(key, Date.now()); - if (isNative && SERVER_SETTINGS.has(key)) { - import('../lib/db-native.js').then(({ dbUpsertSetting }) => dbUpsertSetting(key, value)).catch(() => {}); - } + if (isNative && SERVER_SETTINGS.has(key)) mirrorSetting(key, value).catch(() => {}); scheduleSave(key, value); }, update(fn) { diff --git a/src/stores/unitsOverlay.js b/src/stores/unitsOverlay.js index 14b9fe61..cd8fbdcc 100644 --- a/src/stores/unitsOverlay.js +++ b/src/stores/unitsOverlay.js @@ -13,25 +13,39 @@ const EMPTY = { disabled: [], custom: [] }; let _loaded = false; let _inflight = null; -export const unitsOverlay = writable(EMPTY); +// Loads when the first component subscribes (a UnitPicker on screen), not +// when the module is imported: importing the app pulled this in on the +// sign-in screen, where the request could only be refused. +export const unitsOverlay = writable(EMPTY, () => { + if (!_loaded && !_inflight) refreshUnitsOverlay(); +}); +// Moves when the account changes: a read started before doesn't land. +let _gen = 0; async function _fetch() { + const gen = _gen; try { const res = await NtApi.getUnits(); - unitsOverlay.set({ disabled: res?.disabled || [], custom: res?.custom || [] }); + if (gen === _gen) unitsOverlay.set({ disabled: res?.disabled || [], custom: res?.custom || [] }); } catch { - unitsOverlay.set(EMPTY); + if (gen === _gen) unitsOverlay.set(EMPTY); } } /** Trigger a fetch. Returns a Promise so callers (e.g. Manage edits) * can await consistency before the next render. */ export function refreshUnitsOverlay() { - _inflight = _fetch().finally(() => { _loaded = true; _inflight = null; }); - return _inflight; + const gen = _gen; + const p = _fetch().finally(() => { if (gen === _gen) { _loaded = true; _inflight = null; } }); + _inflight = p; + return p; } -// Auto-load on first store subscription. Idempotent. -unitsOverlay.subscribe(() => { - if (!_loaded && !_inflight) refreshUnitsOverlay(); -}); +/** Another account (or none): forget this one's units; the next picker on + * screen reads them again (lib/user-state.js). */ +export function resetUnitsOverlay() { + _gen++; + _loaded = false; + _inflight = null; + unitsOverlay.set(EMPTY); +}