# ✏️ InkNote A self-hosted note-taking application with rich text editing, freehand drawing, full-text search and encrypted sync — inspired by Samsung Notes. --- ## Features ### Core - **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, task lists, quotes, code blocks - **Tables** — Insert, resize columns, add/remove rows and columns, merge cells, toggle header row - **Note Links** — Type `@` to link to another note or notebook; every note shows what links back to it - **Version History** — Past states of every note, restorable; restoring is itself reversible - **Full-Text Search** — Searches note bodies, not just titles, and works offline - **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note - **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes) - **Undo/Redo** — Full history stack for drawing operations - **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder - **Star/Favorite** — Quick access to important notes - **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks - **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser - **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images - **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand - **Dark & Light Mode** — Full theme support with refined color schemes ### Sync & Storage - **Offline-First** — Works fully offline, syncs when connection returns - **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it - **WebSocket Real-Time Push** — Changes appear on other devices instantly - **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server - **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public sign-up: an administrator creates the accounts - **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density ### Architecture - **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL) - **Docker Ready** — docker-compose up and you're running - **Client Sync Engine** — Portable JS library for any frontend (React Native, Flutter JS bridge, Electron, web) --- ## Project Structure ``` inknote/ ├── docker-compose.yml # Deploy from the Gitea registry ├── docker-compose.dev.yml # Build from source for local work ├── .env.example # Copy to .env — Compose reads it from here ├── build.sh # Build & push images to Gitea ├── backend/ │ ├── server.js # Express API + WebSocket server │ ├── sync-client.js # Client-side sync engine library │ ├── package.json # Node dependencies │ └── Dockerfile # Server container ├── frontend/ │ ├── src/ │ │ ├── App.jsx # Root: where the user is, which dialog is open │ │ ├── types.ts # Shapes shared by server, IndexedDB and UI │ │ ├── syncEngine.ts # Offline-first sync + API client │ │ ├── offlineStore.ts # IndexedDB wrapper │ │ ├── search.ts # Local full-text index │ │ ├── hooks/ # Note data, search, layout, history, drag │ │ ├── export/ # Markdown, ZIP and print-to-PDF │ │ ├── ui/ # Sidebar, dialogs, icons, palette │ │ ├── features/notes/ # List, editor pane, version history │ │ ├── features/notebooks/ # Dashboard │ │ ├── features/drawing/ # Canonical stroke model + render layer │ │ └── editor/ # TipTap schema, toolbar, note links, attachments │ ├── public/sw.js # PWA service worker │ ├── nginx.conf # Static serving + API/WS proxy │ └── dockerfile # Web container ├── e2e/ # Browser suites — run with ./e2e/run.sh └── readme.md # This file ``` --- ## Quick Start ### Where the data lives Both compose files use **bind mounts**, not named volumes, so everything sits in a plain directory on the Docker host and is picked up by whatever backs that host up: ``` $INKNOTE_DATA_DIR/ ├── db/ # inknote.db — notes, drawings, version history └── uploads/ # image attachments, legacy raster drawings ``` Default is `./data` for development and `/srv/inknote` in production; override with `INKNOTE_DATA_DIR`. It is a default, not an extra path — set it and the default location is never touched. **Create the directory before the first start.** Docker will happily create a missing bind-mount source itself, but it creates it as `root:root`, and the container runs as uid 1000 and then cannot write: ```bash mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote ``` The path has to start with `/` or `./`. Anything else Docker reads as the name of a named volume, and a name containing a slash fails outright. #### Keeping it under `/root` `/root` is mode `0700`, so uid 1000 cannot pass through it — the server fails to write no matter who owns the directory underneath. Two ways out: ```bash # A) let uid 1000 traverse /root, without making it readable mkdir -p /root/inknote/{db,uploads} chown -R 1000:1000 /root/inknote chmod o+x /root # traverse only — `ls /root` is still refused # .env: INKNOTE_DATA_DIR=/root/inknote # B) run the container as root instead — no chown, no chmod, but a break-out # lands on root rather than on an unprivileged user # .env: INKNOTE_DATA_DIR=/root/inknote # INKNOTE_UID=0 # INKNOTE_GID=0 ``` A is the better trade: it keeps the unprivileged container, which is the layer that matters if the app is ever exploited. Neither is needed under `/srv` or `/opt`, which is why those are the default. > Back up the `.env` holding `ENCRYPTION_KEY` too. Note content is encrypted at > rest, so a copy of the database without that key cannot be read. ### 1. Local development (Docker) ```bash # Generate secrets once printf 'JWT_SECRET=%s\nENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" "$(openssl rand -hex 32)" > .env # Build and run backend + nginx-served frontend docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:3000 # Same, plus a Vite dev server with hot reload docker compose -f docker-compose.dev.yml --profile dev up -d --build # → http://localhost:5173 ``` `docker-compose.dev.yml` builds from source and uses its own volumes, so it never touches production data. ### 2. Docker Deployment ```bash mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote cp .env.example .env # then fill it in, see below docker login gitea.honeyy.duckdns.org docker compose pull && docker compose up -d docker compose logs inknote-server | head # says which account became admin ``` `.env` needs `JWT_SECRET`, `ENCRYPTION_KEY` and — on a fresh install — `INKNOTE_ADMIN_USER` plus `INKNOTE_ADMIN_PASSWORD`, since there is no sign-up to create the first account with. On an install that already has accounts, `INKNOTE_ADMIN_USER` alone decides which of them is promoted; without it the oldest one is, which is rarely the one you meant. The web UI listens on port `3000`, the API only inside the compose network. If a reverse proxy sits in front, bind the port to localhost (`"127.0.0.1:3000:80"`), otherwise the app is reachable on the host's address without TLS. --- ## API Reference ### Authentication | Method | Endpoint | Body | Response | |--------|-----------------------|-------------------------------|---------------------| | POST | `/api/auth/login` | `{username, password}` | `{token, user}` | | GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` | | PUT | `/api/auth/username` | `{newUsername, password}` | updated user | | PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` | **There is no registration endpoint.** This is a private instance: accounts are created by an administrator, and nothing on the login screen suggests otherwise. Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively. Servers created before this carried e-mail logins; on first start they migrate automatically, deriving each username from the local part of the address (logged to the console). ### Administration | Method | Endpoint | Body | Response | |--------|-----------------------------------|-----------------------------|----------| | GET | `/api/admin/users` | — | user list with note counts | | POST | `/api/admin/users` | `{username, password, displayName}` | created user | | PUT | `/api/admin/users/:id/password` | `{newPassword}` | `{success}` | | DELETE | `/api/admin/users/:id` | — | `{success}` | All four require an account flagged `is_admin`; the server checks this itself, so hiding the menu entry is presentation, not access control. An admin may not delete their own account. **Where the first admin comes from.** `INKNOTE_ADMIN_USER` and `INKNOTE_ADMIN_PASSWORD` in `.env`: | Database state | What happens at start | | --- | --- | | no users at all | the account is created from both values | | users, but no admin | `INKNOTE_ADMIN_USER` picks which one is promoted; without it, the oldest | | an admin exists | both are ignored | So the variables can never reset an existing password or take rights away — on a running instance the worst they can do is nothing. Set `INKNOTE_ADMIN_USER` *before* upgrading an older installation if the oldest account is not the one that should hold the keys. Deleting an account destroys its notes, notebooks, drawings and version history through `ON DELETE CASCADE`; the files behind attachments are collected by the hourly orphan sweep. The deleted person's session stops working immediately: tokens are signed rather than stored, so `authMiddleware` checks on every request that the account still exists. ### Notebooks | Method | Endpoint | Description | |--------|-----------------------|--------------------------| | GET | `/api/notebooks` | List all notebooks | | POST | `/api/notebooks` | Create notebook | | PUT | `/api/notebooks/:id` | Update notebook | | DELETE | `/api/notebooks/:id` | Soft-delete notebook | ### Notes | Method | Endpoint | Description | |--------|---------------------------|-------------------------------| | GET | `/api/notes` | List all notes (metadata) | | GET | `/api/notes/:id` | Get note with decrypted content | | POST | `/api/notes` | Create note | | PUT | `/api/notes/:id` | Update note (`{content, links}`) | | GET | `/api/notes/:id/backlinks`| Notes linking to this one | | GET | `/api/links` | Every link, for backlink counts | | DELETE | `/api/notes/:id` | Soft-delete note | | PUT | `/api/notes/:id/drawing` | Save vector strokes | ### Sync | Method | Endpoint | Description | |--------|------------- |------------------------------------------| ### WebSocket Connect to `ws://localhost:4000/ws`, then send: ```json {"type": "auth", "token": "JWT_TOKEN", "deviceId": "device-123"} ``` Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc. --- ## Sync Engine (Client) ```javascript import { SyncEngine } from './sync-client'; const sync = new SyncEngine('https://your-server.com', 'device-abc'); // Authenticate await sync.authenticate('username', 'password'); // Listen for events sync.on('status', (status) => console.log('Sync:', status)); sync.on('conflict', (conflict) => { // Present both versions to user, let them choose console.log('Conflict:', conflict); }); sync.on('remote_change', (change) => { // Update local state with remote changes console.log('Remote change:', change); }); // Start syncing (every 30s + WebSocket push) sync.start(); // CRUD operations auto-queue for sync await sync.createNote({ type: 'text', title: 'Hello', content: '
World
' }); await sync.updateNote('note-id', { title: 'Updated' }); await sync.deleteNote('note-id'); await sync.uploadPDF(fileBlob); await sync.saveDrawing('note-id', strokeDoc); ``` --- ## Working on the frontend ```bash npm run test # Vitest — store, search and content migration npm run typecheck # tsc --noEmit npm run format # Prettier npm run build # production bundle ``` TypeScript is enabled with `allowJs`, so `.js` and `.ts` coexist and modules can be converted one at a time. The whole data layer is typed — `types`, `offlineStore`, `search`, `editor/content` and `syncEngine` — because that is where the bugs have been: every serious one so far has been a shape bug, or a failure distinguished by comparing an error's `.message` to a magic string. Failures now carry a typed `SyncError.code`; `.message` still holds the same string, so the remaining JavaScript callers are unaffected. The 218 unit tests concentrate on the same places, and several exist because the bug they cover actually happened: - a queue entry that fails must stay queued, with an attempt counter - `fullSync` must not drop notes created offline - `hasPendingWrites` must cover the debounce window, not just requests in flight - v1 note bodies must migrate without losing task state or smuggling in markup ### Browser suites The unit tests cover the data layer; these drive a real browser against a real server, because the failures that hurt most in this app were never visible in a unit test — a stale response overwriting fresh strokes, a note link surviving in memory but not through a reload. ```bash docker compose -f docker-compose.dev.yml up -d --build # must be running ./e2e/run.sh # all suites ./e2e/run.sh editor drawing # only these ``` | Suite | Checks | What it holds down | | --- | --- | --- | | `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly | | `editor` | 17 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | | `drawing` | 7 | strokes survive the poller, and match across four viewport widths | | `pdf` | 82 | import, page proportions, lazy loading, and reading with the network off | | `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does | | `conflict` | 10 | a late write wins but the state it buries survives as a version | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | | `organise` | 35 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | | `offline` | 12 | a cold page load with the network off still opens notes and searches them | Each suite creates a throwaway account through the admin API and deletes it afterwards, so runs leave the database as they found it. They need an admin to do that with — `marc`/`geheim123` by default, override with `INKNOTE_ADMIN` and `INKNOTE_ADMIN_PASS`. Screenshots land in `e2e/shots/`, which is worth a look when a check fails. ### How the frontend is arranged `App.jsx` holds only *where the user is* — the open notebook, the open note, the search box, whichever dialog is up — and hands each of the three panes what it needs. Everything else lives behind a hook: | Hook | Owns | | --- | --- | | `useNoteData` | notes, notebooks, bodies, every mutation, background sync | | `useSearchIndex` | the local index and the background backfill that fills it | | `useResponsive` | phone vs desktop, sidebar and which pane a phone shows | | `useNavigationHistory` | mapping the current location onto browser history | | `useNotebookDnd` | drag-to-reorder, shared by the sidebar, the dashboard and the note list | | `usePwaInstall` | the browser's install offer | `useNoteData` deliberately has no confirmation prompts in it: whether to ask before deleting something is a question for the interface, not the store. The panes (`Sidebar`, `Dashboard`, `NoteList`, `EditorPane`) take props and hold no state of their own, so they can be read on their own. --- ## Note Content The body of a note is a **TipTap/ProseMirror document** stored as JSON: ```json { "v": 2, "doc": { "type": "doc", "content": [ … ] } } ``` Tables, task lists and note links are real nodes rather than HTML that happened to survive `document.execCommand`. Notes written before this were `{ html, tasks }`; they are converted to a document the first time they are opened and rewritten in the new format on the next save, so nothing needs migrating up front. ### Note links and backlinks Typing `@` opens a search over notes and notebooks and inserts a `noteLink` node holding the target's id — so a link survives the target being renamed, and the label is refreshed from the current title whenever the note is opened. The server cannot read encrypted note bodies, so it cannot discover links itself. The client sends the ids it references alongside the content, and the server maintains a `note_links` index from them. That index is what powers `GET /api/notes/:id/backlinks` and the "Linked from N notes" bar under the editor. --- ## Full-Text Search Search runs entirely on the client. The server stores note bodies encrypted and deliberately keeps no plaintext index, so it cannot search them — and a server-side index would undo the point of encrypting at rest. Bodies are normally fetched only when a note is opened, which would leave a fresh device with nothing to search. After login a background pass backfills the missing ones into IndexedDB, slowly enough to stay out of the way, indexing each as it lands. Searching therefore also works offline. Matching is AND across terms, case- and diacritic-insensitive (`kuhlschrank` finds *Kühlschrank*), with title matches ranked above body matches. Results show a snippet around the first match. > Bodies of notes edited on another device are refreshed when that note is > opened, so search results for untouched notes can lag behind until then. --- ## Reading PDFs An imported PDF becomes a note of type `pdf` and is shown by a viewer instead of the text editor. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest from the first frame; a document mixing portrait and landscape keeps both, rather than stretching the narrow pages. Only pages near the viewport are rendered, and each keeps its own canvas — a PDF page is expensive to draw and cheap to hold, so scrolling back up repaints nothing. Canvases are sized in device pixels and scaled down by CSS, which is what keeps text sharp on a high-density screen. **pdf.js is not in the main bundle.** It is larger than the rest of the application put together, so it sits behind a dynamic import: a session that never opens a PDF never downloads it. The worker is bundled too, not fetched from a CDN — a viewer that stops working during someone else's outage is not one you can rely on offline. The **legacy** pdf.js build is used deliberately. Version 6 calls `Uint8Array.prototype.toHex`, which only very recent browsers have; anything older fails to parse the document with a message that says nothing about why. Bytes are read through the authenticated attachment route and cached in IndexedDB, so a document opened once stays readable with no connection. Because storage is content-addressed, a cached copy can never go stale. > nginx needs a MIME type for `.mjs` — without one it serves the worker as > `application/octet-stream` and the browser refuses to run it as a module. --- ## Exporting an annotated PDF A PDF note offers three things rather than the note formats: the document with your drawings written into it, the untouched original, or just the notes you wrote beside it as Markdown. pdf.js can only read, so producing a file needs a second library — `pdf-lib`, loaded the same way, behind a dynamic import, so the bundle everyone downloads carries no PDF writer for the sake of the people who never export one. This is where the coordinate decision pays off. Strokes were stored in page points from the start, which is the only space a PDF understands: nothing has to be guessed back from screen pixels, and the stamping is a direct translation apart from flipping the origin, which PDF puts at the bottom-left. **The stored file is never modified.** `pdf-lib` builds a new document, so a clean copy of the original stays available however often it has been drawn on — checked by the suite, which re-fetches the stored bytes after exporting and compares them. Erased areas cannot come along. On screen an eraser removes ink by drawing in `destination-out`; a PDF content stream has no equivalent, so those strokes are dropped rather than stamped as visible grey scribbles where something was rubbed out. The suite renders both files and counts dark pixels — 1633 against 2557 — because a larger file proves bytes were added, not that any ink can be seen. --- ## Notes beside a PDF A PDF note is a note that happens to have a document attached, not a separate kind of thing: what you write beside it goes into the note's ordinary `content`, so it is encrypted, versioned, linkable and searchable like any other note. The panel is composed from the editor pieces directly rather than reusing `NoteEditor`, which carries a whole drawing mode with its own canvas and undo stack — the PDF has its own drawing layer, and a second one hidden behind the panel would be two places to draw with one of them invisible. Side by side on a wide screen, two tabs on a narrow one. Both halves can be hidden: reading the original without one's own marks in the way is a normal thing to want, and so is writing without the document taking two thirds of the screen. **One trap this uncovered.** A PDF note has two sources of searchable text — what the reader typed and what the document says — written at different moments by different code. Both called `setEntry`, so whichever ran last was the only half you could search: typing a note dropped the document's text, and re-reading the document dropped the note. The index now keeps them apart and only replaces the half it was given. --- ## Selecting and searching PDF text pdf.js draws pages as pictures, so selecting means laying transparent spans over them exactly where each word was painted. The layer carries the same scale as the canvas — the two drift apart otherwise and a selection lands on the wrong words. While drawing it stops taking pointer events entirely, or a stroke would come out as a text selection. The text is also pulled out and handed to the same index that holds the notes, so one search box finds a sentence whether it was typed or imported. Extraction runs *after* the document is on screen and yields between pages: it must never stand between the reader and the first page. **The extracted text is not sent to the server.** It is derivable from a file the server already holds, so storing it again would cost space and a second thing to encrypt for nothing gained. The price is that a PDF becomes searchable on a device once that device has opened it — the same shape as the limitation notes already have, and the honest trade for not duplicating data. The minimal text-layer CSS is written out in `textLayer.css` rather than importing `pdfjs-dist/web/pdf_viewer.css`, which carries the whole reference viewer's toolbars and dialogs. --- ## Drawing on a PDF Strokes are stored **per page, in that page's own coordinate space** — PDF points at scale 1, exactly what pdf.js reports — rather than in the fixed 1200×1600 space a note's drawing uses. A PDF cannot use one space: every page has its own size, and a stroke belongs to the page it was drawn on. That choice is what keeps a mark on the word it was drawn over at any zoom, on any screen, and it is the only form that can be stamped back into the file later, where page points are the only coordinates that mean anything. The suite measures it: zooming in moves a stroke by 0.001 of the page. ```jsonc { "v": 2, "pages": { "1": { "w": 595, "h": 842, "strokes": [...] } } } ``` Only pages that carry ink get an entry. Pen sizes are scaled from the note page width, or the same number would draw twice as thick on an A4 page half as wide. Reading and drawing are separate modes. While reading, the ink layer takes no pointer events at all, so scrolling and selecting text reach the page beneath; while drawing, a finger still scrolls and only a pen or mouse leaves a mark. The drawing can be hidden without being erased, for reading the original clean. --- ## PDF storage An imported PDF is stored as an attachment: encrypted, named by the hash of its contents, and shared by any note holding the same file. The note points at it through `notes.pdf_attachment_id`. It goes up as a raw body rather than the base64 JSON images use — a scanned book runs to tens of megabytes and base64 would add a third to that for nothing. The bytes are checked for the `%PDF-` signature before anything is stored, so the declared content type is never taken on trust. Images and PDFs are limited separately, because a 25 MB picture is nearly always a mistake while a 25 MB document is not: `INKNOTE_MAX_IMAGE_MB` (25) and `INKNOTE_MAX_PDF_MB` (200). The limits are there so a runaway upload cannot fill the disk, not to ration anything. > An earlier `POST /api/notes/pdf` stored a *filesystem path* in the note, which > is why it never survived a container rebuild. That route is gone. --- ## Encryption at rest Note bodies and uploaded files are both encrypted with AES-256-GCM under `ENCRYPTION_KEY`. Losing that key loses the notes — back it up with the database, not next to it. Files carry an 8-byte `INKENC01` header, so a file says for itself whether it is encrypted. That is what lets the startup sweep run on every boot and encrypt only what is still in the clear: files are shared between users by content hash, so a flag on a database row could not have answered the question. Two consequences worth knowing: - **Deduplication still works.** Files are named by the hash of the *plaintext*, so two identical uploads share one copy — encrypting each with a fresh IV would otherwise produce two different files from the same picture. - **Reads are not streamed.** GCM only proves a file is intact once the final block is in, so an attachment is read whole and verified before anything is sent. Half of a corrupt image is worse than an error. Uploads are written to a temporary name and renamed into place. A rename is atomic on one filesystem, so a crash mid-write leaves the old file or the new one, never a half-written one that would fail its tag forever after. --- ## Freeing attachments Take a picture out of a note and the file cannot simply be deleted: the versions from before still show it, and restoring one would find a hole where the image was. So an attachment is released only once **no current note and no kept version** refers to it — which, with retention by count, is the moment the last version holding it is pushed out by newer ones. The server cannot look for itself: bodies are encrypted. So the client reports which attachments a note uses on every save, exactly as it already does for note links, and a version inherits whatever the note held at the moment it was snapshotted — which is by definition what that version contains. Two things make this safe rather than merely plausible: - **Silence is not "uses nothing".** A note written before references existed has no rows, which looks identical to a note with no pictures. Nothing is released for a user while any of their notes has yet to report; the background prefetch that fills the search index reports for them on its first pass. - **An upload in flight has a row before the note that uses it does.** Attachments younger than the grace period are left alone. `POST /api/admin/housekeeping` runs the sweep on demand instead of waiting for its timer — admin only, and it accepts `graceSeconds` to shorten that wait. --- ## Attachments Images used to be inlined into the note body as base64 data URIs — a few screenshots turned one note into megabytes that were re-encrypted and re-uploaded on every keystroke. They are now separate blobs; the body only keeps an id. - Stored on disk under the SHA-256 of their bytes, so the same image pasted into ten notes is kept once - Uploads are type-checked against the actual bytes, not the declared MIME type - Downloads require the owner's token, so `