diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c827c049..00000000 --- a/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -.git -**/node_modules -.turbo -**/.turbo -dist -apps/desktop/out -apps/web/dist -apps/share-viewer/dist -apps/server/web/dist -apps/server/bin diff --git a/.gitattributes b/.gitattributes index e90eaf1c..3b69c5c7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,3 @@ # TUI consumers, so they must check out with LF on every platform, including # Windows runners whose Git defaults to CRLF conversion. packages/bridge-contract/fixtures/** text eol=lf -apps/server/internal/vault/testdata/** text eol=lf -apps/server/internal/httpserver/testdata/** text eol=lf diff --git a/.github/workflows/boundary-artifact-release.yml b/.github/workflows/boundary-artifact-release.yml index e2bb891d..284beca2 100644 --- a/.github/workflows/boundary-artifact-release.yml +++ b/.github/workflows/boundary-artifact-release.yml @@ -28,9 +28,6 @@ jobs: with: node-version: 22 cache: npm - - uses: actions/setup-go@v6 - with: - go-version: stable - run: npm ci - run: npm run typecheck - run: npm run test:run diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f43b4e..d3fe75ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,21 +14,6 @@ concurrency: cancel-in-progress: true jobs: - server: - name: Go server without frontend dependencies - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/server - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: stable - cache-dependency-path: apps/server/go.sum - - run: go vet ./... - - run: go test ./... - production-dependency-audit: name: Production dependency audit runs-on: ubuntu-latest @@ -67,14 +52,6 @@ jobs: node-version: 22 cache: npm - - name: Set up Go - uses: actions/setup-go@v6 - with: - # Keep macOS on the current linker; older Go toolchains produced - # binaries without the LC_UUID load command required by dyld. - go-version: stable - cache-dependency-path: apps/server/go.sum - - name: Install dependencies run: npm ci @@ -111,13 +88,8 @@ jobs: path: ${{ runner.temp }}/app-core-browser-evidence if-no-files-found: ignore - - name: Verify contract fixture copies - run: npm run check:contract-fixtures - - name: Verify browser asset build lock run: npm run test:web-dist-lock - name: Typecheck and build app - env: - GOCACHE: ${{ runner.temp }}/go-build-cache run: npm run build:prod diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 4da645e1..00000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Publish Docker image - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "Extra tag to publish (in addition to latest), e.g. 2.0.1" - required: false - type: string - -permissions: - contents: read - -concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: false - -env: - IMAGE: adibhanna/zennotes - -jobs: - publish: - name: Build and push multi-arch image - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Derive image tags and labels - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ./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 diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index d98fbfce..2067d579 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -1,8 +1,9 @@ name: Nix build -# Builds AND validates the flake packages on a Nix runner whenever the Nix -# packaging changes, so the prebuilt-desktop wrapper and the server package are -# verified in CI (the maintainers don't have a Nix machine to test on locally). +# Builds AND validates the flake package on a Nix runner whenever the Nix +# packaging changes, so the prebuilt-desktop wrapper is verified in CI (the +# maintainers don't have a Nix machine to test on locally). The server's Nix +# build lives in ZenNotes/znserver. on: push: @@ -32,7 +33,6 @@ jobs: - name: Build packages run: | nix build --fallback --print-build-logs .#zennotes-desktop -o result-desktop - nix build --fallback --print-build-logs .#zennotes-server -o result-server - name: Validate desktop package run: | diff --git a/.github/workflows/nix-update.yml b/.github/workflows/nix-update.yml index b79a5313..4745f6c3 100644 --- a/.github/workflows/nix-update.yml +++ b/.github/workflows/nix-update.yml @@ -41,7 +41,6 @@ jobs: run: | set -euo pipefail DATA=packaging/nix/release-data.json - FAKE="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # 1) Source hash — the fetchFromGitHub tree for tag v$V. SRC_HASH=$(nix run --fallback nixpkgs#nix-prefetch-github -- ZenNotes zennotes --rev "v$V" | jq -r '.hash // .sha256') @@ -59,33 +58,15 @@ jobs: "https://github.com/ZenNotes/zennotes/releases/download/v$V/ZenNotes-$V-linux-x64.tar.gz" | jq -r '.hash') echo "desktopHash = $DESKTOP_HASH" - # Write version + source + npm + desktop now; leave vendorHash fake so the - # Go build surfaces the real one. - jq --arg v "$V" --arg h "$SRC_HASH" --arg n "$NPM_HASH" --arg d "$DESKTOP_HASH" --arg f "$FAKE" \ - '.version=$v | .hash=$h | .npmDepsHash=$n | .desktopHash=$d | .vendorHash=$f' "$DATA" > "$DATA.tmp" - mv "$DATA.tmp" "$DATA" - - # 3) vendorHash — build the server; the Go vendor fixed-output derivation - # reports the real hash as a mismatch against the fake one. - set +e - nix build --fallback .#zennotes-server --no-link 2> build.log - set -e - VENDOR_HASH=$(grep -oE 'got:[[:space:]]+sha256-[A-Za-z0-9+/=]+' build.log \ - | grep -oE 'sha256-[A-Za-z0-9+/=]+' | head -1 || true) - if [ -z "$VENDOR_HASH" ]; then - echo "::error::Could not extract vendorHash from the build output." - cat build.log - exit 1 - fi - echo "vendorHash = $VENDOR_HASH" - jq --arg vh "$VENDOR_HASH" '.vendorHash=$vh' "$DATA" > "$DATA.tmp" + jq --arg v "$V" --arg h "$SRC_HASH" --arg n "$NPM_HASH" --arg d "$DESKTOP_HASH" \ + '.version=$v | .hash=$h | .npmDepsHash=$n | .desktopHash=$d' "$DATA" > "$DATA.tmp" mv "$DATA.tmp" "$DATA" echo "=== updated release-data.json ===" cat "$DATA" - name: Verify the packages build with the new hashes - run: nix build --fallback .#zennotes-desktop .#zennotes-server --no-link --print-build-logs + run: nix build --fallback .#zennotes-desktop --no-link --print-build-logs - name: Open a PR with the update uses: peter-evans/create-pull-request@v6 @@ -100,6 +81,5 @@ jobs: - `version` - `hash` (source) — `nix-prefetch-github` - `npmDepsHash` — `prefetch-npm-deps` - - `vendorHash` — Go fixed-output build - Verified with `nix build .#zennotes-desktop .#zennotes-server`. + Verified with `nix build .#zennotes-desktop`. diff --git a/.github/workflows/web-artifact.yml b/.github/workflows/web-artifact.yml index 5a596fa7..410a76d1 100644 --- a/.github/workflows/web-artifact.yml +++ b/.github/workflows/web-artifact.yml @@ -5,7 +5,6 @@ on: pull_request: paths: - 'apps/web/**' - - 'apps/server/**' - 'packages/**' - 'tooling/scripts/**' - 'package*.json' @@ -27,41 +26,9 @@ jobs: cache: npm - run: npm ci - run: npm run test:web-artifact - - run: npm run check:contract-fixtures - run: npm run artifact:web - uses: actions/upload-artifact@v4 with: name: self-hosted-web-candidate path: dist/web-artifacts/* if-no-files-found: error - - server: - needs: browser - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - with: - sparse-checkout: apps/server - - uses: actions/setup-go@v6 - with: - go-version: stable - cache-dependency-path: apps/server/go.sum - - uses: actions/download-artifact@v4 - with: - name: self-hosted-web-candidate - path: ${{ runner.temp }}/web-artifact - - name: Test and build using the pinned archive with Go alone - shell: bash - working-directory: apps/server - run: | - go vet ./... - go test ./... - manifests=("$RUNNER_TEMP"/web-artifact/*.tgz.json) - test "${#manifests[@]}" -eq 1 - go run ./cmd/prepare-web -manifest "${manifests[0]}" -output web/dist - go test -tags=embed_web ./web - go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server diff --git a/.gitignore b/.gitignore index aea56221..96e2cf64 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ apps/web/dist # The bare `dist` above does not cover these suffixed names. dist.stage-* dist.retired-* -apps/server/web/.web-dist.lock -apps/server/bin +apps/web/.web-dist.lock +dist/server-binaries .DS_Store *.log .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e7a01b4..271f942d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,11 @@ npm run dev Useful scripts: -- `npm run dev` — run the desktop app with hot reload (`dev:web`, `dev:server`, and `dev:web-stack` cover the web client and Go server) +- `npm run dev` — run the desktop app with hot reload (`dev:web`, `dev:server`, and `dev:web-stack` cover the web client and the pinned Go server release) - `npm run typecheck` — TypeScript across every workspace - `npm run test:run` — the full test suite, non-interactive (`npm test` is the watch variant) -- `npm run build` — production build of the web client, desktop app, and Go server -- `cd apps/server && go test ./...` — the Go server's own tests +- `npm run build` — production build of the web client and desktop app +- The Go server and its tests live in [ZenNotes/znserver](https://github.com/ZenNotes/znserver) There is no lint step; match the style of the surrounding code (Prettier is available if a file you touched is already formatted with it). diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f7d59d7a..00000000 --- a/Dockerfile +++ /dev/null @@ -1,78 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# ZenNotes self-hosted build from the monorepo. -# -# Stages: -# 1. web-build -> npm workspace install + Vite build for apps/web -# 2. server-build -> Go build for apps/server with the web bundle embedded -# 3. runtime -> minimal image with only the server binary - -# Base images pinned by digest. Refresh with Renovate/Dependabot or by -# running `docker inspect --format='{{index .RepoDigests 0}}' ` -# after a deliberate `docker pull` of the desired floating tag. -# web-build emits a platform-agnostic static bundle, so always run it on the -# native build platform (never under emulation) regardless of the target arch. -FROM --platform=$BUILDPLATFORM node:22-alpine@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f AS web-build -WORKDIR /app - -COPY package.json package-lock.json turbo.json tsconfig.base.json tsconfig.json tailwind.config.js postcss.config.js ./ -COPY apps/web/package.json apps/web/package.json -COPY packages/app-core/package.json packages/app-core/package.json -COPY packages/bridge-contract/package.json packages/bridge-contract/package.json -COPY packages/shared-domain/package.json packages/shared-domain/package.json -COPY packages/shared-ui/package.json packages/shared-ui/package.json -COPY apps/desktop/package.json apps/desktop/package.json -COPY apps/server/package.json apps/server/package.json -COPY apps/share-viewer/package.json apps/share-viewer/package.json - -RUN npm ci --no-audit --no-fund --loglevel=error - -COPY apps apps -COPY packages packages -# The renderer configs import shared Vite plugins from tooling/vite (the -# Harper wasm asset resolver); without this copy the web build cannot resolve them. -COPY tooling/vite tooling/vite - -RUN npm run build --workspace @zennotes/web - -# Run the Go toolchain on the native build platform and cross-compile to the -# target arch (CGO is off, so this is a fast pure-Go cross-build — no QEMU). -FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS server-build -WORKDIR /app - -COPY apps/server/go.mod apps/server/go.sum ./apps/server/ -WORKDIR /app/apps/server -RUN go mod download - -WORKDIR /app -COPY apps/server apps/server -COPY --from=web-build /app/apps/web/dist/ /app/apps/server/web/dist/ - -# TARGETARCH is provided by buildx (e.g. amd64, arm64) for the image being built. -ARG TARGETARCH -ENV CGO_ENABLED=0 \ - GOOS=linux \ - GOARCH=$TARGETARCH \ - GOFLAGS=-trimpath - -WORKDIR /app/apps/server -RUN go build -tags=embed_web -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server - -FROM scratch -LABEL org.opencontainers.image.title="ZenNotes" \ - org.opencontainers.image.description="Self-hosted ZenNotes web/server bundle from the monorepo." \ - org.opencontainers.image.source="https://github.com/ZenNotes/zennotes" - -COPY --from=server-build /out/zennotes-server /zennotes-server - -ENV ZENNOTES_BIND=0.0.0.0:7878 \ - ZENNOTES_CONFIG_PATH=/data/server.json \ - ZENNOTES_DEFAULT_VAULT_PATH=/workspace \ - ZENNOTES_BROWSE_ROOTS=/workspace - -USER 65532:65532 - -EXPOSE 7878 -VOLUME ["/workspace", "/data"] - -ENTRYPOINT ["/zennotes-server"] diff --git a/Makefile b/Makefile index 6aee6b6e..c22352f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -IMAGE ?= zennotes-selfhosted:local +IMAGE ?= adibhanna/zennotes:latest PORT ?= 7878 CONTENT_ROOT ?= ./vault DATA ?= ./data @@ -8,7 +8,7 @@ COMPOSE := $(shell docker compose version >/dev/null 2>&1 && echo "docker compos OPEN_BROWSER := $(shell command -v open 2>/dev/null || command -v xdg-open 2>/dev/null) .PHONY: help install dev desktop web-dev server-dev web-stack \ - build desktop-build web-build server-build \ + build desktop-build web-build \ up down restart logs status open rebuild nuke clean help: @@ -19,30 +19,30 @@ help: @echo " Local development" @echo " make desktop — run the Electron desktop app in dev mode" @echo " make web-dev — run the Vite web client in dev mode" - @echo " make server-dev — run the Go server in dev mode" + @echo " make server-dev — run the pinned ZenNotes/znserver release (or a checkout via ZENNOTES_SERVER_DIR)" @echo " make web-stack — run server + web dev together" @echo "" @echo " Local builds" @echo " make build — build the full monorepo" @echo " make desktop-build — build the Electron desktop app" @echo " make web-build — build apps/web" - @echo " make server-build — build apps/server with the latest embedded web bundle" @echo "" @echo " Docker" - @echo " make up — build and start the self-hosted server" + @echo " make up — start the self-hosted server from the published image" @echo " make down — stop the container" @echo " make restart — restart the container" @echo " make logs — follow logs" @echo " make status — show compose status" @echo " make open — open the app in your browser" - @echo " make rebuild — force a full rebuild" - @echo " make nuke — tear down and remove local image/build output" - @echo " make clean — remove local web/server build output" + @echo " make rebuild — pull the newest image and restart" + @echo " make nuke — tear down and remove the image, data, and build output" + @echo " make clean — remove local web build output and downloaded server binaries" @echo "" @echo " Useful Docker vars" @echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder used as the live vault root" @echo " PORT=7878 — host port" @echo " ALLOW_INSECURE_NOAUTH=1 — opt out of generated auth token (not recommended)" + @echo " IMAGE=adibhanna/zennotes:2.50.5 — pin a published server image (default: latest)" @echo "" install: @@ -96,7 +96,7 @@ up: ZENNOTES_HOST_DATA="$$ABS_DATA" \ ZENNOTES_CONTAINER_UID="$$(id -u)" \ ZENNOTES_CONTAINER_GID="$$(id -g)" \ - $(COMPOSE) up --build -d + $(COMPOSE) up -d @printf "\nZenNotes is running at $(APP_URL)\n\n" ifneq ($(ALLOW_INSECURE_NOAUTH),1) @printf "Auth token: $(DATA)/auth-token\n\n" @@ -149,15 +149,12 @@ rebuild: ZENNOTES_HOST_DATA="$$ABS_DATA" \ ZENNOTES_CONTAINER_UID="$$(id -u)" \ ZENNOTES_CONTAINER_GID="$$(id -g)" \ - $(COMPOSE) build --no-cache + $(COMPOSE) pull @$(MAKE) --no-print-directory up nuke: - @$(COMPOSE) down --rmi local --volumes || true - @rm -rf apps/web/dist apps/server/bin apps/server/web/dist $(DATA) - -server-build: web-build - npm run build --workspace @zennotes/server + @$(COMPOSE) down --rmi all --volumes || true + @rm -rf apps/web/dist dist/server-binaries $(DATA) clean: - rm -rf apps/web/dist apps/server/bin apps/server/web/dist + rm -rf apps/web/dist dist/server-binaries diff --git a/README.md b/README.md index 42b68c59..4f961c70 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,6 @@ ZenNotes now uses a single monorepo. apps/ desktop/ Electron shell, preload, updater, packaging web/ Vite/PWA shell and HTTP bridge - server/ Go server for self-hosted and hosted deployments packages/ app-core/ Shared React application and renderer logic bridge-contract/ Typed runtime contract between UI and host @@ -345,6 +344,13 @@ make web-dev ### Go server +The server lives in its own repository, +[ZenNotes/znserver](https://github.com/ZenNotes/znserver). For browser work +you do not need it checked out: `npm run dev:server` downloads the release +pinned in `tooling/server-release.json`, verifies its checksum, and runs it. +To work on the server itself, point `ZENNOTES_SERVER_DIR` at a znserver +checkout and the same command runs it with `go run`. + ```bash npm run dev:server ``` @@ -371,7 +377,7 @@ Important dev note: - the browser app and the Go server are separate processes in dev mode - frontend-only changes usually need only the web dev server -- backend changes need the Go server restarted +- backend changes happen in ZenNotes/znserver and need that server restarted - if the web client is newer than the running server, ZenNotes now shows a clearer error instead of raw 404 noise for newer API flows like the vault picker ## Root scripts @@ -405,26 +411,29 @@ The root `Makefile` provides a simpler interface: | `make install` | Install workspace dependencies | | `make desktop` | Run the Electron app in dev mode | | `make web-dev` | Run the web client | -| `make server-dev` | Run the Go server | +| `make server-dev` | Run the pinned Go server release (or a checkout) | | `make web-stack` | Run web + server together | | `make build` | Build the full monorepo | | `make desktop-build` | Build the Electron app | | `make web-build` | Build `apps/web` | -| `make server-build` | Build `apps/server` with the latest embedded web bundle | -| `make up` | Build and start the self-hosted Docker stack | +| `make up` | Start the self-hosted Docker stack from the published image | | `make down` | Stop the Docker stack | | `make restart` | Restart the Docker stack | | `make logs` | Follow Docker logs | | `make status` | Show Docker status | | `make open` | Open the self-hosted app in a browser | -| `make rebuild` | Force a full Docker rebuild | -| `make nuke` | Remove local Docker image/build output | -| `make clean` | Remove local web/server build output | +| `make rebuild` | Pull the newest image and restart | +| `make nuke` | Remove the Docker image, data, and build output | +| `make clean` | Remove web build output and downloaded server binaries | Run `make help` to print the same summary. ## Self-hosting with Docker +The image, `adibhanna/zennotes`, is built and published from the server's own +repository, [ZenNotes/znserver](https://github.com/ZenNotes/znserver). This +repository only ships the Compose file and Makefile that run it. + ### Start the self-hosted app ```bash @@ -481,7 +490,7 @@ Useful variables: - `CONTENT_ROOT`: host folder used as the live vault root - `DATA`: host directory used for persisted server config - `PORT`: published host port -- `IMAGE`: Docker image tag +- `IMAGE`: Docker image to run (default `adibhanna/zennotes:latest`; pin with `IMAGE=adibhanna/zennotes:2.50.5`) - `ALLOW_INSECURE_NOAUTH`: disable the default auth requirement ### Docker browse model diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index ce3fd92f..4347f073 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -1893,7 +1893,7 @@ async function folderOf(root: string, absPath: string): Promise, key: string): string | nul * * SYNCED COPIES: the same validator exists in @shared/workflows/prepare-run * (the web client's), and the Go server mirrors the field list in - * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). A new + * requiredWorkflowOpFields (internal/vault/workflows.go in ZenNotes/znserver). A new * op kind or field lands in all three. */ export function parseWorkflowOp(value: unknown): WorkflowOp | null { diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 266137ef..e2ed365a 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -615,7 +615,7 @@ async function folderOf(root: string, abs: string): Promise { * nested under a list item is still a code block (#293). Mirrors * `stripCodeContent` in apps/desktop/src/main/vault.ts, * packages/app-core/src/lib/{tags,wikilinks}.ts, and - * apps/server/internal/vault/parse.go — keep all five in sync. + * internal/vault/parse.go in ZenNotes/znserver — keep all five in sync. */ function stripCodeContent(body: string): string { if (!body.includes('`') && !body.includes('~')) return body diff --git a/apps/server/README.md b/apps/server/README.md deleted file mode 100644 index 97627704..00000000 --- a/apps/server/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# ZenNotes self-hosted server - -This Go service owns filesystem access, the self-hosted HTTP API, authentication, -configuration, and vault watching. Laravel owns ZenNotes Cloud accounts, billing, -sync, and publishing in the separate private website repository. - -The planned server repository is [ZenNotes/znserver](https://github.com/ZenNotes/znserver). -Source and distribution channels still live in the main repository during the migration. -The Go module path stays unchanged until the extraction checkpoint is ready. - -## Develop and test with Go alone - -Requires Go 1.25 or later. From this directory: - -```sh -go vet ./... -go test ./... -go run ./cmd/zennotes-server -``` - -These commands do not require Node, npm, a sibling checkout, or `web/dist`. -The default binary serves the API. It logs that the browser bundle is absent. -For browser development, run the Vite client separately with `npm run dev:web` -from the main repository and use a dedicated test vault and auth token. - -## Build a binary with the browser app - -Production builds use Go's `embed_web` build tag. They require the web distribution -under `web/dist`, including `index.html`. Missing assets fail compilation or the -bundle check. Go's [build constraints](https://pkg.go.dev/cmd/go#hdr-Build_constraints) -keep the [embedded assets](https://pkg.go.dev/embed) out of ordinary Go tests. - -From the main repository: - -```sh -npm run build --workspace @zennotes/web -npm run build --workspace @zennotes/server -``` - -The server build stages the web distribution and holds the existing asset lock -through the bundle check and Go compilation. Its output is `bin/zennotes-server` -(`bin/zennotes-server.exe` on Windows). - -With a prebuilt web distribution already staged, the standalone Go commands are: - -```sh -go test -tags=embed_web ./web -go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server -``` - -Docker and the server Nix package also select `embed_web`. Existing binary names, -configuration variables, API routes, authentication, and vault formats are unchanged. -The source-based distribution channels stay in place during the migration. - -## Build from a pinned browser archive - -The main repository can produce a candidate with `npm run artifact:web`. Its output -under `dist/web-artifacts` contains an immutable `.tgz` and a JSON manifest naming -the protocol, source commit, toolchain, archive checksum, and every asset checksum. -This is separate from desktop releases and from the public share viewer. - -In a clean server build directory, place the reviewed manifest next to its archive: - -```sh -go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist -go test -tags=embed_web ./web -go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server -``` - -Only the artifact producer needs Node. The Go consumer accepts an adjacent archive, -an explicit `-archive` path, or the manifest's HTTPS URL. The manifest is the trust -anchor and must be reviewed and pinned with the server source. Checksums detect -changed downloads; they do not authenticate an independently replaced manifest. - -Uncommitted source candidates require `-allow-dirty` for local testing and have no -release URL. No artifacts from this migration have been published. The candidate -CI workflow only transfers build artifacts between jobs; it does not make a release. - -Use a privately owned build directory. Concurrent cooperating installs are rejected -using an exclusive `.install-lock` beside the output. Other processes must not -modify the output or its parents during the build. If an installer crashes, confirm -it has exited before removing its stale lock. Existing matching assets are verified -and reused; a different build requires a new clean output directory. - -The importer rejects unsupported protocols, ambiguous manifests, traversal, links, -unexpected or duplicate files, size violations, and checksum mismatches before -exposing assets. The protocol marker `self-hosted-http-v1` identifies the tested -HTTP behavior; it is independent of the legacy `/api/version` response. diff --git a/apps/server/cmd/prepare-web/main.go b/apps/server/cmd/prepare-web/main.go deleted file mode 100644 index 92c38727..00000000 --- a/apps/server/cmd/prepare-web/main.go +++ /dev/null @@ -1,28 +0,0 @@ -// prepare-web installs the browser archive pinned by a reviewed local manifest. -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "github.com/ZenNotes/zennotes/apps/server/internal/webartifact" -) - -func main() { - manifest := flag.String("manifest", "", "path to the pinned browser artifact manifest") - archive := flag.String("archive", "", "optional local archive (otherwise use the adjacent file or manifest HTTPS URL)") - output := flag.String("output", "", "new output directory, typically web/dist in a clean build") - allowDirty := flag.Bool("allow-dirty", false, "allow uncommitted source candidates for local testing") - flag.Parse() - if *manifest == "" || *output == "" || flag.NArg() != 0 { - flag.Usage() - os.Exit(2) - } - if err := webartifact.Install(context.Background(), *manifest, *archive, *output, *allowDirty); err != nil { - fmt.Fprintln(os.Stderr, "prepare-web:", err) - os.Exit(1) - } - fmt.Println("Verified browser artifact installed:", *output) -} diff --git a/apps/server/cmd/zennotes-server/main.go b/apps/server/cmd/zennotes-server/main.go deleted file mode 100644 index 1bfa5a6a..00000000 --- a/apps/server/cmd/zennotes-server/main.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "net/http" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/httpserver" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/ZenNotes/zennotes/apps/server/web" -) - -func main() { - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - - cfg := config.Load() - if strings.TrimSpace(cfg.AuthToken) == "" && !cfg.AllowInsecureNoAuth && !bindIsLoopback(cfg.Bind) { - log.Fatal("refusing to start without an auth token on a non-loopback bind; set ZENNOTES_AUTH_TOKEN, point ZENNOTES_AUTH_TOKEN_FILE at a readable token file, or set ZENNOTES_ALLOW_INSECURE_NOAUTH=1 to override") - } - logStartupBanner(cfg) - - v, err := vault.New(cfg.VaultPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - if errors.Is(err, os.ErrPermission) { - logVaultPermissionHelp(cfg.VaultPath, err) - os.Exit(1) - } - log.Fatalf("vault init: %v", err) - } - - if config.LegacyVaultConfigExists(v.Root()) { - log.Printf("warning: ignoring legacy vault config at %s; server secrets now stay in host config only", config.LegacyVaultConfigPath(v.Root())) - } - - // Never fatal: where inotify is restricted (e.g. unprivileged LXC) the - // watcher falls back to a no-op so the server still serves the vault. (#179) - w := watcher.StartOrDisabled(v.Root(), cfg.DisableWatcher) - defer w.Close() - - // Seed from the vault's normalized settings; the watcher re-normalizes for - // itself whenever vault.json changes, so both agree on the folder layout. - if settings, err := v.GetSettings(); err == nil { - w.SetFolderPaths(settings.SystemFolderPaths) - } - - dist, err := web.Dist() - if err != nil { - log.Printf("warning: embedded web bundle not available: %v", err) - dist = nil - } - - srv := httpserver.New(v, w, dist, cfg) - httpSrv := &http.Server{ - Addr: cfg.Bind, - Handler: srv.Router(), - ReadTimeout: 0, // Websocket-friendly. - WriteTimeout: 0, - } - - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - - go func() { - log.Printf("listening on http://%s", cfg.Bind) - if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("http serve: %v", err) - } - }() - - if !bindIsLoopback(cfg.Bind) && !cfg.BehindTLS { - go warnInsecureExposureLoop(ctx) - } - - <-ctx.Done() - log.Printf("shutting down…") - - shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), 5*time.Second) - defer stopShutdown() - _ = httpSrv.Shutdown(shutdownCtx) -} - -// logVaultPermissionHelp turns the cryptic "mkdir … permission denied" into -// actionable guidance: the container runs as a non-root UID, so a bind-mounted -// host vault has to be writable by that UID (#227). -func logVaultPermissionHelp(vaultPath string, err error) { - log.Printf("vault init: %v", err) - log.Printf("→ ZenNotes runs as UID %d:%d and cannot write to the vault directory %q.", os.Getuid(), os.Getgid(), vaultPath) - log.Printf("→ The mounted host directory must be writable by that UID. Either:") - log.Printf("→ • run the container as a user that owns it: docker run --user \"$(id -u):$(id -g)\" …") - log.Printf("→ • or make the host directory owned by / writable for UID %d (e.g. chown).", os.Getuid()) - log.Printf("→ See https://github.com/ZenNotes/zennotes/blob/main/docs/how-to/self-host-with-docker.md#permissions") -} - -func logStartupBanner(cfg config.Config) { - log.Printf("vault: %s", cfg.VaultPath) - log.Printf("bind: %s", cfg.Bind) - authMode := "ZENNOTES_AUTH_TOKEN required" - if strings.TrimSpace(cfg.AuthToken) == "" { - authMode = "OPEN (no auth token set — anyone reachable can read/write)" - } - log.Printf("auth: %s", authMode) - tlsMode := "behind TLS proxy (cookies marked Secure, HSTS sent)" - if !cfg.BehindTLS { - tlsMode = "plain HTTP (set ZENNOTES_BEHIND_TLS=1 once a TLS proxy is in front)" - } - log.Printf("tls: %s", tlsMode) - log.Printf("cors: %s", corsMode(cfg)) - if !bindIsLoopback(cfg.Bind) && !cfg.BehindTLS { - log.Printf("WARNING: bound to a non-loopback address without ZENNOTES_BEHIND_TLS=1.") - log.Printf("WARNING: put a TLS-terminating reverse proxy in front before exposing publicly.") - } -} - -// corsMode describes the effective cross-origin policy at a glance, so an -// operator can see what is allowed at startup instead of inferring it from -// rejection lines after a client fails. (#482) -func corsMode(cfg config.Config) string { - for _, origin := range cfg.AllowedOrigins { - if strings.TrimSpace(origin) == httpserver.AllowAllOrigins { - return "any origin (ZENNOTES_ALLOWED_ORIGINS=*; credentials withheld cross-origin)" - } - } - if len(cfg.AllowedOrigins) == 0 { - return "same-origin only (set ZENNOTES_ALLOWED_ORIGINS for browser or WebView clients)" - } - return fmt.Sprintf("same-origin plus %s", strings.Join(cfg.AllowedOrigins, ", ")) -} - -func warnInsecureExposureLoop(ctx context.Context) { - t := time.NewTicker(15 * time.Minute) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - log.Printf("WARNING: still serving plain HTTP on a non-loopback bind; configure a TLS proxy and set ZENNOTES_BEHIND_TLS=1") - } - } -} - -func bindIsLoopback(bind string) bool { - host, _, err := net.SplitHostPort(bind) - if err != nil { - host = bind - } - host = strings.Trim(host, "[]") - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/apps/server/cmd/zennotes-server/main_test.go b/apps/server/cmd/zennotes-server/main_test.go deleted file mode 100644 index 00c580dd..00000000 --- a/apps/server/cmd/zennotes-server/main_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import "testing" - -func TestBindIsLoopbackTreatsEmptyHostAsNonLoopback(t *testing.T) { - cases := []struct { - bind string - want bool - }{ - {":7878", false}, - {"0.0.0.0:7878", false}, - {"[::]:7878", false}, - {"127.0.0.1:7878", true}, - {"[::1]:7878", true}, - {"localhost:7878", true}, - } - - for _, tc := range cases { - if got := bindIsLoopback(tc.bind); got != tc.want { - t.Fatalf("bindIsLoopback(%q) = %v, want %v", tc.bind, got, tc.want) - } - } -} diff --git a/apps/server/go.mod b/apps/server/go.mod deleted file mode 100644 index 84d0fbc4..00000000 --- a/apps/server/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/ZenNotes/zennotes/apps/server - -go 1.25 - -require ( - github.com/coder/websocket v1.8.12 - github.com/fsnotify/fsnotify v1.8.0 - github.com/go-chi/chi/v5 v5.2.5 -) - -require golang.org/x/sys v0.21.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum deleted file mode 100644 index 0d3d4169..00000000 --- a/apps/server/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go deleted file mode 100644 index b6e58b75..00000000 --- a/apps/server/internal/config/config.go +++ /dev/null @@ -1,277 +0,0 @@ -package config - -import ( - "encoding/json" - "io/fs" - "log" - "net" - "os" - "path/filepath" - "strconv" - "strings" -) - -const ( - defaultMaxAssetBytes int64 = 50 << 20 // 50 MiB - defaultMaxNoteBytes int64 = 10 << 20 // 10 MiB - defaultVaultFileMode = fs.FileMode(0o600) - defaultVaultDirMode = fs.FileMode(0o700) -) - -const ( - AuthTokenSourceNone = "" - AuthTokenSourceConfig = "config" - AuthTokenSourceEnv = "env" - AuthTokenSourceFile = "file" -) - -type Config struct { - VaultPath string `json:"vaultPath"` - DefaultVaultPath string `json:"-"` - BrowseRoots []string `json:"-"` - AllowedOrigins []string `json:"-"` - Bind string `json:"bind"` - BasePath string `json:"basePath"` - AuthToken string `json:"authToken"` - AuthTokenSource string `json:"-"` - AllowUnscopedBrowse bool `json:"-"` - AllowInsecureNoAuth bool `json:"-"` - DevMode bool `json:"-"` - // DisableWatcher turns off the inotify file watcher (ZENNOTES_DISABLE_WATCHER). - // Live updates stop; the vault is still fully served. Useful where inotify is - // restricted and can hang the process (e.g. unprivileged LXC). (#179) - DisableWatcher bool `json:"-"` - - // Limits and security knobs. - MaxAssetBytes int64 `json:"-"` - MaxNoteBytes int64 `json:"-"` - BehindTLS bool `json:"-"` - // PersistSessions saves browser sessions to /sessions.json so they - // survive a server restart. Opt-in via ZENNOTES_PERSIST_SESSIONS. - PersistSessions bool `json:"-"` - TrustedProxies []net.IPNet `json:"-"` - VaultFileMode fs.FileMode `json:"-"` - VaultDirMode fs.FileMode `json:"-"` -} - -func configFilePath() string { - if v := os.Getenv("ZENNOTES_CONFIG_PATH"); v != "" { - return v - } - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, ".zennotes", "server.json") - } - return ".zennotes-server.json" -} - -// SessionsPath is where opt-in persisted browser sessions live — next to the -// host config file (e.g. /data/sessions.json alongside /data/server.json). -func SessionsPath() string { - return filepath.Join(filepath.Dir(configFilePath()), "sessions.json") -} - -func Load() Config { - cfg := Config{ - Bind: "127.0.0.1:7878", - MaxAssetBytes: defaultMaxAssetBytes, - MaxNoteBytes: defaultMaxNoteBytes, - VaultFileMode: defaultVaultFileMode, - VaultDirMode: defaultVaultDirMode, - } - if raw, err := os.ReadFile(configFilePath()); err == nil { - var stored Config - if json.Unmarshal(raw, &stored) == nil { - if stored.VaultPath != "" { - cfg.VaultPath = stored.VaultPath - } - if stored.Bind != "" { - cfg.Bind = stored.Bind - } - if stored.BasePath != "" { - cfg.BasePath = stored.BasePath - } - if stored.AuthToken != "" { - cfg.AuthToken = stored.AuthToken - cfg.AuthTokenSource = AuthTokenSourceConfig - } - } - } - if v := os.Getenv("ZENNOTES_VAULT_PATH"); v != "" { - cfg.VaultPath = v - } - if v := os.Getenv("ZENNOTES_DEFAULT_VAULT_PATH"); v != "" { - cfg.DefaultVaultPath = v - } - cfg.BrowseRoots = parseListEnv("ZENNOTES_BROWSE_ROOTS") - cfg.AllowedOrigins = parseListEnv("ZENNOTES_ALLOWED_ORIGINS") - if v := os.Getenv("ZENNOTES_BIND"); v != "" { - cfg.Bind = v - } - if v := os.Getenv("ZENNOTES_BASE_PATH"); v != "" { - cfg.BasePath = v - } - cfg.BasePath = NormalizeBasePath(cfg.BasePath) - if v := os.Getenv("ZENNOTES_AUTH_TOKEN"); v != "" { - cfg.AuthToken = v - cfg.AuthTokenSource = AuthTokenSourceEnv - } else if path := os.Getenv("ZENNOTES_AUTH_TOKEN_FILE"); path != "" { - // The token comes from a file (the Docker/Kubernetes "*_FILE" secrets - // convention). A set-but-unreadable or empty file is a misconfiguration - // the user meant to work — surface it clearly instead of silently - // falling through to the generic "missing ZENNOTES_AUTH_TOKEN" error. - if raw, err := os.ReadFile(path); err != nil { - log.Printf("config: ZENNOTES_AUTH_TOKEN_FILE is set to %q but it could not be read: %v", path, err) - } else if token := strings.TrimSpace(string(raw)); token == "" { - log.Printf("config: ZENNOTES_AUTH_TOKEN_FILE %q is empty — no auth token loaded", path) - } else { - cfg.AuthToken = token - cfg.AuthTokenSource = AuthTokenSourceFile - } - } - cfg.AllowUnscopedBrowse = envEnabled("ZENNOTES_ALLOW_UNSCOPED_BROWSE") - cfg.AllowInsecureNoAuth = envEnabled("ZENNOTES_ALLOW_INSECURE_NOAUTH") - cfg.DevMode = envEnabled("ZENNOTES_DEV") - cfg.DisableWatcher = envEnabled("ZENNOTES_DISABLE_WATCHER") - cfg.BehindTLS = envEnabled("ZENNOTES_BEHIND_TLS") - cfg.PersistSessions = envEnabled("ZENNOTES_PERSIST_SESSIONS") - cfg.TrustedProxies = parseCIDRListEnv("ZENNOTES_TRUSTED_PROXIES") - if v := parseInt64Env("ZENNOTES_MAX_ASSET_BYTES"); v > 0 { - cfg.MaxAssetBytes = v - } - if v := parseInt64Env("ZENNOTES_MAX_NOTE_BYTES"); v > 0 { - cfg.MaxNoteBytes = v - } - if m, ok := parseFileModeEnv("ZENNOTES_VAULT_FILE_MODE"); ok { - cfg.VaultFileMode = m - } - if m, ok := parseFileModeEnv("ZENNOTES_VAULT_DIR_MODE"); ok { - cfg.VaultDirMode = m - } - if cfg.VaultPath == "" { - if cfg.DefaultVaultPath != "" { - cfg.VaultPath = cfg.DefaultVaultPath - } else { - if home, err := os.UserHomeDir(); err == nil { - cfg.VaultPath = filepath.Join(home, "ZenNotesVault") - } else { - cfg.VaultPath = "./vault" - } - } - } - return cfg -} - -func SaveHost(cfg Config) error { - target := configFilePath() - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { - return err - } - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return os.WriteFile(target, out, 0o600) -} - -func LegacyVaultConfigPath(vaultRoot string) string { - return filepath.Join(vaultRoot, ".zennotes", "server.json") -} - -func LegacyVaultConfigExists(vaultRoot string) bool { - _, err := os.Stat(LegacyVaultConfigPath(vaultRoot)) - return err == nil -} - -// NormalizeBasePath coerces a raw base-path string into the form the -// server uses everywhere: empty (meaning "serve at root") or a path that -// starts with `/` and has no trailing slash, e.g. "/zennotes". Multiple -// adjacent slashes are collapsed. -func NormalizeBasePath(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" || trimmed == "/" { - return "" - } - if !strings.HasPrefix(trimmed, "/") { - trimmed = "/" + trimmed - } - // Collapse repeated slashes ("/foo//bar" → "/foo/bar"). - for strings.Contains(trimmed, "//") { - trimmed = strings.ReplaceAll(trimmed, "//", "/") - } - trimmed = strings.TrimRight(trimmed, "/") - if trimmed == "" { - return "" - } - return trimmed -} - -func parseListEnv(name string) []string { - raw := os.Getenv(name) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - values := make([]string, 0, len(parts)) - for _, part := range parts { - if trimmed := strings.TrimSpace(part); trimmed != "" { - values = append(values, trimmed) - } - } - return values -} - -func parseCIDRListEnv(name string) []net.IPNet { - parts := parseListEnv(name) - if len(parts) == 0 { - return nil - } - out := make([]net.IPNet, 0, len(parts)) - for _, p := range parts { - if !strings.Contains(p, "/") { - if ip := net.ParseIP(p); ip != nil { - bits := 32 - if ip.To4() == nil { - bits = 128 - } - out = append(out, net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) - continue - } - } - if _, n, err := net.ParseCIDR(p); err == nil { - out = append(out, *n) - } - } - return out -} - -func parseInt64Env(name string) int64 { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return 0 - } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return 0 - } - return v -} - -func parseFileModeEnv(name string) (fs.FileMode, bool) { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return 0, false - } - if !strings.HasPrefix(raw, "0") { - raw = "0" + raw - } - v, err := strconv.ParseUint(raw, 8, 32) - if err != nil { - return 0, false - } - return fs.FileMode(v), true -} - -func envEnabled(name string) bool { - raw := strings.TrimSpace(strings.ToLower(os.Getenv(name))) - return raw == "1" || raw == "true" || raw == "yes" || raw == "on" -} diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go deleted file mode 100644 index 6067ab50..00000000 --- a/apps/server/internal/config/config_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package config - -import ( - "io/fs" - "os" - "path/filepath" - "testing" -) - -func TestLoadAuthTokenFromFile(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - const want = "from-file-token-xxxxxx" - if err := os.WriteFile(tokenFile, []byte(" "+want+"\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != want { - t.Fatalf("AuthToken = %q, want %q (whitespace must be trimmed)", cfg.AuthToken, want) - } - if cfg.AuthTokenSource != AuthTokenSourceFile { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceFile) - } -} - -func TestEnvAuthTokenWinsOverFile(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - if err := os.WriteFile(tokenFile, []byte("from-file"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "from-env") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "from-env" { - t.Fatalf("AuthToken = %q, want from-env", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceEnv { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceEnv) - } -} - -// #304: a ZENNOTES_AUTH_TOKEN_FILE pointing at a missing/unreadable path must -// not set a token (and must not panic); the read error is logged so the failure -// is visible rather than surfacing as a misleading "missing token" error. -func TestAuthTokenFileMissingIsIgnoredNotFatal(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist")) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "" { - t.Fatalf("AuthToken = %q, want empty for an unreadable file", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceNone { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceNone) - } -} - -// An empty (or whitespace-only) token file loads no token rather than an empty one. -func TestAuthTokenFileEmptyLoadsNoToken(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - if err := os.WriteFile(tokenFile, []byte(" \n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "" { - t.Fatalf("AuthToken = %q, want empty for a whitespace-only file", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceNone { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceNone) - } -} - -func TestLoadDefaultLimitsAndModes(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - cfg := Load() - if cfg.MaxAssetBytes != defaultMaxAssetBytes { - t.Errorf("MaxAssetBytes default = %d, want %d", cfg.MaxAssetBytes, defaultMaxAssetBytes) - } - if cfg.MaxNoteBytes != defaultMaxNoteBytes { - t.Errorf("MaxNoteBytes default = %d, want %d", cfg.MaxNoteBytes, defaultMaxNoteBytes) - } - if cfg.VaultFileMode != defaultVaultFileMode { - t.Errorf("VaultFileMode default = %v, want %v", cfg.VaultFileMode, defaultVaultFileMode) - } - if cfg.VaultDirMode != defaultVaultDirMode { - t.Errorf("VaultDirMode default = %v, want %v", cfg.VaultDirMode, defaultVaultDirMode) - } -} - -func TestParseCIDRListEnv(t *testing.T) { - t.Setenv("X", "127.0.0.1/32, 10.0.0.0/8 ,bad,192.168.1.5") - got := parseCIDRListEnv("X") - if len(got) != 3 { - t.Fatalf("expected 3 valid entries, got %d: %+v", len(got), got) - } - // 192.168.1.5 (bare) should be expanded to /32. - last := got[2] - ones, bits := last.Mask.Size() - if ones != 32 || bits != 32 { - t.Fatalf("bare IP should be /32, got /%d (bits=%d)", ones, bits) - } - if !last.Contains(last.IP) { - t.Fatalf("Net should contain its own IP") - } -} - -func TestNormalizeBasePath(t *testing.T) { - cases := []struct { - raw string - want string - }{ - {"", ""}, - {" ", ""}, - {"/", ""}, - {"//", ""}, - {"zennotes", "/zennotes"}, - {"/zennotes", "/zennotes"}, - {"/zennotes/", "/zennotes"}, - {"/zennotes//", "/zennotes"}, - {"/foo/bar", "/foo/bar"}, - {"/foo//bar/", "/foo/bar"}, - {" /apps/notes ", "/apps/notes"}, - } - for _, c := range cases { - if got := NormalizeBasePath(c.raw); got != c.want { - t.Errorf("NormalizeBasePath(%q) = %q, want %q", c.raw, got, c.want) - } - } -} - -func TestLoadBasePathFromEnv(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - t.Setenv("ZENNOTES_BASE_PATH", "/zennotes/") - cfg := Load() - if cfg.BasePath != "/zennotes" { - t.Fatalf("BasePath = %q, want /zennotes (trailing slash trimmed)", cfg.BasePath) - } -} - -func TestParseFileModeEnv(t *testing.T) { - cases := []struct { - raw string - want fs.FileMode - ok bool - }{ - {"", 0, false}, - {"600", 0o600, true}, - {"0600", 0o600, true}, - {"0o600", 0, false}, // not octal-prefix syntax - {"755", 0o755, true}, - {"abc", 0, false}, - } - for _, c := range cases { - t.Setenv("X", c.raw) - got, ok := parseFileModeEnv("X") - if ok != c.ok || got != c.want { - t.Errorf("parseFileModeEnv(%q) = (%v, %v), want (%v, %v)", c.raw, got, ok, c.want, c.ok) - } - } -} - -// #sessions: ZENNOTES_PERSIST_SESSIONS is opt-in (off by default), and the -// sessions file sits next to the host config. -func TestPersistSessionsFlagAndPath(t *testing.T) { - cfgPath := filepath.Join(t.TempDir(), "server.json") - t.Setenv("ZENNOTES_AUTH_TOKEN", "x") - t.Setenv("ZENNOTES_CONFIG_PATH", cfgPath) - - t.Setenv("ZENNOTES_PERSIST_SESSIONS", "") - if Load().PersistSessions { - t.Fatal("PersistSessions should default off") - } - t.Setenv("ZENNOTES_PERSIST_SESSIONS", "1") - if !Load().PersistSessions { - t.Fatal("ZENNOTES_PERSIST_SESSIONS=1 should enable it") - } - if got, want := SessionsPath(), filepath.Join(filepath.Dir(cfgPath), "sessions.json"); got != want { - t.Fatalf("SessionsPath = %q, want %q", got, want) - } -} diff --git a/apps/server/internal/httpserver/asset_ops_test.go b/apps/server/internal/httpserver/asset_ops_test.go deleted file mode 100644 index 18631a5b..00000000 --- a/apps/server/internal/httpserver/asset_ops_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// TestAssetRenameAndMoveEndpoints exercises the full HTTP wiring for the asset -// mutation endpoints added for remote vaults (#379): rename in place, then move -// into a folder, asserting the JSON field contract the web bridge relies on. -func TestAssetRenameAndMoveEndpoints(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - postJSON := func(path string, payload map[string]string) (string, int) { - t.Helper() - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - defer resp.Body.Close() - var meta struct { - Path string `json:"path"` - } - if resp.StatusCode == http.StatusOK { - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - t.Fatalf("decode %s response: %v", path, err) - } - } - return meta.Path, resp.StatusCode - } - - gotPath, status := postJSON("/api/assets/rename", map[string]string{"path": "assets/pic.png", "name": "shot.png"}) - if status != http.StatusOK { - t.Fatalf("rename status = %d, want 200", status) - } - if gotPath != "assets/shot.png" { - t.Fatalf("rename path = %q, want assets/shot.png", gotPath) - } - - gotPath, status = postJSON("/api/assets/move", map[string]string{"path": "assets/shot.png", "targetDir": "media"}) - if status != http.StatusOK { - t.Fatalf("move status = %d, want 200", status) - } - if gotPath != "media/shot.png" { - t.Fatalf("move path = %q, want media/shot.png", gotPath) - } - if _, err := os.Stat(filepath.Join(root, "media", "shot.png")); err != nil { - t.Errorf("moved file missing on disk: %v", err) - } -} - -// TestFolderColorsPersistOverHTTP is the reporter's exact scenario (#379): a -// recolor saved from the web client must survive the /vault/settings round-trip -// instead of being silently dropped by the server. -func TestFolderColorsPersistOverHTTP(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - payload := map[string]any{ - "primaryNotesLocation": "inbox", - "folderColors": map[string]string{"inbox:Projects": "violet"}, - } - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+"/api/vault/settings", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST /api/vault/settings: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("set settings status = %d, want 200", resp.StatusCode) - } - - getResp, err := client.Get(server.URL + "/api/vault/settings") - if err != nil { - t.Fatalf("GET /api/vault/settings: %v", err) - } - defer getResp.Body.Close() - var got struct { - FolderColors map[string]string `json:"folderColors"` - } - if err := json.NewDecoder(getResp.Body).Decode(&got); err != nil { - t.Fatalf("decode settings: %v", err) - } - if got.FolderColors["inbox:Projects"] != "violet" { - t.Fatalf("folderColors dropped over HTTP round-trip: %v", got.FolderColors) - } -} - -// TestAssetDeleteRestorePurgeEndpoints exercises the deleted-assets store over -// HTTP: delete parks the file with an undo token, the list surfaces it, restore -// brings it back deduped, and purge/empty leave the store clean. This is the -// contract the desktop remote workspace and the web bridge share. -func TestAssetDeleteRestorePurgeEndpoints(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - post := func(path string, payload any) *http.Response { - t.Helper() - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - return resp - } - - resp := post("/api/assets/delete", map[string]string{"path": "assets/pic.png"}) - if resp.StatusCode != http.StatusOK { - t.Fatalf("delete status = %d, want 200", resp.StatusCode) - } - var deleted struct { - Path string `json:"path"` - Name string `json:"name"` - UndoToken string `json:"undoToken"` - DeletedAt string `json:"deletedAt"` - } - if err := json.NewDecoder(resp.Body).Decode(&deleted); err != nil { - t.Fatalf("decode delete response: %v", err) - } - resp.Body.Close() - if deleted.Path != "assets/pic.png" || deleted.UndoToken == "" || deleted.DeletedAt == "" { - t.Fatalf("delete response = %+v, want path+token+timestamp", deleted) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Fatal("file still present after HTTP delete") - } - - listResp, err := client.Get(server.URL + "/api/assets/deleted") - if err != nil { - t.Fatal(err) - } - var listed []struct { - UndoToken string `json:"undoToken"` - } - if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { - t.Fatalf("decode deleted list: %v", err) - } - listResp.Body.Close() - if len(listed) != 1 || listed[0].UndoToken != deleted.UndoToken { - t.Fatalf("deleted list = %+v, want the parked entry", listed) - } - - resp = post("/api/assets/restore", deleted) - if resp.StatusCode != http.StatusOK { - t.Fatalf("restore status = %d, want 200", resp.StatusCode) - } - var restored struct { - Path string `json:"path"` - } - if err := json.NewDecoder(resp.Body).Decode(&restored); err != nil { - t.Fatalf("decode restore response: %v", err) - } - resp.Body.Close() - if restored.Path != "assets/pic.png" { - t.Fatalf("restored path = %q, want assets/pic.png", restored.Path) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); err != nil { - t.Fatalf("restored file missing: %v", err) - } - - // Round two: delete again, then purge instead of restore. - resp = post("/api/assets/delete", map[string]string{"path": "assets/pic.png"}) - if err := json.NewDecoder(resp.Body).Decode(&deleted); err != nil { - t.Fatalf("decode second delete: %v", err) - } - resp.Body.Close() - resp = post("/api/assets/purge", map[string]string{"undoToken": deleted.UndoToken}) - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("purge status = %d, want 204", resp.StatusCode) - } - resp.Body.Close() - resp = post("/api/assets/empty-deleted", nil) - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("empty status = %d, want 204", resp.StatusCode) - } - resp.Body.Close() -} - -// TestAssetDuplicateEndpoint checks the copy lands next to the source with the -// shared " copy" naming. -func TestAssetDuplicateEndpoint(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{"path": "assets/pic.png"}) - resp, err := client.Post(server.URL+"/api/assets/duplicate", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("duplicate status = %d, want 200", resp.StatusCode) - } - var meta struct { - Path string `json:"path"` - } - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic copy.png" { - t.Fatalf("duplicate path = %q, want assets/pic copy.png", meta.Path) - } -} diff --git a/apps/server/internal/httpserver/basepath_test.go b/apps/server/internal/httpserver/basepath_test.go deleted file mode 100644 index 60c849ee..00000000 --- a/apps/server/internal/httpserver/basepath_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package httpserver - -import ( - "bytes" - "io" - "io/fs" - "net/http" - "net/http/httptest" - "strings" - "testing" - "testing/fstest" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -func newBasePathServer(t *testing.T, basePath string) *httptest.Server { - t.Helper() - cfg := config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:0", - BasePath: basePath, - AllowInsecureNoAuth: true, - } - v, err := vault.New(cfg.VaultPath, vault.Options{}) - if err != nil { - t.Fatalf("vault.New: %v", err) - } - static := fstest.MapFS{ - "index.html": &fstest.MapFile{ - Data: []byte("ZenNotes"), - }, - "manifest.webmanifest": &fstest.MapFile{Data: []byte("{}")}, - "assets/index-test.css": &fstest.MapFile{Data: []byte("body{color:red}")}, - } - srv := httptest.NewServer(New(v, nil, fs.FS(static), cfg).Router()) - t.Cleanup(srv.Close) - return srv -} - -func TestBasePathHealthz(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/api/healthz") - if err != nil { - t.Fatalf("get under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status under base: %d", resp.StatusCode) - } - - off, err := http.Get(srv.URL + "/api/healthz") - if err != nil { - t.Fatalf("get without base: %v", err) - } - defer off.Body.Close() - if off.StatusCode == http.StatusOK { - t.Fatalf("requests outside the base path should not match: got 200") - } -} - -func TestBasePathServesStaticAssets(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - - // A hashed CSS asset under the base path must serve the real file - // with a CSS content type. If the prefix isn't stripped before the - // embedded-FS lookup, serveStatic falls back to index.html and the - // browser refuses it for a bad MIME type (issue #58). - resp, err := http.Get(srv.URL + "/zennotes/assets/index-test.css") - if err != nil { - t.Fatalf("get css under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { - t.Fatalf("expected text/css content type, got %q", ct) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "body{color:red}" { - t.Fatalf("expected css body, got %q", string(body)) - } -} - -func TestBasePathServesManifest(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/manifest.webmanifest") - if err != nil { - t.Fatalf("get manifest under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "{}" { - t.Fatalf("expected manifest body {}, got %q", string(body)) - } -} - -func TestBasePathInjectsRuntimeHint(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/") - if err != nil { - t.Fatalf("get root: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - body := make([]byte, 4096) - n, _ := resp.Body.Read(body) - if !bytes.Contains(body[:n], []byte(``)) { - t.Fatalf("expected base path meta tag in index.html, got:\n%s", string(body[:n])) - } -} - -func TestRootDeploymentHasNoBasePathHint(t *testing.T) { - srv := newBasePathServer(t, "") - resp, err := http.Get(srv.URL + "/") - if err != nil { - t.Fatalf("get root: %v", err) - } - defer resp.Body.Close() - body := make([]byte, 4096) - n, _ := resp.Body.Read(body) - if bytes.Contains(body[:n], []byte("zn-base-path")) { - t.Fatalf("root deployment should not inject base path meta, got:\n%s", string(body[:n])) - } -} diff --git a/apps/server/internal/httpserver/browse_roots.go b/apps/server/internal/httpserver/browse_roots.go deleted file mode 100644 index f0a7381c..00000000 --- a/apps/server/internal/httpserver/browse_roots.go +++ /dev/null @@ -1,131 +0,0 @@ -package httpserver - -import ( - "net/http" - "os" - "path/filepath" - "runtime" - "slices" - "strings" -) - -func existingDir(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -func resolveExistingDir(path string) (string, error) { - if !filepath.IsAbs(path) { - abs, err := filepath.Abs(path) - if err != nil { - return "", err - } - path = abs - } - resolved, err := filepath.EvalSymlinks(filepath.Clean(path)) - if err != nil { - return "", err - } - info, err := os.Stat(resolved) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", httpStatusError{code: http.StatusBadRequest, msg: "path is not a directory"} - } - return resolved, nil -} - -func pathWithinRoot(target string, root string) bool { - cleanTarget := filepath.Clean(target) - cleanRoot := filepath.Clean(root) - return cleanTarget == cleanRoot || strings.HasPrefix(cleanTarget, cleanRoot+string(filepath.Separator)) -} - -func (s *Server) effectiveBrowseRoots() []string { - cfg := s.currentConfig() - if cfg.AllowUnscopedBrowse { - return nil - } - candidates := cfg.BrowseRoots - if len(candidates) == 0 { - if current := s.currentVault(); current != nil { - candidates = append(candidates, current.Root()) - } - } - if len(candidates) == 0 && strings.TrimSpace(cfg.DefaultVaultPath) != "" { - candidates = append(candidates, cfg.DefaultVaultPath) - } - if len(candidates) == 0 && strings.TrimSpace(cfg.VaultPath) != "" { - candidates = append(candidates, cfg.VaultPath) - } - roots := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - if resolved, err := resolveExistingDir(candidate); err == nil { - if !slices.Contains(roots, resolved) { - roots = append(roots, resolved) - } - } - } - return roots -} - -func (s *Server) ensureBrowsePathAllowed(path string) (string, error) { - resolved, err := resolveExistingDir(path) - if err != nil { - return "", err - } - roots := s.effectiveBrowseRoots() - if len(roots) == 0 { - return resolved, nil - } - for _, root := range roots { - if pathWithinRoot(resolved, root) { - return resolved, nil - } - } - return "", httpStatusError{code: http.StatusForbidden, msg: "path is outside the allowed browse roots"} -} - -func (s *Server) defaultBrowsePath() string { - roots := s.effectiveBrowseRoots() - if len(roots) > 0 { - return roots[0] - } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return home - } - if runtime.GOOS == "windows" { - return `C:\` - } - return string(filepath.Separator) -} - -func (s *Server) browseShortcuts() []directoryBrowseShortcut { - shortcuts := make([]directoryBrowseShortcut, 0, 8) - roots := s.effectiveBrowseRoots() - for idx, rootPath := range roots { - shortcuts = appendBrowseShortcut(shortcuts, browseRootLabel(rootPath, idx), rootPath) - } - if current := s.currentVault(); current != nil { - shortcuts = appendBrowseShortcut(shortcuts, "Current Vault", current.Root()) - } - if len(roots) == 0 { - root := filesystemRootForPath(s.defaultBrowsePath()) - shortcuts = appendBrowseShortcut(shortcuts, "Root", root) - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - shortcuts = appendBrowseShortcut(shortcuts, "Home", home) - shortcuts = appendBrowseShortcut(shortcuts, "Desktop", filepath.Join(home, "Desktop")) - shortcuts = appendBrowseShortcut(shortcuts, "Documents", filepath.Join(home, "Documents")) - shortcuts = appendBrowseShortcut(shortcuts, "Downloads", filepath.Join(home, "Downloads")) - if runtime.GOOS == "darwin" { - shortcuts = appendBrowseShortcut( - shortcuts, - "iCloud Drive", - filepath.Join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"), - ) - } - } - } - return shortcuts -} diff --git a/apps/server/internal/httpserver/contract_test.go b/apps/server/internal/httpserver/contract_test.go deleted file mode 100644 index 058495aa..00000000 --- a/apps/server/internal/httpserver/contract_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "reflect" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -func TestSelfHostedHTTPContract(t *testing.T) { - data, err := os.ReadFile("testdata/self-hosted-http.json") - if err != nil { - t.Fatal(err) - } - var fixture struct { - SchemaVersion int - Protocol string - MountPaths []string - RoutePrefixes []string - Note struct { - Path, Body, UpdatedBody string - AssetEmbeds, UpdatedAssetEmbeds []string - } - RequiredNoteFields, RequiredCapabilities []string - Errors struct { - Unauthenticated, MissingNote, DirectoryAsNote int - Challenge string - } - } - if err := json.Unmarshal(data, &fixture); err != nil { - t.Fatal(err) - } - if fixture.SchemaVersion != 1 || fixture.Protocol != "self-hosted-http-v1" { - t.Fatal("unsupported fixture") - } - for _, base := range fixture.MountPaths { - for _, prefix := range fixture.RoutePrefixes { - t.Run("mount="+base+", api="+prefix, func(t *testing.T) { - root := t.TempDir() - const token = "test-only-contract-token" - server, v := newTestServer(t, config.Config{VaultPath: root, DefaultVaultPath: root, BasePath: base, AuthToken: token, BrowseRoots: []string{root}}) - if _, err := v.WriteNote(fixture.Note.Path, fixture.Note.Body); err != nil { - t.Fatal(err) - } - endpoint := server.URL + base + prefix - request := func(client *http.Client, method, path string, body any, bearer bool) *http.Response { - t.Helper() - var input io.Reader - if body != nil { - encoded, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - input = bytes.NewReader(encoded) - } - req, err := http.NewRequest(method, endpoint+path, input) - if err != nil { - t.Fatal(err) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if bearer { - req.Header.Set("Authorization", "Bearer "+token) - } - response, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { response.Body.Close() }) - return response - } - decode := func(response *http.Response, status int) map[string]json.RawMessage { - t.Helper() - if response.StatusCode != status { - body, _ := io.ReadAll(response.Body) - t.Fatalf("status %d, want %d: %s", response.StatusCode, status, body) - } - var value map[string]json.RawMessage - if err := json.NewDecoder(response.Body).Decode(&value); err != nil { - t.Fatal(err) - } - return value - } - assertNote := func(value map[string]json.RawMessage, embeds []string, body *string) { - t.Helper() - for _, field := range fixture.RequiredNoteFields { - if _, ok := value[field]; !ok { - t.Errorf("missing required note field: %s", field) - } - } - var assets []string - if err := json.Unmarshal(value["assetEmbeds"], &assets); err != nil { - t.Error(err) - } else if !reflect.DeepEqual(assets, embeds) { - t.Errorf("assetEmbeds = %#v, want %#v", assets, embeds) - } - if body != nil { - var actual string - if err := json.Unmarshal(value["body"], &actual); err != nil { - t.Fatal(err) - } - if actual != *body { - t.Fatal("HTTP response changed Markdown bytes") - } - } - } - caps := decode(request(http.DefaultClient, "GET", "/capabilities", nil, false), 200) - for _, field := range fixture.RequiredCapabilities { - if _, ok := caps[field]; !ok { - t.Errorf("missing capability: %s", field) - } - } - readPath := "/notes/read?path=" + url.QueryEscape(fixture.Note.Path) - anonymous := request(http.DefaultClient, "GET", readPath, nil, false) - if anonymous.StatusCode != fixture.Errors.Unauthenticated || anonymous.Header.Get("WWW-Authenticate") != fixture.Errors.Challenge { - t.Fatalf("anonymous response: status=%d, challenge=%q", anonymous.StatusCode, anonymous.Header.Get("WWW-Authenticate")) - } - assertNote(decode(request(http.DefaultClient, "GET", readPath, nil, true), 200), fixture.Note.AssetEmbeds, &fixture.Note.Body) - update := map[string]string{"path": fixture.Note.Path, "body": fixture.Note.UpdatedBody} - assertNote(decode(request(http.DefaultClient, "POST", "/notes/write", update, true), 200), fixture.Note.UpdatedAssetEmbeds, nil) - assertNote(decode(request(http.DefaultClient, "GET", readPath, nil, true), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) - stored, err := v.ReadNote(fixture.Note.Path) - if err != nil || stored.Body != fixture.Note.UpdatedBody { - t.Fatalf("stored bytes differ: %v", err) - } - for path, status := range map[string]int{"missing.md": fixture.Errors.MissingNote, "inbox": fixture.Errors.DirectoryAsNote} { - if response := request(http.DefaultClient, "GET", "/notes/read?path="+url.QueryEscape(path), nil, true); response.StatusCode != status { - t.Errorf("read %s: got %d, want %d", path, response.StatusCode, status) - } - } - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatal(err) - } - client := &http.Client{Jar: jar} - apiURL, _ := url.Parse(server.URL + base + "/api/session") - jar.SetCookies(apiURL, []*http.Cookie{{Name: sessionCookieName, Value: "old-narrow-cookie", Path: base + "/api"}}) - login := request(client, "POST", "/session/login", map[string]string{"token": token}, false) - decode(login, 200) - validCookie := false - for _, cookie := range login.Cookies() { - if cookie.Name == sessionCookieName && cookie.Value != "" && cookie.Path == base+"/" { - validCookie = true - } - } - if !validCookie { - t.Errorf("session cookie does not cover mount %q", base) - } - if cookies := jar.Cookies(apiURL); len(cookies) != 1 || cookies[0].Value == "old-narrow-cookie" { - t.Fatal("login did not replace the old API cookie") - } - - assertNote(decode(request(client, "GET", readPath, nil, false), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) - // A cached client can change route families after a browser upgrade. - if prefix == "/api" { - endpoint = server.URL + base - } else { - endpoint = server.URL + base + "/api" - } - assertNote(decode(request(client, "GET", readPath, nil, false), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) - decode(request(client, "POST", "/session/logout", nil, false), 200) - if len(jar.Cookies(apiURL)) != 0 { - t.Fatal("logout left a session cookie") - } - if response := request(client, "GET", readPath, nil, false); response.StatusCode != 401 { - t.Fatal("logout left an authenticated session") - } - }) - } - } -} diff --git a/apps/server/internal/httpserver/cors_test.go b/apps/server/internal/httpserver/cors_test.go deleted file mode 100644 index 6c9e7f3e..00000000 --- a/apps/server/internal/httpserver/cors_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package httpserver - -import ( - "net/http" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// corsHeaders returns what the middleware answers for a given Origin. -func corsHeaders(t *testing.T, cfg config.Config, origin string) http.Header { - t.Helper() - root := t.TempDir() - cfg.VaultPath = root - cfg.DefaultVaultPath = root - if cfg.Bind == "" { - // A non-loopback bind, so the loopback exemption never masks the - // behaviour under test. - cfg.Bind = "192.0.2.10:7878" - } - server, _ := newTestServer(t, cfg) - - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", origin) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight for %q: %v", origin, err) - } - t.Cleanup(func() { resp.Body.Close() }) - return resp.Header -} - -func TestNormalizeOrigin(t *testing.T) { - cases := []struct { - raw string - want string - }{ - // Origins a browser or WebView actually sends. - {"https://notes.example.com", "https://notes.example.com"}, - {"HTTPS://Notes.Example.COM", "https://notes.example.com"}, - {"http://localhost:5173", "http://localhost:5173"}, - {"app://.", "app://."}, - {"capacitor://localhost", "capacitor://localhost"}, - // Opaque and scheme-only origins were dropped before #482, so an - // operator could list them and still be rejected. - {"null", "null"}, - {"NULL", "null"}, - {"file://", "file://"}, - {"*", "*"}, - // Still not origins. - {"", ""}, - {" ", ""}, - {"notes.example.com", ""}, - {"mailto:someone@example.com", ""}, - } - for _, tc := range cases { - if got := normalizeOrigin(tc.raw); got != tc.want { - t.Errorf("normalizeOrigin(%q) = %q, want %q", tc.raw, got, tc.want) - } - } -} - -func TestCORSAllowsExplicitlyListedOpaqueOrigins(t *testing.T) { - // Listing these verbatim is what the docs and operators do; before #482 - // they were parsed away and rejected anyway. - for _, origin := range []string{"null", "file://", "app://."} { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"null", "file://", "app://."}} - headers := corsHeaders(t, cfg, origin) - if got := headers.Get("Access-Control-Allow-Origin"); got != origin { - t.Errorf("origin %q: Allow-Origin = %q, want %q", origin, got, origin) - } - if got := headers.Get("Access-Control-Allow-Credentials"); got != "true" { - t.Errorf("origin %q: an explicitly listed origin keeps credentials, got %q", origin, got) - } - } -} - -func TestCORSWildcardAllowsAnyOriginWithoutCredentials(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"*"}} - for _, origin := range []string{"https://anything.example", "null", "file://", "app://."} { - headers := corsHeaders(t, cfg, origin) - if got := headers.Get("Access-Control-Allow-Origin"); got != origin { - t.Errorf("wildcard: Allow-Origin for %q = %q, want the origin echoed", origin, got) - } - // Echoing any origin *and* allowing credentials would let any site a - // user visits drive their session cookie. - if got := headers.Get("Access-Control-Allow-Credentials"); got != "" { - t.Errorf("wildcard: credentials must be withheld for %q, got %q", origin, got) - } - } -} - -func TestCORSRejectsUnlistedOriginByDefault(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token"} - headers := corsHeaders(t, cfg, "https://evil.example") - if got := headers.Get("Access-Control-Allow-Origin"); got != "" { - t.Errorf("unlisted origin must not be echoed, got %q", got) - } -} - -func TestCORSAllowsSameOriginWithCredentials(t *testing.T) { - // The server's own web bundle: same origin as the request, always allowed. - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "192.0.2.10:7878", - AuthToken: "secret-token", - }) - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", "http://"+req.Host) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight: %v", err) - } - defer resp.Body.Close() - if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { - t.Errorf("same-origin keeps credentials, got %q", got) - } -} - -func TestCORSPreflightShortCircuits(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"https://notes.example.com"}} - root := t.TempDir() - cfg.VaultPath = root - cfg.DefaultVaultPath = root - cfg.Bind = "192.0.2.10:7878" - server, _ := newTestServer(t, cfg) - - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", "https://notes.example.com") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - t.Errorf("preflight status = %d, want %d", resp.StatusCode, http.StatusNoContent) - } -} diff --git a/apps/server/internal/httpserver/csp_test.go b/apps/server/internal/httpserver/csp_test.go deleted file mode 100644 index 3815041a..00000000 --- a/apps/server/internal/httpserver/csp_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package httpserver - -import ( - "strings" - "testing" -) - -// The web client shows a vault's PDFs in an iframe served by this same -// server, and the PDF response carries the policy too. `frame-ancestors -// 'none'` therefore forbade the app's own same-origin frame and every PDF -// embed rendered as a blocked frame (#121). Same-origin framing must stay -// allowed; framing by other sites stays blocked. -func TestContentSecurityPolicyAllowsSameOriginFraming(t *testing.T) { - csp := contentSecurityPolicy() - if !strings.Contains(csp, "frame-ancestors 'self'") { - t.Fatalf("CSP must allow same-origin framing for PDF embeds, got: %s", csp) - } - if strings.Contains(csp, "frame-ancestors 'none'") { - t.Fatalf("CSP still forbids same-origin framing: %s", csp) - } -} diff --git a/apps/server/internal/httpserver/excalidraw_test.go b/apps/server/internal/httpserver/excalidraw_test.go deleted file mode 100644 index cf2d8ca9..00000000 --- a/apps/server/internal/httpserver/excalidraw_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// TestCreateExcalidrawEndpoint exercises the full HTTP wiring: log in, POST -// /api/excalidraw/create, and confirm the drawing comes back as a `.excalidraw` -// note that then shows up in /api/notes (and not in /api/assets). -func TestCreateExcalidrawEndpoint(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{"folder": "inbox", "title": "My Sketch"}) - resp, err := client.Post(server.URL+"/api/excalidraw/create", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST /api/excalidraw/create: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("create status: %d", resp.StatusCode) - } - var created struct { - Path string `json:"path"` - Title string `json:"title"` - } - if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { - t.Fatalf("decode create response: %v", err) - } - if !strings.HasSuffix(created.Path, ".excalidraw") { - t.Fatalf("created path = %q, want a .excalidraw file", created.Path) - } - if created.Title != "My Sketch" { - t.Errorf("created title = %q, want My Sketch", created.Title) - } - - listResp, err := client.Get(server.URL + "/api/notes") - if err != nil { - t.Fatalf("GET /api/notes: %v", err) - } - defer listResp.Body.Close() - var notes []struct { - Path string `json:"path"` - } - if err := json.NewDecoder(listResp.Body).Decode(¬es); err != nil { - t.Fatalf("decode notes: %v", err) - } - found := false - for _, n := range notes { - if n.Path == created.Path { - found = true - } - } - if !found { - t.Errorf("created drawing %q not returned by /api/notes", created.Path) - } -} diff --git a/apps/server/internal/httpserver/limiter_test.go b/apps/server/internal/httpserver/limiter_test.go deleted file mode 100644 index cfbaebb2..00000000 --- a/apps/server/internal/httpserver/limiter_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package httpserver - -import ( - "testing" - "time" -) - -func TestBackoffDelay(t *testing.T) { - cases := []struct { - failures int - want time.Duration - }{ - {0, 0}, - {1, 1 * time.Second}, - {2, 2 * time.Second}, - {3, 4 * time.Second}, - {4, 8 * time.Second}, - {5, 16 * time.Second}, - {6, 32 * time.Second}, - {7, 60 * time.Second}, // capped - {99, 60 * time.Second}, - } - for _, c := range cases { - if got := backoffDelay(c.failures); got != c.want { - t.Errorf("backoffDelay(%d) = %v, want %v", c.failures, got, c.want) - } - } -} - -// TestAttemptLimiterRejectsImmediateRetry is the integration check for -// the backoff machinery. The first call goes through; an immediate -// second call is rejected because the inter-attempt minimum hasn't -// elapsed. -func TestAttemptLimiterRejectsImmediateRetry(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("k") { - t.Fatal("first allow should pass") - } - if l.allow("k") { - t.Fatal("immediate second allow should be rejected by backoff") - } -} - -func TestAttemptLimiterResetClearsBackoff(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("k") { - t.Fatal("first allow should pass") - } - l.reset("k") - if !l.allow("k") { - t.Fatal("after reset, allow should pass again") - } -} - -func TestAttemptLimiterIndependentKeys(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("alice") { - t.Fatal("alice first allow should pass") - } - if !l.allow("bob") { - t.Fatal("bob's allow should pass independently of alice") - } -} diff --git a/apps/server/internal/httpserver/read_note_status_test.go b/apps/server/internal/httpserver/read_note_status_test.go deleted file mode 100644 index 849ed3c4..00000000 --- a/apps/server/internal/httpserver/read_note_status_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package httpserver - -import ( - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// The three answers /api/notes/read can give about a path, which clients read -// as three different things. Databases are composed from these reads, where -// "absent" means "adopt this bare CSV" and "failed" means "stop", so a status -// that blurs them is a data-loss bug rather than a cosmetic one (#kta report). -func TestReadNoteStatusesDistinguishMissingFromBroken(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { - t.Fatalf("mkdir inbox: %v", err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "Real.md"), []byte("# Real\n"), 0o644); err != nil { - t.Fatalf("write note: %v", err) - } - // A database folder, which is exactly the shape a client asks about when - // it reads `.base/data.csv`. - if err := os.MkdirAll(filepath.Join(root, "inbox", "Db.base"), 0o755); err != nil { - t.Fatalf("mkdir db: %v", err) - } - - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - get := func(t *testing.T, path string) int { - t.Helper() - resp, err := client.Get(server.URL + "/api/notes/read?path=" + path) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - defer resp.Body.Close() - return resp.StatusCode - } - - if got := get(t, "inbox%2FReal.md"); got != http.StatusOK { - t.Errorf("existing note: got %d, want 200", got) - } - // Absence is the caller's answer, not a failure: a client that cannot see - // this cannot create a database, because naming one probes for a free name. - if got := get(t, "inbox%2FDb.base%2Fdata.csv"); got != http.StatusNotFound { - t.Errorf("missing file: got %d, want 404", got) - } - // Reading a directory as a file is a malformed request, not a broken - // server. It answered 500 before, which sent a bug report chasing a - // server that was fine. - if got := get(t, "inbox%2FDb.base"); got != http.StatusBadRequest { - t.Errorf("directory read: got %d, want 400", got) - } -} - -// Clients otherwise have to probe for this behavior at runtime, so the server -// states it. Its absence is what marks a server from before 2.20.2. -func TestCapabilitiesReportMissingAsNotFound(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatalf("GET /api/capabilities: %v", err) - } - defer resp.Body.Close() - - var caps map[string]any - if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { - t.Fatalf("decode capabilities: %v", err) - } - if caps["reportsMissingAsNotFound"] != true { - t.Errorf("reportsMissingAsNotFound: got %v, want true", caps["reportsMissingAsNotFound"]) - } -} diff --git a/apps/server/internal/httpserver/security.go b/apps/server/internal/httpserver/security.go deleted file mode 100644 index 70bdf6b4..00000000 --- a/apps/server/internal/httpserver/security.go +++ /dev/null @@ -1,640 +0,0 @@ -package httpserver - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "net/url" - "os" - "strings" - "sync" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -const ( - sessionCookieName = "zennotes_session" - sessionTTL = 30 * 24 * time.Hour -) - -type sessionStore struct { - mu sync.Mutex - sessions map[string]time.Time - // path, when non-empty, persists sessions to disk so browser logins survive a - // server restart (opt-in via ZENNOTES_PERSIST_SESSIONS). - path string -} - -type attemptLimiter struct { - mu sync.Mutex - window time.Duration - maxHits int - hits map[string][]time.Time -} - -type httpStatusError struct { - code int - msg string -} - -func (e httpStatusError) Error() string { - return e.msg -} - -func newSessionStore(path string) *sessionStore { - s := &sessionStore{sessions: make(map[string]time.Time), path: path} - s.load() - return s -} - -// load restores persisted sessions, dropping any already expired. Best-effort: -// a missing/unreadable/corrupt file just starts with no sessions. -func (s *sessionStore) load() { - if s.path == "" { - return - } - raw, err := os.ReadFile(s.path) - if err != nil { - return // no file yet (first run) or unreadable — start clean - } - var stored map[string]time.Time - if err := json.Unmarshal(raw, &stored); err != nil { - log.Printf("sessions: ignoring unreadable %q: %v", s.path, err) - return - } - now := time.Now() - for token, expiresAt := range stored { - if now.Before(expiresAt) { - s.sessions[token] = expiresAt - } - } -} - -// persistLocked writes the current sessions to disk (mode 0600). The caller must -// hold s.mu. Best-effort: a write failure only means sessions won't survive the -// next restart, so it is logged but not fatal. -func (s *sessionStore) persistLocked() { - if s.path == "" { - return - } - data, err := json.Marshal(s.sessions) - if err != nil { - return - } - if err := os.WriteFile(s.path, data, 0o600); err != nil { - log.Printf("sessions: could not persist to %q: %v", s.path, err) - } -} - -func (s *sessionStore) create() (string, time.Time, error) { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", time.Time{}, err - } - token := hex.EncodeToString(buf) - expiresAt := time.Now().Add(sessionTTL) - s.mu.Lock() - s.sessions[token] = expiresAt - s.persistLocked() - s.mu.Unlock() - return token, expiresAt, nil -} - -func (s *sessionStore) isValid(token string) bool { - if strings.TrimSpace(token) == "" { - return false - } - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - for key, expiresAt := range s.sessions { - if now.After(expiresAt) { - delete(s.sessions, key) - } - } - expiresAt, ok := s.sessions[token] - return ok && now.Before(expiresAt) -} - -func (s *sessionStore) delete(token string) { - if strings.TrimSpace(token) == "" { - return - } - s.mu.Lock() - delete(s.sessions, token) - s.persistLocked() - s.mu.Unlock() -} - -func (s *sessionStore) deleteAll() { - s.mu.Lock() - s.sessions = make(map[string]time.Time) - s.persistLocked() - s.mu.Unlock() -} - -func newAttemptLimiter(window time.Duration, maxHits int) *attemptLimiter { - return &attemptLimiter{ - window: window, - maxHits: maxHits, - hits: make(map[string][]time.Time), - } -} - -func (l *attemptLimiter) allow(key string) bool { - if strings.TrimSpace(key) == "" { - key = "unknown" - } - now := time.Now() - cutoff := now.Add(-l.window) - - l.mu.Lock() - defer l.mu.Unlock() - - history := l.hits[key][:0] - for _, ts := range l.hits[key] { - if ts.After(cutoff) { - history = append(history, ts) - } - } - - // Exponential backoff between consecutive attempts. The window-based - // cap below is the absolute ceiling; the per-attempt backoff makes - // even the first few failures cost real time. - if n := len(history); n > 0 { - if wait := backoffDelay(n); now.Sub(history[n-1]) < wait { - l.hits[key] = history - return false - } - } - if len(history) >= l.maxHits { - l.hits[key] = history - return false - } - history = append(history, now) - l.hits[key] = history - return true -} - -// backoffDelay returns the minimum time the caller must wait before the -// (consecutiveFailures+1)-th attempt is allowed: 0, 1, 2, 4, 8, 16, 32, -// then capped at 60s. -func backoffDelay(consecutiveFailures int) time.Duration { - if consecutiveFailures < 1 { - return 0 - } - n := consecutiveFailures - 1 - if n > 6 { - n = 6 - } - d := time.Duration(1< 60*time.Second { - d = 60 * time.Second - } - return d -} - -func (l *attemptLimiter) reset(key string) { - l.mu.Lock() - delete(l.hits, key) - l.mu.Unlock() -} - -// AllowAllOrigins is the wildcard operators reach for first. Comparing it -// literally, as any other string, meant ZENNOTES_ALLOWED_ORIGINS="*" locked -// everything out with no hint that it was unsupported. (#482) -const AllowAllOrigins = "*" - -// NullOrigin is what a browser sends for an opaque origin — a sandboxed -// iframe, a data: document, or a page loaded over file:// in most engines. -const NullOrigin = "null" - -// normalizeOrigin canonicalises an origin for comparison, and returns "" for -// anything that isn't one. -// -// Origins are not always scheme+host. Browsers send the literal "null" for -// opaque origins, and "file://" (no host) for local pages in some engines; -// requiring a host silently dropped both, so an operator who listed them -// verbatim still saw them rejected with no way to allow them. Both are -// preserved here so they can be configured. (#482) -func normalizeOrigin(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - if trimmed == AllowAllOrigins { - return AllowAllOrigins - } - if strings.EqualFold(trimmed, NullOrigin) { - return NullOrigin - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" { - return "" - } - if parsed.Host == "" { - // Scheme-only origin such as "file://". Anything else without a host — - // "mailto:someone", "https:///path" — is not an origin. - if parsed.Opaque != "" || parsed.Path != "" { - return "" - } - return strings.ToLower(parsed.Scheme) + "://" - } - return fmt.Sprintf("%s://%s", strings.ToLower(parsed.Scheme), strings.ToLower(parsed.Host)) -} - -// peerIsTrustedProxy reports whether the immediate TCP peer (r.RemoteAddr) -// is in the configured ZENNOTES_TRUSTED_PROXIES set. Forwarded-* headers -// are only honoured when this is true. -func (s *Server) peerIsTrustedProxy(r *http.Request) bool { - cfg := s.currentConfig() - if len(cfg.TrustedProxies) == 0 { - return false - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - ip := net.ParseIP(strings.Trim(host, "[]")) - if ip == nil { - return false - } - for _, n := range cfg.TrustedProxies { - if n.Contains(ip) { - return true - } - } - return false -} - -// effectiveScheme returns "https" if the request is genuinely on TLS or -// arrived through a trusted proxy that declares X-Forwarded-Proto: https. -// Untrusted X-Forwarded-Proto headers are ignored. -func (s *Server) effectiveScheme(r *http.Request) string { - if r.TLS != nil { - return "https" - } - if s.peerIsTrustedProxy(r) { - if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); forwarded != "" { - return strings.ToLower(forwarded) - } - } - if s.currentConfig().BehindTLS { - return "https" - } - return "http" -} - -func (s *Server) requestOrigin(r *http.Request) string { - scheme := s.effectiveScheme(r) - host := strings.TrimSpace(r.Host) - if s.peerIsTrustedProxy(r) { - if forwardedHost := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); forwardedHost != "" { - host = forwardedHost - } - } - if host == "" { - return "" - } - return fmt.Sprintf("%s://%s", scheme, strings.ToLower(host)) -} - -func isLoopbackBind(bind string) bool { - host, _, err := net.SplitHostPort(bind) - if err != nil { - host = bind - } - host = strings.Trim(host, "[]") - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -func isLoopbackOrigin(origin string) bool { - parsed, err := url.Parse(origin) - if err != nil { - return false - } - host := parsed.Hostname() - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -// originDecision is how a request's Origin fared: whether it may be echoed -// back at all, and whether credentials (the session cookie) may ride with it. -type originDecision struct { - allowed bool - // Credentials are withheld for a wildcard match: echoing - // Access-Control-Allow-Origin for *any* site alongside - // Allow-Credentials: true would let any page a user visits drive their - // session. Bearer-token clients are unaffected — they attach the token - // themselves. (#482) - credentials bool -} - -func (s *Server) originDecisionFor(r *http.Request, origin string) originDecision { - if origin == "" { - return originDecision{allowed: true, credentials: true} - } - normalized := normalizeOrigin(origin) - if normalized == "" { - return originDecision{} - } - if normalized == s.requestOrigin(r) { - return originDecision{allowed: true, credentials: true} - } - - cfg := s.currentConfig() - wildcard := false - for _, allowed := range cfg.AllowedOrigins { - switch normalizeOrigin(allowed) { - case normalized: - return originDecision{allowed: true, credentials: true} - case AllowAllOrigins: - wildcard = true - } - } - if wildcard { - return originDecision{allowed: true} - } - - if (cfg.DevMode || isLoopbackBind(cfg.Bind)) && isLoopbackOrigin(normalized) { - return originDecision{allowed: true, credentials: true} - } - - return originDecision{} -} - -func (s *Server) isAllowedOrigin(r *http.Request, origin string) bool { - return s.originDecisionFor(r, origin).allowed -} - -func (s *Server) corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin != "" { - if decision := s.originDecisionFor(r, origin); decision.allowed { - w.Header().Set("Access-Control-Allow-Origin", origin) - if decision.credentials { - w.Header().Set("Access-Control-Allow-Credentials", "true") - } - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, If-Match") - w.Header().Add("Vary", "Origin") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - } else { - s.logCORSRejection(origin) - } - } - next.ServeHTTP(w, r) - }) -} - -// logCORSRejection emits one log line per unique origin so a -// misconfigured ZENNOTES_ALLOWED_ORIGINS surfaces in operator logs -// instead of silently failing in the browser. -func (s *Server) logCORSRejection(origin string) { - if _, loaded := s.loggedOrigins.LoadOrStore(origin, struct{}{}); loaded { - return - } - log.Printf( - "CORS rejected origin %q; add it verbatim to ZENNOTES_ALLOWED_ORIGINS (comma-separated) to allow it, "+ - "or set ZENNOTES_ALLOWED_ORIGINS=* to allow any origin without credentials. "+ - "The ZenNotes desktop app talks to the server from its main process and sends no Origin, so it is "+ - "never affected by this — browser and WebView clients are.", - origin, - ) -} - -func contentSecurityPolicy() string { - return strings.Join([]string{ - "default-src 'self'", - "script-src 'self' 'unsafe-eval'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "media-src 'self' data: blob:", - "font-src 'self' data:", - "worker-src 'self' blob:", - "connect-src 'self' ws: wss: https:", - "frame-src 'self' data: blob: https://www.youtube-nocookie.com https://player.vimeo.com", - "object-src 'none'", - "base-uri 'none'", - "form-action 'none'", - // 'self', not 'none': the web client shows a vault's PDFs in an iframe - // served by this server, and the PDF response carries this policy too, - // so 'none' forbade the app's own same-origin frame (#121). Third-party - // framing stays blocked. - "frame-ancestors 'self'", - "manifest-src 'self'", - }, "; ") -} - -func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", contentSecurityPolicy()) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") - if s.effectiveScheme(r) == "https" { - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") - } - next.ServeHTTP(w, r) - }) -} - -func sessionStatusPayload(authenticated bool, cfg config.Config) map[string]any { - return map[string]any{ - "authenticated": authenticated, - "authRequired": strings.TrimSpace(cfg.AuthToken) != "", - "supportsSessionLogin": true, - } -} - -func (s *Server) sessionCookie(r *http.Request, token string, expiresAt time.Time) *http.Cookie { - cookie := &http.Cookie{ - Name: sessionCookieName, - Value: token, - // One path supports canonical and legacy routes across browser upgrades. - Path: config.NormalizeBasePath(s.currentConfig().BasePath) + "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Expires: expiresAt, - } - if s.effectiveScheme(r) == "https" { - cookie.Secure = true - } - return cookie -} - -func (s *Server) clearSessionCookie(r *http.Request) *http.Cookie { - cookie := s.sessionCookie(r, "", time.Unix(0, 0)) - cookie.MaxAge = -1 - return cookie -} - -func (s *Server) expireOldAPICookie(w http.ResponseWriter, r *http.Request) { - cookie := s.clearSessionCookie(r) - cookie.Path = config.NormalizeBasePath(s.currentConfig().BasePath) + "/api" - http.SetCookie(w, cookie) -} - -func (s *Server) requestAuthenticatedViaSession(r *http.Request) bool { - cookie, err := r.Cookie(sessionCookieName) - if err != nil { - return false - } - return s.sessions.isValid(cookie.Value) -} - -// clientAddressKey returns a stable identifier for rate-limit keying. It -// honours X-Forwarded-For only when the immediate peer is a configured -// trusted proxy; otherwise it returns the TCP peer IP. This prevents -// untrusted clients from spoofing rate-limit buckets via header. -func (s *Server) clientAddressKey(r *http.Request) string { - if s.peerIsTrustedProxy(r) { - if fwd := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); fwd != "" { - if h, _, err := net.SplitHostPort(fwd); err == nil { - return h - } - return fwd - } - } - host := strings.TrimSpace(r.RemoteAddr) - if h, _, err := net.SplitHostPort(host); err == nil { - return h - } - return host -} - -func (s *Server) sessionStatus(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - writeJSON(w, http.StatusOK, sessionStatusPayload(s.requestAuthenticatedViaSession(r), cfg)) -} - -func (s *Server) sessionLogin(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - if !s.loginLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many login attempts", http.StatusTooManyRequests) - return - } - - if strings.TrimSpace(cfg.AuthToken) == "" { - writeJSON(w, http.StatusOK, sessionStatusPayload(true, cfg)) - return - } - - var req struct { - Token string `json:"token"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if subtleCompare(strings.TrimSpace(req.Token), strings.TrimSpace(cfg.AuthToken)) { - s.loginLimiter.reset(s.clientAddressKey(r)) - token, expiresAt, err := s.sessions.create() - if err != nil { - writeError(w, err) - return - } - http.SetCookie(w, s.sessionCookie(r, token, expiresAt)) - s.expireOldAPICookie(w, r) - writeJSON(w, http.StatusOK, sessionStatusPayload(true, cfg)) - return - } - - http.Error(w, "unauthorized", http.StatusUnauthorized) -} - -func (s *Server) sessionLogout(w http.ResponseWriter, r *http.Request) { - for _, cookie := range r.Cookies() { - if cookie.Name == sessionCookieName { - s.sessions.delete(cookie.Value) - } - } - http.SetCookie(w, s.clearSessionCookie(r)) - s.expireOldAPICookie(w, r) - writeJSON(w, http.StatusOK, sessionStatusPayload(false, s.currentConfig())) -} - -// sessionRotateToken replaces the bootstrap auth token with a caller- -// supplied value. Requires the *current* token in the body even when -// the request is authenticated, so a stolen session alone cannot rotate -// the secret. All existing sessions are invalidated; clients must -// re-login with the new token. -func (s *Server) sessionRotateToken(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4<<10) - var req struct { - CurrentToken string `json:"currentToken"` - NewToken string `json:"newToken"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - current := strings.TrimSpace(req.CurrentToken) - next := strings.TrimSpace(req.NewToken) - if len(next) < 16 { - http.Error(w, "new token must be at least 16 characters", http.StatusBadRequest) - return - } - if next == current { - http.Error(w, "new token must differ from current", http.StatusBadRequest) - return - } - - s.mu.Lock() - cfgCurrent := s.Config.AuthToken - sourceCurrent := s.Config.AuthTokenSource - if sourceCurrent == config.AuthTokenSourceEnv || sourceCurrent == config.AuthTokenSourceFile { - s.mu.Unlock() - http.Error(w, "auth token is managed outside ZenNotes; update the token source and restart", http.StatusConflict) - return - } - if !subtleCompare(current, strings.TrimSpace(cfgCurrent)) { - s.mu.Unlock() - http.Error(w, "current token mismatch", http.StatusUnauthorized) - return - } - s.Config.AuthToken = next - s.Config.AuthTokenSource = config.AuthTokenSourceConfig - cfgCopy := s.Config - s.mu.Unlock() - - if err := config.SaveHost(cfgCopy); err != nil { - s.mu.Lock() - s.Config.AuthToken = cfgCurrent - s.Config.AuthTokenSource = sourceCurrent - s.mu.Unlock() - writeError(w, err) - return - } - s.sessions.deleteAll() - http.SetCookie(w, s.clearSessionCookie(r)) - s.expireOldAPICookie(w, r) - writeJSON(w, http.StatusOK, map[string]any{"rotated": true}) -} - -func subtleCompare(left string, right string) bool { - if len(left) == 0 || len(right) == 0 { - return false - } - return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 -} diff --git a/apps/server/internal/httpserver/security_test.go b/apps/server/internal/httpserver/security_test.go deleted file mode 100644 index 357be339..00000000 --- a/apps/server/internal/httpserver/security_test.go +++ /dev/null @@ -1,562 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "log" - "mime/multipart" - "net" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -func newTestServer(t *testing.T, cfg config.Config) (*httptest.Server, *vault.Vault) { - t.Helper() - - v, err := vault.New(cfg.VaultPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - t.Fatalf("vault.New: %v", err) - } - - server := httptest.NewServer(New(v, nil, nil, cfg).Router()) - t.Cleanup(server.Close) - return server, v -} - -// loginAndJar logs in with the given token and returns a cookiejar -// that subsequent calls can reuse. -func loginAndJar(t *testing.T, server *httptest.Server, token string) http.CookieJar { - t.Helper() - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("cookiejar.New: %v", err) - } - client := &http.Client{Jar: jar} - body, _ := json.Marshal(map[string]string{"token": token}) - resp, err := client.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("login: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login status: %d", resp.StatusCode) - } - return jar -} - -func TestSessionLoginProtectsVaultRoutes(t *testing.T) { - root := t.TempDir() - server, v := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - - unauthenticatedResp, err := http.Get(server.URL + "/api/vault") - if err != nil { - t.Fatalf("GET /api/vault without auth: %v", err) - } - defer unauthenticatedResp.Body.Close() - if unauthenticatedResp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected 401 without auth, got %d", unauthenticatedResp.StatusCode) - } - - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("cookiejar.New: %v", err) - } - client := &http.Client{Jar: jar} - - loginBody, err := json.Marshal(map[string]string{"token": "secret-token"}) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - loginResp, err := client.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginBody)) - if err != nil { - t.Fatalf("POST /api/session/login: %v", err) - } - defer loginResp.Body.Close() - if loginResp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 from login, got %d", loginResp.StatusCode) - } - - loginURL, err := url.Parse(server.URL + "/api/session/login") - if err != nil { - t.Fatalf("url.Parse: %v", err) - } - if len(jar.Cookies(loginURL)) == 0 { - t.Fatal("expected login to set a session cookie") - } - - authedResp, err := client.Get(server.URL + "/api/vault") - if err != nil { - t.Fatalf("GET /api/vault with session cookie: %v", err) - } - defer authedResp.Body.Close() - if authedResp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 with session cookie, got %d", authedResp.StatusCode) - } - - var info struct { - Root string `json:"root"` - } - if err := json.NewDecoder(authedResp.Body).Decode(&info); err != nil { - t.Fatalf("decode /api/vault response: %v", err) - } - if info.Root != v.Root() { - t.Fatalf("expected vault root %q, got %q", v.Root(), info.Root) - } -} - -func TestBrowseRootsEnforced(t *testing.T) { - parent := t.TempDir() - allowedRoot := filepath.Join(parent, "allowed") - blockedRoot := filepath.Join(parent, "blocked") - if err := os.MkdirAll(allowedRoot, 0o755); err != nil { - t.Fatalf("MkdirAll allowedRoot: %v", err) - } - if err := os.MkdirAll(blockedRoot, 0o755); err != nil { - t.Fatalf("MkdirAll blockedRoot: %v", err) - } - - server, _ := newTestServer(t, config.Config{ - VaultPath: allowedRoot, - DefaultVaultPath: allowedRoot, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{allowedRoot}, - }) - - request, err := http.NewRequest(http.MethodGet, server.URL+"/api/fs/browse?path="+url.QueryEscape(blockedRoot), nil) - if err != nil { - t.Fatalf("http.NewRequest: %v", err) - } - request.Header.Set("Authorization", "Bearer secret-token") - - response, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatalf("GET /api/fs/browse outside allowed root: %v", err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 for blocked browse root, got %d", response.StatusCode) - } -} - -// --- T1.2 upload size limit --- - -func TestUploadAssetRespects413(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - MaxAssetBytes: 64, - MaxNoteBytes: 64, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body := &bytes.Buffer{} - mw := multipart.NewWriter(body) - _ = mw.WriteField("notePath", "note.md") - part, _ := mw.CreateFormFile("file", "x.bin") - _, _ = part.Write(bytes.Repeat([]byte("a"), 1024)) - _ = mw.Close() - - resp, err := client.Post(server.URL+"/api/assets/upload", mw.FormDataContentType(), body) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge && resp.StatusCode != http.StatusBadRequest { - // http.MaxBytesReader returns 400 before ParseMultipartForm runs - // for over-cap requests; ImportAsset's own ErrAssetTooLarge maps - // to 413 if the multipart parser somehow lets it through. Either - // is acceptable here. - t.Fatalf("expected 4xx large-body rejection, got %d", resp.StatusCode) - } -} - -func TestWriteNoteRespects413(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - MaxNoteBytes: 64, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - huge := strings.Repeat("a", 200000) - body, _ := json.Marshal(map[string]string{"path": "x.md", "body": huge}) - resp, err := client.Post(server.URL+"/api/notes/write", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 4 { - t.Fatalf("expected 4xx for oversized note, got %d", resp.StatusCode) - } -} - -// --- T2.4 trusted-proxies gate --- - -func TestForwardedProtoIgnoredWithoutTrust(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - }) - body, _ := json.Marshal(map[string]string{"token": "secret-token"}) - req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/session/login", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-Proto", "https") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login: %d", resp.StatusCode) - } - for _, c := range resp.Cookies() { - if c.Name == "zennotes_session" && c.Secure { - t.Fatalf("Secure cookie set despite untrusted X-Forwarded-Proto") - } - } -} - -func TestForwardedProtoHonouredWhenTrusted(t *testing.T) { - _, loop, _ := net.ParseCIDR("127.0.0.0/8") - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - TrustedProxies: []net.IPNet{*loop}, - }) - body, _ := json.Marshal(map[string]string{"token": "secret-token"}) - req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/session/login", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-Proto", "https") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login: %d", resp.StatusCode) - } - var found bool - for _, c := range resp.Cookies() { - if c.Name == "zennotes_session" && c.Secure { - found = true - } - } - if !found { - t.Fatalf("expected Secure cookie when peer is trusted and X-Forwarded-Proto=https") - } -} - -// --- T2.5 HSTS --- - -func TestHSTSOnlyWhenEffectiveHTTPS(t *testing.T) { - root := t.TempDir() - - // Without BehindTLS or trusted proxies: no HSTS. - plain, _ := newTestServer(t, config.Config{VaultPath: root, Bind: "127.0.0.1:7878"}) - resp, err := http.Get(plain.URL + "/api/healthz") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if got := resp.Header.Get("Strict-Transport-Security"); got != "" { - t.Fatalf("HSTS unexpectedly sent on plain HTTP: %q", got) - } - - // With BehindTLS=true: HSTS sent. - tls, _ := newTestServer(t, config.Config{VaultPath: t.TempDir(), Bind: "127.0.0.1:7878", BehindTLS: true}) - resp, err = http.Get(tls.URL + "/api/healthz") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if got := resp.Header.Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") { - t.Fatalf("expected HSTS header with BehindTLS=1, got %q", got) - } -} - -// --- T3.9 token rotation --- - -func TestRotateTokenFullFlow(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - // SaveHost would otherwise touch the user config file. Redirect: - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - // Rotate. - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("rotate: expected 200, got %d", resp.StatusCode) - } - - // Old session is now invalidated. - resp, err = client.Get(server.URL + "/api/vault") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("old session should be invalidated, got %d", resp.StatusCode) - } - - // New token logs in (do this first so the success resets the rate - // limiter; otherwise the next failed attempt would trip the - // inter-attempt backoff and the assertion below would 429). - loginNew, _ := json.Marshal(map[string]string{"token": "rotated-token-yyyyyyy"}) - resp, err = http.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginNew)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("new token should log in, got %d", resp.StatusCode) - } - - // Old token can no longer log in. - loginOld, _ := json.Marshal(map[string]string{"token": "current-token-xxxxxxxx"}) - resp, err = http.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginOld)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - t.Fatalf("old token should be rejected after rotation, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsShortToken(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "tooshort", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for short new token, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsWrongCurrent(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "wrong-token", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected 401 for wrong current, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsExternalTokenSource(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - AuthTokenSource: config.AuthTokenSourceEnv, - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusConflict { - t.Fatalf("expected 409 for externally managed token, got %d", resp.StatusCode) - } -} - -func TestWriteErrorDoesNotExposeInternalDetails(t *testing.T) { - rec := httptest.NewRecorder() - writeError(rec, errors.New("open /Users/example/private/vault/secret.md: permission denied")) - - if rec.Code != http.StatusInternalServerError { - t.Fatalf("expected 500, got %d", rec.Code) - } - body := rec.Body.String() - if strings.Contains(body, "/Users/example") || strings.Contains(body, "permission denied") { - t.Fatalf("internal error leaked details: %q", body) - } - if !strings.Contains(body, "internal server error") { - t.Fatalf("expected generic error body, got %q", body) - } -} - -func TestIsLoopbackBindTreatsEmptyHostAsNonLoopback(t *testing.T) { - cases := []struct { - bind string - want bool - }{ - {":7878", false}, - {"0.0.0.0:7878", false}, - {"[::]:7878", false}, - {"127.0.0.1:7878", true}, - {"[::1]:7878", true}, - {"localhost:7878", true}, - } - - for _, tc := range cases { - if got := isLoopbackBind(tc.bind); got != tc.want { - t.Fatalf("isLoopbackBind(%q) = %v, want %v", tc.bind, got, tc.want) - } - } -} - -// --- T3.10 CORS rejection log --- - -func TestCORSRejectionLoggedOncePerOrigin(t *testing.T) { - var buf strings.Builder - prev := log.Writer() - log.SetOutput(&buf) - t.Cleanup(func() { log.SetOutput(prev) }) - - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - }) - send := func(origin string) { - req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/healthz", nil) - req.Header.Set("Origin", origin) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - } - - send("https://evil.example.com") - send("https://evil.example.com") - send("https://other.example.com") - - out := buf.String() - first := strings.Count(out, `evil.example.com"`) - other := strings.Count(out, `other.example.com"`) - if first != 1 { - t.Errorf("expected exactly one log for evil.example.com, got %d:\n%s", first, out) - } - if other != 1 { - t.Errorf("expected exactly one log for other.example.com, got %d:\n%s", other, out) - } -} - -func TestSessionStorePersistence(t *testing.T) { - path := filepath.Join(t.TempDir(), "sessions.json") - - // A store with a path survives a "restart" (a fresh store on the same file). - first := newSessionStore(path) - token, _, err := first.create() - if err != nil { - t.Fatal(err) - } - if !newSessionStore(path).isValid(token) { - t.Fatal("session should survive a restart when persistence is on") - } - - // Logout removes it from disk too. - newSessionStore(path).delete(token) - if newSessionStore(path).isValid(token) { - t.Fatal("deleted session should not come back after a restart") - } -} - -func TestSessionStoreNoPersistenceByDefault(t *testing.T) { - // An empty path keeps the store in-memory; nothing survives a "restart". - first := newSessionStore("") - token, _, err := first.create() - if err != nil { - t.Fatal(err) - } - if newSessionStore("").isValid(token) { - t.Fatal("without a path, sessions must not persist") - } -} - -func TestSessionStoreDropsExpiredOnLoad(t *testing.T) { - path := filepath.Join(t.TempDir(), "sessions.json") - data, _ := json.Marshal(map[string]time.Time{"stale": time.Now().Add(-time.Hour)}) - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } - if newSessionStore(path).isValid("stale") { - t.Fatal("an expired persisted session should be dropped on load") - } -} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go deleted file mode 100644 index 9b4ffafb..00000000 --- a/apps/server/internal/httpserver/server.go +++ /dev/null @@ -1,1397 +0,0 @@ -package httpserver - -import ( - "encoding/json" - "errors" - "io/fs" - "log" - "mime" - "net/http" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "sync" - "syscall" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/coder/websocket" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -// Per-request body envelope allowances applied on top of the -// configured note/asset size limits. They cover JSON keys + structural -// overhead (writeNote) and multipart boundaries + form fields -// (uploadAsset). Generous enough to never reject a payload that's -// within the configured limit. -const ( - jsonEnvelopeBytes int64 = 64 << 10 // 64 KiB - multipartOverheadBytes int64 = 256 << 10 // 256 KiB -) - -type Server struct { - mu sync.RWMutex - Config config.Config - Vault *vault.Vault - Watcher *watcher.Watcher - Static fs.FS // embedded web bundle, may be nil in dev - sessions *sessionStore - loginLimiter *attemptLimiter - wsRejectLimiter *attemptLimiter - loggedOrigins sync.Map // origin -> struct{}; dedupes CORS-rejection logs -} - -func init() { - // Go's builtin MIME table lacks web-font types, and static assets are served - // with X-Content-Type-Options: nosniff, so register them explicitly. The web - // bundle self-hosts Excalidraw's woff2 fonts under /excalidraw-assets/. - _ = mime.AddExtensionType(".woff2", "font/woff2") - _ = mime.AddExtensionType(".woff", "font/woff") - _ = mime.AddExtensionType(".ttf", "font/ttf") - _ = mime.AddExtensionType(".otf", "font/otf") -} - -func New(v *vault.Vault, w *watcher.Watcher, static fs.FS, cfg config.Config) *Server { - // Opt-in: persist browser sessions next to the host config so they survive a - // restart. Off by default — an empty path keeps the store purely in-memory. - sessionsPath := "" - if cfg.PersistSessions { - sessionsPath = config.SessionsPath() - } - return &Server{ - Vault: v, - Watcher: w, - Static: static, - Config: cfg, - sessions: newSessionStore(sessionsPath), - loginLimiter: newAttemptLimiter(10*time.Minute, 10), - wsRejectLimiter: newAttemptLimiter(1*time.Minute, 20), - } -} - -func (s *Server) currentVault() *vault.Vault { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Vault -} - -func (s *Server) currentWatcher() *watcher.Watcher { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Watcher -} - -func (s *Server) currentConfig() config.Config { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Config -} - -func (s *Server) switchVaultRoot(nextPath string) (*vault.Vault, error) { - cfg := s.currentConfig() - nextVault, err := vault.New(nextPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - return nil, err - } - // Non-fatal: a vault switch must not fail just because inotify is - // unavailable; fall back to a no-op watcher in that case. (#179) - nextWatcher := watcher.StartOrDisabled(nextVault.Root(), cfg.DisableWatcher) - - s.mu.Lock() - prevWatcher := s.Watcher - s.Vault = nextVault - s.Watcher = nextWatcher - s.Config.VaultPath = nextVault.Root() - cfg = s.Config - s.mu.Unlock() - - if prevWatcher != nil { - prevWatcher.Close() - } - _ = config.SaveHost(cfg) - return nextVault, nil -} - -func (s *Server) Router() http.Handler { - inner := chi.NewRouter() - inner.Route("/api", func(r chi.Router) { - r.Get("/healthz", s.healthz) - r.Get("/version", s.version) - r.Get("/capabilities", s.capabilities) - r.Get("/platform", s.platform) - r.Get("/session", s.sessionStatus) - r.Post("/session/login", s.sessionLogin) - r.Post("/session/logout", s.sessionLogout) - - r.Group(func(r chi.Router) { - r.Use(s.requireAuth) - s.registerProtectedRoutes(r) - }) - }) - - // Legacy root-level API compatibility. Keep this around so the web client - // still works during partial restarts or when an older bundle is cached. - inner.Get("/healthz", s.healthz) - inner.Get("/version", s.version) - inner.Get("/capabilities", s.capabilities) - inner.Get("/platform", s.platform) - inner.Get("/session", s.sessionStatus) - inner.Post("/session/login", s.sessionLogin) - inner.Post("/session/logout", s.sessionLogout) - inner.Group(func(r chi.Router) { - r.Use(s.requireAuth) - s.registerProtectedRoutes(r) - }) - - // Static / PWA fallback. - if s.Static != nil { - inner.Get("/*", s.serveStatic) - } - - outer := chi.NewRouter() - outer.Use(middleware.RequestID) - // Intentionally not using middleware.RealIP: it rewrites - // r.RemoteAddr from X-Forwarded-For unconditionally, which would - // let any client spoof the rate-limit and audit identity. - // clientAddressKey() does trust-aware extraction instead. - outer.Use(s.securityHeadersMiddleware) - outer.Use(s.corsMiddleware) - outer.Use(middleware.Recoverer) - - basePath := s.currentConfig().BasePath - if basePath != "" { - outer.Mount(basePath, inner) - return outer - } - outer.Mount("/", inner) - return outer -} - -func (s *Server) registerProtectedRoutes(r chi.Router) { - r.Post("/session/rotate-token", s.sessionRotateToken) - - r.Get("/vault", s.vaultInfo) - r.Get("/vault/settings", s.vaultSettings) - r.Post("/vault/settings", s.setVaultSettings) - r.Post("/vault/select", s.selectVault) - r.Get("/fs/browse", s.browseDirectories) - - r.Get("/notes", s.listNotes) - r.Get("/folders", s.listFolders) - r.Get("/assets", s.listAssets) - r.Get("/assets/exists", s.assetsExists) - r.Get("/assets/raw", s.rawAsset) - r.Post("/assets/upload", s.uploadAsset) - r.Post("/assets/rename", s.renameAsset) - r.Post("/assets/move", s.moveAsset) - r.Post("/assets/duplicate", s.duplicateAsset) - r.Post("/assets/delete", s.deleteAsset) - r.Get("/assets/deleted", s.listDeletedAssets) - r.Post("/assets/restore", s.restoreDeletedAsset) - r.Post("/assets/purge", s.purgeDeletedAsset) - r.Post("/assets/empty-deleted", s.emptyDeletedAssets) - - r.Get("/notes/read", s.readNote) - r.Get("/comments/read", s.readComments) - r.Post("/comments/write", s.writeComments) - r.Post("/notes/write", s.writeNote) - r.Post("/notes/create", s.createNote) - r.Post("/excalidraw/create", s.createExcalidraw) - r.Post("/notes/rename", s.renameNote) - r.Post("/notes/delete", s.deleteNote) - r.Post("/notes/trash", s.trashNote) - r.Post("/notes/restore", s.restoreNote) - r.Post("/notes/empty-trash", s.emptyTrash) - r.Post("/notes/archive", s.archiveNote) - r.Post("/notes/unarchive", s.unarchiveNote) - r.Post("/notes/duplicate", s.duplicateNote) - r.Post("/notes/move", s.moveNote) - - r.Post("/folders/create", s.createFolder) - r.Post("/folders/rename", s.renameFolder) - r.Post("/folders/delete", s.deleteFolder) - r.Post("/folders/duplicate", s.duplicateFolder) - - r.Get("/templates", s.listTemplates) - r.Get("/templates/read", s.readTemplate) - r.Post("/templates/write", s.writeTemplate) - r.Post("/templates/delete", s.deleteTemplate) - - r.Get("/search/capabilities", s.searchCapabilities) - r.Get("/search/text", s.searchText) - - r.Get("/tasks", s.allTasks) - r.Get("/tasks/for", s.tasksFor) - - r.Post("/demo/generate", s.demoGenerate) - r.Post("/demo/remove", s.demoRemove) - - r.Get("/workflows", s.listWorkflows) - r.Post("/workflows/write", s.writeWorkflow) - r.Post("/workflows/delete", s.deleteWorkflow) - r.Post("/workflows/apply", s.applyWorkflow) - r.Post("/workflows/undo", s.undoWorkflowRun) - r.Get("/workflows/runs", s.listWorkflowRuns) - r.Post("/workflows/runs/delete", s.deleteWorkflowRuns) - - r.Get("/watch", s.watchWS) -} - -func platformName() string { - switch runtime.GOOS { - case "darwin": - return "darwin" - case "windows": - return "win32" - default: - return "linux" - } -} - -func (s *Server) requireAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - expected := strings.TrimSpace(cfg.AuthToken) - if expected == "" { - next.ServeHTTP(w, r) - return - } - - provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if subtleCompare(provided, expected) || s.requestAuthenticatedViaSession(r) { - next.ServeHTTP(w, r) - return - } - - if strings.HasSuffix(r.URL.Path, "/watch") || r.URL.Path == "/watch" { - if !s.wsRejectLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many unauthorized websocket attempts", http.StatusTooManyRequests) - return - } - } - - w.Header().Set("WWW-Authenticate", `Bearer realm="ZenNotes"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) - }) -} - -// --- Responses --- - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, err error) { - var statusErr httpStatusError - if errors.As(err, &statusErr) { - http.Error(w, statusErr.Error(), statusErr.code) - return - } - if errors.Is(err, vault.ErrPathEscape) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if errors.Is(err, vault.ErrInvalidWorkflow) || errors.Is(err, vault.ErrInvalidTemplate) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if errors.Is(err, vault.ErrWorkflowConflict) { - http.Error(w, err.Error(), http.StatusConflict) - return - } - // A missing file is the caller's answer, not our failure. Clients rely on - // this to tell "absent" apart from "broken": desktop remote databases map - // 404 to null and surface everything else. - if errors.Is(err, os.ErrNotExist) { - http.Error(w, "not found", http.StatusNotFound) - return - } - // Asking to read a directory as a file is a malformed request, not a - // server failure, and saying 500 sent one report chasing a broken server - // that was working correctly. - // - // The vault layer classifies this from its own stat, because the errno is - // not portable: reading a directory gives EISDIR on Unix and - // ERROR_INVALID_FUNCTION on Windows, which is how this answered 400 on - // macOS and Linux and 500 on Windows for two weeks. EISDIR stays as a - // fallback for read paths that have not been classified, where it is still - // right on the platforms that produce it. - if errors.Is(err, vault.ErrIsDirectory) || errors.Is(err, syscall.EISDIR) { - http.Error(w, "path is a directory, not a file", http.StatusBadRequest) - return - } - log.Printf("handler error: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) -} - -// Error body for the vault-picker routes (/fs/browse, /vault/select). -// -// Every other route answers an error with plain text, which is fine because -// nothing has to tell those errors apart from anything else. These two do: a -// server that predates the routes answers with the router's own plain-text -// 404, and a current server answers a vanished directory with a 404 of its -// own. Only the JSON body distinguishes them, so the web client shows "that -// directory is gone" instead of "upgrade your server" (and vice versa). -type routeErrorBody struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func writeCodedError(w http.ResponseWriter, err error) { - status := http.StatusInternalServerError - message := "internal server error" - var statusErr httpStatusError - switch { - case errors.As(err, &statusErr): - status, message = statusErr.code, statusErr.Error() - case errors.Is(err, vault.ErrPathEscape): - status, message = http.StatusBadRequest, err.Error() - case errors.Is(err, os.ErrNotExist): - status, message = http.StatusNotFound, err.Error() - default: - log.Printf("handler error: %v", err) - } - writeJSON(w, status, routeErrorBody{Code: errorCodeForStatus(status), Message: message}) -} - -func errorCodeForStatus(status int) string { - switch status { - case http.StatusNotFound: - return "not_found" - case http.StatusForbidden: - return "forbidden" - case http.StatusBadRequest: - return "bad_request" - case http.StatusConflict: - return "conflict" - default: - return "internal_error" - } -} - -func readJSON[T any](r *http.Request, out *T) error { - return json.NewDecoder(r.Body).Decode(out) -} - -// --- Handlers: meta --- - -func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) version(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "version": "0.1.0-web", - "go": runtime.Version(), - }) -} - -func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) { - cfg := s.currentConfig() - writeJSON(w, http.StatusOK, map[string]any{ - "version": "0.1.0-web", - "platform": platformName(), - "authRequired": strings.TrimSpace(cfg.AuthToken) != "", - "supportsSessionLogin": true, - "browseRootsEnforced": !cfg.AllowUnscopedBrowse, - "supportsVaultSelection": true, - "supportsDirectoryBrowsing": true, - // Honest, not aspirational: the watcher can be a no-op fallback - // (inotify-restricted hosts, ZENNOTES_DISABLE_WATCHER, #179), and a - // client that believes a dead feed never refreshes on its own. - "supportsWatch": s.currentWatcher().Active(), - // The full asset mutation family incl. the deleted-assets store - // (delete/duplicate/restore/purge). Desktop remote workspaces gate - // on this to give older servers a "server needs an update" message - // instead of a bare 404. - "supportsAssetOps": true, - // Workflow files and run journals live in the mounted vault, and the - // prepared-run endpoint applies them under the same vault lock as note - // writes. Its presence lets bundled web clients enable authoring and Run. - "supportsWorkflows": true, - // Custom-template CRUD under .zennotes/templates/ (the /templates - // routes), the same files the desktop keeps for a local vault. Absent - // before 2.46: the web client and a desktop on a remote vault hide New - // template and Edit there and say the server needs an update. - "supportsCustomTemplates": true, - // Says out loud that a missing file answers 404 rather than 500. - // Databases are composed from file reads where "absent" and "failed" - // mean opposite things (see remote-absence.ts), and a server that - // cannot say which is which forced clients to probe for the answer. - // Absent from every server before 2.20.2, which is exactly what makes - // it usable as a signal. - "reportsMissingAsNotFound": true, - }) -} - -func (s *Server) platform(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"platform": platformName()}) -} - -func (s *Server) vaultInfo(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.currentVault().Info()) -} - -func (s *Server) vaultSettings(w http.ResponseWriter, _ *http.Request) { - settings, err := s.currentVault().GetSettings() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, settings) -} - -func (s *Server) setVaultSettings(w http.ResponseWriter, r *http.Request) { - var req vault.VaultSettings - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - settings, err := s.currentVault().SetSettings(req) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, settings) -} - -func (s *Server) selectVault(w http.ResponseWriter, r *http.Request) { - if osPath := strings.TrimSpace(os.Getenv("ZENNOTES_VAULT_PATH")); osPath != "" { - http.Error(w, "vault path is managed by ZENNOTES_VAULT_PATH", http.StatusConflict) - return - } - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if strings.TrimSpace(req.Path) == "" { - http.Error(w, "vault path is required", http.StatusBadRequest) - return - } - allowedPath, err := s.ensureBrowsePathAllowed(req.Path) - if err != nil { - writeCodedError(w, err) - return - } - nextVault, err := s.switchVaultRoot(allowedPath) - if err != nil { - writeCodedError(w, err) - return - } - writeJSON(w, http.StatusOK, nextVault.Info()) -} - -type directoryBrowseEntry struct { - Name string `json:"name"` - Path string `json:"path"` -} - -type directoryBrowseShortcut struct { - Label string `json:"label"` - Path string `json:"path"` -} - -type directoryBrowseResult struct { - CurrentPath string `json:"currentPath"` - ParentPath *string `json:"parentPath"` - Entries []directoryBrowseEntry `json:"entries"` - Shortcuts []directoryBrowseShortcut `json:"shortcuts"` -} - -func appendBrowseShortcut(shortcuts []directoryBrowseShortcut, label string, path string) []directoryBrowseShortcut { - cleaned := strings.TrimSpace(path) - if cleaned == "" { - return shortcuts - } - for _, shortcut := range shortcuts { - if shortcut.Path == cleaned { - return shortcuts - } - } - if info, err := os.Stat(cleaned); err != nil || !info.IsDir() { - return shortcuts - } - return append(shortcuts, directoryBrowseShortcut{Label: label, Path: cleaned}) -} - -func browseRootLabel(path string, index int) string { - cleaned := filepath.Clean(path) - root := filesystemRootForPath(cleaned) - if cleaned == root { - return "Mounted Root" - } - base := filepath.Base(cleaned) - if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" { - return "Mounted Root" - } - if index == 0 { - return base - } - return base -} - -func defaultBrowsePath() string { - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return home - } - if runtime.GOOS == "windows" { - return `C:\` - } - return string(filepath.Separator) -} - -func filesystemRootForPath(p string) string { - if volume := filepath.VolumeName(p); volume != "" { - return volume + string(filepath.Separator) - } - return string(filepath.Separator) -} - -func (s *Server) browseDirectories(w http.ResponseWriter, r *http.Request) { - requested := strings.TrimSpace(r.URL.Query().Get("path")) - target := requested - if target == "" { - target = s.defaultBrowsePath() - } - target, err := s.ensureBrowsePathAllowed(target) - if err != nil { - writeCodedError(w, err) - return - } - - dirEntries, err := os.ReadDir(target) - if err != nil { - writeCodedError(w, err) - return - } - - entries := make([]directoryBrowseEntry, 0, len(dirEntries)) - for _, entry := range dirEntries { - childPath := filepath.Join(target, entry.Name()) - childInfo, err := os.Stat(childPath) - if err != nil || !childInfo.IsDir() { - continue - } - if _, err := s.ensureBrowsePathAllowed(childPath); err != nil { - continue - } - entries = append(entries, directoryBrowseEntry{ - Name: entry.Name(), - Path: childPath, - }) - } - sort.Slice(entries, func(i, j int) bool { - left := strings.ToLower(entries[i].Name) - right := strings.ToLower(entries[j].Name) - if left == right { - return entries[i].Name < entries[j].Name - } - return left < right - }) - - parentPath := filepath.Dir(target) - var parent *string - if parentPath != "" && parentPath != target { - if allowedParent, err := s.ensureBrowsePathAllowed(parentPath); err == nil { - parent = &allowedParent - } - } - - writeJSON(w, http.StatusOK, directoryBrowseResult{ - CurrentPath: target, - ParentPath: parent, - Entries: entries, - Shortcuts: s.browseShortcuts(), - }) -} - -// --- Listing --- - -func (s *Server) listNotes(w http.ResponseWriter, _ *http.Request) { - notes, err := s.currentVault().ListNotes() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, notes) -} - -func (s *Server) listFolders(w http.ResponseWriter, _ *http.Request) { - folders, err := s.currentVault().ListFolders() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, folders) -} - -func (s *Server) listAssets(w http.ResponseWriter, _ *http.Request) { - assets, err := s.currentVault().ListAssets() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, assets) -} - -func (s *Server) assetsExists(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]bool{"exists": s.currentVault().HasAssetsDir()}) -} - -// --- Notes --- - -func (s *Server) readNote(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - note, err := s.currentVault().ReadNote(rel) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, note) -} - -func (s *Server) readComments(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - comments, err := s.currentVault().ReadNoteComments(rel) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, comments) -} - -func (s *Server) writeComments(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Comments []vault.NoteComment `json:"comments"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - comments, err := s.currentVault().WriteNoteComments(req.Path, req.Comments) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, comments) -} - -func (s *Server) writeNote(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var req struct { - Path string `json:"path"` - Body string `json:"body"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().WriteNote(req.Path, req.Body) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Title string `json:"title"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().CreateNote(req.Folder, req.Title, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) createExcalidraw(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Title string `json:"title"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().CreateExcalidraw(req.Folder, req.Title, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) renameNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Title string `json:"title"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RenameNote(req.Path, req.Title) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteNote(req.Path); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) trashNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveToTrash(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RestoreFromTrash(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) emptyTrash(w http.ResponseWriter, _ *http.Request) { - if err := s.currentVault().EmptyTrash(); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) archiveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().ArchiveNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) unarchiveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().UnarchiveNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) duplicateNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().DuplicateNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) moveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - TargetFolder vault.NoteFolder `json:"targetFolder"` - TargetSubpath string `json:"targetSubpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveNote(req.Path, req.TargetFolder, req.TargetSubpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -// --- Folders --- - -func (s *Server) createFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().CreateFolder(req.Folder, req.Subpath); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) renameFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - OldSubpath string `json:"oldSubpath"` - NewSubpath string `json:"newSubpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - out, err := s.currentVault().RenameFolder(req.Folder, req.OldSubpath, req.NewSubpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) -} - -func (s *Server) deleteFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteFolder(req.Folder, req.Subpath); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) duplicateFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - out, err := s.currentVault().DuplicateFolder(req.Folder, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) -} - -// --- Tasks + Search --- - -func (s *Server) allTasks(w http.ResponseWriter, r *http.Request) { - tasks, err := s.currentVault().ScanTasksWith(vault.ParseTasksOptions{ - IncludeExcluded: taskQueryIncludesExcluded(r), - }) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, tasks) -} - -func (s *Server) tasksFor(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - tasks, err := s.currentVault().ScanTasksForPathWith(rel, vault.ParseTasksOptions{ - IncludeExcluded: taskQueryIncludesExcluded(r), - }) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, tasks) -} - -// taskQueryIncludesExcluded reads the ?includeExcluded= escape hatch (#458): -// scan past the vault's excluded-folders list and the note-level `tasks:` -// opt-out. Accepts the same truthy spellings the config loader does. -func taskQueryIncludesExcluded(r *http.Request) bool { - switch strings.ToLower(r.URL.Query().Get("includeExcluded")) { - case "1", "true", "yes", "on": - return true - } - return false -} - -func (s *Server) searchCapabilities(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.currentVault().SearchCapabilities()) -} - -func (s *Server) searchText(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query().Get("q") - matches, err := s.currentVault().SearchText(q) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, matches) -} - -// --- Demo tour --- - -func (s *Server) demoGenerate(w http.ResponseWriter, _ *http.Request) { - res, err := s.currentVault().GenerateDemoTour() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, res) -} - -func (s *Server) demoRemove(w http.ResponseWriter, _ *http.Request) { - res, err := s.currentVault().RemoveDemoTour() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, res) -} - -// --- Assets --- - -func (s *Server) rawAsset(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - abs, err := s.currentVault().AssetAbsPath(rel) - if err != nil { - writeError(w, err) - return - } - ext := strings.ToLower(filepath.Ext(abs)) - if t := mime.TypeByExtension(ext); t != "" { - w.Header().Set("Content-Type", t) - } - w.Header().Set("Cache-Control", "private, max-age=3600") - http.ServeFile(w, r, abs) -} - -func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxAssetBytes+multipartOverheadBytes) - if err := r.ParseMultipartForm(8 << 20); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - notePath := r.FormValue("notePath") - file, header, err := r.FormFile("file") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - defer file.Close() - asset, err := s.currentVault().ImportAsset(notePath, header.Filename, file) - if err != nil { - if errors.Is(err, vault.ErrAssetTooLarge) { - http.Error(w, "asset too large", http.StatusRequestEntityTooLarge) - return - } - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, asset) -} - -func (s *Server) renameAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Name string `json:"name"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RenameAsset(req.Path, req.Name) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) moveAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - TargetDir string `json:"targetDir"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveAsset(req.Path, req.TargetDir) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) duplicateAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().DuplicateAsset(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - deleted, err := s.currentVault().DeleteAsset(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, deleted) -} - -func (s *Server) listDeletedAssets(w http.ResponseWriter, _ *http.Request) { - deleted, err := s.currentVault().ListDeletedAssets() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, deleted) -} - -func (s *Server) restoreDeletedAsset(w http.ResponseWriter, r *http.Request) { - var req vault.DeletedAsset - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RestoreDeletedAsset(req) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) purgeDeletedAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - UndoToken string `json:"undoToken"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().PurgeDeletedAsset(req.UndoToken); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) emptyDeletedAssets(w http.ResponseWriter, _ *http.Request) { - if err := s.currentVault().EmptyDeletedAssets(); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// --- WebSocket watcher --- - -// watchPingInterval is how often watchWS pings a subscriber to detect a dead -// peer. A var, not a const, so the regression test can shrink it and prove -// events survive ping cycles without waiting out real 25-second ticks. -var watchPingInterval = 25 * time.Second - -func (s *Server) watchWS(w http.ResponseWriter, r *http.Request) { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin != "" && !s.isAllowedOrigin(r, origin) { - if !s.wsRejectLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many invalid websocket origins", http.StatusTooManyRequests) - return - } - http.Error(w, "forbidden origin", http.StatusForbidden) - return - } - ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - log.Printf("ws accept failed: %v", err) - return - } - defer ws.Close(websocket.StatusNormalClosure, "") - // This connection is write-only, but the library only processes incoming - // control frames during a read. Without CloseRead the client's pong is - // never seen, so the first keepalive Ping below blocked forever and the - // subscriber went silent 25 seconds after connecting — the "changes don't - // appear until I refresh" report in the flesh. CloseRead spawns the reader - // that keeps pings honest and cancels the context when the peer goes away. - ctx := ws.CloseRead(r.Context()) - - events, unsubscribe := s.currentWatcher().Subscribe() - defer unsubscribe() - - pingTicker := time.NewTicker(watchPingInterval) - defer pingTicker.Stop() - - for { - select { - case <-ctx.Done(): - return - case ev, ok := <-events: - if !ok { - return - } - payload, _ := json.Marshal(ev) - if err := ws.Write(ctx, websocket.MessageText, payload); err != nil { - return - } - case <-pingTicker.C: - if err := ws.Ping(ctx); err != nil { - return - } - } - } -} - -// --- Static / PWA fallback --- - -func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { - // chi's Mount routes by a stripped path but leaves r.URL.Path intact, - // so under a base-path deploy this still carries the prefix (e.g. - // "/zennotes/assets/app.css"). Trim it before resolving against the - // embedded bundle, otherwise every asset misses and falls back to - // index.html with a text/html MIME type (issue #58). - urlPath := r.URL.Path - if basePath := s.currentConfig().BasePath; basePath != "" { - urlPath = strings.TrimPrefix(urlPath, basePath) - } - urlPath = strings.TrimPrefix(urlPath, "/") - if urlPath == "" { - urlPath = "index.html" - } - f, err := s.Static.Open(urlPath) - if err != nil { - // SPA fallback: serve index.html for unknown paths. - s.serveIndexHTML(w) - return - } - defer f.Close() - if urlPath == "index.html" { - s.serveIndexHTML(w) - return - } - ext := strings.ToLower(filepath.Ext(urlPath)) - if t := mime.TypeByExtension(ext); t != "" { - w.Header().Set("Content-Type", t) - } - _, _ = copyReadSeeker(w, f) -} - -// serveIndexHTML reads the SPA shell from the embedded bundle and -// returns it with a small runtime patch so the JS bundle knows which -// base path to use for API + WebSocket calls. -func (s *Server) serveIndexHTML(w http.ResponseWriter) { - f, err := s.Static.Open("index.html") - if err != nil { - http.NotFound(w, nil) - return - } - defer f.Close() - - body, err := readAll(f) - if err != nil { - http.NotFound(w, nil) - return - } - - basePath := s.currentConfig().BasePath - if basePath != "" { - body = injectBasePathHint(body, basePath) - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - _, _ = w.Write(body) -} - -func readAll(f fs.File) ([]byte, error) { - buf := make([]byte, 0, 4*1024) - tmp := make([]byte, 4*1024) - for { - n, err := f.Read(tmp) - if n > 0 { - buf = append(buf, tmp[:n]...) - } - if err != nil { - if err.Error() == "EOF" { - return buf, nil - } - return nil, err - } - } -} - -// injectBasePathHint splices a `` tag into -// the SPA shell so the bundled JS can route API calls through the -// configured prefix. A meta tag (instead of an inline script) keeps us -// inside the strict CSP — script-src is locked to 'self'. -func injectBasePathHint(body []byte, basePath string) []byte { - snippet := []byte(``) - if idx := indexOfFold(body, []byte("")); idx >= 0 { - out := make([]byte, 0, len(body)+len(snippet)) - out = append(out, body[:idx]...) - out = append(out, snippet...) - out = append(out, body[idx:]...) - return out - } - return append(snippet, body...) -} - -// htmlAttrEscape escapes the characters that would let a base-path -// value break out of a double-quoted HTML attribute. -func htmlAttrEscape(value string) string { - replacer := strings.NewReplacer( - "&", "&", - "\"", """, - "<", "<", - ">", ">", - ) - return replacer.Replace(value) -} - -func indexOfFold(haystack, needle []byte) int { - n := len(needle) - if n == 0 || n > len(haystack) { - return -1 - } - for i := 0; i+n <= len(haystack); i++ { - match := true - for j := 0; j < n; j++ { - a := haystack[i+j] - b := needle[j] - if a >= 'A' && a <= 'Z' { - a += 'a' - 'A' - } - if b >= 'A' && b <= 'Z' { - b += 'a' - 'A' - } - if a != b { - match = false - break - } - } - if match { - return i - } - } - return -1 -} - -func copyReadSeeker(w http.ResponseWriter, f fs.File) (int64, error) { - if rs, ok := f.(interface { - Read(p []byte) (int, error) - }); ok { - buf := make([]byte, 32*1024) - var total int64 - for { - n, err := rs.Read(buf) - if n > 0 { - if _, werr := w.Write(buf[:n]); werr != nil { - return total, werr - } - total += int64(n) - } - if err != nil { - if err.Error() == "EOF" { - return total, nil - } - return total, err - } - } - } - return 0, nil -} diff --git a/apps/server/internal/httpserver/templates.go b/apps/server/internal/httpserver/templates.go deleted file mode 100644 index 3ba65fd4..00000000 --- a/apps/server/internal/httpserver/templates.go +++ /dev/null @@ -1,77 +0,0 @@ -package httpserver - -import ( - "errors" - "net/http" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -// Custom-template routes: the server half of Settings, Templates for the web -// client and for a desktop connected to a remote vault. Clients gate on the -// supportsCustomTemplates capability, so an older server answers a bare 404 -// here and they say the server needs an update instead. - -const maxTemplateMetadataRequestBytes = 64 << 10 - -func (s *Server) listTemplates(w http.ResponseWriter, _ *http.Request) { - files, err := s.currentVault().ListTemplates() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, files) -} - -func (s *Server) readTemplate(w http.ResponseWriter, r *http.Request) { - raw, err := s.currentVault().ReadTemplate(r.URL.Query().Get("path")) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"raw": raw}) -} - -func (s *Server) writeTemplate(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var input vault.WriteTemplateInput - if err := readJSON(r, &input); err != nil { - var tooLarge *http.MaxBytesError - if errors.As(err, &tooLarge) { - http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - // The envelope allowance above is for field names and JSON escaping, not - // for the template: a body that fits the reader can still unescape to a - // raw string past the note limit, and a template is a note in waiting. - if cfg.MaxNoteBytes > 0 && int64(len(input.Raw)) > cfg.MaxNoteBytes { - http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - file, err := s.currentVault().WriteTemplate(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, file) -} - -func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxTemplateMetadataRequestBytes) - var request struct { - SourcePath string `json:"sourcePath"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteTemplate(request.SourcePath); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} diff --git a/apps/server/internal/httpserver/templates_test.go b/apps/server/internal/httpserver/templates_test.go deleted file mode 100644 index 9a6c4266..00000000 --- a/apps/server/internal/httpserver/templates_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -const templateTestToken = "template-token" - -func templateTestServer(t *testing.T, maxNoteBytes int64) (*httptest.Server, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "Note.md"), []byte("# Note\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: templateTestToken, - BrowseRoots: []string{root}, - MaxNoteBytes: maxNoteBytes, - }) - return server, root -} - -func templateRequest(t *testing.T, method, url string, body any, token string) *http.Response { - t.Helper() - var reader io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - reader = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, url, reader) - if err != nil { - t.Fatal(err) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("%s %s: %v", method, url, err) - } - return resp -} - -func decodeBody[T any](t *testing.T, resp *http.Response) T { - t.Helper() - defer resp.Body.Close() - var out T - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - t.Fatalf("decode: %v", err) - } - return out -} - -func TestTemplateRoutesRequireAuth(t *testing.T) { - server, _ := templateTestServer(t, 10<<20) - for _, route := range []struct{ method, path string }{ - {http.MethodGet, "/api/templates"}, - {http.MethodGet, "/api/templates/read?path=.zennotes/templates/adr.md"}, - {http.MethodPost, "/api/templates/write"}, - {http.MethodPost, "/api/templates/delete"}, - } { - resp := templateRequest(t, route.method, server.URL+route.path, map[string]string{}, "") - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("%s %s without a token: %d, want 401", route.method, route.path, resp.StatusCode) - } - } - resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken) - if resp.StatusCode != http.StatusOK { - t.Fatalf("list with a token: %d", resp.StatusCode) - } - if files := decodeBody[[]vault.CustomTemplateFile](t, resp); len(files) != 0 { - t.Fatalf("fresh vault lists %+v", files) - } -} - -func TestTemplateRoutesWriteListReadDelete(t *testing.T) { - server, root := templateTestServer(t, 10<<20) - raw := "---\nname: Weekly\n---\n# {{title}}\n" - - resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "Réunion Hebdo!", "raw": raw, - }, templateTestToken) - if resp.StatusCode != http.StatusOK { - t.Fatalf("write: %d", resp.StatusCode) - } - written := decodeBody[vault.CustomTemplateFile](t, resp) - if written.SourcePath != ".zennotes/templates/r-union-hebdo.md" || written.Raw != raw { - t.Fatalf("written = %+v", written) - } - if body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); err != nil || string(body) != raw { - t.Fatalf("file on disk: %q (%v)", body, err) - } - - files := decodeBody[[]vault.CustomTemplateFile](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken)) - if len(files) != 1 || files[0].SourcePath != written.SourcePath || files[0].Raw != raw { - t.Fatalf("list = %+v", files) - } - - read := decodeBody[map[string]string](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+written.SourcePath, nil, templateTestToken)) - if read["raw"] != raw { - t.Fatalf("read = %+v", read) - } - - renamed := decodeBody[vault.CustomTemplateFile](t, templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "weekly", "raw": raw + "\nmore", "previousSourcePath": written.SourcePath, - }, templateTestToken)) - if renamed.SourcePath != ".zennotes/templates/weekly.md" { - t.Fatalf("renamed = %+v", renamed) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); !os.IsNotExist(err) { - t.Fatalf("rename left the old file: %v", err) - } - - del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": renamed.SourcePath}, templateTestToken) - del.Body.Close() - if del.StatusCode != http.StatusOK { - t.Fatalf("delete: %d", del.StatusCode) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "weekly.md")); !os.IsNotExist(err) { - t.Fatalf("delete left the file: %v", err) - } - gone := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+renamed.SourcePath, nil, templateTestToken) - gone.Body.Close() - if gone.StatusCode != http.StatusNotFound { - t.Fatalf("read after delete: %d, want 404", gone.StatusCode) - } -} - -func TestTemplateRoutesRejectUnsafePaths(t *testing.T) { - server, root := templateTestServer(t, 10<<20) - for _, path := range []string{ - "../../etc/passwd", - ".zennotes/templates/../../inbox/Note.md", - ".zennotes/templates/sub/dir.md", - ".zennotes/templates/not-markdown.txt", - "inbox/Note.md", - } { - resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+path, nil, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("read %q: %d, want 400", path, resp.StatusCode) - } - del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": path}, templateTestToken) - del.Body.Close() - if del.StatusCode != http.StatusBadRequest { - t.Errorf("delete %q: %d, want 400", path, del.StatusCode) - } - } - if _, err := os.Stat(filepath.Join(root, "inbox", "Note.md")); err != nil { - t.Fatalf("a template delete reached a note: %v", err) - } -} - -func TestTemplateWriteRespectsNoteSizeLimit(t *testing.T) { - server, root := templateTestServer(t, 64) - // Past the note limit but inside the JSON envelope allowance: the explicit - // check answers. - resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "big", "raw": strings.Repeat("x", 200), - }, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - t.Fatalf("200-byte template with a 64-byte limit: %d, want 413", resp.StatusCode) - } - // Past the reader itself. - resp = templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "huge", "raw": strings.Repeat("x", 70<<10), - }, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - t.Fatalf("70 KiB template with a 64-byte limit: %d, want 413", resp.StatusCode) - } - if entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")); len(entries) != 0 { - t.Fatalf("rejected writes left files: %v", entries) - } - ok := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "small", "raw": "fits", - }, templateTestToken) - ok.Body.Close() - if ok.StatusCode != http.StatusOK { - t.Fatalf("small template: %d", ok.StatusCode) - } -} - -func TestCapabilitiesAdvertiseCustomTemplateSupport(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatal(err) - } - caps := decodeBody[map[string]any](t, resp) - if caps["supportsCustomTemplates"] != true { - t.Fatalf("supportsCustomTemplates = %v, want true", caps["supportsCustomTemplates"]) - } -} diff --git a/apps/server/internal/httpserver/testdata/self-hosted-http.json b/apps/server/internal/httpserver/testdata/self-hosted-http.json deleted file mode 100644 index d799b7b7..00000000 --- a/apps/server/internal/httpserver/testdata/self-hosted-http.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "schemaVersion": 1, - "protocol": "self-hosted-http-v1", - "mountPaths": [ - "", - "/notes" - ], - "note": { - "path": "inbox/Contract.md", - "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", - "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", - "assetEmbeds": [ - "photo.png", - "assets/document.pdf" - ], - "updatedAssetEmbeds": [ - "assets/photo two.png" - ] - }, - "requiredNoteFields": [ - "path", - "title", - "folder", - "siblingOrder", - "createdAt", - "updatedAt", - "size", - "tags", - "wikilinks", - "assetEmbeds", - "hasAttachments", - "excerpt" - ], - "requiredCapabilities": [ - "version", - "platform", - "authRequired", - "supportsSessionLogin", - "browseRootsEnforced", - "supportsVaultSelection", - "supportsDirectoryBrowsing", - "supportsWatch", - "reportsMissingAsNotFound", - "supportsAssetOps", - "supportsWorkflows", - "supportsCustomTemplates" - ], - "errors": { - "unauthenticated": 401, - "challenge": "Bearer realm=\"ZenNotes\"", - "missingNote": 404, - "directoryAsNote": 400 - }, - "routePrefixes": [ - "/api", - "" - ] -} diff --git a/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json b/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json deleted file mode 100644 index 096d2fe5..00000000 --- a/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sourceRepository": "https://github.com/ZenNotes/zennotes", - "sourcePath": "packages/bridge-contract/fixtures/self-hosted-http.json", - "sha256": "a52743639aa8641ac206a8a887874e7d6034828118725fb89502a661c5890437" -} diff --git a/apps/server/internal/httpserver/watch_ws_test.go b/apps/server/internal/httpserver/watch_ws_test.go deleted file mode 100644 index 9715e21c..00000000 --- a/apps/server/internal/httpserver/watch_ws_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package httpserver - -import ( - "context" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/coder/websocket" - "net/http/httptest" -) - -// TestWatchSubscriberSurvivesPingCycles is the regression test for the wedge -// that produced "changes don't appear until I refresh": watchWS never read -// from the connection, so the client's pong was never processed and the first -// keepalive Ping blocked forever — every subscriber went silent 25 seconds -// after connecting while the connection stayed ESTABLISHED. Here the ping -// interval is shrunk so several cycles pass in milliseconds, then a file -// change must still reach the subscriber. -func TestWatchSubscriberSurvivesPingCycles(t *testing.T) { - oldInterval := watchPingInterval - watchPingInterval = 30 * time.Millisecond - defer func() { watchPingInterval = oldInterval }() - - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - v, err := vault.New(root, vault.Options{}) - if err != nil { - t.Fatal(err) - } - w := watcher.StartOrDisabled(root, false) - if !w.Active() { - t.Skip("filesystem watching unavailable in this environment") - } - t.Cleanup(w.Close) - - cfg := config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - } - server := httptest.NewServer(New(v, w, nil, cfg).Router()) - t.Cleanup(server.Close) - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/watch" - conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ - HTTPHeader: http.Header{"Authorization": []string{"Bearer secret-token"}}, - }) - if err != nil { - t.Fatalf("dial watch socket: %v", err) - } - defer conn.Close(websocket.StatusNormalClosure, "") - - // Let well over a handful of ping cycles pass. The wedged loop hung on - // the very first one. - time.Sleep(300 * time.Millisecond) - - if err := os.WriteFile(filepath.Join(root, "inbox", "Late.md"), []byte("# Late"), 0o600); err != nil { - t.Fatal(err) - } - - // Read pumps control frames client-side too, so this both answers any - // in-flight ping and receives the change event. - _, payload, err := conn.Read(ctx) - if err != nil { - t.Fatalf("no event after ping cycles (the pre-fix wedge): %v", err) - } - if !strings.Contains(string(payload), "Late.md") { - t.Fatalf("event payload = %s, want the Late.md change", payload) - } -} diff --git a/apps/server/internal/httpserver/workflows.go b/apps/server/internal/httpserver/workflows.go deleted file mode 100644 index 06affa91..00000000 --- a/apps/server/internal/httpserver/workflows.go +++ /dev/null @@ -1,128 +0,0 @@ -package httpserver - -import ( - "net/http" - "strings" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -const ( - maxWorkflowRequestBytes = 128 << 20 - maxWorkflowMetadataRequestBytes = 64 << 10 -) - -func (s *Server) listWorkflows(w http.ResponseWriter, _ *http.Request) { - files, err := s.currentVault().ListWorkflows() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, files) -} - -func (s *Server) writeWorkflow(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var input vault.WriteWorkflowInput - if err := readJSON(r, &input); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - file, err := s.currentVault().WriteWorkflow(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, file) -} - -func (s *Server) deleteWorkflow(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - SourcePath string `json:"sourcePath"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteWorkflow(request.SourcePath); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) applyWorkflow(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowRequestBytes) - var input vault.PreparedWorkflowRun - if err := readJSON(r, &input); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - cfg := s.currentConfig() - for _, change := range input.Changes { - // Only what the run WRITES counts against the limit. Before is the - // note's bytes already on disk: counting those made an oversized note - // impossible to shrink, move, or trash from the web client, 413 on - // every apply, while desktop applied the identical run. - if cfg.MaxNoteBytes > 0 && change.After != nil && int64(len(*change.After)) > cfg.MaxNoteBytes { - http.Error(w, "workflow note exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - } - receipt, err := s.currentVault().ApplyPreparedWorkflow(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, receipt) -} - -func (s *Server) undoWorkflowRun(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - RunID string `json:"runId"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - result, err := s.currentVault().UndoWorkflowRun(request.RunID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -func (s *Server) listWorkflowRuns(w http.ResponseWriter, _ *http.Request) { - runs, err := s.currentVault().ListWorkflowRuns() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, runs) -} - -func (s *Server) deleteWorkflowRuns(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - WorkflowID string `json:"workflowId"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - workflowID := strings.TrimSpace(request.WorkflowID) - if workflowID == "" { - http.Error(w, "workflowId is required", http.StatusBadRequest) - return - } - removed, err := s.currentVault().DeleteWorkflowRuns(workflowID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, removed) -} diff --git a/apps/server/internal/httpserver/workflows_test.go b/apps/server/internal/httpserver/workflows_test.go deleted file mode 100644 index f28ab47a..00000000 --- a/apps/server/internal/httpserver/workflows_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// The Docker image serves the web client and owns the mounted vault. Workflow -// authoring, execution and Undo therefore have to cross the HTTP boundary and -// persist inside that mounted vault rather than being treated as desktop-only. -func TestWorkflowEndpointsAuthorApplyAndUndo(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - post := func(path string, payload any) *http.Response { - t.Helper() - body, err := json.Marshal(payload) - if err != nil { - t.Fatal(err) - } - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - return resp - } - requireOK := func(resp *http.Response) { - t.Helper() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - t.Fatalf("%s %s: got %d: %s", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, body) - } - } - - workflowRaw := "---\nname: Docker workflow\nstatus: active\n---\n\nall | append done\n" - writeResp := post("/api/workflows/write", map[string]any{ - "slug": "Docker workflow", - "raw": workflowRaw, - }) - requireOK(writeResp) - var written struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` - } - if err := json.NewDecoder(writeResp.Body).Decode(&written); err != nil { - t.Fatal(err) - } - writeResp.Body.Close() - if written.ID != "docker-workflow" || written.SourcePath != ".zennotes/workflows/docker-workflow.md" || written.Raw != workflowRaw { - t.Fatalf("written workflow = %+v", written) - } - - listResp, err := client.Get(server.URL + "/api/workflows") - if err != nil { - t.Fatal(err) - } - requireOK(listResp) - var listed []map[string]any - if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { - t.Fatal(err) - } - listResp.Body.Close() - if len(listed) != 1 || listed[0]["id"] != "docker-workflow" { - t.Fatalf("listed workflows = %#v", listed) - } - - applyResp := post("/api/workflows/apply", map[string]any{ - "workflowId": "docker-workflow", - "ops": []any{map[string]any{"kind": "append", "path": "inbox/A.md", "text": "done"}}, - "applied": 1, - "irreversible": 0, - "changes": []any{map[string]any{ - "path": "inbox/A.md", - "before": "# A\n", - "after": "# A\n\ndone", - }}, - }) - requireOK(applyResp) - var receipt struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - } - if err := json.NewDecoder(applyResp.Body).Decode(&receipt); err != nil { - t.Fatal(err) - } - applyResp.Body.Close() - if receipt.RunID == "" || receipt.WorkflowID != "docker-workflow" || receipt.Applied != 1 || len(receipt.Paths) != 1 { - t.Fatalf("receipt = %+v", receipt) - } - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n\ndone" { - t.Fatalf("applied note = %q, %v", body, err) - } - - runsResp, err := client.Get(server.URL + "/api/workflows/runs") - if err != nil { - t.Fatal(err) - } - requireOK(runsResp) - var runs []struct { - RunID string `json:"runId"` - Undoable bool `json:"undoable"` - } - if err := json.NewDecoder(runsResp.Body).Decode(&runs); err != nil { - t.Fatal(err) - } - runsResp.Body.Close() - if len(runs) != 1 || runs[0].RunID != receipt.RunID || !runs[0].Undoable { - t.Fatalf("runs = %+v", runs) - } - - undoResp := post("/api/workflows/undo", map[string]string{"runId": receipt.RunID}) - requireOK(undoResp) - undoResp.Body.Close() - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n" { - t.Fatalf("undone note = %q, %v", body, err) - } - - deleteResp := post("/api/workflows/delete", map[string]string{"sourcePath": written.SourcePath}) - requireOK(deleteResp) - deleteResp.Body.Close() - if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "docker-workflow.md")); !os.IsNotExist(err) { - t.Fatalf("workflow still exists after delete: %v", err) - } -} - -func TestCapabilitiesAdvertiseWorkflowSupport(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - var caps map[string]any - if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { - t.Fatal(err) - } - if caps["supportsWorkflows"] != true { - t.Fatalf("supportsWorkflows = %v, want true", caps["supportsWorkflows"]) - } -} - -func TestApplyWorkflowRespectsPerNoteSizeLimit(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - MaxNoteBytes: 8, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - body, err := json.Marshal(map[string]any{ - "workflowId": "oversized", - "ops": []any{map[string]any{"kind": "write-note", "path": "inbox/A.md", "text": "this is too large"}}, - "applied": 1, - "irreversible": 0, - "changes": []any{map[string]any{ - "path": "inbox/A.md", - "before": "# A\n", - "after": "this is too large", - }}, - }) - if err != nil { - t.Fatal(err) - } - resp, err := client.Post(server.URL+"/api/workflows/apply", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - responseBody, _ := io.ReadAll(resp.Body) - t.Fatalf("oversized workflow note: got %d: %s", resp.StatusCode, responseBody) - } - if got, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(got) != "# A\n" { - t.Fatalf("oversized workflow changed note to %q (%v)", got, err) - } -} diff --git a/apps/server/internal/vault/asset_link_rename.go b/apps/server/internal/vault/asset_link_rename.go deleted file mode 100644 index 96cbc032..00000000 --- a/apps/server/internal/vault/asset_link_rename.go +++ /dev/null @@ -1,401 +0,0 @@ -package vault - -import ( - "net/url" - "regexp" - "sort" - "strings" -) - -// Rewriting references to an asset when the asset file is renamed or moved -// (#785). Port of packages/shared-domain/src/asset-link-rename.ts and of the -// resolver in asset-path-resolution.ts: a reference resolves relative to its -// note, then to the vault root, then by unique basename (the renderer's three -// readings); the ones that resolve to the asset are re-targeted in the -// author's own style (relative stays relative, rooted stays rooted, a bare -// name stays bare while unique) and keep everything else: `|alias` / `|300` -// hints, `#page=3` fragments, percent-encoding, angle brackets, link titles. - -var ( - assetWikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]\n]+?)\]\]`) - // Matched from the `](` so the href of an image nested inside a link - // (`[![alt](a.png)](a.png)`) is found as readily as the outer link's own. - assetMdDestRe = regexp.MustCompile(`\]\(\s*(<[^>\n]*>|[^)\n]+?)((?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\s*\)`) -) - -func assetStripQueryAndHash(href string) string { - if i := strings.IndexByte(href, '#'); i >= 0 { - href = href[:i] - } - if i := strings.IndexByte(href, '?'); i >= 0 { - href = href[:i] - } - return href -} - -func assetDecodeHref(value string) string { - cleaned := assetStripQueryAndHash(value) - if decoded, err := url.PathUnescape(cleaned); err == nil { - return decoded - } - return cleaned -} - -func posixJoin(a, b string) string { - switch { - case a == "": - return b - case b == "": - return a - case strings.HasSuffix(a, "/"): - return a + b - } - return a + "/" + b -} - -func posixNormalize(input string) string { - out := []string{} - for _, part := range strings.Split(input, "/") { - switch part { - case "", ".": - continue - case "..": - if len(out) == 0 { - return ".." - } - out = out[:len(out)-1] - default: - out = append(out, part) - } - } - return strings.Join(out, "/") -} - -func lastPathSegment(p string) string { - parts := strings.Split(p, "/") - for i := len(parts) - 1; i >= 0; i-- { - if parts[i] != "" { - return parts[i] - } - } - return "" -} - -// Which of the three readings resolved a reference. Lets a rewrite keep the -// author's style: a note-relative href stays relative, a vault-root path stays -// rooted, a bare file name stays bare. -const ( - assetReadingNoteRelative = "note-relative" - assetReadingVaultRoot = "vault-root" - assetReadingBasename = "basename" -) - -type assetReferenceResolution struct { - path string - reading string - absolute bool // written with a leading `/` -} - -// resolveAssetPathAmong mirrors the renderer's resolveAssetPathAmong: the -// vault-relative path of the existing asset an href or wikilink target points -// at, or false when it points nowhere (or at more than one file by basename). -func resolveAssetPathAmong(assets []AssetMeta, notePath, href string) (string, bool) { - r, ok := resolveAssetReference(assets, notePath, href) - if !ok { - return "", false - } - return r.path, true -} - -// resolveAssetReference mirrors the renderer's resolveAssetReference: the -// resolved path plus the reading that found it. -func resolveAssetReference(assets []AssetMeta, notePath, href string) (assetReferenceResolution, bool) { - none := assetReferenceResolution{} - trimmed := strings.TrimSpace(href) - if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { - return none, false - } - if schemeRe.MatchString(trimmed) { - return none, false - } - noteDir := "" - if i := strings.LastIndexByte(notePath, '/'); i >= 0 { - noteDir = notePath[:i] - } - decoded := assetDecodeHref(trimmed) - isAbs := strings.HasPrefix(decoded, "/") - var target string - switch { - case isAbs: - target = strings.TrimLeft(decoded, "/") - case noteDir != "": - target = posixJoin(noteDir, decoded) - default: - target = decoded - } - target = posixNormalize(target) - if strings.HasPrefix(target, "../") || target == ".." { - return none, false - } - has := func(p string) bool { - for _, a := range assets { - if a.Path == p { - return true - } - } - return false - } - if has(target) { - reading := assetReadingNoteRelative - if isAbs || noteDir == "" { - reading = assetReadingVaultRoot - } - return assetReferenceResolution{path: target, reading: reading, absolute: isAbs}, true - } - if !isAbs && noteDir != "" { - rootTarget := posixNormalize(decoded) - if rootTarget != "" && rootTarget != target && !strings.HasPrefix(rootTarget, "../") && - rootTarget != ".." && has(rootTarget) { - return assetReferenceResolution{path: rootTarget, reading: assetReadingVaultRoot}, true - } - } - base := strings.ToLower(lastPathSegment(target)) - if base == "" { - return none, false - } - match, count := "", 0 - for _, a := range assets { - if strings.ToLower(lastPathSegment(a.Path)) == base { - match = a.Path - count++ - } - } - if count == 1 { - return assetReferenceResolution{path: match, reading: assetReadingBasename}, true - } - return none, false -} - -// assetRelativeTo is the POSIX path from directory fromDir ("" = vault root) to toPath. -func assetRelativeTo(fromDir, toPath string) string { - from := splitNonEmpty(fromDir) - to := splitNonEmpty(toPath) - shared := 0 - for shared < len(from) && shared < len(to) && from[shared] == to[shared] { - shared++ - } - parts := make([]string, 0, len(from)-shared+len(to)-shared) - for i := shared; i < len(from); i++ { - parts = append(parts, "..") - } - parts = append(parts, to[shared:]...) - return strings.Join(parts, "/") -} - -func splitNonEmpty(p string) []string { - out := []string{} - for _, part := range strings.Split(p, "/") { - if part != "" { - out = append(out, part) - } - } - return out -} - -// assetEncodeLike percent-encodes next per segment when the author wrote -// original encoded. -func assetEncodeLike(original, next string) string { - decoded, err := url.PathUnescape(original) - if err != nil || decoded == original { - return next - } - segments := strings.Split(next, "/") - for i, seg := range segments { - if seg != ".." && seg != "." { - segments[i] = url.PathEscape(seg) - } - } - return strings.Join(segments, "/") -} - -// assetRetarget rewrites one reference (a wikilink target or markdown href) -// that resolved via resolution so it points at newPath, in the author's style, -// keeping angle brackets, `#`/`?` suffix and percent-encoding. Wikilinks and -// hrefs differ in one place: a wikilink is written as a bare name or a -// vault-root path (Obsidian resolves them from the root), never with `..`, so -// a wikilink that happened to resolve next to its note stays bare while unique -// and otherwise gets the full path; a markdown href is a real relative path, -// so it follows the file with `..` where needed. -func assetRetarget(reference string, resolution assetReferenceResolution, noteDir, newPath string, bareStillUnique, wikilink bool) string { - angled := strings.HasPrefix(reference, "<") && strings.HasSuffix(reference, ">") - inner := reference - if angled { - inner = reference[1 : len(reference)-1] - } - suffix := "" - if i := strings.IndexAny(inner, "#?"); i >= 0 { - suffix = inner[i:] - inner = inner[:i] - } - bare := !strings.Contains(inner, "/") - var next string - switch { - case resolution.reading == assetReadingBasename || (wikilink && bare): - if bareStillUnique { - next = lastPathSegment(newPath) - } else { - next = newPath - } - case resolution.reading == assetReadingNoteRelative && !wikilink: - next = assetRelativeTo(noteDir, newPath) - default: - next = newPath - if resolution.absolute { - next = "/" + newPath - } - } - out := assetEncodeLike(inner, next) + suffix - if angled { - return "<" + out + ">" - } - return out -} - -// rewriteAssetReferencesInBody rewrites every reference in body that resolves -// to the asset at oldPath so it points at newPath (vault-relative; a rename -// changes the name, a move the directory). assets must be the pre-change -// listing so references resolve to the asset under the path they currently -// use; notePath is the note holding body, since a markdown href resolves -// relative to it. Code is skipped. -func rewriteAssetReferencesInBody(body string, assets []AssetMeta, notePath, oldPath, newPath string) (string, int) { - if newPath == "" || oldPath == newPath { - return body, 0 - } - if !strings.Contains(body, "[[") && !strings.Contains(body, "](") { - return body, 0 - } - noteDir := "" - if i := strings.LastIndexByte(notePath, '/'); i >= 0 { - noteDir = notePath[:i] - } - newBase := strings.ToLower(lastPathSegment(newPath)) - sameBase := 0 - for _, a := range assets { - p := a.Path - if p == oldPath { - p = newPath - } - if strings.ToLower(lastPathSegment(p)) == newBase { - sameBase++ - } - } - bareStillUnique := sameBase == 1 - resolveOld := func(reference string) (assetReferenceResolution, bool) { - t := strings.TrimSpace(reference) - if strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">") { - t = t[1 : len(t)-1] - } - r, ok := resolveAssetReference(assets, notePath, t) - if !ok || r.path != oldPath { - return assetReferenceResolution{}, false - } - return r, true - } - type edit struct { - start, end int - text string - } - var edits []edit - mask := wikiCodeMask(body) - for _, m := range assetWikilinkRe.FindAllStringSubmatchIndex(body, -1) { - if mask[m[0]] { - continue - } - embed := body[m[2]:m[3]] - content := body[m[4]:m[5]] - target, rest := content, "" - if p := strings.IndexByte(content, '|'); p >= 0 { - target, rest = content[:p], content[p:] - } - r, ok := resolveOld(target) - if !ok { - continue - } - next := embed + "[[" + assetRetarget(target, r, noteDir, newPath, bareStillUnique, true) + rest + "]]" - if next == body[m[0]:m[1]] { - continue // a bare name that still resolves reads exactly as before - } - edits = append(edits, edit{m[0], m[1], next}) - } - for _, m := range assetMdDestRe.FindAllStringSubmatchIndex(body, -1) { - if mask[m[0]] { - continue - } - href := body[m[2]:m[3]] - title := "" - if m[4] >= 0 { - title = body[m[4]:m[5]] - } - r, ok := resolveOld(href) - if !ok { - continue - } - next := "](" + assetRetarget(href, r, noteDir, newPath, bareStillUnique, false) + title + ")" - if next == body[m[0]:m[1]] { - continue - } - edits = append(edits, edit{m[0], m[1], next}) - } - if len(edits) == 0 { - return body, 0 - } - sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) - var sb strings.Builder - last, changed := 0, 0 - for _, e := range edits { - if e.start < last { - continue // overlapped an earlier edit; keep the first - } - sb.WriteString(body[last:e.start]) - sb.WriteString(e.text) - last = e.end - changed++ - } - sb.WriteString(body[last:]) - return sb.String(), changed -} - -// rewriteAssetReferences rewrites every note that referenced the renamed or moved asset. -// Only notes that can hold a reference are read: the ones flagged -// HasAttachments (embeds and file links), plus any whose plain wikilinks name -// a file that resolves to the asset. -func (v *Vault) rewriteAssetReferences(notesBefore []NoteMeta, assetsBefore []AssetMeta, oldRel, newRel string) { - for _, n := range notesBefore { - if n.Folder == FolderTrash { - continue - } - candidate := n.HasAttachments - if !candidate { - for _, t := range n.Wikilinks { - if localAssetTargetKind(t) == "" { - continue - } - if r, ok := resolveAssetPathAmong(assetsBefore, n.Path, t); ok && r == oldRel { - candidate = true - break - } - } - } - if !candidate { - continue - } - content, err := v.ReadNote(n.Path) - if err != nil { - continue - } - body, changed := rewriteAssetReferencesInBody(content.Body, assetsBefore, n.Path, oldRel, newRel) - if changed > 0 { - _, _ = v.WriteNote(n.Path, body) - } - } -} diff --git a/apps/server/internal/vault/asset_ops_test.go b/apps/server/internal/vault/asset_ops_test.go deleted file mode 100644 index acd14870..00000000 --- a/apps/server/internal/vault/asset_ops_test.go +++ /dev/null @@ -1,464 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "testing" -) - -// writeAsset drops a file at a vault-relative path, creating parent dirs. -func writeAsset(t *testing.T, root, rel, body string) { - t.Helper() - abs := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { - t.Fatal(err) - } -} - -func TestRenameAssetInPlace(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.RenameAsset("assets/pic.png", "renamed.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/renamed.png" { - t.Fatalf("renamed path = %q, want assets/renamed.png", meta.Path) - } - if meta.Kind != "image" { - t.Errorf("kind = %q, want image", meta.Kind) - } - if _, err := os.Stat(filepath.Join(root, "assets", "renamed.png")); err != nil { - t.Errorf("renamed file missing: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Errorf("old file still present, err = %v", err) - } -} - -func TestRewriteAssetReferencesOnRename(t *testing.T) { - assets := []AssetMeta{ - {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, - {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, - } - note := "inbox/Daily/2026-09-15.md" - cases := []struct { - in, want string - changed int - }{ - {"Shot: ![[assets/old.png]]\n", "Shot: ![[assets/new.png]]\n", 1}, - {"![[assets/old.png|300]] [[assets/old.png|the shot]] [[/assets/old.png#top]]", - "![[assets/new.png|300]] [[assets/new.png|the shot]] [[/assets/new.png#top]]", 3}, - {"![alt](assets/old.png \"Title\")\n[open](../../assets/old.png)\n[p](/assets/old.png#page=2)", - "![alt](assets/new.png \"Title\")\n[open](../../assets/new.png)\n[p](/assets/new.png#page=2)", 3}, - {"[![shot](assets/old.png)](assets/old.png)", "[![shot](assets/new.png)](assets/new.png)", 2}, - {"![[old.png]] ![](old.png)", "![[new.png]] ![](new.png)", 2}, - {"![[dup.png]]", "![[dup.png]]", 0}, - {"`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", - "`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", 0}, - {"![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", - "![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", 0}, - } - for _, c := range cases { - got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", "assets/new.png") - if got != c.want || n != c.changed { - t.Errorf("rewrite(%q) = %q (%d), want %q (%d)", c.in, got, n, c.want, c.changed) - } - } - got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() ![[assets/old name.png]]", - assets, note, "assets/old name.png", "assets/new name.png") - if want := "![](assets/new%20name.png) ![]() ![[assets/new name.png]]"; got != want || n != 3 { - t.Errorf("spaced rename = %q (%d), want %q (3)", got, n, want) - } - if got, n := rewriteAssetReferencesInBody("![[assets/old.png]]", assets, note, "assets/old.png", "assets/old.png"); got != "![[assets/old.png]]" || n != 0 { - t.Errorf("same-name rename changed the body: %q (%d)", got, n) - } -} - -func TestRewriteAssetReferencesOnMove(t *testing.T) { - assets := []AssetMeta{ - {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, - {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, - } - note := "inbox/Daily/2026-09-15.md" - cases := []struct { - in, want, newPath string - changed int - }{ - // Vault-root wikilinks and hrefs re-root; a spelled-out leading slash stays. - {"![[assets/old.png]] [[assets/old.png|the shot]] [p](/assets/old.png#page=2)", - "![[media/shots/old.png]] [[media/shots/old.png|the shot]] [p](/media/shots/old.png#page=2)", "media/shots/old.png", 3}, - // Note-relative stays relative to the note. - {"[open](../../assets/old.png \"Title\")", "[open](../../media/shots/old.png \"Title\")", "media/shots/old.png", 1}, - {"![](../../assets/old.png)", "![](old.png)", "inbox/Daily/old.png", 1}, - // A bare name stays bare while it still names one asset. - {"![[old.png]] ![](old.png)", "![[old.png]] ![](old.png)", "media/shots/old.png", 0}, - // It spells out the path once the bare name would be ambiguous. - {"![[old.png]] ![[assets/old.png]]", "![[docs/dup.png]] ![[docs/dup.png]]", "docs/dup.png", 2}, - {"![[old.png]]", "![[assets/dup.png]]", "assets/dup.png", 1}, - } - for _, c := range cases { - got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", c.newPath) - if got != c.want || n != c.changed { - t.Errorf("move rewrite(%q → %q) = %q (%d), want %q (%d)", c.in, c.newPath, got, n, c.want, c.changed) - } - } - // A note at the vault root writes the plain path either way. - if got, _ := rewriteAssetReferencesInBody("![](assets/old.png)", assets, "Root.md", "assets/old.png", "media/old.png"); got != "![](media/old.png)" { - t.Errorf("root-note move = %q", got) - } - // An asset that sat next to its note: hrefs go relative, wikilinks stay bare or go vault-root, never `..`. - local := []AssetMeta{{Path: "inbox/pic.png"}, {Path: "inbox/sub/chart.png"}, {Path: "assets/other.png"}} - if got, _ := rewriteAssetReferencesInBody("![[pic.png]] ![alt](pic.png) [[inbox/pic.png|the pic]] ![[assets/other.png]]", local, "inbox/Pics.md", "inbox/pic.png", "media/shots/pic.png"); got != "![[pic.png]] ![alt](../media/shots/pic.png) [[media/shots/pic.png|the pic]] ![[assets/other.png]]" { - t.Errorf("same-folder move = %q", got) - } - if got, _ := rewriteAssetReferencesInBody("![[sub/chart.png]] ![](sub/chart.png)", local, "inbox/Pics.md", "inbox/sub/chart.png", "media/chart.png"); got != "![[media/chart.png]] ![](../media/chart.png)" { - t.Errorf("relative-folder move = %q", got) - } - if got, _ := rewriteAssetReferencesInBody("![[pic.png]]", local, "inbox/Pics.md", "inbox/pic.png", "assets/other.png"); got != "![[assets/other.png]]" { - t.Errorf("bare-collision move = %q", got) - } - got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() `![[assets/old name.png]]`", - assets, note, "assets/old name.png", "media/new name.png") - if want := "![](media/new%20name.png) ![]() `![[assets/old name.png]]`"; got != want || n != 2 { - t.Errorf("encoded move = %q (%d), want %q (2)", got, n, want) - } -} - -// End-to-end: a real MoveAsset re-targets the notes that referenced the asset -// in the author's style and leaves the rest alone. (#785) -func TestMoveAssetRewritesReferences(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/shot.png", "PNG") - if _, err := v.WriteNote("inbox/Rooted.md", "![[assets/shot.png|300]]\n[page](/assets/shot.png#page=2)\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Daily/2026-09-15.md", "![shot](../../assets/shot.png \"Shot\")\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Bare.md", "See ![[shot.png]] and [the file](shot.png).\n"); err != nil { - t.Fatal(err) - } - bareBefore, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) - if err != nil { - t.Fatal(err) - } - - meta, err := v.MoveAsset("assets/shot.png", "media/screenshots") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/screenshots/shot.png" { - t.Fatalf("moved path = %q, want media/screenshots/shot.png", meta.Path) - } - rooted, err := v.ReadNote("inbox/Rooted.md") - if err != nil { - t.Fatal(err) - } - if want := "![[media/screenshots/shot.png|300]]\n[page](/media/screenshots/shot.png#page=2)\n"; rooted.Body != want { - t.Fatalf("Rooted after move =\n%q\nwant\n%q", rooted.Body, want) - } - daily, err := v.ReadNote("inbox/Daily/2026-09-15.md") - if err != nil { - t.Fatal(err) - } - if want := "![shot](../../media/screenshots/shot.png \"Shot\")\n"; daily.Body != want { - t.Fatalf("Daily after move = %q, want %q", daily.Body, want) - } - bareAfter, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) - if err != nil { - t.Fatal(err) - } - if !bareAfter.ModTime().Equal(bareBefore.ModTime()) { - t.Errorf("bare-name note was rewritten though its links still resolve") - } -} - -// End-to-end: a real RenameAsset rewrites the notes that referenced the asset -// and leaves the rest alone. (#785) -func TestRenameAssetRewritesReferences(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/shot.png", "PNG") - writeAsset(t, root, "assets/other.png", "PNG") - body := "![[assets/shot.png]] ![[assets/shot.png|300]] ![alt](assets/shot.png \"Shot\") [[assets/shot.png|open]] ![[shot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" - if _, err := v.WriteNote("inbox/Embeds.md", body); err != nil { - t.Fatal(err) - } - // A plain file link is neither an embed nor a note wikilink: HasAttachments carries it. - if _, err := v.WriteNote("inbox/Bare.md", "See [the file](shot.png).\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Plain.md", "No pictures, just [[Embeds]].\n"); err != nil { - t.Fatal(err) - } - plainBefore, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) - if err != nil { - t.Fatal(err) - } - - meta, err := v.RenameAsset("assets/shot.png", "screenshot.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/screenshot.png" { - t.Fatalf("renamed path = %q, want assets/screenshot.png", meta.Path) - } - - got, err := v.ReadNote("inbox/Embeds.md") - if err != nil { - t.Fatal(err) - } - want := "![[assets/screenshot.png]] ![[assets/screenshot.png|300]] ![alt](assets/screenshot.png \"Shot\") [[assets/screenshot.png|open]] ![[screenshot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" - if got.Body != want { - t.Fatalf("Embeds after rename =\n%q\nwant\n%q", got.Body, want) - } - bare, err := v.ReadNote("inbox/Bare.md") - if err != nil { - t.Fatal(err) - } - if bare.Body != "See [the file](screenshot.png).\n" { - t.Fatalf("Bare after rename = %q", bare.Body) - } - plainAfter, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) - if err != nil { - t.Fatal(err) - } - if !plainAfter.ModTime().Equal(plainBefore.ModTime()) { - t.Errorf("a note without references was rewritten") - } -} - -func TestRenameAssetRejectsCollision(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/a.png", "A") - writeAsset(t, root, "assets/b.png", "B") - - if _, err := v.RenameAsset("assets/a.png", "b.png"); err == nil { - t.Fatal("expected collision error, got nil") - } - // Both originals must still be intact. - if _, err := os.Stat(filepath.Join(root, "assets", "a.png")); err != nil { - t.Errorf("source lost after failed rename: %v", err) - } -} - -func TestRenameAssetRejectsMarkdownAndDotDot(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - if _, err := v.RenameAsset("assets/pic.png", "note.md"); err == nil { - t.Error("expected error renaming asset to a .md name") - } - if _, err := v.RenameAsset("assets/pic.png", "sub/dir.png"); err == nil { - t.Error("expected error for a name containing a path separator") - } - if _, err := v.RenameAsset("inbox/Note.md", "x.png"); err == nil { - t.Error("expected error renaming a markdown note through RenameAsset") - } -} - -func TestMoveAssetIntoFolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.MoveAsset("assets/pic.png", "media/screens") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/screens/pic.png" { - t.Fatalf("moved path = %q, want media/screens/pic.png", meta.Path) - } - if _, err := os.Stat(filepath.Join(root, "media", "screens", "pic.png")); err != nil { - t.Errorf("moved file missing: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Errorf("source still present after move, err = %v", err) - } -} - -func TestMoveAssetEmptyTargetGoesToAssetsDir(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A loose asset at the vault root, as a Vault Root-mode drop can leave. - writeAsset(t, root, "pic.png", "PNG") - - meta, err := v.MoveAsset("pic.png", "") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("moved path = %q, want assets/pic.png", meta.Path) - } -} - -func TestMoveAssetUniquifiesOnCollision(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "SRC") - writeAsset(t, root, "media/pic.png", "EXISTING") - - meta, err := v.MoveAsset("assets/pic.png", "media") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/pic 2.png" { - t.Fatalf("moved path = %q, want media/pic 2.png", meta.Path) - } - // The pre-existing file must be untouched. - body, err := os.ReadFile(filepath.Join(root, "media", "pic.png")) - if err != nil || string(body) != "EXISTING" { - t.Errorf("pre-existing file clobbered: body=%q err=%v", body, err) - } -} - -func TestMoveAssetSameDirIsNoop(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.MoveAsset("assets/pic.png", "assets") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("no-op move path = %q, want assets/pic.png", meta.Path) - } -} - -func TestFolderColorsRoundTripAndValidation(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{ - "inbox:Projects": "violet", - "inbox:Bad": "chartreuse", // not a preset — must be dropped - "": "blue", // empty key — must be dropped - }, - }); err != nil { - t.Fatal(err) - } - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.FolderColors["inbox:Projects"] != "violet" { - t.Fatalf("folderColors did not round-trip: %v", got.FolderColors) - } - if _, ok := got.FolderColors["inbox:Bad"]; ok { - t.Error("invalid color id was persisted") - } - if _, ok := got.FolderColors[""]; ok { - t.Error("empty-key color was persisted") - } -} - -func TestFolderColorsFollowFolderRename(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{"inbox:Projects": "teal"}, - }); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder("inbox", "Projects", "Work"); err != nil { - t.Fatal(err) - } - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.FolderColors["inbox:Work"] != "teal" { - t.Errorf("color did not follow rename: %v", got.FolderColors) - } - if _, ok := got.FolderColors["inbox:Projects"]; ok { - t.Error("stale color key survived rename") - } -} - -func TestFolderColorsPrunedOnDeleteAndCopiedOnDuplicate(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{"inbox:Projects": "pink"}, - }); err != nil { - t.Fatal(err) - } - - rel, err := v.DuplicateFolder("inbox", "Projects") - if err != nil { - t.Fatal(err) - } - dup, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if dup.FolderColors["inbox:"+rel] != "pink" { - t.Errorf("duplicate did not inherit color (key inbox:%s): %v", rel, dup.FolderColors) - } - if dup.FolderColors["inbox:Projects"] != "pink" { - t.Errorf("source color lost after duplicate: %v", dup.FolderColors) - } - - if err := v.DeleteFolder("inbox", "Projects"); err != nil { - t.Fatal(err) - } - del, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if _, ok := del.FolderColors["inbox:Projects"]; ok { - t.Error("deleted folder's color was not pruned") - } -} diff --git a/apps/server/internal/vault/asset_trash.go b/apps/server/internal/vault/asset_trash.go deleted file mode 100644 index 84c400f2..00000000 --- a/apps/server/internal/vault/asset_trash.go +++ /dev/null @@ -1,256 +0,0 @@ -package vault - -import ( - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" -) - -// Asset deletion mirrors the desktop implementation (vault.ts) exactly: -// a delete moves the file into .zennotes/deleted-assets// next to -// a .zn-deleted.json holding the original location, so the Trash view can -// list and restore it even across restarts and across the two -// implementations. Both apps read each other's stores; the on-disk layout is -// a contract, not an implementation detail. -const ( - deletedAssetsDir = "deleted-assets" - deletedAssetMetaFile = ".zn-deleted.json" -) - -// Matches the desktop's token validation: 36 chars of hex and dashes. -var deletedAssetTokenRe = regexp.MustCompile(`^[0-9a-fA-F-]{36}$`) - -func newUndoToken() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - b[6] = (b[6] & 0x0f) | 0x40 - b[8] = (b[8] & 0x3f) | 0x80 - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil -} - -func cleanDeletedAssetToken(token string) (string, error) { - if !deletedAssetTokenRe.MatchString(token) { - return "", errors.New("deleted asset restore token is invalid") - } - return token, nil -} - -func cleanDeletedAssetPath(rel string) (string, error) { - normalized := strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/") - if normalized == "" { - return "", errors.New("deleted asset path is required") - } - for _, part := range strings.Split(normalized, "/") { - if part == internalVaultDir { - return "", errors.New("cannot restore internal ZenNotes files") - } - } - if strings.EqualFold(filepath.Ext(normalized), ".md") { - return "", errors.New("use note actions to restore markdown notes") - } - return normalized, nil -} - -func (v *Vault) deletedAssetsRoot() string { - return filepath.Join(v.root, internalVaultDir, deletedAssetsDir) -} - -// DuplicateAsset copies an asset next to itself as " copy", -// mirroring the desktop duplicateAsset. -func (v *Vault) DuplicateAsset(rel string) (AssetMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, err - } - ext := filepath.Ext(srcAbs) - stem := strings.TrimSuffix(filepath.Base(srcAbs), ext) - destAbs := uniquePath(filepath.Dir(srcAbs), stem+" copy", ext) - if err := copyFile(srcAbs, destAbs, v.fileMode); err != nil { - return AssetMeta{}, err - } - return v.assetMetaForAbs(destAbs) -} - -// DeleteAsset moves an asset into the deleted-assets store and returns the -// restore handle, mirroring the desktop deleteAsset. -func (v *Vault) DeleteAsset(rel string) (DeletedAsset, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return DeletedAsset{}, err - } - srcRel, err := filepath.Rel(v.root, srcAbs) - if err != nil { - return DeletedAsset{}, err - } - undoToken, err := newUndoToken() - if err != nil { - return DeletedAsset{}, err - } - trashDir := filepath.Join(v.deletedAssetsRoot(), undoToken) - if err := os.MkdirAll(trashDir, v.dirMode); err != nil { - return DeletedAsset{}, err - } - name := filepath.Base(srcAbs) - deletedAt := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") - deleted := DeletedAsset{ - Path: filepath.ToSlash(srcRel), - Name: name, - UndoToken: undoToken, - DeletedAt: deletedAt, - } - meta, err := json.MarshalIndent(map[string]string{ - "path": deleted.Path, - "name": deleted.Name, - "deletedAt": deleted.DeletedAt, - }, "", " ") - if err != nil { - return DeletedAsset{}, err - } - // Metadata first, file move last: a failure anywhere leaves the asset - // still in the vault. The old order (rename, then metadata) could hit a - // write error (disk full, permissions) after the move and strand the - // asset in a token dir the Trash view skips, gone from the vault with no - // in-app way back. - if err := os.WriteFile(filepath.Join(trashDir, deletedAssetMetaFile), meta, v.fileMode); err != nil { - _ = os.RemoveAll(trashDir) - return DeletedAsset{}, err - } - if err := os.Rename(srcAbs, filepath.Join(trashDir, name)); err != nil { - _ = os.RemoveAll(trashDir) - return DeletedAsset{}, err - } - return deleted, nil -} - -// ListDeletedAssets enumerates restorable entries in the deleted-assets -// store, newest first. Entries without metadata are skipped, exactly like the -// desktop (pre-2.11 deletes have none). -func (v *Vault) ListDeletedAssets() ([]DeletedAsset, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(v.deletedAssetsRoot()) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []DeletedAsset{}, nil - } - return nil, err - } - out := []DeletedAsset{} - for _, entry := range entries { - undoToken := entry.Name() - raw, err := os.ReadFile(filepath.Join(v.deletedAssetsRoot(), undoToken, deletedAssetMetaFile)) - if err != nil { - continue - } - var meta struct { - Path string `json:"path"` - Name string `json:"name"` - DeletedAt string `json:"deletedAt"` - } - if err := json.Unmarshal(raw, &meta); err != nil || meta.Path == "" || meta.Name == "" { - continue - } - // The asset file itself must still be present to be restorable. - if _, err := os.Stat(filepath.Join(v.deletedAssetsRoot(), undoToken, meta.Name)); err != nil { - continue - } - out = append(out, DeletedAsset{ - Path: meta.Path, - Name: meta.Name, - UndoToken: undoToken, - DeletedAt: meta.DeletedAt, - }) - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].DeletedAt > out[j].DeletedAt - }) - return out, nil -} - -// RestoreDeletedAsset moves an asset back to its original folder, deduping -// the filename if something new took its place, mirroring the desktop. -// Only the token comes from the caller; the stored .zn-deleted.json decides -// what gets restored and where. Trusting a client-supplied name here once -// let a request naming the metadata file itself "restore" that file and -// then destroy the real asset bytes with the trash dir cleanup. -func (v *Vault) RestoreDeletedAsset(deleted DeletedAsset) (AssetMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - undoToken, err := cleanDeletedAssetToken(deleted.UndoToken) - if err != nil { - return AssetMeta{}, err - } - trashDir := filepath.Join(v.deletedAssetsRoot(), undoToken) - raw, err := os.ReadFile(filepath.Join(trashDir, deletedAssetMetaFile)) - if err != nil { - return AssetMeta{}, errors.New("deleted asset entry not found") - } - var stored struct { - Path string `json:"path"` - Name string `json:"name"` - } - if err := json.Unmarshal(raw, &stored); err != nil { - return AssetMeta{}, errors.New("deleted asset entry is unreadable") - } - targetRel, err := cleanDeletedAssetPath(stored.Path) - if err != nil { - return AssetMeta{}, err - } - name, err := cleanAssetFilename(stored.Name) - if err != nil { - return AssetMeta{}, err - } - if name == deletedAssetMetaFile { - return AssetMeta{}, errors.New("deleted asset entry is unreadable") - } - srcAbs := filepath.Join(trashDir, name) - targetAbs, err := SafeJoin(v.root, targetRel) - if err != nil { - return AssetMeta{}, err - } - targetDir := filepath.Dir(targetAbs) - if err := os.MkdirAll(targetDir, v.dirMode); err != nil { - return AssetMeta{}, err - } - base := filepath.Base(targetAbs) - ext := filepath.Ext(base) - finalAbs := uniquePath(targetDir, strings.TrimSuffix(base, ext), ext) - if err := os.Rename(srcAbs, finalAbs); err != nil { - return AssetMeta{}, err - } - if err := os.RemoveAll(trashDir); err != nil { - return AssetMeta{}, err - } - return v.assetMetaForAbs(finalAbs) -} - -// PurgeDeletedAsset permanently deletes one entry from the store. -func (v *Vault) PurgeDeletedAsset(undoToken string) error { - v.mu.Lock() - defer v.mu.Unlock() - token, err := cleanDeletedAssetToken(undoToken) - if err != nil { - return err - } - return os.RemoveAll(filepath.Join(v.deletedAssetsRoot(), token)) -} - -// EmptyDeletedAssets permanently deletes every entry in the store. -func (v *Vault) EmptyDeletedAssets() error { - v.mu.Lock() - defer v.mu.Unlock() - return os.RemoveAll(v.deletedAssetsRoot()) -} diff --git a/apps/server/internal/vault/asset_trash_test.go b/apps/server/internal/vault/asset_trash_test.go deleted file mode 100644 index 5faf9cd9..00000000 --- a/apps/server/internal/vault/asset_trash_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestDeleteAssetMovesIntoStoreAndListsBack(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - deleted, err := v.DeleteAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if deleted.Path != "assets/pic.png" || deleted.Name != "pic.png" { - t.Fatalf("deleted = %+v, want original path+name", deleted) - } - if !deletedAssetTokenRe.MatchString(deleted.UndoToken) { - t.Fatalf("undo token %q does not match the desktop token shape", deleted.UndoToken) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Fatal("original file still present after delete") - } - stored := filepath.Join(root, internalVaultDir, deletedAssetsDir, deleted.UndoToken, "pic.png") - if _, err := os.Stat(stored); err != nil { - t.Fatalf("stored file missing: %v", err) - } - - listed, err := v.ListDeletedAssets() - if err != nil { - t.Fatal(err) - } - if len(listed) != 1 || listed[0].UndoToken != deleted.UndoToken || listed[0].Path != "assets/pic.png" { - t.Fatalf("listed = %+v, want the deleted entry", listed) - } -} - -func TestRestoreDeletedAssetReturnsToOriginalFolderAndDedupes(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "docs/report.pdf", "PDF-1") - deleted, err := v.DeleteAsset("docs/report.pdf") - if err != nil { - t.Fatal(err) - } - // Something new takes the original name before the restore. - writeAsset(t, root, "docs/report.pdf", "PDF-2") - - meta, err := v.RestoreDeletedAsset(deleted) - if err != nil { - t.Fatal(err) - } - if meta.Path != "docs/report 2.pdf" { - t.Fatalf("restored path = %q, want the deduped docs/report 2.pdf", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "docs", "report 2.pdf")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PDF-1" { - t.Fatalf("restored body = %q, want the deleted bytes", body) - } - // The store entry is consumed by the restore. - if listed, _ := v.ListDeletedAssets(); len(listed) != 0 { - t.Fatalf("store still lists %d entries after restore", len(listed)) - } -} - -func TestRestoreDeletedAssetRejectsBadInput(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - base := DeletedAsset{Path: "assets/x.png", Name: "x.png", UndoToken: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} - - bad := base - bad.UndoToken = "../../../etc" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("path-traversal token accepted") - } - bad = base - bad.Path = ".zennotes/vault.json" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("internal path accepted") - } - bad = base - bad.Path = "notes/Note.md" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("markdown path accepted") - } - bad = base - bad.Name = "../vault.json" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("path-escaping name accepted") - } -} - -func TestRestoreDeletedAssetFollowsStoredMetadataNotTheRequest(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - deleted, err := v.DeleteAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - - // A hostile request keeps a valid token but names the metadata file as the - // thing to restore. Trusting it would rename .zn-deleted.json into the - // vault and then purge the real asset bytes with the trash dir cleanup. - hostile := deleted - hostile.Name = deletedAssetMetaFile - hostile.Path = "assets/x.json" - meta, err := v.RestoreDeletedAsset(hostile) - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("restored path = %q, want the stored assets/pic.png", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "assets", "pic.png")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PNG" { - t.Fatalf("restored body = %q, want the original asset bytes", body) - } -} - -func TestPurgeAndEmptyDeletedAssets(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "a.png", "A") - writeAsset(t, root, "b.png", "B") - delA, err := v.DeleteAsset("a.png") - if err != nil { - t.Fatal(err) - } - if _, err := v.DeleteAsset("b.png"); err != nil { - t.Fatal(err) - } - - if err := v.PurgeDeletedAsset("not-a-token"); err == nil { - t.Fatal("invalid token accepted by purge") - } - if err := v.PurgeDeletedAsset(delA.UndoToken); err != nil { - t.Fatal(err) - } - listed, err := v.ListDeletedAssets() - if err != nil { - t.Fatal(err) - } - if len(listed) != 1 || listed[0].Name != "b.png" { - t.Fatalf("after purge listed = %+v, want only b.png", listed) - } - - if err := v.EmptyDeletedAssets(); err != nil { - t.Fatal(err) - } - if listed, _ := v.ListDeletedAssets(); len(listed) != 0 { - t.Fatalf("after empty listed = %+v, want none", listed) - } -} - -func TestDeleteAssetRefusesNotesAndInternalFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "inbox/Note.md", "note") - if _, err := v.DeleteAsset("inbox/Note.md"); err == nil { - t.Fatal("markdown note accepted by asset delete") - } - if _, err := v.DeleteAsset(".zennotes/vault.json"); err == nil { - t.Fatal("internal file accepted by asset delete") - } -} - -func TestDuplicateAssetCopiesNextToSource(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.DuplicateAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic copy.png" { - t.Fatalf("duplicate path = %q, want assets/pic copy.png", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "assets", "pic copy.png")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PNG" { - t.Fatalf("duplicate body = %q", body) - } - // A second duplicate dedupes with the shared numbering scheme. - again, err := v.DuplicateAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(again.Path, "pic copy 2.png") { - t.Fatalf("second duplicate = %q, want pic copy 2.png", again.Path) - } -} diff --git a/apps/server/internal/vault/atomicwrite.go b/apps/server/internal/vault/atomicwrite.go deleted file mode 100644 index d1ee5a5e..00000000 --- a/apps/server/internal/vault/atomicwrite.go +++ /dev/null @@ -1,171 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "runtime" - "syscall" - "time" -) - -// The scratch file writeFileAtomic renames from: `...tmp`. -// The shape is shared with the desktop app's writeFileAtomic, and both watchers -// filter on it, so the two must stay recognizable to each other. The trailing -// number is an epoch stamp (millis on the desktop, nanos here), and requiring -// its length is what keeps a file the user actually named `notes.2024.01.tmp` -// out of the filter: events we drop here are events no client ever hears about. -var atomicWriteTempPattern = regexp.MustCompile(`\.\d+\.\d{13,}\.tmp$`) - -// IsAtomicWriteTempPath reports whether p is one of those scratch files. The -// watcher drops them: a temp file appearing and vanishing is not a vault -// change, and a client that heard about it would rebuild its asset list on -// every keystroke-driven note save. -func IsAtomicWriteTempPath(p string) bool { - return atomicWriteTempPattern.MatchString(filepath.Base(p)) -} - -// writeFileAtomic writes data to abs by way of a temp file in the same -// directory, fsynced, then renamed over the target. The rename is atomic, so no -// reader can ever observe a truncated or half-written file. That is what keeps -// a save from erasing the note it is saving: the file watcher echoes each save -// back to every client, and with a truncate-then-write the echo of one save -// could read the file inside the next save's empty window and hand clients an -// empty note (#585). -// -// A rename replaces the DIRECTORY ENTRY, which would silently take away two -// properties the plain os.WriteFile this replaced had for free: -// -// - A symlinked note gets written THROUGH, not over. Pointed straight at a -// link, the rename would leave a regular file where the link was and detach -// it from its target for good. SafeJoin has already proved the target -// resolves inside the vault. -// - An existing file keeps its own permissions. os.WriteFile only applies its -// mode when it creates the file, so a note the operator chmod'ed stays as -// they left it; fileMode applies only to files this call creates. -func writeFileAtomic(abs string, data []byte, fileMode, dirMode fs.FileMode) error { - target, err := resolveLinkTarget(abs) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), dirMode); err != nil { - return err - } - - mode := fileMode - replacing := false - if info, statErr := os.Stat(target); statErr == nil { - mode = info.Mode().Perm() - replacing = true - } else if !errors.Is(statErr, os.ErrNotExist) { - return statErr - } - - temp := fmt.Sprintf("%s.%d.%d.tmp", target, os.Getpid(), time.Now().UnixNano()) - f, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - if err := writeAndSync(f, data); err != nil { - _ = f.Close() - _ = os.Remove(temp) - return err - } - if err := f.Close(); err != nil { - _ = os.Remove(temp) - return err - } - // O_CREATE runs the mode through the process umask, so reproducing the mode - // of a file we are replacing takes an explicit chmod (0664 under umask 022, - // say). A file this call creates is deliberately left umasked, which is what - // os.WriteFile did with fileMode. On Windows chmod touches nothing but the - // read-only bit, which is the most it can mean there. - if replacing { - if err := os.Chmod(temp, mode); err != nil { - _ = os.Remove(temp) - return err - } - } - if err := renameWithRetry(temp, target, os.Rename, time.Sleep); err != nil { - _ = os.Remove(temp) - return err - } - return nil -} - -const atomicRenameAttempts = 20 -const windowsSharingViolation syscall.Errno = 32 - -func transientRenameError(err error) bool { - if errors.Is(err, fs.ErrPermission) { - return true - } - var errno syscall.Errno - return runtime.GOOS == "windows" && errors.As(err, &errno) && errno == windowsSharingViolation -} - -// Windows refuses a replace while any reader has the destination open without -// delete sharing. Watchers, indexers, and antivirus scanners all create that -// short-lived condition, so wait for the handle instead of failing the save. -func renameWithRetry( - from, to string, - rename func(string, string) error, - sleep func(time.Duration), -) error { - delay := time.Millisecond - for attempt := 1; ; attempt++ { - err := rename(from, to) - if err == nil { - return nil - } - if attempt >= atomicRenameAttempts || !transientRenameError(err) { - return err - } - sleep(delay) - delay = min(delay*2, 25*time.Millisecond) - } -} - -func writeAndSync(f *os.File, data []byte) error { - if _, err := f.Write(data); err != nil { - return err - } - // The bytes have to reach the disk before the rename publishes them, or a - // crash can leave the entry pointing at a file with nothing in it. - return f.Sync() -} - -// resolveLinkTarget follows a symlink at abs to the file it points at, so the -// atomic write lands on the target rather than replacing the link. A dangling -// link resolves to the path it names, which is where a plain write would have -// created the file. -func resolveLinkTarget(abs string) (string, error) { - info, err := os.Lstat(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return abs, nil - } - return "", err - } - if info.Mode()&os.ModeSymlink == 0 { - return abs, nil - } - resolved, err := filepath.EvalSymlinks(abs) - if err == nil { - return resolved, nil - } - if !errors.Is(err, os.ErrNotExist) { - return "", err - } - dest, err := os.Readlink(abs) - if err != nil { - return "", err - } - if filepath.IsAbs(dest) { - return dest, nil - } - return filepath.Join(filepath.Dir(abs), dest), nil -} diff --git a/apps/server/internal/vault/atomicwrite_test.go b/apps/server/internal/vault/atomicwrite_test.go deleted file mode 100644 index 570afd1f..00000000 --- a/apps/server/internal/vault/atomicwrite_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" -) - -// The #585 property, and the whole reason WriteNote is atomic: the watcher -// echoes every save to every client, and a client that reads the file inside a -// truncate-then-write window gets an empty note and shows it as the truth. No -// reader may ever observe anything but a complete body. -func TestWriteNoteNeverExposesAPartialFile(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - const rel = "inbox/race.md" - // Big enough that the write is not a single instantaneous syscall. - bodyA := strings.Repeat("A", 96*1024) - bodyB := strings.Repeat("B", 96*1024) - if _, err := v.WriteNote(rel, bodyA); err != nil { - t.Fatal(err) - } - abs := filepath.Join(v.Root(), "inbox", "race.md") - - stop := make(chan struct{}) - bad := make(chan string, 1) - var readers sync.WaitGroup - readers.Add(1) - go func() { - defer readers.Done() - for { - select { - case <-stop: - return - default: - } - data, err := os.ReadFile(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - select { - case bad <- "the note vanished mid-save": - default: - } - return - } - continue - } - if body := string(data); body != bodyA && body != bodyB { - select { - case bad <- fmt.Sprintf("a reader saw %d bytes, neither the old body nor the new one", len(body)): - default: - } - return - } - } - }() - - for i := range 200 { - body := bodyA - if i%2 == 1 { - body = bodyB - } - if _, err := v.WriteNote(rel, body); err != nil { - t.Fatal(err) - } - } - close(stop) - readers.Wait() - - select { - case msg := <-bad: - t.Fatal(msg) - default: - } -} - -func TestRenameWithRetryWaitsOutTransientPermissionErrors(t *testing.T) { - calls := 0 - var delays []time.Duration - err := renameWithRetry( - "note.tmp", - "note.md", - func(_, _ string) error { - calls++ - if calls < 3 { - return fs.ErrPermission - } - return nil - }, - func(delay time.Duration) { delays = append(delays, delay) }, - ) - - if err != nil { - t.Fatal(err) - } - if calls != 3 { - t.Fatalf("rename calls = %d, want 3", calls) - } - if len(delays) != 2 || delays[0] <= 0 || delays[1] <= delays[0] { - t.Fatalf("retry delays = %v, want two increasing delays", delays) - } -} - -// A rename replaces the directory entry, so an atomic write aimed straight at a -// symlinked note would leave a regular file where the link was and detach it -// from its target for good. -func TestWriteNoteFollowsSymlinkInsteadOfReplacingIt(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - realAbs := filepath.Join(v.Root(), "inbox", "real.md") - if err := os.WriteFile(realAbs, []byte("original"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "inbox", "link.md") - // Target inside the vault, which is what SafeJoin permits. - if err := os.Symlink(realAbs, link); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteNote("inbox/link.md", "written through the link"); err != nil { - t.Fatal(err) - } - - info, err := os.Lstat(link) - if err != nil { - t.Fatal(err) - } - if info.Mode()&os.ModeSymlink == 0 { - t.Fatal("the symlink was replaced by a regular file") - } - got, err := os.ReadFile(realAbs) - if err != nil { - t.Fatal(err) - } - if string(got) != "written through the link" { - t.Fatalf("link target holds %q, want the written body", got) - } -} - -// os.WriteFile only applied its mode when it created the file, so replacing it -// with temp-plus-rename must not quietly re-permission notes the operator (or -// another tool) left with a mode of their own. -func TestWriteNotePreservesModeOfAnExistingNote(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("file modes are not meaningful on windows") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - shared := filepath.Join(v.Root(), "inbox", "shared.md") - if err := os.WriteFile(shared, []byte("x"), 0o640); err != nil { - t.Fatal(err) - } - if err := os.Chmod(shared, 0o640); err != nil { // defeat the process umask - t.Fatal(err) - } - - if _, err := v.WriteNote("inbox/shared.md", "updated"); err != nil { - t.Fatal(err) - } - info, err := os.Stat(shared) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o640 { - t.Fatalf("mode after save = %v, want 0640", perm) - } - - // A note this call creates still gets the vault's configured mode. - if _, err := v.WriteNote("inbox/fresh.md", "new"); err != nil { - t.Fatal(err) - } - fresh, err := os.Stat(filepath.Join(v.Root(), "inbox", "fresh.md")) - if err != nil { - t.Fatal(err) - } - if perm := fresh.Mode().Perm(); perm != 0o600 { - t.Fatalf("new note mode = %v, want the vault's 0600", perm) - } -} - -func TestWriteNoteLeavesNoScratchFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - for range 3 { - if _, err := v.WriteNote("inbox/note.md", "body"); err != nil { - t.Fatal(err) - } - } - entries, err := os.ReadDir(filepath.Join(v.Root(), "inbox")) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("a scratch file survived the save: %s", entry.Name()) - } - } -} - -func TestIsAtomicWriteTempPath(t *testing.T) { - cases := []struct { - path string - want bool - }{ - {"inbox/note.md.4123.1786714355519.tmp", true}, // desktop, millis - {"inbox/note.md.4123.1786714355519123456.tmp", true}, // server, nanos - {"inbox/note.md", false}, - {"inbox/note.tmp", false}, - // A file the user named themselves keeps its live updates: the trailing - // group is too short to be an epoch stamp. - {"inbox/report.2024.01.tmp", false}, - } - for _, c := range cases { - if got := IsAtomicWriteTempPath(c.path); got != c.want { - t.Errorf("IsAtomicWriteTempPath(%q) = %v, want %v", c.path, got, c.want) - } - } -} diff --git a/apps/server/internal/vault/demo-tour.json b/apps/server/internal/vault/demo-tour.json deleted file mode 100644 index 29ee1cb8..00000000 --- a/apps/server/internal/vault/demo-tour.json +++ /dev/null @@ -1 +0,0 @@ -{"notes":[{"path":"inbox/demo/00 — Start Here.md","body":"# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"},{"path":"inbox/demo/01 — Markdown Basics.md","body":"# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n"},{"path":"inbox/demo/02 — Code Blocks.md","body":"# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n"},{"path":"inbox/demo/03 — Tables and Task Lists.md","body":"# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n"},{"path":"inbox/demo/04 — Math with KaTeX.md","body":"# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n"},{"path":"inbox/demo/05 — Mermaid Diagrams.md","body":"# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n"},{"path":"inbox/demo/05b — Math Diagrams.md","body":"# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n"},{"path":"inbox/demo/06 — Callouts and Footnotes.md","body":"# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"},{"path":"inbox/demo/07 — Wiki Links and Tags.md","body":"# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n"},{"path":"inbox/demo/08 — Daily Notes.md","body":"---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n"},{"path":"inbox/demo/09 — Vim Cheat Sheet.md","body":"# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n"},{"path":"inbox/demo/10 — Ideas and Tasks.md","body":"# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n"},{"path":"inbox/demo/11 — Workspace, Search, and Views.md","body":"# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"},{"path":"inbox/demo/12 — Settings and Keymaps.md","body":"# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n"},{"path":"inbox/demo/13 — Commands, Help, and Demo Tour.md","body":"# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"},{"path":"inbox/demo/14 — Reference Pane and Floating Windows.md","body":"# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"},{"path":"inbox/demo/15 — Search Backends and Fuzzy Workflows.md","body":"# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n"}],"assets":[{"path":"zennotes-demo-card.svg","body":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n"}]} \ No newline at end of file diff --git a/apps/server/internal/vault/demo.go b/apps/server/internal/vault/demo.go deleted file mode 100644 index 07785fb1..00000000 --- a/apps/server/internal/vault/demo.go +++ /dev/null @@ -1,119 +0,0 @@ -package vault - -import ( - _ "embed" - "encoding/json" - "os" - "path/filepath" - "strings" -) - -//go:embed demo-tour.json -var demoTourJSON []byte - -type demoFile struct { - Path string `json:"path"` - Body string `json:"body"` -} - -type demoTour struct { - Notes []demoFile `json:"notes"` - Assets []demoFile `json:"assets"` -} - -// DemoTourResult mirrors shared/ipc.ts VaultDemoTourResult. -type DemoTourResult struct { - NotePaths []string `json:"notePaths"` - AssetPaths []string `json:"assetPaths"` -} - -func loadDemoTour() (*demoTour, error) { - tour := &demoTour{} - if err := json.Unmarshal(demoTourJSON, tour); err != nil { - return nil, err - } - return tour, nil -} - -// GenerateDemoTour seeds the vault with the built-in tour notes and -// the demo attachment. Existing files are overwritten. -func (v *Vault) GenerateDemoTour() (DemoTourResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - tour, err := loadDemoTour() - if err != nil { - return DemoTourResult{}, err - } - result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} - for _, note := range tour.Notes { - abs, err := SafeJoin(v.root, note.Path) - if err != nil { - return DemoTourResult{}, err - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return DemoTourResult{}, err - } - if err := os.WriteFile(abs, []byte(note.Body), v.fileMode); err != nil { - return DemoTourResult{}, err - } - result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) - } - for _, asset := range tour.Assets { - abs, err := SafeJoin(v.root, asset.Path) - if err != nil { - return DemoTourResult{}, err - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return DemoTourResult{}, err - } - if err := os.WriteFile(abs, []byte(asset.Body), v.fileMode); err != nil { - return DemoTourResult{}, err - } - result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) - } - return result, nil -} - -// RemoveDemoTour deletes the demo notes + asset if they exist. Also -// removes empty parent directories under inbox/demo. -func (v *Vault) RemoveDemoTour() (DemoTourResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - tour, err := loadDemoTour() - if err != nil { - return DemoTourResult{}, err - } - result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} - removedDirs := map[string]bool{} - for _, note := range tour.Notes { - abs, err := SafeJoin(v.root, note.Path) - if err != nil { - continue - } - if err := os.Remove(abs); err == nil { - result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) - removedDirs[filepath.Dir(abs)] = true - } - } - for _, asset := range tour.Assets { - abs, err := SafeJoin(v.root, asset.Path) - if err != nil { - continue - } - if err := os.Remove(abs); err == nil { - result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) - } - } - for dir := range removedDirs { - // Walk up from each removed-note directory and rmdir empties, - // stopping at the vault root. - d := dir - for strings.HasPrefix(d, v.root) && d != v.root { - if err := os.Remove(d); err != nil { - break - } - d = filepath.Dir(d) - } - } - return result, nil -} diff --git a/apps/server/internal/vault/main_test.go b/apps/server/internal/vault/main_test.go deleted file mode 100644 index 7d8a51f2..00000000 --- a/apps/server/internal/vault/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package vault - -import ( - "os" - "testing" -) - -// ListNotes schedules a note-meta cache write one second later. Tests remove -// their temporary vaults as soon as they finish, and on Windows that write -// raced the removal ("The directory is not empty"). Keep the writer off for -// this package's tests; a test that opts back in must drain it with Close. -func TestMain(m *testing.M) { - if os.Getenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE") == "" { - _ = os.Setenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE", "1") - } - os.Exit(m.Run()) -} diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go deleted file mode 100644 index 8251c320..00000000 --- a/apps/server/internal/vault/parse.go +++ /dev/null @@ -1,754 +0,0 @@ -package vault - -import ( - "net/url" - "regexp" - "strings" - "unicode" - "unicode/utf8" -) - -// RE2's \s is ASCII-only; JavaScript also treats these Unicode characters as whitespace. -const assetEmbedSpaceClass = `\t\n\v\f\r \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}` -const assetEmbedTrimSpace = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" - -var assetEmbedMarkdownRe = regexp.MustCompile(`!\[[^\]]*\]\([` + assetEmbedSpaceClass + `]*` + assetEmbedSpaceClass + `]+)>?[^)]*\)`) -var assetEmbedSchemeRe = regexp.MustCompile(`^[a-zA-Z][\w+.\-]*:`) - -// ExtractAssetEmbeds mirrors extractAssetEmbeds in apps/desktop/src/main/vault.ts. -// Keep target order and URI decoding equivalent so both hosts report the same usage. -func ExtractAssetEmbeds(body string) []string { - stripped := stripCodeContent(body) - out := []string{} - seen := map[string]bool{} - add := func(target string) { - if !seen[target] { - seen[target] = true - out = append(out, target) - } - } - for _, match := range embedRe.FindAllStringSubmatch(stripped, -1) { - target := strings.Trim(match[1], assetEmbedTrimSpace) - // Desktop also accepts generic file extensions, beyond previewable media. - clean := strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0] - if strings.Contains(clean, ".") { - add(target) - } - } - for _, match := range assetEmbedMarkdownRe.FindAllStringSubmatch(stripped, -1) { - raw := strings.Trim(match[1], assetEmbedTrimSpace) - if raw == "" || strings.HasPrefix(raw, "#") || assetEmbedSchemeRe.MatchString(raw) { - continue - } - if decoded, err := url.PathUnescape(raw); err == nil && utf8.ValidString(decoded) { - add(decoded) - } else { - add(raw) - } - } - return out -} - -// Regexes below mirror the TS extractors in src/main/vault.ts. They are -// intentionally the same shape so the extracted metadata matches the -// desktop build byte-for-byte for the common cases. - -var ( - fenceLineRe = regexp.MustCompile("^[ \t]*(`{3,}|~{3,})(.*)$") - inlineCodeRe = regexp.MustCompile("`[^`\n]*`") - tagRe = regexp.MustCompile(`(?:^|\s)#(\p{L}[\p{L}\d_/-]*)`) - wikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) - linkRe = regexp.MustCompile(`(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) - embedRe = regexp.MustCompile(`!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) - frontmatterRe = regexp.MustCompile(`(?s)\A---\r?\n(.*?)\r?\n---\r?\n?`) - headingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) - imageMdRe = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) - mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`) - mdEmbedAltRe = regexp.MustCompile(`!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) - mdWikiAltRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) - markupTrimRe = regexp.MustCompile(`[*_~>]+`) - wsCollapseRe = regexp.MustCompile(`\s+`) -) - -var attachmentExts = map[string]bool{ - ".apng": true, ".avif": true, ".gif": true, ".jpeg": true, ".jpg": true, - ".png": true, ".svg": true, ".webp": true, ".pdf": true, - ".aac": true, ".flac": true, ".m4a": true, ".mp3": true, ".ogg": true, ".wav": true, - ".m4v": true, ".mov": true, ".mp4": true, ".ogv": true, ".webm": true, -} - -// stripCodeContent blanks fenced and inline code so the tag/link/excerpt -// scanners never read code as content. Fence detection is line-based and -// indentation-tolerant: a fence nested under a list item is still a code block, -// so its contents (e.g. a C "#include" line) must not be scanned. A -// column-0-anchored regex missed indented fences and leaked them as tags (#293). -// Mirrors stripCodeContent in apps/desktop/src/main/vault.ts and -// packages/app-core/src/lib/tags.ts — keep the three in sync. -func stripCodeContent(body string) string { - if !strings.Contains(body, "`") && !strings.Contains(body, "~") { - return body - } - lines := strings.Split(body, "\n") - inFence := false - var fenceChar byte - fenceLen := 0 - for i, line := range lines { - if m := fenceLineRe.FindStringSubmatch(line); m != nil { - marker := m[1] - char := marker[0] - rest := m[2] - if !inFence { - // A backtick fence's info string may not contain a backtick (CommonMark). - if char == '~' || !strings.Contains(rest, "`") { - inFence = true - fenceChar = char - fenceLen = len(marker) - lines[i] = " " - continue - } - } else if char == fenceChar && len(marker) >= fenceLen && strings.TrimSpace(rest) == "" { - inFence = false - lines[i] = " " - continue - } - } - if inFence { - lines[i] = " " - } - } - out := strings.Join(lines, "\n") - out = inlineCodeRe.ReplaceAllString(out, " ") - return out -} - -// ExtractTags returns unique tags from first-class frontmatter `tags` and inline #tags. -func ExtractTags(body string) []string { - seen := map[string]bool{} - out := []string{} - if m := frontmatterRe.FindStringSubmatch(body); len(m) >= 2 { - fm := parseTaskFrontmatter(m[1]) - for _, raw := range fm["tags"] { - // A bare scalar splits on commas and whitespace: `tags: daily, work` - // is two tags and a tag can contain neither. Kept in sync with - // frontmatterTags in packages/shared-domain/src/frontmatter.ts. - for _, part := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || unicode.IsSpace(r) - }) { - tag := strings.TrimPrefix(part, "#") - if tag != "" && !seen[tag] { - seen[tag] = true - out = append(out, tag) - } - } - } - } - - markdownBody := frontmatterRe.ReplaceAllString(body, "") - if !strings.Contains(markdownBody, "#") { - return out - } - stripped := stripCodeContent(markdownBody) - for _, m := range tagRe.FindAllStringSubmatch(stripped, -1) { - if len(m) >= 2 { - tag := m[1] - if !seen[tag] { - seen[tag] = true - out = append(out, tag) - } - } - } - return out -} - -// ExtractWikilinks returns unique [[wikilink]] targets, ignoring code. -func ExtractWikilinks(body string) []string { - if !strings.Contains(body, "[[") { - return []string{} - } - stripped := stripCodeContent(body) - seen := map[string]bool{} - out := []string{} - for _, m := range wikilinkRe.FindAllStringSubmatch(stripped, -1) { - if len(m) >= 3 { - bang := m[1] - target := strings.TrimSpace(m[2]) - if target == "" { - continue - } - if bang == "!" && localAssetTargetKind(target) != "" { - continue - } - if !seen[target] { - seen[target] = true - out = append(out, target) - } - } - } - return out -} - -// BodyHasLocalAsset is the same cheap heuristic as the TS version. -func BodyHasLocalAsset(body string) bool { - if !strings.Contains(body, "](") && !strings.Contains(body, "![[") { - return false - } - stripped := stripCodeContent(body) - for _, m := range linkRe.FindAllStringSubmatch(stripped, -1) { - if len(m) < 3 { - continue - } - href := strings.TrimSpace(m[2]) - if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "//") { - continue - } - if matched, _ := regexpMatchScheme(href); matched { - continue - } - if localAssetTargetKind(href) != "" { - return true - } - } - for _, m := range embedRe.FindAllStringSubmatch(stripped, -1) { - if len(m) < 2 { - continue - } - if localAssetTargetKind(strings.TrimSpace(m[1])) != "" { - return true - } - } - return false -} - -var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z\d+.\-]*:`) - -func regexpMatchScheme(href string) (bool, error) { - return schemeRe.MatchString(href), nil -} - -// BuildExcerpt makes a short plaintext preview from markdown. -func BuildExcerpt(body string) string { - withoutFront := body - if strings.HasPrefix(body, "---\n") { - withoutFront = frontmatterRe.ReplaceAllString(body, "") - } - text := stripCodeContent(withoutFront) - if strings.Contains(text, "](") { - text = imageMdRe.ReplaceAllString(text, " ") - text = mdLinkRe.ReplaceAllString(text, "$1") - } - if strings.Contains(text, "![[") { - text = mdEmbedAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdEmbedAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - } - if strings.Contains(text, "[[") { - text = mdWikiAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdWikiAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - } - if strings.Contains(text, "#") { - text = headingRe.ReplaceAllString(text, "") - } - if strings.ContainsAny(text, "*_~>") { - text = markupTrimRe.ReplaceAllString(text, "") - } - text = wsCollapseRe.ReplaceAllString(text, " ") - text = strings.TrimSpace(text) - if len(text) > 220 { - text = text[:220] - } - return text -} - -func localAssetTargetKind(target string) string { - clean := target - if i := strings.IndexAny(clean, "#?"); i >= 0 { - clean = clean[:i] - } - dot := strings.LastIndexByte(clean, '.') - if dot < 0 { - return "" - } - ext := strings.ToLower(clean[dot:]) - if attachmentExts[ext] { - return ext - } - return "" -} - -// --- Task parsing (mirrors shared/tasks.ts parseTasksFromBody) --- - -var ( - taskLineRe = regexp.MustCompile(`^(\s*(?:[-*+]|\d+\.)\s+)\[( |x|X|>|-|/)\](.*)$`) - inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:\s*(\S+)`) - inlinePriority = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`) - inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`) - inlineFieldRe = regexp.MustCompile(`(?i)(?:^|\s)@([a-z][a-z0-9_-]*):([\p{L}\d][\p{L}\d/_-]*)`) - inlineTagRe = regexp.MustCompile(`(?:^|\s)#([\p{L}\d][\p{L}\d/_\-]*)`) - isoDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) -) - -func isValidIsoDate(s string) bool { - if !isoDateRe.MatchString(s) { - return false - } - return true -} - -func normalizePriority(raw string) string { - v := strings.ToLower(strings.TrimSpace(raw)) - switch v { - case "high", "h": - return "high" - // `normal` is the TaskNotes default priority; map it onto ZenNotes' `med`. - // The inline `!prio` regex never emits `normal`, so inline parsing is - // unaffected; only frontmatter file-tasks reach this arm. - case "med", "medium", "normal", "m": - return "med" - case "low", "l": - return "low" - } - return "" -} - -type noteDefaults struct { - Due string - Priority string - Status string - TasksMode string -} - -// Note-level participation in the Tasks system, from the frontmatter `tasks:` -// key (#458). Mirrors noteTasksMode in packages/shared-domain/src/tasks.ts; -// keep the accepted values byte-identical. No runtime in this app types YAML -// scalars, so `tasks: false` arrives as the string "false"; matching is exact -// string comparison after lower-casing, anything unrecognized falls back to -// "all" (the pre-#458 behavior). -const ( - tasksModeAll = "all" - tasksModeNoteOnly = "note-only" - tasksModeNone = "none" -) - -func noteTasksMode(val string) string { - switch strings.ToLower(strings.TrimSpace(val)) { - case "false", "off": - return tasksModeNone - case "note": - return tasksModeNoteOnly - } - return tasksModeAll -} - -func parseNoteDefaults(body string) noteDefaults { - m := frontmatterRe.FindStringSubmatch(body) - if len(m) < 2 { - return noteDefaults{TasksMode: tasksModeAll} - } - d := noteDefaults{TasksMode: tasksModeAll} - for _, line := range strings.Split(m[1], "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - colon := strings.IndexByte(trimmed, ':') - if colon < 1 { - continue - } - key := strings.ToLower(strings.TrimSpace(trimmed[:colon])) - val := unquote(strings.TrimSpace(trimmed[colon+1:])) - switch key { - case "due": - if isValidIsoDate(val) { - d.Due = val - } - case "priority": - if p := normalizePriority(val); p != "" { - d.Priority = p - } - case "status": - d.Status = strings.ToLower(val) - case "tasks": - d.TasksMode = noteTasksMode(val) - } - } - return d -} - -func unquote(v string) string { - t := strings.TrimSpace(v) - if len(t) >= 2 { - first, last := t[0], t[len(t)-1] - if (first == '"' || first == '\'') && first == last { - return t[1 : len(t)-1] - } - } - return t -} - -// --- File tasks (TaskNotes-style: one task per note, metadata in frontmatter) --- - -// taskFileTag is the frontmatter tag that marks a whole note as a task -// (TaskNotes convention, interoperable with TaskForge / Obsidian TaskNotes). -const taskFileTag = "task" - -// doneStatuses are frontmatter `status:` values treated as complete (checked). -var doneStatuses = map[string]bool{ - "done": true, "complete": true, "completed": true, "x": true, -} - -// cancelledStatuses are frontmatter `status:` values treated as cancelled (#450). -var cancelledStatuses = map[string]bool{ - "cancelled": true, "canceled": true, -} - -// inProgressStatuses are frontmatter `status:` values treated as in progress -// (#512). `in-progress` is TaskNotes' spelling; the rest are what people type -// by hand. These stay open work, unlike done/cancelled. -var inProgressStatuses = map[string]bool{ - "in-progress": true, "in progress": true, "inprogress": true, - "doing": true, "started": true, "wip": true, -} - -var ( - taskFmListItemRe = regexp.MustCompile(`^\s*-\s+(.*)$`) - taskFmKvRe = regexp.MustCompile(`^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$`) - taskFmLeadWsRe = regexp.MustCompile(`^\s`) -) - -// parseTaskFrontmatter parses a leading frontmatter block into flat fields, -// handling scalars, inline arrays (`tags: [a, b]`) and block lists (`tags:` -// then indented ` - a`). Keys are lower-cased; every value is stored as a -// slice (a scalar becomes a single-element slice). Best-effort and never -// panics: just enough YAML for task files, not a full parser. Mirrors -// parseFrontmatterFields in packages/shared-domain/src/frontmatter.ts. -func parseTaskFrontmatter(block string) map[string][]string { - data := map[string][]string{} - listKey := "" - for _, rawLine := range strings.Split(block, "\n") { - trimmed := strings.TrimSpace(rawLine) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - item := taskFmListItemRe.FindStringSubmatch(rawLine) - if listKey != "" && taskFmLeadWsRe.MatchString(rawLine) && item != nil { - data[listKey] = append(data[listKey], unquote(item[1])) - continue - } - kv := taskFmKvRe.FindStringSubmatch(rawLine) - if kv == nil { - listKey = "" - continue - } - key := strings.ToLower(kv[1]) - rest := strings.TrimSpace(kv[2]) - if rest == "" { - // Bare key: a block list may follow on indented `- item` lines. - listKey = key - data[key] = []string{} - continue - } - listKey = "" - if strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]") { - var arr []string - for _, s := range strings.Split(rest[1:len(rest)-1], ",") { - v := unquote(s) - if v != "" { - arr = append(arr, v) - } - } - data[key] = arr - } else { - data[key] = []string{unquote(rest)} - } - } - return data -} - -// firstScalar returns the first value of a frontmatter field, or "" when absent. -func firstScalar(v []string) string { - if len(v) == 0 { - return "" - } - return v[0] -} - -// normalizeDueDate unquotes/trims a frontmatter date and returns it only when it -// is a valid YYYY-MM-DD string, otherwise "". Reuses the same validation the -// inline due parser uses. -func normalizeDueDate(raw string) string { - v := unquote(strings.TrimSpace(raw)) - if isValidIsoDate(v) { - return v - } - return "" -} - -// parseTaskFile returns a whole-note "file task" when body has a leading -// frontmatter block whose `tags` include `task`, and ok=false otherwise. All -// metadata comes from frontmatter; the note body is free-form. Mirrors -// parseTaskFile in packages/shared-domain/src/tasks.ts. `body` is expected to -// already be newline-normalized by the caller. -func parseTaskFile(path, title string, folder NoteFolder, body string) (Task, bool) { - m := frontmatterRe.FindStringSubmatch(body) - if len(m) < 2 { - return Task{}, false - } - fm := parseTaskFrontmatter(m[1]) - - tags := []string{} - hasTaskTag := false - for _, t := range fm["tags"] { - tag := strings.ToLower(strings.TrimPrefix(t, "#")) - if tag == taskFileTag { - hasTaskTag = true - continue - } - tags = append(tags, tag) - } - if !hasTaskTag { - return Task{}, false - } - - // "open" is the effective state of a note that says nothing, not a custom - // status the author chose: only an explicit status: reaches Fields, so the - // note sits in the board's "No status" column and a drop there (which - // clears the key) survives the next rescan. Mirrors parseTaskFile in - // packages/shared-domain/src/tasks.ts (#672). - status := "open" - fields := map[string]string{} - if s := firstScalar(fm["status"]); s != "" { - status = strings.ToLower(s) - fields["status"] = status - } - content := title - if t := strings.TrimSpace(firstScalar(fm["title"])); t != "" { - content = t - } - - return Task{ - ID: path + "#task", - SourcePath: path, - NoteTitle: title, - NoteFolder: folder, - LineNumber: 0, - TaskIndex: -1, - RawText: "", - Content: content, - Checked: doneStatuses[status], - Cancelled: cancelledStatuses[status], - InProgress: inProgressStatuses[status], - Due: normalizeDueDate(firstScalar(fm["due"])), - Priority: normalizePriority(firstScalar(fm["priority"])), - Waiting: status == "waiting", - Fields: fields, - Status: status, - Tags: tags, - Kind: "file", - Scheduled: normalizeDueDate(firstScalar(fm["scheduled"])), - CompletedDate: normalizeDueDate(firstScalar(fm["completeddate"])), - }, true -} - -// ParseTasksOptions controls scanning past task exclusions (#458). -type ParseTasksOptions struct { - // IncludeExcluded scans past the note-level frontmatter `tasks:` opt-out: - // the GET /tasks?includeExcluded=1 escape hatch. Default listing never sets - // it. - IncludeExcluded bool -} - -// ParseTasks walks a markdown body and returns every checkbox task. -func ParseTasks(path, title string, folder NoteFolder, body string) []Task { - return ParseTasksWith(path, title, folder, body, ParseTasksOptions{}) -} - -// ParseTasksWith is ParseTasks honoring options. The frontmatter `tasks:` -// gate lives here rather than in the parse helpers (the TS mirrors gate -// inside parseTaskFile/parseTasksFromBody because their callers invoke the -// two separately); the semantics are identical: `tasks: false`/`off` emits -// nothing and wins over `tags: [task]`, `tasks: note` keeps only the file -// task, anything else emits everything. -func ParseTasksWith(path, title string, folder NoteFolder, body string, opts ParseTasksOptions) []Task { - normalized := strings.ReplaceAll(body, "\r\n", "\n") - defaults := parseNoteDefaults(normalized) - lines := strings.Split(normalized, "\n") - - mode := defaults.TasksMode - if opts.IncludeExcluded { - mode = tasksModeAll - } - - out := []Task{} - // A whole-note "file task" (if the frontmatter is tagged `task`) is emitted - // before the inline checkbox tasks in the same note, which act as subtasks. - if mode != tasksModeNone { - if fileTask, ok := parseTaskFile(path, title, folder, normalized); ok { - out = append(out, fileTask) - } - } - if mode != tasksModeAll { - return out - } - taskIndex := 0 - inFence := false - fenceMarker := "" - - fenceStart := regexp.MustCompile("^([ \t]*)(`{3,}|~{3,})") - - for i, line := range lines { - if fm := fenceStart.FindStringSubmatch(line); fm != nil { - marker := fm[2] - if !inFence { - inFence = true - fenceMarker = marker - } else if marker == fenceMarker { - inFence = false - fenceMarker = "" - } - continue - } - if inFence { - continue - } - m := taskLineRe.FindStringSubmatch(line) - if m == nil { - continue - } - checkedChar := m[2] - tail := strings.TrimPrefix(m[3], "]") - checked := checkedChar == "x" || checkedChar == "X" - cancelled := checkedChar == "-" - inProgress := checkedChar == "/" - forwarded := checkedChar == ">" - - due := "" - priority := "" - waiting := false - fields := map[string]string{} - tags := []string{} - stripped := tail - - if dm := inlineDueRe.FindStringSubmatch(stripped); dm != nil { - if isValidIsoDate(dm[1]) { - due = dm[1] - } - stripped = inlineDueRe.ReplaceAllString(stripped, " ") - } - if pm := inlinePriority.FindStringSubmatch(stripped); pm != nil { - priority = normalizePriority(pm[1]) - stripped = inlinePriority.ReplaceAllString(stripped, " ") - } - if inlineWaitingRe.MatchString(stripped) { - waiting = true - stripped = inlineWaitingRe.ReplaceAllString(stripped, " ") - } - for _, fm := range inlineFieldRe.FindAllStringSubmatch(stripped, -1) { - if len(fm) < 3 { - continue - } - key := strings.ToLower(fm[1]) - if _, exists := fields[key]; !exists { - fields[key] = strings.ToLower(fm[2]) - } - } - if len(fields) > 0 { - stripped = inlineFieldRe.ReplaceAllString(stripped, " ") - } - for _, tm := range inlineTagRe.FindAllStringSubmatch(tail, -1) { - if len(tm) >= 2 { - tag := strings.ToLower(tm[1]) - dupe := false - for _, t := range tags { - if t == tag { - dupe = true - break - } - } - if !dupe { - tags = append(tags, tag) - } - } - } - stripped = strings.TrimSpace(wsCollapseRe.ReplaceAllString(stripped, " ")) - content := stripped - if content == "" { - content = strings.TrimSpace(tail) - } - - if due == "" { - due = defaults.Due - } - if priority == "" { - priority = defaults.Priority - } - if _, hasStatus := fields["status"]; !hasStatus && defaults.Status != "" { - fields["status"] = defaults.Status - } - - task := Task{ - ID: fmtTaskID(path, taskIndex), - SourcePath: path, - NoteTitle: title, - NoteFolder: folder, - LineNumber: i, - TaskIndex: taskIndex, - RawText: line, - Content: content, - Checked: checked, - Cancelled: cancelled, - InProgress: inProgress, - Forwarded: forwarded, - Due: due, - Priority: priority, - Waiting: waiting, - Fields: fields, - Status: fields["status"], - Tags: tags, - } - out = append(out, task) - taskIndex++ - } - return out -} - -func fmtTaskID(path string, idx int) string { - return path + "#" + itoa(idx) -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := false - if n < 0 { - neg = true - n = -n - } - var buf [20]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - buf[i] = '-' - } - return string(buf[i:]) -} diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go deleted file mode 100644 index cce8b694..00000000 --- a/apps/server/internal/vault/parse_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package vault - -import ( - "reflect" - "testing" -) - -func TestBodyHasLocalAssetDetectsOnlyLocalAssets(t *testing.T) { - cases := []struct { - name string - body string - want bool - }{ - { - name: "plain wikilink", - body: "# Plain\n\n[[Project Note]]\n", - want: false, - }, - { - name: "relative image", - body: "# Image\n\n![diagram](../attachements/diagram.png)\n", - want: true, - }, - { - name: "embedded pdf", - body: "# Embed\n\n![[brief.pdf]]\n", - want: true, - }, - { - name: "remote image", - body: "# Remote\n\n![diagram](https://example.com/diagram.png)\n", - want: false, - }, - { - name: "code fenced local asset", - body: "# Code\n\n```md\n![diagram](local.png)\n```\n", - want: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := BodyHasLocalAsset(tc.body); got != tc.want { - t.Fatalf("BodyHasLocalAsset() = %v, want %v", got, tc.want) - } - }) - } -} - -func TestExtractorsStillIgnoreCodeAfterFastPathGuards(t *testing.T) { - body := "# Real #tag\n\n```md\n#ignored [[Ignored]] ![[ignored.pdf]]\n```\n\n[[Target|Label]]" - - tags := ExtractTags(body) - if len(tags) != 1 || tags[0] != "tag" { - t.Fatalf("ExtractTags() = %#v, want [tag]", tags) - } - - wikilinks := ExtractWikilinks(body) - if len(wikilinks) != 1 || wikilinks[0] != "Target" { - t.Fatalf("ExtractWikilinks() = %#v, want [Target]", wikilinks) - } -} - -// #293: a fenced code block nested under a list item (indented) is still code — -// its `#include` line must not be indexed as a tag. -func TestExtractTagsIgnoresIndentedFence(t *testing.T) { - body := "- bullet\n\n ```c\n #include \n ```\n\n#kept" - - tags := ExtractTags(body) - if len(tags) != 1 || tags[0] != "kept" { - t.Fatalf("ExtractTags() = %#v, want [kept]", tags) - } -} - -func TestExtractTagsIncludesFrontmatterTags(t *testing.T) { - body := "---\ntags: [frontmatter, \"#quoted\", project/nested]\ntitle: #ignored\n---\n\n#inline" - - tags := ExtractTags(body) - want := []string{"frontmatter", "quoted", "project/nested", "inline"} - if len(tags) != len(want) { - t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) - } - for i := range want { - if tags[i] != want[i] { - t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) - } - } -} - -func TestExtractTagsSplitsBareFrontmatterScalar(t *testing.T) { - tags := ExtractTags("---\ntags: daily, work\n---\nbody") - if len(tags) != 2 || tags[0] != "daily" || tags[1] != "work" { - t.Fatalf("ExtractTags() = %#v, want [daily work]", tags) - } -} - -func TestExtractTagsIncludesFrontmatterTagList(t *testing.T) { - body := "---\ntags:\n - daily\n - \"#log\"\n---\n\nBody" - - tags := ExtractTags(body) - if len(tags) != 2 || tags[0] != "daily" || tags[1] != "log" { - t.Fatalf("ExtractTags() = %#v, want [daily log]", tags) - } -} - -// #205: tags in non-Latin scripts (Cyrillic, CJK, …) must be recognized. -func TestExtractTagsUnicode(t *testing.T) { - body := "Заметки: #тест #ошибка/баг и 笔记 #标签 plus #ascii-1 done" - got := ExtractTags(body) - want := map[string]bool{"тест": true, "ошибка/баг": true, "标签": true, "ascii-1": true} - if len(got) != len(want) { - t.Fatalf("ExtractTags() = %#v, want keys %#v", got, want) - } - for _, tag := range got { - if !want[tag] { - t.Fatalf("unexpected tag %q in %#v", tag, got) - } - } -} - -// #450: `[-]` cancelled tasks must be parsed (not dropped) and flagged cancelled. -func TestParseTasksRecognizesCancelled(t *testing.T) { - body := "- [ ] open\n- [x] done\n- [>] gone\n- [-] scrapped\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 4 { - t.Fatalf("expected 4 tasks (none dropped), got %d", len(tasks)) - } - byContent := map[string]Task{} - for _, tk := range tasks { - byContent[tk.Content] = tk - } - if c, ok := byContent["scrapped"]; !ok { - t.Fatal("cancelled task line was dropped") - } else if !c.Cancelled || c.Checked { - t.Errorf("scrapped: Cancelled=%v Checked=%v, want Cancelled=true Checked=false", c.Cancelled, c.Checked) - } - if byContent["open"].Cancelled || byContent["done"].Cancelled { - t.Error("open/done tasks should not be cancelled") - } -} - -func TestParseTaskFileCancelledStatus(t *testing.T) { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: cancelled\n---\n\nAbandoned.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if !task.Cancelled || task.Checked { - t.Errorf("Cancelled=%v Checked=%v, want Cancelled=true Checked=false", task.Cancelled, task.Checked) - } -} - -// #512: `[/]` in-progress tasks parse as open work, flagged InProgress. The -// server mirrors shared-domain here, so a web client sees the same states the -// desktop app does. -func TestParseTasksRecognizesInProgress(t *testing.T) { - body := "- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n1. [/] numbered\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 5 { - t.Fatalf("expected 5 tasks (none dropped), got %d", len(tasks)) - } - byContent := map[string]Task{} - for _, tk := range tasks { - byContent[tk.Content] = tk - } - for _, name := range []string{"started", "numbered"} { - tk, ok := byContent[name] - if !ok { - t.Fatalf("in-progress task %q was dropped", name) - } - if !tk.InProgress { - t.Errorf("%s: InProgress=false, want true", name) - } - if tk.Checked || tk.Cancelled { - t.Errorf("%s: Checked=%v Cancelled=%v, want both false", name, tk.Checked, tk.Cancelled) - } - } - if byContent["open"].InProgress || byContent["done"].InProgress || byContent["scrapped"].InProgress { - t.Error("open/done/cancelled tasks should not be in progress") - } -} - -func TestParseTaskFileInProgressStatus(t *testing.T) { - for _, status := range []string{"in-progress", "doing", "started", "wip"} { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: " + status + "\n---\n\nHalf done.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatalf("%s: expected a file task", status) - } - if !task.InProgress { - t.Errorf("%s: InProgress=false, want true", status) - } - if task.Checked || task.Cancelled { - t.Errorf("%s: Checked=%v Cancelled=%v, want both false", status, task.Checked, task.Cancelled) - } - } -} - -// #643: a server-backed board must receive the same custom-status fields as -// the desktop parser. Otherwise the optimistic move sticks until the watcher -// rescan replaces it with a task that appears to have no status. -func TestParseTaskFileIncludesCustomStatusField(t *testing.T) { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: A\n---\n\nDetails.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if task.Status != "a" { - t.Errorf("Status=%q, want %q", task.Status, "a") - } - if got := task.Fields["status"]; got != "a" { - t.Errorf("Fields[status]=%q, want %q", got, "a") - } -} - -// #672: a file task whose frontmatter says nothing is effectively open but -// has no custom status. Reporting one put it in an "Open" column with a -// phantom @status:open chip, and a drop into "No status" (which clears the -// key) was undone by the next rescan, so the card bounced between the two. -func TestParseTaskFileWithoutStatusHasNoCustomStatusField(t *testing.T) { - body := "---\ntitle: Ship it\ntags: [ task ]\ndue: 2026-08-24\n---\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if task.Status != "open" || task.Checked || task.Cancelled { - t.Errorf("Status=%q Checked=%v Cancelled=%v, want open/false/false", task.Status, task.Checked, task.Cancelled) - } - if _, has := task.Fields["status"]; has { - t.Errorf("Fields=%#v, want no status key", task.Fields) - } - if task.Fields == nil { - t.Error("Fields must be an empty map, not nil, so the JSON stays {}") - } -} - -func TestParseTasksIncludesCustomFields(t *testing.T) { - body := "---\nstatus: Backlog\n---\n- [ ] inherits\n- [ ] override @status:Review @sprint:24 @area:Backend\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(tasks)) - } - if tasks[0].Status != "backlog" || tasks[0].Fields["status"] != "backlog" { - t.Errorf("inherited task fields=%#v status=%q, want status=backlog", tasks[0].Fields, tasks[0].Status) - } - want := map[string]string{"status": "review", "sprint": "24", "area": "backend"} - for key, value := range want { - if got := tasks[1].Fields[key]; got != value { - t.Errorf("Fields[%s]=%q, want %q", key, got, value) - } - } - if tasks[1].Status != "review" { - t.Errorf("Status=%q, want review", tasks[1].Status) - } - if tasks[1].Content != "override" { - t.Errorf("Content=%q, want custom-field tokens stripped", tasks[1].Content) - } -} - -// #458: the frontmatter `tasks:` key turns a note's checkboxes back into plain -// checkboxes. The server mirrors noteTasksMode in shared-domain; the accepted -// values must stay byte-identical across runtimes. -func TestParseTasksFrontmatterTasksOptOut(t *testing.T) { - checklist := "- [ ] Dune\n- [x] Hyperion\n- [ ] Blindsight due:2026-09-01\n" - for _, val := range []string{"false", "off", "False", "OFF", "\"false\""} { - body := "---\ntasks: " + val + "\n---\n" + checklist - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 0 { - t.Errorf("tasks: %s: expected no tasks, got %d", val, len(tasks)) - } - } - // Unrecognized values fall back to the pre-#458 behavior. - for _, val := range []string{"true", "yes", "everything"} { - body := "---\ntasks: " + val + "\n---\n" + checklist - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 3 { - t.Errorf("tasks: %s: expected 3 tasks, got %d", val, len(tasks)) - } - } -} - -func TestParseTasksFrontmatterTasksFalseWinsOverTaskTag(t *testing.T) { - body := "---\ntags: [task]\ntasks: false\n---\n\n- [ ] hidden\n" - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 0 { - t.Fatalf("expected tasks: false to suppress the file task too, got %d tasks", len(tasks)) - } -} - -func TestParseTasksFrontmatterTasksNoteKeepsFileTaskOnly(t *testing.T) { - body := "---\ntags: [task]\ntasks: note\nstatus: in-progress\ndue: 2026-09-01\n---\n\n- [ ] research\n- [x] outline\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 1 { - t.Fatalf("expected exactly the file task, got %d tasks", len(tasks)) - } - tk := tasks[0] - if tk.Kind != "file" || tk.ID != "inbox/t.md#task" { - t.Errorf("Kind=%q ID=%q, want file task", tk.Kind, tk.ID) - } - if !tk.InProgress || tk.Due != "2026-09-01" { - t.Errorf("InProgress=%v Due=%q, want frontmatter metadata intact", tk.InProgress, tk.Due) - } - // On a note without the task tag, `tasks: note` simply silences checkboxes. - plain := "---\ntasks: note\n---\n\n- [ ] a\n- [ ] b\n" - if tasks := ParseTasks("inbox/p.md", "p", FolderInbox, plain); len(tasks) != 0 { - t.Errorf("expected no tasks for tasks: note without a task tag, got %d", len(tasks)) - } -} - -func TestParseTasksWithIncludeExcluded(t *testing.T) { - body := "---\ntags: [task]\ntasks: false\n---\n\n- [ ] hidden\n- [ ] also hidden\n" - tasks := ParseTasksWith("inbox/t.md", "t", FolderInbox, body, ParseTasksOptions{IncludeExcluded: true}) - if len(tasks) != 3 { - t.Fatalf("expected file task + 2 inline with IncludeExcluded, got %d", len(tasks)) - } - if tasks[0].ID != "inbox/t.md#task" { - t.Errorf("first task ID=%q, want the file task", tasks[0].ID) - } - // Index counting is untouched by the gate, so ids stay stable. - if tasks[1].ID != "inbox/t.md#0" || tasks[2].ID != "inbox/t.md#1" { - t.Errorf("inline ids %q, %q, want #0 and #1", tasks[1].ID, tasks[2].ID) - } -} - -func TestExtractAssetEmbedsMatchesDesktopTargets(t *testing.T) { - cases := []struct { - name, body string - want []string - }{ - {"empty", "plain text", []string{}}, - {"JavaScript whitespace", "![](\u00a0) ![](first\u00a0part.png) ![[\ufeffdrawing.psd\ufeff]]", []string{"drawing.psd", "photo.png", "first"}}, - {"generic files", "![[design.psd|Source]] ![[file.zip#section]] ![[file.custom?download]] ![[Note#dot.png]]", []string{"design.psd", "file.zip#section", "file.custom?download"}}, - {"wiki assets only", "![[photo.png|Preview]] ![[brief.pdf]] ![[Other Note]] [[plain.png]]", []string{"photo.png", "brief.pdf"}}, - {"wiki before markdown with deduplication", "![](a.png) ![[b.png]] ![[a.png]] ![](b.png)", []string{"b.png", "a.png"}}, - {"decoded targets", "![Photo]( \"title\") ![](plus+sign.png) ![](%E6%97%A5.png)", []string{"assets/photo two.png", "plus+sign.png", "日.png"}}, - {"malformed escapes", "![](bad%GG.png) ![](%FF.png)", []string{"bad%GG.png", "%FF.png"}}, - {"remote and anchors", "![](https://example.com/x.png) ![](data:image/png;base64,abc) ![](custom_app:x) ![](#heading)", []string{}}, - {"code", "`![[inline.png]]`\n ~~~md\n![](fenced.png)\n ~~~\n![[visible.png]]", []string{"visible.png"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := ExtractAssetEmbeds(tc.body); !reflect.DeepEqual(got, tc.want) { - t.Fatalf("got %#v, want %#v", got, tc.want) - } - }) - } -} diff --git a/apps/server/internal/vault/safepath.go b/apps/server/internal/vault/safepath.go deleted file mode 100644 index 44698ac9..00000000 --- a/apps/server/internal/vault/safepath.go +++ /dev/null @@ -1,92 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "strings" -) - -var ErrPathEscape = errors.New("path escapes vault root") - -// ErrIsDirectory is returned when a caller asks to read a directory as a file, -// which a client does by accident whenever it treats a `.base` database folder -// as a note. It is classified here, from a stat the read already performs, -// rather than from the errno the read returns: Unix answers EISDIR but Windows -// answers ERROR_INVALID_FUNCTION ("Incorrect function"), so an errno test makes -// the same request a 400 on one platform and a 500 on another. -var ErrIsDirectory = errors.New("path is a directory, not a file") - -// SafeJoin cleans a user-supplied relative POSIX path and joins it onto -// `root`, refusing anything that resolves outside `root`. Any existing -// component of the joined path that is a symbolic link is resolved and -// must still resolve to a location inside `root`; otherwise ErrPathEscape -// is returned. Components that do not yet exist (write-create case) are -// left alone — they cannot be symlinks until they're created. -// -// The returned path is in the same namespace as `root` (i.e. rooted at -// the abs form of the caller's root, not the canonical form), so callers -// can still strip the root prefix to get a stable relative path. -func SafeJoin(root, rel string) (string, error) { - if root == "" { - return "", errors.New("root is empty") - } - rootAbs, err := filepath.Abs(root) - if err != nil { - return "", err - } - cleaned := filepath.Clean("/" + strings.TrimPrefix(rel, "/")) - joined := filepath.Join(rootAbs, filepath.FromSlash(cleaned)) - - relBack, err := filepath.Rel(rootAbs, joined) - if err != nil { - return "", err - } - if relBack == ".." || strings.HasPrefix(relBack, ".."+string(filepath.Separator)) { - return "", ErrPathEscape - } - - rootCanonical, err := filepath.EvalSymlinks(rootAbs) - if err != nil { - // Root not on disk yet (e.g. caller is about to MkdirAll). Without - // a canonical root we can't meaningfully evaluate symlink targets, - // so fall back to the lexical-only result. - return joined, nil - } - if relBack == "." { - return joined, nil - } - - parts := strings.Split(relBack, string(filepath.Separator)) - walk := rootAbs - for _, part := range parts { - walk = filepath.Join(walk, part) - info, err := os.Lstat(walk) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return joined, nil - } - return "", err - } - if info.Mode()&os.ModeSymlink != 0 { - target, err := filepath.EvalSymlinks(walk) - if err != nil { - return "", err - } - relTarget, err := filepath.Rel(rootCanonical, target) - if err != nil { - return "", err - } - if relTarget == ".." || strings.HasPrefix(relTarget, ".."+string(filepath.Separator)) { - return "", ErrPathEscape - } - walk = target - } - } - return joined, nil -} - -// ToPosix converts an OS-native path to forward-slash form. -func ToPosix(p string) string { - return filepath.ToSlash(p) -} diff --git a/apps/server/internal/vault/safepath_test.go b/apps/server/internal/vault/safepath_test.go deleted file mode 100644 index 2c132e9d..00000000 --- a/apps/server/internal/vault/safepath_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestSafeJoinLexical(t *testing.T) { - root := t.TempDir() - - cases := []struct { - name string - rel string - wantErr error - }{ - {"plain file", "note.md", nil}, - {"nested", "a/b/c.md", nil}, - {"leading slash", "/note.md", nil}, - {"dot only", ".", nil}, - // Leading ".." is neutralised by the leading "/" anchor before - // Clean(), so these resolve safely inside root rather than - // escaping. The escape path is exercised via symlinks in the - // dedicated tests below. - {"parent neutralised", "../escape.md", nil}, - {"deep parent neutralised", "a/../../escape.md", nil}, - {"absolute neutralised", "/../../etc/passwd", nil}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - abs, err := SafeJoin(root, tc.rel) - if tc.wantErr != nil { - if !errors.Is(err, tc.wantErr) { - t.Fatalf("expected %v, got err=%v abs=%q", tc.wantErr, err, abs) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - rootAbs, _ := filepath.Abs(root) - if !strings.HasPrefix(abs, rootAbs) { - t.Fatalf("result %q is not under root %q", abs, rootAbs) - } - }) - } -} - -func TestSafeJoinSymlinkEscape(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - target := filepath.Join(outside, "secret.txt") - if err := os.WriteFile(target, []byte("hush"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "evil.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - if _, err := SafeJoin(root, "evil.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape, got %v", err) - } -} - -func TestSafeJoinSymlinkInsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - target := filepath.Join(root, "real.md") - if err := os.WriteFile(target, []byte("hi"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "alias.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - abs, err := SafeJoin(root, "alias.md") - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if abs == "" { - t.Fatal("empty result") - } -} - -func TestSafeJoinSymlinkInPathSegment(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - - // /root/sneaky -> /outside (symlinked directory) - if err := os.Symlink(outside, filepath.Join(root, "sneaky")); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "leak.md"), []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := SafeJoin(root, "sneaky/leak.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape on symlinked subdir, got %v", err) - } -} - -func TestSafeJoinNonexistentTail(t *testing.T) { - root := t.TempDir() - abs, err := SafeJoin(root, "newfolder/new.md") - if err != nil { - t.Fatalf("expected success for non-existent path under root, got %v", err) - } - rootAbs, _ := filepath.Abs(root) - if !strings.HasPrefix(abs, rootAbs) { - t.Fatalf("result %q is not under root %q", abs, rootAbs) - } -} - -func TestSafeJoinRootMissing(t *testing.T) { - root := filepath.Join(t.TempDir(), "does-not-exist-yet") - abs, err := SafeJoin(root, "note.md") - if err != nil { - t.Fatalf("expected lexical fallback when root absent, got %v", err) - } - if !strings.HasSuffix(abs, "note.md") { - t.Fatalf("unexpected path %q", abs) - } -} diff --git a/apps/server/internal/vault/system_folder_paths_test.go b/apps/server/internal/vault/system_folder_paths_test.go deleted file mode 100644 index f150dc74..00000000 --- a/apps/server/internal/vault/system_folder_paths_test.go +++ /dev/null @@ -1,395 +0,0 @@ -package vault - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// remapVault returns a vault whose system folders live at custom paths. -func remapVault(t *testing.T, paths map[string]string) *Vault { - t.Helper() - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: paths, - }); err != nil { - t.Fatal(err) - } - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - for folder, want := range paths { - if got := settings.SystemFolderPaths[folder]; got != want { - t.Fatalf("settings.systemFolderPaths[%s] = %q, want %q", folder, got, want) - } - } - return v -} - -func TestRemappedInboxIsReadAndWrittenInTheCustomDirectory(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket"}) - - if _, err := os.Stat(filepath.Join(v.Root(), "bucket")); err != nil { - t.Fatalf("remapped inbox directory was not created: %v", err) - } - - meta, err := v.CreateNote(FolderInbox, "Remapped", "") - if err != nil { - t.Fatal(err) - } - if meta.Path != "bucket/Remapped.md" { - t.Fatalf("created note path = %q, want bucket/Remapped.md", meta.Path) - } - if _, err := os.Stat(filepath.Join(v.Root(), "bucket", "Remapped.md")); err != nil { - t.Fatalf("note did not land in the remapped inbox: %v", err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "inbox", "Remapped.md")); err == nil { - t.Fatal("note was written to the default inbox directory") - } - - read, err := v.ReadNote("bucket/Remapped.md") - if err != nil { - t.Fatal(err) - } - if read.Folder != FolderInbox { - t.Fatalf("read note folder = %q, want inbox", read.Folder) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - found := false - for _, note := range notes { - if note.Path == "bucket/Remapped.md" { - found = true - if note.Folder != FolderInbox { - t.Fatalf("listed note folder = %q, want inbox", note.Folder) - } - } - } - if !found { - t.Fatalf("remapped inbox note missing from ListNotes: %+v", notes) - } -} - -func TestRemappedTrashRoundTripKeepsTheSubfolder(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket", "trash": "deleted"}) - - created, err := v.CreateNote(FolderInbox, "Doomed", "Projects") - if err != nil { - t.Fatal(err) - } - if created.Path != "bucket/Projects/Doomed.md" { - t.Fatalf("created note path = %q, want bucket/Projects/Doomed.md", created.Path) - } - - trashed, err := v.MoveToTrash(created.Path) - if err != nil { - t.Fatal(err) - } - if trashed.Path != "deleted/Projects/Doomed.md" || trashed.Folder != FolderTrash { - t.Fatalf("trashed note = {%q %q}, want {deleted/Projects/Doomed.md trash}", trashed.Path, trashed.Folder) - } - - restored, err := v.RestoreFromTrash(trashed.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "bucket/Projects/Doomed.md" || restored.Folder != FolderInbox { - t.Fatalf("restored note = {%q %q}, want {bucket/Projects/Doomed.md inbox}", restored.Path, restored.Folder) - } -} - -func TestPrimaryRootListsADefaultNameThatWasRemappedAway(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesRoot, - SystemFolderPaths: map[string]string{"quick": "Fast"}, - }); err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - - // `quick/` is an ordinary user folder now that quick lives in `Fast/`, so - // the root walk must stop skipping it. - write := func(rel string) { - t.Helper() - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte("body"), 0o600); err != nil { - t.Fatal(err) - } - } - write("quick/User.md") - write("Fast/Scratch.md") - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - got := map[string]NoteFolder{} - for _, note := range notes { - got[note.Path] = note.Folder - } - if folder, ok := got["quick/User.md"]; !ok || folder != FolderInbox { - t.Fatalf("quick/User.md = (%q, %v), want (inbox, true); listing: %v", folder, ok, got) - } - if folder, ok := got["Fast/Scratch.md"]; !ok || folder != FolderQuick { - t.Fatalf("Fast/Scratch.md = (%q, %v), want (quick, true); listing: %v", folder, ok, got) - } -} - -func TestSettingsRoundTripKeepsSystemFolderPaths(t *testing.T) { - v := remapVault(t, map[string]string{"archive": "cold-storage"}) - - written, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: map[string]string{"archive": "cold-storage"}, - }) - if err != nil { - t.Fatal(err) - } - // SetSettings' return value is what the HTTP layer echoes to the client. - if got := written.SystemFolderPaths["archive"]; got != "cold-storage" { - t.Fatalf("SetSettings returned systemFolderPaths[archive] = %q, want cold-storage", got) - } -} - -func TestGetSettingsCachesUntilTheFileChanges(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket"}) - - // White-box: poison the cached copy. A second read that still reports the - // poisoned value proves the file was not re-read and re-parsed. - v.settingsMu.Lock() - if v.settingsCache == nil { - v.settingsMu.Unlock() - t.Fatal("settings were not cached after a read") - } - v.settingsCache.settings.SystemFolderPaths["inbox"] = "sentinel" - v.settingsMu.Unlock() - - cached, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if cached.SystemFolderPaths["inbox"] != "sentinel" { - t.Fatal("GetSettings re-parsed vault.json even though it had not changed") - } - - // The caller gets a copy, so mutating it cannot corrupt the cache. - cached.SystemFolderPaths["inbox"] = "mutated" - again, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if again.SystemFolderPaths["inbox"] != "sentinel" { - t.Fatal("mutating the returned settings leaked into the cache") - } - - // A vault.json written behind the vault's back is picked up: the mtime and - // size no longer match the cached key. - raw, err := json.Marshal(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: map[string]string{"inbox": "elsewhere", "trash": "deleted"}, - }) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(v.settingsPath(), raw, 0o600); err != nil { - t.Fatal(err) - } - fresh, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if fresh.SystemFolderPaths["inbox"] != "elsewhere" || fresh.SystemFolderPaths["trash"] != "deleted" { - t.Fatalf("edited vault.json was not picked up: %+v", fresh.SystemFolderPaths) - } -} - -func TestFolderForRelativePathPrefersOverridesOverDefaultNames(t *testing.T) { - cases := []struct { - name string - rel string - paths map[string]string - want NoteFolder - ok bool - }{ - { - name: "default names classify with no overrides", - rel: "archive/Note.md", - want: FolderArchive, - ok: true, - }, - { - name: "remapped folder classifies by its custom path", - rel: "deleted/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderTrash, - ok: true, - }, - { - name: "a default name left behind is a user folder", - rel: "trash/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderInbox, - ok: true, - }, - { - name: "a remapped-away inbox name is a user folder", - rel: "inbox/Note.md", - paths: map[string]string{"inbox": "bucket"}, - want: FolderInbox, - ok: true, - }, - { - name: "a swap classifies by location, not by name", - rel: "archive/Note.md", - paths: map[string]string{"inbox": "archive", "archive": "bucket"}, - want: FolderInbox, - ok: true, - }, - { - name: "the swapped-out folder classifies too", - rel: "bucket/Note.md", - paths: map[string]string{"inbox": "archive", "archive": "bucket"}, - want: FolderArchive, - ok: true, - }, - { - // The on-disk case is whatever the directory was created with, so - // classification is case-insensitive like the TS side. (#186) - name: "a default name classifies whatever its case", - rel: "Archive/Note.md", - want: FolderArchive, - ok: true, - }, - { - name: "a custom path classifies whatever its case", - rel: "DELETED/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderTrash, - ok: true, - }, - { - name: "reserved root names stay unclassified", - rel: "assets/image.png", - ok: false, - }, - { - name: "reserved root names stay unclassified with overrides too", - rel: "attachements/image.png", - paths: map[string]string{"inbox": "bucket"}, - ok: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - folder, ok := FolderForRelativePathWithSettings(tc.rel, tc.paths) - if ok != tc.ok || (tc.ok && folder != tc.want) { - t.Fatalf("FolderForRelativePathWithSettings(%q, %v) = (%q, %v), want (%q, %v)", - tc.rel, tc.paths, folder, ok, tc.want, tc.ok) - } - }) - } -} - -func TestNormalizeSystemFolderPathsRejectsCollisions(t *testing.T) { - cases := []struct { - name string - raw map[string]string - want map[string]string - }{ - { - name: "a swap collapses to no overrides at all", - raw: map[string]string{"inbox": "archive", "archive": "inbox"}, - want: nil, - }, - { - name: "taking another folder's default name is rejected", - raw: map[string]string{"inbox": "trash"}, - want: nil, - }, - { - name: "taking another folder's default name is rejected case-insensitively", - raw: map[string]string{"inbox": "Trash"}, - want: nil, - }, - { - name: "the rejection is per entry", - raw: map[string]string{"inbox": "archive", "trash": "deleted"}, - want: map[string]string{"trash": "deleted"}, - }, - { - // Which of the two survives is the sweep order (inbox, quick, - // archive, trash), the same order the TS normalizer uses. - name: "two folders may not share one custom path", - raw: map[string]string{"quick": "shared", "trash": "shared"}, - want: map[string]string{"trash": "shared"}, - }, - { - name: "a three-way rotation collapses too", - raw: map[string]string{"inbox": "quick", "quick": "archive", "archive": "inbox"}, - want: nil, - }, - { - name: "a valid override survives its rejected swap partners", - raw: map[string]string{"inbox": "archive", "archive": "inbox", "trash": "deleted"}, - want: map[string]string{"trash": "deleted"}, - }, - { - // Only the OTHER folders' defaults are off limits; recasing your own - // is a real rename on a case-preserving filesystem. - name: "a folder may recase its own default name", - raw: map[string]string{"archive": "Archive"}, - want: map[string]string{"archive": "Archive"}, - }, - { - name: "reserved names are rejected", - raw: map[string]string{"trash": "assets"}, - want: nil, - }, - { - name: "reserved names are rejected case-insensitively", - raw: map[string]string{"trash": "Comments"}, - want: nil, - }, - { - name: "a folder's own default name is dropped as a no-op", - raw: map[string]string{"trash": "trash"}, - want: nil, - }, - { - name: "independent custom paths survive", - raw: map[string]string{"inbox": "bucket", "trash": "deleted"}, - want: map[string]string{"inbox": "bucket", "trash": "deleted"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := NormalizeSystemFolderPaths(tc.raw) - if len(got) != len(tc.want) { - t.Fatalf("NormalizeSystemFolderPaths(%v) = %v, want %v", tc.raw, got, tc.want) - } - for key, want := range tc.want { - if got[key] != want { - t.Fatalf("NormalizeSystemFolderPaths(%v) = %v, want %v", tc.raw, got, tc.want) - } - } - }) - } -} diff --git a/apps/server/internal/vault/task_roundtrip_contract_test.go b/apps/server/internal/vault/task_roundtrip_contract_test.go deleted file mode 100644 index bc5411f7..00000000 --- a/apps/server/internal/vault/task_roundtrip_contract_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package vault - -import ( - "encoding/json" - "os" - "reflect" - "testing" -) - -func TestSharedTaskRoundtripContract(t *testing.T) { - data, err := os.ReadFile("testdata/task-roundtrip.json") - if err != nil { - t.Fatal(err) - } - var fixture struct { - SchemaVersion int `json:"schemaVersion"` - Cases []struct { - ID string `json:"id"` - Note struct { - Path string `json:"path"` - Title string `json:"title"` - Folder NoteFolder `json:"folder"` - } `json:"note"` - Body string `json:"body"` - ExpectedBody string `json:"expectedBody"` - TaskIndex int `json:"taskIndex"` - ExpectedBefore map[string]any `json:"expectedBefore"` - ExpectedAfter map[string]any `json:"expectedAfter"` - ExpectedTaskCount int `json:"expectedTaskCount"` - } `json:"cases"` - } - if err := json.Unmarshal(data, &fixture); err != nil { - t.Fatal(err) - } - if fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 { - t.Fatal("unsupported or empty task contract fixture") - } - for _, tc := range fixture.Cases { - t.Run(tc.ID, func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - var originalID string - // The client transforms Markdown; Go stores those exact bytes and - // parses the resulting task state for the next client read. - for index, phase := range []struct { - body string - want map[string]any - }{{tc.Body, tc.ExpectedBefore}, {tc.ExpectedBody, tc.ExpectedAfter}} { - if _, err := v.WriteNote(tc.Note.Path, phase.body); err != nil { - t.Fatal(err) - } - note, err := v.ReadNote(tc.Note.Path) - if err != nil { - t.Fatal(err) - } - if note.Body != phase.body { - t.Fatal("storage changed Markdown bytes") - } - tasks := ParseTasks(tc.Note.Path, tc.Note.Title, tc.Note.Folder, note.Body) - if len(tasks) != tc.ExpectedTaskCount || tc.TaskIndex < 0 || tc.TaskIndex >= len(tasks) { - t.Fatalf("got %d tasks, want %d with index %d", len(tasks), tc.ExpectedTaskCount, tc.TaskIndex) - } - task := tasks[tc.TaskIndex] - if index == 0 { - originalID = task.ID - } else if task.ID != originalID { - t.Fatal("task identity changed after editing") - } - encoded, err := json.Marshal(task) - if err != nil { - t.Fatal(err) - } - var actual map[string]any - if err := json.Unmarshal(encoded, &actual); err != nil { - t.Fatal(err) - } - for field, want := range phase.want { - if !reflect.DeepEqual(actual[field], want) { - t.Errorf("phase %d field %s: got %#v, want %#v", index, field, actual[field], want) - } - } - } - }) - } -} diff --git a/apps/server/internal/vault/tasks_exclude.go b/apps/server/internal/vault/tasks_exclude.go deleted file mode 100644 index 5fa8e675..00000000 --- a/apps/server/internal/vault/tasks_exclude.go +++ /dev/null @@ -1,87 +0,0 @@ -package vault - -import "strings" - -// The vault-level "exclude this folder from Tasks" list (#458). Byte-for-byte -// mirror of packages/shared-domain/src/tasks-excluded-folders.ts: change both -// together. Entries are vault-relative directory paths exactly as they exist -// on disk, so remapped system folders (#115) need no translation. - -// normalizeTasksExcludedFolder validates one entry: forward slashes, no empty -// or dot segments, no traversal. Returns "" when invalid. -func normalizeTasksExcludedFolder(value string) string { - parts := []string{} - for _, seg := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") { - s := strings.TrimSpace(seg) - if s == "" { - continue - } - if s == "." || s == ".." { - return "" - } - parts = append(parts, s) - } - if len(parts) == 0 { - return "" - } - joined := strings.Join(parts, "/") - if len(joined) > 512 { - return "" - } - return joined -} - -// normalizeTasksExcludedFolders drops invalid entries and duplicates, -// preserving order. -func normalizeTasksExcludedFolders(values []string) []string { - out := []string{} - seen := map[string]struct{}{} - for _, entry := range values { - cleaned := normalizeTasksExcludedFolder(entry) - if cleaned == "" { - continue - } - if _, dup := seen[cleaned]; dup { - continue - } - seen[cleaned] = struct{}{} - out = append(out, cleaned) - } - return out -} - -// normalizeTasksSettings carries the Tasks-system settings through the -// settings round-trip: a validated exclusion list, or nil so vault.json stays -// free of empty stubs. -func normalizeTasksSettings(value *TasksSettings) *TasksSettings { - if value == nil { - return nil - } - excluded := normalizeTasksExcludedFolders(value.ExcludedFolders) - if len(excluded) == 0 { - return nil - } - return &TasksSettings{ExcludedFolders: excluded} -} - -// tasksExcludedFolders reads the exclusion list off already-normalized -// settings. -func tasksExcludedFolders(settings VaultSettings) []string { - if settings.Tasks == nil { - return nil - } - return settings.Tasks.ExcludedFolders -} - -// isPathExcludedFromTasks reports whether a vault-relative POSIX path lives -// inside any excluded folder. Segment-prefix match, case-sensitive like the -// rest of the vault layer: `inbox/Books` excludes `inbox/Books/x.md` and -// `inbox/Books/sub/y.md`, never `inbox/Bookshelf.md`. -func isPathExcludedFromTasks(relPath string, excluded []string) bool { - for _, folder := range excluded { - if relPath == folder || strings.HasPrefix(relPath, folder+"/") { - return true - } - } - return false -} diff --git a/apps/server/internal/vault/tasks_exclude_test.go b/apps/server/internal/vault/tasks_exclude_test.go deleted file mode 100644 index 1d3ae4e7..00000000 --- a/apps/server/internal/vault/tasks_exclude_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -// #458: mirrors tasks-excluded-folders.test.ts in shared-domain; the rules -// must stay byte-compatible across runtimes. - -func TestNormalizeTasksExcludedFolders(t *testing.T) { - got := normalizeTasksExcludedFolders([]string{ - "inbox/Books", - "../x", - "inbox/Books/", - "/inbox//Books", - "inbox\\Books", - "archive/Old", - "./inbox", - " ", - }) - want := []string{"inbox/Books", "archive/Old"} - if !reflect.DeepEqual(got, want) { - t.Errorf("normalizeTasksExcludedFolders = %v, want %v", got, want) - } -} - -func TestIsPathExcludedFromTasks(t *testing.T) { - excluded := []string{"inbox/Books", "archive/Old Projects"} - cases := []struct { - path string - want bool - }{ - {"inbox/Books/dune.md", true}, - {"inbox/Books/scifi/blindsight.md", true}, - {"archive/Old Projects/site.md", true}, - {"inbox/Bookshelf.md", false}, - {"inbox/Books.md", false}, - {"inbox/books/dune.md", false}, // case-sensitive - {"quick/note.md", false}, - } - for _, tc := range cases { - if got := isPathExcludedFromTasks(tc.path, excluded); got != tc.want { - t.Errorf("isPathExcludedFromTasks(%q) = %v, want %v", tc.path, got, tc.want) - } - } - if isPathExcludedFromTasks("inbox/Books/dune.md", nil) { - t.Error("empty exclusion list must never match") - } -} - -// End to end through New → GetSettings → ScanTasks, so the settings cache and -// cloneSettings are on the hook too: the defensive copy silently dropped the -// Tasks object once, and only a live-server smoke test caught it. -func TestScanTasksHonorsExcludedFolders(t *testing.T) { - root := t.TempDir() - mustWrite := func(rel, body string) { - t.Helper() - abs := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - mustWrite(".zennotes/vault.json", `{"tasks":{"excludedFolders":["inbox/Books"]}}`) - mustWrite("inbox/Real Work.md", "- [ ] ship it\n") - mustWrite("inbox/Books/Backlog.md", "- [ ] Excession\n- [ ] Player of Games\n") - - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - tasks, err := v.ScanTasks() - if err != nil { - t.Fatal(err) - } - for _, tk := range tasks { - if isPathExcludedFromTasks(tk.SourcePath, []string{"inbox/Books"}) { - t.Errorf("excluded-folder task leaked into the default scan: %s", tk.ID) - } - } - if len(tasks) != 1 { - t.Fatalf("expected only the Real Work task, got %d tasks", len(tasks)) - } - - all, err := v.ScanTasksWith(ParseTasksOptions{IncludeExcluded: true}) - if err != nil { - t.Fatal(err) - } - if len(all) != 3 { - t.Fatalf("expected 3 tasks with IncludeExcluded, got %d", len(all)) - } -} diff --git a/apps/server/internal/vault/templates.go b/apps/server/internal/vault/templates.go deleted file mode 100644 index 0cf9c752..00000000 --- a/apps/server/internal/vault/templates.go +++ /dev/null @@ -1,229 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// Custom-template file I/O for a vault served over HTTP. Templates are plain -// `.md` files in the flat `.zennotes/templates/` directory, and this layer is -// deliberately parse-free: it moves raw bytes, and the client owns the -// frontmatter format (`packages/shared-domain/src/template-files.ts`). -// -// SYNCED COPY: the filename rules here (safeTemplateSlug, uniqueTemplateSlug, -// resolveTemplatePath) mirror `apps/desktop/src/main/templates.ts` byte for -// byte. A vault served remotely today is opened locally tomorrow, and a -// template's id is `custom:`, so both sides must land the same -// bytes on the same filename. Change one, change both. - -const templatesRelDir = ".zennotes/templates" - -var ErrInvalidTemplate = errors.New("invalid template request") - -// CustomTemplateFile matches bridge-contract's CustomTemplateFile. -type CustomTemplateFile struct { - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` -} - -// WriteTemplateInput matches bridge-contract's WriteTemplateInput. -type WriteTemplateInput struct { - Slug string `json:"slug"` - Raw string `json:"raw"` - PreviousSourcePath string `json:"previousSourcePath,omitempty"` -} - -func templateDir(root string) string { - return filepath.Join(root, ".zennotes", "templates") -} - -func templateSourcePath(name string) string { - return templatesRelDir + "/" + name -} - -func templateFilenameStem(sourcePath string) string { - name := sourcePath[strings.LastIndex(sourcePath, "/")+1:] - if strings.EqualFold(filepath.Ext(name), ".md") { - return name[:len(name)-len(".md")] - } - return name -} - -// safeTemplateSlug keeps lowercase letters, digits and dashes; every run of -// anything else becomes one dash, and leading and trailing dashes go. Dashes -// that were already there stay as typed (`a--b` remains `a--b`): that is what -// the desktop does, and the renderer's slugifyTemplateName has collapsed them -// before the request is made anyway. -func safeTemplateSlug(slug string) string { - var out strings.Builder - inRun := false - for _, r := range strings.ToLower(slug) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - if inRun { - out.WriteByte('-') - inRun = false - } - out.WriteRune(r) - continue - } - inRun = true - } - if inRun { - out.WriteByte('-') - } - cleaned := strings.Trim(out.String(), "-") - if cleaned == "" { - return "template" - } - return cleaned -} - -// resolveTemplatePath turns a vault-relative sourcePath into an absolute one, -// refusing anything outside the flat templates directory (no traversal, no -// subdirectories, no symlinked escape) and anything that is not a `.md` file. -func (v *Vault) resolveTemplatePath(sourcePath string) (string, error) { - abs, err := SafeJoin(v.root, sourcePath) - if err != nil { - return "", err - } - rel, err := filepath.Rel(templateDir(v.root), abs) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { - return "", fmt.Errorf("%w: refusing template path outside templates dir: %s", ErrInvalidTemplate, sourcePath) - } - if !strings.EqualFold(filepath.Ext(rel), ".md") { - return "", fmt.Errorf("%w: template path must be a .md file: %s", ErrInvalidTemplate, sourcePath) - } - return abs, nil -} - -// uniqueTemplateSlug picks a free slug. Editing the same file keeps its slug -// (the write lands in place); otherwise the slug is de-duplicated against the -// files already there (adr, adr-2, adr-3, ...). -func uniqueTemplateSlug(dir, base, previousSourcePath string) string { - prevStem := "" - if previousSourcePath != "" { - prevStem = templateFilenameStem(previousSourcePath) - } - candidate := base - for n := 2; ; n++ { - if candidate == prevStem { - return candidate - } - if _, err := os.Lstat(filepath.Join(dir, candidate+".md")); errors.Is(err, os.ErrNotExist) { - return candidate - } - candidate = fmt.Sprintf("%s-%d", base, n) - } -} - -// ListTemplates returns every custom template with its raw bytes. A vault -// without a templates directory has no templates rather than an error, and -// an unreadable file is skipped, as the desktop does. -func (v *Vault) ListTemplates() ([]CustomTemplateFile, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(templateDir(v.root)) - if errors.Is(err, os.ErrNotExist) { - return []CustomTemplateFile{}, nil - } - if err != nil { - return nil, err - } - out := make([]CustomTemplateFile, 0, len(entries)) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - continue - } - sourcePath := templateSourcePath(name) - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - continue - } - raw, err := os.ReadFile(abs) - if err != nil { - continue - } - out = append(out, CustomTemplateFile{SourcePath: sourcePath, Raw: string(raw)}) - } - sort.Slice(out, func(i, j int) bool { return out[i].SourcePath < out[j].SourcePath }) - return out, nil -} - -func (v *Vault) ReadTemplate(sourcePath string) (string, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return "", err - } - raw, err := os.ReadFile(abs) - if err != nil { - return "", err - } - return string(raw), nil -} - -// WriteTemplate saves a template under a slug derived from the request, and -// removes the file it replaces when an edit changed the slug. The previous -// path is validated before anything is written, so a bad one cannot leave a -// stray new file behind. -func (v *Vault) WriteTemplate(input WriteTemplateInput) (CustomTemplateFile, error) { - v.mu.Lock() - defer v.mu.Unlock() - var previous string - if input.PreviousSourcePath != "" { - abs, err := v.resolveTemplatePath(input.PreviousSourcePath) - if err != nil { - return CustomTemplateFile{}, err - } - previous = abs - } - dir := templateDir(v.root) - slug := uniqueTemplateSlug(dir, safeTemplateSlug(input.Slug), input.PreviousSourcePath) - sourcePath := templateSourcePath(slug + ".md") - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return CustomTemplateFile{}, err - } - if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { - return CustomTemplateFile{}, err - } - if previous != "" && previous != abs { - // On a case-insensitive filesystem two differently-cased paths can name - // the SAME file, and writeFileAtomic just landed the new content on it; - // a spelling compare would then delete the template that was just - // saved. Compare file identity, not path strings. - sameFile := false - if prevInfo, statErr := os.Stat(previous); statErr == nil { - if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { - sameFile = true - } - } - if !sameFile { - if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { - return CustomTemplateFile{}, err - } - } - } - return CustomTemplateFile{SourcePath: sourcePath, Raw: input.Raw}, nil -} - -// DeleteTemplate removes a template; a file that is already gone is a -// success, as it is on the desktop. -func (v *Vault) DeleteTemplate(sourcePath string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} diff --git a/apps/server/internal/vault/templates_test.go b/apps/server/internal/vault/templates_test.go deleted file mode 100644 index d6bff8af..00000000 --- a/apps/server/internal/vault/templates_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func templateTestVault(t *testing.T) (*Vault, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - return v, root -} - -// The slug rules are a synced copy of the desktop module; these cases are the -// desktop's behaviour, spelled out so a drift on either side fails here. -func TestSafeTemplateSlugMirrorsDesktop(t *testing.T) { - cases := map[string]string{ - "Réunion Hebdo!": "r-union-hebdo", - " ADR ": "adr", - "a--b": "a--b", - "a - b": "a---b", - "Weekly Review 2026": "weekly-review-2026", - "UPPER_case.name": "upper-case-name", - "": "template", - "---": "template", - "!!!": "template", - } - for input, want := range cases { - if got := safeTemplateSlug(input); got != want { - t.Errorf("safeTemplateSlug(%q) = %q, want %q", input, got, want) - } - } - stems := map[string]string{ - ".zennotes/templates/adr.md": "adr", - ".zennotes/templates/adr.MD": "adr", - ".zennotes/templates/x.y.md": "x.y", - "adr": "adr", - } - for input, want := range stems { - if got := templateFilenameStem(input); got != want { - t.Errorf("templateFilenameStem(%q) = %q, want %q", input, got, want) - } - } -} - -func TestWriteTemplateDedupesAndKeepsSlugOnEdit(t *testing.T) { - v, root := templateTestVault(t) - - first, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}) - if err != nil { - t.Fatal(err) - } - if first.SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("first sourcePath = %q", first.SourcePath) - } - second, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v2"}) - if err != nil { - t.Fatal(err) - } - if second.SourcePath != ".zennotes/templates/adr-2.md" { - t.Fatalf("duplicate slug landed on %q, want adr-2.md", second.SourcePath) - } - - edited, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1 edited", PreviousSourcePath: first.SourcePath}) - if err != nil { - t.Fatal(err) - } - if edited.SourcePath != first.SourcePath { - t.Fatalf("editing in place moved the file to %q", edited.SourcePath) - } - body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "adr.md")) - if err != nil || string(body) != "v1 edited" { - t.Fatalf("edit did not land: %q (%v)", body, err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr-3.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("editing in place must not create adr-3.md: %v", err) - } - - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/adr-2.md" || files[1].SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("list = %+v", files) - } - entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")) - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("atomic write left its scratch file behind: %s", entry.Name()) - } - } -} - -func TestWriteTemplateRenameRemovesPrevious(t *testing.T) { - v, root := templateTestVault(t) - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); err != nil { - t.Fatal(err) - } - renamed, err := v.WriteTemplate(WriteTemplateInput{Slug: "Decision Record", Raw: "v2", PreviousSourcePath: ".zennotes/templates/adr.md"}) - if err != nil { - t.Fatal(err) - } - if renamed.SourcePath != ".zennotes/templates/decision-record.md" { - t.Fatalf("renamed sourcePath = %q", renamed.SourcePath) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("previous file should be gone after the rename: %v", err) - } - raw, err := v.ReadTemplate(renamed.SourcePath) - if err != nil || raw != "v2" { - t.Fatalf("read after rename = %q (%v)", raw, err) - } - // Deleting twice is fine: the desktop's rm --force semantics. - if err := v.DeleteTemplate(renamed.SourcePath); err != nil { - t.Fatal(err) - } - if err := v.DeleteTemplate(renamed.SourcePath); err != nil { - t.Fatalf("second delete should be a no-op, got %v", err) - } - if _, err := v.ReadTemplate(renamed.SourcePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("read after delete = %v, want ErrNotExist", err) - } -} - -func TestTemplatePathsMustStayInsideTemplatesDir(t *testing.T) { - v, root := templateTestVault(t) - for _, bad := range []string{ - "../../etc/passwd", - "/etc/passwd", - ".zennotes/templates/../../inbox/A.md", - ".zennotes/templates/sub/dir.md", - ".zennotes/templates/not-markdown.txt", - ".zennotes/templates", - "inbox/A.md", - "", - } { - if _, err := v.ReadTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { - t.Errorf("ReadTemplate(%q) = %v, want an invalid-path error", bad, err) - } - if err := v.DeleteTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { - t.Errorf("DeleteTemplate(%q) = %v, want an invalid-path error", bad, err) - } - } - // A template delete can never reach a note. - if _, err := os.Stat(filepath.Join(root, "inbox", "A.md")); err != nil { - t.Fatalf("note went missing: %v", err) - } - // A bad previous path fails before anything is written. - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1", PreviousSourcePath: "inbox/A.md"}); !errors.Is(err, ErrInvalidTemplate) { - t.Fatalf("write with a note as previous = %v, want ErrInvalidTemplate", err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("rejected write left a file behind: %v", err) - } -} - -func TestTemplatesRejectSymlinkedTemplatesDir(t *testing.T) { - v, root := templateTestVault(t) - external := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".zennotes"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, filepath.Join(root, ".zennotes", "templates")); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); !errors.Is(err, ErrPathEscape) { - t.Fatalf("write through a symlinked templates dir = %v, want ErrPathEscape", err) - } - if entries, _ := os.ReadDir(external); len(entries) != 0 { - t.Fatalf("write escaped into %s: %v", external, entries) - } - if err := os.WriteFile(filepath.Join(external, "leak.md"), []byte("outside"), 0o600); err != nil { - t.Fatal(err) - } - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 0 { - t.Fatalf("list followed the symlink: %+v", files) - } -} - -func TestListTemplatesSkipsDotfilesDirsAndNonMarkdown(t *testing.T) { - v, root := templateTestVault(t) - dir := filepath.Join(root, ".zennotes", "templates") - if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { - t.Fatal(err) - } - for name, body := range map[string]string{ - "adr.md": "adr", - "Weekly.MD": "weekly", - ".draft.md": "hidden", - "notes.txt": "text", - "nested/x.md": "nested", - } { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/Weekly.MD" || files[0].Raw != "weekly" || files[1].SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("list = %+v", files) - } - empty, root2 := templateTestVault(t) - _ = root2 - files, err = empty.ListTemplates() - if err != nil || len(files) != 0 { - t.Fatalf("vault without a templates dir: %+v, %v", files, err) - } -} diff --git a/apps/server/internal/vault/testdata/task-roundtrip.json b/apps/server/internal/vault/testdata/task-roundtrip.json deleted file mode 100644 index b3988cac..00000000 --- a/apps/server/internal/vault/testdata/task-roundtrip.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "schemaVersion": 1, - "cases": [ - { - "id": "reschedule-in-progress-task-without-changing-other-content", - "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, - "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", - "taskIndex": 0, - "due": "2026-09-16", - "expectedBefore": { "due": "2026-09-15", "inProgress": true }, - "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", - "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, - "expectedTaskCount": 2 - }, - { - "id": "assign-local-today-near-midnight", - "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, - "body": "# Today\n\n- [ ] Review notes\n", - "taskIndex": 0, - "localNow": [2026, 9, 15, 0, 15], - "expectedBefore": { "checked": false }, - "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", - "expectedAfter": { "due": "2026-09-15", "checked": false }, - "expectedTaskCount": 1 - }, - { - "id": "assign-local-today-late-at-night", - "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, - "body": "# Today\n\n- [/] Review notes\n", - "taskIndex": 0, - "localNow": [2026, 9, 15, 23, 45], - "expectedBefore": { "inProgress": true }, - "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", - "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, - "expectedTaskCount": 1 - } - ] -} diff --git a/apps/server/internal/vault/testdata/task-roundtrip.json.source.json b/apps/server/internal/vault/testdata/task-roundtrip.json.source.json deleted file mode 100644 index c9f327de..00000000 --- a/apps/server/internal/vault/testdata/task-roundtrip.json.source.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sourceRepository": "https://github.com/ZenNotes/zennotes", - "sourcePath": "packages/bridge-contract/fixtures/task-roundtrip.json", - "sha256": "59705ba724a1a96822ec47a2cbcc7fd09e140036972ad1769869eac9b4cac37f" -} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go deleted file mode 100644 index 8911b067..00000000 --- a/apps/server/internal/vault/types.go +++ /dev/null @@ -1,470 +0,0 @@ -package vault - -import ( - "path/filepath" - "strings" -) - -// Types in this file mirror the TypeScript interfaces in -// `src/shared/ipc.ts` and `src/shared/tasks.ts`. The JSON tags must -// match the TS field names exactly so the client can consume the -// responses without translation. - -type NoteFolder string -type PrimaryNotesLocation string -type FolderIconID string -type FolderColorID string - -const ( - FolderInbox NoteFolder = "inbox" - FolderQuick NoteFolder = "quick" - FolderArchive NoteFolder = "archive" - FolderTrash NoteFolder = "trash" - - PrimaryNotesInbox PrimaryNotesLocation = "inbox" - PrimaryNotesRoot PrimaryNotesLocation = "root" - DefaultDailyNotesDirectory = "Daily Notes" - DefaultDailyNoteTitlePattern = "yyyy-MM-dd" - DefaultDailyNoteLocale = "system" - DefaultWeeklyNotesDirectory = "Weekly Notes" - DefaultWeeklyNoteTitlePattern = "yyyy-'W'ww" - DefaultWeeklyNoteLocale = "system" - DefaultMonthlyNotesDirectory = "Monthly Notes" - DefaultMonthlyNoteTitlePattern = "yyyy-MM" - DefaultMonthlyNoteLocale = "system" -) - -func IsValidFolder(f NoteFolder) bool { - switch f { - case FolderInbox, FolderQuick, FolderArchive, FolderTrash: - return true - } - return false -} - -var AllFolders = []NoteFolder{FolderInbox, FolderQuick, FolderArchive, FolderTrash} - -var defaultFolderPaths = map[NoteFolder]string{ - FolderInbox: string(FolderInbox), - FolderQuick: string(FolderQuick), - FolderArchive: string(FolderArchive), - FolderTrash: string(FolderTrash), -} - -var reservedFolderPathNames = map[string]struct{}{ - "assets": {}, - ".zennotes": {}, - "attachements": {}, - "_assets": {}, - "deleted-assets": {}, - "comments": {}, -} - -func isValidFolderPath(p string) bool { - if p == "" || len(p) > 128 { - return false - } - if strings.Contains(p, "/") || strings.Contains(p, "\\") { - return false - } - if p == "." || p == ".." || strings.HasPrefix(p, ".") { - return false - } - for _, c := range p { - if c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || - c == '|' || c == '#' || c == '^' || c == '[' || c == ']' { - return false - } - } - if _, reserved := reservedFolderPathNames[strings.ToLower(p)]; reserved { - return false - } - return true -} - -// NormalizeSystemFolderPaths validates a raw systemFolderPaths map the way the -// vault settings reader does. Exported so callers that read vault.json without -// going through GetSettings (the watcher) classify against the same paths the -// vault itself uses. Mirrors normalizeSystemFolderPaths in -// packages/shared-domain/src/system-folder-paths.ts. -func NormalizeSystemFolderPaths(raw map[string]string) map[string]string { - return normalizeSystemFolderPaths(raw) -} - -func normalizeSystemFolderPaths(raw map[string]string) map[string]string { - if raw == nil { - return nil - } - next := map[string]string{} - for _, folder := range AllFolders { - val, ok := raw[string(folder)] - if !ok || val == "" { - continue - } - val = strings.TrimSpace(val) - if !isValidFolderPath(val) { - continue - } - if val == string(folder) { - continue - } - // Never let a folder claim ANOTHER folder's default name, even when that - // other folder has moved out of the way: {inbox: "archive", archive: - // "inbox"} resolves without collision, and the swap it describes reads - // backwards on every surface that classifies a path by its top segment - // (and in every other app looking at the same directory). - if claimsAnotherDefaultName(folder, val) { - continue - } - next[string(folder)] = val - } - changed := true - for changed { - changed = false - for _, folder := range AllFolders { - val, ok := next[string(folder)] - if !ok { - continue - } - lower := strings.ToLower(val) - for _, other := range AllFolders { - if other == folder { - continue - } - otherResolved := strings.ToLower(resolveFolderPath(other, next)) - if lower == otherResolved { - delete(next, string(folder)) - changed = true - break - } - } - } - } - if len(next) == 0 { - return nil - } - return next -} - -func claimsAnotherDefaultName(folder NoteFolder, val string) bool { - lower := strings.ToLower(val) - for _, other := range AllFolders { - if other == folder { - continue - } - if lower == defaultFolderPaths[other] { - return true - } - } - return false -} - -func resolveFolderPath(folder NoteFolder, paths map[string]string) string { - if p, ok := paths[string(folder)]; ok { - return p - } - return defaultFolderPaths[folder] -} - -// SystemFolderForDirName returns the system folder that owns a top-level -// directory name, or false when the name belongs to no system folder (an -// ordinary user folder, an assets dir, anything else). -// -// This is THE classification rule for a path's first segment, because only the -// RESOLVED name of each folder counts: with inbox remapped to `01 - Entry`, -// `01 - Entry/` is the inbox and a directory literally named `inbox/` is just a -// user folder. Case-insensitive, since macOS and Windows preserve whatever case -// the directory was created with (#186). Mirrors systemFolderForDirName in -// packages/shared-domain/src/system-folder-paths.ts. -func SystemFolderForDirName(name string, paths map[string]string) (NoteFolder, bool) { - lower := strings.ToLower(name) - for _, folder := range AllFolders { - if strings.ToLower(resolveFolderPath(folder, paths)) == lower { - return folder, true - } - } - return "", false -} - -func FolderForRelativePath(rel string) (NoteFolder, bool) { - return FolderForRelativePathWithSettings(rel, nil) -} - -func FolderForRelativePathWithSettings(rel string, paths map[string]string) (NoteFolder, bool) { - normalized := filepath.ToSlash(rel) - top := strings.SplitN(normalized, "/", 2)[0] - if top == "" || strings.HasPrefix(top, ".") { - return "", false - } - if folder, ok := SystemFolderForDirName(top, paths); ok { - return folder, true - } - // Only the dirs that are reserved no matter where the system folders live. - // A default name whose folder has moved (`inbox/` once inbox is `bucket`) - // is an ordinary user folder and must classify as one. - if _, reserved := reservedNonSystemRootNames[top]; reserved { - return "", false - } - return FolderInbox, true -} - -type DateNotePatternSettings struct { - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` -} - -type DailyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` - // Pointers so an absent field round-trips as "unset" (the TS client applies - // the real default — true for TasksDueOnNoteDate, false for rollover). These - // drive purely client-side behavior; the server only persists them. - TasksDueOnNoteDate *bool `json:"tasksDueOnNoteDate,omitempty"` - RolloverUnfinishedTasks *bool `json:"rolloverUnfinishedTasks,omitempty"` -} - -type WeeklyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` -} - -type MonthlyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` -} - -// FileLocationMode mirrors shared/ipc.ts FileLocationMode: where a new -// drawing / database / task file is created. -type FileLocationMode string - -const ( - FileLocationPrimary FileLocationMode = "primary" - FileLocationActiveNote FileLocationMode = "active-note" - FileLocationFolder FileLocationMode = "folder" -) - -// FileLocationSetting mirrors shared/ipc.ts FileLocationSetting. Persisted so -// the web client's Drawings / Databases / Tasks location choices survive a -// round-trip instead of being silently dropped by the settings struct (#446). -type FileLocationSetting struct { - Mode FileLocationMode `json:"mode"` - Folder string `json:"folder,omitempty"` -} - -type VaultSettings struct { - PrimaryNotesLocation PrimaryNotesLocation `json:"primaryNotesLocation"` - DailyNotes DailyNotesSettings `json:"dailyNotes"` - WeeklyNotes WeeklyNotesSettings `json:"weeklyNotes"` - MonthlyNotes MonthlyNotesSettings `json:"monthlyNotes"` - DrawingsLocation FileLocationSetting `json:"drawingsLocation"` - DatabasesLocation FileLocationSetting `json:"databasesLocation"` - TasksLocation FileLocationSetting `json:"tasksLocation"` - FolderIcons map[string]FolderIconID `json:"folderIcons"` - // FolderColors are per-folder accent colors, keyed by `folder:subpath` (the - // same key as FolderIcons). Persisted so the web client's recolors survive a - // round-trip instead of being silently dropped. (#379) - FolderColors map[string]FolderColorID `json:"folderColors"` - // Favorites are note paths or `folder:subpath` keys pinned to the top of - // the sidebar. Persisted so the web client's favorites survive a round-trip. - Favorites []string `json:"favorites"` - // Per-system-folder on-disk path overrides (#115). Maps internal folder IDs - // to vault-relative directory names. Absent entries fall back to the default. - SystemFolderPaths map[string]string `json:"systemFolderPaths,omitempty"` - // Tasks-system settings (#458). Mirrors shared/ipc.ts VaultSettings.tasks; - // persisted as a first-class field so a web client's settings write never - // drops a desktop-written exclusion list (the #446/#379 round-trip rule). - Tasks *TasksSettings `json:"tasks,omitempty"` - // Typst preamble settings (#486, #562). Mirrors shared/ipc.ts - // VaultSettings.typstPreambles; a first-class field for the same round-trip - // reason as Tasks above. - TypstPreambles *TypstPreambleSettings `json:"typstPreambles,omitempty"` - // Harper grammar-checker data that belongs to the vault (dictionary words - // and ignored-suggestion hashes). Mirrors shared/ipc.ts VaultSettings.harper; - // a first-class field for the same round-trip reason as Tasks above. - Harper *HarperSettings `json:"harper,omitempty"` -} - -// HarperSettings mirrors shared/ipc.ts VaultSettings.harper. IgnoredLints are -// Harper's unsigned 64-bit context hashes carried as digit strings, because -// the browser clients cannot hold them as numbers without rounding. -type HarperSettings struct { - Words []string `json:"words"` - IgnoredLints []string `json:"ignoredLints"` -} - -// TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458). -type TasksSettings struct { - // ExcludedFolders lists vault-relative directory paths (as they exist on - // disk) whose notes never feed the Tasks surfaces. - ExcludedFolders []string `json:"excludedFolders,omitempty"` -} - -// TypstPreambleSettings mirrors shared/ipc.ts VaultSettings.typstPreambles -// (#562). -type TypstPreambleSettings struct { - // Folder names the directory whose notes are Typst preambles, matched at - // any depth. Empty means the default, `typst`. - Folder string `json:"folder,omitempty"` -} - -// NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. -type NoteMeta struct { - Path string `json:"path"` - Title string `json:"title"` - Folder NoteFolder `json:"folder"` - SiblingOrder int `json:"siblingOrder"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - Size int64 `json:"size"` - Tags []string `json:"tags"` - Wikilinks []string `json:"wikilinks"` - AssetEmbeds []string `json:"assetEmbeds"` - HasAttachments bool `json:"hasAttachments"` - Excerpt string `json:"excerpt"` -} - -// NoteContent extends NoteMeta with the raw body. -type NoteContent struct { - NoteMeta - Body string `json:"body"` -} - -// NoteComment — sidecar annotation/comment data for a note. -type NoteComment struct { - ID string `json:"id"` - NotePath string `json:"notePath"` - AnchorStart int `json:"anchorStart"` - AnchorEnd int `json:"anchorEnd"` - AnchorText string `json:"anchorText"` - Body string `json:"body"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - ResolvedAt *int64 `json:"resolvedAt"` - // Author is who wrote it: empty for the vault's owner, an assistant's - // name otherwise. ParentID threads a reply under a top-level comment. - // Both mirror shared-domain/note-comments.ts (#738). - Author string `json:"author,omitempty"` - ParentID string `json:"parentId,omitempty"` -} - -// FolderEntry — mirrors shared/ipc.ts FolderEntry. -type FolderEntry struct { - Folder NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - SiblingOrder int `json:"siblingOrder"` -} - -// AssetMeta — mirrors shared/ipc.ts AssetMeta. -type AssetMeta struct { - Path string `json:"path"` - Name string `json:"name"` - Kind string `json:"kind"` - SiblingOrder int `json:"siblingOrder"` - Size int64 `json:"size"` - UpdatedAt int64 `json:"updatedAt"` -} - -// DeletedAsset — mirrors shared/ipc.ts DeletedAsset. The meta file it is -// read from (.zn-deleted.json) is shared with the desktop app: a vault can be -// served remotely today and opened locally tomorrow, so the field set must -// stay byte-compatible with desktop vault.ts. -type DeletedAsset struct { - Path string `json:"path"` - Name string `json:"name"` - UndoToken string `json:"undoToken"` - DeletedAt string `json:"deletedAt,omitempty"` -} - -// ImportedAsset — mirrors shared/ipc.ts ImportedAsset. -type ImportedAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Markdown string `json:"markdown"` - Kind string `json:"kind"` -} - -// VaultInfo — mirrors shared/ipc.ts VaultInfo. -type VaultInfo struct { - Root string `json:"root"` - Name string `json:"name"` -} - -// TextSearchCapabilities — mirrors shared/ipc.ts VaultTextSearchCapabilities. -type TextSearchCapabilities struct { - Ripgrep bool `json:"ripgrep"` - Fzf bool `json:"fzf"` -} - -// TextSearchMatch — mirrors shared/ipc.ts VaultTextSearchMatch. -type TextSearchMatch struct { - Path string `json:"path"` - Title string `json:"title"` - Folder NoteFolder `json:"folder"` - LineNumber int `json:"lineNumber"` - Offset int `json:"offset"` - LineText string `json:"lineText"` -} - -// Task — mirrors shared/tasks.ts VaultTask. -type Task struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - NoteTitle string `json:"noteTitle"` - NoteFolder NoteFolder `json:"noteFolder"` - LineNumber int `json:"lineNumber"` - TaskIndex int `json:"taskIndex"` - RawText string `json:"rawText"` - Content string `json:"content"` - Checked bool `json:"checked"` - // Cancelled is true for a `[-]` task — intentionally abandoned (#450). - Cancelled bool `json:"cancelled,omitempty"` - // InProgress is true for a `[/]` task: started, not finished (#512). - // Unlike Checked/Cancelled it is still open work, so it keeps its place - // in the active buckets on every surface. - InProgress bool `json:"inProgress,omitempty"` - // Forwarded is true for a `[>]` record: the task moved to another note - // and a live copy exists there (#316). Without it, a web client read - // carried tasks as open twice, record and copy alike (#611 review). - Forwarded bool `json:"forwarded,omitempty"` - Due string `json:"due,omitempty"` - Priority string `json:"priority,omitempty"` - Waiting bool `json:"waiting"` - // Fields contains inline @key:value metadata (or a file task's explicit - // frontmatter status) so remote Kanban boards group tasks exactly like the - // desktop parser (#643). Status is the effective status: Fields["status"] - // when set, and "open" for a file task whose frontmatter says nothing, - // which deliberately stays out of Fields (#672). - Fields map[string]string `json:"fields"` - Status string `json:"status,omitempty"` - Tags []string `json:"tags"` - // Kind is how the task is stored: "file" for a whole-note task - // (TaskNotes-style, tagged `task` with metadata in frontmatter) or - // empty/"inline" for a classic `- [ ]` checkbox line. The renderer - // branches its toggle logic on this. - Kind string `json:"kind,omitempty"` - // Scheduled and CompletedDate are file-task-only frontmatter dates - // (YYYY-MM-DD). They mirror the TS VaultTask shape. - Scheduled string `json:"scheduled,omitempty"` - CompletedDate string `json:"completedDate,omitempty"` -} - -// ChangeEvent — mirrors shared/ipc.ts VaultChangeEvent. -type ChangeEvent struct { - Kind string `json:"kind"` // "add" | "change" | "unlink" - Path string `json:"path"` - Folder NoteFolder `json:"folder"` - Scope string `json:"scope,omitempty"` -} diff --git a/apps/server/internal/vault/typst_preamble.go b/apps/server/internal/vault/typst_preamble.go deleted file mode 100644 index 88f93b49..00000000 --- a/apps/server/internal/vault/typst_preamble.go +++ /dev/null @@ -1,90 +0,0 @@ -package vault - -import "strings" - -// The vault-level Typst preamble folder (#486, configurable since #562). -// Byte-for-byte mirror of packages/shared-domain/src/typst-preamble-folder.ts: -// change both together. The setting names a single directory NAME matched at -// any depth, so `inbox/typst/physics.md` and `archive/notes/typst/maths.md` -// are both preambles. -// -// Preamble notes hold Typst source, not prose: `#let vec(x) = bold(x)` and the -// `#var` references inside formulas are variables, and indexing them filled a -// vault's tag list with `let` and every variable name. Tags are the only thing -// skipped; a preamble keeps its excerpt, wikilinks and searchability. - -// DefaultTypstPreambleFolder is the folder name used when the vault says -// nothing. -const DefaultTypstPreambleFolder = "typst" - -const maxTypstPreambleFolderLength = 128 - -// Same character rules as a system folder path (#115): one directory name, no -// separators, nothing that needs escaping on any platform we ship to. -const invalidTypstPreambleFolderChars = `\/:*?"<>|#^[]` - -// normalizeTypstPreambleFolder validates a configured folder name: exactly one -// directory segment, no traversal, no dotfiles. Returns "" when the value is -// unusable and the caller should fall back to the default. -func normalizeTypstPreambleFolder(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" || len(trimmed) > maxTypstPreambleFolderLength { - return "" - } - if trimmed == "." || trimmed == ".." || strings.HasPrefix(trimmed, ".") { - return "" - } - if strings.ContainsAny(trimmed, invalidTypstPreambleFolderChars) { - return "" - } - return trimmed -} - -// resolveTypstPreambleFolder returns the folder name in effect for the given -// already-parsed settings. Never fails: anything malformed resolves to the -// default. -func resolveTypstPreambleFolder(settings VaultSettings) string { - if settings.TypstPreambles == nil { - return DefaultTypstPreambleFolder - } - if folder := normalizeTypstPreambleFolder(settings.TypstPreambles.Folder); folder != "" { - return folder - } - return DefaultTypstPreambleFolder -} - -// normalizeTypstPreambleSettings carries the preamble settings through the -// settings round-trip. Returns nil for the default folder so an untouched -// vault.json never grows an empty stub, matching normalizeTasksSettings. -func normalizeTypstPreambleSettings(value *TypstPreambleSettings) *TypstPreambleSettings { - if value == nil { - return nil - } - folder := normalizeTypstPreambleFolder(value.Folder) - if folder == "" || folder == DefaultTypstPreambleFolder { - return nil - } - return &TypstPreambleSettings{Folder: folder} -} - -// isTypstPreamblePath reports whether a vault-relative POSIX path is a -// preamble note, i.e. sits in a directory with the configured name at any -// depth. Case-insensitive, matching how the rest of the preamble layer treats -// tags and titles. -func isTypstPreamblePath(relPath, folder string) bool { - name := strings.ToLower(folder) - if name == "" { - return false - } - parts := strings.Split(relPath, "/") - if len(parts) < 2 { - return false - } - // The last part is the file itself, so look for the folder among the parents. - for _, part := range parts[:len(parts)-1] { - if strings.ToLower(strings.TrimSpace(part)) == name { - return true - } - } - return false -} diff --git a/apps/server/internal/vault/typst_preamble_test.go b/apps/server/internal/vault/typst_preamble_test.go deleted file mode 100644 index b15d8e51..00000000 --- a/apps/server/internal/vault/typst_preamble_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package vault - -import "testing" - -// These cases mirror packages/shared-domain/src/typst-preamble-folder.test.ts -// one for one. When either side gains a rule, add it here too: the two -// implementations only stay compatible if they are tested on the same inputs. - -func TestNormalizeTypstPreambleFolder(t *testing.T) { - valid := map[string]string{ - "typst": "typst", - " Preambles ": "Preambles", - "math defs": "math defs", - } - for input, want := range valid { - if got := normalizeTypstPreambleFolder(input); got != want { - t.Errorf("normalizeTypstPreambleFolder(%q) = %q, want %q", input, got, want) - } - } - - invalid := []string{ - "a/b", `a\b`, "/typst", ".", "..", ".hidden", "", " ", - "a:b", "a*b", "a#b", "a[b]", - } - for _, input := range invalid { - if got := normalizeTypstPreambleFolder(input); got != "" { - t.Errorf("normalizeTypstPreambleFolder(%q) = %q, want \"\"", input, got) - } - } - - long := make([]byte, 129) - for i := range long { - long[i] = 'x' - } - if got := normalizeTypstPreambleFolder(string(long)); got != "" { - t.Errorf("129-char name accepted: %q", got) - } - if got := normalizeTypstPreambleFolder(string(long[:128])); got != string(long[:128]) { - t.Errorf("128-char name rejected") - } -} - -func TestResolveTypstPreambleFolder(t *testing.T) { - if got := resolveTypstPreambleFolder(VaultSettings{}); got != DefaultTypstPreambleFolder { - t.Errorf("absent settings = %q, want %q", got, DefaultTypstPreambleFolder) - } - bad := VaultSettings{TypstPreambles: &TypstPreambleSettings{Folder: "a/b"}} - if got := resolveTypstPreambleFolder(bad); got != DefaultTypstPreambleFolder { - t.Errorf("invalid folder = %q, want the default", got) - } - ok := VaultSettings{TypstPreambles: &TypstPreambleSettings{Folder: "Preambles"}} - if got := resolveTypstPreambleFolder(ok); got != "Preambles" { - t.Errorf("override = %q, want Preambles", got) - } -} - -func TestNormalizeTypstPreambleSettings(t *testing.T) { - // The default never persists, so an untouched vault.json grows no stub. - for _, in := range []*TypstPreambleSettings{ - nil, - {Folder: "typst"}, - {Folder: ""}, - {Folder: "a/b"}, - } { - if got := normalizeTypstPreambleSettings(in); got != nil { - t.Errorf("normalizeTypstPreambleSettings(%v) = %+v, want nil", in, got) - } - } - got := normalizeTypstPreambleSettings(&TypstPreambleSettings{Folder: " Preambles "}) - if got == nil || got.Folder != "Preambles" { - t.Errorf("override lost: %+v", got) - } -} - -func TestIsTypstPreamblePath(t *testing.T) { - cases := []struct { - path string - folder string - want bool - }{ - {"typst/physics.md", "typst", true}, - {"inbox/typst/physics.md", "typst", true}, - {"archive/notes/TYPST/maths.md", "typst", true}, - // The file itself is never the folder. - {"inbox/typst.md", "typst", false}, - {"typst", "typst", false}, - // Exact segment match, not a prefix. - {"inbox/typstish/x.md", "typst", false}, - {"inbox/my-typst/x.md", "typst", false}, - // A renamed folder moves the exclusion with it. - {"inbox/Preambles/physics.md", "Preambles", true}, - {"inbox/typst/physics.md", "Preambles", false}, - // An empty name is inert rather than matching everything. - {"inbox/typst/physics.md", "", false}, - } - for _, c := range cases { - if got := isTypstPreamblePath(c.path, c.folder); got != c.want { - t.Errorf("isTypstPreamblePath(%q, %q) = %v, want %v", c.path, c.folder, got, c.want) - } - } -} diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go deleted file mode 100644 index 1add6ec7..00000000 --- a/apps/server/internal/vault/vault.go +++ /dev/null @@ -1,3162 +0,0 @@ -package vault - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "hash/fnv" - "io" - "io/fs" - "log" - "math" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -const ( - // AssetsDir is the canonical top-level folder for assets; attachements/_assets - // are recognized legacy dirs. Asset migration runs on the desktop (#185). - AssetsDir = "assets" - PrimaryAttachmentsDir = "attachements" - internalVaultDir = ".zennotes" - vaultSettingsFile = "vault.json" - noteMetaCacheFile = "note-meta-cache-v1.json" - noteMetaCacheVersion = 2 - noteCommentsDir = "comments" - noteCommentsSuffix = ".comments.json" - noteMetaReadLimit = 64 - // formDirSuffix marks a database folder (`.base/`), a self-contained - // folder holding data.csv, schema.json, and record-page notes. Databases are - // a desktop-only feature; the server hides these folders (it neither serves - // the grid nor exposes the internals as loose notes/assets). - formDirSuffix = ".base" -) - -// isFormDirName reports whether a folder name marks a database folder. -func isFormDirName(name string) bool { - return strings.HasSuffix(strings.ToLower(name), formDirSuffix) -} - -// excalidrawExt marks a standalone Excalidraw drawing — the native Excalidraw -// JSON scene format. Drawings are a first-class file type alongside Markdown -// notes: listed in the sidebar (not as assets) and opened in a dedicated editor. -const excalidrawExt = ".excalidraw" - -// isExcalidrawName reports whether a filename is an Excalidraw drawing. -func isExcalidrawName(name string) bool { - return strings.EqualFold(filepath.Ext(name), excalidrawExt) -} - -// noteExt returns the on-disk extension for a note-like file, preserving -// `.excalidraw` for drawings and defaulting to `.md` otherwise. Rename/move/ -// duplicate use it so a drawing never silently becomes a Markdown note. -func noteExt(name string) string { - if isExcalidrawName(name) { - return excalidrawExt - } - return ".md" -} - -// emptyExcalidrawJSON mirrors emptyExcalidrawDocument() in -// packages/shared-domain/src/excalidraw.ts (JSON.stringify, 2-space indent). -const emptyExcalidrawJSON = `{ - "type": "excalidraw", - "version": 2, - "source": "zennotes", - "elements": [], - "appState": {}, - "files": {} -}` - -// ErrAssetTooLarge is returned when an asset upload exceeds the -// vault's MaxAssetBytes limit. -var ErrAssetTooLarge = errors.New("asset exceeds maximum size") - -var legacyAttachmentsDirs = []string{PrimaryAttachmentsDir, "_assets"} -var reservedRootNames = map[string]struct{}{ - string(FolderInbox): {}, - string(FolderQuick): {}, - string(FolderArchive): {}, - string(FolderTrash): {}, - AssetsDir: {}, - PrimaryAttachmentsDir: {}, - internalVaultDir: {}, -} - -// reservedNonSystemRootNames is the subset of reservedRootNames that stays -// reserved however the system folders are remapped: asset dirs and our own -// internal dir are never user note folders, while `inbox`/`archive`/… are -// reserved only while a system folder actually resolves there (see -// SystemFolderForDirName). Mirrors RESERVED_NON_SYSTEM_ROOT_NAMES in -// apps/desktop/src/main/vault.ts. -var reservedNonSystemRootNames = map[string]struct{}{ - AssetsDir: {}, - PrimaryAttachmentsDir: {}, - internalVaultDir: {}, -} - -var validFolderIconIDs = map[FolderIconID]struct{}{ - "folder": {}, - "bolt": {}, - "tray": {}, - "archive": {}, - "trash": {}, - "book": {}, - "bookmark": {}, - "calendar": {}, - "briefcase": {}, - "tag": {}, - "document": {}, - "sparkle": {}, - "code": {}, - "user": {}, - "star": {}, - "heart": {}, - "link": {}, - "lightbulb": {}, - "flask": {}, - "graduation": {}, - "music": {}, - "image": {}, - "palette": {}, - "terminal": {}, - "wrench": {}, - "globe": {}, - "map": {}, - "chart": {}, - "home": {}, -} - -// validFolderColorIDs mirrors the FolderColorId presets in -// packages/bridge-contract/src/ipc.ts. (#379) -var validFolderColorIDs = map[FolderColorID]struct{}{ - "red": {}, - "orange": {}, - "amber": {}, - "green": {}, - "teal": {}, - "sky": {}, - "blue": {}, - "indigo": {}, - "violet": {}, - "pink": {}, -} - -func init() { - for _, dir := range legacyAttachmentsDirs { - reservedRootNames[dir] = struct{}{} - reservedNonSystemRootNames[dir] = struct{}{} - } -} - -func shouldHidePrimaryRootName(name string, hidden map[string]struct{}) bool { - _, skip := hidden[name] - return skip -} - -// hiddenPrimaryRootNames returns the directory names skipped while walking the -// vault root in `root` primary mode, where the root itself is the inbox: the -// asset dirs, our internal dir, and the RESOLVED directory of every other -// system folder. A default name whose folder has been remapped away (`quick/` -// once quick lives in `Fast/`) is an ordinary user folder and must not be -// hidden. Mirrors hiddenPrimaryRootNames in apps/desktop/src/main/vault.ts. -func hiddenPrimaryRootNames(settings VaultSettings) map[string]struct{} { - names := map[string]struct{}{} - for name := range reservedNonSystemRootNames { - names[name] = struct{}{} - } - for _, folder := range []NoteFolder{FolderQuick, FolderArchive, FolderTrash} { - names[resolveFolderPath(folder, settings.SystemFolderPaths)] = struct{}{} - } - return names -} - -// Vault encapsulates all operations against a filesystem vault root. -// It is concurrency-safe at the public-method level; internally most -// ops do a short RW-lock dance around mutating operations. -type Vault struct { - root string - fileMode fs.FileMode - dirMode fs.FileMode - maxAssetBytes int64 - mu sync.RWMutex - searchCacheMu sync.Mutex - searchCache *textSearchCache - metaCacheMu sync.Mutex - metaCache map[string]noteMetaCacheEntry - metaCacheLoad bool - metaCacheGen uint64 - // settingsMu guards settingsCache only. GetSettings never takes v.mu (and - // is called with v.mu already held), so the lock order is always v.mu - // first, settingsMu second, and never the reverse. - settingsMu sync.Mutex - settingsCache *cachedVaultSettings - // pending counts delayed background writers, currently the note-meta - // cache snapshot, so Close can drain them before a caller removes the - // vault directory. Windows refuses to delete a directory a writer is - // still creating files in. - pending sync.WaitGroup -} - -// Close waits for background writers such as the delayed note-meta cache -// snapshot. The vault remains usable afterwards; Close only drains. -func (v *Vault) Close() { - v.pending.Wait() -} - -// cachedVaultSettings is a parsed vault.json plus the identity of the bytes it -// was parsed from. -type cachedVaultSettings struct { - settings VaultSettings - modTime time.Time - size int64 -} - -// Options tunes vault filesystem permissions and limits. Zero values -// fall back to a private-by-default profile (0o600 / 0o700, 50 MiB). -type Options struct { - FileMode fs.FileMode - DirMode fs.FileMode - MaxAssetBytes int64 -} - -type textSearchFile struct { - abs string - relPosix string - title string - folder NoteFolder -} - -type textSearchCandidate struct { - match TextSearchMatch - lineLower string -} - -type textSearchCache struct { - signature uint64 - candidates []textSearchCandidate -} - -type noteMetaCacheEntry struct { - mtimeMs float64 - size int64 - meta NoteMeta -} - -type persistedNoteMetaCache struct { - Version int `json:"version"` - Entries []persistedNoteMetaEntry `json:"entries"` -} - -type persistedNoteMetaEntry struct { - Path string `json:"path"` - MtimeMs float64 `json:"mtimeMs"` - Size int64 `json:"size"` - Meta NoteMeta `json:"meta"` -} - -func mtimeMs(info fs.FileInfo) float64 { - return float64(info.ModTime().UnixNano()) / 1_000_000 -} - -func sameMtimeMs(a, b float64) bool { - return math.Abs(a-b) < 0.001 -} - -func (v *Vault) noteMetaCachePath() string { - return filepath.Join(v.root, internalVaultDir, noteMetaCacheFile) -} - -func (v *Vault) invalidateNoteMetaCache() { - v.metaCacheMu.Lock() - v.metaCache = map[string]noteMetaCacheEntry{} - v.metaCacheLoad = false - v.metaCacheGen++ - v.metaCacheMu.Unlock() -} - -func (v *Vault) invalidateTextSearchCache() { - v.searchCacheMu.Lock() - v.searchCache = nil - v.searchCacheMu.Unlock() - v.invalidateNoteMetaCache() -} - -func New(root string, opts Options) (*Vault, error) { - abs, err := filepath.Abs(root) - if err != nil { - return nil, err - } - if opts.FileMode == 0 { - opts.FileMode = 0o600 - } - if opts.DirMode == 0 { - opts.DirMode = 0o700 - } - if opts.MaxAssetBytes <= 0 { - opts.MaxAssetBytes = 50 << 20 - } - if err := os.MkdirAll(abs, opts.DirMode); err != nil { - return nil, err - } - v := &Vault{ - root: abs, - fileMode: opts.FileMode, - dirMode: opts.DirMode, - maxAssetBytes: opts.MaxAssetBytes, - metaCache: map[string]noteMetaCacheEntry{}, - } - if err := v.EnsureLayout(); err != nil { - return nil, err - } - return v, nil -} - -func (v *Vault) Root() string { - return v.root -} - -func (v *Vault) Info() VaultInfo { - return VaultInfo{Root: v.root, Name: filepath.Base(v.root)} -} - -func cloneSettings(settings VaultSettings) VaultSettings { - folderIcons := make(map[string]FolderIconID, len(settings.FolderIcons)) - for key, value := range settings.FolderIcons { - folderIcons[key] = value - } - folderColors := make(map[string]FolderColorID, len(settings.FolderColors)) - for key, value := range settings.FolderColors { - folderColors[key] = value - } - favorites := make([]string, len(settings.Favorites)) - copy(favorites, settings.Favorites) - // Nil stays nil: an absent map marshals away thanks to `omitempty`, and an - // empty one would claim the vault has overrides it does not have. - var systemFolderPaths map[string]string - if settings.SystemFolderPaths != nil { - systemFolderPaths = make(map[string]string, len(settings.SystemFolderPaths)) - for key, value := range settings.SystemFolderPaths { - systemFolderPaths[key] = value - } - } - // Nil stays nil here too (#458); the walker treats an absent Tasks object - // as "nothing excluded". - var tasks *TasksSettings - if settings.Tasks != nil { - excluded := make([]string, len(settings.Tasks.ExcludedFolders)) - copy(excluded, settings.Tasks.ExcludedFolders) - tasks = &TasksSettings{ExcludedFolders: excluded} - } - // The same rule for the two later pointer fields: a caller reading the - // value SetSettings hands back must see what was written, not nil. - var typstPreambles *TypstPreambleSettings - if settings.TypstPreambles != nil { - typstPreambles = &TypstPreambleSettings{Folder: settings.TypstPreambles.Folder} - } - var harper *HarperSettings - if settings.Harper != nil { - words := make([]string, len(settings.Harper.Words)) - copy(words, settings.Harper.Words) - ignored := make([]string, len(settings.Harper.IgnoredLints)) - copy(ignored, settings.Harper.IgnoredLints) - harper = &HarperSettings{Words: words, IgnoredLints: ignored} - } - dailyLegacyPatterns := make([]DateNotePatternSettings, len(settings.DailyNotes.LegacyPatterns)) - copy(dailyLegacyPatterns, settings.DailyNotes.LegacyPatterns) - weeklyLegacyPatterns := make([]DateNotePatternSettings, len(settings.WeeklyNotes.LegacyPatterns)) - copy(weeklyLegacyPatterns, settings.WeeklyNotes.LegacyPatterns) - monthlyLegacyPatterns := make([]DateNotePatternSettings, len(settings.MonthlyNotes.LegacyPatterns)) - copy(monthlyLegacyPatterns, settings.MonthlyNotes.LegacyPatterns) - return VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: DailyNotesSettings{ - Enabled: settings.DailyNotes.Enabled, - Directory: settings.DailyNotes.Directory, - TitlePattern: settings.DailyNotes.TitlePattern, - Locale: settings.DailyNotes.Locale, - LegacyPatterns: dailyLegacyPatterns, - TemplateID: settings.DailyNotes.TemplateID, - TasksDueOnNoteDate: settings.DailyNotes.TasksDueOnNoteDate, - RolloverUnfinishedTasks: settings.DailyNotes.RolloverUnfinishedTasks, - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: settings.WeeklyNotes.Enabled, - Directory: settings.WeeklyNotes.Directory, - TitlePattern: settings.WeeklyNotes.TitlePattern, - Locale: settings.WeeklyNotes.Locale, - LegacyPatterns: weeklyLegacyPatterns, - TemplateID: settings.WeeklyNotes.TemplateID, - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: settings.MonthlyNotes.Enabled, - Directory: settings.MonthlyNotes.Directory, - TitlePattern: settings.MonthlyNotes.TitlePattern, - Locale: settings.MonthlyNotes.Locale, - LegacyPatterns: monthlyLegacyPatterns, - TemplateID: settings.MonthlyNotes.TemplateID, - }, - DrawingsLocation: settings.DrawingsLocation, - DatabasesLocation: settings.DatabasesLocation, - TasksLocation: settings.TasksLocation, - FolderIcons: folderIcons, - FolderColors: folderColors, - Favorites: favorites, - SystemFolderPaths: systemFolderPaths, - Tasks: tasks, - TypstPreambles: typstPreambles, - Harper: harper, - } -} - -func normalizeDailyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultDailyNotesDirectory - } - return trimmed -} - -func normalizeDailyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultDailyNoteTitlePattern - } - return trimmed -} - -func normalizeDailyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultDailyNoteLocale - } - return trimmed -} - -func normalizeWeeklyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultWeeklyNotesDirectory - } - return trimmed -} - -func normalizeWeeklyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultWeeklyNoteTitlePattern - } - return trimmed -} - -func normalizeWeeklyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultWeeklyNoteLocale - } - return trimmed -} - -func normalizeMonthlyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultMonthlyNotesDirectory - } - return trimmed -} - -func normalizeMonthlyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultMonthlyNoteTitlePattern - } - return trimmed -} - -func normalizeMonthlyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultMonthlyNoteLocale - } - return trimmed -} - -func normalizeDailyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeDailyNotesDirectory(pattern.Directory), - TitlePattern: normalizeDailyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeDailyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizeWeeklyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeWeeklyNotesDirectory(pattern.Directory), - TitlePattern: normalizeWeeklyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeWeeklyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizeMonthlyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeMonthlyNotesDirectory(pattern.Directory), - TitlePattern: normalizeMonthlyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeMonthlyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizePrimaryNotesLocation(value PrimaryNotesLocation) PrimaryNotesLocation { - if value == PrimaryNotesRoot { - return PrimaryNotesRoot - } - return PrimaryNotesInbox -} - -func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLocation) VaultSettings { - folderIcons := map[string]FolderIconID{} - for key, value := range value.FolderIcons { - if key == "" { - continue - } - if _, ok := validFolderIconIDs[value]; !ok { - continue - } - folderIcons[key] = value - } - folderColors := map[string]FolderColorID{} - for key, value := range value.FolderColors { - if key == "" { - continue - } - if _, ok := validFolderColorIDs[value]; !ok { - continue - } - folderColors[key] = value - } - return VaultSettings{ - PrimaryNotesLocation: normalizePrimaryNotesLocation(func() PrimaryNotesLocation { - if value.PrimaryNotesLocation == "" { - return fallbackPrimary - } - return value.PrimaryNotesLocation - }()), - DailyNotes: DailyNotesSettings{ - Enabled: value.DailyNotes.Enabled, - Directory: normalizeDailyNotesDirectory(value.DailyNotes.Directory), - TitlePattern: normalizeDailyNoteTitlePattern(value.DailyNotes.TitlePattern), - Locale: normalizeDailyNoteLocale(value.DailyNotes.Locale), - LegacyPatterns: normalizeDailyNoteLegacyPatterns(value.DailyNotes.LegacyPatterns), - TemplateID: value.DailyNotes.TemplateID, - TasksDueOnNoteDate: value.DailyNotes.TasksDueOnNoteDate, - RolloverUnfinishedTasks: value.DailyNotes.RolloverUnfinishedTasks, - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: value.WeeklyNotes.Enabled, - Directory: normalizeWeeklyNotesDirectory(value.WeeklyNotes.Directory), - TitlePattern: normalizeWeeklyNoteTitlePattern(value.WeeklyNotes.TitlePattern), - Locale: normalizeWeeklyNoteLocale(value.WeeklyNotes.Locale), - LegacyPatterns: normalizeWeeklyNoteLegacyPatterns(value.WeeklyNotes.LegacyPatterns), - TemplateID: value.WeeklyNotes.TemplateID, - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: value.MonthlyNotes.Enabled, - Directory: normalizeMonthlyNotesDirectory(value.MonthlyNotes.Directory), - TitlePattern: normalizeMonthlyNoteTitlePattern(value.MonthlyNotes.TitlePattern), - Locale: normalizeMonthlyNoteLocale(value.MonthlyNotes.Locale), - LegacyPatterns: normalizeMonthlyNoteLegacyPatterns(value.MonthlyNotes.LegacyPatterns), - TemplateID: value.MonthlyNotes.TemplateID, - }, - DrawingsLocation: normalizeFileLocation(value.DrawingsLocation), - DatabasesLocation: normalizeFileLocation(value.DatabasesLocation), - TasksLocation: normalizeFileLocation(value.TasksLocation), - FolderIcons: folderIcons, - FolderColors: folderColors, - Favorites: normalizeFavorites(value.Favorites), - SystemFolderPaths: normalizeSystemFolderPaths(value.SystemFolderPaths), - Tasks: normalizeTasksSettings(value.Tasks), - TypstPreambles: normalizeTypstPreambleSettings(value.TypstPreambles), - Harper: normalizeHarperSettings(value.Harper), - } -} - -// normalizeHarperSettings mirrors shared-domain's normalizeHarperVaultState: -// trimmed, de-duplicated words; ignored lints kept only when they are digit -// strings; nil when nothing is left so vault.json carries no empty block. -func normalizeHarperSettings(value *HarperSettings) *HarperSettings { - if value == nil { - return nil - } - words := uniqueTrimmedStrings(value.Words, func(string) bool { return true }) - ignored := uniqueTrimmedStrings(value.IgnoredLints, isDigitString) - if len(words) == 0 && len(ignored) == 0 { - return nil - } - return &HarperSettings{Words: words, IgnoredLints: ignored} -} - -func uniqueTrimmedStrings(values []string, keep func(string) bool) []string { - seen := map[string]struct{}{} - result := []string{} - for _, entry := range values { - cleaned := strings.TrimSpace(entry) - if cleaned == "" || !keep(cleaned) { - continue - } - if _, dup := seen[cleaned]; dup { - continue - } - seen[cleaned] = struct{}{} - result = append(result, cleaned) - } - return result -} - -func isDigitString(value string) bool { - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - return true -} - -// normalizeFileLocation mirrors app-core's normalizeFileLocation: validate the -// mode (unknown → primary) and, for folder mode, trim whitespace and slashes so -// the stored value round-trips cleanly (#446). -func normalizeFileLocation(value FileLocationSetting) FileLocationSetting { - switch value.Mode { - case FileLocationActiveNote: - return FileLocationSetting{Mode: FileLocationActiveNote} - case FileLocationFolder: - folder := strings.Trim(strings.TrimSpace(value.Folder), "/") - return FileLocationSetting{Mode: FileLocationFolder, Folder: folder} - default: - return FileLocationSetting{Mode: FileLocationPrimary} - } -} - -func normalizeFavorites(value []string) []string { - out := []string{} - seen := map[string]struct{}{} - for _, entry := range value { - if entry == "" { - continue - } - if _, ok := seen[entry]; ok { - continue - } - seen[entry] = struct{}{} - out = append(out, entry) - } - return out -} - -func folderIconKey(folder NoteFolder, subpath string) string { - return fmt.Sprintf("%s:%s", folder, subpath) -} - -func rewriteFolderIconsForRename( - folderIcons map[string]FolderIconID, - folder NoteFolder, - oldSubpath string, - newSubpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - exactKey := folderIconKey(folder, oldSubpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - switch { - case key == exactKey: - next[folderIconKey(folder, newSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, newSubpath)+key[len(exactKey):]] = value - default: - next[key] = value - } - } - return next -} - -// rewriteFolderColorsForRename keeps a folder's accent color attached to it (and -// its descendants) when the folder is renamed, mirroring the icon rewrite. (#379) -func rewriteFolderColorsForRename( - folderColors map[string]FolderColorID, - folder NoteFolder, - oldSubpath string, - newSubpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - exactKey := folderIconKey(folder, oldSubpath) - prefix := exactKey + "/" - for key, value := range folderColors { - switch { - case key == exactKey: - next[folderIconKey(folder, newSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, newSubpath)+key[len(exactKey):]] = value - default: - next[key] = value - } - } - return next -} - -func removeFolderIcons( - folderIcons map[string]FolderIconID, - folder NoteFolder, - subpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - exactKey := folderIconKey(folder, subpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - if key == exactKey || strings.HasPrefix(key, prefix) { - continue - } - next[key] = value - } - return next -} - -// removeFolderColors drops the deleted folder's (and its descendants') accent -// colors, mirroring removeFolderIcons. (#379) -func removeFolderColors( - folderColors map[string]FolderColorID, - folder NoteFolder, - subpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - exactKey := folderIconKey(folder, subpath) - prefix := exactKey + "/" - for key, value := range folderColors { - if key == exactKey || strings.HasPrefix(key, prefix) { - continue - } - next[key] = value - } - return next -} - -func duplicateFolderIcons( - folderIcons map[string]FolderIconID, - folder NoteFolder, - sourceSubpath string, - targetSubpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - for key, value := range folderIcons { - next[key] = value - } - exactKey := folderIconKey(folder, sourceSubpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - switch { - case key == exactKey: - next[folderIconKey(folder, targetSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, targetSubpath)+key[len(exactKey):]] = value - } - } - return next -} - -// duplicateFolderColors copies the source folder's (and descendants') accent -// colors onto the duplicated folder, mirroring duplicateFolderIcons. (#379) -func duplicateFolderColors( - folderColors map[string]FolderColorID, - folder NoteFolder, - sourceSubpath string, - targetSubpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - for key, value := range folderColors { - next[key] = value - } - exactKey := folderIconKey(folder, sourceSubpath) - prefix := exactKey + "/" - for key, value := range folderColors { - switch { - case key == exactKey: - next[folderIconKey(folder, targetSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, targetSubpath)+key[len(exactKey):]] = value - } - } - return next -} - -func (v *Vault) settingsPath() string { - return filepath.Join(v.root, internalVaultDir, vaultSettingsFile) -} - -func (v *Vault) commentsRoot() string { - return filepath.Join(v.root, internalVaultDir, noteCommentsDir) -} - -func (v *Vault) commentsPath(rel string) (string, error) { - return SafeJoin(v.commentsRoot(), filepath.ToSlash(rel)+noteCommentsSuffix) -} - -func (v *Vault) inferPrimaryNotesLocation() PrimaryNotesLocation { - entries, err := os.ReadDir(v.root) - if err != nil { - return PrimaryNotesInbox - } - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") { - continue - } - if _, reserved := reservedRootNames[name]; reserved { - continue - } - if entry.IsDir() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { - return PrimaryNotesRoot - } - } - return PrimaryNotesInbox -} - -func (v *Vault) vaultLooksEmpty() bool { - entries, err := os.ReadDir(v.root) - if err != nil { - return true - } - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") || name == internalVaultDir { - continue - } - return false - } - return true -} - -// GetSettings reads the vault settings, reparsing vault.json only when the file -// on disk has actually changed. Every folderRoot() call consults the settings, -// so a single client refresh asked for them about five times and each ask meant -// a ReadDir of the root plus a read and a JSON parse of vault.json. -// -// The cache is keyed on the file's mtime and size, and one open handle serves -// both the stat and the read so the key always describes the bytes that were -// parsed. A stat of the path followed by a read of the path would leave a -// window where the file is swapped in between (js/file-system-race). This -// mirrors getVaultSettings in apps/desktop/src/main/vault.ts. -func (v *Vault) GetSettings() (VaultSettings, error) { - file, err := os.Open(v.settingsPath()) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - v.invalidateSettingsCache() - return normalizeVaultSettings(VaultSettings{}, v.inferPrimaryNotesLocation()), nil - } - return VaultSettings{}, err - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return VaultSettings{}, err - } - if cached, ok := v.cachedSettings(info); ok { - return cached, nil - } - raw, err := io.ReadAll(file) - if err != nil { - return VaultSettings{}, err - } - var settings VaultSettings - if err := json.Unmarshal(raw, &settings); err != nil { - return VaultSettings{}, err - } - normalized := normalizeVaultSettings(settings, v.inferPrimaryNotesLocation()) - v.storeSettingsCache(normalized, info) - return cloneSettings(normalized), nil -} - -// cachedSettings returns the cached settings when they were parsed from a -// vault.json with this exact mtime and size. The copy is defensive: callers -// mutate what GetSettings hands them. -func (v *Vault) cachedSettings(info os.FileInfo) (VaultSettings, bool) { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - cached := v.settingsCache - if cached == nil || cached.size != info.Size() || !cached.modTime.Equal(info.ModTime()) { - return VaultSettings{}, false - } - return cloneSettings(cached.settings), true -} - -func (v *Vault) storeSettingsCache(settings VaultSettings, info os.FileInfo) { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - v.settingsCache = &cachedVaultSettings{ - settings: cloneSettings(settings), - modTime: info.ModTime(), - size: info.Size(), - } -} - -func (v *Vault) invalidateSettingsCache() { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - v.settingsCache = nil -} - -func (v *Vault) SetSettings(next VaultSettings) (VaultSettings, error) { - fallbackPrimary := v.inferPrimaryNotesLocation() - normalized := normalizeVaultSettings(next, fallbackPrimary) - // Read before the write, while the cache still answers with the old value: - // a note's tags depend on the preamble folder (#562), so cached metas - // describe the previous setting the moment it moves. - previousPreambleFolder := v.typstPreambleFolder() - if err := os.MkdirAll(filepath.Dir(v.settingsPath()), v.dirMode); err != nil { - return VaultSettings{}, err - } - data, err := json.MarshalIndent(normalized, "", " ") - if err != nil { - return VaultSettings{}, err - } - if err := os.WriteFile(v.settingsPath(), data, v.fileMode); err != nil { - return VaultSettings{}, err - } - // The next read re-parses rather than trusting a same-tick mtime. - v.invalidateSettingsCache() - if normalized.PrimaryNotesLocation == PrimaryNotesInbox { - inbox := filepath.Join(v.root, resolveFolderPath(FolderInbox, normalized.SystemFolderPaths)) - if err := os.MkdirAll(inbox, v.dirMode); err != nil { - return VaultSettings{}, err - } - } - v.invalidateTextSearchCache() - if resolveTypstPreambleFolder(normalized) != previousPreambleFolder { - v.invalidateNoteMetaCache() - } - return cloneSettings(normalized), nil -} - -func (v *Vault) primaryNotesRoot() (string, error) { - settings, err := v.GetSettings() - if err != nil { - return "", err - } - if settings.PrimaryNotesLocation == PrimaryNotesRoot { - return v.root, nil - } - // Resolve through the override, the same as EnsureLayout: hardcoding - // `inbox` here made every inbox read and write miss a remapped inbox while - // the layout pass dutifully created the remapped directory. (#115) - return filepath.Join(v.root, resolveFolderPath(FolderInbox, settings.SystemFolderPaths)), nil -} - -func (v *Vault) folderRoot(folder NoteFolder) (string, error) { - if folder == FolderInbox { - return v.primaryNotesRoot() - } - settings, err := v.GetSettings() - if err != nil { - return "", err - } - p := resolveFolderPath(folder, settings.SystemFolderPaths) - return filepath.Join(v.root, p), nil -} - -// EnsureLayout creates the four top-level folders and seeds a welcome -// note if the vault is empty. Matches src/main/vault.ts ensureVaultLayout. -func (v *Vault) EnsureLayout() error { - wasEmpty := v.vaultLooksEmpty() - settings, err := v.GetSettings() - if err != nil { - return err - } - for _, f := range AllFolders { - if f == FolderInbox && settings.PrimaryNotesLocation == PrimaryNotesRoot { - continue - } - p := resolveFolderPath(f, settings.SystemFolderPaths) - if err := os.MkdirAll(filepath.Join(v.root, p), v.dirMode); err != nil { - return err - } - } - if wasEmpty { - welcomeDir, err := v.primaryNotesRoot() - if err != nil { - return err - } - if err := os.MkdirAll(welcomeDir, v.dirMode); err != nil { - return err - } - welcome := filepath.Join(welcomeDir, "Welcome.md") - if _, err := os.Stat(welcome); errors.Is(err, os.ErrNotExist) { - _ = os.WriteFile(welcome, []byte(welcomeNote), v.fileMode) - } - } - return nil -} - -// --- Listing --- - -func validCachedNoteMeta(meta NoteMeta, path string) bool { - if meta.Path != path || meta.Title == "" || !IsValidFolder(meta.Folder) { - return false - } - if meta.Tags == nil || meta.Wikilinks == nil || meta.AssetEmbeds == nil { - return false - } - return true -} - -func (v *Vault) hydratePersistedNoteMetaCache() { - v.metaCacheMu.Lock() - if v.metaCacheLoad { - v.metaCacheMu.Unlock() - return - } - v.metaCacheLoad = true - v.metaCacheMu.Unlock() - - raw, err := os.ReadFile(v.noteMetaCachePath()) - if err != nil { - return - } - var persisted persistedNoteMetaCache - if err := json.Unmarshal(raw, &persisted); err != nil || persisted.Version != noteMetaCacheVersion { - return - } - - entries := map[string]noteMetaCacheEntry{} - for _, entry := range persisted.Entries { - if entry.Path == "" || !validCachedNoteMeta(entry.Meta, entry.Path) { - continue - } - abs, err := SafeJoin(v.root, entry.Path) - if err != nil { - continue - } - entries[abs] = noteMetaCacheEntry{ - mtimeMs: entry.MtimeMs, - size: entry.Size, - meta: entry.Meta, - } - } - if len(entries) == 0 { - return - } - - v.metaCacheMu.Lock() - for key, entry := range entries { - v.metaCache[key] = entry - } - v.metaCacheMu.Unlock() -} - -func (v *Vault) persistNoteMetaCacheSnapshot(metas []NoteMeta) { - if os.Getenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE") == "1" { - return - } - v.metaCacheMu.Lock() - generation := v.metaCacheGen - v.metaCacheMu.Unlock() - if len(metas) == 0 { - return - } - metas = append([]NoteMeta(nil), metas...) - - v.pending.Add(1) - go func(metas []NoteMeta, generation uint64) { - defer v.pending.Done() - time.Sleep(time.Second) - - entries := make([]persistedNoteMetaEntry, 0, len(metas)) - v.metaCacheMu.Lock() - if v.metaCacheGen != generation { - v.metaCacheMu.Unlock() - return - } - for _, meta := range metas { - abs, err := SafeJoin(v.root, meta.Path) - if err != nil { - continue - } - cached, ok := v.metaCache[abs] - if !ok { - continue - } - metaCopy := cached.meta - metaCopy.SiblingOrder = meta.SiblingOrder - entries = append(entries, persistedNoteMetaEntry{ - Path: meta.Path, - MtimeMs: cached.mtimeMs, - Size: cached.size, - Meta: metaCopy, - }) - } - v.metaCacheMu.Unlock() - if len(entries) == 0 { - return - } - - target := v.noteMetaCachePath() - temp := fmt.Sprintf("%s.%d.%d.tmp", target, os.Getpid(), time.Now().UnixNano()) - if err := os.MkdirAll(filepath.Dir(target), v.dirMode); err != nil { - return - } - data, err := json.Marshal(persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: entries, - }) - if err != nil { - return - } - data = append(data, '\n') - if err := os.WriteFile(temp, data, v.fileMode); err != nil { - return - } - v.metaCacheMu.Lock() - stillCurrent := v.metaCacheGen == generation - v.metaCacheMu.Unlock() - if !stillCurrent { - _ = os.Remove(temp) - return - } - if err := os.Rename(temp, target); err != nil { - _ = os.Remove(temp) - } - }(metas, generation) -} - -// ListNotes walks every top-level folder and returns metadata for each -// note. Sibling order is the directory-listing order per folder, which -// matches the TS version's behaviour for non-sorted filesystems. -// isSkippableWalkErr reports whether a directory-walk error should skip the -// offending entry and keep scanning, rather than aborting the whole vault scan. -// Covers entries that vanished mid-scan and, importantly for self-hosted -// servers, entries the process lacks permission to read (e.g. a vault copied in -// with root-owned files while the container runs as a non-root user). (#159) -func isSkippableWalkErr(err error) bool { - return errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) -} - -func (v *Vault) ListNotes() ([]NoteMeta, error) { - v.mu.RLock() - defer v.mu.RUnlock() - v.hydratePersistedNoteMetaCache() - - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - - type noteFile struct { - folder NoteFolder - path string - } - - files := []noteFile{} - for _, folder := range AllFolders { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - // A `.base` folder is NOT skipped here. Its record pages are - // real notes: the user opens them, writes in them, and links to - // them, and the desktop lists them exactly like any other note. - // Skipping the folder made every wikilink into a database resolve - // to nothing on a remote vault while working locally (#527). Only - // `.md` files are collected below, so the database's own data.csv - // and schema.json still never surface as notes. ListFolders and - // ListAssets DO skip it, deliberately: the internals are not loose - // folders or attachments, and the renderer draws the database - // itself rather than its directory. - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") && !isExcalidrawName(d.Name()) { - return nil - } - files = append(files, noteFile{folder: folder, path: path}) - return nil - }) - if err != nil { - return nil, err - } - } - - results := make([]NoteMeta, len(files)) - ok := make([]bool, len(files)) - limit := noteMetaReadLimit - if len(files) < limit { - limit = len(files) - } - sem := make(chan struct{}, limit) - // Resolved once for the whole scan, not once per note: this loop reads a - // meta per file across a pool of goroutines, and resolving inside would - // open and stat vault.json (and take settingsMu) for every one of them. - preambleFolder := v.typstPreambleFolder() - var wg sync.WaitGroup - for index, file := range files { - wg.Add(1) - go func(index int, file noteFile) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - meta, err := v.readMetaWithPreambleFolder(file.folder, file.path, preambleFolder) - if err != nil { - return // skip unreadable files silently - } - results[index] = meta - ok[index] = true - }(index, file) - } - wg.Wait() - - out := make([]NoteMeta, 0, len(files)) - for index, meta := range results { - if ok[index] { - out = append(out, meta) - } - } - - // sibling order per directory (by appearance in out for that dir) - assignSiblingOrder(out, func(m NoteMeta) string { - return filepath.Dir(m.Path) - }, func(m *NoteMeta, i int) { m.SiblingOrder = i }) - v.persistNoteMetaCacheSnapshot(out) - return out, nil -} - -func assignSiblingOrder[T any](list []T, key func(T) string, set func(*T, int)) { - counts := map[string]int{} - for i := range list { - k := key(list[i]) - set(&list[i], counts[k]) - counts[k]++ - } -} - -// ListFolders enumerates every non-root subdirectory under each top-level folder. -func (v *Vault) ListFolders() ([]FolderEntry, error) { - v.mu.RLock() - defer v.mu.RUnlock() - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - out := []FolderEntry{} - for _, folder := range AllFolders { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - if !d.IsDir() { - return nil - } - if path == folderRoot { - return nil - } - if strings.HasPrefix(d.Name(), ".") { - return filepath.SkipDir - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - rel, err := filepath.Rel(folderRoot, path) - if err != nil { - return nil - } - out = append(out, FolderEntry{ - Folder: folder, - Subpath: filepath.ToSlash(rel), - }) - // A `.base` database folder is listed (the renderer shows it as - // a database) but its internals are not exposed as folders. - if isFormDirName(d.Name()) { - return filepath.SkipDir - } - return nil - }) - if err != nil { - return nil, err - } - } - sort.SliceStable(out, func(i, j int) bool { - if out[i].Folder != out[j].Folder { - return out[i].Folder < out[j].Folder - } - return out[i].Subpath < out[j].Subpath - }) - assignSiblingOrder(out, func(f FolderEntry) string { - parent := filepath.Dir(f.Subpath) - return string(f.Folder) + "/" + parent - }, func(f *FolderEntry, i int) { f.SiblingOrder = i }) - return out, nil -} - -// ListAssets walks the attachments directory. -func (v *Vault) ListAssets() ([]AssetMeta, error) { - v.mu.RLock() - defer v.mu.RUnlock() - out := []AssetMeta{} - var walk func(dir string) error - walk = func(dir string) error { - entries, err := os.ReadDir(dir) - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - for index, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") || IsAtomicWriteTempPath(name) { - continue - } - full := filepath.Join(dir, name) - if entry.IsDir() { - if filepath.Clean(dir) == filepath.Clean(v.root) && name == internalVaultDir { - continue - } - if isFormDirName(name) { - continue // database folder — its data.csv/schema.json aren't assets - } - if err := walk(full); err != nil { - if isSkippableWalkErr(err) { - continue - } - return err - } - continue - } - if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - rel, err := filepath.Rel(v.root, full) - if err != nil { - continue - } - out = append(out, AssetMeta{ - Path: filepath.ToSlash(rel), - Name: name, - Kind: kindForExt(strings.ToLower(filepath.Ext(name))), - SiblingOrder: index, - Size: info.Size(), - UpdatedAt: info.ModTime().UnixMilli(), - }) - } - return nil - } - if err := walk(v.root); err != nil { - return nil, err - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].UpdatedAt > out[j].UpdatedAt - }) - return out, nil -} - -func (v *Vault) HasAssetsDir() bool { - v.mu.RLock() - defer v.mu.RUnlock() - for _, dir := range append([]string{AssetsDir}, legacyAttachmentsDirs...) { - info, err := os.Stat(filepath.Join(v.root, dir)) - if err == nil && info.IsDir() { - return true - } - } - return false -} - -func kindForExt(ext string) string { - switch ext { - case ".apng", ".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp": - return "image" - case ".pdf": - return "pdf" - case ".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav": - return "audio" - case ".m4v", ".mov", ".mp4", ".ogv", ".webm": - return "video" - } - return "file" -} - -// --- Read / Write --- - -// buildNoteMeta assembles NoteMeta for a note-like file. Excalidraw drawings -// store JSON, not Markdown, so their tags/wikilinks/excerpt are skipped — a hex -// color like "#1971c2" in the scene must not register as a #tag. -func buildNoteMeta(relPosix, title string, folder NoteFolder, info os.FileInfo, bodyStr, preambleFolder string) NoteMeta { - meta := NoteMeta{ - Path: relPosix, - Title: title, - Folder: folder, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: []string{}, - Wikilinks: []string{}, - AssetEmbeds: []string{}, - } - if isExcalidrawName(relPosix) { - return meta - } - meta.Wikilinks = ExtractWikilinks(bodyStr) - meta.AssetEmbeds = ExtractAssetEmbeds(bodyStr) - meta.HasAttachments = BodyHasLocalAsset(bodyStr) - meta.Excerpt = BuildExcerpt(bodyStr) - // A Typst preamble holds Typst source, not prose: `#let vec(x) = bold(x)` - // and the `#var` references in its formulas are variables, so indexing them - // filled the tag list with `let` and every variable name (#562). Tags only: - // the note keeps its excerpt, wikilinks and searchability. - if isTypstPreamblePath(relPosix, preambleFolder) { - return meta - } - meta.Tags = ExtractTags(bodyStr) - return meta -} - -func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { - return v.readMetaWithPreambleFolder(folder, abs, v.typstPreambleFolder()) -} - -// readMetaWithPreambleFolder is readMeta with the preamble folder already -// resolved, for callers reading many notes at once. -func (v *Vault) readMetaWithPreambleFolder( - folder NoteFolder, - abs, preambleFolder string, -) (NoteMeta, error) { - info, err := os.Stat(abs) - if err != nil { - return NoteMeta{}, err - } - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return NoteMeta{}, err - } - relPosix := filepath.ToSlash(rel) - statMtimeMs := mtimeMs(info) - v.metaCacheMu.Lock() - cached, ok := v.metaCache[abs] - if ok && - sameMtimeMs(cached.mtimeMs, statMtimeMs) && - cached.size == info.Size() && - cached.meta.Path == relPosix && - cached.meta.Folder == folder { - meta := cached.meta - v.metaCacheMu.Unlock() - return meta, nil - } - v.metaCacheMu.Unlock() - - body, err := os.ReadFile(abs) - if err != nil { - return NoteMeta{}, err - } - bodyStr := string(body) - - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - - meta := buildNoteMeta(relPosix, title, folder, info, bodyStr, preambleFolder) - v.metaCacheMu.Lock() - v.metaCache[abs] = noteMetaCacheEntry{ - mtimeMs: statMtimeMs, - size: info.Size(), - meta: meta, - } - v.metaCacheMu.Unlock() - return meta, nil -} - -func (v *Vault) ReadNote(rel string) (NoteContent, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteContent{}, err - } - info, err := os.Stat(abs) - if err != nil { - return NoteContent{}, err - } - // The stat above already knows the answer, so say it here instead of - // reading and then guessing from the platform's errno. - if info.IsDir() { - return NoteContent{}, ErrIsDirectory - } - body, err := os.ReadFile(abs) - if err != nil { - return NoteContent{}, err - } - folder, _ := v.folderOf(abs) - bodyStr := string(body) - rel = filepath.ToSlash(rel) - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - meta := buildNoteMeta(rel, title, folder, info, bodyStr, v.typstPreambleFolder()) - return NoteContent{NoteMeta: meta, Body: bodyStr}, nil -} - -func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - if err := writeFileAtomic(abs, []byte(body), v.fileMode, v.dirMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - folder, _ := v.folderOf(abs) - return v.readMeta(folder, abs) -} - -func newCommentID() string { - var b [16]byte - if _, err := rand.Read(b[:]); err == nil { - return hex.EncodeToString(b[:]) - } - return fmt.Sprintf("comment-%d", time.Now().UnixNano()) -} - -func normalizeComment(input NoteComment, notePath string) (NoteComment, bool) { - body := strings.TrimSpace(input.Body) - if body == "" { - return NoteComment{}, false - } - start := input.AnchorStart - if start < 0 { - start = 0 - } - end := input.AnchorEnd - if end < 0 { - end = start - } - if end < start { - start, end = end, start - } - anchorText := strings.Join(strings.Fields(input.AnchorText), " ") - if len(anchorText) > 500 { - anchorText = anchorText[:500] - } - now := time.Now().UnixMilli() - createdAt := input.CreatedAt - if createdAt <= 0 { - createdAt = now - } - updatedAt := input.UpdatedAt - if updatedAt <= 0 { - updatedAt = now - } - id := strings.TrimSpace(input.ID) - if id == "" { - id = newCommentID() - } - return NoteComment{ - ID: id, - NotePath: notePath, - AnchorStart: start, - AnchorEnd: end, - AnchorText: anchorText, - Body: body, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - ResolvedAt: input.ResolvedAt, - Author: normalizeCommentAuthor(input.Author), - ParentID: strings.TrimSpace(input.ParentID), - }, true -} - -// normalizeCommentAuthor collapses whitespace and caps the name, mirroring -// normalizeCommentAuthor in shared-domain/note-comments.ts. -func normalizeCommentAuthor(raw string) string { - author := strings.Join(strings.Fields(raw), " ") - if len(author) > 80 { - author = author[:80] - } - return author -} - -func normalizeComments(inputs []NoteComment, notePath string) []NoteComment { - out := make([]NoteComment, 0, len(inputs)) - seen := map[string]struct{}{} - for _, input := range inputs { - comment, ok := normalizeComment(input, notePath) - if !ok { - continue - } - if _, exists := seen[comment.ID]; exists { - continue - } - seen[comment.ID] = struct{}{} - out = append(out, comment) - } - sort.SliceStable(out, func(i, j int) bool { - if out[i].CreatedAt == out[j].CreatedAt { - return out[i].ID < out[j].ID - } - return out[i].CreatedAt < out[j].CreatedAt - }) - // A reply whose parent is gone (or is itself) stays as a comment of its - // own rather than vanishing from the thread view. - ids := make(map[string]struct{}, len(out)) - for _, comment := range out { - ids[comment.ID] = struct{}{} - } - for i := range out { - if out[i].ParentID == "" { - continue - } - if _, ok := ids[out[i].ParentID]; !ok || out[i].ParentID == out[i].ID { - out[i].ParentID = "" - } - } - return out -} - -func (v *Vault) readNoteCommentsLocked(rel string) ([]NoteComment, error) { - notePath := filepath.ToSlash(rel) - abs, err := v.commentsPath(notePath) - if err != nil { - return nil, err - } - raw, err := os.ReadFile(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []NoteComment{}, nil - } - return nil, err - } - var envelope struct { - Comments []NoteComment `json:"comments"` - } - if err := json.Unmarshal(raw, &envelope); err == nil && envelope.Comments != nil { - return normalizeComments(envelope.Comments, notePath), nil - } - var comments []NoteComment - if err := json.Unmarshal(raw, &comments); err != nil { - return []NoteComment{}, nil - } - return normalizeComments(comments, notePath), nil -} - -func (v *Vault) ReadNoteComments(rel string) ([]NoteComment, error) { - v.mu.RLock() - defer v.mu.RUnlock() - return v.readNoteCommentsLocked(rel) -} - -func (v *Vault) writeNoteCommentsLocked(rel string, comments []NoteComment) ([]NoteComment, error) { - notePath := filepath.ToSlash(rel) - normalized := normalizeComments(comments, notePath) - abs, err := v.commentsPath(notePath) - if err != nil { - return nil, err - } - if len(normalized) == 0 { - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err - } - return []NoteComment{}, nil - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return nil, err - } - data, err := json.MarshalIndent(struct { - Version int `json:"version"` - Comments []NoteComment `json:"comments"` - }{Version: 1, Comments: normalized}, "", " ") - if err != nil { - return nil, err - } - if err := os.WriteFile(abs, data, v.fileMode); err != nil { - return nil, err - } - return normalized, nil -} - -func (v *Vault) WriteNoteComments(rel string, comments []NoteComment) ([]NoteComment, error) { - v.mu.Lock() - defer v.mu.Unlock() - return v.writeNoteCommentsLocked(rel, comments) -} - -func (v *Vault) removeNoteCommentsLocked(rel string) error { - abs, err := v.commentsPath(rel) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func (v *Vault) moveNoteCommentsLocked(oldRel, nextRel string) error { - oldAbs, err := v.commentsPath(oldRel) - if err != nil { - return err - } - nextAbs, err := v.commentsPath(nextRel) - if err != nil { - return err - } - if oldAbs == nextAbs { - return nil - } - if _, err := os.Stat(oldAbs); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if err := os.MkdirAll(filepath.Dir(nextAbs), v.dirMode); err != nil { - return err - } - if _, err := os.Stat(nextAbs); err == nil { - existing, err := v.readNoteCommentsLocked(nextRel) - if err != nil { - return err - } - moving, err := v.readNoteCommentsLocked(oldRel) - if err != nil { - return err - } - if _, err := v.writeNoteCommentsLocked(nextRel, append(existing, moving...)); err != nil { - return err - } - return os.Remove(oldAbs) - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - return os.Rename(oldAbs, nextAbs) -} - -func (v *Vault) copyNoteCommentsLocked(sourceRel, nextRel string) error { - source, err := v.readNoteCommentsLocked(sourceRel) - if err != nil { - return err - } - if len(source) == 0 { - return nil - } - now := time.Now().UnixMilli() - copyComments := make([]NoteComment, 0, len(source)) - for _, comment := range source { - comment.ID = newCommentID() - comment.NotePath = filepath.ToSlash(nextRel) - comment.CreatedAt = now - comment.UpdatedAt = now - copyComments = append(copyComments, comment) - } - _, err = v.writeNoteCommentsLocked(nextRel, copyComments) - return err -} - -// folderOf classifies an absolute path by the folder it lives in, honoring the -// on-disk overrides: without them a note in a remapped trash directory came -// back tagged `inbox`, and a restore lost the subfolder it was trashed from. -// typstPreambleFolder resolves the vault's preamble folder (#562) the same way -// folderOf resolves the system-folder remap: through GetSettings, whose parse -// is cached against vault.json's identity. Falls back to the default when the -// settings cannot be read, so a note is never mistaken for a preamble. -func (v *Vault) typstPreambleFolder() string { - settings, err := v.GetSettings() - if err != nil { - return DefaultTypstPreambleFolder - } - return resolveTypstPreambleFolder(settings) -} - -func (v *Vault) folderOf(abs string) (NoteFolder, bool) { - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return "", false - } - var paths map[string]string - if settings, err := v.GetSettings(); err == nil { - paths = settings.SystemFolderPaths - } - return FolderForRelativePathWithSettings(rel, paths) -} - -// --- Create / Rename / Delete --- - -func (v *Vault) CreateNote(folder NoteFolder, title, subpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) - } - if title == "" { - title = defaultTitle() - } - title = sanitizeFileStem(title) - dir, err := v.folderRoot(folder) - if err != nil { - return NoteMeta{}, err - } - if subpath != "" { - sub, err := SafeJoin(dir, subpath) - if err != nil { - return NoteMeta{}, err - } - dir = sub - } - if err := os.MkdirAll(dir, v.dirMode); err != nil { - return NoteMeta{}, err - } - abs := uniquePath(dir, title, ".md") - // Seed the same `# Title` body the desktop app writes (main vault.ts and - // the MCP vault-ops both do), from the FINAL on-disk stem so a deduped - // "Title 2" heads itself correctly. A remote vault otherwise creates - // blank notes where a local one has its title, which is most visible on - // daily notes, whose date heading is the whole point. - stem := strings.TrimSuffix(filepath.Base(abs), ".md") - if err := os.WriteFile(abs, fmt.Appendf(nil, "# %s\n\n", stem), v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return v.readMeta(folder, abs) -} - -// CreateExcalidraw writes a new empty `.excalidraw` drawing under folder/subpath -// and returns its meta. Mirrors CreateNote but seeds an empty Excalidraw scene. -func (v *Vault) CreateExcalidraw(folder NoteFolder, title, subpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) - } - if title == "" { - title = defaultTitle() - } - title = sanitizeFileStem(title) - dir, err := v.folderRoot(folder) - if err != nil { - return NoteMeta{}, err - } - if subpath != "" { - sub, err := SafeJoin(dir, subpath) - if err != nil { - return NoteMeta{}, err - } - dir = sub - } - if err := os.MkdirAll(dir, v.dirMode); err != nil { - return NoteMeta{}, err - } - abs := uniquePath(dir, title, excalidrawExt) - if err := os.WriteFile(abs, []byte(emptyExcalidrawJSON), v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return v.readMeta(folder, abs) -} - -func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { - // Snapshot the vault before the rename (ListNotes takes its own read lock) - // so inbound [[wikilinks]] still resolve to this note under its current name. - notesBefore, err := v.ListNotes() - if err != nil { - return NoteMeta{}, err - } - meta, err := v.renameNoteFile(rel, nextTitle) - if err != nil { - return NoteMeta{}, err - } - if meta.Path != rel { - // ReadNote / WriteNote take their own locks, so this runs after the - // rename's write lock has been released. - v.rewriteInboundWikilinks(notesBefore, rel, meta.Title) - } - return meta, nil -} - -func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - nextTitle = sanitizeFileStem(nextTitle) - if nextTitle == "" { - return NoteMeta{}, errors.New("empty title") - } - dir := filepath.Dir(abs) - desired := filepath.Join(dir, nextTitle+noteExt(abs)) - newAbs := desired - source, err := os.Stat(abs) - if err != nil { - return NoteMeta{}, err - } - if target, statErr := os.Stat(desired); statErr == nil { - if !os.SameFile(source, target) { - newAbs = uniquePath(dir, nextTitle, noteExt(abs)) - } - } else if !errors.Is(statErr, os.ErrNotExist) { - return NoteMeta{}, statErr - } - nextRel, err := filepath.Rel(v.root, newAbs) - if err != nil { - return NoteMeta{}, err - } - oldComments, err := v.commentsPath(rel) - if err != nil { - return NoteMeta{}, err - } - nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) - if err != nil { - return NoteMeta{}, err - } - folder, _ := v.folderOf(newAbs) - var meta NoteMeta - err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { - var readErr error - meta, readErr = v.readMeta(folder, newAbs) - return readErr - }) - if err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return meta, nil -} - -// rewriteInboundWikilinks rewrites every note that linked to the renamed note's -// old name so it points to the new title. Only notes that actually link to it -// are read and rewritten. -func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitle string) { - for _, n := range notesBefore { - if n.Path == oldPath || n.Folder == FolderTrash { - continue - } - linksToIt := false - for _, t := range n.Wikilinks { - target, _, _ := wikiSplitContent(t) - if r, ok := wikiResolveTarget(notesBefore, target); ok && r.Path == oldPath { - linksToIt = true - break - } - } - if !linksToIt { - continue - } - content, err := v.ReadNote(n.Path) - if err != nil { - continue - } - body, changed := rewriteWikilinksForRename(content.Body, notesBefore, oldPath, newTitle) - if changed > 0 { - _, _ = v.WriteNote(n.Path, body) - } - } -} - -func (v *Vault) DeleteNote(rel string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return err - } - info, err := os.Lstat(abs) - if err != nil { - return err - } - if info.IsDir() { - return errors.New("use the folder action to delete a directory") - } - comments, err := v.commentsPath(rel) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { - return err - } - temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "note-delete-") - if err != nil { - return err - } - if err := v.relocateFolderTrees([][2]string{{abs, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { return nil }); err != nil { - return err - } - v.invalidateTextSearchCache() - // Cleanup cannot resurrect comments at a live note path. - if err := os.RemoveAll(temporary); err != nil { - log.Printf("note cleanup pending: %v", err) - } - return nil -} - -// --- Trash / Restore / Archive / Unarchive / Duplicate / Move --- - -func (v *Vault) MoveToTrash(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderTrash) -} -func (v *Vault) RestoreFromTrash(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderInbox) -} -func (v *Vault) ArchiveNote(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderArchive) -} -func (v *Vault) UnarchiveNote(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderInbox) -} - -// folderSubpathOf returns the note's directory relative to its top-level -// folder root ("" when it sits at the folder root). Carried along on -// archive/trash moves so the reverse move restores the subfolder. -// Mirrors folderSubpathOf in apps/desktop/src/main/vault.ts. -func (v *Vault) folderSubpathOf(abs string) string { - folder, ok := v.folderOf(abs) - if !ok { - return "" - } - sourceRoot, err := v.folderRoot(folder) - if err != nil { - return "" - } - relDir, err := filepath.Rel(sourceRoot, filepath.Dir(abs)) - if err != nil || relDir == "." || strings.HasPrefix(relDir, "..") || filepath.IsAbs(relDir) { - return "" - } - return relDir -} - -func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - // Mirror the source subfolder in the destination so a round-trip - // (archive → unarchive, trash → restore) puts the note back where - // it came from instead of at the folder's top level. - subpath := v.folderSubpathOf(abs) - targetRoot, err := v.folderRoot(target) - if err != nil { - return NoteMeta{}, err - } - destDir := targetRoot - if subpath != "" { - destDir, err = SafeJoin(targetRoot, subpath) - if err != nil { - return NoteMeta{}, err - } - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return NoteMeta{}, err - } - newAbs := uniquePath(destDir, title, noteExt(abs)) - nextRel, err := filepath.Rel(v.root, newAbs) - if err != nil { - return NoteMeta{}, err - } - oldComments, err := v.commentsPath(rel) - if err != nil { - return NoteMeta{}, err - } - nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) - if err != nil { - return NoteMeta{}, err - } - var meta NoteMeta - err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { - var readErr error - meta, readErr = v.readMeta(target, newAbs) - return readErr - }) - if err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return meta, nil -} - -func (v *Vault) EmptyTrash() error { - v.mu.Lock() - defer v.mu.Unlock() - trashDir, err := v.folderRoot(FolderTrash) - if err != nil { - return err - } - rel, err := filepath.Rel(v.root, trashDir) - if err != nil { - return err - } - comments, err := SafeJoin(v.commentsRoot(), rel) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { - return err - } - temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "trash-delete-") - if err != nil { - return err - } - if err := v.relocateFolderTrees([][2]string{{trashDir, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { return nil }); err != nil { - return err - } - v.invalidateTextSearchCache() - if err := os.RemoveAll(temporary); err != nil { - log.Printf("trash cleanup pending: %v", err) - } - return nil -} - -func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - folder, _ := v.folderOf(abs) - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + " copy" - newAbs := uniquePath(filepath.Dir(abs), sanitizeFileStem(title), noteExt(abs)) - if err := copyFile(abs, newAbs, v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - meta, err := v.readMeta(folder, newAbs) - if err != nil { - return NoteMeta{}, err - } - if err := v.copyNoteCommentsLocked(rel, meta.Path); err != nil { - return NoteMeta{}, err - } - return meta, nil -} - -func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - if !IsValidFolder(target) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", target) - } - destDir, err := v.folderRoot(target) - if err != nil { - return NoteMeta{}, err - } - if targetSubpath != "" { - sub, err := SafeJoin(destDir, targetSubpath) - if err != nil { - return NoteMeta{}, err - } - destDir = sub - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return NoteMeta{}, err - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - newAbs := uniquePath(destDir, title, noteExt(abs)) - nextRel, err := filepath.Rel(v.root, newAbs) - if err != nil { - return NoteMeta{}, err - } - oldComments, err := v.commentsPath(rel) - if err != nil { - return NoteMeta{}, err - } - nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) - if err != nil { - return NoteMeta{}, err - } - var meta NoteMeta - err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { - var readErr error - meta, readErr = v.readMeta(target, newAbs) - return readErr - }) - if err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return meta, nil -} - -// --- Folders --- - -func (v *Vault) CreateFolder(folder NoteFolder, subpath string) error { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return fmt.Errorf("invalid folder: %s", folder) - } - base, err := v.folderRoot(folder) - if err != nil { - return err - } - abs, err := SafeJoin(base, subpath) - if err != nil { - return err - } - return os.MkdirAll(abs, v.dirMode) -} - -// relocateFolderTrees keeps content and its parallel comment tree together. -func (v *Vault) relocateFolderTrees(moves [][2]string, persistSettings func() error) error { - present := make([][2]string, 0, len(moves)) - for _, move := range moves { - source, err := os.Stat(move[0]) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if target, err := os.Stat(move[1]); err == nil { - if source == nil || !os.SameFile(source, target) { - return errors.New("destination folder or its comments already exist") - } - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - if source != nil { - present = append(present, move) - } - } - moved := make([][2]string, 0, len(present)) - rollback := func(cause error) error { - failures := []error{cause} - for i := len(moved) - 1; i >= 0; i-- { - if err := os.Rename(moved[i][1], moved[i][0]); err != nil { - failures = append(failures, err) - } - } - if len(failures) > 1 { - return fmt.Errorf("FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing: %w", errors.Join(failures...)) - } - return cause - } - for _, move := range present { - if err := os.MkdirAll(filepath.Dir(move[1]), v.dirMode); err != nil { - return rollback(err) - } - if err := os.Rename(move[0], move[1]); err != nil { - return rollback(err) - } - moved = append(moved, move) - } - if err := persistSettings(); err != nil { - return rollback(err) - } - return nil -} - -func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return "", err - } - oldAbs, err := SafeJoin(base, oldSub) - if err != nil { - return "", err - } - newAbs, err := SafeJoin(base, newSub) - if err != nil { - return "", err - } - if oldAbs == base { - return "", errors.New("refusing to rename top-level folder") - } - if _, err := os.Stat(oldAbs); err != nil { - return "", err - } - if strings.HasPrefix(newAbs+string(filepath.Separator), oldAbs+string(filepath.Separator)) && newAbs != oldAbs { - return "", errors.New("cannot move a folder into itself") - } - oldRel, _ := filepath.Rel(v.root, oldAbs) - newRel, _ := filepath.Rel(v.root, newAbs) - oldComments, err := SafeJoin(v.commentsRoot(), oldRel) - if err != nil { - return "", err - } - newComments, err := SafeJoin(v.commentsRoot(), newRel) - if err != nil { - return "", err - } - settings, err := v.GetSettings() - if err != nil { - return "", err - } - err = v.relocateFolderTrees([][2]string{{oldAbs, newAbs}, {oldComments, newComments}}, func() error { - next := settings - next.FolderIcons = rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub) - next.FolderColors = rewriteFolderColorsForRename(settings.FolderColors, folder, oldSub, newSub) - _, err := v.SetSettings(next) - return err - }) - if err != nil { - return "", err - } - v.invalidateTextSearchCache() - rel, _ := filepath.Rel(base, newAbs) - return filepath.ToSlash(rel), nil -} - -func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return err - } - abs, err := SafeJoin(base, subpath) - if err != nil { - return err - } - if abs == base { - return errors.New("refusing to delete top-level folder") - } - rel, _ := filepath.Rel(v.root, abs) - comments, err := SafeJoin(v.commentsRoot(), rel) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { - return err - } - temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "folder-delete-") - if err != nil { - return err - } - settings, err := v.GetSettings() - if err != nil { - return err - } - err = v.relocateFolderTrees([][2]string{{abs, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { - next := settings - next.FolderIcons = removeFolderIcons(settings.FolderIcons, folder, subpath) - next.FolderColors = removeFolderColors(settings.FolderColors, folder, subpath) - _, err := v.SetSettings(next) - return err - }) - if err != nil { - return err - } - v.invalidateTextSearchCache() - // Cleanup cannot resurrect comments at a live note path. - if err := os.RemoveAll(temporary); err != nil { - log.Printf("folder cleanup pending: %v", err) - } - return nil -} - -func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return "", err - } - src, err := SafeJoin(base, subpath) - if err != nil { - return "", err - } - parent := filepath.Dir(src) - baseName := filepath.Base(src) + " copy" - dst := uniqueDir(parent, baseName) - if err := copyDir(src, dst, v.fileMode, v.dirMode); err != nil { - return "", err - } - v.invalidateTextSearchCache() - settings, err := v.GetSettings() - if err != nil { - return "", err - } - rel, _ := filepath.Rel(base, dst) - relPath := filepath.ToSlash(rel) - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: duplicateFolderIcons(settings.FolderIcons, folder, subpath, relPath), - FolderColors: duplicateFolderColors(settings.FolderColors, folder, subpath, relPath), - // A duplicated folder isn't auto-favorited; carry existing favorites through. - Favorites: settings.Favorites, - }) - if err != nil { - return "", err - } - return relPath, nil -} - -// --- Tasks --- - -func (v *Vault) ScanTasks() ([]Task, error) { - return v.ScanTasksWith(ParseTasksOptions{}) -} - -// ScanTasksWith is ScanTasks honoring options: IncludeExcluded scans past -// both the vault-level excluded-folders list and the note-level frontmatter -// `tasks:` opt-out (#458). -func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) { - v.mu.RLock() - defer v.mu.RUnlock() - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - excluded := tasksExcludedFolders(settings) - if opts.IncludeExcluded { - excluded = nil - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - all := []Task{} - for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - // Not skipped, for the same reason as ListNotes: a database's - // record pages are notes, and a task written in one counts. The - // desktop scans tasks straight off its note list, so skipping here - // would have the two disagree about what a note is (#527). - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return nil - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { - return nil - } - body, err := os.ReadFile(path) - if err != nil { - return nil - } - rel, _ := filepath.Rel(v.root, path) - relPosix := filepath.ToSlash(rel) - if isPathExcludedFromTasks(relPosix, excluded) { - return nil - } - title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - tasks := ParseTasksWith(relPosix, title, folder, string(body), opts) - all = append(all, tasks...) - return nil - }) - } - return all, nil -} - -func (v *Vault) ScanTasksForPath(rel string) ([]Task, error) { - return v.ScanTasksForPathWith(rel, ParseTasksOptions{}) -} - -// ScanTasksForPathWith is ScanTasksForPath honoring options. IncludeExcluded -// scans past the excluded-folders list and the frontmatter `tasks:` opt-out -// (never the trash gate): the remote task-toggle flow re-parses through here, -// and an explicitly-named task id is an explicit ask. -func (v *Vault) ScanTasksForPathWith(rel string, opts ParseTasksOptions) ([]Task, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return nil, err - } - body, err := os.ReadFile(abs) - if err != nil { - return nil, err - } - // Same gates as the full scan, or a single-note rescan would resurrect - // tasks the walker skips: trashed and unclassifiable notes contribute - // nothing (mirrors the desktop's LIVE_FOLDERS check), and neither do notes - // under an excluded folder (#458). The caller uses the empty result to - // drop stale rows. - folder, ok := v.folderOf(abs) - if !ok || folder == FolderTrash { - return []Task{}, nil - } - relPosix := filepath.ToSlash(rel) - if !opts.IncludeExcluded { - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - if isPathExcludedFromTasks(relPosix, tasksExcludedFolders(settings)) { - return []Task{}, nil - } - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - return ParseTasksWith(relPosix, title, folder, string(body), opts), nil -} - -// --- Text search --- - -func (v *Vault) SearchCapabilities() TextSearchCapabilities { - return TextSearchCapabilities{Ripgrep: false, Fzf: false} -} - -func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { - h := fnv.New64a() - files := []textSearchFile{} - settings, err := v.GetSettings() - if err != nil { - return 0, nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return 0, nil, err - } - cleanFolderRoot := filepath.Clean(folderRoot) - isPrimaryRoot := folder == FolderInbox && cleanFolderRoot == filepath.Clean(v.root) - fmt.Fprintf(h, "folder\x00%s\x00%s\x00%t\x00", folder, filepath.ToSlash(cleanFolderRoot), isPrimaryRoot) - _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return nil - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - rel, _ := filepath.Rel(v.root, path) - relPosix := filepath.ToSlash(rel) - title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - modNano := info.ModTime().UnixNano() - size := info.Size() - fmt.Fprintf(h, "file\x00%s\x00%s\x00%d\x00%d\x00", folder, relPosix, size, modNano) - files = append(files, textSearchFile{ - abs: path, - relPosix: relPosix, - title: title, - folder: folder, - }) - return nil - }) - } - return h.Sum64(), files, nil -} - -func (v *Vault) textSearchCandidatesLocked() ([]textSearchCandidate, error) { - signature, files, err := v.textSearchFilesLocked() - if err != nil { - return nil, err - } - - v.searchCacheMu.Lock() - if v.searchCache != nil && v.searchCache.signature == signature { - candidates := v.searchCache.candidates - v.searchCacheMu.Unlock() - return candidates, nil - } - v.searchCacheMu.Unlock() - - groups := make([][]textSearchCandidate, len(files)) - limit := noteMetaReadLimit - if len(files) < limit { - limit = len(files) - } - sem := make(chan struct{}, limit) - var wg sync.WaitGroup - for index, file := range files { - wg.Add(1) - go func(index int, file textSearchFile) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - body, err := os.ReadFile(file.abs) - if err != nil { - return - } - lines := strings.Split(string(body), "\n") - offset := 0 - candidates := make([]textSearchCandidate, 0, len(lines)) - for i, line := range lines { - collapsed := wsCollapseRe.ReplaceAllString(line, " ") - collapsed = strings.TrimSpace(collapsed) - if len(collapsed) > 220 { - collapsed = collapsed[:220] - } - candidates = append(candidates, textSearchCandidate{ - match: TextSearchMatch{ - Path: file.relPosix, - Title: file.title, - Folder: file.folder, - LineNumber: i + 1, - Offset: offset, - LineText: collapsed, - }, - lineLower: strings.ToLower(line), - }) - offset += len(line) + 1 - } - groups[index] = candidates - }(index, file) - } - wg.Wait() - - candidates := []textSearchCandidate{} - for _, group := range groups { - candidates = append(candidates, group...) - } - - v.searchCacheMu.Lock() - if v.searchCache != nil && v.searchCache.signature == signature { - candidates = v.searchCache.candidates - } else { - v.searchCache = &textSearchCache{ - signature: signature, - candidates: candidates, - } - } - v.searchCacheMu.Unlock() - - return candidates, nil -} - -func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { - v.mu.RLock() - defer v.mu.RUnlock() - query = strings.TrimSpace(query) - if query == "" { - return []TextSearchMatch{}, nil - } - needle := strings.ToLower(query) - candidates, err := v.textSearchCandidatesLocked() - if err != nil { - return nil, err - } - out := []TextSearchMatch{} - for _, candidate := range candidates { - if !strings.Contains(candidate.lineLower, needle) { - continue - } - out = append(out, candidate.match) - if len(out) >= 200 { - break - } - } - return out, nil -} - -// --- Assets upload + raw serving --- - -// ImportAsset writes raw bytes into the unified assets/ folder and returns -// the vault-relative markdown snippet to embed. The destination -// mirrors the desktop importFiles/importPastedImage (#377): uploads used to -// land at the vault root, which in Vault Root mode dumped them right next to -// the notes. -// notePath is no longer read: the link is vault-relative now, so where the note -// lives does not change what gets written. It stays in the signature because -// the HTTP handler and clients still send it. -func (v *Vault) ImportAsset(notePath, filename string, body io.Reader) (ImportedAsset, error) { - _ = notePath - v.mu.Lock() - defer v.mu.Unlock() - assetsAbs := filepath.Join(v.root, AssetsDir) - if err := os.MkdirAll(assetsAbs, v.dirMode); err != nil { - return ImportedAsset{}, err - } - safeName := sanitizeFileName(filename) - if safeName == "" { - safeName = "file" - } - ext := filepath.Ext(safeName) - stem := strings.TrimSuffix(safeName, ext) - abs := uniquePath(assetsAbs, stem, ext) - f, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, v.fileMode) - if err != nil { - return ImportedAsset{}, err - } - cleanupPartial := func() { - _ = f.Close() - _ = os.Remove(abs) - } - limited := io.LimitReader(body, v.maxAssetBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - cleanupPartial() - return ImportedAsset{}, err - } - if written > v.maxAssetBytes { - cleanupPartial() - return ImportedAsset{}, ErrAssetTooLarge - } - if err := f.Close(); err != nil { - _ = os.Remove(abs) - return ImportedAsset{}, err - } - relFromRoot, err := filepath.Rel(v.root, abs) - if err != nil { - return ImportedAsset{}, err - } - rel := filepath.ToSlash(relFromRoot) - kind := kindForExt(strings.ToLower(filepath.Ext(abs))) - markdown := makeAssetMarkdown(rel, kind, filepath.Base(abs)) - return ImportedAsset{ - Name: filepath.Base(abs), - Path: rel, - Markdown: markdown, - Kind: kind, - }, nil -} - -func (v *Vault) AssetAbsPath(rel string) (string, error) { - v.mu.RLock() - defer v.mu.RUnlock() - return SafeJoin(v.root, rel) -} - -// RenameAsset renames an asset file in place (same directory), mirroring the -// desktop renameAsset. It refuses internal files and markdown notes, and -// handles a case-only rename on case-insensitive filesystems. (#379) Every -// note that referenced the asset is then rewritten to its new name, the way -// RenameNote handles inbound wikilinks. (#785) -func (v *Vault) RenameAsset(rel, nextName string) (AssetMeta, error) { - // Snapshot before the move (both listings take their own read locks) so - // references still resolve to the asset under its current name; they are - // rewritten after the rename's write lock has been released. - assetsBefore, _ := v.ListAssets() - notesBefore, _ := v.ListNotes() - meta, oldRel, err := v.renameAssetFile(rel, nextName) - if err != nil { - return AssetMeta{}, err - } - if meta.Path != oldRel { - v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) - } - return meta, nil -} - -// renameAssetFile is the locked file move behind RenameAsset. It returns the -// new meta and the asset's vault-relative path before the move. -func (v *Vault) renameAssetFile(rel, nextName string) (AssetMeta, string, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, "", err - } - before, err := v.assetMetaForAbs(srcAbs) - if err != nil { - return AssetMeta{}, "", err - } - cleanName, err := cleanAssetFilename(nextName) - if err != nil { - return AssetMeta{}, "", err - } - destAbs := filepath.Join(filepath.Dir(srcAbs), cleanName) - if destAbs != srcAbs { - if dstInfo, statErr := os.Stat(destAbs); statErr == nil { - // Something is already at the destination. Allow it only when it is - // literally the same file (case-only rename on a case-insensitive - // filesystem), routing through a temp name; otherwise it collides. - srcInfo, srcErr := os.Stat(srcAbs) - if srcErr != nil { - return AssetMeta{}, "", srcErr - } - if !os.SameFile(dstInfo, srcInfo) { - return AssetMeta{}, "", fmt.Errorf("an asset named %q already exists in this folder", cleanName) - } - tmp := srcAbs + ".zenrename.tmp" - if err := os.Rename(srcAbs, tmp); err != nil { - return AssetMeta{}, "", err - } - if err := os.Rename(tmp, destAbs); err != nil { - return AssetMeta{}, "", err - } - } else if !errors.Is(statErr, os.ErrNotExist) { - return AssetMeta{}, "", statErr - } else if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, "", err - } - } - meta, err := v.assetMetaForAbs(destAbs) - return meta, before.Path, err -} - -// MoveAsset moves an asset file into targetDir (vault-relative; empty means the -// unified assets/ folder), mirroring the desktop moveAsset. The filename is made -// unique in the destination. (#379) Every note that referenced the asset is -// then re-targeted to its new location, like RenameAsset. (#785) -func (v *Vault) MoveAsset(rel, targetDir string) (AssetMeta, error) { - assetsBefore, _ := v.ListAssets() - notesBefore, _ := v.ListNotes() - meta, oldRel, err := v.moveAssetFile(rel, targetDir) - if err != nil { - return AssetMeta{}, err - } - if meta.Path != oldRel { - v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) - } - return meta, nil -} - -// moveAssetFile is the locked file move behind MoveAsset. It returns the new -// meta and the asset's vault-relative path before the move. -func (v *Vault) moveAssetFile(rel, targetDir string) (AssetMeta, string, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, "", err - } - before, err := v.assetMetaForAbs(srcAbs) - if err != nil { - return AssetMeta{}, "", err - } - destDir, err := v.cleanAssetTargetDir(targetDir) - if err != nil { - return AssetMeta{}, "", err - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return AssetMeta{}, "", err - } - if filepath.Clean(destDir) == filepath.Clean(filepath.Dir(srcAbs)) { - return before, before.Path, nil - } - name := filepath.Base(srcAbs) - ext := filepath.Ext(name) - stem := strings.TrimSuffix(name, ext) - destAbs := uniquePath(destDir, stem, ext) - if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, "", err - } - meta, err := v.assetMetaForAbs(destAbs) - return meta, before.Path, err -} - -// assertAssetFile validates rel points at an existing, editable asset file and -// returns its safe absolute path. Assumes the caller holds v.mu. -func (v *Vault) assertAssetFile(rel string) (string, error) { - trimmed := strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/") - if trimmed == "" { - return "", errors.New("asset path is required") - } - for _, part := range strings.Split(trimmed, "/") { - if part == internalVaultDir { - return "", errors.New("cannot modify internal ZenNotes files") - } - } - if strings.EqualFold(filepath.Ext(trimmed), ".md") { - return "", errors.New("use note actions to modify markdown notes") - } - abs, err := SafeJoin(v.root, trimmed) - if err != nil { - return "", err - } - info, err := os.Stat(abs) - if err != nil { - return "", err - } - if info.IsDir() { - return "", errors.New("asset path is not a file") - } - return abs, nil -} - -// cleanAssetTargetDir resolves a vault-relative destination directory for a -// move. Empty resolves to the unified assets/ folder. Assumes caller holds v.mu. -func (v *Vault) cleanAssetTargetDir(targetDir string) (string, error) { - normalized := strings.Trim(strings.TrimSpace(filepath.ToSlash(targetDir)), "/") - if normalized == "" { - return SafeJoin(v.root, AssetsDir) - } - for _, part := range strings.Split(normalized, "/") { - if part == internalVaultDir { - return "", errors.New("cannot move assets into internal ZenNotes files") - } - } - return SafeJoin(v.root, normalized) -} - -func (v *Vault) assetMetaForAbs(abs string) (AssetMeta, error) { - info, err := os.Stat(abs) - if err != nil { - return AssetMeta{}, err - } - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return AssetMeta{}, err - } - name := filepath.Base(abs) - return AssetMeta{ - Path: filepath.ToSlash(rel), - Name: name, - Kind: kindForExt(strings.ToLower(filepath.Ext(name))), - SiblingOrder: 0, - Size: info.Size(), - UpdatedAt: info.ModTime().UnixMilli(), - }, nil -} - -func cleanAssetFilename(name string) (string, error) { - raw := strings.TrimSpace(name) - if strings.ContainsAny(raw, "/\\") { - return "", errors.New("use only a file name") - } - trimmed := filepath.Base(raw) - if trimmed == "" || trimmed == "." || trimmed == ".." { - return "", errors.New("asset name is required") - } - if strings.EqualFold(filepath.Ext(trimmed), ".md") { - return "", errors.New("use note actions for markdown notes") - } - return trimmed, nil -} - -// makeAssetMarkdown mirrors the desktop markdownForImportedAsset: everything is -// linked by VAULT-relative path, an image as a wikilink and anything else as a -// markdown link, which is the single form every client now writes. The link -// used to be relative to the note, so it broke as soon as the note moved to -// another depth (nothing rewrites relative asset paths on move). -func makeAssetMarkdown(vaultRelPath, kind, name string) string { - if kind == "image" { - return "![[" + vaultRelPath + "]]" - } - dest := "<" + strings.ReplaceAll(vaultRelPath, ">", "%3E") + ">" - return "[" + name + "](" + dest + ")" -} - -// --- Misc helpers --- - -var forbiddenFilenameChars = []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} - -func sanitizeFileStem(title string) string { - t := title - for _, c := range forbiddenFilenameChars { - t = strings.ReplaceAll(t, c, "") - } - t = strings.TrimSpace(t) - if t == "" { - t = defaultTitle() - } - return t -} - -func sanitizeFileName(name string) string { - leaf := filepath.Base(name) - safe := strings.Map(func(r rune) rune { - if r < 0x20 || strings.ContainsRune("\\/:%*?\"<>|[]#^", r) { - return '-' - } - return r - }, leaf) - safe = strings.Join(strings.Fields(safe), " ") - if safe == "." || safe == ".." { - return "" - } - return safe -} - -func defaultTitle() string { - return "Untitled-" + time.Now().Format("2006-01-02-150405") -} - -func uniquePath(dir, stem, ext string) string { - candidate := filepath.Join(dir, stem+ext) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - for i := 2; ; i++ { - candidate = filepath.Join(dir, fmt.Sprintf("%s %d%s", stem, i, ext)) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - } -} - -func uniqueDir(parent, base string) string { - candidate := filepath.Join(parent, base) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - for i := 2; ; i++ { - candidate = filepath.Join(parent, fmt.Sprintf("%s %d", base, i)) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - } -} - -func copyFile(src, dst string, mode fs.FileMode) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, in) - return err -} - -func copyDir(src, dst string, fileMode, dirMode fs.FileMode) error { - return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return ErrPathEscape - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, dirMode) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("unsupported file type in folder copy: %s", path) - } - return copyFile(path, target, fileMode) - }) -} - -const welcomeNote = `# Welcome to ZenNotes - -ZenNotes keeps your notes as plain markdown files. Press ` + "`?`" + ` to see the -keybinding cheat sheet, or start typing to begin. - -- Notes live in ` + "`inbox/`" + `, ` + "`quick/`" + `, ` + "`archive/`" + `, and ` + "`trash/`" + `. -- Every word you write stays on disk, under your control. -- Vim motions are on by default. -` diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go deleted file mode 100644 index 3616ca94..00000000 --- a/apps/server/internal/vault/vault_test.go +++ /dev/null @@ -1,1778 +0,0 @@ -package vault - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func TestVaultDefaultModesAreTight(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("posix file modes") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("hello.md", "hi"); err != nil { - t.Fatal(err) - } - - info, err := os.Stat(filepath.Join(v.Root(), "hello.md")) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Fatalf("note perm = %o, want 0600", perm) - } - - // Note files live under inbox/, but the directory was created during - // EnsureLayout. Inspect the inbox dir to verify dirMode applied. - dirInfo, err := os.Stat(filepath.Join(v.Root(), "inbox")) - if err != nil { - t.Fatal(err) - } - if perm := dirInfo.Mode().Perm(); perm != 0o700 { - t.Fatalf("inbox dir perm = %o, want 0700", perm) - } -} - -func TestVaultModeOverride(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("posix file modes") - } - root := t.TempDir() - v, err := New(root, Options{FileMode: 0o644, DirMode: 0o755}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("hello.md", "hi"); err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(v.Root(), "hello.md")) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o644 { - t.Fatalf("override perm = %o, want 0644", perm) - } -} - -func TestImportAssetEnforcesMaxBytes(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 16}) - if err != nil { - t.Fatal(err) - } - big := bytes.Repeat([]byte("a"), 17) - _, err = v.ImportAsset("note.md", "x.bin", bytes.NewReader(big)) - if !errors.Is(err, ErrAssetTooLarge) { - t.Fatalf("expected ErrAssetTooLarge, got %v", err) - } - // Partial file should be removed from the assets/ destination. - for _, dir := range []string{v.Root(), filepath.Join(v.Root(), AssetsDir)} { - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".bin") { - t.Fatalf("partial asset %q should be cleaned up", e.Name()) - } - } - } -} - -func TestImportAssetWithinLimit(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 32}) - if err != nil { - t.Fatal(err) - } - body := bytes.Repeat([]byte("a"), 16) - asset, err := v.ImportAsset("note.md", "x.bin", bytes.NewReader(body)) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - // Uploads land in the unified assets/ folder, matching the desktop (#377). - if asset.Path != AssetsDir+"/"+asset.Name { - t.Fatalf("asset path = %q, want it under %s/", asset.Path, AssetsDir) - } - abs := filepath.Join(v.Root(), filepath.FromSlash(asset.Path)) - got, err := os.ReadFile(abs) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, body) { - t.Fatalf("written bytes differ from input") - } - // A non-image also uses a vault-relative markdown link into assets/. - if asset.Markdown == "" || !strings.Contains(asset.Markdown, "assets/x.bin") { - t.Fatalf("markdown = %q, want a link into assets/", asset.Markdown) - } -} - -// An image embeds by vault-relative wikilink whatever folder the note is in, -// matching the desktop and both mobile apps, so moving the note cannot break -// the link (nothing rewrites relative asset paths on move). -func TestImportAssetEmbedsImagesByVaultRelativeWikilink(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - png := []byte{0x89, 'P', 'N', 'G'} - for _, notePath := range []string{"Note.md", "inbox/Note.md", "inbox/deep/Note.md"} { - asset, err := v.ImportAsset(notePath, "Photo.png", bytes.NewReader(png)) - if err != nil { - t.Fatalf("%s: %v", notePath, err) - } - want := "![[" + asset.Path + "]]" - if asset.Markdown != want { - t.Fatalf("%s: markdown = %q, want %q", notePath, asset.Markdown, want) - } - if strings.Contains(asset.Markdown, "../") { - t.Fatalf("%s: markdown = %q, must not be note-relative", notePath, asset.Markdown) - } - } -} - -func TestImportAssetScrubsNamesThatBreakWikilinks(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - asset, err := v.ImportAsset("Note.md", "Photo%5D [v2] #3.png", bytes.NewReader([]byte{0x89, 'P', 'N', 'G'})) - if err != nil { - t.Fatal(err) - } - if asset.Path != "assets/Photo-5D -v2- -3.png" { - t.Fatalf("asset path = %q, want a wikilink-safe filename", asset.Path) - } - if asset.Markdown != "![[assets/Photo-5D -v2- -3.png]]" { - t.Fatalf("markdown = %q, want a valid wikilink embed", asset.Markdown) - } -} - -func TestImportAssetReportsAtBoundary(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 8}) - if err != nil { - t.Fatal(err) - } - body := bytes.Repeat([]byte("a"), 8) - if _, err := v.ImportAsset("note.md", "x.bin", bytes.NewReader(body)); err != nil { - t.Fatalf("8/8 bytes should succeed, got %v", err) - } - // 9-byte body must be rejected even though only one byte over. - body9 := bytes.Repeat([]byte("a"), 9) - if _, err := v.ImportAsset("note.md", "y.bin", bytes.NewReader(body9)); !errors.Is(err, ErrAssetTooLarge) { - t.Fatalf("9/8 should reject with ErrAssetTooLarge, got %v", err) - } -} - -func TestNoteCommentsFollowRenameDuplicateAndDelete(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Alpha.md", "hello world") - if err != nil { - t.Fatal(err) - } - comments, err := v.WriteNoteComments(meta.Path, []NoteComment{{ - AnchorStart: 0, - AnchorEnd: 5, - AnchorText: "hello", - Body: "Tighten this claim.", - }}) - if err != nil { - t.Fatal(err) - } - if len(comments) != 1 || comments[0].ID == "" { - t.Fatalf("comment was not normalized: %#v", comments) - } - - renamed, err := v.RenameNote(meta.Path, "Beta") - if err != nil { - t.Fatal(err) - } - oldComments, err := v.ReadNoteComments(meta.Path) - if err != nil { - t.Fatal(err) - } - if len(oldComments) != 0 { - t.Fatalf("old sidecar still has comments: %#v", oldComments) - } - renamedComments, err := v.ReadNoteComments(renamed.Path) - if err != nil { - t.Fatal(err) - } - if len(renamedComments) != 1 || renamedComments[0].NotePath != renamed.Path { - t.Fatalf("comments did not follow rename: %#v", renamedComments) - } - - duplicated, err := v.DuplicateNote(renamed.Path) - if err != nil { - t.Fatal(err) - } - duplicatedComments, err := v.ReadNoteComments(duplicated.Path) - if err != nil { - t.Fatal(err) - } - if len(duplicatedComments) != 1 || duplicatedComments[0].NotePath != duplicated.Path { - t.Fatalf("comments did not copy to duplicate: %#v", duplicatedComments) - } - if duplicatedComments[0].ID == renamedComments[0].ID { - t.Fatalf("duplicated note should get independent comment ids") - } - - if err := v.DeleteNote(renamed.Path); err != nil { - t.Fatal(err) - } - deletedComments, err := v.ReadNoteComments(renamed.Path) - if err != nil { - t.Fatal(err) - } - if len(deletedComments) != 0 { - t.Fatalf("comments should be removed with deleted note: %#v", deletedComments) - } -} - -func TestReadNoteRefusesSymlinkOutsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - secret := filepath.Join(outside, "secret.txt") - if err := os.WriteFile(secret, []byte("classified"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "evil.md") - if err := os.Symlink(secret, link); err != nil { - t.Fatal(err) - } - - if _, err := v.ReadNote("evil.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape via ReadNote, got %v", err) - } -} - -// A `.base` database is a directory, and a client that treats one as a note -// asks to read it as a file. The answer has to be the same everywhere: the -// errno differs by platform (EISDIR on Unix, ERROR_INVALID_FUNCTION on -// Windows), which is why the HTTP layer once said 400 on macOS and Linux and -// 500 on Windows for the identical request. ReadNote classifies it from its -// own stat, so this test means the same thing on every runner. -func TestReadNoteRejectsADirectoryOnEveryPlatform(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(v.Root(), "inbox", "Db.base"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(v.Root(), "inbox", "Real.md"), []byte("# Real\n"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := v.ReadNote("inbox/Db.base"); !errors.Is(err, ErrIsDirectory) { - t.Fatalf("reading a directory: got %v, want ErrIsDirectory", err) - } - // The classification must not swallow the two answers around it: a real - // note still reads, and a missing file inside that directory is still - // absent rather than "is a directory". - if _, err := v.ReadNote("inbox/Real.md"); err != nil { - t.Fatalf("reading a note: %v", err) - } - if _, err := v.ReadNote("inbox/Db.base/data.csv"); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("missing file: got %v, want os.ErrNotExist", err) - } -} - -func TestWriteNoteRefusesSymlinkOutsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - target := filepath.Join(outside, "victim.txt") - if err := os.WriteFile(target, []byte("original"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "evil.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteNote("evil.md", "tampered"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape via WriteNote, got %v", err) - } - // The target file outside the vault must not be touched. - got, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(got) != "original" { - t.Fatalf("file outside vault was modified: %q", got) - } -} - -func TestDuplicateFolderRefusesNestedSymlink(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - secret := filepath.Join(outside, "secret.md") - if err := os.WriteFile(secret, []byte("classified"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - source := filepath.Join(v.Root(), string(FolderInbox), "source") - if err := os.MkdirAll(source, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "safe.md"), []byte("safe"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(secret, filepath.Join(source, "leak.md")); err != nil { - t.Fatal(err) - } - - if _, err := v.DuplicateFolder(FolderInbox, "source"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape duplicating folder with symlink, got %v", err) - } - if _, err := os.Stat(filepath.Join(v.Root(), string(FolderInbox), "source copy", "leak.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("symlink target should not be copied into duplicated folder, stat err=%v", err) - } -} - -func TestSearchTextRefreshesAfterExternalChange(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Search.md", "alpha only\n") - if err != nil { - t.Fatal(err) - } - - matches, err := v.SearchText("alpha") - if err != nil { - t.Fatal(err) - } - if !textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("initial search did not find %s: %#v", meta.Path, matches) - } - - abs := filepath.Join(v.Root(), filepath.FromSlash(meta.Path)) - if err := os.WriteFile(abs, []byte("beta only\n"), 0o600); err != nil { - t.Fatal(err) - } - future := time.Now().Add(2 * time.Second) - if err := os.Chtimes(abs, future, future); err != nil { - t.Fatal(err) - } - - matches, err = v.SearchText("alpha") - if err != nil { - t.Fatal(err) - } - if textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("stale search result still found %s: %#v", meta.Path, matches) - } - - matches, err = v.SearchText("beta") - if err != nil { - t.Fatal(err) - } - if !textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("refreshed search did not find %s: %#v", meta.Path, matches) - } -} - -func TestListNotesUsesMatchingPersistedMetadata(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "cached.md")) - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.WriteFile(abs, []byte("# Disk Title\n\n#disk\n"), 0o600); err != nil { - t.Fatal(err) - } - info, err := os.Stat(abs) - if err != nil { - t.Fatal(err) - } - cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { - t.Fatal(err) - } - cache := persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: []persistedNoteMetaEntry{{ - Path: rel, - MtimeMs: mtimeMs(info), - Size: info.Size(), - Meta: NoteMeta{ - Path: rel, - Title: "Cached Title", - Folder: FolderInbox, - SiblingOrder: 0, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: []string{"cached"}, - Wikilinks: []string{"Cached Target"}, - AssetEmbeds: []string{}, - HasAttachments: false, - Excerpt: "cached excerpt", - }, - }}, - } - raw, err := json.Marshal(cache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, raw, 0o600); err != nil { - t.Fatal(err) - } - v.invalidateNoteMetaCache() - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - meta, ok := findNoteMeta(notes, rel) - if !ok { - t.Fatalf("note %s not found in %#v", rel, notes) - } - if meta.Title != "Cached Title" || len(meta.Tags) != 1 || meta.Tags[0] != "cached" || meta.Excerpt != "cached excerpt" { - t.Fatalf("did not use matching persisted metadata: %#v", meta) - } -} - -func TestListNotesIgnoresStalePersistedMetadata(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "stale.md")) - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.WriteFile(abs, []byte("# Fresh Title\n\n#fresh\n"), 0o600); err != nil { - t.Fatal(err) - } - cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { - t.Fatal(err) - } - cache := persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: []persistedNoteMetaEntry{{ - Path: rel, - MtimeMs: 1, - Size: 1, - Meta: NoteMeta{ - Path: rel, - Title: "Stale Title", - Folder: FolderInbox, - SiblingOrder: 0, - CreatedAt: 1, - UpdatedAt: 1, - Size: 1, - Tags: []string{"stale"}, - Wikilinks: []string{}, - AssetEmbeds: []string{}, - HasAttachments: false, - Excerpt: "stale excerpt", - }, - }}, - } - raw, err := json.Marshal(cache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, raw, 0o600); err != nil { - t.Fatal(err) - } - v.invalidateNoteMetaCache() - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - meta, ok := findNoteMeta(notes, rel) - if !ok { - t.Fatalf("note %s not found in %#v", rel, notes) - } - if meta.Title != "stale" || len(meta.Tags) != 1 || meta.Tags[0] != "fresh" || !strings.Contains(meta.Excerpt, "Fresh Title") { - t.Fatalf("stale persisted metadata was not ignored: %#v", meta) - } -} - -func findNoteMeta(notes []NoteMeta, path string) (NoteMeta, bool) { - for _, note := range notes { - if note.Path == path { - return note, true - } - } - return NoteMeta{}, false -} - -func textSearchMatchesPath(matches []TextSearchMatch, path string) bool { - for _, match := range matches { - if match.Path == path { - return true - } - } - return false -} - -// Compile-time assertion that ImportAsset accepts an io.Reader (silences -// unused-import lints if the asset tests are stripped down later). -var _ = io.Reader(bytes.NewReader(nil)) - -func TestArchiveRoundTripPreservesSubfolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "demo"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "demo", "Tables.md"), []byte("# Tables\n"), 0o600); err != nil { - t.Fatal(err) - } - - archived, err := v.ArchiveNote("inbox/demo/Tables.md") - if err != nil { - t.Fatal(err) - } - if archived.Path != "archive/demo/Tables.md" { - t.Fatalf("archived path = %q, want archive/demo/Tables.md", archived.Path) - } - - restored, err := v.UnarchiveNote(archived.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "inbox/demo/Tables.md" { - t.Fatalf("unarchived path = %q, want inbox/demo/Tables.md", restored.Path) - } -} - -func TestTrashRoundTripPreservesSubfolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "demo"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "demo", "Tables.md"), []byte("# Tables\n"), 0o600); err != nil { - t.Fatal(err) - } - - trashed, err := v.MoveToTrash("inbox/demo/Tables.md") - if err != nil { - t.Fatal(err) - } - if trashed.Path != "trash/demo/Tables.md" { - t.Fatalf("trashed path = %q, want trash/demo/Tables.md", trashed.Path) - } - - restored, err := v.RestoreFromTrash(trashed.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "inbox/demo/Tables.md" { - t.Fatalf("restored path = %q, want inbox/demo/Tables.md", restored.Path) - } -} - -func TestVaultSettingsWeeklyNotesRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - // Mirrors what the web client POSTs: weekly notes enabled with a custom - // directory and a template, plus a daily-notes template. Before the fix - // the server struct lacked WeeklyNotes (and DailyNotes.TemplateID), so - // these were silently dropped on decode/normalize and never persisted — - // the toggle always reverted after a reload. (#117) - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{ - Enabled: true, - Directory: "Daily", - TitlePattern: "yyyy-MM-dd-EEE", - Locale: "pt-BR", - TemplateID: "daily-tmpl", - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: true, - Directory: "My Weeks", - TitlePattern: "yyyy-'W'ww-EEE", - Locale: "en-US", - TemplateID: "weekly-tmpl", - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: true, - Directory: "My Months", - TitlePattern: "yyyy-MM", - Locale: "en-GB", - TemplateID: "monthly-tmpl", - }, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if !got.WeeklyNotes.Enabled { - t.Error("weekly notes enabled did not persist") - } - if got.WeeklyNotes.Directory != "My Weeks" { - t.Errorf("weekly directory = %q, want %q", got.WeeklyNotes.Directory, "My Weeks") - } - if got.WeeklyNotes.TemplateID != "weekly-tmpl" { - t.Errorf("weekly templateId = %q, want %q", got.WeeklyNotes.TemplateID, "weekly-tmpl") - } - if got.WeeklyNotes.TitlePattern != "yyyy-'W'ww-EEE" { - t.Errorf("weekly titlePattern = %q, want %q", got.WeeklyNotes.TitlePattern, "yyyy-'W'ww-EEE") - } - if got.WeeklyNotes.Locale != "en-US" { - t.Errorf("weekly locale = %q, want %q", got.WeeklyNotes.Locale, "en-US") - } - if got.DailyNotes.TemplateID != "daily-tmpl" { - t.Errorf("daily templateId = %q, want %q", got.DailyNotes.TemplateID, "daily-tmpl") - } - if got.DailyNotes.TitlePattern != "yyyy-MM-dd-EEE" { - t.Errorf("daily titlePattern = %q, want %q", got.DailyNotes.TitlePattern, "yyyy-MM-dd-EEE") - } - if got.DailyNotes.Locale != "pt-BR" { - t.Errorf("daily locale = %q, want %q", got.DailyNotes.Locale, "pt-BR") - } - if !got.MonthlyNotes.Enabled { - t.Error("monthly notes enabled did not persist") - } - if got.MonthlyNotes.Directory != "My Months" { - t.Errorf("monthly directory = %q, want %q", got.MonthlyNotes.Directory, "My Months") - } - if got.MonthlyNotes.TemplateID != "monthly-tmpl" { - t.Errorf("monthly templateId = %q, want %q", got.MonthlyNotes.TemplateID, "monthly-tmpl") - } - if got.MonthlyNotes.TitlePattern != "yyyy-MM" { - t.Errorf("monthly titlePattern = %q, want %q", got.MonthlyNotes.TitlePattern, "yyyy-MM") - } - if got.MonthlyNotes.Locale != "en-GB" { - t.Errorf("monthly locale = %q, want %q", got.MonthlyNotes.Locale, "en-GB") - } - - // The key must actually reach vault.json — the original bug was that it - // never hit disk. - raw, err := os.ReadFile(v.settingsPath()) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte("weeklyNotes")) { - t.Errorf("vault.json missing weeklyNotes key:\n%s", raw) - } - - // An empty weekly directory normalizes to the default, mirroring daily. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - WeeklyNotes: WeeklyNotesSettings{Enabled: true, Directory: ""}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.WeeklyNotes.Directory != DefaultWeeklyNotesDirectory { - t.Errorf("empty weekly directory = %q, want default %q", got.WeeklyNotes.Directory, DefaultWeeklyNotesDirectory) - } -} - -// The web client POSTs where new drawings / databases / task files should be -// created (Settings -> New Drawings, Databases & Tasks). Before the fix the -// server struct had none of these three FileLocationSetting fields, so they -// were silently dropped on decode/normalize and the segmented controls always -// snapped back — every new task landed in the inbox regardless of the choice, -// like #117. (#446) -func TestVaultSettingsFileLocationsRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DrawingsLocation: FileLocationSetting{Mode: FileLocationActiveNote}, - DatabasesLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: "assets/databases"}, - TasksLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: "Tasks"}, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DrawingsLocation.Mode != FileLocationActiveNote { - t.Errorf("drawings mode = %q, want %q", got.DrawingsLocation.Mode, FileLocationActiveNote) - } - if got.DatabasesLocation.Mode != FileLocationFolder || got.DatabasesLocation.Folder != "assets/databases" { - t.Errorf("databases location = %+v, want folder mode with assets/databases", got.DatabasesLocation) - } - if got.TasksLocation.Mode != FileLocationFolder || got.TasksLocation.Folder != "Tasks" { - t.Errorf("tasks location = %+v, want folder mode with Tasks", got.TasksLocation) - } - - // An unknown/empty mode normalizes to primary, and folder-mode paths are - // trimmed of surrounding whitespace and slashes. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - TasksLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: " /Projects/ "}, - DrawingsLocation: FileLocationSetting{Mode: "bogus"}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.TasksLocation.Folder != "Projects" { - t.Errorf("tasks folder = %q, want trimmed %q", got.TasksLocation.Folder, "Projects") - } - if got.DrawingsLocation.Mode != FileLocationPrimary { - t.Errorf("unknown drawings mode = %q, want normalized %q", got.DrawingsLocation.Mode, FileLocationPrimary) - } -} - -// The web client drives the implicit-due and task-rollover behavior off two -// daily-notes booleans. They are pointers so "absent" round-trips as unset -// (the TS client applies the real default); an explicit value must survive a -// SetSettings -> GetSettings round-trip and reach vault.json, or the web -// toggles would silently revert like #117. -func TestVaultSettingsDailyTaskFlagsRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - yes := true - no := false - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{ - Enabled: true, - Directory: "Daily", - TasksDueOnNoteDate: &no, // explicitly turn the default (true) OFF - RolloverUnfinishedTasks: &yes, - }, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DailyNotes.TasksDueOnNoteDate == nil || *got.DailyNotes.TasksDueOnNoteDate != false { - t.Errorf("tasksDueOnNoteDate = %v, want explicit false", got.DailyNotes.TasksDueOnNoteDate) - } - if got.DailyNotes.RolloverUnfinishedTasks == nil || *got.DailyNotes.RolloverUnfinishedTasks != true { - t.Errorf("rolloverUnfinishedTasks = %v, want explicit true", got.DailyNotes.RolloverUnfinishedTasks) - } - - raw, err := os.ReadFile(v.settingsPath()) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte("tasksDueOnNoteDate")) { - t.Errorf("vault.json missing tasksDueOnNoteDate key:\n%s", raw) - } - if !bytes.Contains(raw, []byte("rolloverUnfinishedTasks")) { - t.Errorf("vault.json missing rolloverUnfinishedTasks key:\n%s", raw) - } - - // Absent pointers must stay nil (omitted) so the client default wins. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{Enabled: true, Directory: "Daily"}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DailyNotes.TasksDueOnNoteDate != nil { - t.Errorf("absent tasksDueOnNoteDate = %v, want nil", *got.DailyNotes.TasksDueOnNoteDate) - } -} - -// A file or directory the server can't read must be skipped, not abort the whole -// vault scan — otherwise one root-owned entry hides the entire vault. (#159) -func TestListSkipsUnreadableEntries(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits don't apply on Windows") - } - if os.Geteuid() == 0 { - t.Skip("permission errors are bypassed when running as root") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatalf("New: %v", err) - } - if _, err := v.WriteNote("inbox/Readable.md", "ok"); err != nil { - t.Fatalf("WriteNote readable: %v", err) - } - if _, err := v.WriteNote("inbox/Locked/Secret.md", "secret"); err != nil { - t.Fatalf("WriteNote locked: %v", err) - } - - // Make the subfolder unreadable, simulating a root-owned dir the non-root - // server process can't read. Locate it by name so this is mode-independent. - var locked string - _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { - if err == nil && d.IsDir() && d.Name() == "Locked" { - locked = p - } - return nil - }) - if locked == "" { - t.Fatal("could not locate the Locked subfolder on disk") - } - if err := os.Chmod(locked, 0o000); err != nil { - t.Fatalf("chmod: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) - - notes, err := v.ListNotes() - if err != nil { - t.Fatalf("ListNotes aborted instead of skipping the unreadable dir: %v", err) - } - var sawReadable, sawSecret bool - for _, n := range notes { - if strings.Contains(n.Path, "Readable.md") { - sawReadable = true - } - if strings.Contains(n.Path, "Secret.md") { - sawSecret = true - } - } - if !sawReadable { - t.Errorf("readable note missing from %d listed notes", len(notes)) - } - if sawSecret { - t.Error("note inside the unreadable dir should have been skipped") - } - - if _, err := v.ListFolders(); err != nil { - t.Fatalf("ListFolders aborted instead of skipping the unreadable dir: %v", err) - } -} - -func TestDatabaseBaseFolderListedButInternalsHidden(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A database folder with its internals, a record-page note, and a nested dir - // (its internals + nested dirs must NOT surface as folders). - baseDir := filepath.Join(root, "inbox", "Books.base") - if err := os.MkdirAll(filepath.Join(baseDir, "pages"), 0o700); err != nil { - t.Fatal(err) - } - for name, body := range map[string]string{ - "data.csv": "id,Title\nr1,Dune\n", - "schema.json": `{"version":1}`, - "Dune.md": "# Dune", - } { - if err := os.WriteFile(filepath.Join(baseDir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - // A regular note + folder that MUST still surface. - if err := os.WriteFile(filepath.Join(root, "inbox", "Regular.md"), []byte("# Hi"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "RealFolder"), 0o700); err != nil { - t.Fatal(err) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - // #527: a database's record pages ARE notes. The desktop lists them, so a - // remote vault must too, or every wikilink into a database resolves to - // nothing on the server while working locally. - if !hasNotePath(notes, "inbox/Books.base/Dune.md") { - t.Error("ListNotes dropped a database record page, so wikilinks into the database cannot resolve") - } - // The database's own machinery is not a note, and never was: only `.md` - // files are collected, so data.csv and schema.json cannot surface here. - for _, n := range notes { - if strings.HasSuffix(n.Path, "data.csv") || strings.HasSuffix(n.Path, "schema.json") { - t.Errorf("ListNotes leaked database internals as a note: %s", n.Path) - } - } - if !hasNotePath(notes, "inbox/Regular.md") { - t.Error("ListNotes dropped a regular note") - } - - folders, err := v.ListFolders() - if err != nil { - t.Fatal(err) - } - sawReal := false - sawBase := false - for _, f := range folders { - // The database folder itself lists (renderer renders it as a database)... - if f.Subpath == "Books.base" { - sawBase = true - continue - } - // ...but nothing INSIDE it (e.g. Books.base/pages) is exposed as a folder. - if strings.Contains(f.Subpath, ".base/") { - t.Errorf("ListFolders leaked a database-internal folder: %s", f.Subpath) - } - if f.Subpath == "RealFolder" { - sawReal = true - } - } - if !sawBase { - t.Error("ListFolders should list the .base database folder itself") - } - if !sawReal { - t.Error("ListFolders dropped a regular folder") - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - for _, a := range assets { - if strings.Contains(a.Path, ".base") { - t.Errorf("ListAssets leaked a database-internal file: %s", a.Path) - } - } -} - -func hasNotePath(notes []NoteMeta, path string) bool { - for _, n := range notes { - if n.Path == path { - return true - } - } - return false -} - -func TestFavoritesRoundTripAndDedupe(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - saved, err := v.SetSettings(VaultSettings{ - // Mix of a note path and a folder key, with a duplicate and an empty entry. - Favorites: []string{"inbox/Idea.md", "inbox:Projects", "inbox/Idea.md", ""}, - }) - if err != nil { - t.Fatal(err) - } - want := []string{"inbox/Idea.md", "inbox:Projects"} - if len(saved.Favorites) != len(want) { - t.Fatalf("favorites = %v, want %v", saved.Favorites, want) - } - for i, f := range want { - if saved.Favorites[i] != f { - t.Errorf("favorites[%d] = %q, want %q", i, saved.Favorites[i], f) - } - } - // Persisted to disk and reloaded. - reloaded, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if len(reloaded.Favorites) != len(want) { - t.Errorf("reloaded favorites = %v, want %v", reloaded.Favorites, want) - } -} - -func TestFavoritesSurviveFolderRename(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{Favorites: []string{"inbox:Projects", "inbox/Idea.md"}}); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder("inbox", "Projects", "Work"); err != nil { - t.Fatal(err) - } - // The server carries favorites through verbatim (the client rewrites keys). - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if len(settings.Favorites) != 2 { - t.Fatalf("folder rename dropped favorites: %v", settings.Favorites) - } -} - -func TestExcalidrawListedAsNoteNotAsset(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A drawing whose JSON body contains a hex color (#1971c2) that must NOT - // be mistaken for a #tag, plus an image that should stay an asset. - scene := `{"type":"excalidraw","version":2,"elements":[{"strokeColor":"#1971c2"}],"appState":{},"files":{}}` - if err := os.WriteFile(filepath.Join(root, "inbox", "Sketch.excalidraw"), []byte(scene), 0o600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - if !hasNotePath(notes, "inbox/Sketch.excalidraw") { - t.Error("ListNotes dropped the .excalidraw drawing") - } - for _, n := range notes { - if n.Path == "inbox/Sketch.excalidraw" { - if n.Title != "Sketch" { - t.Errorf("drawing title = %q, want Sketch", n.Title) - } - if len(n.Tags) != 0 { - t.Errorf("drawing leaked tags from JSON hex colors: %v", n.Tags) - } - } - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - sawImage := false - for _, a := range assets { - if strings.HasSuffix(a.Path, ".excalidraw") { - t.Errorf("ListAssets leaked a drawing: %s", a.Path) - } - if a.Path == "assets/pic.png" { - sawImage = true - } - } - if !sawImage { - t.Error("ListAssets dropped a real asset") - } -} - -func TestCreateExcalidrawSeedsEmptyScene(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.CreateExcalidraw(FolderInbox, "My Drawing", "") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(meta.Path, ".excalidraw") { - t.Errorf("created path = %q, want a .excalidraw file", meta.Path) - } - content, err := v.ReadNote(meta.Path) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(content.Body, `"type": "excalidraw"`) { - t.Errorf("seeded scene missing excalidraw type: %s", content.Body) - } -} - -func TestListAssetsIgnoresAtomicWriteScratchFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - const scratch = "Daily.md.3252272.1787800172047252.tmp" - if err := os.WriteFile(filepath.Join(root, "inbox", scratch), []byte("in-flight save"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "report.2024.01.tmp"), []byte("user file"), 0o600); err != nil { - t.Fatal(err) - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - sawUserFile := false - for _, asset := range assets { - if asset.Name == scratch { - t.Fatalf("ListAssets leaked an atomic save scratch file: %s", asset.Path) - } - if asset.Name == "report.2024.01.tmp" { - sawUserFile = true - } - } - if !sawUserFile { - t.Fatal("ListAssets dropped a user-authored .tmp file that does not match the atomic-save pattern") - } -} - -func TestRenameAndMovePreserveExcalidrawExt(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - created, err := v.CreateExcalidraw(FolderInbox, "Diagram", "") - if err != nil { - t.Fatal(err) - } - - renamed, err := v.RenameNote(created.Path, "Flowchart") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(renamed.Path, ".excalidraw") { - t.Errorf("rename dropped the extension: %q", renamed.Path) - } - - moved, err := v.MoveNote(renamed.Path, FolderArchive, "") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(moved.Path, ".excalidraw") { - t.Errorf("move dropped the extension: %q", moved.Path) - } -} - -// CreateNote seeds the same `# Title` body the desktop app writes (main -// vault.ts and the MCP vault-ops both do). A remote vault otherwise creates -// blank notes where a local one has its title, which is most visible on daily -// notes, whose date heading is the whole point. -func TestCreateNoteSeedsTheTitleHeadingLikeTheDesktopApp(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - - meta, err := v.CreateNote(FolderInbox, "2026-08-04", "Daily Notes") - if err != nil { - t.Fatal(err) - } - body, err := os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(meta.Path))) - if err != nil { - t.Fatal(err) - } - if string(body) != "# 2026-08-04\n\n" { - t.Fatalf("seeded body = %q, want %q", string(body), "# 2026-08-04\n\n") - } - - // A deduped file heads itself by its final on-disk stem, like the desktop. - if _, err := v.CreateNote(FolderInbox, "Note", ""); err != nil { - t.Fatal(err) - } - second, err := v.CreateNote(FolderInbox, "Note", "") - if err != nil { - t.Fatal(err) - } - if second.Path != "inbox/Note 2.md" { - t.Fatalf("deduped path = %q, want inbox/Note 2.md", second.Path) - } - body, err = os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(second.Path))) - if err != nil { - t.Fatal(err) - } - if string(body) != "# Note 2\n\n" { - t.Fatalf("deduped body = %q, want %q", string(body), "# Note 2\n\n") - } -} - -func TestHarperSettingsRoundTripAndNormalize(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - saved, err := v.SetSettings(VaultSettings{ - Harper: &HarperSettings{ - Words: []string{" zennotes ", "zennotes", ""}, - IgnoredLints: []string{"9722060015410969502", "not-a-hash", "9722060015410969502"}, - }, - }) - if err != nil { - t.Fatal(err) - } - if saved.Harper == nil { - t.Fatal("harper settings dropped on save") - } - if len(saved.Harper.Words) != 1 || saved.Harper.Words[0] != "zennotes" { - t.Errorf("words = %v, want [zennotes]", saved.Harper.Words) - } - if len(saved.Harper.IgnoredLints) != 1 || saved.Harper.IgnoredLints[0] != "9722060015410969502" { - t.Errorf("ignoredLints = %v, want the one digit string", saved.Harper.IgnoredLints) - } - reloaded, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if reloaded.Harper == nil || reloaded.Harper.Words[0] != "zennotes" { - t.Errorf("reloaded harper = %+v", reloaded.Harper) - } - cleared, err := v.SetSettings(VaultSettings{Harper: &HarperSettings{}}) - if err != nil { - t.Fatal(err) - } - if cleared.Harper != nil { - t.Errorf("empty harper block should be dropped, got %+v", cleared.Harper) - } -} - -func TestNoteCommentsKeepAuthorAndThreadReplies(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Reviewed.md", "line one\nline two\nline three") - if err != nil { - t.Fatalf("write note: %v", err) - } - written, err := v.WriteNoteComments(meta.Path, []NoteComment{ - {ID: "c1", Body: "Is this right?", CreatedAt: 1, UpdatedAt: 1}, - {ID: "c2", Body: "Yes, see line 3.", CreatedAt: 2, UpdatedAt: 2, Author: " Claude Code ", ParentID: " c1 "}, - {ID: "c3", Body: "orphan", CreatedAt: 3, UpdatedAt: 3, ParentID: "missing"}, - }) - if err != nil { - t.Fatalf("write comments: %v", err) - } - if len(written) != 3 { - t.Fatalf("expected 3 comments, got %d", len(written)) - } - read, err := v.ReadNoteComments(meta.Path) - if err != nil { - t.Fatalf("read comments: %v", err) - } - if read[1].Author != "Claude Code" || read[1].ParentID != "c1" { - t.Fatalf("reply lost its author or parent: %#v", read[1]) - } - if read[0].Author != "" || read[0].ParentID != "" { - t.Fatalf("top-level comment gained fields: %#v", read[0]) - } - if read[2].ParentID != "" { - t.Fatalf("orphan reply kept a missing parent: %#v", read[2]) - } -} - -func TestListNotesRebuildsCacheWithoutAssetEmbeds(t *testing.T) { - for _, version := range []int{1, noteMetaCacheVersion} { - t.Run(fmt.Sprint(version), func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/asset-cache.md", "![[photo.png]]\n") - if err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(v.Root(), filepath.FromSlash(meta.Path))) - if err != nil { - t.Fatal(err) - } - meta.AssetEmbeds = nil - cache := persistedNoteMetaCache{Version: version, Entries: []persistedNoteMetaEntry{{Path: meta.Path, MtimeMs: mtimeMs(info), Size: info.Size(), Meta: meta}}} - raw, err := json.Marshal(cache) - if err != nil { - t.Fatal(err) - } - cachePath := v.noteMetaCachePath() - if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, raw, 0o600); err != nil { - t.Fatal(err) - } - v.invalidateNoteMetaCache() - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - got, ok := findNoteMeta(notes, meta.Path) - if !ok || len(got.AssetEmbeds) != 1 || got.AssetEmbeds[0] != "photo.png" { - t.Fatalf("cache did not rebuild asset metadata: %#v", got) - } - }) - } -} - -func TestFolderMutationsPreserveCommentStorage(t *testing.T) { - for _, location := range []PrimaryNotesLocation{PrimaryNotesInbox, PrimaryNotesRoot} { - t.Run(string(location), func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - settings.PrimaryNotesLocation = PrimaryNotesLocation(location) - settings.SystemFolderPaths = map[string]string{string(FolderInbox): "My Notes"} - if _, err := v.SetSettings(settings); err != nil { - t.Fatal(err) - } - prefix := "My Notes/" - if location == PrimaryNotesRoot { - prefix = "" - } - original := prefix + "Work/Nested/Note.md" - if _, err := v.WriteNote(original, "Body.\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments(original, []NoteComment{{ID: "comment", Body: "Keep this comment", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder(FolderInbox, "Work", "Renamed"); err != nil { - t.Fatal(err) - } - renamed := prefix + "Renamed/Nested/Note.md" - comments, err := v.ReadNoteComments(renamed) - if err != nil { - t.Fatal(err) - } - if len(comments) != 1 || comments[0].Body != "Keep this comment" || comments[0].NotePath != renamed { - t.Fatalf("comments lost: %#v", comments) - } - old, err := v.ReadNoteComments(original) - if err != nil || len(old) != 0 { - t.Fatalf("old comments remain: %#v %v", old, err) - } - if err := v.DeleteFolder(FolderInbox, "Renamed"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote(renamed, "New note.\n"); err != nil { - t.Fatal(err) - } - comments, err = v.ReadNoteComments(renamed) - if err != nil || len(comments) != 0 { - t.Fatalf("deleted comments returned: %#v %v", comments, err) - } - }) - } -} - -func TestFolderTreesRollbackOnSettingsFailure(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - source := filepath.Join(root, "content") - comments := filepath.Join(root, "comments") - for _, dir := range []string{source, comments} { - if err := os.Mkdir(dir, 0700); err != nil { - t.Fatal(err) - } - } - err = v.relocateFolderTrees([][2]string{{source, source + "-new"}, {comments, comments + "-new"}}, func() error { return errors.New("settings failed") }) - if err == nil { - t.Fatal("expected settings failure") - } - for _, dir := range []string{source, comments} { - if _, err := os.Stat(dir); err != nil { - t.Fatal(err) - } - } - for _, dir := range []string{source + "-new", comments + "-new"} { - if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("target remains: %s", dir) - } - } -} - -func TestFolderRenameRejectsMissingSourceAndCommentCollision(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder(FolderInbox, "Missing", "New"); err == nil { - t.Fatal("missing source accepted") - } - if _, err := v.WriteNote("inbox/Work/Note.md", "Original"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments("inbox/Renamed/Note.md", []NoteComment{{ID: "orphan", Body: "Retain", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder(FolderInbox, "Work", "Renamed"); err == nil { - t.Fatal("comment collision accepted") - } - if _, err := v.ReadNote("inbox/Work/Note.md"); err != nil { - t.Fatal(err) - } - comments, err := v.ReadNoteComments("inbox/Renamed/Note.md") - if err != nil || len(comments) != 1 { - t.Fatalf("orphan lost: %v %v", comments, err) - } -} - -func TestDeleteFolderInFreshVault(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Work/Note.md", "Original"); err != nil { - t.Fatal(err) - } - if err := v.DeleteFolder(FolderInbox, "Work"); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(v.root, "inbox/Work")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("folder remains: %v", err) - } -} - -func TestNoteMoveRetainsSourceOnCommentCollision(t *testing.T) { - for _, withComments := range []bool{false, true} { - t.Run(fmt.Sprint(withComments), func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - original := "Original café. \n" - if _, err := v.WriteNote("inbox/One.md", original); err != nil { - t.Fatal(err) - } - if withComments { - if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "source", Body: "Source discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - } - if _, err := v.WriteNoteComments("inbox/Work/One.md", []NoteComment{{ID: "destination", Body: "Keep destination", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := v.MoveNote("inbox/One.md", FolderInbox, "Work"); err == nil { - t.Fatal("expected collision") - } - content, err := v.ReadNote("inbox/One.md") - if err != nil || content.Body != original { - t.Fatalf("source lost: %#v %v", content, err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "inbox/Work/One.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("target exists: %v", err) - } - comments, err := v.ReadNoteComments("inbox/Work/One.md") - if err != nil || len(comments) != 1 || comments[0].Body != "Keep destination" { - t.Fatalf("target comments changed: %#v %v", comments, err) - } - }) - } -} - -func TestNoteRenameRetainsSourceOnCommentCollision(t *testing.T) { - for _, withComments := range []bool{false, true} { - t.Run(fmt.Sprint(withComments), func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - original := "Original café. \n" - if _, err := v.WriteNote("inbox/One.md", original); err != nil { - t.Fatal(err) - } - if withComments { - if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "source", Body: "Source discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - } - if _, err := v.WriteNoteComments("inbox/Renamed.md", []NoteComment{{ID: "destination", Body: "Keep destination", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := v.RenameNote("inbox/One.md", "Renamed"); err == nil { - t.Fatal("expected collision") - } - content, err := v.ReadNote("inbox/One.md") - if err != nil || content.Body != original { - t.Fatalf("source lost: %#v %v", content, err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "inbox/Renamed.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("target exists: %v", err) - } - comments, err := v.ReadNoteComments("inbox/Renamed.md") - if err != nil || len(comments) != 1 || comments[0].Body != "Keep destination" { - t.Fatalf("target comments changed: %#v %v", comments, err) - } - }) - } -} - -func TestRenameNoteCaseOnlyKeepsFilenameAndComments(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/One.md", "Keep café. \n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "one", Body: "Keep discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - meta, err := v.RenameNote("inbox/One.md", "one") - if err != nil { - t.Fatal(err) - } - if meta.Path != "inbox/one.md" { - t.Fatalf("unexpected path: %s", meta.Path) - } - entries, err := os.ReadDir(filepath.Join(v.Root(), "inbox")) - if err != nil { - t.Fatal(err) - } - found := false - for _, entry := range entries { - if entry.Name() == "One.md" { - t.Fatal("old spelling remains on disk") - } - if entry.Name() == "one.md" { - found = true - } - } - if !found { - t.Fatal("new spelling missing on disk") - } - comments, err := v.ReadNoteComments(meta.Path) - if err != nil || len(comments) != 1 || comments[0].Body != "Keep discussion" { - t.Fatalf("lost comments: %#v %v", comments, err) - } -} - -func TestNoteLifecycleCommentCollisions(t *testing.T) { - for _, action := range []string{"archive", "trash", "unarchive", "restore"} { - t.Run(action, func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - source, target := "inbox/One.md", "archive/One.md" - mutate := v.ArchiveNote - switch action { - case "trash": - target = "trash/One.md" - mutate = v.MoveToTrash - case "unarchive": - source = "archive/One.md" - target = "inbox/One.md" - mutate = v.UnarchiveNote - case "restore": - source = "trash/One.md" - target = "inbox/One.md" - mutate = v.RestoreFromTrash - } - if _, err := v.WriteNote(source, "Keep café. \n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments(target, []NoteComment{{ID: "orphan", Body: "Keep target discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := mutate(source); err == nil { - t.Fatal("expected comment collision") - } - note, err := v.ReadNote(source) - if err != nil || note.Body != "Keep café. \n" { - t.Fatalf("lost source: %#v %v", note, err) - } - if _, err := os.Stat(filepath.Join(v.Root(), target)); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("target remains: %v", err) - } - comments, err := v.ReadNoteComments(target) - if err != nil || len(comments) != 1 || comments[0].Body != "Keep target discussion" { - t.Fatalf("lost discussion: %#v %v", comments, err) - } - }) - } -} - -func TestNoteLifecycleRoundTripAndDeletion(t *testing.T) { - for _, location := range []string{"inbox", "root"} { - t.Run(location, func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - settings.PrimaryNotesLocation = PrimaryNotesLocation(location) - settings.SystemFolderPaths = map[string]string{"inbox": "My Notes", "archive": "Filed", "trash": "Bin"} - if _, err := v.SetSettings(settings); err != nil { - t.Fatal(err) - } - original := "My Notes/One.md" - if location == "root" { - original = "One.md" - } - body := "Keep café 日本語. \n" - if _, err := v.WriteNote(original, body); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments(original, []NoteComment{{ID: "source", Body: "Discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - meta, err := v.ArchiveNote(original) - if err != nil { - t.Fatal(err) - } - if meta.Path != "Filed/One.md" { - t.Fatal(meta.Path) - } - meta, err = v.UnarchiveNote(meta.Path) - if err != nil { - t.Fatal(err) - } - if meta.Path != original { - t.Fatal(meta.Path) - } - meta, err = v.MoveToTrash(meta.Path) - if err != nil { - t.Fatal(err) - } - if meta.Path != "Bin/One.md" { - t.Fatal(meta.Path) - } - meta, err = v.RestoreFromTrash(meta.Path) - if err != nil { - t.Fatal(err) - } - note, err := v.ReadNote(meta.Path) - if err != nil || note.Body != body { - t.Fatalf("lost bytes: %#v %v", note, err) - } - comments, err := v.ReadNoteComments(meta.Path) - if err != nil || len(comments) != 1 || comments[0].Body != "Discussion" { - t.Fatalf("lost comments: %#v %v", comments, err) - } - if err := v.DeleteNote(meta.Path); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote(meta.Path, "New note"); err != nil { - t.Fatal(err) - } - comments, err = v.ReadNoteComments(meta.Path) - if err != nil || len(comments) != 0 { - t.Fatalf("resurrected comments: %#v %v", comments, err) - } - }) - } -} - -func TestEmptyTrashRespectsRemappedPath(t *testing.T) { - for _, location := range []PrimaryNotesLocation{PrimaryNotesRoot, PrimaryNotesInbox} { - t.Run(string(location), func(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - settings.PrimaryNotesLocation = location - settings.SystemFolderPaths = map[string]string{"trash": "Deleted files"} - if _, err := v.SetSettings(settings); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("Deleted files/Nested/One.md", "Delete me"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNoteComments("Deleted files/Nested/One.md", []NoteComment{{ID: "comment", Body: "Remove discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("trash/Unrelated.md", "Keep literal trash folder"); err != nil { - t.Fatal(err) - } - if err := v.EmptyTrash(); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "Deleted files/Nested/One.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("note remains: %v", err) - } - comments, err := v.ReadNoteComments("Deleted files/Nested/One.md") - if err != nil || len(comments) != 0 { - t.Fatalf("comments remain: %#v %v", comments, err) - } - body, err := os.ReadFile(filepath.Join(v.Root(), "trash/Unrelated.md")) - if err != nil || string(body) != "Keep literal trash folder" { - t.Fatalf("unrelated note changed: %s %v", body, err) - } - if err := v.EmptyTrash(); err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/apps/server/internal/vault/wikilink_rename.go b/apps/server/internal/vault/wikilink_rename.go deleted file mode 100644 index f3bc671e..00000000 --- a/apps/server/internal/vault/wikilink_rename.go +++ /dev/null @@ -1,235 +0,0 @@ -package vault - -import ( - "regexp" - "strings" -) - -// Rewriting inbound [[wikilinks]] when a note is renamed. The resolution here -// mirrors packages/app-core/src/lib/wikilinks.ts and the desktop main's -// wikilink-rename.ts: a target resolves by note title (case-insensitive) unless -// it looks like a path, in which case it resolves by explicit/suffix path match. - -var wikilinkRewriteRe = regexp.MustCompile(`(!?)\[\[([^\]\n]+)\]\]`) - -var wikiTopFolders = []string{"inbox", "quick", "archive", "trash"} - -func wikiNormalizeSlashes(value string) string { - value = strings.ReplaceAll(value, "\\", "/") - for strings.Contains(value, "//") { - value = strings.ReplaceAll(value, "//", "/") - } - return value -} - -func wikiStripMd(value string) string { - if strings.HasSuffix(strings.ToLower(value), ".md") { - return value[:len(value)-3] - } - return value -} - -func wikiNormCompare(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func wikiIsPathLike(target string) bool { - t := strings.TrimSpace(target) - return strings.HasPrefix(t, "/") || strings.Contains(t, "/") || - strings.HasSuffix(strings.ToLower(t), ".md") -} - -func wikiResolveExplicitPath(notes []NoteMeta, target string) (NoteMeta, bool) { - normalized := wikiNormalizeSlashes(strings.TrimSpace(target)) - if normalized == "" { - return NoteMeta{}, false - } - trimmed := strings.Trim(wikiStripMd(normalized), "/") - if trimmed == "" { - return NoteMeta{}, false - } - relPath := "" - if strings.HasPrefix(normalized, "/") { - relPath = "inbox/" + trimmed + ".md" - } else { - lower := strings.ToLower(trimmed) - for _, f := range wikiTopFolders { - if strings.HasPrefix(lower, f+"/") { - relPath = trimmed + ".md" - break - } - } - } - if relPath == "" { - return NoteMeta{}, false - } - needle := wikiNormCompare(relPath) - for _, n := range notes { - if wikiNormCompare(n.Path) == needle { - return n, true - } - } - return NoteMeta{}, false -} - -func wikiResolvePathSuffix(notes []NoteMeta, target string) (NoteMeta, bool) { - trimmed := strings.Trim(wikiStripMd(wikiNormalizeSlashes(strings.TrimSpace(target))), "/") - if trimmed == "" { - return NoteMeta{}, false - } - suffix := wikiNormCompare("/" + trimmed + ".md") - exact := wikiNormCompare(trimmed + ".md") - var match NoteMeta - count := 0 - for _, n := range notes { - p := wikiNormCompare(n.Path) - if p == exact || strings.HasSuffix(p, suffix) { - match = n - count++ - } - } - if count == 1 { - return match, true - } - return NoteMeta{}, false -} - -func wikiResolveTarget(notes []NoteMeta, target string) (NoteMeta, bool) { - visible := make([]NoteMeta, 0, len(notes)) - for _, n := range notes { - if n.Folder != FolderTrash { - visible = append(visible, n) - } - } - if wikiIsPathLike(target) { - if n, ok := wikiResolveExplicitPath(visible, target); ok { - return n, true - } - return wikiResolvePathSuffix(visible, target) - } - needle := wikiNormCompare(wikiStripMd(target)) - for _, n := range visible { - if wikiNormCompare(n.Title) == needle { - return n, true - } - } - return NoteMeta{}, false -} - -func wikiSplitContent(content string) (target, anchor, alias string) { - rest := content - if pipe := strings.IndexByte(rest, '|'); pipe >= 0 { - alias = rest[pipe:] - rest = rest[:pipe] - } - if idx := strings.IndexAny(rest, "#^"); idx >= 0 { - anchor = rest[idx:] - rest = rest[:idx] - } - return rest, anchor, alias -} - -func wikiSwapBasename(target, newTitle string) string { - dir := "" - base := target - if slash := strings.LastIndexByte(target, '/'); slash >= 0 { - dir = target[:slash+1] - base = target[slash+1:] - } - md := "" - if strings.HasSuffix(strings.ToLower(base), ".md") { - md = base[len(base)-3:] - } - return dir + newTitle + md -} - -// wikiCodeMask marks byte positions inside fenced (line-start ``` / ~~~) or -// inline (`...`) code so links there are left untouched. RE2 has no lazy -// quantifiers, so we scan rather than match the whole block with a regex. -func wikiCodeMask(body string) []bool { - mask := make([]bool, len(body)) - lineStart := 0 - inFence := false - for i := 0; i <= len(body); i++ { - if i == len(body) || body[i] == '\n' { - line := body[lineStart:i] - trimmed := strings.TrimLeft(line, " \t") - isFence := strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") - switch { - case inFence: - for j := lineStart; j < i; j++ { - mask[j] = true - } - if isFence { - inFence = false - } - case isFence: - inFence = true - for j := lineStart; j < i; j++ { - mask[j] = true - } - default: - wikiMarkInlineCode(mask, line, lineStart) - } - lineStart = i + 1 - } - } - return mask -} - -func wikiMarkInlineCode(mask []bool, line string, offset int) { - open := -1 - for i := 0; i < len(line); i++ { - if line[i] != '`' { - continue - } - if open < 0 { - open = i - } else { - for j := open; j <= i; j++ { - mask[offset+j] = true - } - open = -1 - } - } -} - -// rewriteWikilinksForRename rewrites every [[target]] / ![[target]] in body -// whose target resolves to the note at oldPath, pointing it at newTitle. Aliases, -// #heading / ^block anchors, and embeds are preserved; code is skipped. notes -// must reflect the pre-rename vault so links resolve to what they currently target. -func rewriteWikilinksForRename(body string, notes []NoteMeta, oldPath, newTitle string) (string, int) { - matches := wikilinkRewriteRe.FindAllStringSubmatchIndex(body, -1) - if len(matches) == 0 { - return body, 0 - } - mask := wikiCodeMask(body) - var sb strings.Builder - last := 0 - changed := 0 - for _, m := range matches { - start, end := m[0], m[1] - if mask[start] { - continue - } - embed := body[m[2]:m[3]] - content := body[m[4]:m[5]] - target, anchor, alias := wikiSplitContent(content) - if n, ok := wikiResolveTarget(notes, target); !ok || n.Path != oldPath { - continue - } - newTarget := wikiSwapBasename(target, newTitle) - if newTarget == target { - continue - } - sb.WriteString(body[last:start]) - sb.WriteString(embed + "[[" + newTarget + anchor + alias + "]]") - last = end - changed++ - } - if changed == 0 { - return body, 0 - } - sb.WriteString(body[last:]) - return sb.String(), changed -} diff --git a/apps/server/internal/vault/wikilink_rename_test.go b/apps/server/internal/vault/wikilink_rename_test.go deleted file mode 100644 index efef269c..00000000 --- a/apps/server/internal/vault/wikilink_rename_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package vault - -import "testing" - -func renameTestNotes() []NoteMeta { - return []NoteMeta{ - {Path: "inbox/demo/Old Title.md", Title: "Old Title", Folder: FolderInbox}, - {Path: "inbox/Other.md", Title: "Other", Folder: FolderInbox}, - } -} - -func TestRewriteWikilinksForRename(t *testing.T) { - notes := renameTestNotes() - rw := func(body string) (string, int) { - return rewriteWikilinksForRename(body, notes, "inbox/demo/Old Title.md", "New Title") - } - - cases := []struct { - name, in, want string - changed int - }{ - {"title", "See [[Old Title]] here.", "See [[New Title]] here.", 1}, - {"alias", "[[Old Title|the old one]]", "[[New Title|the old one]]", 1}, - {"heading", "[[Old Title#Intro]]", "[[New Title#Intro]]", 1}, - {"block", "[[Old Title^a1b2]]", "[[New Title^a1b2]]", 1}, - {"heading+alias", "[[Old Title#Intro|see]]", "[[New Title#Intro|see]]", 1}, - {"embed", "![[Old Title]]", "![[New Title]]", 1}, - {"path", "[[inbox/demo/Old Title]]", "[[inbox/demo/New Title]]", 1}, - {"path-rel", "[[demo/Old Title]]", "[[demo/New Title]]", 1}, - {"path-slash", "[[/demo/Old Title]]", "[[/demo/New Title]]", 1}, - {"path-md", "[[inbox/demo/Old Title.md]]", "[[inbox/demo/New Title.md]]", 1}, - {"multiple", "[[Old Title]] and [[Old Title|x]]", "[[New Title]] and [[New Title|x]]", 2}, - {"other", "[[Other]] stays", "[[Other]] stays", 0}, - {"none", "nothing here", "nothing here", 0}, - {"inline-code", "use `[[Old Title]]` literally", "use `[[Old Title]]` literally", 0}, - {"fenced-code", "```\n[[Old Title]]\n```", "```\n[[Old Title]]\n```", 0}, - {"code-then-link", "`[[Old Title]]` then [[Old Title]]", "`[[Old Title]]` then [[New Title]]", 1}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got, changed := rw(c.in) - if got != c.want || changed != c.changed { - t.Fatalf("rewrite(%q) = (%q, %d), want (%q, %d)", c.in, got, changed, c.want, c.changed) - } - }) - } -} - -func TestRewriteWikilinksAmbiguousTitle(t *testing.T) { - dup := []NoteMeta{ - {Path: "inbox/A.md", Title: "Dup", Folder: FolderInbox}, - {Path: "inbox/B.md", Title: "Dup", Folder: FolderInbox}, - } - // [[Dup]] resolves to the first match (inbox/A.md). Renaming B must not - // touch it; renaming A must. - if _, changed := rewriteWikilinksForRename("[[Dup]]", dup, "inbox/B.md", "New"); changed != 0 { - t.Fatalf("renaming B should not rewrite [[Dup]] (resolves to A), changed=%d", changed) - } - if got, _ := rewriteWikilinksForRename("[[Dup]]", dup, "inbox/A.md", "New"); got != "[[New]]" { - t.Fatalf("renaming A should rewrite [[Dup]] -> [[New]], got %q", got) - } -} - -// End-to-end: a real RenameNote should rewrite inbound links across the vault. -func TestRenameNoteRewritesInboundWikilinks(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Target.md", "# Target\n"); err != nil { - t.Fatal(err) - } - src := "See [[Target]], [[Target|alias]], and ![[Target]].\n\nCode stays: `[[Target]]`\n" - if _, err := v.WriteNote("inbox/Source.md", src); err != nil { - t.Fatal(err) - } - - meta, err := v.RenameNote("inbox/Target.md", "Renamed") - if err != nil { - t.Fatal(err) - } - if meta.Title != "Renamed" { - t.Fatalf("renamed title = %q, want Renamed", meta.Title) - } - - got, err := v.ReadNote("inbox/Source.md") - if err != nil { - t.Fatal(err) - } - want := "See [[Renamed]], [[Renamed|alias]], and ![[Renamed]].\n\nCode stays: `[[Target]]`\n" - if got.Body != want { - t.Fatalf("source after rename =\n%q\nwant\n%q", got.Body, want) - } -} - -func TestRenameNoteRewritesAnchoredOnlyInboundWikilinks(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Target.md", "# Target\n"); err != nil { - t.Fatal(err) - } - src := "See [[Target#Heading|alias]].\n\nCode stays: `[[Target]]`\n" - if _, err := v.WriteNote("inbox/Source.md", src); err != nil { - t.Fatal(err) - } - - meta, err := v.RenameNote("inbox/Target.md", "Renamed") - if err != nil { - t.Fatal(err) - } - if meta.Title != "Renamed" { - t.Fatalf("renamed title = %q, want Renamed", meta.Title) - } - - got, err := v.ReadNote("inbox/Source.md") - if err != nil { - t.Fatal(err) - } - want := "See [[Renamed#Heading|alias]].\n\nCode stays: `[[Target]]`\n" - if got.Body != want { - t.Fatalf("source after rename =\n%q\nwant\n%q", got.Body, want) - } -} diff --git a/apps/server/internal/vault/workflows.go b/apps/server/internal/vault/workflows.go deleted file mode 100644 index 21b53a41..00000000 --- a/apps/server/internal/vault/workflows.go +++ /dev/null @@ -1,809 +0,0 @@ -package vault - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "runtime" - "sort" - "strings" - "time" - "unicode/utf8" -) - -const ( - workflowsRelDir = ".zennotes/workflows" - workflowRunsRelDir = ".zennotes/workflows/.runs" - workflowLedgerVersion = 1 - maxWorkflowSlugLength = 64 - maxWorkflowIDLength = 256 - maxWorkflowOps = 5000 - maxWorkflowChanges = 10000 - maxRetainedWorkflowRuns = 100 - maxRetainedWorkflowRunByte = 50 * 1024 * 1024 -) - -var ( - ErrInvalidWorkflow = errors.New("invalid workflow request") - ErrWorkflowConflict = errors.New("workflow plan is stale") - workflowRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,160}$`) -) - -type WorkflowFile struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` -} - -type WriteWorkflowInput struct { - Slug string `json:"slug"` - Raw string `json:"raw"` - PreviousSourcePath string `json:"previousSourcePath,omitempty"` -} - -type WorkflowRunFileChange struct { - Path string `json:"path"` - Before *string `json:"before"` - After *string `json:"after"` -} - -type PreparedWorkflowRun struct { - WorkflowID string `json:"workflowId"` - Ops []json.RawMessage `json:"ops"` - Applied int `json:"applied"` - Irreversible int `json:"irreversible"` - Changes []WorkflowRunFileChange `json:"changes"` -} - -type WorkflowRunReceipt struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - Irreversible int `json:"irreversible"` - RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` -} - -type WorkflowRollback struct { - Reason string `json:"reason"` -} - -type WorkflowUndoResult struct { - RunID string `json:"runId"` - Restored int `json:"restored"` - DriftedPaths []string `json:"driftedPaths,omitempty"` -} - -type WorkflowRunSummary struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - Undoable bool `json:"undoable"` - Interrupted bool `json:"interrupted,omitempty"` -} - -type workflowJournalEntry struct { - Path string `json:"path"` - Before *string `json:"before"` -} - -type workflowRunLedger struct { - Version int `json:"version"` - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - FinishedAt int64 `json:"finishedAt"` - Applied int `json:"applied"` - Irreversible int `json:"irreversible"` - Paths []string `json:"paths"` - Ops []json.RawMessage `json:"ops"` - Journal []workflowJournalEntry `json:"journal"` - Hashes map[string]*string `json:"hashes"` - Undone bool `json:"undone"` - UndoneAt int64 `json:"undoneAt,omitempty"` - RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` - Interrupted *WorkflowRollback `json:"interrupted,omitempty"` -} - -func workflowDir(root string) string { - return filepath.Join(root, ".zennotes", "workflows") -} - -func safeWorkflowSlug(value string) string { - var out strings.Builder - dash := false - for _, r := range strings.ToLower(strings.TrimSpace(value)) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - if dash && out.Len() > 0 && out.Len() < maxWorkflowSlugLength { - out.WriteByte('-') - } - dash = false - if out.Len() < maxWorkflowSlugLength { - out.WriteRune(r) - } - continue - } - dash = true - } - result := strings.Trim(out.String(), "-") - if result == "" { - return "workflow" - } - return result -} - -func (v *Vault) resolveWorkflowFilePath(sourcePath string) (string, error) { - abs, err := SafeJoin(v.root, sourcePath) - if err != nil { - return "", err - } - dir := workflowDir(v.root) - rel, err := filepath.Rel(dir, abs) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { - return "", fmt.Errorf("%w: refusing workflow path outside workflows dir", ErrInvalidWorkflow) - } - if !strings.EqualFold(filepath.Ext(rel), ".md") { - return "", fmt.Errorf("%w: workflow path must be a .md file", ErrInvalidWorkflow) - } - return abs, nil -} - -func workflowIDForName(name string) string { - return strings.TrimSuffix(name, filepath.Ext(name)) -} - -func (v *Vault) ListWorkflows() ([]WorkflowFile, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(workflowDir(v.root)) - if errors.Is(err, os.ErrNotExist) { - return []WorkflowFile{}, nil - } - if err != nil { - return nil, err - } - out := make([]WorkflowFile, 0, len(entries)) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - continue - } - sourcePath := workflowsRelDir + "/" + name - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - continue - } - raw, err := os.ReadFile(abs) - if err != nil { - continue - } - out = append(out, WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: string(raw)}) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -func (v *Vault) WriteWorkflow(input WriteWorkflowInput) (WorkflowFile, error) { - v.mu.Lock() - defer v.mu.Unlock() - name := safeWorkflowSlug(input.Slug) + ".md" - sourcePath := workflowsRelDir + "/" + name - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - return WorkflowFile{}, err - } - var previous string - if input.PreviousSourcePath != "" { - previous, err = v.resolveWorkflowFilePath(input.PreviousSourcePath) - if err != nil { - return WorkflowFile{}, err - } - } - if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { - return WorkflowFile{}, err - } - if previous != "" && previous != abs { - // On a case-insensitive filesystem two differently-cased paths can name - // the SAME file, and writeFileAtomic just landed the new content on it; - // a spelling compare then let os.Remove delete the workflow that was - // just saved. Compare file identity, not path strings. - sameFile := false - if prevInfo, statErr := os.Stat(previous); statErr == nil { - if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { - sameFile = true - } - } - if !sameFile { - if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { - return WorkflowFile{}, err - } - } - } - return WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: input.Raw}, nil -} - -func (v *Vault) DeleteWorkflow(sourcePath string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func workflowPathSegments(path string) []string { - return strings.Split(strings.ReplaceAll(path, "\\", "/"), "/") -} - -func (v *Vault) resolveWorkflowNotePath(rel string) (string, error) { - if rel == "" || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "\\") || filepath.IsAbs(rel) || (len(rel) >= 2 && ((rel[0] >= 'A' && rel[0] <= 'Z') || (rel[0] >= 'a' && rel[0] <= 'z')) && rel[1] == ':') { - return "", fmt.Errorf("%w: workflow note path is absolute or empty: %s", ErrInvalidWorkflow, rel) - } - segments := workflowPathSegments(rel) - for _, segment := range segments { - if segment == ".." { - return "", fmt.Errorf("%w: workflow note path escapes the vault: %s", ErrInvalidWorkflow, rel) - } - } - if len(segments) > 0 && strings.EqualFold(segments[0], internalVaultDir) { - return "", fmt.Errorf("%w: workflow note path is inside %s: %s", ErrInvalidWorkflow, internalVaultDir, rel) - } - ext := strings.ToLower(filepath.Ext(rel)) - if ext != ".md" && ext != excalidrawExt { - return "", fmt.Errorf("%w: workflow path is not a note: %s", ErrInvalidWorkflow, rel) - } - return SafeJoin(v.root, rel) -} - -func nullableString(value string) *string { - copy := value - return © -} - -func readOptionalText(abs string) (*string, error) { - body, err := os.ReadFile(abs) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - return nullableString(string(body)), nil -} - -func optionalStringsEqual(left, right *string) bool { - if left == nil || right == nil { - return left == nil && right == nil - } - return *left == *right -} - -// coerceUTF8ForWire mirrors what encoding/json does to a string on its way to -// the client: every invalid UTF-8 byte becomes one U+FFFD replacement. The -// client can never echo back bytes JSON already destroyed, so before-bytes -// comparisons must compare against this view of the disk, byte-for-byte -// identical to what /notes/read served. -func coerceUTF8ForWire(s string) string { - if utf8.ValidString(s) { - return s - } - var b strings.Builder - b.Grow(len(s)) - for i := 0; i < len(s); { - r, size := utf8.DecodeRuneInString(s[i:]) - if r == utf8.RuneError && size == 1 { - b.WriteRune(utf8.RuneError) - i++ - continue - } - b.WriteString(s[i : i+size]) - i += size - } - return b.String() -} - -func optionalWireEqual(disk, client *string) bool { - if optionalStringsEqual(disk, client) { - return true - } - if disk == nil || client == nil { - return false - } - return coerceUTF8ForWire(*disk) == *client -} - -func workflowJournalKey(path string) string { - if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { - return strings.ToLower(path) - } - return path -} - -func workflowHash(value *string) *string { - if value == nil { - return nil - } - hash := sha256.Sum256([]byte(*value)) - encoded := hex.EncodeToString(hash[:]) - return &encoded -} - -func newWorkflowRunID(startedAt int64) string { - var suffix [6]byte - if _, err := rand.Read(suffix[:]); err != nil { - return fmt.Sprintf("%013d-%d", startedAt, time.Now().UnixNano()) - } - return fmt.Sprintf("%013d-%s", startedAt, hex.EncodeToString(suffix[:])) -} - -func (v *Vault) resolveWorkflowLedgerPath(runID string) (string, error) { - if !workflowRunIDPattern.MatchString(runID) { - return "", fmt.Errorf("%w: invalid workflow run id", ErrInvalidWorkflow) - } - return SafeJoin(v.root, workflowRunsRelDir+"/"+runID+".json") -} - -func (v *Vault) resolveWorkflowRunsDir() (string, error) { - return SafeJoin(v.root, workflowRunsRelDir) -} - -func (v *Vault) writeWorkflowLedgerLocked(ledger workflowRunLedger) error { - abs, err := v.resolveWorkflowLedgerPath(ledger.RunID) - if err != nil { - return err - } - body, err := json.MarshalIndent(ledger, "", " ") - if err != nil { - return err - } - body = append(body, '\n') - return writeFileAtomic(abs, body, v.fileMode, v.dirMode) -} - -func (v *Vault) readWorkflowLedgerLocked(runID string) (workflowRunLedger, error) { - abs, err := v.resolveWorkflowLedgerPath(runID) - if err != nil { - return workflowRunLedger{}, err - } - body, err := os.ReadFile(abs) - if err != nil { - return workflowRunLedger{}, err - } - var ledger workflowRunLedger - if err := json.Unmarshal(body, &ledger); err != nil { - return workflowRunLedger{}, err - } - if ledger.Version != workflowLedgerVersion || ledger.RunID != runID { - return workflowRunLedger{}, fmt.Errorf("%w: unsupported workflow run ledger", ErrInvalidWorkflow) - } - return ledger, nil -} - -func (v *Vault) restoreWorkflowJournalLocked(journal []workflowJournalEntry) (int, []error) { - return v.restoreWorkflowJournalSnapshotLocked(journal, nil, nil) -} - -// restoreWorkflowJournalSnapshotLocked restores the journal, consulting an -// optional pre-read snapshot (liveByPath/absByPath) so a caller that already -// read every file, like undo's drift check, does not read the whole run a -// second time while holding the exclusive vault lock. Entries missing from -// the snapshot fall back to resolving and reading here. -func (v *Vault) restoreWorkflowJournalSnapshotLocked( - journal []workflowJournalEntry, - liveByPath map[string]*string, - absByPath map[string]string, -) (int, []error) { - restored := 0 - failures := []error{} - for _, entry := range journal { - abs, haveAbs := absByPath[entry.Path] - if !haveAbs { - resolved, err := v.resolveWorkflowNotePath(entry.Path) - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - abs = resolved - } - live, haveLive := liveByPath[entry.Path] - if !haveLive { - read, err := readOptionalText(abs) - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - live = read - } - if optionalStringsEqual(live, entry.Before) { - continue - } - var err error - if entry.Before == nil { - err = os.Remove(abs) - if errors.Is(err, os.ErrNotExist) { - err = nil - } - } else { - err = writeFileAtomic(abs, []byte(*entry.Before), v.fileMode, v.dirMode) - } - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - restored++ - } - return restored, failures -} - -func workflowFailureMessage(failures []error) string { - parts := make([]string, len(failures)) - for index, err := range failures { - parts[index] = err.Error() - } - return strings.Join(parts, "; ") -} - -// requiredWorkflowOpFields is the Go mirror of the workflow op schema. Three -// synced copies exist and MUST change together (the stripCodeContent rule): -// the op types in packages/shared-domain/src/workflows/types.ts, the -// parseWorkflowOp validator in packages/shared-domain/src/workflows/ -// prepare-run.ts (duplicated into apps/desktop/src/main/workflow-apply.ts), -// and this map. Miss this one and every web run carrying the new op kind -// 400s as "not valid" while desktop applies it fine. -var requiredWorkflowOpFields = map[string][]string{ - "set-frontmatter": {"path", "field", "value"}, - "add-tag": {"path", "tag"}, - "remove-tag": {"path", "tag"}, - "move": {"path", "to"}, - "rename": {"path", "to"}, - "append": {"path", "text"}, - "prepend": {"path", "text"}, - "write-section": {"path", "heading", "text"}, - "write-note": {"path", "text"}, - "create-note": {"path", "body"}, - "apply-template": {"path", "template"}, - "archive": {"path"}, - "trash": {"path"}, - "notify": {"message"}, - "clipboard": {"text"}, -} - -func validatePreparedWorkflowOps(ops []json.RawMessage) (int, error) { - irreversible := 0 - for index, raw := range ops { - var op map[string]json.RawMessage - if err := json.Unmarshal(raw, &op); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - var kind string - if err := json.Unmarshal(op["kind"], &kind); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - required, valid := requiredWorkflowOpFields[kind] - if !valid { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - for _, field := range required { - var value string - if err := json.Unmarshal(op[field], &value); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is missing string field %s", ErrInvalidWorkflow, index, field) - } - } - if kind == "notify" || kind == "clipboard" { - irreversible++ - } - } - return irreversible, nil -} - -func (v *Vault) ApplyPreparedWorkflow(input PreparedWorkflowRun) (WorkflowRunReceipt, error) { - v.mu.Lock() - defer v.mu.Unlock() - startedAt := time.Now().UnixMilli() - workflowID := strings.TrimSpace(input.WorkflowID) - if workflowID == "" { - workflowID = "unknown" - } - if len(workflowID) > maxWorkflowIDLength { - return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow id is too long", ErrInvalidWorkflow) - } - // Name the cap when a run is over it: the dry run just promised success, - // so a bare "invalid counts" read as a client bug instead of a server - // limit the user can see and reason about. - if len(input.Ops) > maxWorkflowOps { - return WorkflowRunReceipt{}, fmt.Errorf("%w: this run has %d operations, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Ops), maxWorkflowOps) - } - if len(input.Changes) > maxWorkflowChanges { - return WorkflowRunReceipt{}, fmt.Errorf("%w: this run touches %d files, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Changes), maxWorkflowChanges) - } - if input.Applied < 0 || input.Irreversible < 0 || input.Applied > len(input.Ops) || input.Irreversible > len(input.Ops) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: invalid workflow run counts", ErrInvalidWorkflow) - } - irreversible, err := validatePreparedWorkflowOps(input.Ops) - if err != nil { - return WorkflowRunReceipt{}, err - } - if input.Irreversible != irreversible || input.Applied != len(input.Ops)-irreversible || (len(input.Changes) > 0 && input.Applied == 0) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow operation counts do not match the prepared changes", ErrInvalidWorkflow) - } - - paths := make([]string, 0, len(input.Changes)) - journal := make([]workflowJournalEntry, 0, len(input.Changes)) - hashes := make(map[string]*string, len(input.Changes)) - resolved := make([]string, 0, len(input.Changes)) - seen := map[string]struct{}{} - for _, change := range input.Changes { - path := filepath.ToSlash(filepath.Clean(filepath.FromSlash(change.Path))) - abs, err := v.resolveWorkflowNotePath(path) - if err != nil { - return WorkflowRunReceipt{}, err - } - key := workflowJournalKey(path) - if _, exists := seen[key]; exists { - return WorkflowRunReceipt{}, fmt.Errorf("%w: duplicate workflow path %s", ErrInvalidWorkflow, path) - } - seen[key] = struct{}{} - live, err := readOptionalText(abs) - if err != nil { - return WorkflowRunReceipt{}, err - } - // Compare against the client's WIRE view of the file: JSON coerced any - // invalid UTF-8 to U+FFFD on the way out, so a note carrying one stray - // non-UTF-8 byte would otherwise 409 on every apply, forever, and - // re-planning reads the same lossy view so the loop never resolved. - if !optionalWireEqual(live, change.Before) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: %s changed after the dry run", ErrWorkflowConflict, path) - } - paths = append(paths, path) - journal = append(journal, workflowJournalEntry{Path: path, Before: change.Before}) - hashes[path] = workflowHash(change.After) - resolved = append(resolved, abs) - } - - runID := newWorkflowRunID(startedAt) - ledger := workflowRunLedger{ - Version: workflowLedgerVersion, - RunID: runID, - WorkflowID: workflowID, - StartedAt: startedAt, - FinishedAt: startedAt, - Applied: 0, - Irreversible: input.Irreversible, - Paths: paths, - Ops: input.Ops, - Journal: journal, - Hashes: map[string]*string{}, - Undone: false, - Interrupted: &WorkflowRollback{Reason: "ZenNotes stopped while this run was still applying, so part of it may have landed. Undo restores every file it had recorded."}, - } - if len(input.Ops) > 0 { - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - return WorkflowRunReceipt{}, err - } - } - if len(input.Changes) > 0 { - defer v.invalidateTextSearchCache() - } - - for index, change := range input.Changes { - var err error - if change.After == nil { - err = os.Remove(resolved[index]) - if errors.Is(err, os.ErrNotExist) { - err = nil - } - } else { - err = writeFileAtomic(resolved[index], []byte(*change.After), v.fileMode, v.dirMode) - } - if err == nil { - continue - } - _, failures := v.restoreWorkflowJournalLocked(journal) - reason := fmt.Sprintf("%v. The run was rolled back; your vault is unchanged.", err) - if len(failures) == 0 { - if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { - _ = os.Remove(abs) - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil - } - reason = fmt.Sprintf("%v. ROLLBACK INCOMPLETE: %s", err, workflowFailureMessage(failures)) - ledger.FinishedAt = time.Now().UnixMilli() - ledger.RolledBack = &WorkflowRollback{Reason: reason} - ledger.Interrupted = nil - _ = v.writeWorkflowLedgerLocked(ledger) - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: paths, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil - } - - if len(input.Ops) > 0 { - ledger.FinishedAt = time.Now().UnixMilli() - ledger.Applied = input.Applied - ledger.Hashes = hashes - ledger.Interrupted = nil - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - _, failures := v.restoreWorkflowJournalLocked(journal) - if len(failures) == 0 { - if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { - _ = os.Remove(abs) - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: fmt.Sprintf("The run could not be recorded, so it was rolled back (%v).", err)}}, nil - } - return WorkflowRunReceipt{}, fmt.Errorf("record workflow run: %w; rollback: %s", err, workflowFailureMessage(failures)) - } - v.pruneWorkflowRunsLocked() - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Applied: input.Applied, Paths: paths, Irreversible: input.Irreversible}, nil -} - -func (v *Vault) pruneWorkflowRunsLocked() { - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return - } - entries, err := os.ReadDir(runsDir) - if err != nil { - return - } - type retainedFile struct { - name string - size int64 - } - files := []retainedFile{} - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - info, err := entry.Info() - if err == nil { - files = append(files, retainedFile{name: entry.Name(), size: info.Size()}) - } - } - sort.Slice(files, func(i, j int) bool { return files[i].name > files[j].name }) - var total int64 - for index, file := range files { - total += file.size - // The newest ledger is the run the user is being shown right now. Keep - // it even when one whole-vault run exceeds the history byte budget, or - // pruning would remove Undo from the run that just completed. - if index == 0 || (index < maxRetainedWorkflowRuns && total <= maxRetainedWorkflowRunByte) { - continue - } - _ = os.Remove(filepath.Join(runsDir, file.name)) - } -} - -func (v *Vault) UndoWorkflowRun(runID string) (WorkflowUndoResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - ledger, err := v.readWorkflowLedgerLocked(runID) - if errors.Is(err, os.ErrNotExist) { - return WorkflowUndoResult{}, fmt.Errorf("%w: unknown workflow run %s", ErrInvalidWorkflow, runID) - } - if err != nil { - return WorkflowUndoResult{}, err - } - if ledger.Undone { - return WorkflowUndoResult{}, fmt.Errorf("%w: workflow run was already undone", ErrInvalidWorkflow) - } - // One read per journaled file: the drift check and the restore both need - // the live bytes, and reading a whole-vault run twice under the exclusive - // lock doubled how long every other request stayed blocked. The lock - // guarantees nothing changes between this pass and the restore. - liveByPath := make(map[string]*string, len(ledger.Journal)) - absByPath := make(map[string]string, len(ledger.Journal)) - drifted := []string{} - for _, entry := range ledger.Journal { - abs, err := v.resolveWorkflowNotePath(entry.Path) - if err != nil { - continue - } - absByPath[entry.Path] = abs - live, err := readOptionalText(abs) - if err != nil { - continue - } - liveByPath[entry.Path] = live - if expected, tracked := ledger.Hashes[entry.Path]; tracked { - if !optionalStringsEqual(workflowHash(live), expected) { - drifted = append(drifted, entry.Path) - } - } - } - restored, failures := v.restoreWorkflowJournalSnapshotLocked(ledger.Journal, liveByPath, absByPath) - if len(failures) > 0 { - return WorkflowUndoResult{}, fmt.Errorf("undo of run %s is incomplete: %s", runID, workflowFailureMessage(failures)) - } - ledger.Undone = true - ledger.UndoneAt = time.Now().UnixMilli() - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - return WorkflowUndoResult{}, err - } - v.invalidateTextSearchCache() - return WorkflowUndoResult{RunID: runID, Restored: restored, DriftedPaths: drifted}, nil -} - -func (v *Vault) ListWorkflowRuns() ([]WorkflowRunSummary, error) { - v.mu.RLock() - defer v.mu.RUnlock() - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return nil, err - } - entries, err := os.ReadDir(runsDir) - if errors.Is(err, os.ErrNotExist) { - return []WorkflowRunSummary{}, nil - } - if err != nil { - return nil, err - } - runs := []WorkflowRunSummary{} - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - ledger, err := v.readWorkflowLedgerLocked(runID) - if err != nil { - continue - } - runs = append(runs, WorkflowRunSummary{ - RunID: ledger.RunID, - WorkflowID: ledger.WorkflowID, - StartedAt: ledger.StartedAt, - Applied: ledger.Applied, - Paths: ledger.Paths, - Undoable: !ledger.Undone && len(ledger.Journal) > 0, - Interrupted: ledger.Interrupted != nil, - }) - } - sort.Slice(runs, func(i, j int) bool { - if runs[i].StartedAt != runs[j].StartedAt { - return runs[i].StartedAt > runs[j].StartedAt - } - return runs[i].RunID > runs[j].RunID - }) - return runs, nil -} - -func (v *Vault) DeleteWorkflowRuns(workflowID string) (int, error) { - v.mu.Lock() - defer v.mu.Unlock() - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return 0, err - } - entries, err := os.ReadDir(runsDir) - if errors.Is(err, os.ErrNotExist) { - return 0, nil - } - if err != nil { - return 0, err - } - removed := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - ledger, err := v.readWorkflowLedgerLocked(runID) - if err != nil || ledger.WorkflowID != workflowID { - continue - } - if err := os.Remove(filepath.Join(runsDir, entry.Name())); err == nil || errors.Is(err, os.ErrNotExist) { - removed++ - } - } - return removed, nil -} diff --git a/apps/server/internal/vault/workflows_hardening_test.go b/apps/server/internal/vault/workflows_hardening_test.go deleted file mode 100644 index 23a19eba..00000000 --- a/apps/server/internal/vault/workflows_hardening_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package vault - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// A save whose slug differs from the previous filename only by case used to -// delete the workflow that was just written: on a case-insensitive filesystem -// both spellings name one physical file, and the string-compare guard let the -// cleanup remove it. Either filesystem must end with exactly one surviving -// workflow carrying the new content. -func TestWriteWorkflowCaseOnlyRenameKeepsTheFile(t *testing.T) { - v, root := workflowTestVault(t) - dir := filepath.Join(root, ".zennotes", "workflows") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "My-Flow.md"), []byte("old\n"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteWorkflow(WriteWorkflowInput{ - Slug: "my-flow", - Raw: "new\n", - PreviousSourcePath: ".zennotes/workflows/My-Flow.md", - }); err != nil { - t.Fatal(err) - } - - body, err := os.ReadFile(filepath.Join(dir, "my-flow.md")) - if err != nil { - t.Fatalf("saved workflow unreadable after case-only rename: %v", err) - } - if string(body) != "new\n" { - t.Fatalf("saved workflow = %q, want the new content", body) - } -} - -// A note carrying invalid UTF-8 reaches the browser through JSON, which -// coerces the bad bytes to U+FFFD; the client can only echo that view back. -// Comparing it against raw disk bytes made every apply 409 forever. -func TestApplyPreparedWorkflowAcceptsWireCoercedBeforeBytes(t *testing.T) { - v, root := workflowTestVault(t) - raw := []byte("head \xff\xfe tail\n") - if err := os.WriteFile(filepath.Join(root, "inbox", "B.md"), raw, 0o600); err != nil { - t.Fatal(err) - } - - // What the client saw: each invalid byte as one replacement char. - before := coerceUTF8ForWire(string(raw)) - if !strings.Contains(before, "��") { - t.Fatalf("test fixture did not coerce: %q", before) - } - after := "rewritten\n" - - receipt, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "utf8", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{ - "kind": "write-note", "path": "inbox/B.md", "text": after, - })}, - Applied: 1, - Changes: []WorkflowRunFileChange{{Path: "inbox/B.md", Before: &before, After: &after}}, - }) - if err != nil { - t.Fatalf("apply over wire-coerced before bytes = %v, want success", err) - } - if receipt.RolledBack != nil { - t.Fatalf("run rolled back: %v", receipt.RolledBack.Reason) - } - body, err := os.ReadFile(filepath.Join(root, "inbox", "B.md")) - if err != nil || string(body) != after { - t.Fatalf("note after run = %q (%v), want %q", body, err, after) - } -} - -// An over-cap run must say WHICH limit it crossed: the dry run just promised -// success, so a bare "invalid counts" reads as a client bug. -func TestApplyPreparedWorkflowNamesTheScaleCap(t *testing.T) { - v, _ := workflowTestVault(t) - ops := make([]json.RawMessage, maxWorkflowOps+1) - for i := range ops { - ops[i] = rawWorkflowOp(t, map[string]string{"kind": "notify", "message": "x"}) - } - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{WorkflowID: "big", Ops: ops}) - if err == nil || !strings.Contains(err.Error(), "server limit") { - t.Fatalf("over-cap error = %v, want the limit named", err) - } -} diff --git a/apps/server/internal/vault/workflows_security_test.go b/apps/server/internal/vault/workflows_security_test.go deleted file mode 100644 index 9e58d1fd..00000000 --- a/apps/server/internal/vault/workflows_security_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package vault - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" -) - -func workflowTestVault(t *testing.T) (*Vault, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - return v, root -} - -func rawWorkflowOp(t *testing.T, value any) json.RawMessage { - t.Helper() - body, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return body -} - -func TestPreparedWorkflowRequiresValidMatchingOps(t *testing.T) { - v, root := workflowTestVault(t) - before := "# A\n" - after := "# Changed\n" - - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "missing-op", - Changes: []WorkflowRunFileChange{{ - Path: "inbox/A.md", Before: &before, After: &after, - }}, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("missing op error = %v, want ErrInvalidWorkflow", err) - } - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != before { - t.Fatalf("missing-op request changed note to %q (%v)", body, err) - } - - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "unknown-op", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "shell"})}, - Applied: 1, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("unknown op error = %v, want ErrInvalidWorkflow", err) - } - - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "malformed-op", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "write-note"})}, - Applied: 1, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("malformed op error = %v, want ErrInvalidWorkflow", err) - } -} - -func TestPreparedWorkflowRejectsStaleAndInternalPaths(t *testing.T) { - v, root := workflowTestVault(t) - stale := "# Stale\n" - after := "# Changed\n" - op := rawWorkflowOp(t, map[string]string{"kind": "write-note", "path": "inbox/A.md", "text": after}) - - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "stale", - Ops: []json.RawMessage{op}, - Applied: 1, - Changes: []WorkflowRunFileChange{{ - Path: "inbox/A.md", Before: &stale, After: &after, - }}, - }) - if !errors.Is(err, ErrWorkflowConflict) { - t.Fatalf("stale error = %v, want ErrWorkflowConflict", err) - } - - missing := (*string)(nil) - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "internal", - Ops: []json.RawMessage{op}, - Applied: 1, - Changes: []WorkflowRunFileChange{{ - Path: ".zennotes/workflows/owned.md", Before: missing, After: &after, - }}, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("internal path error = %v, want ErrInvalidWorkflow", err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "owned.md")); !os.IsNotExist(err) { - t.Fatalf("internal path was written: %v", err) - } -} - -func TestWorkflowRunsReadDesktopInterruptedLedger(t *testing.T) { - v, root := workflowTestVault(t) - runsDir := filepath.Join(root, ".zennotes", "workflows", ".runs") - if err := os.MkdirAll(runsDir, 0o700); err != nil { - t.Fatal(err) - } - ledger := map[string]any{ - "version": 1, - "runId": "desktop-run", - "workflowId": "desktop-workflow", - "startedAt": 1, - "finishedAt": 2, - "applied": 0, - "irreversible": 0, - "paths": []string{"inbox/A.md"}, - "ops": []any{}, - "journal": []any{map[string]any{"path": "inbox/A.md", "before": "# A\n"}}, - "hashes": map[string]any{}, - "undone": false, - "interrupted": map[string]string{"reason": "desktop stopped while applying"}, - } - body, err := json.Marshal(ledger) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(runsDir, "desktop-run.json"), body, 0o600); err != nil { - t.Fatal(err) - } - - runs, err := v.ListWorkflowRuns() - if err != nil { - t.Fatal(err) - } - if len(runs) != 1 || !runs[0].Interrupted || !runs[0].Undoable { - t.Fatalf("desktop interrupted runs = %+v", runs) - } -} - -func TestWorkflowRunsRejectSymlinkedHistoryDirectory(t *testing.T) { - v, root := workflowTestVault(t) - external := t.TempDir() - workflowDir := filepath.Join(root, ".zennotes", "workflows") - if err := os.MkdirAll(workflowDir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, filepath.Join(workflowDir, ".runs")); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - - if _, err := v.ListWorkflowRuns(); !errors.Is(err, ErrPathEscape) { - t.Fatalf("symlinked history error = %v, want ErrPathEscape", err) - } -} diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go deleted file mode 100644 index 64c56371..00000000 --- a/apps/server/internal/watcher/watcher.go +++ /dev/null @@ -1,451 +0,0 @@ -package watcher - -import ( - "encoding/json" - "log" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/fsnotify/fsnotify" -) - -const ( - internalVaultDir = ".zennotes" - vaultSettingsFilePath = ".zennotes/vault.json" - noteCommentsPrefix = ".zennotes/comments/" - noteCommentsSuffix = ".comments.json" - templatesPrefix = ".zennotes/templates/" -) - -// Watcher recursively watches the vault root and fans out change -// events to any subscribed channels. Mirrors the chokidar-based -// watcher in src/main/watcher.ts. -type Watcher struct { - root string - fs *fsnotify.Watcher - mu sync.Mutex - subs map[chan vault.ChangeEvent]struct{} - closed bool - stopCh chan struct{} - // dirs tracks the absolute paths we believe are directories, so a - // remove/rename event (which can't be os.Stat'd) can still be recognized - // as a folder change. Only touched from the single loop goroutine (and - // Start, before the loop begins), so it needs no separate lock. - dirs map[string]struct{} - // folderPaths holds the systemFolderPaths from vault settings for - // classifying note paths to folder IDs. - folderPaths map[string]string -} - -func (w *Watcher) SetFolderPaths(paths map[string]string) { - w.mu.Lock() - defer w.mu.Unlock() - w.folderPaths = paths -} - -func (w *Watcher) getFolderPaths() map[string]string { - w.mu.Lock() - defer w.mu.Unlock() - return w.folderPaths -} - -// reloadFolderPaths re-reads the folder overrides after vault.json changes. -// The raw map goes through the vault's normalizer, exactly as the paths seeded -// at startup did (main.go reads them from vault.GetSettings). A value the -// normalizer rejects, `trash: "assets"` say, would otherwise make the watcher -// route assets/ events to Trash while the vault, which classifies against the -// normalized settings, disagrees. -func (w *Watcher) reloadFolderPaths() { - settingsPath := filepath.Join(w.root, vaultSettingsFilePath) - raw, err := os.ReadFile(settingsPath) - if err != nil { - // A deleted vault.json means no overrides, which is what the vault - // reports too. Any other read error leaves the last known paths in place. - if os.IsNotExist(err) { - w.SetFolderPaths(nil) - } - return - } - var settings struct { - SystemFolderPaths map[string]string `json:"systemFolderPaths"` - } - if err := json.Unmarshal(raw, &settings); err != nil { - return - } - w.SetFolderPaths(vault.NormalizeSystemFolderPaths(settings.SystemFolderPaths)) -} - -func Start(root string) (*Watcher, error) { - fsw, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - w := &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - stopCh: make(chan struct{}), - dirs: map[string]struct{}{}, - folderPaths: nil, - } - // Recursively add all existing directories under the vault. - var addErrs int - var firstAddErr error - _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - name := d.Name() - if path != root && strings.HasPrefix(name, ".") && name != internalVaultDir { - return filepath.SkipDir - } - // Don't discard the error: inotify can be exhausted or restricted - // (notably in unprivileged LXC containers), and a silent failure - // leaves clients without live updates for no apparent reason. (#179) - if addErr := fsw.Add(path); addErr != nil { - addErrs++ - if firstAddErr == nil { - firstAddErr = addErr - } - } - w.dirs[path] = struct{}{} - } - return nil - }) - if addErrs > 0 { - log.Printf("watcher: could not watch %d director(ies) (first error: %v); live updates may be incomplete — set ZENNOTES_DISABLE_WATCHER=1 if this environment restricts inotify (e.g. unprivileged LXC)", addErrs, firstAddErr) - } - go w.loop() - return w, nil -} - -// Disabled returns a watcher that does no filesystem watching. It still -// supports Subscribe/Close so the rest of the server can treat it like a real -// watcher; it simply never emits change events. Used where inotify is -// unavailable or explicitly turned off — notably unprivileged LXC containers, -// where inotify operations can wedge the process (unkillable, bind-mount -// locked) instead of returning an error. (#179) -func Disabled(root string) *Watcher { - w := &Watcher{ - root: root, - fs: nil, - subs: map[chan vault.ChangeEvent]struct{}{}, - stopCh: make(chan struct{}), - dirs: map[string]struct{}{}, - } - go w.loop() - return w -} - -// StartOrDisabled starts a real watcher, or falls back to a no-op watcher when -// watching is turned off (disable) or unavailable. It never returns an error, -// so the server can always serve the vault even where inotify is restricted. (#179) -func StartOrDisabled(root string, disable bool) *Watcher { - if disable { - log.Printf("watcher: disabled via ZENNOTES_DISABLE_WATCHER; live updates are off") - return Disabled(root) - } - w, err := Start(root) - if err != nil { - log.Printf("watcher: unavailable (%v); continuing without live updates — set ZENNOTES_DISABLE_WATCHER=1 to disable watching explicitly", err) - return Disabled(root) - } - return w -} - -// Active reports whether this watcher really watches the filesystem. The -// Disabled fallback (inotify unavailable or explicitly off, #179) has no -// fsnotify handle and never emits an event; capabilities must not promise -// live updates it cannot deliver, or clients skip polling AND never hear -// about changes. -func (w *Watcher) Active() bool { - return w != nil && w.fs != nil -} - -func (w *Watcher) Subscribe() (<-chan vault.ChangeEvent, func()) { - ch := make(chan vault.ChangeEvent, 64) - w.mu.Lock() - w.subs[ch] = struct{}{} - w.mu.Unlock() - return ch, func() { - w.mu.Lock() - if _, ok := w.subs[ch]; ok { - delete(w.subs, ch) - close(ch) - } - w.mu.Unlock() - } -} - -func (w *Watcher) Close() { - w.mu.Lock() - if w.closed { - w.mu.Unlock() - return - } - w.closed = true - close(w.stopCh) - for ch := range w.subs { - delete(w.subs, ch) - close(ch) - } - w.mu.Unlock() - if w.fs != nil { - _ = w.fs.Close() - } -} - -func (w *Watcher) loop() { - // A disabled (no-op) watcher has no fsnotify handle — just block until close. - if w.fs == nil { - <-w.stopCh - return - } - for { - select { - case <-w.stopCh: - return - case err, ok := <-w.fs.Errors: - if !ok { - return - } - log.Printf("watcher error: %v", err) - case ev, ok := <-w.fs.Events: - if !ok { - return - } - w.handle(ev) - } - } -} - -func (w *Watcher) relativePath(absPath string) string { - rel, err := filepath.Rel(w.root, absPath) - if err != nil { - return "" - } - return filepath.ToSlash(rel) -} - -func (w *Watcher) isVaultSettingsPath(absPath string) bool { - return w.relativePath(absPath) == vaultSettingsFilePath -} - -func (w *Watcher) commentsNotePath(absPath string) (string, bool) { - rel := w.relativePath(absPath) - if !strings.HasPrefix(rel, noteCommentsPrefix) || !strings.HasSuffix(rel, noteCommentsSuffix) { - return "", false - } - return strings.TrimSuffix(strings.TrimPrefix(rel, noteCommentsPrefix), noteCommentsSuffix), true -} - -// templatePath reports whether the path is a custom template: a `.md` file -// directly inside .zennotes/templates/, the flat directory the template -// routes serve. Dotfiles and nested paths are not templates there either. -func (w *Watcher) templatePath(absPath string) (string, bool) { - rel := w.relativePath(absPath) - if !strings.HasPrefix(rel, templatesPrefix) { - return "", false - } - name := strings.TrimPrefix(rel, templatesPrefix) - if name == "" || strings.Contains(name, "/") || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - return "", false - } - return rel, true -} - -// watchSubdirs adds the directories already inside a directory that just -// appeared. A tree that arrives in one go (mkdir -p, or a template write -// creating .zennotes/templates/ in a vault that had no .zennotes/ yet) raises -// one Create for the top; its children were created before that watch -// existed, so without this walk they would stay unwatched until a restart. -func (w *Watcher) watchSubdirs(dir string) { - _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || path == dir || !d.IsDir() { - return nil - } - name := d.Name() - if strings.HasPrefix(name, ".") && name != internalVaultDir { - return filepath.SkipDir - } - if _, ok := w.dirs[path]; ok { - return nil - } - if addErr := w.fs.Add(path); addErr != nil { - log.Printf("watcher: cannot watch new directory %s: %v", path, addErr) - } - w.dirs[path] = struct{}{} - w.broadcastFolder(path, "add") - return nil - }) -} - -func (w *Watcher) handle(ev fsnotify.Event) { - base := filepath.Base(ev.Name) - // The scratch file every atomic write renames from. Its create/write/rename - // burst is not a vault change, and since the name does not end in .md a - // client would answer each one by re-listing the whole asset tree. - if vault.IsAtomicWriteTempPath(ev.Name) { - return - } - if strings.HasPrefix(base, ".") && !w.isVaultSettingsPath(ev.Name) && base != internalVaultDir { - return - } - info, statErr := os.Stat(ev.Name) - if statErr == nil && info.IsDir() { - if ev.Op&fsnotify.Create != 0 { - if err := w.fs.Add(ev.Name); err != nil { - log.Printf("watcher: cannot watch new directory %s: %v", ev.Name, err) - } - w.dirs[ev.Name] = struct{}{} - // An empty folder produces no note event, so clients would never - // learn about it until a manual refresh. Surface it explicitly. - w.broadcastFolder(ev.Name, "add") - w.watchSubdirs(ev.Name) - } - return - } - // A removed/renamed path we had tracked as a directory. We can't os.Stat - // it anymore, so the tracking set is what tells us it was a folder. - if statErr != nil { - if _, ok := w.dirs[ev.Name]; ok { - delete(w.dirs, ev.Name) - w.broadcastFolder(ev.Name, "unlink") - return - } - } - relPosix := w.relativePath(ev.Name) - if relPosix == "" { - return - } - if relPosix == vaultSettingsFilePath { - w.reloadFolderPaths() - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: relPosix, - Folder: vault.FolderInbox, - Scope: "vault-settings", - }) - return - } - if notePath, ok := w.commentsNotePath(ev.Name); ok { - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(notePath, w.getFolderPaths()) - if !ok { - folder = vault.FolderInbox - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: notePath, - Folder: folder, - Scope: "comments", - }) - return - } - if templatePath, ok := w.templatePath(ev.Name); ok { - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - // A template is not a note: its own scope keeps clients from - // re-listing the note tree and rescanning tasks for every save. - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: templatePath, - Folder: vault.FolderInbox, - Scope: "templates", - }) - return - } - if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(relPosix, w.getFolderPaths()) - if !ok { - if relPosix == vault.AssetsDir || - strings.HasPrefix(relPosix, vault.AssetsDir+"/") || - relPosix == vault.PrimaryAttachmentsDir || - strings.HasPrefix(relPosix, vault.PrimaryAttachmentsDir+"/") || - relPosix == "_assets" || - strings.HasPrefix(relPosix, "_assets/") { - folder = vault.FolderInbox - } else { - return - } - } - - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - - change := vault.ChangeEvent{ - Kind: kind, - Path: relPosix, - Folder: folder, - } - - w.broadcast(change) -} - -// exists says whether the path was still on disk when the event was handled, -// which is what separates a deleted note from a replaced one. -func eventKind(ev fsnotify.Event, exists bool) string { - switch { - case ev.Op&fsnotify.Create != 0: - return "add" - case ev.Op&fsnotify.Write != 0: - return "change" - case ev.Op&fsnotify.Remove != 0, ev.Op&fsnotify.Rename != 0: - // A rename into place, which is what every atomic save is, drops the - // old directory entry while the replacement is already sitting there. - // The kqueue backend (a server hosted on macOS) reports that as a - // delete of the note itself, and a client told its open note was - // deleted closes the tab. A path that still exists was replaced. - if exists { - return "add" - } - return "unlink" - default: - return "" - } -} - -func (w *Watcher) broadcastFolder(absPath, kind string) { - rel := w.relativePath(absPath) - if rel == "" { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(rel, w.getFolderPaths()) - if !ok { - return - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: rel, - Folder: folder, - Scope: "folder", - }) -} - -func (w *Watcher) broadcast(change vault.ChangeEvent) { - w.mu.Lock() - for ch := range w.subs { - select { - case ch <- change: - default: - } - } - w.mu.Unlock() -} diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go deleted file mode 100644 index 03ecd1ef..00000000 --- a/apps/server/internal/watcher/watcher_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package watcher - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/fsnotify/fsnotify" -) - -// newTestWatcher builds a Watcher with a real fsnotify handle but without -// starting the event loop, so handle() can be driven deterministically -// (no dependence on real filesystem-event timing). -func newTestWatcher(t *testing.T, root string) *Watcher { - t.Helper() - fsw, err := fsnotify.NewWatcher() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = fsw.Close() }) - return &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - dirs: map[string]struct{}{}, - stopCh: make(chan struct{}), - folderPaths: nil, - } -} - -func recvChange(t *testing.T, ch <-chan vault.ChangeEvent) vault.ChangeEvent { - t.Helper() - select { - case ev := <-ch: - return ev - case <-time.After(time.Second): - t.Fatal("timed out waiting for a change event") - return vault.ChangeEvent{} - } -} - -func TestWatcherBroadcastsFolderCreateAndRemove(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, "inbox", "Projects") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - - // Folder create — previously swallowed, so a client sharing this vault - // never learned of an empty folder until a manual refresh. - w.handle(fsnotify.Event{Name: dir, Op: fsnotify.Create}) - ev := recvChange(t, ch) - if ev.Scope != "folder" || ev.Kind != "add" || ev.Path != "inbox/Projects" { - t.Fatalf("folder create event = %+v, want {add inbox/Projects folder}", ev) - } - if _, ok := w.dirs[dir]; !ok { - t.Error("created dir was not tracked") - } - - // Folder remove — can't be stat'd once gone, so the tracking set is what - // identifies it as a directory rather than a file. - if err := os.RemoveAll(dir); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: dir, Op: fsnotify.Remove}) - ev = recvChange(t, ch) - if ev.Scope != "folder" || ev.Kind != "unlink" || ev.Path != "inbox/Projects" { - t.Fatalf("folder remove event = %+v, want {unlink inbox/Projects folder}", ev) - } - if _, ok := w.dirs[dir]; ok { - t.Error("removed dir is still tracked") - } -} - -func TestDisabledWatcherIsNoop(t *testing.T) { - root := t.TempDir() - w := Disabled(root) - if w.fs != nil { - t.Fatal("disabled watcher should have no fsnotify handle") - } - ch, unsub := w.Subscribe() - defer unsub() - - // Creating a directory must NOT produce an event — nothing is watched. - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - select { - case ev := <-ch: - t.Fatalf("disabled watcher emitted an event: %+v", ev) - case <-time.After(100 * time.Millisecond): - // Expected: a no-op watcher never emits. - } - - // Close must be safe even though there is no fsnotify handle to close. - w.Close() -} - -func TestStartOrDisabledFallsBackWhenDisabled(t *testing.T) { - root := t.TempDir() - - disabled := StartOrDisabled(root, true) - if disabled.fs != nil { - t.Error("StartOrDisabled(_, true) should return a no-op watcher") - } - disabled.Close() - - enabled := StartOrDisabled(root, false) - if enabled.fs == nil { - t.Error("StartOrDisabled(_, false) should start a real watcher") - } - enabled.Close() -} - -func writeVaultSettings(t *testing.T, root, body string) { - t.Helper() - dir := filepath.Join(root, internalVaultDir) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "vault.json"), []byte(body), 0o600); err != nil { - t.Fatal(err) - } -} - -func TestReloadFolderPathsNormalizesLikeTheVault(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - - // `assets` is a reserved directory name, so the vault's normalizer drops the - // override. Taking it at face value routed every assets/ event to Trash - // while the vault kept classifying those paths as assets. - writeVaultSettings(t, root, `{"systemFolderPaths":{"trash":"assets","quick":"scratch"}}`) - w.reloadFolderPaths() - - paths := w.getFolderPaths() - if _, rejected := paths["trash"]; rejected { - t.Fatalf("reserved override survived normalization: %v", paths) - } - if paths["quick"] != "scratch" { - t.Fatalf("valid override was lost: %v", paths) - } - - folder, ok := vault.FolderForRelativePathWithSettings("assets/image.png", paths) - if ok { - t.Fatalf("assets/image.png classified as %q; it is not a note folder", folder) - } - - // A deleted vault.json means no overrides, matching what the vault reports. - if err := os.Remove(filepath.Join(root, vaultSettingsFilePath)); err != nil { - t.Fatal(err) - } - w.reloadFolderPaths() - if got := w.getFolderPaths(); len(got) != 0 { - t.Fatalf("folder paths after vault.json removal = %v, want none", got) - } -} - -func TestWatcherClassifiesRemappedFolderEvents(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - writeVaultSettings(t, root, `{"systemFolderPaths":{"trash":"deleted"}}`) - w.reloadFolderPaths() - - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "deleted", "Gone.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("gone"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Write}) - ev := recvChange(t, ch) - if ev.Folder != vault.FolderTrash || ev.Path != "deleted/Gone.md" { - t.Fatalf("event = %+v, want {change deleted/Gone.md trash}", ev) - } -} - -func TestWatcherDoesNotSurfaceInternalDirAsFolder(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - internal := filepath.Join(root, internalVaultDir) - if err := os.MkdirAll(internal, 0o700); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: internal, Op: fsnotify.Create}) - - select { - case ev := <-ch: - t.Fatalf("unexpected folder event for %s: %+v", internalVaultDir, ev) - case <-time.After(100 * time.Millisecond): - // Expected: .zennotes is not a user-facing folder. - } -} - -func TestActiveDistinguishesRealFromDisabledWatcher(t *testing.T) { - root := t.TempDir() - - disabled := Disabled(root) - defer disabled.Close() - if disabled.Active() { - t.Fatal("Disabled watcher reports Active; capabilities would promise live updates it cannot deliver") - } - - real, err := Start(root) - if err != nil { - t.Skipf("fsnotify unavailable here: %v", err) - } - defer real.Close() - if !real.Active() { - t.Fatal("real watcher reports inactive") - } - - var nilWatcher *Watcher - if nilWatcher.Active() { - t.Fatal("nil watcher reports Active") - } -} - -// Every atomic note save creates a scratch file next to the note and renames it -// into place. The scratch file is not a vault change, and because its name does -// not end in .md a client that heard about it would answer by re-listing the -// whole asset tree, on every save. -func TestWatcherIgnoresAtomicWriteScratchFiles(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - scratch := filepath.Join(root, "inbox", "note.md.4123.1786714355519123456.tmp") - for _, op := range []fsnotify.Op{fsnotify.Create, fsnotify.Write, fsnotify.Rename} { - w.handle(fsnotify.Event{Name: scratch, Op: op}) - } - - select { - case ev := <-ch: - t.Fatalf("a scratch file reached clients: %+v", ev) - case <-time.After(100 * time.Millisecond): - } - - // The note the scratch file was renamed onto still reports normally. - w.handle(fsnotify.Event{Name: filepath.Join(root, "inbox", "note.md"), Op: fsnotify.Create}) - if ev := recvChange(t, ch); ev.Path != "inbox/note.md" { - t.Fatalf("note event = %+v, want inbox/note.md", ev) - } -} - -// inotify reports a rename-into-place as IN_MOVED_TO, which fsnotify folds into -// Create, so an atomic write (ours, or git/rsync/vim/Syncthing doing the same -// dance) surfaces as "add" rather than "change". Clients therefore have to treat -// an "add" for a note they hold open as new content to read, and this test is -// what pins that contract down on the server side. -func TestWatcherReportsRenameIntoPlaceAsAdd(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "inbox", "note.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("replaced by rename"), 0o600); err != nil { - t.Fatal(err) - } - - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Create}) - ev := recvChange(t, ch) - if ev.Kind != "add" || ev.Path != "inbox/note.md" || ev.Scope != "" { - t.Fatalf("rename-into-place event = %+v, want {add inbox/note.md}", ev) - } -} - -// The kqueue backend (a server hosted on macOS) reports the rename half of an -// atomic save as a delete of the note itself, arriving just before the add. A -// client that believes it closes the tab of the note being saved, so a path -// that still exists must never be reported as gone. -func TestWatcherDoesNotReportAReplacedNoteAsDeleted(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "inbox", "note.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("the replacement is already here"), 0o600); err != nil { - t.Fatal(err) - } - - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Remove}) - if ev := recvChange(t, ch); ev.Kind == "unlink" { - t.Fatalf("a replaced note was reported as deleted: %+v", ev) - } - - // A note that really is gone still reports as gone. - if err := os.Remove(note); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Remove}) - if ev := recvChange(t, ch); ev.Kind != "unlink" { - t.Fatalf("deleted note event = %+v, want unlink", ev) - } -} - -func TestWatcherSurfacesTemplateChangesWithOwnScope(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, internalVaultDir, "templates") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - file := filepath.Join(dir, "adr.md") - if err := os.WriteFile(file, []byte("---\nname: ADR\n---\n"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) - ev := recvChange(t, ch) - if ev.Scope != "templates" || ev.Kind != "change" || ev.Path != ".zennotes/templates/adr.md" { - t.Fatalf("template write event = %+v", ev) - } - - if err := os.Remove(file); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Remove}) - ev = recvChange(t, ch) - if ev.Scope != "templates" || ev.Kind != "unlink" { - t.Fatalf("template remove event = %+v", ev) - } -} - -func TestWatcherIgnoresNonTemplatesUnderTemplatesDir(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, internalVaultDir, "templates") - if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { - t.Fatal(err) - } - for _, name := range []string{".draft.md", "notes.txt", filepath.Join("nested", "x.md")} { - file := filepath.Join(dir, name) - if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) - } - select { - case ev := <-ch: - t.Fatalf("unexpected event for a non-template: %+v", ev) - case <-time.After(100 * time.Millisecond): - // Expected: dotfiles, other extensions and nested paths are not templates. - } -} - -func TestWatcherWatchesDirectoriesCreatedWithTheirParent(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - _, unsub := w.Subscribe() - defer unsub() - - // .zennotes/ and .zennotes/templates/ arrive together (one MkdirAll); the - // watcher hears one Create for the parent. - internal := filepath.Join(root, internalVaultDir) - templates := filepath.Join(internal, "templates") - if err := os.MkdirAll(templates, 0o700); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: internal, Op: fsnotify.Create}) - if _, ok := w.dirs[internal]; !ok { - t.Fatalf("parent directory not tracked") - } - if _, ok := w.dirs[templates]; !ok { - t.Fatalf("child directory created with its parent is not watched") - } -} diff --git a/apps/server/internal/webartifact/artifact.go b/apps/server/internal/webartifact/artifact.go deleted file mode 100644 index e2669d3b..00000000 --- a/apps/server/internal/webartifact/artifact.go +++ /dev/null @@ -1,473 +0,0 @@ -// Package webartifact verifies pinned browser distributions for Go-only builds. -package webartifact - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -const ( - maxManifest = 4 << 20 - maxArchive = 256 << 20 - maxExpanded = 512 << 20 - maxFiles = 4096 -) - -type File struct { - Path string `json:"path"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` -} - -type Archive struct { - File string `json:"file"` - URL string `json:"url,omitempty"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` -} - -type Source struct { - Repository string `json:"repository"` - Commit string `json:"commit"` - Dirty *bool `json:"dirty"` - LockfileSHA256 string `json:"lockfileSha256,omitempty"` -} - -type Manifest struct { - SchemaVersion int `json:"schemaVersion"` - Artifact string `json:"artifact"` - Version string `json:"version"` - Protocol string `json:"protocol"` - Source Source `json:"source"` - Toolchain map[string]string `json:"toolchain,omitempty"` - Archive Archive `json:"archive"` - Entrypoints []string `json:"entrypoints"` - Files []File `json:"files"` -} - -var checksum = regexp.MustCompile(`^[a-f0-9]{64}$`) -var commit = regexp.MustCompile(`^[a-f0-9]{40}$`) - -func portablePath(name string) bool { - if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, `\:*?"<>|`) { - return false - } - for _, part := range strings.Split(name, "/") { - if strings.TrimRight(part, ". ") != part { - return false - } - base := strings.ToUpper(strings.SplitN(part, ".", 2)[0]) - if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || - (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '0' && base[3] <= '9') { - return false - } - for _, char := range part { - if char < 32 || char == 127 { - return false - } - } - } - return true -} - -// Reject ambiguous keys before decoding into structs, where JSON otherwise uses -// the last duplicate. Limit nesting independently of the manifest's byte limit. -func checkJSONValue(decoder *json.Decoder, depth int) error { - if depth > 32 { - return errors.New("manifest nesting exceeds limit") - } - token, err := decoder.Token() - if err != nil { - return err - } - switch token { - case json.Delim('{'): - seen := map[string]bool{} - for decoder.More() { - key, err := decoder.Token() - if err != nil { - return err - } - name, ok := key.(string) - if !ok || seen[strings.ToLower(name)] { - return fmt.Errorf("duplicate or invalid manifest key: %v", key) - } - seen[strings.ToLower(name)] = true - if err := checkJSONValue(decoder, depth+1); err != nil { - return err - } - } - _, err = decoder.Token() - case json.Delim('['): - for decoder.More() { - if err := checkJSONValue(decoder, depth+1); err != nil { - return err - } - } - _, err = decoder.Token() - } - return err -} - -func ReadManifest(path string, allowDirty bool) (Manifest, error) { - var manifest Manifest - file, err := os.Open(path) - if err != nil { - return manifest, err - } - defer file.Close() - data, err := io.ReadAll(io.LimitReader(file, maxManifest+1)) - if err != nil { - return manifest, err - } - if len(data) > maxManifest { - return manifest, errors.New("artifact manifest is too large") - } - if err := checkJSONValue(json.NewDecoder(bytes.NewReader(data)), 0); err != nil { - return manifest, err - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&manifest); err != nil { - return manifest, err - } - if _, err := decoder.Token(); err != io.EOF { - return manifest, errors.New("unexpected data after manifest") - } - if manifest.SchemaVersion != 1 || manifest.Artifact != "zennotes-self-hosted-web" || manifest.Protocol != "self-hosted-http-v1" { - return manifest, errors.New("unsupported browser artifact or protocol") - } - if manifest.Version == "" || !commit.MatchString(manifest.Source.Commit) || manifest.Source.Repository != "https://github.com/ZenNotes/zennotes" { - return manifest, errors.New("invalid artifact provenance") - } - if manifest.Source.Dirty == nil { - return manifest, errors.New("source.dirty must be an explicit boolean") - } - if manifest.Source.LockfileSHA256 != "" && !checksum.MatchString(manifest.Source.LockfileSHA256) { - return manifest, errors.New("invalid lockfile checksum") - } - if *manifest.Source.Dirty && !allowDirty { - return manifest, errors.New("uncommitted source requires -allow-dirty for local testing") - } - if !portablePath(manifest.Archive.File) || strings.Contains(manifest.Archive.File, "/") || - !strings.HasSuffix(manifest.Archive.File, ".tgz") || !checksum.MatchString(manifest.Archive.SHA256) || - manifest.Archive.Size <= 0 || manifest.Archive.Size > maxArchive { - return manifest, errors.New("invalid archive pin") - } - if len(manifest.Files) == 0 || len(manifest.Files) > maxFiles { - return manifest, errors.New("invalid artifact file count") - } - paths := make(map[string]bool) - var total int64 - for _, item := range manifest.Files { - key := strings.ToLower(item.Path) - if !portablePath(item.Path) || paths[key] || !checksum.MatchString(item.SHA256) || item.Size < 0 || item.Size > 128<<20 { - return manifest, fmt.Errorf("invalid or duplicate asset %q", item.Path) - } - paths[key] = true - total += item.Size - } - if total > maxExpanded { - return manifest, errors.New("expanded artifact is too large") - } - if len(manifest.Entrypoints) == 0 { - return manifest, errors.New("missing artifact entrypoints") - } - hasIndex := false - for _, name := range manifest.Entrypoints { - found := false - for _, item := range manifest.Files { - if item.Path == name && item.Size > 0 { - found = true - } - } - if !found { - return manifest, fmt.Errorf("entrypoint %q is missing from inventory", name) - } - if name == "index.html" { - hasIndex = true - } - } - if !hasIndex { - return manifest, errors.New("missing index.html entrypoint") - } - return manifest, nil -} - -func validateHTTPS(raw string) error { - u, err := url.Parse(raw) - if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { - return errors.New("artifact download requires an HTTPS URL without credentials or a fragment") - } - return nil -} - -func openArchive(ctx context.Context, manifestPath, override string, archive Archive) (io.ReadCloser, error) { - if override != "" { - return os.Open(override) - } - local, err := os.Open(filepath.Join(filepath.Dir(manifestPath), archive.File)) - if err == nil { - return local, nil - } - if !os.IsNotExist(err) { - return nil, err - } - if err := validateHTTPS(archive.URL); err != nil { - return nil, err - } - client := &http.Client{Timeout: 2 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return errors.New("too many artifact redirects") - } - return validateHTTPS(req.URL.String()) - }} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, archive.URL, nil) - if err != nil { - return nil, err - } - response, err := client.Do(req) - if err != nil { - return nil, err - } - if response.StatusCode != http.StatusOK { - response.Body.Close() - return nil, fmt.Errorf("artifact download: HTTP %d", response.StatusCode) - } - return response.Body, nil -} - -func verifyFile(path string, expected File) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - hash := sha256.New() - n, err := io.Copy(hash, io.LimitReader(f, expected.Size+1)) - if err != nil { - return err - } - if n != expected.Size || hex.EncodeToString(hash.Sum(nil)) != expected.SHA256 { - return fmt.Errorf("asset checksum or size mismatch: %s", expected.Path) - } - return nil -} - -func verifyTree(root string, manifest Manifest) error { - want := make(map[string]File, len(manifest.Files)) - for _, item := range manifest.Files { - want[item.Path] = item - } - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Type()&os.ModeSymlink != 0 { - return errors.New("artifact tree contains a symbolic link") - } - if entry.IsDir() { - return nil - } - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - name := filepath.ToSlash(rel) - item, ok := want[name] - if !ok || !entry.Type().IsRegular() { - return fmt.Errorf("unexpected artifact file: %s", name) - } - if err := verifyFile(path, item); err != nil { - return err - } - delete(want, name) - return nil - }) - if err != nil { - return err - } - if len(want) != 0 { - return errors.New("artifact tree is incomplete") - } - return nil -} - -func extract(archive *os.File, stage string, manifest Manifest) error { - gz, err := gzip.NewReader(archive) - if err != nil { - return err - } - defer gz.Close() - limited := &io.LimitedReader{R: gz, N: maxExpanded + 16<<20} - reader := tar.NewReader(limited) - want := make(map[string]File, len(manifest.Files)) - for _, item := range manifest.Files { - want[item.Path] = item - } - seen := make(map[string]bool) - for count := 0; ; count++ { - if count > maxFiles*2 { - return errors.New("too many archive entries") - } - header, err := reader.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - name := strings.TrimSuffix(header.Name, "/") - if !portablePath(name) || seen[strings.ToLower(name)] { - return fmt.Errorf("unsafe or duplicate archive path %q", header.Name) - } - seen[strings.ToLower(name)] = true - if header.Typeflag == tar.TypeDir { - if name != "package" && name != "package/dist" && !strings.HasPrefix(name, "package/dist/") { - return errors.New("unexpected archive directory") - } - continue - } - if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { - return fmt.Errorf("unsupported archive entry %q", name) - } - if name == "package/package.json" || name == "package/LICENSE" { - if header.Size > 64<<10 { - return errors.New("oversized package metadata") - } - continue - } - rel := strings.TrimPrefix(name, "package/dist/") - item, ok := want[rel] - if !strings.HasPrefix(name, "package/dist/") || !ok || header.Size != item.Size { - return fmt.Errorf("unexpected asset or size: %q", name) - } - destination := filepath.Join(stage, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return err - } - file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - return err - } - hash := sha256.New() - n, copyErr := io.Copy(io.MultiWriter(file, hash), reader) - closeErr := file.Close() - if copyErr != nil { - return copyErr - } - if closeErr != nil { - return closeErr - } - if n != item.Size || hex.EncodeToString(hash.Sum(nil)) != item.SHA256 { - return fmt.Errorf("asset checksum mismatch: %s", rel) - } - delete(want, rel) - } - if len(want) != 0 { - return errors.New("archive is missing declared assets") - } - // Read through gzip's checksum, accepting only tar's trailing zero padding. - buffer := make([]byte, 32<<10) - for { - n, err := limited.Read(buffer) - for _, b := range buffer[:n] { - if b != 0 { - return errors.New("unexpected data after tar archive") - } - } - if err == io.EOF { - break - } - if err != nil { - return err - } - } - if limited.N <= 0 { - return errors.New("expanded archive exceeds limit") - } - return nil -} - -// Install verifies an archive before exposing its files and serializes cooperating -// installers. Use a privately owned build directory: other processes must not -// mutate the destination or its parents during verification and publication. -// An existing distribution is reused only if all its files match the manifest. -func Install(ctx context.Context, manifestPath, archiveOverride, destination string, allowDirty bool) error { - manifest, err := ReadManifest(manifestPath, allowDirty) - if err != nil { - return err - } - if destination == "" { - return errors.New("an output directory is required") - } - destination = filepath.Clean(destination) - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return err - } - lockPath := destination + ".install-lock" - lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return fmt.Errorf("acquire artifact install lock: %w", err) - } - lock.Close() - defer os.Remove(lockPath) - if _, err := os.Lstat(destination); err == nil { - if err := verifyTree(destination, manifest); err != nil { - return fmt.Errorf("destination already exists; use a clean build directory: %w", err) - } - return nil - } else if !os.IsNotExist(err) { - return err - } - work, err := os.MkdirTemp(filepath.Dir(destination), ".web-artifact-") - if err != nil { - return err - } - defer os.RemoveAll(work) - input, err := openArchive(ctx, manifestPath, archiveOverride, manifest.Archive) - if err != nil { - return err - } - defer input.Close() - archive, err := os.CreateTemp(work, "archive-") - if err != nil { - return err - } - defer archive.Close() - hash := sha256.New() - n, err := io.Copy(io.MultiWriter(archive, hash), io.LimitReader(input, manifest.Archive.Size+1)) - if err != nil { - return err - } - if n != manifest.Archive.Size || hex.EncodeToString(hash.Sum(nil)) != manifest.Archive.SHA256 { - return errors.New("archive checksum or size mismatch") - } - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return err - } - stage := filepath.Join(work, "dist") - if err := os.Mkdir(stage, 0o755); err != nil { - return err - } - if err := extract(archive, stage, manifest); err != nil { - return err - } - return os.Rename(stage, destination) -} diff --git a/apps/server/internal/webartifact/artifact_test.go b/apps/server/internal/webartifact/artifact_test.go deleted file mode 100644 index 11486ec7..00000000 --- a/apps/server/internal/webartifact/artifact_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package webartifact - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func dirty(value bool) *bool { return &value } - -func digest(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -type entry struct { - name, body string - typeflag byte -} - -func candidate(t *testing.T, entries []entry, change func(*Manifest)) (string, string) { - t.Helper() - root := t.TempDir() - var buffer bytes.Buffer - gz := gzip.NewWriter(&buffer) - tw := tar.NewWriter(gz) - for _, item := range entries { - kind := item.typeflag - if kind == 0 { - kind = tar.TypeReg - } - header := &tar.Header{Name: item.name, Mode: 0o644, Typeflag: kind, Size: int64(len(item.body))} - if kind == tar.TypeSymlink || kind == tar.TypeLink { - header.Linkname = "../../outside" - header.Size = 0 - } - if err := tw.WriteHeader(header); err != nil { - t.Fatal(err) - } - if header.Size > 0 { - if _, err := tw.Write([]byte(item.body)); err != nil { - t.Fatal(err) - } - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := gz.Close(); err != nil { - t.Fatal(err) - } - manifest := Manifest{ - SchemaVersion: 1, Artifact: "zennotes-self-hosted-web", Version: "1.0.0-test", - Protocol: "self-hosted-http-v1", - Source: Source{Repository: "https://github.com/ZenNotes/zennotes", Commit: strings.Repeat("a", 40), Dirty: dirty(false)}, - Archive: Archive{File: "web.tgz", Size: int64(buffer.Len()), SHA256: digest(buffer.Bytes())}, - Entrypoints: []string{"index.html"}, - Files: []File{{Path: "index.html", Size: 5, SHA256: digest([]byte("hello"))}}, - } - if change != nil { - change(&manifest) - } - data, err := json.Marshal(manifest) - if err != nil { - t.Fatal(err) - } - manifestPath := filepath.Join(root, "web.json") - if err := os.WriteFile(manifestPath, data, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "web.tgz"), buffer.Bytes(), 0o600); err != nil { - t.Fatal(err) - } - return manifestPath, filepath.Join(root, "build", "dist") -} - -func TestInstallVerifiedArtifactAndReuse(t *testing.T) { - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - for range 2 { - if err := Install(context.Background(), manifest, "", target, false); err != nil { - t.Fatal(err) - } - } - data, err := os.ReadFile(filepath.Join(target, "index.html")) - if err != nil || string(data) != "hello" { - t.Fatalf("installed entrypoint: %q, %v", data, err) - } -} - -func TestInvalidArtifactsLeaveDestinationAbsent(t *testing.T) { - valid := []entry{{name: "package/dist/index.html", body: "hello"}} - tests := []struct { - name string - entries []entry - change func(*Manifest) - }{ - {"archive checksum", valid, func(m *Manifest) { m.Archive.SHA256 = strings.Repeat("0", 64) }}, - {"file checksum", valid, func(m *Manifest) { m.Files[0].SHA256 = strings.Repeat("0", 64) }}, - {"wrong size", valid, func(m *Manifest) { m.Files[0].Size++ }}, - {"missing entrypoint", nil, nil}, - {"unexpected file", append(append([]entry{}, valid...), entry{name: "package/dist/extra.js", body: "extra"}), nil}, - {"duplicate file", append(append([]entry{}, valid...), valid...), nil}, - {"traversal", []entry{{name: "package/dist/../../outside", body: "bad"}}, nil}, - {"absolute path", []entry{{name: "/outside", body: "bad"}}, nil}, - {"backslash", []entry{{name: `package/dist/..\outside`, body: "bad"}}, nil}, - {"symlink", []entry{{name: "package/dist/index.html", typeflag: tar.TypeSymlink}}, nil}, - {"hard link", []entry{{name: "package/dist/index.html", typeflag: tar.TypeLink}}, nil}, - {"dirty source", valid, func(m *Manifest) { m.Source.Dirty = dirty(true) }}, - {"unknown protocol", valid, func(m *Manifest) { m.Protocol = "cloud-v1" }}, - {"unsafe manifest path", valid, func(m *Manifest) { m.Files[0].Path = "../outside" }}, - {"case collision", valid, func(m *Manifest) { f := m.Files[0]; f.Path = "INDEX.html"; m.Files = append(m.Files, f) }}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - manifest, target := candidate(t, tc.entries, tc.change) - if err := Install(context.Background(), manifest, "", target, false); err == nil { - t.Fatal("accepted invalid artifact") - } - if _, err := os.Lstat(target); !os.IsNotExist(err) { - t.Fatalf("failed install left destination behind: %v", err) - } - }) - } -} - -func TestExistingBuildIsNeverReplaced(t *testing.T) { - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(target, "index.html"), []byte("previous build"), 0o644); err != nil { - t.Fatal(err) - } - if err := Install(context.Background(), manifest, "", target, false); err == nil { - t.Fatal("replaced an existing distribution") - } - data, _ := os.ReadFile(filepath.Join(target, "index.html")) - if string(data) != "previous build" { - t.Fatal("changed previous distribution bytes") - } -} - -func TestDirtyCandidateRequiresExplicitOptIn(t *testing.T) { - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, func(m *Manifest) { m.Source.Dirty = dirty(true) }) - if err := Install(context.Background(), manifest, "", target, true); err != nil { - t.Fatal(err) - } -} - -func TestManifestRequiresUnambiguousSchema(t *testing.T) { - for _, tc := range []struct{ name, from, to string }{ - {"missing dirty", `"dirty":false`, `"lockfileSha256":""`}, - {"null dirty", `"dirty":false`, `"dirty":null`}, - {"unknown nested field", `"dirty":false`, `"dirty":false,"reviewed":true`}, - {"unknown top field", `"schemaVersion":1`, `"schemaVersion":1,"verified":true`}, - {"duplicate dirty", `"dirty":false`, `"dirty":true,"dirty":false`}, - {"case duplicate dirty", `"dirty":false`, `"Dirty":true,"dirty":false`}, - } { - t.Run(tc.name, func(t *testing.T) { - manifest, _ := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - data, err := os.ReadFile(manifest) - if err != nil { - t.Fatal(err) - } - changed := strings.Replace(string(data), tc.from, tc.to, 1) - if changed == string(data) { - t.Fatal("test did not change manifest") - } - if err := os.WriteFile(manifest, []byte(changed), 0o600); err != nil { - t.Fatal(err) - } - if _, err := ReadManifest(manifest, false); err == nil { - t.Fatal("accepted ambiguous manifest") - } - }) - } -} - -func TestExistingTreeRejectsExtraFilesAndLinks(t *testing.T) { - for _, link := range []bool{false, true} { - t.Run(fmt.Sprint(link), func(t *testing.T) { - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - if err := Install(context.Background(), manifest, "", target, false); err != nil { - t.Fatal(err) - } - extra := filepath.Join(target, "extra") - var err error - if link { - err = os.Symlink("index.html", extra) - } else { - err = os.WriteFile(extra, []byte("extra"), 0o600) - } - if err != nil { - if link { - t.Skipf("symlink unavailable: %v", err) - } - t.Fatal(err) - } - if err := Install(context.Background(), manifest, "", target, false); err == nil { - t.Fatal("reused unexpected file") - } - if _, err := os.Lstat(extra); err != nil { - t.Fatal("changed existing tree") - } - }) - } -} - -func TestInstallLockAndArchiveOverride(t *testing.T) { - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - t.Fatal(err) - } - lock := target + ".install-lock" - if err := os.WriteFile(lock, nil, 0o600); err != nil { - t.Fatal(err) - } - if err := Install(context.Background(), manifest, "", target, false); err == nil { - t.Fatal("ignored another installer") - } - if err := os.Remove(lock); err != nil { - t.Fatal(err) - } - override := filepath.Join(filepath.Dir(manifest), "candidate with spaces & symbols.tgz") - if err := os.Rename(filepath.Join(filepath.Dir(manifest), "web.tgz"), override); err != nil { - t.Fatal(err) - } - if err := Install(context.Background(), manifest, override, target, false); err != nil { - t.Fatal(err) - } -} - -func TestHTTPSDownloads(t *testing.T) { - for _, mode := range []string{"success", "http", "downgrade", "redirect loop", "credentials", "oversized", "wrong checksum"} { - t.Run(mode, func(t *testing.T) { - var payload []byte - var serverURL string - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { - t.Error("download sent credentials") - } - switch mode { - case "downgrade": - http.Redirect(w, r, "http://127.0.0.1:1/forbidden", http.StatusFound) - case "redirect loop": - http.Redirect(w, r, serverURL+"/again", http.StatusFound) - case "oversized": - w.Write(append(payload, 'x')) - case "wrong checksum": - w.Write(bytes.Repeat([]byte{'x'}, len(payload))) - default: - w.Write(payload) - } - })) - defer server.Close() - serverURL = server.URL - // Trust only this test server's certificate without changing production TLS. - previous := http.DefaultTransport - http.DefaultTransport = server.Client().Transport - defer func() { http.DefaultTransport = previous }() - download := server.URL + "/web.tgz" - if mode == "http" { - download = strings.Replace(download, "https:", "http:", 1) - } - if mode == "credentials" { - download = strings.Replace(download, "https://", "https://user:secret@", 1) - } - manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, func(m *Manifest) { m.Archive.URL = download }) - archive := filepath.Join(filepath.Dir(manifest), "web.tgz") - var err error - payload, err = os.ReadFile(archive) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(archive); err != nil { - t.Fatal(err) - } - err = Install(context.Background(), manifest, "", target, false) - if mode == "success" { - if err != nil { - t.Fatal(err) - } - } else { - if err == nil { - t.Fatal("accepted invalid download") - } - if _, err := os.Lstat(target); !os.IsNotExist(err) { - t.Fatal("failed download exposed files") - } - } - }) - } -} - -func TestManifestRejectsTrailingData(t *testing.T) { - manifest, _ := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) - f, err := os.OpenFile(manifest, os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - t.Fatal(err) - } - _, err = io.WriteString(f, "{}") - f.Close() - if err != nil { - t.Fatal(err) - } - if _, err := ReadManifest(manifest, false); err == nil { - t.Fatal("accepted trailing JSON") - } -} diff --git a/apps/server/package.json b/apps/server/package.json deleted file mode 100644 index 9a5f860a..00000000 --- a/apps/server/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@zennotes/server", - "private": true, - "version": "2.50.4", - "scripts": { - "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", - "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", - "typecheck": "node ../../tooling/scripts/run-go-server-test.mjs", - "test": "node ../../tooling/scripts/run-go-server-test.mjs", - "test:run": "node ../../tooling/scripts/run-go-server-test.mjs", - "sync-web": "node ../../tooling/scripts/sync-web-dist.mjs", - "build": "node ../../tooling/scripts/build-go-server.mjs" - } -} diff --git a/apps/server/web/api_only.go b/apps/server/web/api_only.go deleted file mode 100644 index b0eafabd..00000000 --- a/apps/server/web/api_only.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !embed_web - -package web - -import ( - "errors" - "io/fs" -) - -// Dist allows Go development and tests without a frontend toolchain or assets. -// Distribution builds select embed_web and include the pinned browser bundle. -func Dist() (fs.FS, error) { - return nil, errors.New("web bundle not embedded: use Vite in development or build with -tags embed_web") -} diff --git a/apps/server/web/embed.go b/apps/server/web/embed.go deleted file mode 100644 index 4aa0a4cc..00000000 --- a/apps/server/web/embed.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build embed_web - -package web - -import ( - "embed" - "io/fs" -) - -//go:embed all:dist dist/index.html -var dist embed.FS - -// Dist returns the embedded PWA bundle. Production builds require index.html -// at compile time so an incomplete asset bundle cannot produce a release binary. -func Dist() (fs.FS, error) { - return fs.Sub(dist, "dist") -} diff --git a/apps/server/web/embed_test.go b/apps/server/web/embed_test.go deleted file mode 100644 index 5450b176..00000000 --- a/apps/server/web/embed_test.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build embed_web - -package web - -import ( - "io/fs" - "regexp" - "strings" - "testing" -) - -func TestEmbeddedBrowserEntrypoint(t *testing.T) { - bundle, err := Dist() - if err != nil { - t.Fatal(err) - } - index, err := fs.ReadFile(bundle, "index.html") - if err != nil { - t.Fatal(err) - } - // Vite emits local module scripts and stylesheets in the entry document. - // Check the actual distribution so an incomplete staging step fails CI. - references := regexp.MustCompile(`(?:src|href)="((?:\./|/)?assets/[^"?#]+)(?:[?#][^"]*)?"`).FindAllSubmatch(index, -1) - if len(references) == 0 { - t.Fatal("index.html does not reference any bundled browser assets") - } - for _, reference := range references { - path := strings.TrimPrefix(strings.TrimPrefix(string(reference[1]), "./"), "/") - info, err := fs.Stat(bundle, path) - if err != nil { - t.Errorf("entrypoint asset %s: %v", path, err) - } else if info.IsDir() || info.Size() == 0 { - t.Errorf("entrypoint asset %s is not a nonempty file", path) - } - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 2537a962..8a49b989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ +# The image is published from ZenNotes/znserver (the Go server's repository); +# nothing here builds it. Pin a version with ZENNOTES_IMAGE or `make up IMAGE=...`. services: zennotes: - build: - context: . - dockerfile: Dockerfile - image: "${ZENNOTES_IMAGE:-zennotes-selfhosted:local}" + image: "${ZENNOTES_IMAGE:-adibhanna/zennotes:latest}" container_name: zennotes-selfhosted restart: unless-stopped user: "${ZENNOTES_CONTAINER_UID:-1000}:${ZENNOTES_CONTAINER_GID:-1000}" diff --git a/docs/boundary-release-cutover.md b/docs/boundary-release-cutover.md index 4da0e9a6..87b8977f 100644 --- a/docs/boundary-release-cutover.md +++ b/docs/boundary-release-cutover.md @@ -48,6 +48,10 @@ must move together; they cannot mix the package store with private source import ## Go repository and channel order +Status: done on September 16, 2026 through the manual publisher and a channel +rehearsal; see the server extraction document. The steps below are kept as the +record of the order that was followed. + - Verify `ZenNotes/znserver` is still empty before import. Preserve old repository history and tags. The documented dry-run is complete; actual filtering must run only in a disposable clone of the approved checkpoint. diff --git a/docs/explanation/how-zennotes-works.md b/docs/explanation/how-zennotes-works.md index 8e301b62..e2a1d1e6 100644 --- a/docs/explanation/how-zennotes-works.md +++ b/docs/explanation/how-zennotes-works.md @@ -51,7 +51,7 @@ The current architecture is stricter: - `packages/app-core` is the shared product - `apps/desktop` is the Electron shell - `apps/web` is the browser shell -- `apps/server` is the server runtime for browser and remote use +- the Go server in ZenNotes/znserver is the server runtime for browser and remote use This is the real difference between: diff --git a/docs/explanation/team-collaboration-plan.md b/docs/explanation/team-collaboration-plan.md index 10a667a9..f78a66d4 100644 --- a/docs/explanation/team-collaboration-plan.md +++ b/docs/explanation/team-collaboration-plan.md @@ -2,7 +2,7 @@ > **Status:** Proposed — not yet implemented. > **Last updated:** 2026-05-26 -> **Scope:** Turn the self-hosted Go server (`apps/server`) from a single-secret, +> **Scope:** Turn the self-hosted Go server (now in ZenNotes/znserver) from a single-secret, > single-vault deployment into a multi-user, team-aware server that companies can > run themselves, while keeping notes as ordinary `.md` files on their own disk. diff --git a/docs/monorepo-architecture.md b/docs/monorepo-architecture.md index 116712c9..f87dbb2f 100644 --- a/docs/monorepo-architecture.md +++ b/docs/monorepo-architecture.md @@ -30,12 +30,14 @@ Platform-specific code should stay in the app shells: - `apps/desktop` for Electron-only concerns such as windows, menus, updater, packaging - `apps/web` for browser/PWA bootstrapping -- `apps/server` for HTTP/WebSocket serving, vault access, and deployment/runtime config +- [ZenNotes/znserver](https://github.com/ZenNotes/znserver) for HTTP/WebSocket serving, vault access, and deployment/runtime config -Go development and tests now run without frontend assets. Distribution builds use -`-tags=embed_web` and require a complete browser bundle. The root server build, -Docker, Nix, and browser runtime harness select that tag. See -[the server build guide](../apps/server/README.md) for both modes. +The Go server has its own repository. It develops and tests without frontend +assets; its distribution builds embed the browser artifact that this repository +publishes (`web-*` releases) and pin in its manifest. This repository's browser +harness, perf runs, and `dev:web-stack` use the release pinned in +`tooling/server-release.json`, an explicit `ZENNOTES_SERVER_BINARY`, or a +checkout in `ZENNOTES_SERVER_DIR`. ## Bridge Contract @@ -75,7 +77,7 @@ and [types](https://www.typescriptlang.org/tsconfig/types.html) documentation. Runtime ownership is: - desktop: `apps/desktop` -- self-hosted: `apps/web` + `apps/server` +- self-hosted: `apps/web` + the Go server from ZenNotes/znserver - Cloud: the separate private `ZenNotes/website` Laravel application owns accounts, billing, vault revisions, storage authorization, and publishing diff --git a/docs/reference/runtime-and-package-map.md b/docs/reference/runtime-and-package-map.md index 82cf066f..01186ef3 100644 --- a/docs/reference/runtime-and-package-map.md +++ b/docs/reference/runtime-and-package-map.md @@ -56,9 +56,11 @@ Important point: The web app does not reimplement the product UI. It mounts the shared UI from `packages/app-core`. -## apps/server +## The Go server (ZenNotes/znserver) -`apps/server` is the Go backend for self-hosted and future hosted modes. +The Go backend for self-hosted and future hosted modes lives in its own +repository, [ZenNotes/znserver](https://github.com/ZenNotes/znserver). It +embeds the browser artifact this repository publishes. Responsibilities: @@ -70,10 +72,10 @@ Responsibilities: - security headers and CORS/origin checks - serving the embedded web bundle -Important scripts: +Important scripts here: -- `npm run dev:server` -- `npm run build --workspace @zennotes/server` +- `npm run dev:server` runs the pinned release (or a checkout via `ZENNOTES_SERVER_DIR`) +- `npm run server:binary` prints the resolved server binary path ## packages/app-core @@ -166,7 +168,7 @@ As a rule: - runtime contract -> `packages/bridge-contract` - desktop-only shell concerns -> `apps/desktop` - browser-only bootstrapping -> `apps/web` -- server-side vault/network/security behavior -> `apps/server` +- server-side vault/network/security behavior -> ZenNotes/znserver ## Related docs diff --git a/docs/server-extraction-rehearsal.md b/docs/server-extraction-rehearsal.md index 18a57254..07478bef 100644 --- a/docs/server-extraction-rehearsal.md +++ b/docs/server-extraction-rehearsal.md @@ -1,8 +1,11 @@ # Local Go server extraction rehearsal -Status: local verification on September 15, 2026. Nothing has been committed, -published, or pushed as part of this migration. The working repository still owns -the server; `ZenNotes/znserver` remains the selected destination. +Status: completed. On September 16, 2026 the server history was extracted to +[ZenNotes/znserver](https://github.com/ZenNotes/znserver) (main `975412e8`), +its release `v2.50.4` was published, and the Docker channel moved to that +repository's publisher (`adibhanna/zennotes:2.50.5`). `apps/server` and the +rehearsal tooling were removed from this repository afterwards. The rest of +this document records how the rehearsal was run and what it proved. ## Reproduce the source and artifact boundary diff --git a/docs/specs/mobile/README.md b/docs/specs/mobile/README.md index 6b5fcba1..5eb5f044 100644 --- a/docs/specs/mobile/README.md +++ b/docs/specs/mobile/README.md @@ -34,7 +34,7 @@ ZenNotes already runs three product modes over one product core (`packages/app-c ```text apps/desktop → Electron shell + Electron/IPC bridge (runtime: 'desktop') apps/web → Vite/PWA shell + HTTP bridge → Go server (runtime: 'web') -apps/server → Go backend +ZenNotes/znserver (separate repository) → Go backend apps/mobile → Capacitor shell + native bridge (runtime: 'mobile') ← NEW packages/app-core → shared React UI + renderers (reused verbatim) packages/bridge-contract → the ZenBridge seam (extended with a 'mobile' runtime + capability flags) diff --git a/flake.nix b/flake.nix index 83a3b224..aa4a8d07 100644 --- a/flake.nix +++ b/flake.nix @@ -12,26 +12,23 @@ forAllSystems = nixpkgs.lib.genAttrs systems; in { + # The desktop package wraps the prebuilt linux-x64 release tarball, so it + # only exists on x86_64-linux. The self-hosted server is packaged in its + # own repository, ZenNotes/znserver. packages = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; - zennotes-server = pkgs.callPackage ./packaging/nix/package-server.nix { }; in - { inherit zennotes-server; } - # The desktop package wraps the prebuilt linux-x64 release tarball, so it - # only exists on x86_64-linux; elsewhere the server is the default. - // ( - if system == "x86_64-linux" then - let - zennotes-desktop = pkgs.callPackage ./packaging/nix/package-desktop.nix { }; - in - { - inherit zennotes-desktop; - default = zennotes-desktop; - } - else - { default = zennotes-server; } - ) + if system == "x86_64-linux" then + let + zennotes-desktop = pkgs.callPackage ./packaging/nix/package-desktop.nix { }; + in + { + inherit zennotes-desktop; + default = zennotes-desktop; + } + else + { } ); devShell = forAllSystems (system: @@ -40,7 +37,6 @@ in pkgs.mkShell { buildInputs = with pkgs; [ - go nodejs electron turbo diff --git a/guide.md b/guide.md index 53f235b8..c6dec50d 100644 --- a/guide.md +++ b/guide.md @@ -165,7 +165,7 @@ Use this if you do not want Docker and you are okay running both the frontend an - Node.js 22+ - npm -- Go 1.22+ +- Go, only if you want to run the server from a ZenNotes/znserver checkout instead of the pinned release binary ### Steps @@ -175,7 +175,8 @@ Install dependencies: npm ci ``` -Run both the web client and Go server together: +Run both the web client and the pinned Go server release together (the server +binary is downloaded from ZenNotes/znserver and checksum-verified on first use): ```bash make web-stack @@ -208,20 +209,25 @@ That means: ## 4. Run the self-hosted server without Docker -If you want a built server binary instead of dev mode: +The server is a single static binary released by +[ZenNotes/znserver](https://github.com/ZenNotes/znserver). Download the asset +for your platform from that repository's releases page, check it against the +release's `SHA256SUMS`, and run it: ```bash -npm ci -make server-build -./apps/server/bin/zennotes-server +chmod +x zennotes-server-linux-amd64 +./zennotes-server-linux-amd64 ``` +From a checkout of this repository, `npm run server:binary` downloads the +pinned release, verifies its checksum, and prints the path of the binary. + Then open: - [http://localhost:7878](http://localhost:7878) - or `http://YOUR_SERVER_IP:7878` -The built server embeds the web app, so you do not need to run `dev:web` for this path. +The released server embeds the web app, so you do not need to run `dev:web` for this path. ## 5. Choose a vault in the web version diff --git a/package-lock.json b/package-lock.json index 230bf53f..9f388c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -872,10 +872,6 @@ "node": ">=18" } }, - "apps/server": { - "name": "@zennotes/server", - "version": "2.50.4" - }, "apps/share-viewer": { "name": "@zennotes/share-viewer", "version": "2.50.4", @@ -6154,10 +6150,6 @@ "resolved": "apps/desktop", "link": true }, - "node_modules/@zennotes/server": { - "resolved": "apps/server", - "link": true - }, "node_modules/@zennotes/share-viewer": { "resolved": "apps/share-viewer", "link": true diff --git a/package.json b/package.json index 2fa3bce5..dca19e81 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dev": "npm run dev:desktop", "dev:desktop": "npm run dev --workspace @zennotes/desktop", "dev:web": "npm run dev --workspace @zennotes/web", - "dev:server": "npm run dev --workspace @zennotes/server", + "dev:server": "node tooling/scripts/run-server-dev.mjs", "dev:web-stack": "node tooling/scripts/dev-web-stack.mjs", "start": "npm run start --workspace @zennotes/desktop", "typecheck": "turbo run typecheck", @@ -38,7 +38,7 @@ "test:web-artifact": "node --test tooling/scripts/pack-web-artifact.test.mjs", "check:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs", "sync:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs --write", - "build": "turbo run build --filter=!@zennotes/server && npm run build --workspace @zennotes/server", + "build": "turbo run build", "build:prod": "npm run typecheck && npm run test:run && npm run build", "perf:bench": "node tooling/scripts/perf-large-vault.mjs", "perf:desktop-runtime": "node tooling/scripts/perf-desktop-runtime.mjs", @@ -56,7 +56,8 @@ "artifact:app-core": "node tooling/scripts/pack-app-core.mjs", "test:app-core-package": "node tooling/scripts/test-app-core-package.mjs", "test:app-core-browser": "node tooling/scripts/test-app-core-browser.mjs", - "pack:share-viewer": "node tooling/scripts/pack-share-viewer.mjs" + "pack:share-viewer": "node tooling/scripts/pack-share-viewer.mjs", + "server:binary": "node tooling/scripts/server-binary.mjs" }, "devDependencies": { "patch-package": "8.0.1", diff --git a/packages/app-core/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts index f8830278..ea8ae6f5 100644 --- a/packages/app-core/src/lib/tags.ts +++ b/packages/app-core/src/lib/tags.ts @@ -37,7 +37,7 @@ export function extractTags(body: string): string[] { * tag. Fence detection is line-based and indentation-tolerant: a fence nested * under a list item is still a code block, so e.g. a C `#include` line inside * it is not a tag (#293). Mirrors `stripCodeContent` in - * apps/desktop/src/main/vault.ts and apps/server/internal/vault/parse.go — + * apps/desktop/src/main/vault.ts and internal/vault/parse.go in ZenNotes/znserver — * keep the three in sync. */ function stripCodeContent(body: string): string { diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index f96f0647..b21cad79 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -9,7 +9,7 @@ const INVALID_NOTE_PATH_CHARS = /[\\:*?"<>|#^\[\]]/ * reads code as a link. Line-based and indentation-tolerant: a fence nested * under a list item is still a code block (#293). Mirrors `stripCodeContent` in * tags.ts, apps/desktop/src/main/vault.ts, apps/desktop/src/mcp/vault-ops.ts, - * and apps/server/internal/vault/parse.go — keep all five in sync. + * and internal/vault/parse.go in ZenNotes/znserver — keep all five in sync. */ function stripCodeContent(body: string): string { if (!body.includes('`') && !body.includes('~')) return body diff --git a/packages/bridge-contract/fixtures/README.md b/packages/bridge-contract/fixtures/README.md index c65088dd..f1567d63 100644 --- a/packages/bridge-contract/fixtures/README.md +++ b/packages/bridge-contract/fixtures/README.md @@ -31,13 +31,15 @@ TZ=America/Los_Angeles npm run test:run --workspace @zennotes/shared-domain -- t TZ=Pacific/Auckland npm run test:run --workspace @zennotes/shared-domain -- task-roundtrip ``` -The Go server runs the same cases in `internal/vault/task_roundtrip_contract_test.go`. +The Go server (ZenNotes/znserver) runs the same cases in `internal/vault/task_roundtrip_contract_test.go`. Its client sends the edited Markdown; the server verifies storage and parsing before and after that write. The JSON and SHA-256 provenance are vendored in its `testdata` directory so `go test ./...` needs no Node or sibling checkout. -After changing a fixture, run `npm run sync:contract-fixtures`, then the TypeScript -and Go checks. CI runs `npm run check:contract-fixtures` to reject drift. The TUI +After changing a fixture, run `npm run sync:contract-fixtures -- ` +(or set `ZENNOTES_SERVER_DIR`), then the TypeScript and Go checks in both +repositories; `npm run check:contract-fixtures -- ` compares without +writing. The TUI consumer and fixture artifact publication are still pending. Do not silently rewrite expected results to match a divergent implementation; identify the intended behavior first. diff --git a/packages/shared-domain/src/workflows/apply-ops.ts b/packages/shared-domain/src/workflows/apply-ops.ts index d7e091da..7a8b51c4 100644 --- a/packages/shared-domain/src/workflows/apply-ops.ts +++ b/packages/shared-domain/src/workflows/apply-ops.ts @@ -233,7 +233,7 @@ interface CodeMap { * * The state machine is lifted from `stripCodeContent` (packages/app-core/src/ * lib/tags.ts, apps/desktop/src/main/vault.ts, apps/desktop/src/mcp/vault-ops.ts - * and apps/server/internal/vault/parse.go all carry a copy) so that a `#tag` + * and internal/vault/parse.go in ZenNotes/znserver all carry a copy) so that a `#tag` * this module writes, and a `#tag` the vault's indexer reads, can never disagree * about what is code. It returns per-line flags instead of a blanked string * because we edit the original bytes and therefore need offsets to survive. diff --git a/packages/shared-domain/src/workflows/prepare-run.ts b/packages/shared-domain/src/workflows/prepare-run.ts index aca39ca5..a1a74eb5 100644 --- a/packages/shared-domain/src/workflows/prepare-run.ts +++ b/packages/shared-domain/src/workflows/prepare-run.ts @@ -52,7 +52,7 @@ function stringField(record: Record, key: string): string | nul /** Validate one operation that crossed a process or HTTP boundary. * SYNCED COPIES: the same validator exists in apps/desktop/src/main/ * workflow-apply.ts, and the Go server keeps a field map in - * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). + * requiredWorkflowOpFields (internal/vault/workflows.go in ZenNotes/znserver). * A new op kind or field lands in all three or web and desktop disagree * about which runs are valid. */ export function parseWorkflowOp(value: unknown): WorkflowOp | null { diff --git a/packaging/PUBLISHING.md b/packaging/PUBLISHING.md index 34df5eec..1c3d9d32 100644 --- a/packaging/PUBLISHING.md +++ b/packaging/PUBLISHING.md @@ -13,8 +13,10 @@ There are **three** Linux channels (plus Docker, which is separate): | **Nix flake** | `flake.nix` in this repo | Merge to `main` | Bump `release-data.json` (3 hashes) | | **Homebrew** (`brew install --cask`, macOS) | `packaging/homebrew/` → `ZenNotes/homebrew-tap` | You, manually | Bump + sha256 + push to the tap | -> Docker (`adibhanna/zennotes`) is handled separately by `docker-publish.yml` — -> not a Linux desktop package, not covered here. +> Docker (`adibhanna/zennotes`) is published from the server's own repository, +> [ZenNotes/znserver](https://github.com/ZenNotes/znserver), by its manual +> publisher after each server release. It is not a Linux desktop package and is +> not covered here. --- @@ -134,13 +136,11 @@ github:ZenNotes/zennotes` reads the repo's default branch (`main`), so the | `version` | `X.Y.Z` | | `hash` | `nix-prefetch-github ZenNotes zennotes --rev vX.Y.Z` | | `npmDepsHash` | `prefetch-npm-deps package-lock.json` | - | `vendorHash` | run `nix build`, read the expected hash from the mismatch error | Then verify: ```sh nix build && ./result/bin/zennotes-desktop - nix build .#zennotes-server && ./result/bin/zennotes-server ``` - **Needs Nix.** The flake targets darwin too, so you can do this on your Mac with @@ -186,10 +186,10 @@ source) and is mirrored into the **`ZenNotes/homebrew-tap`** repo, which is what `.SRCINFO`, `git push origin main:master` (§2). 7. **Nix:** bump `release-data.json`'s 3 hashes — on your Mac with Nix, or a contributor PR (§3). -8. **Docker:** confirm `docker-publish.yml` ran and pushed `adibhanna/zennotes`. +8. **Docker:** the image ships from ZenNotes/znserver on its own release cadence; nothing to do here. 9. **Homebrew (macOS):** `packaging/homebrew/update-cask.sh X.Y.Z`, commit, then mirror `Casks/zennotes.rb` into `ZenNotes/homebrew-tap` and push (§4). -**Fully automatic:** GitHub installers (incl. tar.gz once §0 lands), Docker. +**Fully automatic:** GitHub installers (incl. tar.gz once §0 lands). **Needs you every release:** AUR push, Nix hash bump, Homebrew push. **One-time future setups:** nixpkgs submission, create the Homebrew tap. diff --git a/packaging/nix/README.md b/packaging/nix/README.md index fdc35849..32059557 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -11,10 +11,9 @@ For the desktop app: nix run github:ZenNotes/zennotes ``` -For the server: -```sh -nix run github:ZenNotes/zennotes#zennotes-server -``` +The self-hosted server is packaged in its own repository, +[ZenNotes/znserver](https://github.com/ZenNotes/znserver), which ships a +`default.nix` (`nix-build` in a checkout). ## Installing on NixOS @@ -41,7 +40,6 @@ And then you can add it to your system packages: { environment.systemPackages = [ inputs.zennotes.packages.${pkgs.system}.zennotes-desktop - inputs.zennotes.packages.${pkgs.system}.zennotes-server ]; } ``` @@ -55,14 +53,6 @@ environment.systemPackages = [ ]; ``` -Same goes for the server package: - -```nix -environment.systemPackages = [ - (pkgs.callPackage ./package-server.nix { }) -]; -``` - ## Updating to a new release 1. Open `release-data.json` @@ -91,7 +81,7 @@ nix-prefetch-github ZenNotes zennotes --rev "vX.X.X" ``` -3. Update the npmDepsHash (if needed) and vendorHash (if needed) +3. Update the npmDepsHash (if needed) To obtain a new npmDepsHash use this command in an updated project root: ```sh @@ -101,8 +91,7 @@ prefetch-npm-deps package-lock.json ```json { // ... - "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=", - "vendorHash": "sha256-wYBF7CjM6AvoWMWql9hFmIaj6pCmli4vOef6POyGkfU=" + "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=" } ``` @@ -113,11 +102,6 @@ nix build ./result/bin/zennotes-desktop ``` -```sh -nix build .#server -./result/bin/zennotes-server -``` - ## Notes & limitations * Automatic updates inside ZenNotes are disabled because Nix packages are immutable. diff --git a/packaging/nix/package-server.nix b/packaging/nix/package-server.nix deleted file mode 100644 index 28c235ad..00000000 --- a/packaging/nix/package-server.nix +++ /dev/null @@ -1,67 +0,0 @@ -{ - lib, - buildGoModule, - fetchFromGitHub, - buildNpmPackage, -}: -let - releaseData = lib.importJSON ./release-data.json; - - src = fetchFromGitHub { - owner = "ZenNotes"; - repo = "zennotes"; - tag = "v${releaseData.version}"; - inherit (releaseData) hash; - }; - - web = buildNpmPackage { - pname = "zennotes-web"; - - inherit (releaseData) version npmDepsHash; - inherit src; - - npmWorkspace = "apps/web"; - - env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; - - installPhase = '' - runHook preInstall - - mkdir -p "$out" - cp -R apps/web/dist/. "$out/" - - runHook postInstall - ''; - }; -in -buildGoModule (finalAttrs: { - pname = "zennotes-server"; - - inherit (releaseData) version vendorHash; - inherit src; - - modRoot = "apps/server"; - - subPackages = [ "cmd/zennotes-server" ]; - tags = [ "embed_web" ]; - ldflags = [ - "-s" - "-w" - ]; - - preBuild = '' - rm -rf web/dist - mkdir -p web/dist - cp -R ${web}/. web/dist/ - ''; - - meta = { - description = "A server API for hosting remote ZenNotes vaults"; - homepage = "https://zennotes.org/"; - changelog = "https://github.com/ZenNotes/zennotes/releases/tag/v${finalAttrs.version}"; - license = lib.licenses.mit; - maintainers = with lib.maintainers; [ justkrysteq ]; - mainProgram = finalAttrs.pname; - platforms = lib.platforms.linux ++ lib.platforms.darwin; - }; -}) diff --git a/packaging/nix/release-data.json b/packaging/nix/release-data.json index 09991ed1..e36476c1 100644 --- a/packaging/nix/release-data.json +++ b/packaging/nix/release-data.json @@ -2,6 +2,5 @@ "version": "2.50.4", "hash": "sha256-E3KparZ1Gc7hcUW5fQnmYoQs2IQNxz+NQZFMJEy5gG4=", "npmDepsHash": "sha256-Nr2f0H6UT2eorlr60BjDl87OQzJRLWjVEgreJnHCMxg=", - "vendorHash": "sha256-ZdOHC2JldvnKSDUFnBUJrKD4F1IWfvYJBksgeDnU9cw=", "desktopHash": "sha256-QovewPQ29GccNOBKc9OKD5NF+3EZkk55OK3VAJRwNSc=" } diff --git a/tooling/scripts/build-go-server.mjs b/tooling/scripts/build-go-server.mjs deleted file mode 100644 index 07738f23..00000000 --- a/tooling/scripts/build-go-server.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' -import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') -const serverBinaryName = - process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - env: options.env ?? process.env, - stdio: 'inherit', - shell - }) - - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise( - new Error( - `${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}` - ) - ) - }) - - child.on('error', rejectPromise) - }) -} - -await withWebDistLock(async (lock) => { - const env = webDistLockEnv(lock) - await run( - process.execPath, - [resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs')], - repoRoot, - { env } - ) - await run( - 'go', - ['test', '-tags=embed_web', './web'], - serverRoot, - { env: withGoEnv(env) } - ) - await run( - 'go', - [ - 'build', - '-tags=embed_web', - '-trimpath', - '-ldflags=-s -w', - '-o', - resolve(serverRoot, 'bin', serverBinaryName), - './cmd/zennotes-server' - ], - serverRoot, - { - env: withGoEnv(env) - } - ) -}) diff --git a/tooling/scripts/perf-web-runtime.mjs b/tooling/scripts/perf-web-runtime.mjs index cd6823c1..8fcce2e6 100644 --- a/tooling/scripts/perf-web-runtime.mjs +++ b/tooling/scripts/perf-web-runtime.mjs @@ -14,7 +14,14 @@ import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') +// The Go server lives in ZenNotes/znserver. A perf run measures the local web +// bundle, so it needs a server that embeds it: either a checkout in +// ZENNOTES_SERVER_DIR (web dist synced in, then `go build -tags=embed_web`) or +// a binary the caller built the same way. The pinned release binary embeds the +// pinned artifact instead, so it is never picked up silently; pass it through +// ZEN_PERF_WEB_SERVER_BINARY=$(npm run -s server:binary) to measure a release. +const serverCheckout = process.env.ZENNOTES_SERVER_DIR?.trim() + ? resolve(process.env.ZENNOTES_SERVER_DIR.trim()) : null const webDistIndex = resolve(repoRoot, 'apps/web/dist/index.html') const syncWebDistScript = resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs') @@ -22,8 +29,8 @@ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' const noteCount = parsePositiveInt(process.env.ZEN_PERF_WEB_NOTES, 5000) const enforceBudgets = process.env.ZEN_PERF_ENFORCE === '1' const skipWebBuild = process.env.ZEN_PERF_SKIP_WEB_BUILD === '1' -const prebuiltServer = process.env.ZEN_PERF_WEB_SERVER_BINARY?.trim() - ? resolve(process.env.ZEN_PERF_WEB_SERVER_BINARY.trim()) : null +const prebuiltServerEnv = process.env.ZEN_PERF_WEB_SERVER_BINARY?.trim() || process.env.ZENNOTES_SERVER_BINARY?.trim() +const prebuiltServer = prebuiltServerEnv ? resolve(prebuiltServerEnv) : null const externalVaultRoot = externalVaultRootFromEnv('ZEN_PERF_WEB_VAULT_ROOT') const configuredTempRoot = process.env.ZEN_PERF_WEB_TEMP_ROOT?.trim() ? resolve(process.env.ZEN_PERF_WEB_TEMP_ROOT.trim()) @@ -254,7 +261,7 @@ function appendBounded(buffer, chunk, maxLength = 12000) { return next.length > maxLength ? next.slice(next.length - maxLength) : next } -function startGoServer({ vaultRoot, bind, serverBinary, configPath, disablePersistedMetaCache }) { +function startGoServer({ vaultRoot, bind, serverBinary, configPath, cwd, disablePersistedMetaCache }) { const env = { ...process.env, ZENNOTES_BIND: bind, @@ -264,7 +271,7 @@ function startGoServer({ vaultRoot, bind, serverBinary, configPath, disablePersi ...(disablePersistedMetaCache ? { ZEN_PERF_DISABLE_PERSISTED_META_CACHE: '1' } : {}) } const child = spawn(serverBinary, [], { - cwd: serverRoot, + cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -454,12 +461,12 @@ async function prepareWebDist(env) { env }) } - await run(process.execPath, [syncWebDistScript], { env }) + await run(process.execPath, [syncWebDistScript, join(serverCheckout, 'web/dist')], { env }) } async function buildGoServer(outputPath, env) { await run('go', ['build', '-tags=embed_web', '-trimpath', '-o', outputPath, './cmd/zennotes-server'], { - cwd: serverRoot, + cwd: serverCheckout, env: withGoEnv(env) }) } @@ -590,6 +597,14 @@ async function stopChild(child) { } async function main() { + if (!prebuiltServer && !serverCheckout) { + throw new Error( + 'perf:web-runtime needs a server that embeds the local web bundle: set ZENNOTES_SERVER_DIR to a ' + + 'ZenNotes/znserver checkout, or ZEN_PERF_WEB_SERVER_BINARY to a server built with -tags=embed_web ' + + '(use $(npm run -s server:binary) to measure the pinned release instead)' + ) + } + if (prebuiltServer && serverCheckout) throw new Error('Choose ZEN_PERF_WEB_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') const tempRoot = configuredTempRoot ?? await mkdtemp(join(tmpdir(), 'zennotes-web-perf-')) if (configuredTempRoot) await mkdir(tempRoot, { recursive: true }) const vaultRoot = externalVaultRoot ?? join(tempRoot, 'vault') @@ -627,6 +642,7 @@ async function main() { bind: `127.0.0.1:${serverPort}`, serverBinary, configPath: join(tempRoot, 'zennotes-perf-server.json'), + cwd: tempRoot, disablePersistedMetaCache: Boolean(externalVaultRoot) }) await waitForHttpOk(`http://127.0.0.1:${serverPort}/healthz`, 20000) diff --git a/tooling/scripts/prepare-server-web-dist.mjs b/tooling/scripts/prepare-server-web-dist.mjs deleted file mode 100644 index fcb36b62..00000000 --- a/tooling/scripts/prepare-server-web-dist.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import { access } from 'node:fs/promises' -import { constants } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const webDistIndex = resolve(repoRoot, 'apps/web/dist/index.html') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' - -async function fileExists(path) { - try { - await access(path, constants.F_OK) - return true - } catch { - return false - } -} - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - stdio: 'inherit', - shell, - env: options.env ?? process.env - }) - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) - }) - child.on('error', rejectPromise) - }) -} - -// The build and the sync run under one lock. Two turbo tasks arriving at once -// would otherwise both spawn `vite build` into apps/web/dist, and vite empties -// its outDir before writing, so the loser could stage a half-written tree into -// the server bundle. The second one through finds index.html already there and -// only syncs. The child processes inherit the lock rather than wait on it. -await withWebDistLock(async (lock) => { - const env = webDistLockEnv(lock) - if (!(await fileExists(webDistIndex))) { - await run(npmCommand, ['run', 'build', '--workspace', '@zennotes/web'], repoRoot, { - shell: process.platform === 'win32', - env - }) - } - await run(process.execPath, [resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs')], repoRoot, { env }) -}) diff --git a/tooling/scripts/rehearse-server-extraction.mjs b/tooling/scripts/rehearse-server-extraction.mjs deleted file mode 100644 index 908fcb41..00000000 --- a/tooling/scripts/rehearse-server-extraction.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import { createHash } from 'node:crypto' -import { spawnSync } from 'node:child_process' -import { cp, mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') -const manifest = process.argv[2] && resolve(process.argv[2]) -if (!manifest || process.argv.length !== 3) - throw new Error('Usage: node tooling/scripts/rehearse-server-extraction.mjs ') -const pin = JSON.parse(await readFile(manifest, 'utf8')) -if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(pin.archive?.file ?? '')) - throw new Error('Expected a local web archive basename in the manifest') -const output = await mkdtemp(join(tmpdir(), 'zennotes-server-rehearsal-')) -const source = join(output, 'source') -const files = [] -const originalModule = 'github.com/ZenNotes/zennotes/apps/server' -const destinationModule = 'github.com/ZenNotes/znserver' -const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') - -async function copySource(directory, relative = '') { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = relative + entry.name - if (['web/dist', 'bin', 'node_modules', '.git'].includes(path)) continue - if (entry.isDirectory()) await copySource(join(directory, entry.name), `${path}/`) - else if ( - entry.isFile() && - ((/\.(go|json)$/.test(path) && path !== 'package.json') || - ['go.mod', 'go.sum', 'README.md'].includes(path)) - ) { - const before = await readFile(join(directory, entry.name)) - const after = - path.endsWith('.go') || path === 'go.mod' - ? Buffer.from(before.toString('utf8').replaceAll(originalModule, destinationModule)) - : before - await mkdir(dirname(join(source, path)), { recursive: true }) - await writeFile(join(source, path), after) - files.push({ - path, - originalSha256: hash(before), - extractedSha256: hash(after) - }) - } - } -} - -await copySource(join(root, 'apps/server')) -await cp(join(root, 'LICENSE'), join(source, 'LICENSE')) -await cp(join(root, 'tooling/server-repository'), source, { recursive: true }) -const release = JSON.parse(await readFile(join(root, 'packaging/nix/release-data.json'), 'utf8')) -const serverPackage = JSON.parse(await readFile(join(root, 'apps/server/package.json'), 'utf8')) -await writeFile(join(source, 'release.json'), JSON.stringify({ version: serverPackage.version, vendorHash: release.vendorHash }, null, 2) + '\n') -await mkdir(join(source, 'web-artifact')) -await cp(manifest, join(source, 'web-artifact', 'manifest.json')) -await cp(join(dirname(manifest), pin.archive.file), join(source, 'web-artifact', pin.archive.file)) -await writeFile( - join(output, 'provenance.json'), - JSON.stringify( - { - kind: 'local-uncommitted-extraction-rehearsal', - originalModule, - destinationModule, - webArtifact: pin.version, - manifestSha256: hash(await readFile(join(source, 'web-artifact', 'manifest.json'))), - archiveSha256: hash(await readFile(join(source, 'web-artifact', pin.archive.file))), - files: files.sort((a, b) => a.path.localeCompare(b.path)) - }, - null, - 2 - ) + '\n' -) - -function go(args) { - const result = spawnSync('go', args, { - cwd: source, - stdio: 'inherit', - env: { ...process.env, GOWORK: 'off' } - }) - if (result.error) throw result.error - if (result.status !== 0) - throw new Error(`go ${args.join(' ')} failed; rehearsal retained at ${output}`) -} - -// All commands below run from the copied source with no frontend source or npm manifest. -go(['vet', './...']) -go(['test', './...']) -go(['build', '-trimpath', '-o', join(output, 'server-api'), './cmd/zennotes-server']) -go([ - 'run', - './cmd/prepare-web', - '-manifest', - 'web-artifact/manifest.json', - '-output', - 'web/dist', - ...(pin.source.dirty ? ['-allow-dirty'] : []) -]) -go(['test', '-tags=embed_web', './web']) -const binary = join( - output, - process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' -) -go(['build', '-tags=embed_web', '-trimpath', '-o', binary, './cmd/zennotes-server']) -process.stdout.write( - JSON.stringify({ output, source, binary, webArtifact: pin.version }, null, 2) + '\n' -) diff --git a/tooling/scripts/run-go-server-dev.mjs b/tooling/scripts/run-go-server-dev.mjs deleted file mode 100644 index 12908c9d..00000000 --- a/tooling/scripts/run-go-server-dev.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = process.env.ZENNOTES_SERVER_DIR - ? resolve(process.env.ZENNOTES_SERVER_DIR) - : resolve(repoRoot, 'apps/server') -const binary = process.env.ZENNOTES_SERVER_BINARY -if (binary && process.env.ZENNOTES_SERVER_DIR) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR') - -const child = spawn(binary ? resolve(binary) : 'go', binary ? [] : ['run', './cmd/zennotes-server'], { - cwd: serverRoot, - env: withGoEnv({ - ZENNOTES_DEV: '1' - }), - stdio: 'inherit' -}) - -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal) - return - } - process.exit(code ?? 1) -}) - -child.on('error', (error) => { - console.error(error) - process.exit(1) -}) diff --git a/tooling/scripts/run-go-server-test.mjs b/tooling/scripts/run-go-server-test.mjs deleted file mode 100644 index 31848a2c..00000000 --- a/tooling/scripts/run-go-server-test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - env: options.env ?? process.env, - stdio: 'inherit', - shell, - }) - - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) - }) - - child.on('error', rejectPromise) - }) -} - -await run('go', ['test', './...'], serverRoot, { - env: withGoEnv(), -}) diff --git a/tooling/scripts/run-server-dev.mjs b/tooling/scripts/run-server-dev.mjs new file mode 100644 index 00000000..f21c7330 --- /dev/null +++ b/tooling/scripts/run-server-dev.mjs @@ -0,0 +1,26 @@ +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' +import { resolveServerBinary } from './server-binary.mjs' + +// dev:server and dev:web-stack start the server this way. A ZenNotes/znserver +// checkout in ZENNOTES_SERVER_DIR keeps the edit-and-restart loop through +// `go run`; an explicit ZENNOTES_SERVER_BINARY runs as is; otherwise the pinned +// published release runs, so browser work needs neither Go nor a checkout. +const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() +const explicit = process.env.ZENNOTES_SERVER_BINARY?.trim() +if (checkout && explicit) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') +const env = { ...process.env, ZENNOTES_DEV: '1' } +const child = checkout + ? spawn('go', ['run', './cmd/zennotes-server'], { cwd: resolve(checkout), env, stdio: 'inherit' }) + : spawn(await resolveServerBinary(), [], { env, stdio: 'inherit' }) +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) +child.on('error', (error) => { + console.error(error) + process.exit(1) +}) diff --git a/tooling/scripts/server-binary.mjs b/tooling/scripts/server-binary.mjs new file mode 100644 index 00000000..b1cd5484 --- /dev/null +++ b/tooling/scripts/server-binary.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const pinPath = join(repoRoot, 'tooling/server-release.json') + +// The Go server lives in ZenNotes/znserver. Anything here that needs a running +// server (the browser harness, perf runs, dev:web-stack) takes, in order, an +// explicit binary, an explicit checkout built with Go, or the pinned published +// release verified against the SHA-256 recorded in tooling/server-release.json. +// Nothing in this repository builds the server from source any more. +export async function resolveServerBinary({ log = (line) => process.stderr.write(`${line}\n`) } = {}) { + const explicit = process.env.ZENNOTES_SERVER_BINARY?.trim() + const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() + if (explicit && checkout) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') + if (explicit) { + await access(explicit, constants.X_OK) + return resolve(explicit) + } + if (checkout) return buildFromCheckout(resolve(checkout), log) + return downloadPinned(log) +} + +export async function readServerPin() { + return JSON.parse(await readFile(pinPath, 'utf8')) +} + +export function pinnedAssetKey() { + return `${process.platform}-${process.arch}` +} + +async function buildFromCheckout(dir, log) { + const out = join(dir, 'bin', process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server') + log(`[server] building ${out} from ${dir}`) + execFileSync('go', ['build', '-trimpath', '-o', out, './cmd/zennotes-server'], { cwd: dir, stdio: 'inherit' }) + return out +} + +async function sha256Of(path) { + try { + return createHash('sha256').update(await readFile(path)).digest('hex') + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +async function downloadPinned(log) { + const pin = await readServerPin() + const key = pinnedAssetKey() + const asset = pin.assets[key] + if (!asset) throw new Error(`No pinned server binary for ${key}; set ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR`) + const directory = join(repoRoot, 'dist/server-binaries', pin.version) + const target = join(directory, asset.file) + if ((await sha256Of(target)) === asset.sha256) { + await chmod(target, 0o755) + return target + } + const url = `https://github.com/${pin.repository}/releases/download/${pin.version}/${asset.file}` + log(`[server] downloading ${url}`) + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180000) }) + if (!response.ok) throw new Error(`Server download failed: HTTP ${response.status} for ${url}`) + const bytes = Buffer.from(await response.arrayBuffer()) + const digest = createHash('sha256').update(bytes).digest('hex') + if (digest !== asset.sha256) { + throw new Error(`Server download checksum mismatch for ${asset.file}: expected ${asset.sha256}, got ${digest}`) + } + await mkdir(directory, { recursive: true }) + const temp = `${target}.${process.pid}.tmp` + await writeFile(temp, bytes, { mode: 0o755 }) + await rename(temp, target) + await chmod(target, 0o755) + return target +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(`${await resolveServerBinary()}\n`) +} diff --git a/tooling/scripts/sync-contract-fixtures.mjs b/tooling/scripts/sync-contract-fixtures.mjs index 36477944..b847c7b9 100644 --- a/tooling/scripts/sync-contract-fixtures.mjs +++ b/tooling/scripts/sync-contract-fixtures.mjs @@ -4,13 +4,18 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +// The Go consumer of these fixtures is ZenNotes/znserver. Point at a checkout +// with ZENNOTES_SERVER_DIR (or the first non-flag argument); --write copies the +// fixtures and their provenance in, and without it the copies are compared. +const serverDir = process.argv.slice(2).find((arg) => !arg.startsWith('--')) ?? process.env.ZENNOTES_SERVER_DIR +if (!serverDir) throw new Error('Pass a ZenNotes/znserver checkout path or set ZENNOTES_SERVER_DIR') const fixtures = [ ['task-roundtrip.json', 'vault'], ['self-hosted-http.json', 'httpserver'] ] for (const [name, consumer] of fixtures) { const source = `packages/bridge-contract/fixtures/${name}` - const target = resolve(root, `apps/server/internal/${consumer}/testdata/${name}`) + const target = resolve(serverDir, `internal/${consumer}/testdata/${name}`) const bytes = await readFile(resolve(root, source)) const provenance = JSON.stringify( @@ -33,9 +38,9 @@ for (const [name, consumer] of fixtures) { } else { const current = await readFile(path) if (!current.equals(content)) { - throw new Error(`Contract fixture differs: ${path}. Run npm run sync:contract-fixtures.`) + throw new Error(`Contract fixture differs: ${path}. Run npm run sync:contract-fixtures -- .`) } } } } -process.stdout.write('Go fixtures match the shared contract bytes.\n') +process.stdout.write(`Go fixtures in ${serverDir} match the shared contract bytes.\n`) diff --git a/tooling/scripts/sync-web-dist.mjs b/tooling/scripts/sync-web-dist.mjs index ba1d1b44..5d0058dd 100644 --- a/tooling/scripts/sync-web-dist.mjs +++ b/tooling/scripts/sync-web-dist.mjs @@ -8,10 +8,17 @@ import { withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') const webDist = resolve(repoRoot, 'apps/web/dist') -const serverDist = resolve(repoRoot, 'apps/server/web/dist') +// The server no longer lives here. The destination is a ZenNotes/znserver +// checkout's web/dist, given as the first argument or through ZENNOTES_SERVER_DIR. +const destinationArg = process.argv[2]?.trim() +const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() +const serverDist = destinationArg ? resolve(destinationArg) : checkout ? resolve(checkout, 'web/dist') : null +if (!serverDist) { + throw new Error('sync-web-dist needs a destination: pass /web/dist or set ZENNOTES_SERVER_DIR to a ZenNotes/znserver checkout') +} -// `apps/server` runs `prepare-web` from BOTH `typecheck` and `test:run`, and -// turbo schedules those two tasks concurrently. A plain `rm` followed by `cp` +// Two producers can run this concurrently (a perf run and a manual sync, or +// two turbo tasks in the checkout). A plain `rm` followed by `cp` // therefore races: the second process deletes the directory while the first is // still copying into it, and the first dies with ENOENT partway through. It // surfaces as `turbo run typecheck test:run` failing on a machine where each @@ -109,8 +116,8 @@ async function main() { if (source === null) { throw new Error(`no web bundle at ${webDist}; run \`npm run build --workspace @zennotes/web\` first`) } - // Identical trees are the steady state across repeated turbo runs. Skipping - // the swap keeps `apps/server/web/dist` continuously present for go:embed. + // Identical trees are the steady state across repeated runs. Skipping the + // swap keeps the checkout's web/dist continuously present for go:embed. if (source === (await treeSignature(serverDist))) return const stage = `${serverDist}.stage-${process.pid}` diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 14326ddc..3d0f8620 100644 --- a/tooling/scripts/test-app-core-browser.mjs +++ b/tooling/scripts/test-app-core-browser.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict' -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import net from 'node:net' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { withGoEnv } from './go-env.mjs' +import { resolveServerBinary } from './server-binary.mjs' const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const manifest = resolve(process.argv[2] || join(root, 'dist/shared-packages/app-core-consumer.json')) @@ -75,8 +75,8 @@ class CDP { close() { this.socket.close() } } const apiPort = await port(), uiPort = await port(), debugPort = await port() -const binary = join(run, process.platform === 'win32' ? 'server.exe' : 'server') -execFileSync('go', ['build', '-o', binary, './cmd/zennotes-server'], { cwd: join(root, 'apps/server'), env: withGoEnv(), stdio: 'inherit' }) +// The server is the pinned ZenNotes/znserver release (or an explicit binary or checkout). +const binary = await resolveServerBinary() const children = [] const logs = {} function launch(name, command, args, options) { @@ -94,11 +94,11 @@ const pendingAbsenceProbes = new Set() const absenceProbes = new Map() const token = 'isolated-package-test-only-token' try { - launch('server', binary, [], { cwd: run, env: withGoEnv({ + launch('server', binary, [], { cwd: run, env: { ...process.env, ZENNOTES_BIND: `127.0.0.1:${apiPort}`, ZENNOTES_DEFAULT_VAULT_PATH: vault, ZENNOTES_CONFIG_PATH: join(run, 'server.json'), ZENNOTES_BROWSE_ROOTS: vault, ZENNOTES_AUTH_TOKEN: token, ZENNOTES_BASE_PATH: '' - }) }) + } }) const api = `http://127.0.0.1:${apiPort}` await until(async () => (await fetch(`${api}/api/healthz`, { signal: AbortSignal.timeout(1000) })).ok, 'API startup') const path = 'inbox/Package test.md' diff --git a/tooling/scripts/web-dist-lock.mjs b/tooling/scripts/web-dist-lock.mjs index cdf89c13..19e5ddf2 100644 --- a/tooling/scripts/web-dist-lock.mjs +++ b/tooling/scripts/web-dist-lock.mjs @@ -9,11 +9,12 @@ const repoRoot = resolve(scriptDir, '..', '..') // One lock serializes every process that produces the web bundle: the vite // build that fills apps/web/dist (it empties the directory first, so a reader -// can otherwise stage a half-written tree) and the swap that moves that tree -// into apps/server/web/dist (which briefly has no dist/ at all, and `go:embed -// all:dist` cannot compile in that window). It lives next to the tree it -// guards so a leftover lock is easy to spot and delete by hand. -export const WEB_DIST_LOCK_DIR = resolve(repoRoot, 'apps/server/web/.web-dist.lock') +// can otherwise stage a half-written tree), the artifact packer that reads it, +// and the sync that moves that tree into an external server checkout's +// web/dist (which briefly has no dist/ at all, and `go:embed all:dist` cannot +// compile in that window). It lives next to the tree it guards so a leftover +// lock is easy to spot and delete by hand. +export const WEB_DIST_LOCK_DIR = resolve(repoRoot, 'apps/web/.web-dist.lock') const OWNER_FILE = resolve(WEB_DIST_LOCK_DIR, 'owner.json') // Only an ownerless lock can expire by age. A cold Go build may legitimately // hold the lock much longer while the compiler reads embedded browser assets. diff --git a/tooling/server-release.json b/tooling/server-release.json new file mode 100644 index 00000000..291665b4 --- /dev/null +++ b/tooling/server-release.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "repository": "ZenNotes/znserver", + "version": "v2.50.4", + "assets": { + "darwin-arm64": { "file": "zennotes-server-darwin-arm64", "sha256": "13295e97392fadc95cc6c7b35386d993c7dcc159e8e3b0bbe7b0f282bef02409" }, + "darwin-x64": { "file": "zennotes-server-darwin-amd64", "sha256": "1ccaff087bfc33b7741cc4e014e638a60cc392bf8563931ce2b3d498a6cb3aca" }, + "linux-arm64": { "file": "zennotes-server-linux-arm64", "sha256": "72fdc108db6eda4e4a1476835177955960f2b3f6b095625e5082d146c8321168" }, + "linux-x64": { "file": "zennotes-server-linux-amd64", "sha256": "15a137b0e1cabd77c0de7f7e0b359696acdd94955f4dab5fbf65b1dc26cd8d7c" }, + "win32-x64": { "file": "zennotes-server-windows-amd64.exe", "sha256": "f90dc3444de8b84bb76fac991ae298881cd31e8c5c75f4b45db5caf520e84e7d" } + } +} diff --git a/tooling/server-repository/.dockerignore b/tooling/server-repository/.dockerignore deleted file mode 100644 index 9c1d413c..00000000 --- a/tooling/server-repository/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.git -bin -web/dist -result diff --git a/tooling/server-repository/.github/workflows/ci.yml b/tooling/server-repository/.github/workflows/ci.yml deleted file mode 100644 index 1b974ced..00000000 --- a/tooling/server-repository/.github/workflows/ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Server boundary -on: [push, pull_request, workflow_dispatch] -permissions: - contents: read -jobs: - go: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - run: go vet ./... - - run: go test ./... - - run: go build -trimpath -o bin/server-api ./cmd/zennotes-server - - run: go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist - - run: go test -tags=embed_web ./web - - run: go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server - docker: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: false - nix: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Build server with pinned Nix toolchain and browser artifact - run: docker run --rm -v "$PWD:/source:ro" nixos/nix@sha256:7a007c766426c1877758ddc5cb87a965ac131fc78c582ce0083d922d51ae945c sh -c 'cd /source && nix-build --no-out-link' diff --git a/tooling/server-repository/.github/workflows/docker-publish.yml b/tooling/server-repository/.github/workflows/docker-publish.yml deleted file mode 100644 index 7b800f5b..00000000 --- a/tooling/server-repository/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Publish Docker image after channel cutover - -on: - workflow_dispatch: - inputs: - tag: - description: "Extra tag to publish (in addition to latest), e.g. 2.0.1" - required: false - type: string - -permissions: - contents: read - -concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: false - -env: - IMAGE: adibhanna/zennotes - -jobs: - publish: - name: Build and push multi-arch image - runs-on: ubuntu-latest - environment: server-docker-publisher - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Derive image tags and labels - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ./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 diff --git a/tooling/server-repository/.github/workflows/release.yml b/tooling/server-repository/.github/workflows/release.yml deleted file mode 100644 index e108b636..00000000 --- a/tooling/server-repository/.github/workflows/release.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Prepare server release -on: - workflow_dispatch: - inputs: - source_commit: - description: Reviewed full source commit SHA - type: string - required: true - tag: - description: New server release tag (vX.Y.Z) - type: string - required: true -permissions: - contents: read -jobs: - binaries: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.source_commit }} - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - env: - SOURCE_COMMIT: ${{ inputs.source_commit }} - TAG: ${{ inputs.tag }} - run: | - test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" - printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' - test "$TAG" = "v$(python3 -c 'import json; print(json.load(open("release.json"))["version"])')" - - run: go vet ./... && go test ./... - - run: go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist - - run: go test -tags=embed_web ./web - - name: Cross-compile binaries and checksums - run: | - mkdir release - for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do - os="${target%/*}" - arch="${target#*/}" - suffix=""; if [ "$os" = windows ]; then suffix=.exe; fi - CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -tags=embed_web -trimpath -ldflags="-s -w" -o "release/zennotes-server-$os-$arch$suffix" ./cmd/zennotes-server - done - cp LICENSE web-artifact/manifest.json release/ - cd release && sha256sum zennotes-server-* > SHA256SUMS - - uses: actions/upload-artifact@v4 - with: - name: server-release - path: release/* - draft: - needs: binaries - runs-on: ubuntu-latest - environment: server-release - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - name: server-release - path: release - - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: gh release create "$TAG" release/* --target "$SOURCE_COMMIT" --title "$TAG" --draft --notes "Self-hosted server with a pinned browser artifact. Complete candidate install and rollback checks before publication." diff --git a/tooling/server-repository/.gitignore b/tooling/server-repository/.gitignore deleted file mode 100644 index 6a1031cf..00000000 --- a/tooling/server-repository/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/bin/ -/web/dist/ -/web-artifact/*.tgz -/result diff --git a/tooling/server-repository/Dockerfile b/tooling/server-repository/Dockerfile deleted file mode 100644 index bde5170d..00000000 --- a/tooling/server-repository/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# syntax=docker/dockerfile:1.7 -FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS build -WORKDIR /source -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -# Dirty source is an explicit local rehearsal option; release builds use false. -ARG ALLOW_DIRTY=false -RUN if [ "$ALLOW_DIRTY" = true ]; then go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist -allow-dirty; else go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist; fi -RUN go test -tags=embed_web ./web -ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -tags=embed_web -trimpath -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server -FROM scratch -LABEL org.opencontainers.image.title="ZenNotes" \ - org.opencontainers.image.description="Self-hosted ZenNotes server with a pinned browser artifact." \ - org.opencontainers.image.source="https://github.com/ZenNotes/znserver" -COPY --from=build /out/zennotes-server /zennotes-server -ENV ZENNOTES_BIND=0.0.0.0:7878 \ - ZENNOTES_CONFIG_PATH=/data/server.json \ - ZENNOTES_DEFAULT_VAULT_PATH=/workspace \ - ZENNOTES_BROWSE_ROOTS=/workspace -USER 65532:65532 -EXPOSE 7878 -VOLUME ["/workspace", "/data"] -ENTRYPOINT ["/zennotes-server"] diff --git a/tooling/server-repository/README.md b/tooling/server-repository/README.md deleted file mode 100644 index 7b91000a..00000000 --- a/tooling/server-repository/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# ZenNotes self-hosted server - -The Go server owns the self-hosted API, authentication, filesystem access, and -vault watching. The main ZenNotes repository owns the browser app and publishes -its immutable build. Laravel Cloud remains a separate service. - -## Build and verify - -Go 1.25 or later is sufficient for API development: - -```sh -go vet ./... -go test ./... -go run ./cmd/zennotes-server -``` - -Production bundles include the browser artifact pinned in -`web-artifact/manifest.json`: - -```sh -go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist -go test -tags=embed_web ./web -go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server -``` - -No Node install or sibling source checkout is needed. The importer checks -protocol, source, tar paths/types, size, archive SHA-256, and every file checksum. -The reviewed manifest is the trust anchor. Dirty local candidates require -`-allow-dirty`; release and CI paths deliberately omit that flag. - -## Distribution - -`docker build .` builds the same Go-only distribution. Preserve image ownership -and the existing `adibhanna/zennotes` image when the publisher cutover is approved. -Runtime defaults remain UID 65532, port 7878, `/workspace`, `/data/server.json`, and -the existing `ZENNOTES_*` variables. Existing authentication/base-path behavior -and note bytes are covered by HTTP fixtures under `internal/httpserver/testdata`. - -`nix-build` uses the pinned browser archive and Go vendor hash in `release.json`. -It never compiles frontend source. A local rehearsal can use -`nix-build --arg allowDirty true` with an adjacent candidate archive. - -To update the web app, review a new manifest, run Go/import/embed/HTTP tests, and -release it with the server source. Roll back by selecting the prior server image -or binary and restoring its pin; no vault migration is introduced. - -## Extraction gate - -This directory is prepared in a local rehearsal before publication. Do not enable -its Docker publisher while the main repository still publishes the same tags. -First approve and publish a clean browser artifact, extract approved history, -verify destination CI and candidate installation, then switch one channel at a -time. The main repository retains its source and previous release until a verified -destination release and rollback rehearsal exist. diff --git a/tooling/server-repository/default.nix b/tooling/server-repository/default.nix deleted file mode 100644 index 90cd74b2..00000000 --- a/tooling/server-repository/default.nix +++ /dev/null @@ -1,21 +0,0 @@ -{ pkgs ? import {}, allowDirty ? false }: -let - release = builtins.fromJSON (builtins.readFile ./release.json); - manifest = builtins.fromJSON (builtins.readFile ./web-artifact/manifest.json); - localArchive = ./web-artifact + "/${manifest.archive.file}"; - archive = if builtins.pathExists localArchive then localArchive else pkgs.fetchurl { - inherit (manifest.archive) url sha256; - }; -in pkgs.callPackage ./nix/package.nix { - src = pkgs.lib.cleanSourceWith { - src = ./.; - filter = path: type: pkgs.lib.cleanSourceFilter path type - && !(pkgs.lib.hasPrefix (toString ./. + "/web/dist") path) - && !(pkgs.lib.hasPrefix (toString ./. + "/web-artifact") path) - && builtins.baseNameOf path != "result"; - }; - inherit (release) version vendorHash; - inherit allowDirty; - webManifest = ./web-artifact/manifest.json; - webArchive = archive; -} diff --git a/tooling/server-repository/nix/package.nix b/tooling/server-repository/nix/package.nix deleted file mode 100644 index 66e845c7..00000000 --- a/tooling/server-repository/nix/package.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ lib, buildGoModule, src, version, vendorHash, webManifest, webArchive, allowDirty ? false }: -buildGoModule { - pname = "zennotes-server"; - inherit src version vendorHash; - subPackages = [ "cmd/zennotes-server" ]; - tags = [ "embed_web" ]; - ldflags = [ "-s" "-w" ]; - postConfigure = '' - go run ./cmd/prepare-web -manifest ${webManifest} -archive ${webArchive} -output web/dist ${lib.optionalString allowDirty "-allow-dirty"} - go test -tags=embed_web ./web - ''; - meta = { - description = "A server API for hosting remote ZenNotes vaults"; - homepage = "https://zennotes.org/"; - license = lib.licenses.mit; - mainProgram = "zennotes-server"; - platforms = lib.platforms.linux ++ lib.platforms.darwin; - }; -}