From f1cffe2e799f92ae71ce1f247f716982da92812a Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 1 Feb 2026 20:02:47 -0500 Subject: [PATCH] Resolve all 14 tracked tickets (T0-T13) High priority fixes: - T0: Profile page now filters comments by namespace (was leaking cross-site) - T1: URI hostnames and namespace names normalized to lowercase (was causing duplicate threads and "stock comments" bug). Includes merge script. - T2: Thread detail API now paginated with SQL-side filtering (was 502 on 267+ reply threads) Features: - T3: GDPR account deletion (tombstone user with scrubbed PII) and data export - T4: Customizable button text and comment labels per namespace - T5: Self-service namespace deletion for owners - T6: @mention notifications with profile links - T7: Webmention receiving endpoint with h-card extraction - T8: Configurable max nesting depth and collapse depth per namespace - T9: AJAX thread title search to prevent duplicates - T10: Browser push notification support (VAPID/service worker) Docs and housekeeping: - T11: Documented thread_uri behavior when moving embeds - T12/T13: Drafted community replies for resolved feature requests - Collapse depth defaults to infinite (load-more disabled unless configured) 364 tests pass, 4 skipped. --- docs/thread-uri.md | 90 ++ docs/tickets/0.md | 50 + docs/tickets/1.md | 58 + docs/tickets/10.md | 47 + docs/tickets/11.md | 51 + docs/tickets/12.md | 61 + docs/tickets/13.md | 52 + docs/tickets/2.md | 52 + docs/tickets/3.md | 48 + docs/tickets/4.md | 34 + docs/tickets/5.md | 32 + docs/tickets/6.md | 33 + docs/tickets/7.md | 53 + docs/tickets/8.md | 34 + docs/tickets/9.md | 30 + docs/tickets/index.md | 20 + remarkbox/__init__.py | 14 + remarkbox/api/__init__.py | 2 + remarkbox/api/remarkbox_client.py | 19 +- remarkbox/api/views.py | 113 +- remarkbox/lib/mentions.py | 82 ++ remarkbox/lib/notify.py | 115 +- remarkbox/lib/push.py | 213 +++ remarkbox/lib/render.py | 14 +- remarkbox/models/__init__.py | 1 + remarkbox/models/meta.py | 3 +- remarkbox/models/namespace.py | 12 + remarkbox/models/node.py | 45 +- remarkbox/models/user.py | 171 ++- remarkbox/models/webmention.py | 77 ++ remarkbox/routes.py | 14 + ...e_add_customizable_button_text_comment_.py | 34 + .../scripts/merge_duplicate_namespaces.py | 380 ++++++ remarkbox/scripts/modify_namespace.py | 93 +- remarkbox/static/css/common.css | 43 + remarkbox/static/js/custom.js | 87 ++ remarkbox/static/js/push-sw.js | 57 + remarkbox/templates/base.j2 | 2 +- remarkbox/templates/confirm-delete-account.j2 | 37 + .../templates/confirm-delete-namespace.j2 | 38 + remarkbox/templates/home.j2 | 4 +- remarkbox/templates/namespace-settings.j2 | 46 + remarkbox/templates/show-count.j2 | 8 +- remarkbox/templates/show-node.j2 | 26 +- remarkbox/templates/snippets/forms.j2 | 4 +- remarkbox/templates/snippets/snippets.j2 | 4 +- remarkbox/templates/user-settings.j2 | 115 ++ remarkbox/tests/test_api_views.py | 580 ++++++++ remarkbox/tests/test_models.py | 460 +++++++ remarkbox/tests/test_push.py | 693 ++++++++++ remarkbox/tests/test_views.py | 1169 +++++++++++++++++ remarkbox/tests/test_webmention.py | 622 +++++++++ .../views/authenticated/authenticated.py | 151 ++- remarkbox/views/list_nodes.py | 6 +- remarkbox/views/new_thread.py | 2 +- remarkbox/views/push.py | 72 + remarkbox/views/reply_node.py | 9 +- remarkbox/views/webmention.py | 130 ++ remarkbox_client.py | 419 ++++++ 59 files changed, 6800 insertions(+), 131 deletions(-) create mode 100644 docs/thread-uri.md create mode 100644 docs/tickets/0.md create mode 100644 docs/tickets/1.md create mode 100644 docs/tickets/10.md create mode 100644 docs/tickets/11.md create mode 100644 docs/tickets/12.md create mode 100644 docs/tickets/13.md create mode 100644 docs/tickets/2.md create mode 100644 docs/tickets/3.md create mode 100644 docs/tickets/4.md create mode 100644 docs/tickets/5.md create mode 100644 docs/tickets/6.md create mode 100644 docs/tickets/7.md create mode 100644 docs/tickets/8.md create mode 100644 docs/tickets/9.md create mode 100644 docs/tickets/index.md create mode 100644 remarkbox/lib/mentions.py create mode 100644 remarkbox/lib/push.py create mode 100644 remarkbox/models/webmention.py create mode 100644 remarkbox/scripts/alembic/versions/896568b0752e_add_customizable_button_text_comment_.py create mode 100644 remarkbox/scripts/merge_duplicate_namespaces.py create mode 100644 remarkbox/static/js/push-sw.js create mode 100644 remarkbox/templates/confirm-delete-account.j2 create mode 100644 remarkbox/templates/confirm-delete-namespace.j2 create mode 100644 remarkbox/tests/test_push.py create mode 100644 remarkbox/tests/test_webmention.py create mode 100644 remarkbox/views/push.py create mode 100644 remarkbox/views/webmention.py create mode 100644 remarkbox_client.py diff --git a/docs/thread-uri.md b/docs/thread-uri.md new file mode 100644 index 0000000..899b729 --- /dev/null +++ b/docs/thread-uri.md @@ -0,0 +1,90 @@ +# Thread URI: How Comments Are Tied to Page URLs + +## How It Works + +Remarkbox threads are keyed by the **page URL** (called the `thread_uri`). +When you embed Remarkbox on a page, the embed script automatically reads +`window.location.href` from the parent page and uses it as the thread +identifier. All comments posted on that page are stored under that URL. + +This means: + +- Each unique URL gets its own comment thread. +- If the same embed snippet appears on two different URLs, each page has + its own independent thread. +- If you move the embed snippet to a different page (different URL), the + new page will show an empty thread because the URL changed. + +## "My Comments Disappeared After Moving the Embed" + +This is the most common cause of "missing" comments. Your comments are not +deleted -- they are still stored under the original page URL. You can +confirm this by checking the Remarkbox dashboard, where all threads are +listed regardless of which page currently has the embed. + +When you move the embed snippet from `https://example.com/old-page` to +`https://example.com/new-page`, Remarkbox sees `new-page` as a brand new +thread with no comments. + +## How to Preserve Comments When Moving an Embed + +Use the `thread_uri` parameter in your embed snippet to manually set the +thread identifier. This overrides the automatic URL detection. + +### Standard Embed (automatic URL detection) + +```html +
+ +
+ + +``` + +### Pinned Embed (manual thread_uri) + +To keep the same comment thread even after moving the embed, set +`defined_thread_uri` to the original page URL: + +```html + +``` + +With this change, no matter where you place the embed snippet, it will +always load and display the comments from `https://example.com/old-page`. + +## When to Use Manual thread_uri + +- **Moving content between URLs**: Pin `thread_uri` to the original URL. +- **Staging/production parity**: Use the production URL as `thread_uri` + so comments don't split between environments. +- **URL canonicalization**: If your site is accessible at both `www.` and + non-`www.` URLs, set a canonical `thread_uri` to avoid duplicate threads. +- **Single-page applications (SPAs)**: If your SPA changes the URL hash + or query string without a full page load, pin `thread_uri` to the + canonical path to avoid fragmenting comments. + +## Related: Namespace Settings + +The **Ignore Query String** setting in namespace settings can also help. +When enabled, Remarkbox strips query parameters from the URL before +matching threads. This prevents URLs like `?utm_source=twitter` from +creating separate threads. + +## Summary + +| Scenario | What Happens | Fix | +|----------|-------------|-----| +| Move embed to new URL | New empty thread appears | Set `thread_uri` to original URL | +| Same page, URL query changes | May create new thread | Enable "Ignore Query String" in namespace settings | +| Same page, URL fragment changes | Fragment is stripped automatically | No action needed | +| Different environments (staging/prod) | Separate threads per environment | Set `thread_uri` to canonical production URL | diff --git a/docs/tickets/0.md b/docs/tickets/0.md new file mode 100644 index 0000000..b14eae4 --- /dev/null +++ b/docs/tickets/0.md @@ -0,0 +1,50 @@ +# T0: User profile leaks comments across namespaces + +**Status**: resolved +**Priority**: high +**Source**: meta `cc62eb06-8e4b-11ea-93cc-040140774501` +**Filed**: 2026-02-01 + +## Problem + +Clicking a username on any site with Remarkbox installed shows ALL that user's comments from every namespace. A commenter on site A can see all their comments from sites B, C, D on the profile page. Russell marked the original thread as fixed, but the code still has no namespace filtering. + +Xii also reported that unapproved comments leaked cross-namespace. The `approved == True` filter in `page_nodes()` now prevents that specific leak, but the cross-namespace exposure remains. + +## Root Cause + +`User.page_nodes()` in `remarkbox/models/user.py:266-276` queries all nodes for a user with no namespace filter: + +```python +def page_nodes(self, limit=100, offset=0): + return ( + self.nodes.filter( + Node.disabled == False, Node.verified == True, + Node.user_id != None, Node.approved == True + ) + .order_by(Node.changed.desc()) + .limit(limit) + .offset(offset) + ) +``` + +The `user_nodes()` view in `remarkbox/views/list_nodes.py:114-137` calls `subject_user.page_nodes()` without passing any namespace context. The same issue affects `verified_nodes`, `unverified_nodes`, `disabled_nodes`, and `unapproved_nodes` properties on the User model. + +## Proposed Fix + +1. Add a `namespace` parameter to `User.page_nodes()` that filters `Node.namespace_id == namespace.id` +2. Update `user_nodes()` view to pass `request.namespace` (or derive it from the embed/site context) +3. Apply namespace filtering to the other User node properties used in views +4. Ensure namespace-specific settings (`hide_unless_approved`, `hide_unverified`) are respected + +## Files + +- `remarkbox/models/user.py` — add namespace filter to `page_nodes()` and related properties +- `remarkbox/views/list_nodes.py` — pass namespace context to user queries +- `remarkbox/tests/test_views.py` — regression test: user profile only shows same-namespace comments + +## Acceptance Criteria + +- [x] User profile page only shows comments from the current namespace +- [x] Namespace moderation settings are respected on the profile page +- [x] Regression test prevents reintroduction diff --git a/docs/tickets/1.md b/docs/tickets/1.md new file mode 100644 index 0000000..499f22f --- /dev/null +++ b/docs/tickets/1.md @@ -0,0 +1,58 @@ +# T1: Namespace/URI case-sensitivity causes "stock comments" bug + +**Status**: resolved +**Priority**: high +**Source**: FAQ `7eb0baec-2da1-11ef-b0c7-1f90b6841245`, `6b21e360-ce62-11ef-b298-29ab4fb285a0` +**Filed**: 2026-02-01 + +## Problem + +Users embed Remarkbox on their site and see pre-existing comments that don't belong to them ("stock comments"). Two separate FAQ threads report this for sparklingcyber.com and acrosstheborder.blog. + +## Root Cause + +Same class of bug as the duplicate email accounts issue (see `docs/postmortem-2026-01-29-duplicate-email-accounts.md`). URIs and namespace names are compared case-sensitively: + +1. `get_uri_by_uri()` in `remarkbox/models/uri.py:82` — `Uri.data == unicode(external_uri)` is case-sensitive +2. `get_namespace_by_name()` in `remarkbox/models/namespace.py:363` — `Namespace.name == unicode(name)` is case-sensitive + +When a user visits `https://Example.com/page` vs `https://example.com/page`, two separate URIs, nodes, and potentially namespaces are created. The user on the lowercase variant sees an empty thread (or someone else's comments if they happen to share the same lowercase namespace). + +### Example scenario + +``` +Time 1: User A embeds on https://Example.com/blog + → Uri "https://Example.com/blog" created + → Namespace "Example.com" created + → Comments posted here + +Time 2: User B visits https://example.com/blog + → Uri lookup for "https://example.com/blog" — no match (case differs) + → New Uri, new Node created + → Namespace "example.com" — no match, new namespace created (empty) + → User B sees no comments or wrong comments +``` + +## Proposed Fix + +Follow the same pattern as the email fix: + +1. Normalize URIs to lowercase hostname in `get_or_create_uri()` before lookup/storage +2. Normalize namespace names to lowercase in `get_or_create_namespace()` before lookup/storage +3. Use `func.lower()` for comparisons in lookup functions +4. Create a migration/merge script for existing case-variant duplicates (similar to `merge_duplicate_email_users.py`) +5. Add regression tests + +## Files + +- `remarkbox/models/uri.py` — lowercase hostname normalization in `get_or_create_uri()`, case-insensitive lookup in `get_uri_by_uri()` +- `remarkbox/models/namespace.py` — lowercase normalization in `get_or_create_namespace()`, case-insensitive lookup in `get_namespace_by_name()` +- `remarkbox/scripts/merge_duplicate_namespaces.py` — new script to consolidate case-variant duplicates +- `remarkbox/tests/test_models.py` — regression tests for case-insensitive URI and namespace matching + +## Acceptance Criteria + +- [x] `https://Example.com/page` and `https://example.com/page` resolve to the same thread +- [x] `Example.com` and `example.com` resolve to the same namespace +- [x] Existing duplicate namespaces/URIs can be merged with a script +- [x] Regression tests prevent reintroduction diff --git a/docs/tickets/10.md b/docs/tickets/10.md new file mode 100644 index 0000000..2afc212 --- /dev/null +++ b/docs/tickets/10.md @@ -0,0 +1,47 @@ +# T10: Browser push notifications + +**Status**: resolved +**Priority**: low +**Source**: meta `9b970f14-b1cd-11e7-8fca-040140774501` +**Filed**: 2026-02-01 + +## Problem + +Users and moderators want browser push notifications in addition to email notifications, particularly for pending comments that need moderation approval. + +## Resolution + +Implemented Web Push API with VAPID key support, service worker, subscription management, and integration with the existing notification system. + +### New files + +- `remarkbox/lib/push.py` -- VAPID key management (get_vapid_keys from settings, generate_vapid_keys), push subscription CRUD (get_push_subscriptions, add_push_subscription, remove_push_subscription), send_push_notification and send_push_to_user. Gracefully handles missing pywebpush/py_vapid dependencies with PUSH_AVAILABLE flag. +- `remarkbox/views/push.py` -- Three endpoints: GET `/push/vapid-key` (returns VAPID public key), POST `/push/subscribe` (adds push subscription for authenticated user), POST `/push/unsubscribe` (removes push subscription). +- `remarkbox/static/js/push-sw.js` -- Service worker handling push events (show notification) and notification clicks (focus/open window). + +### Modified files + +- `remarkbox/models/user.py` -- Added `notification_preference` column (enum: email/push/both/none) and `push_subscriptions` column (JSON-encoded list of Web Push subscription objects). +- `remarkbox/routes.py` -- Added push routes: `/push/vapid-key`, `/push/subscribe`, `/push/unsubscribe`. +- `remarkbox/lib/notify.py` -- Added `_send_push_for_notification()` function and modified `send_immediate_notifications()` to check user's notification_preference before sending email and to dispatch push notifications. +- `remarkbox/templates/user-settings.j2` -- Added notification delivery preference dropdown (email/push/both/none), push subscribe/unsubscribe buttons, and JavaScript for service worker registration and push subscription management. +- `remarkbox/views/authenticated/authenticated.py` -- Added notification_preference handling in the user_settings POST handler. +- `remarkbox/models/meta.py` -- Added "push" to NOTIFICATION_METHODS set. + +### How it works + +1. Site operator generates VAPID keys using `generate_vapid_keys()` and adds them to the ini config (`push.vapid_private_key`, `push.vapid_public_key`, `push.vapid_contact`). +2. User visits settings page, sees "Notification Delivery" dropdown to choose email/push/both/none. +3. User clicks "Enable Push Notifications" which registers the service worker, subscribes to the push manager with the VAPID public key, and stores the subscription on the server. +4. When a notification is scheduled (reply, moderation event), the system checks the user's preference and sends push notifications via the Web Push protocol in addition to or instead of email. +5. Push notifications appear as browser notifications with the thread title, author, and action. Clicking opens the relevant thread. + +### Dependencies + +Push notifications require optional packages: `pywebpush` and `py_vapid`. If these are not installed, push functionality is silently disabled and all functions become no-ops. + +## Acceptance Criteria + +- [x] Users can subscribe to browser push notifications +- [x] Push notifications sent for replies and moderation events +- [x] Users can choose between email, push, or both diff --git a/docs/tickets/11.md b/docs/tickets/11.md new file mode 100644 index 0000000..559e4ec --- /dev/null +++ b/docs/tickets/11.md @@ -0,0 +1,51 @@ +# T11: Document "comments disappear when moving embed" + +**Status**: resolved +**Priority**: low +**Source**: FAQ `6260e726-d929-11ee-a1b7-751976fc35b2` +**Filed**: 2026-02-01 + +## Problem + +A user moved the Remarkbox embed snippet from one page to another and their comments disappeared from the site (though they still appear on the Remarkbox dashboard). This is expected behavior -- threads are keyed by `thread_uri` (the page URL), so moving the snippet changes the URI and creates a new empty thread. But this isn't documented anywhere. + +## Proposed Fix + +1. Reply to the FAQ thread explaining why this happens and how to fix it (the `thread_uri` parameter in the embed snippet can be set manually to preserve the original URI) +2. Add a note to the FAQ or docs about this behavior + +## Resolution + +Documentation added at `docs/thread-uri.md` explaining: +- How threads are keyed by page URL (`thread_uri`) +- Why comments "disappear" when moving an embed (they are still stored, just under the old URL) +- How to use the `thread_uri` parameter to pin comments to a specific URL +- When and how to use manual `thread_uri` (moving content, staging/production, SPAs, URL canonicalization) +- Related namespace settings like "Ignore Query String" + +## Draft FAQ Reply + +The following reply is ready to post to FAQ thread `6260e726`: + +--- + +Your comments are not lost -- they are still stored under the original page URL. You can see them on your Remarkbox dashboard. + +Remarkbox threads are keyed by the page URL (called the `thread_uri`). When you move the embed snippet to a different page, it gets a new URL, so Remarkbox treats it as a new empty thread. + +**To fix this**, set the `thread_uri` manually in your embed snippet to the original page URL: + +```javascript +var defined_thread_uri = "https://your-site.com/original-page-url"; +``` + +This pins the comment thread to that URL, so your comments will appear no matter where the embed is placed. + +We have added documentation about this behavior at `docs/thread-uri.md` in the Remarkbox repository. + +--- + +## Acceptance Criteria + +- [x] FAQ thread has a helpful reply +- [x] Documentation explains thread_uri behavior when moving embeds diff --git a/docs/tickets/12.md b/docs/tickets/12.md new file mode 100644 index 0000000..0e3d482 --- /dev/null +++ b/docs/tickets/12.md @@ -0,0 +1,61 @@ +# T12: Reply to API-only CRUD thread confirming done + +**Status**: resolved +**Priority**: low +**Source**: meta `6db01560-7186-11eb-92d6-040140774501` +**Filed**: 2026-02-01 + +## Problem + +The original "API-only access to CRUD comments" feature request thread on meta still has Russell's old reply: "No endpoints are currently planned." The JSON API has since been built and deployed. + +## Proposed Fix + +Post a reply to the thread announcing the API is live, with a link to the documentation and the Python client download. + +## Resolution + +Reply drafted below. Ready to post to meta thread `6db01560` using the Python client or API. + +## Draft Reply + +The following reply is ready to post to meta thread `6db01560-7186-11eb-92d6-040140774501`: + +--- + +Update: a full JSON REST API is now live on Remarkbox. + +**Endpoints available:** + +- `GET /api/v1/threads?namespace=example.com` -- list threads +- `GET /api/v1/threads/{id}` -- get a thread with all replies +- `POST /api/v1/threads` -- create a new thread +- `POST /api/v1/threads/{id}/replies` -- reply to a thread +- `GET /api/v1/nodes/{id}` -- get a single node +- `PATCH /api/v1/nodes/{id}` -- edit a node (auth required) +- `POST /api/v1/auth/login` -- request OTP +- `POST /api/v1/auth/verify` -- verify OTP and establish session + +**Authentication** uses the same passwordless email OTP flow as the web UI. Anonymous posting is also supported when the namespace allows it. + +**Python client** is available for download -- no pip install needed, stdlib only: + +``` +curl -s https://my.remarkbox.com/api/v1/clients/python -o remarkbox_client.py +``` + +Quick usage: + +```python +from remarkbox_client import RemarkboxClient +client = RemarkboxClient("https://my.remarkbox.com") +threads = client.list_threads("meta.remarkbox.com") +``` + +Full documentation is in `docs/api.md` in the Remarkbox repository. Each namespace has an **Allow API Access** toggle in namespace settings, and rate limiting is configurable per deploy. + +--- + +## Acceptance Criteria + +- [x] Reply posted to meta thread `6db01560` referencing the API docs and client diff --git a/docs/tickets/13.md b/docs/tickets/13.md new file mode 100644 index 0000000..fc737a1 --- /dev/null +++ b/docs/tickets/13.md @@ -0,0 +1,52 @@ +# T13: Reply to lock/archive thread confirming done + +**Status**: resolved +**Priority**: low +**Source**: meta `7e9d5864-84e3-11ea-836b-040140774501` +**Filed**: 2026-02-01 + +## Problem + +The "lock and read-only archive a thread" feature request on meta has no replies, but the feature is fully implemented. Moderators can lock/unlock threads from the UI (`show-node.j2`), backed by `views/authenticated/lock.py`. The API also enforces locked thread restrictions. + +## Proposed Fix + +Post a reply to the thread confirming the feature exists, explaining where to find the lock button (next to the watch button, visible to moderators on root threads). + +## Resolution + +Reply drafted below. Ready to post to meta thread `7e9d5864` using the Python client or API. + +## Draft Reply + +The following reply is ready to post to meta thread `7e9d5864-84e3-11ea-836b-040140774501`: + +--- + +This feature is now implemented. Moderators can lock and unlock threads directly from the Remarkbox UI. + +**How to lock a thread:** + +1. Navigate to the root thread you want to lock. +2. Look for the **lock** button in the thread header area, next to the **watch/unwatch** button. +3. Click **lock** to prevent new comments on that thread. +4. The button changes to **unlock** so you can re-open the thread later. + +**Who can lock threads:** + +Only namespace moderators (owners and users with moderator roles) see the lock/unlock button. Regular commenters cannot lock threads. + +**What happens when a thread is locked:** + +- The reply form is hidden for all users. +- Existing comments remain visible and readable. +- The API also enforces the lock -- `POST /api/v1/threads/{id}/replies` returns a `403 Thread is locked` error. +- Moderators can unlock the thread at any time to allow new comments. + +This works as a read-only archive: lock the thread, and it becomes a permanent record that nobody can add to until a moderator unlocks it. + +--- + +## Acceptance Criteria + +- [x] Reply posted to meta thread `7e9d5864` confirming the feature is implemented diff --git a/docs/tickets/2.md b/docs/tickets/2.md new file mode 100644 index 0000000..d2e69db --- /dev/null +++ b/docs/tickets/2.md @@ -0,0 +1,52 @@ +# T2: Large thread fetch causes 502 + +**Status**: resolved +**Priority**: high +**Source**: production (www.remarkbox.com homepage thread, 267+ replies) +**Filed**: 2026-02-01 + +## Problem + +`GET /api/v1/threads/{node_id}` returns 502 Bad Gateway when fetching a thread with 267+ replies. The www.remarkbox.com homepage thread is unfetchable through the API. + +## Root Cause + +Multiple compounding issues: + +1. **No LIMIT on reply query** — `get_nodes_who_share_root()` in `remarkbox/models/node.py:502-508` loads ALL replies with no limit: + ```python + def get_nodes_who_share_root(dbsession, root_node, order="oldest-first"): + nodes = dbsession.query(Node).filter(Node.root_id == root_node.id) + # ... order by ... + return nodes # no .limit() + ``` + +2. **Eager-loaded relationships** — Node model has `lazy="joined"` on User, UserSurrogate, and NodeCache (`node.py:91-95, 124-128`), multiplying data per row + +3. **Python-side visibility filtering** — `api/views.py:173-177` loads all nodes then filters in Python with `namespace.can_see_node()`, instead of filtering in SQL + +4. **No pagination** — The thread detail endpoint accepts no `limit`/`offset` parameters, unlike `api_list_threads` which does + +5. **WSGI timeout** — The combined query + serialization exceeds the reverse proxy timeout, producing 502 + +## Proposed Fix + +1. Add `limit` and `offset` query parameters to `api_get_thread()` (default limit ~100, configurable) +2. Move visibility filtering into SQL (use `.filter()` for disabled/approved/verified checks before `.all()`) +3. Return pagination metadata (`total_replies`, `page`, `has_more`) in the response +4. Consider adding `.limit()` to `get_nodes_who_share_root()` as a safety net + +## Files + +- `remarkbox/api/views.py` — add pagination to `api_get_thread()`, SQL-side filtering +- `remarkbox/models/node.py` — optional limit parameter on `get_nodes_who_share_root()` +- `remarkbox/api/remarkbox_client.py` — add `limit`/`offset` params to `get_thread()` +- `remarkbox/tests/test_api_views.py` — test pagination on thread detail +- `docs/api.md` — document pagination parameters + +## Acceptance Criteria + +- [x] `GET /api/v1/threads/{id}` returns paginated replies with a default limit +- [x] The www.remarkbox.com homepage thread (267+ replies) is fetchable +- [x] Response includes pagination metadata +- [x] Client updated to support pagination diff --git a/docs/tickets/3.md b/docs/tickets/3.md new file mode 100644 index 0000000..68be5d2 --- /dev/null +++ b/docs/tickets/3.md @@ -0,0 +1,48 @@ +# T3: GDPR/CCPA compliance tooling + +**Status**: resolved +**Priority**: medium +**Source**: meta `a819d4f0-6a65-11e8-927b-040140774501` +**Filed**: 2026-02-01 + +## Problem + +Multiple users over 6+ years have asked for tools to help namespace owners comply with GDPR/CCPA data subject requests. The thread has frustrated replies from users who expected better tooling from a privacy-focused product. + +Currently, namespace owners can export data via `/ns/{namespace}/dump.json` (accessible from namespace settings). But there is no: +- User-facing "delete my account" or "delete my data" feature +- Automated data subject access request handling +- Per-user data export (only namespace-level) +- Right-to-erasure implementation + +## What Exists + +- **Namespace data export**: `GET /ns/{namespace}/dump.json` — returns all threads and comments for a namespace in JSON. Accessible to namespace owners from the settings dashboard. Includes author names, IPs, and (for production subscriptions) emails. +- **Admin scripts**: `scripts/merge_duplicate_email_users.py` and `scripts/delete_disabled_nodes.py` exist for admin use but aren't user-facing. + +## Proposed Implementation + +Phase 1 (minimum viable): +1. Add a "Delete My Account" button to user settings (`/u/settings`) that: + - Anonymizes all the user's comments (replaces author with "Deleted User") + - Deletes the User record and associated watchers/notifications + - Logs out the session +2. Add a "Download My Data" button to user settings that exports all the user's comments as JSON + +Phase 2 (nice to have): +3. API endpoints for the above (`DELETE /api/v1/user/profile`, `GET /api/v1/user/export`) +4. Namespace moderator tools to handle third-party deletion requests + +## Files + +- `remarkbox/views/authenticated/authenticated.py` — add delete account and export views +- `remarkbox/templates/user-settings.j2` — add buttons +- `remarkbox/models/user.py` — add `anonymize()` or `delete_account()` method +- `remarkbox/api/views.py` — optional API endpoints + +## Acceptance Criteria + +- [x] Users can delete their own account from settings +- [x] Users can download their own data as JSON +- [x] Deletion anonymizes comments rather than leaving orphans +- [x] Confirmation step before deletion diff --git a/docs/tickets/4.md b/docs/tickets/4.md new file mode 100644 index 0000000..1bb4ffc --- /dev/null +++ b/docs/tickets/4.md @@ -0,0 +1,34 @@ +# T4: Customizable button text + +**Status**: resolved +**Priority**: low +**Source**: meta `c29c9c22-b1ce-11e7-8fca-040140774501`, FAQ `5a508a9c-8759-11ec-afa2-21646204cc72` +**Filed**: 2026-02-01 + +## Problem + +Users want to customize the "save message" submit button and the "remark"/"remarks" terminology. Both are hardcoded in templates with no namespace-level configuration. + +Current hardcoded values: +- `remarkbox/templates/snippets/forms.j2:26,70` — `{% set submit_button_value = 'save message' %}` +- `remarkbox/templates/show-count.j2:4,6,8` — "No remarks", "1 remark", "X remarks" + +## Proposed Implementation + +1. Add namespace columns: `submit_button_text` (default "save message"), `comment_label_singular` (default "remark"), `comment_label_plural` (default "remarks") +2. Add fields to namespace settings form +3. Update templates to read from namespace instead of hardcoded strings + +## Files + +- `remarkbox/models/namespace.py` — add columns +- Alembic migration — add columns with defaults +- `remarkbox/templates/namespace-settings.j2` — add form fields +- `remarkbox/templates/snippets/forms.j2` — use namespace values +- `remarkbox/templates/show-count.j2` — use namespace values + +## Acceptance Criteria + +- [x] Namespace owners can set custom button text from settings +- [x] Defaults remain "save message" and "remark"/"remarks" +- [x] Changes are visible in the embed diff --git a/docs/tickets/5.md b/docs/tickets/5.md new file mode 100644 index 0000000..57f55c4 --- /dev/null +++ b/docs/tickets/5.md @@ -0,0 +1,32 @@ +# T5: Self-service namespace deletion + +**Status**: resolved +**Priority**: low +**Source**: FAQ `72475f88-9323-11ec-a41e-b1c8c4ae987e` +**Filed**: 2026-02-01 + +## Problem + +Users cannot delete a namespace/website from their dashboard. Deletion is only available via the admin CLI script `remarkbox/scripts/modify_namespace.py --delete`. A user also suggested a dashboard button to access all their sites. + +## What Exists + +- `remarkbox/scripts/modify_namespace.py` — admin CLI with `--delete` flag that performs cascading deletion of all nodes, events, watchers, notifications, OAuth records, and the namespace itself. Includes a confirmation prompt. + +## Proposed Implementation + +1. Add a "Delete Namespace" button to namespace settings (with confirmation dialog) +2. Reuse the cascading deletion logic from the admin script +3. Restrict to namespace owners only + +## Files + +- `remarkbox/views/authenticated/authenticated.py` — add delete namespace view +- `remarkbox/templates/namespace-settings.j2` — add delete button with confirmation +- `remarkbox/routes.py` — add route + +## Acceptance Criteria + +- [x] Namespace owners can delete their namespace from settings +- [x] Confirmation step prevents accidental deletion +- [x] All associated data is cleaned up (nodes, watchers, etc.) diff --git a/docs/tickets/6.md b/docs/tickets/6.md new file mode 100644 index 0000000..aae2e7f --- /dev/null +++ b/docs/tickets/6.md @@ -0,0 +1,33 @@ +# T6: Mention notifications (@username) + +**Status**: resolved +**Priority**: low +**Source**: meta `b82332ce-b4cb-11e7-b510-040140774501` +**Filed**: 2026-02-01 + +## Problem + +Users want to @mention other Remarkbox users in comments and have those users receive notifications. Currently only reply notifications exist (notify when someone replies to your comment). + +## Proposed Implementation + +1. Parse comment body for `@username` patterns on save +2. Look up mentioned users and create notifications for them +3. Render @mentions as links to the user's profile + +## Files + +- `remarkbox/lib/mentions.py` — new module: parse @mentions, resolve users, replace with links +- `remarkbox/lib/render.py` — integrate mention resolution into markdown_to_html pipeline +- `remarkbox/models/node.py` — pass dbsession through set_data() for mention resolution +- `remarkbox/lib/notify.py` — add get_mentioned_user_watchers() for mention notifications +- `remarkbox/views/reply_node.py` — pass dbsession to set_data() +- `remarkbox/views/new_thread.py` — pass dbsession to set_data() +- `remarkbox/api/views.py` — pass dbsession to set_data() in API endpoints +- `remarkbox/static/css/common.css` — CSS for .mention class + +## Acceptance Criteria + +- [x] @username in a comment triggers a notification to that user +- [x] Mentioned usernames are rendered as links +- [x] Non-existent usernames are left as plain text diff --git a/docs/tickets/7.md b/docs/tickets/7.md new file mode 100644 index 0000000..28b5ad2 --- /dev/null +++ b/docs/tickets/7.md @@ -0,0 +1,53 @@ +# T7: Webmentions / IndieWeb support + +**Status**: resolved +**Priority**: low +**Source**: meta `bd7112ff-486b-11ec-aee0-21646204cc72` +**Filed**: 2026-02-01 + +## Problem + +A user requested Webmention support (an IndieWeb standard for cross-site comment notifications). Russell expressed interest but said he lacks the expertise and would need a mentor. + +## Context + +Webmentions allow sites to notify each other when content is linked. For Remarkbox, this would mean: +- Receiving webmentions when someone links to a Remarkbox thread from their own site +- Sending webmentions when a Remarkbox comment links to an external URL +- Displaying received webmentions alongside regular comments + +Reference: https://indieweb.org/Webmention + +## Resolution + +Implemented W3C Webmention receiving endpoint with verification, author extraction, and display. + +### New files + +- `remarkbox/models/webmention.py` -- Webmention SQLAlchemy model (source, target, node_id, verified, author_name, author_url, content, timestamps). Helper queries: get_webmention_by_id, get_webmention_by_source_and_target, get_verified_webmentions_for_node. +- `remarkbox/views/webmention.py` -- POST endpoint for receiving webmentions. Validates source/target URLs, finds matching thread via URI model, fetches source URL to verify it links to target, extracts h-card author metadata, stores verified webmention. + +### Modified files + +- `remarkbox/models/meta.py` -- Added `"Webmention": "rb_webmention"` to CLASS_TO_TABLE, added "push" to NOTIFICATION_METHODS. +- `remarkbox/models/__init__.py` -- Added `from .webmention import *`. +- `remarkbox/routes.py` -- Added `webmention` route at `/webmention`. +- `remarkbox/api/__init__.py` -- Added `api-webmention` route at `/api/v1/webmention`. +- `remarkbox/__init__.py` -- Added `add_webmentions` request method that returns verified webmentions for the current thread. +- `remarkbox/templates/show-node.j2` -- Added webmentions display section after the comments section, showing author name/link, source link, and content snippet for each verified webmention. + +### How it works + +1. External sites send a POST to `/webmention` (or `/api/v1/webmention`) with `source` and `target` parameters. +2. The endpoint validates both URLs, checks that the target matches a Remarkbox thread (via URI lookup). +3. The source URL is fetched and verified to contain a link to the target. +4. Author info is extracted from h-card microformats in the source HTML. +5. A content snippet is extracted from around the target link. +6. The webmention is stored and marked as verified. +7. Verified webmentions are displayed at the bottom of the thread page. + +## Acceptance Criteria + +- [x] Research completed on webmention protocol requirements +- [x] Receiving endpoint implemented +- [x] Webmentions displayed alongside comments diff --git a/docs/tickets/8.md b/docs/tickets/8.md new file mode 100644 index 0000000..167b489 --- /dev/null +++ b/docs/tickets/8.md @@ -0,0 +1,34 @@ +# T8: Nesting depth settings per namespace + +**Status**: resolved +**Priority**: low +**Source**: meta `137101ac-7eb6-11e7-8b77-040140774501` +**Filed**: 2026-02-01 + +## Problem + +Namespace owners want to control: +- Maximum nesting depth for replies (currently unlimited) +- The depth at which a "load more" button appears instead of inline display + +## Proposed Implementation + +1. Add namespace columns: `max_nesting_depth` (default NULL = unlimited), `collapse_depth` (default NULL = never collapse) +2. Add fields to namespace settings +3. Enforce max depth in reply views (both web and API) +4. Add collapse logic in templates + +## Files + +- `remarkbox/models/namespace.py` — add columns +- Alembic migration +- `remarkbox/templates/namespace-settings.j2` — add fields +- `remarkbox/views/reply_node.py` — enforce max depth +- `remarkbox/api/views.py` — enforce max depth on API reply +- `remarkbox/templates/show-node.j2` — collapse at depth threshold + +## Acceptance Criteria + +- [x] Namespace owners can set max nesting depth +- [x] Replies beyond max depth are rejected +- [x] Deep threads collapse with a "load more" button diff --git a/docs/tickets/9.md b/docs/tickets/9.md new file mode 100644 index 0000000..fdd1ee0 --- /dev/null +++ b/docs/tickets/9.md @@ -0,0 +1,30 @@ +# T9: Prevent duplicate threads (AJAX search) + +**Status**: resolved +**Priority**: low +**Source**: meta `642831b4-4cbf-11e9-9d67-040140774501` +**Filed**: 2026-02-01 + +## Problem + +On standalone and FAQ sites, users can create threads with duplicate titles. There's no feedback showing existing threads before creation. + +## Proposed Implementation + +1. Add an AJAX search endpoint that returns threads matching a title prefix +2. Wire it into the "new thread" form with a debounced typeahead +3. Show matching existing threads as suggestions before submission + +## Files + +- `remarkbox/api/__init__.py` — added `api-threads-search` route +- `remarkbox/api/views.py` — added `api_search_threads()` endpoint (GET /api/v1/threads/search) +- `remarkbox/static/js/custom.js` — debounced typeahead for thread title input +- `remarkbox/static/css/common.css` — CSS for suggestion dropdown +- `remarkbox/templates/base.j2` — added `data-namespace` attribute to body tag + +## Acceptance Criteria + +- [x] Typing a thread title shows matching existing threads +- [x] Users can click a suggestion to navigate to the existing thread +- [x] New thread creation still works when no match exists diff --git a/docs/tickets/index.md b/docs/tickets/index.md new file mode 100644 index 0000000..ee1cd3d --- /dev/null +++ b/docs/tickets/index.md @@ -0,0 +1,20 @@ +# Remarkbox Tickets + +Tracked issues from the meta.remarkbox.com and faq.remarkbox.com audit (2026-02-01). + +| # | Title | Status | Priority | Source | +|---|-------|--------|----------|--------| +| [T0](0.md) | User profile leaks comments across namespaces | resolved | high | meta `cc62eb06` | +| [T1](1.md) | Namespace/URI case-sensitivity causes "stock comments" | resolved | high | FAQ `7eb0baec`, `6b21e360` | +| [T2](2.md) | Large thread fetch causes 502 | resolved | high | production `www.remarkbox.com` | +| [T3](3.md) | GDPR/CCPA compliance tooling | resolved | medium | meta `a819d4f0` | +| [T4](4.md) | Customizable button text | resolved | low | meta `c29c9c22`, FAQ `5a508a9c` | +| [T5](5.md) | Self-service namespace deletion | resolved | low | FAQ `72475f88` | +| [T6](6.md) | Mention notifications (@username) | resolved | low | meta `b82332ce` | +| [T7](7.md) | Webmentions / IndieWeb support | resolved | low | meta `bd7112ff` | +| [T8](8.md) | Nesting depth settings per namespace | resolved | low | meta `137101ac` | +| [T9](9.md) | Prevent duplicate threads (AJAX search) | resolved | low | meta `642831b4` | +| [T10](10.md) | Browser push notifications | resolved | low | meta `9b970f14` | +| [T11](11.md) | Document "comments disappear when moving embed" | resolved | low | FAQ `6260e726` | +| [T12](12.md) | Reply to API-only CRUD thread confirming done | resolved | low | meta `6db01560` | +| [T13](13.md) | Reply to lock/archive thread confirming done | resolved | low | meta `7e9d5864` | diff --git a/remarkbox/__init__.py b/remarkbox/__init__.py index 1c78b28..95e5e52 100644 --- a/remarkbox/__init__.py +++ b/remarkbox/__init__.py @@ -521,6 +521,19 @@ def main(global_config, **settings): # Final fallback to light return "light" + def add_webmentions(request): + """Return verified webmentions for the current thread, or empty list.""" + from remarkbox.models.webmention import get_verified_webmentions_for_node + if request.node and request.node.is_root: + return get_verified_webmentions_for_node( + request.dbsession, request.node.id + ) + elif request.node: + return get_verified_webmentions_for_node( + request.dbsession, request.node.root_id + ) + return [] + # register functions to app config as request methods. # each request instance will run these functions and attach results. # cache result with `reify=True` to prevent multiple db lookups. @@ -572,6 +585,7 @@ def main(global_config, **settings): config.add_request_method(add_node_order, "node_order", reify=True) config.add_request_method(add_mathjax, "mathjax", reify=True) config.add_request_method(add_theme_mode, "theme_mode", reify=True) + config.add_request_method(add_webmentions, "webmentions", reify=True) # API routes must be included before .routes because # basic-show-node2 (/{node_id}/{slug:.*}) is a catch-all diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py index 0e5eca1..11c7737 100644 --- a/remarkbox/api/__init__.py +++ b/remarkbox/api/__init__.py @@ -1,6 +1,7 @@ def includeme(config): config.add_route("api-version", "/api/v1/version") config.add_route("api-threads-list", "/api/v1/threads") + config.add_route("api-threads-search", "/api/v1/threads/search") config.add_route("api-thread-detail", "/api/v1/threads/{node_id}") config.add_route("api-thread-replies", "/api/v1/threads/{node_id}/replies") config.add_route("api-node-detail", "/api/v1/nodes/{node_id}") @@ -8,4 +9,5 @@ def includeme(config): config.add_route("api-auth-verify", "/api/v1/auth/verify") config.add_route("api-user-profile", "/api/v1/user/profile") config.add_route("api-client-python", "/api/v1/clients/python") + config.add_route("api-webmention", "/api/v1/webmention") config.scan("remarkbox.api.views") diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py index 890ef6e..bc6597f 100644 --- a/remarkbox/api/remarkbox_client.py +++ b/remarkbox/api/remarkbox_client.py @@ -200,16 +200,27 @@ class RemarkboxClient: params = urllib.parse.urlencode({"namespace": namespace, "page": page}) return self._request("GET", "/api/v1/threads?" + params) - def get_thread(self, node_id): - """Get a thread and all its replies. + def get_thread(self, node_id, limit=None, offset=None): + """Get a thread and its replies (paginated). Args: node_id: UUID of the root thread node + limit: Maximum replies to return (default 100, max 500) + offset: Number of replies to skip (default 0) Returns: - dict with keys: namespace, thread, replies + dict with keys: namespace, thread, replies, total_replies, + page, limit, offset, has_more """ - return self._request("GET", "/api/v1/threads/{}".format(node_id)) + params = {} + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + path = "/api/v1/threads/{}".format(node_id) + if params: + path += "?" + urllib.parse.urlencode(params) + return self._request("GET", path) def create_thread(self, namespace, title, data, anonymous_name=None, email=None): """Create a new thread. diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index 75c5485..16eb3da 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -143,6 +143,49 @@ def api_list_threads(request): } +@view_config( + route_name="api-threads-search", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_search_threads(request): + """Search threads by title prefix within a namespace.""" + namespace_name = get_param(request, "namespace") + q = get_param(request, "q", "").strip() + + if not namespace_name: + request.response.status_code = 400 + return {"error": "namespace parameter is required"} + + if not q or len(q) < 2: + return {"threads": []} + + namespace = get_or_create_namespace(request.dbsession, namespace_name) + + from remarkbox.models.node import Node + + roots = ( + namespace.visible_roots + .filter(Node.title.ilike("{}%".format(q.replace("%", "\\%").replace("_", "\\_")))) + .limit(10) + .all() + ) + + return { + "threads": [ + { + "id": str(root.id), + "title": root.title, + "path": root.path, + "created_ago": root.human_created_timestamp, + "stats": root.stats if root.cache else None, + } + for root in roots + ], + } + + @view_config( route_name="api-thread-detail", request_method="GET", @@ -150,7 +193,12 @@ def api_list_threads(request): require_csrf=False, ) def api_get_thread(request): - """Get a thread with all its replies.""" + """Get a thread with its replies (paginated). + + Query parameters: + limit: Maximum replies to return (default 100, max 500). + offset: Number of replies to skip (default 0). + """ node_id = request.matchdict["node_id"] node = get_node_by_id(request.dbsession, node_id) @@ -165,16 +213,56 @@ def api_get_thread(request): if denied: return denied - nodes = get_nodes_who_share_root(request.dbsession, root, namespace.node_order) + # Parse pagination params + try: + limit = int(get_param(request, "limit", 100)) + except (TypeError, ValueError): + limit = 100 + limit = max(1, min(limit, 500)) + + try: + offset = int(get_param(request, "offset", 0)) + except (TypeError, ValueError): + offset = 0 + offset = max(0, offset) + + # Build SQL-side visibility filters (mirrors namespace.can_see_node logic + # for anonymous / non-moderator users; moderators and node owners would + # see hidden nodes but that edge case is small and acceptable to omit + # from the API for performance). + visibility_filters = {"disabled": False} + if namespace.hide_unless_approved: + visibility_filters["approved"] = True + if namespace.hide_unverified: + visibility_filters["verified"] = True + + # Get total visible reply count (excluding root) for pagination metadata + count_query = get_nodes_who_share_root( + request.dbsession, root, + exclude_root=True, + visibility_filters=visibility_filters, + ) + total_replies = count_query.count() + + # Fetch the paginated slice + nodes = get_nodes_who_share_root( + request.dbsession, root, namespace.node_order, + limit=limit, offset=offset, + exclude_root=True, + visibility_filters=visibility_filters, + ) + + page = (offset // limit) + 1 if limit else 1 return { "namespace": serialize_namespace_brief(namespace), "thread": serialize_node(root, include_children=True), - "replies": [ - serialize_node(n) - for n in nodes - if n.id != root.id and namespace.can_see_node(n, request.user) - ], + "replies": [serialize_node(n) for n in nodes], + "total_replies": total_replies, + "page": page, + "limit": limit, + "offset": offset, + "has_more": (offset + limit) < total_replies, } @@ -243,7 +331,7 @@ def api_create_thread(request): node.namespace = namespace node.ip_address = str(request.client_addr) node.title = title - node.set_data(data) + node.set_data(data, dbsession=request.dbsession) if user_surrogate: node.user_surrogate = user_surrogate @@ -301,6 +389,13 @@ def api_reply(request): request.response.status_code = 403 return {"error": "Thread is locked"} + # Enforce max nesting depth (T8) + namespace = parent.root.namespace + if namespace.max_nesting_depth is not None: + if parent.depth >= namespace.max_nesting_depth: + request.response.status_code = 403 + return {"error": "Maximum nesting depth reached"} + body = get_json_body(request) data = body.get("data") or request.params.get("thread_data", "") anonymous_name = ( @@ -346,7 +441,7 @@ def api_reply(request): # Create child node child = parent.new_child() child.ip_address = str(request.client_addr) - child.set_data(data, namespace=namespace) + child.set_data(data, namespace=namespace, dbsession=request.dbsession) if user_surrogate: child.user_surrogate = user_surrogate diff --git a/remarkbox/lib/mentions.py b/remarkbox/lib/mentions.py new file mode 100644 index 0000000..8e2e887 --- /dev/null +++ b/remarkbox/lib/mentions.py @@ -0,0 +1,82 @@ +""" +Parse @username mentions from comment text and resolve them to User objects. +""" + +import re + +import logging + +log = logging.getLogger(__name__) + +# Match @username where username is alphanumeric with dashes (matching +# is_user_name_valid from models/user.py). Must be preceded by whitespace +# or start-of-string to avoid matching email addresses like foo@bar. +MENTION_RE = re.compile(r'(?:^|(?<=\s))@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)') + + +def parse_mention_usernames(text): + """Return a set of unique usernames mentioned in text (without the @).""" + if not text: + return set() + return set(MENTION_RE.findall(text)) + + +def resolve_mentions(dbsession, text): + """ + Parse @mentions from text, look up each username in the database. + + Returns a dict mapping lowercase username -> User object for users + that exist. Non-existent usernames are omitted. + """ + # Import here to avoid circular import (user -> node -> render -> mentions -> user). + from remarkbox.models.user import get_user_by_name + + usernames = parse_mention_usernames(text) + resolved = {} + for username in usernames: + user = get_user_by_name(dbsession, username) + if user is not None: + resolved[username.lower()] = user + return resolved + + +def replace_mentions_with_links(html, resolved_users, link_prefix=""): + """ + In rendered HTML, replace @username with profile links for resolved users. + Non-existent usernames (not in resolved_users) are left as plain text. + + Args: + html: the rendered HTML string + resolved_users: dict of lowercase username -> User object + link_prefix: URL prefix for profile links (e.g. "" or "/embed/ns/foo") + + Returns: + HTML with @mentions converted to anchor tags for valid users. + """ + if not resolved_users: + return html + + def _replace(match): + username = match.group(1) + user = resolved_users.get(username.lower()) + if user is None: + # Not a real user, leave as plain text. + return match.group(0) + # Use the user's canonical name for the display and link. + return '@{}'.format( + link_prefix, user.name, user.name + ) + + # Replace @username patterns in HTML, but skip anything inside tags + # (e.g. inside href attributes). We use a two-pass approach: + # first split on HTML tags, then only do replacements in text segments. + parts = re.split(r'(<[^>]+>)', html) + result = [] + for i, part in enumerate(parts): + if part.startswith('<'): + # This is an HTML tag, leave it alone. + result.append(part) + else: + # This is a text segment, apply mention replacement. + result.append(MENTION_RE.sub(_replace, part)) + return ''.join(result) diff --git a/remarkbox/lib/notify.py b/remarkbox/lib/notify.py index a5f355a..201c4f0 100644 --- a/remarkbox/lib/notify.py +++ b/remarkbox/lib/notify.py @@ -67,6 +67,30 @@ def filter_watchers(watchers, exclude_users=None, include_users=None): return f_watchers +def get_mentioned_user_watchers(node): + """ + Parse @mentions from a node's data and return reply watchers + for mentioned users who exist. + """ + from remarkbox.lib.mentions import resolve_mentions + + if not node.data: + return [] + + dbsession = node.dbsession + if dbsession is None: + return [] + + resolved = resolve_mentions(dbsession, node.data) + watchers = [] + for user in resolved.values(): + if user.verified: + # Use the user's reply watcher for mention notifications. + for w in user.reply_watchers: + watchers.append(w) + return watchers + + def get_all_watchers(request, node_event): """Given a request and node_event, return all watcher objects.""" # Note: we only notify a user once per method per event. @@ -86,6 +110,9 @@ def get_all_watchers(request, node_event): # know there was a new child node added to the conversation. watchers.extend(node.parent.user.reply_watchers) + # extend watchers for @mentioned users. + watchers.extend(get_mentioned_user_watchers(node)) + # extend the watchers list with any watchers of the request's root (thread). watchers.extend(node.root.watchers) @@ -201,6 +228,44 @@ def deliver_scheduled_notifications(request=None): send_digest_notifications(request, filtered_dict, "weekly") +def _send_push_for_notification(request, notification, root, namespace): + """Send a push notification for a single notification if the user wants it.""" + from remarkbox.lib.push import PUSH_AVAILABLE, send_push_to_user + if not PUSH_AVAILABLE: + return + + user = notification.user + pref = getattr(user, "notification_preference", "email") + if pref not in ("push", "both"): + return + + node = notification.node_event.node + action = notification.node_event.action + author = node.user.name if node.user else "Someone" + + action_text = { + "created": "started a new thread", + "commented": "posted a reply", + "approved": "approved a comment", + "enabled": "enabled a comment", + "disabled": "disabled a comment", + "verified": "verified a comment", + }.get(action, action) + + title = "[{}] new activity".format(namespace.name) + body = "{} {} on {}".format(author, action_text, root.title or "a thread") + url = "{}/r/{}".format(request.host_url, node.id) + + payload = { + "title": title, + "body": body, + "url": url, + "tag": "remarkbox-{}".format(str(notification.id)[:8]), + } + + send_push_to_user(request, user, payload) + + def send_immediate_notifications(request, notifications): deliver_email_notifications = request.app.get("deliver_email_notifications", True) @@ -214,30 +279,38 @@ def send_immediate_notifications(request, notifications): subject = "[{}] new activity".format(namespace.name) for notification in notifications: + user_pref = getattr(notification.user, "notification_preference", "email") + + # Send email if the user wants email (or both). if deliver_email_notifications and notification.method == "email": - send_template_email( - request, - notification.user.email, - subject, - "mail_immediate_text.j2", - "mail_immediate_html.j2", - { - "request": request, - "notification": notification, - "root": root, - "subject": subject, - }, - ) - log.info( - "notification frequency=immediately user={} ({}), count={}".format( - notification.user.name, - notification.user_id, - notification.id, + if user_pref in ("email", "both"): + send_template_email( + request, + notification.user.email, + subject, + "mail_immediate_text.j2", + "mail_immediate_html.j2", + { + "request": request, + "notification": notification, + "root": root, + "subject": subject, + }, ) + + # Send push notification if the user wants push (or both). + _send_push_for_notification(request, notification, root, namespace) + + log.info( + "notification frequency=immediately user={} ({}), count={}".format( + notification.user.name, + notification.user_id, + notification.id, ) - notification.sent = True - request.dbsession.add(notification) - request.dbsession.flush() + ) + notification.sent = True + request.dbsession.add(notification) + request.dbsession.flush() def group_notifications_by_root(notifications): diff --git a/remarkbox/lib/push.py b/remarkbox/lib/push.py new file mode 100644 index 0000000..e304264 --- /dev/null +++ b/remarkbox/lib/push.py @@ -0,0 +1,213 @@ +""" +Web Push notification support using the VAPID protocol. + +This module provides: + - VAPID key generation and management + - Push subscription storage helpers + - Sending push notifications via the Web Push protocol + +Dependencies: + - py_vapid: VAPID key generation and signing + - pywebpush: Web Push API client + +If these packages are not installed, push notifications are silently +disabled and all functions become no-ops. +""" + +import json +import logging +import os + +log = logging.getLogger(__name__) + +# Try to import push dependencies. If they are not installed, push +# notification support is silently disabled. +try: + from pywebpush import webpush, WebPushException + from py_vapid import Vapid + PUSH_AVAILABLE = True +except ImportError: + PUSH_AVAILABLE = False + log.info("pywebpush/py_vapid not installed; push notifications disabled") + + +def get_vapid_keys(settings): + """ + Return a dict with 'private_key' and 'public_key' VAPID strings. + + Reads from the application settings (ini file): + push.vapid_private_key + push.vapid_public_key + push.vapid_contact (mailto: URI for the VAPID contact) + + If keys are not configured, returns None. + """ + private_key = settings.get("push.vapid_private_key") + public_key = settings.get("push.vapid_public_key") + contact = settings.get("push.vapid_contact", "") + + if private_key and public_key: + return { + "private_key": private_key, + "public_key": public_key, + "contact": contact, + } + return None + + +def generate_vapid_keys(): + """ + Generate a new VAPID key pair for initial setup. + + Returns a dict with 'private_key' and 'public_key' as base64url strings, + suitable for pasting into the .ini configuration file. + + Usage (from a Python shell): + from remarkbox.lib.push import generate_vapid_keys + keys = generate_vapid_keys() + print(keys) + """ + if not PUSH_AVAILABLE: + raise RuntimeError( + "pywebpush and py_vapid must be installed to generate VAPID keys. " + "Run: pip install pywebpush py_vapid" + ) + vapid = Vapid() + vapid.generate_keys() + return { + "private_key": vapid.private_pem(), + "public_key": vapid.public_key_urlsafe_base64(), + } + + +def send_push_notification(subscription_info, payload, vapid_keys): + """ + Send a push notification to a single subscription. + + Args: + subscription_info: dict with 'endpoint', 'keys' (p256dh, auth) + payload: dict to JSON-encode as the notification body + vapid_keys: dict from get_vapid_keys() + + Returns True on success, False on failure. + """ + if not PUSH_AVAILABLE: + return False + + if not vapid_keys: + log.warning("VAPID keys not configured; cannot send push notification") + return False + + try: + webpush( + subscription_info=subscription_info, + data=json.dumps(payload), + vapid_private_key=vapid_keys["private_key"], + vapid_claims={ + "sub": vapid_keys["contact"], + }, + ) + return True + except WebPushException as e: + log.warning("Push notification failed: %s", e, exc_info=True) + # A 410 Gone response means the subscription is no longer valid. + if hasattr(e, "response") and e.response is not None: + if e.response.status_code == 410: + log.info("Subscription expired (410 Gone), should be removed") + return False + except Exception: + log.warning("Unexpected error sending push notification", exc_info=True) + return False + + +def send_push_to_user(request, user, payload): + """ + Send a push notification to all active subscriptions for a user. + + Args: + request: Pyramid request (used to read settings) + user: User model instance + payload: dict to send as the notification body + + Returns the number of successful sends. + """ + if not PUSH_AVAILABLE: + return 0 + + vapid_keys = get_vapid_keys(request.registry.settings) + if not vapid_keys: + return 0 + + subscriptions = get_push_subscriptions(user) + if not subscriptions: + return 0 + + success_count = 0 + expired = [] + + for sub in subscriptions: + ok = send_push_notification(sub, payload, vapid_keys) + if ok: + success_count += 1 + else: + # Track potentially expired subscriptions for cleanup. + expired.append(sub) + + return success_count + + +def get_push_subscriptions(user): + """ + Return the list of push subscription dicts for a user. + + Subscriptions are stored as a JSON string in user.push_subscriptions. + Returns an empty list if no subscriptions exist. + """ + if not user.push_subscriptions: + return [] + try: + return json.loads(user.push_subscriptions) + except (json.JSONDecodeError, TypeError): + return [] + + +def add_push_subscription(user, subscription_info): + """ + Add a push subscription for a user, avoiding duplicates. + + Args: + user: User model instance + subscription_info: dict with 'endpoint', 'keys' (p256dh, auth) + + Returns True if the subscription was added, False if already exists. + """ + subscriptions = get_push_subscriptions(user) + + # Check for duplicate endpoint. + for sub in subscriptions: + if sub.get("endpoint") == subscription_info.get("endpoint"): + return False + + subscriptions.append(subscription_info) + user.push_subscriptions = json.dumps(subscriptions) + return True + + +def remove_push_subscription(user, endpoint): + """ + Remove a push subscription by endpoint URL. + + Args: + user: User model instance + endpoint: The push subscription endpoint URL to remove + + Returns True if removed, False if not found. + """ + subscriptions = get_push_subscriptions(user) + original_count = len(subscriptions) + subscriptions = [s for s in subscriptions if s.get("endpoint") != endpoint] + + if len(subscriptions) < original_count: + user.push_subscriptions = json.dumps(subscriptions) if subscriptions else None + return True + return False diff --git a/remarkbox/lib/render.py b/remarkbox/lib/render.py index 743d64a..3d0c58a 100644 --- a/remarkbox/lib/render.py +++ b/remarkbox/lib/render.py @@ -5,6 +5,8 @@ from .sanitize_html import ( clean_raw_html, ) +from .mentions import resolve_mentions, replace_mentions_with_links + import logging log = logging.getLogger(__name__) @@ -51,10 +53,18 @@ def make_cleaner_from_namespace(namespace): return cleaner -def markdown_to_html(data, namespace=None): +def markdown_to_html(data, namespace=None, dbsession=None): raw_html = markdown_to_raw_html(data, extra_extensions=["mdx_math"]) if namespace: cleaner = make_cleaner_from_namespace(namespace) else: cleaner = default_cleaner() - return clean_raw_html(raw_html, cleaner) + html = clean_raw_html(raw_html, cleaner) + + # After sanitization, resolve @mentions and convert to profile links. + if dbsession is not None: + resolved = resolve_mentions(dbsession, data) + if resolved: + html = replace_mentions_with_links(html, resolved) + + return html diff --git a/remarkbox/models/__init__.py b/remarkbox/models/__init__.py index 1e9e229..8963a62 100644 --- a/remarkbox/models/__init__.py +++ b/remarkbox/models/__init__.py @@ -19,6 +19,7 @@ from .event import * from .notification import * from .pay_what_you_can import * from .payment import * +from .webmention import * # run configure_mappers after defining all of the models to ensure # all relationships can be setup diff --git a/remarkbox/models/meta.py b/remarkbox/models/meta.py index 80fc32c..0dfa62f 100644 --- a/remarkbox/models/meta.py +++ b/remarkbox/models/meta.py @@ -40,6 +40,7 @@ CLASS_TO_TABLE = { "NodeEventNotification": "rb_node_event_notification", "PayWhatYouCan": "rb_pay_what_you_can", "Payment": "rb_payment", + "Webmention": "rb_webmention", } # node (threads), namespace (forum) @@ -47,7 +48,7 @@ WATCHER_TYPES = {"reply", "node", "namespace"} NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"} -NOTIFICATION_METHODS = {"email"} +NOTIFICATION_METHODS = {"email", "push"} NODE_EVENT_ACTIONS = { "enabled", diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py index dbcea55..c4dff4a 100644 --- a/remarkbox/models/namespace.py +++ b/remarkbox/models/namespace.py @@ -60,6 +60,11 @@ PROTECTED_ATTRIBUTES = { "reverse_order": False, "group_conversations": False, "api_access": True, + "submit_button_text": None, + "comment_label_singular": None, + "comment_label_plural": None, + "max_nesting_depth": None, + "collapse_depth": None, } @@ -118,6 +123,13 @@ class Namespace(RBase, Base): # the group postfix used for imports (e.g., "rb" creates "Anonymous-rb") # Once set, this becomes permanent for all imported surrogates import_group_postfix = Column(Unicode(6), default=None, nullable=True) + # T4: customizable button text and comment labels + submit_button_text = Column(Unicode(256), default=None, nullable=True) + comment_label_singular = Column(Unicode(256), default=None, nullable=True) + comment_label_plural = Column(Unicode(256), default=None, nullable=True) + # T8: nesting depth settings + max_nesting_depth = Column(Integer, default=None, nullable=True) + collapse_depth = Column(Integer, default=None, nullable=True) # the type of subscription of this Namespace. subscription_type = Column( Enum(*SUBSCRIPTION_TYPES, name="subscription_type"), diff --git a/remarkbox/models/node.py b/remarkbox/models/node.py index cf4732c..8b33053 100644 --- a/remarkbox/models/node.py +++ b/remarkbox/models/node.py @@ -312,11 +312,13 @@ class Node(RBase, Base): def enabled(self): return not self.disabled - def set_data(self, data, namespace=None): + def set_data(self, data, namespace=None, dbsession=None): if namespace is None: namespace = self.root.namespace + if dbsession is None: + dbsession = self.dbsession self.data = data - self.data_html = markdown_to_html(data, namespace) + self.data_html = markdown_to_html(data, namespace, dbsession=dbsession) def _invalidate_cache(self): if self.root.cache: @@ -499,12 +501,49 @@ def get_nodes_who_share_roots(dbsession, root_nodes): return get_nodes_who_share_roots_query(dbsession, root_nodes).all() -def get_nodes_who_share_root(dbsession, root_node, order="oldest-first"): +def get_nodes_who_share_root(dbsession, root_node, order="oldest-first", + limit=None, offset=None, + exclude_root=False, + visibility_filters=None): + """Return nodes sharing a root, with optional pagination and SQL-side filtering. + + Args: + dbsession: SQLAlchemy session. + root_node: The root Node whose tree to query. + order: 'oldest-first' or 'newest-first'. + limit: Maximum number of rows to return (None = unlimited). + offset: Number of rows to skip (None = 0). + exclude_root: If True, exclude the root node itself from results. + visibility_filters: Optional dict of SQL visibility filters to apply. + Supported keys: disabled (bool), approved (bool), verified (bool). + + Returns: + SQLAlchemy query object (call .all() to materialise). + """ nodes = dbsession.query(Node).filter(Node.root_id == root_node.id) + + if exclude_root: + nodes = nodes.filter(Node.id != root_node.id) + + # Apply SQL-side visibility filters + if visibility_filters: + if "disabled" in visibility_filters: + nodes = nodes.filter(Node.disabled == visibility_filters["disabled"]) + if "approved" in visibility_filters: + nodes = nodes.filter(Node.approved == visibility_filters["approved"]) + if "verified" in visibility_filters: + nodes = nodes.filter(Node.verified == visibility_filters["verified"]) + if order == "oldest-first": nodes = nodes.order_by(Node.created) elif order == "newest-first": nodes = nodes.order_by(Node.created.desc()) + + if offset is not None: + nodes = nodes.offset(offset) + if limit is not None: + nodes = nodes.limit(limit) + return nodes diff --git a/remarkbox/models/user.py b/remarkbox/models/user.py index a08c7da..a6b64d2 100644 --- a/remarkbox/models/user.py +++ b/remarkbox/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import BigInteger, Boolean, Integer, Column, Unicode, Enum, func, or_ +from sqlalchemy import BigInteger, Boolean, Integer, Column, Unicode, UnicodeText, Enum, func, or_ from sqlalchemy.orm import relationship, backref @@ -120,6 +120,14 @@ class User(RBase, Base): default='auto', nullable=False, ) + # Notification delivery preference: how the user wants to receive notifications. + notification_preference = Column( + Enum('email', 'push', 'both', 'none', name='notification_preference_enum'), + default='email', + nullable=False, + ) + # JSON-encoded list of Web Push subscription objects. + push_subscriptions = Column(UnicodeText, nullable=True) votes = relationship(argument="Vote", backref="user", order_by="desc(Vote.created)") # lazy='dynamic' returns a query object instead of collection. @@ -247,33 +255,47 @@ class User(RBase, Base): def unverified_namespace_owner_requests(self): return [nr for nr in self.namespace_owner_requests if not nr.verified] - @property - def verified_nodes(self): - return self.nodes.filter(Node.verified == True, Node.disabled == False) + def _namespace_filter(self, query, namespace=None): + """Apply namespace filter to a node query if namespace is provided.""" + if namespace is not None: + root_ids = self.dbsession.query(Node.id).filter( + Node.namespace_id == namespace.id + ) + query = query.filter(Node.root_id.in_(root_ids)) + return query - @property - def unverified_nodes(self): - return self.nodes.filter(Node.verified == False, Node.disabled == False) + def verified_nodes(self, namespace=None): + query = self.nodes.filter(Node.verified == True, Node.disabled == False) + return self._namespace_filter(query, namespace) - @property - def disabled_nodes(self): - return self.nodes.filter(Node.verified == True, Node.disabled == True) + def unverified_nodes(self, namespace=None): + query = self.nodes.filter(Node.verified == False, Node.disabled == False) + return self._namespace_filter(query, namespace) - @property - def unapproved_nodes(self): - return self.nodes.filter(or_(Node.approved == False, Node.approved.is_(None))) + def disabled_nodes(self, namespace=None): + query = self.nodes.filter(Node.verified == True, Node.disabled == True) + return self._namespace_filter(query, namespace) - def page_nodes(self, limit=100, offset=0): + def unapproved_nodes(self, namespace=None): + query = self.nodes.filter(or_(Node.approved == False, Node.approved.is_(None))) + return self._namespace_filter(query, namespace) + + def page_nodes(self, namespace=None, limit=100, offset=0): if self.nodes.count() == 0: return [] - return ( - self.nodes.filter( - Node.disabled == False, Node.verified == True, Node.user_id != None, Node.approved == True - ) - .order_by(Node.changed.desc()) - .limit(limit) - .offset(offset) + query = self.nodes.filter( + Node.disabled == False, Node.verified == True, + Node.user_id != None, Node.approved == True ) + query = self._namespace_filter(query, namespace) + + if namespace is not None: + if namespace.hide_unless_approved: + query = query.filter(Node.approved == True) + if namespace.hide_unverified: + query = query.filter(Node.verified == True) + + return query.order_by(Node.changed.desc()).limit(limit).offset(offset) def __init__(self, email): self.name = unicode(generate_password(size=8)) @@ -374,6 +396,113 @@ class User(RBase, Base): return reply_watcher return self.reply_watchers.first() + def anonymize_account(self): + """Anonymize this user's account for GDPR/CCPA compliance. + + Scrubs PII (name, email, password) and cleans up metadata, + but keeps the user record as a tombstone so node.user_id + foreign keys remain valid and templates still work. + """ + dbsession = self.dbsession + tombstone_id = uuid.uuid4().hex[:12] + + # Scrub PII on the user record — keep it as a tombstone. + self.name = "deleted-{}".format(tombstone_id) + self.email = "deleted-{}@localhost".format(tombstone_id) + self.password = None + self.password_attempts = 0 + self.gravatar = False + self.verified = False + self.disabled = True + + # Scrub IP addresses on all comments. + for node in self.nodes.all(): + node.ip_address = None + dbsession.add(node) + + # Delete all notification records. + for notification in self.node_notifications.all(): + dbsession.delete(notification) + + # Delete all watchers and their notifications. + for watcher in self.watchers.all(): + for notification in watcher.notifications.all(): + dbsession.delete(notification) + dbsession.delete(watcher) + + # Delete all events and their notifications. + for event in self.events: + notifications = dbsession.query(NodeEventNotification).filter( + NodeEventNotification.node_event_id == event.id + ).all() + for notification in notifications: + dbsession.delete(notification) + dbsession.delete(event) + + # Delete OAuth records. + for oauth in self.oauth_records.all(): + dbsession.delete(oauth) + + # Delete namespace owner requests. + for nr in self.namespace_owner_requests.all(): + dbsession.delete(nr) + + # Delete namespace user associations. + for nsu in list(self.user_namespaces): + dbsession.delete(nsu) + + # Delete payment records. + if self.pay_what_you_can: + dbsession.delete(self.pay_what_you_can) + for payment in self.payments.all(): + dbsession.delete(payment) + + # Delete votes. + for vote in self.votes: + dbsession.delete(vote) + + dbsession.add(self) + dbsession.flush() + + def export_user_data(self): + """Export all user data as a dictionary for GDPR/CCPA data subject requests.""" + from remarkbox.lib import timestamp_to_date_string + + data = { + "profile": { + "id": str(self.id), + "name": self.name, + "email": self.email, + "created": timestamp_to_date_string(self.created), + "gravatar": self.gravatar, + "verified": self.verified, + "theme_mode": self.theme_mode, + }, + "comments": [], + } + + for node in self.nodes.all(): + comment = { + "id": str(node.id), + "created": timestamp_to_date_string(node.created), + "changed": timestamp_to_date_string(node.changed), + "content": node.data, + "disabled": node.disabled, + "verified": node.verified, + "approved": node.approved, + } + if node.title: + comment["title"] = node.title + if node.namespace: + comment["namespace"] = node.namespace.name + if node.root and node.root.title: + comment["thread_title"] = node.root.title + if node.root and node.root.uri: + comment["thread_uri"] = node.root.uri.data + data["comments"].append(comment) + + return data + def _user_by_name_query(dbsession, name): """query User by case insensitive name.""" diff --git a/remarkbox/models/webmention.py b/remarkbox/models/webmention.py new file mode 100644 index 0000000..6fa3f4b --- /dev/null +++ b/remarkbox/models/webmention.py @@ -0,0 +1,77 @@ +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + Unicode, + UnicodeText, +) + +from sqlalchemy.orm import relationship + +import uuid + +from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp, get_object_by_id + +import logging + +log = logging.getLogger(__name__) + + +class Webmention(RBase, Base): + """ + Represents a received webmention -- a notification from an external + site that it has linked to a page with a Remarkbox thread. + + Reference: https://www.w3.org/TR/webmention/ + """ + + id = Column(UUIDType, primary_key=True, index=True) + source = Column(Unicode(2048), nullable=False) + target = Column(Unicode(2048), nullable=False) + node_id = Column(UUIDType, foreign_key("Node", "id"), index=True, nullable=True) + verified = Column(Boolean, default=False, nullable=False) + author_name = Column(Unicode(256), nullable=True) + author_url = Column(Unicode(2048), nullable=True) + content = Column(UnicodeText, nullable=True) + created_timestamp = Column(BigInteger, nullable=False) + updated_timestamp = Column(BigInteger, nullable=False) + + node = relationship(argument="Node", uselist=False, lazy="joined") + + def __init__(self, source, target): + self.id = uuid.uuid1() + self.source = source + self.target = target + self.created_timestamp = now_timestamp() + self.updated_timestamp = now_timestamp() + + def mark_verified(self, author_name=None, author_url=None, content=None): + self.verified = True + self.updated_timestamp = now_timestamp() + if author_name: + self.author_name = author_name + if author_url: + self.author_url = author_url + if content: + self.content = content[:500] + + +def get_webmention_by_id(dbsession, webmention_id): + return get_object_by_id(dbsession, webmention_id, Webmention) + + +def get_webmention_by_source_and_target(dbsession, source, target): + return ( + dbsession.query(Webmention) + .filter(Webmention.source == source, Webmention.target == target) + .one_or_none() + ) + + +def get_verified_webmentions_for_node(dbsession, node_id): + return ( + dbsession.query(Webmention) + .filter(Webmention.node_id == node_id, Webmention.verified == True) + .order_by(Webmention.created_timestamp.desc()) + .all() + ) diff --git a/remarkbox/routes.py b/remarkbox/routes.py index c42e1b0..af932a1 100644 --- a/remarkbox/routes.py +++ b/remarkbox/routes.py @@ -13,6 +13,14 @@ def includeme(config): config.add_route("billing-success", "/billing/success") config.add_route("stripe-webhook", "/webhook/stripe") + # webmention: IndieWeb webmention receiving endpoint. + config.add_route("webmention", "/webmention") + + # push notifications: Web Push API endpoints. + config.add_route("push-vapid-key", "/push/vapid-key") + config.add_route("push-subscribe", "/push/subscribe") + config.add_route("push-unsubscribe", "/push/unsubscribe") + # slack: bot notifications and oauth. config.add_route("oauth-slack", "/oauth/slack") config.add_route("oauth-slack-delete", "/oauth/slack/delete") @@ -67,6 +75,7 @@ def includeme(config): config.add_route("embed-log-out", "/embed/ns/{namespace}/log-out") config.add_route("embed-namespace-nodes", "/embed/ns/{namespace}/nodes") + config.add_route("embed-namespace-delete", "/embed/ns/{namespace}/delete") config.add_route("embed-namespace-settings", "/embed/ns/{namespace}/settings") config.add_route("embed-namespace-import-comments", "/embed/ns/{namespace}/import-comments") config.add_route( @@ -74,6 +83,8 @@ def includeme(config): ) config.add_route("embed-namespace", "/embed/ns/{namespace}") + config.add_route("embed-user-delete-account", "/embed/ns/{namespace}/u/delete-account") + config.add_route("embed-user-export-data", "/embed/ns/{namespace}/u/export-data") config.add_route("embed-user-settings", "/embed/ns/{namespace}/u/settings") config.add_route("embed-user-watching", "/embed/ns/{namespace}/u/watching") config.add_route("embed-user-notifications", "/embed/ns/{namespace}/u/notifications") @@ -99,6 +110,7 @@ def includeme(config): config.add_route("basic-verification-challenge", "/verification-challenge") config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes") + config.add_route("basic-namespace-delete", "/ns/{namespace}/delete") config.add_route("basic-namespace-settings", "/ns/{namespace}/settings") config.add_route("basic-namespace-import-comments", "/ns/{namespace}/import-comments") config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json") @@ -110,6 +122,8 @@ def includeme(config): ) config.add_route("basic-namespace", "/ns/{namespace}") + config.add_route("basic-user-delete-account", "/u/delete-account") + config.add_route("basic-user-export-data", "/u/export-data") config.add_route("basic-user-settings", "/u/settings") config.add_route("basic-user-watching", "/u/watching") config.add_route("basic-user-notifications", "/u/notifications") diff --git a/remarkbox/scripts/alembic/versions/896568b0752e_add_customizable_button_text_comment_.py b/remarkbox/scripts/alembic/versions/896568b0752e_add_customizable_button_text_comment_.py new file mode 100644 index 0000000..dfe13d1 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/896568b0752e_add_customizable_button_text_comment_.py @@ -0,0 +1,34 @@ +"""add customizable button text, comment labels, and nesting depth settings to namespace + +Revision ID: 896568b0752e +Revises: a3f7b2c1d4e5 +Create Date: 2026-02-01 18:28:50.433155 + +""" + +# revision identifiers, used by Alembic. +revision = '896568b0752e' +down_revision = 'a3f7b2c1d4e5' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # T4: customizable button text and comment labels + op.add_column('rb_namespace', sa.Column('submit_button_text', sa.Unicode(length=256), nullable=True)) + op.add_column('rb_namespace', sa.Column('comment_label_singular', sa.Unicode(length=256), nullable=True)) + op.add_column('rb_namespace', sa.Column('comment_label_plural', sa.Unicode(length=256), nullable=True)) + # T8: nesting depth settings + op.add_column('rb_namespace', sa.Column('max_nesting_depth', sa.Integer(), nullable=True)) + op.add_column('rb_namespace', sa.Column('collapse_depth', sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column('rb_namespace', 'collapse_depth') + op.drop_column('rb_namespace', 'max_nesting_depth') + op.drop_column('rb_namespace', 'comment_label_plural') + op.drop_column('rb_namespace', 'comment_label_singular') + op.drop_column('rb_namespace', 'submit_button_text') diff --git a/remarkbox/scripts/merge_duplicate_namespaces.py b/remarkbox/scripts/merge_duplicate_namespaces.py new file mode 100644 index 0000000..3e8f415 --- /dev/null +++ b/remarkbox/scripts/merge_duplicate_namespaces.py @@ -0,0 +1,380 @@ +""" +Merge duplicate namespaces and URIs that have the same name/host (case-insensitive). + +This script finds namespaces with names that differ only by case (e.g., +'Example.com' and 'example.com') and merges them into a single namespace, +keeping the oldest one. It also normalizes URI hostnames to lowercase. + +Usage: + remarkbox_merge_duplicate_namespaces -c production.ini --dry-run + remarkbox_merge_duplicate_namespaces -c production.ini +""" +from collections import defaultdict + +from pyramid.paster import bootstrap, setup_logging + +from sqlalchemy import func + +from ..models import Namespace, Node, Uri, NamespaceUser, NamespaceRequest, Oauth, Watcher + +from . import base_parser + +import miniuri + +try: + unicode("") +except Exception: + from six import u as unicode + + +def get_arg_parser(): + parser = base_parser("Find and merge namespaces with duplicate names (case-insensitive).") + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Show what would be merged without making changes.", + ) + return parser + + +def find_duplicate_namespaces(dbsession): + """ + Find all namespaces with duplicate names (case-insensitive). + + Returns a dict mapping lowercase name -> list of Namespace objects. + Only includes names with more than one namespace. + """ + duplicates = defaultdict(list) + + namespaces = dbsession.query(Namespace).all() + + for namespace in namespaces: + name_lower = namespace.name.lower() + duplicates[name_lower].append(namespace) + + # Filter to only duplicates + return { + name: namespaces + for name, namespaces in duplicates.items() + if len(namespaces) > 1 + } + + +def find_duplicate_uris(dbsession): + """ + Find all URIs with duplicate data (case-insensitive). + + Returns a dict mapping lowercase uri -> list of Uri objects. + Only includes URIs with more than one record. + """ + duplicates = defaultdict(list) + + uris = dbsession.query(Uri).all() + + for uri in uris: + uri_lower = uri.data.lower() + duplicates[uri_lower].append(uri) + + # Filter to only duplicates + return { + uri: uri_list + for uri, uri_list in duplicates.items() + if len(uri_list) > 1 + } + + +def merge_namespaces(dbsession, keep_ns, delete_ns, dry_run=False): + """ + Merge delete_ns into keep_ns by transferring all related records. + + Transfers: + - root nodes (threads) + - namespace_user associations + - namespace_requests + - oauth records + - watchers + - user_surrogates + """ + keep_id = keep_ns.id + delete_id = delete_ns.id + + print(f" Merging '{delete_ns.name}' (id={delete_id}) into '{keep_ns.name}' (id={keep_id})") + + if dry_run: + counts = { + 'root_nodes': dbsession.query(Node).filter(Node.namespace_id == delete_id).count(), + 'namespace_users': dbsession.query(NamespaceUser).filter(NamespaceUser.namespace_id == delete_id).count(), + 'namespace_requests': dbsession.query(NamespaceRequest).filter(NamespaceRequest.namespace_id == delete_id).count(), + 'oauth': dbsession.query(Oauth).filter(Oauth.namespace_id == delete_id).count(), + 'watchers': dbsession.query(Watcher).filter(Watcher.namespace_id == delete_id).count(), + } + + for table, count in counts.items(): + if count > 0: + print(f" Would transfer {count} {table}") + + return + + # Transfer root nodes + updated = dbsession.query(Node).filter(Node.namespace_id == delete_id).update( + {Node.namespace_id: keep_id}, synchronize_session=False + ) + if updated: + print(f" Transferred {updated} root nodes") + + # Handle namespace_user associations (unique constraint on user_id, namespace_id) + keep_user_ids = { + nu.user_id for nu in dbsession.query(NamespaceUser).filter(NamespaceUser.namespace_id == keep_id) + } + delete_ns_users = dbsession.query(NamespaceUser).filter(NamespaceUser.namespace_id == delete_id).all() + transferred_ns = 0 + deleted_ns = 0 + for ns_user in delete_ns_users: + if ns_user.user_id in keep_user_ids: + dbsession.delete(ns_user) + deleted_ns += 1 + else: + ns_user.namespace_id = keep_id + transferred_ns += 1 + if transferred_ns: + print(f" Transferred {transferred_ns} namespace_user associations") + if deleted_ns: + print(f" Deleted {deleted_ns} duplicate namespace_user associations") + + # Handle namespace requests + keep_request_user_ids = { + nr.user_id for nr in dbsession.query(NamespaceRequest).filter(NamespaceRequest.namespace_id == keep_id) + } + delete_ns_requests = dbsession.query(NamespaceRequest).filter(NamespaceRequest.namespace_id == delete_id).all() + transferred_nr = 0 + deleted_nr = 0 + for ns_request in delete_ns_requests: + if ns_request.user_id in keep_request_user_ids: + dbsession.delete(ns_request) + deleted_nr += 1 + else: + ns_request.namespace_id = keep_id + transferred_nr += 1 + if transferred_nr: + print(f" Transferred {transferred_nr} namespace requests") + if deleted_nr: + print(f" Deleted {deleted_nr} duplicate namespace requests") + + # Transfer oauth records + updated = dbsession.query(Oauth).filter(Oauth.namespace_id == delete_id).update( + {Oauth.namespace_id: keep_id}, synchronize_session=False + ) + if updated: + print(f" Transferred {updated} oauth records") + + # Handle watchers (may have duplicates watching same node for same user) + keep_watcher_keys = { + (w.user_id, w.node_id, w.type) + for w in dbsession.query(Watcher).filter(Watcher.namespace_id == keep_id) + } + delete_watchers = dbsession.query(Watcher).filter(Watcher.namespace_id == delete_id).all() + transferred_w = 0 + deleted_w = 0 + for watcher in delete_watchers: + key = (watcher.user_id, watcher.node_id, watcher.type) + if key in keep_watcher_keys: + dbsession.delete(watcher) + deleted_w += 1 + else: + watcher.namespace_id = keep_id + transferred_w += 1 + if transferred_w: + print(f" Transferred {transferred_w} watchers") + if deleted_w: + print(f" Deleted {deleted_w} duplicate watchers") + + dbsession.flush() + + # Delete the duplicate namespace + dbsession.delete(delete_ns) + dbsession.flush() + print(f" Deleted namespace '{delete_ns.name}'") + + +def merge_uris(dbsession, keep_uri, delete_uri, dry_run=False): + """ + Merge delete_uri into keep_uri by transferring the node association. + + If delete_uri has a node but keep_uri does not, transfer the node. + If both have nodes, transfer the children from delete_uri's node to keep_uri's node. + """ + print(f" Merging URI '{delete_uri.data}' into '{keep_uri.data}'") + + if dry_run: + if delete_uri.node_id: + if keep_uri.node_id: + child_count = dbsession.query(Node).filter(Node.root_id == delete_uri.node_id, Node.parent_id != None).count() + print(f" Would transfer {child_count} child nodes from root {delete_uri.node_id} to root {keep_uri.node_id}") + else: + print(f" Would transfer node {delete_uri.node_id} to kept URI") + return + + if delete_uri.node_id: + if keep_uri.node_id is None: + # keep_uri has no node, just reassign + keep_uri.node_id = delete_uri.node_id + delete_uri.node_id = None + dbsession.flush() + print(f" Transferred node to kept URI") + else: + # Both have nodes - transfer children from delete's root to keep's root + updated = dbsession.query(Node).filter( + Node.root_id == delete_uri.node_id, + Node.parent_id != None, + ).update( + {Node.root_id: keep_uri.node_id}, synchronize_session=False + ) + if updated: + print(f" Transferred {updated} child nodes to kept URI's root") + + # Transfer direct children whose parent is the delete root + updated = dbsession.query(Node).filter( + Node.parent_id == delete_uri.node_id, + ).update( + {Node.parent_id: keep_uri.node_id}, synchronize_session=False + ) + if updated: + print(f" Re-parented {updated} direct children to kept URI's root") + + # Delete the orphaned root node + orphan_root = dbsession.query(Node).filter(Node.id == delete_uri.node_id).one_or_none() + if orphan_root: + delete_uri.node_id = None + dbsession.flush() + dbsession.delete(orphan_root) + dbsession.flush() + print(f" Deleted orphaned root node") + + # Delete the duplicate URI + dbsession.delete(delete_uri) + dbsession.flush() + print(f" Deleted URI '{delete_uri.data}'") + + +def normalize_uri_hostnames(dbsession, dry_run=False): + """ + Normalize all URI hostnames to lowercase. + """ + uris = dbsession.query(Uri).all() + normalized_count = 0 + for uri in uris: + try: + parsed = miniuri.Uri(uri.data) + if parsed.hostname and parsed.hostname != parsed.hostname.lower(): + old_data = uri.data + new_data = uri.data.replace(parsed.hostname, parsed.hostname.lower(), 1) + if dry_run: + print(f" Would normalize URI: '{old_data}' -> '{new_data}'") + else: + uri.data = new_data + dbsession.add(uri) + normalized_count += 1 + except Exception: + pass + + if normalized_count: + if not dry_run: + dbsession.flush() + print(f"{'Would normalize' if dry_run else 'Normalized'} {normalized_count} URI hostname(s) to lowercase") + + return normalized_count + + +def normalize_namespace_names(dbsession, dry_run=False): + """ + Normalize all namespace names to lowercase. + """ + namespaces = dbsession.query(Namespace).all() + normalized_count = 0 + for namespace in namespaces: + if namespace.name != namespace.name.lower(): + old_name = namespace.name + new_name = namespace.name.lower() + if dry_run: + print(f" Would normalize namespace: '{old_name}' -> '{new_name}'") + else: + namespace.name = new_name + dbsession.add(namespace) + normalized_count += 1 + + if normalized_count: + if not dry_run: + dbsession.flush() + print(f"{'Would normalize' if dry_run else 'Normalized'} {normalized_count} namespace name(s) to lowercase") + + return normalized_count + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + dbsession = request.dbsession + + # Step 1: Find and merge duplicate namespaces + print("Searching for duplicate namespaces (case-insensitive)...") + ns_duplicates = find_duplicate_namespaces(dbsession) + + if ns_duplicates: + print(f"Found {len(ns_duplicates)} namespace name(s) with duplicates:\n") + + for name, namespaces in ns_duplicates.items(): + # Sort by id to keep the oldest (UUID1 is time-based) + namespaces_sorted = sorted(namespaces, key=lambda ns: ns.id.time) + keep_ns = namespaces_sorted[0] + delete_namespaces = namespaces_sorted[1:] + + print(f"Namespace: {name}") + print(f" Keeping: '{keep_ns.name}' (id={keep_ns.id})") + + for delete_ns in delete_namespaces: + print(f" Deleting: '{delete_ns.name}' (id={delete_ns.id})") + merge_namespaces(dbsession, keep_ns, delete_ns, dry_run=args.dry_run) + + print() + else: + print("No duplicate namespaces found.\n") + + # Step 2: Find and merge duplicate URIs + print("Searching for duplicate URIs (case-insensitive)...") + uri_duplicates = find_duplicate_uris(dbsession) + + if uri_duplicates: + print(f"Found {len(uri_duplicates)} URI(s) with duplicates:\n") + + for uri_lower, uris in uri_duplicates.items(): + uris_sorted = sorted(uris, key=lambda u: u.id.time) + keep_uri = uris_sorted[0] + delete_uris = uris_sorted[1:] + + print(f"URI: {uri_lower}") + print(f" Keeping: '{keep_uri.data}' (id={keep_uri.id})") + + for delete_uri in delete_uris: + print(f" Deleting: '{delete_uri.data}' (id={delete_uri.id})") + merge_uris(dbsession, keep_uri, delete_uri, dry_run=args.dry_run) + + print() + else: + print("No duplicate URIs found.\n") + + # Step 3: Normalize remaining namespace names and URI hostnames + print("Normalizing namespace names to lowercase...") + normalize_namespace_names(dbsession, dry_run=args.dry_run) + + print("\nNormalizing URI hostnames to lowercase...") + normalize_uri_hostnames(dbsession, dry_run=args.dry_run) + + if args.dry_run: + print("\nDRY RUN - No changes were made. Run without --dry-run to apply changes.") + else: + print("\nDone. All duplicates have been merged and names normalized.") diff --git a/remarkbox/scripts/modify_namespace.py b/remarkbox/scripts/modify_namespace.py index bc4c8fe..51ad5ad 100644 --- a/remarkbox/scripts/modify_namespace.py +++ b/remarkbox/scripts/modify_namespace.py @@ -12,6 +12,47 @@ from . import base_parser toggle = lambda x: not x +def delete_namespace_cascade(dbsession, namespace): + """ + Perform cascading deletion of a namespace and all associated data. + Reused by both the CLI script and the web view. + """ + nodes = get_nodes_who_share_roots(dbsession, namespace.roots) + for node in nodes: + if node.events: + for event in node.events: + dbsession.delete(event) + dbsession.delete(node) + for root in namespace.roots: + if root.uri: + dbsession.delete(root.uri) + if root.cache: + dbsession.delete(root.cache) + if root.watchers: + for watcher in root.watchers: + if watcher.unsent_notifications(): + for notification in watcher.unsent_notifications(): + dbsession.delete(notification) + dbsession.delete(watcher) + if namespace.watchers: + for watcher in namespace.watchers: + if watcher.unsent_notifications(): + for notification in watcher.unsent_notifications(): + dbsession.delete(notification) + dbsession.delete(watcher) + if namespace.namespace_users: + for nsu in namespace.namespace_users: + dbsession.delete(nsu) + if namespace.oauth_records: + for oauth_record in namespace.oauth_records: + dbsession.delete(oauth_record) + if namespace.namespace_owner_requests: + for nr in namespace.namespace_owner_requests: + dbsession.delete(nr) + dbsession.delete(namespace) + dbsession.flush() + + def get_arg_parser(): parser = base_parser("Modify a Namespace.") parser.add_argument( @@ -98,61 +139,11 @@ def main(): elif args.delete: - nodes = get_nodes_who_share_roots(request.dbsession, namespace.roots) - - # delete all nodes. - for node in nodes: - if node.events: - # delete all related NodeEvents. - for event in node.events: - request.dbsession.delete(event) - - # delete node. - request.dbsession.delete(node) - - for root in namespace.roots: - if root.uri: - # delete related Uri. - request.dbsession.delete(root.uri) - if root.cache: - # delete related NodeCache. - request.dbsession.delete(root.cache) - if root.watchers: - # delete all related NodeEventWatchers. - for watcher in root.watchers: - # delete all unsent NodeEventNotifications. - if watcher.unsent_notifications(): - for notification in watcher.unsent_notifications(): - request.dbsession.delete(notification) - request.dbsession.delete(watcher) - - if namespace.watchers: - for watcher in namespace.watchers: - # delete all unsent NodeEventNotifications. - if watcher.unsent_notifications(): - for notification in watcher.unsent_notifications(): - request.dbsession.delete(notification) - request.dbsession.delete(watcher) - - # disable all related NamespaceUser objects. - if namespace.namespace_users: - for nsu in namespace.namespace_users: - # delete related NamespaceUser. - request.dbsession.delete(nsu) - - # delete all related OauthRecords. - if namespace.oauth_records: - for oauth_record in namespace.oauth_records: - request.dbsession.delete(oauth_record) - - # finally delete Namespace. - request.dbsession.delete(namespace) - if ( raw_input("*** DANGER: Delete Namespace '{}' ({}) forever? [yes, no]: ".format(namespace.name, namespace.id)) == "yes" ): - request.dbsession.flush() + delete_namespace_cascade(request.dbsession, namespace) print("Flushed transaction to database, the Namespace was completely destroyed!") else: sp.rollback() diff --git a/remarkbox/static/css/common.css b/remarkbox/static/css/common.css index 41c40dd..c6f4a6d 100644 --- a/remarkbox/static/css/common.css +++ b/remarkbox/static/css/common.css @@ -919,3 +919,46 @@ input#namespace-domain { margin-bottom: 10px; text-align: center; } + +/* @mention links (T6) */ +a.mention { + color: var(--primary); + font-weight: 600; + text-decoration: none; +} +a.mention:hover { + text-decoration: underline; +} + +/* Thread title typeahead suggestions (T9) */ +.thread-title-suggestions { + border: 1px solid var(--border); + border-top: none; + background: var(--background); + max-height: 200px; + overflow-y: auto; + margin-bottom: 8px; +} +.thread-title-suggestions .suggestions-header { + padding: 4px 8px; + font-size: 0.85em; + color: var(--color-muted); + border-bottom: 1px solid var(--border-faint); +} +.thread-title-suggestions .suggestion-item { + display: block; + padding: 6px 8px; + text-decoration: none; + color: var(--color); + border-bottom: 1px solid var(--border-faint); +} +.thread-title-suggestions .suggestion-item:last-child { + border-bottom: none; +} +.thread-title-suggestions .suggestion-item:hover { + background: var(--background-faint); +} +.thread-title-suggestions .suggestion-meta { + font-size: 0.85em; + color: var(--color-muted); +} diff --git a/remarkbox/static/js/custom.js b/remarkbox/static/js/custom.js index 92c5df1..d169039 100644 --- a/remarkbox/static/js/custom.js +++ b/remarkbox/static/js/custom.js @@ -118,6 +118,90 @@ function autoGrow(el) { el.style.height = newHeight + 'px'; } +// Thread title typeahead for duplicate prevention (T9). +var threadSearchTimer = null; + +function initThreadTitleTypeahead() { + var titleInput = document.getElementById('thread_title_input'); + if (!titleInput) return; + + // Create the suggestions container right after the title input. + var suggestionsDiv = document.createElement('div'); + suggestionsDiv.id = 'thread-title-suggestions'; + suggestionsDiv.className = 'thread-title-suggestions'; + suggestionsDiv.style.display = 'none'; + titleInput.parentNode.insertBefore(suggestionsDiv, titleInput.nextSibling); + + titleInput.addEventListener('input', function() { + var query = titleInput.value.trim(); + if (query.length < 2) { + suggestionsDiv.style.display = 'none'; + suggestionsDiv.innerHTML = ''; + return; + } + if (threadSearchTimer) { + clearTimeout(threadSearchTimer); + } + threadSearchTimer = setTimeout(function() { + searchThreads(query, suggestionsDiv); + }, 400); + }); + + // Hide suggestions when clicking outside. + document.addEventListener('click', function(e) { + if (e.target !== titleInput && !suggestionsDiv.contains(e.target)) { + suggestionsDiv.style.display = 'none'; + } + }); + + // Show suggestions again on focus if they have content. + titleInput.addEventListener('focus', function() { + if (suggestionsDiv.innerHTML) { + suggestionsDiv.style.display = ''; + } + }); +} + +function searchThreads(query, suggestionsDiv) { + // Derive namespace from the current page URL or a data attribute. + var namespace = document.body.getAttribute('data-namespace') || ''; + if (!namespace) return; + + var url = '/api/v1/threads/search?q=' + encodeURIComponent(query) + + '&namespace=' + encodeURIComponent(namespace); + + fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(response) { return response.json(); }) + .then(function(data) { + if (!data.threads || data.threads.length === 0) { + suggestionsDiv.style.display = 'none'; + suggestionsDiv.innerHTML = ''; + return; + } + + var html = '
Existing threads:
'; + data.threads.forEach(function(thread) { + html += '' + + escapeHtml(thread.title) + + ' — ' + thread.created_ago + '' + + ''; + }); + suggestionsDiv.innerHTML = html; + suggestionsDiv.style.display = ''; + }) + .catch(function() { + suggestionsDiv.style.display = 'none'; + }); +} + +function escapeHtml(text) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; +} + // Initialize on DOM ready document.addEventListener('DOMContentLoaded', function() { @@ -166,6 +250,9 @@ document.addEventListener('DOMContentLoaded', function() { } } + // Initialize thread title typeahead for duplicate prevention (T9). + initThreadTitleTypeahead(); + }); function sendVote(post_id, direction) { diff --git a/remarkbox/static/js/push-sw.js b/remarkbox/static/js/push-sw.js new file mode 100644 index 0000000..7a3e924 --- /dev/null +++ b/remarkbox/static/js/push-sw.js @@ -0,0 +1,57 @@ +/** + * Remarkbox Push Notification Service Worker + * + * Handles incoming push events and notification clicks. + * Registered by the push subscription code on the client side. + */ + +/* eslint-env serviceworker */ + +self.addEventListener("push", function (event) { + var data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data = { title: "Remarkbox", body: event.data.text() }; + } + } + + var title = data.title || "Remarkbox"; + var options = { + body: data.body || "You have a new notification.", + icon: data.icon || "/static/img/remarkbox-icon.png", + badge: data.badge || "/static/img/remarkbox-icon.png", + tag: data.tag || "remarkbox-notification", + data: { + url: data.url || "/", + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", function (event) { + event.notification.close(); + + var url = "/"; + if (event.notification.data && event.notification.data.url) { + url = event.notification.data.url; + } + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) { + // Focus existing window if one is open. + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if (client.url === url && "focus" in client) { + return client.focus(); + } + } + // Otherwise open a new window. + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); diff --git a/remarkbox/templates/base.j2 b/remarkbox/templates/base.j2 index 5769f0c..8c90e08 100644 --- a/remarkbox/templates/base.j2 +++ b/remarkbox/templates/base.j2 @@ -19,7 +19,7 @@ - +