snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

commit 970ebf52cf5910cc2f16975f6021a80ada3a308a
parent 75c1e31e8c45fc0eb6860007da09c05d2de16097
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Wed,  3 Jun 2026 11:09:20 -0300

better

Diffstat:
M.dockerignore | 2++
M.env.example | 17+++++++++++++++++
M.gitignore | 4++++
MDockerfile | 4++++
MREADME.md | 259+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Abackend/Dockerfile | 10++++++++++
Abackend/package-lock.json | 846+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/package.json | 17+++++++++++++++++
Abackend/src/db.js | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/src/messages.js | 18++++++++++++++++++
Abackend/src/routes/documents.js | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/src/server.js | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/src/utils.js | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocker-compose.yml | 20+++++++++++++++++++-
Mnginx.conf | 13+++++++++++++
Mpackage-lock.json | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 3++-
Apublic/robots.txt | 2++
Msrc/App.jsx | 311++++++-------------------------------------------------------------------------
Asrc/components/EditorLayout.jsx | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/IconButton.jsx | 24++++++++++++++++++++++++
Asrc/components/ReadOnlyBanner.jsx | 16++++++++++++++++
Asrc/components/SaveStatus.jsx | 19+++++++++++++++++++
Asrc/components/ShareModal.jsx | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/StatusBadge.jsx | 3+++
Asrc/components/icons/ClearIcon.jsx | 10++++++++++
Asrc/components/icons/DownloadIcon.jsx | 12++++++++++++
Asrc/components/icons/ShareIcon.jsx | 13+++++++++++++
Asrc/components/icons/UploadIcon.jsx | 12++++++++++++
Asrc/components/icons/iconProps.js | 10++++++++++
Asrc/components/icons/index.js | 4++++
Asrc/hooks/useEditLock.js | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hooks/useServerAutosave.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/api.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/clientId.js | 18++++++++++++++++++
Asrc/lib/download.js | 14++++++++++++++
Msrc/lib/editorConstants.js | 22+++++++++++-----------
Asrc/lib/strings.js | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pages/LinkErrorPage.jsx | 19+++++++++++++++++++
Asrc/pages/LocalEditorPage.jsx | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pages/SharedEditPage.jsx | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pages/SharedViewPage.jsx | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/styles.css | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvite.config.js | 22++++++++++++++++++++--
Mvite.preview.config.js | 6++++++
Mvite.shared.js | 39+++++++++++++++++++++++++++++++++++++++
46 files changed, 3466 insertions(+), 404 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -1,4 +1,6 @@ node_modules +backend/node_modules +data dist .git .gitignore diff --git a/.env.example b/.env.example @@ -1,3 +1,20 @@ # Comma-separated hostnames allowed by Vite (dev + preview). # Use * to allow any host (not recommended in production). ALLOWED_HOSTS=snow.pablomurad.com,localhost,127.0.0.1 + +# Backend (local npm — terminal 2: cd backend && npm run dev) +PORT=41738 +DATABASE_PATH=./data/snow.db + +# Optional CORS for local dev when API is called directly (default: use Vite proxy) +# CORS_ORIGIN=http://localhost:41737 + +# Frontend API base (empty = same origin /api via proxy) +# VITE_API_BASE= + +# Public site URL for shared view/edit links (empty = use current browser origin) +# Set when developing on localhost but sharing links for production: +VITE_PUBLIC_ORIGIN=https://snow.pablomurad.com + +# Allow search engines to index the site (true | false). Rebuild after changing. +VITE_ALLOW_SEARCH_INDEXING=false diff --git a/.gitignore b/.gitignore @@ -37,3 +37,7 @@ coverage/ # Docker (optional local overrides) docker-compose.override.yml + +# SQLite data (local / Docker volume) +data/ +!data/.gitkeep diff --git a/Dockerfile b/Dockerfile @@ -1,5 +1,9 @@ # Build FROM node:20-alpine AS build +ARG VITE_PUBLIC_ORIGIN=https://snow.pablomurad.com +ARG VITE_ALLOW_SEARCH_INDEXING=false +ENV VITE_PUBLIC_ORIGIN=$VITE_PUBLIC_ORIGIN +ENV VITE_ALLOW_SEARCH_INDEXING=$VITE_ALLOW_SEARCH_INDEXING WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci diff --git a/README.md b/README.md @@ -3,65 +3,32 @@ **Version:** 0.0.1 **Creator:** Pablo Murad — [pablomurad@pm.me](mailto:pablomurad@pm.me) -A cozy online editor inspired by snow and calm reading. Write in **Markdown** or experimental **Org-mode** in one panel and see the live preview in the other. No backend, no login — everything runs in the browser. +A cozy online editor inspired by snow and calm reading. Write in **Markdown** or experimental **Org-mode** with live preview. Use it **locally** in the browser (localStorage) or **share** documents by link with a small backend API. ## Features - Real-time editor with side-by-side preview - **Markdown** (full) and **Org-mode** (experimental, basic parser) -- Mode switch saved in `localStorage` -- Separate drafts per mode (autosave, debounced) -- **Clear** — start a blank document (instant when still on the default starter text) -- Save as `.md` or `.org` -- Import `.md`, `.markdown`, `.org`, `.txt` -- Long-document friendly: deferred preview, debounced storage, lazy `marked` load -- Production Docker image: nginx serving static build (compact) -- Word and character counters in both modes +- **Local mode** — drafts in `localStorage`, import/export `.md` / `.org` +- **Shared links** — view-only and edit links with optional expiry +- **Edit lock** — only one editor at a time per document (no realtime collaboration) +- **Server autosave** on shared edit links (debounced 1s) +- Mode switch saved in `localStorage` (local editor only) +- Production Docker: nginx (frontend) + Node API (backend) + SQLite volume - Preview HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify) -## Editing modes - -Use the **Markdown** / **Org-mode** switch below the subtitle. - -| Mode | Preview | Save | -|------|---------|------| -| Markdown | `marked` | `document.md` | -| Org-mode | Custom parser (`src/lib/parseOrgMode.js`) | `document.org` | - -### Browser storage keys - -| Key | Purpose | -|-----|---------| -| `editor_mode` | Last selected mode | -| `snow_editor_markdown_content` | Markdown draft | -| `snow_editor_org_content` | Org-mode draft | - -Legacy key `cozy-markdown-editor-content` is migrated once into `snow_editor_markdown_content`. - -### Org-mode (partial support) - -Supported in the experimental parser: - -- Headings `*` … `****` with optional **TODO** / **DONE** badges -- Simple lists (`-`, `+`) -- Bold `*text*`, italic `/text/`, inline code `~code~` and `=code=` -- Links `[[url][label]]` and `[[url]]` -- Blocks `#+BEGIN_SRC` / `#+END_SRC` and `#+BEGIN_QUOTE` / `#+END_QUOTE` - -Not supported yet: agenda, tables, properties, drawers, advanced blocks, PDF export. - ## Stack -- React + Vite +- **Frontend:** React + Vite + react-router-dom +- **Backend:** Node.js (≥22.5) + Express + SQLite (`node:sqlite`) - [marked](https://marked.js.org/) — Markdown to HTML -- Custom Org parser — lightweight JavaScript -- [DOMPurify](https://github.com/cure53/DOMPurify) — HTML sanitization +- Custom Org parser — [`src/lib/parseOrgMode.js`](src/lib/parseOrgMode.js) - Plain CSS (cozy / snow theme) ## Prerequisites -- **Local:** Node.js 18 or newer -- **Docker (optional):** Docker and Docker Compose +- **Local:** Node.js **22.5+** (frontend + backend) +- **Docker:** Docker and Docker Compose ## Environment @@ -71,103 +38,197 @@ cp .env.example .env | Variable | Description | |----------|-------------| -| `ALLOWED_HOSTS` | Hostnames for Vite dev/preview (e.g. `snow.pablomurad.com,localhost`) | +| `ALLOWED_HOSTS` | Hostnames for Vite dev/preview | +| `PORT` | Backend port (default `41738`) | +| `DATABASE_PATH` | SQLite path (default `./data/snow.db`) | +| `CORS_ORIGIN` | Optional; dev only if not using Vite proxy | +| `VITE_API_BASE` | Optional API prefix (empty = `/api` on same origin) | +| `VITE_PUBLIC_ORIGIN` | Public site URL for share links (e.g. `https://snow.pablomurad.com`). If empty, uses the browser origin (`localhost` in local dev) | +| `VITE_ALLOW_SEARCH_INDEXING` | `true` to allow search engines; `false` adds `noindex` meta and `Disallow: /` in `robots.txt`. **Rebuild required** after changing | + +### Search engine indexing + +Set in `.env` before `npm run build` or Docker build: + +- `VITE_ALLOW_SEARCH_INDEXING=false` (default) — site and shared links are not indexed; `robots.txt` blocks crawlers +- `VITE_ALLOW_SEARCH_INDEXING=true` — public indexing allowed + +The API (`/api/*`) always sends `X-Robots-Tag: noindex` via nginx, regardless of this setting. + +## Run with Docker + +```bash +docker compose up -d --build +``` + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:41737 | +| Backend API | http://localhost:41738/api/health | + +SQLite data is stored in `./data/snow.db` (volume `./data:/app/data` on the backend service). + +The frontend nginx proxies `/api/` to the backend container. -## Local installation +## Run locally with npm + +**Terminal 1 — frontend:** ```bash npm install cp .env.example .env +npm run dev ``` -## Run without Docker +Open: **http://localhost:41737** + +**Terminal 2 — backend:** ```bash +cd backend +npm install npm run dev ``` -Open: **http://localhost:41737** +API: **http://localhost:41738/api/health** + +Vite proxies `/api` → `http://localhost:41738` in dev and preview. ```bash npm run build -npm run preview +npm run preview # frontend only; backend must still be running for sharing ``` -## Pre-deploy checklist (production) +## Share a document -1. Commit all sources under `src/` (including `src/lib/`). -2. Local smoke test: `npm run build` then `npm run preview`. -3. For **local dev** behind a custom host: `cp .env.example .env` and set `ALLOWED_HOSTS`. -4. Deploy: `docker compose up -d --build` (nginx serves static `dist/` on port **41737**). -5. Verify **http://localhost:41737** (or your public URL) — editor, both modes, save/import, Clear. +1. Open the **local** editor at `/`. +2. Click **Share**. +3. Set title and link expiry (1h, 24h, 7d, 30d, or never). +4. Copy the **view link** (`/v/...`) or **edit link** (`/e/...`). URLs use `VITE_PUBLIC_ORIGIN` when set; otherwise they use whatever host you opened in the browser. -Production Docker uses **nginx:alpine** (lightweight). It does not need `ALLOWED_HOSTS` — any hostname works for static files. +### View link (`/v/:token`) -## Run with Docker +- Read-only preview +- Download `.md` or `.org` +- No server saves -```bash -docker compose up -d --build -``` +### Edit link (`/e/:token`) -Open: **http://localhost:41737** +- Tries to acquire an **edit lock** for your browser (`snow_client_id` in localStorage) +- If lock granted: live preview, **Save to server**, autosave after 1s idle +- If another person holds the lock: read-only + friendly message +- **Release edit lock** releases the lock for others -Port **41737** (`41737:41737` in `docker-compose.yml`). +### Edit lock rules -## Editor actions +- One active editor per document +- Lock TTL: **2 minutes**; renewed every **30 seconds** while the tab is open +- Lock release on tab close is best-effort (`fetch` keepalive) -### Clear +### Link expiry -1. Click **Clear** -2. If the editor still shows the default starter for the current mode, it clears immediately -3. Otherwise confirm, then you get a **blank** document (persisted on reload) +- Optional expiry when creating the share +- Expired links return **410** with a friendly page: “This link has expired.” -Default starter text appears only on first visit per mode (or after you delete storage). To get the sample again, clear browser storage for the site or paste the sample manually. +## Local editor (unchanged) + +- Routes: `/` only for local editing +- Badge **Local** +- Autosave to `localStorage` (500ms debounce) +- **Save .md** / **Save .org**, **Import**, **Clear**, mode switch + +### Browser storage keys -### Save +| Key | Purpose | +|-----|---------| +| `editor_mode` | Last selected mode | +| `snow_editor_markdown_content` | Markdown draft | +| `snow_editor_org_content` | Org-mode draft | +| `snow_client_id` | Browser id for shared edit locks | -- **Markdown:** **Save .md** → `document.md` -- **Org-mode:** **Save .org** → `document.org` +## API endpoints -### Import +| Method | Path | +|--------|------| +| GET | `/api/health` | +| POST | `/api/documents` | +| GET | `/api/documents/view/:token` | +| GET | `/api/documents/edit/:token` | +| POST | `/api/documents/edit/:token/lock` | +| POST | `/api/documents/edit/:token/lock/refresh` | +| DELETE | `/api/documents/edit/:token/lock` | +| PUT | `/api/documents/edit/:token` | -| Extension | Mode after import | -|-----------|-------------------| -| `.md`, `.markdown` | Markdown | -| `.org` | Org-mode | -| `.txt` | Keeps current mode | +Rate limit: 60 requests/minute per IP on `/api`. Max document size: **1 MB**. -## Long documents +## Current limitations -- Editor and preview scroll independently -- Preview uses `useDeferredValue` -- Autosave debounced (500ms) -- If storage quota is exceeded, a footer warning appears — use **Save .md** / **Save .org** +- No realtime collaboration (no WebSocket, Yjs, CRDT, remote cursors) +- No version history UI (`document_versions` is stored server-side only) +- No login or user accounts +- No email or PDF export +- Anyone with a valid **edit link** can edit when the document is not locked +- `POST /api/documents` is open (mitigated by rate limit and long random tokens) ## Security -Content is converted to HTML (`marked` or `parseOrgMode`), then `DOMPurify.sanitize()` before preview. User text is escaped in the Org parser before tags are applied. +- Preview uses DOMPurify on the frontend +- Tokens: `crypto.randomBytes(32)`; IDs: `crypto.randomUUID()` +- Org parser escapes text before applying markup ## Project structure ``` -├── Dockerfile +├── backend/ +│ ├── Dockerfile +│ ├── package.json +│ └── src/ +│ ├── server.js +│ ├── db.js +│ ├── utils.js +│ └── routes/documents.js +├── data/ # SQLite (gitignored, .gitkeep only) +├── docker-compose.yml +├── Dockerfile # frontend static build ├── nginx.conf -├── vite.config.js -├── vite.preview.config.js -├── vite.shared.js -├── public/ -│ ├── favicon.png -│ └── favicon.svg -└── src/ - ├── main.jsx - ├── App.jsx - ├── styles.css - └── lib/ - ├── editorConstants.js - ├── parseOrgMode.js - └── previewHtml.js +├── src/ +│ ├── App.jsx # routes +│ ├── pages/ +│ ├── components/ +│ ├── hooks/ +│ └── lib/ +└── public/ ``` +## Maintenance and optimization + +Repeat before each release: + +1. `npm run build` — confirm chunk sizes; no errors +2. `rg` for stray Portuguese in `src/` and `backend/src/` — should be empty +3. `VITE_ALLOW_SEARCH_INDEXING=false` — view-source has `noindex`; `/robots.txt` contains `Disallow: /` +4. `VITE_ALLOW_SEARCH_INDEXING=true` — no blocking robots meta; `/robots.txt` allows crawlers +5. Shared routes (`/v/`, `/e/`) load via lazy chunks (smaller initial bundle on `/`) +6. Rebuild Docker images after any `VITE_*` change + +**Kept light by design:** inline SVG icons (no icon font), lazy `marked`, manual vendor chunks, minimal backend deps (`express` + `node:sqlite`). + +## Manual test checklist + +1. `docker compose up -d --build` works +2. Frontend at http://localhost:41737 +3. `GET /api/health` returns `{ "ok": true }` +4. Local editor at `/` works (Markdown + Org, import, clear, localStorage) +5. **Share** creates view + edit links +6. `/v/:token` is read-only +7. `/e/:token` edits with autosave when lock is free +8. Second browser/profile on same edit link → read-only (423) +9. Lock expires after ~2 minutes without refresh +10. Expired share shows friendly message +11. Content over 1 MB rejected with 413 +12. Download `.md` / `.org` still works on shared pages +13. `/robots.txt` and HTML robots meta match `VITE_ALLOW_SEARCH_INDEXING` + ## License Free to use for personal projects and learning. diff --git a/backend/Dockerfile b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev +COPY src ./src +ENV NODE_ENV=production +ENV PORT=41738 +ENV DATABASE_PATH=/app/data/snow.db +EXPOSE 41738 +CMD ["node", "src/server.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json @@ -0,0 +1,846 @@ +{ + "name": "snow-editor-backend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "snow-editor-backend", + "version": "0.0.1", + "dependencies": { + "express": "^4.21.2", + "express-rate-limit": "^7.5.0" + }, + "engines": { + "node": ">=22.5.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/package.json b/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "snow-editor-backend", + "private": true, + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=22.5.0" + }, + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js" + }, + "dependencies": { + "express": "^4.21.2", + "express-rate-limit": "^7.5.0" + } +} diff --git a/backend/src/db.js b/backend/src/db.js @@ -0,0 +1,70 @@ +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; + +let db; + +export function getDb() { + if (!db) { + throw new Error('Database not initialized'); + } + return db; +} + +export function initDb(databasePath) { + const dir = path.dirname(databasePath); + fs.mkdirSync(dir, { recursive: true }); + + db = new DatabaseSync(databasePath); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + initSchema(db); + return db; +} + +function initSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'Untitled document', + mode TEXT NOT NULL CHECK (mode IN ('markdown', 'org')), + content TEXT NOT NULL DEFAULT '', + view_token TEXT UNIQUE NOT NULL, + edit_token TEXT UNIQUE NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS edit_locks ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + lock_token TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS document_versions ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + title TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('markdown', 'org')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_documents_view_token ON documents(view_token); + CREATE INDEX IF NOT EXISTS idx_documents_edit_token ON documents(edit_token); + CREATE INDEX IF NOT EXISTS idx_edit_locks_document_id ON edit_locks(document_id); + CREATE INDEX IF NOT EXISTS idx_edit_locks_lock_token ON edit_locks(lock_token); + `); +} + +export function purgeExpiredLocks(database) { + const now = new Date().toISOString(); + database.prepare('DELETE FROM edit_locks WHERE expires_at <= ?').run(now); +} diff --git a/backend/src/messages.js b/backend/src/messages.js @@ -0,0 +1,18 @@ +export const MSG = { + NOT_FOUND: 'Document not found.', + EXPIRED: 'This link has expired.', + INVALID_MODE: 'Invalid mode. Use markdown or org.', + INVALID_CONTENT: 'Invalid content.', + CONTENT_TOO_LARGE: 'Document is larger than 1 MB.', + INVALID_EXPIRES_IN: 'Invalid validity. Use 1h, 24h, 7d, 30d, or never.', + INVALID_CLIENT: 'clientId is required.', + INVALID_REQUEST: 'clientId and lockToken are required.', + DOCUMENT_LOCKED: 'This document is being edited by someone else.', + LOCK_INVALID: 'Edit lock not found or expired.', + LOCK_REQUIRED: 'You need an active edit lock to save.', + RATE_LIMIT: 'Too many requests. Try again in a minute.', + INVALID_JSON: 'Invalid JSON in request body.', + INTERNAL_ERROR: 'Internal server error.', +}; + +export const DEFAULT_DOCUMENT_TITLE = 'Untitled document'; diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js @@ -0,0 +1,316 @@ +import { Router } from 'express'; +import { getDb, purgeExpiredLocks } from '../db.js'; +import { DEFAULT_DOCUMENT_TITLE, MSG } from '../messages.js'; +import { + assertContentSize, + assertMode, + isExpired, + lockExpiresAtFromNow, + newId, + parseExpiresIn, + secureToken, + sendError, +} from '../utils.js'; + +const router = Router(); + +function getDocumentByViewToken(token) { + return getDb() + .prepare('SELECT * FROM documents WHERE view_token = ?') + .get(token); +} + +function getDocumentByEditToken(token) { + return getDb() + .prepare('SELECT * FROM documents WHERE edit_token = ?') + .get(token); +} + +function documentToPublic(doc) { + return { + id: doc.id, + title: doc.title, + mode: doc.mode, + content: doc.content, + expiresAt: doc.expires_at, + createdAt: doc.created_at, + updatedAt: doc.updated_at, + }; +} + +function checkDocumentAccess(doc, res) { + if (!doc) { + sendError(res, 404, 'NOT_FOUND', MSG.NOT_FOUND); + return false; + } + if (isExpired(doc.expires_at)) { + sendError(res, 410, 'EXPIRED', MSG.EXPIRED); + return false; + } + return true; +} + +function getActiveLock(documentId) { + purgeExpiredLocks(getDb()); + const now = new Date().toISOString(); + return getDb() + .prepare( + 'SELECT * FROM edit_locks WHERE document_id = ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1', + ) + .get(documentId, now); +} + +router.get('/health', (_req, res) => { + res.json({ ok: true }); +}); + +router.post('/documents', (req, res) => { + const { title, mode, content, expiresIn } = req.body ?? {}; + + if (!assertMode(mode)) { + return sendError(res, 400, 'INVALID_MODE', MSG.INVALID_MODE); + } + + if (typeof content !== 'string') { + return sendError(res, 400, 'INVALID_CONTENT', MSG.INVALID_CONTENT); + } + + if (!assertContentSize(content)) { + return sendError(res, 413, 'CONTENT_TOO_LARGE', MSG.CONTENT_TOO_LARGE); + } + + const expiresAt = parseExpiresIn(expiresIn); + if (expiresAt === undefined) { + return sendError( + res, + 400, + 'INVALID_EXPIRES_IN', + MSG.INVALID_EXPIRES_IN, + ); + } + + const id = newId(); + const viewToken = secureToken(); + const editToken = secureToken(); + const docTitle = + typeof title === 'string' && title.trim() ? title.trim() : DEFAULT_DOCUMENT_TITLE; + const now = new Date().toISOString(); + + getDb() + .prepare( + `INSERT INTO documents (id, title, mode, content, view_token, edit_token, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(id, docTitle, mode, content, viewToken, editToken, expiresAt, now, now); + + res.status(201).json({ + id, + title: docTitle, + mode, + viewToken, + editToken, + viewUrl: `/v/${viewToken}`, + editUrl: `/e/${editToken}`, + expiresAt, + }); +}); + +router.get('/documents/view/:token', (req, res) => { + const doc = getDocumentByViewToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + res.json(documentToPublic(doc)); +}); + +router.get('/documents/edit/:token', (req, res) => { + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + res.json(documentToPublic(doc)); +}); + +router.post('/documents/edit/:token/lock', (req, res) => { + const { clientId } = req.body ?? {}; + if (!clientId || typeof clientId !== 'string') { + return sendError(res, 400, 'INVALID_CLIENT', MSG.INVALID_CLIENT); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + + purgeExpiredLocks(getDb()); + const activeLock = getActiveLock(doc.id); + + if (activeLock) { + if (activeLock.client_id === clientId) { + const expiresAt = lockExpiresAtFromNow(); + const now = new Date().toISOString(); + getDb() + .prepare( + 'UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?', + ) + .run(expiresAt, now, activeLock.id); + + return res.json({ + locked: true, + lockToken: activeLock.lock_token, + expiresAt, + }); + } + + return res.status(423).json({ + locked: false, + error: 'DOCUMENT_LOCKED', + message: MSG.DOCUMENT_LOCKED, + lockExpiresAt: activeLock.expires_at, + }); + } + + const lockId = newId(); + const lockToken = secureToken(); + const expiresAt = lockExpiresAtFromNow(); + const now = new Date().toISOString(); + + getDb() + .prepare( + `INSERT INTO edit_locks (id, document_id, lock_token, client_id, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(lockId, doc.id, lockToken, clientId, expiresAt, now, now); + + res.json({ locked: true, lockToken, expiresAt }); +}); + +router.post('/documents/edit/:token/lock/refresh', (req, res) => { + const { clientId, lockToken } = req.body ?? {}; + if (!clientId || !lockToken) { + return sendError(res, 400, 'INVALID_REQUEST', MSG.INVALID_REQUEST); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + + purgeExpiredLocks(getDb()); + const lock = getDb() + .prepare( + 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', + ) + .get(doc.id, lockToken, clientId); + + if (!lock || isExpired(lock.expires_at)) { + return sendError( + res, + 403, + 'LOCK_INVALID', + MSG.LOCK_INVALID, + ); + } + + const expiresAt = lockExpiresAtFromNow(); + const now = new Date().toISOString(); + getDb() + .prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?') + .run(expiresAt, now, lock.id); + + res.json({ locked: true, lockToken, expiresAt }); +}); + +router.delete('/documents/edit/:token/lock', (req, res) => { + const { clientId, lockToken } = req.body ?? {}; + if (!clientId || !lockToken) { + return sendError(res, 400, 'INVALID_REQUEST', MSG.INVALID_REQUEST); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!doc) { + return sendError(res, 404, 'NOT_FOUND', MSG.NOT_FOUND); + } + + const result = getDb() + .prepare( + 'DELETE FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', + ) + .run(doc.id, lockToken, clientId); + + if (result.changes === 0) { + return sendError(res, 403, 'LOCK_INVALID', MSG.LOCK_INVALID); + } + + res.json({ success: true }); +}); + +function validateLock(doc, clientId, lockToken) { + purgeExpiredLocks(getDb()); + const lock = getDb() + .prepare( + 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', + ) + .get(doc.id, lockToken, clientId); + + if (!lock || isExpired(lock.expires_at)) { + return null; + } + return lock; +} + +router.put('/documents/edit/:token', (req, res) => { + const { clientId, lockToken, title, mode, content } = req.body ?? {}; + + if (!clientId || !lockToken) { + return sendError( + res, + 403, + 'LOCK_REQUIRED', + MSG.LOCK_REQUIRED, + ); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + + const lock = validateLock(doc, clientId, lockToken); + if (!lock) { + return sendError( + res, + 403, + 'LOCK_REQUIRED', + MSG.LOCK_REQUIRED, + ); + } + + if (!assertMode(mode)) { + return sendError(res, 400, 'INVALID_MODE', MSG.INVALID_MODE); + } + + if (typeof content !== 'string') { + return sendError(res, 400, 'INVALID_CONTENT', MSG.INVALID_CONTENT); + } + + if (!assertContentSize(content)) { + return sendError(res, 413, 'CONTENT_TOO_LARGE', MSG.CONTENT_TOO_LARGE); + } + + const docTitle = + typeof title === 'string' && title.trim() ? title.trim() : doc.title; + const now = new Date().toISOString(); + + const db = getDb(); + const versionId = newId(); + db.prepare( + `INSERT INTO document_versions (id, document_id, title, mode, content, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(versionId, doc.id, doc.title, doc.mode, doc.content, now); + + db.prepare( + `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`, + ).run(docTitle, mode, content, now, doc.id); + + const expiresAt = lockExpiresAtFromNow(); + db.prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?').run( + expiresAt, + now, + lock.id, + ); + + res.json({ success: true, updated_at: now }); +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js @@ -0,0 +1,76 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { initDb } from './db.js'; +import { MSG } from './messages.js'; +import documentsRouter from './routes/documents.js'; + +const PORT = Number(process.env.PORT) || 41738; +const DATABASE_PATH = + process.env.DATABASE_PATH || + (process.env.NODE_ENV === 'production' ? '/app/data/snow.db' : './data/snow.db'); +const CORS_ORIGIN = process.env.CORS_ORIGIN?.trim() || ''; + +initDb(DATABASE_PATH); + +const app = express(); + +app.set('trust proxy', 1); + +if (CORS_ORIGIN) { + app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin === CORS_ORIGIN) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + } + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + next(); + }); +} + +app.use(express.json({ limit: '1.1mb' })); + +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { + error: 'RATE_LIMIT', + message: MSG.RATE_LIMIT, + }, +}); + +app.use('/api', apiLimiter, documentsRouter); + +app.use((err, _req, res, next) => { + if (err instanceof SyntaxError && 'body' in err) { + return res.status(400).json({ + error: 'INVALID_JSON', + message: MSG.INVALID_JSON, + }); + } + next(err); +}); + +app.use((err, _req, res, _next) => { + if (err?.type === 'entity.too.large') { + return res.status(413).json({ + error: 'CONTENT_TOO_LARGE', + message: MSG.CONTENT_TOO_LARGE, + }); + } + console.error(err); + res.status(500).json({ + error: 'INTERNAL_ERROR', + message: MSG.INTERNAL_ERROR, + }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Snow Editor API listening on http://0.0.0.0:${PORT}`); + console.log(`Database: ${DATABASE_PATH}`); +}); diff --git a/backend/src/utils.js b/backend/src/utils.js @@ -0,0 +1,55 @@ +import crypto from 'crypto'; + +export const LOCK_TTL_MS = 2 * 60 * 1000; +export const MAX_CONTENT_BYTES = 1024 * 1024; + +export function secureToken() { + return crypto.randomBytes(32).toString('hex'); +} + +export function newId() { + return crypto.randomUUID(); +} + +export function parseExpiresIn(expiresIn) { + const now = Date.now(); + switch (expiresIn) { + case '1h': + return new Date(now + 60 * 60 * 1000).toISOString(); + case '24h': + return new Date(now + 24 * 60 * 60 * 1000).toISOString(); + case '7d': + return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(); + case '30d': + return new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(); + case 'never': + return null; + default: + return undefined; + } +} + +export function isExpired(expiresAt) { + if (expiresAt == null) return false; + return Date.parse(expiresAt) <= Date.now(); +} + +export function assertMode(mode) { + return mode === 'markdown' || mode === 'org'; +} + +export function getContentByteLength(content) { + return Buffer.byteLength(content ?? '', 'utf8'); +} + +export function assertContentSize(content) { + return getContentByteLength(content) <= MAX_CONTENT_BYTES; +} + +export function lockExpiresAtFromNow() { + return new Date(Date.now() + LOCK_TTL_MS).toISOString(); +} + +export function sendError(res, status, error, message) { + return res.status(status).json({ error, message }); +} diff --git a/docker-compose.yml b/docker-compose.yml @@ -1,6 +1,24 @@ services: + backend: + build: ./backend + ports: + - "41738:41738" + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=41738 + - DATABASE_PATH=/app/data/snow.db + editor: - build: . + build: + context: . + args: + VITE_PUBLIC_ORIGIN: https://snow.pablomurad.com + VITE_ALLOW_SEARCH_INDEXING: "false" ports: - "41737:41737" + depends_on: + - backend restart: unless-stopped diff --git a/nginx.conf b/nginx.conf @@ -8,6 +8,19 @@ server { gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 256; + location /api/ { + proxy_pass http://backend:41738; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + add_header X-Robots-Tag "noindex" always; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/package-lock.json b/package-lock.json @@ -11,7 +11,8 @@ "dompurify": "^3.2.4", "marked": "^15.0.7", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.4.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", @@ -1343,6 +1344,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1643,6 +1657,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "license": "MIT", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.61.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", @@ -1704,6 +1756,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json @@ -12,7 +12,8 @@ "dompurify": "^3.2.4", "marked": "^15.0.7", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.4.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", diff --git a/public/robots.txt b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/src/App.jsx b/src/App.jsx @@ -1,297 +1,30 @@ -import { - useCallback, - useDeferredValue, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { - MODES, - isDefaultContent, - loadContent, - loadMode, - persistContent, - persistMode, -} from './lib/editorConstants.js'; -import { buildPreviewHtml, ensureMarkedLoaded } from './lib/previewHtml.js'; +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import LocalEditorPage from './pages/LocalEditorPage.jsx'; +import { STR } from './lib/strings.js'; -const STORAGE_DEBOUNCE_MS = 500; -const APP_VERSION = '0.0.1'; -const CREATOR_NAME = 'Pablo Murad'; -const CREATOR_EMAIL = 'pablomurad@pm.me'; - -function countWords(text) { - const trimmed = text.trim(); - if (!trimmed) return 0; - return trimmed.split(/\s+/).filter(Boolean).length; -} - -function getFileExtension(filename) { - const parts = filename.toLowerCase().split('.'); - if (parts.length < 2) return 'txt'; - return parts[parts.length - 1]; -} - -function useDividerOrientation() { - const [orientation, setOrientation] = useState('vertical'); - - useEffect(() => { - const media = window.matchMedia('(max-width: 768px)'); - const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical'); - update(); - media.addEventListener('change', update); - return () => media.removeEventListener('change', update); - }, []); - - return orientation; -} - -function App() { - const initialMode = loadMode(); - const [mode, setMode] = useState(initialMode); - const [content, setContent] = useState(() => loadContent(initialMode)); - const [storageWarning, setStorageWarning] = useState(false); - const [markedReady, setMarkedReady] = useState(false); - const fileInputRef = useRef(null); - const editorRef = useRef(null); - const dividerOrientation = useDividerOrientation(); - - const deferredContent = useDeferredValue(content); - const previewIsStale = content !== deferredContent; - - useEffect(() => { - if (mode !== MODES.MARKDOWN) return; - let cancelled = false; - ensureMarkedLoaded().then(() => { - if (!cancelled) setMarkedReady(true); - }); - return () => { - cancelled = true; - }; - }, [mode]); - - useEffect(() => { - const timer = window.setTimeout(() => { - const saved = persistContent(mode, content); - setStorageWarning(!saved); - }, STORAGE_DEBOUNCE_MS); - - return () => window.clearTimeout(timer); - }, [content, mode]); - - const html = useMemo(() => { - return buildPreviewHtml(mode, deferredContent); - }, [mode, deferredContent, markedReady]); - - const wordCount = useMemo(() => countWords(content), [content]); - const charCount = content.length; - - const saveLabel = mode === MODES.ORG ? 'Save .org' : 'Save .md'; - - const prefetchMarkdown = useCallback(() => { - ensureMarkedLoaded().then(() => setMarkedReady(true)); - }, []); - - const handleModeChange = useCallback( - (nextMode) => { - if (nextMode === mode) return; - - persistContent(mode, content); - persistMode(nextMode); - setMode(nextMode); - setContent(loadContent(nextMode)); - setStorageWarning(false); - if (nextMode === MODES.MARKDOWN) { - prefetchMarkdown(); - } - editorRef.current?.focus(); - }, - [mode, content, prefetchMarkdown], - ); - - const handleSave = useCallback(() => { - const isOrg = mode === MODES.ORG; - const blob = new Blob([content], { - type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', - }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = isOrg ? 'document.org' : 'document.md'; - link.click(); - URL.revokeObjectURL(url); - }, [content, mode]); - - const handleImport = useCallback( - (event) => { - const file = event.target.files?.[0]; - if (!file) return; - - const ext = getFileExtension(file.name); - let targetMode = mode; - if (ext === 'org') targetMode = MODES.ORG; - else if (ext === 'md' || ext === 'markdown') targetMode = MODES.MARKDOWN; - - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - if (typeof result !== 'string') return; - - if (targetMode !== mode) { - persistContent(mode, content); - persistMode(targetMode); - setMode(targetMode); - if (targetMode === MODES.MARKDOWN) { - prefetchMarkdown(); - } - } - - setContent(result); - persistContent(targetMode, result); - setStorageWarning(false); - }; - reader.readAsText(file); - event.target.value = ''; - }, - [mode, content, prefetchMarkdown], - ); - - const handleClear = useCallback(() => { - if (!isDefaultContent(mode, content)) { - const confirmed = window.confirm( - 'Clear the editor and start a blank document? Current content will be replaced.', - ); - if (!confirmed) return; - } - - setContent(''); - persistContent(mode, ''); - setStorageWarning(false); - editorRef.current?.focus(); - }, [mode, content]); - - const editorAriaLabel = - mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; +const SharedViewPage = lazy(() => import('./pages/SharedViewPage.jsx')); +const SharedEditPage = lazy(() => import('./pages/SharedEditPage.jsx')); +function RouteFallback() { return ( <div className="app"> - <header className="app-header"> - <div className="app-header-text"> - <h1 className="app-title">Snow Editor</h1> - <p className="app-subtitle">Write calmly. See the result live.</p> - <div className="mode-switch" role="group" aria-label="Editor mode"> - <button - type="button" - className={`mode-switch__btn${mode === MODES.MARKDOWN ? ' is-active' : ''}`} - aria-pressed={mode === MODES.MARKDOWN} - onClick={() => handleModeChange(MODES.MARKDOWN)} - onMouseEnter={prefetchMarkdown} - onFocus={prefetchMarkdown} - > - Markdown - </button> - <button - type="button" - className={`mode-switch__btn${mode === MODES.ORG ? ' is-active' : ''}`} - aria-pressed={mode === MODES.ORG} - onClick={() => handleModeChange(MODES.ORG)} - > - Org-mode - </button> - </div> - </div> - <div className="toolbar" role="toolbar" aria-label="Editor actions"> - <button - type="button" - className="btn" - onClick={handleSave} - aria-label={mode === MODES.ORG ? 'Save as Org-mode file' : 'Save as Markdown file'} - > - {saveLabel} - </button> - <button - type="button" - className="btn" - onClick={() => fileInputRef.current?.click()} - aria-label="Import file" - > - Import - </button> - <input - ref={fileInputRef} - id="file-import" - type="file" - accept=".md,.markdown,.org,.txt,text/markdown,text/plain" - className="file-input-hidden" - onChange={handleImport} - aria-label="Choose file to import" - /> - <button - type="button" - className="btn btn-ghost" - onClick={handleClear} - aria-label="Clear editor and start blank document" - > - Clear - </button> - </div> - </header> - - <main className="app-layout"> - <section - className="panel panel-editor" - aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} - > - <div className="panel-label">Write</div> - <textarea - ref={editorRef} - className="editor" - value={content} - onChange={(e) => setContent(e.target.value)} - spellCheck="true" - aria-label={editorAriaLabel} - placeholder="Start writing..." - /> - </section> - - <div - className="layout-divider" - role="separator" - aria-orientation={dividerOrientation} - /> - - <section className="panel panel-preview" aria-label="Document preview"> - <div className="panel-label">Read</div> - <div - className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} - dangerouslySetInnerHTML={{ __html: html }} - /> - </section> - </main> - - <footer className="app-footer"> - <div className="app-footer-stats"> - <span> - {wordCount} {wordCount === 1 ? 'word' : 'words'} - </span> - <span className="footer-separator">·</span> - <span> - {charCount} {charCount === 1 ? 'character' : 'characters'} - </span> - </div> - {storageWarning && ( - <p className="app-storage-warning" role="status"> - Draft too large to save in browser storage; export with {saveLabel}. - </p> - )} - <p className="app-meta"> - Snow Editor v{APP_VERSION} · {CREATOR_NAME} ·{' '} - <a href={`mailto:${CREATOR_EMAIL}`}>{CREATOR_EMAIL}</a> - </p> - </footer> + <p className="page-loading">{STR.LOADING_DOCUMENT}</p> </div> ); } -export default App; +export default function App() { + return ( + <BrowserRouter> + <Suspense fallback={<RouteFallback />}> + <Routes> + <Route path="/" element={<LocalEditorPage />} /> + <Route path="/v/:token" element={<SharedViewPage />} /> + <Route path="/e/:token" element={<SharedEditPage />} /> + <Route path="*" element={<Navigate to="/" replace />} /> + </Routes> + </Suspense> + </BrowserRouter> + ); +} diff --git a/src/components/EditorLayout.jsx b/src/components/EditorLayout.jsx @@ -0,0 +1,113 @@ +import { useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { MODES } from '../lib/editorConstants.js'; +import { buildPreviewHtml, ensureMarkedLoaded } from '../lib/previewHtml.js'; + +function useDividerOrientation() { + const [orientation, setOrientation] = useState('vertical'); + + useEffect(() => { + const media = window.matchMedia('(max-width: 768px)'); + const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical'); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + return orientation; +} + +export function countWords(text) { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).filter(Boolean).length; +} + +export default function EditorLayout({ + mode, + content, + onContentChange, + readOnly = false, + showEditor = true, + editorRef, + previewOnly = false, +}) { + const [markedReady, setMarkedReady] = useState(false); + const dividerOrientation = useDividerOrientation(); + const deferredContent = useDeferredValue(content); + const previewIsStale = content !== deferredContent; + + useEffect(() => { + if (mode !== MODES.MARKDOWN) return; + let cancelled = false; + ensureMarkedLoaded().then(() => { + if (!cancelled) setMarkedReady(true); + }); + return () => { + cancelled = true; + }; + }, [mode]); + + const html = useMemo(() => { + return buildPreviewHtml(mode, deferredContent); + }, [mode, deferredContent, markedReady]); + + const wordCount = useMemo(() => countWords(content), [content]); + const charCount = content.length; + + const editorAriaLabel = + mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; + + return ( + <> + <main className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}`}> + {showEditor && !previewOnly && ( + <> + <section + className="panel panel-editor" + aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} + > + <div className="panel-label">Write</div> + <textarea + ref={editorRef} + className="editor" + value={content} + onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined} + readOnly={readOnly} + spellCheck="true" + aria-label={editorAriaLabel} + placeholder="Start writing..." + /> + </section> + + <div + className="layout-divider" + role="separator" + aria-orientation={dividerOrientation} + /> + </> + )} + + <section + className={`panel panel-preview${previewOnly ? ' panel-preview--full' : ''}`} + aria-label="Document preview" + > + <div className="panel-label">Read</div> + <div + className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} + dangerouslySetInnerHTML={{ __html: html }} + /> + </section> + </main> + + <div className="app-footer-stats app-footer-stats--inline"> + <span> + {wordCount} {wordCount === 1 ? 'word' : 'words'} + </span> + <span className="footer-separator">·</span> + <span> + {charCount} {charCount === 1 ? 'character' : 'characters'} + </span> + </div> + </> + ); +} diff --git a/src/components/IconButton.jsx b/src/components/IconButton.jsx @@ -0,0 +1,24 @@ +export default function IconButton({ + icon, + label, + variant = 'default', + title, + className = '', + type = 'button', + ...rest +}) { + const variantClass = variant === 'ghost' ? ' btn-ghost' : ''; + const tooltip = title ?? label; + + return ( + <button + type={type} + className={`btn btn-icon${variantClass}${className ? ` ${className}` : ''}`} + aria-label={label} + title={tooltip} + {...rest} + > + {icon} + </button> + ); +} diff --git a/src/components/ReadOnlyBanner.jsx b/src/components/ReadOnlyBanner.jsx @@ -0,0 +1,16 @@ +import { formatLockExpiry, STR } from '../lib/strings.js'; + +export default function ReadOnlyBanner({ message, lockExpiresAt, variant = 'info' }) { + const expiry = formatLockExpiry(lockExpiresAt); + + return ( + <div className={`alert-banner alert-banner--${variant}`} role="status"> + <p>{message}</p> + {expiry && ( + <p className="alert-banner__meta"> + {STR.LOCK_EXPIRES_AT}: {expiry} + </p> + )} + </div> + ); +} diff --git a/src/components/SaveStatus.jsx b/src/components/SaveStatus.jsx @@ -0,0 +1,19 @@ +import { STR } from '../lib/strings.js'; + +const LABELS = { + idle: '', + saving: STR.SAVE_SAVING, + saved: STR.SAVE_SAVED, + error: STR.SAVE_ERROR, + no_permission: STR.SAVE_NO_PERMISSION, +}; + +export default function SaveStatus({ status }) { + const label = LABELS[status]; + if (!label) return null; + return ( + <span className={`save-status save-status--${status}`} role="status"> + {label} + </span> + ); +} diff --git a/src/components/ShareModal.jsx b/src/components/ShareModal.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { createDocument, friendlyErrorMessage, toAbsoluteUrl } from '../lib/api.js'; +import { EXPIRY_OPTIONS, formatExpiryDate, STR } from '../lib/strings.js'; + +export default function ShareModal({ open, onClose, title, mode, content }) { + const [docTitle, setDocTitle] = useState(title); + const [expiresIn, setExpiresIn] = useState('7d'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(''); + + if (!open) return null; + + const handleSubmit = async (event) => { + event.preventDefault(); + setLoading(true); + setError(''); + setResult(null); + + try { + const data = await createDocument({ + title: docTitle, + mode, + content, + expiresIn, + }); + setResult({ + ...data, + viewUrlAbs: toAbsoluteUrl(data.viewUrl), + editUrlAbs: toAbsoluteUrl(data.editUrl), + }); + } catch (err) { + setError(friendlyErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const copyLink = async (url, key) => { + try { + await navigator.clipboard.writeText(url); + setCopied(key); + window.setTimeout(() => setCopied(''), 2000); + } catch { + setError(STR.COPY_FAILED); + } + }; + + return ( + <div className="modal-backdrop" role="presentation" onClick={onClose}> + <div + className="modal share-panel" + role="dialog" + aria-labelledby="share-title" + aria-modal="true" + onClick={(e) => e.stopPropagation()} + > + <h2 id="share-title" className="modal__title"> + {STR.SHARE_DOCUMENT} + </h2> + + {!result ? ( + <form onSubmit={handleSubmit}> + <label className="share-field"> + <span>{STR.TITLE}</span> + <input + type="text" + value={docTitle} + onChange={(e) => setDocTitle(e.target.value)} + maxLength={200} + /> + </label> + + <fieldset className="share-field"> + <legend>{STR.LINK_VALIDITY}</legend> + {EXPIRY_OPTIONS.map((opt) => ( + <label key={opt.value} className="share-radio"> + <input + type="radio" + name="expiresIn" + value={opt.value} + checked={expiresIn === opt.value} + onChange={() => setExpiresIn(opt.value)} + /> + {opt.label} + </label> + ))} + </fieldset> + + {error && ( + <p className="share-error" role="alert"> + {error} + </p> + )} + + <div className="modal__actions"> + <button type="button" className="btn btn-ghost" onClick={onClose}> + {STR.CANCEL} + </button> + <button type="submit" className="btn" disabled={loading}> + {loading ? STR.CREATING : STR.CREATE_LINKS} + </button> + </div> + </form> + ) : ( + <div className="share-result"> + <p className="share-result__expiry">{formatExpiryDate(result.expiresAt)}</p> + + <label className="share-field"> + <span>{STR.VIEW_LINK}</span> + <div className="share-copy-row"> + <input type="text" readOnly value={result.viewUrlAbs} /> + <button + type="button" + className="btn" + onClick={() => copyLink(result.viewUrlAbs, 'view')} + > + {copied === 'view' ? STR.COPIED : STR.COPY} + </button> + </div> + </label> + + <label className="share-field"> + <span>{STR.EDIT_LINK}</span> + <div className="share-copy-row"> + <input type="text" readOnly value={result.editUrlAbs} /> + <button + type="button" + className="btn" + onClick={() => copyLink(result.editUrlAbs, 'edit')} + > + {copied === 'edit' ? STR.COPIED : STR.COPY} + </button> + </div> + </label> + + <div className="modal__actions"> + <button type="button" className="btn" onClick={onClose}> + {STR.CLOSE} + </button> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx @@ -0,0 +1,3 @@ +export default function StatusBadge({ variant, children }) { + return <span className={`badge badge--${variant}`}>{children}</span>; +} diff --git a/src/components/icons/ClearIcon.jsx b/src/components/icons/ClearIcon.jsx @@ -0,0 +1,10 @@ +import { ICON_PROPS } from './iconProps.js'; + +export default function ClearIcon({ className }) { + return ( + <svg {...ICON_PROPS} className={className}> + <path d="M16.75 5.25 8.5 13.5l-2.75 2.75 4.25 4.25 8.25-8.25 2.75-2.75-4.25-4.25z" /> + <path d="M5.5 19.25h13.25" /> + </svg> + ); +} diff --git a/src/components/icons/DownloadIcon.jsx b/src/components/icons/DownloadIcon.jsx @@ -0,0 +1,12 @@ +import { ICON_PROPS } from './iconProps.js'; + +export default function DownloadIcon({ className }) { + return ( + <svg {...ICON_PROPS} className={className}> + <path d="M12 3.5v10.25" /> + <path d="M8.25 10 12 13.75 15.75 10" /> + <path d="M5.5 17.75h13" /> + <path d="M7.5 20.25h9" /> + </svg> + ); +} diff --git a/src/components/icons/ShareIcon.jsx b/src/components/icons/ShareIcon.jsx @@ -0,0 +1,13 @@ +import { ICON_PROPS } from './iconProps.js'; + +export default function ShareIcon({ className }) { + return ( + <svg {...ICON_PROPS} className={className}> + <circle cx="18" cy="5" r="2.25" /> + <circle cx="6" cy="12" r="2.25" /> + <circle cx="18" cy="19" r="2.25" /> + <path d="M8.25 10.75 15.5 6.5" /> + <path d="M8.25 13.25 15.5 17.5" /> + </svg> + ); +} diff --git a/src/components/icons/UploadIcon.jsx b/src/components/icons/UploadIcon.jsx @@ -0,0 +1,12 @@ +import { ICON_PROPS } from './iconProps.js'; + +export default function UploadIcon({ className }) { + return ( + <svg {...ICON_PROPS} className={className}> + <path d="M12 20.5V10.25" /> + <path d="M8.25 13.75 12 10 15.75 13.75" /> + <path d="M5.5 6.25h13" /> + <path d="M7.5 3.75h9" /> + </svg> + ); +} diff --git a/src/components/icons/iconProps.js b/src/components/icons/iconProps.js @@ -0,0 +1,10 @@ +export const ICON_PROPS = { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.75, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true, +}; diff --git a/src/components/icons/index.js b/src/components/icons/index.js @@ -0,0 +1,4 @@ +export { default as ShareIcon } from './ShareIcon.jsx'; +export { default as DownloadIcon } from './DownloadIcon.jsx'; +export { default as UploadIcon } from './UploadIcon.jsx'; +export { default as ClearIcon } from './ClearIcon.jsx'; diff --git a/src/hooks/useEditLock.js b/src/hooks/useEditLock.js @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + acquireEditLock, + ApiError, + refreshEditLock, + releaseEditLock, +} from '../lib/api.js'; +import { getOrCreateClientId } from '../lib/clientId.js'; + +const REFRESH_INTERVAL_MS = 30 * 1000; + +export function useEditLock(editToken, enabled = true) { + const [lockState, setLockState] = useState({ + status: 'idle', + lockToken: null, + lockExpiresAt: null, + blockedExpiresAt: null, + }); + const clientIdRef = useRef(getOrCreateClientId()); + const lockTokenRef = useRef(null); + + const acquire = useCallback(async () => { + if (!editToken || !enabled) return; + setLockState((s) => ({ ...s, status: 'acquiring' })); + + try { + const result = await acquireEditLock(editToken, clientIdRef.current); + lockTokenRef.current = result.lockToken; + setLockState({ + status: 'held', + lockToken: result.lockToken, + lockExpiresAt: result.expiresAt, + blockedExpiresAt: null, + }); + return { locked: true, lockToken: result.lockToken }; + } catch (error) { + if (error instanceof ApiError && error.status === 423) { + setLockState({ + status: 'blocked', + lockToken: null, + lockExpiresAt: null, + blockedExpiresAt: error.payload?.lockExpiresAt ?? null, + }); + return { locked: false, lockExpiresAt: error.payload?.lockExpiresAt }; + } + setLockState({ + status: 'error', + lockToken: null, + lockExpiresAt: null, + blockedExpiresAt: null, + }); + throw error; + } + }, [editToken, enabled]); + + const refresh = useCallback(async () => { + if (!editToken || !lockTokenRef.current) return false; + try { + const result = await refreshEditLock( + editToken, + clientIdRef.current, + lockTokenRef.current, + ); + lockTokenRef.current = result.lockToken; + setLockState((s) => ({ + ...s, + status: 'held', + lockExpiresAt: result.expiresAt, + })); + return true; + } catch { + lockTokenRef.current = null; + setLockState({ + status: 'lost', + lockToken: null, + lockExpiresAt: null, + blockedExpiresAt: null, + }); + return false; + } + }, [editToken]); + + const release = useCallback(async () => { + if (!editToken || !lockTokenRef.current) return; + const token = lockTokenRef.current; + lockTokenRef.current = null; + try { + await releaseEditLock(editToken, clientIdRef.current, token); + } catch { + /* best effort */ + } + setLockState({ + status: 'released', + lockToken: null, + lockExpiresAt: null, + blockedExpiresAt: null, + }); + }, [editToken]); + + useEffect(() => { + if (!enabled || lockState.status !== 'held' || !editToken) return; + + const interval = window.setInterval(() => { + refresh(); + }, REFRESH_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [enabled, editToken, lockState.status, refresh]); + + useEffect(() => { + if (!enabled || !editToken) return; + + const onUnload = () => { + if (!lockTokenRef.current) return; + const body = JSON.stringify({ + clientId: clientIdRef.current, + lockToken: lockTokenRef.current, + }); + const url = `${import.meta.env.VITE_API_BASE ?? ''}/api/documents/edit/${encodeURIComponent(editToken)}/lock`; + fetch(url, { + method: 'DELETE', + body, + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + }).catch(() => {}); + }; + + window.addEventListener('beforeunload', onUnload); + return () => window.removeEventListener('beforeunload', onUnload); + }, [enabled, editToken]); + + return { + clientId: clientIdRef.current, + lockState, + acquire, + refresh, + release, + hasLock: lockState.status === 'held' && !!lockTokenRef.current, + lockToken: lockTokenRef.current, + }; +} diff --git a/src/hooks/useServerAutosave.js b/src/hooks/useServerAutosave.js @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError, updateDocument } from '../lib/api.js'; + +const AUTOSAVE_DEBOUNCE_MS = 1000; + +export function useServerAutosave({ + editToken, + clientId, + lockToken, + enabled, + title, + mode, + content, +}) { + const [saveStatus, setSaveStatus] = useState('idle'); + const timerRef = useRef(null); + const lastSavedRef = useRef(''); + + const saveNow = useCallback(async () => { + if (!enabled || !editToken || !clientId || !lockToken) { + setSaveStatus('no_permission'); + return false; + } + + const snapshot = JSON.stringify({ title, mode, content }); + if (snapshot === lastSavedRef.current) { + setSaveStatus('saved'); + return true; + } + + setSaveStatus('saving'); + try { + const result = await updateDocument(editToken, { + clientId, + lockToken, + title, + mode, + content, + }); + lastSavedRef.current = snapshot; + setSaveStatus('saved'); + return result; + } catch (error) { + if (error instanceof ApiError && (error.status === 403 || error.status === 423)) { + setSaveStatus('no_permission'); + } else { + setSaveStatus('error'); + } + return false; + } + }, [enabled, editToken, clientId, lockToken, title, mode, content]); + + useEffect(() => { + if (!enabled) { + setSaveStatus('no_permission'); + return; + } + + window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + saveNow(); + }, AUTOSAVE_DEBOUNCE_MS); + + return () => window.clearTimeout(timerRef.current); + }, [enabled, title, mode, content, saveNow]); + + return { saveStatus, saveNow, setSaveStatus }; +} diff --git a/src/lib/api.js b/src/lib/api.js @@ -0,0 +1,126 @@ +import { STR } from './strings.js'; + +const API_BASE = import.meta.env.VITE_API_BASE ?? ''; + +function getPublicOrigin() { + const configured = import.meta.env.VITE_PUBLIC_ORIGIN?.trim(); + if (configured) { + return configured.replace(/\/$/, ''); + } + return window.location.origin; +} + +export class ApiError extends Error { + constructor(status, code, message, payload = {}) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.payload = payload; + } +} + +const ERROR_MESSAGES = { + NOT_FOUND: STR.NOT_FOUND, + EXPIRED: STR.EXPIRED, + DOCUMENT_LOCKED: STR.DOCUMENT_LOCKED, + LOCK_REQUIRED: STR.LOCK_REQUIRED, + CONTENT_TOO_LARGE: STR.CONTENT_TOO_LARGE, + RATE_LIMIT: STR.RATE_LIMIT, + NETWORK: STR.NETWORK, +}; + +export function friendlyErrorMessage(error) { + if (error instanceof ApiError) { + return error.message || ERROR_MESSAGES[error.code] || STR.GENERIC_ERROR; + } + return ERROR_MESSAGES.NETWORK; +} + +async function parseResponse(res) { + let data = null; + const text = await res.text(); + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { message: text }; + } + } + + if (!res.ok) { + const code = data?.error ?? 'UNKNOWN'; + const message = + data?.message ?? ERROR_MESSAGES[code] ?? STR.UNEXPECTED_ERROR; + throw new ApiError(res.status, code, message, data ?? {}); + } + + return data; +} + +async function request(path, options = {}) { + let res; + try { + res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers ?? {}), + }, + }); + } catch { + throw new ApiError(0, 'NETWORK', ERROR_MESSAGES.NETWORK); + } + return parseResponse(res); +} + +export function createDocument(body) { + return request('/api/documents', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export function fetchViewDocument(token) { + return request(`/api/documents/view/${encodeURIComponent(token)}`); +} + +export function fetchEditDocument(token) { + return request(`/api/documents/edit/${encodeURIComponent(token)}`); +} + +export function acquireEditLock(token, clientId) { + return request(`/api/documents/edit/${encodeURIComponent(token)}/lock`, { + method: 'POST', + body: JSON.stringify({ clientId }), + }); +} + +export function refreshEditLock(token, clientId, lockToken) { + return request( + `/api/documents/edit/${encodeURIComponent(token)}/lock/refresh`, + { + method: 'POST', + body: JSON.stringify({ clientId, lockToken }), + }, + ); +} + +export function releaseEditLock(token, clientId, lockToken) { + return request(`/api/documents/edit/${encodeURIComponent(token)}/lock`, { + method: 'DELETE', + body: JSON.stringify({ clientId, lockToken }), + }); +} + +export function updateDocument(token, body) { + return request(`/api/documents/edit/${encodeURIComponent(token)}`, { + method: 'PUT', + body: JSON.stringify(body), + }); +} + +export function toAbsoluteUrl(path) { + if (path.startsWith('http')) return path; + return `${getPublicOrigin()}${path}`; +} diff --git a/src/lib/clientId.js b/src/lib/clientId.js @@ -0,0 +1,18 @@ +const STORAGE_CLIENT_ID = 'snow_client_id'; + +export function getOrCreateClientId() { + try { + const existing = localStorage.getItem(STORAGE_CLIENT_ID); + if (existing) return existing; + } catch { + /* ignore */ + } + + const id = crypto.randomUUID(); + try { + localStorage.setItem(STORAGE_CLIENT_ID, id); + } catch { + /* ignore */ + } + return id; +} diff --git a/src/lib/download.js b/src/lib/download.js @@ -0,0 +1,14 @@ +import { MODES } from './editorConstants.js'; + +export function downloadDocument(content, mode, filenameBase = 'document') { + const isOrg = mode === MODES.ORG; + const blob = new Blob([content], { + type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = isOrg ? `${filenameBase}.org` : `${filenameBase}.md`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/editorConstants.js b/src/lib/editorConstants.js @@ -28,28 +28,28 @@ console.log("writing in peace"); \`\`\` `; -const DEFAULT_ORG = `* Diário de Neve +const DEFAULT_ORG = `* Snow Journal -Bem-vindo ao seu pequeno canto de escrita em Org-mode. +Welcome to your quiet Org-mode writing nook. -** TODO Ideias -- Escrever notas -- Criar documentos -- Organizar pensamentos +** TODO Ideas +- Write notes +- Create documents +- Organize thoughts -** DONE Primeiro rascunho +** DONE First draft #+BEGIN_QUOTE -Um espaço limpo para pensar com calma. +A clean space to think calmly. #+END_QUOTE #+BEGIN_SRC js -console.log("escrevendo em paz"); +console.log("writing in peace"); #+END_SRC -** Link de exemplo +** Example link -[[https://orgmode.org][Site oficial do Org-mode]] +[[https://orgmode.org][Official Org-mode site]] `; function getStorageKey(mode) { diff --git a/src/lib/strings.js b/src/lib/strings.js @@ -0,0 +1,97 @@ +export const STR = { + UNTITLED_DOCUMENT: 'Untitled document', + + SHARE: 'Share', + SHARE_DOCUMENT: 'Share document', + TITLE: 'Title', + LINK_VALIDITY: 'Link validity', + EXPIRY_1H: '1 hour', + EXPIRY_24H: '24 hours', + EXPIRY_7D: '7 days', + EXPIRY_30D: '30 days', + EXPIRY_NEVER: 'Never expires', + EXPIRES_ON: (date) => `Expires on ${date}`, + EXPIRES_SOON: 'Expires soon', + CANCEL: 'Cancel', + CLOSE: 'Close', + CREATING: 'Creating…', + CREATE_LINKS: 'Create links', + VIEW_LINK: 'View link', + EDIT_LINK: 'Edit link', + COPY: 'Copy', + COPIED: 'Copied', + COPY_FAILED: 'Could not copy the link.', + + BADGE_LOCAL: 'Local', + BADGE_SHARED: 'Shared', + BADGE_READONLY: 'Read-only', + BADGE_EDITING: 'Editing', + + SHARED_VIEW: 'Shared view', + SHARED_EDIT: 'Shared edit', + SAVE_TO_SERVER: 'Save to server', + RELEASE_EDIT_LOCK: 'Release edit lock', + DOWNLOAD_MD: 'Download .md', + DOWNLOAD_ORG: 'Download .org', + + LOADING_DOCUMENT: 'Loading document…', + OPEN_IN_SNOW_EDITOR: 'Open in Snow Editor', + + LINK_EXPIRED_TITLE: 'Link expired', + LINK_EXPIRED_VIEW: 'This link has expired.', + LINK_EXPIRED_EDIT: 'This edit link has expired.', + DOCUMENT_NOT_FOUND_TITLE: 'Document not found', + LOAD_ERROR_TITLE: 'Failed to load', + + LOCKED_BY_OTHER: + 'This document is being edited by someone else. You can view it, but not edit it right now.', + LOCK_LOST: + 'You lost edit permission for this document. Your text was not deleted. Save a local copy before leaving.', + LOCK_EXPIRES_AT: 'Lock expires at', + + SAVE_SAVING: 'Saving…', + SAVE_SAVED: 'Saved', + SAVE_ERROR: 'Save failed', + SAVE_NO_PERMISSION: 'No edit permission', + + FOOTER_SHARED: 'Snow Editor · shared document', + + NOT_FOUND: 'Document not found.', + EXPIRED: 'This link has expired.', + DOCUMENT_LOCKED: 'This document is being edited by someone else.', + LOCK_REQUIRED: 'You need an active edit lock to save.', + CONTENT_TOO_LARGE: 'Document is larger than 1 MB.', + RATE_LIMIT: 'Too many requests. Try again in a minute.', + NETWORK: 'Could not connect to the server. Check your connection.', + GENERIC_ERROR: 'Something went wrong.', + UNEXPECTED_ERROR: 'An unexpected error occurred.', +}; + +export const EXPIRY_OPTIONS = [ + { value: '1h', label: STR.EXPIRY_1H }, + { value: '24h', label: STR.EXPIRY_24H }, + { value: '7d', label: STR.EXPIRY_7D }, + { value: '30d', label: STR.EXPIRY_30D }, + { value: 'never', label: STR.EXPIRY_NEVER }, +]; + +const DATE_LOCALE = 'en-US'; +const DATE_OPTIONS = { dateStyle: 'medium', timeStyle: 'short' }; + +export function formatExpiryDate(iso) { + if (!iso) return STR.EXPIRY_NEVER; + try { + return STR.EXPIRES_ON(new Date(iso).toLocaleString(DATE_LOCALE, DATE_OPTIONS)); + } catch { + return STR.EXPIRES_SOON; + } +} + +export function formatLockExpiry(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleString(DATE_LOCALE, DATE_OPTIONS); + } catch { + return null; + } +} diff --git a/src/pages/LinkErrorPage.jsx b/src/pages/LinkErrorPage.jsx @@ -0,0 +1,19 @@ +import { Link } from 'react-router-dom'; +import { STR } from '../lib/strings.js'; + +export default function LinkErrorPage({ title, message }) { + return ( + <div className="app link-error-page"> + <header className="app-header"> + <h1 className="app-title">Snow Editor</h1> + </header> + <main className="link-error-page__main"> + <h2>{title}</h2> + <p>{message}</p> + <Link to="/" className="btn"> + {STR.OPEN_IN_SNOW_EDITOR} + </Link> + </main> + </div> + ); +} diff --git a/src/pages/LocalEditorPage.jsx b/src/pages/LocalEditorPage.jsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import IconButton from '../components/IconButton.jsx'; +import ShareModal from '../components/ShareModal.jsx'; +import StatusBadge from '../components/StatusBadge.jsx'; +import EditorLayout from '../components/EditorLayout.jsx'; +import { + ClearIcon, + DownloadIcon, + ShareIcon, + UploadIcon, +} from '../components/icons/index.js'; +import { + MODES, + isDefaultContent, + loadContent, + loadMode, + persistContent, + persistMode, +} from '../lib/editorConstants.js'; +import { ensureMarkedLoaded } from '../lib/previewHtml.js'; +import { STR } from '../lib/strings.js'; + +const STORAGE_DEBOUNCE_MS = 500; +const APP_VERSION = '0.0.1'; +const CREATOR_NAME = 'Pablo Murad'; +const CREATOR_EMAIL = 'pablomurad@pm.me'; + +function getFileExtension(filename) { + const parts = filename.toLowerCase().split('.'); + if (parts.length < 2) return 'txt'; + return parts[parts.length - 1]; +} + +function deriveTitle(content, mode) { + const line = content.split('\n').find((l) => l.trim()); + if (!line) return STR.UNTITLED_DOCUMENT; + if (mode === MODES.MARKDOWN) { + const m = line.match(/^#+\s+(.+)$/); + if (m) return m[1].trim(); + } + if (mode === MODES.ORG) { + const m = line.match(/^\*+\s+(.+)$/); + if (m) return m[1].replace(/^(TODO|DONE)\s+/, '').trim(); + } + return line.trim().slice(0, 80) || STR.UNTITLED_DOCUMENT; +} + +export default function LocalEditorPage() { + const initialMode = loadMode(); + const [mode, setMode] = useState(initialMode); + const [content, setContent] = useState(() => loadContent(initialMode)); + const [storageWarning, setStorageWarning] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const fileInputRef = useRef(null); + const editorRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + const saved = persistContent(mode, content); + setStorageWarning(!saved); + }, STORAGE_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [content, mode]); + + const saveLabel = mode === MODES.ORG ? 'Save .org' : 'Save .md'; + + const prefetchMarkdown = useCallback(() => { + ensureMarkedLoaded(); + }, []); + + const handleModeChange = useCallback( + (nextMode) => { + if (nextMode === mode) return; + persistContent(mode, content); + persistMode(nextMode); + setMode(nextMode); + setContent(loadContent(nextMode)); + setStorageWarning(false); + if (nextMode === MODES.MARKDOWN) prefetchMarkdown(); + editorRef.current?.focus(); + }, + [mode, content, prefetchMarkdown], + ); + + const handleSave = useCallback(() => { + const isOrg = mode === MODES.ORG; + const blob = new Blob([content], { + type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = isOrg ? 'document.org' : 'document.md'; + link.click(); + URL.revokeObjectURL(url); + }, [content, mode]); + + const handleImport = useCallback( + (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + const ext = getFileExtension(file.name); + let targetMode = mode; + if (ext === 'org') targetMode = MODES.ORG; + else if (ext === 'md' || ext === 'markdown') targetMode = MODES.MARKDOWN; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') return; + + if (targetMode !== mode) { + persistContent(mode, content); + persistMode(targetMode); + setMode(targetMode); + if (targetMode === MODES.MARKDOWN) prefetchMarkdown(); + } + + setContent(result); + persistContent(targetMode, result); + setStorageWarning(false); + }; + reader.readAsText(file); + event.target.value = ''; + }, + [mode, content, prefetchMarkdown], + ); + + const handleClear = useCallback(() => { + if (!isDefaultContent(mode, content)) { + const confirmed = window.confirm( + 'Clear the editor and start a blank document? Current content will be replaced.', + ); + if (!confirmed) return; + } + + setContent(''); + persistContent(mode, ''); + setStorageWarning(false); + editorRef.current?.focus(); + }, [mode, content]); + + return ( + <div className="app"> + <header className="app-header"> + <div className="app-header-text"> + <div className="app-header-top"> + <h1 className="app-title">Snow Editor</h1> + <StatusBadge variant="local">{STR.BADGE_LOCAL}</StatusBadge> + </div> + <p className="app-subtitle">Write calmly. See the result live.</p> + <div className="mode-switch" role="group" aria-label="Editor mode"> + <button + type="button" + className={`mode-switch__btn${mode === MODES.MARKDOWN ? ' is-active' : ''}`} + aria-pressed={mode === MODES.MARKDOWN} + onClick={() => handleModeChange(MODES.MARKDOWN)} + onMouseEnter={prefetchMarkdown} + onFocus={prefetchMarkdown} + > + Markdown + </button> + <button + type="button" + className={`mode-switch__btn${mode === MODES.ORG ? ' is-active' : ''}`} + aria-pressed={mode === MODES.ORG} + onClick={() => handleModeChange(MODES.ORG)} + > + Org-mode + </button> + </div> + </div> + <div className="toolbar" role="toolbar" aria-label="Editor actions"> + <IconButton + icon={<ShareIcon />} + label={STR.SHARE} + onClick={() => setShareOpen(true)} + /> + <IconButton + icon={<DownloadIcon />} + label={mode === MODES.ORG ? 'Save as Org-mode file' : 'Save as Markdown file'} + onClick={handleSave} + /> + <IconButton + icon={<UploadIcon />} + label="Import file" + onClick={() => fileInputRef.current?.click()} + /> + <input + ref={fileInputRef} + id="file-import" + type="file" + accept=".md,.markdown,.org,.txt,text/markdown,text/plain" + className="file-input-hidden" + onChange={handleImport} + aria-label="Choose file to import" + /> + <IconButton + icon={<ClearIcon />} + variant="ghost" + label="Clear editor and start blank document" + onClick={handleClear} + /> + </div> + </header> + + <EditorLayout + mode={mode} + content={content} + onContentChange={setContent} + editorRef={editorRef} + /> + + <footer className="app-footer"> + {storageWarning && ( + <p className="app-storage-warning" role="status"> + Draft too large to save in browser storage; export with {saveLabel}. + </p> + )} + <p className="app-meta"> + Snow Editor v{APP_VERSION} · {CREATOR_NAME} ·{' '} + <a href={`mailto:${CREATOR_EMAIL}`}>{CREATOR_EMAIL}</a> + </p> + </footer> + + <ShareModal + open={shareOpen} + onClose={() => setShareOpen(false)} + title={deriveTitle(content, mode)} + mode={mode} + content={content} + /> + </div> + ); +} diff --git a/src/pages/SharedEditPage.jsx b/src/pages/SharedEditPage.jsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import EditorLayout from '../components/EditorLayout.jsx'; +import ReadOnlyBanner from '../components/ReadOnlyBanner.jsx'; +import SaveStatus from '../components/SaveStatus.jsx'; +import StatusBadge from '../components/StatusBadge.jsx'; +import { useEditLock } from '../hooks/useEditLock.js'; +import { useServerAutosave } from '../hooks/useServerAutosave.js'; +import { + ApiError, + fetchEditDocument, + friendlyErrorMessage, +} from '../lib/api.js'; +import { downloadDocument } from '../lib/download.js'; +import { STR } from '../lib/strings.js'; +import LinkErrorPage from './LinkErrorPage.jsx'; + +export default function SharedEditPage() { + const { token } = useParams(); + const [doc, setDoc] = useState(null); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [mode, setMode] = useState('markdown'); + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(true); + const [lockLost, setLockLost] = useState(false); + const editorRef = useRef(null); + + const { lockState, acquire, release, hasLock, lockToken, clientId } = + useEditLock(token, !!doc && !loadError); + + const canEdit = hasLock && !lockLost; + + const { saveStatus, saveNow } = useServerAutosave({ + editToken: token, + clientId, + lockToken, + enabled: canEdit, + title, + mode, + content, + }); + + useEffect(() => { + let cancelled = false; + + (async () => { + setLoading(true); + setLoadError(null); + try { + const data = await fetchEditDocument(token); + if (cancelled) return; + setDoc(data); + setTitle(data.title); + setContent(data.content); + setMode(data.mode); + } catch (err) { + if (!cancelled) setLoadError(err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [token]); + + const lockRequestedRef = useRef(false); + + useEffect(() => { + lockRequestedRef.current = false; + }, [token]); + + useEffect(() => { + if (!doc || loadError || lockRequestedRef.current) return; + lockRequestedRef.current = true; + acquire().catch(() => {}); + }, [doc, loadError, acquire]); + + useEffect(() => { + if (lockState.status === 'lost') { + setLockLost(true); + } + if (saveStatus === 'no_permission') { + setLockLost(true); + } + }, [lockState.status, saveStatus]); + + const handleSaveServer = useCallback(async () => { + const ok = await saveNow(); + if (!ok) setLockLost(true); + }, [saveNow]); + + const handleRelease = useCallback(async () => { + await release(); + setLockLost(true); + }, [release]); + + const handleDownload = useCallback(() => { + downloadDocument(content, mode, title); + }, [content, mode, title]); + + if (loading) { + return ( + <div className="app"> + <p className="page-loading">{STR.LOADING_DOCUMENT}</p> + </div> + ); + } + + if (loadError instanceof ApiError) { + if (loadError.status === 410) { + return ( + <LinkErrorPage + title={STR.LINK_EXPIRED_TITLE} + message={STR.LINK_EXPIRED_EDIT} + /> + ); + } + if (loadError.status === 404) { + return ( + <LinkErrorPage + title={STR.DOCUMENT_NOT_FOUND_TITLE} + message={friendlyErrorMessage(loadError)} + /> + ); + } + } + + if (loadError || !doc) { + return ( + <LinkErrorPage + title={STR.LOAD_ERROR_TITLE} + message={friendlyErrorMessage(loadError)} + /> + ); + } + + const saveLabel = mode === 'org' ? STR.DOWNLOAD_ORG : STR.DOWNLOAD_MD; + const readOnly = + !canEdit || lockState.status === 'blocked' || lockState.status === 'acquiring'; + + return ( + <div className="app"> + <header className="app-header"> + <div className="app-header-text"> + <div className="app-header-top"> + <input + className="doc-title-input" + value={title} + onChange={(e) => setTitle(e.target.value)} + readOnly={readOnly} + aria-label="Document title" + /> + <StatusBadge variant="shared">{STR.BADGE_SHARED}</StatusBadge> + {canEdit ? ( + <StatusBadge variant="editing">{STR.BADGE_EDITING}</StatusBadge> + ) : ( + <StatusBadge variant="readonly">{STR.BADGE_READONLY}</StatusBadge> + )} + </div> + <p className="app-subtitle"> + {canEdit ? STR.SHARED_EDIT : STR.SHARED_VIEW} + </p> + </div> + <div className="toolbar"> + {canEdit && ( + <> + <button type="button" className="btn" onClick={handleSaveServer}> + {STR.SAVE_TO_SERVER} + </button> + <button type="button" className="btn btn-ghost" onClick={handleRelease}> + {STR.RELEASE_EDIT_LOCK} + </button> + </> + )} + <button type="button" className="btn" onClick={handleDownload}> + {saveLabel} + </button> + <SaveStatus status={saveStatus} /> + </div> + </header> + + {lockState.status === 'blocked' && ( + <ReadOnlyBanner + message={STR.LOCKED_BY_OTHER} + lockExpiresAt={lockState.blockedExpiresAt} + /> + )} + + {lockLost && ( + <ReadOnlyBanner variant="warning" message={STR.LOCK_LOST} /> + )} + + <EditorLayout + mode={mode} + content={content} + onContentChange={canEdit ? setContent : undefined} + readOnly={readOnly} + editorRef={editorRef} + showEditor + /> + + <footer className="app-footer"> + <p className="app-meta">{STR.FOOTER_SHARED}</p> + </footer> + </div> + ); +} diff --git a/src/pages/SharedViewPage.jsx b/src/pages/SharedViewPage.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import EditorLayout from '../components/EditorLayout.jsx'; +import StatusBadge from '../components/StatusBadge.jsx'; +import { ApiError, fetchViewDocument, friendlyErrorMessage } from '../lib/api.js'; +import { downloadDocument } from '../lib/download.js'; +import { STR } from '../lib/strings.js'; +import LinkErrorPage from './LinkErrorPage.jsx'; + +export default function SharedViewPage() { + const { token } = useParams(); + const [doc, setDoc] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + (async () => { + setLoading(true); + setError(null); + try { + const data = await fetchViewDocument(token); + if (!cancelled) setDoc(data); + } catch (err) { + if (!cancelled) setError(err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [token]); + + if (loading) { + return ( + <div className="app"> + <p className="page-loading">{STR.LOADING_DOCUMENT}</p> + </div> + ); + } + + if (error instanceof ApiError) { + if (error.status === 410) { + return ( + <LinkErrorPage + title={STR.LINK_EXPIRED_TITLE} + message={STR.LINK_EXPIRED_VIEW} + /> + ); + } + if (error.status === 404) { + return ( + <LinkErrorPage + title={STR.DOCUMENT_NOT_FOUND_TITLE} + message={friendlyErrorMessage(error)} + /> + ); + } + } + + if (error || !doc) { + return ( + <LinkErrorPage + title={STR.LOAD_ERROR_TITLE} + message={friendlyErrorMessage(error)} + /> + ); + } + + const saveLabel = doc.mode === 'org' ? STR.DOWNLOAD_ORG : STR.DOWNLOAD_MD; + + return ( + <div className="app"> + <header className="app-header"> + <div className="app-header-text"> + <div className="app-header-top"> + <h1 className="app-title">{doc.title}</h1> + <StatusBadge variant="shared">{STR.BADGE_SHARED}</StatusBadge> + <StatusBadge variant="readonly">{STR.BADGE_READONLY}</StatusBadge> + </div> + <p className="app-subtitle">{STR.SHARED_VIEW}</p> + </div> + <div className="toolbar"> + <button + type="button" + className="btn" + onClick={() => downloadDocument(doc.content, doc.mode, doc.title)} + > + {saveLabel} + </button> + </div> + </header> + + <EditorLayout mode={doc.mode} content={doc.content} previewOnly showEditor={false} /> + + <footer className="app-footer"> + <p className="app-meta">{STR.FOOTER_SHARED}</p> + </footer> + </div> + ); +} diff --git a/src/styles.css b/src/styles.css @@ -160,6 +160,33 @@ body { background: rgba(245, 240, 232, 0.9); } +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem; + min-width: 2.25rem; + min-height: 2.25rem; +} + +.btn-icon svg { + width: 1.15rem; + height: 1.15rem; + flex-shrink: 0; + color: var(--text-soft); + transition: color 0.2s ease; +} + +.btn-icon:hover svg, +.btn-icon:focus-visible svg { + color: var(--accent-hover); +} + +.btn-icon:focus-visible { + outline: 2px solid rgba(122, 155, 184, 0.45); + outline-offset: 2px; +} + .file-input-hidden { position: absolute; width: 1px; @@ -505,6 +532,239 @@ body { max-width: 28rem; } +.app-header-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; +} + +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-glass); + color: var(--text-muted); +} + +.badge--local { + color: var(--accent-hover); + border-color: rgba(122, 155, 184, 0.35); +} + +.badge--shared { + color: #4a6a85; +} + +.badge--readonly { + color: #6b7580; +} + +.badge--editing { + color: #3d5c4a; + border-color: rgba(61, 92, 74, 0.25); + background: #e5efe8; +} + +.save-status { + font-size: 0.75rem; + color: var(--text-muted); + align-self: center; +} + +.save-status--saved { + color: #3d5c4a; +} + +.save-status--saving { + color: var(--accent); +} + +.save-status--error, +.save-status--no_permission { + color: #8b5a4a; +} + +.alert-banner { + margin: 0 0 1rem; + padding: 0.85rem 1rem; + border-radius: var(--radius-btn); + border: 1px solid var(--border-soft); + background: var(--ice-blue); +} + +.alert-banner p { + margin: 0; + font-size: 0.85rem; +} + +.alert-banner__meta { + margin-top: 0.35rem !important; + font-size: 0.75rem !important; + color: var(--text-muted); +} + +.alert-banner--warning { + background: #fdf8f6; + border-color: rgba(139, 90, 74, 0.25); + color: #6b4a42; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(42, 51, 64, 0.25); + backdrop-filter: blur(4px); +} + +.modal { + width: min(100%, 28rem); + max-height: 90vh; + overflow-y: auto; + padding: 1.25rem 1.35rem; + border-radius: 14px; + border: 1px solid var(--border-soft); + background: var(--snow); + box-shadow: var(--shadow-soft); +} + +.modal__title { + margin: 0 0 1rem; + font-family: var(--font-serif); + font-size: 1.35rem; + font-weight: 600; + color: var(--text-heading); +} + +.modal__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +.share-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.85rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.share-field input[type='text'] { + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-btn); + background: var(--paper); + color: var(--text-soft); +} + +.share-field legend { + margin-bottom: 0.35rem; +} + +.share-radio { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; + color: var(--text-soft); + cursor: pointer; +} + +.share-copy-row { + display: flex; + gap: 0.4rem; +} + +.share-copy-row input { + flex: 1; + min-width: 0; +} + +.share-error { + margin: 0; + font-size: 0.8rem; + color: #8b5a4a; +} + +.share-result__expiry { + margin: 0 0 0.85rem; + font-size: 0.8rem; + color: var(--accent-hover); +} + +.doc-title-input { + flex: 1; + min-width: 8rem; + margin: 0; + padding: 0; + border: none; + background: transparent; + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 600; + color: var(--text-heading); +} + +.doc-title-input:focus { + outline: 2px solid rgba(122, 155, 184, 0.35); + outline-offset: 2px; + border-radius: 4px; +} + +.app-layout--preview-only { + grid-template-columns: 1fr; +} + +.panel-preview--full { + min-height: 50vh; +} + +.app-footer-stats--inline { + margin-top: 0.75rem; + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); +} + +.page-loading { + text-align: center; + padding: 3rem 1rem; + color: var(--text-muted); +} + +.link-error-page__main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + text-align: center; + padding: 2rem 1rem; +} + +.link-error-page__main h2 { + margin: 0; + font-family: var(--font-serif); + font-size: 1.5rem; + color: var(--text-heading); +} + @media (prefers-reduced-motion: reduce) { .btn, .mode-switch__btn, diff --git a/vite.config.js b/vite.config.js @@ -1,12 +1,18 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -import { previewServerOptions, resolveAllowedHosts } from './vite.shared.js'; +import { + previewServerOptions, + resolveAllowSearchIndexing, + resolveAllowedHosts, + robotsSeoPlugin, +} from './vite.shared.js'; export default defineConfig(({ mode }) => { const allowedHosts = resolveAllowedHosts(mode); + const allowSearchIndexing = resolveAllowSearchIndexing(mode); return { - plugins: [react()], + plugins: [react(), robotsSeoPlugin(allowSearchIndexing)], build: { target: 'es2020', rollupOptions: { @@ -25,10 +31,22 @@ export default defineConfig(({ mode }) => { server: { ...previewServerOptions, allowedHosts, + proxy: { + '/api': { + target: 'http://localhost:41738', + changeOrigin: true, + }, + }, }, preview: { ...previewServerOptions, allowedHosts, + proxy: { + '/api': { + target: 'http://localhost:41738', + changeOrigin: true, + }, + }, }, }; }); diff --git a/vite.preview.config.js b/vite.preview.config.js @@ -5,5 +5,11 @@ export default defineConfig(({ mode }) => ({ preview: { ...previewServerOptions, allowedHosts: resolveAllowedHosts(mode), + proxy: { + '/api': { + target: 'http://localhost:41738', + changeOrigin: true, + }, + }, }, })); diff --git a/vite.shared.js b/vite.shared.js @@ -1,3 +1,5 @@ +import fs from 'fs'; +import path from 'path'; import { loadEnv } from 'vite'; export function parseAllowedHosts(value) { @@ -18,6 +20,43 @@ export function resolveAllowedHosts(mode) { return parseAllowedHosts(process.env.ALLOWED_HOSTS ?? fileEnv.ALLOWED_HOSTS); } +export function resolveAllowSearchIndexing(mode) { + const fileEnv = loadEnv(mode, process.cwd(), ''); + const value = ( + process.env.VITE_ALLOW_SEARCH_INDEXING ?? fileEnv.VITE_ALLOW_SEARCH_INDEXING ?? '' + ) + .trim() + .toLowerCase(); + return value === 'true'; +} + +export function robotsSeoPlugin(allowIndexing) { + let outDir = 'dist'; + + return { + name: 'snow-robots-seo', + configResolved(config) { + outDir = config.build.outDir; + }, + transformIndexHtml(html) { + if (allowIndexing) return html; + return html.replace( + '</head>', + ' <meta name="robots" content="noindex, nofollow" />\n </head>', + ); + }, + closeBundle() { + const content = allowIndexing + ? 'User-agent: *\nAllow: /\n' + : 'User-agent: *\nDisallow: /\n'; + const robotsPath = path.join(outDir, 'robots.txt'); + fs.writeFileSync(robotsPath, content, 'utf8'); + // Keep public/ in sync for the next dev session (Vite serves public/ as-is) + fs.writeFileSync(path.join('public', 'robots.txt'), content, 'utf8'); + }, + }; +} + export const previewServerOptions = { host: '0.0.0.0', port: 41737,