Date: Sat Apr 25 20:51:37 2026 +1200
fix mobile resume startup and editor targeting
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ddda3c3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.git
+node_modules
+npm-debug.log*
+data
+.env
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..50aabe3
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,55 @@
+name: Build and push Joplock image
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/joplock
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch
+ type=sha,format=short
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dc848c5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+npm-debug.log*
+.env
+.DS_Store
+data/
+docker-compose.dev.yml
diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md
new file mode 100644
index 0000000..e3a1b3d
--- /dev/null
+++ b/AGENT_GUIDE.md
@@ -0,0 +1,382 @@
+# Joplock Agent Guide
+
+
+
+## Purpose
+
+This repo owns Joplock, standalone thin-client sidecar web UI for stock Joplin Server.
+
+Use this guide when working in this repository.
+
+## Product Direction
+
+- Joplin Server stays unmodified
+- Joplock stays separate project and separate repo
+- Reuses existing Joplin Server auth/session/user model through sidecar logic
+- Keeps compatibility with desktop/mobile/CLI clients on same server and same DB
+- Browser stays thin and untrusted
+- Shared-browser safety matters: logout should clear client-visible state/cache as much as platform allows
+- Installable PWA shell, no offline notes/editing
+- Uses same Postgres database as Joplin Server, no separate app DB
+
+## Architecture Overview
+
+### Stack
+
+- **Server**: Node.js HTTP server, no framework
+- **Client**: SSR HTML + htmx fragment swaps
+- **Editor**: CodeMirror 6 for markdown editing, contenteditable rendered markdown preview mode
+- **Code blocks**: Full-screen code modal with CM6 editor and language picker; highlight.js for preview mode syntax highlighting
+- **Autosave**: htmx delayed PUT after typing pause (deferred while modals are open)
+- **Markdown**: server-side `renderMarkdown()`, client-side Turndown `htmlToMarkdown()`
+- **Auth**: reuses Joplin Server `sessionId` cookie
+- **DB access**: reads direct from shared Postgres; writes go through stock Joplin Server API
+
+### Runtime Shape
+
+- Initial page load is full SSR HTML from `layoutPage()` in `app/templates.js`
+- After load, most interactions are fragment-driven via htmx
+- The browser is intentionally thin: most state is DOM state, form state, or small client-only UI state in inline JS
+- There is no frontend router and no SPA store
+- Desktop and mobile share the same server routes and most of the same editor code; mobile is a different screen shell around the same editor fragment
+
+### Request Flow
+
+1. Browser hits Joplock
+2. Joplock validates `sessionId` against Joplin session/user tables
+3. Fragment endpoints return HTML chunks; htmx swaps DOM
+4. Writes serialize note/folder/resource and send upstream to stock Joplin Server API
+
+### Main UI Flow
+
+1. `GET /` renders the full shell
+2. Navigation / notes / editor content is loaded from fragment endpoints
+3. Selecting a folder swaps the notes list or nav tree fragment
+4. Selecting a note swaps in `editorFragment()`
+5. Autosave sends `PUT /fragments/editor/:id` with the current form state
+6. Preview rendering uses `POST /fragments/preview`
+
+### Fragment Conventions
+
+- `templates.js` returns raw HTML strings, not JSX/templates/components
+- htmx targets are mostly `#nav-panel`, `#notelist-panel`, `#editor-panel`, and mobile-specific targets like `#mobile-editor-body`
+- Out-of-band swaps are used sparingly; note metadata is one example
+- Client logic often relies on stable IDs, so be careful renaming DOM IDs used by inline JS
+
+## Core Rules
+
+1. Do not modify Joplin Server source for Joplock features unless explicitly approved.
+2. Server authoritative. Browser ephemeral.
+3. Preserve sync compatibility with normal Joplin clients.
+4. Do not build browser-local authoritative storage.
+5. Keep sidecar API app-oriented. Do not expose raw sync/storage model to frontend.
+6. Treat logout as client cleanup event on shared machines.
+
+## Service Responsibilities
+
+### Stock Joplin Server
+
+Owns:
+- login/session/auth source of truth
+- sync endpoints
+- canonical storage rules
+- existing user/session tables
+
+### Joplock
+
+Owns:
+- thin-client UI
+- sidecar API endpoints
+- session validation against shared DB
+- markdown rendering and editor behavior
+- resource upload/serving
+- app-specific settings in `joplock_settings`
+- PWA shell/assets
+
+Does not own:
+- canonical note/folder/resource persistence rules
+- sync protocol semantics
+- auth/session source of truth
+- offline-first storage
+
+## File Map
+
+### Entry / Server
+- `server.js` — entry point, env wiring, server startup
+- `app/createServer.js` — all HTTP routes, SSR, preview, upload, resources, auth pages
+
+### Templates / UI
+- `app/templates.js` — SSR HTML, inline client JS, markdown rendering, editor mode logic
+
+Important subareas inside `templates.js`:
+- `settingsPage()` — Settings UI and simple client save helpers
+- `editorFragment()` — shared editor DOM used by desktop and mobile
+- `layoutPage()` — logged-in app shell, inline client JS, mobile shell, autosave/editor wiring
+- `renderMarkdown()` — server-side markdown-to-HTML for preview/render mode
+- mobile fragments and shell logic — folder-first mobile UI, note list, search, editor screen stack
+
+### Auth
+- `app/auth/cookies.js` — cookie parsing
+- `app/auth/sessionService.js` — shared DB session lookup
+- `app/auth/mfaService.js` — env-driven TOTP verification and otpauth/QR generation
+
+### Data
+- `app/items/itemService.js` — DB reads for folders, notes, search, resources
+- `app/items/itemWriteService.js` — note/folder/resource serialization and upstream writes
+- `app/settingsService.js` — Joplock-owned settings table access
+
+### How Reads vs Writes Work
+
+- Reads come from the shared Postgres DB for speed and to match the current server state
+- Writes do not write directly to Joplin tables; they go through stock Joplin Server APIs
+- That split is intentional: Joplock can stay lightweight while preserving compatibility with normal Joplin clients
+- If behavior looks inconsistent after a write, inspect both the sidecar request path and the upstream Joplin API call path
+
+### Static Assets
+- `public/htmx.min.js`
+- `public/codemirror.min.js` — CM6 bundle with 11 language parsers (built from `cm-build/`)
+- `public/hljs.min.js` — highlight.js bundle for preview mode code highlighting (built from `hljs-build/`)
+- `public/styles.css`
+- `public/service-worker.js`
+- `public/manifest.webmanifest`
+
+### Bundle Build Sources
+- `cm-build/` — CM6 bundle source (`npm install && npm run build` → `public/codemirror.min.js`)
+- `hljs-build/` — highlight.js bundle source (`npm install && npm run build` → `public/hljs.min.js`)
+
+### Tests
+- `tests/*.test.js`
+- Run: `node --test tests/**/*.test.js`
+
+### Deployment
+- `Dockerfile`
+- `docker-compose.yml` — sidecar-only example
+- `docker-compose.example-full.yml` — Postgres + Joplin Server + Joplock example
+- `.env.example`
+
+## MFA Notes
+
+- MFA is per-user, managed via Settings → Security → Two-Factor Authentication.
+- Each user's TOTP seed is stored in `joplock_settings.totp_seed` in the shared Postgres DB.
+- No global/shared TOTP seed. The old `JOPLOCK_TOTP_SEED` / `JOPLOCK_TOTP_ISSUER` env vars are removed.
+- `IGNORE_ADMIN_MFA=true` skips the per-user MFA check at login for the docker-defined admin account (`JOPLOCK_ADMIN_EMAIL`). Other users are unaffected.
+- Admin can force-enable/disable MFA for any user via the Admin tab (no code required).
+
+## Design Decisions
+
+### Separate repo
+Joplock lives outside Joplin monorepo. Keep standalone build, test, docs, Docker flow working without Joplin source tree.
+
+### Shared Postgres database
+Joplock reads same Postgres database as Joplin Server. No data duplication. Writes still go through Joplin Server API for compatibility and validation.
+
+### Configurable open mode
+Notes can open in rendered mode or markdown mode based on the per-user `noteOpenMode` setting. Desktop and mobile both respect the same setting.
+
+### Shared editor fragment
+Desktop and mobile do not have separate editor implementations. Both use the same `editorFragment()` and client editor logic; mobile wraps it in a mobile-specific shell and screen navigation layer.
+
+### PWA shell
+Cache shell/static assets only. Do not cache note/resource/API responses in ways that break shared-browser safety.
+
+### Mobile-first navigation without SPA rewrite
+Mobile uses a folders screen, notes screen, and editor screen implemented in SSR + htmx + inline JS. Do not introduce a client router or framework state layer to solve mobile flow problems.
+
+### Tablet behavior
+Tablet still uses the mobile shell in the current responsive design. Mobile/tablet editor behavior should be reasoned about by editor container context, not just viewport width.
+
+## Editor Model
+
+### Two modes
+
+- **Markdown mode**: CodeMirror 6 is visible, textarea is sync target
+- **Rendered mode**: contenteditable preview is visible, Turndown converts edited HTML back to markdown
+
+### Source of truth during editing
+
+- The hidden textarea `#note-body` is the form field used for saves
+- In markdown mode, CodeMirror changes sync into `#note-body`
+- In rendered mode, preview DOM changes sync back into markdown via `htmlToMarkdown()`
+- The title is mirrored between `.editor-title`, hidden title input, and mobile title header when applicable
+
+### Save lifecycle
+
+- `markEdited()` updates UI state to `Edited`
+- `scheduleSave()` triggers delayed autosave for body/form changes
+- `scheduleSaveTitle()` is a shorter timer for title changes
+- `htmx:afterRequest` on the editor save path transitions UI state back to `Saved`
+- Offline/request failure paths set status to `Offline`
+
+### Important fragility points
+
+- DOM IDs and class names are part of the editor contract with inline JS
+- Preview HTML must remain convertible back to markdown with acceptable fidelity
+- Checkbox, code block, and blank-line handling are easy to regress
+- The code modal is outside the fragment-swapped editor so it survives swaps
+
+## Mobile UI Model
+
+### Shell structure
+
+- `#mobile-folders-screen`
+- `#mobile-notes-screen`
+- `#mobile-editor-screen`
+
+These screens are shown/hidden by inline JS in `layoutPage()` using class changes, not route changes.
+
+### Mobile navigation behavior
+
+- Folder-first flow: folders -> notes -> editor
+- Search has its own mobile header state
+- Mobile note creation uses dedicated fragment endpoints and server headers to drive the next UI step
+- The floating action button is only a mobile affordance; desktop should stay unaffected
+
+### Mobile editor behavior
+
+- Mobile hides the desktop title bar and uses the mobile header instead
+- Mobile header mirrors note title and save state
+- Mode buttons should remain visible and clearly indicate the active mode
+- Toolbar visibility should be keyed to being inside the mobile editor container, not only screen width
+- Newly-created empty mobile notes may be discarded on back if still blank/untitled
+
+### Tablet expectations
+
+- Tablet is still in the mobile shell range
+- Existing note open path and new note open path should behave the same with respect to default open mode, toolbar visibility, and title/save-state UI
+- When debugging tablet issues, compare the exact htmx target and after-settle path used by new-note vs existing-note opens
+
+## Settings Model
+
+### Storage
+
+- Settings are stored per-user in `joplock_settings.settings` as JSONB
+- `app/settingsService.js` owns defaults and normalization
+- Unknown or invalid values should normalize back to safe defaults
+
+### Current notable settings
+
+- `theme`
+- `noteFontSize`
+- `mobileNoteFontSize`
+- `codeFontSize`
+- `noteMonospace`
+- `noteOpenMode`
+- `dateFormat`
+- `datetimeFormat`
+- `liveSearch`
+- `autoLogout`
+- `autoLogoutMinutes`
+
+### Adding a new setting
+
+1. Add default + normalization in `app/settingsService.js`
+2. Allow the key in `/api/web/settings` in `app/createServer.js`
+3. Add the UI in `settingsPage()` in `app/templates.js`
+4. If needed, inject the normalized setting into `layoutPage()` client JS
+5. Rebuild with `./scripts/rebuild-dev.sh`
+
+## Route Notes
+
+Useful route groups in `app/createServer.js`:
+
+- auth pages and login/logout
+- full page render for `/`
+- fragment routes for nav, notes, editor, preview
+- mobile fragment routes for folders, notes, search, mobile note creation
+- resource upload and resource serving
+- settings save endpoints
+- history endpoints
+
+If a UI action appears broken, check:
+1. Which endpoint it hits
+2. Which htmx target it swaps
+3. Which client event handler expects to run after swap/request
+4. Whether the response includes headers or OOB fragments the client depends on
+
+## Coding Guidance
+
+- Keep changes minimal
+- Preserve sidecar/frontend boundary
+- Inline JS in `templates.js` is fragile; validate escaping-heavy changes carefully
+- The code modal lives in `loggedInLayout`, not inside `navigationFragment` or `editorFragment`, so it survives htmx OOB swaps
+- Be careful with checkbox text handling, `\n`, regex escaping, and DOM-to-markdown round trips
+- Keep standalone repo paths/docs/scripts correct; avoid reintroducing monorepo assumptions
+- Prefer changing existing inline helpers over introducing a new abstraction unless there is clear reuse
+- When fixing mobile behavior, verify desktop is unchanged
+- When fixing desktop editor behavior, verify mobile still works because both use the same editor fragment
+- Be cautious with `htmx:afterRequest` assumptions; in htmx 2.x, response headers are often more reliable than old event-property assumptions
+
+## Debugging Guidance
+
+### If a code change does not appear in the app
+
+- Rebuild with `./scripts/rebuild-dev.sh`
+- Do not rely on `docker compose ... restart joplock` after source edits
+- If still stale, inspect the built container logs and confirm the right compose stack is running
+
+### If mobile note creation/opening misbehaves
+
+- Check whether the server response includes the expected mobile header such as `X-Mobile-Note-Id`
+- Check the `htmx:afterRequest` handler that consumes that header
+- Compare new-note path vs existing-note path
+
+### If toolbar/mode behavior is inconsistent
+
+- Verify whether the current editor is actually inside `#mobile-editor-body`
+- Check `syncEditorModeButtons()` and `setEditorMode()`
+- Check whether the note was initialized with the expected `noteOpenMode`
+
+### If title UI drifts
+
+- Check `.editor-title`
+- Check hidden input `.editor-title-hidden`
+- Check `#mobile-editor-title`
+- Check `autoTitle()` and `syncTitle()`
+
+### If save-state UI drifts
+
+- Check `setSaveState()`
+- Check `#autosave-status`
+- Check `#mobile-editor-status`
+- Check htmx save success/failure handlers and upload progress handlers
+
+## Verification
+
+- Run tests: `npm test`
+- Build image: `npm run docker:build`
+- Sidecar-only compose: `npm run docker:up`
+- Full example compose: `npm run docker:up:full`
+
+## Development Stack
+
+Use the dev compose stack for all development work. It includes Postgres, Joplin Server, and Joplock together.
+
+- Rebuild Joplock app container after code changes: `./scripts/rebuild-dev.sh`
+- Start / restart full dev stack: `docker compose -f docker-compose.dev.yml up -d --build`
+- Stop dev stack: `docker compose -f docker-compose.dev.yml down`
+
+Do not use the sidecar-only `docker-compose.yml` for development.
+
+Important:
+- `docker compose ... restart joplock` is not enough after source edits because the Docker image copies `app/`, `public/`, and `server.js` at build time.
+- For app code changes, use `./scripts/rebuild-dev.sh` from now on.
+
+Recommended inner loop:
+
+1. Edit source
+2. Rebuild with `./scripts/rebuild-dev.sh`
+3. Refresh the app
+4. Check `docker compose -f docker-compose.dev.yml logs --tail=... joplock` if something looks wrong
+
+## Reference Material
+
+- Mobile UX reference: `~/dev/joplin/packages/app-mobile/`
+- Use it for interaction ideas and behavior parity targets, not as a copy-paste implementation source
+- Joplock must still fit the SSR + htmx sidecar architecture
+
+## Current Baseline
+
+- standalone repo at `abort-retry-ignore/joplock`
+- tests passing in standalone repo
+- Docker build passing in standalone repo
+- full example compose verified with alternate free host ports
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2d67f61
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package.json ./package.json
+COPY package-lock.json ./package-lock.json
+RUN npm install --omit=dev
+
+COPY app ./app
+COPY server.js ./server.js
+COPY public ./public
+
+EXPOSE 3001
+
+CMD ["node", "server.js"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..075061d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# Joplock
+
+A secure, fast web client for [Joplin Server](https://github.com/laurent22/joplin).
+
+Joplock runs as a sidecar alongside an unmodified Joplin Server instance, sharing the same Postgres database, sessions, notes, folders, and resources. It gives you a lightweight browser-based interface to your Joplin notes without modifying Joplin Server itself. Keep using the other Joplin clients too, this won't interfere.
+
+### Key Features
+
+- **Full Joplin compatibility** -- desktop, mobile, CLI, and Joplock all work with the same account and data simultaneously
+- **Low Resource usage** -- minimal memory usage on the client, fast and responsive
+- **Security-first design** -- no private data stored on the client; sessions are cleaned up on logout; per-user settings and admin controls for user management
+- **User creation from Joplock UI** -- create and modify users directly from Joplock settings page
+- **Multi-factor authentication** -- optional TOTP-based MFA on top of standard Joplin sessions
+- **Fast search** -- searches titles and note bodies directly in Postgres; optional live-as-you-type search
+- **Near-instant autosave** -- debounced saves with conflict detection, hash-based deduplication, and an undo ring buffer with full note history snapshots
+- **PWA support** -- installable as a home screen app on mobile and desktop with splash screens, offline indicator, and service worker shell
+- **Server-side rendering** -- SSR with htmx for minimal client-side JavaScript; CodeMirror editor for markdown, rich preview mode with WYSIWYG editing
+
+## Runtime Model
+
+Joplock:
+- reads Joplin data directly from the shared Postgres database
+- validates the same `sessionId` cookie used by Joplin Server
+- writes notes, folders, and resources through stock Joplin Server APIs
+
+That keeps desktop, mobile, CLI, and Joplock compatible with the same account and data.
+
+## Requirements
+
+- docker
+- an existng Joplin Server instance, or run the fullstack option
+
+## Environment
+
+All configuration is done directly in the compose files via inline environment variables with comments. No `.env` file is needed -- just edit the values in `docker-compose.yml` or `docker-compose.example-full.yml` before starting.
+
+## Docker
+
+Published container image:
+- `ghcr.io/abort-retry-ignore/joplock:latest`
+
+### Sidecar Install
+
+Use this when you already have Joplin Server and Postgres running elsewhere. Edit the environment values in `docker-compose.yml` to point at your existing setup, or copy into your existing compose. Then:
+
+```bash
+docker compose up -d
+```
+
+This pulls the pre-built image from GitHub Container Registry. To build from source instead:
+
+```bash
+docker compose -f docker-compose-build.yml up -d --build
+```
+
+On Linux, the compose files map `host.docker.internal` to the host gateway so Joplock can reach host services by default.
+
+### Full Example Stack
+
+Use this as a reference/demo stack with Postgres, Joplin Server, and Joplock together. Edit the values in `docker-compose.example-full.yml` as needed, then:
+
+```bash
+docker compose -f docker-compose.example-full.yml up -d
+```
+
+The full example uses the public `joplin/server:latest` image. Joplock is exposed on `http://localhost:5444` by default. Joplin Server is internal-only unless you add a port mapping.
+
+The full example is meant as a working reference compose file. Adjust it for your real deployment.
+
diff --git a/app/adminService.js b/app/adminService.js
new file mode 100644
index 0000000..2c852b3
--- /dev/null
+++ b/app/adminService.js
@@ -0,0 +1,266 @@
+'use strict';
+
+const http = require('http');
+const bcrypt = require('bcryptjs');
+
+// ---------- password strength ----------
+const isStrongPassword = password => {
+ if (!password || password.length < 12) return false;
+ const hasLower = /[a-z]/.test(password);
+ const hasUpper = /[A-Z]/.test(password);
+ const hasDigit = /[0-9]/.test(password);
+ const hasSpecial = /[^a-zA-Z0-9]/.test(password);
+ const categories = [hasLower, hasUpper, hasDigit, hasSpecial].filter(Boolean).length;
+ return categories >= 3;
+};
+
+// ---------- HTTP helper to Joplin internal API ----------
+const requestJoplin = (origin, method, path, body, sessionId, publicUrl) => new Promise((resolve, reject) => {
+ const url = new URL(origin);
+ const pub = publicUrl ? new URL(publicUrl) : url;
+ const payload = body ? JSON.stringify(body) : null;
+ const headers = {
+ 'Content-Type': 'application/json',
+ Host: pub.host,
+ Origin: pub.origin,
+ 'X-Forwarded-Host': pub.host,
+ 'X-Forwarded-Proto': pub.protocol.replace(':', ''),
+ };
+ if (sessionId) headers['x-api-auth'] = sessionId;
+ if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
+
+ const req = http.request({
+ hostname: url.hostname,
+ port: url.port,
+ path,
+ method,
+ headers,
+ }, res => {
+ const chunks = [];
+ res.on('data', c => chunks.push(c));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ let json = null;
+ try { json = JSON.parse(text); } catch {}
+ resolve({ statusCode: res.statusCode, body: json, raw: text });
+ });
+ });
+ req.on('error', reject);
+ if (payload) req.write(payload);
+ req.end();
+});
+
+const createAdminService = ({ database, joplinServerOrigin, joplinServerPublicUrl, adminEmail, adminPassword }) => {
+ // token cache
+ let _token = null;
+ let _tokenExpiry = 0;
+ const TOKEN_TTL_MS = 11 * 60 * 60 * 1000; // 11h
+
+ const getAdminToken = async () => {
+ if (_token && Date.now() < _tokenExpiry) return _token;
+ const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', {
+ email: adminEmail,
+ password: adminPassword,
+ }, null, joplinServerPublicUrl);
+ if (!res.body || !res.body.id) {
+ throw new Error(`Admin login failed (${res.statusCode}): ${res.raw}`);
+ }
+ _token = res.body.id;
+ _tokenExpiry = Date.now() + TOKEN_TTL_MS;
+ return _token;
+ };
+
+ // ---------- Bootstrap: ensure admin user exists ----------
+ const ensureAdminUser = async () => {
+ // Validate password strength first
+ if (!isStrongPassword(adminPassword)) {
+ process.stderr.write('[joplock] FATAL: JOPLOCK_ADMIN_PASSWORD is too weak. Must be ≥12 chars with letters, numbers, and at least 3 character categories.\n');
+ process.exit(1);
+ }
+
+ // Check if admin user exists in Joplin DB
+ let rows;
+ try {
+ const result = await database.query(
+ 'SELECT id, email, is_admin, enabled FROM users WHERE email = $1 LIMIT 1',
+ [adminEmail],
+ );
+ rows = result.rows;
+ } catch (err) {
+ process.stderr.write(`[joplock] WARNING: Could not query users table for admin bootstrap: ${err.message}\n`);
+ return;
+ }
+
+ if (rows.length === 0) {
+ // Fresh install — create user directly in DB
+ process.stdout.write(`[joplock] Creating admin user ${adminEmail}...\n`);
+ const passwordHash = await bcrypt.hash(adminPassword, 10);
+ const now = Date.now();
+ const id = require('crypto').randomBytes(16).toString('hex');
+ try {
+ await database.query(`
+ INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time, must_set_password, account_type, max_item_size, can_share_folder, can_share_note, max_total_item_size)
+ VALUES ($1, $2, $3, 1, 1, 1, $4, $4, 0, 0, 0, 1, 1, 0)
+ `, [id, adminEmail, passwordHash, now]);
+ process.stdout.write(`[joplock] Admin user created.\n`);
+ } catch (err) {
+ // Fallback: minimal insert if some columns don't exist
+ try {
+ await database.query(`
+ INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time)
+ VALUES ($1, $2, $3, 1, 1, 1, $4, $4)
+ `, [id, adminEmail, passwordHash, now]);
+ process.stdout.write(`[joplock] Admin user created (minimal).\n`);
+ } catch (err2) {
+ process.stderr.write(`[joplock] ERROR: Could not create admin user: ${err2.message}\n`);
+ }
+ }
+ } else {
+ const user = rows[0];
+ const needsFix = !Number(user.is_admin) || !Number(user.enabled);
+ // Always reset password to match docker-defined JOPLOCK_ADMIN_PASSWORD
+ const passwordHash = await bcrypt.hash(adminPassword, 10);
+ const now = Date.now();
+ try {
+ await database.query(
+ 'UPDATE users SET password=$1, is_admin=1, enabled=1, updated_time=$2 WHERE email=$3',
+ [passwordHash, now, adminEmail],
+ );
+ if (needsFix) {
+ process.stdout.write(`[joplock] Admin user fixed (is_admin/enabled) and password reset.\n`);
+ }
+ } catch (err) {
+ process.stderr.write(`[joplock] WARNING: Could not reset admin password: ${err.message}\n`);
+ }
+ }
+ };
+
+ // ---------- User operations ----------
+
+ const listUsers = async () => {
+ const token = await getAdminToken();
+ const res = await requestJoplin(joplinServerOrigin, 'GET', '/api/users', null, token, joplinServerPublicUrl);
+ if (!res.body || !res.body.items) throw Object.assign(new Error('Failed to list users'), { statusCode: res.statusCode });
+
+ // Augment with enabled flag from DB
+ const apiUsers = res.body.items;
+ const ids = apiUsers.map(u => u.id);
+ let enabledMap = {};
+ if (ids.length) {
+ try {
+ const result = await database.query(
+ `SELECT id, enabled FROM users WHERE id = ANY($1)`,
+ [ids],
+ );
+ for (const row of result.rows) {
+ enabledMap[row.id] = !!Number(row.enabled);
+ }
+ } catch {}
+ }
+ return apiUsers.map(u => ({ ...u, enabled: enabledMap[u.id] !== undefined ? enabledMap[u.id] : true }));
+ };
+
+ const createUser = async (email, fullName, password) => {
+ const token = await getAdminToken();
+ // Step 1: create user (Joplin sets a random password + must_set_password=1)
+ const createRes = await requestJoplin(joplinServerOrigin, 'POST', '/api/users', { email, full_name: fullName }, token, joplinServerPublicUrl);
+ if (!createRes.body || !createRes.body.id) {
+ throw Object.assign(new Error(createRes.body && createRes.body.error ? createRes.body.error : 'Create user failed'), { statusCode: createRes.statusCode });
+ }
+ const userId = createRes.body.id;
+ // Step 2: set real password + clear must_set_password
+ const passwordRes = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
+ password,
+ must_set_password: 0,
+ }, token, joplinServerPublicUrl);
+ if (passwordRes.statusCode >= 400) {
+ throw Object.assign(new Error(passwordRes.body && passwordRes.body.error ? passwordRes.body.error : 'Set password failed'), { statusCode: passwordRes.statusCode });
+ }
+ // Step 3: confirm email via direct DB write
+ try {
+ await database.query('UPDATE users SET email_confirmed=1, enabled=1 WHERE id=$1', [userId]);
+ } catch {}
+ return { id: userId, email, full_name: fullName };
+ };
+
+ const resetPassword = async (userId, newPassword) => {
+ const token = await getAdminToken();
+ const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
+ password: newPassword,
+ must_set_password: 0,
+ }, token, joplinServerPublicUrl);
+ if (res.statusCode >= 400) {
+ throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Reset password failed'), { statusCode: res.statusCode });
+ }
+ };
+
+ const setEnabled = async (userId, enabled) => {
+ await database.query('UPDATE users SET enabled=$1 WHERE id=$2', [enabled ? 1 : 0, userId]);
+ };
+
+ const deleteUser = async (userId) => {
+ // Disable first, then schedule deletion via user_deletions table
+ await setEnabled(userId, false);
+ const now = Date.now();
+ try {
+ await database.query(`
+ INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
+ VALUES ($1, $2, 1, 1, $3)
+ ON CONFLICT DO NOTHING
+ `, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
+ } catch (err) {
+ // Try without ON CONFLICT if old schema
+ try {
+ await database.query(`
+ INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
+ VALUES ($1, $2, 1, 1, $3)
+ `, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
+ } catch (err2) {
+ process.stderr.write(`[joplock] WARNING: Could not insert user_deletions: ${err2.message}\n`);
+ }
+ }
+ };
+
+ const updateProfile = async (sessionToken, userId, { fullName, email }) => {
+ const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
+ ...(fullName !== undefined ? { full_name: fullName } : {}),
+ ...(email !== undefined ? { email } : {}),
+ }, sessionToken, joplinServerPublicUrl);
+ if (res.statusCode >= 400) {
+ throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Update profile failed'), { statusCode: res.statusCode });
+ }
+ return res.body;
+ };
+
+ const verifyPassword = async (email, password) => {
+ const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', { email, password }, null, joplinServerPublicUrl);
+ if (res.statusCode < 200 || res.statusCode >= 300) return null;
+ return res.body && res.body.id ? res.body.id : null;
+ };
+
+ const changePassword = async (sessionToken, userId, newPassword) => {
+ const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
+ password: newPassword,
+ must_set_password: 0,
+ }, sessionToken, joplinServerPublicUrl);
+ if (res.statusCode >= 400) {
+ throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Change password failed'), { statusCode: res.statusCode });
+ }
+ };
+
+ return {
+ ensureAdminUser,
+ getAdminToken,
+ listUsers,
+ createUser,
+ resetPassword,
+ setEnabled,
+ deleteUser,
+ updateProfile,
+ verifyPassword,
+ changePassword,
+ adminEmail,
+ };
+};
+
+module.exports = { createAdminService, isStrongPassword };
diff --git a/app/auth/cookies.js b/app/auth/cookies.js
new file mode 100644
index 0000000..d2101db
--- /dev/null
+++ b/app/auth/cookies.js
@@ -0,0 +1,27 @@
+const parseCookies = cookieHeader => {
+ if (!cookieHeader) return {};
+
+ const output = {};
+ for (const part of cookieHeader.split(';')) {
+ const trimmed = part.trim();
+ if (!trimmed) continue;
+ const separatorIndex = trimmed.indexOf('=');
+ if (separatorIndex < 0) continue;
+ const key = trimmed.slice(0, separatorIndex).trim();
+ const value = trimmed.slice(separatorIndex + 1).trim();
+ if (!key) continue;
+ output[key] = decodeURIComponent(value);
+ }
+
+ return output;
+};
+
+const sessionIdFromHeaders = (headers, cookieName = 'sessionId') => {
+ const cookies = parseCookies(headers.cookie || '');
+ return cookies[cookieName] || '';
+};
+
+module.exports = {
+ parseCookies,
+ sessionIdFromHeaders,
+};
diff --git a/app/auth/mfaService.js b/app/auth/mfaService.js
new file mode 100644
index 0000000..60b4b20
--- /dev/null
+++ b/app/auth/mfaService.js
@@ -0,0 +1,150 @@
+const crypto = require('crypto');
+const qrImage = require('qr-image');
+
+const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+
+const normalizeSeed = seed => `${seed || ''}`.trim().toUpperCase().replace(/\s+/g, '');
+
+const base32Decode = value => {
+ const normalized = normalizeSeed(value).replace(/=+$/g, '');
+ let bits = '';
+ for (const char of normalized) {
+ const index = base32Alphabet.indexOf(char);
+ if (index < 0) throw new Error('Invalid TOTP seed');
+ bits += index.toString(2).padStart(5, '0');
+ }
+ const bytes = [];
+ for (let i = 0; i + 8 <= bits.length; i += 8) {
+ bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
+ }
+ return Buffer.from(bytes);
+};
+
+const base32Encode = buffer => {
+ let bits = '';
+ for (const byte of buffer) bits += byte.toString(2).padStart(8, '0');
+ let result = '';
+ for (let i = 0; i < bits.length; i += 5) {
+ const chunk = bits.slice(i, i + 5).padEnd(5, '0');
+ result += base32Alphabet[Number.parseInt(chunk, 2)];
+ }
+ return result;
+};
+
+const generateSeed = () => {
+ // 20 bytes = 160 bits, standard for TOTP
+ const bytes = crypto.randomBytes(20);
+ return base32Encode(bytes);
+};
+
+const hotp = (secret, counter) => {
+ const buf = Buffer.alloc(8);
+ buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
+ buf.writeUInt32BE(counter % 0x100000000, 4);
+ const digest = crypto.createHmac('sha1', secret).update(buf).digest();
+ const offset = digest[digest.length - 1] & 0x0f;
+ const binary = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
+ return `${binary % 1000000}`.padStart(6, '0');
+};
+
+// Verify TOTP code against arbitrary seed
+const verifyWithSeed = (seed, code, now = Date.now()) => {
+ if (!seed) return false;
+ const token = `${code || ''}`.replace(/\s+/g, '');
+ if (!/^\d{6}$/.test(token)) return false;
+ try {
+ const secret = base32Decode(seed);
+ const counter = Math.floor(now / 30000);
+ for (let offset = -1; offset <= 1; offset++) {
+ if (hotp(secret, counter + offset) === token) return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+};
+
+// Generate otpauth URI for arbitrary seed
+const otpauthUri = (seed, accountLabel, issuer = 'Joplock') => {
+ if (!seed) return '';
+ const normalizedSeed = normalizeSeed(seed);
+ const label = encodeURIComponent(`${issuer}:${accountLabel}`);
+ return `otpauth://totp/${label}?secret=${normalizedSeed}&issuer=${encodeURIComponent(issuer)}`;
+};
+
+// Generate QR code as SVG data URL (synchronous for template use)
+let _qrCache = new Map();
+const qrCodeDataUrl = (text) => {
+ if (!text) return '';
+ if (_qrCache.has(text)) return _qrCache.get(text);
+ let svg = '';
+ try {
+ svg = qrImage.imageSync(text, { type: 'svg', margin: 2 });
+ } catch {
+ return '';
+ }
+ const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+ _qrCache.set(text, dataUrl);
+ // Limit cache size
+ if (_qrCache.size > 100) {
+ const first = _qrCache.keys().next().value;
+ _qrCache.delete(first);
+ }
+ return dataUrl;
+};
+
+const createMfaService = options => {
+ const seed = normalizeSeed(options.seed);
+ const issuer = options.issuer || 'Joplock';
+ const enabled = !!seed;
+ const secret = enabled ? base32Decode(seed) : null;
+
+ return {
+ enabled() {
+ return enabled;
+ },
+
+ issuer() {
+ return issuer;
+ },
+
+ otpauthUri(accountLabel) {
+ if (!enabled) return '';
+ const label = encodeURIComponent(`${issuer}:${accountLabel}`);
+ return `otpauth://totp/${label}?secret=${seed}&issuer=${encodeURIComponent(issuer)}`;
+ },
+
+ verify(code, now = Date.now()) {
+ if (!enabled) return true;
+ const token = `${code || ''}`.replace(/\s+/g, '');
+ if (!/^\d{6}$/.test(token)) return false;
+ const counter = Math.floor(now / 30000);
+ for (let offset = -1; offset <= 1; offset++) {
+ if (hotp(secret, counter + offset) === token) return true;
+ }
+ return false;
+ },
+
+ maskedSeed() {
+ if (!enabled) return '';
+ return seed;
+ },
+
+ qrDataUrl(accountLabel) {
+ if (!enabled) return '';
+ return qrCodeDataUrl(this.otpauthUri(accountLabel));
+ },
+ };
+};
+
+module.exports = {
+ base32Decode,
+ base32Encode,
+ createMfaService,
+ generateSeed,
+ hotp,
+ normalizeSeed,
+ otpauthUri,
+ qrCodeDataUrl,
+ verifyWithSeed,
+};
diff --git a/app/auth/sessionService.js b/app/auth/sessionService.js
new file mode 100644
index 0000000..99a2aaf
--- /dev/null
+++ b/app/auth/sessionService.js
@@ -0,0 +1,73 @@
+const { Pool } = require('pg');
+
+const defaultSessionTtlMs = 12 * 60 * 60 * 1000;
+
+const createPoolFromEnv = env => {
+ return new Pool({
+ host: env.POSTGRES_HOST || '127.0.0.1',
+ port: Number(env.POSTGRES_PORT || '5432'),
+ user: env.POSTGRES_USER || 'joplin',
+ password: env.POSTGRES_PASSWORD || 'joplin',
+ database: env.POSTGRES_DATABASE || 'joplin',
+ });
+};
+
+const isSessionExpired = (createdTime, now = Date.now()) => {
+ const numericCreatedTime = Number(createdTime || 0);
+ if (!numericCreatedTime) return true;
+ return now - numericCreatedTime >= defaultSessionTtlMs;
+};
+
+const createSessionService = database => {
+ return {
+ async userBySessionId(sessionId) {
+ if (!sessionId) return null;
+
+ const result = await database.query(`
+ SELECT
+ s.id AS session_id,
+ s.user_id,
+ s.created_time AS session_created_time,
+ u.id,
+ u.email,
+ u.full_name,
+ u.is_admin,
+ u.can_upload,
+ u.email_confirmed,
+ u.account_type,
+ u.created_time,
+ u.updated_time,
+ u.enabled
+ FROM sessions s
+ INNER JOIN users u ON u.id = s.user_id
+ WHERE s.id = $1
+ LIMIT 1
+ `, [sessionId]);
+
+ const row = result.rows[0];
+ if (!row) return null;
+ if (isSessionExpired(row.session_created_time)) return null;
+ if (!Number(row.enabled)) return null;
+
+ return {
+ id: row.id,
+ sessionId: row.session_id,
+ email: row.email,
+ fullName: row.full_name,
+ isAdmin: !!Number(row.is_admin),
+ canUpload: !!Number(row.can_upload),
+ emailConfirmed: !!Number(row.email_confirmed),
+ accountType: Number(row.account_type || 0),
+ createdTime: Number(row.created_time || 0),
+ updatedTime: Number(row.updated_time || 0),
+ };
+ },
+ };
+};
+
+module.exports = {
+ createPoolFromEnv,
+ createSessionService,
+ defaultSessionTtlMs,
+ isSessionExpired,
+};
diff --git a/app/createServer.js b/app/createServer.js
new file mode 100644
index 0000000..df15ecc
--- /dev/null
+++ b/app/createServer.js
@@ -0,0 +1,1756 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { sessionIdFromHeaders } = require('./auth/cookies');
+const { generateSeed, otpauthUri, qrCodeDataUrl, verifyWithSeed } = require('./auth/mfaService');
+const templates = require('./templates');
+
+const contentTypes = {
+ '.css': 'text/css; charset=utf-8',
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
+ '.woff2': 'font/woff2',
+};
+
+const fileExists = filePath => {
+ try {
+ return fs.statSync(filePath).isFile();
+ } catch (error) {
+ return false;
+ }
+};
+
+const send = (response, statusCode, body, headers = {}) => {
+ response.writeHead(statusCode, headers);
+ response.end(body);
+};
+
+const sendHtml = (response, statusCode, html) => {
+ send(response, statusCode, html, {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'text/html; charset=utf-8',
+ });
+};
+
+const sendJson = (response, statusCode, body) => {
+ send(response, statusCode, JSON.stringify(body), {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/json; charset=utf-8',
+ });
+};
+
+const expiredSessionCookie = () => 'sessionId=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
+
+const readBody = request => {
+ return new Promise((resolve, reject) => {
+ let body = '';
+ request.setEncoding('utf8');
+ request.on('data', chunk => {
+ body += chunk;
+ });
+ request.on('end', () => resolve(body));
+ request.on('error', reject);
+ });
+};
+
+const readRawBody = request => {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ request.on('data', chunk => chunks.push(chunk));
+ request.on('end', () => resolve(Buffer.concat(chunks)));
+ request.on('error', reject);
+ });
+};
+
+// Minimal multipart parser — extracts the first file field
+const parseMultipart = (buffer, contentType) => {
+ const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
+ if (!match) return null;
+ const boundary = match[1] || match[2];
+ const boundaryBuf = Buffer.from(`--${boundary}`);
+
+ // Find first occurrence after the boundary
+ let start = buffer.indexOf(boundaryBuf);
+ if (start === -1) return null;
+ start += boundaryBuf.length;
+
+ // Find the header/body separator (\r\n\r\n)
+ const headerEnd = buffer.indexOf('\r\n\r\n', start);
+ if (headerEnd === -1) return null;
+ const headerStr = buffer.slice(start, headerEnd).toString('utf8');
+
+ // Extract filename and content-type from headers
+ const fnMatch = headerStr.match(/filename="([^"]+)"/);
+ const ctMatch = headerStr.match(/Content-Type:\s*(.+)/i);
+ const filename = fnMatch ? fnMatch[1] : 'upload';
+ const fileMime = ctMatch ? ctMatch[1].trim() : 'application/octet-stream';
+
+ const bodyStart = headerEnd + 4;
+ // Find ending boundary
+ const endBoundary = buffer.indexOf(boundaryBuf, bodyStart);
+ // The body ends 2 bytes before the next boundary (\r\n)
+ const bodyEnd = endBoundary !== -1 ? endBoundary - 2 : buffer.length;
+
+ return {
+ filename,
+ mime: fileMime,
+ data: buffer.slice(bodyStart, bodyEnd),
+ };
+};
+
+const parseBody = async request => {
+ const raw = await readBody(request);
+ if (!raw) return {};
+ const contentType = request.headers['content-type'] || '';
+ if (contentType.includes('application/json')) {
+ return JSON.parse(raw);
+ }
+ // Parse URL-encoded form data (htmx default)
+ const params = new URLSearchParams(raw);
+ const result = {};
+ for (const [key, value] of params) {
+ result[key] = value;
+ }
+ return result;
+};
+
+const nextConflictCopyTitle = (title, existingTitles) => {
+ const source = `${title || 'Untitled note'}`.trim() || 'Untitled note';
+ const base = source.replace(/-\d+$/, '');
+ const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const re = new RegExp(`^${escapedBase}-(\\d+)$`);
+ let maxSuffix = 0;
+ for (const existingTitle of existingTitles) {
+ const match = `${existingTitle || ''}`.match(re);
+ if (match) maxSuffix = Math.max(maxSuffix, Number(match[1] || 0));
+ }
+ return `${base}-${maxSuffix + 1}`;
+};
+
+const TRASH_FOLDER_ID = 'de1e7ede1e7ede1e7ede1e7ede1e7ede';
+const ALL_NOTES_FOLDER_ID = '__all_notes__';
+
+const allNotesFolder = notes => ({
+ id: ALL_NOTES_FOLDER_ID,
+ parentId: '',
+ title: 'All Notes',
+ noteCount: notes.filter(note => !note.deletedTime).length,
+ createdTime: 0,
+ updatedTime: 0,
+ isVirtualAllNotes: true,
+});
+
+const selectedFolderForNav = currentFolderId => currentFolderId === ALL_NOTES_FOLDER_ID ? ALL_NOTES_FOLDER_ID : currentFolderId;
+
+const normalizeStoredFolderId = folderId => folderId === '__all__' ? ALL_NOTES_FOLDER_ID : `${folderId || ''}`;
+
+const plainNoteTitle = title => templates.stripMarkdownForTitle(`${title || ''}`) || 'Untitled note';
+
+const saveLastNoteState = async (settingsService, userId, currentSettings, noteId, folderId) => {
+ if (!settingsService) return currentSettings;
+ return settingsService.saveSettings(userId, {
+ ...currentSettings,
+ lastNoteId: `${noteId || ''}`,
+ lastNoteFolderId: normalizeStoredFolderId(folderId),
+ });
+};
+
+const notesForFolder = async (itemService, userId, folderId) => {
+ if (!folderId || folderId === ALL_NOTES_FOLDER_ID) return itemService.notesByUserId(userId);
+ if (folderId === TRASH_FOLDER_ID) return itemService.notesByUserId(userId, { deleted: 'only' });
+ return itemService.notesByUserId(userId, { folderId });
+};
+
+const contentDispositionFilename = value => `${value || 'attachment'}`.replace(/[\r\n"]/g, '_');
+
+const shouldInlineResource = mime => /^(image\/.+|application\/pdf|text\/plain)$/i.test(`${mime || ''}`);
+
+const trashFolder = notes => ({
+ id: TRASH_FOLDER_ID,
+ parentId: '',
+ title: 'Trash',
+ noteCount: notes.length,
+ createdTime: 0,
+ updatedTime: 0,
+});
+
+const mapNavNotes = notes => notes.map(note => note.deletedTime ? { ...note, parentId: TRASH_FOLDER_ID } : note);
+
+const serveFile = (response, filePath) => {
+ const extension = path.extname(filePath).toLowerCase();
+ const contentType = contentTypes[extension] || 'application/octet-stream';
+ const stat = fs.statSync(filePath);
+
+ response.writeHead(200, {
+ 'Cache-Control': extension === '.html' ? 'no-store' : (extension === '.woff2' ? 'public, max-age=31536000, immutable' : 'public, max-age=300'),
+ 'Content-Length': stat.size,
+ 'Content-Type': contentType,
+ });
+
+ fs.createReadStream(filePath).pipe(response);
+};
+
+const createServer = options => {
+ const {
+ publicDir,
+ joplinPublicBasePath,
+ joplinPublicBaseUrl,
+ joplinServerPublicUrl,
+ joplinServerOrigin,
+ sessionService,
+ itemService,
+ settingsService,
+ historyService,
+ itemWriteService,
+ adminService = null,
+ adminEmail = '',
+ ignoreAdminMfa = false,
+ database = null,
+ debug = false,
+ } = options;
+
+ const isJoplockAdmin = user => !!(
+ adminService &&
+ adminEmail &&
+ user &&
+ user.email === adminEmail &&
+ user.isAdmin
+ );
+
+ const log = debug ? (...args) => process.stdout.write(`[joplock] ${args.join(' ')}\n`) : () => {};
+
+ const configuredPublicUrl = new URL(joplinPublicBaseUrl);
+ const configuredServerPublicUrl = new URL(joplinServerPublicUrl);
+
+ const authenticatedUser = async request => {
+ const sessionId = sessionIdFromHeaders(request.headers);
+ if (!sessionId) return { error: 'Missing session', user: null };
+ const user = await sessionService.userBySessionId(sessionId);
+ if (!user) return { error: 'Invalid or expired session', user: null };
+ return { error: null, user };
+ };
+
+ const navData = async userId => {
+ const [folders, notes, trashedNotes] = await Promise.all([
+ itemService.foldersByUserId(userId),
+ itemService.noteHeadersByUserId(userId),
+ itemService.noteHeadersByUserId(userId, { deleted: 'only' }),
+ ]);
+ const allNotes = mapNavNotes(notes.concat(trashedNotes));
+ const allFolders = [allNotesFolder(notes)].concat(folders, [trashFolder(trashedNotes)]);
+ return { folders: allFolders, notes: allNotes };
+ };
+
+ const ensureStarterContent = async (user, request) => {
+ const folders = await itemService.foldersByUserId(user.id);
+ if (folders.length > 0) return;
+ const ctx = upstreamRequestContext(request);
+ const examplesFolder = await itemWriteService.createFolder(user.sessionId, { title: 'Examples' }, ctx);
+ await itemWriteService.createNote(user.sessionId, {
+ title: 'Start Here',
+ body: `# Welcome to Joplock
+
+This notebook is here so a fresh install has something to open and edit right away.
+
+## What Joplock is
+
+- Open source: [abort-retry-ignore/joplock](https://github.com/abort-retry-ignore/joplock)
+- Thin web UI for Joplin Server
+- Mobile friendly and installable as PWA
+- Light on memory and system resources
+- Sync is automatic and usually near instant
+
+## Security and logout
+
+- Browser stays thin and untrusted
+- Notes and attachments are not cached for offline use
+- Logout clears client-visible state and cached shell data as much as browser allows
+
+## Editing notes
+
+- Click this note to open it.
+- Use the toolbar for headings, bold, lists, links, code, and clear formatting.
+- Switch between Markdown and Preview mode with the editor buttons.
+- Preview mode is editable too.
+
+## Saving changes
+
+- Joplock autosaves after you stop typing for a moment.
+- The status near the editor shows when a note is edited, saved, or offline.
+
+## Creating notes and notebooks
+
+- Use **+ Folder** to create a new notebook.
+- Use the **+** button on a notebook row to create a note inside it.
+- Search from the left panel to find notes quickly.
+
+## Admin and users
+
+- If this deployment defines \`JOPLOCK_ADMIN_EMAIL\` and \`JOPLOCK_ADMIN_PASSWORD\`, that user gets the Admin tab in Settings.
+- The Admin tab can create users and enable or disable MFA for users.
+
+## MFA
+
+- Each user manages their own MFA in **Settings -> Security**.
+- Admins can also manage MFA for users from the Admin tab.
+- If \`IGNORE_ADMIN_MFA=true\`, the configured deployment admin can sign in without MFA.
+
+## Markdown examples
+
+- **Bold**
+- *Italic*
+- \`Inline code\`
+- [Link to Joplin](https://joplinapp.org)
+- [ ] Checkbox item
+
+\`\`\`
+Code block example
+\`\`\`
+`,
+ parentId: examplesFolder.id,
+ }, ctx);
+ };
+
+ const userSettings = async userId => settingsService ? settingsService.settingsByUserId(userId) : null;
+
+ const upstreamRequestContext = _request => ({
+ host: configuredServerPublicUrl.host,
+ protocol: configuredServerPublicUrl.protocol.replace(':', ''),
+ });
+
+ const proxyToJoplinServer = (request, response, url) => {
+ const targetPath = joplinPublicBasePath ? (url.pathname.replace(joplinPublicBasePath, '') || '/') : url.pathname;
+ const targetUrl = new URL(joplinServerOrigin);
+ const headers = { ...request.headers };
+ headers.host = configuredServerPublicUrl.host;
+ delete headers.origin;
+ delete headers.referer;
+ headers['x-forwarded-host'] = configuredServerPublicUrl.host;
+ headers['x-forwarded-proto'] = configuredServerPublicUrl.protocol.replace(':', '');
+
+ const upstreamRequest = http.request({
+ hostname: targetUrl.hostname,
+ port: targetUrl.port,
+ path: targetPath + url.search,
+ method: request.method,
+ headers,
+ }, upstreamResponse => {
+ const responseHeaders = { ...upstreamResponse.headers };
+ if (responseHeaders.location) {
+ const location = responseHeaders.location;
+ if (location === '/' || (joplinPublicBasePath && (location === `${joplinPublicBasePath}` || location === `${joplinPublicBasePath}/`))) {
+ responseHeaders.location = '/';
+ } else if (joplinPublicBasePath && location.startsWith('/')) {
+ responseHeaders.location = `${joplinPublicBasePath}${location}`;
+ }
+ }
+ response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
+ upstreamResponse.pipe(response);
+ });
+
+ upstreamRequest.on('error', error => {
+ send(response, 502, `Upstream Joplin Server proxy error: ${error.message}`, {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ });
+ });
+
+ request.pipe(upstreamRequest);
+ };
+
+ return http.createServer(async (request, response) => {
+ const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
+ const reqStart = Date.now();
+ log(`${request.method} ${url.pathname}${url.search}`);
+
+ const origEnd = response.end.bind(response);
+ response.end = function (...args) {
+ log(`${request.method} ${url.pathname} -> ${response.statusCode} (${Date.now() - reqStart}ms)`);
+ return origEnd(...args);
+ };
+
+ // --- Health check ---
+ if (url.pathname === '/health') {
+ send(response, 200, 'ok', { 'Content-Type': 'text/plain; charset=utf-8' });
+ return;
+ }
+
+ if (url.pathname === '/settings' && request.method === 'GET') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const settings = await userSettings(auth.user.id);
+ const isAdmin = isJoplockAdmin(auth.user);
+ let adminUsers = null;
+ if (isAdmin) {
+ try {
+ const users = await adminService.listUsers();
+ adminUsers = await Promise.all(users.map(async u => {
+ const totpSeed = await settingsService.getTotpSeed(u.id);
+ return {
+ ...u,
+ totpEnabled: !!totpSeed,
+ totpSeed: totpSeed || null,
+ totpQr: totpSeed ? qrCodeDataUrl(otpauthUri(totpSeed, u.email, 'Joplock')) : null,
+ };
+ }));
+ } catch {}
+ }
+ // Per-user TOTP
+ const userTotpSeed = await settingsService.getTotpSeed(auth.user.id);
+ const userTotpEnabled = !!userTotpSeed;
+ // Check if in setup mode (seed in query param)
+ const setupSeed = url.searchParams.get('mfaSetup') || '';
+ const userTotpSetupSeed = setupSeed && !userTotpEnabled ? setupSeed : '';
+ const userTotpSetupQr = userTotpSetupSeed ? qrCodeDataUrl(otpauthUri(userTotpSetupSeed, auth.user.email, 'Joplock')) : '';
+
+ sendHtml(response, 200, templates.settingsPage({
+ user: auth.user,
+ settings,
+ userTotpEnabled,
+ userTotpSetupSeed,
+ userTotpSetupQr,
+ isAdmin,
+ isDockerAdmin: isAdmin,
+ adminUsers,
+ flash: url.searchParams.get('saved') === '1' ? 'Settings saved.' : (url.searchParams.get('mfaEnabled') === '1' ? 'MFA enabled successfully.' : ''),
+ flashError: url.searchParams.get('error') || '',
+ activeTab: url.searchParams.get('tab') || 'appearance',
+ }));
+ return;
+ }
+
+ // --- POST /settings/security (session settings) ---
+ if (url.pathname === '/settings/security' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ const current = await settingsService.settingsByUserId(auth.user.id);
+ await settingsService.saveSettings(auth.user.id, {
+ ...current,
+ autoLogout: body.autoLogout,
+ autoLogoutMinutes: body.autoLogoutMinutes,
+ });
+ response.writeHead(302, { Location: '/settings?saved=1&tab=security' });
+ response.end();
+ return;
+ }
+
+ // --- POST /settings/password ---
+ if (url.pathname === '/settings/password' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ // Block password change for docker-defined admin
+ if (isJoplockAdmin(auth.user)) {
+ response.writeHead(302, { Location: '/settings?error=Password+is+managed+via+deployment+configuration&tab=security' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ try {
+ if (!body.currentPassword) {
+ response.writeHead(302, { Location: '/settings?error=Current+password+required&tab=security' });
+ response.end();
+ return;
+ }
+ if (!body.newPassword) {
+ response.writeHead(302, { Location: '/settings?error=New+password+required&tab=security' });
+ response.end();
+ return;
+ }
+ if (body.newPassword !== body.confirmPassword) {
+ response.writeHead(302, { Location: '/settings?error=Passwords+do+not+match&tab=security' });
+ response.end();
+ return;
+ }
+ if (adminService) {
+ const verifyToken = await adminService.verifyPassword(auth.user.email, body.currentPassword);
+ if (!verifyToken) {
+ response.writeHead(302, { Location: '/settings?error=Current+password+is+incorrect&tab=security' });
+ response.end();
+ return;
+ }
+ await adminService.changePassword(auth.user.sessionId, auth.user.id, body.newPassword);
+ }
+ response.writeHead(302, { Location: '/settings?saved=1&tab=appearance' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Password change failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=security` });
+ response.end();
+ }
+ return;
+ }
+
+ // --- POST /settings/profile ---
+ if (url.pathname === '/settings/profile' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ try {
+ // Update full_name directly in Postgres — Joplin Server's PATCH API
+ // silently ignores full_name for non-admin sessions.
+ if (body.fullName !== undefined) {
+ await database.query(
+ 'UPDATE users SET full_name = $1, updated_time = $2 WHERE id = $3',
+ [body.fullName, Date.now(), auth.user.id]
+ );
+ }
+ if (adminService && body.email && body.email !== auth.user.email) {
+ await adminService.updateProfile(auth.user.sessionId, auth.user.id, {
+ email: body.email,
+ });
+ }
+ response.writeHead(302, { Location: '/settings?saved=1&tab=profile' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Update failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=profile` });
+ response.end();
+ }
+ return;
+ }
+
+ // --- MFA routes ---
+ if (url.pathname === '/settings/mfa/setup' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ // Check if already has TOTP
+ const existingSeed = await settingsService.getTotpSeed(auth.user.id);
+ if (existingSeed) {
+ response.writeHead(302, { Location: '/settings?error=MFA+already+enabled&tab=security' });
+ response.end();
+ return;
+ }
+ // Generate new seed and redirect to setup page
+ const newSeed = generateSeed();
+ response.writeHead(302, { Location: `/settings?mfaSetup=${encodeURIComponent(newSeed)}&tab=security` });
+ response.end();
+ return;
+ }
+
+ if (url.pathname === '/settings/mfa/verify' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ const seed = body.seed || '';
+ const code = body.totp || '';
+ if (!seed || !verifyWithSeed(seed, code)) {
+ response.writeHead(302, { Location: `/settings?mfaSetup=${encodeURIComponent(seed)}&error=Invalid+code.+Try+again.&tab=security` });
+ response.end();
+ return;
+ }
+ // Save seed
+ await settingsService.setTotpSeed(auth.user.id, seed);
+ response.writeHead(302, { Location: '/settings?mfaEnabled=1&tab=security' });
+ response.end();
+ return;
+ }
+
+ if (url.pathname === '/settings/mfa/cancel' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ response.writeHead(302, { Location: '/settings?tab=security' });
+ response.end();
+ return;
+ }
+
+ if (url.pathname === '/settings/mfa/disable' && request.method === 'POST') {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ const code = body.totp || '';
+ const existingSeed = await settingsService.getTotpSeed(auth.user.id);
+ if (!existingSeed) {
+ response.writeHead(302, { Location: '/settings?error=MFA+not+enabled&tab=security' });
+ response.end();
+ return;
+ }
+ if (!verifyWithSeed(existingSeed, code)) {
+ response.writeHead(302, { Location: '/settings?error=Invalid+code&tab=security' });
+ response.end();
+ return;
+ }
+ await settingsService.clearTotpSeed(auth.user.id);
+ response.writeHead(302, { Location: '/settings?saved=1&tab=security' });
+ response.end();
+ return;
+ }
+
+ // --- Admin routes ---
+ if (url.pathname.startsWith('/admin')) {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user || !isJoplockAdmin(auth.user)) {
+ response.writeHead(302, { Location: '/' });
+ response.end();
+ return;
+ }
+
+ // POST /admin/users — create user
+ if (url.pathname === '/admin/users' && request.method === 'POST') {
+ const body = await parseBody(request);
+ try {
+ await adminService.createUser(body.email, body.fullName || '', body.password || '');
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Create user failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // POST /admin/users/:id/password — reset password
+ const resetMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/password$/);
+ if (resetMatch && request.method === 'POST') {
+ const userId = decodeURIComponent(resetMatch[1]);
+ const body = await parseBody(request);
+ try {
+ await adminService.resetPassword(userId, body.password || '');
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Reset password failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // POST /admin/users/:id/disable
+ const disableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/(disable|enable)$/);
+ if (disableMatch && request.method === 'POST') {
+ const userId = decodeURIComponent(disableMatch[1]);
+ const enabled = disableMatch[2] === 'enable';
+ try {
+ await adminService.setEnabled(userId, enabled);
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Operation failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // POST /admin/users/:id/delete
+ const deleteMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/delete$/);
+ if (deleteMatch && request.method === 'POST') {
+ const userId = decodeURIComponent(deleteMatch[1]);
+ try {
+ await adminService.deleteUser(userId);
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Delete failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // POST /admin/users/:id/mfa/enable
+ const mfaEnableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/mfa\/enable$/);
+ if (mfaEnableMatch && request.method === 'POST') {
+ const userId = decodeURIComponent(mfaEnableMatch[1]);
+ try {
+ const newSeed = generateSeed();
+ await settingsService.setTotpSeed(userId, newSeed);
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Enable MFA failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // POST /admin/users/:id/mfa/disable
+ const mfaDisableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/mfa\/disable$/);
+ if (mfaDisableMatch && request.method === 'POST') {
+ const userId = decodeURIComponent(mfaDisableMatch[1]);
+ try {
+ await settingsService.clearTotpSeed(userId);
+ response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
+ response.end();
+ } catch (error) {
+ const msg = encodeURIComponent(error.message || 'Disable MFA failed');
+ response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
+ response.end();
+ }
+ return;
+ }
+
+ // Unknown admin route
+ response.writeHead(302, { Location: '/settings?tab=admin' });
+ response.end();
+ return;
+ }
+
+ // --- save individual setting (fire-and-forget from client) ---
+ if (url.pathname === '/api/web/settings' && request.method === 'PUT') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) { response.writeHead(401); response.end(); return; }
+ const body = await parseBody(request);
+ const current = await settingsService.settingsByUserId(auth.user.id);
+ const updates = {};
+ // Only allow specific keys
+ const allowedKeys = ['theme', 'noteFontSize', 'mobileNoteFontSize', 'codeFontSize', 'noteMonospace', 'noteOpenMode', 'resumeLastNote', 'dateFormat', 'datetimeFormat', 'liveSearch'];
+ for (const key of allowedKeys) {
+ if (body[key] !== undefined) updates[key] = body[key];
+ }
+ if (Object.keys(updates).length > 0) {
+ await settingsService.saveSettings(auth.user.id, { ...current, ...updates });
+ }
+ response.writeHead(204);
+ response.end();
+ } catch {
+ response.writeHead(500);
+ response.end();
+ }
+ return;
+ }
+
+ // --- save theme (fire-and-forget from client) - legacy ---
+ if (url.pathname === '/api/web/theme' && request.method === 'PUT') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) { response.writeHead(401); response.end(); return; }
+ const body = await parseBody(request);
+ const current = await settingsService.settingsByUserId(auth.user.id);
+ await settingsService.saveSettings(auth.user.id, { ...current, theme: body.theme });
+ response.writeHead(204);
+ response.end();
+ } catch {
+ response.writeHead(500);
+ response.end();
+ }
+ return;
+ }
+
+ // --- history: list snapshots ---
+ if (url.pathname.startsWith('/fragments/history/') && !url.pathname.includes('/restore/') && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const noteId = decodeURIComponent(url.pathname.slice('/fragments/history/'.length));
+ const snapshots = historyService ? await historyService.listSnapshots(noteId) : [];
+ sendHtml(response, 200, templates.historyModalFragment(noteId, snapshots));
+ } catch (error) {
+ sendHtml(response, 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ // --- history: get snapshot body preview ---
+ if (url.pathname.startsWith('/fragments/history-snapshot/') && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const snapshotId = decodeURIComponent(url.pathname.slice('/fragments/history-snapshot/'.length));
+ const snapshot = historyService ? await historyService.getSnapshot(snapshotId) : null;
+ if (!snapshot) { sendHtml(response, 404, 'Snapshot not found.
'); return; }
+ sendHtml(response, 200, templates.historySnapshotPreviewFragment(snapshot));
+ } catch (error) {
+ sendHtml(response, 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ // --- history: restore snapshot ---
+ if (url.pathname.startsWith('/fragments/history/') && url.pathname.includes('/restore/') && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired '); return; }
+ const parts = url.pathname.slice('/fragments/history/'.length).split('/restore/');
+ const noteId = decodeURIComponent(parts[0]);
+ const snapshotId = decodeURIComponent(parts[1] || '');
+ const snapshot = historyService ? await historyService.getSnapshot(snapshotId) : null;
+ if (!snapshot || snapshot.noteId !== noteId) { sendHtml(response, 404, 'Snapshot not found '); return; }
+ const body = await parseBody(request);
+ const currentFolderId = `${body.currentFolderId || ''}`;
+ const existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ if (!existing) { sendHtml(response, 404, 'Note not found '); return; }
+ await itemWriteService.updateNote(auth.user.sessionId, existing, {
+ title: snapshot.title,
+ body: snapshot.body,
+ parentId: existing.parentId,
+ }, upstreamRequestContext(request));
+ const refreshed = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, `${templates.autosaveStatusFragment()}${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId || existing.parentId), noteId, '', selectedFolderForNav(currentFolderId || existing.parentId))}
${templates.editorFragment(refreshed || existing, folders.filter(f => f.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId || existing.parentId))}
`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Restore failed: ${templates.escapeHtml(error.message || `${error}`)} `);
+ }
+ return;
+ }
+
+ // --- htmx fragment: create folder ---
+ if (url.pathname === '/fragments/folders' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const body = await parseBody(request);
+ const title = `${body.title || ''}`.trim();
+ if (!title) { sendHtml(response, 400, 'Folder title is required.
'); return; }
+
+ await itemWriteService.createFolder(auth.user.sessionId, { title, parentId: body.parentId || '' }, upstreamRequestContext(request));
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, templates.navigationFragment(folders, notes, '', ''));
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/fragments/folders/') && request.method === 'DELETE') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const folderId = decodeURIComponent(url.pathname.slice('/fragments/folders/'.length));
+ await itemWriteService.deleteFolder(auth.user.sessionId, folderId, upstreamRequestContext(request));
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, templates.navigationFragment(folders, notes, '', ''));
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/fragments/folders/') && request.method === 'PUT') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const folderId = decodeURIComponent(url.pathname.slice('/fragments/folders/'.length));
+ const body = await parseBody(request);
+ const title = `${body.title || ''}`.trim();
+ if (!folderId) { sendHtml(response, 404, 'Folder not found.
'); return; }
+ if (!title) { sendHtml(response, 400, 'Folder title is required.
'); return; }
+ const existingFolder = await itemService.folderByUserIdAndJopId(auth.user.id, folderId);
+ if (!existingFolder) { sendHtml(response, 404, 'Folder not found.
'); return; }
+
+ await itemWriteService.updateFolder(auth.user.sessionId, existingFolder, { title }, upstreamRequestContext(request));
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, templates.navigationFragment(folders, notes, folderId, ''));
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ // --- htmx fragment: navigation tree ---
+ if (url.pathname === '/fragments/nav' && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const rawQuery = url.searchParams.get('q') || '';
+ const query = rawQuery.trim();
+ const data = await navData(auth.user.id);
+ const notes = query ? mapNavNotes(await itemService.searchNotes(auth.user.id, query)) : data.notes;
+ const navFolders = data.folders;
+ sendHtml(response, 200, templates.navigationFragment(navFolders, notes, '', '', rawQuery));
+ } catch (error) {
+ sendHtml(response, 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname === '/fragments/notes' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const body = await parseBody(request);
+ const parentId = `${body.parentId || ''}`;
+ const currentFolderId = `${body.currentFolderId || parentId || ''}`;
+ if (!parentId) { sendHtml(response, 400, 'Select a folder first.
'); return; }
+
+ const created = await itemWriteService.createNote(auth.user.sessionId, {
+ title: `${body.title || ''}`.trim() || 'Untitled note',
+ body: '',
+ parentId,
+ }, upstreamRequestContext(request));
+
+ const [{ folders, notes }, note] = await Promise.all([
+ navData(auth.user.id),
+ itemService.noteByUserIdAndJopId(auth.user.id, created.id),
+ ]);
+ sendHtml(response, 200, `${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId), created.id, '', selectedFolderForNav(currentFolderId))}${templates.editorFragment(note, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId))}
`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname === '/fragments/notes/in-general' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ // Find or create the 'General' folder
+ const folders = await itemService.foldersByUserId(auth.user.id);
+ let general = folders.find(f => !f.deletedTime && f.title === 'General');
+ if (!general) {
+ const created = await itemWriteService.createFolder(auth.user.sessionId, { title: 'General', parentId: '' }, upstreamRequestContext(request));
+ general = { id: created.id, title: 'General' };
+ }
+
+ const created = await itemWriteService.createNote(auth.user.sessionId, {
+ title: 'Untitled note',
+ body: '',
+ parentId: general.id,
+ }, upstreamRequestContext(request));
+
+ const [{ folders: navFolders, notes }, note] = await Promise.all([
+ navData(auth.user.id),
+ itemService.noteByUserIdAndJopId(auth.user.id, created.id),
+ ]);
+ sendHtml(response, 200, `${templates.navigationFragment(navFolders, notes, selectedFolderForNav(general.id), created.id, '', selectedFolderForNav(general.id))}${templates.editorFragment(note, navFolders.filter(f => f.id !== TRASH_FOLDER_ID), selectedFolderForNav(general.id))}
`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/fragments/notes/') && !url.pathname.startsWith('/fragments/editor/') && request.method === 'DELETE') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const noteId = decodeURIComponent(url.pathname.slice('/fragments/notes/'.length));
+ let existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ if (!existing) existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' });
+ if (!existing) { sendHtml(response, 404, 'Note not found.
'); return; }
+ if (existing.deletedTime) {
+ await itemWriteService.deleteNote(auth.user.sessionId, noteId, upstreamRequestContext(request));
+ } else {
+ await itemWriteService.trashNote(auth.user.sessionId, existing, upstreamRequestContext(request));
+ }
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, `${templates.navigationFragment(folders, notes, TRASH_FOLDER_ID, '')}`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/fragments/notes/') && url.pathname.endsWith('/restore') && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const noteId = decodeURIComponent(url.pathname.slice('/fragments/notes/'.length, -'/restore'.length));
+ const [existing, folders] = await Promise.all([
+ itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' }),
+ itemService.foldersByUserId(auth.user.id),
+ ]);
+ if (!existing) { sendHtml(response, 404, 'Note not found.
'); return; }
+ let restoreParentId = existing.parentId;
+ if (!folders.find(folder => folder.id === restoreParentId)) {
+ if (folders.length) {
+ restoreParentId = folders[0].id;
+ } else {
+ const createdFolder = await itemWriteService.createFolder(auth.user.sessionId, { title: 'Restored items', parentId: '' }, upstreamRequestContext(request));
+ restoreParentId = createdFolder.id;
+ }
+ }
+ await itemWriteService.restoreNote(auth.user.sessionId, existing, restoreParentId, upstreamRequestContext(request));
+ const [{ folders: navFolders, notes }, restoredNote] = await Promise.all([
+ navData(auth.user.id),
+ itemService.noteByUserIdAndJopId(auth.user.id, noteId),
+ ]);
+ sendHtml(response, 200, `${templates.navigationFragment(navFolders, notes, restoreParentId, noteId, '', restoreParentId)}${templates.editorFragment(restoredNote, navFolders.filter(folder => folder.id !== TRASH_FOLDER_ID))}
`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ if (url.pathname === '/fragments/trash/empty' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const trashedNotes = await itemService.noteHeadersByUserId(auth.user.id, { deleted: 'only' });
+ for (const note of trashedNotes) {
+ await itemWriteService.deleteNote(auth.user.sessionId, note.id, upstreamRequestContext(request));
+ }
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, `${templates.navigationFragment(folders, notes, '', '')}`);
+ } catch (error) {
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error.message || `${error}`)}
`);
+ }
+ return;
+ }
+
+ // --- Resource binary serving ---
+ if (url.pathname.startsWith('/resources/') && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { send(response, 401, 'Unauthorized', { 'Content-Type': 'text/plain' }); return; }
+
+ const resourceId = decodeURIComponent(url.pathname.slice('/resources/'.length));
+ if (!resourceId || !/^[0-9a-zA-Z]{32}$/.test(resourceId)) {
+ send(response, 400, 'Invalid resource ID', { 'Content-Type': 'text/plain' });
+ return;
+ }
+
+ const [meta, blob] = await Promise.all([
+ itemService.resourceMetaByUserId(auth.user.id, resourceId),
+ itemService.resourceBlobByUserId(auth.user.id, resourceId),
+ ]);
+
+ if (!blob) { send(response, 404, 'Resource not found', { 'Content-Type': 'text/plain' }); return; }
+
+ const mime = (meta && meta.mime) || 'application/octet-stream';
+ const filename = contentDispositionFilename((meta && (meta.filename || meta.title)) || `${resourceId}`);
+ const download = url.searchParams.get('download') === '1';
+ const disposition = `${download || !shouldInlineResource(mime) ? 'attachment' : 'inline'}; filename="${filename}"`;
+ response.writeHead(200, {
+ 'Content-Type': mime,
+ 'Content-Length': blob.length,
+ 'Cache-Control': 'no-store',
+ 'Content-Disposition': disposition,
+ });
+ response.end(blob);
+ } catch (error) {
+ send(response, 500, 'Error loading resource', { 'Content-Type': 'text/plain' });
+ }
+ return;
+ }
+
+ // --- File upload (multipart) ---
+ if (url.pathname === '/fragments/upload' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: 'Session expired' }); return; }
+
+ const contentType = request.headers['content-type'] || '';
+ if (!contentType.includes('multipart/form-data')) {
+ sendJson(response, 400, { error: 'Expected multipart/form-data' });
+ return;
+ }
+
+ const rawBody = await readRawBody(request);
+ const file = parseMultipart(rawBody, contentType);
+ if (!file || !file.data.length) {
+ sendJson(response, 400, { error: 'No file uploaded' });
+ return;
+ }
+
+ const extMatch = file.filename.match(/\.([^.]+)$/);
+ const fileExtension = extMatch ? extMatch[1].toLowerCase() : '';
+
+ const created = await itemWriteService.createResource(auth.user.sessionId, {
+ title: file.filename,
+ mime: file.mime,
+ filename: file.filename,
+ fileExtension,
+ size: file.data.length,
+ }, file.data, upstreamRequestContext(request));
+
+ const isImage = file.mime.startsWith('image/');
+ const markdown = isImage
+ ? ``
+ : `[${file.filename}](:/${created.id})`;
+
+ sendJson(response, 200, { resourceId: created.id, markdown });
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || 'Upload failed' });
+ }
+ return;
+ }
+
+ // --- Markdown preview ---
+ if (url.pathname === '/fragments/preview' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired
'); return; }
+
+ const body = await parseBody(request);
+ const html = templates.renderMarkdown(body.body || '');
+ sendHtml(response, 200, html);
+ } catch (error) {
+ sendHtml(response, 500, 'Preview error
');
+ }
+ return;
+ }
+
+ // --- htmx fragment: search ---
+ if (url.pathname === '/fragments/search' && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const query = url.searchParams.get('q') || '';
+ if (!query.trim()) { sendHtml(response, 200, ''); return; }
+ const notes = await itemService.searchNotes(auth.user.id, query);
+ sendHtml(response, 200, templates.searchResultsFragment(notes));
+ } catch (error) {
+ sendHtml(response, 500, 'Search error
');
+ }
+ return;
+ }
+
+ // --- mobile fragment: folders list ---
+ if (url.pathname === '/fragments/mobile/folders' && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const [folders, notes] = await Promise.all([
+ itemService.foldersByUserId(auth.user.id),
+ itemService.noteHeadersByUserId(auth.user.id),
+ ]);
+ sendHtml(response, 200, templates.mobileFoldersFragment(folders, notes));
+ } catch (error) {
+ sendHtml(response, 500, 'Error
');
+ }
+ return;
+ }
+
+ // --- mobile fragment: notes list ---
+ if (url.pathname === '/fragments/mobile/notes' && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const folderId = url.searchParams.get('folderId') || '';
+ const notes = folderId === '__all__'
+ ? await itemService.noteHeadersByUserId(auth.user.id)
+ : (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => n.parentId === folderId);
+ const filtered = notes.filter(n => !n.deletedTime);
+ sendHtml(response, 200, templates.mobileNotesFragment(filtered, folderId));
+ } catch (error) {
+ sendHtml(response, 500, 'Error
');
+ }
+ return;
+ }
+
+ // --- mobile fragment: new note ---
+ if (url.pathname === '/fragments/mobile/notes/new' && request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const body = await parseBody(request);
+ let folderId = body.folderId || '';
+ // If "all notes" or no folder, find/create General folder
+ if (!folderId || folderId === '__all__') {
+ const folders = await itemService.foldersByUserId(auth.user.id);
+ const real = folders.filter(f => !f.isVirtualAllNotes && f.id !== TRASH_FOLDER_ID);
+ let general = real.find(f => (f.title || '').toLowerCase() === 'general');
+ if (!general) general = real[0];
+ if (general) folderId = general.id;
+ }
+ const note = await itemWriteService.createNote(auth.user.sessionId, { title: 'Untitled note', body: '', parentId: folderId }, upstreamRequestContext(request));
+ const notes = folderId && folderId !== '__all__'
+ ? (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => !n.deletedTime && n.parentId === folderId)
+ : (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => !n.deletedTime);
+ response.writeHead(200, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'X-Mobile-Note-Id': note.id || '',
+ });
+ response.end(templates.mobileNotesFragment(notes, folderId));
+ } catch (error) {
+ console.error('[mobile] notes/new error:', error);
+ sendHtml(response, error.statusCode || 500, `Error: ${templates.escapeHtml(error && (error.message || `${error}`) || 'creating note')}
`);
+ }
+ return;
+ }
+
+ // --- mobile fragment: search ---
+ if (url.pathname === '/fragments/mobile/search' && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+ const query = url.searchParams.get('q') || '';
+ const notes = query ? (await itemService.searchNotes(auth.user.id, query)).filter(n => !n.deletedTime) : [];
+ sendHtml(response, 200, templates.mobileSearchFragment(notes));
+ } catch (error) {
+ sendHtml(response, 500, 'Search error
');
+ }
+ return;
+ }
+
+ // --- htmx fragment: note editor ---
+ if (url.pathname.startsWith('/fragments/editor/') && request.method === 'GET') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired.
'); return; }
+
+ const noteId = decodeURIComponent(url.pathname.slice('/fragments/editor/'.length));
+ const currentFolderId = url.searchParams.get('currentFolderId') || '';
+ const currentSettings = await userSettings(auth.user.id);
+ const [note, folders] = await Promise.all([
+ itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'all' }),
+ itemService.foldersByUserId(auth.user.id),
+ ]);
+ if (!note) { sendHtml(response, 404, 'Note not found.
'); return; }
+ await saveLastNoteState(settingsService, auth.user.id, currentSettings, note.id, currentFolderId || note.parentId);
+ sendHtml(response, 200, templates.editorFragment(note, folders, currentFolderId || note.parentId));
+ } catch (error) {
+ sendHtml(response, 500, 'Error
');
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/fragments/editor/') && request.method === 'PUT') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendHtml(response, 401, 'Session expired '); return; }
+
+ const noteId = decodeURIComponent(url.pathname.slice('/fragments/editor/'.length));
+ const body = await parseBody(request);
+ const currentSettings = await userSettings(auth.user.id);
+ const baseUpdatedTime = Number(body.baseUpdatedTime || 0);
+ const forceSave = `${body.forceSave || ''}` === '1';
+ const createCopy = `${body.createCopy || ''}` === '1';
+ let existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ if (!existing) existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' });
+ if (!existing) { sendHtml(response, 404, 'Note not found '); return; }
+ const currentFolderId = `${body.currentFolderId || body.parentId || existing.parentId || ''}`;
+ if (createCopy) {
+ const [{ folders, notes }] = await Promise.all([
+ navData(auth.user.id),
+ ]);
+ const copyTitle = nextConflictCopyTitle(plainNoteTitle(body.title), notes.map(note => note.title));
+ const created = await itemWriteService.createNote(auth.user.sessionId, {
+ title: copyTitle,
+ body: body.body,
+ parentId: body.parentId || existing.parentId,
+ }, upstreamRequestContext(request));
+ const createdNote = await itemService.noteByUserIdAndJopId(auth.user.id, created.id);
+ await saveLastNoteState(settingsService, auth.user.id, currentSettings, created.id, currentFolderId || (createdNote && createdNote.parentId) || body.parentId || existing.parentId);
+ sendHtml(response, 200, `${templates.autosaveStatusFragment()}${templates.navigationFragment(folders, notes.concat([{ id: created.id, title: copyTitle, parentId: body.parentId || existing.parentId, updatedTime: createdNote ? createdNote.updatedTime : 0, deletedTime: 0 }]), selectedFolderForNav(currentFolderId), created.id, '', selectedFolderForNav(currentFolderId))}
${templates.editorFragment(createdNote, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId))}
`);
+ return;
+ }
+ if (!forceSave && baseUpdatedTime && Number(existing.updatedTime || 0) !== baseUpdatedTime) {
+ sendHtml(response, 200, templates.autosaveConflictFragment(noteId));
+ return;
+ }
+ await itemWriteService.updateNote(auth.user.sessionId, existing, {
+ title: plainNoteTitle(body.title),
+ body: body.body,
+ parentId: body.parentId,
+ }, upstreamRequestContext(request));
+ // save history snapshot (best-effort, fire-and-forget)
+ if (historyService) {
+ historyService.saveSnapshot(auth.user.id, noteId, existing.title, existing.body).catch(() => {});
+ }
+ const refreshed = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ await saveLastNoteState(settingsService, auth.user.id, currentSettings, noteId, currentFolderId || (refreshed && refreshed.parentId) || body.parentId || existing.parentId);
+
+ const titleChanged = plainNoteTitle(body.title) !== `${existing.title || ''}`;
+ const folderChanged = `${body.parentId || ''}` !== `${existing.parentId || ''}`;
+ const needsNav = titleChanged || folderChanged;
+ let navOob = '';
+ if (needsNav) {
+ const { folders, notes } = await navData(auth.user.id);
+ navOob = `${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId), noteId, '', selectedFolderForNav(currentFolderId))}
`;
+ }
+ sendHtml(response, 200, `${templates.autosaveStatusFragment()}${navOob}${templates.noteSyncStateFragment(refreshed || existing).replace('', '')}${templates.noteMetaFragment(refreshed || existing).replace('Save failed ');
+ }
+ return;
+ }
+
+ // --- Logout (htmx) ---
+ if (url.pathname === '/logout' && (request.method === 'POST' || request.method === 'GET')) {
+ const sendLoggedOutPage = () => {
+ send(response, 200, templates.loggedOutPage(joplinPublicBasePath), {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Set-Cookie': expiredSessionCookie(),
+ });
+ };
+ // Return the logout page immediately; upstream logout is best-effort.
+ sendLoggedOutPage();
+
+ const logoutUrl = new URL(joplinServerOrigin);
+ const headers = { ...request.headers };
+ headers.host = request.headers.host || configuredPublicUrl.host;
+ headers['x-forwarded-host'] = headers.host;
+ headers['x-forwarded-proto'] = (request.headers['x-forwarded-proto'] || configuredPublicUrl.protocol.replace(':', ''));
+ delete headers.origin;
+ delete headers.referer;
+
+ const upstreamReq = http.request({
+ hostname: logoutUrl.hostname,
+ port: logoutUrl.port,
+ path: '/logout',
+ method: 'POST',
+ headers,
+ timeout: 3000,
+ }, upstreamRes => {
+ upstreamRes.resume();
+ });
+ upstreamReq.on('timeout', () => upstreamReq.destroy());
+ upstreamReq.on('error', () => {});
+ if (request.method === 'POST') {
+ request.pipe(upstreamReq);
+ } else {
+ upstreamReq.end();
+ }
+ return;
+ }
+
+ // --- JSON API (kept for potential programmatic use) ---
+ if (url.pathname === '/api/web/me') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ sendJson(response, 200, { user: auth.user });
+ } catch (error) {
+ sendJson(response, 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+
+ if (url.pathname === '/api/web/folders') {
+ if (request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ const body = await parseBody(request);
+ const title = `${body.title || ''}`.trim();
+ if (!title) { sendJson(response, 400, { error: 'Folder title is required' }); return; }
+ const created = await itemWriteService.createFolder(auth.user.sessionId, { title, parentId: body.parentId || '' }, upstreamRequestContext(request));
+ const folder = await itemService.folderByUserIdAndJopId(auth.user.id, created.id);
+ sendJson(response, 201, { item: folder });
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ const folders = await itemService.foldersByUserId(auth.user.id);
+ sendJson(response, 200, { items: folders });
+ } catch (error) {
+ sendJson(response, 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/api/web/folders/') && request.method === 'DELETE') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ const folderId = decodeURIComponent(url.pathname.slice('/api/web/folders/'.length));
+ if (!folderId) { sendJson(response, 404, { error: 'Folder not found' }); return; }
+ await itemWriteService.deleteFolder(auth.user.sessionId, folderId, upstreamRequestContext(request));
+ sendJson(response, 204, {});
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+
+ if (url.pathname === '/api/web/notes') {
+ if (request.method === 'POST') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ const body = await parseBody(request);
+ const parentId = `${body.parentId || ''}`;
+ if (!parentId) { sendJson(response, 400, { error: 'Note parentId is required' }); return; }
+ const created = await itemWriteService.createNote(auth.user.sessionId, {
+ title: plainNoteTitle(body.title),
+ body: `${body.body || ''}`,
+ parentId,
+ }, upstreamRequestContext(request));
+ const note = await itemService.noteByUserIdAndJopId(auth.user.id, created.id);
+ sendJson(response, 201, { item: note });
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ const folderId = url.searchParams.get('folderId') || '';
+ const notes = await notesForFolder(itemService, auth.user.id, folderId);
+ sendJson(response, 200, { items: notes });
+ } catch (error) {
+ sendJson(response, 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+
+ if (url.pathname.startsWith('/api/web/notes/')) {
+ const noteId = decodeURIComponent(url.pathname.slice('/api/web/notes/'.length));
+ if (request.method === 'PUT') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
+ const existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ if (!existing) { sendJson(response, 404, { error: 'Note not found' }); return; }
+ const body = await parseBody(request);
+ const updated = await itemWriteService.updateNote(auth.user.sessionId, existing, {
+ title: plainNoteTitle(body.title), body: body.body, parentId: body.parentId,
+ }, upstreamRequestContext(request));
+ const note = await itemService.noteByUserIdAndJopId(auth.user.id, updated.id);
+ sendJson(response, 200, { item: note });
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+ if (request.method === 'DELETE') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
+ await itemWriteService.deleteNote(auth.user.sessionId, noteId, upstreamRequestContext(request));
+ sendJson(response, 204, {});
+ } catch (error) {
+ sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
+ if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
+ const note = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
+ if (!note) { sendJson(response, 404, { error: 'Note not found' }); return; }
+ sendJson(response, 200, { item: note });
+ } catch (error) {
+ sendJson(response, 500, { error: error.message || `${error}` });
+ }
+ return;
+ }
+
+ // --- POST /login — authenticate via Joplin Server API ---
+ if (url.pathname === '/login' && request.method === 'POST') {
+ try {
+ const body = await parseBody(request);
+ const email = body.email || '';
+ const password = body.password || '';
+ const totp = body.totp || '';
+ if (!email || !password) {
+ response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Email and password are required')}` });
+ response.end();
+ return;
+ }
+ const apiUrl = new URL('/api/sessions', joplinServerOrigin);
+ const requestContext = upstreamRequestContext(request);
+ const origin = `${requestContext.protocol}://${requestContext.host}`;
+ const payload = JSON.stringify({ email, password });
+ const loginResult = await new Promise((resolve, reject) => {
+ const upstreamRequest = http.request({
+ hostname: apiUrl.hostname,
+ port: apiUrl.port,
+ path: apiUrl.pathname + apiUrl.search,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payload),
+ Host: requestContext.host,
+ Origin: origin,
+ Referer: `${origin}/login`,
+ 'X-Forwarded-Host': requestContext.host,
+ 'X-Forwarded-Proto': requestContext.protocol,
+ },
+ }, upstreamResponse => {
+ const chunks = [];
+ upstreamResponse.on('data', chunk => chunks.push(chunk));
+ upstreamResponse.on('end', () => {
+ resolve({
+ statusCode: upstreamResponse.statusCode || 500,
+ body: Buffer.concat(chunks).toString('utf8'),
+ });
+ });
+ });
+
+ upstreamRequest.on('error', reject);
+ upstreamRequest.write(payload);
+ upstreamRequest.end();
+ });
+
+ if (loginResult.statusCode < 200 || loginResult.statusCode >= 300) {
+ response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Invalid email or password')}` });
+ response.end();
+ return;
+ }
+ const session = JSON.parse(loginResult.body);
+
+ // Check per-user MFA (skip for docker-defined admin when IGNORE_ADMIN_MFA is set)
+ const user = await sessionService.userBySessionId(session.id);
+ const isDockerAdmin = ignoreAdminMfa && adminEmail && user && user.email === adminEmail;
+ if (user && !isDockerAdmin) {
+ const userTotpSeed = await settingsService.getTotpSeed(user.id);
+ if (userTotpSeed) {
+ // User has MFA - check if code provided
+ if (!totp) {
+ // No code yet - show MFA page with pending session
+ response.writeHead(302, {
+ 'Cache-Control': 'no-store',
+ 'Set-Cookie': `pendingSession=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=300`,
+ Location: '/login/mfa',
+ });
+ response.end();
+ return;
+ }
+ if (!verifyWithSeed(userTotpSeed, totp)) {
+ // Code invalid
+ response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Invalid authentication code')}` });
+ response.end();
+ return;
+ }
+ }
+ }
+
+ // Clear any pending session, set real session
+ if (user) {
+ try {
+ await ensureStarterContent({ ...user, sessionId: session.id }, request);
+ } catch {}
+ }
+ response.writeHead(302, {
+ 'Cache-Control': 'no-store',
+ 'Set-Cookie': [
+ `sessionId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
+ 'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
+ ],
+ Location: '/',
+ });
+ response.end();
+ } catch (error) {
+ response.writeHead(302, { Location: `/login?error=${encodeURIComponent(`Login failed: ${error.message || error}`)}` });
+ response.end();
+ }
+ return;
+ }
+
+ // --- GET /login/mfa — MFA code entry page ---
+ if (url.pathname === '/login/mfa' && request.method === 'GET') {
+ const pendingSession = sessionIdFromHeaders(request.headers, 'pendingSession');
+ if (!pendingSession) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ sendHtml(response, 200, templates.mfaPage({
+ error: url.searchParams.get('error') || '',
+ }));
+ return;
+ }
+
+ // --- POST /login/mfa — verify MFA code ---
+ if (url.pathname === '/login/mfa' && request.method === 'POST') {
+ try {
+ const pendingSession = sessionIdFromHeaders(request.headers, 'pendingSession');
+ if (!pendingSession) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const body = await parseBody(request);
+ const totp = body.totp || '';
+
+ const user = await sessionService.userBySessionId(pendingSession);
+ if (!user) {
+ response.writeHead(302, {
+ 'Set-Cookie': 'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
+ Location: '/login?error=Session+expired',
+ });
+ response.end();
+ return;
+ }
+
+ const userTotpSeed = await settingsService.getTotpSeed(user.id);
+ if (!userTotpSeed || !verifyWithSeed(userTotpSeed, totp)) {
+ response.writeHead(302, { Location: '/login/mfa?error=Invalid+code' });
+ response.end();
+ return;
+ }
+
+ // MFA verified - set real session
+ try {
+ await ensureStarterContent({ ...user, sessionId: pendingSession }, request);
+ } catch {}
+ response.writeHead(302, {
+ 'Cache-Control': 'no-store',
+ 'Set-Cookie': [
+ `sessionId=${pendingSession}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
+ 'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
+ ],
+ Location: '/',
+ });
+ response.end();
+ } catch (error) {
+ response.writeHead(302, { Location: '/login/mfa?error=Verification+failed' });
+ response.end();
+ }
+ return;
+ }
+
+ if (url.pathname === '/login' && request.method === 'GET') {
+ if (url.searchParams.get('loggedOut') === '1') {
+ sendHtml(response, 200, templates.layoutPage({ debug,
+ user: null,
+ joplinBasePath: joplinPublicBasePath,
+ settings: null,
+ mfaEnabled: false,
+ loginError: url.searchParams.get('error') || '',
+ }));
+ return;
+ }
+ const auth = await authenticatedUser(request);
+ if (!auth.error && auth.user) {
+ response.writeHead(302, { Location: '/' });
+ response.end();
+ return;
+ }
+
+ sendHtml(response, 200, templates.layoutPage({ debug,
+ user: null,
+ joplinBasePath: joplinPublicBasePath,
+ settings: null,
+ mfaEnabled: false,
+ loginError: url.searchParams.get('error') || '',
+ }));
+ return;
+ }
+
+ // --- Joplin Server proxy ---
+ if (joplinPublicBasePath && (url.pathname === joplinPublicBasePath || url.pathname.startsWith(`${joplinPublicBasePath}/`))) {
+ proxyToJoplinServer(request, response, url);
+ return;
+ }
+
+ // --- SSR full page (GET /) ---
+ const relativePath = url.pathname === '/' ? '/index.html' : url.pathname;
+
+ if (relativePath === '/index.html') {
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+
+ const settings = await userSettings(auth.user.id);
+ try {
+ await ensureStarterContent(auth.user, request);
+ } catch {}
+ let { folders, notes } = await navData(auth.user.id);
+ let selectedFolderId = '';
+ let selectedNoteId = '';
+ let selectedNoteContextFolderId = null;
+ let editorContent = 'Select a note
';
+ let mobileStartup = null;
+ let mobileEditorContent = '';
+ if (settings && settings.resumeLastNote && settings.lastNoteId) {
+ const resumed = await itemService.noteByUserIdAndJopId(auth.user.id, settings.lastNoteId, { deleted: 'all' });
+ if (resumed && !resumed.deletedTime) {
+ const resumeFolderId = normalizeStoredFolderId(settings.lastNoteFolderId || resumed.parentId || '');
+ selectedFolderId = selectedFolderForNav(resumeFolderId || resumed.parentId || '');
+ selectedNoteId = resumed.id;
+ selectedNoteContextFolderId = selectedFolderId || null;
+ editorContent = templates.editorFragment(resumed, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderId || resumed.parentId);
+ mobileEditorContent = editorContent;
+ mobileStartup = {
+ folderId: selectedFolderId || resumed.parentId || '',
+ folderTitle: selectedFolderId === ALL_NOTES_FOLDER_ID ? 'All Notes' : ((folders.find(folder => folder.id === (selectedFolderId || resumed.parentId || '')) || {}).title || 'Notes'),
+ noteId: resumed.id,
+ noteTitle: plainNoteTitle(resumed.title),
+ };
+ } else if (settings.lastNoteId || settings.lastNoteFolderId) {
+ await settingsService.saveSettings(auth.user.id, { ...settings, lastNoteId: '', lastNoteFolderId: '' });
+ }
+ }
+
+ sendHtml(response, 200, templates.layoutPage({ debug,
+ user: auth.user,
+ settings,
+ mobileStartup,
+ mobileEditorContent,
+ navContent: templates.navigationFragment(folders, notes, selectedFolderId, selectedNoteId, '', selectedNoteContextFolderId),
+ editorContent,
+ joplinBasePath: joplinPublicBasePath,
+ }));
+ } catch (error) {
+ sendHtml(response, 200, templates.layoutPage({ debug, user: null, joplinBasePath: joplinPublicBasePath }));
+ }
+ return;
+ }
+
+ // --- Static files ---
+ const filePath = path.join(publicDir, relativePath.replace(/^\/+/, ''));
+
+ if (filePath.startsWith(publicDir) && fileExists(filePath) && !relativePath.endsWith('/')) {
+ serveFile(response, filePath);
+ return;
+ }
+
+ // Fallback: serve SSR page for any unknown path (SPA-like)
+ try {
+ const auth = await authenticatedUser(request);
+ if (auth.error || !auth.user) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ return;
+ }
+ const settings = await userSettings(auth.user.id);
+ const { folders, notes } = await navData(auth.user.id);
+ sendHtml(response, 200, templates.layoutPage({ debug,
+ user: auth.user,
+ settings,
+ navContent: templates.navigationFragment(folders, notes, '', ''),
+ joplinBasePath: joplinPublicBasePath,
+ }));
+ } catch (error) {
+ response.writeHead(302, { Location: '/login' });
+ response.end();
+ }
+ });
+};
+
+module.exports = {
+ createServer,
+};
diff --git a/app/historyService.js b/app/historyService.js
new file mode 100644
index 0000000..e385557
--- /dev/null
+++ b/app/historyService.js
@@ -0,0 +1,129 @@
+const MAX_SNAPSHOTS = 50;
+const MIN_INTERVAL_MS = 30000; // minimum 30s between snapshots for a given note
+
+// simple djb2 hash for body deduplication
+const hashBody = str => {
+ let h = 5381;
+ for (let i = 0; i < str.length; i++) h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
+ return h.toString(36);
+};
+
+const nowMs = () => Date.now();
+
+const createHistoryService = database => {
+ let _tableAvailable = null;
+
+ const ensureTable = async () => {
+ if (_tableAvailable !== null) return;
+ try {
+ await database.query(`
+ CREATE TABLE IF NOT EXISTS joplock_history (
+ id BIGSERIAL PRIMARY KEY,
+ note_id VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ body TEXT NOT NULL DEFAULT '',
+ body_hash VARCHAR(16) NOT NULL DEFAULT '',
+ saved_time BIGINT NOT NULL
+ )
+ `);
+ await database.query(`
+ CREATE INDEX IF NOT EXISTS joplock_history_note_time
+ ON joplock_history (note_id, saved_time DESC)
+ `);
+ _tableAvailable = true;
+ } catch {
+ _tableAvailable = false;
+ }
+ };
+
+ return {
+ /**
+ * Save a snapshot. Skips if:
+ * - table unavailable
+ * - body hash identical to most recent snapshot for this note
+ * - last snapshot for this note was < MIN_INTERVAL_MS ago
+ * After insert, prunes to MAX_SNAPSHOTS per note.
+ */
+ async saveSnapshot(userId, noteId, title, body) {
+ await ensureTable();
+ if (!_tableAvailable) return;
+ const hash = hashBody(body);
+ const ts = nowMs();
+ try {
+ // check most recent snapshot
+ const last = await database.query(
+ 'SELECT body_hash, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT 1',
+ [noteId],
+ );
+ const prev = last.rows[0];
+ if (prev) {
+ if (prev.body_hash === hash) return; // identical content
+ if (ts - Number(prev.saved_time) < MIN_INTERVAL_MS) return; // too soon
+ }
+ await database.query(
+ 'INSERT INTO joplock_history (note_id, user_id, title, body, body_hash, saved_time) VALUES ($1,$2,$3,$4,$5,$6)',
+ [noteId, userId, title || '', body || '', hash, ts],
+ );
+ // prune old entries beyond MAX_SNAPSHOTS
+ await database.query(`
+ DELETE FROM joplock_history
+ WHERE note_id = $1 AND id NOT IN (
+ SELECT id FROM joplock_history WHERE note_id = $1
+ ORDER BY saved_time DESC LIMIT $2
+ )
+ `, [noteId, MAX_SNAPSHOTS]);
+ } catch {
+ // history is best-effort; never break saves
+ }
+ },
+
+ /**
+ * List snapshots for a note (newest first), metadata only (no body).
+ */
+ async listSnapshots(noteId) {
+ await ensureTable();
+ if (!_tableAvailable) return [];
+ try {
+ const result = await database.query(
+ 'SELECT id, title, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT $2',
+ [noteId, MAX_SNAPSHOTS],
+ );
+ return result.rows.map(r => ({
+ id: String(r.id),
+ title: r.title,
+ savedTime: Number(r.saved_time),
+ }));
+ } catch {
+ return [];
+ }
+ },
+
+ /**
+ * Get a single snapshot by id (includes body).
+ */
+ async getSnapshot(snapshotId) {
+ await ensureTable();
+ if (!_tableAvailable) return null;
+ try {
+ const result = await database.query(
+ 'SELECT id, note_id, title, body, saved_time FROM joplock_history WHERE id = $1 LIMIT 1',
+ [snapshotId],
+ );
+ const r = result.rows[0];
+ if (!r) return null;
+ return {
+ id: String(r.id),
+ noteId: r.note_id,
+ title: r.title,
+ body: r.body,
+ savedTime: Number(r.saved_time),
+ };
+ } catch {
+ return null;
+ }
+ },
+ };
+};
+
+module.exports = { createHistoryService, hashBody };
diff --git a/app/items/itemService.js b/app/items/itemService.js
new file mode 100644
index 0000000..ef7bb8e
--- /dev/null
+++ b/app/items/itemService.js
@@ -0,0 +1,212 @@
+const MODEL_TYPE_NOTE = 1;
+const MODEL_TYPE_FOLDER = 2;
+const MODEL_TYPE_RESOURCE = 4;
+const TRASH_FOLDER_ID = 'de1e7ede1e7ede1e7ede1e7ede1e7ede';
+
+const decodeItemContent = content => {
+ if (!content) return {};
+ const raw = Buffer.isBuffer(content) ? content.toString('utf8') : `${content}`;
+ if (!raw) return {};
+ return JSON.parse(raw);
+};
+
+const mapFolderRow = row => {
+ const content = decodeItemContent(row.content);
+ return {
+ id: row.jop_id,
+ parentId: row.jop_parent_id || '',
+ title: content.title || '',
+ icon: content.icon || '',
+ deletedTime: Number(content.deleted_time || 0),
+ createdTime: Number(content.created_time || row.created_time || 0),
+ updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
+ };
+};
+
+const mapNoteRow = row => {
+ const content = decodeItemContent(row.content);
+ const body = content.body || '';
+ return {
+ id: row.jop_id,
+ parentId: row.jop_parent_id || '',
+ title: content.title || '',
+ body,
+ bodyPreview: body.slice(0, 240),
+ isTodo: !!Number(content.is_todo || 0),
+ todoCompleted: Number(content.todo_completed || 0),
+ deletedTime: Number(content.deleted_time || 0),
+ createdTime: Number(content.created_time || row.created_time || 0),
+ updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
+ };
+};
+
+const mapNoteHeaderRow = row => {
+ return {
+ id: row.jop_id,
+ parentId: row.jop_parent_id || '',
+ title: row.title || '',
+ deletedTime: Number(row.deleted_time || 0),
+ updatedTime: Number(row.jop_updated_time || 0),
+ };
+};
+
+const deletedFilterSql = mode => {
+ if (mode === 'only') return ' AND COALESCE((convert_from(content, \'UTF8\')::json->>\'deleted_time\')::bigint, 0) > 0';
+ if (mode === 'all') return '';
+ return ' AND COALESCE((convert_from(content, \'UTF8\')::json->>\'deleted_time\')::bigint, 0) = 0';
+};
+
+const createItemService = database => {
+ return {
+ async foldersByUserId(userId) {
+ const result = await database.query(`
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql('exclude')}
+ ORDER BY LOWER(COALESCE(convert_from(content, 'UTF8')::json->>'title', '')) ASC, created_time ASC
+ `, [userId, MODEL_TYPE_FOLDER]);
+
+ return result.rows.map(mapFolderRow);
+ },
+
+ async folderByUserIdAndJopId(userId, folderId) {
+ const result = await database.query(`
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3
+ LIMIT 1
+ `, [userId, MODEL_TYPE_FOLDER, folderId]);
+
+ const row = result.rows[0];
+ if (!row) return null;
+ return mapFolderRow(row);
+ },
+
+ async notesByUserId(userId, options = {}) {
+ const folderId = options.folderId || '';
+ const deleted = options.deleted || 'exclude';
+ const params = [userId, MODEL_TYPE_NOTE];
+ let where = `WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql(deleted)}`;
+
+ if (folderId) {
+ params.push(folderId);
+ where += ` AND jop_parent_id = $${params.length}`;
+ }
+
+ const result = await database.query(`
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
+ FROM items
+ ${where}
+ ORDER BY jop_updated_time DESC, created_time DESC
+ `, params);
+
+ return result.rows.map(mapNoteRow);
+ },
+
+ async noteHeadersByUserId(userId, options = {}) {
+ const deleted = options.deleted || 'exclude';
+ const result = await database.query(`
+ SELECT
+ jop_id,
+ jop_parent_id,
+ jop_updated_time,
+ COALESCE(convert_from(content, 'UTF8')::json->>'title', '') AS title,
+ COALESCE((convert_from(content, 'UTF8')::json->>'deleted_time')::bigint, 0) AS deleted_time
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql(deleted)}
+ ORDER BY jop_updated_time DESC, created_time DESC
+ `, [userId, MODEL_TYPE_NOTE]);
+
+ return result.rows.map(mapNoteHeaderRow);
+ },
+
+ async searchNotes(userId, query) {
+ if (!query || !query.trim()) return [];
+ const pattern = `%${query.trim()}%`;
+ const result = await database.query(`
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
+ FROM (
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content,
+ convert_from(content, 'UTF8')::json AS parsed
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2
+ ) sub
+ WHERE COALESCE((parsed->>'deleted_time')::bigint, 0) = 0
+ AND (
+ parsed->>'title' ILIKE $3
+ OR regexp_replace(
+ regexp_replace(parsed->>'body', '!?\[[^\]]*\]\(:/[a-f0-9]+\)', '', 'g'),
+ 'data:image/[^;]+;base64,[A-Za-z0-9+/=]+', '', 'g'
+ ) ILIKE $3
+ )
+ ORDER BY jop_updated_time DESC, created_time DESC
+ LIMIT 50
+ `, [userId, MODEL_TYPE_NOTE, pattern]);
+
+ return result.rows.map(mapNoteRow);
+ },
+
+ async noteByUserIdAndJopId(userId, noteId, options = {}) {
+ const deleted = options.deleted || 'exclude';
+ const result = await database.query(`
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3${deletedFilterSql(deleted)}
+ LIMIT 1
+ `, [userId, MODEL_TYPE_NOTE, noteId]);
+
+ const row = result.rows[0];
+ if (!row) return null;
+ return mapNoteRow(row);
+ },
+
+ // Returns the binary content of a resource blob (.resource/)
+ async resourceBlobByUserId(userId, resourceId) {
+ const blobName = `.resource/${resourceId}`;
+ const result = await database.query(`
+ SELECT content
+ FROM items
+ WHERE owner_id = $1 AND name = $2
+ LIMIT 1
+ `, [userId, blobName]);
+
+ const row = result.rows[0];
+ if (!row) return null;
+ return row.content; // Buffer
+ },
+
+ // Returns resource metadata (mime, filename, etc.) from the .md item
+ async resourceMetaByUserId(userId, resourceId) {
+ const result = await database.query(`
+ SELECT content
+ FROM items
+ WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3
+ LIMIT 1
+ `, [userId, MODEL_TYPE_RESOURCE, resourceId]);
+
+ const row = result.rows[0];
+ if (!row) return null;
+ const content = decodeItemContent(row.content);
+ return {
+ id: resourceId,
+ title: content.title || '',
+ mime: content.mime || 'application/octet-stream',
+ filename: content.filename || '',
+ fileExtension: content.file_extension || '',
+ size: Number(content.size || 0),
+ };
+ },
+ };
+};
+
+module.exports = {
+ MODEL_TYPE_FOLDER,
+ MODEL_TYPE_NOTE,
+ MODEL_TYPE_RESOURCE,
+ TRASH_FOLDER_ID,
+ createItemService,
+ decodeItemContent,
+ mapFolderRow,
+ mapNoteHeaderRow,
+ mapNoteRow,
+};
diff --git a/app/items/itemWriteService.js b/app/items/itemWriteService.js
new file mode 100644
index 0000000..1e3597f
--- /dev/null
+++ b/app/items/itemWriteService.js
@@ -0,0 +1,295 @@
+const http = require('http');
+const { randomBytes } = require('crypto');
+
+const notePath = noteId => `root:/${noteId}.md:`;
+const folderPath = folderId => `root:/${folderId}.md:`;
+const resourceMetaPath = resourceId => `root:/${resourceId}.md:`;
+const resourceBlobPath = resourceId => `root:/.resource/${resourceId}:`;
+
+const itemId = suffix => {
+ const token = randomBytes(16).toString('hex').slice(0, 31);
+ return `${token}${suffix}`;
+};
+
+const formatTimestamp = timestamp => new Date(timestamp).toISOString();
+
+const serializeNote = note => {
+ const now = Date.now();
+ const noteId = note.id || itemId('1');
+ const parentId = note.parentId || '';
+ const createdTime = note.createdTime || now;
+ const deletedTime = note.deletedTime || 0;
+
+ return {
+ id: noteId,
+ path: notePath(noteId),
+ body: `${note.title || 'Untitled note'}
+
+${note.body || ''}
+
+id: ${noteId}
+parent_id: ${parentId}
+created_time: ${formatTimestamp(createdTime)}
+updated_time: ${formatTimestamp(now)}
+is_conflict: 0
+latitude: 0.00000000
+longitude: 0.00000000
+altitude: 0.0000
+author:
+source_url:
+is_todo: 0
+todo_due: 0
+todo_completed: 0
+source: joplock-web
+source_application: net.cozic.joplock-web
+application_data:
+order: 0
+user_created_time: ${formatTimestamp(createdTime)}
+user_updated_time: ${formatTimestamp(now)}
+encryption_cipher_text:
+encryption_applied: 0
+markup_language: 1
+is_shared: 0
+share_id:
+conflict_original_id:
+master_key_id:
+user_data:
+deleted_time: ${deletedTime}
+type_: 1`,
+ };
+};
+
+const serializeFolder = folder => {
+ const now = Date.now();
+ const folderId = folder.id || itemId('2');
+ const parentId = folder.parentId || '';
+
+ return {
+ id: folderId,
+ path: folderPath(folderId),
+ body: `${folder.title || 'Untitled folder'}
+
+id: ${folderId}
+created_time: ${formatTimestamp(now)}
+updated_time: ${formatTimestamp(now)}
+user_created_time: ${formatTimestamp(now)}
+user_updated_time: ${formatTimestamp(now)}
+encryption_cipher_text:
+encryption_applied: 0
+parent_id: ${parentId}
+is_shared: 0
+share_id:
+user_data:
+type_: 2`,
+ };
+};
+
+const serializeResource = resource => {
+ const now = Date.now();
+ const resourceId = resource.id || itemId('4');
+ const mime = resource.mime || 'application/octet-stream';
+ const filename = resource.filename || '';
+ const fileExtension = resource.fileExtension || '';
+ const size = resource.size || 0;
+
+ return {
+ id: resourceId,
+ metaPath: resourceMetaPath(resourceId),
+ blobPath: resourceBlobPath(resourceId),
+ body: `${resource.title || filename || 'Untitled resource'}
+
+id: ${resourceId}
+mime: ${mime}
+filename: ${filename}
+created_time: ${formatTimestamp(now)}
+updated_time: ${formatTimestamp(now)}
+user_created_time: ${formatTimestamp(now)}
+user_updated_time: ${formatTimestamp(now)}
+file_extension: ${fileExtension}
+encryption_cipher_text:
+encryption_applied: 0
+encryption_blob_encrypted: 0
+size: ${size}
+is_shared: 0
+share_id:
+master_key_id:
+user_data:
+blob_updated_time: ${formatTimestamp(now)}
+type_: 4`,
+ };
+};
+
+const requestUpstream = (origin, options = {}, body = null) => {
+ const target = new URL(origin);
+ const requestHeaders = { ...(options.headers || {}) };
+ requestHeaders.host = options.publicHost || requestHeaders.host || '';
+ requestHeaders['x-forwarded-host'] = options.publicHost || requestHeaders.host || '';
+ requestHeaders['x-forwarded-proto'] = options.publicProtocol || 'http';
+ delete requestHeaders.origin;
+ delete requestHeaders.referer;
+ if (body !== null && !requestHeaders['content-length']) {
+ requestHeaders['content-length'] = Buffer.byteLength(body);
+ }
+
+ return new Promise((resolve, reject) => {
+ const request = http.request({
+ hostname: target.hostname,
+ port: target.port,
+ path: options.path || '/',
+ method: options.method || 'GET',
+ headers: requestHeaders,
+ }, response => {
+ const chunks = [];
+ response.on('data', chunk => {
+ chunks.push(chunk);
+ });
+ response.on('end', () => {
+ resolve({
+ statusCode: response.statusCode || 500,
+ body: Buffer.concat(chunks),
+ headers: response.headers,
+ });
+ });
+ });
+
+ request.on('error', reject);
+
+ if (body !== null) request.write(body);
+ request.end();
+ });
+};
+
+const checkUpstreamResponse = response => {
+ if (response.statusCode >= 200 && response.statusCode < 300) return;
+ const message = response.body.toString('utf8') || `Upstream request failed: ${response.statusCode}`;
+ const error = new Error(message);
+ error.statusCode = response.statusCode;
+ throw error;
+};
+
+const createItemWriteService = options => {
+ const { joplinServerOrigin, joplinServerPublicUrl } = options;
+ const configuredPublicUrl = new URL(joplinServerPublicUrl);
+
+ const putSerializedItem = async (sessionId, serializedItem, requestContext = {}) => {
+ const response = await requestUpstream(joplinServerOrigin, {
+ method: 'PUT',
+ path: `/api/items/${serializedItem.path}/content`,
+ publicHost: requestContext.host || configuredPublicUrl.host,
+ publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
+ headers: {
+ 'content-type': 'multipart/form-data; boundary=----joplockboundary',
+ 'x-api-auth': sessionId,
+ },
+ }, `------joplockboundary\r\nContent-Disposition: form-data; name="file"; filename="item.md"\r\nContent-Type: text/markdown\r\n\r\n${serializedItem.body}\r\n------joplockboundary--\r\n`);
+
+ checkUpstreamResponse(response);
+ return serializedItem.id;
+ };
+
+ const putBinaryItem = async (sessionId, itemPath, binaryBuffer, contentType, requestContext = {}) => {
+ const boundary = '----joplockblobbound';
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="blob"\r\nContent-Type: ${contentType}\r\n\r\n`;
+ const footer = `\r\n--${boundary}--\r\n`;
+ const body = Buffer.concat([
+ Buffer.from(header, 'utf8'),
+ binaryBuffer,
+ Buffer.from(footer, 'utf8'),
+ ]);
+
+ const response = await requestUpstream(joplinServerOrigin, {
+ method: 'PUT',
+ path: `/api/items/${itemPath}/content`,
+ publicHost: requestContext.host || configuredPublicUrl.host,
+ publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
+ headers: {
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
+ 'x-api-auth': sessionId,
+ },
+ }, body);
+
+ checkUpstreamResponse(response);
+ };
+
+ const deleteItem = async (sessionId, itemPath, requestContext = {}) => {
+ const response = await requestUpstream(joplinServerOrigin, {
+ method: 'DELETE',
+ path: `/api/items/${itemPath}`,
+ publicHost: requestContext.host || configuredPublicUrl.host,
+ publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
+ headers: {
+ 'x-api-auth': sessionId,
+ },
+ });
+
+ checkUpstreamResponse(response);
+ };
+
+ return {
+ async createFolder(sessionId, folder, requestContext) {
+ const serialized = serializeFolder(folder);
+ await putSerializedItem(sessionId, serialized, requestContext);
+ return { id: serialized.id };
+ },
+
+ async deleteFolder(sessionId, folderId, requestContext) {
+ await deleteItem(sessionId, folderPath(folderId), requestContext);
+ },
+
+ async updateFolder(sessionId, existingFolder, updates, requestContext) {
+ const serialized = serializeFolder({
+ id: existingFolder.id,
+ title: updates.title !== undefined ? updates.title : existingFolder.title,
+ parentId: updates.parentId !== undefined ? updates.parentId : existingFolder.parentId,
+ });
+ await putSerializedItem(sessionId, serialized, requestContext);
+ return { id: serialized.id };
+ },
+
+ async createNote(sessionId, note, requestContext) {
+ const serialized = serializeNote(note);
+ await putSerializedItem(sessionId, serialized, requestContext);
+ return { id: serialized.id };
+ },
+
+ async updateNote(sessionId, existingNote, updates, requestContext) {
+ const serialized = serializeNote({
+ id: existingNote.id,
+ title: updates.title !== undefined ? updates.title : existingNote.title,
+ body: updates.body !== undefined ? updates.body : existingNote.body,
+ parentId: updates.parentId !== undefined ? updates.parentId : existingNote.parentId,
+ createdTime: existingNote.createdTime,
+ deletedTime: updates.deletedTime !== undefined ? updates.deletedTime : existingNote.deletedTime,
+ });
+ await putSerializedItem(sessionId, serialized, requestContext);
+ return { id: serialized.id };
+ },
+
+ async deleteNote(sessionId, noteId, requestContext) {
+ await deleteItem(sessionId, notePath(noteId), requestContext);
+ },
+
+ async trashNote(sessionId, existingNote, requestContext) {
+ return this.updateNote(sessionId, existingNote, { deletedTime: Date.now() }, requestContext);
+ },
+
+ async restoreNote(sessionId, existingNote, restoreParentId, requestContext) {
+ return this.updateNote(sessionId, existingNote, { deletedTime: 0, parentId: restoreParentId }, requestContext);
+ },
+
+ async createResource(sessionId, resource, binaryBuffer, requestContext) {
+ const serialized = serializeResource(resource);
+ // Upload metadata .md first, then binary blob
+ await putSerializedItem(sessionId, { id: serialized.id, path: serialized.metaPath, body: serialized.body }, requestContext);
+ await putBinaryItem(sessionId, serialized.blobPath, binaryBuffer, resource.mime || 'application/octet-stream', requestContext);
+ return { id: serialized.id };
+ },
+ };
+};
+
+module.exports = {
+ createItemWriteService,
+ serializeFolder,
+ serializeNote,
+ serializeResource,
+};
diff --git a/app/settingsService.js b/app/settingsService.js
new file mode 100644
index 0000000..6b33ee5
--- /dev/null
+++ b/app/settingsService.js
@@ -0,0 +1,179 @@
+const validThemes = ['matrix','matrix-blue','matrix-purple','matrix-amber','matrix-orange','dark-grey','dark-red','dark','light','oled-dark','solarized-light','solarized-dark','nord','dracula','aritim-dark'];
+
+const validDateFormats = [
+ 'YYYY-MM-DD',
+ 'MM/DD/YYYY',
+ 'DD/MM/YYYY',
+ 'MMM DD, YYYY',
+ 'DD MMM YYYY',
+ 'YYYY.MM.DD',
+];
+
+const validDatetimeFormats = [
+ 'YYYY-MM-DD HH:mm',
+ 'MM/DD/YYYY HH:mm',
+ 'DD/MM/YYYY HH:mm',
+ 'MMM DD, YYYY HH:mm',
+ 'DD MMM YYYY HH:mm',
+ 'YYYY.MM.DD HH:mm',
+ 'YYYY-MM-DD HH:mm:ss',
+ 'MM/DD/YYYY hh:mm A',
+ 'DD/MM/YYYY hh:mm A',
+];
+
+const defaultSettings = Object.freeze({
+ noteFontSize: 15,
+ mobileNoteFontSize: 17,
+ codeFontSize: 12,
+ noteMonospace: false,
+ noteOpenMode: 'preview',
+ resumeLastNote: true,
+ lastNoteId: '',
+ lastNoteFolderId: '',
+ dateFormat: 'YYYY-MM-DD',
+ datetimeFormat: 'YYYY-MM-DD HH:mm',
+ autoLogout: false,
+ autoLogoutMinutes: 15,
+ theme: 'matrix',
+ liveSearch: false,
+});
+
+const nowMs = () => Date.now();
+
+const normalizeInteger = (value, fallback, min, max) => {
+ const numeric = Number.parseInt(`${value}`, 10);
+ if (Number.isNaN(numeric)) return fallback;
+ return Math.max(min, Math.min(max, numeric));
+};
+
+const normalizeSettings = settings => ({
+ noteFontSize: normalizeInteger(settings.noteFontSize, defaultSettings.noteFontSize, 12, 24),
+ mobileNoteFontSize: normalizeInteger(settings.mobileNoteFontSize, normalizeInteger(settings.noteFontSize, defaultSettings.noteFontSize, 12, 24) + 2, 12, 28),
+ codeFontSize: normalizeInteger(settings.codeFontSize, defaultSettings.codeFontSize, 10, 22),
+ noteMonospace: !!Number(settings.noteMonospace) || settings.noteMonospace === true || settings.noteMonospace === '1',
+ noteOpenMode: settings.noteOpenMode === 'markdown' ? 'markdown' : defaultSettings.noteOpenMode,
+ resumeLastNote: !!Number(settings.resumeLastNote) || settings.resumeLastNote === true || settings.resumeLastNote === '1',
+ lastNoteId: `${settings.lastNoteId || ''}`,
+ lastNoteFolderId: `${settings.lastNoteFolderId || ''}`,
+ dateFormat: validDateFormats.includes(settings.dateFormat) ? settings.dateFormat : defaultSettings.dateFormat,
+ datetimeFormat: validDatetimeFormats.includes(settings.datetimeFormat) ? settings.datetimeFormat : defaultSettings.datetimeFormat,
+ autoLogout: !!Number(settings.autoLogout) || settings.autoLogout === true || settings.autoLogout === '1',
+ autoLogoutMinutes: normalizeInteger(settings.autoLogoutMinutes, defaultSettings.autoLogoutMinutes, 1, 480),
+ theme: validThemes.includes(settings.theme) ? settings.theme : defaultSettings.theme,
+ liveSearch: !!Number(settings.liveSearch) || settings.liveSearch === true || settings.liveSearch === '1',
+});
+
+const createSettingsService = database => {
+ let _tableAvailable = null;
+
+ const ensureTable = async () => {
+ if (_tableAvailable !== null) return;
+ try {
+ await database.query(`
+ CREATE TABLE IF NOT EXISTS joplock_settings (
+ user_id VARCHAR(32) PRIMARY KEY,
+ settings JSONB NOT NULL DEFAULT '{}',
+ updated_time BIGINT NOT NULL,
+ totp_seed VARCHAR(64)
+ )
+ `);
+ // Add totp_seed column if missing (migration for existing tables)
+ await database.query(`
+ ALTER TABLE joplock_settings ADD COLUMN IF NOT EXISTS totp_seed VARCHAR(64)
+ `).catch(() => {});
+ _tableAvailable = true;
+ } catch {
+ _tableAvailable = false;
+ }
+ };
+
+ return {
+ async settingsByUserId(userId) {
+ await ensureTable();
+ if (!_tableAvailable) return { ...defaultSettings };
+ try {
+ const result = await database.query(
+ 'SELECT settings FROM joplock_settings WHERE user_id = $1 LIMIT 1',
+ [userId],
+ );
+ const row = result.rows[0];
+ if (!row) return { ...defaultSettings };
+ const json = typeof row.settings === 'string' ? JSON.parse(row.settings) : (row.settings || {});
+ return normalizeSettings({ ...defaultSettings, ...json });
+ } catch {
+ return { ...defaultSettings };
+ }
+ },
+
+ async saveSettings(userId, settings) {
+ await ensureTable();
+ const normalized = normalizeSettings(settings);
+ if (!_tableAvailable) return normalized;
+ const timestamp = nowMs();
+ await database.query(`
+ INSERT INTO joplock_settings (user_id, settings, updated_time)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (user_id) DO UPDATE SET
+ settings = EXCLUDED.settings,
+ updated_time = EXCLUDED.updated_time
+ `, [userId, JSON.stringify(normalized), timestamp]);
+ return normalized;
+ },
+
+ async getTotpSeed(userId) {
+ await ensureTable();
+ if (!_tableAvailable) return null;
+ try {
+ const result = await database.query(
+ 'SELECT totp_seed FROM joplock_settings WHERE user_id = $1 LIMIT 1',
+ [userId],
+ );
+ return result.rows[0]?.totp_seed || null;
+ } catch {
+ return null;
+ }
+ },
+
+ async setTotpSeed(userId, seed) {
+ await ensureTable();
+ if (!_tableAvailable) return false;
+ const timestamp = nowMs();
+ try {
+ await database.query(`
+ INSERT INTO joplock_settings (user_id, settings, updated_time, totp_seed)
+ VALUES ($1, '{}', $2, $3)
+ ON CONFLICT (user_id) DO UPDATE SET
+ totp_seed = EXCLUDED.totp_seed,
+ updated_time = EXCLUDED.updated_time
+ `, [userId, timestamp, seed]);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ async clearTotpSeed(userId) {
+ await ensureTable();
+ if (!_tableAvailable) return false;
+ const timestamp = nowMs();
+ try {
+ await database.query(
+ 'UPDATE joplock_settings SET totp_seed = NULL, updated_time = $2 WHERE user_id = $1',
+ [userId, timestamp],
+ );
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ };
+};
+
+module.exports = {
+ createSettingsService,
+ defaultSettings,
+ normalizeSettings,
+ validDateFormats,
+ validDatetimeFormats,
+ validThemes,
+};
diff --git a/app/templates.js b/app/templates.js
new file mode 100644
index 0000000..0b34963
--- /dev/null
+++ b/app/templates.js
@@ -0,0 +1,1864 @@
+// SSR HTML templates for htmx-driven UI
+// 3-column layout: folders | note list | editor (like Joplin desktop)
+
+const { validDateFormats, validDatetimeFormats } = require('./settingsService');
+
+const escapeHtml = value => `${value}`
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll('\'', ''');
+
+const appleSplashLinks = [
+ ['1320x2868.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2868x1320.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['1290x2796.png', 'screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2796x1290.png', 'screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['1179x2556.png', 'screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2556x1179.png', 'screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['1170x2532.png', 'screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2532x1170.png', 'screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['1125x2436.png', 'screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2436x1125.png', 'screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['1242x2688.png', 'screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
+ ['2688x1242.png', 'screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
+ ['828x1792.png', 'screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'],
+ ['1792x828.png', 'screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'],
+ ['1536x2048.png', 'screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'],
+ ['2048x1536.png', 'screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'],
+ ['1668x2388.png', 'screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'],
+ ['2388x1668.png', 'screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'],
+ ['1640x2360.png', 'screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'],
+ ['2360x1640.png', 'screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'],
+ ['2048x2732.png', 'screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'],
+ ['2732x2048.png', 'screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'],
+].map(([fileName, media]) => ` `).join('\n\t');
+
+const folderOutlineIcon = ' ';
+const allNotesIcon = '📄';
+const trashFolderId = 'de1e7ede1e7ede1e7ede1e7ede1e7ede';
+const themeOptions = [['matrix','Matrix'],['matrix-blue','Dark Blue'],['matrix-purple','Dark Purple'],['matrix-amber','Dark Amber'],['matrix-orange','Dark Orange'],['dark-grey','Dark Grey'],['dark-red','Dark Red'],['dark','Dark'],['light','Light'],['oled-dark','OLED Dark'],['solarized-light','Solarized Light'],['solarized-dark','Solarized Dark'],['nord','Nord'],['dracula','Dracula'],['aritim-dark','Aritim Dark']];
+
+const stripMarkdownForTitle = value => {
+ let text = `${value || ''}`.trim();
+ while (text.startsWith('#')) text = text.slice(1).trimStart();
+ text = text
+ .replaceAll('**', '')
+ .replaceAll('__', '')
+ .replaceAll('++', '')
+ .replaceAll('*', '')
+ .replaceAll('_', '')
+ .replaceAll('~~', '')
+ .replaceAll('`', '');
+ let output = '';
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === '!' && text[i + 1] === '[') {
+ const altEnd = text.indexOf(']', i + 2);
+ const imgOpen = altEnd >= 0 ? text.indexOf('(', altEnd + 1) : -1;
+ const imgClose = imgOpen >= 0 ? text.indexOf(')', imgOpen + 1) : -1;
+ if (altEnd >= 0 && imgOpen === altEnd + 1 && imgClose >= 0) {
+ output += text.slice(i + 2, altEnd);
+ i = imgClose;
+ continue;
+ }
+ }
+ if (ch === '[') {
+ const labelEnd = text.indexOf(']', i + 1);
+ const linkOpen = labelEnd >= 0 ? text.indexOf('(', labelEnd + 1) : -1;
+ const linkClose = linkOpen >= 0 ? text.indexOf(')', linkOpen + 1) : -1;
+ if (labelEnd >= 0 && linkOpen === labelEnd + 1 && linkClose >= 0) {
+ output += text.slice(i + 1, labelEnd);
+ i = linkClose;
+ continue;
+ }
+ }
+ output += ch;
+ }
+ return output.trim();
+};
+
+const noteDomId = (noteId, contextFolderId = '') => {
+ const safeContext = `${contextFolderId || 'root'}`.replace(/[^a-zA-Z0-9_-]/g, '-');
+ return `note-item-${safeContext}-${noteId}`;
+};
+
+// Column 1: folder list item
+const folderListItem = (folder, selectedFolderId) => {
+ const active = folder.id === selectedFolderId ? ' active' : '';
+ return ``;
+};
+
+// Column 1: full folder list
+const folderListFragment = (folders, selectedFolderId) => {
+ if (!folders.length) {
+ return 'No notebooks yet
';
+ }
+ return folders.map(f => folderListItem(f, selectedFolderId)).join('');
+};
+
+// Column 2: single note in the note list
+const noteListItem = (note, selectedNoteId, contextFolderId = '', selectedContextFolderId = null) => {
+ const active = note.id === selectedNoteId && (selectedContextFolderId === null || contextFolderId === selectedContextFolderId) ? ' active' : '';
+ const editorPath = `/fragments/editor/${encodeURIComponent(note.id)}${contextFolderId ? `?currentFolderId=${encodeURIComponent(contextFolderId)}` : ''}`;
+ return `
+ ${escapeHtml(stripMarkdownForTitle(note.title || 'Untitled') || 'Untitled')}
+ `;
+};
+
+// Column 2: note list with header (new note button + search)
+const noteListFragment = (notes, selectedNoteId, folderId) => {
+ const header = ``;
+
+ const items = notes.length
+ ? notes.map(n => noteListItem(n, selectedNoteId, folderId)).join('')
+ : 'No notes
';
+
+ return `${header}${items}
`;
+};
+
+const noteSyncStateFragment = note => ` `;
+
+const noteMetaFragment = note => ` `;
+
+const autosaveConflictFragment = noteId => `Conflict Overwrite Create copy `;
+
+const fmtHistoryTime = ts => {
+ const d = new Date(Number(ts));
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
+};
+
+const historyModalFragment = (noteId, snapshots) => {
+ const list = snapshots.length === 0
+ ? 'No saved snapshots yet.
'
+ : snapshots.map((s, i) => `${escapeHtml(fmtHistoryTime(s.savedTime))}${escapeHtml((s.title || 'Untitled').slice(0, 40))} `).join('');
+ return `
+
${list}
+
${snapshots.length > 0 ? `
` : '
Select a snapshot to preview.
'}
+
+
+ ${snapshots.length > 0 ? escapeHtml(fmtHistoryTime(snapshots[0].savedTime)) : 'No snapshots'}
+ ${snapshots.length > 0 ? `Restore this version ` : ''}
+ Close
+
`;
+};
+
+const historySnapshotPreviewFragment = snapshot => {
+ const body = (snapshot.body || '').replace(/ /g, ' ');
+ const preview = body.slice(0, 3000) + (body.length > 3000 ? '\n…' : '');
+ return ``;
+};
+
+const navigationFragment = (folders, notes, selectedFolderId, selectedNoteId, query = '', selectedNoteContextFolderId = null) => {
+ const notesByFolder = new Map();
+ for (const note of notes || []) {
+ const key = note.parentId || '';
+ if (!notesByFolder.has(key)) notesByFolder.set(key, []);
+ notesByFolder.get(key).push(note);
+ }
+ const hasQuery = !!`${query || ''}`.trim();
+
+ const folderSections = hasQuery ? (() => {
+ const searchNotes = (notes || []).filter(n => !n.deletedTime);
+ const count = searchNotes.length;
+ if (!count) return 'No results
';
+ return `
+
+ ▸
+
+ Search Results
+
+
+
+ ${searchNotes.map(n => noteListItem(n, selectedNoteId, '__search_results__', selectedNoteContextFolderId)).join('')}
+
+
`;
+ })() : (folders || []).map(folder => {
+ const folderNotes = folder.isVirtualAllNotes ? (notes || []).filter(note => !note.deletedTime && note.parentId !== trashFolderId) : (notesByFolder.get(folder.id) || []);
+ const isOpen = folder.id === selectedFolderId || folderNotes.some(n => n.id === selectedNoteId);
+ const count = folderNotes.length;
+ const isExpandable = !!count;
+ const isTrash = folder.id === trashFolderId;
+ const isAllNotes = !!folder.isVirtualAllNotes;
+ return `
+
+ ${isExpandable ? '▸ ' : ' '}
+
+ ${escapeHtml(folder.title || 'Untitled')}
+
+ ${isTrash ? `✕ ` : (isAllNotes ? `+ ` : `+ `)}
+
+
+ ${folderNotes.length ? folderNotes.map(n => noteListItem(n, selectedNoteId, folder.id, selectedNoteContextFolderId)).join('') : '
No notes
'}
+
+
`;
+ }).join('');
+
+ return `${folderSections || '
No notebooks yet
'}
+
+
+
+
+
+
+ `;
+};
+
+const adminUserRow = (u, currentUserId) => {
+ const enabled = u.enabled !== false;
+ const isSelf = u.id === currentUserId;
+ const created = u.created_time ? new Date(u.created_time).toISOString().slice(0, 10) : '';
+ const modalId = `user-modal-${u.id}`;
+ const totpEnabled = !!u.totpEnabled;
+ return `
+ ${escapeHtml(u.email || '')}
+ ${escapeHtml(u.full_name || '')}
+
+ ${enabled ? 'Enabled' : 'Disabled'}
+ ${totpEnabled ? 'MFA ' : ''}
+
+ ${escapeHtml(created)}
+
+ Actions
+
+
+
+
+ ${escapeHtml(u.email || '')}
+ ${u.full_name ? `${escapeHtml(u.full_name)} ` : ''}
+ ${enabled ? 'Enabled' : 'Disabled'}
+ ${totpEnabled ? 'MFA ' : ''}
+
+
+
+
+
+ ${!isSelf ? `
+
+
` : `
+
This is your admin account. You cannot disable or delete yourself.
`}
+
+
+
+
+
+ `;
+};
+
+const settingsPage = (options = {}) => {
+ const { user, settings = {}, userTotpEnabled = false, userTotpSetupSeed = '', userTotpSetupQr = '', isAdmin = false, isDockerAdmin = false, adminUsers = null, flash = '', flashError = '', activeTab = 'appearance' } = options;
+ const validTabs = ['appearance', 'profile', 'security'];
+ if (isAdmin) validTabs.push('admin');
+ const tab = validTabs.includes(activeTab) ? activeTab : 'appearance';
+ return `
+
+
+
+
+
+
+
+
+ ${appleSplashLinks}
+
+ Joplock Settings
+
+
+
+
+
+ ${flash ? `
${escapeHtml(flash)}
` : ''}
+ ${flashError ? `
${escapeHtml(flashError)}
` : ''}
+
+ Appearance
+ Profile
+ Security
+ ${isAdmin ? `Admin ` : ''}
+
+
+
+
+
+
+
+
+
+
+
+ ${isDockerAdmin ? `
+
+ Change Password
+ This account's password is managed via JOPLOCK_ADMIN_PASSWORD in the deployment configuration.
+
+ ` : `
+
+ Change Password
+ Enter your current password and a new password.
+
+
+ `}
+
+ Two-Factor Authentication
+ Protect your account with a 6-digit code from your authenticator app.
+ ${userTotpEnabled ? `
+
+
Enabled Two-factor authentication is active on your account.
+
+
+ ` : userTotpSetupSeed ? `
+
+
Setup in progress
+
Scan this QR code with your authenticator app:
+
+
Or enter manually: ${escapeHtml(userTotpSetupSeed)}
+
+
+
+ ` : `
+
+
Disabled Two-factor authentication is not enabled.
+
+
+ `}
+
+
+
+ ${isAdmin ? `
+
+
+
+ Users
+ ${adminUsers && adminUsers.length ? `
+ Email Name Status Created Actions
+ ${adminUsers.map(u => adminUserRow(u, user.id)).join('')}
+
` : 'No users found.
'}
+
+
` : ''}
+
+
+
+
+`;
+};
+
+// Column 3: editor
+const editorFragment = (note, folders, currentFolderId = '') => {
+ if (!note) {
+ return 'Select a note
';
+ }
+ const folderOptions = (folders || []).map(f =>
+ `${escapeHtml(f.title || 'Untitled')} `,
+ ).join('');
+ return `${noteMetaFragment(note).replace(' 'Saved ';
+
+// Render only inline markdown (bold, italic, strikethrough, inline code) — no block elements
+const renderInlineMarkdown = (text) => {
+ if (!text) return '';
+ let html = text;
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+ html = html.replace(/\*(.+?)\*/g, '$1 ');
+ html = html.replace(/~~(.+?)~~/g, '$1');
+ html = html.replace(/\+\+(.+?)\+\+/g, '$1 ');
+ html = html.replace(/`(.+?)`/g, '$1');
+ return html;
+};
+
+// Simple markdown to HTML renderer (handles common Joplin markdown)
+const renderMarkdown = (markdown) => {
+ if (!markdown) return '';
+ const codeBlocks = [];
+ const storeCodeBlock = (code, lang) => {
+ const i = codeBlocks.length;
+ codeBlocks.push({code, lang: lang || ''});
+ return `\x00CB${i}\x00`;
+ };
+
+ let text = String(markdown);
+
+ // Extract code blocks before any markdown/html transforms so their contents stay opaque.
+ text = text.replace(/^```(\w*)\n([\s\S]*?)\n```$/gm, (_m, lang, code) => storeCodeBlock(code, lang));
+
+ // Consecutive full-line backtick spans → code block (ASCII art pasted as `line` per line)
+ text = text.replace(/(^`.+`(?:\n(?:`.+`|[ \t]*))*)/gm, match => {
+ const lines = match.split('\n');
+ const code = lines.map(l => /^`([\s\S]*)`$/.test(l) ? l.replace(/^`([\s\S]*)`$/, '$1') : l).join('\n').trimEnd();
+ return storeCodeBlock(code);
+ });
+
+ let html = escapeHtml(text);
+
+ // Passthrough tags (used for blank line preservation in Joplin)
+ html = html.replace(/<br>/g, ' ');
+
+ // Passthrough (common in notes pasted from web/rich text)
+ html = html.replace(/ /g, ' ');
+
+ // Passthrough inline HTML tags (restore escaped versions)
+ // Handles: , , and normal URL src
+ html = html.replace(/<img\s([\s\S]*?)(?:\/)?>/g, (_m, attrs) => {
+ const restored = attrs.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '\'');
+ const srcMatch = restored.match(/src=":\/([\w]{32})"/);
+ const fixedAttrs = srcMatch ? restored.replace(/src=":\/([\w]{32})"/, `src="/resources/${srcMatch[1]}"`) : restored;
+ return ` `;
+ });
+
+ // Headings
+ html = html.replace(/^######\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^#####\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^####\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^###\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^##\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^#\s+(.+)$/gm, '$1 ');
+
+ // Horizontal rule
+ html = html.replace(/^---+$/gm, ' ');
+
+ // Bold + italic
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1 ');
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+ // Italic
+ html = html.replace(/\*(.+?)\*/g, '$1 ');
+ // Strikethrough
+ html = html.replace(/~~(.+?)~~/g, '$1');
+ // Underline (Joplin markdown-it plugin)
+ html = html.replace(/\+\+(.+?)\+\+/g, '$1 ');
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // Joplin resource images: 
+ html = html.replace(/!\[([^\]]*)\]\(:\/([0-9a-zA-Z]{32})\)/g, ' ');
+ // Regular images: 
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' ');
+ // Joplin resource links: [text](:/resourceId)
+ html = html.replace(/\[([^\]]*)\]\(:\/([0-9a-zA-Z]{32})\)/g, '$1 ');
+ // Regular links: [text](url)
+ html = html.replace(/\[([^\]]*)\]\(([^)]+)\)/g, '$1 ');
+
+ // Checkboxes
+ html = html.replace(/^- \[x\](?:\s+(.*))?$/gm, (_m, text) => `☑ ${text || ''}
`);
+ html = html.replace(/^- \[ \](?:\s+(.*))?$/gm, (_m, text) => `☐ ${text || ''}
`);
+
+ // Unordered lists
+ html = html.replace(/^[-*]\s+(.+)$/gm, '$1 ');
+ // Wrap consecutive in
+ html = html.replace(/((?:.*<\/li>\n?)+)/g, '');
+ // Ordered lists
+ html = html.replace(/^\d+\.\s+(.+)$/gm, ' $1 ');
+ // Wrap consecutive ol-item in
+ html = html.replace(/((?:.*<\/li>\n?)+)/g, (_m, items) => `${items.replace(/ class="ol-item"/g, '')} `);
+ // Isolate block tags so paragraph wrapping does not create invalid
...