No description
  • Kotlin 33.9%
  • Python 33.6%
  • JavaScript 25.8%
  • CSS 6.4%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
brian 8ac84e48ce
All checks were successful
Build and Deploy / test-backend (push) Successful in 2m14s
Build and Deploy / build-frontend (push) Successful in 19s
Build and Deploy / build-borg-agent (push) Successful in 46s
Build and Deploy / build-backend (push) Successful in 1m27s
Build and Deploy / deploy (push) Successful in 17s
Build Android APK / build-apk (push) Successful in 4m24s
Merge pull request 'Highlight every dose of a medication on tap, and move the chart picker below' (#9) from highlight-doses-and-move-chart-picker into master
Reviewed-on: #9
2026-09-22 21:59:33 +00:00
.claude Add tests 2026-05-29 23:05:33 -06:00
.forgejo/workflows Sort the admin screen into sub-sections, and back up with Borg 2026-09-04 10:50:20 -06:00
.idea initial working medsync 2026-05-27 09:05:25 -06:00
android Highlight every dose of a medication on tap, and move the chart picker below 2026-09-22 15:42:34 -06:00
backend Sort the admin screen into sub-sections, and back up with Borg 2026-09-04 10:50:20 -06:00
borg Sort the admin screen into sub-sections, and back up with Borg 2026-09-04 10:50:20 -06:00
frontend Highlight every dose of a medication on tap, and move the chart picker below 2026-09-22 15:42:34 -06:00
.env ntfy add 2026-06-09 20:16:33 -06:00
.gitignore Ignore signing keystores repo-wide, not just under android/ 2026-08-05 13:53:19 -06:00
docker-compose.yml Sort the admin screen into sub-sections, and back up with Borg 2026-09-04 10:50:20 -06:00
README.md Sort the admin screen into sub-sections, and back up with Borg 2026-09-04 10:50:20 -06:00

MedSync — Medication Tracker

A self-hosted medication tracker PWA with bottle scanning via Claude AI, push notifications, and optional SMS / ntfy alerts.

Stack

Layer Tech
Backend Python 3.12 · FastAPI · APScheduler
Database SQLite (via Python stdlib)
Frontend React 18 · Vite · PWA (service worker)
Serving nginx (frontend) · uvicorn (backend)
Infra Docker Compose

Quick Start

1. Generate secrets

# JWT secret
openssl rand -hex 32

# VAPID keys for push notifications
pip install pywebpush
python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print('Public:', v.public_key); print('Private:', v.private_key)"
# OR use the web tool: https://vapidkeys.com

2. Configure environment

cp .env.example .env
# Edit .env and fill in all values

3. Build and run

docker compose up -d --build

Frontend: http://localhost:3000
API docs: http://localhost:3001/docs (FastAPI auto-generated Swagger UI)


Environment Variables

Variable Required Description
JWT_SECRET ✅ Random secret for signing tokens
ANTHROPIC_API_KEY ✅ For pill bottle scanning
VAPID_PUBLIC_KEY ✅ For browser push notifications
VAPID_PRIVATE_KEY ✅ For browser push notifications
VAPID_EMAIL ✅ mailto:you@domain.com
TWILIO_ACCOUNT_SID ➖ Optional — enables SMS alerts
TWILIO_AUTH_TOKEN ➖ Optional — enables SMS alerts
TWILIO_FROM_NUMBER ➖ Optional — your Twilio phone number
NTFY_SERVER_URL ➖ Initial ntfy server (admin-editable later; default https://ntfy.sh)
NTFY_TOKEN ➖ Optional — bearer token for a private server
NTFY_USERNAME ➖ Optional — username for a private server (HTTP Basic)
NTFY_PASSWORD ➖ Optional — password for a private server (HTTP Basic)
NTFY_PRIORITY ➖ Optional — message priority (default default)
OIDC_ISSUER ➖ Optional — OIDC issuer URL (enables SSO)
OIDC_CLIENT_ID ➖ OIDC client id
OIDC_CLIENT_SECRET ➖ OIDC client secret
OIDC_REDIRECT_URI ➖ https://<host>/api/auth/oidc/callback
OIDC_PROVIDER_NAME ➖ Button label (default Authentik)
FRONTEND_URL ➖ Only if frontend ≠ API origin

OIDC Single Sign-On (Authentik)

MedSync can tie accounts to an OIDC provider such as Authentik. When configured, the login page shows a Sign in with Authentik button and existing users can connect their identity from Settings → Connected accounts.

  1. In Authentik, create an OAuth2/OpenID confidential provider:
    • Redirect URI: https://<your-host>/api/auth/oidc/callback
    • Scopes: openid profile email
  2. Copy the client id/secret and the provider's issuer URL into .env (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI).
  3. Restart the backend. The feature auto-enables once these are set.

Account linking behaviour:

  • A first OIDC login auto-links to an existing account when the email claim (or, failing that, preferred_username) matches a MedSync user.
  • Otherwise a new account is provisioned from the OIDC claims (no local password).
  • Logged-in users can explicitly connect/disconnect from Settings.

Username/password login keeps working alongside SSO.


Admin & User Management

The first account created (whether by registration or OIDC sign-in) automatically becomes an admin. On an existing database with no admin, the earliest user is promoted on next startup.

Admins get an Admin tab, organised into sub-sections down the left-hand side. Each one is its own URL, so a link to a particular screen can be bookmarked and the back button works normally:

Sub-section /admin/… What lives there
General general Display timezone, Anthropic API key
Login login OIDC / single sign-on
Users users Create accounts, promote/demote, reset passwords, delete
Notifications notifications ntfy server, web push (VAPID), Twilio SMS
Backup backup Whole-instance archives to Nextcloud, and restore
Borg backup borg Deduplicated backups to a remote Borg server

The app refuses to demote or delete the last remaining admin, and you cannot delete your own account.

Every setting on these screens is stored in the database, not the environment. The .env values only seed the initial value of a setting that has never been edited — once an admin saves one, the database wins and the env var is ignored.


Backup & Restore

Two independent levels, both reachable from the UI and both able to write either to your browser's Downloads folder or to a Nextcloud server over WebDAV — plus a third, admin-only destination: a remote Borg repository over SSH.

Full system backup (Admin tab)

A single .zip holding a consistent snapshot of the SQLite database plus every uploaded pill photo — all accounts, medications, dose history and stored settings.

  • Download archive — streams the archive straight to the browser.
  • Back up to Nextcloud — uploads it to the configured destination.
  • Automatic backups — an optional nightly run at a chosen hour (in the instance's display timezone), keeping the newest N archives and deleting the rest.
  • Restore — from an archive listed on Nextcloud, or from a file you upload.

The database snapshot is taken with SQLite's online-backup API, not a file copy, so it is consistent even though the app runs in WAL mode and is serving requests at the time.

⚠️ A full archive contains password hashes, API-key hashes and every stored integration credential (Anthropic key, VAPID private key, Twilio token, OIDC secret). Treat it as sensitive.

⚠️ Restoring replaces everything — every account and dose logged since that backup is lost. The database being replaced is kept on the server next to the live one as medsync.db.pre-restore-<timestamp> (the newest three are retained), so a mistaken restore is still recoverable from the filesystem.

Borg backup (Admin → Borg backup)

Deduplicated, compressed, encrypted, incremental backups pushed to a remote Borg repository over SSH. Unlike the Nextcloud archive — a fresh full zip every night — only the chunks that actually changed cross the wire, so months of nightly history costs little more than a single archive.

This one runs in its own container, borg-agent, because it needs three things the backend should not have: the borg binary, an SSH private key, and the ability to stop MedSync itself.

browser ──▶ backend ──▶ borg-agent ──▶ ssh ──▶ your Borg server
                            │
                            ├── stops/starts medsync-backend (Docker socket)
                            ├── holds the SSH key (medsync-borg volume)
                            └── reads the database + photos (medsync-data volume)

Setting it up

  1. Generate an SSH key under SSH identity. The private half is created inside the agent container and never leaves it; copy the public key shown and add it to ~/.ssh/authorized_keys on your Borg server (or to your provider's key list — BorgBase, rsync.net and Hetzner Storage Box all work).
  2. Set the repository, e.g. ssh://borg@backup.example.com:22/./medsync. The scp-style borg@host:medsync also works, as does an absolute path if you mount a disk into the agent container.
  3. Generate a passphrase — or type your own — and write it down somewhere else. It is stored here so nightly backups can run unattended, but the archives cannot be read without it, and losing both the instance and the passphrase means losing the backups.
  4. Save settings, then Create repository (borg init --encryption repokey-blake2). This only works on an empty path; point at an existing repository and skip this step.
  5. Test connection to confirm SSH, the key and the passphrase all line up.

Host keys are trusted on first use by default and pinned thereafter. Scan host key pins one explicitly, which lets you switch Host key checking to strict.

Cold vs hot

Stop MedSync while the backup runs is on by default. The agent stops the listed containers, takes the archive, then starts them again in reverse order — nothing can be mid-write while the data is read, at the cost of the app being unavailable for as long as the archive takes (seconds, after the first run has seeded the repository).

Turn it off and the app stays up: the database is still captured consistently through SQLite's online-backup API, but a pill photo uploaded during the run may miss the archive.

A restore always stops MedSync, whatever this setting says — swapping the database out from under a running backend is not survivable.

Because a cold backup stops the very process the browser is talking to, the panel works a little differently from the rest of the app: starting a backup returns a job, the job id is remembered across a page reload, and a failed poll is treated as "still running" rather than an error. The outcome is written into the run log by the agent itself, so it survives the backend being restarted underneath it.

⚠️ The agent mounts /var/run/docker.sock, which is effectively root on the host. That is what lets it stop and start containers. If you'd rather not grant that, drop the socket mount from docker-compose.yml and turn the cold-backup toggle off — backups keep working, restores do not.

Retention

After each successful backup the agent applies borg prune with the keep daily / weekly / monthly counts from the panel, then borg compact to actually release the space. Setting one of the three to 0 keeps none of that kind. Pruning failures are reported but never fail a backup that has already been written.

Personal backup (Settings tab)

Each user can back up and restore their own data — every medication they've added with its ingredients and pill photo, their groups, and the complete record of what they took and when.

Format Contents Restorable
.zip Everything, including pill photos ✅
.json The same document without photos ✅
.csv Dose history only, for spreadsheets ➖

Restoring offers two modes:

  • Replace — clear the account's medications and history, then rebuild from the backup.
  • Merge — keep what's there and add only what's missing. Medications are matched by name and doses by medication + timestamp, so merging the same bundle twice is a no-op.

A user bundle carries no credentials and restores into the account doing the restoring, never as an account — so it can't be used to take over a login, and restoring someone else's exported file simply copies that data into your own account.

Caretakers can back up and restore for a patient they manage, using the same for_user_id access check as the rest of the app.

Configuring Nextcloud

Both levels use Nextcloud's WebDAV endpoint (/remote.php/dav/files/<user>/) with HTTP Basic auth. Create an app password under Settings → Security in Nextcloud rather than using your account password, so it can be revoked independently.

The instance-wide destination is set by an admin and is stored in the database (an admin-editable setting, like the ntfy server). Each user's personal destination lives on their own account and is entirely separate — a user's backups never touch the admin's credentials or folder, and vice versa. Both offer a Test connection button that verifies the credentials and creates the target folder.


Public API (for other apps)

MedSync exposes a small, scoped API so other apps and scripts can view your medications and log doses without your login credentials.

📖 Full reference: https://your-host/api/public/docs — a self-contained page covering every key-accessible endpoint, its parameters, response fields, and worked curl examples. It needs no login, so you can hand the URL to whoever is writing the integration. The summary below is just an orientation.

API keys

Generate a key by calling the management endpoints with a normal logged-in session (JWT). Keys are shown once at creation — store them securely.

# Create a key (returns the plaintext "key" exactly once)
curl -X POST https://your-host/api/auth/api-keys \
  -H "Authorization: Bearer <your-jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Home Assistant"}'
# → {"id": 1, "name": "Home Assistant", "key_prefix": "msk_AbC12345", "key": "msk_…"}

curl https://your-host/api/auth/api-keys   -H "Authorization: Bearer <your-jwt>"   # list
curl -X DELETE https://your-host/api/auth/api-keys/1 -H "Authorization: Bearer <your-jwt>"  # revoke

Only the SHA-256 hash of a key is stored; a lost key can only be replaced, not recovered. Managing keys requires a real login — an API key cannot mint or revoke keys.

Using a key

Pass the key as a bearer token, exactly like a JWT:

Authorization: Bearer msk_…

Scope — an API key can only view and log. It cannot delete medications, edit your profile, manage caretakers, or reach admin settings; those stay login-only. A key inherits the owner's caretaker access, so for_user_id works for patients they care for. Use GET /api/caretaker/patients to discover those patient ids — it lists who the key's owner cares for, but linking and unlinking patients remains login-only.

Capability Endpoint
List the patients you're a caretaker for (id, username, display_name) GET /api/caretaker/patients
List medications & supplements (with next_due / overdue) GET /api/medications
List groups and their members GET /api/groups
Dose history (rolling window, or start/end for a past range) GET /api/medications/history?days=7
Log a dose (now, or backdated via taken_at) POST /api/medications/{id}/log
Log every med in a group at once POST /api/groups/{id}/log

Supplements are simply medications with category: "supplement", so they appear in the same lists. When a medication is next due is returned as the next_due (ISO-8601) and overdue (boolean) fields on each medication; next_due is null for paused meds, as-needed (PRN) meds, and ones never taken.

overdue means "MedSync would chase you for this", not merely "past its time": it is false for an as_needed med (no schedule at all) and for one with reminders_enabled: 0 (muted on purpose, so it still reports a past next_due without ever alerting). Compare next_due to the clock yourself if you want raw lateness rather than MedSync's judgement.

# View what's due
curl https://your-host/api/medications -H "Authorization: Bearer msk_…"

# Log a dose taken two hours ago
curl -X POST https://your-host/api/medications/42/log \
  -H "Authorization: Bearer msk_…" -H "Content-Type: application/json" \
  -d '{"taken_at": "2026-07-14T10:00:00+00:00"}'

Interactive docs for all endpoints are at /docs (Swagger UI).


Android

Two options — they talk to the same API and can be used interchangeably.

A Kotlin/Compose client lives in android/. It mirrors the web UI and keeps working without a connection: cached medications, groups and history stay readable, and doses, edits, pauses and group logs queue locally and replay in order once the server is reachable again — including after the app is closed.

cd android
./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk

CI builds it too: .forgejo/workflows/build-android.yaml uploads the APK as an artifact on every push under android/.

It also raises dose reminders on the phone itself, scheduled from the cached schedule rather than pushed from the server — so they still fire when the server is unreachable, and each one can log the dose straight from the notification. SMS and ntfy remain available alongside them.

On first launch enter the same address you use in a browser. Admin and OIDC sign-in are web-only — see the Android README for why, and for what queues offline.

PWA

  1. Open Chrome on Android and navigate to your server URL
  2. Tap the ⋮ menu → "Add to Home screen"
  3. The app installs like a native app with push notification support
  4. First launch: tap Enable Notifications in the app settings

Project Structure

medsync/
├── docker-compose.yml
├── .env.example
├── backend/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py              # FastAPI app + lifespan
│       ├── db/
│       │   └── schema.py        # SQLite init + context manager
│       ├── routes/
│       │   ├── auth.py          # Register, login, profile
│       │   ├── admin.py         # Admin user management
│       │   ├── medications.py   # CRUD + dose logging
│       │   ├── public_docs.py   # Public API docs page (/api/public/docs)
│       │   ├── backup.py        # System + per-user backup / restore
│       │   ├── borg.py          # Borg settings + agent endpoints (admin)
│       │   └── scan.py          # Claude Vision bottle scan
│       └── services/
│           ├── auth.py          # JWT + bcrypt
│           ├── backup.py        # Archive building, restore, user bundles
│           ├── backup_jobs.py   # Destinations, retention, run log
│           ├── borg.py          # Thin client for the borg-agent container
│           ├── nextcloud.py     # WebDAV client (MKCOL/PUT/PROPFIND/GET)
│           ├── notifications.py # WebPush + Twilio SMS + ntfy
│           └── scheduler.py     # APScheduler — dose checks + nightly backups
├── borg/                        # Sidecar: borg binary, SSH key, container control
│   ├── Dockerfile
│   └── agent.py                 # HTTP agent the backend drives
├── frontend/
│   ├── Dockerfile
│   ├── nginx.conf
│   ├── vite.config.js           # Vite + PWA plugin
│   └── src/
│       ├── services/api.js      # All API calls
│       ├── pages/AdminPage.jsx  # Admin shell — sidebar + sub-section outlet
│       ├── pages/admin/         # One component per admin sub-section
│       ├── contexts/AuthContext.jsx
│       └── hooks/usePushNotifications.js
└── android/                     # Native Kotlin/Compose client
    └── app/src/main/java/com/medsync/android/
        ├── data/                # API client, offline cache, outbox, sync
        └── ui/                  # Theme ported from index.css, screens

Development (without Docker)

Backend:

cd backend
pip install -r requirements.txt
cp ../.env.example .env  # fill in values
uvicorn app.main:app --reload --port 3001
# Swagger UI at http://localhost:3001/docs

Frontend:

cd frontend
npm install
npm run dev   # proxies /api to localhost:3001

Android:

cd android
./gradlew installDebug   # needs JDK 17 + an Android SDK (see android/README.md)

From an emulator, the host's backend is 10.0.2.2:3001.


Put nginx or Caddy in front to add HTTPS — required for:

  • Camera access on Android Chrome
  • Push notifications (requires secure context)

Example Caddy snippet:

medsync.yourdomain.com {
    reverse_proxy localhost:3000
}