mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #303 from MBombeck/release/v1.15.17
v1.15.17 — large imports, demo chart toggles
This commit is contained in:
7 files changed
+278
-8
No files matched your search
@@ -2,6 +2,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.17] — 2026-06-07 — large imports, demo chart toggles
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Large Apple Health exports import correctly.** A request-body size limit in the middleware layer truncated uploads over ~10 MB before they reached the importer, so a multi-megabyte `export.zip` lost its ZIP structure and failed with an "end-of-central-directory" error. The limit is raised so real exports arrive intact. (GitHub #281)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chart display toggles work in demo mode.** On a demo instance, the comparison-baseline selector and per-chart overlay toggles above charts are no longer blocked — these are harmless display preferences.
|
||||
|
||||
## [1.15.16] — 2026-06-07 — wellness ring glow no longer clipped
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.15.16
|
||||
version: 1.15.17
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -157,8 +157,18 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingExcludes: {
|
||||
"*": ["./next.config.ts"],
|
||||
},
|
||||
// Next 16 caps the request body that passes THROUGH middleware (our
|
||||
// `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies
|
||||
// before the route handler sees them. The Apple Health `export.zip` importer
|
||||
// (`/api/import/apple-health-export`) streams multi-MB archives to disk; the
|
||||
// 10MB cap truncated them so the ZIP end-of-central-directory was lost and
|
||||
// the parser failed (GitHub #281). Raise the ceiling so real exports pass
|
||||
// intact. The value is a ceiling, not a buffer — only the actual upload size
|
||||
// is held — so this is safe; very large exports also want a matching
|
||||
// reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md).
|
||||
experimental: {
|
||||
optimizePackageImports: ["recharts", "lucide-react"],
|
||||
middlewareClientMaxBodySize: "512mb",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.16",
|
||||
"version": "1.15.17",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# migrate-demo-edge01.sh
|
||||
|
||||
Copies **exactly one** user — the demo account
|
||||
`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01
|
||||
Postgres to the **separate** edge01 demo Postgres. No other user's rows are
|
||||
transferred under any circumstance.
|
||||
|
||||
> Review the script first, then run it deliberately. It performs destructive,
|
||||
> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice.
|
||||
> It never writes to apps01 (source is read-only).
|
||||
|
||||
## How it connects
|
||||
|
||||
- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss`
|
||||
(stable), psql `healthlog`/`healthlog`.
|
||||
- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate
|
||||
Coolify resource whose container name is **not hardcoded** — the script
|
||||
resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts
|
||||
if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`).
|
||||
The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked
|
||||
Postgres resource, not the docs/landing DB.
|
||||
- **Edge demo user id is resolved at runtime** by `username='demo'` (it may
|
||||
differ from the apps01 id). The wipe phase is scoped to that resolved id; the
|
||||
load then brings the apps01 row in, so afterwards the edge01 demo user
|
||||
carries the apps01 canonical id.
|
||||
|
||||
Each table is transferred with a scoped, server-side `\copy ... TO STDOUT`
|
||||
piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists
|
||||
on both ends** so secret columns can be NULLed and column order is pinned.
|
||||
|
||||
## What moves (FK-parent-first, 20 tables)
|
||||
|
||||
| # | Table | Scope predicate |
|
||||
|---|---|---|
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
|
||||
## Secret / credential columns NULLed on the `users` row
|
||||
|
||||
`codex_access_token_encrypted`, `codex_refresh_token_encrypted`,
|
||||
`codex_token_expires_at`, `codex_connected_at` (and `codex_connection_status`
|
||||
reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
|
||||
`ai_local_key_encrypted`, `ai_openai_key_encrypted`,
|
||||
`withings_client_id_encrypted`, `withings_client_secret_encrypted`,
|
||||
`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`,
|
||||
`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`,
|
||||
`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`,
|
||||
`mood_log_api_key_encrypted`, `mood_log_webhook_secret`,
|
||||
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
|
||||
|
||||
Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`,
|
||||
`role='ADMIN'`.
|
||||
|
||||
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
|
||||
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
|
||||
display / preference / layout JSON (`thresholds_json`,
|
||||
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
|
||||
`source_priority_json`, `doctor_report_prefs_json`, `notification_prefs`,
|
||||
`healthkit_config_json`, unit prefs, `height_cm`, `gender`, `timezone`,
|
||||
`locale`, `date_of_birth`, `display_name`, onboarding state, research-mode
|
||||
acknowledgement).
|
||||
|
||||
## What is excluded — and why
|
||||
|
||||
- **Auth / device / push** (sessions, passkeys, auth_challenges, api_tokens,
|
||||
refresh_tokens, devices, push_subscriptions, push_attempts,
|
||||
notification_channels, notification_preferences) — per-environment, never
|
||||
portable.
|
||||
- **Integration / OAuth** (withings/whoop/fitbit connections, all
|
||||
`*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions +
|
||||
reminder messages, clinician_share_links, provider_health) — tied to live
|
||||
credentials.
|
||||
- **Encrypted-at-rest payloads we can't decrypt on edge01:**
|
||||
`insight_narratives` and all `coach_*` (coach_conversations, coach_messages,
|
||||
coach_facts, coach_usage). The edge01 instance does not hold the prod
|
||||
encryption key, so the AES-256-GCM ciphertext would be unreadable. The same
|
||||
reason drives NULLing `cycle_day_logs.sensitive_encrypted` /
|
||||
`notes_encrypted` while still carrying that table's plaintext cycle fields.
|
||||
The plaintext AI assessment text is preserved through the `insights.%`
|
||||
`audit_logs` rows (the text lives in the `details` JSON) instead.
|
||||
- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups,
|
||||
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
|
||||
tables (measurement_rollups, mood_entry_rollups,
|
||||
medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these
|
||||
rebuild on edge01.
|
||||
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
|
||||
- **Instance / global:** app_settings; global catalogues (mood_tags,
|
||||
cycle_symptoms) — these pre-exist on edge01 and the carried link rows
|
||||
reference the global ids only.
|
||||
|
||||
## Encryption note
|
||||
|
||||
The edge01 demo instance is assumed **not** to share the apps01 encryption
|
||||
key. Every column whose value is AES-256-GCM ciphertext under the prod key is
|
||||
either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped
|
||||
entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying
|
||||
that ciphertext would produce undecryptable rows that the app's fail-closed
|
||||
loader would reject.
|
||||
|
||||
## role = ADMIN + DEMO_MODE expectations
|
||||
|
||||
The demo user is loaded with `role='ADMIN'` so the admin surfaces are
|
||||
viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on
|
||||
edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in
|
||||
demo mode, so an admin demo session can browse admin pages but cannot change
|
||||
anything. If DEMO_MODE were ever off on edge01, this admin demo account would
|
||||
be a write-capable admin. Keep DEMO_MODE enabled.
|
||||
|
||||
## Safety mechanisms built into the script
|
||||
|
||||
- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call.
|
||||
- **Pre-flight guard (apps01):** for each carried set, a query that must
|
||||
return `0` foreign-owned rows — aborts on any non-zero. Includes a check
|
||||
that the demo owns **0 custom mood_tags** (so every carried
|
||||
`mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus
|
||||
a defensive "non-global tag" link count.
|
||||
- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to
|
||||
the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to
|
||||
proceed if `username='demo'` on edge01 resolves to the operator id.
|
||||
- **Post-load verification (edge01):** per-table demo row counts; a hard
|
||||
assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero
|
||||
rows; a check that user_id-bearing carried tables hold only the demo id; and
|
||||
a confirmation the demo user exists with `role=ADMIN`.
|
||||
|
||||
## Caveats / things to confirm before running
|
||||
|
||||
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
|
||||
tables on edge01, not just what this run inserted. On a clean
|
||||
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
|
||||
accounts, treat a non-zero value as a warning to inspect, not an automatic
|
||||
failure (the script prints it as a WARNING, not an abort).
|
||||
- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script
|
||||
verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the
|
||||
demo Postgres resource uses different ones).
|
||||
- The script assumes edge01's schema is at the same (or a compatible) Prisma
|
||||
migration level as apps01. A column present on apps01 but missing on edge01
|
||||
fails the `\copy` loudly rather than silently corrupting data — run
|
||||
migrations on edge01 first if the two have drifted.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy
|
||||
* 403s every non-GET `/api/*` request except a narrow allowlist. Login
|
||||
* is the historical baseline; the two dashboard display-pref writes
|
||||
* (chart-overlay toggles + comparison-baseline selector) were added so
|
||||
* the above-chart toggles work in the demo. They touch nothing but the
|
||||
* caller's own `User.dashboardWidgetsJson` blob.
|
||||
*
|
||||
* This guard locks the allowlist shape in place so a refactor can't
|
||||
* silently widen it (admitting a data-bearing mutation) or narrow it
|
||||
* (re-breaking the demo toggles). The whole block is gated on
|
||||
* `DEMO_MODE === "true"`, so production is unaffected by construction —
|
||||
* the final case proves the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function makeRequest(pathname: string, method: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, { method });
|
||||
}
|
||||
|
||||
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
|
||||
|
||||
describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_DEMO_MODE === undefined) {
|
||||
delete process.env.DEMO_MODE;
|
||||
} else {
|
||||
process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
|
||||
);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => {
|
||||
const res = proxy(makeRequest("/api/dashboard/widgets", "PUT"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("still blocks a health-data mutation (POST /api/measurements)", () => {
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
|
||||
// The allowlist pins method per path; admitting the widgets PUT
|
||||
// must not open DELETE (which wipes the user's dashboard layout).
|
||||
const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("is inert when DEMO_MODE is off — production is unaffected", () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
// With the demo gate off, the proxy never short-circuits an API
|
||||
// mutation; the route's own auth/handler runs instead.
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
+25
-6
@@ -83,12 +83,29 @@ function isPublicPath(pathname: string): boolean {
|
||||
/**
|
||||
* API paths that are allowed to accept mutations (POST/PUT/PATCH/DELETE)
|
||||
* even when DEMO_MODE is enabled. Everything else is read-only.
|
||||
*
|
||||
* Each entry pins the exact path AND the exact method, so admitting one
|
||||
* verb on a route never opens its siblings (e.g. the dashboard-widgets
|
||||
* PUT must not drag in the layout-reset DELETE on the same path).
|
||||
*
|
||||
* The login family is the historical baseline — a demo visitor still has
|
||||
* to authenticate. The two dashboard entries are display-only preference
|
||||
* writes: idempotent, user-scoped, Zod-validated, and touching nothing but
|
||||
* the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles
|
||||
* + comparison-baseline selector). They carry no health data and are safe
|
||||
* to exercise in the demo so the above-chart toggles work there. This whole
|
||||
* block only runs under `DEMO_MODE=true`, so production (apps01) is
|
||||
* unaffected by construction.
|
||||
*/
|
||||
const DEMO_MUTATION_ALLOWLIST = [
|
||||
"/api/auth/login",
|
||||
"/api/auth/passkey/login-options",
|
||||
"/api/auth/passkey/login-verify",
|
||||
];
|
||||
const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
[
|
||||
{ path: "/api/auth/login", method: "POST" },
|
||||
{ path: "/api/auth/passkey/login-options", method: "POST" },
|
||||
{ path: "/api/auth/passkey/login-verify", method: "POST" },
|
||||
// Chart display-pref toggles above the charts (dashboard + insights):
|
||||
{ path: "/api/dashboard/chart-overlay-prefs", method: "PUT" },
|
||||
{ path: "/api/dashboard/widgets", method: "PUT" },
|
||||
];
|
||||
|
||||
// Legacy route redirects (German → English)
|
||||
const LEGACY_REDIRECTS: Record<string, string> = {
|
||||
@@ -170,7 +187,9 @@ export function proxy(request: NextRequest) {
|
||||
if (
|
||||
isApi &&
|
||||
isMutation &&
|
||||
!DEMO_MUTATION_ALLOWLIST.some((p) => pathname === p)
|
||||
!DEMO_MUTATION_ALLOWLIST.some(
|
||||
(entry) => pathname === entry.path && method === entry.method,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user