From 0a755611bf344dd10bb80370a59e6ccbc1248c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 08:00:09 +0000 Subject: [PATCH] Add public read-only note sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share an open note via a revocable public link that always serves the note's latest saved markdown, plus a copy-raw-markdown tool on both the private editor and the public page. - models: NoteShare (one-per-note, unique token, delete-orphan); Note.to_dict exposes public_share_token - routes/notes: POST/DELETE /api/notes//share (idempotent, audited) - routes/pages: GET /n/ — public, no auth, server-rendered every request so it reflects the latest version; unknown/revoked token 404s - templates/public_note.html: read-only, preview-locked EasyMDE with a copy-raw-markdown toolbar tool - notes.js: Share/Copy-link/Stop-sharing buttons + copy-markdown tool - migrate_db: additive note_shares table + indexes - tests + docs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BzQsU2rYQodWiDhyNFckz2 --- README.md | 2 +- doc/PROJECT_DESCRIPTION.md | 14 +++- migrate_db.py | 22 ++++++ src/models.py | 39 ++++++++++ src/routes/notes.py | 40 +++++++++- src/routes/pages.py | 20 ++++- src/static/css/style.css | 25 ++++++- src/static/js/notes.js | 104 ++++++++++++++++++++++++++ src/templates/index.html | 21 +++++- src/templates/public_note.html | 123 ++++++++++++++++++++++++++++++ tests/test_note_sharing.py | 133 +++++++++++++++++++++++++++++++++ 11 files changed, 530 insertions(+), 13 deletions(-) create mode 100644 src/templates/public_note.html create mode 100644 tests/test_note_sharing.py diff --git a/README.md b/README.md index 2a37c24..75a70c1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A self-hosted workspace that unifies **tasks, calendar, notes, and mail** around shared *Spaces* — with AI-powered capture everywhere and automatic scheduling. Built for ADHD workflows: one page, one header, everything reachable in as few clicks (or keystrokes) as possible. - **Tasks** — kanban board home (`todo / doing / blocked / done`), space filter chips, drag between columns, inline create; grouped-by-space overview (with a show-done toggle, most recently finished first) as the secondary view -- **Notes** — space-scoped markdown capture (full EasyMDE toolbar) with AI "Cleanify" and promote-selection-to-task +- **Notes** — space-scoped markdown capture (full EasyMDE toolbar) with AI "Cleanify", promote-selection-to-task, copy-raw-markdown, and public read-only sharing (a revocable `/n/` link that always serves the note's latest version) - **Mail** — register IMAP inboxes (passwords encrypted at rest), browse live, click to read, right-click an email → AI-drafted task - **Calendar** — AI-parsed tasks auto-scheduled around your external ICS calendars and per-space time windows; drag to reschedule (and freeze) - **Spaces** — manage your contexts (work / study / …) with per-weekday scheduling windows and a per-space **AI context markdown** that guides every AI task creation (guide, not source — never copied into tasks) diff --git a/doc/PROJECT_DESCRIPTION.md b/doc/PROJECT_DESCRIPTION.md index cc30a72..0e33705 100644 --- a/doc/PROJECT_DESCRIPTION.md +++ b/doc/PROJECT_DESCRIPTION.md @@ -55,10 +55,10 @@ simpler-smart-calendar/ │ ├── app.py # App factory only; registers blueprints │ ├── models.py # Task, Space, ChangeLog, Note, Mailbox, CalendarSource │ ├── routes/ # Per-domain blueprints -│ │ ├── pages.py # /, /notes (deep link), /login, /logout +│ │ ├── pages.py # /, /notes (deep link), /n/ (public note), /login, /logout │ │ ├── tasks.py # /api/tasks* (CRUD, parse, freeze, reorder) │ │ ├── spaces.py # /api/spaces* -│ │ ├── notes.py # /api/notes* (CRUD, cleanify, promote-to-task) +│ │ ├── notes.py # /api/notes* (CRUD, cleanify, promote-to-task, public share) │ │ ├── mailboxes.py # /api/mailboxes* (CRUD, messages, add-task) │ │ ├── calendar_sources.py# /api/calendar-sources*, /api/external-events │ │ └── schedule.py # /api/schedule, /api/logs @@ -155,7 +155,10 @@ Default spaces seeded on first run: `work` (Mon-Fri 9-17), `study` (unconstraine All mutation routes write through `audit.record_change()` so the entity mutation and its audit row land in **one transaction**. ### `notes` -`id`, `space_id` (FK, **NOT NULL**), `title` (nullable — the list UI falls back to "Untitled"), `content_markdown` (raw markdown source), `created_at` / `updated_at`. +`id`, `space_id` (FK, **NOT NULL**), `title` (nullable — the list UI falls back to "Untitled"), `content_markdown` (raw markdown source), `created_at` / `updated_at`. `to_dict()` also exposes `public_share_token` (the token of the note's public share, or `null` when unshared). + +### `note_shares` +`id`, `note_id` (FK, **NOT NULL**, **UNIQUE** — at most one share per note), `token` (**UNIQUE**, random `secrets.token_urlsafe(16)`), `created_at`. A row is the note's single public read-only credential: anyone holding `/n/` can view the note. "Stop sharing" **deletes** the row (the token is revoked, never reused; re-sharing mints a fresh one). Deleting the note cascades the share away (ORM `delete-orphan`). ### `mailboxes` | Column | Type | Description | @@ -209,6 +212,9 @@ All `/api/*` routes require the session cookie (`@login_required`, JSON 401 othe - `GET/PUT/DELETE /api/notes/` — PUT re-runs the title backfill on every save: linked tasks (`note_id`) whose title is still empty take the note's title; DELETE detaches linked tasks (`note_id → NULL`) - `POST /api/notes//cleanify` — → `{content}`; does NOT persist (the editor applies it and the debounced PUT autosave persists). Degrades to the original content on AI failure - `POST /api/notes//promote-to-task` — `{selected_text}` → task draft DTOs (space defaulting to the note's, `note_id` provenance tag, empty AI title borrows the note's); persists nothing +- `POST /api/notes//share` — create (or return the existing) public read-only share → `{token}`; idempotent (an already-shared note keeps its token); audited (`action='share'`). The client builds the URL as `/n/` +- `DELETE /api/notes//share` — revoke the public share → 204; idempotent; audited (`action='unshare'`) +- `GET /n/` — **public, no auth** (the token is the credential). Server-rendered on every request, so it always shows the note's latest saved markdown, mounted in a read-only EasyMDE locked to preview mode with a "copy raw markdown" toolbar tool. An unknown/revoked token 404s (`noindex`) ### Mail (`src/routes/mailboxes.py`) - `GET /api/mailboxes` — DTOs with `has_password`, never the password @@ -233,7 +239,7 @@ One page, one header: - **Destinations** are sections toggled client-side (no page reloads), deep-linkable via `#tasks / #notes / #mail / #calendar / #spaces`; the last destination is remembered (`localStorage`). - **Tasks**: kanban board (SortableJS: cross-column drag → `PUT {status}` only; same-column drag = manual reorder nudging just the dragged task's priority via `POST /api/tasks/reorder`; Done stays completion-time ordered, no intra-column sort there; modifier+mousedown never starts a drag — Shift/Ctrl/Alt clicks stay clicks even with hand jitter, so a sloppy Shift+click can't drop the card into a neighbouring column), space filter chips (persisted; click = one space, Ctrl+click = toggle several spaces into the filter, Alt+click = exclude a space — greyed-out chip, its tasks hidden until Alt+clicked again; "All spaces" resets both), per-column "+" inline create (Enter creates in that column; `restrict_space` only sent when exactly one space is visible), Doing column's magic button → "what do you want to do?" modal → `POST /api/tasks/auto-doing` moves the AI-matched to-dos into Doing, Done column capped at 30 most recently finished (`completed_at` desc). Board ⇄ Overview toggle persisted; the Overview has a persisted "Show done" toggle listing finished tasks most-recently-finished first. - **Calendar**: preserved behavior — FullCalendar with drag = reschedule + auto-freeze (Ctrl skips freeze), resize = duration change, sidebar task list with drag-to-reorder (same single-task priority nudge as the board). -- **Notes** (`notes.js`, `NotesView` module, lazy init): space chips like the board (click = one space, Ctrl+click = multi-space view, Alt+click = exclude a space — greyed chip, its notes hidden; rows show a space tag when several spaces are visible; new notes land in the first visible selected space), EasyMDE source editor with the standard formatting toolbar (headings, lists, quote, code, link/image, preview, side-by-side — table and fullscreen deliberately omitted; side-by-side stays inside the notes layout via `sideBySideFullscreen: false`) plus the custom add-task/Cleanify/Undo actions. Existing notes open **rendered (preview mode)** — clicking the preview switches to edit mode; new/empty notes open straight in edit mode. Deferred persistence (no empty "Untitled" rows), debounced autosave (Ctrl+Enter flushes it immediately), Cleanify + single-step Undo, promote-selection-to-task. +- **Notes** (`notes.js`, `NotesView` module, lazy init): space chips like the board (click = one space, Ctrl+click = multi-space view, Alt+click = exclude a space — greyed chip, its notes hidden; rows show a space tag when several spaces are visible; new notes land in the first visible selected space), EasyMDE source editor with the standard formatting toolbar (headings, lists, quote, code, link/image, preview, side-by-side — table and fullscreen deliberately omitted; side-by-side stays inside the notes layout via `sideBySideFullscreen: false`) plus the custom add-task/Cleanify/Undo actions and a **copy-raw-markdown** toolbar tool (copies the note's markdown source — present on both the private editor and the public share page). Existing notes open **rendered (preview mode)** — clicking the preview switches to edit mode; new/empty notes open straight in edit mode. Deferred persistence (no empty "Untitled" rows), debounced autosave (Ctrl+Enter flushes it immediately), Cleanify + single-step Undo, promote-selection-to-task. **Public sharing**: a Share button (next to Download in the notes toolbar, enabled once a note is open) POSTs `/api/notes//share`, then copies `/n/` to the clipboard and flips to "Copy link"; a Stop-sharing button appears to revoke it. The `/n/` page is a standalone server-rendered template (`public_note.html`) mounting a read-only, preview-locked EasyMDE that always reflects the note's latest saved markdown. - **Spaces** (`spaces.js`, `SpacesView` module, lazy init): space list + editor — name, description, **AI context markdown** (guidance injected into every AI task prompt), and per-weekday time windows. Replaces the old header-button modal. - **Mail** (`mail.js`, `MailView` module, lazy init): mailbox sidebar + add/edit modal, live inbox list, click a message → reader modal (full plain-text body, still read-only server-side), right-click (or Task button) → AI draft → shared confirm modal. - **`task_draft_modal.js`**: the shared "confirm this AI task draft" modal used by both promote-to-task and email-to-task (drafts are never silently persisted). diff --git a/migrate_db.py b/migrate_db.py index bdbdd1b..e49714e 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -185,6 +185,28 @@ 'updated_at': 'DATETIME', }, }, + 'note_shares': { + 'create': """ + CREATE TABLE note_shares ( + id INTEGER NOT NULL PRIMARY KEY, + note_id INTEGER NOT NULL UNIQUE REFERENCES notes(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + created_at DATETIME + ) + """, + 'columns': { + 'id': 'INTEGER NOT NULL PRIMARY KEY', + 'note_id': 'INTEGER NOT NULL UNIQUE REFERENCES notes(id) ON DELETE CASCADE', + 'token': 'VARCHAR(64) NOT NULL UNIQUE', + 'created_at': 'DATETIME', + }, + 'indexes': { + 'ix_note_shares_note_id': + 'CREATE INDEX ix_note_shares_note_id ON note_shares (note_id)', + 'ix_note_shares_token': + 'CREATE INDEX ix_note_shares_token ON note_shares (token)', + }, + }, 'mailboxes': { 'create': """ CREATE TABLE mailboxes ( diff --git a/src/models.py b/src/models.py index 4ba873f..96ac1e9 100644 --- a/src/models.py +++ b/src/models.py @@ -216,17 +216,56 @@ class Note(db.Model): space_rel = db.relationship('Space', backref='notes', foreign_keys=[space_id]) + # Public read-only share (0-or-1 per note). uselist=False makes it a scalar; + # delete-orphan means dropping the note (or detaching the share) removes the + # link so a stale token can never resolve to a note. + share = db.relationship( + 'NoteShare', backref='note', uselist=False, + cascade='all, delete-orphan', lazy='selectin') + def to_dict(self): return { 'id': self.id, 'space_id': self.space_id, 'title': self.title, 'content_markdown': self.content_markdown or '', + # Opaque token of the note's public share, or None when not shared. + # The client builds the shareable URL from its own window.origin, so + # to_dict stays request-context-free and proxy-agnostic. + 'public_share_token': self.share.token if self.share else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, } +class NoteShare(db.Model): + """A public, read-only share of a Note. + + One row per shared note (note_id is UNIQUE — creating a share for an + already-shared note reuses the existing token). The random `token` is the + only credential: anyone holding the `/n/` URL can view the note's + latest markdown, rendered read-only. Deleting the row ("stop sharing") is + what revokes access — the token is not reused. No auth is attached to the + row itself; there is a single owner (APP_PASSWORD) and every share is that + owner's. + """ + __tablename__ = 'note_shares' + + id = db.Column(db.Integer, primary_key=True) + note_id = db.Column(db.Integer, db.ForeignKey('notes.id', ondelete='CASCADE'), + nullable=False, unique=True, index=True) + token = db.Column(db.String(64), nullable=False, unique=True, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'note_id': self.note_id, + 'token': self.token, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + class Mailbox(db.Model): """A registered IMAP mailbox, linked to a Space. diff --git a/src/routes/notes.py b/src/routes/notes.py index f60c758..4603b33 100644 --- a/src/routes/notes.py +++ b/src/routes/notes.py @@ -1,5 +1,6 @@ -"""Notes CRUD, Cleanify, and promote-to-task routes.""" +"""Notes CRUD, Cleanify, promote-to-task, and public-share routes.""" +import secrets from datetime import datetime from flask import Blueprint, current_app, jsonify, request @@ -7,7 +8,7 @@ from ai_parser import cleanify_note_with_ai, parse_task_with_ai from audit import record_change from auth import login_required -from models import db, Note, Task +from models import db, Note, NoteShare, Task from prompt_context import build_task_parse_prompt notes_bp = Blueprint('notes', __name__) @@ -103,6 +104,41 @@ def delete_note(note_id): return '', 204 +@notes_bp.route('/api/notes//share', methods=['POST']) +@login_required +def share_note(note_id): + """Create (or return the existing) public read-only share for a note. + + Idempotent: sharing an already-shared note returns the same token rather + than minting a new one, so the previously copied link keeps working. The + response carries the token only — the client builds the URL from its own + origin (see Note.to_dict).""" + note = Note.query.get_or_404(note_id) + + if note.share is None: + # token_urlsafe(16) → 22-char URL-safe token; unique+indexed in the DB. + note.share = NoteShare(token=secrets.token_urlsafe(16)) + db.session.flush() + record_change('share', 'note', note.id, new={'token': note.share.token}) + db.session.commit() + + return jsonify({'token': note.share.token}) + + +@notes_bp.route('/api/notes//share', methods=['DELETE']) +@login_required +def stop_sharing_note(note_id): + """Revoke a note's public share. Idempotent — 204 whether or not one + existed. The token is dropped, not reused; re-sharing mints a fresh one.""" + note = Note.query.get_or_404(note_id) + if note.share is not None: + old_token = note.share.token + note.share = None # delete-orphan drops the NoteShare row + record_change('unshare', 'note', note.id, old={'token': old_token}) + db.session.commit() + return '', 204 + + @notes_bp.route('/api/notes//cleanify', methods=['POST']) @login_required def cleanify_note(note_id): diff --git a/src/routes/pages.py b/src/routes/pages.py index 112fe27..526526f 100644 --- a/src/routes/pages.py +++ b/src/routes/pages.py @@ -1,6 +1,8 @@ """Server-rendered pages + session auth (login/logout).""" -from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for +from flask import Blueprint, abort, current_app, jsonify, redirect, render_template, request, session, url_for + +from models import NoteShare pages_bp = Blueprint('pages', __name__) @@ -23,6 +25,22 @@ def notes_page(): return redirect('/#notes') +@pages_bp.route('/n/') +def public_note(token): + """Public, read-only view of a shared note — NO auth (the token is the + credential). Built server-side on every request, so it always renders the + note's latest saved markdown. A revoked (or never-issued) token 404s.""" + share = NoteShare.query.filter_by(token=token).first() + if share is None or share.note is None: + abort(404) + note = share.note + return render_template( + 'public_note.html', + note_title=(note.title or '').strip() or 'Untitled note', + content_markdown=note.content_markdown or '', + ) + + @pages_bp.route('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/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': diff --git a/src/static/css/style.css b/src/static/css/style.css index 13992dc..61c9ca0 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -966,11 +966,34 @@ body { font-weight: 500; } -.notes-download-btn:hover { +.notes-download-btn:hover:not(:disabled) { border-color: #667eea; color: #667eea; } +.notes-download-btn:disabled { + opacity: .5; + cursor: default; +} + +/* Share / Download / Stop-sharing sit together on the right of the toolbar */ +.notes-toolbar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* Stop-sharing reads as a "revoke" action */ +.notes-stop-share-btn { + border-color: #f0b2b2; + color: #c0392b; +} + +.notes-stop-share-btn:hover:not(:disabled) { + border-color: #c0392b; + color: #c0392b; +} + .space-chips { display: flex; gap: 6px; diff --git a/src/static/js/notes.js b/src/static/js/notes.js index 16c1383..6ff9c73 100644 --- a/src/static/js/notes.js +++ b/src/static/js/notes.js @@ -298,6 +298,102 @@ window.NotesView = (function () { }[c])); } + // --- Clipboard (shared by the copy-markdown tool and the share link copy) --- + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); + } + return Promise.resolve(fallbackCopy(text)); + } + + function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* no-op */ } + ta.remove(); + } + + // EasyMDE toolbar tool: copy the raw markdown source of the open note. + // Same tool ships on the public read-only share page. + function copyRawMarkdown(editor) { + copyToClipboard(editor.value()).then(() => setSaveIndicator('copied')); + } + + // --- Public share of the open note --- + function shareUrl(token) { + return `${window.location.origin}/n/${token}`; + } + + function currentShareToken() { + return state.currentNote && state.currentNote.public_share_token; + } + + function updateShareButtons() { + const shareBtn = document.getElementById('shareNoteBtn'); + const shareLabel = document.getElementById('shareNoteLabel'); + const stopBtn = document.getElementById('stopShareBtn'); + if (!shareBtn) return; // notes toolbar not on this page + const hasNote = !!state.currentNote; + const token = currentShareToken(); + shareBtn.disabled = !hasNote; + shareLabel.textContent = token ? 'Copy link' : 'Share'; + stopBtn.style.display = (hasNote && token) ? '' : 'none'; + } + + async function onShareClick() { + if (!state.currentNote) return; + const existing = currentShareToken(); + if (existing) { + // Already shared → the button is a plain "copy the link again". + await copyToClipboard(shareUrl(existing)); + setSaveIndicator('link copied'); + return; + } + // First share: create the link, then copy it and flip the button. + const shareBtn = document.getElementById('shareNoteBtn'); + shareBtn.disabled = true; + setSaveIndicator('creating link…'); + try { + const resp = await api(`/api/notes/${state.currentNote.id}/share`, { method: 'POST' }); + const token = resp && resp.token; + if (!token) { setSaveIndicator('share failed'); return; } + state.currentNote.public_share_token = token; + syncNoteInList(token); + await copyToClipboard(shareUrl(token)); + setSaveIndicator('link copied'); + } catch (e) { + setSaveIndicator('share failed'); + } finally { + updateShareButtons(); + } + } + + async function onStopShareClick() { + if (!state.currentNote || !currentShareToken()) return; + setSaveIndicator('removing link…'); + try { + await api(`/api/notes/${state.currentNote.id}/share`, { method: 'DELETE' }); + state.currentNote.public_share_token = null; + syncNoteInList(null); + setSaveIndicator('link removed'); + } catch (e) { + setSaveIndicator('remove failed'); + } finally { + updateShareButtons(); + } + } + + // Keep the cached list DTO in step with the open note's share state, so + // reopening it (without a refetch) shows the right button. + function syncNoteInList(token) { + const row = state.notes.find(n => n.id === (state.currentNote && state.currentNote.id)); + if (row) row.public_share_token = token; + } + // --- Editor --- // Notes open rendered (EasyMDE preview mode) by default; double-clicking // the preview switches to edit mode. Empty notes go straight to edit — an @@ -388,6 +484,9 @@ window.NotesView = (function () { await loadNotes(); } setSaveIndicator('saved'); + // First save flips currentNote from null → persisted, enabling the + // per-note actions (share included). + setActiveButtonsDisabledState(); } catch (e) { setSaveIndicator('save failed'); } @@ -410,6 +509,7 @@ window.NotesView = (function () { document.getElementById('undoCleanifyBtn').disabled = !(hasNote && state.lastCleaned !== null); document.getElementById('notePromoteBtn').disabled = !(hasNote && hasSelection); document.getElementById('deleteNoteBtn').disabled = !hasNote; + updateShareButtons(); } async function cleanifyCurrentNote() { @@ -523,6 +623,8 @@ window.NotesView = (function () { 'code', 'horizontal-rule', '|', 'link', 'image', '|', 'preview', 'side-by-side', '|', + { name: 'copy-markdown', action: copyRawMarkdown, + className: 'fa fa-copy', title: 'Copy raw markdown' }, { name: 'add-task', action: promoteSelectionToTask, className: 'fa fa-plus-square', title: 'Add as task' }, { name: 'cleanify', action: cleanifyCurrentNote, @@ -559,6 +661,8 @@ window.NotesView = (function () { function initWiring() { document.getElementById('newNoteBtn').addEventListener('click', newNote); document.getElementById('downloadNotesBtn').addEventListener('click', downloadNotes); + document.getElementById('shareNoteBtn').addEventListener('click', onShareClick); + document.getElementById('stopShareBtn').addEventListener('click', onStopShareClick); // Esc clears the download selection (only when Notes is the visible // destination and the user is not typing somewhere). document.addEventListener('keydown', (e) => { diff --git a/src/templates/index.html b/src/templates/index.html index c1d6cf7..ed2b8aa 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -224,10 +224,23 @@
Tasks
- +
+ + + + +