Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Docker build-context excludes (shared by crates/ldgr-server/Dockerfile and
# apps/web/Dockerfile, both of which build from the repository root). Only build
# artifacts, dependencies, and VCS metadata are excluded — never source needed by
# either image build.
**/target
**/node_modules
**/*.tsbuildinfo
apps/web/out
apps/web/.next
apps/web/pkg
apps/web/public/sql.js

.git
.github
.DS_Store
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ LDGR_VERSION=latest
# Host port to expose the server on (container always listens on 8080).
LDGR_HOST_PORT=8080

# Host port for the web app + admin panel (served by Caddy). Browse to
# http://<host>:<LDGR_WEB_PORT>/admin. The container always listens on 8080.
LDGR_WEB_PORT=8081

# ── Server settings (LDGR_*) ─────────────────────────────────────────────────

# Address the server binds inside the container. Must be 0.0.0.0 so the port is
Expand Down Expand Up @@ -54,6 +58,14 @@ LDGR_DEFAULT_QUOTA_BYTES=1073741824
# auth). Handy to label your instance.
LDGR_SERVER_NAME=ldgr-server

# Cross-origin allowlist for the browser admin panel / web client
# (comma-separated origins). Empty by default = deny all cross-origin requests
# (the secure default for a zero-knowledge server). Set this ONLY when the web
# app is served from a DIFFERENT origin than this API (a separate admin host, or
# local development). A same-origin deployment behind one reverse proxy needs
# nothing here.
#LDGR_ALLOWED_ORIGINS=https://admin.example.com,https://ldgr.example.com

# ── Optional TLS reverse proxy (Caddy `tls` profile) ─────────────────────────
# Only needed if you run `docker compose --profile tls up -d`. Point your
# domain's DNS at this host first; Caddy will obtain a certificate automatically.
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ jobs:
- name: Run tests
working-directory: apps/web
run: npm test
- name: Add wasm target
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack
run: curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
- name: Build WASM bundle
working-directory: apps/web
run: npm run build:wasm
- name: Build static export
working-directory: apps/web
run: npm run build

# ── Infra (Cloudflare Worker) ─────────────────────────────────────────────────

Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,50 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

# Web app + admin panel image (Apache-2.0 static export served by Caddy).
# Same tag/dispatch triggers as `docker` above — NOT a pull-request merge gate,
# so it needs no entry in kafkade/github-infra required_status_checks.
docker-web:
name: Build & push web (multi-arch)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/ldgr-web
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' }}

- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ldgr watch` and `ldgr portfolio` now fetch market data through the shared caching proxy (`api.ldgr.dev/market/`) first, falling back to direct provider requests when it is unavailable, on top of the existing local price cache (local cache → proxy → direct). Set `LDGR_MARKET_PROXY` to point at your own proxy deployment, or use `LDGR_MARKET_PROXY=none` / the `--no-proxy` flag to bypass the proxy and fetch providers directly. Only symbol names are ever sent to the proxy — never vault, balance, or portfolio data
- PDF report export: `ldgr export --format pdf --report <balancesheet|incomestatement|networth> --output <file>` renders a paginated, styled PDF of the chosen report — title, period (derived from `date:`/`begin:`/`end:` query filters), an indented account hierarchy, and right-aligned totals — with multi-currency amounts grouped per commodity and decimal values rendered exactly. The `export` command also gains an `--output` flag to write CSV/JSON/hledger output to a file instead of stdout
- Advanced financial goal projections via a new `ldgr goals` command: `goals list` shows progress for each goal (pulling the current amount from a linked account when available), `goals project <id>` returns an optimistic/expected/pessimistic projection band with an optional inflation-adjusted (nominal vs real) target, `goals plan <id>` projects a variable contribution schedule (step-ups and one-off lump sums), and `goals allocate --budget <amount>` divides a shared monthly budget across competing goals by priority, deadline, proportional, or equal strategy with per-goal shortfall reporting. Goals are defined in `~/.ldgr/goals.json` and every subcommand supports `--output table|json`. All projection math is deterministic and money is computed without floating point
- Batteries-included web admin panel in the self-hosting bundle: `docker compose up -d` now serves the `/admin` panel out of the box. The existing Caddy container serves a static build of the Apache-2.0 web app and reverse-proxies the API to the server, so the panel is reachable at `http://<host>:8081/admin` with its **Server URL** pre-filled to the same origin — no separate hosting step. The AGPL `ldgr-server` binary still serves no web assets (Caddy only), the optional Caddy `tls` profile fronts both the panel and API on a single HTTPS origin, and the panel's published port is configurable with `LDGR_WEB_PORT`
- `LDGR_ALLOWED_ORIGINS` on `ldgr-server`: a comma-separated allowlist of browser origins permitted to call the admin/sync API cross-origin (methods `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`OPTIONS`, `authorization`/`content-type` headers, credentials disabled). It is empty by default, so cross-origin browser requests are denied unless you explicitly opt in — needed only when serving the web admin panel from a different origin than the server

### Fixed

Expand All @@ -104,11 +106,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Web vault writes (add account, add/delete transaction) now record sync-outbox events, so locally created changes are actually included in the next push instead of being silently skipped (the `sync_events`/`sync_state` tables were previously dead scaffolding)
- Resolving a sync conflict as "keep remote" now actually re-applies the remote change (via the Swift/iOS bindings it previously only marked the conflict resolved without materializing the remote version)
- Sync against a self-hosted `ldgr-server` now lists and pulls every encrypted batch and snapshot in large vaults: blob listings were previously capped at a single server page (~1000 entries), so a vault with more than one page of batches or snapshots would silently drop — and never pull — the overflow. The client now follows the server's continuation cursor across all pages
- The `apps/web` admin panel static export builds again: the build now targets the webpack compiler the project's WASM config requires, and Tailwind CSS is aligned to the v3 toolchain its config is written against, so `npm run build` produces the static panel instead of failing

### Security

- The local working store (`vault.db`) is now encrypted at rest with SQLCipher (AES-256): the database is keyed by a subkey derived from your vault key via HKDF, so account names, transaction descriptions, amounts, and commodities are no longer readable from disk without your master password. This applies to both the CLI and the shared Rust core used by the iOS/iPadOS app, so anyone with filesystem access to a locked vault — including another local user or a stolen locked laptop — sees only ciphertext. Existing plaintext CLI vaults are upgraded with `ldgr migrate`
- The unlocked session key is no longer written to a plaintext `session.json`; it is stored in the operating system keystore (macOS Keychain, Linux kernel keyring, or Windows Credential Manager) while `session.json` holds only non-secret metadata. `ldgr lock` now removes the key from the keystore and clears the session, so locking renders the working store unreadable rather than merely dropping a marker
- First-admin bootstrap is now atomic on servers with no `LDGR_ADMIN_EMAIL` configured: the first registered user is elected admin inside a single guarded database transaction, so under concurrent sign-ups at most one admin can ever be created. The previous fallback counted existing users and inserted the new user in separate steps, so two simultaneous registrations could both read zero users and both become admin (and on an unconfigured internet-exposed instance, whoever registered first silently became admin). The env-seeded `LDGR_ADMIN_EMAIL` path is unchanged

## [1.2.0] - 2026-05-30

Expand Down
8 changes: 4 additions & 4 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# ldgr-server — automatic-HTTPS reverse proxy (optional `tls` compose profile).
# ldgr — automatic-HTTPS reverse proxy (optional `tls` compose profile).
#
# Enable with: docker compose --profile tls up -d
# Requires LDGR_DOMAIN (and optionally LDGR_ACME_EMAIL) in .env, plus DNS for
# that domain pointed at this host. Caddy obtains and renews a certificate for
# you and proxies HTTPS traffic to the ldgr-server container on port 8080.

# you and fronts the `web` service, which serves the panel and (via its own
# reverse proxy) the JSON API — so a single HTTPS origin serves everything.
{
# Uncomment to receive ACME/expiry notices (or set LDGR_ACME_EMAIL in .env).
# email {$LDGR_ACME_EMAIL}
}

{$LDGR_DOMAIN} {
encode zstd gzip
reverse_proxy server:8080
reverse_proxy web:8080
}
28 changes: 28 additions & 0 deletions apps/web/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# ldgr web — Caddy config baked into the `ldgr-web` image (apps/web/Dockerfile).
#
# Caddy serves the Apache-2.0 web app + admin panel as a static Next.js export
# and reverse-proxies the JSON API to the ldgr-server container, so the panel and
# the API share a single origin and the panel is reachable out of the box
# (`docker compose up`). The AGPL ldgr-server never serves these web assets
# (ADR-006 licensing split; ADR-008 §7).

:8080 {
encode zstd gzip

# The server's JSON API and health probe are proxied to the server
# container. `LDGR_API_UPSTREAM` lets non-compose deploys point elsewhere.
@api path /api/* /health
handle @api {
reverse_proxy {$LDGR_API_UPSTREAM:server:8080}
}

# Everything else is the static export (index, /admin, /vault, assets).
# The export emits `route.html` files (e.g. admin.html) alongside same-named
# directories for nested routes, so try the `.html` form before the bare path
# to avoid matching an index-less directory.
handle {
root * /srv/www
try_files {path}.html {path} {path}/index.html /index.html
file_server
}
}
58 changes: 58 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1
#
# ldgr-web — static admin panel + web client, served by Caddy.
#
# The Apache-2.0 web app is a Next.js static export bundled with the in-browser
# WASM client. This image builds the export and serves it from Caddy, reverse-
# proxying the JSON API to the ldgr-server container so the panel is reachable
# out of the box (`docker compose up`). The AGPL ldgr-server never serves these
# assets (ADR-006 licensing split; ADR-008 §7).
#
# Build context is the repository root (the WASM crate lives outside apps/web):
# docker build -f apps/web/Dockerfile -t ldgr-web .

# ── WASM builder ────────────────────────────────────────────────────────────────
# Pinned to the same Rust as crates/ldgr-server/Dockerfile (workspace MSRV 1.95).
FROM rust:1.95-bookworm AS wasm
RUN rustup target add wasm32-unknown-unknown \
&& curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
WORKDIR /src
COPY . .
# Mirrors apps/web `npm run build:wasm` (core + sync features only).
RUN wasm-pack build crates/ldgr-wasm --target web --release \
--out-dir /src/apps/web/pkg -- --no-default-features --features core,sync

# ── Web builder ─────────────────────────────────────────────────────────────────
FROM node:22-bookworm AS web
WORKDIR /src/apps/web
# Install deps first for layer caching.
COPY apps/web/package.json apps/web/package-lock.json ./
RUN npm ci
# App sources + the WASM package produced above.
COPY apps/web/ ./
COPY --from=wasm /src/apps/web/pkg ./pkg
# The sync engine loads sql.js at runtime; ship its wasm alongside the export.
RUN mkdir -p public/sql.js \
&& cp node_modules/sql.js/dist/sql-wasm.wasm public/sql.js/ \
&& npm run build

# ── Runtime (Caddy static server) ───────────────────────────────────────────────
FROM caddy:2
COPY apps/web/Caddyfile /etc/caddy/Caddyfile
COPY --from=web /src/apps/web/out /srv/www

EXPOSE 8080

# Server API endpoint Caddy proxies to (overridable for non-compose setups).
ENV LDGR_API_UPSTREAM=server:8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null 2>&1 || exit 1

# OCI image metadata. The web app is Apache-2.0 (see LICENSE at the repo root).
LABEL org.opencontainers.image.title="ldgr-web" \
org.opencontainers.image.description="ldgr web app + admin panel (static export served by Caddy)" \
org.opencontainers.image.source="https://github.com/kafkade/ldgr" \
org.opencontainers.image.url="https://github.com/kafkade/ldgr" \
org.opencontainers.image.documentation="https://github.com/kafkade/ldgr/blob/main/docs/self-hosting.md" \
org.opencontainers.image.licenses="Apache-2.0"
Loading
Loading