commit d606ecf60fbd5edd20cdf1420113a19263201dbb Author: igor 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.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, '')}
Select a note
`); + } 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, '', '')}
Select a note
`); + } 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})` + : `[${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.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 = ``; + } + 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 ``; +}; + +// Column 2: note list with header (new note button + search) +const noteListFragment = (notes, selectedNoteId, folderId) => { + const header = `
+ ${folderId ? `` : ''} + +
`; + + 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`; + +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) => ``).join(''); + return `
+
${list}
+
${snapshots.length > 0 ? `
` : '
Select a snapshot to preview.
'}
+
+
+ ${snapshots.length > 0 ? escapeHtml(fmtHistoryTime(snapshots[0].savedTime)) : 'No snapshots'} + ${snapshots.length > 0 ? `` : ''} + +
`; +}; + +const historySnapshotPreviewFragment = snapshot => { + const body = (snapshot.body || '').replace(/ /g, ' '); + const preview = body.slice(0, 3000) + (body.length > 3000 ? '\n…' : ''); + return `
${escapeHtml(preview)}
`; +}; + +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 ``; + })() : (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 ``; + }).join(''); + + return ` + + + + + + + `; +}; + +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)} + + + +
+
+

Manage User

+ +
+
+ ${escapeHtml(u.email || '')} + ${u.full_name ? `${escapeHtml(u.full_name)}` : ''} + ${enabled ? 'Enabled' : 'Disabled'} + ${totpEnabled ? 'MFA' : ''} +
+
+
+ +
+ + +
+
+
+
+ + ${totpEnabled ? ` +

MFA is enabled for this user.

+
+ Show TOTP Secret +
+ TOTP QR + ${escapeHtml(u.totpSeed || '')} +
+
+
+ +
+ ` : ` +

MFA is not enabled. Generate a new TOTP seed for this user.

+
+ +
+ `} +
+ ${!isSelf ? `
+
+ +

${enabled ? 'User will not be able to log in or sync.' : 'User will be able to log in and sync again.'}

+ +
+
+ +

Permanently delete this user and all their notes, folders, and resources.

+ +
` : `
+

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 + + +
+
+
+
+

Joplock Settings

+

${escapeHtml(user.email)}

+
+ Back to notes +
+ ${flash ? `
${escapeHtml(flash)}
` : ''} + ${flashError ? `
${escapeHtml(flashError)}
` : ''} +
+ + + + ${isAdmin ? `` : ''} +
+ + +
+
+

Appearance

+

Font and theme settings — changes are saved automatically.

+
+ + + + + + + + + + +
+
+
+ + +
+
+
+

Profile

+

Update your name and email.

+
+ + +
+
+
+
+
+ + +
+
+
+

Session

+

Automatically log out after a period of inactivity.

+
+ + +
+
+
+
+ ${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.

+
+

To disable MFA, enter your current 6-digit code.

+
+ + +
+
+
+ ` : userTotpSetupSeed ? ` +
+

Setup in progress

+

Scan this QR code with your authenticator app:

+ MFA QR code +

Or enter manually: ${escapeHtml(userTotpSetupSeed)}

+
+ +

Enter the 6-digit code from your app to confirm setup:

+
+ + +
+
+
+ +
+
+ ` : ` +
+

Disabled Two-factor authentication is not enabled.

+
+ +
+
+ `} +
+
+ + ${isAdmin ? ` +
+
+

Create New User

+
+
+ + + +
+
+
+
+
+

Users

+ ${adminUsers && adminUsers.length ? `
+ + ${adminUsers.map(u => adminUserRow(u, user.id)).join('')} +
EmailNameStatusCreatedActions
` : '

No users found.

'} +
+
` : ''} +
+
+ + +`; +}; + +// Column 3: editor +const editorFragment = (note, folders, currentFolderId = '') => { + if (!note) { + return '
Select a note
'; + } + const folderOptions = (folders || []).map(f => + ``, + ).join(''); + return `
+
+ + + ${noteSyncStateFragment(note)} + + +
${escapeHtml(stripMarkdownForTitle(note.title || ''))}
+ + + Saving... + ${note.deletedTime ? `` : ''} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
${renderMarkdown(note.body || '')}
+
${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(/&nbsp;/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: ![alt](:/resourceId) + html = html.replace(/!\[([^\]]*)\]\(:\/([0-9a-zA-Z]{32})\)/g, '$1'); + // Regular images: ![alt](url) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + // 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, '
        $1
      '); + // Ordered lists + html = html.replace(/^\d+\.\s+(.+)$/gm, '
    • $1
    • '); + // Wrap consecutive ol-item
    • in
        + html = html.replace(/((?:
      1. .*<\/li>\n?)+)/g, (_m, items) => `
          ${items.replace(/ class="ol-item"/g, '')}
        `); + // Isolate block tags so paragraph wrapping does not create invalid

        ...

        markup. + html = html.replace(/\n+(<(?:h[1-6]|pre|ul|ol|blockquote|hr|div)[> ])/g, '\n\n$1'); + html = html.replace(/(<\/(?:h[1-6]|pre|ul|ol|blockquote|div)>|
        )\n+/g, '$1\n\n'); + // Blockquote + html = html.replace(/^>\s+(.+)$/gm, '
        $1
        '); + + // Preserve extra blank lines (3+ newlines) as explicit markers before paragraph splitting + html = html.replace(/\n{3,}/g, match => { + const extraBlanks = match.length - 2; // beyond the normal paragraph break + return `\n\n${Array.from({ length: extraBlanks }, () => '

        ').join('')}\n\n`; + }); + + // Paragraphs: double newline → paragraph break + const blocks = html.split('\n\n'); + const blockRe = /^<(?:h[1-6]|pre|ul|ol|blockquote|hr|div)|\x00CB\d+\x00/; + const out = []; + for (let i = 0; i < blocks.length; i++) { + const trimmed = blocks[i].trim(); + if (!trimmed) continue; + if (blockRe.test(trimmed)) { out.push(trimmed); continue; } + out.push(`

        ${trimmed.replace(/\n/g, '
        ')}

        `); + } + html = out.join(''); + + // Restore code block placeholders + html = html.replace(/\x00CB(\d+)\x00/g, (_m, i) => { + const b = codeBlocks[i]; + const cls = b.lang ? ` class="language-${b.lang}"` : ''; + return `
        ${escapeHtml(b.code)}
        `; + }); + + return html; +}; + +const searchResultsFragment = (notes) => { + if (!notes.length) return '
        No results
        '; + return notes.map(n => noteListItem(n, '', 'search')).join(''); +}; + +// Full page +const layoutPage = (options = {}) => { + const { user, navContent, editorContent, loginError, debug = false, mobileStartup = null, mobileEditorContent = '' } = options; + const settings = options.settings || {}; + const loggedIn = !!user; + + if (!loggedIn) { + return ` + + + + + + + + + + + + + ${appleSplashLinks} + + Joplock + + + + + +`; + } + + return ` + + + + + + + + + + + + + ${appleSplashLinks} + + + + + + Joplock + + +
        +
        + + +
        + ${editorContent || '
        Select a note
        '} +
        +
        + + + + + + + + +
        + + ${escapeHtml(user.fullName || user.email)} + ${noteMetaFragment({ createdTime: 0, updatedTime: 0 })} + + + Logout +
        + + + +`; +}; + +const loggedOutPage = () => ` + + + + + + + + + + + + + ${appleSplashLinks} + + Logging out... + + + + + +`; + +// MFA verification page (two-step login) +const mfaPage = (options = {}) => { + const { error = '' } = options; + return ` + + + + + + + + + Verify Identity - Joplock + + + + +`; +}; + +const mobileFoldersFragment = (folders, notes) => { + const allCount = (notes || []).filter(n => !n.deletedTime).length; + const notesByFolder = new Map(); + for (const note of notes || []) { + if (note.deletedTime) continue; + const key = note.parentId || ''; + if (!notesByFolder.has(key)) notesByFolder.set(key, 0); + notesByFolder.set(key, notesByFolder.get(key) + 1); + } + const allRow = ``; + const folderRows = (folders || []).filter(f => !f.isVirtualAllNotes && f.id !== trashFolderId).map(f => { + const count = notesByFolder.get(f.id) || 0; + return ``; + }).join(''); + return `${allRow}${folderRows || '
        📁
        No notebooks yet
        Create one in the desktop app
        '}`; +}; + +const mobileNotesFragment = (notes, folderId, folderTitle) => { + if (!notes.length) return '
        📝
        No notes yet
        Tap + to create one
        '; + return notes.map(n => ``).join(''); +}; + +const mobileSearchFragment = (notes) => { + if (!notes.length) return '
        🔍
        No results found
        '; + return notes.map(n => ``).join(''); +}; + + +module.exports = { + escapeHtml, + folderListItem, + folderListFragment, + navigationFragment, + noteListItem, + noteListFragment, + noteSyncStateFragment, + noteMetaFragment, + editorFragment, + autosaveStatusFragment, + autosaveConflictFragment, + historyModalFragment, + historySnapshotPreviewFragment, + stripMarkdownForTitle, + renderInlineMarkdown, + renderMarkdown, + searchResultsFragment, + mobileFoldersFragment, + mobileNotesFragment, + mobileSearchFragment, + settingsPage, + adminUserRow, + layoutPage, + loggedOutPage, + mfaPage, +}; diff --git a/cm-build/index.js b/cm-build/index.js new file mode 100644 index 0000000..6c913c7 --- /dev/null +++ b/cm-build/index.js @@ -0,0 +1,66 @@ +// CM6 bundle entry point — exports everything on window.CM +import { EditorView } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { keymap, placeholder, drawSelection, highlightActiveLine } from "@codemirror/view"; +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, HighlightStyle, StreamLanguage } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import { searchKeymap, highlightSelectionMatches, openSearchPanel, SearchQuery, setSearchQuery } from "@codemirror/search"; + +// Language parsers +import { javascript } from "@codemirror/lang-javascript"; +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { json } from "@codemirror/lang-json"; +import { sql } from "@codemirror/lang-sql"; +import { python } from "@codemirror/lang-python"; +import { xml } from "@codemirror/lang-xml"; +import { go } from "@codemirror/lang-go"; +import { cpp } from "@codemirror/lang-cpp"; +import { yaml } from "@codemirror/lang-yaml"; +import { shell } from "@codemirror/legacy-modes/mode/shell"; + +// Language description imports for codeLanguages mapping +import { LanguageDescription } from "@codemirror/language"; + +window.CM = { + // Core (same as before) + EditorView, + EditorState, + markdown, + markdownLanguage, + keymap, + placeholder, + drawSelection, + highlightActiveLine, + defaultKeymap, + history, + historyKeymap, + indentWithTab, + syntaxHighlighting, + defaultHighlightStyle, + bracketMatching, + searchKeymap, + highlightSelectionMatches, + openSearchPanel, + SearchQuery, + setSearchQuery, + tags, + HighlightStyle, + + // Language parsers (new) + javascript, + html, + css, + json, + sql, + python, + xml, + go, + cpp, + yaml, + shell, + StreamLanguage, + LanguageDescription, +}; diff --git a/cm-build/package-lock.json b/cm-build/package-lock.json new file mode 100644 index 0000000..8387bbd --- /dev/null +++ b/cm-build/package-lock.json @@ -0,0 +1,832 @@ +{ + "name": "cm-build", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "esbuild": "^0.20.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/cm-build/package.json b/cm-build/package.json new file mode 100644 index 0000000..2cce1f7 --- /dev/null +++ b/cm-build/package.json @@ -0,0 +1,28 @@ +{ + "private": true, + "type": "module", + "scripts": { + "build": "esbuild index.js --bundle --minify --outfile=../public/codemirror.min.js --format=iife --global-name=__CM" + }, + "dependencies": { + "@codemirror/view": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/legacy-modes": "^6.0.0", + "esbuild": "^0.20.0" + } +} diff --git a/docker-compose.example-full-build.yml b/docker-compose.example-full-build.yml new file mode 100644 index 0000000..f39ba28 --- /dev/null +++ b/docker-compose.example-full-build.yml @@ -0,0 +1,67 @@ +# Full-stack development example: Postgres + Joplin Server + Joplock (built from source) +# +# Same as docker-compose.example-full.yml but builds Joplock from source +# instead of pulling the pre-built image. +# +# Usage: +# docker compose -f docker-compose.example-full-build.yml up -d --build +# +# Joplock UI: http://localhost:5444 +# Joplin Server: internal only (not exposed to host by default) + +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + POSTGRES_DB: joplin + volumes: + - db_data:/var/lib/postgresql/data + + server: + image: joplin/server:latest + restart: unless-stopped + depends_on: + - db + environment: + APP_PORT: 22300 + APP_BASE_URL: http://server:22300 + SIGNUP_ENABLED: 'true' + DB_CLIENT: pg + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: joplin + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + + joplock: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + - server + ports: + - '5444:3001' + environment: + HOST: 0.0.0.0 + PORT: 3001 + JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444 + JOPLIN_SERVER_ORIGIN: http://server:22300 + JOPLIN_SERVER_PUBLIC_URL: http://server:22300 + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: joplin + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + # Create or reset Admin account in Joplin Server — optional, + # set to enable the admin panel or change admin password + # also can be used to turn off MFA if lost + JOPLOCK_ADMIN_EMAIL: '' + JOPLOCK_ADMIN_PASSWORD: '' + IGNORE_ADMIN_MFA: 'false' + +volumes: + db_data: diff --git a/docker-compose.example-full.yml b/docker-compose.example-full.yml new file mode 100644 index 0000000..b315470 --- /dev/null +++ b/docker-compose.example-full.yml @@ -0,0 +1,84 @@ +# Full-stack example: Postgres + Joplin Server + Joplock +# +# Zero-setup rollout for users who don't have an existing Joplin Server. +# Pulls pre-built images — no source checkout required. +# +# Usage: +# 1. Set JOPLOCK_PUBLIC_BASE_URL to the actual reachable URL for this host, such +# as a hostname served from a reverse proxy. +# (e.g. http://myhost:5444 or a Tailscale DNS name) +# 2. Set JOPLOCK_ADMIN_EMAIL and JOPLOCK_ADMIN_PASSWORD for the admin account +# 3. docker compose -f docker-compose.example-full.yml up -d +# +# Joplock UI: http://localhost:5444 +# Joplin Server: internal only (not exposed to host by default) +# +# Note: On WSL2, the raw WSL IP may not be reliably reachable from browsers. +# A Windows host port proxy or a Tailscale DNS name can work better. +# +# To connect desktop/mobile Joplin clients, use the Joplin Server URL if exposed + +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + POSTGRES_DB: joplin + volumes: + - db_data:/var/lib/postgresql/data + + server: + image: joplin/server:latest + restart: unless-stopped + depends_on: + - db + environment: + APP_PORT: 22300 + # Internal base URL — Joplin Server is not exposed to the host. + # Joplock proxies writes to it over the internal Docker network. + APP_BASE_URL: http://server:22300 + # Set to false to disable new user self-registration + SIGNUP_ENABLED: 'true' + DB_CLIENT: pg + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: joplin + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + + joplock: + image: ghcr.io/abort-retry-ignore/joplock:latest + restart: unless-stopped + depends_on: + - server + ports: + - '5444:3001' + environment: + HOST: 0.0.0.0 + PORT: 3001 + # Public URL where Joplock is reachable. + # Use the real browser-reachable host here, such as a LAN hostname, + # reverse-proxy URL, or Tailscale DNS name. + JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444 + # Internal URL Joplock uses to talk to Joplin Server + JOPLIN_SERVER_ORIGIN: http://server:22300 + # Joplin Server is not publicly exposed in this example. + # Desktop/mobile sync clients cannot connect directly — Joplock is the only UI. + # To expose Joplin Server for sync clients, add a ports mapping to the server + # service and set this to its public URL (e.g. http://10.0.1.14:22300). + JOPLIN_SERVER_PUBLIC_URL: http://server:22300 + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: joplin + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + # Admin account — set both to enable the admin panel + JOPLOCK_ADMIN_EMAIL: '' + JOPLOCK_ADMIN_PASSWORD: '' + # Set to true to skip MFA for the admin account defined above + IGNORE_ADMIN_MFA: 'false' + +volumes: + db_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..adb626f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# Sidecar deployment example: Joplock alongside an existing Joplin Server + Postgres +# +# Usage: +# 1. Edit the environment values below to match your existing setup +# 2. docker compose up -d +# +# ALTERNATELY you can just paste the joplock section into your existing joplin server +# compose file. +# +# On Linux, host.docker.internal is mapped to the host gateway so Joplock +# can reach services running on the host by default. + +services: + joplock: + image: ghcr.io/abort-retry-ignore/joplock:latest + ports: + # Host port : container port + - '5444:3001' + environment: + HOST: 0.0.0.0 + PORT: 3001 + # Public URL where Joplock is reachable from a browser + JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444 + # Internal URL Joplock uses to call Joplin Server API + JOPLIN_SERVER_ORIGIN: http://host.docker.internal:22300 + # Public URL users already use for Joplin Server (for sync clients) + JOPLIN_SERVER_PUBLIC_URL: http://localhost:22300 + # Base path prefix if Joplin Server sits behind a reverse proxy subpath + JOPLIN_PUBLIC_BASE_PATH: '' + # Postgres connection — must be the same database used by Joplin Server + POSTGRES_HOST: host.docker.internal + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: joplin + POSTGRES_USER: joplin + POSTGRES_PASSWORD: joplin + # Admin account — set both to enable the admin panel in Joplock + JOPLOCK_ADMIN_EMAIL: '' + JOPLOCK_ADMIN_PASSWORD: '' + # Set to true to skip MFA for the admin account defined above + IGNORE_ADMIN_MFA: 'false' + extra_hosts: + - 'host.docker.internal:host-gateway' diff --git a/hljs-build/index.js b/hljs-build/index.js new file mode 100644 index 0000000..d1b9c47 --- /dev/null +++ b/hljs-build/index.js @@ -0,0 +1,41 @@ +import hljs from 'highlight.js/lib/core'; +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; // html +import css from 'highlight.js/lib/languages/css'; +import json from 'highlight.js/lib/languages/json'; +import sql from 'highlight.js/lib/languages/sql'; +import python from 'highlight.js/lib/languages/python'; +import go from 'highlight.js/lib/languages/go'; +import cpp from 'highlight.js/lib/languages/cpp'; +import c from 'highlight.js/lib/languages/c'; +import yaml from 'highlight.js/lib/languages/yaml'; +import bash from 'highlight.js/lib/languages/bash'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; + +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('js', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('ts', typescript); +hljs.registerLanguage('html', xml); +hljs.registerLanguage('xml', xml); +hljs.registerLanguage('css', css); +hljs.registerLanguage('json', json); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('python', python); +hljs.registerLanguage('py', python); +hljs.registerLanguage('go', go); +hljs.registerLanguage('golang', go); +hljs.registerLanguage('cpp', cpp); +hljs.registerLanguage('c++', cpp); +hljs.registerLanguage('c', c); +hljs.registerLanguage('yaml', yaml); +hljs.registerLanguage('yml', yaml); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('sh', bash); +hljs.registerLanguage('shell', bash); +hljs.registerLanguage('zsh', bash); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerLanguage('docker-compose', yaml); + +window.hljs = hljs; diff --git a/hljs-build/package-lock.json b/hljs-build/package-lock.json new file mode 100644 index 0000000..d03d89c --- /dev/null +++ b/hljs-build/package-lock.json @@ -0,0 +1,428 @@ +{ + "name": "hljs-build", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "esbuild": "^0.20.0", + "highlight.js": "^11.11.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + } + } +} diff --git a/hljs-build/package.json b/hljs-build/package.json new file mode 100644 index 0000000..594aae1 --- /dev/null +++ b/hljs-build/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "type": "module", + "scripts": { + "build": "esbuild index.js --bundle --minify --outfile=../public/hljs.min.js --format=iife --global-name=__hljs" + }, + "dependencies": { + "highlight.js": "^11.11.1", + "esbuild": "^0.20.0" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..95b7f54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1358 @@ +{ + "name": "joplock", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "joplock", + "version": "0.1.0", + "dependencies": { + "@adobe/css-tools": "4.4.4", + "@mixmark-io/domino": "2.2.0", + "bcryptjs": "^3.0.3", + "html-entities": "1.4.0", + "pg": "8.16.3", + "qr-image": "^3.2.0" + }, + "devDependencies": { + "jsdom": "26.1.0", + "sharp": "^0.34.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-entities": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", + "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qr-image": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/qr-image/-/qr-image-3.2.0.tgz", + "integrity": "sha512-rXKDS5Sx3YipVsqmlMJsJsk6jXylEpiHRC2+nJy66fxA5ExYyGa4PqwteW69SaVmAb2OQ18HbYriT7cGQMbduw==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e66a973 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "joplock", + "private": true, + "version": "0.1.0", + "description": "Thin-client sidecar web UI for stock Joplin Server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "test": "node --test tests/*.test.js", + "generate:pwa-assets": "node ./scripts/generatePwaAssets.mjs", + "docker:build": "docker build -t joplock .", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:up:build": "docker compose -f docker-compose-build.yml up -d --build", + "docker:down:build": "docker compose -f docker-compose-build.yml down", + "docker:up:dev": "docker compose -f docker-compose.dev.yml up -d --build", + "docker:down:dev": "docker compose -f docker-compose.dev.yml down", + "docker:up:full": "docker compose -f docker-compose.example-full.yml up -d", + "docker:down:full": "docker compose -f docker-compose.example-full.yml down" + }, + "dependencies": { + "@adobe/css-tools": "4.4.4", + "@mixmark-io/domino": "2.2.0", + "bcryptjs": "^3.0.3", + "html-entities": "1.4.0", + "pg": "8.16.3", + "qr-image": "^3.2.0" + }, + "devDependencies": { + "jsdom": "26.1.0", + "sharp": "^0.34.5" + } +} diff --git a/plans/code-block-highlighting.md b/plans/code-block-highlighting.md new file mode 100644 index 0000000..1b57750 --- /dev/null +++ b/plans/code-block-highlighting.md @@ -0,0 +1,57 @@ +# Code Block Syntax Highlighting + Language Picker Modal + +## Overview +Add syntax highlighting for fenced code blocks inside the CM6 markdown editor, with a modal for inserting code blocks that lets the user pick a language. + +## 1. Rebuild CM6 bundle with language parsers + +**Install packages:** +- `@codemirror/lang-javascript`, `lang-html`, `lang-css`, `lang-json`, `lang-sql`, `lang-python`, `lang-xml`, `lang-go`, `lang-cpp`, `lang-yaml` +- `@codemirror/legacy-modes` (for shell via `StreamLanguage`) + +**Bundle build:** Add all language constructors to `window.CM` exports. Rebuild `public/codemirror.min.js`. + +**`initCM()` changes:** Pass a `codeLanguages` map to `markdown()` that maps info strings to parsers, including aliases: +- `js`, `javascript`, `jsx`, `ts`, `typescript`, `tsx` -> javascript +- `html` -> html +- `css` -> css +- `json` -> json +- `sql` -> sql +- `python`, `py` -> python +- `xml` -> xml +- `go`, `golang` -> go +- `c`, `cpp`, `c++` -> cpp +- `yaml`, `yml`, `dockerfile`, `docker-compose` -> yaml +- `bash`, `sh`, `shell`, `zsh` -> shell (via StreamLanguage) + +## 2. Code block language picker modal + +**Modal HTML** (following link modal pattern): +- `#code-modal-backdrop` + `#code-modal` with `.folder-modal` / `.folder-modal-card` classes +- `', + mobileStartup: { folderId: '__all_notes__', folderTitle: 'All Notes', noteId: 'n1', noteTitle: 'Hello' }, + }); + assert.ok(html.includes('var _mobileStartup={"folderId":"__all_notes__","folderTitle":"All Notes","noteId":"n1","noteTitle":"Hello"};')); + assert.ok(html.includes('function activeEditorForm(){if(isMobileShellMode()){')); + assert.ok(html.includes('function queryActiveEditor(selector){var form=activeEditorForm();')); + assert.ok(html.includes('function mobileResumeTarget(){')); + assert.ok(html.includes('
        ')); + assert.ok(html.includes('
        ')); + assert.ok(html.includes('showMobileScreen(\'editor\',\'forward\')')); + assert.ok(!html.includes('htmx.ajax(\'GET\',\'/fragments/editor/\'+encodeURIComponent(_mobileNoteId)+\'?currentFolderId=\'+encodeURIComponent(_mobileFolderId),{target:\'#mobile-editor-body\',swap:\'innerHTML\'})')); + assert.ok(!html.includes('function getTA(){return document.getElementById(\'note-body\')}')); +}); + +test('logged out layout does not show global auth code field', () => { + const html = layoutPage({ user: null, loginError: '' }); + assert.ok(!html.includes('Global auth code')); +}); + +test('logged in layout preserves plain square brackets on preview round trip', () => { + const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' }); + assert.ok(html.includes('function htmlToMarkdown(el){')); + assert.ok(html.includes('var root=el.cloneNode(true);')); + assert.ok(html.includes("root.querySelectorAll('.pre-copy-btn').forEach(function(btn){btn.remove()})")); + assert.ok(html.includes('getTurndown().turndown(root.innerHTML)')); + assert.ok(html.includes('$1')); +}); + +test('logged in layout includes extended Joplin theme options', () => { + const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' }); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); +}); + +test('logged in layout uses ordered list command and block transforms in preview toolbar', () => { + const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' }); + assert.ok(html.includes('function syncEditorModeButtons(){')); + assert.ok(html.includes('mdBtn.classList.toggle(\'active\',mode===\'markdown\')')); + assert.ok(html.includes('pvBtn.classList.toggle(\'active\',mode===\'preview\')')); + assert.ok(html.includes('var _previewDirty=false;')); + assert.ok(html.includes('if(pv.contentEditable===\'true\'&&_previewDirty){syncPV()}')); + assert.ok(!html.includes('clean-md-toggle')); + assert.ok(html.includes('function transformPVBlock(tagName,defaultText)')); + assert.ok(html.includes('document.execCommand(\'insertOrderedList\',false,null)')); + assert.ok(html.includes('if(p===\'> \'&&transformPVBlock(\'blockquote\',\'Quote\'))return')); + assert.ok(html.includes('var fenced=String.fromCharCode(10)+String.fromCharCode(96,96,96)+String.fromCharCode(10)')); + assert.ok(html.includes('if(a===fenced&&b===fenced&&transformPVBlock(\'pre\',\'code\'))return')); + assert.ok(html.includes('var inlineCode=String.fromCharCode(96)')); + assert.ok(html.includes('if(a===inlineCode&&b===inlineCode){document.execCommand(\'insertHTML\',false,\'\'+(window.getSelection().toString()||\'code\')+\'\')')); + assert.ok(html.includes('function formatStamp(kind){')); + assert.ok(html.includes('var _dateFmt=')); + assert.ok(html.includes('var _datetimeFmt=')); + assert.ok(html.includes('fmt.replace(\'YYYY\'')); + assert.ok(html.includes('function insertStamp(kind){insertTxt(formatStamp(kind))}')); + assert.ok(html.includes('var pre=el&&el.closest?el.closest(\'pre\'):null')); + assert.ok(html.includes('if(pre&&pv.contains(pre)){e.preventDefault();')); + assert.ok(html.includes('if(insertPVText(\'\\n\'))syncPV();return}')); +}); + +test('logged in layout emits inline script that parses', () => { + const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '
        ' }); + const match = html.match(/