Compare commits
No commits in common. "master" and "sirius-press/wordpress-upstream" have entirely different histories.
master
...
sirius-pre
3848 changed files with 12 additions and 11699 deletions
29
.gitattributes
vendored
29
.gitattributes
vendored
|
|
@ -1,29 +0,0 @@
|
|||
# Line endings, pinned where getting them wrong breaks something.
|
||||
#
|
||||
# Git on Windows defaults to core.autocrlf=true, which rewrites LF to CRLF on
|
||||
# checkout. For most files that is harmless. For these it is not:
|
||||
|
||||
# Shell scripts run on Linux. A CRLF checkout that is copied to a server
|
||||
# fails with `bash: \r: command not found`, which tells you almost nothing
|
||||
# about what is wrong.
|
||||
*.sh text eol=lf
|
||||
install.sh text eol=lf
|
||||
|
||||
# Patch files must survive byte for byte: `patch` and `git apply` both care,
|
||||
# and tools/refresh-patches.sh --check compares them against freshly
|
||||
# generated LF output.
|
||||
*.patch -text
|
||||
|
||||
# Container and CI inputs are read by Linux tooling.
|
||||
Dockerfile text eol=lf
|
||||
*.yml text eol=lf
|
||||
.env.example text eol=lf
|
||||
docker/nginx/* text eol=lf
|
||||
|
||||
# PHP and JS are served, not executed by a shell, so either ending works —
|
||||
# but keeping them LF means a diff between a Windows and a Linux checkout is
|
||||
# about the code rather than about the line endings.
|
||||
*.php text eol=lf
|
||||
*.js text eol=lf
|
||||
*.css text eol=lf
|
||||
*.md text eol=lf
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Build output: a patched WordPress tree, the release zip, and the download
|
||||
# cache. All of it is reproducible from tools/build.sh plus tools/wordpress.lock,
|
||||
# and dist/sirius-press alone is 113 MB.
|
||||
dist/
|
||||
|
||||
# The live configuration, which holds database passwords and SIRIUS_PRESS_KEY.
|
||||
docker/.env
|
||||
128
CHANGELOG.md
128
CHANGELOG.md
|
|
@ -1,128 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.0 — unreleased
|
||||
|
||||
First cut. Sirius Press installs, signs people in with a wallet, and publishes
|
||||
static copies of its pages to a BCNR name.
|
||||
|
||||
### Accounts
|
||||
|
||||
- Sign in by signing a challenge with a Bitcoin Cash key. The address is
|
||||
recovered from the signature, so nothing has to be typed but the signature
|
||||
itself.
|
||||
- Three ways to produce one: a wallet the browser already exposes (Theseus), a
|
||||
recovery phrase used once in the page and wiped, or a signature pasted in
|
||||
from any BIP-137 wallet. The last works with JavaScript disabled.
|
||||
- One-step registration — the signature is the confirmation, so there is no
|
||||
email round trip and no pending state.
|
||||
- Password sign-in stays on by default and can be turned off once every
|
||||
account has a wallet. The screen that turns it off refuses to do so while it
|
||||
would lock out the person asking.
|
||||
- No password reset, and the "lost password" page explains why rather than
|
||||
pretending otherwise.
|
||||
- `/sirius-press/v1/confirm` lets any plugin demand a fresh signature before
|
||||
something irreversible.
|
||||
|
||||
### Publishing
|
||||
|
||||
- Publishing a post exports it, the home page and its archives to the name's
|
||||
storage on Sia, signed BNS-SITE1.
|
||||
- Two signing modes: manual, where the browser signs and the server stores
|
||||
nothing, and automatic, where an encrypted phrase lets cron publish alone.
|
||||
Manual is the default.
|
||||
- Unchanged pages are hashed and skipped rather than re-uploaded.
|
||||
- Unpublishing a post removes its file from the mirror.
|
||||
- `wp sirius export` and `wp sirius status` for the command line.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Every account carries an unroutable `.invalid` placeholder `user_email`, so
|
||||
the thousands of ecosystem reads of that field keep returning a string.
|
||||
- Mail to those placeholders is captured into an in-app inbox. Mail to real
|
||||
addresses is passed through untouched, so SMTP works normally.
|
||||
- Shims for WooCommerce, Contact Form 7 and core's admin-email machinery.
|
||||
|
||||
### Core
|
||||
|
||||
- One change, 75 lines, in `wp-admin/install.php`: the setup wizard asks for a
|
||||
wallet address instead of an email address, and the address is optional.
|
||||
- WordPress is vendored at `wordpress/` as a git subtree, already patched.
|
||||
Upstream releases arrive through `git subtree merge` against
|
||||
`sirius-press/wordpress-upstream`, a branch of pristine imports, so a
|
||||
release that touches code near the fork's change is three-way merged rather
|
||||
than re-derived by hand. `tools/update-wordpress.sh <version>` runs the
|
||||
whole thing.
|
||||
- `patches/` is now generated from the tree by `tools/refresh-patches.sh`
|
||||
(with `--check` for CI) and exists to answer "what does this fork change in
|
||||
core?" without reading a 3,800-file log. It is documentation, not the build
|
||||
mechanism.
|
||||
|
||||
### Packaging
|
||||
|
||||
- `install.sh` for a fresh Ubuntu VPS; Docker stack with MariaDB, PHP-FPM and
|
||||
nginx. Core lives in the image, so rebuilding is a real upgrade.
|
||||
- `tools/build.sh --zip` for shared hosting.
|
||||
- `tools/update-wordpress.sh` to move onto a new upstream release.
|
||||
- `tools/publish-release.sh` to ship to both mirrors.
|
||||
|
||||
### Fixed while testing against a live instance
|
||||
|
||||
- **Registration and wallet-linking accepted a signature over the wrong text.**
|
||||
Public-key recovery always succeeds — it returns a different key rather than
|
||||
failing — so a mismatched signature silently bound an account to an address
|
||||
nobody could sign for. Both paths now require the claimed address and
|
||||
compare it to the recovered one. Sign-in was never exposed to this, because
|
||||
a wrong address simply matches no account.
|
||||
- URL rewriting mangled links on any site whose URL carries a port: the
|
||||
protocol-relative pass matched inside absolute URLs and doubled the scheme,
|
||||
and a host-only match left the port stranded. Both covered by tests now.
|
||||
- Translations loaded on `plugins_loaded`, which WordPress 6.7+ warns about on
|
||||
every request. Moved to `init`.
|
||||
- The Publishing screen now refuses to be quiet about plain permalinks, which
|
||||
would collapse an entire site onto one exported file.
|
||||
- Removed an `is_email()` filter that rested on a false premise: WordPress
|
||||
validates syntax, not whether a domain can exist, so `.invalid` addresses
|
||||
already pass and the filter never fired. The documentation said otherwise
|
||||
and has been corrected.
|
||||
|
||||
### Fixed after opening it in a browser
|
||||
|
||||
- Wallet sign-in did not work in a browser at all. WordPress marks its
|
||||
username and password inputs `required`; the wallet path leaves both empty
|
||||
on purpose, so the form refused to submit a valid signature and pointed a
|
||||
validation bubble at a field the visitor is not meant to fill in.
|
||||
- The `hidden` attribute was being overridden by WordPress's own button
|
||||
styles, so controls this plugin ships hidden were visible regardless —
|
||||
offering a browser wallet that is not there, and showing a JavaScript-only
|
||||
button to visitors without JavaScript.
|
||||
|
||||
### WordPress 7.1.2
|
||||
|
||||
Taken through `tools/update-wordpress.sh` and merged cleanly; upstream did not
|
||||
touch the one file this fork patches. Two bugs in the update tool surfaced
|
||||
doing it: a Windows path-subtraction that produced an absolute `--prefix`, and
|
||||
git-subtree's refusal to run in a repository with any uncommitted file. The
|
||||
merge now runs in a scratch worktree and a failed run can be retried.
|
||||
|
||||
### Fixed after deploying to a real VPS
|
||||
|
||||
- **Static export could never work in the bundled Docker stack.** The exporter
|
||||
fetches each page over HTTP, and a container cannot reach the host's own
|
||||
published port on most Docker hosts — every export died with a cURL
|
||||
timeout while the site itself was plainly up. `SIRIUS_PRESS_LOOPBACK_URL`
|
||||
now points the exporter at the nginx service on the compose network, with
|
||||
the site's real `Host` header so the rendered page is unchanged and the
|
||||
internal address never reaches the output. The stack sets it by default.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- No byte has yet been written to the real gateway under a registered name.
|
||||
The signing is verified against a transcription of the gateway's own
|
||||
verifier, and the live instance reads the correct on-chain owner from
|
||||
`navigate.st`, but the upload itself is untested.
|
||||
- Manual-mode publishing (browser signs and uploads) has not been clicked
|
||||
through; the server-signing path has.
|
||||
- Publishing is verified against a transcription of the gateway's own
|
||||
verification logic, not against `navigate.st` with a registered name.
|
||||
- Seven of the ten rows in the plugin compatibility matrix are reasoned rather
|
||||
than tested; the three named in the ship criteria were installed and run.
|
||||
204
README.md
204
README.md
|
|
@ -1,204 +0,0 @@
|
|||
# Sirius Press
|
||||
|
||||
WordPress, with the mailbox taken out of the middle of it.
|
||||
|
||||
An account on a Sirius Press site is a Bitcoin Cash address. Signing in means
|
||||
signing a challenge with the key that controls it. There is no registration
|
||||
email, no confirmation link, no password reset — and no list of your readers'
|
||||
email addresses sitting in a database waiting to leak.
|
||||
|
||||
Everything the site publishes is also mirrored, as plain static HTML, to the
|
||||
BCNR name it belongs to. When the server is down, being upgraded, or seized,
|
||||
the name keeps serving the last thing it published.
|
||||
|
||||
It is still WordPress. Yoast, WooCommerce, Contact Form 7 and the rest of the
|
||||
ecosystem install and run; roles, capabilities, nonces and the REST API are
|
||||
untouched. The fork's diff against upstream core is **one file, 75 lines** —
|
||||
everything else is plugins.
|
||||
|
||||
---
|
||||
|
||||
## What this is for
|
||||
|
||||
You own a BCNR name. You want a real site behind it — one with an editor, a
|
||||
media library, plugins, a theme you did not have to write — without the two
|
||||
things WordPress assumes and this project rejects:
|
||||
|
||||
1. **That identity comes from email.** It does not. A mailbox is rented from
|
||||
somebody who can close it, read it, or be compelled to hand it over. A key
|
||||
is not.
|
||||
2. **That the site lives at one server.** Sirius Press publishes a static copy
|
||||
of every page to your name's storage on Sia, signed by the key that owns the
|
||||
name. Readers reach that copy through any BCNR resolver, with no DNS and no
|
||||
certificate authority in the path.
|
||||
|
||||
Email itself still works. `wp_mail()` is not disabled — configure SMTP and your
|
||||
contact forms send, your WooCommerce receipts arrive. The objection was never
|
||||
to email as a feature. It was to email as an identity.
|
||||
|
||||
---
|
||||
|
||||
## Getting it running
|
||||
|
||||
On a fresh Ubuntu 22.04 or 24.04 server:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://silentmode.st/sirius-press/install.sh | sudo bash
|
||||
```
|
||||
|
||||
That installs Docker if needed, brings up MariaDB + PHP-FPM + nginx, and prints
|
||||
the URL to finish setup. It takes about five minutes on a small VPS, most of it
|
||||
building the PHP image.
|
||||
|
||||
For shared hosting, a `.zip` you can upload is on
|
||||
[the releases page](https://code.silentmode.st/silentmode/sirius-press/releases).
|
||||
|
||||
Full instructions, including how to point your name at it:
|
||||
**[docs/install.md](docs/install.md)**.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Signing in
|
||||
|
||||
The login page issues a short, single-use challenge. You sign it, the server
|
||||
recovers the public key from the signature, derives the address, and looks up
|
||||
the account. Nothing is typed but the signature.
|
||||
|
||||
Three ways to produce it, in order of preference:
|
||||
|
||||
- **A wallet the browser already has.** Theseus exposes one; the key never
|
||||
touches the page.
|
||||
- **A recovery phrase typed into the login form.** Used once, in the page, then
|
||||
wiped. It is never transmitted — only the signature is.
|
||||
- **Any Bitcoin Cash wallet at all.** The challenge is a BIP-137 message, the
|
||||
format Electron Cash has had a "Sign message" box for since forever. Copy the
|
||||
text, sign it on a machine that never touches the web, paste the result back.
|
||||
This path works with JavaScript switched off.
|
||||
|
||||
The signature becomes an ordinary WordPress session cookie. Everything
|
||||
downstream — `current_user_can()`, nonces, REST permissions, every plugin that
|
||||
checks a capability — behaves exactly as it does on stock WordPress.
|
||||
|
||||
**There is no password reset, because there is nothing to reset.** If you lose
|
||||
your phrase, the account is gone. An administrator can point an account at a
|
||||
new address from the user editor, which makes recovery a decision a human
|
||||
makes about a person they recognise — not something an attacker triggers by
|
||||
compromising a mailbox. [docs/accounts.md](docs/accounts.md) says this at more
|
||||
length, and the fork's "lost password" page says it to your users.
|
||||
|
||||
### Publishing
|
||||
|
||||
Publish a post and the affected pages — the post, the home page, the archives
|
||||
it belongs to — are queued. Draining the queue renders each page over a
|
||||
loopback request, rewrites its links to be document-relative, and PUTs it to
|
||||
`/api/site/<name>/<path>` on the BNS gateway, signed by the key that owns the
|
||||
name.
|
||||
|
||||
Signing happens one of two ways, and the choice is yours:
|
||||
|
||||
| | Where the key lives | Unattended publishing |
|
||||
|---|---|---|
|
||||
| **Manual** (default) | Nowhere. You type the phrase on the Publishing screen, it signs in your browser and uploads straight to the gateway. | No — the queue waits for you. |
|
||||
| **Automatic** | Encrypted in the database, under `SIRIUS_PRESS_KEY` from `wp-config.php`. | Yes — cron drains the queue. |
|
||||
|
||||
Automatic is genuinely convenient and genuinely risky, and the settings screen
|
||||
says so in those words. The gateway only accepts writes signed by the name's
|
||||
current on-chain owner, so the key that can publish for your name is the key
|
||||
that owns your name and its funds. There is no middle option where the server
|
||||
holds something weaker; that is a property of the protocol, not a gap in this
|
||||
fork. [docs/publishing.md](docs/publishing.md) has the details.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
plugins/
|
||||
sirius-press-core/ wallet crypto, gateway client, settings, the inbox
|
||||
sirius-press-auth/ wallet sign-in, registration, the recovery page
|
||||
sirius-press-sia-export/ the static export queue and uploader
|
||||
sirius-press-compat/ shims for plugins that insist on an admin email
|
||||
mu-plugins/ the bits that must load before plugins do
|
||||
wordpress/ WordPress itself, vendored as a subtree, patched
|
||||
patches/ a generated record of the core diff — one file
|
||||
docker/ compose stack: MariaDB, PHP-FPM, nginx
|
||||
tools/ build, upstream update, release
|
||||
tests/ 138 unit checks plus a live end-to-end suite
|
||||
docs/
|
||||
```
|
||||
|
||||
### Where WordPress itself is
|
||||
|
||||
In `wordpress/`, as a git subtree, already patched. That is what gets built
|
||||
and what gets shipped — there is no download step and no checksum to trust at
|
||||
build time.
|
||||
|
||||
Upstream releases arrive through `git subtree merge` against a branch of
|
||||
pristine imports, which three-way merges them against the fork's own commit
|
||||
on top. `tools/update-wordpress.sh <version>` does the whole thing;
|
||||
`patches/` keeps a generated, readable record of the 75 lines that differ
|
||||
from stock WordPress, so nobody has to read a 3,800-file log to find out what
|
||||
this fork changes in core.
|
||||
|
||||
The trade is repository size — WordPress is about 149 MB. Worth it for a fork
|
||||
that can absorb a security release in a minute.
|
||||
[docs/upstream-merges.md](docs/upstream-merges.md) has the procedure.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 7.4+ — 8.2 or newer recommended
|
||||
- **GMP or BCMath.** Not optional: the wallet cryptography runs in PHP. GMP is
|
||||
about twenty times faster and is what the Docker image uses.
|
||||
- 64-bit PHP — address checksums need 40-bit arithmetic
|
||||
- OpenSSL, for encrypting a stored publishing key
|
||||
- MySQL 5.7+ / MariaDB 10.4+
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
tests/run.sh # 138 checks, no framework, about a second
|
||||
node tests/live.mjs # 40 more, against a running instance
|
||||
```
|
||||
|
||||
The suite that matters most is `tests/interop.mjs`, which pins the browser
|
||||
wallet against the PHP one. Both implement secp256k1, RFC 6979, BIP-32 and
|
||||
BIP-137 independently, and every vector came from libauth — the library the
|
||||
Sirius portal wallet and the BNS gateway both use. A signature made in a
|
||||
browser verifies on the server and at the gateway, or the suite fails.
|
||||
|
||||
### What has actually been run
|
||||
|
||||
On WordPress 7.1.1, against a live instance:
|
||||
|
||||
- The patched setup wizard asks for a wallet and installs with or without one.
|
||||
- Sign-in, registration, replay refusal, wrong-key refusal, altered-text
|
||||
refusal and purpose separation — 40 checks, all through real HTTP with real
|
||||
session cookies.
|
||||
- Yoast SEO 28.6, Contact Form 7 6.1.7 and WooCommerce 11.1.1 install,
|
||||
activate, and leave every Sirius Press screen rendering cleanly.
|
||||
- Publishing: 29 files signed by the server and accepted by a gateway running
|
||||
the real verification logic, read back intact, with a wrong key refused 403.
|
||||
- Signing in **in an actual browser**, which found two bugs the HTTP suites
|
||||
structurally could not — see docs/testing.md.
|
||||
- Taking WordPress **7.1.2** through `tools/update-wordpress.sh`: imported,
|
||||
three-way merged into the subtree, clean, tests still green.
|
||||
|
||||
- **`install.sh` on a real Ubuntu 24.04 VPS**, start to finish: Docker stack
|
||||
built, MariaDB healthy, the patched wizard served, plugins activated, and
|
||||
the 40-check auth suite run against it over the public internet.
|
||||
|
||||
Not yet run: an upload to the real gateway under a registered name — the
|
||||
signing is verified, the byte has not been written.
|
||||
[docs/testing.md](docs/testing.md) says how to do it and what it proves.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-2.0-or-later, inherited from WordPress.
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# Sirius Press — copy to .env and edit before the first `docker compose up`.
|
||||
#
|
||||
# install.sh writes this file for you with generated passwords. Doing it by
|
||||
# hand is fine too; just do not leave the passwords below as they are.
|
||||
|
||||
# --- database ---------------------------------------------------------------
|
||||
# These are read once, when MariaDB initialises its data directory. Changing
|
||||
# them later does not change the database — it just stops the app connecting.
|
||||
DB_NAME=wordpress
|
||||
DB_USER=wordpress
|
||||
DB_PASSWORD=change-me
|
||||
DB_ROOT_PASSWORD=change-me-too
|
||||
|
||||
# --- the site ---------------------------------------------------------------
|
||||
# The URL visitors reach this instance on. Set it once the name's `p` or `ip`
|
||||
# record points here; leaving it blank lets WordPress guess from the request,
|
||||
# which is fine for a first run on an IP address.
|
||||
SITE_URL=
|
||||
|
||||
# Port nginx publishes on the host. Put 8080 here if something else already
|
||||
# owns 80 and you are running a reverse proxy in front.
|
||||
HTTP_PORT=80
|
||||
|
||||
WP_DEBUG=false
|
||||
|
||||
# --- the publishing key ------------------------------------------------------
|
||||
# What the site's recovery phrase is encrypted with at rest. Generated on first
|
||||
# start if left blank, and then kept in the config volume.
|
||||
#
|
||||
# Worth setting explicitly and keeping a copy somewhere safe: if this value is
|
||||
# lost, the stored phrase cannot be decrypted and has to be entered again.
|
||||
# It is not the phrase itself and is useless without the database.
|
||||
SIRIUS_PRESS_KEY=
|
||||
|
||||
# WordPress itself is vendored in the repository (wordpress/) and baked into
|
||||
# the image, so there is no version or checksum to configure here. See
|
||||
# docs/upstream-merges.md for how a new upstream release gets in.
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
# Sirius Press — PHP-FPM image with the patched WordPress baked in.
|
||||
#
|
||||
# Core comes from this repository's `wordpress/` subtree, not from a download
|
||||
# and not from the official `wordpress` image. That image ships whatever
|
||||
# version it was tagged with, and the fork's core patch is pinned to an exact
|
||||
# one; applying a fork's patch to a different core is how a setup wizard ends
|
||||
# up half rewritten. Copying the vendored tree makes the image contain exactly
|
||||
# what `git log wordpress/` describes, with nothing fetched at build time.
|
||||
#
|
||||
# Core is staged at /opt/sirius-press/core, not at the document root. The
|
||||
# entrypoint copies it into place on every start, which is what makes
|
||||
# `docker compose build --pull && up -d` a real upgrade: the usual layout,
|
||||
# where the document root is itself a volume, pins core to whatever version
|
||||
# first created that volume and turns every security release into a manual
|
||||
# migration.
|
||||
#
|
||||
# GMP is installed because the wallet cryptography runs in PHP. Without it the
|
||||
# fork falls back to BCMath, which works and is roughly twenty times slower —
|
||||
# unavoidable on a shared host, wasteful in a container we control.
|
||||
|
||||
FROM php:8.3-fpm-bookworm
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libwebp-dev \
|
||||
libzip-dev libgmp-dev libicu-dev \
|
||||
; \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \
|
||||
docker-php-ext-install -j"$(nproc)" \
|
||||
bcmath gd gmp intl mysqli opcache zip exif \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Settings a WordPress host wants and the PHP image does not set.
|
||||
RUN { \
|
||||
echo 'upload_max_filesize = 64M'; \
|
||||
echo 'post_max_size = 64M'; \
|
||||
echo 'memory_limit = 256M'; \
|
||||
echo 'max_execution_time = 120'; \
|
||||
echo 'opcache.memory_consumption = 128'; \
|
||||
echo 'opcache.max_accelerated_files = 10000'; \
|
||||
echo 'opcache.revalidate_freq = 2'; \
|
||||
echo 'expose_php = Off'; \
|
||||
} > /usr/local/etc/php/conf.d/sirius-press.ini
|
||||
|
||||
# --- core, from the subtree --------------------------------------------------
|
||||
|
||||
COPY wordpress/ /opt/sirius-press/core/
|
||||
|
||||
RUN set -eux; \
|
||||
test -f /opt/sirius-press/core/wp-includes/version.php; \
|
||||
sed -n "s/.*wp_version = '\\(.*\\)'.*/\\1/p" \
|
||||
/opt/sirius-press/core/wp-includes/version.php \
|
||||
> /opt/sirius-press/core/.sirius-core-version; \
|
||||
echo "vendored WordPress $(cat /opt/sirius-press/core/.sirius-core-version)"
|
||||
|
||||
# --- the fork -----------------------------------------------------------------
|
||||
#
|
||||
# The patch is already applied in the subtree, so there is nothing to patch
|
||||
# here. patches/ is carried for auditing, not for building.
|
||||
|
||||
WORKDIR /opt/sirius-press/core
|
||||
|
||||
COPY plugins/ /opt/sirius-press/core/wp-content/plugins/
|
||||
COPY mu-plugins/ /opt/sirius-press/core/wp-content/mu-plugins/
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/sirius-entrypoint
|
||||
RUN chmod +x /usr/local/bin/sirius-entrypoint; \
|
||||
mkdir -p /var/www/html /var/www/config; \
|
||||
chown -R www-data:www-data /var/www/html /var/www/config
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
ENTRYPOINT ["sirius-entrypoint"]
|
||||
CMD ["php-fpm"]
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# Sirius Press — a complete self-hosted stack.
|
||||
#
|
||||
# Three containers: MariaDB, PHP-FPM with the patched WordPress baked in, and
|
||||
# nginx in front. Only nginx is published.
|
||||
#
|
||||
# The volume layout is the part worth reading. WordPress core comes from the
|
||||
# repository's `wordpress/` subtree, is baked into the image, and is refilled
|
||||
# into the document root on every start — so `docker compose build && up -d`
|
||||
# genuinely upgrades it — the usual arrangement, where the whole document root is a
|
||||
# volume, freezes core at whatever version first created the volume and turns
|
||||
# every security release into a manual migration. What people actually need to
|
||||
# keep — uploads, plugins and themes they installed, and wp-config.php — is
|
||||
# what gets a volume.
|
||||
#
|
||||
# cp .env.example .env # then edit it
|
||||
# docker compose up -d
|
||||
|
||||
name: sirius-press
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_NAME:-wordpress}
|
||||
MARIADB_USER: ${DB_USER:-wordpress}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?set DB_ROOT_PASSWORD in .env}
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
command:
|
||||
# utf8mb4 throughout: a post containing an emoji should not be a
|
||||
# database error, and WordPress has assumed this for years.
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- db:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: db
|
||||
WORDPRESS_DB_NAME: ${DB_NAME:-wordpress}
|
||||
WORDPRESS_DB_USER: ${DB_USER:-wordpress}
|
||||
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
||||
WORDPRESS_SITE_URL: ${SITE_URL:-}
|
||||
WORDPRESS_DEBUG: ${WP_DEBUG:-false}
|
||||
SIRIUS_PRESS_KEY: ${SIRIUS_PRESS_KEY:-}
|
||||
# nginx on this compose network. The exporter fetches pages here because
|
||||
# a container cannot reach the host's own published port.
|
||||
SIRIUS_PRESS_LOOPBACK_URL: ${SIRIUS_PRESS_LOOPBACK_URL:-http://web}
|
||||
volumes:
|
||||
- config:/var/www/config
|
||||
# The document root is a volume so nginx can read the same files. It is
|
||||
# refilled from the image on every start, so this does not pin core.
|
||||
- core:/var/www/html
|
||||
- uploads:/var/www/html/wp-content/uploads
|
||||
- plugins:/var/www/html/wp-content/plugins
|
||||
- themes:/var/www/html/wp-content/themes
|
||||
|
||||
web:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# nginx serves static files directly and only proxies PHP, so it needs
|
||||
# to see the same tree. Read-only: the web tier has no business writing.
|
||||
- core:/var/www/html:ro
|
||||
- uploads:/var/www/html/wp-content/uploads:ro
|
||||
- plugins:/var/www/html/wp-content/plugins:ro
|
||||
- themes:/var/www/html/wp-content/themes:ro
|
||||
|
||||
volumes:
|
||||
db:
|
||||
config:
|
||||
core:
|
||||
uploads:
|
||||
plugins:
|
||||
themes:
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Prepare the site, then hand over to PHP-FPM.
|
||||
#
|
||||
# Three jobs, all of which have to survive an image upgrade without destroying
|
||||
# anything the site owner did:
|
||||
#
|
||||
# 1. WordPress core and the fork's own plugins are copied out of the image
|
||||
# into the document root on every start, so rebuilding the image is a
|
||||
# real upgrade. Plugins and themes installed from the dashboard live in
|
||||
# their own volumes and are left alone.
|
||||
#
|
||||
# 2. wp-config.php is generated once, into a volume, and copied into place
|
||||
# each start. Never regenerated — new salts would end every session, and
|
||||
# a new SIRIUS_PRESS_KEY would make the stored publishing phrase
|
||||
# undecryptable, which looks exactly like a corrupted wallet.
|
||||
#
|
||||
# 3. Nothing waits on the network except the database, and that with a
|
||||
# bounded timeout: a site that will not start is worse than one that
|
||||
# starts and says the database is unreachable.
|
||||
|
||||
set -eu
|
||||
|
||||
DOCROOT=/var/www/html
|
||||
SOURCE=/opt/sirius-press/core
|
||||
CONFIG_STORE=/var/www/config/wp-config.php
|
||||
CONFIG=$DOCROOT/wp-config.php
|
||||
|
||||
random_key() {
|
||||
# 64 characters of printable entropy. `tr -dc` over /dev/urandom is the one
|
||||
# method available in a slim image without adding tools.
|
||||
tr -dc 'A-Za-z0-9!@#%^&*()_+=-' < /dev/urandom | head -c 64
|
||||
}
|
||||
|
||||
wait_for_db() {
|
||||
# Compose's healthcheck covers the container, but a first run also has to
|
||||
# wait out MariaDB's own initialisation.
|
||||
i=0
|
||||
while [ "$i" -lt 60 ]; do
|
||||
if php -r 'exit(@mysqli_connect(getenv("WORDPRESS_DB_HOST"), getenv("WORDPRESS_DB_USER"), getenv("WORDPRESS_DB_PASSWORD")) ? 0 : 1);' 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
echo "sirius-press: the database did not answer within 60s; starting anyway" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- core
|
||||
|
||||
# Copy everything except wp-content, which has volumes of its own, then put
|
||||
# back the directories the fork owns. Anything the site owner installed from
|
||||
# the dashboard sits in those same volumes and is never touched.
|
||||
echo "sirius-press: syncing WordPress $(cat "$SOURCE/.sirius-core-version" 2>/dev/null || echo '?') into the document root"
|
||||
|
||||
for entry in "$SOURCE"/*; do
|
||||
name="$(basename "$entry")"
|
||||
if [ "$name" = "wp-content" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "${DOCROOT:?}/${name}"
|
||||
cp -R "$entry" "$DOCROOT/"
|
||||
done
|
||||
cp "$SOURCE/.sirius-core-version" "$DOCROOT/.sirius-core-version" 2>/dev/null || true
|
||||
|
||||
mkdir -p "$DOCROOT/wp-content/plugins" "$DOCROOT/wp-content/themes" "$DOCROOT/wp-content/mu-plugins" "$DOCROOT/wp-content/uploads"
|
||||
|
||||
# Seed bundled themes only when the themes volume is empty — replacing them on
|
||||
# every start would undo a site owner's edits to a bundled theme.
|
||||
if [ -z "$(ls -A "$DOCROOT/wp-content/themes" 2>/dev/null)" ]; then
|
||||
cp -R "$SOURCE/wp-content/themes/." "$DOCROOT/wp-content/themes/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# The fork's own plugins are the image's to own, and are replaced outright.
|
||||
for dir in "$SOURCE"/wp-content/plugins/sirius-press-*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
rm -rf "${DOCROOT:?}/wp-content/plugins/${name}"
|
||||
cp -R "$dir" "$DOCROOT/wp-content/plugins/"
|
||||
done
|
||||
cp "$SOURCE"/wp-content/mu-plugins/*.php "$DOCROOT/wp-content/mu-plugins/" 2>/dev/null || true
|
||||
|
||||
# Everything else WordPress ships in wp-content (akismet, the index.php
|
||||
# guards) only needs to exist once.
|
||||
for extra in akismet index.php; do
|
||||
if [ ! -e "$DOCROOT/wp-content/plugins/$extra" ] && [ -e "$SOURCE/wp-content/plugins/$extra" ]; then
|
||||
cp -R "$SOURCE/wp-content/plugins/$extra" "$DOCROOT/wp-content/plugins/"
|
||||
fi
|
||||
done
|
||||
|
||||
chown -R www-data:www-data "$DOCROOT"
|
||||
|
||||
# ---------------------------------------------------------------- wp-config
|
||||
|
||||
if [ ! -f "$CONFIG_STORE" ]; then
|
||||
echo "sirius-press: generating wp-config.php"
|
||||
|
||||
: "${WORDPRESS_DB_HOST:=db}"
|
||||
: "${WORDPRESS_DB_NAME:=wordpress}"
|
||||
: "${WORDPRESS_DB_USER:=wordpress}"
|
||||
: "${WORDPRESS_DB_PASSWORD:=wordpress}"
|
||||
: "${WORDPRESS_TABLE_PREFIX:=wp_}"
|
||||
if [ -z "${SIRIUS_PRESS_KEY:-}" ]; then
|
||||
SIRIUS_PRESS_KEY="$(random_key)"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_STORE")"
|
||||
{
|
||||
echo "<?php"
|
||||
echo "// Generated by the Sirius Press container on first start."
|
||||
echo "// Edit freely — this file is kept in a volume and never rewritten."
|
||||
echo
|
||||
echo "define( 'DB_NAME', '${WORDPRESS_DB_NAME}' );"
|
||||
echo "define( 'DB_USER', '${WORDPRESS_DB_USER}' );"
|
||||
echo "define( 'DB_PASSWORD', '${WORDPRESS_DB_PASSWORD}' );"
|
||||
echo "define( 'DB_HOST', '${WORDPRESS_DB_HOST}' );"
|
||||
echo "define( 'DB_CHARSET', 'utf8mb4' );"
|
||||
echo "define( 'DB_COLLATE', '' );"
|
||||
echo
|
||||
for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY \
|
||||
AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
|
||||
echo "define( '${k}', '$(random_key)' );"
|
||||
done
|
||||
echo
|
||||
echo "// The key the publishing phrase is encrypted with. Separate from"
|
||||
echo "// the auth salts so rotating sessions does not orphan the stored"
|
||||
echo "// key. Lose this and the phrase must be entered again."
|
||||
echo "define( 'SIRIUS_PRESS_KEY', '${SIRIUS_PRESS_KEY}' );"
|
||||
echo
|
||||
echo "// nginx terminates TLS in front of this container, so PHP has to"
|
||||
echo "// be told. Without it WordPress builds http:// URLs and a"
|
||||
echo "// redirect loop follows."
|
||||
echo "if ( isset( \$_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === \$_SERVER['HTTP_X_FORWARDED_PROTO'] ) {"
|
||||
echo " \$_SERVER['HTTPS'] = 'on';"
|
||||
echo "}"
|
||||
echo "define( 'SIRIUS_PRESS_TRUST_PROXY', true );"
|
||||
echo
|
||||
echo "// Where the static exporter fetches this site's own pages."
|
||||
echo "// A container cannot reach the host's public address on most Docker"
|
||||
echo "// hosts — the request times out instead of arriving at nginx — so the"
|
||||
echo "// exporter goes to the web container directly and carries the site's"
|
||||
echo "// real Host header, which keeps the rendered page identical."
|
||||
echo "define( 'SIRIUS_PRESS_LOOPBACK_URL', '${SIRIUS_PRESS_LOOPBACK_URL:-http://web}' );"
|
||||
echo
|
||||
if [ -n "${WORDPRESS_SITE_URL:-}" ]; then
|
||||
echo "define( 'WP_HOME', '${WORDPRESS_SITE_URL}' );"
|
||||
echo "define( 'WP_SITEURL', '${WORDPRESS_SITE_URL}' );"
|
||||
fi
|
||||
echo "define( 'FS_METHOD', 'direct' );"
|
||||
echo "define( 'WP_DEBUG', ${WORDPRESS_DEBUG:-false} );"
|
||||
echo
|
||||
echo "\$table_prefix = '${WORDPRESS_TABLE_PREFIX}';"
|
||||
echo
|
||||
echo "if ( ! defined( 'ABSPATH' ) ) {"
|
||||
echo " define( 'ABSPATH', __DIR__ . '/' );"
|
||||
echo "}"
|
||||
echo "require_once ABSPATH . 'wp-settings.php';"
|
||||
} > "$CONFIG_STORE"
|
||||
chmod 640 "$CONFIG_STORE"
|
||||
fi
|
||||
|
||||
cp "$CONFIG_STORE" "$CONFIG"
|
||||
chown www-data:www-data "$CONFIG"
|
||||
chmod 640 "$CONFIG"
|
||||
|
||||
|
||||
if [ "${1:-}" = "php-fpm" ]; then
|
||||
wait_for_db || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
# nginx in front of Sirius Press.
|
||||
#
|
||||
# Static files are served here; only PHP is proxied. That matters more than
|
||||
# usual for this fork: the wallet library and the login script are plain files,
|
||||
# and routing them through PHP-FPM would put a process on the critical path of
|
||||
# every sign-in for no reason.
|
||||
#
|
||||
# TLS is not configured here. Most people putting this on a VPS already have a
|
||||
# reverse proxy, Caddy, or a Cloudflare tunnel in front; baking half a
|
||||
# certificate story into this file would fight all three. docs/install.md
|
||||
# covers the two common arrangements.
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
index index.php;
|
||||
|
||||
client_max_body_size 64m;
|
||||
|
||||
# WordPress hands its own version out in a header; there is no reason to
|
||||
# advertise nginx's too.
|
||||
server_tokens off;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# --- things that should never be reachable -------------------------------
|
||||
|
||||
# wp-config.php holds the database password and SIRIUS_PRESS_KEY, which is
|
||||
# what the stored publishing phrase is encrypted under. A misconfiguration
|
||||
# that serves it as text hands over the site's wallet.
|
||||
location = /wp-config.php { deny all; }
|
||||
location ~* /wp-config.*\.php$ { deny all; }
|
||||
location ~ /\.(?!well-known) { deny all; }
|
||||
location = /xmlrpc.php { deny all; }
|
||||
location ~* /(?:uploads|files)/.*\.php$ { deny all; }
|
||||
location ~* ^/wp-content/.*\.(?:sql|log|bak|swp)$ { deny all; }
|
||||
|
||||
# --- routing -------------------------------------------------------------
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param HTTPS $http_x_forwarded_proto if_not_empty;
|
||||
|
||||
# Signature verification on a BCMath host takes a few hundred
|
||||
# milliseconds, and a full static export batch takes longer. The
|
||||
# default 60s would turn a slow first login into a 504.
|
||||
fastcgi_read_timeout 300;
|
||||
fastcgi_buffers 16 16k;
|
||||
fastcgi_buffer_size 32k;
|
||||
}
|
||||
|
||||
# --- caching -------------------------------------------------------------
|
||||
|
||||
location ~* \.(?:css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The login screen must never be cached anywhere: the challenge it carries
|
||||
# is single-use, and a cached copy would hand every visitor a nonce that
|
||||
# has already been spent.
|
||||
location = /wp-login.php {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param HTTPS $http_x_forwarded_proto if_not_empty;
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
}
|
||||
178
docs/accounts.md
178
docs/accounts.md
|
|
@ -1,178 +0,0 @@
|
|||
# Accounts, sign-in and recovery
|
||||
|
||||
## What an account is
|
||||
|
||||
A CashAddress, and a WordPress user row attached to it.
|
||||
|
||||
The address lives in user meta, not in a new column on `wp_users`. That is a
|
||||
deliberate departure from the original design sketch, which proposed replacing
|
||||
`user_email` with a `wallet_addr` column: a schema change to a core table makes
|
||||
every future upstream merge and every `dbDelta()` run a negotiation, and it
|
||||
buys nothing here. Meta lookups are indexed, and a plugin reading
|
||||
`$user->user_email` keeps getting a string instead of crashing.
|
||||
|
||||
Everything else about the user is ordinary WordPress. Roles, capabilities,
|
||||
nonces, sessions, the REST API — none of it knows the login was different.
|
||||
|
||||
---
|
||||
|
||||
## Signing in
|
||||
|
||||
The login page issues a challenge: a few lines of readable text containing the
|
||||
site, the purpose, a nonce and a timestamp. You sign it; the server recovers
|
||||
the public key from the signature, derives the address, and finds the account.
|
||||
|
||||
You never type your address. It comes out of the signature.
|
||||
|
||||
### The three ways to sign
|
||||
|
||||
**A wallet the browser already has.** Theseus exposes `window.bitcoincash` on
|
||||
`.x` origins. The button appears when it is there. The key stays in the
|
||||
browser's wallet and never touches the page.
|
||||
|
||||
**A phrase typed into the form.** Derived in the page, used once, wiped. The
|
||||
phrase is never transmitted — only the signature is. This is the path most
|
||||
people will use, and it is the one worth being clear-eyed about: you are typing
|
||||
a recovery phrase into a web page. It is the same trust you place in the Sirius
|
||||
portal, and less than you place in a browser extension, but it is not nothing.
|
||||
|
||||
**A signature made somewhere else.** The challenge is a BIP-137 message — the
|
||||
format Electron Cash's "Sign message" box has produced for a decade. Copy the
|
||||
text out, sign it on a machine that never touches this site, paste the result
|
||||
back. **This path needs no JavaScript at all**, which is why the paste box is
|
||||
in the markup from the start and the buttons are revealed by script rather than
|
||||
the other way round.
|
||||
|
||||
### The challenge
|
||||
|
||||
```
|
||||
Sign in to Example Site
|
||||
|
||||
Site: https://example.bch/
|
||||
Purpose: sign in
|
||||
Nonce: 1758412800000.3f9ab2c1d4e5f607.9a1b…
|
||||
Issued: 2026-09-21T10:00:00Z
|
||||
|
||||
Signing this proves you control this wallet. It moves no coins.
|
||||
```
|
||||
|
||||
Written to be readable in a wallet's approval dialog, because that dialog is
|
||||
the only place a user gets to check what they are agreeing to. Anything a
|
||||
wallet renders as a wall of hex is a security control the user cannot exercise.
|
||||
|
||||
Issuing one writes nothing: the nonce carries its own timestamp and an HMAC
|
||||
under the site's salts, so a forged nonce fails arithmetic rather than a
|
||||
database lookup. Only a *successful* verification writes — a short-lived marker
|
||||
that burns the nonce so the signature cannot be replayed.
|
||||
|
||||
The purpose is inside the signed bytes, which is what stops a login signature
|
||||
being replayed to create an account.
|
||||
|
||||
---
|
||||
|
||||
## Registration
|
||||
|
||||
One step. Sign the challenge, optionally pick a username, done — there is no
|
||||
confirmation link because the signature *is* the confirmation.
|
||||
|
||||
No pending state, no expiring link, no way for a typo to strand an account.
|
||||
|
||||
Turn it on or off at **Sirius Press → Sign-in**.
|
||||
|
||||
---
|
||||
|
||||
## Recovery, and the absence of it
|
||||
|
||||
**If you lose your phrase, the account is gone.** Nobody can reset it, and
|
||||
nothing in the software is trying to.
|
||||
|
||||
This is not an oversight to be worked around later. A reset mechanism is, by
|
||||
construction, a way to take an account from its owner — and every such
|
||||
mechanism ends up being the thing attacked, because it is easier than the
|
||||
cryptography it bypasses. Password reset by email is exactly that: a promise
|
||||
that whoever controls a mailbox controls the account.
|
||||
|
||||
The fork's "lost password" page says this to your users in those terms, and
|
||||
offers the two things that genuinely help: if they still have a phrase, they
|
||||
are not locked out at all and should just sign in; and if they have several
|
||||
phrases and do not know which is which, the page derives the address from a
|
||||
phrase in the browser so they can check.
|
||||
|
||||
### The one route back
|
||||
|
||||
An administrator can point an account at a different address from **Users →
|
||||
Edit**.
|
||||
|
||||
That is intentionally a human decision about a person somebody recognises,
|
||||
rather than a capability an attacker triggers. It also means an administrator
|
||||
can take any account on the site, which is true of WordPress administrators
|
||||
generally and is worth knowing rather than discovering.
|
||||
|
||||
If nobody has an administrator account any more, nothing in the software can
|
||||
help — but the database can. Attach a wallet to user 1 directly:
|
||||
|
||||
```sql
|
||||
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
|
||||
VALUES (1, 'sirius_wallet_address', 'bitcoincash:qq…')
|
||||
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value);
|
||||
```
|
||||
|
||||
Or with WP-CLI, which handles the prefix for you:
|
||||
|
||||
```bash
|
||||
wp user meta update 1 sirius_wallet_address 'bitcoincash:qq…'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passwords
|
||||
|
||||
Left on by default, and the reason is unglamorous: a fresh install has exactly
|
||||
one account, created by the installer, with no wallet attached yet. Shipping
|
||||
with passwords off would mean the first thing a new site does is lock out its
|
||||
own administrator.
|
||||
|
||||
Turn them off at **Sirius Press → Sign-in** once every account that needs
|
||||
access has a wallet. That screen refuses to do it until *your* account has one,
|
||||
and lists every account that would be stranded.
|
||||
|
||||
With passwords off, core's password checks are removed from the `authenticate`
|
||||
chain entirely rather than left to run and fail — a site that has turned
|
||||
passwords off should not still have a password oracle on its login page.
|
||||
|
||||
Application passwords and WP-CLI are unaffected either way. They do not go
|
||||
through the interactive login path.
|
||||
|
||||
---
|
||||
|
||||
## Asking for a fresh signature
|
||||
|
||||
A capability check proves what a session is allowed to do. It cannot prove the
|
||||
person holding the key is still at the keyboard. For something irreversible —
|
||||
transferring a name, moving money, deleting a site — a plugin can demand a new
|
||||
signature:
|
||||
|
||||
```php
|
||||
$challenge = sirius_press_confirmation_challenge();
|
||||
// Render $challenge['message'] for the user to sign, then POST
|
||||
// { nonce, signature } to /wp-json/sirius-press/v1/confirm
|
||||
```
|
||||
|
||||
It returns 403 if the signature belongs to a different wallet than the one
|
||||
signed in, so a caller that forgets to check the response body still fails
|
||||
closed.
|
||||
|
||||
---
|
||||
|
||||
## The REST surface
|
||||
|
||||
| Endpoint | Method | Auth | Does |
|
||||
|---|---|---|---|
|
||||
| `/sirius-press/v1/challenge` | GET | none | Hands out something to sign. `?purpose=login\|register\|link\|confirm` |
|
||||
| `/sirius-press/v1/login` | POST | none | `{nonce, signature}` → sets a session cookie |
|
||||
| `/sirius-press/v1/register` | POST | none | `{nonce, signature, user_login?}` → creates an account |
|
||||
| `/sirius-press/v1/confirm` | POST | logged in | `{nonce, signature}` → re-proves the current user's key |
|
||||
|
||||
Everything that does curve maths is rate limited per client, because signature
|
||||
recovery is the most expensive thing a stranger can make the server do — a few
|
||||
hundred milliseconds of CPU each on a BCMath host.
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# Pointing your name at your site
|
||||
|
||||
A BCNR name carries a small set of records on-chain. Two of them say "the site
|
||||
for this name is served from here":
|
||||
|
||||
| Record | Holds | Use it when |
|
||||
|---|---|---|
|
||||
| `ip` | An IPv4 or IPv6 address | You have a server with a fixed address — the normal case for a VPS |
|
||||
| `p` | A hostname | Something else already resolves to your server: a DNS name, a tunnel endpoint, a load balancer |
|
||||
|
||||
Either one makes a BCNR resolver — Theseus, Ariadne, or the public gateway —
|
||||
send visitors to your Sirius Press instance.
|
||||
|
||||
---
|
||||
|
||||
## Setting the record
|
||||
|
||||
From the Sirius portal at [sirius.x](https://sirius.x): open your name, go to
|
||||
the DNS tab, add the record, and sign. The change costs nothing and takes
|
||||
effect as soon as the manifest is published, because records live in a
|
||||
wallet-signed manifest rather than in a chain transaction.
|
||||
|
||||
You can also set them from the registrar CLI if you run one.
|
||||
|
||||
---
|
||||
|
||||
## Which one to use
|
||||
|
||||
**`ip`, if you can.** It is one fewer system between a reader and your site,
|
||||
and it does not depend on anyone's DNS continuing to answer — which is most of
|
||||
the point of having a BCNR name in the first place.
|
||||
|
||||
**`p`, if your address moves**, or if you are behind a tunnel, or if you want
|
||||
a CDN in front. The tradeoff is real: a `p` record points at a name in somebody
|
||||
else's namespace, and whoever controls that namespace can redirect your
|
||||
visitors. If you are using BCNR to get out from under ICANN, pointing your name
|
||||
back into it is worth doing deliberately rather than by default.
|
||||
|
||||
---
|
||||
|
||||
## Telling WordPress its own address
|
||||
|
||||
WordPress builds absolute URLs from a value it stores, and it has to match what
|
||||
visitors actually type or you get redirect loops.
|
||||
|
||||
In Docker, set `SITE_URL` in `docker/.env` and restart:
|
||||
|
||||
```
|
||||
SITE_URL=https://example.bch
|
||||
```
|
||||
|
||||
Elsewhere, set it in `wp-config.php`:
|
||||
|
||||
```php
|
||||
define( 'WP_HOME', 'https://example.bch' );
|
||||
define( 'WP_SITEURL', 'https://example.bch' );
|
||||
```
|
||||
|
||||
Do this once your name resolves, not before — WordPress will happily guess from
|
||||
the request until then, which is what you want during setup.
|
||||
|
||||
---
|
||||
|
||||
## The static mirror is separate
|
||||
|
||||
Setting `ip` or `p` points readers at your **live WordPress**. It has nothing
|
||||
to do with the static export.
|
||||
|
||||
The export goes to your name's storage on Sia, at `bns/<name>/`, and resolvers
|
||||
reach it through the name's `s3` record. That means a name can have both:
|
||||
|
||||
- an `ip` record, so visitors get the live site with its comments and search
|
||||
- a populated bucket, so that when the server is down, or slow, or gone, the
|
||||
last published copy still answers
|
||||
|
||||
Which one a given resolver prefers is its business, and different resolvers
|
||||
choose differently. The useful way to think about it: the live site is where
|
||||
your site is, and the static mirror is what survives if it stops being there.
|
||||
|
||||
If you want *only* the static copy served — no PHP exposed to the public at all
|
||||
— leave `ip` and `p` unset and let the `s3` record do the work. Your WordPress
|
||||
then runs somewhere private, as an authoring tool, and publishes outward. For
|
||||
a site that is mostly reading rather than interaction, this is the strongest
|
||||
arrangement available, and it is what `silentmode.st` itself does.
|
||||
|
||||
---
|
||||
|
||||
## Checking it
|
||||
|
||||
```bash
|
||||
curl -s https://navigate.st/api/dns/example.bch | python3 -m json.tool
|
||||
```
|
||||
|
||||
shows the verified records a resolver sees, and
|
||||
|
||||
```bash
|
||||
curl -s https://navigate.st/api/site/example.bch | python3 -m json.tool
|
||||
```
|
||||
|
||||
lists what is actually in the name's bucket — which is the quickest way to
|
||||
confirm an export arrived.
|
||||
|
||||
The **Sirius Press → Publishing** screen shows the same thing from inside
|
||||
wp-admin, along with whatever the queue is still working through.
|
||||
169
docs/install.md
169
docs/install.md
|
|
@ -1,169 +0,0 @@
|
|||
# Installing Sirius Press
|
||||
|
||||
Three ways in, depending on what you have. All of them end at the same place:
|
||||
a WordPress site where accounts are wallets and every page is mirrored to your
|
||||
BCNR name.
|
||||
|
||||
---
|
||||
|
||||
## A VPS, with Docker (recommended)
|
||||
|
||||
On a fresh Ubuntu 22.04 or 24.04 box:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://silentmode.st/sirius-press/install.sh | sudo bash
|
||||
```
|
||||
|
||||
It installs Docker from the distribution's own packages if it is missing,
|
||||
clones the repository to `/opt/sirius-press`, generates database passwords and
|
||||
a `SIRIUS_PRESS_KEY`, builds the image and starts the stack. Then it waits
|
||||
until the site answers and prints the setup URL.
|
||||
|
||||
About five minutes on a 1 GB VPS, nearly all of it compiling PHP extensions.
|
||||
|
||||
If you would rather read the script before running it as root — a reasonable
|
||||
instinct — it is [`install.sh`](../install.sh), and it is about a hundred lines.
|
||||
|
||||
### By hand, if you prefer
|
||||
|
||||
```bash
|
||||
git clone https://code.silentmode.st/silentmode/sirius-press.git /opt/sirius-press
|
||||
cd /opt/sirius-press/docker
|
||||
cp .env.example .env
|
||||
# edit .env: set DB_PASSWORD and DB_ROOT_PASSWORD at minimum
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Finishing setup
|
||||
|
||||
Open `http://your-server/wp-admin/install.php`.
|
||||
|
||||
The screen is the usual WordPress one with a single change: where it asked for
|
||||
your email, it asks for your **wallet address**. You can paste one or leave it
|
||||
blank and attach a wallet afterwards from your profile — which is actually the
|
||||
better order, because the profile screen makes you *prove* the address by
|
||||
signing, and the installer cannot.
|
||||
|
||||
Set a password too. It is the only way into the site until a wallet is
|
||||
attached, and you should turn it off afterwards (**Sirius Press → Sign-in**).
|
||||
|
||||
---
|
||||
|
||||
## Shared hosting
|
||||
|
||||
Download `sirius-press-<version>.zip` from
|
||||
[the releases page](https://code.silentmode.st/silentmode/sirius-press/releases),
|
||||
check it against the published `.sha256`, and upload its contents the way you
|
||||
would upload WordPress — because that is what it is, patched, with the plugins
|
||||
already in place.
|
||||
|
||||
Then create a database and run through `wp-admin/install.php`.
|
||||
|
||||
Before you start, confirm the host gives you **GMP or BCMath**. Without one of
|
||||
them the fork cannot verify a signature and refuses to activate. Most hosts
|
||||
have BCMath; ask for GMP if you can get it, since it is roughly twenty times
|
||||
faster and signature verification sits on the login path.
|
||||
|
||||
### Building the zip yourself
|
||||
|
||||
```bash
|
||||
tools/build.sh --zip
|
||||
```
|
||||
|
||||
Copies the vendored WordPress out of `wordpress/`, adds the plugins, and
|
||||
writes `dist/sirius-press-<version>.zip` alongside its checksum. Nothing is
|
||||
downloaded — core is in the repository.
|
||||
|
||||
---
|
||||
|
||||
## An existing WordPress site
|
||||
|
||||
Sirius Press can be added to a site you already run, without the core patch.
|
||||
You lose only the setup-wizard change, which you have already been through.
|
||||
|
||||
1. Copy the four directories in `plugins/` into `wp-content/plugins/`.
|
||||
2. Copy `mu-plugins/sirius-press-bootstrap.php` into `wp-content/mu-plugins/`
|
||||
(create the directory if it does not exist).
|
||||
3. Activate **Sirius Press Core** first, then Auth, Publishing and
|
||||
Compatibility.
|
||||
4. Attach a wallet to your own account at **Users → Profile**.
|
||||
5. Turn passwords off at **Sirius Press → Sign-in** — but read the list of
|
||||
accounts without wallets on that screen first. Every one of them is somebody
|
||||
who will be locked out.
|
||||
|
||||
Existing accounts keep working with passwords until each attaches a wallet.
|
||||
Nothing is migrated, deleted or rewritten.
|
||||
|
||||
---
|
||||
|
||||
## Pointing your name at it
|
||||
|
||||
See **[bcnr-records.md](bcnr-records.md)**. The short version: set your name's
|
||||
`ip` record to the server's address, or its `p` record to a hostname that
|
||||
resolves there.
|
||||
|
||||
---
|
||||
|
||||
## After it is up
|
||||
|
||||
Three things, in this order:
|
||||
|
||||
1. **Sirius Press → Settings**: set the BCNR name this site publishes under,
|
||||
and the network (mainnet or chipnet).
|
||||
2. **Users → Profile**: attach your wallet by signing. Check the address that
|
||||
appears is the one you expect.
|
||||
3. **Sirius Press → Sign-in**: turn off password sign-in once every account
|
||||
that needs access has a wallet.
|
||||
|
||||
Then decide how exports get signed — see [publishing.md](publishing.md). The
|
||||
default is manual, meaning nothing sensitive is stored on the server and you
|
||||
sign from your browser when you publish.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
cd /opt/sirius-press
|
||||
git pull
|
||||
cd docker && docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Core and the fork's plugins both come from the image, so this upgrades both.
|
||||
Your uploads, your database, your other plugins and themes, and
|
||||
`wp-config.php` live in volumes and are untouched.
|
||||
|
||||
This is why the document root is refilled from the image on every start rather
|
||||
than being a volume of its own — the usual WordPress-in-Docker layout pins core
|
||||
at whatever version first created the volume, and turns every security release
|
||||
into a manual migration.
|
||||
|
||||
---
|
||||
|
||||
## When it does not work
|
||||
|
||||
**The site does not answer.**
|
||||
`cd /opt/sirius-press/docker && docker compose logs -f app`. The most common
|
||||
cause on a first run is the database still initialising; the entrypoint waits
|
||||
sixty seconds and then starts anyway, so give it another minute.
|
||||
|
||||
**"Sirius Press needs either the GMP or the BCMath PHP extension."**
|
||||
The host has neither. Nothing in the fork works without one. On Debian or
|
||||
Ubuntu outside Docker: `apt install php-gmp && systemctl restart php-fpm`.
|
||||
|
||||
**Signing in does nothing, and no error appears.**
|
||||
JavaScript did not load, or the page is being served over plain HTTP where
|
||||
WebCrypto is unavailable. Open "Paste a signature instead" and use any wallet —
|
||||
that path needs no JavaScript at all.
|
||||
|
||||
**"That signature does not match the text this site asked you to sign."**
|
||||
Usually the challenge was copied without its trailing blank line, or with a
|
||||
line wrapped. Copy it straight out of the box with the copy button rather than
|
||||
selecting it by hand.
|
||||
|
||||
**Exports fail with "the stored publishing key is not the address that
|
||||
currently owns this name."**
|
||||
The phrase on the server derives a different address than the one holding the
|
||||
name's NFT. **Sirius Press → Settings** shows both side by side. The usual
|
||||
cause is a wallet created on a different derivation path — see
|
||||
[publishing.md](publishing.md#derivation-paths).
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# Plugin compatibility
|
||||
|
||||
The short answer: plugins work. Sirius Press changes how people sign in, not
|
||||
how WordPress works. Roles, capabilities, nonces, the options API, the REST
|
||||
API, the hook system and `wp_mail()` are all untouched, so a plugin that does
|
||||
not ask "what is this user's email address?" cannot tell the difference.
|
||||
|
||||
The rest of this page is the long answer, for the plugins that do ask.
|
||||
|
||||
---
|
||||
|
||||
## The mechanism
|
||||
|
||||
Every account has a `user_email`. It is a placeholder under a domain ending in
|
||||
`.invalid` — a top-level domain [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606)
|
||||
reserves precisely so that it can never resolve anywhere, ever.
|
||||
|
||||
This matters more than it sounds. `$user->user_email` is read in thousands of
|
||||
places across the ecosystem, usually without a null check. Leaving it empty
|
||||
would produce warnings, blank "From" headers and outright fatals in other
|
||||
people's code. Filling it with something permanently undeliverable means every
|
||||
one of those reads returns a string and behaves normally.
|
||||
|
||||
Mail addressed to one of those placeholders never reaches a mail transport. It
|
||||
is caught by `pre_wp_mail` and delivered to the **Sirius Press inbox**, where
|
||||
the recipient reads it while signed in. That is where password-reset mails,
|
||||
comment-moderation notices, update nags and "your plugin needs attention"
|
||||
messages end up.
|
||||
|
||||
Mail addressed to a real domain is passed straight through. If you configured
|
||||
SMTP, it sends. If you did not, it fails exactly as stock WordPress fails.
|
||||
|
||||
`is_email()` is left completely alone, and it is worth saying why, because
|
||||
the obvious guess is wrong. WordPress validates an address's *syntax*, not
|
||||
whether its domain could ever exist — so `.invalid` addresses already pass
|
||||
`is_email()` unchanged. No shim is needed to make the placeholders acceptable
|
||||
to plugins that validate, and none is shipped. (Verified against WordPress
|
||||
7.1.1; an earlier draft of this fork carried a filter based on the wrong
|
||||
assumption, and it never fired.)
|
||||
|
||||
The flip side is that `is_email()` will also accept `someone@example.invalid`
|
||||
typed into a contact form by a visitor. That is stock WordPress behaviour, not
|
||||
something this fork introduced, and it is the contact form's business to care
|
||||
about.
|
||||
|
||||
---
|
||||
|
||||
## The top ten
|
||||
|
||||
Three of these were installed and activated on a Sirius Press 0.1.0 instance
|
||||
running WordPress 7.1.1, with all four fork plugins active, and the whole
|
||||
wallet sign-in suite re-run with them loaded. Those rows say **tested**. The
|
||||
rest are reasoned from each plugin's setup and activation path and say
|
||||
**expected** — treat them as claims awaiting a test, and report anything that
|
||||
behaves differently.
|
||||
|
||||
| Plugin | Status | Notes |
|
||||
|---|---|---|
|
||||
| **Yoast SEO** 28.6 | tested — activates cleanly | No errors on any admin screen with it loaded. Reads `admin_email` for schema output and gets the placeholder. Its XML sitemaps export to your name along with everything else. |
|
||||
| **Contact Form 7** 6.1.7 | tested — activates cleanly | Forms build and save. The default recipient is the placeholder, so submissions land in the inbox — fine for a small site, not what you want for a real contact form. Set a real address and configure SMTP. |
|
||||
| **WooCommerce** 11.1.1 | tested — activates cleanly | Store and admin work. Order emails to customers use the address the customer typed, so they send once SMTP is configured. One behaviour worth knowing: WooCommerce redirects subscriber-role accounts away from wp-admin, so a newly registered reader lands on the shop rather than their profile. That is WooCommerce's own setting, not this fork's. |
|
||||
| **Elementor** | expected to work | No email dependency. Pages built with it export normally. |
|
||||
| **Wordfence** | expected to work | Alert emails go to the inbox unless you set a real address. Its login-security features overlap with wallet auth; two-factor on top of a signature is redundant, and its "email me a code" option cannot work. |
|
||||
| **WP Super Cache / W3 Total Cache** | expected to work | Compatible, but consider whether you need them: the static export already serves cached HTML from the name, which is the harder-working cache. |
|
||||
| **Akismet** | expected to work | Needs an API key, obtained on akismet.com with a real address of yours. Nothing to do with site accounts. |
|
||||
| **Jetpack** | expected to work partly | Connection requires a WordPress.com account. The modules built around subscriber email lists cannot do anything useful here. Not recommended. |
|
||||
| **UpdraftPlus** | expected to work | Backups work. Report emails go to the inbox. Back up `wp-config.php` too, or you lose `SIRIUS_PRESS_KEY` and with it the stored publishing phrase. |
|
||||
| **Advanced Custom Fields** | expected to work | No email dependency at all. |
|
||||
|
||||
"Activates cleanly" means the plugin activated without a fatal or a WP_Error,
|
||||
and every Sirius Press admin screen plus Users, Profile and Plugins rendered
|
||||
with no PHP diagnostic while it was loaded.
|
||||
|
||||
---
|
||||
|
||||
## What genuinely cannot work
|
||||
|
||||
Not because of a shim we did not write, but because the feature is the mailbox:
|
||||
|
||||
- **Email-based two-factor.** There is no mailbox to send a code to.
|
||||
- **Newsletter plugins managing your site's own accounts.** A newsletter to a
|
||||
list of real addresses people typed into a form works fine. A newsletter to
|
||||
"all subscribers" does not, because subscribers have wallets, not mailboxes.
|
||||
- **"Email me when someone comments" for account holders.** Goes to the inbox
|
||||
instead, which the person sees next time they sign in.
|
||||
- **Password reset by email.** Deliberately removed. See
|
||||
[accounts.md](accounts.md).
|
||||
|
||||
---
|
||||
|
||||
## Making a plugin behave
|
||||
|
||||
If a plugin refuses to finish its setup because of a missing address, the usual
|
||||
fix is one filter:
|
||||
|
||||
```php
|
||||
add_filter( 'pre_option_their_email_setting', function ( $value ) {
|
||||
return $value ?: 'admin@' . SP_Settings::stub_email_domain();
|
||||
} );
|
||||
```
|
||||
|
||||
`sirius-press-compat` is a short file of exactly these. If you write one for a
|
||||
plugin not listed here, it is worth sending — the whole file is under a hundred
|
||||
lines of real code and it grows one plugin at a time.
|
||||
|
||||
If a plugin sends mail to a real address and it does not arrive, that is an
|
||||
SMTP problem rather than a Sirius Press one: see [smtp.md](smtp.md).
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
# Publishing to your name
|
||||
|
||||
Every page Sirius Press serves can also exist as a static file in your name's
|
||||
storage, signed by the key that owns the name. This is what makes the site
|
||||
outlive its server.
|
||||
|
||||
---
|
||||
|
||||
## What gets published, and when
|
||||
|
||||
Publishing or editing a post queues the pages it affects:
|
||||
|
||||
- the post itself
|
||||
- the home page
|
||||
- the blog page, if you have one
|
||||
- every category, tag or other public archive it belongs to
|
||||
- any asset those pages reference — stylesheets, scripts, images — the first
|
||||
time it is seen
|
||||
|
||||
Unpublishing or deleting a post removes its file and re-queues the pages that
|
||||
linked to it. A static mirror that keeps serving a post the author deleted is
|
||||
worse than no mirror at all, because the author believes it is gone.
|
||||
|
||||
**Export everything** on the Publishing screen queues the whole site: every
|
||||
published post and page, every public archive, the home page.
|
||||
|
||||
Password-protected posts are never exported. To the exporter they render as a
|
||||
password form, and publishing that form to permanent public storage would be a
|
||||
strange thing to do on purpose.
|
||||
|
||||
---
|
||||
|
||||
## Who signs, and where the key lives
|
||||
|
||||
The BNS gateway only accepts a write signed by the name's **current on-chain
|
||||
owner**. That is the whole security model, and it has a consequence there is no
|
||||
way around: whatever signs your uploads holds the key that owns your name and
|
||||
whatever funds sit with it. There is no weaker credential to give a server.
|
||||
|
||||
So the question is not *how much* of your key the server gets. It is whether it
|
||||
gets it at all.
|
||||
|
||||
### Manual — the default
|
||||
|
||||
Nothing is stored. Exports queue up; when you open **Sirius Press → Publishing**
|
||||
you type your recovery phrase, and the page signs each file and uploads it
|
||||
straight from your browser to the gateway. The phrase is cleared from the form
|
||||
as soon as it has been used and the derived key is wiped when the run ends.
|
||||
|
||||
- Nothing sensitive on the server. A compromised host leaks your posts, which
|
||||
were public anyway.
|
||||
- A post published on a schedule sits in the queue until somebody signs in.
|
||||
|
||||
### Automatic
|
||||
|
||||
Your recovery phrase is encrypted with AES-256-GCM under a key derived from
|
||||
`SIRIUS_PRESS_KEY` in `wp-config.php`, and stored in the database. Cron drains
|
||||
the queue by itself.
|
||||
|
||||
- Scheduled posts publish while you sleep.
|
||||
- Anyone who can read **both** your database and `wp-config.php` has your name
|
||||
and your coins. That is most people who get a shell on the box.
|
||||
|
||||
The encryption protects one specific thing — a stolen database dump, a leaked
|
||||
backup — and the settings screen says exactly that rather than implying more.
|
||||
|
||||
**A reasonable middle path:** run automatic mode with a wallet that holds the
|
||||
name and nothing else. Keep your coins somewhere that has never been typed into
|
||||
a web server. This does not protect the name, but it bounds what a compromise
|
||||
costs to "the attacker can publish to my name until I move it", which is
|
||||
recoverable.
|
||||
|
||||
---
|
||||
|
||||
## Derivation paths
|
||||
|
||||
Sirius Press derives its publishing key at `m/44'/145'/0'/0/0` — BCH coin type,
|
||||
first account, first address. Same as the Sirius portal wallet, same as most
|
||||
BCH wallets.
|
||||
|
||||
If your name was bought with a wallet minted somewhere else, the path may
|
||||
differ. The symptom is unmistakable once you know it: every upload returns 403,
|
||||
and **Sirius Press → Settings** shows two different addresses side by side —
|
||||
the one your phrase derives, and the one the chain says owns the name.
|
||||
|
||||
Change the path in Settings. Operator wallets created from the Silent Mode CLI
|
||||
were derived on the BTC coin type, `m/44'/0'/0'/0/0`.
|
||||
|
||||
The settings screen checks this for you as soon as you save a phrase: it
|
||||
derives the address, asks the gateway who owns the name, and tells you whether
|
||||
they match. A mismatch caught there is a sentence of explanation. The same
|
||||
mismatch caught later is a 403 in a cron log nobody reads.
|
||||
|
||||
---
|
||||
|
||||
## The queue
|
||||
|
||||
Rows are keyed by path, not by post — several posts changing in a minute should
|
||||
not queue the home page five times.
|
||||
|
||||
Each attempt renders the page, hashes the result, and compares it to what was
|
||||
last published at that path. Identical bytes are marked done without an upload,
|
||||
which is the common case: a WordPress page re-renders byte-for-byte far more
|
||||
often than it changes.
|
||||
|
||||
Failures retry up to five times and then stop, and the first exhausted retry
|
||||
puts a message in your inbox — once an hour at most, because a misconfigured
|
||||
key fails on every row and four hundred identical messages are the same as
|
||||
none.
|
||||
|
||||
### From the command line
|
||||
|
||||
```bash
|
||||
wp sirius status # name, key, owner, queue depth
|
||||
wp sirius export --all # queue everything, then push it
|
||||
wp sirius export --limit=50 # work through part of the queue
|
||||
wp sirius export --all --dry-run # build every page, upload nothing
|
||||
```
|
||||
|
||||
`--dry-run` is the fastest way to find a page that renders badly for the
|
||||
exporter without publishing anything.
|
||||
|
||||
The first full export of an existing site is the one job genuinely better in a
|
||||
terminal: thousands of pages, a long run, and no browser tab that must stay
|
||||
open. Note that CLI publishing needs automatic mode, since there is no browser
|
||||
to sign in manual mode.
|
||||
|
||||
---
|
||||
|
||||
## When the site cannot reach itself
|
||||
|
||||
The exporter renders each page by asking the site for it over HTTP. That
|
||||
normally just works — and then you put it in a container.
|
||||
|
||||
On most Docker hosts a container cannot reach the host's own published port:
|
||||
the PHP container asking for `http://203.0.113.10:8081/` times out, even
|
||||
though that is exactly where visitors arrive. Nothing is misconfigured; there
|
||||
is simply no route back in. The symptom is every export failing with
|
||||
`cURL error 28 ... Timeout was reached`, while the site itself is plainly up.
|
||||
|
||||
The fix is a second address for the exporter to use:
|
||||
|
||||
```php
|
||||
define( 'SIRIUS_PRESS_LOOPBACK_URL', 'http://web' );
|
||||
```
|
||||
|
||||
The bundled Docker stack sets this for you — `web` is the nginx service on the
|
||||
compose network. Elsewhere, point it at whatever PHP can reach:
|
||||
`http://127.0.0.1`, `http://localhost:8080`, a socket-backed vhost.
|
||||
|
||||
The request still carries the site's real `Host` header, so WordPress renders
|
||||
exactly the page a visitor would get and does not redirect the exporter to a
|
||||
canonical URL it cannot follow. The internal address never appears in the
|
||||
output.
|
||||
|
||||
**A related question that trips people up:** what should `WP_HOME` be when the
|
||||
site's public address is a BCNR name? Not the name. Set it to whatever the
|
||||
server is actually reachable at — an IP and port is fine. The exported pages
|
||||
use document-relative links, so they work under the name regardless of what
|
||||
`WP_HOME` said when they were rendered.
|
||||
|
||||
---
|
||||
|
||||
## What the exported pages look like
|
||||
|
||||
Pages are fetched over a loopback HTTP request, not rendered in-process. That
|
||||
costs a round trip and is worth it: a theme's output depends on the whole
|
||||
request lifecycle, and reconstructing it by calling `get_the_content()` gives
|
||||
you something that resembles the page rather than the page.
|
||||
|
||||
Links are rewritten to be **document-relative** — `../../about/index.html`
|
||||
rather than `/about/index.html` or an absolute URL. Absolute links would drag
|
||||
readers back to the origin server the export exists to make optional.
|
||||
Root-relative ones break when the same bucket is browsed under
|
||||
`/bns/<name>/` on the public gateway. Document-relative works in both places.
|
||||
|
||||
Asset URLs lose their `?ver=` query, because the bucket stores one copy of each
|
||||
file and the query would only ever produce a 404.
|
||||
|
||||
### Things that cannot work in a static copy
|
||||
|
||||
Anything needing PHP at read time: the search form, the comment form, a cart, a
|
||||
"related posts" widget that queries at render. They will appear and do nothing.
|
||||
|
||||
If your theme has such a thing, hide it during export:
|
||||
|
||||
```php
|
||||
add_action( 'sirius_press_rendering_export', function () {
|
||||
add_filter( 'get_search_form', '__return_empty_string' );
|
||||
remove_action( 'wp_footer', 'my_theme_live_widget' );
|
||||
} );
|
||||
```
|
||||
|
||||
That action fires on the live request, while the exporter is fetching — which
|
||||
is the right moment, because removing something afterwards means also unpicking
|
||||
the styles and spacing it left behind.
|
||||
|
||||
---
|
||||
|
||||
## Hooks
|
||||
|
||||
```php
|
||||
// Skip a post entirely.
|
||||
add_filter( 'sirius_press_should_export_post', function ( $export, $post ) {
|
||||
return 'private-type' === $post->post_type ? false : $export;
|
||||
}, 10, 2 );
|
||||
|
||||
// Add to, or trim, what a full export covers.
|
||||
add_filter( 'sirius_press_export_targets', function ( $targets ) {
|
||||
$targets['feed/index.xml'] = array( 'url' => home_url( '/feed/' ), 'kind' => 'feed' );
|
||||
return $targets;
|
||||
} );
|
||||
|
||||
// Change which paths a post edit invalidates.
|
||||
add_filter( 'sirius_press_affected_paths', function ( $paths, $post ) { … }, 10, 2 );
|
||||
|
||||
// React to a successful upload.
|
||||
add_action( 'sirius_press_exported', function ( $path, $bytes ) { … }, 10, 2 );
|
||||
```
|
||||
98
docs/smtp.md
98
docs/smtp.md
|
|
@ -1,98 +0,0 @@
|
|||
# Sending real email
|
||||
|
||||
Sirius Press does not disable `wp_mail()`. Contact forms, order receipts,
|
||||
newsletters and every other reason a site has to send a human an email still
|
||||
work — you just have to configure a way to send them, exactly as you would on
|
||||
any WordPress site that is not running on a shared host with a mail server
|
||||
already attached.
|
||||
|
||||
What the fork removes is email as an *identity*. What it leaves alone is email
|
||||
as a *feature*. Those are different things, and conflating them is how people
|
||||
end up believing the fork is more restrictive than it is.
|
||||
|
||||
---
|
||||
|
||||
## What happens without SMTP
|
||||
|
||||
Mail to your site's own `.invalid` placeholder addresses never reaches a mail
|
||||
transport at all. It goes to the Sirius Press inbox — **Inbox** in the admin
|
||||
menu — where the account holder reads it while signed in. Password-reset
|
||||
attempts, comment-moderation notices and update nags all land there.
|
||||
|
||||
Mail to a real address is handed to PHP's `mail()`. In the Docker stack there
|
||||
is no mail transport in the container, so it fails. That is the same failure
|
||||
stock WordPress has in the same situation, reported the same way.
|
||||
|
||||
---
|
||||
|
||||
## Setting up SMTP
|
||||
|
||||
Any of the usual plugins work, because `wp_mail()` is untouched: WP Mail SMTP,
|
||||
Post SMTP, FluentSMTP. Install one, point it at a provider, done.
|
||||
|
||||
Without a plugin, the `phpmailer_init` hook is enough:
|
||||
|
||||
```php
|
||||
// wp-content/mu-plugins/smtp.php
|
||||
add_action( 'phpmailer_init', function ( $mailer ) {
|
||||
$mailer->isSMTP();
|
||||
$mailer->Host = 'smtp.example.net';
|
||||
$mailer->Port = 587;
|
||||
$mailer->SMTPAuth = true;
|
||||
$mailer->SMTPSecure = 'tls';
|
||||
$mailer->Username = 'postmaster@example.net';
|
||||
// Keep the secret in wp-config.php, not here, so it stays out of backups
|
||||
// that include wp-content.
|
||||
$mailer->Password = defined( 'SMTP_PASSWORD' ) ? SMTP_PASSWORD : '';
|
||||
$mailer->From = 'hello@example.net';
|
||||
$mailer->FromName = get_bloginfo( 'name' );
|
||||
} );
|
||||
```
|
||||
|
||||
A must-use plugin rather than a theme function: it should survive a theme
|
||||
change, and it should not be editable from the plugin editor in wp-admin.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a From address
|
||||
|
||||
The one thing that will not work is sending *from* a `.invalid` address. Most
|
||||
providers reject it outright, and the ones that do not will have every message
|
||||
binned for failing SPF.
|
||||
|
||||
So a site that sends mail needs one real domain, for outbound only. This is
|
||||
slightly awkward for a project whose point is not depending on the DNS system,
|
||||
and worth being honest about: if your site sends email, you have a dependency
|
||||
on somebody's DNS, and no amount of BCNR changes that. Email is a DNS protocol.
|
||||
|
||||
Two ways to live with it:
|
||||
|
||||
- **Accept it, narrowly.** Register a domain used for nothing but outbound mail.
|
||||
It has no bearing on how readers reach your site, and if it disappears you
|
||||
lose contact-form delivery rather than your site.
|
||||
- **Avoid sending.** Use the inbox for anything addressed to an account holder,
|
||||
and for contact forms use a plugin that stores submissions in the database
|
||||
and shows them in wp-admin instead of mailing them. For most small sites this
|
||||
is better anyway: you read submissions where you already are, and there is no
|
||||
provider to pay or be cut off by.
|
||||
|
||||
The second is what a Sirius Press site does by default if you never configure
|
||||
anything, which is deliberate.
|
||||
|
||||
---
|
||||
|
||||
## Checking it works
|
||||
|
||||
Send yourself something:
|
||||
|
||||
```bash
|
||||
wp eval 'var_dump( wp_mail( "you@example.net", "Sirius Press test", "It sends." ) );'
|
||||
```
|
||||
|
||||
`true` means PHPMailer accepted it, not that it arrived. If it returns `true`
|
||||
and nothing turns up, the problem is between your provider and the recipient —
|
||||
SPF, DKIM or a spam filter — and is no longer anything to do with this fork.
|
||||
|
||||
To confirm the placeholder interception is working, mail one of your own
|
||||
accounts. It should return `true`, nothing should leave the server, and the
|
||||
message should be sitting in that account's inbox.
|
||||
183
docs/testing.md
183
docs/testing.md
|
|
@ -1,183 +0,0 @@
|
|||
# Testing
|
||||
|
||||
Four suites, in rough order of how fast they run and how much they prove.
|
||||
|
||||
```bash
|
||||
tests/run.sh # unit + interop, ~1 second, no services
|
||||
node tests/live.mjs # end to end against a running instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The fast suites
|
||||
|
||||
```bash
|
||||
tests/run.sh
|
||||
```
|
||||
|
||||
Needs PHP 7.4+ with GMP or BCMath, and Node 18+. About a second, no database
|
||||
and no server.
|
||||
|
||||
| Suite | Checks | What it pins |
|
||||
|---|---|---|
|
||||
| `test-crypto.php` | 23 | secp256k1 against published vectors, RFC 6979 determinism, low-S, recovery round trips |
|
||||
| `test-identity.php` | 44 | CashAddress encode/decode/normalise, BIP-39/32 derivation, both signing envelopes, canonical JSON |
|
||||
| `test-export.php` | 49 | URL-to-path mapping, document-relative rewriting, gateway path rules |
|
||||
| `interop.mjs` | 22 | the browser wallet against the PHP one, byte for byte |
|
||||
|
||||
**`interop.mjs` is the one that matters most.** Sirius Press implements the
|
||||
same cryptography twice — PHP verifies, JavaScript signs — and every vector in
|
||||
that file came from libauth, the library the Sirius portal wallet and the BNS
|
||||
gateway both use. If the two implementations disagree by a byte, nobody can
|
||||
log in, and the error looks like a rejected password rather than a hash
|
||||
mismatch. This suite makes that a test failure instead of a bad evening.
|
||||
|
||||
---
|
||||
|
||||
## A throwaway instance
|
||||
|
||||
You do not need Docker, MySQL or a VPS to run a real Sirius Press. PHP's
|
||||
built-in server plus the official SQLite drop-in is enough, and it starts in
|
||||
seconds.
|
||||
|
||||
```bash
|
||||
# 1. a complete tree (copied out of the vendored wordpress/, plugins added)
|
||||
tools/build.sh
|
||||
cp -R dist/sirius-press /tmp/wpsite
|
||||
|
||||
# 2. SQLite instead of MySQL
|
||||
curl -fsSL -o /tmp/sqlite.zip \
|
||||
https://downloads.wordpress.org/plugin/sqlite-database-integration.zip
|
||||
unzip -q /tmp/sqlite.zip -d /tmp/wpsite/wp-content/plugins/
|
||||
cp /tmp/wpsite/wp-content/plugins/sqlite-database-integration/db.copy \
|
||||
/tmp/wpsite/wp-content/db.php
|
||||
mkdir -p /tmp/wpsite/wp-content/database
|
||||
|
||||
# 3. a config (any salts will do for a throwaway)
|
||||
cp /tmp/wpsite/wp-config-sample.php /tmp/wpsite/wp-config.php
|
||||
# edit it: DB_* values are ignored by the SQLite drop-in, but add
|
||||
# define( 'WP_HOME', 'http://127.0.0.1:8760' );
|
||||
# define( 'WP_SITEURL', 'http://127.0.0.1:8760' );
|
||||
|
||||
# 4. run it
|
||||
php -S 127.0.0.1:8760 -t /tmp/wpsite
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8760/wp-admin/install.php`. The setup screen asks
|
||||
for a wallet address instead of an email one — that alone confirms the core
|
||||
patch applied.
|
||||
|
||||
PHP needs `pdo_sqlite`, `sqlite3`, `gd`, `mbstring` and one of GMP or BCMath.
|
||||
|
||||
**Set pretty permalinks before testing publishing.** With plain permalinks
|
||||
every post's URL is `/?p=N`, whose path is `/`, so the whole site maps to
|
||||
`index.html` and each page overwrites the last. The Publishing screen refuses
|
||||
to let this pass quietly, but a script driving the queue directly will not see
|
||||
the warning.
|
||||
|
||||
---
|
||||
|
||||
## End to end
|
||||
|
||||
```bash
|
||||
WALLET=/tmp/admin-wallet.json BASE=http://127.0.0.1:8760 node tests/live.mjs
|
||||
```
|
||||
|
||||
`WALLET` is a JSON file holding an administrator's phrase and address:
|
||||
|
||||
```json
|
||||
{ "phrase": "twelve words …", "address": "bchtest:qq…" }
|
||||
```
|
||||
|
||||
40 checks covering sign-in, replay refusal, a stranger's signature, a
|
||||
signature over altered text, purpose separation, registration, the recovery
|
||||
page and the REST endpoints — against a real WordPress, with real sessions.
|
||||
|
||||
It creates one account per run, so point it at a throwaway.
|
||||
|
||||
### Running it with the ecosystem loaded
|
||||
|
||||
The suite is worth far more with third-party plugins active, because it then
|
||||
also proves the fork's admin screens survive them:
|
||||
|
||||
```bash
|
||||
for p in wordpress-seo contact-form-7 woocommerce; do
|
||||
curl -fsSL -o "/tmp/$p.zip" "https://downloads.wordpress.org/plugin/$p.zip"
|
||||
unzip -q -o "/tmp/$p.zip" -d /tmp/wpsite/wp-content/plugins/
|
||||
done
|
||||
# activate them, then re-run tests/live.mjs
|
||||
```
|
||||
|
||||
If you hit a 429, the rate limiter is doing its job — it caps signature
|
||||
verification per client. Clear it between runs:
|
||||
|
||||
```sql
|
||||
DELETE FROM wp_options WHERE option_name LIKE '%_transient_%sirius_rl_%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Publishing, without a registered name
|
||||
|
||||
The upload path cannot be tested end to end without a BCNR name and the key
|
||||
that owns it. What *can* be tested is the part that actually breaks — the
|
||||
bytes being signed — against the real verifier:
|
||||
|
||||
```bash
|
||||
node tests/mock-gateway.mjs --owner bchtest:qq… --port 8799
|
||||
```
|
||||
|
||||
This implements `PUT /api/site/<name>/<path>` with the signature check
|
||||
transcribed from `Argus/src/gateway/public-gateway.mjs`: same envelope, same
|
||||
digest, same recovery, same comparison against the owner. A request it accepts
|
||||
is one the real gateway accepts.
|
||||
|
||||
Point the site at it — **Sirius Press → Settings**, gateway
|
||||
`http://127.0.0.1:8799` — store the matching phrase, publish a post, and watch
|
||||
the queue drain. Signing with any other key gets the same 403 the real gateway
|
||||
returns.
|
||||
|
||||
It needs `@bitauth/libauth` resolvable from the script, which is the point: it
|
||||
verifies with the same library the gateway does, not with ours.
|
||||
|
||||
---
|
||||
|
||||
## In a browser
|
||||
|
||||
Everything above drives the site over HTTP, which is fast and repeatable and
|
||||
misses a whole class of bug. Two got through it and were only found by opening
|
||||
`wp-login.php` in a browser and clicking the button:
|
||||
|
||||
- WordPress marks its username and password inputs `required`. The wallet path
|
||||
leaves both empty on purpose, so `requestSubmit()` failed constraint
|
||||
validation and refused to send a perfectly good signature — with a bubble
|
||||
pointing at a field the visitor is not meant to fill in.
|
||||
- The `hidden` attribute does not survive WordPress's own stylesheet.
|
||||
`.wp-core-ui .button { display: inline-block }` outranks the UA rule that
|
||||
makes `hidden` work, so buttons this plugin ships hidden were on screen
|
||||
anyway, inverting the progressive-enhancement design.
|
||||
|
||||
So: **after changing anything in `plugins/sirius-press-auth/assets/`, open the
|
||||
login page and click the button.** The HTTP suites will not tell you.
|
||||
|
||||
Serve the throwaway instance and point a browser at
|
||||
`http://127.0.0.1:8760/wp-login.php`. `127.0.0.1` counts as a secure context,
|
||||
so WebCrypto works over plain HTTP there — on any other host the in-page
|
||||
wallet needs real HTTPS, and the script says so and falls back to the paste
|
||||
box.
|
||||
|
||||
Worth checking by hand, in this order: the sign-in button appears, the
|
||||
browser-wallet button does *not* (unless you are in Theseus), typing the
|
||||
phrase and clicking signs and lands you on the dashboard, and the challenge
|
||||
text in the box matches what your wallet shows you.
|
||||
|
||||
---
|
||||
|
||||
## What is not covered
|
||||
|
||||
- **A real gateway upload.** The signing is verified against the transcribed
|
||||
verifier above, and against the real gateway's answer about who owns a name,
|
||||
but no byte has yet been written to `navigate.st` under a registered name.
|
||||
- **Manual-mode publishing in a browser.** The server-signing path is
|
||||
exercised end to end; the browser-signs-and-uploads path in
|
||||
`assets/export.js` has not been clicked through.
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
# Keeping up with WordPress
|
||||
|
||||
WordPress ships security releases, and a fork that cannot take them quickly is
|
||||
a liability rather than a project. This page is how Sirius Press takes them.
|
||||
|
||||
---
|
||||
|
||||
## The arrangement
|
||||
|
||||
WordPress lives in this repository, at `wordpress/`, as a **git subtree**. The
|
||||
fork's own change sits on top of it as an ordinary commit.
|
||||
|
||||
Three pieces make that work:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `wordpress/` | The vendored tree, patched. This is what gets built and shipped. |
|
||||
| `sirius-press/wordpress-upstream` | A branch holding **pristine** upstream releases, one commit each, never edited. The other side of the merge. |
|
||||
| `patches/` | A readable record of what the fork changes in core. Generated, not maintained. |
|
||||
|
||||
The entire core diff is **one file, 75 lines**: `wp-admin/install.php`, the
|
||||
setup wizard. Everything else Sirius Press does is plugins and hooks.
|
||||
|
||||
### Why a subtree and not a patch series
|
||||
|
||||
Because a three-way merge understands something a patch does not. When
|
||||
upstream edits lines near the fork's change, `git subtree merge` merges them
|
||||
and moves on; `patch` either applies with fuzz and hopes, or fails and hands
|
||||
you the job of re-deriving the change by hand. A conflict from the merge is
|
||||
resolved once, in the file, and stays resolved — the next release merges
|
||||
against the resolution.
|
||||
|
||||
The cost is repository size: WordPress is about 149 MB and 3,800 files. That
|
||||
is the price of a fork that can take a security release in a minute, and it is
|
||||
the right trade for a project whose whole argument is that it should outlive
|
||||
its maintainers' attention.
|
||||
|
||||
---
|
||||
|
||||
## Taking a new release
|
||||
|
||||
```bash
|
||||
tools/update-wordpress.sh 7.1.2
|
||||
```
|
||||
|
||||
It downloads the release, checks it against the SHA-1 wordpress.org publishes,
|
||||
imports the pristine tree onto `sirius-press/wordpress-upstream`, and merges
|
||||
that branch into `wordpress/`. Then it updates `tools/wordpress.lock` and
|
||||
regenerates `patches/`.
|
||||
|
||||
Two outcomes.
|
||||
|
||||
**Clean.** The merge went through. Run the tests, build, commit. Usually a
|
||||
minute of work.
|
||||
|
||||
**Conflicted.** Upstream changed the same lines the fork changes — in
|
||||
practice, `wp-admin/install.php`. Git leaves the conflict in the file:
|
||||
|
||||
```bash
|
||||
git status
|
||||
$EDITOR wordpress/wp-admin/install.php
|
||||
git add wordpress/wp-admin/install.php
|
||||
git commit
|
||||
tools/refresh-patches.sh
|
||||
```
|
||||
|
||||
Then set `WP_VERSION`, `WP_URL`, `WP_SHA256` and `WP_SHA1` in
|
||||
`tools/wordpress.lock` by hand, since the script stopped before it got there.
|
||||
|
||||
Either way the fork is **not broken while you work**: the merge is in your
|
||||
working tree, and until you commit it, `wordpress/` still holds the last
|
||||
version that worked.
|
||||
|
||||
---
|
||||
|
||||
## After any bump
|
||||
|
||||
```bash
|
||||
tests/run.sh
|
||||
tools/build.sh
|
||||
```
|
||||
|
||||
The suite does not test core, but it tests every assumption the fork makes
|
||||
about it. Then walk through `wp-admin/install.php` once in a browser — the
|
||||
setup wizard is the only patched file, so it is the only thing an upstream
|
||||
change can break in a way the tests would miss.
|
||||
[testing.md](testing.md) has a recipe for a throwaway instance that needs no
|
||||
database server.
|
||||
|
||||
---
|
||||
|
||||
## Keeping `patches/` honest
|
||||
|
||||
`patches/` is documentation. It answers the question anyone auditing this fork
|
||||
asks first — *what exactly did you change inside WordPress?* — in a minute,
|
||||
which `git log wordpress/` cannot, because that log is mostly upstream
|
||||
imports.
|
||||
|
||||
It is generated from the tree, never edited:
|
||||
|
||||
```bash
|
||||
tools/refresh-patches.sh # rewrite it
|
||||
tools/refresh-patches.sh --check # fail if it has drifted (for CI)
|
||||
```
|
||||
|
||||
Generated documentation stays true. A hand-maintained record of a fork's core
|
||||
diff drifts, and a stale one is worse than none, because people trust it.
|
||||
|
||||
---
|
||||
|
||||
## Changing core yourself
|
||||
|
||||
Edit the file under `wordpress/` and commit it like any other change. The next
|
||||
upstream merge will three-way it.
|
||||
|
||||
Before you do: check whether a plugin hook can carry the change instead. Every
|
||||
line added to `wordpress/` is a line that can conflict with upstream forever,
|
||||
and the reason this fork's merges are cheap is that there are only 75 of them.
|
||||
The registration form, the password-reset page and the mail pipeline were all
|
||||
replaced from plugins precisely so they would never appear in this directory.
|
||||
|
||||
---
|
||||
|
||||
## Where this repository lives
|
||||
|
||||
`code.silentmode.st/silentmode/sirius-press` is a **mirror**. Sirius Press is
|
||||
developed inside the Silent Mode monorepo and split out with
|
||||
`scripts/push-split.sh`, which force-pushes `master` each time. History on the
|
||||
forge is real — file blame points at the actual commits — but it is rewritten
|
||||
on every push, so a pull request against it will not survive.
|
||||
|
||||
What that means in practice:
|
||||
|
||||
- **Silent Mode takes upstream releases in the monorepo**, where the subtree's
|
||||
merge history is intact, and the result is mirrored here.
|
||||
- **A downstream fork** that clones from the forge and wants to bump WordPress
|
||||
itself should treat its clone as the source of truth from that point on and
|
||||
stop pulling the mirror, because the next mirror push will not merge with
|
||||
its work.
|
||||
|
||||
Patches and bug reports are welcome as issues; code is easiest to take as a
|
||||
diff rather than as a branch.
|
||||
|
||||
---
|
||||
|
||||
## If the upstream branch is missing
|
||||
|
||||
A clone made with `--single-branch` will not have
|
||||
`sirius-press/wordpress-upstream`, and `tools/update-wordpress.sh` will say so
|
||||
rather than guessing. Fetch it:
|
||||
|
||||
```bash
|
||||
git fetch origin sirius-press/wordpress-upstream:sirius-press/wordpress-upstream
|
||||
```
|
||||
|
||||
Building and running do not need it — only taking a new upstream release does.
|
||||
157
install.sh
157
install.sh
|
|
@ -1,157 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sirius Press installer.
|
||||
#
|
||||
# curl -fsSL https://silentmode.st/sirius-press/install.sh | bash
|
||||
#
|
||||
# Brings up a working instance on a fresh Ubuntu 22.04 or 24.04 VPS: Docker if
|
||||
# it is missing, the repository, generated passwords, and the stack running
|
||||
# behind nginx.
|
||||
#
|
||||
# Two things this script will not do, both on purpose:
|
||||
#
|
||||
# It does not pipe anything else into a shell. Docker is installed from the
|
||||
# distribution's own packages, not from get.docker.com — you are already
|
||||
# trusting one curl-to-bash by running this, and chaining a second is how a
|
||||
# supply chain gets long.
|
||||
#
|
||||
# It does not ask for, generate or store a wallet phrase. The site's
|
||||
# publishing key is entered later in wp-admin, by a person, over whatever
|
||||
# TLS they have arranged — not typed into a terminal session that scrolls
|
||||
# into a log.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${SIRIUS_PRESS_REPO:-https://code.silentmode.st/silentmode/sirius-press.git}"
|
||||
DIR="${SIRIUS_PRESS_DIR:-/opt/sirius-press}"
|
||||
PORT="${SIRIUS_PRESS_PORT:-80}"
|
||||
BRANCH="${SIRIUS_PRESS_BRANCH:-master}"
|
||||
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "run this as root, or with sudo."
|
||||
|
||||
# --------------------------------------------------------------- prerequisites
|
||||
|
||||
say "checking prerequisites"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
say "installing Docker from the distribution repositories"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker.io docker-compose-v2 git curl \
|
||||
|| die "could not install Docker. Install it yourself and run this again."
|
||||
systemctl enable --now docker
|
||||
else
|
||||
command -v git >/dev/null 2>&1 || apt-get install -y -qq git
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE="docker-compose"
|
||||
else
|
||||
die "Docker is installed but Compose is not. Install docker-compose-v2."
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ the code
|
||||
|
||||
if [ -d "$DIR/.git" ]; then
|
||||
say "updating $DIR"
|
||||
git -C "$DIR" fetch --quiet origin "$BRANCH"
|
||||
git -C "$DIR" checkout --quiet "$BRANCH"
|
||||
git -C "$DIR" pull --quiet --ff-only origin "$BRANCH" \
|
||||
|| warn "could not fast-forward; leaving the checkout as it is."
|
||||
else
|
||||
say "cloning into $DIR"
|
||||
mkdir -p "$(dirname "$DIR")"
|
||||
git clone --quiet --branch "$BRANCH" --depth 1 "$REPO" "$DIR" \
|
||||
|| die "could not clone $REPO"
|
||||
fi
|
||||
|
||||
cd "$DIR/docker"
|
||||
|
||||
# ---------------------------------------------------------------- the config
|
||||
|
||||
random() { head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
||||
|
||||
if [ -f .env ]; then
|
||||
say "keeping the existing .env"
|
||||
else
|
||||
say "writing .env with generated passwords"
|
||||
# No WordPress version or checksum here: core is vendored in the
|
||||
# repository and baked into the image, so there is nothing to pin at
|
||||
# deploy time.
|
||||
{
|
||||
echo "# Written by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)."
|
||||
echo "DB_NAME=wordpress"
|
||||
echo "DB_USER=wordpress"
|
||||
echo "DB_PASSWORD=$(random)"
|
||||
echo "DB_ROOT_PASSWORD=$(random)"
|
||||
echo "SITE_URL="
|
||||
echo "HTTP_PORT=$PORT"
|
||||
echo "WP_DEBUG=false"
|
||||
echo "SIRIUS_PRESS_KEY=$(random)"
|
||||
} > .env
|
||||
chmod 600 .env
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------- run it
|
||||
|
||||
say "building the image (this is the slow part — a few minutes on a small VPS)"
|
||||
$COMPOSE build --quiet 2>&1 | tail -5 || die "the image did not build."
|
||||
|
||||
say "starting"
|
||||
$COMPOSE up -d || die "the stack did not start. Try: cd $DIR/docker && $COMPOSE logs"
|
||||
|
||||
# Give the app a moment, then check that something answers. A silent failure
|
||||
# here is the difference between "installed" and "installed and working".
|
||||
say "waiting for the site to answer"
|
||||
url="http://127.0.0.1:${PORT}/wp-admin/install.php"
|
||||
ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || true)"
|
||||
case "$code" in
|
||||
200|30[0-9]) ok=1; break ;;
|
||||
esac
|
||||
sleep 2
|
||||
done
|
||||
|
||||
ip="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
suffix=""
|
||||
[ "$PORT" = "80" ] || suffix=":$PORT"
|
||||
|
||||
echo
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
bold "Sirius Press is running."
|
||||
else
|
||||
bold "Sirius Press started, but nothing answered on port $PORT yet."
|
||||
warn "Check with: cd $DIR/docker && $COMPOSE logs -f app"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Finish setup: http://${ip:-your-server}${suffix}/wp-admin/install.php
|
||||
|
||||
The setup screen asks for a wallet address instead of an email address.
|
||||
You can leave it blank and attach one later from your profile.
|
||||
|
||||
Next:
|
||||
|
||||
1. Point your BCNR name's p or ip record at ${ip:-this server}.
|
||||
See ${DIR}/docs/bcnr-records.md
|
||||
2. In wp-admin, open Sirius Press and set the name this site publishes under.
|
||||
3. Decide whether this server may hold your publishing key, or whether you
|
||||
would rather sign exports from your browser. Manual is the default and
|
||||
the safer answer. See ${DIR}/docs/publishing.md
|
||||
|
||||
Managing it:
|
||||
|
||||
cd ${DIR}/docker
|
||||
${COMPOSE} logs -f app # what the site is doing
|
||||
${COMPOSE} restart # after changing .env
|
||||
git -C ${DIR} pull && ${COMPOSE} build && ${COMPOSE} up -d # upgrade
|
||||
|
||||
EOF
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Sirius Press Bootstrap
|
||||
* Description: Loads before everything else, so the parts of WordPress that run before plugins do — the installer, and core's pluggable notification functions — behave on a site with no email identity.
|
||||
* Version: 0.1.0
|
||||
* License: GPL-2.0-or-later
|
||||
*
|
||||
* @package SiriusPress
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* Must-use plugins are loaded by `wp-settings.php` at a point where two useful
|
||||
* things are still true: ordinary plugins have not loaded (so this runs during
|
||||
* the installer, when they are skipped entirely), and `pluggable.php` has not
|
||||
* been included yet (so functions declared here win over core's).
|
||||
*
|
||||
* Both properties are needed, and neither is available anywhere else:
|
||||
*
|
||||
* - The patched `wp-admin/install.php` calls two helpers that must exist
|
||||
* before any plugin could have defined them.
|
||||
* - Core's mail notifications are pluggable functions. Overriding them is the
|
||||
* only way to stop the installer and the user system from handing messages
|
||||
* to a mail transport that has nowhere to send them.
|
||||
*
|
||||
* This file is deliberately tiny and dependency-free. Anything that can wait
|
||||
* for `plugins_loaded` belongs in Sirius Press Core instead.
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'SIRIUS_PRESS_BOOTSTRAP', '0.1.0' );
|
||||
|
||||
/**
|
||||
* Validate and normalise an address typed into the installer.
|
||||
*
|
||||
* @param string $address
|
||||
* @return string|false Normalised address, '' when blank, false when invalid.
|
||||
*/
|
||||
function sirius_press_install_address( $address ) {
|
||||
$address = trim( (string) $address );
|
||||
if ( '' === $address ) {
|
||||
return '';
|
||||
}
|
||||
$class = WP_PLUGIN_DIR . '/sirius-press-core/includes/class-sp-cashaddr.php';
|
||||
if ( ! class_exists( 'SP_CashAddr' ) && is_readable( $class ) ) {
|
||||
require_once $class;
|
||||
}
|
||||
if ( ! class_exists( 'SP_CashAddr' ) ) {
|
||||
// Core plugin not present. Refusing here would make the site
|
||||
// uninstallable; accepting an unchecked string would attach an
|
||||
// account to an address nobody can prove. Reject the value and let
|
||||
// the operator install without one.
|
||||
return false;
|
||||
}
|
||||
$normalised = SP_CashAddr::normalize( $address );
|
||||
return '' === $normalised ? false : $normalised;
|
||||
}
|
||||
|
||||
/**
|
||||
* A permanently unroutable address for a user, derived from their wallet.
|
||||
*
|
||||
* `wp_install()` and `wp_insert_user()` both want an email string. They get
|
||||
* one that RFC 2606 guarantees can never resolve.
|
||||
*/
|
||||
function sirius_press_install_stub_email( $address ) {
|
||||
$tag = is_string( $address ) && '' !== $address
|
||||
? substr( hash( 'sha256', $address ), 0, 16 )
|
||||
: substr( hash( 'sha256', (string) wp_rand( 0, PHP_INT_MAX ) . microtime() ), 0, 16 );
|
||||
return 'noreply+' . $tag . '@sirius-press.invalid';
|
||||
}
|
||||
|
||||
/*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Core notifications that have nowhere to go.
|
||||
*
|
||||
* These are declared before `pluggable.php` so core's versions never load.
|
||||
* Each one exists in WordPress to email somebody about their own account;
|
||||
* on a site where accounts have no mailbox, the message belongs in the
|
||||
* in-app inbox, and where the inbox is not available yet (during install)
|
||||
* it belongs nowhere at all.
|
||||
*
|
||||
* Deliberately NOT overridden: wp_mail() itself. Contact forms, order
|
||||
* receipts and newsletters go to addresses real people supplied, and those
|
||||
* must keep working for a site owner who has configured SMTP. Only the
|
||||
* account-notification functions are replaced.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if ( ! function_exists( 'wp_new_blog_notification' ) ) {
|
||||
/**
|
||||
* Core mails the new administrator their own login URL and password.
|
||||
*
|
||||
* Suppressed: the person triggering it is looking at the screen that
|
||||
* already shows both, and the address on file cannot receive mail.
|
||||
*
|
||||
* @param string $blog_title
|
||||
* @param string $blog_url
|
||||
* @param int $user_id
|
||||
* @param string $password
|
||||
*/
|
||||
function wp_new_blog_notification( $blog_title, $blog_url, $user_id, $password ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames
|
||||
// Nothing to send, and nothing to log — the installer's success page
|
||||
// is the notification.
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wp_password_change_notification' ) ) {
|
||||
/**
|
||||
* Core mails every administrator when a user changes their password.
|
||||
*
|
||||
* Routed to the in-app inbox when it exists. During install it does not,
|
||||
* and no password has changed anyway.
|
||||
*
|
||||
* @param WP_User $user
|
||||
*/
|
||||
function wp_password_change_notification( $user ) {
|
||||
if ( ! class_exists( 'SP_Inbox' ) ) {
|
||||
return;
|
||||
}
|
||||
SP_Inbox::add(
|
||||
0,
|
||||
__( 'A password was changed', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: %s: the user login whose password changed. */
|
||||
esc_html__( 'The password for %s was changed. On this site passwords are a fallback, not the identity — the account is still controlled by its wallet.', 'sirius-press' ),
|
||||
'<code>' . esc_html( $user->user_login ) . '</code>'
|
||||
),
|
||||
'core'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
Subject: [PATCH] wp-admin/install.php
|
||||
|
||||
The fork's change to this file, regenerated by tools/refresh-patches.sh.
|
||||
It is a record, not the mechanism: core is vendored under wordpress/ and
|
||||
this diff is what distinguishes it from pristine upstream.
|
||||
|
||||
Applies to: WordPress 7.1.2
|
||||
--- a/wp-admin/install.php
|
||||
+++ b/wp-admin/install.php
|
||||
@@ -101,6 +101,8 @@ function display_setup_form( $error = null ) {
|
||||
$weblog_title = isset( $_POST['weblog_title'] ) ? trim( wp_unslash( $_POST['weblog_title'] ) ) : '';
|
||||
$user_name = isset( $_POST['user_name'] ) ? trim( wp_unslash( $_POST['user_name'] ) ) : '';
|
||||
$admin_email = isset( $_POST['admin_email'] ) ? trim( wp_unslash( $_POST['admin_email'] ) ) : '';
|
||||
+ // Sirius Press: the identity field on this form is a wallet address.
|
||||
+ $admin_wallet = isset( $_POST['admin_wallet'] ) ? trim( wp_unslash( $_POST['admin_wallet'] ) ) : '';
|
||||
|
||||
if ( ! is_null( $error ) ) {
|
||||
?>
|
||||
@@ -175,9 +177,9 @@ function display_setup_form( $error = null ) {
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr>
|
||||
- <th scope="row"><label for="admin_email"><?php _e( 'Your Email' ); ?></label></th>
|
||||
- <td><input name="admin_email" type="email" id="admin_email" size="25" aria-describedby="admin-email-desc" value="<?php echo esc_attr( $admin_email ); ?>" />
|
||||
- <p id="admin-email-desc"><?php _e( 'Double-check your email address before continuing.' ); ?></p></td>
|
||||
+ <th scope="row"><label for="admin_wallet"><?php _e( 'Your wallet address' ); ?></label></th>
|
||||
+ <td><input name="admin_wallet" type="text" id="admin_wallet" size="25" class="code" aria-describedby="admin-wallet-desc" value="<?php echo esc_attr( $admin_wallet ); ?>" autocapitalize="off" autocomplete="off" spellcheck="false" />
|
||||
+ <p id="admin-wallet-desc"><?php _e( 'Sirius Press: your Bitcoin Cash address is your account. Leave this blank to attach one later from your profile — until then the password above is the only way in.' ); ?></p></td>
|
||||
</tr>
|
||||
<?php $blog_privacy_selector_title = has_action( 'blog_privacy_selector' ) ? __( 'Site visibility' ) : __( 'Search engine visibility' ); ?>
|
||||
<tr>
|
||||
@@ -411,10 +413,20 @@ switch ( $step ) {
|
||||
$user_name = isset( $_POST['user_name'] ) ? trim( wp_unslash( $_POST['user_name'] ) ) : '';
|
||||
$admin_password = isset( $_POST['admin_password'] ) ? wp_unslash( $_POST['admin_password'] ) : '';
|
||||
$admin_password_check = isset( $_POST['admin_password2'] ) ? wp_unslash( $_POST['admin_password2'] ) : '';
|
||||
- $admin_email = isset( $_POST['admin_email'] ) ? trim( wp_unslash( $_POST['admin_email'] ) ) : '';
|
||||
+ $admin_wallet = isset( $_POST['admin_wallet'] ) ? trim( wp_unslash( $_POST['admin_wallet'] ) ) : '';
|
||||
$public = isset( $_POST['blog_public'] ) ? (int) $_POST['blog_public'] : 1;
|
||||
|
||||
- // Check email address.
|
||||
+ /*
|
||||
+ * Sirius Press: there is no email step. The account identity is a
|
||||
+ * CashAddress, and it is optional here because the installer has nothing
|
||||
+ * to verify a signature against yet — the site does not exist. An address
|
||||
+ * typed now is attached below; an administrator who leaves it blank
|
||||
+ * attaches one from their profile afterwards, where they can prove it.
|
||||
+ */
|
||||
+ $admin_wallet = sirius_press_install_address( $admin_wallet );
|
||||
+ $admin_email = sirius_press_install_stub_email( $admin_wallet );
|
||||
+
|
||||
+ // Check the username and password.
|
||||
$error = false;
|
||||
if ( empty( $user_name ) ) {
|
||||
// TODO: Poka-yoke.
|
||||
@@ -427,19 +439,20 @@ switch ( $step ) {
|
||||
// TODO: Poka-yoke.
|
||||
display_setup_form( __( 'Your passwords do not match. Please try again.' ) );
|
||||
$error = true;
|
||||
- } elseif ( empty( $admin_email ) ) {
|
||||
- // TODO: Poka-yoke.
|
||||
- display_setup_form( __( 'You must provide an email address.' ) );
|
||||
- $error = true;
|
||||
- } elseif ( ! is_email( $admin_email ) ) {
|
||||
- // TODO: Poka-yoke.
|
||||
- display_setup_form( __( 'Sorry, that is not a valid email address. Email addresses look like <code>username@example.com</code>.' ) );
|
||||
+ } elseif ( false === $admin_wallet ) {
|
||||
+ display_setup_form( __( 'Sorry, that is not a valid Bitcoin Cash address. Leave the field blank if you would rather attach a wallet after installing.' ) );
|
||||
$error = true;
|
||||
}
|
||||
|
||||
if ( false === $error ) {
|
||||
$wpdb->show_errors();
|
||||
$result = wp_install( $weblog_title, $user_name, $admin_email, $public, '', wp_slash( $admin_password ), $loaded_language );
|
||||
+
|
||||
+ // Sirius Press: record the administrator's wallet so they can sign in
|
||||
+ // with it immediately.
|
||||
+ if ( '' !== $admin_wallet && ! empty( $result['user_id'] ) ) {
|
||||
+ update_user_meta( (int) $result['user_id'], 'sirius_wallet_address', $admin_wallet );
|
||||
+ }
|
||||
?>
|
||||
|
||||
<h1><?php _e( 'Success!' ); ?></h1>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,185 +0,0 @@
|
|||
/*
|
||||
* Sign-in screen styling.
|
||||
*
|
||||
* Sits on top of whatever wp-login.php already looks like rather than
|
||||
* replacing it, so a site using a login-branding plugin keeps its branding.
|
||||
* The only strong visual claim made here is hierarchy: the wallet block reads
|
||||
* as the way in, and the password field above it reads as a leftover.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Make the `hidden` attribute actually hide things.
|
||||
*
|
||||
* `hidden` works through a UA rule — `[hidden] { display: none }` — which any
|
||||
* author rule carrying a `display` outranks. WordPress ships exactly such a
|
||||
* rule: `.wp-core-ui .button { display: inline-block }` in buttons.css, which
|
||||
* matches every button in this block.
|
||||
*
|
||||
* Without this the whole progressive-enhancement story silently inverts. The
|
||||
* markup ships with the sign-in button, the browser-wallet button and the
|
||||
* phrase field hidden, and the script reveals only the ones that can work.
|
||||
* Defeat `hidden` and a visitor with JavaScript off is offered a button that
|
||||
* does nothing, and everyone is offered a browser wallet that is not there.
|
||||
*/
|
||||
.sirius-wallet [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sirius-wallet {
|
||||
margin: 16px 0 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 6px;
|
||||
background: #fbfbfc;
|
||||
}
|
||||
|
||||
.sirius-wallet__message {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #2c3338;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sirius-wallet__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.sirius-wallet__phrase {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sirius-wallet__phrase label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sirius-wallet__phrase-input,
|
||||
.sirius-wallet__signature,
|
||||
.sirius-wallet__address-input,
|
||||
#sirius_derive_phrase {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
border: 1px solid #8c8f94;
|
||||
border-radius: 4px;
|
||||
/* A recovery phrase is the one secret on this page; never offer it to a
|
||||
password manager or a spellchecker's network round trip. */
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
|
||||
.sirius-wallet__hint,
|
||||
.sirius-hint {
|
||||
font-size: 12px;
|
||||
color: #50575e;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sirius-wallet__manual {
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sirius-wallet__manual summary {
|
||||
cursor: pointer;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
.sirius-wallet__status {
|
||||
margin: 10px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sirius-wallet__status.is-error {
|
||||
color: #b32d2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sirius-intro {
|
||||
border-left-color: #2271b1 !important;
|
||||
}
|
||||
|
||||
/* --- the recovery page ------------------------------------------------- */
|
||||
|
||||
.sirius-recovery {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 6px;
|
||||
padding: 20px 24px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sirius-recovery h2 {
|
||||
font-size: 15px;
|
||||
margin: 20px 0 6px;
|
||||
}
|
||||
|
||||
.sirius-recovery .message {
|
||||
font-weight: 600;
|
||||
border-left: 4px solid #dba617;
|
||||
padding: 10px 12px;
|
||||
background: #fcf9e8;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.sirius-derive {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f6f7f7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sirius-derive label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sirius-derive__out {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
margin: 8px 0 0;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
.sirius-derive__out.is-address {
|
||||
color: #007017;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sirius-derive__out.is-error {
|
||||
color: #b32d2e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* wp-login.php has no dark mode of its own, so only soften what would
|
||||
glare if a login-branding plugin has provided one. */
|
||||
.sirius-wallet {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sirius-wallet__address-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
// Wiring for the sign-in and registration screens.
|
||||
//
|
||||
// This file contains no cryptography — wallet.js does all of that. What it
|
||||
// does is decide which of the three ways to sign is on offer and get out of
|
||||
// the way of the other two:
|
||||
//
|
||||
// 1. A wallet the browser already has (Theseus exposes one). Best case: the
|
||||
// key never comes near this page.
|
||||
// 2. A recovery phrase typed here, used once, and wiped.
|
||||
// 3. A signature produced somewhere else entirely and pasted in. This one
|
||||
// works with JavaScript switched off, which is why the markup ships with
|
||||
// the paste box present and the buttons hidden until this script decides
|
||||
// they are usable.
|
||||
//
|
||||
// The progressive-enhancement direction matters: buttons start hidden and are
|
||||
// revealed, rather than starting visible and being disabled. A script that
|
||||
// fails to load leaves a page that still works.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const CONFIG = window.SIRIUS_PRESS || {};
|
||||
const strings = CONFIG.strings || {};
|
||||
|
||||
function setStatus(block, text, kind = "") {
|
||||
const el = block.querySelector(".sirius-wallet__status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "sirius-wallet__status" + (kind ? " is-" + kind : "");
|
||||
}
|
||||
|
||||
/** Submit the form the block sits in, once a signature is in place. */
|
||||
function submitForm(block) {
|
||||
const form = block.closest("form");
|
||||
if (!form) return;
|
||||
|
||||
// WordPress marks its username and password inputs `required`, and the
|
||||
// wallet path deliberately leaves both empty — the signature is the
|
||||
// credential. Constraint validation would otherwise refuse the submit and
|
||||
// point a "Please fill out this field" bubble at an input the visitor is
|
||||
// not supposed to touch, with the signature already sitting in the form.
|
||||
//
|
||||
// Only reached from the wallet buttons, so password sign-in keeps its
|
||||
// validation.
|
||||
form.noValidate = true;
|
||||
|
||||
if (typeof form.requestSubmit === "function") {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
function setSignature(block, signature, address) {
|
||||
const field = block.querySelector(".sirius-wallet__signature");
|
||||
if (field) field.value = signature;
|
||||
// The address the signature claims. The server compares it against the
|
||||
// one it recovers, which is the only way a signature over the wrong text
|
||||
// becomes an error instead of a different identity.
|
||||
const claim = block.querySelector(".sirius-wallet__address");
|
||||
if (claim && address) claim.value = address;
|
||||
}
|
||||
|
||||
/** On the paste path the visitor supplies the address themselves. */
|
||||
function adoptTypedAddress(block) {
|
||||
const typed = block.querySelector(".sirius-wallet__address-input");
|
||||
const claim = block.querySelector(".sirius-wallet__address");
|
||||
if (typed && claim && typed.value.trim() && !claim.value) {
|
||||
claim.value = typed.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function signWithPhrase(block) {
|
||||
const input = block.querySelector(".sirius-wallet__phrase-input");
|
||||
const message = block.querySelector(".sirius-wallet__message").value;
|
||||
const phrase = input ? input.value : "";
|
||||
|
||||
const check = window.SiriusWallet.validatePhrase(phrase);
|
||||
if (!check.ok) {
|
||||
setStatus(block, check.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(block, strings.signing || "Signing…");
|
||||
let wallet;
|
||||
try {
|
||||
wallet = await window.SiriusWallet.fromPhrase(phrase, {
|
||||
prefix: CONFIG.prefix,
|
||||
path: CONFIG.path,
|
||||
});
|
||||
setSignature(block, await wallet.sign(message), wallet.address);
|
||||
} catch (err) {
|
||||
setStatus(block, err.message || String(err), "error");
|
||||
return;
|
||||
} finally {
|
||||
// The phrase has done its one job. Clear it from the field and from the
|
||||
// wallet object before anything else on the page runs.
|
||||
if (input) input.value = "";
|
||||
if (wallet && wallet.forget) wallet.forget();
|
||||
}
|
||||
submitForm(block);
|
||||
}
|
||||
|
||||
async function signWithExternal(block) {
|
||||
const external = window.SiriusWallet.external();
|
||||
if (!external) {
|
||||
setStatus(block, strings.noWallet || "No wallet found in this browser.", "error");
|
||||
return;
|
||||
}
|
||||
const message = block.querySelector(".sirius-wallet__message").value;
|
||||
setStatus(block, strings.signing || "Signing…");
|
||||
try {
|
||||
setSignature(block, await external.sign(message), await external.address());
|
||||
} catch (err) {
|
||||
// A user declining the wallet's approval dialog is a normal outcome, not
|
||||
// an error worth shouting about.
|
||||
const text = err && err.message ? err.message : String(err);
|
||||
setStatus(block, /reject/i.test(text) ? "" : text, /reject/i.test(text) ? "" : "error");
|
||||
return;
|
||||
}
|
||||
submitForm(block);
|
||||
}
|
||||
|
||||
function enhance(block) {
|
||||
// Whatever happens next, a hand-typed address must reach the server.
|
||||
const form = block.closest("form");
|
||||
if (form) form.addEventListener("submit", () => adoptTypedAddress(block));
|
||||
|
||||
const signButton = block.querySelector(".sirius-wallet__sign");
|
||||
const externalButton = block.querySelector(".sirius-wallet__external");
|
||||
const phraseArea = block.querySelector(".sirius-wallet__phrase");
|
||||
|
||||
if (!window.SiriusWallet || !window.crypto || !window.crypto.subtle) {
|
||||
// No usable crypto — for instance an http:// origin, where WebCrypto is
|
||||
// unavailable. Say so, and leave the paste path alone: it still works.
|
||||
setStatus(
|
||||
block,
|
||||
"This browser cannot sign here (a secure https connection is required). Paste a signature from your own wallet instead.",
|
||||
"error",
|
||||
);
|
||||
const manual = block.querySelector(".sirius-wallet__manual");
|
||||
if (manual) manual.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (phraseArea) phraseArea.hidden = false;
|
||||
if (signButton) {
|
||||
signButton.hidden = false;
|
||||
signButton.addEventListener("click", () => signWithPhrase(block));
|
||||
}
|
||||
if (externalButton && window.SiriusWallet.external()) {
|
||||
externalButton.hidden = false;
|
||||
externalButton.addEventListener("click", () => signWithExternal(block));
|
||||
}
|
||||
|
||||
// Enter in the phrase box should sign, not submit an unsigned form.
|
||||
const input = block.querySelector(".sirius-wallet__phrase-input");
|
||||
if (input) {
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
signWithPhrase(block);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The "which address is this phrase?" helper on the recovery page. */
|
||||
function enhanceDerive() {
|
||||
const button = document.getElementById("sirius_derive_go");
|
||||
const input = document.getElementById("sirius_derive_phrase");
|
||||
const out = document.querySelector(".sirius-derive__out");
|
||||
if (!button || !input || !out) return;
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
const check = window.SiriusWallet.validatePhrase(input.value);
|
||||
if (!check.ok) {
|
||||
out.textContent = check.error;
|
||||
out.className = "sirius-derive__out is-error";
|
||||
return;
|
||||
}
|
||||
out.textContent = strings.signing || "Working…";
|
||||
out.className = "sirius-derive__out";
|
||||
try {
|
||||
const wallet = await window.SiriusWallet.fromPhrase(input.value, {
|
||||
prefix: CONFIG.prefix,
|
||||
path: CONFIG.path,
|
||||
});
|
||||
out.textContent = wallet.address;
|
||||
out.className = "sirius-derive__out is-address";
|
||||
wallet.forget();
|
||||
} catch (err) {
|
||||
out.textContent = err.message || String(err);
|
||||
out.className = "sirius-derive__out is-error";
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
document.querySelectorAll(".sirius-wallet").forEach(enhance);
|
||||
enhanceDerive();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,511 +0,0 @@
|
|||
// The wallet that signs Sirius Press sign-ins, running in the visitor's page.
|
||||
//
|
||||
// Why this exists at all: a self-hosted Sirius Press site must be able to log
|
||||
// its own users in without calling home. Redirecting to sirius.x to sign would
|
||||
// make every install depend on Silent Mode's portal being up and willing —
|
||||
// which is exactly the arrangement this whole project exists to get away from.
|
||||
// So the signing happens here, in the browser, against a phrase the server
|
||||
// never sees.
|
||||
//
|
||||
// No build step and no dependencies, deliberately: a wallet you cannot read is
|
||||
// a wallet you cannot trust, and a site owner should be able to open this file
|
||||
// and follow every line from phrase to signature. BigInt does the curve maths,
|
||||
// WebCrypto does SHA-256, HMAC and PBKDF2. RIPEMD-160 is implemented below
|
||||
// because WebCrypto does not offer it and address derivation needs it.
|
||||
//
|
||||
// The phrase never leaves the page. It is not posted, not stored in a cookie,
|
||||
// and — unless the visitor asks to stay signed in — not written to disk at
|
||||
// all. What crosses the wire is a signature over a challenge the server
|
||||
// issued, which proves control of the key and nothing else.
|
||||
//
|
||||
// window.SiriusWallet = { fromPhrase, generatePhrase, validatePhrase,
|
||||
// external, addressFromPublicKey }
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------- secp256k1
|
||||
|
||||
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
|
||||
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
||||
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
|
||||
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
|
||||
const HALF_N = N >> 1n;
|
||||
|
||||
const mod = (a, m = P) => ((a % m) + m) % m;
|
||||
|
||||
function powMod(base, exp, m) {
|
||||
let result = 1n;
|
||||
let b = mod(base, m);
|
||||
let e = exp;
|
||||
while (e > 0n) {
|
||||
if (e & 1n) result = (result * b) % m;
|
||||
b = (b * b) % m;
|
||||
e >>= 1n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Both moduli here are prime, so Fermat gives the inverse without an
|
||||
// extended-Euclid routine.
|
||||
const invMod = (a, m) => powMod(a, m - 2n, m);
|
||||
|
||||
// Points are Jacobian [X, Y, Z]; affine is X/Z^2, Y/Z^3. One inversion per
|
||||
// scalar multiply instead of one per bit.
|
||||
const INF = [1n, 1n, 0n];
|
||||
|
||||
function jDouble([x, y, z]) {
|
||||
if (z === 0n || y === 0n) return INF;
|
||||
const a = mod(x * x);
|
||||
const b = mod(y * y);
|
||||
const c = mod(b * b);
|
||||
const d = mod(2n * (mod((x + b) * (x + b)) - a - c));
|
||||
const e = mod(3n * a);
|
||||
const f = mod(e * e);
|
||||
const x3 = mod(f - 2n * d);
|
||||
const y3 = mod(e * (d - x3) - 8n * c);
|
||||
const z3 = mod(2n * y * z);
|
||||
return [x3, y3, z3];
|
||||
}
|
||||
|
||||
function jAdd(p1, p2) {
|
||||
if (p1[2] === 0n) return p2;
|
||||
if (p2[2] === 0n) return p1;
|
||||
const [x1, y1, z1] = p1;
|
||||
const [x2, y2, z2] = p2;
|
||||
const z1z1 = mod(z1 * z1);
|
||||
const z2z2 = mod(z2 * z2);
|
||||
const u1 = mod(x1 * z2z2);
|
||||
const u2 = mod(x2 * z1z1);
|
||||
const s1 = mod(y1 * z2 * z2z2);
|
||||
const s2 = mod(y2 * z1 * z1z1);
|
||||
if (u1 === u2) return s1 === s2 ? jDouble(p1) : INF;
|
||||
const h = mod(u2 - u1);
|
||||
const i = mod(mod(2n * h) * mod(2n * h));
|
||||
const j = mod(h * i);
|
||||
const r = mod(2n * (s2 - s1));
|
||||
const v = mod(u1 * i);
|
||||
const x3 = mod(r * r - j - 2n * v);
|
||||
const y3 = mod(r * (v - x3) - 2n * s1 * j);
|
||||
const z3 = mod((mod((z1 + z2) * (z1 + z2)) - z1z1 - z2z2) * h);
|
||||
return [x3, y3, z3];
|
||||
}
|
||||
|
||||
function toAffine([x, y, z]) {
|
||||
if (z === 0n) return null;
|
||||
const zi = invMod(z, P);
|
||||
const zi2 = mod(zi * zi);
|
||||
return [mod(x * zi2), mod(y * zi2 * zi)];
|
||||
}
|
||||
|
||||
function mulPoint(k, px, py) {
|
||||
k = mod(k, N);
|
||||
if (k === 0n) return null;
|
||||
const base = [px, py, 1n];
|
||||
let acc = INF;
|
||||
for (const bit of k.toString(2)) {
|
||||
acc = jDouble(acc);
|
||||
if (bit === "1") acc = jAdd(acc, base);
|
||||
}
|
||||
return toAffine(acc);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- bytes
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const hexToBytes = (hex) =>
|
||||
Uint8Array.from(hex.match(/.{1,2}/g).map((b) => parseInt(b, 16)));
|
||||
const bytesToHex = (b) =>
|
||||
[...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||
const bytesToBig = (b) => BigInt("0x" + (bytesToHex(b) || "0"));
|
||||
const bigToBytes = (n, len = 32) => hexToBytes(n.toString(16).padStart(len * 2, "0"));
|
||||
const concat = (...arrays) => {
|
||||
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const a of arrays) { out.set(a, at); at += a.length; }
|
||||
return out;
|
||||
};
|
||||
const toBase64 = (b) => btoa(String.fromCharCode(...b));
|
||||
|
||||
const sha256 = async (bytes) =>
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
|
||||
async function hmac(hash, key, data) {
|
||||
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash }, false, ["sign"]);
|
||||
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- RIPEMD-160
|
||||
//
|
||||
// WebCrypto has no RIPEMD-160 and an address cannot be derived without it.
|
||||
// Straight transcription of the reference implementation.
|
||||
|
||||
function ripemd160(message) {
|
||||
const rl = [
|
||||
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,
|
||||
7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,
|
||||
3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,
|
||||
1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,
|
||||
4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13,
|
||||
];
|
||||
const rr = [
|
||||
5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,
|
||||
6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,
|
||||
15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,
|
||||
8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,
|
||||
12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11,
|
||||
];
|
||||
const sl = [
|
||||
11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,
|
||||
7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,
|
||||
11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,
|
||||
11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,
|
||||
9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6,
|
||||
];
|
||||
const sr = [
|
||||
8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,
|
||||
9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,
|
||||
9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,
|
||||
15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,
|
||||
8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11,
|
||||
];
|
||||
const kl = [0x00000000, 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xa953fd4e];
|
||||
const kr = [0x50a28be6, 0x5c4dd124, 0x6d703ef3, 0x7a6d76e9, 0x00000000];
|
||||
|
||||
const rol = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;
|
||||
const f = (j, x, y, z) => {
|
||||
if (j < 16) return x ^ y ^ z;
|
||||
if (j < 32) return (x & y) | (~x & z);
|
||||
if (j < 48) return (x | ~y) ^ z;
|
||||
if (j < 64) return (x & z) | (y & ~z);
|
||||
return x ^ (y | ~z);
|
||||
};
|
||||
|
||||
// Pad to 64-byte blocks: 0x80, zeros, then a 64-bit little-endian length.
|
||||
const len = message.length;
|
||||
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||
withPad.set(message);
|
||||
withPad[len] = 0x80;
|
||||
const view = new DataView(withPad.buffer);
|
||||
view.setUint32(withPad.length - 8, (len << 3) >>> 0, true);
|
||||
view.setUint32(withPad.length - 4, Math.floor((len * 8) / 0x100000000), true);
|
||||
|
||||
let h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
|
||||
const x = new Array(16);
|
||||
|
||||
for (let block = 0; block < withPad.length; block += 64) {
|
||||
for (let i = 0; i < 16; i++) x[i] = view.getUint32(block + i * 4, true);
|
||||
let [al, bl, cl, dl, el] = h;
|
||||
let [ar, br, cr, dr, er] = h;
|
||||
for (let j = 0; j < 80; j++) {
|
||||
const round = Math.floor(j / 16);
|
||||
let t = (al + f(j, bl, cl, dl) + x[rl[j]] + kl[round]) >>> 0;
|
||||
t = (rol(t, sl[j]) + el) >>> 0;
|
||||
al = el; el = dl; dl = rol(cl, 10); cl = bl; bl = t;
|
||||
t = (ar + f(79 - j, br, cr, dr) + x[rr[j]] + kr[round]) >>> 0;
|
||||
t = (rol(t, sr[j]) + er) >>> 0;
|
||||
ar = er; er = dr; dr = rol(cr, 10); cr = br; br = t;
|
||||
}
|
||||
// The new state is a rotation of the old one: h0 takes the value built
|
||||
// from h1, h1 from h2, and so on, with h4 wrapping back to h0.
|
||||
h = [
|
||||
(h[1] + cl + dr) >>> 0,
|
||||
(h[2] + dl + er) >>> 0,
|
||||
(h[3] + el + ar) >>> 0,
|
||||
(h[4] + al + br) >>> 0,
|
||||
(h[0] + bl + cr) >>> 0,
|
||||
];
|
||||
}
|
||||
|
||||
const out = new Uint8Array(20);
|
||||
const ov = new DataView(out.buffer);
|
||||
h.forEach((word, i) => ov.setUint32(i * 4, word, true));
|
||||
return out;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- CashAddr
|
||||
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
|
||||
function polyMod(values) {
|
||||
// 40-bit accumulator, split across two 32-bit halves because JavaScript's
|
||||
// bitwise operators truncate to 32 bits and BigInt here would be slower
|
||||
// than the arithmetic is worth.
|
||||
let c = 1n;
|
||||
for (const d of values) {
|
||||
const c0 = c >> 35n;
|
||||
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
|
||||
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
|
||||
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
|
||||
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
|
||||
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
|
||||
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
|
||||
}
|
||||
return c ^ 1n;
|
||||
}
|
||||
|
||||
const expandPrefix = (prefix) => [...prefix].map((ch) => ch.charCodeAt(0) & 0x1f).concat([0]);
|
||||
|
||||
function convertBits(values, from, to, pad) {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out = [];
|
||||
const max = (1 << to) - 1;
|
||||
for (const v of values) {
|
||||
if (v < 0 || v >> from !== 0) return null;
|
||||
acc = (acc << from) | v;
|
||||
bits += from;
|
||||
while (bits >= to) {
|
||||
bits -= to;
|
||||
out.push((acc >> bits) & max);
|
||||
}
|
||||
}
|
||||
if (pad) {
|
||||
if (bits > 0) out.push((acc << (to - bits)) & max);
|
||||
} else if (bits >= from || ((acc << (to - bits)) & max)) {
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeCashAddr(prefix, hash160, type = 0) {
|
||||
const payload = concat(Uint8Array.of(type << 3), hash160);
|
||||
const data = convertBits([...payload], 8, 5, true);
|
||||
const checksum = polyMod(expandPrefix(prefix).concat(data, [0, 0, 0, 0, 0, 0, 0, 0]));
|
||||
const cs = [];
|
||||
for (let i = 0; i < 8; i++) cs.push(Number((checksum >> BigInt(5 * (7 - i))) & 0x1fn));
|
||||
return prefix + ":" + data.concat(cs).map((v) => CHARSET[v]).join("");
|
||||
}
|
||||
|
||||
const addressFromPublicKey = async (pub, prefix) =>
|
||||
encodeCashAddr(prefix, ripemd160(await sha256(pub)), 0);
|
||||
|
||||
// ------------------------------------------------------------------- keys
|
||||
|
||||
async function publicKey(priv) {
|
||||
const pt = mulPoint(bytesToBig(priv), GX, GY);
|
||||
if (!pt) throw new Error("that key does not produce a valid public key");
|
||||
return concat(Uint8Array.of(pt[1] & 1n ? 3 : 2), bigToBytes(pt[0]));
|
||||
}
|
||||
|
||||
// BIP-32 CKDpriv. Hardened steps use the private key, normal steps the
|
||||
// public one — which is why this has to be async all the way down.
|
||||
async function deriveChild(node, index) {
|
||||
const hardened = index >= 0x80000000;
|
||||
const indexBytes = new Uint8Array(4);
|
||||
new DataView(indexBytes.buffer).setUint32(0, index, false);
|
||||
const data = hardened
|
||||
? concat(Uint8Array.of(0), node.key, indexBytes)
|
||||
: concat(await publicKey(node.key), indexBytes);
|
||||
const i = await hmac("SHA-512", node.chain, data);
|
||||
const tweak = bytesToBig(i.slice(0, 32));
|
||||
if (tweak >= N) throw new Error("derivation hit an invalid tweak");
|
||||
const child = mod(tweak + bytesToBig(node.key), N);
|
||||
if (child === 0n) throw new Error("derivation produced a zero key");
|
||||
return { key: bigToBytes(child), chain: i.slice(32) };
|
||||
}
|
||||
|
||||
async function derivePath(seed, path) {
|
||||
const i = await hmac("SHA-512", enc.encode("Bitcoin seed"), seed);
|
||||
let node = { key: i.slice(0, 32), chain: i.slice(32) };
|
||||
for (const part of path.split("/").slice(1)) {
|
||||
if (!part) continue;
|
||||
const hardened = /['h]$/i.test(part);
|
||||
const num = parseInt(part.replace(/['h]$/i, ""), 10);
|
||||
if (!Number.isInteger(num) || num < 0) throw new Error("bad derivation path: " + path);
|
||||
node = await deriveChild(node, hardened ? num + 0x80000000 : num);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function seedFromPhrase(phrase, passphrase = "") {
|
||||
const key = await crypto.subtle.importKey("raw", enc.encode(phrase), "PBKDF2", false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "PBKDF2", salt: enc.encode("mnemonic" + passphrase), iterations: 2048, hash: "SHA-512" },
|
||||
key,
|
||||
512,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- signing
|
||||
|
||||
const MAGIC = "Bitcoin Signed Message:\n";
|
||||
|
||||
function varStr(bytes) {
|
||||
if (bytes.length < 0xfd) return concat(Uint8Array.of(bytes.length), bytes);
|
||||
const len = new Uint8Array(3);
|
||||
len[0] = 0xfd;
|
||||
new DataView(len.buffer).setUint16(1, bytes.length, true);
|
||||
return concat(len, bytes);
|
||||
}
|
||||
|
||||
// The digest every BCH wallet signs for a text message: double SHA-256 over
|
||||
// varstr(magic) || varstr(message). Sirius Press verifies against this, so a
|
||||
// signature made in Electron Cash or the Theseus wallet works identically.
|
||||
async function messageDigest(message) {
|
||||
const payload = concat(varStr(enc.encode(MAGIC)), varStr(enc.encode(message)));
|
||||
return sha256(await sha256(payload));
|
||||
}
|
||||
|
||||
// RFC 6979: the nonce comes from the key and the message, never from the
|
||||
// page's random source. Two signatures of the same thing are identical, and
|
||||
// a bad RNG cannot leak the key.
|
||||
async function* nonces(digest, priv) {
|
||||
const h1 = bigToBytes(mod(bytesToBig(digest), N));
|
||||
let v = new Uint8Array(32).fill(1);
|
||||
let k = new Uint8Array(32).fill(0);
|
||||
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0), priv, h1));
|
||||
v = await hmac("SHA-256", k, v);
|
||||
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(1), priv, h1));
|
||||
v = await hmac("SHA-256", k, v);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
v = await hmac("SHA-256", k, v);
|
||||
const candidate = bytesToBig(v);
|
||||
if (candidate > 0n && candidate < N) yield candidate;
|
||||
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0)));
|
||||
v = await hmac("SHA-256", k, v);
|
||||
}
|
||||
}
|
||||
|
||||
async function signDigest(digest, priv) {
|
||||
const z = mod(bytesToBig(digest), N);
|
||||
const d = bytesToBig(priv);
|
||||
for await (const k of nonces(digest, priv)) {
|
||||
const pt = mulPoint(k, GX, GY);
|
||||
if (!pt) continue;
|
||||
const r = mod(pt[0], N);
|
||||
if (r === 0n) continue;
|
||||
let s = mod(invMod(k, N) * (z + r * d), N);
|
||||
if (s === 0n) continue;
|
||||
let recid = (pt[1] & 1n ? 1 : 0) | (pt[0] >= N ? 2 : 0);
|
||||
if (s > HALF_N) {
|
||||
s = N - s;
|
||||
recid ^= 1;
|
||||
}
|
||||
return concat(Uint8Array.of(27 + 4 + recid), bigToBytes(r), bigToBytes(s));
|
||||
}
|
||||
throw new Error("could not produce a signature");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- public API
|
||||
|
||||
const normalizePhrase = (p) => String(p || "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
function validatePhrase(phrase) {
|
||||
const clean = normalizePhrase(phrase);
|
||||
if (!clean) return { ok: false, error: "Enter your recovery phrase." };
|
||||
const words = clean.split(" ");
|
||||
if (![12, 15, 18, 21, 24].includes(words.length)) {
|
||||
return { ok: false, error: `A recovery phrase has 12, 15, 18, 21 or 24 words — this has ${words.length}.` };
|
||||
}
|
||||
const list = window.SIRIUS_BIP39_EN;
|
||||
if (Array.isArray(list)) {
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (!list.includes(words[i])) {
|
||||
return { ok: false, error: `Word ${i + 1}, “${words[i]}”, is not a recovery-phrase word.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, error: "" };
|
||||
}
|
||||
|
||||
/** A fresh phrase with a valid BIP-39 checksum, from the browser's CSPRNG. */
|
||||
async function generatePhrase(wordCount = 12) {
|
||||
const list = window.SIRIUS_BIP39_EN;
|
||||
if (!Array.isArray(list) || list.length !== 2048) {
|
||||
throw new Error("the word list has not loaded, so a phrase cannot be generated safely");
|
||||
}
|
||||
const entropyBits = (wordCount / 3) * 32;
|
||||
const entropy = crypto.getRandomValues(new Uint8Array(entropyBits / 8));
|
||||
const hash = await sha256(entropy);
|
||||
let bits = [...entropy].map((b) => b.toString(2).padStart(8, "0")).join("");
|
||||
bits += hash[0].toString(2).padStart(8, "0").slice(0, entropyBits / 32);
|
||||
const words = [];
|
||||
for (let i = 0; i < bits.length / 11; i++) {
|
||||
words.push(list[parseInt(bits.slice(i * 11, i * 11 + 11), 2)]);
|
||||
}
|
||||
return words.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a wallet from a phrase.
|
||||
*
|
||||
* @returns {{address: string, sign: (message: string) => Promise<string>}}
|
||||
*/
|
||||
async function fromPhrase(phrase, { prefix = "bitcoincash", path = "m/44'/145'/0'/0/0" } = {}) {
|
||||
const clean = normalizePhrase(phrase);
|
||||
const check = validatePhrase(clean);
|
||||
if (!check.ok) throw new Error(check.error);
|
||||
const node = await derivePath(await seedFromPhrase(clean), path);
|
||||
const pub = await publicKey(node.key);
|
||||
const address = await addressFromPublicKey(pub, prefix);
|
||||
return {
|
||||
address,
|
||||
async sign(message) {
|
||||
return toBase64(await signDigest(await messageDigest(message), node.key));
|
||||
},
|
||||
/**
|
||||
* Sign an already-computed 32-byte digest.
|
||||
*
|
||||
* The gateway's upload envelope (BNS-SITE1) is a single SHA-256 over a
|
||||
* fixed line format, not the BIP-137 message scheme — so the static
|
||||
* exporter, signing from the browser, needs this rather than sign().
|
||||
* Handing a wallet a bare digest is a sharp tool: nothing about the
|
||||
* bytes is legible to the person approving it, which is exactly why
|
||||
* the interactive login path does not use it.
|
||||
*/
|
||||
async signRaw(digest) {
|
||||
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
|
||||
throw new Error("signRaw expects a 32-byte digest");
|
||||
}
|
||||
return toBase64(await signDigest(digest, node.key));
|
||||
},
|
||||
/** sha256 of arbitrary bytes, so callers need no second hash library. */
|
||||
sha256,
|
||||
/** Drop the key material once the page is done with it. */
|
||||
forget() {
|
||||
node.key.fill(0);
|
||||
node.chain.fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A wallet the browser already provides, if there is one.
|
||||
*
|
||||
* Theseus exposes `window.bitcoincash` on `.x` origins, which signs with the
|
||||
* same BIP-137 scheme. Using it means the key stays in the browser's own
|
||||
* wallet and never touches this page — strictly better than asking for a
|
||||
* phrase, so the login screen offers it first when it is there.
|
||||
*/
|
||||
function external() {
|
||||
const bridge = window.bitcoincash;
|
||||
if (!bridge || typeof bridge.signMessage !== "function") return null;
|
||||
return {
|
||||
name: "Theseus",
|
||||
async address() {
|
||||
const a = await bridge.getAddress();
|
||||
return typeof a === "string" ? a : a && a.address ? a.address : "";
|
||||
},
|
||||
async sign(message) {
|
||||
const r = await bridge.signMessage(message);
|
||||
if (typeof r === "string") return r;
|
||||
if (r && r.signature) return r.signature;
|
||||
throw new Error("the wallet returned a signature this page did not understand");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.SiriusWallet = {
|
||||
fromPhrase,
|
||||
generatePhrase,
|
||||
validatePhrase,
|
||||
normalizePhrase,
|
||||
external,
|
||||
addressFromPublicKey,
|
||||
// Exposed for the test page in tests/browser/, not used by the UI.
|
||||
_internals: { signDigest, messageDigest, publicKey, ripemd160, encodeCashAddr },
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The thing a user signs to prove who they are.
|
||||
*
|
||||
* A challenge is a short piece of text this site produced, which the user
|
||||
* signs with their wallet. The signature comes back, the public key is
|
||||
* recovered from it, and the address that public key controls *is* the
|
||||
* identity — nothing had to be typed, remembered or emailed.
|
||||
*
|
||||
* **Stateless issue, stateful consume.** Handing out a challenge writes
|
||||
* nothing: the nonce carries its own timestamp and an HMAC under the site's
|
||||
* salts, so a forged nonce fails arithmetic rather than a database lookup,
|
||||
* and a login page that is rendered and abandoned costs nothing. Only a
|
||||
* *successful* verification writes — a short-lived marker that burns the
|
||||
* nonce, so the same signature cannot be replayed.
|
||||
*
|
||||
* **The message is reconstructed, never trusted.** The client sends back the
|
||||
* nonce and what it was for; this class rebuilds the exact text from those and
|
||||
* recovers the signer from the rebuilt copy.
|
||||
*
|
||||
* **Recovery is not verification.** This is the subtle part, and getting it
|
||||
* wrong is how a wallet-auth system quietly breaks. Public-key recovery
|
||||
* always succeeds: given any well-formed signature and any digest, it returns
|
||||
* *a* key — just not the signer's, unless the digest is the one that was
|
||||
* actually signed. So a signature over the wrong text does not produce an
|
||||
* error, it produces a stranger's address.
|
||||
*
|
||||
* At sign-in that is harmless: the wrong address matches no account and the
|
||||
* attempt fails. Anywhere the outcome *binds* an address to an account —
|
||||
* registration, attaching a wallet — it is not harmless at all, because the
|
||||
* account would be bound to an address nobody can sign for, and the person
|
||||
* would only discover it the next time they tried to sign in.
|
||||
*
|
||||
* So those callers pass the address the signer claims, and verification means
|
||||
* "the recovered address is that one". A mismatch is then a clear error
|
||||
* instead of a broken account, and a login signature genuinely cannot be
|
||||
* replayed to register: it recovers to a different address than the one the
|
||||
* request claims.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Challenge {
|
||||
|
||||
/** How long a challenge stays signable. */
|
||||
const TTL = 600;
|
||||
|
||||
const PURPOSE_LOGIN = 'login';
|
||||
const PURPOSE_REGISTER = 'register';
|
||||
const PURPOSE_LINK = 'link';
|
||||
const PURPOSE_CONFIRM = 'confirm';
|
||||
|
||||
private static function salt() {
|
||||
return wp_salt( 'auth' ) . '|sirius-press-challenge|v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a nonce: `<issued_ms>.<random>.<tag>`.
|
||||
*
|
||||
* The tag binds the first two parts to this site's salts, so a nonce
|
||||
* cannot be invented elsewhere and the server does not have to remember
|
||||
* which ones it gave out.
|
||||
*/
|
||||
public static function issue() {
|
||||
$ts = (int) round( microtime( true ) * 1000 );
|
||||
$rand = bin2hex( random_bytes( 8 ) );
|
||||
$body = $ts . '.' . $rand;
|
||||
return $body . '.' . substr( hash_hmac( 'sha256', $body, self::salt() ), 0, 32 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a nonce's shape, tag and age.
|
||||
*
|
||||
* @return int|WP_Error Issue time in milliseconds.
|
||||
*/
|
||||
public static function inspect( $nonce ) {
|
||||
$parts = explode( '.', (string) $nonce );
|
||||
if ( 3 !== count( $parts ) || ! ctype_digit( $parts[0] ) || ! ctype_xdigit( $parts[1] ) ) {
|
||||
return new WP_Error( 'sirius_bad_nonce', __( 'That sign-in request is malformed. Reload the page and try again.', 'sirius-press' ) );
|
||||
}
|
||||
$expected = substr( hash_hmac( 'sha256', $parts[0] . '.' . $parts[1], self::salt() ), 0, 32 );
|
||||
if ( ! hash_equals( $expected, $parts[2] ) ) {
|
||||
return new WP_Error( 'sirius_bad_nonce', __( 'That sign-in request did not come from this site. Reload the page and try again.', 'sirius-press' ) );
|
||||
}
|
||||
$age = ( round( microtime( true ) * 1000 ) - (int) $parts[0] ) / 1000;
|
||||
if ( $age > self::TTL || $age < -60 ) {
|
||||
return new WP_Error( 'sirius_expired', __( 'That sign-in request has expired. Reload the page and try again.', 'sirius-press' ) );
|
||||
}
|
||||
return (int) $parts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact text to be signed.
|
||||
*
|
||||
* Written to be readable in a wallet's approval dialog, because that is
|
||||
* the only place a user gets to check what they are agreeing to. Anything
|
||||
* in here that a wallet renders as a wall of hex is a security control the
|
||||
* user cannot exercise.
|
||||
*
|
||||
* @param string $nonce
|
||||
* @param string $purpose One of the PURPOSE_* constants.
|
||||
* @return string
|
||||
*/
|
||||
public static function message( $nonce, $purpose = self::PURPOSE_LOGIN ) {
|
||||
$issued = self::inspect( $nonce );
|
||||
$when = is_wp_error( $issued ) ? 0 : (int) floor( $issued / 1000 );
|
||||
|
||||
$lines = array(
|
||||
self::headline( $purpose ),
|
||||
'',
|
||||
'Site: ' . home_url( '/' ),
|
||||
'Purpose: ' . self::purpose_label( $purpose ),
|
||||
'Nonce: ' . $nonce,
|
||||
'Issued: ' . gmdate( 'Y-m-d\TH:i:s\Z', $when ),
|
||||
'',
|
||||
'Signing this proves you control this wallet. It moves no coins.',
|
||||
);
|
||||
return implode( "\n", $lines );
|
||||
}
|
||||
|
||||
private static function headline( $purpose ) {
|
||||
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
|
||||
switch ( $purpose ) {
|
||||
case self::PURPOSE_REGISTER:
|
||||
return sprintf( 'Create an account on %s', $site );
|
||||
case self::PURPOSE_LINK:
|
||||
return sprintf( 'Attach this wallet to your account on %s', $site );
|
||||
case self::PURPOSE_CONFIRM:
|
||||
return sprintf( 'Confirm an action on %s', $site );
|
||||
default:
|
||||
return sprintf( 'Sign in to %s', $site );
|
||||
}
|
||||
}
|
||||
|
||||
private static function purpose_label( $purpose ) {
|
||||
$known = array(
|
||||
self::PURPOSE_LOGIN => 'sign in',
|
||||
self::PURPOSE_REGISTER => 'create account',
|
||||
self::PURPOSE_LINK => 'attach wallet',
|
||||
self::PURPOSE_CONFIRM => 'confirm action',
|
||||
);
|
||||
return isset( $known[ $purpose ] ) ? $known[ $purpose ] : 'sign in';
|
||||
}
|
||||
|
||||
/** Purposes a request is allowed to name. */
|
||||
public static function is_known_purpose( $purpose ) {
|
||||
return in_array(
|
||||
$purpose,
|
||||
array( self::PURPOSE_LOGIN, self::PURPOSE_REGISTER, self::PURPOSE_LINK, self::PURPOSE_CONFIRM ),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signature and return the address that made it.
|
||||
*
|
||||
* Burns the nonce on success, so a captured signature is worth one use and
|
||||
* that use has already happened.
|
||||
*
|
||||
* @param string $nonce
|
||||
* @param string $signature Base64, 65 bytes.
|
||||
* @param string $purpose
|
||||
* @param string $claimed The address the caller says signed. Required by
|
||||
* any caller that will bind the result to an
|
||||
* account; see the note on recovery above. When
|
||||
* given, a recovered address that differs is an
|
||||
* error rather than a new identity.
|
||||
* @return string|WP_Error Normalised CashAddress.
|
||||
*/
|
||||
public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN, $claimed = '' ) {
|
||||
if ( ! self::is_known_purpose( $purpose ) ) {
|
||||
return new WP_Error( 'sirius_bad_purpose', __( 'Unknown sign-in purpose.', 'sirius-press' ) );
|
||||
}
|
||||
$issued = self::inspect( $nonce );
|
||||
if ( is_wp_error( $issued ) ) {
|
||||
return $issued;
|
||||
}
|
||||
if ( self::is_spent( $nonce ) ) {
|
||||
return new WP_Error( 'sirius_replay', __( 'That signature has already been used. Reload the page and sign again.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$raw = base64_decode( (string) $signature, true );
|
||||
if ( false === $raw || 65 !== strlen( $raw ) ) {
|
||||
return new WP_Error( 'sirius_bad_signature', __( 'That is not a wallet signature. It should be a short block of base64 text.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$digest = SP_Message::bip137_digest( self::message( $nonce, $purpose ) );
|
||||
$pubkey = SP_Secp256k1::recover( $raw, $digest );
|
||||
if ( '' === $pubkey ) {
|
||||
return new WP_Error( 'sirius_bad_signature', __( 'That signature does not match the text this site asked you to sign.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$address = SP_CashAddr::from_public_key( $pubkey, SP_Settings::prefix() );
|
||||
if ( '' === $address ) {
|
||||
return new WP_Error( 'sirius_bad_signature', __( 'That signature could not be turned into an address.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
if ( '' !== $claimed ) {
|
||||
$want = SP_CashAddr::normalize( $claimed );
|
||||
if ( '' === $want ) {
|
||||
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||||
}
|
||||
if ( ! hash_equals( $want, $address ) ) {
|
||||
// The signature is well formed but over different bytes than
|
||||
// this site asked for — a copy that lost a line, a wallet that
|
||||
// rewrote the text, or a signature meant for something else.
|
||||
return new WP_Error(
|
||||
'sirius_address_mismatch',
|
||||
__( 'That signature does not match the text this site asked you to sign. Copy the text again exactly as shown, including the blank lines, and sign it once more.', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::spend( $nonce );
|
||||
return $address;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- single use
|
||||
|
||||
private static function spent_key( $nonce ) {
|
||||
return 'sirius_spent_' . substr( hash( 'sha256', $nonce ), 0, 32 );
|
||||
}
|
||||
|
||||
private static function is_spent( $nonce ) {
|
||||
return (bool) get_transient( self::spent_key( $nonce ) );
|
||||
}
|
||||
|
||||
private static function spend( $nonce ) {
|
||||
// Outlives the challenge itself, so a nonce can never come back after
|
||||
// its marker expires but before the signature would have gone stale.
|
||||
set_transient( self::spent_key( $nonce ), 1, self::TTL + 120 );
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- rate limits
|
||||
|
||||
/**
|
||||
* Throttle signature attempts per client.
|
||||
*
|
||||
* Signature recovery is the most expensive thing an unauthenticated
|
||||
* visitor can ask this site to do — on a BCMath host it is a few hundred
|
||||
* milliseconds of CPU each. Without a cap, the login endpoint is a free
|
||||
* denial-of-service amplifier.
|
||||
*
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public static function check_rate_limit( $bucket = 'verify', $max = 20, $window = 300 ) {
|
||||
$key = 'sirius_rl_' . $bucket . '_' . substr( hash( 'sha256', self::client_ip() . wp_salt() ), 0, 24 );
|
||||
$count = (int) get_transient( $key );
|
||||
if ( $count >= $max ) {
|
||||
return new WP_Error(
|
||||
'sirius_rate_limited',
|
||||
__( 'Too many sign-in attempts from this address. Wait a few minutes and try again.', 'sirius-press' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
set_transient( $key, $count + 1, $window );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's address, as well as it can be known.
|
||||
*
|
||||
* Only proxy headers the site owner has explicitly vouched for are
|
||||
* believed. Trusting `X-Forwarded-For` by default would let anyone reset
|
||||
* their own rate limit by inventing a header.
|
||||
*/
|
||||
private static function client_ip() {
|
||||
$remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
|
||||
/**
|
||||
* Filters whether forwarded-for headers may be believed.
|
||||
*
|
||||
* Set true only when this site is genuinely behind a proxy that
|
||||
* overwrites the header — the docker-compose nginx in this repo does.
|
||||
*
|
||||
* @param bool $trust
|
||||
*/
|
||||
if ( ! apply_filters( 'sirius_press_trust_proxy', defined( 'SIRIUS_PRESS_TRUST_PROXY' ) && SIRIUS_PRESS_TRUST_PROXY ) ) {
|
||||
return $remote;
|
||||
}
|
||||
foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ) as $header ) {
|
||||
if ( empty( $_SERVER[ $header ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
|
||||
$first = trim( explode( ',', $value )[0] );
|
||||
if ( filter_var( $first, FILTER_VALIDATE_IP ) ) {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
return $remote;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Signing in.
|
||||
*
|
||||
* The whole substitution happens in one filter. WordPress asks its
|
||||
* `authenticate` chain "who is this?", and this plugin answers first: if the
|
||||
* request carries a wallet signature over a challenge this site issued, the
|
||||
* signer's address identifies the account, and a `WP_User` comes back. Core
|
||||
* then sets its ordinary auth cookie, and from that point on every
|
||||
* capability, role check, nonce and REST permission works exactly as it does
|
||||
* on stock WordPress. Nothing downstream knows the login was different.
|
||||
*
|
||||
* The form degrades honestly. With JavaScript, a button signs the challenge
|
||||
* in the page or hands it to the browser's own wallet. Without it, the
|
||||
* challenge is printed in a box next to a field for the signature — which is
|
||||
* precisely the "Sign message" workflow every desktop BCH wallet already has.
|
||||
* That path is not a courtesy to the JavaScript-averse; it is the path for
|
||||
* someone whose keys live on a machine that never touches this site.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Login {
|
||||
|
||||
const OPT_ALLOW_PASSWORDS = 'sirius_press_allow_password_login';
|
||||
|
||||
public static function hooks() {
|
||||
add_filter( 'authenticate', array( __CLASS__, 'authenticate' ), 5, 3 );
|
||||
add_action( 'login_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
||||
add_action( 'login_form', array( __CLASS__, 'render_form' ) );
|
||||
add_filter( 'login_message', array( __CLASS__, 'login_message' ) );
|
||||
add_action( 'login_footer', array( __CLASS__, 'footer_config' ) );
|
||||
|
||||
if ( ! self::passwords_allowed() ) {
|
||||
// Pull core's password checks out of the chain entirely rather
|
||||
// than letting them run and fail: a site that has turned
|
||||
// passwords off should not have a password oracle on its login
|
||||
// page at all.
|
||||
remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
|
||||
remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
|
||||
add_action( 'login_head', array( __CLASS__, 'hide_password_fields' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether username+password sign-in is still accepted. */
|
||||
public static function passwords_allowed() {
|
||||
return (bool) get_option( self::OPT_ALLOW_PASSWORDS, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* The substitution itself.
|
||||
*
|
||||
* @param null|WP_User|WP_Error $user
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @return null|WP_User|WP_Error
|
||||
*/
|
||||
public static function authenticate( $user, $username, $password ) {
|
||||
if ( $user instanceof WP_User ) {
|
||||
return $user;
|
||||
}
|
||||
// Nonce checking is not applicable here: the signed challenge *is* the
|
||||
// anti-forgery token, and it is stronger than one — it is bound to a
|
||||
// key, single-use, and expires.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
|
||||
return $user;
|
||||
}
|
||||
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
|
||||
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
|
||||
// Optional at sign-in: the recovered address is looked up against real
|
||||
// accounts, so a wrong one simply finds nothing. When the page does
|
||||
// send it, checking it turns "no account uses that wallet" into the
|
||||
// more useful "that signature is not over the text we asked for".
|
||||
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
$limited = SPA_Challenge::check_rate_limit( 'login' );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
|
||||
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN, $claimed );
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
$found = SP_Identity::user_by_address( $address );
|
||||
if ( ! $found ) {
|
||||
if ( SP_Settings::open_registration() ) {
|
||||
return new WP_Error(
|
||||
'sirius_no_account',
|
||||
sprintf(
|
||||
/* translators: %s: URL of the registration page. */
|
||||
__( 'No account here uses that wallet yet. <a href="%s">Create one</a> — it takes one more signature.', 'sirius-press' ),
|
||||
esc_url( SPA_Register::url() )
|
||||
)
|
||||
);
|
||||
}
|
||||
return new WP_Error(
|
||||
'sirius_no_account',
|
||||
__( 'No account on this site uses that wallet, and registration is closed.', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after a wallet signature has been accepted for a user.
|
||||
*
|
||||
* A plugin that wants a second factor can return a WP_Error from the
|
||||
* `authenticate` chain at a later priority; this is the hook that
|
||||
* tells it a wallet proof already succeeded.
|
||||
*
|
||||
* @param WP_User $found
|
||||
* @param string $address
|
||||
*/
|
||||
do_action( 'sirius_press_wallet_authenticated', $found, $address );
|
||||
return $found;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- UI
|
||||
|
||||
public static function enqueue() {
|
||||
self::enqueue_wallet();
|
||||
wp_enqueue_style(
|
||||
'sirius-press-login',
|
||||
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
|
||||
array(),
|
||||
SIRIUS_PRESS_AUTH_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared by the login screen, the registration screen and the profile. */
|
||||
public static function enqueue_wallet() {
|
||||
wp_enqueue_script(
|
||||
'sirius-press-bip39',
|
||||
SIRIUS_PRESS_AUTH_URL . 'assets/bip39-en.js',
|
||||
array(),
|
||||
SIRIUS_PRESS_AUTH_VERSION,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'sirius-press-wallet',
|
||||
SIRIUS_PRESS_AUTH_URL . 'assets/wallet.js',
|
||||
array( 'sirius-press-bip39' ),
|
||||
SIRIUS_PRESS_AUTH_VERSION,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'sirius-press-login-js',
|
||||
SIRIUS_PRESS_AUTH_URL . 'assets/login.js',
|
||||
array( 'sirius-press-wallet' ),
|
||||
SIRIUS_PRESS_AUTH_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/** Configuration the scripts need, printed once. */
|
||||
public static function footer_config() {
|
||||
self::print_config();
|
||||
}
|
||||
|
||||
public static function print_config() {
|
||||
static $printed = false;
|
||||
if ( $printed ) {
|
||||
return;
|
||||
}
|
||||
$printed = true;
|
||||
printf(
|
||||
'<script>window.SIRIUS_PRESS = %s;</script>',
|
||||
wp_json_encode(
|
||||
array(
|
||||
'prefix' => SP_Settings::prefix(),
|
||||
'path' => SP_Settings::derivation_path(),
|
||||
'network' => SP_Settings::network(),
|
||||
'site' => home_url( '/' ),
|
||||
'strings' => array(
|
||||
'signing' => __( 'Signing…', 'sirius-press' ),
|
||||
'noWallet' => __( 'No wallet found in this browser.', 'sirius-press' ),
|
||||
'generated' => __( 'Write these words down. They are the only way back into this account — nobody, including this site, can reset them for you.', 'sirius-press' ),
|
||||
),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet block on wp-login.php.
|
||||
*
|
||||
* Rendered by `login_form`, which puts it directly under the password
|
||||
* field, so the wallet path reads as the primary one and passwords as the
|
||||
* leftover they are.
|
||||
*/
|
||||
public static function render_form() {
|
||||
$nonce = SPA_Challenge::issue();
|
||||
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LOGIN );
|
||||
self::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_LOGIN, __( 'Sign in with your wallet', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The signing widget, shared by login and registration.
|
||||
*
|
||||
* @param string $nonce
|
||||
* @param string $message Exact text to be signed.
|
||||
* @param string $purpose
|
||||
* @param string $button Label for the primary action.
|
||||
*/
|
||||
public static function render_signing_block( $nonce, $message, $purpose, $button ) {
|
||||
?>
|
||||
<div class="sirius-wallet" data-sirius-purpose="<?php echo esc_attr( $purpose ); ?>">
|
||||
<input type="hidden" name="sirius_nonce" value="<?php echo esc_attr( $nonce ); ?>" />
|
||||
<input type="hidden" name="sirius_purpose" value="<?php echo esc_attr( $purpose ); ?>" />
|
||||
<?php
|
||||
/*
|
||||
* The address the signer claims. Filled in by the script after
|
||||
* signing; typed by hand on the paste-a-signature path.
|
||||
*
|
||||
* Not decoration: recovering a key from a signature always
|
||||
* succeeds, so without something to compare against, a signature
|
||||
* over the wrong text yields a stranger's address instead of an
|
||||
* error. See the note in class-spa-challenge.php.
|
||||
*/
|
||||
?>
|
||||
<input type="hidden" name="sirius_address" class="sirius-wallet__address" value="" />
|
||||
<textarea
|
||||
class="sirius-wallet__message"
|
||||
readonly
|
||||
rows="8"
|
||||
aria-label="<?php esc_attr_e( 'The exact text to sign', 'sirius-press' ); ?>"
|
||||
><?php echo esc_textarea( $message ); ?></textarea>
|
||||
|
||||
<div class="sirius-wallet__actions">
|
||||
<button type="button" class="button button-primary sirius-wallet__sign" hidden>
|
||||
<?php echo esc_html( $button ); ?>
|
||||
</button>
|
||||
<button type="button" class="button sirius-wallet__external" hidden>
|
||||
<?php esc_html_e( 'Use the browser wallet', 'sirius-press' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sirius-wallet__phrase" hidden>
|
||||
<label for="sirius_phrase_<?php echo esc_attr( $purpose ); ?>">
|
||||
<?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?>
|
||||
</label>
|
||||
<textarea
|
||||
id="sirius_phrase_<?php echo esc_attr( $purpose ); ?>"
|
||||
class="sirius-wallet__phrase-input"
|
||||
rows="2"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<p class="sirius-wallet__hint">
|
||||
<?php esc_html_e( 'Typed here, the phrase never leaves this page — only the signature is sent. If you would rather not type it, sign the text above in your own wallet and paste the result below.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details class="sirius-wallet__manual">
|
||||
<summary><?php esc_html_e( 'Paste a signature instead', 'sirius-press' ); ?></summary>
|
||||
<p class="sirius-wallet__hint">
|
||||
<?php esc_html_e( 'Sign the text above in any Bitcoin Cash wallet — Electron Cash calls it “Sign message” — and paste what it gives you.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
<textarea
|
||||
name="sirius_signature"
|
||||
class="sirius-wallet__signature"
|
||||
rows="3"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>"
|
||||
></textarea>
|
||||
<label class="sirius-wallet__address-label" for="sirius_address_<?php echo esc_attr( $purpose ); ?>">
|
||||
<?php esc_html_e( 'The address you signed with', 'sirius-press' ); ?>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sirius_address_<?php echo esc_attr( $purpose ); ?>"
|
||||
class="sirius-wallet__address-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
placeholder="<?php echo esc_attr( SP_Settings::prefix() ); ?>:…"
|
||||
/>
|
||||
<p class="sirius-wallet__hint">
|
||||
<?php esc_html_e( 'Your wallet shows this next to the signature. It is checked against the signature, so a mistyped address is caught rather than quietly creating the wrong account.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<p class="sirius-wallet__status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function login_message( $message ) {
|
||||
if ( ! empty( $_GET['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return $message;
|
||||
}
|
||||
$intro = '<p class="message sirius-intro">'
|
||||
. esc_html__( 'This site has no passwords to forget and no email to confirm. Your wallet is the account.', 'sirius-press' )
|
||||
. '</p>';
|
||||
return $intro . $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually retire the password field when passwords are off.
|
||||
*
|
||||
* The submit button stays. It is what the paste-a-signature path — the one
|
||||
* for people whose keys are on another machine — uses to send the form,
|
||||
* and hiding it would leave that path with no way to submit at all.
|
||||
*/
|
||||
public static function hide_password_fields() {
|
||||
echo '<style>'
|
||||
. '#user_pass, label[for="user_pass"], .user-pass-wrap, .forgetmenot + p { display:none !important; }'
|
||||
. '</style>';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Attaching a wallet to an account that does not have one.
|
||||
*
|
||||
* Two kinds of account arrive without an address: the one the installer made
|
||||
* before anybody had signed anything, and any account created by a plugin
|
||||
* that called `wp_insert_user()` directly. Both can sign in with a password —
|
||||
* and then have no way to sign in once passwords are turned off. This screen
|
||||
* is the bridge.
|
||||
*
|
||||
* It demands a signature rather than accepting a typed address. Typing an
|
||||
* address proves nothing: a typo, or a copy-paste of an address belonging to
|
||||
* somebody else, would attach an account to a key its owner cannot use, and
|
||||
* the failure would only show up later at a login screen with no way back.
|
||||
* Signing proves the key exists and is held, right now, by the person at the
|
||||
* keyboard.
|
||||
*
|
||||
* Administrators can still set an address directly from the user editor,
|
||||
* because account recovery has to be possible for someone. That path is in
|
||||
* SP_Identity and is deliberately capability-gated.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Profile {
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'show_user_profile', array( __CLASS__, 'render' ), 5 );
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
||||
add_action( 'personal_options_update', array( __CLASS__, 'save' ), 5 );
|
||||
add_action( 'admin_notices', array( __CLASS__, 'nag' ) );
|
||||
}
|
||||
|
||||
public static function enqueue( $hook ) {
|
||||
if ( 'profile.php' !== $hook ) {
|
||||
return;
|
||||
}
|
||||
SPA_Login::enqueue_wallet();
|
||||
wp_enqueue_style(
|
||||
'sirius-press-login',
|
||||
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
|
||||
array(),
|
||||
SIRIUS_PRESS_AUTH_VERSION
|
||||
);
|
||||
add_action( 'admin_footer', array( 'SPA_Login', 'print_config' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn an administrator who cannot yet sign in the way this site works.
|
||||
*
|
||||
* Shown before they turn passwords off, not after — after is too late.
|
||||
*/
|
||||
public static function nag() {
|
||||
if ( ! is_user_logged_in() || '' !== sirius_press_address() ) {
|
||||
return;
|
||||
}
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
if ( $screen && 'profile' === $screen->id ) {
|
||||
return;
|
||||
}
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'This account has no wallet attached, so it can only sign in with a password. If passwords are turned off, it will be locked out.', 'sirius-press' ),
|
||||
esc_url( admin_url( 'profile.php#sirius-wallet-link' ) ),
|
||||
esc_html__( 'Attach a wallet', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function render( $user ) {
|
||||
$current = SP_Identity::address_of( $user->ID );
|
||||
$nonce = SPA_Challenge::issue();
|
||||
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LINK );
|
||||
?>
|
||||
<h2 id="sirius-wallet-link"><?php esc_html_e( 'Wallet sign-in', 'sirius-press' ); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Current wallet', 'sirius-press' ); ?></th>
|
||||
<td>
|
||||
<?php if ( '' !== $current ) : ?>
|
||||
<code><?php echo esc_html( $current ); ?></code>
|
||||
<p class="description"><?php esc_html_e( 'Signing with the key for this address signs you in.', 'sirius-press' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><strong><?php esc_html_e( 'None yet.', 'sirius-press' ); ?></strong>
|
||||
<?php esc_html_e( 'This account can only sign in with a password until a wallet is attached.', 'sirius-press' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php echo '' !== $current ? esc_html__( 'Change wallet', 'sirius-press' ) : esc_html__( 'Attach a wallet', 'sirius-press' ); ?></th>
|
||||
<td>
|
||||
<?php if ( '' !== $current ) : ?>
|
||||
<p class="description" style="margin-bottom:10px">
|
||||
<?php esc_html_e( 'Signing with a different key moves this account to that key. The old one stops working immediately.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
SPA_Login::render_signing_block(
|
||||
$nonce,
|
||||
$message,
|
||||
SPA_Challenge::PURPOSE_LINK,
|
||||
'' !== $current ? __( 'Sign with the new wallet', 'sirius-press' ) : __( 'Sign to attach', 'sirius-press' )
|
||||
);
|
||||
?>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Signing fills the box above; saving this page applies it.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function save( $user_id ) {
|
||||
// WordPress has already run check_admin_referer( 'update-user_' . $id )
|
||||
// before this action fires.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
|
||||
return;
|
||||
}
|
||||
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
|
||||
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
|
||||
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( get_current_user_id() !== (int) $user_id ) {
|
||||
return; // Only the account holder attaches their own key here.
|
||||
}
|
||||
|
||||
/*
|
||||
* This is the most damaging place to get it wrong. Recovery always
|
||||
* yields some address, so a signature over slightly different text
|
||||
* would move the account to an address nobody holds — locking the
|
||||
* person out of their own site at the next sign-in. Require the claim
|
||||
* and compare.
|
||||
*/
|
||||
if ( '' === $claimed ) {
|
||||
SP_Inbox::add(
|
||||
$user_id,
|
||||
__( 'Wallet not attached', 'sirius-press' ),
|
||||
esc_html__( 'The address you signed with was not sent, so the signature could not be checked against it. Nothing was changed.', 'sirius-press' ),
|
||||
'auth'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LINK, $claimed );
|
||||
if ( is_wp_error( $address ) ) {
|
||||
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $address->get_error_message(), 'auth' );
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = SP_Identity::address_of( $user_id );
|
||||
$result = SP_Identity::set_address( $user_id, $address );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $result->get_error_message(), 'auth' );
|
||||
return;
|
||||
}
|
||||
|
||||
SP_Inbox::add(
|
||||
$user_id,
|
||||
__( 'Wallet attached', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: 1: new address, 2: previous address or a note that there was none. */
|
||||
esc_html__( 'This account now signs in with %1$s. Previously: %2$s.', 'sirius-press' ),
|
||||
'<code>' . esc_html( $address ) . '</code>',
|
||||
'' !== $previous ? '<code>' . esc_html( $previous ) . '</code>' : esc_html__( 'no wallet', 'sirius-press' )
|
||||
),
|
||||
'auth'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* What happens when someone clicks "Lost your password?".
|
||||
*
|
||||
* On stock WordPress that link starts a mail round-trip: the server sends a
|
||||
* reset token to an address it has on file, and whoever reads that mailbox
|
||||
* gets the account. There is no equivalent here, and pretending otherwise
|
||||
* would be the worst thing this plugin could do.
|
||||
*
|
||||
* So the page is honest. It explains that the site cannot reset anything,
|
||||
* because it never held anything to reset — no mailbox, no password, no
|
||||
* recovery key. What it *can* do is help: if the visitor still has their
|
||||
* recovery phrase, the in-page wallet derives the address and they can sign
|
||||
* straight back in. If they do not, the account is gone, and saying so
|
||||
* plainly is better than a reset form that quietly does nothing.
|
||||
*
|
||||
* The one recovery route that does exist is social, not technical: an
|
||||
* administrator can change the address on an account from the user editor.
|
||||
* That is deliberate — it means account recovery on a Sirius Press site is a
|
||||
* decision a human makes, not a capability an attacker can trigger by
|
||||
* compromising a mailbox.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Recovery {
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'login_form_lostpassword', array( __CLASS__, 'render' ) );
|
||||
add_action( 'login_form_retrievepassword', array( __CLASS__, 'render' ) );
|
||||
// `resetpass` / `rp` only ever arrive from a link this site can no
|
||||
// longer send; treat them as the dead ends they are.
|
||||
add_action( 'login_form_resetpass', array( __CLASS__, 'render' ) );
|
||||
add_action( 'login_form_rp', array( __CLASS__, 'render' ) );
|
||||
add_filter( 'lostpassword_url', array( __CLASS__, 'url' ), 10, 0 );
|
||||
add_filter( 'allow_password_reset', '__return_false' );
|
||||
}
|
||||
|
||||
public static function url() {
|
||||
return add_query_arg( 'action', 'lostpassword', wp_login_url() );
|
||||
}
|
||||
|
||||
public static function render() {
|
||||
if ( SPA_Login::passwords_allowed() && ! empty( $_GET['sirius_force'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return;
|
||||
}
|
||||
|
||||
login_header( __( 'Recovering an account', 'sirius-press' ) );
|
||||
SPA_Login::print_config();
|
||||
?>
|
||||
<div class="sirius-recovery">
|
||||
<p class="message">
|
||||
<?php esc_html_e( 'This site cannot reset your account, and that is on purpose.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<?php esc_html_e( 'There is no password on file to replace and no email address to send a link to. Your account is a wallet — whoever can sign with its key is the account holder, and this site has never been able to sign on your behalf.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
|
||||
<h2><?php esc_html_e( 'If you still have your recovery phrase', 'sirius-press' ); ?></h2>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: sign-in URL. */
|
||||
wp_kses_post( __( 'You are not locked out at all. Go to <a href="%s">the sign-in page</a>, enter your phrase there, and sign the challenge. Nothing needs recovering.', 'sirius-press' ) ),
|
||||
esc_url( wp_login_url() )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
|
||||
<h2><?php esc_html_e( 'If you have lost the phrase', 'sirius-press' ); ?></h2>
|
||||
<p>
|
||||
<?php esc_html_e( 'Then the key is gone, and so is the account — no part of this site can bring it back. The only route left is to ask an administrator to point your account at a new wallet address, which is a decision they make about a person they recognise, not something a form can do.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<?php esc_html_e( 'If you can still sign in with a different wallet, create a new account and ask an administrator to move your content across.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
|
||||
<h2><?php esc_html_e( 'Checking which address a phrase belongs to', 'sirius-press' ); ?></h2>
|
||||
<p class="sirius-hint">
|
||||
<?php esc_html_e( 'If you have several phrases and are not sure which one this account uses, work it out here. The phrase is not sent anywhere — the address is computed in this page.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
<div class="sirius-derive">
|
||||
<label for="sirius_derive_phrase"><?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?></label>
|
||||
<textarea id="sirius_derive_phrase" rows="3" autocomplete="off" spellcheck="false"></textarea>
|
||||
<p>
|
||||
<button type="button" class="button" id="sirius_derive_go">
|
||||
<?php esc_html_e( 'Show the address', 'sirius-press' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<p class="sirius-derive__out" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="nav">
|
||||
<a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '← Sign in', 'sirius-press' ); ?></a>
|
||||
</p>
|
||||
<?php
|
||||
login_footer();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Creating an account without an email address.
|
||||
*
|
||||
* Stock WordPress registration is a two-step dance around a mailbox: you hand
|
||||
* over an address, it sends you a link, you click it, and the click is the
|
||||
* proof. The proof here is a signature, which arrives in the same request —
|
||||
* so registration is one step, and there is no pending state, no confirmation
|
||||
* link to expire, and no way for a typo'd address to strand an account.
|
||||
*
|
||||
* This lives at `wp-login.php?action=sirius_register` rather than replacing
|
||||
* core's `action=register`, so the plugins work unmodified on a stock
|
||||
* WordPress too. The core patch in `patches/` is then a two-line redirect
|
||||
* instead of a rewritten form — which is the whole reason the fork's diff
|
||||
* against upstream stays small enough to merge security releases into.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Register {
|
||||
|
||||
const ACTION = 'sirius_register';
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'login_form_' . self::ACTION, array( __CLASS__, 'handle' ) );
|
||||
add_filter( 'register_url', array( __CLASS__, 'filter_register_url' ) );
|
||||
// Core's own registration form cannot work here — it requires an email
|
||||
// address — so send anyone who reaches it to the wallet flow.
|
||||
add_action( 'login_form_register', array( __CLASS__, 'redirect_core_register' ) );
|
||||
}
|
||||
|
||||
public static function url() {
|
||||
return add_query_arg( 'action', self::ACTION, wp_login_url() );
|
||||
}
|
||||
|
||||
public static function filter_register_url( $url ) {
|
||||
return self::url();
|
||||
}
|
||||
|
||||
public static function redirect_core_register() {
|
||||
wp_safe_redirect( self::url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Render and process the registration screen. */
|
||||
public static function handle() {
|
||||
if ( is_user_logged_in() ) {
|
||||
wp_safe_redirect( admin_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
$errors = new WP_Error();
|
||||
|
||||
if ( 'POST' === strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET' ) ) {
|
||||
$result = self::process();
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$errors = $result;
|
||||
} else {
|
||||
// Straight in. There is nothing left to confirm, so making
|
||||
// someone sign a second time to start their session would be
|
||||
// ceremony, not security.
|
||||
wp_set_current_user( $result );
|
||||
wp_set_auth_cookie( $result, false );
|
||||
do_action( 'wp_login', get_userdata( $result )->user_login, get_userdata( $result ) );
|
||||
wp_safe_redirect( apply_filters( 'sirius_press_after_register_redirect', admin_url(), $result ) );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
self::render( $errors );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|WP_Error User ID.
|
||||
*/
|
||||
private static function process() {
|
||||
if ( ! SP_Settings::open_registration() ) {
|
||||
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$limited = SPA_Challenge::check_rate_limit( 'register', 10, 900 );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- the signed challenge is the anti-forgery proof.
|
||||
$nonce = isset( $_POST['sirius_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) ) : '';
|
||||
$signature = isset( $_POST['sirius_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) ) : '';
|
||||
$login = isset( $_POST['user_login'] ) ? sanitize_user( wp_unslash( $_POST['user_login'] ), true ) : '';
|
||||
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( '' === $signature || '' === $nonce ) {
|
||||
return new WP_Error( 'sirius_missing', __( 'Sign the text above before submitting.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
/*
|
||||
* The claimed address is required here, unlike at sign-in. Recovering
|
||||
* a key from a signature always succeeds, so without something to
|
||||
* compare against, a signature over the wrong text would create an
|
||||
* account bound to an address the registrant cannot sign for — and
|
||||
* they would only find out the next time they tried to get in.
|
||||
*/
|
||||
if ( '' === $claimed ) {
|
||||
return new WP_Error(
|
||||
'sirius_missing_address',
|
||||
__( 'Tell us which address you signed with, so the signature can be checked against it.', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
|
||||
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_REGISTER, $claimed );
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
$existing = SP_Identity::user_by_address( $address );
|
||||
if ( $existing ) {
|
||||
return new WP_Error(
|
||||
'sirius_exists',
|
||||
sprintf(
|
||||
/* translators: %s: login URL. */
|
||||
__( 'That wallet already has an account here. <a href="%s">Sign in</a> instead.', 'sirius-press' ),
|
||||
esc_url( wp_login_url() )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' !== $login && username_exists( $login ) ) {
|
||||
return new WP_Error( 'sirius_username_taken', __( 'That username is taken. Pick another.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
return SP_Identity::create_user( $address, $login );
|
||||
}
|
||||
|
||||
private static function render( WP_Error $errors ) {
|
||||
$nonce = SPA_Challenge::issue();
|
||||
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_REGISTER );
|
||||
$open = SP_Settings::open_registration();
|
||||
|
||||
login_header(
|
||||
__( 'Create an account', 'sirius-press' ),
|
||||
'',
|
||||
$errors
|
||||
);
|
||||
SPA_Login::print_config();
|
||||
?>
|
||||
<form name="siriusregisterform" id="siriusregisterform" action="<?php echo esc_url( self::url() ); ?>" method="post">
|
||||
<?php if ( ! $open ) : ?>
|
||||
<p><?php esc_html_e( 'This site is not accepting new accounts right now.', 'sirius-press' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p>
|
||||
<label for="user_login"><?php esc_html_e( 'Username (optional)', 'sirius-press' ); ?></label>
|
||||
<input type="text" name="user_login" id="user_login" class="input" value="" size="20"
|
||||
autocapitalize="off" autocomplete="username" />
|
||||
</p>
|
||||
<p class="description sirius-hint">
|
||||
<?php esc_html_e( 'Leave blank and one is made from your address. There is no email field, and there will not be a confirmation link — the signature below is the confirmation.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
|
||||
<?php SPA_Login::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_REGISTER, __( 'Create account', 'sirius-press' ) ); ?>
|
||||
|
||||
<p class="submit">
|
||||
<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large"
|
||||
value="<?php esc_attr_e( 'Create account', 'sirius-press' ); ?>" />
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<p id="nav">
|
||||
<a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '← Sign in', 'sirius-press' ); ?></a>
|
||||
</p>
|
||||
<?php
|
||||
login_footer();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The wallet-auth REST surface.
|
||||
*
|
||||
* Two audiences. Themes and front-end code that want a sign-in form somewhere
|
||||
* other than `wp-login.php`, and other plugins that want to demand a fresh
|
||||
* signature before doing something irreversible — deleting a site, moving
|
||||
* money, transferring a name. The second is the reason `confirm` exists: a
|
||||
* capability check proves what a session is allowed to do, but it cannot
|
||||
* prove that the person holding the key is still at the keyboard. A fresh
|
||||
* signature can.
|
||||
*
|
||||
* Everything here is unauthenticated by design except `confirm`, and every
|
||||
* endpoint that does curve maths is rate limited, because signature recovery
|
||||
* is the most expensive thing a stranger can make this server do.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_REST {
|
||||
|
||||
const NS = 'sirius-press/v1';
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route(
|
||||
self::NS,
|
||||
'/challenge',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'permission_callback' => '__return_true',
|
||||
'callback' => array( __CLASS__, 'challenge' ),
|
||||
'args' => array(
|
||||
'purpose' => array(
|
||||
'type' => 'string',
|
||||
'default' => SPA_Challenge::PURPOSE_LOGIN,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::NS,
|
||||
'/login',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'permission_callback' => '__return_true',
|
||||
'callback' => array( __CLASS__, 'login' ),
|
||||
'args' => self::signature_args(),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::NS,
|
||||
'/register',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'permission_callback' => '__return_true',
|
||||
'callback' => array( __CLASS__, 'register' ),
|
||||
'args' => self::signature_args(),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::NS,
|
||||
'/confirm',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'callback' => array( __CLASS__, 'confirm' ),
|
||||
'args' => self::signature_args(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static function signature_args() {
|
||||
return array(
|
||||
'nonce' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'signature' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Hand out something to sign. */
|
||||
public static function challenge( WP_REST_Request $request ) {
|
||||
$limited = SPA_Challenge::check_rate_limit( 'challenge', 60, 300 );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
$purpose = (string) $request->get_param( 'purpose' );
|
||||
if ( ! SPA_Challenge::is_known_purpose( $purpose ) ) {
|
||||
return new WP_Error( 'sirius_bad_purpose', __( 'Unknown purpose.', 'sirius-press' ), array( 'status' => 400 ) );
|
||||
}
|
||||
if ( SPA_Challenge::PURPOSE_REGISTER === $purpose && ! SP_Settings::open_registration() ) {
|
||||
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ), array( 'status' => 403 ) );
|
||||
}
|
||||
$nonce = SPA_Challenge::issue();
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'nonce' => $nonce,
|
||||
'message' => SPA_Challenge::message( $nonce, $purpose ),
|
||||
'purpose' => $purpose,
|
||||
'expires_in' => SPA_Challenge::TTL,
|
||||
'prefix' => SP_Settings::prefix(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function login( WP_REST_Request $request ) {
|
||||
$limited = SPA_Challenge::check_rate_limit( 'login' );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
$address = SPA_Challenge::verify(
|
||||
(string) $request->get_param( 'nonce' ),
|
||||
(string) $request->get_param( 'signature' ),
|
||||
SPA_Challenge::PURPOSE_LOGIN
|
||||
);
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return self::with_status( $address, 401 );
|
||||
}
|
||||
$user = SP_Identity::user_by_address( $address );
|
||||
if ( ! $user ) {
|
||||
return new WP_Error(
|
||||
'sirius_no_account',
|
||||
__( 'No account on this site uses that wallet.', 'sirius-press' ),
|
||||
array(
|
||||
'status' => 404,
|
||||
'address' => $address,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$remember = (bool) $request->get_param( 'remember' );
|
||||
wp_set_current_user( $user->ID );
|
||||
wp_set_auth_cookie( $user->ID, $remember );
|
||||
do_action( 'wp_login', $user->user_login, $user );
|
||||
do_action( 'sirius_press_wallet_authenticated', $user, $address );
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'ok' => true,
|
||||
'user_id' => (int) $user->ID,
|
||||
'address' => $address,
|
||||
'redirect' => user_can( $user, 'read' ) ? admin_url() : home_url( '/' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function register( WP_REST_Request $request ) {
|
||||
if ( ! SP_Settings::open_registration() ) {
|
||||
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ), array( 'status' => 403 ) );
|
||||
}
|
||||
$limited = SPA_Challenge::check_rate_limit( 'register', 10, 900 );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
// Required, for the reason set out in class-spa-challenge.php: recovery
|
||||
// alone would happily mint an account for an address the caller cannot
|
||||
// sign with.
|
||||
$claimed = (string) $request->get_param( 'address' );
|
||||
if ( '' === $claimed ) {
|
||||
return new WP_Error(
|
||||
'sirius_missing_address',
|
||||
__( 'Send the address you signed with, so the signature can be checked against it.', 'sirius-press' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
$address = SPA_Challenge::verify(
|
||||
(string) $request->get_param( 'nonce' ),
|
||||
(string) $request->get_param( 'signature' ),
|
||||
SPA_Challenge::PURPOSE_REGISTER,
|
||||
$claimed
|
||||
);
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return self::with_status( $address, 400 );
|
||||
}
|
||||
$user_id = SP_Identity::create_user( $address, (string) $request->get_param( 'user_login' ) );
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return self::with_status( $user_id, 409 );
|
||||
}
|
||||
wp_set_current_user( $user_id );
|
||||
wp_set_auth_cookie( $user_id, false );
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'ok' => true,
|
||||
'user_id' => (int) $user_id,
|
||||
'address' => $address,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-prove the current user's key.
|
||||
*
|
||||
* Returns 403 rather than 200-with-false when the signature belongs to
|
||||
* somebody else's wallet, so a caller that forgets to check the body still
|
||||
* fails closed.
|
||||
*/
|
||||
public static function confirm( WP_REST_Request $request ) {
|
||||
$limited = SPA_Challenge::check_rate_limit( 'confirm', 30, 300 );
|
||||
if ( is_wp_error( $limited ) ) {
|
||||
return $limited;
|
||||
}
|
||||
$address = SPA_Challenge::verify(
|
||||
(string) $request->get_param( 'nonce' ),
|
||||
(string) $request->get_param( 'signature' ),
|
||||
SPA_Challenge::PURPOSE_CONFIRM
|
||||
);
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return self::with_status( $address, 400 );
|
||||
}
|
||||
$mine = SP_Identity::address_of( get_current_user_id() );
|
||||
if ( '' === $mine || ! hash_equals( $mine, $address ) ) {
|
||||
return new WP_Error(
|
||||
'sirius_wrong_wallet',
|
||||
__( 'That signature is from a different wallet than the one signed in.', 'sirius-press' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Fires when a signed-in user re-proves their key.
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param string $address
|
||||
*/
|
||||
do_action( 'sirius_press_wallet_confirmed', get_current_user_id(), $address );
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'ok' => true,
|
||||
'address' => $address,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Attach an HTTP status to a WP_Error that was created without one. */
|
||||
private static function with_status( WP_Error $error, $status ) {
|
||||
$data = $error->get_error_data();
|
||||
if ( ! is_array( $data ) ) {
|
||||
$data = array();
|
||||
}
|
||||
if ( empty( $data['status'] ) ) {
|
||||
$data['status'] = $status;
|
||||
}
|
||||
$error->add_data( $data, $error->get_error_code() );
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The one auth decision a site owner has to make.
|
||||
*
|
||||
* Passwords stay on by default, and that is not timidity — a fresh install
|
||||
* has exactly one account, created by the installer, with no wallet attached
|
||||
* yet. Shipping with passwords off would mean the first thing a new site does
|
||||
* is lock out its own administrator.
|
||||
*
|
||||
* The screen therefore refuses to turn passwords off until the person doing
|
||||
* it has a wallet on their own account. That check is the difference between
|
||||
* a setting and a trapdoor.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPA_Settings {
|
||||
|
||||
const PAGE = 'sirius-press-auth';
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'menu' ), 20 );
|
||||
}
|
||||
|
||||
public static function menu() {
|
||||
add_submenu_page(
|
||||
'sirius-press',
|
||||
__( 'Sign-in', 'sirius-press' ),
|
||||
__( 'Sign-in', 'sirius-press' ),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( __CLASS__, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function render() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to configure this site.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$errors = array();
|
||||
$notes = array();
|
||||
|
||||
if ( isset( $_POST['sirius_auth_settings'] ) && check_admin_referer( 'sirius_auth_settings' ) ) {
|
||||
$want_passwords = ! empty( $_POST['sirius_allow_passwords'] );
|
||||
|
||||
if ( ! $want_passwords && '' === sirius_press_address() ) {
|
||||
$errors[] = __( 'Attach a wallet to your own account first. Turning passwords off right now would lock you out of this site with no way back in.', 'sirius-press' );
|
||||
} else {
|
||||
update_option( SPA_Login::OPT_ALLOW_PASSWORDS, $want_passwords );
|
||||
$notes[] = $want_passwords
|
||||
? __( 'Password sign-in is on.', 'sirius-press' )
|
||||
: __( 'Password sign-in is off. Only wallet signatures are accepted from now on.', 'sirius-press' );
|
||||
}
|
||||
|
||||
update_option( SP_Settings::OPT_OPEN_REG, ! empty( $_POST['sirius_open_registration'] ) );
|
||||
}
|
||||
|
||||
$stranded = self::accounts_without_wallets();
|
||||
|
||||
echo '<div class="wrap"><h1>' . esc_html__( 'Sign-in', 'sirius-press' ) . '</h1>';
|
||||
foreach ( $errors as $e ) {
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $e ) );
|
||||
}
|
||||
foreach ( $notes as $n ) {
|
||||
printf( '<div class="notice notice-success"><p>%s</p></div>', esc_html( $n ) );
|
||||
}
|
||||
|
||||
echo '<form method="post">';
|
||||
wp_nonce_field( 'sirius_auth_settings' );
|
||||
echo '<input type="hidden" name="sirius_auth_settings" value="1">';
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
|
||||
echo '<tr><th>' . esc_html__( 'Passwords', 'sirius-press' ) . '</th><td><label>';
|
||||
printf(
|
||||
'<input type="checkbox" name="sirius_allow_passwords" value="1"%s> %s',
|
||||
checked( SPA_Login::passwords_allowed(), true, false ),
|
||||
esc_html__( 'Also accept username and password sign-in', 'sirius-press' )
|
||||
);
|
||||
echo '</label><p class="description">';
|
||||
esc_html_e( 'Leave this on until every account that needs access has a wallet attached. With it off, a wallet signature is the only way in — which is the point of this fork, but it is also final.', 'sirius-press' );
|
||||
echo '</p></td></tr>';
|
||||
|
||||
echo '<tr><th>' . esc_html__( 'Registration', 'sirius-press' ) . '</th><td><label>';
|
||||
printf(
|
||||
'<input type="checkbox" name="sirius_open_registration" value="1"%s> %s',
|
||||
checked( SP_Settings::open_registration(), true, false ),
|
||||
esc_html__( 'Anyone who can sign with a wallet may create an account', 'sirius-press' )
|
||||
);
|
||||
echo '</label></td></tr>';
|
||||
|
||||
echo '</table>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
|
||||
echo '<h2>' . esc_html__( 'Accounts without a wallet', 'sirius-press' ) . '</h2>';
|
||||
if ( ! $stranded ) {
|
||||
echo '<p>' . esc_html__( 'Every account here can sign in with a wallet.', 'sirius-press' ) . '</p>';
|
||||
} else {
|
||||
echo '<p>' . esc_html__( 'These accounts can only sign in with a password. Turning passwords off locks them out.', 'sirius-press' ) . '</p>';
|
||||
echo '<table class="widefat striped" style="max-width:640px"><thead><tr><th>'
|
||||
. esc_html__( 'Account', 'sirius-press' ) . '</th><th>'
|
||||
. esc_html__( 'Role', 'sirius-press' ) . '</th></tr></thead><tbody>';
|
||||
foreach ( $stranded as $user ) {
|
||||
printf(
|
||||
'<tr><td><a href="%s">%s</a></td><td>%s</td></tr>',
|
||||
esc_url( get_edit_user_link( $user->ID ) ),
|
||||
esc_html( $user->user_login ),
|
||||
esc_html( implode( ', ', $user->roles ) )
|
||||
);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
echo '<h2>' . esc_html__( 'For other plugins', 'sirius-press' ) . '</h2>';
|
||||
echo '<p>' . esc_html__( 'A plugin that wants a fresh signature before something irreversible can call sirius_press_confirmation_challenge() and post the answer to:', 'sirius-press' ) . '</p>';
|
||||
echo '<p><code>' . esc_html( rest_url( 'sirius-press/v1/confirm' ) ) . '</code></p>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/** Accounts that would be locked out if passwords were turned off. */
|
||||
private static function accounts_without_wallets() {
|
||||
return get_users(
|
||||
array(
|
||||
'meta_query' => array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => SP_Identity::META_ADDRESS,
|
||||
'compare' => 'NOT EXISTS',
|
||||
),
|
||||
array(
|
||||
'key' => SP_Identity::META_ADDRESS,
|
||||
'value' => '',
|
||||
'compare' => '=',
|
||||
),
|
||||
),
|
||||
'number' => 50,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Sirius Press Auth
|
||||
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||
* Description: Sign in by signing a challenge with a Bitcoin Cash wallet. Replaces email as the account identity; leaves roles, capabilities and sessions exactly as WordPress made them.
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.5
|
||||
* Requires PHP: 7.4
|
||||
* Requires Plugins: sirius-press-core
|
||||
* Author: Silent Mode
|
||||
* Author URI: https://silentmode.st
|
||||
* License: GPL-2.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: sirius-press
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'SIRIUS_PRESS_AUTH_VERSION', '0.1.0' );
|
||||
define( 'SIRIUS_PRESS_AUTH_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'SIRIUS_PRESS_AUTH_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
function () {
|
||||
// `Requires Plugins` keeps this from being activated without the core
|
||||
// plugin, but an installation can still be mid-upgrade or have core
|
||||
// deactivated by hand. Failing with a notice beats a fatal on the
|
||||
// login screen, which would lock everyone out of the site.
|
||||
if ( ! class_exists( 'SP_Message' ) ) {
|
||||
add_action(
|
||||
'admin_notices',
|
||||
function () {
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s</p></div>',
|
||||
esc_html__( 'Sirius Press Auth needs Sirius Press Core to be active. Wallet sign-in is off until it is.', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( array( 'challenge', 'login', 'register', 'recovery', 'rest', 'profile', 'settings' ) as $part ) {
|
||||
require_once SIRIUS_PRESS_AUTH_DIR . 'includes/class-spa-' . $part . '.php';
|
||||
}
|
||||
|
||||
SPA_Login::hooks();
|
||||
SPA_Register::hooks();
|
||||
SPA_Recovery::hooks();
|
||||
SPA_REST::hooks();
|
||||
SPA_Profile::hooks();
|
||||
SPA_Settings::hooks();
|
||||
},
|
||||
5
|
||||
);
|
||||
|
||||
/**
|
||||
* Ask the current user to re-prove their key before something irreversible.
|
||||
*
|
||||
* Intended for other plugins: hand it a purpose string and it returns the
|
||||
* challenge to render; verify the answer with the `/sirius-press/v1/confirm`
|
||||
* endpoint. Exposed as a plain function so a plugin does not have to know
|
||||
* this fork's class names to use it.
|
||||
*
|
||||
* @return array{nonce:string,message:string}
|
||||
*/
|
||||
function sirius_press_confirmation_challenge() {
|
||||
$nonce = SPA_Challenge::issue();
|
||||
return array(
|
||||
'nonce' => $nonce,
|
||||
'message' => SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_CONFIRM ),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Sirius Press Compatibility
|
||||
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||
* Description: Keeps plugins that hard-require an administrator email address installable and activatable on a site that has none.
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.5
|
||||
* Requires PHP: 7.4
|
||||
* Requires Plugins: sirius-press-core
|
||||
* Author: Silent Mode
|
||||
* Author URI: https://silentmode.st
|
||||
* License: GPL-2.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: sirius-press
|
||||
*
|
||||
* @package SiriusPress
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* A large part of the WordPress ecosystem assumes `get_option('admin_email')`
|
||||
* returns something, and a smaller but important part refuses to finish its
|
||||
* setup wizard if it does not. WooCommerce wants a store address for order
|
||||
* emails. Contact Form 7 defaults a form's "To" field to it. Yoast reads it
|
||||
* while building schema. None of these are wrong to do so — they were written
|
||||
* for a WordPress where that value always exists.
|
||||
*
|
||||
* Rather than patch each of them, this plugin makes the assumption true: the
|
||||
* site always has an admin email, and it is permanently undeliverable. The
|
||||
* `.invalid` top-level domain is reserved by RFC 2606 for exactly this — it
|
||||
* is guaranteed never to resolve, anywhere, ever. So a plugin that stores it,
|
||||
* validates it or puts it in a "From" header behaves normally, and a plugin
|
||||
* that tries to *send* to it has its message caught by the Sirius Press inbox
|
||||
* before it reaches a mail transport.
|
||||
*
|
||||
* What this plugin deliberately does NOT do is disable `wp_mail()`. A site
|
||||
* owner who configures SMTP gets working contact forms and working order
|
||||
* receipts, because those go to real addresses that real people gave. The
|
||||
* fork's objection was never to email as a feature — only to email as an
|
||||
* identity.
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'SIRIUS_PRESS_COMPAT_VERSION', '0.1.0' );
|
||||
|
||||
/**
|
||||
* Supply a placeholder wherever WordPress or a plugin reads the admin email.
|
||||
*
|
||||
* Filtered rather than written to the database so that a site owner who does
|
||||
* set a real address keeps it: the filter defers to any non-empty stored
|
||||
* value and only fills a gap.
|
||||
*/
|
||||
function sirius_press_admin_email( $value ) {
|
||||
if ( is_string( $value ) && '' !== trim( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
return 'admin@' . SP_Settings::stub_email_domain();
|
||||
}
|
||||
add_filter( 'option_admin_email', 'sirius_press_admin_email' );
|
||||
add_filter( 'default_option_admin_email', 'sirius_press_admin_email' );
|
||||
add_filter( 'pre_option_new_admin_email', '__return_empty_string' );
|
||||
|
||||
/**
|
||||
* Fill a blank `user_email` on save rather than letting one persist.
|
||||
*
|
||||
* `$user->user_email` is read in thousands of places across the ecosystem,
|
||||
* usually without a null check, so an account with an empty one leaves
|
||||
* warnings and blank "From" headers scattered through other people's code.
|
||||
* An account created before this fork was installed, or by a plugin calling
|
||||
* `wp_insert_user()` directly, can have exactly that.
|
||||
*
|
||||
* Runs late so a plugin that supplies a real address wins.
|
||||
*/
|
||||
add_filter(
|
||||
'wp_pre_insert_user_data',
|
||||
function ( $data, $update, $user_id ) {
|
||||
if ( ! class_exists( 'SP_Identity' ) ) {
|
||||
return $data;
|
||||
}
|
||||
if ( ! empty( $data['user_email'] ) ) {
|
||||
return $data;
|
||||
}
|
||||
$address = $user_id ? SP_Identity::address_of( (int) $user_id ) : '';
|
||||
$data['user_email'] = SP_Identity::stub_email( $address );
|
||||
return $data;
|
||||
},
|
||||
20,
|
||||
3
|
||||
);
|
||||
|
||||
/**
|
||||
* Stop core nagging about an unconfirmed administrator email change.
|
||||
*
|
||||
* The "Your admin email is still <x>, please confirm" prompt sends a
|
||||
* confirmation link to an address that cannot receive it, which makes the
|
||||
* prompt permanent and unactionable.
|
||||
*/
|
||||
add_filter( 'admin_email_check_interval', '__return_zero' );
|
||||
|
||||
/**
|
||||
* WooCommerce.
|
||||
*
|
||||
* Its setup wizard and its system-status report both read the store address
|
||||
* out of `woocommerce_email_from_address`, and the onboarding profiler posts
|
||||
* to a remote service with it. Supplying the placeholder lets the wizard
|
||||
* finish; order emails to real customers are unaffected, because those use
|
||||
* the address the customer typed at checkout.
|
||||
*/
|
||||
add_filter(
|
||||
'pre_option_woocommerce_email_from_address',
|
||||
function ( $value ) {
|
||||
if ( ! empty( $value ) || ! class_exists( 'SP_Settings' ) ) {
|
||||
return $value;
|
||||
}
|
||||
return 'shop@' . SP_Settings::stub_email_domain();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Contact Form 7.
|
||||
*
|
||||
* CF7 validates a form's mail template on save and marks the form as
|
||||
* misconfigured if the "To" address is empty, which it will be on a site with
|
||||
* no admin email. The placeholder makes new forms save cleanly; a site owner
|
||||
* who wants the form to actually deliver replaces it with a real address and
|
||||
* configures SMTP, which is documented in docs/smtp.md.
|
||||
*/
|
||||
add_filter(
|
||||
'wpcf7_default_template',
|
||||
function ( $template, $prop ) {
|
||||
if ( 'mail' !== $prop || ! is_array( $template ) || ! class_exists( 'SP_Settings' ) ) {
|
||||
return $template;
|
||||
}
|
||||
if ( empty( $template['recipient'] ) ) {
|
||||
$template['recipient'] = 'admin@' . SP_Settings::stub_email_domain();
|
||||
}
|
||||
return $template;
|
||||
},
|
||||
10,
|
||||
2
|
||||
);
|
||||
|
||||
/*
|
||||
* Note on is_email(): no filter is needed.
|
||||
*
|
||||
* It would be reasonable to assume WordPress rejects a `.invalid` address,
|
||||
* since RFC 2606 reserves that TLD precisely so it can never resolve — and an
|
||||
* earlier version of this file carried a filter to force such addresses
|
||||
* through on that assumption. The assumption is wrong. `is_email()` validates
|
||||
* syntax, not whether a domain could ever exist, so `noreply@….invalid`
|
||||
* already passes and the filter never fired.
|
||||
*
|
||||
* Leaving it in would have been worse than useless: a filter that appears to
|
||||
* relax a validation rule, but does not, is exactly the kind of thing someone
|
||||
* later reasons from. Verified against WordPress 7.1.1.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A short explanation on the plugins screen, next to anything known to want
|
||||
* an address. Better a sentence here than a confused hour later.
|
||||
*/
|
||||
add_action(
|
||||
'after_plugin_row',
|
||||
function ( $plugin_file ) {
|
||||
static $notes = array(
|
||||
'woocommerce/woocommerce.php' => 'WooCommerce will work, including checkout. Order emails need SMTP — see docs/smtp.md.',
|
||||
'contact-form-7/wp-contact-form-7.php' => 'Forms save and submit. Delivery needs SMTP and a real recipient address — see docs/smtp.md.',
|
||||
'wordpress-seo/wp-seo.php' => 'Yoast works unchanged. Its sitemaps are exported to your name along with everything else.',
|
||||
);
|
||||
if ( ! isset( $notes[ $plugin_file ] ) || ! is_plugin_active( $plugin_file ) ) {
|
||||
return;
|
||||
}
|
||||
printf(
|
||||
'<tr class="plugin-update-tr active"><td colspan="4" class="plugin-update"><div class="update-message notice inline notice-info notice-alt"><p>%s</p></div></td></tr>',
|
||||
esc_html( $notes[ $plugin_file ] )
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The Sirius Press settings screen.
|
||||
*
|
||||
* One page, because there are only a handful of decisions to make and they
|
||||
* are all consequential: which name this site is, which network, where the
|
||||
* gateway is, and whether the server is allowed to hold the publishing key.
|
||||
*
|
||||
* The screen does one thing stock WordPress settings screens rarely do: it
|
||||
* checks its own answers against reality. After a phrase is saved it derives
|
||||
* the address, asks the gateway who currently owns the name, and says plainly
|
||||
* whether those match. A mismatch caught here is a sentence of explanation; a
|
||||
* mismatch caught later is a 403 in a cron log that nobody reads.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SP_Admin {
|
||||
|
||||
const PAGE = 'sirius-press';
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
|
||||
add_filter( 'plugin_action_links_' . plugin_basename( SIRIUS_PRESS_CORE_FILE ), array( __CLASS__, 'action_links' ) );
|
||||
}
|
||||
|
||||
public static function action_links( $links ) {
|
||||
array_unshift(
|
||||
$links,
|
||||
'<a href="' . esc_url( admin_url( 'admin.php?page=' . self::PAGE ) ) . '">' . esc_html__( 'Settings', 'sirius-press' ) . '</a>'
|
||||
);
|
||||
return $links;
|
||||
}
|
||||
|
||||
public static function menu() {
|
||||
add_menu_page(
|
||||
__( 'Sirius Press', 'sirius-press' ),
|
||||
__( 'Sirius Press', 'sirius-press' ),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( __CLASS__, 'render' ),
|
||||
'dashicons-admin-site-alt3',
|
||||
71
|
||||
);
|
||||
}
|
||||
|
||||
public static function render() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to configure this site.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$errors = array();
|
||||
$notes = array();
|
||||
|
||||
if ( isset( $_POST['sirius_press_settings'] ) && check_admin_referer( 'sirius_press_settings' ) ) {
|
||||
list( $errors, $notes ) = self::save( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each field sanitised in save().
|
||||
}
|
||||
|
||||
$name = SP_Settings::name();
|
||||
$address = SP_Settings::publishing_address();
|
||||
$mode = SP_Settings::mode();
|
||||
$requirement = sirius_press_requirements_problem();
|
||||
|
||||
echo '<div class="wrap"><h1>' . esc_html__( 'Sirius Press', 'sirius-press' ) . '</h1>';
|
||||
|
||||
if ( '' !== $requirement ) {
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $requirement ) );
|
||||
}
|
||||
foreach ( $errors as $e ) {
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $e ) );
|
||||
}
|
||||
foreach ( $notes as $n ) {
|
||||
printf( '<div class="notice notice-success"><p>%s</p></div>', wp_kses_post( $n ) );
|
||||
}
|
||||
|
||||
echo '<form method="post">';
|
||||
wp_nonce_field( 'sirius_press_settings' );
|
||||
echo '<input type="hidden" name="sirius_press_settings" value="1" />';
|
||||
|
||||
echo '<h2>' . esc_html__( 'Identity', 'sirius-press' ) . '</h2>';
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
|
||||
self::text_row(
|
||||
'sirius_name',
|
||||
__( 'BCNR name', 'sirius-press' ),
|
||||
$name,
|
||||
__( 'The name this site publishes under, exactly as registered — for example <code>example.bch</code>. Point that name\'s <code>p</code> or <code>ip</code> record at this server so visitors reach it.', 'sirius-press' )
|
||||
);
|
||||
|
||||
echo '<tr><th><label for="sirius_network">' . esc_html__( 'Network', 'sirius-press' ) . '</label></th><td>';
|
||||
echo '<select name="sirius_network" id="sirius_network">';
|
||||
foreach ( array(
|
||||
'mainnet' => __( 'Mainnet (bitcoincash:…)', 'sirius-press' ),
|
||||
'chipnet' => __( 'Chipnet (bchtest:…) — testing only', 'sirius-press' ),
|
||||
) as $value => $label ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $value ),
|
||||
selected( SP_Settings::network(), $value, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<p class="description">' . esc_html__( 'Accounts on this site must use addresses from this network. Changing it after people have signed up locks them out.', 'sirius-press' ) . '</p>';
|
||||
echo '</td></tr>';
|
||||
|
||||
self::text_row(
|
||||
'sirius_gateway',
|
||||
__( 'Gateway', 'sirius-press' ),
|
||||
SP_Settings::gateway(),
|
||||
__( 'The BNS gateway that accepts static exports. Leave as the default unless you run your own.', 'sirius-press' )
|
||||
);
|
||||
|
||||
echo '</table>';
|
||||
|
||||
echo '<h2>' . esc_html__( 'Publishing', 'sirius-press' ) . '</h2>';
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
|
||||
echo '<tr><th>' . esc_html__( 'How exports are signed', 'sirius-press' ) . '</th><td><fieldset>';
|
||||
printf(
|
||||
'<label><input type="radio" name="sirius_mode" value="%s"%s> %s</label><p class="description" style="margin:4px 0 12px 24px">%s</p>',
|
||||
esc_attr( SP_Settings::MODE_MANUAL ),
|
||||
checked( $mode, SP_Settings::MODE_MANUAL, false ),
|
||||
esc_html__( 'Manual — nothing is stored on this server', 'sirius-press' ),
|
||||
esc_html__( 'Exports queue up and are signed from your browser when you visit the export screen. Safest, but a post published by a schedule sits in the queue until somebody signs in.', 'sirius-press' )
|
||||
);
|
||||
printf(
|
||||
'<label><input type="radio" name="sirius_mode" value="%s"%s> %s</label><p class="description" style="margin:4px 0 0 24px">%s</p>',
|
||||
esc_attr( SP_Settings::MODE_SERVER ),
|
||||
checked( $mode, SP_Settings::MODE_SERVER, false ),
|
||||
esc_html__( 'Automatic — this server holds the publishing key', 'sirius-press' ),
|
||||
esc_html__( 'Posts export the moment they go live, with no one present. The trade is real: the recovery phrase below is the phrase that owns the name and its funds, and anyone who can read this server\'s database and its wp-config.php can take both.', 'sirius-press' )
|
||||
);
|
||||
echo '</fieldset></td></tr>';
|
||||
|
||||
echo '<tr><th><label for="sirius_phrase">' . esc_html__( 'Recovery phrase', 'sirius-press' ) . '</label></th><td>';
|
||||
printf(
|
||||
'<textarea name="sirius_phrase" id="sirius_phrase" rows="3" class="large-text code" autocomplete="off" spellcheck="false" placeholder="%s"></textarea>',
|
||||
esc_attr__( 'twelve words, separated by spaces', 'sirius-press' )
|
||||
);
|
||||
if ( SP_Settings::has_phrase() ) {
|
||||
echo '<p class="description">' . esc_html__( 'A phrase is stored. Leave this blank to keep it, or type a new one to replace it.', 'sirius-press' ) . '</p>';
|
||||
echo '<p><label><input type="checkbox" name="sirius_forget_phrase" value="1"> ' . esc_html__( 'Forget the stored phrase', 'sirius-press' ) . '</label></p>';
|
||||
} else {
|
||||
echo '<p class="description">' . esc_html__( 'Only needed for automatic publishing. It is encrypted before it is written to the database — which protects a stolen database dump, and nothing more.', 'sirius-press' ) . '</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
|
||||
self::text_row(
|
||||
'sirius_path',
|
||||
__( 'Derivation path', 'sirius-press' ),
|
||||
SP_Settings::derivation_path(),
|
||||
__( 'Leave alone unless your wallet was created somewhere that used a different path. The default matches the Sirius portal wallet.', 'sirius-press' )
|
||||
);
|
||||
|
||||
echo '<tr><th>' . esc_html__( 'Publish on update', 'sirius-press' ) . '</th><td><label>';
|
||||
printf(
|
||||
'<input type="checkbox" name="sirius_auto_publish" value="1"%s> %s',
|
||||
checked( SP_Settings::auto_publish(), true, false ),
|
||||
esc_html__( 'Export a page to the name\'s storage whenever it is published or edited', 'sirius-press' )
|
||||
);
|
||||
echo '</label></td></tr>';
|
||||
|
||||
echo '</table>';
|
||||
|
||||
echo '<h2>' . esc_html__( 'Accounts', 'sirius-press' ) . '</h2>';
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
echo '<tr><th>' . esc_html__( 'Registration', 'sirius-press' ) . '</th><td><label>';
|
||||
printf(
|
||||
'<input type="checkbox" name="sirius_open_registration" value="1"%s> %s',
|
||||
checked( SP_Settings::open_registration(), true, false ),
|
||||
esc_html__( 'Anyone who can sign with a wallet may create an account', 'sirius-press' )
|
||||
);
|
||||
echo '</label></td></tr>';
|
||||
self::text_row(
|
||||
'sirius_stub_domain',
|
||||
__( 'Placeholder mail domain', 'sirius-press' ),
|
||||
SP_Settings::stub_email_domain(),
|
||||
__( 'Plugins that insist on an email address get one here. It must stay unroutable — keep the <code>.invalid</code> ending, which is reserved so it can never resolve.', 'sirius-press' )
|
||||
);
|
||||
echo '</table>';
|
||||
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
|
||||
self::render_status( $name, $address );
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/** The reality check: does the stored key actually own the name? */
|
||||
private static function render_status( $name, $address ) {
|
||||
echo '<h2>' . esc_html__( 'Status', 'sirius-press' ) . '</h2>';
|
||||
echo '<table class="widefat striped" style="max-width:860px"><tbody>';
|
||||
|
||||
self::status_row( __( 'Name', 'sirius-press' ), '' !== $name ? $name : __( 'not set', 'sirius-press' ), '' !== $name );
|
||||
self::status_row(
|
||||
__( 'Publishing address', 'sirius-press' ),
|
||||
'' !== $address ? $address : __( 'no phrase stored', 'sirius-press' ),
|
||||
'' !== $address
|
||||
);
|
||||
|
||||
if ( '' === $name ) {
|
||||
echo '</tbody></table>';
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = SP_Gateway::owner_of_name();
|
||||
if ( '' === $owner ) {
|
||||
self::status_row(
|
||||
__( 'On-chain owner', 'sirius-press' ),
|
||||
__( 'the gateway did not answer — it may not have indexed this name yet', 'sirius-press' ),
|
||||
null
|
||||
);
|
||||
} elseif ( '' === $address ) {
|
||||
self::status_row( __( 'On-chain owner', 'sirius-press' ), $owner, null );
|
||||
} elseif ( hash_equals( $owner, $address ) ) {
|
||||
self::status_row(
|
||||
__( 'On-chain owner', 'sirius-press' ),
|
||||
__( 'matches the stored key — this site can publish', 'sirius-press' ),
|
||||
true
|
||||
);
|
||||
} else {
|
||||
self::status_row(
|
||||
__( 'On-chain owner', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: %s: the address that currently owns the name. */
|
||||
__( '%s — which is NOT the stored key. Exports will be refused until the phrase here is the one that owns the name.', 'sirius-press' ),
|
||||
$owner
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
private static function status_row( $label, $value, $ok ) {
|
||||
$mark = true === $ok ? '✓' : ( false === $ok ? '✗' : '·' );
|
||||
$col = true === $ok ? '#007017' : ( false === $ok ? '#b32d2e' : '#646970' );
|
||||
printf(
|
||||
'<tr><th style="width:200px;text-align:left">%s</th><td><span style="color:%s;font-weight:700">%s</span> <code>%s</code></td></tr>',
|
||||
esc_html( $label ),
|
||||
esc_attr( $col ),
|
||||
esc_html( $mark ),
|
||||
esc_html( $value )
|
||||
);
|
||||
}
|
||||
|
||||
private static function text_row( $field, $label, $value, $description ) {
|
||||
printf(
|
||||
'<tr><th><label for="%1$s">%2$s</label></th><td><input type="text" name="%1$s" id="%1$s" value="%3$s" class="regular-text code"><p class="description">%4$s</p></td></tr>',
|
||||
esc_attr( $field ),
|
||||
esc_html( $label ),
|
||||
esc_attr( $value ),
|
||||
wp_kses_post( $description )
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0:string[],1:string[]} errors, notices */
|
||||
private static function save( $post ) {
|
||||
$errors = array();
|
||||
$notes = array();
|
||||
|
||||
$name = strtolower( sanitize_text_field( isset( $post['sirius_name'] ) ? $post['sirius_name'] : '' ) );
|
||||
if ( '' !== $name && ! preg_match( '/^[a-z0-9][a-z0-9.\-]{0,200}\.[a-z0-9\-]+$/', $name ) ) {
|
||||
$errors[] = __( 'That does not look like a BCNR name. A name is a label and a TLD, such as example.bch.', 'sirius-press' );
|
||||
} else {
|
||||
update_option( SP_Settings::OPT_NAME, $name );
|
||||
}
|
||||
|
||||
update_option( SP_Settings::OPT_NETWORK, 'chipnet' === ( isset( $post['sirius_network'] ) ? $post['sirius_network'] : '' ) ? 'chipnet' : 'mainnet' );
|
||||
|
||||
$gateway = esc_url_raw( trim( isset( $post['sirius_gateway'] ) ? $post['sirius_gateway'] : '' ) );
|
||||
if ( '' !== $gateway && 0 !== strpos( $gateway, 'http' ) ) {
|
||||
$errors[] = __( 'The gateway must be a full URL.', 'sirius-press' );
|
||||
} else {
|
||||
update_option( SP_Settings::OPT_GATEWAY, '' === $gateway ? SP_Settings::DEFAULT_GATEWAY : $gateway );
|
||||
}
|
||||
|
||||
update_option(
|
||||
SP_Settings::OPT_MODE,
|
||||
SP_Settings::MODE_SERVER === ( isset( $post['sirius_mode'] ) ? $post['sirius_mode'] : '' )
|
||||
? SP_Settings::MODE_SERVER
|
||||
: SP_Settings::MODE_MANUAL
|
||||
);
|
||||
update_option( SP_Settings::OPT_AUTOPUB, ! empty( $post['sirius_auto_publish'] ) );
|
||||
update_option( SP_Settings::OPT_OPEN_REG, ! empty( $post['sirius_open_registration'] ) );
|
||||
|
||||
$path = sanitize_text_field( isset( $post['sirius_path'] ) ? $post['sirius_path'] : '' );
|
||||
if ( '' !== $path && ! preg_match( "#^m(/\d+'?)*$#", $path ) ) {
|
||||
$errors[] = __( 'That is not a derivation path. It looks like m/44\'/145\'/0\'/0/0.', 'sirius-press' );
|
||||
} else {
|
||||
update_option( SP_Settings::OPT_PATH, $path );
|
||||
}
|
||||
|
||||
$stub = strtolower( sanitize_text_field( isset( $post['sirius_stub_domain'] ) ? $post['sirius_stub_domain'] : '' ) );
|
||||
if ( '' !== $stub && ! preg_match( '/\.invalid$/', $stub ) ) {
|
||||
$errors[] = __( 'The placeholder mail domain must end in .invalid, so that mail to it can never be delivered by accident.', 'sirius-press' );
|
||||
} else {
|
||||
update_option( SP_Settings::OPT_STUB_MAIL, $stub );
|
||||
}
|
||||
|
||||
if ( ! empty( $post['sirius_forget_phrase'] ) ) {
|
||||
SP_Settings::set_phrase( '' );
|
||||
$notes[] = __( 'The stored recovery phrase has been deleted. Automatic publishing is off until a phrase is provided again.', 'sirius-press' );
|
||||
} elseif ( ! empty( $post['sirius_phrase'] ) ) {
|
||||
$problem = SP_Settings::set_phrase( (string) $post['sirius_phrase'] );
|
||||
if ( '' !== $problem ) {
|
||||
$errors[] = $problem;
|
||||
} else {
|
||||
$notes[] = sprintf(
|
||||
/* translators: %s: the derived CashAddress. */
|
||||
__( 'Phrase stored. It publishes from <code>%s</code> — check that this is the address that owns the name.', 'sirius-press' ),
|
||||
esc_html( SP_Settings::publishing_address() )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $errors ) {
|
||||
$notes[] = __( 'Settings saved.', 'sirius-press' );
|
||||
}
|
||||
return array( $errors, $notes );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Arbitrary-precision integers, on whatever the host happens to have.
|
||||
*
|
||||
* Sirius Press has to do secp256k1 arithmetic inside PHP because the whole
|
||||
* identity model is "prove you hold the key". Shared hosts are wildly
|
||||
* inconsistent about which big-number extension they ship: some have GMP,
|
||||
* many only have BCMath, a few have both. Rather than make the fork refuse to
|
||||
* install on half the hosts it targets, every curve operation goes through
|
||||
* this shim and picks a backend at load time.
|
||||
*
|
||||
* GMP is roughly 20x faster and is preferred. BCMath is the fallback; a login
|
||||
* verification costs a few hundred milliseconds there, which is survivable
|
||||
* because it happens once per session, not once per page.
|
||||
*
|
||||
* Numbers are opaque handles: GMP objects on the GMP backend, decimal strings
|
||||
* on the BCMath one. Never do arithmetic on them directly — always via here.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||
|
||||
final class SP_BN {
|
||||
|
||||
const BACKEND_GMP = 'gmp';
|
||||
const BACKEND_BC = 'bc';
|
||||
|
||||
/** @var string|null One of the BACKEND_* constants, or null if neither exists. */
|
||||
private static $backend = null;
|
||||
|
||||
/** Resolve the backend once. Returns null when the host has neither extension. */
|
||||
public static function backend() {
|
||||
if ( null === self::$backend ) {
|
||||
if ( extension_loaded( 'gmp' ) ) {
|
||||
self::$backend = self::BACKEND_GMP;
|
||||
} elseif ( extension_loaded( 'bcmath' ) ) {
|
||||
self::$backend = self::BACKEND_BC;
|
||||
bcscale( 0 );
|
||||
} else {
|
||||
self::$backend = '';
|
||||
}
|
||||
}
|
||||
return '' === self::$backend ? null : self::$backend;
|
||||
}
|
||||
|
||||
/** True when this host can do wallet crypto at all. */
|
||||
public static function available() {
|
||||
return null !== self::backend();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
/** @param string $hex Unprefixed hex, any length, case-insensitive. */
|
||||
public static function from_hex( $hex ) {
|
||||
$hex = ltrim( strtolower( (string) $hex ), '0' );
|
||||
if ( '' === $hex ) {
|
||||
return self::from_int( 0 );
|
||||
}
|
||||
if ( self::BACKEND_GMP === self::backend() ) {
|
||||
return gmp_init( $hex, 16 );
|
||||
}
|
||||
// BCMath has no base conversion, so fold the hex in one nibble at a
|
||||
// time. 64 iterations of bcmul/bcadd — negligible next to the curve.
|
||||
$out = '0';
|
||||
$len = strlen( $hex );
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$out = bcadd( bcmul( $out, '16' ), (string) hexdec( $hex[ $i ] ) );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param string $bin Raw bytes, big-endian. */
|
||||
public static function from_bin( $bin ) {
|
||||
return self::from_hex( bin2hex( $bin ) );
|
||||
}
|
||||
|
||||
public static function from_int( $n ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_init( (string) $n, 10 ) : (string) $n;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- output
|
||||
|
||||
/**
|
||||
* @param mixed $a
|
||||
* @param int $pad Zero-pad to this many hex characters (0 = no padding).
|
||||
*/
|
||||
public static function to_hex( $a, $pad = 64 ) {
|
||||
if ( self::BACKEND_GMP === self::backend() ) {
|
||||
$hex = gmp_strval( $a, 16 );
|
||||
} else {
|
||||
$hex = '';
|
||||
$n = $a;
|
||||
if ( 0 === bccomp( $n, '0' ) ) {
|
||||
$hex = '0';
|
||||
}
|
||||
while ( bccomp( $n, '0' ) > 0 ) {
|
||||
$hex = dechex( (int) bcmod( $n, '16' ) ) . $hex;
|
||||
$n = bcdiv( $n, '16' );
|
||||
}
|
||||
}
|
||||
if ( $pad > 0 && strlen( $hex ) < $pad ) {
|
||||
$hex = str_repeat( '0', $pad - strlen( $hex ) ) . $hex;
|
||||
}
|
||||
return $hex;
|
||||
}
|
||||
|
||||
/** Fixed-width big-endian bytes. */
|
||||
public static function to_bin( $a, $bytes = 32 ) {
|
||||
return hex2bin( self::to_hex( $a, $bytes * 2 ) );
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ arithmetic
|
||||
|
||||
public static function add( $a, $b ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_add( $a, $b ) : bcadd( $a, $b );
|
||||
}
|
||||
|
||||
public static function sub( $a, $b ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_sub( $a, $b ) : bcsub( $a, $b );
|
||||
}
|
||||
|
||||
public static function mul( $a, $b ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_mul( $a, $b ) : bcmul( $a, $b );
|
||||
}
|
||||
|
||||
/** Always returns a non-negative residue, matching gmp_mod's sign rule. */
|
||||
public static function mod( $a, $m ) {
|
||||
if ( self::BACKEND_GMP === self::backend() ) {
|
||||
return gmp_mod( $a, $m );
|
||||
}
|
||||
$r = bcmod( $a, $m );
|
||||
return bccomp( $r, '0' ) < 0 ? bcadd( $r, $m ) : $r;
|
||||
}
|
||||
|
||||
public static function pow_mod( $a, $e, $m ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_powm( $a, $e, $m ) : bcpowmod( $a, $e, $m );
|
||||
}
|
||||
|
||||
/** -1, 0 or 1. */
|
||||
public static function cmp( $a, $b ) {
|
||||
return self::BACKEND_GMP === self::backend() ? gmp_cmp( $a, $b ) : bccomp( $a, $b );
|
||||
}
|
||||
|
||||
public static function is_zero( $a ) {
|
||||
return 0 === self::cmp( $a, self::from_int( 0 ) );
|
||||
}
|
||||
|
||||
public static function is_odd( $a ) {
|
||||
if ( self::BACKEND_GMP === self::backend() ) {
|
||||
return 1 === gmp_intval( gmp_mod( $a, gmp_init( 2 ) ) );
|
||||
}
|
||||
return '1' === bcmod( $a, '2' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Modular inverse by Fermat's little theorem: a^(m-2) mod m.
|
||||
*
|
||||
* Only correct for prime moduli, which is all this library ever uses (the
|
||||
* field prime p and the group order n are both prime). Saves carrying an
|
||||
* extended-Euclid implementation that BCMath would make painful.
|
||||
*/
|
||||
public static function inv_mod( $a, $m ) {
|
||||
return self::pow_mod( $a, self::sub( $m, self::from_int( 2 ) ), $m );
|
||||
}
|
||||
|
||||
/**
|
||||
* The scalar's bits, most-significant first, as a string of '0'/'1'.
|
||||
*
|
||||
* Point multiplication walks these. Deriving them from hex rather than by
|
||||
* repeated division keeps the BCMath path from doing 256 bcdiv calls.
|
||||
*/
|
||||
public static function bits( $a ) {
|
||||
$hex = self::to_hex( $a, 0 );
|
||||
$bits = '';
|
||||
$len = strlen( $hex );
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$bits .= str_pad( decbin( hexdec( $hex[ $i ] ) ), 4, '0', STR_PAD_LEFT );
|
||||
}
|
||||
return ltrim( $bits, '0' );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* CashAddress encoding — the fork's replacement for the email address.
|
||||
*
|
||||
* Wherever WordPress core would have stored, validated or compared a
|
||||
* `user_email`, Sirius Press stores, validates and compares one of these. The
|
||||
* format is the Bitcoin Cash address spec: a human-readable prefix, a colon,
|
||||
* and base32 of [version byte | hash160] with a 40-bit BCH checksum.
|
||||
*
|
||||
* Two prefixes matter in practice: `bitcoincash` for mainnet names and
|
||||
* `bchtest` for chipnet, which is what the test instances and the registrar's
|
||||
* staging flow use. Token-aware forms (types 2 and 3) decode too, because a
|
||||
* name certificate lives at a token address and users copy whichever form
|
||||
* their wallet showed them — but they normalise to the same hash160, so a
|
||||
* user who signs up with one form and signs in with the other is the same
|
||||
* person as far as the auth plugin is concerned.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||
|
||||
final class SP_CashAddr {
|
||||
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
const TYPE_P2PKH = 0;
|
||||
const TYPE_P2SH = 1;
|
||||
const TYPE_P2PKH_TOKEN = 2;
|
||||
const TYPE_P2SH_TOKEN = 3;
|
||||
|
||||
/**
|
||||
* Encode a 20-byte hash160 as a CashAddress.
|
||||
*
|
||||
* @param string $prefix e.g. 'bitcoincash' or 'bchtest'.
|
||||
* @param string $hash 20 raw bytes.
|
||||
* @param int $type One of the TYPE_* constants.
|
||||
* @return string 'prefix:payload', or '' on bad input.
|
||||
*/
|
||||
public static function encode( $prefix, $hash, $type = self::TYPE_P2PKH ) {
|
||||
$prefix = strtolower( (string) $prefix );
|
||||
if ( 20 !== strlen( $hash ) || ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) ) {
|
||||
return '';
|
||||
}
|
||||
// Version byte: type in the high nibble, size code in the low bits.
|
||||
// 20-byte hashes are size code 0, which is every address we mint.
|
||||
$payload = chr( ( $type << 3 ) | 0 ) . $hash;
|
||||
$data = self::convert_bits( self::bytes_to_array( $payload ), 8, 5, true );
|
||||
if ( null === $data ) {
|
||||
return '';
|
||||
}
|
||||
$checksum = self::checksum( $prefix, array_merge( $data, array_fill( 0, 8, 0 ) ) );
|
||||
$out = '';
|
||||
foreach ( array_merge( $data, $checksum ) as $v ) {
|
||||
$out .= self::CHARSET[ $v ];
|
||||
}
|
||||
return $prefix . ':' . $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a CashAddress.
|
||||
*
|
||||
* Accepts the address with or without its prefix; a bare payload is tried
|
||||
* against both known prefixes, because half the UIs in the ecosystem strip
|
||||
* the prefix when they display an address and users paste what they see.
|
||||
*
|
||||
* @param string $addr
|
||||
* @return array{prefix:string,type:int,hash:string}|null
|
||||
*/
|
||||
public static function decode( $addr ) {
|
||||
$addr = trim( (string) $addr );
|
||||
if ( '' === $addr ) {
|
||||
return null;
|
||||
}
|
||||
if ( false !== strpos( $addr, ':' ) ) {
|
||||
list( $prefix, $payload ) = explode( ':', strtolower( $addr ), 2 );
|
||||
return self::decode_parts( $prefix, $payload );
|
||||
}
|
||||
foreach ( array( 'bitcoincash', 'bchtest', 'bchreg' ) as $prefix ) {
|
||||
$r = self::decode_parts( $prefix, strtolower( $addr ) );
|
||||
if ( null !== $r ) {
|
||||
return $r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function decode_parts( $prefix, $payload ) {
|
||||
if ( ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) || ! preg_match( '/^[' . self::CHARSET . ']{8,124}$/', $payload ) ) {
|
||||
return null;
|
||||
}
|
||||
$values = array();
|
||||
$len = strlen( $payload );
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$pos = strpos( self::CHARSET, $payload[ $i ] );
|
||||
if ( false === $pos ) {
|
||||
return null;
|
||||
}
|
||||
$values[] = $pos;
|
||||
}
|
||||
$sum = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $values ) );
|
||||
if ( 0 !== $sum ) {
|
||||
return null;
|
||||
}
|
||||
$data = self::convert_bits( array_slice( $values, 0, count( $values ) - 8 ), 5, 8, false );
|
||||
if ( null === $data || count( $data ) < 21 ) {
|
||||
return null;
|
||||
}
|
||||
$version = $data[0];
|
||||
$hash = self::array_to_bytes( array_slice( $data, 1 ) );
|
||||
// Only the 20-byte size code is ever produced by a wallet in this
|
||||
// ecosystem; anything else is a paste of something that is not an
|
||||
// address we can check a signature against.
|
||||
if ( 0 !== ( $version & 0x07 ) || 20 !== strlen( $hash ) ) {
|
||||
return null;
|
||||
}
|
||||
return array(
|
||||
'prefix' => $prefix,
|
||||
'type' => ( $version >> 3 ) & 0x0f,
|
||||
'hash' => $hash,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `$addr` is a well-formed CashAddress with a valid checksum. */
|
||||
public static function is_valid( $addr ) {
|
||||
return null !== self::decode( $addr );
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form for storage and comparison: lowercase, prefixed, and
|
||||
* always the non-token p2pkh spelling.
|
||||
*
|
||||
* Two spellings of the same key must not become two accounts, so every
|
||||
* address is squashed to this before it is written to or compared against
|
||||
* the users table.
|
||||
*
|
||||
* @return string '' when the input is not an address.
|
||||
*/
|
||||
public static function normalize( $addr ) {
|
||||
$d = self::decode( $addr );
|
||||
if ( null === $d ) {
|
||||
return '';
|
||||
}
|
||||
$type = ( self::TYPE_P2SH === $d['type'] || self::TYPE_P2SH_TOKEN === $d['type'] )
|
||||
? self::TYPE_P2SH
|
||||
: self::TYPE_P2PKH;
|
||||
return self::encode( $d['prefix'], $d['hash'], $type );
|
||||
}
|
||||
|
||||
/** The address a compressed public key controls. */
|
||||
public static function from_public_key( $pubkey_bin, $prefix = 'bitcoincash' ) {
|
||||
if ( 33 !== strlen( $pubkey_bin ) && 65 !== strlen( $pubkey_bin ) ) {
|
||||
return '';
|
||||
}
|
||||
$hash = hash( 'ripemd160', hash( 'sha256', $pubkey_bin, true ), true );
|
||||
return self::encode( $prefix, $hash, self::TYPE_P2PKH );
|
||||
}
|
||||
|
||||
/** A short, human-checkable form for UI: `bchtest:qr4…8ktm`. */
|
||||
public static function shorten( $addr, $head = 8, $tail = 6 ) {
|
||||
$addr = (string) $addr;
|
||||
$pos = strpos( $addr, ':' );
|
||||
$body = false === $pos ? $addr : substr( $addr, $pos + 1 );
|
||||
$pre = false === $pos ? '' : substr( $addr, 0, $pos + 1 );
|
||||
if ( strlen( $body ) <= $head + $tail + 1 ) {
|
||||
return $addr;
|
||||
}
|
||||
return $pre . substr( $body, 0, $head ) . '…' . substr( $body, -$tail );
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- checksum
|
||||
|
||||
private static function expand_prefix( $prefix ) {
|
||||
$out = array();
|
||||
$len = strlen( $prefix );
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$out[] = ord( $prefix[ $i ] ) & 0x1f;
|
||||
}
|
||||
$out[] = 0;
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function checksum( $prefix, $data ) {
|
||||
$mod = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $data ) );
|
||||
$out = array();
|
||||
for ( $i = 0; $i < 8; $i++ ) {
|
||||
$out[] = ( $mod >> ( 5 * ( 7 - $i ) ) ) & 0x1f;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The BCH checksum polynomial over GF(32).
|
||||
*
|
||||
* Works on 40-bit accumulators, so this needs a 64-bit PHP build. 32-bit
|
||||
* PHP has been unsupported by WordPress-adjacent hosting for years; the
|
||||
* core plugin's requirements check says so explicitly rather than letting
|
||||
* addresses silently fail to validate here.
|
||||
*/
|
||||
private static function poly_mod( $values ) {
|
||||
$c = 1;
|
||||
foreach ( $values as $d ) {
|
||||
$c0 = ( $c >> 35 ) & 0xff;
|
||||
$c = ( ( $c & 0x07ffffffff ) << 5 ) ^ $d;
|
||||
if ( $c0 & 0x01 ) {
|
||||
$c ^= 0x98f2bc8e61;
|
||||
}
|
||||
if ( $c0 & 0x02 ) {
|
||||
$c ^= 0x79b76d99e2;
|
||||
}
|
||||
if ( $c0 & 0x04 ) {
|
||||
$c ^= 0xf33e5fb3c4;
|
||||
}
|
||||
if ( $c0 & 0x08 ) {
|
||||
$c ^= 0xae2eabe2a8;
|
||||
}
|
||||
if ( $c0 & 0x10 ) {
|
||||
$c ^= 0x1e4f43e470;
|
||||
}
|
||||
}
|
||||
return $c ^ 1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- bit fiddling
|
||||
|
||||
private static function bytes_to_array( $bin ) {
|
||||
return array_values( unpack( 'C*', $bin ) );
|
||||
}
|
||||
|
||||
private static function array_to_bytes( $arr ) {
|
||||
$out = '';
|
||||
foreach ( $arr as $v ) {
|
||||
$out .= chr( $v & 0xff );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroup a list of values from `$from` bits each to `$to` bits each.
|
||||
*
|
||||
* @param bool $pad Pad the tail (encoding) or require it to be zero (decoding).
|
||||
* @return array|null
|
||||
*/
|
||||
private static function convert_bits( $values, $from, $to, $pad ) {
|
||||
$acc = 0;
|
||||
$bits = 0;
|
||||
$out = array();
|
||||
$max = ( 1 << $to ) - 1;
|
||||
foreach ( $values as $v ) {
|
||||
if ( $v < 0 || ( $v >> $from ) !== 0 ) {
|
||||
return null;
|
||||
}
|
||||
$acc = ( $acc << $from ) | $v;
|
||||
$bits += $from;
|
||||
while ( $bits >= $to ) {
|
||||
$bits -= $to;
|
||||
$out[] = ( $acc >> $bits ) & $max;
|
||||
}
|
||||
}
|
||||
if ( $pad ) {
|
||||
if ( $bits > 0 ) {
|
||||
$out[] = ( $acc << ( $to - $bits ) ) & $max;
|
||||
}
|
||||
} elseif ( $bits >= $from || ( ( $acc << ( $to - $bits ) ) & $max ) ) {
|
||||
return null;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The BNS gateway client.
|
||||
*
|
||||
* Three endpoints matter to this fork:
|
||||
*
|
||||
* GET /api/name/<name> who owns the name right now, so the
|
||||
* settings screen can tell an owner that the
|
||||
* key they pasted is not the one the chain
|
||||
* recognises — before their first publish
|
||||
* fails with a bare 403.
|
||||
* GET /api/site/<name>/<path> read back what was published.
|
||||
* PUT /api/site/<name>/<path> publish, signed BNS-SITE1.
|
||||
*
|
||||
* Every response is treated as untrusted input from a third party. The
|
||||
* gateway is a relay, not an authority: it cannot forge a signature, but it
|
||||
* can lie about what is stored, and nothing here grants it more trust than
|
||||
* "it told us the upload succeeded".
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SP_Gateway {
|
||||
|
||||
/** Gateway's own cap. Anything larger is rejected before the round trip. */
|
||||
const MAX_BYTES = 8388608;
|
||||
|
||||
/**
|
||||
* Publish one file into the name's bucket.
|
||||
*
|
||||
* @param string $path Path inside the bucket, e.g. 'blog/hello/index.html'.
|
||||
* @param string $body Raw bytes.
|
||||
* @param string $mime Content type; guessed from the extension if ''.
|
||||
* @return array{ok:bool,status:int,error:string,bytes:int,sia_key:string}
|
||||
*/
|
||||
public static function put( $path, $body, $mime = '' ) {
|
||||
$name = SP_Settings::name();
|
||||
if ( '' === $name ) {
|
||||
return self::fail( 'This site has no BCNR name configured yet.' );
|
||||
}
|
||||
$path = self::clean_path( $path );
|
||||
if ( '' === $path ) {
|
||||
return self::fail( 'Refusing to publish to an unusable path.' );
|
||||
}
|
||||
if ( strlen( $body ) > self::MAX_BYTES ) {
|
||||
return self::fail( sprintf( 'That file is %s; the gateway accepts 8 MB per file.', size_format( strlen( $body ) ) ) );
|
||||
}
|
||||
|
||||
try {
|
||||
$priv = SP_Settings::publishing_private_key();
|
||||
} catch ( Exception $e ) {
|
||||
return self::fail( $e->getMessage() );
|
||||
}
|
||||
if ( '' === $priv ) {
|
||||
return self::fail( 'No publishing key is stored, so this site cannot sign an upload. Add the name\'s recovery phrase in Sirius Press settings, or switch to manual publishing.' );
|
||||
}
|
||||
|
||||
try {
|
||||
$headers = SP_Message::site_headers( $name, $path, $body, $priv );
|
||||
} catch ( Exception $e ) {
|
||||
return self::fail( 'Signing failed: ' . $e->getMessage() );
|
||||
}
|
||||
$headers['content-type'] = '' !== $mime ? $mime : self::mime_for( $path );
|
||||
|
||||
$res = wp_remote_request(
|
||||
self::url( $name, $path ),
|
||||
array(
|
||||
'method' => 'PUT',
|
||||
'timeout' => 45,
|
||||
'headers' => $headers,
|
||||
'body' => $body,
|
||||
)
|
||||
);
|
||||
return self::interpret( $res );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the bucket — used when a post is unpublished or its
|
||||
* permalink changes, so the static mirror does not keep serving a page the
|
||||
* site no longer has.
|
||||
*/
|
||||
public static function delete( $path ) {
|
||||
$name = SP_Settings::name();
|
||||
$path = self::clean_path( $path );
|
||||
if ( '' === $name || '' === $path ) {
|
||||
return self::fail( 'Nothing to delete.' );
|
||||
}
|
||||
try {
|
||||
$priv = SP_Settings::publishing_private_key();
|
||||
if ( '' === $priv ) {
|
||||
return self::fail( 'No publishing key is stored.' );
|
||||
}
|
||||
$headers = SP_Message::site_headers( $name, $path, '', $priv );
|
||||
} catch ( Exception $e ) {
|
||||
return self::fail( $e->getMessage() );
|
||||
}
|
||||
$res = wp_remote_request(
|
||||
self::url( $name, $path ),
|
||||
array(
|
||||
'method' => 'DELETE',
|
||||
'timeout' => 30,
|
||||
'headers' => $headers,
|
||||
)
|
||||
);
|
||||
return self::interpret( $res );
|
||||
}
|
||||
|
||||
/** Read a published file back. @return string|null */
|
||||
public static function get( $path ) {
|
||||
$name = SP_Settings::name();
|
||||
$path = self::clean_path( $path );
|
||||
if ( '' === $name || '' === $path ) {
|
||||
return null;
|
||||
}
|
||||
$res = wp_remote_get( self::url( $name, $path ), array( 'timeout' => 30 ) );
|
||||
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||
return null;
|
||||
}
|
||||
return wp_remote_retrieve_body( $res );
|
||||
}
|
||||
|
||||
/** List what the bucket currently holds. @return array|null */
|
||||
public static function listing() {
|
||||
$name = SP_Settings::name();
|
||||
if ( '' === $name ) {
|
||||
return null;
|
||||
}
|
||||
$res = wp_remote_get( SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ), array( 'timeout' => 30 ) );
|
||||
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||
return is_array( $json ) && isset( $json['files'] ) ? $json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name's current on-chain owner address, or '' if the gateway will not
|
||||
* say. Used to warn about a key mismatch before it becomes a 403.
|
||||
*/
|
||||
public static function owner_of_name() {
|
||||
$name = SP_Settings::name();
|
||||
if ( '' === $name ) {
|
||||
return '';
|
||||
}
|
||||
$res = wp_remote_get(
|
||||
SP_Settings::gateway() . '/api/name/' . rawurlencode( $name ),
|
||||
array( 'timeout' => 20 )
|
||||
);
|
||||
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||
return '';
|
||||
}
|
||||
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||
if ( ! is_array( $json ) ) {
|
||||
return '';
|
||||
}
|
||||
foreach ( array( 'owner', 'owner_address', 'address' ) as $k ) {
|
||||
if ( ! empty( $json[ $k ] ) && is_string( $json[ $k ] ) ) {
|
||||
return SP_CashAddr::normalize( $json[ $k ] );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- utils
|
||||
|
||||
private static function url( $name, $path ) {
|
||||
$encoded = implode( '/', array_map( 'rawurlencode', explode( '/', $path ) ) );
|
||||
return SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ) . '/' . $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Squeeze a path into what the gateway's own validator accepts:
|
||||
* `[A-Za-z0-9._\-\/]{1,200}`, no `..`, no leading or trailing slash.
|
||||
*
|
||||
* Anything that cannot be represented comes back '' rather than being
|
||||
* silently mangled into a different file.
|
||||
*/
|
||||
public static function clean_path( $path ) {
|
||||
$path = ltrim( str_replace( '\\', '/', (string) $path ), '/' );
|
||||
$path = preg_replace( '#/+#', '/', $path );
|
||||
if ( '' === $path || '/' === substr( $path, -1 ) ) {
|
||||
return '';
|
||||
}
|
||||
if ( false !== strpos( $path, '..' ) || strlen( $path ) > 200 ) {
|
||||
return '';
|
||||
}
|
||||
return preg_match( '#^[A-Za-z0-9._\-/]+$#', $path ) ? $path : '';
|
||||
}
|
||||
|
||||
/** Content types the gateway itself recognises, keyed by extension. */
|
||||
public static function mime_for( $path ) {
|
||||
static $types = array(
|
||||
'html' => 'text/html; charset=utf-8',
|
||||
'htm' => 'text/html; charset=utf-8',
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'js' => 'text/javascript; charset=utf-8',
|
||||
'mjs' => 'text/javascript; charset=utf-8',
|
||||
'json' => 'application/json',
|
||||
'txt' => 'text/plain; charset=utf-8',
|
||||
'md' => 'text/markdown; charset=utf-8',
|
||||
'xml' => 'application/xml',
|
||||
'svg' => 'image/svg+xml',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'avif' => 'image/avif',
|
||||
'ico' => 'image/x-icon',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'pdf' => 'application/pdf',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'mp4' => 'video/mp4',
|
||||
'webm' => 'video/webm',
|
||||
'wasm' => 'application/wasm',
|
||||
);
|
||||
$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
|
||||
return isset( $types[ $ext ] ) ? $types[ $ext ] : 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function fail( $message ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'status' => 0,
|
||||
'error' => $message,
|
||||
'bytes' => 0,
|
||||
'sia_key' => '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn a wp_remote_* result into the shape the queue stores. */
|
||||
private static function interpret( $res ) {
|
||||
if ( is_wp_error( $res ) ) {
|
||||
return self::fail( $res->get_error_message() );
|
||||
}
|
||||
$status = (int) wp_remote_retrieve_response_code( $res );
|
||||
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||
if ( 200 === $status && is_array( $json ) && ! empty( $json['ok'] ) ) {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'status' => $status,
|
||||
'error' => '',
|
||||
'bytes' => isset( $json['bytes'] ) ? (int) $json['bytes'] : 0,
|
||||
'sia_key' => isset( $json['sia_key'] ) ? (string) $json['sia_key'] : '',
|
||||
);
|
||||
}
|
||||
$error = is_array( $json ) && ! empty( $json['error'] )
|
||||
? (string) $json['error']
|
||||
: sprintf( 'gateway returned HTTP %d', $status );
|
||||
// The two failures an operator will actually hit, translated out of
|
||||
// gateway-speak into something that says what to do about it.
|
||||
if ( 403 === $status ) {
|
||||
$error .= ' — the stored publishing key is not the address that currently owns this name.';
|
||||
} elseif ( 503 === $status ) {
|
||||
$error .= ' — the gateway has not indexed this name yet; it usually catches up within a minute.';
|
||||
}
|
||||
$out = self::fail( $error );
|
||||
$out['status'] = $status;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* BIP39 + BIP32, enough of them to turn a recovery phrase into the one key
|
||||
* that matters.
|
||||
*
|
||||
* The publishing wallet is not a new secret invented by the CMS — it is the
|
||||
* same wallet that holds the name's NFT certificate, because the BNS gateway
|
||||
* only accepts writes signed by the current on-chain owner. So Sirius Press
|
||||
* has to walk the identical derivation the portal wallet walks
|
||||
* (Argus/src/lib/wallet-web.js): BIP39 phrase to seed, then m/44'/145'/0'/0/0.
|
||||
*
|
||||
* A different path here would produce a different address, the gateway would
|
||||
* return 403, and the failure would look like a signing bug rather than a
|
||||
* derivation mismatch — so the path is a constant, not a setting, and the
|
||||
* tests pin it against libauth.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||
|
||||
require_once __DIR__ . '/class-sp-bn.php';
|
||||
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||||
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||||
|
||||
final class SP_HD {
|
||||
|
||||
/** BCH coin type 145, account 0, external branch, first address. */
|
||||
const DEFAULT_PATH = "m/44'/145'/0'/0/0";
|
||||
|
||||
/**
|
||||
* BIP39 phrase to 64-byte seed.
|
||||
*
|
||||
* @param string $mnemonic Space-separated words. Normalised the same way
|
||||
* the portal normalises: trimmed, lowercased,
|
||||
* runs of whitespace collapsed.
|
||||
* @param string $passphrase Optional BIP39 passphrase ("25th word").
|
||||
*/
|
||||
public static function seed_from_mnemonic( $mnemonic, $passphrase = '' ) {
|
||||
$clean = self::normalize_mnemonic( $mnemonic );
|
||||
return hash_pbkdf2( 'sha512', $clean, 'mnemonic' . $passphrase, 2048, 64, true );
|
||||
}
|
||||
|
||||
public static function normalize_mnemonic( $mnemonic ) {
|
||||
return trim( preg_replace( '/\s+/u', ' ', strtolower( (string) $mnemonic ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Master key from a seed.
|
||||
*
|
||||
* @return array{key:string,chain:string} 32 raw bytes each.
|
||||
*/
|
||||
public static function master( $seed ) {
|
||||
$i = hash_hmac( 'sha512', $seed, 'Bitcoin seed', true );
|
||||
return array(
|
||||
'key' => substr( $i, 0, 32 ),
|
||||
'chain' => substr( $i, 32, 32 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One CKDpriv step.
|
||||
*
|
||||
* @param array $node {key, chain}.
|
||||
* @param int $index Child index; add 0x80000000 for a hardened step.
|
||||
* @return array{key:string,chain:string}
|
||||
* @throws Exception If the derived key is invalid (probability ~2^-127).
|
||||
*/
|
||||
public static function derive_child( $node, $index ) {
|
||||
$hardened = $index >= 0x80000000;
|
||||
if ( $hardened ) {
|
||||
$data = "\x00" . $node['key'];
|
||||
} else {
|
||||
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||||
if ( '' === $pub ) {
|
||||
throw new Exception( 'cannot derive public key for a hardened-only node' );
|
||||
}
|
||||
$data = $pub;
|
||||
}
|
||||
$data .= pack( 'N', $index );
|
||||
|
||||
$i = hash_hmac( 'sha512', $data, $node['chain'], true );
|
||||
$il = substr( $i, 0, 32 );
|
||||
$ir = substr( $i, 32, 32 );
|
||||
|
||||
$n = SP_BN::from_hex( SP_Secp256k1::N );
|
||||
$tweak = SP_BN::from_bin( $il );
|
||||
if ( SP_BN::cmp( $tweak, $n ) >= 0 ) {
|
||||
throw new Exception( 'derived tweak out of range — pick the next index' );
|
||||
}
|
||||
$child = SP_BN::mod( SP_BN::add( $tweak, SP_BN::from_bin( $node['key'] ) ), $n );
|
||||
if ( SP_BN::is_zero( $child ) ) {
|
||||
throw new Exception( 'derived key is zero — pick the next index' );
|
||||
}
|
||||
return array(
|
||||
'key' => SP_BN::to_bin( $child, 32 ),
|
||||
'chain' => $ir,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a full path such as "m/44'/145'/0'/0/0".
|
||||
*
|
||||
* @return array{key:string,chain:string}
|
||||
* @throws Exception On a malformed path.
|
||||
*/
|
||||
public static function derive_path( $seed, $path = self::DEFAULT_PATH ) {
|
||||
$node = self::master( $seed );
|
||||
$parts = preg_split( '#/#', trim( (string) $path ) );
|
||||
if ( ! $parts || 'm' !== strtolower( $parts[0] ) ) {
|
||||
throw new Exception( 'derivation path must start with "m"' );
|
||||
}
|
||||
foreach ( array_slice( $parts, 1 ) as $part ) {
|
||||
if ( '' === $part ) {
|
||||
continue;
|
||||
}
|
||||
$hardened = ( "'" === substr( $part, -1 ) || 'h' === strtolower( substr( $part, -1 ) ) );
|
||||
$num = (int) rtrim( $part, "'hH" );
|
||||
if ( $num < 0 || $num > 0x7fffffff ) {
|
||||
throw new Exception( "derivation index out of range: {$part}" );
|
||||
}
|
||||
$node = self::derive_child( $node, $hardened ? $num + 0x80000000 : $num );
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The private key and address a phrase publishes with.
|
||||
*
|
||||
* @param string $mnemonic
|
||||
* @param string $prefix 'bitcoincash' or 'bchtest'.
|
||||
* @param string $path Override only for wallets minted on another path
|
||||
* (operator wallets were created on BTC coin type).
|
||||
* @return array{private:string,public:string,address:string}
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function publishing_key( $mnemonic, $prefix = 'bitcoincash', $path = self::DEFAULT_PATH ) {
|
||||
$node = self::derive_path( self::seed_from_mnemonic( $mnemonic ), $path );
|
||||
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||||
if ( '' === $pub ) {
|
||||
throw new Exception( 'derivation produced an unusable key' );
|
||||
}
|
||||
return array(
|
||||
'private' => $node['key'],
|
||||
'public' => $pub,
|
||||
'address' => SP_CashAddr::from_public_key( $pub, $prefix ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-check a phrase before storing it.
|
||||
*
|
||||
* Deliberately structural rather than a full BIP39 checksum test: this
|
||||
* fork must accept phrases from every wallet a name owner might already
|
||||
* use, and refusing one over a wordlist we shipped a year ago is a worse
|
||||
* failure than accepting one that later derives the wrong address. The
|
||||
* real check is the address comparison the settings screen does right
|
||||
* after: derive, show the address, and let the owner confirm it is theirs.
|
||||
*
|
||||
* @return string '' when acceptable, otherwise a human-readable reason.
|
||||
*/
|
||||
public static function phrase_problem( $mnemonic ) {
|
||||
$clean = self::normalize_mnemonic( $mnemonic );
|
||||
if ( '' === $clean ) {
|
||||
return 'The recovery phrase is empty.';
|
||||
}
|
||||
$words = explode( ' ', $clean );
|
||||
$count = count( $words );
|
||||
if ( ! in_array( $count, array( 12, 15, 18, 21, 24 ), true ) ) {
|
||||
return sprintf( 'A recovery phrase has 12, 15, 18, 21 or 24 words — this one has %d.', $count );
|
||||
}
|
||||
foreach ( $words as $w ) {
|
||||
if ( ! preg_match( '/^[a-z]{3,8}$/', $w ) ) {
|
||||
return sprintf( '"%s" does not look like a recovery-phrase word.', $w );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Who a user is, when there is no email address.
|
||||
*
|
||||
* The identity of a Sirius Press account is a CashAddress, and the proof of
|
||||
* that identity is a signature. Everything else about WordPress users —
|
||||
* roles, capabilities, nonces, the session cookie — is untouched, because
|
||||
* those parts were never the problem. Only the "prove you can read this
|
||||
* mailbox" step is replaced.
|
||||
*
|
||||
* **Where the address lives.** In user meta, not a new `wp_users` column.
|
||||
* The roadmap sketched replacing `user_email` with a `wallet_addr` column,
|
||||
* but a schema change to a core table is the one thing that makes every
|
||||
* future upstream merge and every `dbDelta()` run a negotiation, and it buys
|
||||
* nothing here: meta lookups are indexed, and a plugin that reads
|
||||
* `$user->user_email` keeps reading something instead of crashing.
|
||||
*
|
||||
* **Why every user still has an email string.** `user_email` is populated
|
||||
* with a unique, permanently unroutable address under a `.invalid` domain
|
||||
* (RFC 2606 reserves it so it can never resolve). This is not a fallback path
|
||||
* that might one day deliver mail — it is a placeholder that exists so the
|
||||
* ecosystem's thousands of `$user->user_email` reads return a string. Mail to
|
||||
* it cannot leave the building. See docs/plugin-compatibility.md.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SP_Identity {
|
||||
|
||||
const META_ADDRESS = 'sirius_wallet_address';
|
||||
const META_ADDED = 'sirius_wallet_added';
|
||||
|
||||
/**
|
||||
* The wallet address for a user, or '' if they have none (an account
|
||||
* created before the plugin was active, or by a plugin that made its own).
|
||||
*/
|
||||
public static function address_of( $user_id ) {
|
||||
return (string) get_user_meta( (int) $user_id, self::META_ADDRESS, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the account that owns an address.
|
||||
*
|
||||
* @return WP_User|null
|
||||
*/
|
||||
public static function user_by_address( $address ) {
|
||||
$address = SP_CashAddr::normalize( $address );
|
||||
if ( '' === $address ) {
|
||||
return null;
|
||||
}
|
||||
$found = get_users(
|
||||
array(
|
||||
'meta_key' => self::META_ADDRESS,
|
||||
'meta_value' => $address,
|
||||
'number' => 2,
|
||||
'fields' => 'all',
|
||||
'count_total' => false,
|
||||
)
|
||||
);
|
||||
// Two accounts on one address should be impossible — set_address()
|
||||
// refuses it — but if a direct database edit ever produced one,
|
||||
// authenticating an ambiguous identity is the wrong move.
|
||||
if ( 1 !== count( $found ) ) {
|
||||
return null;
|
||||
}
|
||||
return $found[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an address to an account.
|
||||
*
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public static function set_address( $user_id, $address ) {
|
||||
$user_id = (int) $user_id;
|
||||
$address = SP_CashAddr::normalize( $address );
|
||||
if ( '' === $address ) {
|
||||
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||||
}
|
||||
if ( SP_CashAddr::decode( $address )['prefix'] !== SP_Settings::prefix() ) {
|
||||
return new WP_Error(
|
||||
'sirius_wrong_network',
|
||||
sprintf(
|
||||
/* translators: %s: the network prefix this site expects, e.g. bitcoincash */
|
||||
__( 'This site uses %s addresses. That address is on a different network.', 'sirius-press' ),
|
||||
SP_Settings::prefix()
|
||||
)
|
||||
);
|
||||
}
|
||||
$existing = self::user_by_address( $address );
|
||||
if ( $existing && (int) $existing->ID !== $user_id ) {
|
||||
return new WP_Error( 'sirius_address_taken', __( 'Another account already uses that address.', 'sirius-press' ) );
|
||||
}
|
||||
update_user_meta( $user_id, self::META_ADDRESS, $address );
|
||||
if ( ! get_user_meta( $user_id, self::META_ADDED, true ) ) {
|
||||
update_user_meta( $user_id, self::META_ADDED, gmdate( 'c' ) );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, unroutable placeholder email for an address.
|
||||
*
|
||||
* Derived from the address so it is deterministic: re-running a migration
|
||||
* does not churn every user's `user_email`, and two accounts never collide
|
||||
* on one placeholder.
|
||||
*/
|
||||
public static function stub_email( $address ) {
|
||||
$address = SP_CashAddr::normalize( $address );
|
||||
$tag = $address ? substr( hash( 'sha256', $address ), 0, 16 ) : wp_generate_password( 16, false );
|
||||
return 'noreply+' . $tag . '@' . SP_Settings::stub_email_domain();
|
||||
}
|
||||
|
||||
/**
|
||||
* A login name derived from an address.
|
||||
*
|
||||
* Addresses are 42 characters of base32 and make miserable usernames, so
|
||||
* the default is the last eight characters of the payload — short enough
|
||||
* to type, long enough not to collide in practice, and disambiguated with
|
||||
* a counter if it does.
|
||||
*/
|
||||
public static function suggest_login( $address ) {
|
||||
$address = SP_CashAddr::normalize( $address );
|
||||
$body = substr( (string) strstr( $address, ':' ), 1 );
|
||||
$base = 'bch_' . substr( $body, -8 );
|
||||
$login = $base;
|
||||
$n = 2;
|
||||
while ( username_exists( $login ) ) {
|
||||
$login = $base . '_' . $n;
|
||||
$n++;
|
||||
if ( $n > 50 ) {
|
||||
$login = $base . '_' . wp_generate_password( 4, false );
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $login;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an account for an address that has proved itself.
|
||||
*
|
||||
* The password is random and never shown to anyone: there is no password
|
||||
* login path in this fork, and leaving it empty would let any plugin that
|
||||
* calls `wp_authenticate()` with an empty password walk straight in.
|
||||
*
|
||||
* @param string $address
|
||||
* @param string $login Optional preferred username.
|
||||
* @param string $role Defaults to the site's configured default role.
|
||||
* @return int|WP_Error User ID.
|
||||
*/
|
||||
public static function create_user( $address, $login = '', $role = '' ) {
|
||||
$address = SP_CashAddr::normalize( $address );
|
||||
if ( '' === $address ) {
|
||||
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||||
}
|
||||
if ( self::user_by_address( $address ) ) {
|
||||
return new WP_Error( 'sirius_address_taken', __( 'An account already exists for that address.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$login = sanitize_user( $login, true );
|
||||
if ( '' === $login || username_exists( $login ) ) {
|
||||
$login = self::suggest_login( $address );
|
||||
}
|
||||
|
||||
$user_id = wp_insert_user(
|
||||
array(
|
||||
'user_login' => $login,
|
||||
'user_pass' => wp_generate_password( 64, true, true ),
|
||||
'user_email' => self::stub_email( $address ),
|
||||
'display_name' => $login,
|
||||
'role' => '' !== $role ? $role : get_option( 'default_role', 'subscriber' ),
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return $user_id;
|
||||
}
|
||||
$set = self::set_address( $user_id, $address );
|
||||
if ( is_wp_error( $set ) ) {
|
||||
// Do not leave a half-made account that can never be signed into.
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
wp_delete_user( $user_id );
|
||||
return $set;
|
||||
}
|
||||
/**
|
||||
* Fires after a wallet-backed account is created.
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param string $address Normalised CashAddress.
|
||||
*/
|
||||
do_action( 'sirius_press_user_registered', $user_id, $address );
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- admin UI
|
||||
|
||||
/** Show the address in the users list, where the email column used to be useful. */
|
||||
public static function hooks() {
|
||||
add_filter( 'manage_users_columns', array( __CLASS__, 'add_column' ) );
|
||||
add_filter( 'manage_users_custom_column', array( __CLASS__, 'render_column' ), 10, 3 );
|
||||
add_action( 'show_user_profile', array( __CLASS__, 'profile_field' ) );
|
||||
add_action( 'edit_user_profile', array( __CLASS__, 'profile_field' ) );
|
||||
add_action( 'personal_options_update', array( __CLASS__, 'save_profile_field' ) );
|
||||
add_action( 'edit_user_profile_update', array( __CLASS__, 'save_profile_field' ) );
|
||||
}
|
||||
|
||||
public static function add_column( $columns ) {
|
||||
$out = array();
|
||||
foreach ( $columns as $key => $label ) {
|
||||
if ( 'email' === $key ) {
|
||||
// Replace rather than append: a column of identical .invalid
|
||||
// placeholders is worse than no column.
|
||||
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||||
continue;
|
||||
}
|
||||
$out[ $key ] = $label;
|
||||
}
|
||||
if ( ! isset( $out['sirius_address'] ) ) {
|
||||
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function render_column( $value, $column, $user_id ) {
|
||||
if ( 'sirius_address' !== $column ) {
|
||||
return $value;
|
||||
}
|
||||
$address = self::address_of( $user_id );
|
||||
if ( '' === $address ) {
|
||||
return '<span style="color:#b32d2e">' . esc_html__( 'no wallet', 'sirius-press' ) . '</span>';
|
||||
}
|
||||
return '<code title="' . esc_attr( $address ) . '">' . esc_html( SP_CashAddr::shorten( $address ) ) . '</code>';
|
||||
}
|
||||
|
||||
public static function profile_field( $user ) {
|
||||
$address = self::address_of( $user->ID );
|
||||
$can_edit = current_user_can( 'edit_users' ) || get_current_user_id() === (int) $user->ID;
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Wallet', 'sirius-press' ); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th><label for="sirius_wallet_address"><?php esc_html_e( 'Bitcoin Cash address', 'sirius-press' ); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="sirius_wallet_address" id="sirius_wallet_address"
|
||||
value="<?php echo esc_attr( $address ); ?>" class="regular-text code"
|
||||
<?php disabled( ! $can_edit ); ?> />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'This address is the account. Signing in means signing a challenge with the key that controls it — so changing it here hands the account to whoever holds the new key.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function save_profile_field( $user_id ) {
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
return;
|
||||
}
|
||||
// Nonce is checked by WordPress before these actions fire
|
||||
// (check_admin_referer( 'update-user_' . $user_id ) in user-edit.php).
|
||||
if ( ! isset( $_POST['sirius_wallet_address'] ) ) {
|
||||
return;
|
||||
}
|
||||
$address = sanitize_text_field( wp_unslash( $_POST['sirius_wallet_address'] ) );
|
||||
if ( '' === trim( $address ) ) {
|
||||
return;
|
||||
}
|
||||
if ( trim( $address ) === self::address_of( $user_id ) ) {
|
||||
return;
|
||||
}
|
||||
$result = self::set_address( $user_id, $address );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
SP_Inbox::add(
|
||||
$user_id,
|
||||
__( 'Wallet address not changed', 'sirius-press' ),
|
||||
$result->get_error_message()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The in-app inbox that catches mail with nowhere to go.
|
||||
*
|
||||
* Sirius Press does not disable `wp_mail()`. That would break Contact Form 7,
|
||||
* WooCommerce receipts, newsletters — every legitimate reason a site has to
|
||||
* send a human an email — and those are not what the fork objects to. What it
|
||||
* objects to is *identity* depending on a mailbox.
|
||||
*
|
||||
* So the rule is narrow and mechanical: messages addressed to one of this
|
||||
* site's own `.invalid` placeholder addresses never leave the server. They
|
||||
* land here, in an inbox the recipient reads while signed in. That is where
|
||||
* password resets, comment-moderation pings, update nags and "your plugin
|
||||
* needs attention" notices end up — everything core and plugins send to an
|
||||
* account rather than to a person's real address.
|
||||
*
|
||||
* Mail addressed to a real domain is passed straight through to whatever the
|
||||
* site owner configured. If they set up SMTP, it sends. If they did not, it
|
||||
* fails exactly the way stock WordPress fails, which is the honest outcome.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SP_Inbox {
|
||||
|
||||
const TABLE = 'sirius_notices';
|
||||
const VERSION = 1;
|
||||
|
||||
public static function table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE;
|
||||
}
|
||||
|
||||
/** Create the table. Called on activation and on a version bump. */
|
||||
public static function install() {
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
$table = self::table();
|
||||
$collate = $wpdb->get_charset_collate();
|
||||
dbDelta(
|
||||
"CREATE TABLE {$table} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
created_at datetime NOT NULL,
|
||||
read_at datetime DEFAULT NULL,
|
||||
subject varchar(255) NOT NULL DEFAULT '',
|
||||
body longtext NOT NULL,
|
||||
source varchar(60) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id),
|
||||
KEY user_unread (user_id, read_at),
|
||||
KEY created (created_at)
|
||||
) {$collate};"
|
||||
);
|
||||
update_option( 'sirius_press_inbox_version', self::VERSION, false );
|
||||
}
|
||||
|
||||
public static function hooks() {
|
||||
add_filter( 'pre_wp_mail', array( __CLASS__, 'intercept_mail' ), 10, 2 );
|
||||
add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
|
||||
add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar' ), 80 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message to a user's inbox.
|
||||
*
|
||||
* @param int $user_id 0 addresses every administrator.
|
||||
* @param string $subject
|
||||
* @param string $body HTML allowed; rendered with wp_kses_post.
|
||||
* @param string $source Free-form tag, e.g. 'wp_mail' or 'export'.
|
||||
* @return int Rows written.
|
||||
*/
|
||||
public static function add( $user_id, $subject, $body, $source = '' ) {
|
||||
global $wpdb;
|
||||
$targets = array();
|
||||
if ( (int) $user_id > 0 ) {
|
||||
$targets[] = (int) $user_id;
|
||||
} else {
|
||||
foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) as $id ) {
|
||||
$targets[] = (int) $id;
|
||||
}
|
||||
}
|
||||
$written = 0;
|
||||
foreach ( array_unique( $targets ) as $id ) {
|
||||
$ok = $wpdb->insert(
|
||||
self::table(),
|
||||
array(
|
||||
'user_id' => $id,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
'subject' => mb_substr( wp_strip_all_tags( (string) $subject ), 0, 250 ),
|
||||
'body' => (string) $body,
|
||||
'source' => mb_substr( (string) $source, 0, 60 ),
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
$written += $ok ? 1 : 0;
|
||||
}
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-circuit wp_mail for placeholder recipients.
|
||||
*
|
||||
* Returning a non-null value from `pre_wp_mail` tells WordPress the send
|
||||
* is handled. Returning `true` specifically means "delivered", which is
|
||||
* the truth here — it was delivered, to the inbox.
|
||||
*
|
||||
* @param null|bool $short Whatever an earlier filter decided.
|
||||
* @param array $atts wp_mail()'s arguments.
|
||||
* @return null|bool
|
||||
*/
|
||||
public static function intercept_mail( $short, $atts ) {
|
||||
if ( null !== $short ) {
|
||||
return $short; // Somebody else already claimed this send.
|
||||
}
|
||||
$to = isset( $atts['to'] ) ? $atts['to'] : array();
|
||||
if ( ! is_array( $to ) ) {
|
||||
$to = explode( ',', (string) $to );
|
||||
}
|
||||
$stub_domain = strtolower( SP_Settings::stub_email_domain() );
|
||||
|
||||
$captured = array();
|
||||
$passing = array();
|
||||
foreach ( $to as $recipient ) {
|
||||
$addr = strtolower( trim( self::bare_address( $recipient ) ) );
|
||||
if ( '' === $addr ) {
|
||||
continue;
|
||||
}
|
||||
// Any .invalid recipient is ours by definition — RFC 2606 says the
|
||||
// TLD never resolves, so passing it to a mailer only produces a
|
||||
// bounce or a hard error in the log.
|
||||
if ( substr( $addr, -strlen( $stub_domain ) - 1 ) === '@' . $stub_domain
|
||||
|| preg_match( '/@[^@]*\.invalid$/', $addr ) ) {
|
||||
$captured[] = $addr;
|
||||
} else {
|
||||
$passing[] = $recipient;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $captured ) {
|
||||
return null; // Nothing of ours — let the normal mailer have it.
|
||||
}
|
||||
|
||||
$subject = isset( $atts['subject'] ) ? (string) $atts['subject'] : __( '(no subject)', 'sirius-press' );
|
||||
$body = isset( $atts['message'] ) ? (string) $atts['message'] : '';
|
||||
foreach ( $captured as $addr ) {
|
||||
$user = get_user_by( 'email', $addr );
|
||||
self::add( $user ? (int) $user->ID : 0, $subject, self::to_html( $body ), 'wp_mail' );
|
||||
}
|
||||
|
||||
if ( $passing ) {
|
||||
// A mixed send: the real recipients still deserve their copy, so
|
||||
// re-enter wp_mail with only those. `pre_wp_mail` is not re-entered
|
||||
// for them because their addresses are not placeholders.
|
||||
$rest = $atts;
|
||||
$rest['to'] = $passing;
|
||||
wp_mail(
|
||||
$rest['to'],
|
||||
$subject,
|
||||
$body,
|
||||
isset( $rest['headers'] ) ? $rest['headers'] : '',
|
||||
isset( $rest['attachments'] ) ? $rest['attachments'] : array()
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pull `foo@bar` out of `Name <foo@bar>`. */
|
||||
private static function bare_address( $recipient ) {
|
||||
$recipient = (string) $recipient;
|
||||
if ( preg_match( '/<([^>]+)>/', $recipient, $m ) ) {
|
||||
return $m[1];
|
||||
}
|
||||
return $recipient;
|
||||
}
|
||||
|
||||
/** Plain-text mail bodies are the common case; keep their line breaks. */
|
||||
private static function to_html( $body ) {
|
||||
if ( preg_match( '/<(a|p|br|div|table|html)\b/i', $body ) ) {
|
||||
return $body;
|
||||
}
|
||||
return wpautop( make_clickable( esc_html( $body ) ) );
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- reading it
|
||||
|
||||
public static function unread_count( $user_id ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND read_at IS NULL", (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
|
||||
public static function recent( $user_id, $limit = 50, $offset = 0 ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
return (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE user_id = %d ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", (int) $user_id, (int) $limit, (int) $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
|
||||
public static function mark_all_read( $user_id ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare( "UPDATE {$table} SET read_at = %s WHERE user_id = %d AND read_at IS NULL", current_time( 'mysql', true ), (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
|
||||
public static function delete_all( $user_id ) {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->delete( self::table(), array( 'user_id' => (int) $user_id ), array( '%d' ) );
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- UI
|
||||
|
||||
public static function menu() {
|
||||
$count = self::unread_count( get_current_user_id() );
|
||||
$label = __( 'Inbox', 'sirius-press' );
|
||||
if ( $count > 0 ) {
|
||||
$label .= sprintf( ' <span class="awaiting-mod"><span class="pending-count">%d</span></span>', $count );
|
||||
}
|
||||
add_menu_page(
|
||||
__( 'Inbox', 'sirius-press' ),
|
||||
$label,
|
||||
'read',
|
||||
'sirius-inbox',
|
||||
array( __CLASS__, 'render_page' ),
|
||||
'dashicons-email-alt',
|
||||
72
|
||||
);
|
||||
}
|
||||
|
||||
public static function admin_bar( $bar ) {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
return;
|
||||
}
|
||||
$count = self::unread_count( get_current_user_id() );
|
||||
$bar->add_node(
|
||||
array(
|
||||
'id' => 'sirius-inbox',
|
||||
'title' => '<span class="ab-icon dashicons dashicons-email-alt" style="top:2px"></span>'
|
||||
. ( $count > 0 ? '<span class="ab-label">' . (int) $count . '</span>' : '' ),
|
||||
'href' => admin_url( 'admin.php?page=sirius-inbox' ),
|
||||
'meta' => array( 'title' => __( 'Sirius Press inbox', 'sirius-press' ) ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function render_page() {
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
if ( isset( $_POST['sirius_inbox_action'] ) && check_admin_referer( 'sirius_inbox' ) ) {
|
||||
$action = sanitize_key( wp_unslash( $_POST['sirius_inbox_action'] ) );
|
||||
if ( 'read_all' === $action ) {
|
||||
self::mark_all_read( $user_id );
|
||||
} elseif ( 'delete_all' === $action ) {
|
||||
self::delete_all( $user_id );
|
||||
}
|
||||
}
|
||||
|
||||
$items = self::recent( $user_id, 100 );
|
||||
echo '<div class="wrap"><h1>' . esc_html__( 'Inbox', 'sirius-press' ) . '</h1>';
|
||||
echo '<p class="description">' . esc_html__( 'Messages this site would have emailed to your account. Nothing here was sent anywhere.', 'sirius-press' ) . '</p>';
|
||||
|
||||
echo '<form method="post" style="margin:12px 0">';
|
||||
wp_nonce_field( 'sirius_inbox' );
|
||||
echo '<button class="button" name="sirius_inbox_action" value="read_all">' . esc_html__( 'Mark all read', 'sirius-press' ) . '</button> ';
|
||||
echo '<button class="button" name="sirius_inbox_action" value="delete_all" onclick="return confirm(' . esc_attr( wp_json_encode( __( 'Delete every message in your inbox?', 'sirius-press' ) ) ) . ')">' . esc_html__( 'Delete all', 'sirius-press' ) . '</button>';
|
||||
echo '</form>';
|
||||
|
||||
if ( ! $items ) {
|
||||
echo '<p>' . esc_html__( 'Nothing here yet.', 'sirius-press' ) . '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<table class="widefat striped"><thead><tr>';
|
||||
echo '<th style="width:160px">' . esc_html__( 'When', 'sirius-press' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Message', 'sirius-press' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
foreach ( $items as $item ) {
|
||||
$unread = empty( $item->read_at );
|
||||
printf(
|
||||
'<tr%s><td>%s<br><small>%s</small></td><td><strong>%s</strong><div style="margin-top:6px">%s</div></td></tr>',
|
||||
$unread ? ' style="font-weight:600;background:#fff8e5"' : '',
|
||||
esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $item->created_at ) ),
|
||||
esc_html( $item->source ),
|
||||
esc_html( $item->subject ),
|
||||
wp_kses_post( $item->body )
|
||||
);
|
||||
}
|
||||
echo '</tbody></table></div>';
|
||||
|
||||
self::mark_all_read( $user_id );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The two signing envelopes Sirius Press speaks.
|
||||
*
|
||||
* **BIP-137** — the "Bitcoin Signed Message:\n" scheme every BCH wallet has
|
||||
* implemented for a decade. Login, registration and any other place a *human*
|
||||
* proves who they are uses this, because it is the only format a user can
|
||||
* produce without installing anything of ours: Electron Cash's Sign Message
|
||||
* box, the Theseus wallet bridge and the in-page wallet all emit it, so a
|
||||
* signature can be pasted in from whatever the person already runs.
|
||||
*
|
||||
* **BNS-SITE1** — the BNS gateway's own envelope for `PUT /api/site/<name>/…`.
|
||||
* Single SHA-256 over a fixed line format, no magic prefix. Only the server
|
||||
* ever produces these, using the stored publishing key, so no wallet has to
|
||||
* understand it.
|
||||
*
|
||||
* Getting these two confused is the single most likely source of a
|
||||
* "signature does not match" that looks like a key problem, so they live side
|
||||
* by side here rather than being inlined at their call sites.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||
|
||||
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||||
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||||
|
||||
final class SP_Message {
|
||||
|
||||
const MAGIC = "Bitcoin Signed Message:\n";
|
||||
|
||||
// ------------------------------------------------------------- BIP-137
|
||||
|
||||
/** Bitcoin-style varint length prefix. */
|
||||
private static function var_int( $n ) {
|
||||
if ( $n < 0xfd ) {
|
||||
return chr( $n );
|
||||
}
|
||||
if ( $n <= 0xffff ) {
|
||||
return "\xfd" . pack( 'v', $n );
|
||||
}
|
||||
return "\xfe" . pack( 'V', $n );
|
||||
}
|
||||
|
||||
private static function var_str( $s ) {
|
||||
return self::var_int( strlen( $s ) ) . $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 32-byte digest a wallet actually signs for a text message.
|
||||
*
|
||||
* Double SHA-256 over varstr(magic) || varstr(message).
|
||||
*/
|
||||
public static function bip137_digest( $message ) {
|
||||
$payload = self::var_str( self::MAGIC ) . self::var_str( (string) $message );
|
||||
return hash( 'sha256', hash( 'sha256', $payload, true ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a wallet's message signature against a claimed address.
|
||||
*
|
||||
* The comparison is on the normalised address, so a user who registered
|
||||
* with the plain form and signs with a token-aware wallet still matches.
|
||||
* The network prefix is taken from the claimed address, which means a
|
||||
* chipnet signature can never authenticate a mainnet account.
|
||||
*
|
||||
* @param string $message Exactly the text that was signed.
|
||||
* @param string $sig_b64 Base64 of 65 bytes.
|
||||
* @param string $address The CashAddress being claimed.
|
||||
* @return bool
|
||||
*/
|
||||
public static function verify( $message, $sig_b64, $address ) {
|
||||
$claimed = SP_CashAddr::normalize( $address );
|
||||
if ( '' === $claimed ) {
|
||||
return false;
|
||||
}
|
||||
$decoded = SP_CashAddr::decode( $claimed );
|
||||
$sig = base64_decode( (string) $sig_b64, true );
|
||||
if ( false === $sig || 65 !== strlen( $sig ) ) {
|
||||
return false;
|
||||
}
|
||||
$pub = SP_Secp256k1::recover( $sig, self::bip137_digest( $message ) );
|
||||
if ( '' === $pub ) {
|
||||
return false;
|
||||
}
|
||||
$derived = SP_CashAddr::from_public_key( $pub, $decoded['prefix'] );
|
||||
return '' !== $derived && hash_equals( $claimed, $derived );
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a BIP-137 signature. Used by tests and by WP-CLI helpers; the
|
||||
* running site never signs on a user's behalf.
|
||||
*
|
||||
* @return string Base64 of 65 bytes.
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function sign( $message, $priv_bin ) {
|
||||
return base64_encode( SP_Secp256k1::sign_recoverable( self::bip137_digest( $message ), $priv_bin ) );
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- BNS-SITE1
|
||||
|
||||
/**
|
||||
* Digest for a gateway site write.
|
||||
*
|
||||
* Mirrors public-gateway.mjs exactly:
|
||||
* sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
||||
*
|
||||
* @param string $name Fully-qualified BCNR name, e.g. "example.bch".
|
||||
* @param string $path Path inside the name's bucket, no leading slash.
|
||||
* @param string $body Raw bytes being uploaded ('' for DELETE).
|
||||
* @param int $ts Unix milliseconds.
|
||||
*/
|
||||
public static function site_digest( $name, $path, $body, $ts ) {
|
||||
$body_hex = hash( 'sha256', $body );
|
||||
return hash( 'sha256', "BNS-SITE1\n{$name}\n{$path}\n{$body_hex}\n{$ts}", true );
|
||||
}
|
||||
|
||||
/**
|
||||
* The `x-bns-sig` / `x-bns-ts` pair for a site write.
|
||||
*
|
||||
* @return array{'x-bns-sig':string,'x-bns-ts':string}
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function site_headers( $name, $path, $body, $priv_bin, $ts = null ) {
|
||||
$ts = null === $ts ? (int) round( microtime( true ) * 1000 ) : (int) $ts;
|
||||
$sig = SP_Secp256k1::sign_recoverable( self::site_digest( $name, $path, $body, $ts ), $priv_bin );
|
||||
return array(
|
||||
'x-bns-sig' => base64_encode( $sig ),
|
||||
'x-bns-ts' => (string) $ts,
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- canonical JSON
|
||||
|
||||
/**
|
||||
* Canonical JSON per DESIGN-signed-records-manifest.md: keys sorted at
|
||||
* every level, no insignificant whitespace, non-ASCII \u-escaped, null
|
||||
* members dropped.
|
||||
*
|
||||
* Only needed when this plugin writes a `_records.json` manifest — the
|
||||
* export path signs raw bytes, not JSON. Kept here so there is exactly one
|
||||
* canonicaliser in the codebase.
|
||||
*/
|
||||
public static function canonical_json( $value ) {
|
||||
// JSON_UNESCAPED_SLASHES matches JavaScript's JSON.stringify, which the
|
||||
// portal signs with; PHP would otherwise emit \/ and produce different
|
||||
// bytes for the same manifest. Non-ASCII stays \u-escaped, which is
|
||||
// json_encode's default and is what the spec asks for.
|
||||
return json_encode( self::canonicalize( $value ), JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
private static function canonicalize( $value ) {
|
||||
if ( is_object( $value ) ) {
|
||||
$value = (array) $value;
|
||||
}
|
||||
if ( is_array( $value ) ) {
|
||||
$is_list = ( array() === $value ) || array_keys( $value ) === range( 0, count( $value ) - 1 );
|
||||
if ( $is_list ) {
|
||||
return array_map( array( __CLASS__, 'canonicalize' ), $value );
|
||||
}
|
||||
$out = array();
|
||||
foreach ( $value as $k => $v ) {
|
||||
if ( null === $v ) {
|
||||
continue;
|
||||
}
|
||||
$out[ (string) $k ] = self::canonicalize( $v );
|
||||
}
|
||||
ksort( $out, SORT_STRING );
|
||||
return $out;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* secp256k1 in pure PHP: recoverable message signatures, nothing else.
|
||||
*
|
||||
* Sirius Press replaces "prove you can read this mailbox" with "prove you hold
|
||||
* this key", so signature verification sits on the login path and cannot be
|
||||
* optional. Requiring a native secp256k1 extension would rule out most of the
|
||||
* shared hosts this fork is meant to run on, so the curve lives here.
|
||||
*
|
||||
* Scope is deliberately narrow — the two operations the fork actually needs:
|
||||
*
|
||||
* sign_recoverable() produce the 65-byte [flag|r|s] blob the BNS gateway
|
||||
* and the Sirius portal already speak.
|
||||
* recover() pull the compressed public key back out of one, so a
|
||||
* login signature can be turned into a CashAddress and
|
||||
* compared to the user's stored address.
|
||||
*
|
||||
* Point arithmetic is Jacobian, so a scalar multiply costs exactly one modular
|
||||
* inversion at the end instead of one per bit. That is what keeps the BCMath
|
||||
* fallback in the "noticeable pause" range rather than the "gateway timeout"
|
||||
* range.
|
||||
*
|
||||
* Signature format matches Argus/src/lib/wallet-web.js BuiltInWallet::signMessage
|
||||
* byte for byte: flag = 27 + 4 + recovery_id, then r, then s, all big-endian.
|
||||
* The +4 says "the public key was compressed"; the gateway recovers the id
|
||||
* back out with (flag - 27) & 3.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||
|
||||
require_once __DIR__ . '/class-sp-bn.php';
|
||||
|
||||
final class SP_Secp256k1 {
|
||||
|
||||
const P = 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f';
|
||||
const N = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141';
|
||||
const GX = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||
const GY = '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8';
|
||||
|
||||
/** Memoised curve constants, per request. */
|
||||
private static $c = null;
|
||||
|
||||
private static function consts() {
|
||||
if ( null === self::$c ) {
|
||||
self::$c = array(
|
||||
'p' => SP_BN::from_hex( self::P ),
|
||||
'n' => SP_BN::from_hex( self::N ),
|
||||
'gx' => SP_BN::from_hex( self::GX ),
|
||||
'gy' => SP_BN::from_hex( self::GY ),
|
||||
'zero' => SP_BN::from_int( 0 ),
|
||||
'one' => SP_BN::from_int( 1 ),
|
||||
'two' => SP_BN::from_int( 2 ),
|
||||
'three' => SP_BN::from_int( 3 ),
|
||||
'four' => SP_BN::from_int( 4 ),
|
||||
'seven' => SP_BN::from_int( 7 ),
|
||||
'eight' => SP_BN::from_int( 8 ),
|
||||
);
|
||||
// n/2, the low-S boundary.
|
||||
self::$c['half_n'] = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' );
|
||||
}
|
||||
return self::$c;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- field ops
|
||||
|
||||
private static function fadd( $a, $b ) {
|
||||
$c = self::consts();
|
||||
return SP_BN::mod( SP_BN::add( $a, $b ), $c['p'] );
|
||||
}
|
||||
|
||||
private static function fsub( $a, $b ) {
|
||||
$c = self::consts();
|
||||
return SP_BN::mod( SP_BN::sub( $a, $b ), $c['p'] );
|
||||
}
|
||||
|
||||
private static function fmul( $a, $b ) {
|
||||
$c = self::consts();
|
||||
return SP_BN::mod( SP_BN::mul( $a, $b ), $c['p'] );
|
||||
}
|
||||
|
||||
// -------------------------------------------------- Jacobian point maths
|
||||
|
||||
/** The point at infinity, in Jacobian coordinates (Z = 0). */
|
||||
private static function jinf() {
|
||||
$c = self::consts();
|
||||
return array( $c['one'], $c['one'], $c['zero'] );
|
||||
}
|
||||
|
||||
private static function is_inf( $pt ) {
|
||||
return SP_BN::is_zero( $pt[2] );
|
||||
}
|
||||
|
||||
/** Affine (x, y) into Jacobian with Z = 1. */
|
||||
private static function to_jacobian( $x, $y ) {
|
||||
$c = self::consts();
|
||||
return array( $x, $y, $c['one'] );
|
||||
}
|
||||
|
||||
/** Jacobian back to affine: (X/Z^2, Y/Z^3). The one inversion. */
|
||||
private static function to_affine( $pt ) {
|
||||
$c = self::consts();
|
||||
if ( self::is_inf( $pt ) ) {
|
||||
return null;
|
||||
}
|
||||
$zi = SP_BN::inv_mod( $pt[2], $c['p'] );
|
||||
$zi2 = self::fmul( $zi, $zi );
|
||||
$zi3 = self::fmul( $zi2, $zi );
|
||||
return array( self::fmul( $pt[0], $zi2 ), self::fmul( $pt[1], $zi3 ) );
|
||||
}
|
||||
|
||||
/** Point doubling, a = 0 case (dbl-2009-l). */
|
||||
private static function jdouble( $pt ) {
|
||||
$c = self::consts();
|
||||
list( $x, $y, $z ) = $pt;
|
||||
if ( SP_BN::is_zero( $z ) || SP_BN::is_zero( $y ) ) {
|
||||
return self::jinf();
|
||||
}
|
||||
$a = self::fmul( $x, $x ); // X^2
|
||||
$b = self::fmul( $y, $y ); // Y^2
|
||||
$cc = self::fmul( $b, $b ); // Y^4
|
||||
$d = self::fmul( $c['two'], self::fsub( self::fmul( self::fadd( $x, $b ), self::fadd( $x, $b ) ), self::fadd( $a, $cc ) ) );
|
||||
$e = self::fmul( $c['three'], $a );
|
||||
$f = self::fmul( $e, $e );
|
||||
$x3 = self::fsub( $f, self::fmul( $c['two'], $d ) );
|
||||
$y3 = self::fsub( self::fmul( $e, self::fsub( $d, $x3 ) ), self::fmul( $c['eight'], $cc ) );
|
||||
$z3 = self::fmul( self::fmul( $c['two'], $y ), $z );
|
||||
return array( $x3, $y3, $z3 );
|
||||
}
|
||||
|
||||
/** Point addition, Jacobian + Jacobian (add-2007-bl). */
|
||||
private static function jadd( $p1, $p2 ) {
|
||||
$c = self::consts();
|
||||
if ( self::is_inf( $p1 ) ) {
|
||||
return $p2;
|
||||
}
|
||||
if ( self::is_inf( $p2 ) ) {
|
||||
return $p1;
|
||||
}
|
||||
list( $x1, $y1, $z1 ) = $p1;
|
||||
list( $x2, $y2, $z2 ) = $p2;
|
||||
|
||||
$z1z1 = self::fmul( $z1, $z1 );
|
||||
$z2z2 = self::fmul( $z2, $z2 );
|
||||
$u1 = self::fmul( $x1, $z2z2 );
|
||||
$u2 = self::fmul( $x2, $z1z1 );
|
||||
$s1 = self::fmul( $y1, self::fmul( $z2, $z2z2 ) );
|
||||
$s2 = self::fmul( $y2, self::fmul( $z1, $z1z1 ) );
|
||||
|
||||
if ( 0 === SP_BN::cmp( $u1, $u2 ) ) {
|
||||
return 0 === SP_BN::cmp( $s1, $s2 ) ? self::jdouble( $p1 ) : self::jinf();
|
||||
}
|
||||
|
||||
$h = self::fsub( $u2, $u1 );
|
||||
$i = self::fmul( self::fmul( $c['two'], $h ), self::fmul( $c['two'], $h ) );
|
||||
$j = self::fmul( $h, $i );
|
||||
$r = self::fmul( $c['two'], self::fsub( $s2, $s1 ) );
|
||||
$v = self::fmul( $u1, $i );
|
||||
$x3 = self::fsub( self::fsub( self::fmul( $r, $r ), $j ), self::fmul( $c['two'], $v ) );
|
||||
$y3 = self::fsub( self::fmul( $r, self::fsub( $v, $x3 ) ), self::fmul( $c['two'], self::fmul( $s1, $j ) ) );
|
||||
$z3 = self::fmul( self::fsub( self::fmul( self::fadd( $z1, $z2 ), self::fadd( $z1, $z2 ) ), self::fadd( $z1z1, $z2z2 ) ), $h );
|
||||
return array( $x3, $y3, $z3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Scalar multiply, left-to-right double-and-add.
|
||||
*
|
||||
* Not constant time. It does not need to be: every scalar multiplied here
|
||||
* is either a public value (recovery) or a per-signature nonce on a host
|
||||
* where the attacker able to time it can already read the private key off
|
||||
* disk.
|
||||
*
|
||||
* @return array{0:mixed,1:mixed}|null Affine point, or null for infinity.
|
||||
*/
|
||||
private static function mul_point( $k, $px, $py ) {
|
||||
$c = self::consts();
|
||||
$k = SP_BN::mod( $k, $c['n'] );
|
||||
if ( SP_BN::is_zero( $k ) ) {
|
||||
return null;
|
||||
}
|
||||
$base = self::to_jacobian( $px, $py );
|
||||
$acc = self::jinf();
|
||||
$bits = SP_BN::bits( $k );
|
||||
$len = strlen( $bits );
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$acc = self::jdouble( $acc );
|
||||
if ( '1' === $bits[ $i ] ) {
|
||||
$acc = self::jadd( $acc, $base );
|
||||
}
|
||||
}
|
||||
return self::to_affine( $acc );
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ public API
|
||||
|
||||
/**
|
||||
* Compressed public key for a private key.
|
||||
*
|
||||
* @param string $priv_bin 32 raw bytes.
|
||||
* @return string 33 raw bytes, or '' if the key is out of range.
|
||||
*/
|
||||
public static function public_key( $priv_bin ) {
|
||||
$c = self::consts();
|
||||
$d = SP_BN::from_bin( $priv_bin );
|
||||
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||||
return '';
|
||||
}
|
||||
$pt = self::mul_point( $d, $c['gx'], $c['gy'] );
|
||||
return self::compress( $pt );
|
||||
}
|
||||
|
||||
/** @param array|null $pt Affine point. */
|
||||
private static function compress( $pt ) {
|
||||
if ( null === $pt ) {
|
||||
return '';
|
||||
}
|
||||
return chr( SP_BN::is_odd( $pt[1] ) ? 0x03 : 0x02 ) . SP_BN::to_bin( $pt[0], 32 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a 32-byte digest, producing the 65-byte recoverable blob.
|
||||
*
|
||||
* Low-S normalised, so the same message and key always yield the same
|
||||
* bytes and nothing downstream has to worry about malleability.
|
||||
*
|
||||
* @param string $digest 32 raw bytes (already hashed).
|
||||
* @param string $priv_bin 32 raw bytes.
|
||||
* @return string 65 raw bytes.
|
||||
* @throws Exception When the key is out of range or the host has no bignum extension.
|
||||
*/
|
||||
public static function sign_recoverable( $digest, $priv_bin ) {
|
||||
if ( ! SP_BN::available() ) {
|
||||
throw new Exception( 'Sirius Press needs either the GMP or the BCMath PHP extension to sign.' );
|
||||
}
|
||||
if ( 32 !== strlen( $digest ) || 32 !== strlen( $priv_bin ) ) {
|
||||
throw new Exception( 'sign_recoverable expects a 32-byte digest and a 32-byte private key.' );
|
||||
}
|
||||
$c = self::consts();
|
||||
$d = SP_BN::from_bin( $priv_bin );
|
||||
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||||
throw new Exception( 'private key out of range' );
|
||||
}
|
||||
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||||
|
||||
foreach ( self::rfc6979_nonces( $digest, $priv_bin ) as $k ) {
|
||||
$pt = self::mul_point( $k, $c['gx'], $c['gy'] );
|
||||
if ( null === $pt ) {
|
||||
continue;
|
||||
}
|
||||
$r = SP_BN::mod( $pt[0], $c['n'] );
|
||||
if ( SP_BN::is_zero( $r ) ) {
|
||||
continue;
|
||||
}
|
||||
$s = SP_BN::mod(
|
||||
SP_BN::mul( SP_BN::inv_mod( $k, $c['n'] ), SP_BN::add( $z, SP_BN::mul( $r, $d ) ) ),
|
||||
$c['n']
|
||||
);
|
||||
if ( SP_BN::is_zero( $s ) ) {
|
||||
continue;
|
||||
}
|
||||
$recid = ( SP_BN::is_odd( $pt[1] ) ? 1 : 0 ) | ( SP_BN::cmp( $pt[0], $c['n'] ) >= 0 ? 2 : 0 );
|
||||
if ( SP_BN::cmp( $s, $c['half_n'] ) > 0 ) {
|
||||
$s = SP_BN::sub( $c['n'], $s );
|
||||
$recid ^= 1;
|
||||
}
|
||||
return chr( 27 + 4 + $recid ) . SP_BN::to_bin( $r, 32 ) . SP_BN::to_bin( $s, 32 );
|
||||
}
|
||||
throw new Exception( 'could not find a valid signature nonce' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the compressed public key that produced a 65-byte signature.
|
||||
*
|
||||
* @param string $sig 65 raw bytes, [flag|r|s].
|
||||
* @param string $digest 32 raw bytes.
|
||||
* @return string 33 raw bytes, or '' when the signature is malformed or
|
||||
* does not correspond to any point on the curve.
|
||||
*/
|
||||
public static function recover( $sig, $digest ) {
|
||||
if ( ! SP_BN::available() || 65 !== strlen( $sig ) || 32 !== strlen( $digest ) ) {
|
||||
return '';
|
||||
}
|
||||
$c = self::consts();
|
||||
$flag = ord( $sig[0] );
|
||||
$recid = ( $flag - 27 ) & 3;
|
||||
$r = SP_BN::from_bin( substr( $sig, 1, 32 ) );
|
||||
$s = SP_BN::from_bin( substr( $sig, 33, 32 ) );
|
||||
if ( SP_BN::is_zero( $r ) || SP_BN::is_zero( $s )
|
||||
|| SP_BN::cmp( $r, $c['n'] ) >= 0 || SP_BN::cmp( $s, $c['n'] ) >= 0 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// x = r + (recid >> 1) * n, which is only on the curve for the
|
||||
// overflow cases the signer flagged.
|
||||
$x = $r;
|
||||
if ( $recid >= 2 ) {
|
||||
$x = SP_BN::add( $r, $c['n'] );
|
||||
if ( SP_BN::cmp( $x, $c['p'] ) >= 0 ) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// y^2 = x^3 + 7; p ≡ 3 (mod 4) so the square root is a single powmod.
|
||||
$alpha = self::fadd( self::fmul( self::fmul( $x, $x ), $x ), $c['seven'] );
|
||||
$exp = self::sqrt_exponent();
|
||||
$beta = SP_BN::pow_mod( $alpha, $exp, $c['p'] );
|
||||
// Reject non-residues: if beta^2 != alpha there is no such point.
|
||||
if ( 0 !== SP_BN::cmp( self::fmul( $beta, $beta ), $alpha ) ) {
|
||||
return '';
|
||||
}
|
||||
$want_odd = ( $recid & 1 ) === 1;
|
||||
$y = ( SP_BN::is_odd( $beta ) === $want_odd ) ? $beta : SP_BN::sub( $c['p'], $beta );
|
||||
|
||||
// Q = r^-1 (sR - zG)
|
||||
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||||
$rinv = SP_BN::inv_mod( $r, $c['n'] );
|
||||
|
||||
$sr = self::mul_point( $s, $x, $y );
|
||||
$zg = self::mul_point( SP_BN::mod( SP_BN::sub( $c['n'], $z ), $c['n'] ), $c['gx'], $c['gy'] );
|
||||
if ( null === $sr ) {
|
||||
return '';
|
||||
}
|
||||
$sum = null === $zg
|
||||
? self::to_jacobian( $sr[0], $sr[1] )
|
||||
: self::jadd( self::to_jacobian( $sr[0], $sr[1] ), self::to_jacobian( $zg[0], $zg[1] ) );
|
||||
$sum = self::to_affine( $sum );
|
||||
if ( null === $sum ) {
|
||||
return '';
|
||||
}
|
||||
$q = self::mul_point( $rinv, $sum[0], $sum[1] );
|
||||
return self::compress( $q );
|
||||
}
|
||||
|
||||
/** (p + 1) / 4, precomputed as hex — the square-root exponent. */
|
||||
private static function sqrt_exponent() {
|
||||
return SP_BN::from_hex( '3fffffffffffffffffffffffffffffffffffffffffffffffffffffffbfffff0c' );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- nonces
|
||||
|
||||
/**
|
||||
* RFC 6979 deterministic nonces, yielded one at a time.
|
||||
*
|
||||
* Determinism matters here beyond tidiness: a PHP process on a shared host
|
||||
* is exactly the place where a weak random source silently leaks the key
|
||||
* across two signatures. Nothing in this path touches the system RNG.
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
private static function rfc6979_nonces( $digest, $priv_bin ) {
|
||||
$c = self::consts();
|
||||
$h1 = $digest;
|
||||
// bits2octets(h1): reduce the digest mod n, re-pad to 32 bytes.
|
||||
$h1_oct = SP_BN::to_bin( SP_BN::mod( SP_BN::from_bin( $h1 ), $c['n'] ), 32 );
|
||||
|
||||
$v = str_repeat( "\x01", 32 );
|
||||
$k = str_repeat( "\x00", 32 );
|
||||
|
||||
$k = hash_hmac( 'sha256', $v . "\x00" . $priv_bin . $h1_oct, $k, true );
|
||||
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||
$k = hash_hmac( 'sha256', $v . "\x01" . $priv_bin . $h1_oct, $k, true );
|
||||
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||
|
||||
for ( $attempt = 0; $attempt < 64; $attempt++ ) {
|
||||
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||
$cand = SP_BN::from_bin( $v );
|
||||
if ( ! SP_BN::is_zero( $cand ) && SP_BN::cmp( $cand, $c['n'] ) < 0 ) {
|
||||
yield $cand;
|
||||
}
|
||||
$k = hash_hmac( 'sha256', $v . "\x00", $k, true );
|
||||
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Everything Sirius Press needs to know about the site it is running.
|
||||
*
|
||||
* Two things live here that ordinary WordPress has no concept of: which BCNR
|
||||
* name this installation publishes under, and the key it publishes with.
|
||||
*
|
||||
* The key is the uncomfortable part and the docs say so plainly. The BNS
|
||||
* gateway only accepts a write signed by the name's current on-chain owner,
|
||||
* so unattended publishing — a post going live from wp-cron at 3am — means
|
||||
* the server holds the owning key. There is no clever way around that: a key
|
||||
* that can publish for the name is the key that owns the name. Sites that
|
||||
* will not accept the risk run in `manual` mode instead, where the export
|
||||
* queue drains from an admin's browser and the server stores no secret.
|
||||
*
|
||||
* At rest the phrase is AES-256-GCM encrypted under a key derived from
|
||||
* SIRIUS_PRESS_KEY (or, failing that, the site's own auth salts). That
|
||||
* protects against a leaked database dump, not against someone who can read
|
||||
* wp-config.php — and the settings screen says exactly that rather than
|
||||
* implying more.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SP_Settings {
|
||||
|
||||
const OPT_NAME = 'sirius_press_name';
|
||||
const OPT_NETWORK = 'sirius_press_network';
|
||||
const OPT_GATEWAY = 'sirius_press_gateway';
|
||||
const OPT_MODE = 'sirius_press_publish_mode';
|
||||
const OPT_PHRASE = 'sirius_press_phrase';
|
||||
const OPT_PATH = 'sirius_press_derivation_path';
|
||||
const OPT_ADDRESS = 'sirius_press_publish_address';
|
||||
const OPT_AUTOPUB = 'sirius_press_auto_publish';
|
||||
const OPT_OPEN_REG = 'sirius_press_open_registration';
|
||||
const OPT_STUB_MAIL = 'sirius_press_stub_email_domain';
|
||||
const OPT_LOOPBACK = 'sirius_press_loopback';
|
||||
|
||||
const DEFAULT_GATEWAY = 'https://navigate.st';
|
||||
|
||||
/** Publishing modes. */
|
||||
const MODE_SERVER = 'server'; // Server holds the phrase and signs by itself.
|
||||
const MODE_MANUAL = 'manual'; // Nothing stored; an admin's browser signs.
|
||||
|
||||
public static function name() {
|
||||
return (string) get_option( self::OPT_NAME, '' );
|
||||
}
|
||||
|
||||
public static function network() {
|
||||
$n = (string) get_option( self::OPT_NETWORK, 'mainnet' );
|
||||
return 'chipnet' === $n ? 'chipnet' : 'mainnet';
|
||||
}
|
||||
|
||||
/** The CashAddress prefix this site's accounts and keys use. */
|
||||
public static function prefix() {
|
||||
return 'chipnet' === self::network() ? 'bchtest' : 'bitcoincash';
|
||||
}
|
||||
|
||||
public static function gateway() {
|
||||
$g = trim( (string) get_option( self::OPT_GATEWAY, self::DEFAULT_GATEWAY ) );
|
||||
return rtrim( '' === $g ? self::DEFAULT_GATEWAY : $g, '/' );
|
||||
}
|
||||
|
||||
public static function mode() {
|
||||
return self::MODE_SERVER === get_option( self::OPT_MODE, self::MODE_MANUAL )
|
||||
? self::MODE_SERVER
|
||||
: self::MODE_MANUAL;
|
||||
}
|
||||
|
||||
public static function derivation_path() {
|
||||
$p = trim( (string) get_option( self::OPT_PATH, '' ) );
|
||||
return '' === $p ? SP_HD::DEFAULT_PATH : $p;
|
||||
}
|
||||
|
||||
/** Whether publishing a post should trigger a static export. */
|
||||
public static function auto_publish() {
|
||||
return (bool) get_option( self::OPT_AUTOPUB, true );
|
||||
}
|
||||
|
||||
/** Whether strangers may create accounts by proving an address. */
|
||||
public static function open_registration() {
|
||||
return (bool) get_option( self::OPT_OPEN_REG, (bool) get_option( 'users_can_register', false ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain used to mint placeholder addresses for plugins that insist on
|
||||
* an email column. `.invalid` is reserved by RFC 2606 precisely so that it
|
||||
* can never route anywhere.
|
||||
*/
|
||||
public static function stub_email_domain() {
|
||||
$d = trim( (string) get_option( self::OPT_STUB_MAIL, '' ) );
|
||||
if ( '' !== $d ) {
|
||||
return $d;
|
||||
}
|
||||
$name = self::name();
|
||||
return ( '' === $name ? 'sirius-press' : $name ) . '.invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the exporter should fetch this site's own pages from.
|
||||
*
|
||||
* Normally empty, meaning "the site's own URL". It exists because a
|
||||
* container cannot always reach the address the outside world uses: on a
|
||||
* Docker host without NAT hairpinning, the PHP container asking for
|
||||
* `http://203.0.113.10:8081/` simply times out, even though that is
|
||||
* exactly where visitors arrive.
|
||||
*
|
||||
* Set this to something reachable from where PHP runs — in the bundled
|
||||
* stack, `http://web`, the nginx service on the compose network. The
|
||||
* request still carries the site's real Host header, so WordPress renders
|
||||
* the same page it would for a visitor and does not bounce the exporter
|
||||
* to its canonical URL.
|
||||
*
|
||||
* @return string '' to fetch the public URL directly.
|
||||
*/
|
||||
public static function loopback_base() {
|
||||
$configured = trim( (string) get_option( self::OPT_LOOPBACK, '' ) );
|
||||
if ( '' === $configured && defined( 'SIRIUS_PRESS_LOOPBACK_URL' ) ) {
|
||||
$configured = trim( (string) SIRIUS_PRESS_LOOPBACK_URL );
|
||||
}
|
||||
return '' === $configured ? '' : untrailingslashit( $configured );
|
||||
}
|
||||
|
||||
/** True when the site knows its name and where to publish. */
|
||||
public static function is_configured() {
|
||||
return '' !== self::name() && '' !== self::gateway();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the publishing key
|
||||
|
||||
/** True when a phrase is stored, whatever mode the site is in. */
|
||||
public static function has_phrase() {
|
||||
return '' !== (string) get_option( self::OPT_PHRASE, '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (or clear) the publishing phrase, and cache the address it derives.
|
||||
*
|
||||
* @param string $mnemonic Pass '' to forget it.
|
||||
* @return string '' on success, otherwise a human-readable error.
|
||||
*/
|
||||
public static function set_phrase( $mnemonic ) {
|
||||
$mnemonic = SP_HD::normalize_mnemonic( $mnemonic );
|
||||
if ( '' === $mnemonic ) {
|
||||
delete_option( self::OPT_PHRASE );
|
||||
delete_option( self::OPT_ADDRESS );
|
||||
return '';
|
||||
}
|
||||
$problem = SP_HD::phrase_problem( $mnemonic );
|
||||
if ( '' !== $problem ) {
|
||||
return $problem;
|
||||
}
|
||||
try {
|
||||
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
|
||||
} catch ( Exception $e ) {
|
||||
return 'Could not derive a key from that phrase: ' . $e->getMessage();
|
||||
}
|
||||
$sealed = self::seal( $mnemonic );
|
||||
if ( '' === $sealed ) {
|
||||
return 'This server has no working encryption (openssl), so the phrase cannot be stored safely.';
|
||||
}
|
||||
update_option( self::OPT_PHRASE, $sealed, false );
|
||||
update_option( self::OPT_ADDRESS, $key['address'], false );
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The address the stored phrase publishes from, or ''. */
|
||||
public static function publishing_address() {
|
||||
return (string) get_option( self::OPT_ADDRESS, '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The private key for signing gateway writes.
|
||||
*
|
||||
* @return string 32 raw bytes, or '' when nothing is stored.
|
||||
* @throws Exception When the stored phrase cannot be unsealed or derived.
|
||||
*/
|
||||
public static function publishing_private_key() {
|
||||
$sealed = (string) get_option( self::OPT_PHRASE, '' );
|
||||
if ( '' === $sealed ) {
|
||||
return '';
|
||||
}
|
||||
$mnemonic = self::unseal( $sealed );
|
||||
if ( '' === $mnemonic ) {
|
||||
throw new Exception( 'The stored recovery phrase could not be decrypted. If SIRIUS_PRESS_KEY or the site salts changed, re-enter the phrase in Sirius Press settings.' );
|
||||
}
|
||||
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
|
||||
return $key['private'];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ encryption
|
||||
|
||||
/** 32-byte key from the site's configured secret. */
|
||||
private static function secret() {
|
||||
$material = defined( 'SIRIUS_PRESS_KEY' ) && SIRIUS_PRESS_KEY
|
||||
? SIRIUS_PRESS_KEY
|
||||
: ( ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : '' ) );
|
||||
return hash( 'sha256', 'sirius-press/v1/' . $material, true );
|
||||
}
|
||||
|
||||
/** @return string base64 of iv|tag|ciphertext, or '' if unavailable. */
|
||||
private static function seal( $plain ) {
|
||||
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||
return '';
|
||||
}
|
||||
$iv = random_bytes( 12 );
|
||||
$tag = '';
|
||||
$ct = openssl_encrypt( $plain, 'aes-256-gcm', self::secret(), OPENSSL_RAW_DATA, $iv, $tag, 'sirius-press', 16 );
|
||||
if ( false === $ct ) {
|
||||
return '';
|
||||
}
|
||||
return base64_encode( $iv . $tag . $ct );
|
||||
}
|
||||
|
||||
/** @return string '' when the blob cannot be authenticated. */
|
||||
private static function unseal( $sealed ) {
|
||||
if ( ! function_exists( 'openssl_decrypt' ) ) {
|
||||
return '';
|
||||
}
|
||||
$raw = base64_decode( $sealed, true );
|
||||
if ( false === $raw || strlen( $raw ) < 29 ) {
|
||||
return '';
|
||||
}
|
||||
$plain = openssl_decrypt(
|
||||
substr( $raw, 28 ),
|
||||
'aes-256-gcm',
|
||||
self::secret(),
|
||||
OPENSSL_RAW_DATA,
|
||||
substr( $raw, 0, 12 ),
|
||||
substr( $raw, 12, 16 ),
|
||||
'sirius-press'
|
||||
);
|
||||
return false === $plain ? '' : $plain;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Sirius Press Core
|
||||
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||
* Description: Wallet identity, gateway publishing and the in-app inbox that the rest of Sirius Press is built on.
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.5
|
||||
* Requires PHP: 7.4
|
||||
* Author: Silent Mode
|
||||
* Author URI: https://silentmode.st
|
||||
* License: GPL-2.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: sirius-press
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'SIRIUS_PRESS_VERSION', '0.1.0' );
|
||||
define( 'SIRIUS_PRESS_CORE_FILE', __FILE__ );
|
||||
define( 'SIRIUS_PRESS_CORE_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'SIRIUS_PRESS_CORE_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
foreach (
|
||||
array(
|
||||
'class-sp-bn',
|
||||
'class-sp-secp256k1',
|
||||
'class-sp-cashaddr',
|
||||
'class-sp-hd',
|
||||
'class-sp-message',
|
||||
'class-sp-settings',
|
||||
'class-sp-gateway',
|
||||
'class-sp-identity',
|
||||
'class-sp-inbox',
|
||||
'class-sp-admin',
|
||||
) as $sp_class
|
||||
) {
|
||||
require_once SIRIUS_PRESS_CORE_DIR . 'includes/' . $sp_class . '.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* The function this whole fork turns on.
|
||||
*
|
||||
* Core calls `is_email()` in a dozen places to decide whether a string is a
|
||||
* usable identity. Sirius Press answers the same question about a different
|
||||
* kind of string, and the core patches in `patches/` swap the call rather
|
||||
* than changing what `is_email()` itself means — plugins still need the real
|
||||
* `is_email()` for contact forms and newsletters.
|
||||
*
|
||||
* @param string $value
|
||||
* @return bool
|
||||
*/
|
||||
function is_cashaddr( $value ) {
|
||||
return SP_CashAddr::is_valid( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* The address that identifies a user, or '' — the `user_email` of this fork.
|
||||
*
|
||||
* @param int|WP_User|null $user Defaults to the current user.
|
||||
*/
|
||||
function sirius_press_address( $user = null ) {
|
||||
if ( null === $user ) {
|
||||
$user = get_current_user_id();
|
||||
}
|
||||
$id = $user instanceof WP_User ? $user->ID : (int) $user;
|
||||
return $id ? SP_Identity::address_of( $id ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this installation can do wallet cryptography at all.
|
||||
*
|
||||
* Checked on activation and shown as an admin notice rather than left to
|
||||
* surface as a fatal during someone's first login attempt.
|
||||
*
|
||||
* @return string '' when fine, otherwise what is missing.
|
||||
*/
|
||||
function sirius_press_requirements_problem() {
|
||||
if ( ! SP_BN::available() ) {
|
||||
return __( 'Sirius Press needs either the GMP or the BCMath PHP extension to verify wallet signatures. Ask your host to enable one — GMP is much faster.', 'sirius-press' );
|
||||
}
|
||||
if ( PHP_INT_SIZE < 8 ) {
|
||||
return __( 'Sirius Press needs 64-bit PHP. Address checksums cannot be computed on a 32-bit build.', 'sirius-press' );
|
||||
}
|
||||
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||
return __( 'Sirius Press needs the OpenSSL PHP extension to store a publishing key safely.', 'sirius-press' );
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
register_activation_hook(
|
||||
__FILE__,
|
||||
function () {
|
||||
SP_Inbox::install();
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
function () {
|
||||
if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) {
|
||||
SP_Inbox::install();
|
||||
}
|
||||
|
||||
SP_Identity::hooks();
|
||||
SP_Inbox::hooks();
|
||||
SP_Admin::hooks();
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
* Translations load on `init`, not `plugins_loaded`. WordPress 6.7 started
|
||||
* warning about the earlier hook because the locale is not settled yet, and a
|
||||
* notice on every request is the kind of thing that trains people to ignore
|
||||
* their logs.
|
||||
*/
|
||||
add_action(
|
||||
'init',
|
||||
function () {
|
||||
load_plugin_textdomain( 'sirius-press', false, dirname( plugin_basename( SIRIUS_PRESS_CORE_FILE ) ) . '/languages' );
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'admin_notices',
|
||||
function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$problem = sirius_press_requirements_problem();
|
||||
if ( '' !== $problem ) {
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $problem ) );
|
||||
return;
|
||||
}
|
||||
if ( ! SP_Settings::is_configured() ) {
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'Sirius Press does not know which BCNR name this site publishes under yet.', 'sirius-press' ),
|
||||
esc_url( admin_url( 'admin.php?page=sirius-press' ) ),
|
||||
esc_html__( 'Set it now', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
// Manual-mode publishing: sign in the browser, upload straight to the gateway.
|
||||
//
|
||||
// The point of this file is that the server never holds the key. WordPress
|
||||
// builds the bytes — it is the only thing that can, since it knows how the
|
||||
// theme renders — and then hands them here. This page hashes them, signs the
|
||||
// gateway's upload envelope with a phrase typed a moment ago, PUTs the file,
|
||||
// and tells WordPress what happened. The phrase is wiped when the run ends.
|
||||
//
|
||||
// The envelope is BNS-SITE1:
|
||||
// sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<unix_ms>")
|
||||
// signed as a 65-byte recoverable signature, base64, in x-bns-sig, with the
|
||||
// same timestamp in x-bns-ts. The gateway recovers the signer and checks it
|
||||
// against whoever owns the name on-chain right now.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const CFG = window.SIRIUS_EXPORT || {};
|
||||
const els = {};
|
||||
let stopping = false;
|
||||
let running = false;
|
||||
|
||||
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function log(text, kind = "") {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = text;
|
||||
if (kind === "error") li.style.color = "#b32d2e";
|
||||
if (kind === "ok") li.style.color = "#007017";
|
||||
els.log.prepend(li);
|
||||
}
|
||||
|
||||
function status(text) {
|
||||
els.status.textContent = text;
|
||||
}
|
||||
|
||||
const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(CFG.restUrl + path, {
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-wp-nonce": CFG.nonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(json.message || `WordPress returned HTTP ${response.status}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Sign and PUT one file. Returns nothing; throws on failure. */
|
||||
async function upload(wallet, item) {
|
||||
const body = base64ToBytes(item.body_b64);
|
||||
const ts = Date.now();
|
||||
const envelope = `BNS-SITE1\n${CFG.name}\n${item.path}\n${item.sha256}\n${ts}`;
|
||||
const digest = await wallet.sha256(enc.encode(envelope));
|
||||
const signature = await wallet.signRaw(digest);
|
||||
|
||||
const url =
|
||||
CFG.gateway.replace(/\/$/, "") +
|
||||
"/api/site/" +
|
||||
encodeURIComponent(CFG.name) +
|
||||
"/" +
|
||||
item.path.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": item.mime,
|
||||
"x-bns-sig": signature,
|
||||
"x-bns-ts": String(ts),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !json.ok) {
|
||||
throw new Error(json.error || `gateway returned HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (running) return;
|
||||
|
||||
const phrase = els.phrase.value;
|
||||
const check = window.SiriusWallet.validatePhrase(phrase);
|
||||
if (!check.ok) {
|
||||
status(check.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let wallet;
|
||||
try {
|
||||
wallet = await window.SiriusWallet.fromPhrase(phrase, {
|
||||
prefix: CFG.prefix,
|
||||
path: CFG.path,
|
||||
});
|
||||
} catch (err) {
|
||||
status(err.message || String(err));
|
||||
return;
|
||||
}
|
||||
// Out of the DOM as soon as it has been used. The wallet object keeps the
|
||||
// derived key for the run and is wiped in the finally below.
|
||||
els.phrase.value = "";
|
||||
|
||||
running = true;
|
||||
stopping = false;
|
||||
els.stop.hidden = false;
|
||||
els.run.disabled = true;
|
||||
log(`signing as ${wallet.address}`);
|
||||
|
||||
let published = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (stopping) {
|
||||
status("Stopped.");
|
||||
break;
|
||||
}
|
||||
status("Building the next batch…");
|
||||
const batch = await api("/next?limit=3");
|
||||
if (!batch.items.length) {
|
||||
status(
|
||||
published || failed
|
||||
? `Finished — ${published} published, ${failed} failed.`
|
||||
: "Nothing queued.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (stopping) break;
|
||||
status(`Publishing ${item.path} — ${batch.left} left`);
|
||||
try {
|
||||
await upload(wallet, item);
|
||||
await api("/ack", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: item.id, sha256: item.sha256 }),
|
||||
});
|
||||
published++;
|
||||
log(`✓ ${item.path}`, "ok");
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const message = err.message || String(err);
|
||||
log(`✗ ${item.path} — ${message}`, "error");
|
||||
await api("/ack", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: item.id, error: message }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
status(err.message || String(err));
|
||||
} finally {
|
||||
if (wallet.forget) wallet.forget();
|
||||
running = false;
|
||||
els.stop.hidden = true;
|
||||
els.run.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
els.phrase = document.getElementById("sirius_export_phrase");
|
||||
els.run = document.getElementById("sirius_export_run");
|
||||
els.stop = document.getElementById("sirius_export_stop");
|
||||
els.status = document.getElementById("sirius_export_status");
|
||||
els.log = document.getElementById("sirius_export_log");
|
||||
if (!els.run || !window.SiriusWallet) return;
|
||||
|
||||
els.run.addEventListener("click", run);
|
||||
els.stop.addEventListener("click", () => {
|
||||
stopping = true;
|
||||
status("Stopping after this file…");
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The export screen.
|
||||
*
|
||||
* Shows what is waiting, what failed and why, and offers the two buttons that
|
||||
* matter: export everything, and try the failures again.
|
||||
*
|
||||
* It also hosts manual signing. In manual mode the server has no key, so the
|
||||
* upload cannot happen without a person: this page builds each queued file
|
||||
* server-side, hands the bytes to the browser, and the browser signs and PUTs
|
||||
* them straight to the gateway. The file never round-trips through anyone
|
||||
* else's server, and the key never touches this one.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Admin {
|
||||
|
||||
const PAGE = 'sirius-press-export';
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'menu' ), 30 );
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
|
||||
}
|
||||
|
||||
public static function menu() {
|
||||
add_submenu_page(
|
||||
'sirius-press',
|
||||
__( 'Publishing', 'sirius-press' ),
|
||||
__( 'Publishing', 'sirius-press' ),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( __CLASS__, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function enqueue( $hook ) {
|
||||
if ( false === strpos( (string) $hook, self::PAGE ) ) {
|
||||
return;
|
||||
}
|
||||
if ( SP_Settings::MODE_SERVER === SP_Settings::mode() ) {
|
||||
return; // Nothing to sign here; cron has the key.
|
||||
}
|
||||
if ( ! class_exists( 'SPA_Login' ) ) {
|
||||
return; // Auth plugin inactive — no wallet library to lean on.
|
||||
}
|
||||
SPA_Login::enqueue_wallet();
|
||||
wp_enqueue_script(
|
||||
'sirius-press-export',
|
||||
SIRIUS_PRESS_EXPORT_URL . 'assets/export.js',
|
||||
array( 'sirius-press-wallet', 'wp-api-fetch' ),
|
||||
SIRIUS_PRESS_EXPORT_VERSION,
|
||||
true
|
||||
);
|
||||
wp_localize_script(
|
||||
'sirius-press-export',
|
||||
'SIRIUS_EXPORT',
|
||||
array(
|
||||
'restUrl' => esc_url_raw( rest_url( 'sirius-press/v1/export' ) ),
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
'gateway' => SP_Settings::gateway(),
|
||||
'name' => SP_Settings::name(),
|
||||
'prefix' => SP_Settings::prefix(),
|
||||
'path' => SP_Settings::derivation_path(),
|
||||
)
|
||||
);
|
||||
add_action( 'admin_footer', array( 'SPA_Login', 'print_config' ) );
|
||||
}
|
||||
|
||||
// -------------------------------------------------- manual-signing routes
|
||||
|
||||
public static function register_routes() {
|
||||
$can_manage = function () {
|
||||
return current_user_can( 'manage_options' );
|
||||
};
|
||||
|
||||
register_rest_route(
|
||||
'sirius-press/v1',
|
||||
'/export/next',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'permission_callback' => $can_manage,
|
||||
'callback' => array( __CLASS__, 'rest_next' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
'sirius-press/v1',
|
||||
'/export/ack',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'permission_callback' => $can_manage,
|
||||
'callback' => array( __CLASS__, 'rest_ack' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the next few queued files and hand them to the browser.
|
||||
*
|
||||
* Bodies come back base64-encoded because a queued file may be a PNG, and
|
||||
* JSON has no way to carry arbitrary bytes otherwise.
|
||||
*/
|
||||
public static function rest_next( WP_REST_Request $request ) {
|
||||
$limit = max( 1, min( 5, (int) $request->get_param( 'limit' ) ?: 3 ) );
|
||||
$items = array();
|
||||
|
||||
foreach ( SPE_Queue::claim( $limit ) as $row ) {
|
||||
$body = SPE_Runner::build( $row );
|
||||
if ( is_wp_error( $body ) ) {
|
||||
SPE_Queue::mark_failed( $row->id, $body->get_error_message() );
|
||||
continue;
|
||||
}
|
||||
$hash = hash( 'sha256', $body );
|
||||
if ( '' !== $row->content_hash && hash_equals( $row->content_hash, $hash ) ) {
|
||||
SPE_Queue::mark_done( $row->id, $hash );
|
||||
continue;
|
||||
}
|
||||
$items[] = array(
|
||||
'id' => (int) $row->id,
|
||||
'path' => $row->path,
|
||||
'mime' => SP_Gateway::mime_for( $row->path ),
|
||||
'sha256' => $hash,
|
||||
'body_b64' => base64_encode( $body ),
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'items' => $items,
|
||||
'left' => SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The browser reports how an upload went. */
|
||||
public static function rest_ack( WP_REST_Request $request ) {
|
||||
$id = (int) $request->get_param( 'id' );
|
||||
$hash = (string) $request->get_param( 'sha256' );
|
||||
$error = (string) $request->get_param( 'error' );
|
||||
|
||||
if ( '' !== $error ) {
|
||||
SPE_Queue::mark_failed( $id, $error );
|
||||
return rest_ensure_response( array( 'ok' => false ) );
|
||||
}
|
||||
if ( ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
|
||||
return new WP_Error( 'sirius_bad_hash', __( 'Bad hash.', 'sirius-press' ), array( 'status' => 400 ) );
|
||||
}
|
||||
SPE_Queue::mark_done( $id, $hash );
|
||||
return rest_ensure_response( array( 'ok' => true ) );
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ page
|
||||
|
||||
public static function render() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You are not allowed to publish this site.', 'sirius-press' ) );
|
||||
}
|
||||
|
||||
$notes = array();
|
||||
if ( isset( $_POST['sirius_export_action'] ) && check_admin_referer( 'sirius_export' ) ) {
|
||||
$action = sanitize_key( wp_unslash( $_POST['sirius_export_action'] ) );
|
||||
if ( 'full' === $action ) {
|
||||
$n = SPE_Runner::queue_full_site();
|
||||
$notes[] = sprintf(
|
||||
/* translators: %d: number of pages queued. */
|
||||
_n( '%d page queued for export.', '%d pages queued for export.', $n, 'sirius-press' ),
|
||||
$n
|
||||
);
|
||||
} elseif ( 'retry' === $action ) {
|
||||
$n = SPE_Queue::retry_failed();
|
||||
$notes[] = sprintf(
|
||||
/* translators: %d: number of failed exports re-queued. */
|
||||
_n( '%d failed export queued again.', '%d failed exports queued again.', $n, 'sirius-press' ),
|
||||
$n
|
||||
);
|
||||
} elseif ( 'run' === $action ) {
|
||||
$r = SPE_Runner::run_batch( 20 );
|
||||
$notes[] = sprintf(
|
||||
/* translators: 1: uploaded, 2: unchanged, 3: failed, 4: remaining. */
|
||||
__( 'Uploaded %1$d, %2$d unchanged, %3$d failed, %4$d still queued.', 'sirius-press' ),
|
||||
$r['done'],
|
||||
$r['skipped'],
|
||||
$r['failed'],
|
||||
$r['left']
|
||||
);
|
||||
} elseif ( 'clear' === $action ) {
|
||||
SPE_Queue::clear_done();
|
||||
$notes[] = __( 'Completed rows cleared.', 'sirius-press' );
|
||||
}
|
||||
}
|
||||
|
||||
$counts = SPE_Queue::counts();
|
||||
$manual = SP_Settings::MODE_SERVER !== SP_Settings::mode();
|
||||
|
||||
echo '<div class="wrap"><h1>' . esc_html__( 'Publishing', 'sirius-press' ) . '</h1>';
|
||||
|
||||
foreach ( $notes as $n ) {
|
||||
printf( '<div class="notice notice-success"><p>%s</p></div>', esc_html( $n ) );
|
||||
}
|
||||
|
||||
if ( ! SP_Settings::is_configured() ) {
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'No BCNR name is configured, so there is nowhere to publish to.', 'sirius-press' ),
|
||||
esc_url( admin_url( 'admin.php?page=sirius-press' ) ),
|
||||
esc_html__( 'Settings', 'sirius-press' )
|
||||
);
|
||||
echo '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Plain permalinks are fatal to a static export and the failure is
|
||||
* silent: every post's URL is `/?p=N`, whose path is `/`, so every
|
||||
* page in the site maps to index.html and each one overwrites the
|
||||
* last. The queue looks healthy the whole time.
|
||||
*/
|
||||
if ( '' === (string) get_option( 'permalink_structure' ) ) {
|
||||
printf(
|
||||
'<div class="notice notice-error"><p><strong>%s</strong> %s <a href="%s">%s</a></p></div>',
|
||||
esc_html__( 'This site uses plain permalinks.', 'sirius-press' ),
|
||||
esc_html__( 'Every page would be exported to the same file and overwrite the one before it, so nothing useful can be published until that is changed.', 'sirius-press' ),
|
||||
esc_url( admin_url( 'options-permalink.php' ) ),
|
||||
esc_html__( 'Choose a permalink structure', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
|
||||
printf(
|
||||
'<p>%s <code>%s</code>%s</p>',
|
||||
esc_html__( 'Publishing to', 'sirius-press' ),
|
||||
esc_html( SP_Settings::name() ),
|
||||
$manual
|
||||
? ' — ' . esc_html__( 'signing happens in your browser, on this page.', 'sirius-press' )
|
||||
: ' — ' . esc_html__( 'this server signs uploads by itself.', 'sirius-press' )
|
||||
);
|
||||
|
||||
printf(
|
||||
'<p><strong>%1$d</strong> %2$s <strong>%3$d</strong> %4$s <strong>%5$d</strong> %6$s</p>',
|
||||
(int) $counts[ SPE_Queue::STATUS_PENDING ],
|
||||
esc_html__( 'queued', 'sirius-press' ),
|
||||
(int) $counts[ SPE_Queue::STATUS_DONE ],
|
||||
esc_html__( 'published', 'sirius-press' ),
|
||||
(int) $counts[ SPE_Queue::STATUS_FAILED ],
|
||||
esc_html__( 'failed', 'sirius-press' )
|
||||
);
|
||||
|
||||
echo '<form method="post" style="margin:16px 0">';
|
||||
wp_nonce_field( 'sirius_export' );
|
||||
echo '<button class="button button-primary" name="sirius_export_action" value="full">'
|
||||
. esc_html__( 'Export everything', 'sirius-press' ) . '</button> ';
|
||||
if ( ! $manual ) {
|
||||
echo '<button class="button" name="sirius_export_action" value="run">'
|
||||
. esc_html__( 'Run a batch now', 'sirius-press' ) . '</button> ';
|
||||
}
|
||||
echo '<button class="button" name="sirius_export_action" value="retry">'
|
||||
. esc_html__( 'Try failures again', 'sirius-press' ) . '</button> ';
|
||||
echo '<button class="button" name="sirius_export_action" value="clear">'
|
||||
. esc_html__( 'Clear finished rows', 'sirius-press' ) . '</button>';
|
||||
echo '</form>';
|
||||
|
||||
if ( $manual ) {
|
||||
self::render_manual_panel();
|
||||
}
|
||||
|
||||
self::render_queue_table();
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private static function render_manual_panel() {
|
||||
?>
|
||||
<div id="sirius-export-panel" class="sirius-wallet" style="max-width:820px">
|
||||
<h2 style="margin-top:0"><?php esc_html_e( 'Sign and upload', 'sirius-press' ); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Your phrase is used in this page to sign each upload and is wiped when the run finishes. Files go straight from this browser to the gateway.', 'sirius-press' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<label for="sirius_export_phrase"><strong><?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?></strong></label><br>
|
||||
<textarea id="sirius_export_phrase" rows="2" style="width:100%;font-family:monospace"
|
||||
autocomplete="off" spellcheck="false"></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-primary" id="sirius_export_run">
|
||||
<?php esc_html_e( 'Publish queued pages', 'sirius-press' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="sirius_export_stop" hidden>
|
||||
<?php esc_html_e( 'Stop', 'sirius-press' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<p id="sirius_export_status" role="status" aria-live="polite"></p>
|
||||
<ul id="sirius_export_log" style="font-family:monospace;font-size:12px;max-height:240px;overflow:auto;margin:0"></ul>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
private static function render_queue_table() {
|
||||
$rows = SPE_Queue::recent( 100 );
|
||||
echo '<h2>' . esc_html__( 'Queue', 'sirius-press' ) . '</h2>';
|
||||
if ( ! $rows ) {
|
||||
echo '<p>' . esc_html__( 'Nothing queued. Publish a post, or use “Export everything”.', 'sirius-press' ) . '</p>';
|
||||
return;
|
||||
}
|
||||
echo '<table class="widefat striped"><thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Path', 'sirius-press' ) . '</th>';
|
||||
echo '<th style="width:100px">' . esc_html__( 'Status', 'sirius-press' ) . '</th>';
|
||||
echo '<th style="width:60px">' . esc_html__( 'Tries', 'sirius-press' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Last error', 'sirius-press' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
foreach ( $rows as $row ) {
|
||||
$colour = SPE_Queue::STATUS_FAILED === $row->status
|
||||
? '#b32d2e'
|
||||
: ( SPE_Queue::STATUS_DONE === $row->status ? '#007017' : '#646970' );
|
||||
printf(
|
||||
'<tr><td><code>%s</code></td><td style="color:%s;font-weight:600">%s</td><td>%d</td><td><small>%s</small></td></tr>',
|
||||
esc_html( $row->path ),
|
||||
esc_attr( $colour ),
|
||||
esc_html( $row->status ),
|
||||
(int) $row->attempts,
|
||||
esc_html( (string) $row->last_error )
|
||||
);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* WP-CLI commands for publishing.
|
||||
*
|
||||
* The one place the fork is genuinely better driven from a terminal is the
|
||||
* first full export of an existing site: thousands of pages, a long run, and
|
||||
* a browser tab that must not be closed. `wp sirius export --all` does it
|
||||
* with no tab involved.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish this site to its BCNR name.
|
||||
*/
|
||||
final class SPE_CLI {
|
||||
|
||||
/**
|
||||
* Queue pages and push them to the name's storage.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--all]
|
||||
* : Queue every published page, archive and attachment page first.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : Stop after this many uploads. Default: everything queued.
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Build each file and report what would be uploaded, without uploading.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp sirius export --all
|
||||
* wp sirius export --limit=50
|
||||
* wp sirius export --all --dry-run
|
||||
*
|
||||
* @when after_wp_load
|
||||
*/
|
||||
public function export( $args, $assoc ) {
|
||||
if ( ! SP_Settings::is_configured() ) {
|
||||
WP_CLI::error( 'No BCNR name is configured. Set one in Sirius Press settings first.' );
|
||||
}
|
||||
|
||||
if ( ! empty( $assoc['all'] ) ) {
|
||||
$queued = SPE_Runner::queue_full_site();
|
||||
WP_CLI::log( sprintf( 'Queued %d paths.', $queued ) );
|
||||
}
|
||||
|
||||
$dry = ! empty( $assoc['dry-run'] );
|
||||
$limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 0;
|
||||
|
||||
if ( ! $dry && SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||
WP_CLI::error(
|
||||
'This site is set to manual signing, so no key is available here. '
|
||||
. 'Switch to automatic publishing in settings, or sign from the Publishing screen in wp-admin.'
|
||||
);
|
||||
}
|
||||
|
||||
$done = 0;
|
||||
$fail = 0;
|
||||
$skip = 0;
|
||||
|
||||
while ( true ) {
|
||||
$rows = SPE_Queue::claim( 10 );
|
||||
if ( ! $rows ) {
|
||||
break;
|
||||
}
|
||||
foreach ( $rows as $row ) {
|
||||
if ( $limit > 0 && $done + $fail + $skip >= $limit ) {
|
||||
break 2;
|
||||
}
|
||||
if ( $dry ) {
|
||||
$body = SPE_Runner::build( $row );
|
||||
if ( is_wp_error( $body ) ) {
|
||||
WP_CLI::warning( sprintf( '%s — %s', $row->path, $body->get_error_message() ) );
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
WP_CLI::log( sprintf( '%s (%s)', $row->path, size_format( strlen( $body ) ) ) );
|
||||
$skip++;
|
||||
// A dry run must not consume the queue, so the row is left
|
||||
// pending — which also means this loop would spin forever
|
||||
// on the same rows. Stop after one pass instead.
|
||||
continue;
|
||||
}
|
||||
$outcome = SPE_Runner::process( $row );
|
||||
if ( 'done' === $outcome ) {
|
||||
$done++;
|
||||
WP_CLI::log( sprintf( '✓ %s', $row->path ) );
|
||||
} elseif ( 'skipped' === $outcome ) {
|
||||
$skip++;
|
||||
} else {
|
||||
$fail++;
|
||||
WP_CLI::warning( sprintf( '✗ %s', $row->path ) );
|
||||
}
|
||||
}
|
||||
if ( $dry ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
WP_CLI::success( sprintf( '%d uploaded, %d unchanged, %d failed.', $done, $skip, $fail ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the publishing status of this site.
|
||||
*
|
||||
* @when after_wp_load
|
||||
*/
|
||||
public function status() {
|
||||
$counts = SPE_Queue::counts();
|
||||
$owner = SP_Gateway::owner_of_name();
|
||||
$mine = SP_Settings::publishing_address();
|
||||
|
||||
WP_CLI::log( 'Name: ' . ( SP_Settings::name() ?: '(not set)' ) );
|
||||
WP_CLI::log( 'Network: ' . SP_Settings::network() );
|
||||
WP_CLI::log( 'Gateway: ' . SP_Settings::gateway() );
|
||||
WP_CLI::log( 'Mode: ' . SP_Settings::mode() );
|
||||
WP_CLI::log( 'Signs as: ' . ( $mine ?: '(no key stored)' ) );
|
||||
WP_CLI::log( 'Owner: ' . ( $owner ?: '(gateway did not answer)' ) );
|
||||
|
||||
if ( $owner && $mine && ! hash_equals( $owner, $mine ) ) {
|
||||
WP_CLI::warning( 'The stored key does not own this name. Uploads will be refused.' );
|
||||
}
|
||||
|
||||
WP_CLI::log(
|
||||
sprintf(
|
||||
'Queue: %d pending, %d done, %d failed',
|
||||
$counts[ SPE_Queue::STATUS_PENDING ],
|
||||
$counts[ SPE_Queue::STATUS_DONE ],
|
||||
$counts[ SPE_Queue::STATUS_FAILED ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
WP_CLI::add_command( 'sirius', 'SPE_CLI' );
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* What makes publishing a post also publish it to the name.
|
||||
*
|
||||
* Everything hangs off `transition_post_status`, which fires for every way a
|
||||
* post can change state — the editor, quick edit, a scheduled publish firing
|
||||
* from cron, the REST API, WP-CLI, an importer. Hooking `save_post` instead
|
||||
* would miss the scheduled case, which is exactly the case a site owner most
|
||||
* expects to work while they are asleep.
|
||||
*
|
||||
* Unpublishing is handled too, and it matters more than it looks: a static
|
||||
* mirror that keeps serving a post the author deleted is worse than no mirror,
|
||||
* because the author believes the page is gone.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Hooks {
|
||||
|
||||
public static function hooks() {
|
||||
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||
add_action( 'before_delete_post', array( __CLASS__, 'on_delete' ), 10, 2 );
|
||||
add_action( 'edited_term', array( __CLASS__, 'on_term_edited' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $new_status
|
||||
* @param string $old_status
|
||||
* @param WP_Post $post
|
||||
*/
|
||||
public static function on_transition( $new_status, $old_status, $post ) {
|
||||
if ( ! self::should_export( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Went from public to not — take the file down before refreshing the
|
||||
// pages that used to link to it.
|
||||
if ( 'publish' === $old_status && 'publish' !== $new_status ) {
|
||||
$path = SPE_Mapper::path_for_post( $post );
|
||||
if ( '' !== $path ) {
|
||||
self::unpublish( $path );
|
||||
}
|
||||
}
|
||||
|
||||
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
|
||||
return; // A draft being edited changes nothing anyone can see.
|
||||
}
|
||||
|
||||
foreach ( SPE_Mapper::affected_by_post( $post ) as $path => $target ) {
|
||||
SPE_Queue::add( $path, $target['url'], $target['kind'] );
|
||||
}
|
||||
}
|
||||
|
||||
public static function on_delete( $post_id, $post = null ) {
|
||||
$post = $post ? $post : get_post( $post_id );
|
||||
if ( ! $post || ! self::should_export( $post ) || 'publish' !== $post->post_status ) {
|
||||
return;
|
||||
}
|
||||
$path = SPE_Mapper::path_for_post( $post );
|
||||
if ( '' !== $path ) {
|
||||
self::unpublish( $path );
|
||||
}
|
||||
foreach ( SPE_Mapper::affected_by_post( $post ) as $affected => $target ) {
|
||||
if ( $affected !== $path ) {
|
||||
SPE_Queue::add( $affected, $target['url'], $target['kind'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Renaming a category changes its archive URL and every page linking to it. */
|
||||
public static function on_term_edited( $term_id, $tt_id, $taxonomy ) {
|
||||
if ( ! SP_Settings::auto_publish() ) {
|
||||
return;
|
||||
}
|
||||
$tax = get_taxonomy( $taxonomy );
|
||||
if ( ! $tax || ! $tax->public ) {
|
||||
return;
|
||||
}
|
||||
$link = get_term_link( (int) $term_id, $taxonomy );
|
||||
if ( is_wp_error( $link ) ) {
|
||||
return;
|
||||
}
|
||||
$path = SPE_Mapper::path_for_url_path( (string) wp_parse_url( $link, PHP_URL_PATH ) );
|
||||
if ( '' !== $path ) {
|
||||
SPE_Queue::add( $path, $link, 'archive' );
|
||||
}
|
||||
}
|
||||
|
||||
private static function should_export( $post ) {
|
||||
if ( ! SP_Settings::auto_publish() || ! SP_Settings::is_configured() ) {
|
||||
return false;
|
||||
}
|
||||
if ( ! $post instanceof WP_Post ) {
|
||||
return false;
|
||||
}
|
||||
if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
|
||||
return false;
|
||||
}
|
||||
$type = get_post_type_object( $post->post_type );
|
||||
if ( ! $type || ! $type->public ) {
|
||||
return false;
|
||||
}
|
||||
// A password-protected post renders as a password form to the
|
||||
// exporter, and publishing that form to permanent public storage
|
||||
// would be worse than not publishing at all.
|
||||
if ( '' !== $post->post_password ) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Filters whether a post is exported at all.
|
||||
*
|
||||
* @param bool $export
|
||||
* @param WP_Post $post
|
||||
*/
|
||||
return (bool) apply_filters( 'sirius_press_should_export_post', true, $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a path from the bucket.
|
||||
*
|
||||
* In manual mode there is no key to sign a delete with, so the operator is
|
||||
* told rather than left with a stale page they think is gone.
|
||||
*/
|
||||
private static function unpublish( $path ) {
|
||||
if ( SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||
SP_Inbox::add(
|
||||
0,
|
||||
__( 'A removed page is still published to your name', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: 1: bucket path, 2: export screen URL. */
|
||||
wp_kses_post( __( '<code>%1$s</code> is no longer public on this site, but this server holds no key to delete it from the name\'s storage. <a href="%2$s">Remove it from the export screen</a>.', 'sirius-press' ) ),
|
||||
esc_html( $path ),
|
||||
esc_url( admin_url( 'admin.php?page=sirius-press-export' ) )
|
||||
),
|
||||
'export'
|
||||
);
|
||||
return;
|
||||
}
|
||||
$result = SP_Gateway::delete( $path );
|
||||
if ( empty( $result['ok'] ) ) {
|
||||
SP_Inbox::add(
|
||||
0,
|
||||
__( 'A removed page could not be unpublished', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: 1: bucket path, 2: error message. */
|
||||
wp_kses_post( __( '<code>%1$s</code> could not be deleted from the name\'s storage: %2$s', 'sirius-press' ) ),
|
||||
esc_html( $path ),
|
||||
esc_html( $result['error'] )
|
||||
),
|
||||
'export'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Where each URL lands in the name's bucket.
|
||||
*
|
||||
* WordPress URLs are directories: `/hello-world/`. Object storage has no
|
||||
* directories and no index resolution of its own, so every such URL becomes
|
||||
* `hello-world/index.html` and the resolver's directory-index rescue does the
|
||||
* rest. URLs that already name a file — `/robots.txt`, `/wp-content/…/app.css`
|
||||
* — keep their own name.
|
||||
*
|
||||
* The one rule this file exists to enforce is that the mapping is total and
|
||||
* reversible enough to be safe: anything that cannot be represented as a
|
||||
* gateway-legal path returns '' rather than being coerced into some other
|
||||
* file's name. A path collision in a static export is silent data loss — one
|
||||
* page overwrites another and nobody notices until a reader hits the wrong
|
||||
* article.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Mapper {
|
||||
|
||||
/** Extensions treated as assets rather than pages. */
|
||||
const ASSET_EXTENSIONS = array(
|
||||
'css', 'js', 'mjs', 'json', 'map',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico',
|
||||
'woff', 'woff2', 'ttf', 'eot', 'otf',
|
||||
'mp3', 'mp4', 'webm', 'ogg', 'wav', 'pdf', 'zip', 'txt', 'xml',
|
||||
);
|
||||
|
||||
/**
|
||||
* Bucket path for a site-relative URL path.
|
||||
*
|
||||
* @param string $url_path e.g. '/blog/hello/' or '/wp-content/x.css'.
|
||||
* @return string '' when the path cannot be exported.
|
||||
*/
|
||||
public static function path_for_url_path( $url_path ) {
|
||||
$home_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
|
||||
$url_path = '/' . ltrim( (string) $url_path, '/' );
|
||||
|
||||
// A site installed in a subdirectory serves its own root at that
|
||||
// prefix; the export has the name's root, so the prefix comes off.
|
||||
if ( '' !== $home_path && '/' !== $home_path && 0 === strpos( $url_path, $home_path ) ) {
|
||||
$url_path = '/' . ltrim( substr( $url_path, strlen( $home_path ) ), '/' );
|
||||
}
|
||||
|
||||
$url_path = rawurldecode( $url_path );
|
||||
$trimmed = trim( $url_path, '/' );
|
||||
|
||||
if ( '' === $trimmed ) {
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
// A URL naming a file keeps its name; a URL naming a directory gets an
|
||||
// index.html inside it. "Names a file" means the last segment has an
|
||||
// extension AND the URL did not end in a slash — `/about.us/` is a
|
||||
// page called "about.us", not a file.
|
||||
$slash = strrpos( $trimmed, '/' );
|
||||
$last = false === $slash ? $trimmed : substr( $trimmed, $slash + 1 );
|
||||
$path = ( false !== strpos( $last, '.' ) && '/' !== substr( $url_path, -1 ) )
|
||||
? $trimmed
|
||||
: $trimmed . '/index.html';
|
||||
|
||||
// Everything the gateway would reject is better refused here, where
|
||||
// there is still a chance to leave the original URL alone.
|
||||
return SP_Gateway::clean_path( $path );
|
||||
}
|
||||
|
||||
/** Bucket path for a post, page or any public post type. */
|
||||
public static function path_for_post( $post ) {
|
||||
$post = get_post( $post );
|
||||
if ( ! $post ) {
|
||||
return '';
|
||||
}
|
||||
$permalink = get_permalink( $post );
|
||||
if ( ! $permalink ) {
|
||||
return '';
|
||||
}
|
||||
return self::path_for_url_path( (string) wp_parse_url( $permalink, PHP_URL_PATH ) );
|
||||
}
|
||||
|
||||
public static function is_asset( $path ) {
|
||||
$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
|
||||
return in_array( $ext, self::ASSET_EXTENSIONS, true ) && 'html' !== $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a full export should cover.
|
||||
*
|
||||
* @return array<string,array{url:string,kind:string}> path => descriptor.
|
||||
*/
|
||||
public static function full_site() {
|
||||
$targets = array();
|
||||
|
||||
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
|
||||
if ( ! $url ) {
|
||||
return;
|
||||
}
|
||||
$path = self::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
|
||||
if ( '' === $path ) {
|
||||
return;
|
||||
}
|
||||
$targets[ $path ] = array(
|
||||
'url' => $url,
|
||||
'kind' => $kind,
|
||||
);
|
||||
};
|
||||
|
||||
$add( home_url( '/' ) );
|
||||
|
||||
// Every published thing with a public permalink.
|
||||
$types = get_post_types( array( 'public' => true ), 'names' );
|
||||
unset( $types['attachment'] );
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => array_values( $types ),
|
||||
'post_status' => 'publish',
|
||||
'numberposts' => -1,
|
||||
'fields' => 'ids',
|
||||
'suppress_filters' => false,
|
||||
)
|
||||
);
|
||||
foreach ( $posts as $post_id ) {
|
||||
$add( get_permalink( $post_id ) );
|
||||
}
|
||||
|
||||
// Archives people actually navigate through. Date archives are left
|
||||
// out by default: on a long-running blog they multiply into hundreds
|
||||
// of near-identical pages that nothing links to.
|
||||
foreach ( get_taxonomies( array( 'public' => true ), 'names' ) as $taxonomy ) {
|
||||
$terms = get_terms(
|
||||
array(
|
||||
'taxonomy' => $taxonomy,
|
||||
'hide_empty' => true,
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $terms ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( $terms as $term ) {
|
||||
$link = get_term_link( $term );
|
||||
if ( ! is_wp_error( $link ) ) {
|
||||
$add( $link, 'archive' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
|
||||
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the full list of export targets.
|
||||
*
|
||||
* @param array $targets path => {url, kind}.
|
||||
*/
|
||||
return apply_filters( 'sirius_press_export_targets', $targets );
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths that go stale when one post changes.
|
||||
*
|
||||
* A post edit does not only change the post: it changes the home page, the
|
||||
* archives it belongs to, and the posts either side of it if the theme
|
||||
* shows previous/next links. Being generous here costs a few uploads;
|
||||
* being stingy leaves a reader looking at a stale index.
|
||||
*
|
||||
* @return array<string,array{url:string,kind:string}>
|
||||
*/
|
||||
public static function affected_by_post( $post ) {
|
||||
$post = get_post( $post );
|
||||
$targets = array();
|
||||
if ( ! $post ) {
|
||||
return $targets;
|
||||
}
|
||||
|
||||
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
|
||||
if ( ! $url || is_wp_error( $url ) ) {
|
||||
return;
|
||||
}
|
||||
$path = SPE_Mapper::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
|
||||
if ( '' !== $path ) {
|
||||
$targets[ $path ] = array(
|
||||
'url' => $url,
|
||||
'kind' => $kind,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if ( 'publish' === $post->post_status ) {
|
||||
$add( get_permalink( $post ) );
|
||||
}
|
||||
$add( home_url( '/' ) );
|
||||
|
||||
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
|
||||
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
|
||||
}
|
||||
|
||||
foreach ( get_object_taxonomies( $post->post_type, 'names' ) as $taxonomy ) {
|
||||
$tax = get_taxonomy( $taxonomy );
|
||||
if ( ! $tax || ! $tax->public ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( (array) get_the_terms( $post, $taxonomy ) as $term ) {
|
||||
if ( $term instanceof WP_Term ) {
|
||||
$add( get_term_link( $term ), 'archive' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters which paths a post change invalidates.
|
||||
*
|
||||
* @param array $targets
|
||||
* @param WP_Post $post
|
||||
*/
|
||||
return apply_filters( 'sirius_press_affected_paths', $targets, $post );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The export queue.
|
||||
*
|
||||
* Publishing a post must not wait on a network round trip to a gateway that
|
||||
* might be slow, rate limited or down. If it did, the first flaky minute the
|
||||
* gateway has would show up as WordPress hanging on "Publish", and the site
|
||||
* owner would reasonably conclude the fork is broken.
|
||||
*
|
||||
* So publishing writes a row here and returns. Something else — cron, an
|
||||
* admin screen, WP-CLI, or a browser in manual-signing mode — drains it. The
|
||||
* queue is also what makes retries, bulk exports and manual signing the same
|
||||
* mechanism instead of three.
|
||||
*
|
||||
* Rows are keyed by path, not by post. A post and its archive page and the
|
||||
* home page all move when one post changes, and several posts changing in a
|
||||
* minute should not queue the home page five times.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Queue {
|
||||
|
||||
const TABLE = 'sirius_export_queue';
|
||||
const VERSION = 1;
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_DONE = 'done';
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
/** Give up after this many attempts and leave the row for a human. */
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
public static function table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE;
|
||||
}
|
||||
|
||||
public static function install() {
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
$table = self::table();
|
||||
$collate = $wpdb->get_charset_collate();
|
||||
dbDelta(
|
||||
"CREATE TABLE {$table} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
path varchar(200) NOT NULL,
|
||||
source_url text NOT NULL,
|
||||
kind varchar(20) NOT NULL DEFAULT 'page',
|
||||
status varchar(12) NOT NULL DEFAULT 'pending',
|
||||
attempts smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||
last_error text NULL,
|
||||
content_hash char(64) NOT NULL DEFAULT '',
|
||||
queued_at datetime NOT NULL,
|
||||
done_at datetime NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY path (path),
|
||||
KEY status (status)
|
||||
) {$collate};"
|
||||
);
|
||||
update_option( 'sirius_press_export_db_version', self::VERSION, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or refresh a path.
|
||||
*
|
||||
* An existing row is reset to pending rather than duplicated, so a path
|
||||
* that changes ten times before the queue drains is exported once, from
|
||||
* its final state.
|
||||
*
|
||||
* @param string $path Path inside the bucket.
|
||||
* @param string $source_url The URL to render.
|
||||
* @param string $kind 'page', 'asset', 'feed' — for the admin UI only.
|
||||
*/
|
||||
public static function add( $path, $source_url, $kind = 'page' ) {
|
||||
global $wpdb;
|
||||
$path = SP_Gateway::clean_path( $path );
|
||||
if ( '' === $path ) {
|
||||
return false;
|
||||
}
|
||||
$table = self::table();
|
||||
// A single statement so two concurrent publishes cannot both decide
|
||||
// the row is missing and then collide on the unique index.
|
||||
$sql = $wpdb->prepare(
|
||||
"INSERT INTO {$table} (path, source_url, kind, status, attempts, queued_at)
|
||||
VALUES (%s, %s, %s, %s, 0, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
source_url = VALUES(source_url),
|
||||
kind = VALUES(kind),
|
||||
status = VALUES(status),
|
||||
attempts = 0,
|
||||
last_error = NULL,
|
||||
queued_at = VALUES(queued_at)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$path,
|
||||
(string) $source_url,
|
||||
$kind,
|
||||
self::STATUS_PENDING,
|
||||
current_time( 'mysql', true )
|
||||
);
|
||||
return false !== $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
|
||||
/** @return array Rows waiting to go out. */
|
||||
public static function claim( $limit = 10 ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
return (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE status = %s AND attempts < %d ORDER BY id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
self::STATUS_PENDING,
|
||||
self::MAX_ATTEMPTS,
|
||||
(int) $limit
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function mark_done( $id, $hash ) {
|
||||
global $wpdb;
|
||||
$wpdb->update(
|
||||
self::table(),
|
||||
array(
|
||||
'status' => self::STATUS_DONE,
|
||||
'done_at' => current_time( 'mysql', true ),
|
||||
'content_hash' => $hash,
|
||||
'last_error' => null,
|
||||
),
|
||||
array( 'id' => (int) $id ),
|
||||
array( '%s', '%s', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function mark_failed( $id, $error ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET attempts = attempts + 1, last_error = %s,
|
||||
status = CASE WHEN attempts + 1 >= %d THEN %s ELSE %s END
|
||||
WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
mb_substr( (string) $error, 0, 1000 ),
|
||||
self::MAX_ATTEMPTS,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_PENDING,
|
||||
(int) $id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Put failed rows back in the queue — the "try again" button. */
|
||||
public static function retry_failed() {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->update(
|
||||
self::table(),
|
||||
array(
|
||||
'status' => self::STATUS_PENDING,
|
||||
'attempts' => 0,
|
||||
),
|
||||
array( 'status' => self::STATUS_FAILED ),
|
||||
array( '%s', '%d' ),
|
||||
array( '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function counts() {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
$rows = (array) $wpdb->get_results( "SELECT status, COUNT(*) AS n FROM {$table} GROUP BY status" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$out = array(
|
||||
self::STATUS_PENDING => 0,
|
||||
self::STATUS_DONE => 0,
|
||||
self::STATUS_FAILED => 0,
|
||||
);
|
||||
foreach ( $rows as $row ) {
|
||||
$out[ $row->status ] = (int) $row->n;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function recent( $limit = 50, $status = '' ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
if ( '' !== $status ) {
|
||||
return (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY id DESC LIMIT %d", $status, (int) $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
return (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", (int) $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hash of what was last successfully published at a path.
|
||||
*
|
||||
* Used to skip an upload whose bytes have not changed — the common case
|
||||
* when a post edit re-renders the home page identically, and the
|
||||
* difference between a queue that costs bandwidth and one that does not.
|
||||
*/
|
||||
public static function hash_at( $path ) {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
// Deliberately not filtered by status: re-queueing a path resets it to
|
||||
// pending but leaves the hash of what is actually sitting in the
|
||||
// bucket, which is the thing worth comparing against.
|
||||
return (string) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT content_hash FROM {$table} WHERE path = %s", $path ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
|
||||
public static function clear_done() {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->delete( self::table(), array( 'status' => self::STATUS_DONE ), array( '%s' ) );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Turning a live WordPress page into a file that stands on its own.
|
||||
*
|
||||
* The export is fetched over a loopback HTTP request rather than rendered
|
||||
* in-process. That costs a round trip, and it is worth it: a theme's output
|
||||
* depends on the whole request lifecycle — the main query, `wp_head`,
|
||||
* enqueued assets, late-filtered content, caching plugins. Re-creating that
|
||||
* by calling `get_the_content()` produces something that looks like the page
|
||||
* and is not the page, and the differences only show up in whichever theme
|
||||
* the site owner actually uses.
|
||||
*
|
||||
* URLs are then rewritten to be document-relative. Not absolute, because the
|
||||
* exported copy is served from the name, not from this WordPress host, and
|
||||
* every absolute link would drag readers back to the origin the export exists
|
||||
* to make optional. Not root-relative either, because the same bucket is
|
||||
* browsable under `/bns/<name>/` on the public gateway, where a root-relative
|
||||
* link points outside the site. Document-relative is the only form that works
|
||||
* in both places.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Renderer {
|
||||
|
||||
/** Marks a request as the exporter's own, so the site can render for it. */
|
||||
const FLAG = 'sirius_export';
|
||||
|
||||
/**
|
||||
* Fetch a URL as an anonymous visitor would see it.
|
||||
*
|
||||
* @param string $url
|
||||
* @return array{ok:bool,body:string,mime:string,error:string}
|
||||
*/
|
||||
public static function fetch( $url ) {
|
||||
$request_url = add_query_arg( self::FLAG, self::token(), $url );
|
||||
$headers = array(
|
||||
'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)',
|
||||
'accept-encoding' => 'identity',
|
||||
);
|
||||
|
||||
/*
|
||||
* Send the request somewhere PHP can actually reach, while still
|
||||
* claiming the site's real hostname.
|
||||
*
|
||||
* Needed whenever the address visitors use is not routable from where
|
||||
* PHP runs — the normal case in Docker, where a container asking for
|
||||
* the host's own public IP gets a timeout rather than its own nginx.
|
||||
* The Host header is what keeps this honest: WordPress builds the
|
||||
* page exactly as it would for a visitor, and does not redirect the
|
||||
* exporter to a canonical URL it cannot follow.
|
||||
*/
|
||||
$loopback = SP_Settings::loopback_base();
|
||||
if ( '' !== $loopback ) {
|
||||
$parts = wp_parse_url( $request_url );
|
||||
if ( ! empty( $parts['host'] ) ) {
|
||||
$headers['host'] = $parts['host'] . ( empty( $parts['port'] ) ? '' : ':' . $parts['port'] );
|
||||
$request_url = $loopback
|
||||
. ( isset( $parts['path'] ) ? $parts['path'] : '/' )
|
||||
. ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
|
||||
}
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
$request_url,
|
||||
array(
|
||||
'timeout' => 60,
|
||||
'redirection' => 3,
|
||||
// No cookies: the export must be what a logged-out reader
|
||||
// sees, not what the admin who triggered it sees.
|
||||
'cookies' => array(),
|
||||
'sslverify' => apply_filters( 'sirius_press_export_sslverify', true ),
|
||||
'headers' => $headers,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return self::error( $response->get_error_message() );
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return self::error( sprintf( 'the site returned HTTP %d for %s', $code, $url ) );
|
||||
}
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
if ( '' === $body ) {
|
||||
return self::error( sprintf( '%s rendered an empty page', $url ) );
|
||||
}
|
||||
return array(
|
||||
'ok' => true,
|
||||
'body' => $body,
|
||||
'mime' => (string) wp_remote_retrieve_header( $response, 'content-type' ),
|
||||
'error' => '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A token proving a loopback request came from this site.
|
||||
*
|
||||
* Not a security boundary — the pages being fetched are public. It exists
|
||||
* so the site can recognise its own exporter and suppress things that
|
||||
* make no sense in a static copy (admin bar, nonce-bearing markup) without
|
||||
* giving a stranger a way to request that same altered output.
|
||||
*/
|
||||
public static function token() {
|
||||
return substr( hash_hmac( 'sha256', 'sirius-export', wp_salt( 'nonce' ) ), 0, 24 );
|
||||
}
|
||||
|
||||
/** True when the current request is the exporter fetching a page. */
|
||||
public static function is_export_request() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return isset( $_GET[ self::FLAG ] ) && hash_equals( self::token(), sanitize_text_field( wp_unslash( $_GET[ self::FLAG ] ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a page's URLs for life outside this host.
|
||||
*
|
||||
* @param string $html
|
||||
* @param string $doc_path Where this document will live, e.g. 'blog/hi/index.html'.
|
||||
* @return array{html:string,assets:array<string,string>} Rewritten HTML and the
|
||||
* local assets it referenced, as path => absolute source URL.
|
||||
*/
|
||||
public static function rewrite( $html, $doc_path ) {
|
||||
$home = untrailingslashit( home_url() );
|
||||
$site = untrailingslashit( site_url() );
|
||||
$assets = array();
|
||||
|
||||
// The exporter's own marker must never survive into the output, or
|
||||
// every internal link in the static copy carries it.
|
||||
$html = preg_replace( '/([?&])' . preg_quote( self::FLAG, '/' ) . '=[^"\'&\s]*(&|&)?/', '$1', $html );
|
||||
$html = str_replace( array( '?"', "?'" ), array( '"', "'" ), $html );
|
||||
|
||||
$bases = array_unique( array( $home, $site, set_url_scheme( $home, 'http' ), set_url_scheme( $home, 'https' ) ) );
|
||||
|
||||
// Protocol-relative references to our own host (`//example.test/…`)
|
||||
// are normalised to absolute first, so the pass below only has one
|
||||
// shape to match.
|
||||
//
|
||||
// Two details, both of which produced mangled links when they were
|
||||
// missing. The lookbehind stops this from also matching the
|
||||
// `//example.test` sitting inside every `https://example.test`, which
|
||||
// would give every absolute URL on the page a second scheme. And the
|
||||
// authority has to include the port: replacing `//host` alone inside
|
||||
// `//host:8760/x` leaves the port behind, producing `//host:8760:8760/x`.
|
||||
foreach ( array_unique( array_filter( array( $home, $site ) ) ) as $base ) {
|
||||
$authority = preg_replace( '#^https?:#', '', $base ); // '//host[:port]'
|
||||
if ( '' === $authority ) {
|
||||
continue;
|
||||
}
|
||||
$html = preg_replace(
|
||||
'#(?<!:)' . preg_quote( $authority, '#' ) . '#',
|
||||
$base,
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
$pattern = '#(?:' . implode( '|', array_map( function ( $b ) {
|
||||
return preg_quote( $b, '#' );
|
||||
}, $bases ) ) . ')(/[^"\'\s<>\\\\)]*)?#';
|
||||
|
||||
$html = preg_replace_callback(
|
||||
$pattern,
|
||||
function ( $matches ) use ( $doc_path, &$assets ) {
|
||||
$path = isset( $matches[1] ) ? $matches[1] : '/';
|
||||
return self::localise( $path, $doc_path, $assets );
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
return array(
|
||||
'html' => $html,
|
||||
'assets' => $assets,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One absolute URL becomes one relative one, and any asset it names gets
|
||||
* remembered so the caller can queue it.
|
||||
*/
|
||||
private static function localise( $path, $doc_path, &$assets ) {
|
||||
$parts = wp_parse_url( $path );
|
||||
$clean = isset( $parts['path'] ) ? $parts['path'] : '/';
|
||||
$suffix = ( isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : '' )
|
||||
. ( isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '' );
|
||||
$target = SPE_Mapper::path_for_url_path( $clean );
|
||||
|
||||
if ( '' === $target ) {
|
||||
// Nothing sensible to point at — an upload directory listing, a
|
||||
// path with characters the bucket will not take. Leave the URL
|
||||
// absolute rather than producing a link to nowhere.
|
||||
return untrailingslashit( home_url() ) . $path;
|
||||
}
|
||||
|
||||
if ( SPE_Mapper::is_asset( $target ) ) {
|
||||
$assets[ $target ] = home_url( $clean );
|
||||
// Assets are versioned with ?ver=; the bucket stores one copy, so
|
||||
// the query string would only ever produce a 404.
|
||||
$suffix = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
|
||||
}
|
||||
|
||||
return self::relative( $doc_path, $target ) . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of `$target` as seen from the document at `$from`.
|
||||
*
|
||||
* `blog/post/index.html` referring to `assets/app.css` yields
|
||||
* `../../assets/app.css`.
|
||||
*/
|
||||
public static function relative( $from, $target ) {
|
||||
$from_dir = explode( '/', $from );
|
||||
array_pop( $from_dir ); // Drop the file name.
|
||||
$to = explode( '/', $target );
|
||||
|
||||
while ( $from_dir && $to && $from_dir[0] === $to[0] ) {
|
||||
array_shift( $from_dir );
|
||||
array_shift( $to );
|
||||
}
|
||||
$up = str_repeat( '../', count( $from_dir ) );
|
||||
$rel = $up . implode( '/', $to );
|
||||
return '' === $rel ? './' : $rel;
|
||||
}
|
||||
|
||||
private static function error( $message ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'body' => '',
|
||||
'mime' => '',
|
||||
'error' => $message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the parts of a page that only make sense on the live site.
|
||||
*
|
||||
* Runs on the *live* request when the exporter is the one asking, which
|
||||
* is why it is here rather than in the rewriting pass: removing the admin
|
||||
* bar after the fact means also unpicking the styles and spacing it added.
|
||||
*/
|
||||
public static function hooks() {
|
||||
add_action(
|
||||
'init',
|
||||
function () {
|
||||
if ( ! self::is_export_request() ) {
|
||||
return;
|
||||
}
|
||||
show_admin_bar( false );
|
||||
/**
|
||||
* Fires on a page being rendered for static export.
|
||||
*
|
||||
* The place for a theme or plugin to drop anything that cannot
|
||||
* work without PHP behind it — a live search box, a comment
|
||||
* form, a cart widget.
|
||||
*/
|
||||
do_action( 'sirius_press_rendering_export' );
|
||||
},
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Draining the queue.
|
||||
*
|
||||
* One row at a time: fetch the source URL, rewrite its links, compare the
|
||||
* result against what is already in the bucket, and upload only if it
|
||||
* differs. The hash comparison is not an optimisation detail — a WordPress
|
||||
* page re-renders byte-identically far more often than it changes, and
|
||||
* without it every post edit would push the home page, every archive and
|
||||
* every asset they reference back over the wire.
|
||||
*
|
||||
* Batches are small and bounded by wall-clock time rather than count, because
|
||||
* the thing that kills a cron-driven exporter is a run that takes longer than
|
||||
* PHP's `max_execution_time` and dies halfway, leaving rows claimed and
|
||||
* nothing to show. Finishing early and picking up next time is always
|
||||
* correct here; the queue is the state.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class SPE_Runner {
|
||||
|
||||
const CRON_HOOK = 'sirius_press_export_tick';
|
||||
|
||||
/** Stop a batch after this long, whatever is left in the queue. */
|
||||
const TIME_BUDGET = 20;
|
||||
|
||||
public static function hooks() {
|
||||
add_action( self::CRON_HOOK, array( __CLASS__, 'run_batch' ) );
|
||||
add_filter( 'cron_schedules', array( __CLASS__, 'add_schedule' ) );
|
||||
}
|
||||
|
||||
public static function add_schedule( $schedules ) {
|
||||
$schedules['sirius_minute'] = array(
|
||||
'interval' => 60,
|
||||
'display' => __( 'Every minute (Sirius Press export)', 'sirius-press' ),
|
||||
);
|
||||
return $schedules;
|
||||
}
|
||||
|
||||
public static function schedule() {
|
||||
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
|
||||
wp_schedule_event( time() + 60, 'sirius_minute', self::CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
public static function unschedule() {
|
||||
$timestamp = wp_next_scheduled( self::CRON_HOOK );
|
||||
if ( $timestamp ) {
|
||||
wp_unschedule_event( $timestamp, self::CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch.
|
||||
*
|
||||
* @param int $limit Maximum rows to attempt.
|
||||
* @return array{done:int,failed:int,skipped:int,left:int}
|
||||
*/
|
||||
public static function run_batch( $limit = 10 ) {
|
||||
$result = array(
|
||||
'done' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'left' => 0,
|
||||
);
|
||||
|
||||
if ( SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||
// Manual mode: the server has no key, so there is nothing for cron
|
||||
// to do. Rows wait for a browser to sign them.
|
||||
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||||
return $result;
|
||||
}
|
||||
if ( ! SP_Settings::is_configured() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$deadline = microtime( true ) + self::TIME_BUDGET;
|
||||
foreach ( SPE_Queue::claim( $limit ) as $row ) {
|
||||
if ( microtime( true ) > $deadline ) {
|
||||
break;
|
||||
}
|
||||
$outcome = self::process( $row );
|
||||
if ( 'done' === $outcome ) {
|
||||
$result['done']++;
|
||||
} elseif ( 'skipped' === $outcome ) {
|
||||
$result['skipped']++;
|
||||
} else {
|
||||
$result['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row.
|
||||
*
|
||||
* @return string 'done', 'skipped' or 'failed'.
|
||||
*/
|
||||
public static function process( $row ) {
|
||||
$body = self::build( $row );
|
||||
if ( is_wp_error( $body ) ) {
|
||||
SPE_Queue::mark_failed( $row->id, $body->get_error_message() );
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
$hash = hash( 'sha256', $body );
|
||||
if ( '' !== $row->content_hash && hash_equals( $row->content_hash, $hash ) ) {
|
||||
// Byte-identical to what is already published.
|
||||
SPE_Queue::mark_done( $row->id, $hash );
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$upload = SP_Gateway::put( $row->path, $body );
|
||||
if ( empty( $upload['ok'] ) ) {
|
||||
SPE_Queue::mark_failed( $row->id, $upload['error'] );
|
||||
self::report_failure( $row, $upload['error'] );
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
SPE_Queue::mark_done( $row->id, $hash );
|
||||
/**
|
||||
* Fires after a path is published to the name's bucket.
|
||||
*
|
||||
* @param string $path
|
||||
* @param int $bytes
|
||||
*/
|
||||
do_action( 'sirius_press_exported', $row->path, strlen( $body ) );
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the bytes for a row.
|
||||
*
|
||||
* Assets are fetched and passed through untouched. Pages are fetched,
|
||||
* rewritten, and the assets they mention are queued behind them — which
|
||||
* is how a bulk export discovers the theme's stylesheet without anybody
|
||||
* having to list it.
|
||||
*
|
||||
* @return string|WP_Error
|
||||
*/
|
||||
public static function build( $row ) {
|
||||
$fetched = SPE_Renderer::fetch( $row->source_url );
|
||||
if ( empty( $fetched['ok'] ) ) {
|
||||
return new WP_Error( 'sirius_export_fetch', $fetched['error'] );
|
||||
}
|
||||
|
||||
if ( 'asset' === $row->kind || SPE_Mapper::is_asset( $row->path ) ) {
|
||||
return $fetched['body'];
|
||||
}
|
||||
|
||||
$rewritten = SPE_Renderer::rewrite( $fetched['body'], $row->path );
|
||||
foreach ( $rewritten['assets'] as $path => $url ) {
|
||||
// Only queue an asset the first time it is seen; re-queueing it on
|
||||
// every page that links the stylesheet would reset its row to
|
||||
// pending forever and the queue would never empty.
|
||||
if ( '' === SPE_Queue::hash_at( $path ) ) {
|
||||
SPE_Queue::add( $path, $url, 'asset' );
|
||||
}
|
||||
}
|
||||
return $rewritten['html'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell somebody when an export stops working.
|
||||
*
|
||||
* Only on the attempt that exhausts the retries, and only once per hour:
|
||||
* a misconfigured key fails on every row in the queue, and an inbox with
|
||||
* four hundred copies of the same message is indistinguishable from no
|
||||
* message at all.
|
||||
*/
|
||||
private static function report_failure( $row, $error ) {
|
||||
if ( (int) $row->attempts + 1 < SPE_Queue::MAX_ATTEMPTS ) {
|
||||
return;
|
||||
}
|
||||
if ( get_transient( 'sirius_export_failure_notified' ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( 'sirius_export_failure_notified', 1, HOUR_IN_SECONDS );
|
||||
|
||||
SP_Inbox::add(
|
||||
0,
|
||||
__( 'A page could not be published to your name', 'sirius-press' ),
|
||||
sprintf(
|
||||
/* translators: 1: bucket path, 2: error message, 3: URL of the export screen. */
|
||||
wp_kses_post( __( '<code>%1$s</code> failed to upload: %2$s<br><br>Other pages may be failing for the same reason. <a href="%3$s">Open the export screen</a> to see the queue.', 'sirius-press' ) ),
|
||||
esc_html( $row->path ),
|
||||
esc_html( $error ),
|
||||
esc_url( admin_url( 'admin.php?page=sirius-press-export' ) )
|
||||
),
|
||||
'export'
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue every exportable page on the site. */
|
||||
public static function queue_full_site() {
|
||||
$queued = 0;
|
||||
foreach ( SPE_Mapper::full_site() as $path => $target ) {
|
||||
if ( SPE_Queue::add( $path, $target['url'], $target['kind'] ) ) {
|
||||
$queued++;
|
||||
}
|
||||
}
|
||||
return $queued;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Sirius Press Publishing
|
||||
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||
* Description: Publishes a static copy of every page to the site's BCNR name, so the site keeps serving from decentralised storage even when this server does not.
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.5
|
||||
* Requires PHP: 7.4
|
||||
* Requires Plugins: sirius-press-core
|
||||
* Author: Silent Mode
|
||||
* Author URI: https://silentmode.st
|
||||
* License: GPL-2.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: sirius-press
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'SIRIUS_PRESS_EXPORT_VERSION', '0.1.0' );
|
||||
define( 'SIRIUS_PRESS_EXPORT_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'SIRIUS_PRESS_EXPORT_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
register_activation_hook(
|
||||
__FILE__,
|
||||
function () {
|
||||
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-queue.php';
|
||||
SPE_Queue::install();
|
||||
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-runner.php';
|
||||
SPE_Runner::schedule();
|
||||
}
|
||||
);
|
||||
|
||||
register_deactivation_hook(
|
||||
__FILE__,
|
||||
function () {
|
||||
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-runner.php';
|
||||
SPE_Runner::unschedule();
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
function () {
|
||||
if ( ! class_exists( 'SP_Gateway' ) ) {
|
||||
add_action(
|
||||
'admin_notices',
|
||||
function () {
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s</p></div>',
|
||||
esc_html__( 'Sirius Press Publishing needs Sirius Press Core to be active. Nothing is being published.', 'sirius-press' )
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( array( 'queue', 'mapper', 'renderer', 'runner', 'hooks', 'admin', 'cli' ) as $part ) {
|
||||
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-' . $part . '.php';
|
||||
}
|
||||
|
||||
if ( (int) get_option( 'sirius_press_export_db_version', 0 ) < SPE_Queue::VERSION ) {
|
||||
SPE_Queue::install();
|
||||
}
|
||||
|
||||
SPE_Renderer::hooks();
|
||||
SPE_Runner::hooks();
|
||||
SPE_Hooks::hooks();
|
||||
SPE_Admin::hooks();
|
||||
SPE_Runner::schedule();
|
||||
}
|
||||
);
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Enough of WordPress to test the parts that do not need it.
|
||||
*
|
||||
* The classes worth testing hardest — the curve, address encoding, the signing
|
||||
* envelopes, URL-to-path mapping — are the ones with no WordPress in them.
|
||||
* Standing up a full WordPress test install to exercise them would make the
|
||||
* suite slow enough that nobody runs it, which is the failure mode that
|
||||
* matters most for cryptographic code.
|
||||
*
|
||||
* So this file supplies the handful of core functions those classes touch, and
|
||||
* the suite runs under plain PHP in well under a second. Anything that needs
|
||||
* real WordPress — the auth flow, the queue, the admin screens — is tested by
|
||||
* installing the fork, which docs/testing.md describes.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
define( 'SP_CLI', true );
|
||||
|
||||
// --- a tiny test runner ------------------------------------------------------
|
||||
|
||||
final class T {
|
||||
public static $passed = 0;
|
||||
public static $failed = 0;
|
||||
public static $group = '';
|
||||
|
||||
public static function group( $name ) {
|
||||
self::$group = $name;
|
||||
echo "\n {$name}\n";
|
||||
}
|
||||
|
||||
public static function ok( $condition, $what ) {
|
||||
if ( $condition ) {
|
||||
self::$passed++;
|
||||
echo " ok {$what}\n";
|
||||
return true;
|
||||
}
|
||||
self::$failed++;
|
||||
echo " FAIL {$what}\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function is( $actual, $expected, $what ) {
|
||||
if ( $actual === $expected ) {
|
||||
self::$passed++;
|
||||
echo " ok {$what}\n";
|
||||
return true;
|
||||
}
|
||||
self::$failed++;
|
||||
echo " FAIL {$what}\n";
|
||||
echo " expected: " . self::show( $expected ) . "\n";
|
||||
echo " actual: " . self::show( $actual ) . "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function show( $value ) {
|
||||
if ( is_bool( $value ) ) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
if ( null === $value ) {
|
||||
return 'null';
|
||||
}
|
||||
if ( is_array( $value ) ) {
|
||||
return json_encode( $value );
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
public static function summary() {
|
||||
$total = self::$passed + self::$failed;
|
||||
echo "\n";
|
||||
if ( self::$failed > 0 ) {
|
||||
echo " {$total} checks, " . self::$failed . " FAILED\n\n";
|
||||
return 1;
|
||||
}
|
||||
echo " {$total} checks, all passed\n\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- WordPress functions the tested classes call -----------------------------
|
||||
|
||||
if ( ! function_exists( 'wp_parse_url' ) ) {
|
||||
function wp_parse_url( $url, $component = -1 ) {
|
||||
return parse_url( $url, $component );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'home_url' ) ) {
|
||||
/**
|
||||
* The site's URL.
|
||||
*
|
||||
* Overridable through $GLOBALS['sirius_test_home'] so a test can exercise
|
||||
* an installation whose URL carries a port, which is where the URL
|
||||
* rewriting has historically gone wrong.
|
||||
*/
|
||||
function home_url( $path = '' ) {
|
||||
$base = isset( $GLOBALS['sirius_test_home'] ) ? $GLOBALS['sirius_test_home'] : 'https://example.test';
|
||||
return $base . ( '' !== $path ? $path : '' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'site_url' ) ) {
|
||||
function site_url( $path = '' ) {
|
||||
return home_url( $path );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'set_url_scheme' ) ) {
|
||||
function set_url_scheme( $url, $scheme = null ) {
|
||||
return preg_replace( '#^\w+://#', ( $scheme ? $scheme : 'https' ) . '://', (string) $url );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'untrailingslashit' ) ) {
|
||||
function untrailingslashit( $value ) {
|
||||
return rtrim( (string) $value, '/\\' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'apply_filters' ) ) {
|
||||
function apply_filters( $hook, $value ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'size_format' ) ) {
|
||||
function size_format( $bytes ) {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'esc_html' ) ) {
|
||||
function esc_html( $text ) {
|
||||
return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( '__' ) ) {
|
||||
function __( $text, $domain = '' ) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
// --- the code under test -----------------------------------------------------
|
||||
|
||||
$core = __DIR__ . '/../plugins/sirius-press-core/includes/';
|
||||
require_once $core . 'class-sp-bn.php';
|
||||
require_once $core . 'class-sp-secp256k1.php';
|
||||
require_once $core . 'class-sp-cashaddr.php';
|
||||
require_once $core . 'class-sp-hd.php';
|
||||
require_once $core . 'class-sp-message.php';
|
||||
|
||||
/*
|
||||
* These three reference WordPress functions only from inside their methods, so
|
||||
* they load cleanly here and the suite exercises the code that actually ships
|
||||
* rather than a copy of it.
|
||||
*/
|
||||
require_once $core . 'class-sp-gateway.php';
|
||||
|
||||
$export = __DIR__ . '/../plugins/sirius-press-sia-export/includes/';
|
||||
require_once $export . 'class-spe-mapper.php';
|
||||
require_once $export . 'class-spe-renderer.php';
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
// The browser wallet against the PHP one.
|
||||
//
|
||||
// Sirius Press has two independent implementations of the same cryptography:
|
||||
// PHP on the server, which verifies, and JavaScript in the page, which signs.
|
||||
// If they disagree by one byte, nobody can log in — and the error will look
|
||||
// like a rejected password rather than a hash mismatch, which is a miserable
|
||||
// thing to debug at three in the morning.
|
||||
//
|
||||
// So this compares them directly. Every vector is the output of the other
|
||||
// implementation, not of this one.
|
||||
//
|
||||
// node tests/interop.mjs
|
||||
//
|
||||
// Needs no dependencies: the wallet is loaded as plain source, with `window`
|
||||
// pointed at the Node global so WebCrypto is found where the browser puts it.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assets = join(here, "..", "plugins", "sirius-press-auth", "assets");
|
||||
|
||||
globalThis.window = globalThis;
|
||||
new Function(readFileSync(join(assets, "bip39-en.js"), "utf8"))();
|
||||
new Function(readFileSync(join(assets, "wallet.js"), "utf8"))();
|
||||
|
||||
const W = window.SiriusWallet;
|
||||
const I = W._internals;
|
||||
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function is(actual, expected, what) {
|
||||
if (actual === expected) {
|
||||
passed++;
|
||||
console.log(` ok ${what}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` FAIL ${what}`);
|
||||
console.log(` expected: ${expected}`);
|
||||
console.log(` actual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(condition, what) {
|
||||
is(Boolean(condition), true, what);
|
||||
}
|
||||
|
||||
// --- RIPEMD-160, against the published vectors -------------------------------
|
||||
// WebCrypto has no RIPEMD-160, so the wallet carries its own. These are the
|
||||
// reference values from the algorithm's own specification.
|
||||
|
||||
console.log("\n RIPEMD-160");
|
||||
const enc = new TextEncoder();
|
||||
is(hex(I.ripemd160(new Uint8Array(0))), "9c1185a5c5e9fc54612808977ee8f548b2258d31", 'the empty string');
|
||||
is(hex(I.ripemd160(enc.encode("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", '"abc"');
|
||||
is(
|
||||
hex(I.ripemd160(enc.encode("message digest"))),
|
||||
"5d0689ef49d2fae572b881b123a85ffa21595f36",
|
||||
'"message digest"',
|
||||
);
|
||||
is(
|
||||
hex(I.ripemd160(enc.encode("abcdefghijklmnopqrstuvwxyz"))),
|
||||
"f71c27109c692c1b56bbdceb5b9d2865b3708dbc",
|
||||
"the lowercase alphabet",
|
||||
);
|
||||
// Longer than one 64-byte block, which exercises the padding and the length
|
||||
// field — the parts a single-block test never reaches.
|
||||
is(
|
||||
hex(I.ripemd160(enc.encode("1234567890".repeat(8)))),
|
||||
"9b752e45573d4b39f4dbd3323cab82bf63326bfb",
|
||||
"eighty bytes, spanning two blocks",
|
||||
);
|
||||
|
||||
// --- secp256k1 ---------------------------------------------------------------
|
||||
|
||||
console.log("\n secp256k1");
|
||||
const privOne = Uint8Array.from([...new Array(31).fill(0), 1]);
|
||||
is(
|
||||
hex(await I.publicKey(privOne)),
|
||||
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"the public key for private key 1",
|
||||
);
|
||||
is(
|
||||
await W.addressFromPublicKey(await I.publicKey(privOne), "bitcoincash"),
|
||||
"bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
|
||||
"the mainnet address for private key 1",
|
||||
);
|
||||
|
||||
// --- derivation, against libauth ---------------------------------------------
|
||||
// This phrase, and the address below it, came out of BuiltInWallet in
|
||||
// Argus/src/lib/wallet-web.js — the wallet the Sirius portal runs.
|
||||
|
||||
console.log("\n BIP-39 and BIP-32");
|
||||
const phrase = "trash key flip dawn impulse float medal rain sell hand neither hub";
|
||||
const wallet = await W.fromPhrase(phrase, { prefix: "bchtest" });
|
||||
is(wallet.address, "bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy", "derives the portal wallet's address");
|
||||
|
||||
const messy = await W.fromPhrase(" Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", {
|
||||
prefix: "bchtest",
|
||||
});
|
||||
is(messy.address, wallet.address, "spacing and capitals do not change the wallet");
|
||||
|
||||
// --- signing, against PHP -----------------------------------------------------
|
||||
// The signature below was produced by SP_Message::sign() in PHP. Both sides run
|
||||
// RFC 6979, so the same key and message give the same bytes — an equality this
|
||||
// strict is only possible because neither implementation uses randomness.
|
||||
|
||||
console.log("\n signatures match PHP");
|
||||
const loginMessage = [
|
||||
"SIRIUS-PRESS-LOGIN1",
|
||||
"https://example.bch",
|
||||
wallet.address,
|
||||
"abc123",
|
||||
"1758412800000",
|
||||
].join("\n");
|
||||
|
||||
is(
|
||||
hex(await I.messageDigest(loginMessage)),
|
||||
"98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19",
|
||||
"the BIP-137 digest matches PHP and the Theseus wallet",
|
||||
);
|
||||
is(
|
||||
await wallet.sign(loginMessage),
|
||||
"IGr9FSoDInLUr+iKZGw8w5LPyi/JsZAU6mFvGc80uQReP3IQZU7KMODzwS78FkRE57sh2osmWq1sVHZam81PTkY=",
|
||||
"the signature is byte-identical to the one PHP produces",
|
||||
);
|
||||
is(await wallet.sign(loginMessage), await wallet.sign(loginMessage), "signing is deterministic");
|
||||
|
||||
// --- the upload envelope ------------------------------------------------------
|
||||
// Manual-mode publishing signs a raw digest rather than a text message. The
|
||||
// expected value is SP_Message::site_digest() from PHP.
|
||||
|
||||
console.log("\n BNS-SITE1 upload envelope");
|
||||
const body = "<h1>hi</h1>";
|
||||
const bodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(body))));
|
||||
const envelope = `BNS-SITE1\nexample.bch\nindex.html\n${bodyHash}\n1758412800000`;
|
||||
const siteDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(envelope)));
|
||||
is(
|
||||
hex(siteDigest),
|
||||
"a71c4db0bd7268cd2b2103e2c0753ad37b046b203a4bf1ba7e84271b816d10de",
|
||||
"the upload digest matches PHP",
|
||||
);
|
||||
const rawSig = await wallet.signRaw(siteDigest);
|
||||
is(atob(rawSig).length, 65, "signRaw produces 65 bytes");
|
||||
|
||||
// --- phrase generation --------------------------------------------------------
|
||||
|
||||
console.log("\n phrase handling");
|
||||
const generated = await W.generatePhrase(12);
|
||||
is(generated.split(" ").length, 12, "a generated phrase has twelve words");
|
||||
ok(W.validatePhrase(generated).ok, "a generated phrase validates");
|
||||
const generated24 = await W.generatePhrase(24);
|
||||
is(generated24.split(" ").length, 24, "twenty-four words on request");
|
||||
ok(W.validatePhrase(generated24).ok, "the long phrase validates too");
|
||||
ok((await W.fromPhrase(generated, { prefix: "bchtest" })).address.startsWith("bchtest:"), "and opens a wallet");
|
||||
|
||||
ok(!W.validatePhrase("").ok, "an empty phrase is refused");
|
||||
ok(!W.validatePhrase("one two three").ok, "a three-word phrase is refused");
|
||||
is(
|
||||
W.validatePhrase("abandon abandon recieve abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
.error,
|
||||
"Word 3, “recieve”, is not a recovery-phrase word.",
|
||||
"a misspelled word is named and numbered",
|
||||
);
|
||||
|
||||
console.log("");
|
||||
if (failed > 0) {
|
||||
console.log(` ${passed + failed} checks, ${failed} FAILED\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ${passed + failed} checks, all passed\n`);
|
||||
416
tests/live.mjs
416
tests/live.mjs
|
|
@ -1,416 +0,0 @@
|
|||
// End-to-end checks against a running Sirius Press instance.
|
||||
//
|
||||
// The other suites prove the cryptography in isolation. This one proves the
|
||||
// thing that actually matters: that a signature made in a browser gets a real
|
||||
// WordPress session out of a real WordPress, and that the failure paths fail.
|
||||
//
|
||||
// node tests/live.mjs
|
||||
//
|
||||
// Expects:
|
||||
// BASE the site's URL (default http://127.0.0.1:8760)
|
||||
// WALLET a JSON file holding {"phrase": "...", "address": "bchtest:..."}
|
||||
// for an existing administrator on that site
|
||||
//
|
||||
// docs/testing.md has the recipe for standing up a throwaway instance with
|
||||
// SQLite and PHP's built-in server — no database server, no Docker.
|
||||
//
|
||||
// Nothing here is destructive except that it creates one account per run, in
|
||||
// a site you were already willing to point a test at.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
globalThis.window = globalThis;
|
||||
const ASSETS = new URL("../plugins/sirius-press-auth/assets/", import.meta.url);
|
||||
new Function(readFileSync(new URL("bip39-en.js", ASSETS), "utf8"))();
|
||||
new Function(readFileSync(new URL("wallet.js", ASSETS), "utf8"))();
|
||||
|
||||
const BASE = (process.env.BASE || "http://127.0.0.1:8760").replace(/\/$/, "");
|
||||
const WALLET = process.env.WALLET;
|
||||
if (!WALLET) {
|
||||
console.error("set WALLET to a JSON file with the administrator's phrase and address");
|
||||
process.exit(2);
|
||||
}
|
||||
const admin = JSON.parse(readFileSync(WALLET, "utf8"));
|
||||
const W = window.SiriusWallet;
|
||||
|
||||
/**
|
||||
* Where the REST API lives.
|
||||
*
|
||||
* A site without pretty permalinks serves it at ?rest_route= rather than
|
||||
* /wp-json/, and a fresh install has exactly that. Probing both is the
|
||||
* difference between testing the API and testing the permalink setting.
|
||||
*/
|
||||
const restBase = await (async () => {
|
||||
const pretty = await fetch(`${BASE}/wp-json/`).catch(() => null);
|
||||
if (pretty && pretty.ok && (pretty.headers.get("content-type") || "").includes("json")) {
|
||||
return (path) => `${BASE}/wp-json${path}`;
|
||||
}
|
||||
return (path) => `${BASE}/index.php?rest_route=${encodeURIComponent(path)}`;
|
||||
})();
|
||||
const restUrl = (path, query = "") => {
|
||||
const url = restBase(path);
|
||||
return query ? url + (url.includes("?") ? "&" : "?") + query : url;
|
||||
};
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
function check(condition, what, extra = "") {
|
||||
if (condition) {
|
||||
pass++;
|
||||
console.log(` ok ${what}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` FAIL ${what}`);
|
||||
if (extra) console.log(` ${extra}`);
|
||||
}
|
||||
}
|
||||
|
||||
function group(name) {
|
||||
console.log(`\n ${name}`);
|
||||
}
|
||||
|
||||
const decode = (s) =>
|
||||
s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/—/g, "—")
|
||||
.replace(/’/g, "’");
|
||||
|
||||
/** A cookie jar and a fetch that uses it — one browser, in effect. */
|
||||
function Session() {
|
||||
const jar = new Map([["wordpress_test_cookie", "WP%20Cookie%20check"]]);
|
||||
return {
|
||||
loggedIn: () => [...jar.keys()].some((k) => k.startsWith("wordpress_logged_in_")),
|
||||
async go(path, options = {}) {
|
||||
const res = await fetch(BASE + path, {
|
||||
redirect: "manual",
|
||||
...options,
|
||||
headers: {
|
||||
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
for (const raw of res.headers.getSetCookie?.() ?? []) {
|
||||
const pair = raw.split(";")[0];
|
||||
const eq = pair.indexOf("=");
|
||||
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1));
|
||||
}
|
||||
return { res, body: await res.text() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const post = (fields) => ({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(fields),
|
||||
});
|
||||
|
||||
/** Fetch a page and pull out the challenge it is offering. */
|
||||
async function challenge(session, path) {
|
||||
const { body } = await session.go(path);
|
||||
const message = /class="sirius-wallet__message"[^>]*>([\s\S]*?)<\/textarea>/.exec(body);
|
||||
return {
|
||||
body,
|
||||
nonce: /name="sirius_nonce" value="([^"]+)"/.exec(body)?.[1],
|
||||
message: message ? decode(message[1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const errorIn = (html) =>
|
||||
(/<div id="login_error">([\s\S]*?)<\/div>/.exec(html)?.[1] || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// ---------------------------------------------------------------- sign in
|
||||
|
||||
group("wallet sign-in");
|
||||
{
|
||||
const s = Session();
|
||||
const c = await challenge(s, "/wp-login.php");
|
||||
check(Boolean(c.nonce && c.message), "the login page issues a challenge");
|
||||
|
||||
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
|
||||
check(wallet.address === admin.address, "the phrase derives the administrator's address");
|
||||
|
||||
const { res } = await s.go(
|
||||
"/wp-login.php",
|
||||
post({
|
||||
log: "",
|
||||
pwd: "",
|
||||
sirius_nonce: c.nonce,
|
||||
sirius_purpose: "login",
|
||||
sirius_signature: await wallet.sign(c.message),
|
||||
sirius_address: wallet.address,
|
||||
"wp-submit": "Log In",
|
||||
redirect_to: `${BASE}/wp-admin/`,
|
||||
testcookie: "1",
|
||||
}),
|
||||
);
|
||||
check(res.status === 302, `a valid signature signs you in (${res.status})`);
|
||||
check(s.loggedIn(), "an ordinary WordPress session cookie is issued");
|
||||
|
||||
const dash = await s.go("/wp-admin/");
|
||||
check(dash.res.status === 200 && /Dashboard/i.test(dash.body), "wp-admin loads with that session");
|
||||
|
||||
// Every screen the fork adds has to render without a PHP diagnostic.
|
||||
const screens = [
|
||||
["Settings", "/wp-admin/admin.php?page=sirius-press"],
|
||||
["Sign-in", "/wp-admin/admin.php?page=sirius-press-auth"],
|
||||
["Publishing", "/wp-admin/admin.php?page=sirius-press-export"],
|
||||
["Inbox", "/wp-admin/admin.php?page=sirius-inbox"],
|
||||
["Profile", "/wp-admin/profile.php"],
|
||||
["Users", "/wp-admin/users.php"],
|
||||
["Plugins", "/wp-admin/plugins.php"],
|
||||
];
|
||||
for (const [label, path] of screens) {
|
||||
const page = await s.go(path);
|
||||
// Match PHP's own diagnostic output, not the word "Warning" wherever it
|
||||
// happens to appear. PHP always appends " in <file> on line <n>", and
|
||||
// with html_errors on it wraps the label in <b>. Without that anchor this
|
||||
// check trips over plugins whose translation strings contain the word —
|
||||
// Yoast ships several.
|
||||
const LABEL = "Fatal error|Parse error|Warning|Notice|Deprecated";
|
||||
const diagnostic =
|
||||
new RegExp(`<b>(?:${LABEL})</b>:[^<]{0,200}`).exec(page.body) ||
|
||||
new RegExp(`(?:${LABEL}):[^\n<]{0,200}? in [^\n<]{0,200}? on line \d+`).exec(page.body);
|
||||
check(
|
||||
page.res.status === 200 && !diagnostic,
|
||||
`${label} renders cleanly (${page.res.status})`,
|
||||
diagnostic ? diagnostic[0] : "",
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await s.go("/wp-admin/admin.php?page=sirius-press");
|
||||
check(/Publishing address/.test(settings.body), "settings shows the publishing status block");
|
||||
|
||||
const profile = await s.go("/wp-admin/profile.php");
|
||||
check(profile.body.includes(admin.address), "the profile shows the attached wallet");
|
||||
|
||||
const users = await s.go("/wp-admin/users.php");
|
||||
check(/Wallet/.test(users.body), "the users list has a Wallet column");
|
||||
check(!/noreply\+/.test(users.body), "and does not show placeholder email addresses");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- one signature, one use
|
||||
|
||||
group("a signature is worth one use");
|
||||
{
|
||||
const c = await challenge(Session(), "/wp-login.php");
|
||||
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
|
||||
const signature = await wallet.sign(c.message);
|
||||
const fields = {
|
||||
log: "",
|
||||
pwd: "",
|
||||
sirius_nonce: c.nonce,
|
||||
sirius_purpose: "login",
|
||||
sirius_signature: signature,
|
||||
sirius_address: wallet.address,
|
||||
"wp-submit": "Log In",
|
||||
testcookie: "1",
|
||||
};
|
||||
|
||||
const first = await Session().go("/wp-login.php", post(fields));
|
||||
check(first.res.status === 302, "the first use is accepted");
|
||||
|
||||
const second = await Session().go("/wp-login.php", post(fields));
|
||||
check(second.res.status === 200, "replaying it does not sign anyone in");
|
||||
check(/already been used/i.test(second.body), "and the page says why", errorIn(second.body));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ wrong wallet
|
||||
|
||||
group("a stranger's signature");
|
||||
{
|
||||
const s = Session();
|
||||
const c = await challenge(s, "/wp-login.php");
|
||||
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
|
||||
const { res, body } = await s.go(
|
||||
"/wp-login.php",
|
||||
post({
|
||||
log: "",
|
||||
pwd: "",
|
||||
sirius_nonce: c.nonce,
|
||||
sirius_purpose: "login",
|
||||
sirius_signature: await stranger.sign(c.message),
|
||||
sirius_address: stranger.address,
|
||||
"wp-submit": "Log In",
|
||||
testcookie: "1",
|
||||
}),
|
||||
);
|
||||
check(res.status === 200, "an unknown wallet is not signed in");
|
||||
check(/No account/i.test(body), "and is told no account uses it", errorIn(body));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- tampered message
|
||||
|
||||
group("a signature over different text");
|
||||
{
|
||||
const s = Session();
|
||||
const c = await challenge(s, "/wp-login.php");
|
||||
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
|
||||
const { res, body } = await s.go(
|
||||
"/wp-login.php",
|
||||
post({
|
||||
log: "",
|
||||
pwd: "",
|
||||
sirius_nonce: c.nonce,
|
||||
sirius_purpose: "login",
|
||||
sirius_signature: await wallet.sign(c.message + " "),
|
||||
sirius_address: wallet.address,
|
||||
"wp-submit": "Log In",
|
||||
testcookie: "1",
|
||||
}),
|
||||
);
|
||||
check(res.status === 200, "a signature over altered text is refused");
|
||||
check(/does not match/i.test(body), "and says the text does not match", errorIn(body));
|
||||
}
|
||||
|
||||
// ------------------------------------------- purposes are not interchangeable
|
||||
|
||||
group("a login signature cannot create an account");
|
||||
{
|
||||
const s = Session();
|
||||
const login = await challenge(s, "/wp-login.php");
|
||||
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
|
||||
const { body } = await s.go(
|
||||
"/wp-login.php?action=sirius_register",
|
||||
post({
|
||||
user_login: "",
|
||||
sirius_nonce: login.nonce,
|
||||
sirius_purpose: "register",
|
||||
sirius_signature: await stranger.sign(login.message),
|
||||
sirius_address: stranger.address,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
/does not match/i.test(body),
|
||||
"signing the login text does not register an account",
|
||||
errorIn(body),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- registration
|
||||
|
||||
group("registration");
|
||||
{
|
||||
const s = Session();
|
||||
const c = await challenge(s, "/wp-login.php?action=sirius_register");
|
||||
check(Boolean(c.nonce && c.message), "the registration page issues its own challenge");
|
||||
check(/create an account/i.test(c.message || ""), "the challenge says what it is for",
|
||||
(c.message || "").split("\n")[0]);
|
||||
check(!/name="user_email"|type="email"/.test(c.body), "there is no email field on it");
|
||||
|
||||
const wallet = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
|
||||
const suffix = Math.random().toString(36).slice(2, 8);
|
||||
const { res } = await s.go(
|
||||
"/wp-login.php?action=sirius_register",
|
||||
post({
|
||||
user_login: `reader_${suffix}`,
|
||||
sirius_nonce: c.nonce,
|
||||
sirius_purpose: "register",
|
||||
sirius_signature: await wallet.sign(c.message),
|
||||
sirius_address: wallet.address,
|
||||
}),
|
||||
);
|
||||
check(res.status === 302, `one signature creates the account and signs it in (${res.status})`);
|
||||
check(s.loggedIn(), "the new account has a session straight away");
|
||||
|
||||
/*
|
||||
* Prove the account was bound to the right key by signing in again with it,
|
||||
* rather than by loading wp-admin. A subscriber cannot necessarily reach
|
||||
* wp-admin at all — WooCommerce redirects them away by default — and that
|
||||
* would make this assertion a test of whichever plugins happen to be
|
||||
* installed instead of a test of registration.
|
||||
*/
|
||||
const fresh = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
|
||||
const freshJson = await fresh.json().catch(() => ({}));
|
||||
const back = await fetch(restUrl("/sirius-press/v1/login"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nonce: freshJson.nonce, signature: await wallet.sign(freshJson.message) }),
|
||||
});
|
||||
const backJson = await back.json().catch(() => ({}));
|
||||
check(
|
||||
back.status === 200 && backJson.address === wallet.address,
|
||||
"the new account signs in again with the same wallet",
|
||||
`status ${back.status}, address ${backJson.address}`,
|
||||
);
|
||||
|
||||
const s2 = Session();
|
||||
const c2 = await challenge(s2, "/wp-login.php?action=sirius_register");
|
||||
const again = await s2.go(
|
||||
"/wp-login.php?action=sirius_register",
|
||||
post({
|
||||
user_login: "",
|
||||
sirius_nonce: c2.nonce,
|
||||
sirius_purpose: "register",
|
||||
sirius_signature: await wallet.sign(c2.message),
|
||||
sirius_address: wallet.address,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
again.res.status === 200 && /already has an account/i.test(again.body),
|
||||
"the same wallet cannot register twice",
|
||||
errorIn(again.body),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ recovery page
|
||||
|
||||
group("the recovery page");
|
||||
{
|
||||
const { res, body } = await Session().go("/wp-login.php?action=lostpassword");
|
||||
check(res.status === 200, "it loads");
|
||||
check(/cannot reset your account/i.test(body), "it says the site cannot reset anything");
|
||||
check(!/Get New Password/i.test(body), "and offers no reset form");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- REST surface
|
||||
|
||||
group("the REST endpoints");
|
||||
{
|
||||
const res = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
|
||||
const json = await res.json().catch(() => ({}));
|
||||
check(res.status === 200, `challenge returns 200 (${res.status})`);
|
||||
check(Boolean(json.nonce && json.message), "and carries a nonce and a message");
|
||||
|
||||
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
|
||||
const login = await fetch(restUrl("/sirius-press/v1/login"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nonce: json.nonce, signature: await wallet.sign(json.message) }),
|
||||
});
|
||||
const out = await login.json().catch(() => ({}));
|
||||
check(login.status === 200 && out.ok === true, `login returns a session (${login.status})`);
|
||||
check(out.address === admin.address, "and reports the address that signed");
|
||||
|
||||
const bad = await fetch(restUrl("/sirius-press/v1/login"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nonce: json.nonce, signature: "not-a-signature" }),
|
||||
});
|
||||
check(bad.status >= 400, `a bad signature is rejected with an error status (${bad.status})`);
|
||||
|
||||
// Registration through the API must insist on the claimed address for the
|
||||
// same reason the form does.
|
||||
const reg = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=register"));
|
||||
const regJson = await reg.json().catch(() => ({}));
|
||||
if (regJson.nonce) {
|
||||
const orphan = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
|
||||
const noAddr = await fetch(restUrl("/sirius-press/v1/register"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nonce: regJson.nonce, signature: await orphan.sign(regJson.message) }),
|
||||
});
|
||||
check(noAddr.status === 400, `register without an address is refused (${noAddr.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${pass + fail} checks, ${fail ? `${fail} FAILED` : "all passed"}\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
// A stand-in for the BNS gateway, for testing the publishing path.
|
||||
//
|
||||
// It implements exactly one thing, and implements it the way the real gateway
|
||||
// does: the signature check on `PUT /api/site/<name>/<path>`. The verification
|
||||
// below is transcribed from Argus/src/gateway/public-gateway.mjs — same
|
||||
// envelope, same digest, same recovery, same comparison against the name's
|
||||
// owner — so a request this accepts is one the real gateway accepts, and a
|
||||
// request it rejects would have been rejected there too.
|
||||
//
|
||||
// That is the whole point. Publishing cannot be tested end to end without a
|
||||
// registered name and a funded key, but the part that actually breaks — the
|
||||
// bytes being signed — can be checked against the real verifier.
|
||||
//
|
||||
// node tests/mock-gateway.mjs --owner bchtest:qq… [--port 8799]
|
||||
//
|
||||
// Needs @bitauth/libauth resolvable from this file — deliberately, because
|
||||
// verifying with the same library the real gateway uses is what makes this
|
||||
// worth running at all. In the Silent Mode monorepo, copy it next to
|
||||
// Argus/package.json and run it there; standalone, `npm i @bitauth/libauth`
|
||||
// in this directory.
|
||||
//
|
||||
// Uploads are kept in memory and listed on GET /api/site/<name>. Nothing is
|
||||
// written to disk and nothing leaves the machine.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { createHash } from "node:crypto";
|
||||
import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const argOf = (name, fallback) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
};
|
||||
|
||||
const OWNER = argOf("owner", "");
|
||||
const PORT = Number(argOf("port", "8799"));
|
||||
if (!OWNER) {
|
||||
console.error("usage: node tests/mock-gateway.mjs --owner <cashaddress> [--port 8799]");
|
||||
process.exit(2);
|
||||
}
|
||||
const PREFIX = OWNER.split(":")[0] || "bchtest";
|
||||
|
||||
/** path -> {bytes, type} */
|
||||
const store = new Map();
|
||||
let accepted = 0;
|
||||
let rejected = 0;
|
||||
|
||||
/**
|
||||
* The real gateway's check, transcribed.
|
||||
*
|
||||
* digest = sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
||||
* then recover the compressed public key from the 65-byte signature and
|
||||
* compare the address it controls with the name's current on-chain owner.
|
||||
*/
|
||||
function verify({ name, path, body, ts, sigB64 }) {
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) {
|
||||
return "x-bns-ts missing or more than 10 minutes off";
|
||||
}
|
||||
let sig;
|
||||
try {
|
||||
sig = base64ToBin(sigB64);
|
||||
} catch {
|
||||
return "x-bns-sig is not valid base64";
|
||||
}
|
||||
if (sig.length !== 65) return `x-bns-sig must decode to 65 bytes (got ${sig.length})`;
|
||||
|
||||
const bodyHex = createHash("sha256").update(body).digest("hex");
|
||||
const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${path}\n${bodyHex}\n${ts}`));
|
||||
const recovered = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 27) & 3, digest);
|
||||
if (typeof recovered === "string") return `signature recovery failed: ${recovered}`;
|
||||
|
||||
const encoded = encodeCashAddress({
|
||||
prefix: PREFIX,
|
||||
type: "p2pkh",
|
||||
payload: ripemd160.hash(sha256.hash(recovered)),
|
||||
});
|
||||
const derived = typeof encoded === "string" ? encoded : encoded?.address ?? "";
|
||||
if (derived !== OWNER) {
|
||||
return `signature does not match current on-chain NFT owner (derived ${derived})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const json = (code, obj) => {
|
||||
res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
const url = new URL(req.url, "http://x");
|
||||
if (!url.pathname.startsWith("/api/site/")) {
|
||||
return json(404, { error: "only /api/site is implemented" });
|
||||
}
|
||||
|
||||
const rest = url.pathname.slice("/api/site/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
const name = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
|
||||
const path = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1));
|
||||
|
||||
if (req.method === "GET" && !path) {
|
||||
return json(200, {
|
||||
name,
|
||||
prefix: `bns/${name}/`,
|
||||
files: [...store].map(([p, v]) => ({ path: p, size: v.bytes.length, modified: null })),
|
||||
});
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const hit = store.get(path);
|
||||
if (!hit) return json(404, { error: "not found" });
|
||||
res.writeHead(200, { "content-type": hit.type });
|
||||
return res.end(hit.bytes);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const problem = verify({
|
||||
name,
|
||||
path,
|
||||
body: req.method === "PUT" ? body : Buffer.alloc(0),
|
||||
ts: Number(req.headers["x-bns-ts"] || 0),
|
||||
sigB64: String(req.headers["x-bns-sig"] || ""),
|
||||
});
|
||||
if (problem) {
|
||||
rejected++;
|
||||
console.log(` reject ${req.method} ${path} — ${problem}`);
|
||||
return json(problem.includes("owner") ? 403 : 401, { error: problem });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
store.delete(path);
|
||||
accepted++;
|
||||
console.log(` delete ${path}`);
|
||||
return json(200, { ok: true, name, path, deleted: true });
|
||||
}
|
||||
|
||||
store.set(path, { bytes: body, type: String(req.headers["content-type"] || "") });
|
||||
accepted++;
|
||||
console.log(` accept ${path} (${body.length} bytes)`);
|
||||
return json(200, { ok: true, name, path, bytes: body.length, sia_key: `bns/${name}/${path}` });
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
process.on("SIGINT", () => {
|
||||
console.log(`\naccepted ${accepted}, rejected ${rejected}, holding ${store.size} files`);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`mock gateway on http://127.0.0.1:${PORT}, owner ${OWNER}`);
|
||||
});
|
||||
82
tests/run.sh
82
tests/run.sh
|
|
@ -1,82 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run the Sirius Press test suite.
|
||||
#
|
||||
# tests/run.sh
|
||||
#
|
||||
# Needs PHP 7.4+ with GMP or BCMath, and Node 18+ for the interop suite. Both
|
||||
# are optional in the sense that the script says which one is missing and runs
|
||||
# the rest — but the interop suite is the one that proves a signature made in
|
||||
# a browser verifies on the server, so a run without it has not checked the
|
||||
# thing most worth checking.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PHP_BIN="${PHP:-php}"
|
||||
NODE_BIN="${NODE:-node}"
|
||||
status=0
|
||||
|
||||
bar() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||
for suite in test-crypto test-identity test-export; do
|
||||
bar "php ${suite}"
|
||||
"$PHP_BIN" "$here/$suite.php" || status=1
|
||||
done
|
||||
else
|
||||
printf '\033[33mskipped:\033[0m no PHP on PATH (set PHP=/path/to/php)\n' >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
if command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||
bar "node interop (browser wallet vs PHP)"
|
||||
"$NODE_BIN" "$here/interop.mjs" || status=1
|
||||
else
|
||||
printf '\033[33mskipped:\033[0m no Node on PATH — the browser wallet was not checked against PHP\n' >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
bar "syntax"
|
||||
syntax_ok=1
|
||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||
# The fork's own PHP, plus every core file the fork patches. Sweeping all
|
||||
# of wordpress/ would lint 3,800 files of upstream code that upstream
|
||||
# already tests, and turn a one-second suite into a minute of waiting.
|
||||
lint_targets() {
|
||||
find "$here/../plugins" "$here/../mu-plugins" "$here" \
|
||||
-name '*.php' -not -path '*/node_modules/*' 2>/dev/null
|
||||
for patch in "$here"/../patches/*.patch; do
|
||||
[ -e "$patch" ] || continue
|
||||
sed -n 's|^--- a/||p' "$patch" | while IFS= read -r rel; do
|
||||
case "$rel" in *.php) echo "$here/../wordpress/$rel" ;; esac
|
||||
done
|
||||
done
|
||||
}
|
||||
while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if ! "$PHP_BIN" -l "$file" >/dev/null 2>&1; then
|
||||
echo " FAIL $file"
|
||||
"$PHP_BIN" -l "$file" 2>&1 | head -3
|
||||
syntax_ok=0
|
||||
status=1
|
||||
fi
|
||||
done < <(lint_targets)
|
||||
fi
|
||||
if command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||
while IFS= read -r file; do
|
||||
if ! "$NODE_BIN" --check "$file" >/dev/null 2>&1; then
|
||||
echo " FAIL $file"
|
||||
syntax_ok=0
|
||||
status=1
|
||||
fi
|
||||
done < <(find "$here/../plugins" "$here" -name '*.js' -not -path '*/node_modules/*' 2>/dev/null)
|
||||
fi
|
||||
[ "$syntax_ok" -eq 1 ] && echo " ok the fork's PHP and JS parses, including every patched core file"
|
||||
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
printf '\033[32mAll suites passed.\033[0m\n\n'
|
||||
else
|
||||
printf '\033[31mSomething failed or was skipped.\033[0m\n\n'
|
||||
fi
|
||||
exit "$status"
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* The cryptography, against fixed vectors.
|
||||
*
|
||||
* Every number in here was produced by something other than this code —
|
||||
* published secp256k1 and RIPEMD-160 test vectors, and libauth, which is the
|
||||
* library the Sirius portal wallet and the BNS gateway both use. If this file
|
||||
* passes, a signature made in Sirius Press verifies at the gateway and a
|
||||
* signature made in the portal verifies here.
|
||||
*
|
||||
* That cross-check is the point. A self-consistent implementation that signs
|
||||
* and verifies its own output proves nothing: it would pass just as happily
|
||||
* with the wrong curve.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
T::group( 'big numbers' );
|
||||
|
||||
T::ok( SP_BN::available(), 'a bignum backend is available (' . SP_BN::backend() . ')' );
|
||||
T::is( SP_BN::to_hex( SP_BN::from_hex( 'deadbeef' ), 8 ), 'deadbeef', 'hex round-trips' );
|
||||
T::is( SP_BN::to_hex( SP_BN::from_int( 255 ), 2 ), 'ff', 'integers convert' );
|
||||
T::is(
|
||||
SP_BN::to_hex( SP_BN::mod( SP_BN::from_int( -7 ), SP_BN::from_int( 5 ) ), 1 ),
|
||||
'3',
|
||||
'mod of a negative is a non-negative residue'
|
||||
);
|
||||
T::is( SP_BN::bits( SP_BN::from_int( 13 ) ), '1101', 'bit decomposition' );
|
||||
T::ok( SP_BN::is_odd( SP_BN::from_int( 7 ) ), '7 is odd' );
|
||||
T::ok( ! SP_BN::is_odd( SP_BN::from_int( 8 ) ), '8 is not odd' );
|
||||
|
||||
// a^-1 * a == 1 (mod p)
|
||||
$p = SP_BN::from_hex( SP_Secp256k1::P );
|
||||
$a = SP_BN::from_hex( '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' );
|
||||
$prod = SP_BN::mod( SP_BN::mul( $a, SP_BN::inv_mod( $a, $p ) ), $p );
|
||||
T::is( SP_BN::to_hex( $prod, 1 ), '1', 'modular inverse' );
|
||||
|
||||
T::group( 'secp256k1' );
|
||||
|
||||
/*
|
||||
* Generator multiples. These are the canonical public keys for private keys
|
||||
* 1, 2 and 3 — any implementation that gets these wrong is not on the curve
|
||||
* everyone else is using.
|
||||
*/
|
||||
$vectors = array(
|
||||
'0000000000000000000000000000000000000000000000000000000000000001' => '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
||||
'0000000000000000000000000000000000000000000000000000000000000002' => '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5',
|
||||
'0000000000000000000000000000000000000000000000000000000000000003' => '02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9',
|
||||
// n-1 gives -G: the same x as G, the other y, so the parity byte flips
|
||||
// from 02 to 03. A good check that compression reads y and not something
|
||||
// that merely correlates with it.
|
||||
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140' => '0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
||||
);
|
||||
foreach ( $vectors as $priv => $expected ) {
|
||||
T::is(
|
||||
bin2hex( SP_Secp256k1::public_key( hex2bin( $priv ) ) ),
|
||||
$expected,
|
||||
'public key for ' . substr( $priv, -8 )
|
||||
);
|
||||
}
|
||||
|
||||
T::is( SP_Secp256k1::public_key( str_repeat( "\x00", 32 ) ), '', 'a zero private key is refused' );
|
||||
T::is(
|
||||
SP_Secp256k1::public_key( hex2bin( 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141' ) ),
|
||||
'',
|
||||
'a private key equal to the group order is refused'
|
||||
);
|
||||
|
||||
// Sign, then recover: the public key must come back out.
|
||||
$priv = hex2bin( str_repeat( '0', 63 ) . '1' );
|
||||
$digest = hash( 'sha256', 'hello sirius', true );
|
||||
$sig = SP_Secp256k1::sign_recoverable( $digest, $priv );
|
||||
|
||||
T::is( strlen( $sig ), 65, 'a signature is 65 bytes' );
|
||||
T::is(
|
||||
bin2hex( $sig ),
|
||||
'1f5ec23ce0b5572febc59f09cb5b93e73e7b8ced06dbffa5cae138faf0cc4d06236093c3e0296d6d76cd811eb29c7e4b6ba30c430313e8e2e18666a3edc277a5c8',
|
||||
'the signature is byte-identical to libauth for the same key and digest'
|
||||
);
|
||||
T::is(
|
||||
bin2hex( SP_Secp256k1::recover( $sig, $digest ) ),
|
||||
bin2hex( SP_Secp256k1::public_key( $priv ) ),
|
||||
'recovery returns the signing key'
|
||||
);
|
||||
T::ok(
|
||||
SP_Secp256k1::recover( $sig, hash( 'sha256', 'a different message', true ) ) !== SP_Secp256k1::public_key( $priv ),
|
||||
'a tampered digest does not recover the signing key'
|
||||
);
|
||||
|
||||
// Determinism: RFC 6979 means the same inputs always give the same bytes.
|
||||
T::is(
|
||||
bin2hex( SP_Secp256k1::sign_recoverable( $digest, $priv ) ),
|
||||
bin2hex( $sig ),
|
||||
'signing is deterministic'
|
||||
);
|
||||
|
||||
// Malformed input must be refused rather than half-processed.
|
||||
T::is( SP_Secp256k1::recover( 'short', $digest ), '', 'a short signature is refused' );
|
||||
T::is( SP_Secp256k1::recover( str_repeat( "\x00", 65 ), $digest ), '', 'an all-zero signature is refused' );
|
||||
|
||||
T::group( 'low-S normalisation' );
|
||||
|
||||
/*
|
||||
* Every signature must have S in the lower half of the order. A high-S
|
||||
* signature is still valid arithmetic but is the malleable twin of the low-S
|
||||
* one, and tooling across the ecosystem rejects it.
|
||||
*/
|
||||
$half_n = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' );
|
||||
$all_low = true;
|
||||
for ( $i = 1; $i <= 8; $i++ ) {
|
||||
$k = hash( 'sha256', "key {$i}", true );
|
||||
$d = hash( 'sha256', "message {$i}", true );
|
||||
$s = SP_BN::from_bin( substr( SP_Secp256k1::sign_recoverable( $d, $k ), 33, 32 ) );
|
||||
if ( SP_BN::cmp( $s, $half_n ) > 0 ) {
|
||||
$all_low = false;
|
||||
}
|
||||
}
|
||||
T::ok( $all_low, 'S is in the lower half of the order across 8 signatures' );
|
||||
|
||||
T::group( 'many round trips' );
|
||||
|
||||
$round_trips = true;
|
||||
for ( $i = 1; $i <= 12; $i++ ) {
|
||||
$k = hash( 'sha256', "roundtrip key {$i}", true );
|
||||
$d = hash( 'sha256', "roundtrip message {$i}", true );
|
||||
$s = SP_Secp256k1::sign_recoverable( $d, $k );
|
||||
if ( SP_Secp256k1::recover( $s, $d ) !== SP_Secp256k1::public_key( $k ) ) {
|
||||
$round_trips = false;
|
||||
echo " failed at iteration {$i}\n";
|
||||
}
|
||||
}
|
||||
// Twelve is enough to catch the recovery-id bug that only shows on the
|
||||
// signatures where R.y happens to be odd — which is half of them.
|
||||
T::ok( $round_trips, '12 sign-and-recover round trips, covering both recovery ids' );
|
||||
|
||||
exit( T::summary() );
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* URL-to-path mapping and link rewriting.
|
||||
*
|
||||
* A wrong answer here is silent. Two pages that map to one path means one
|
||||
* quietly overwrites the other, and nobody finds out until a reader opens an
|
||||
* article and gets a different one. A link rewritten wrongly means the static
|
||||
* copy points back at the origin server it exists to make optional.
|
||||
*
|
||||
* So the cases below are mostly the awkward ones: a URL that looks like a file
|
||||
* but is a page, a path the gateway would refuse, a document deep in a tree
|
||||
* linking back up to an asset at the root.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
T::group( 'gateway path rules' );
|
||||
|
||||
T::is( SP_Gateway::clean_path( '/blog/hello/index.html' ), 'blog/hello/index.html', 'a leading slash is dropped' );
|
||||
T::is( SP_Gateway::clean_path( 'blog//hello///index.html' ), 'blog/hello/index.html', 'repeated slashes collapse' );
|
||||
T::is( SP_Gateway::clean_path( 'a/../../etc/passwd' ), '', 'traversal is refused' );
|
||||
T::is( SP_Gateway::clean_path( 'blog/' ), '', 'a trailing slash is refused — the gateway stores objects, not directories' );
|
||||
T::is( SP_Gateway::clean_path( '' ), '', 'an empty path is refused' );
|
||||
T::is( SP_Gateway::clean_path( 'café/index.html' ), '', 'characters the gateway rejects are refused here' );
|
||||
T::is( SP_Gateway::clean_path( str_repeat( 'a', 201 ) ), '', 'an over-long path is refused' );
|
||||
T::is( SP_Gateway::clean_path( 'wp-content/themes/x/style.css' ), 'wp-content/themes/x/style.css', 'an asset path survives' );
|
||||
|
||||
T::is( SP_Gateway::mime_for( 'index.html' ), 'text/html; charset=utf-8', 'html content type' );
|
||||
T::is( SP_Gateway::mime_for( 'app.CSS' ), 'text/css; charset=utf-8', 'extensions are matched case-insensitively' );
|
||||
T::is( SP_Gateway::mime_for( 'thing.unknown' ), 'application/octet-stream', 'an unknown extension falls back' );
|
||||
|
||||
T::group( 'URL to bucket path' );
|
||||
|
||||
$cases = array(
|
||||
'/' => 'index.html',
|
||||
'' => 'index.html',
|
||||
'/hello-world/' => 'hello-world/index.html',
|
||||
'/hello-world' => 'hello-world/index.html',
|
||||
'/blog/2026/09/a-post/' => 'blog/2026/09/a-post/index.html',
|
||||
'/robots.txt' => 'robots.txt',
|
||||
'/wp-content/themes/x/style.css' => 'wp-content/themes/x/style.css',
|
||||
'/sitemap.xml' => 'sitemap.xml',
|
||||
// Ends in a slash, so it is a page whose name happens to contain a dot —
|
||||
// not a file called "about.us".
|
||||
'/about.us/' => 'about.us/index.html',
|
||||
);
|
||||
foreach ( $cases as $url => $expected ) {
|
||||
T::is( SPE_Mapper::path_for_url_path( $url ), $expected, "'{$url}' maps to '{$expected}'" );
|
||||
}
|
||||
|
||||
T::ok( SPE_Mapper::is_asset( 'wp-content/x.css' ), 'a stylesheet is an asset' );
|
||||
T::ok( SPE_Mapper::is_asset( 'a/b/photo.JPG' ), 'an uppercase extension still reads as an asset' );
|
||||
T::ok( ! SPE_Mapper::is_asset( 'blog/post/index.html' ), 'a page is not an asset' );
|
||||
|
||||
// Distinct pages must never land on one path.
|
||||
$paths = array();
|
||||
foreach ( array( '/a/', '/b/', '/a/b/', '/a/b/c/', '/a.html', '/ab/' ) as $url ) {
|
||||
$paths[] = SPE_Mapper::path_for_url_path( $url );
|
||||
}
|
||||
T::is( count( array_unique( $paths ) ), count( $paths ), 'six distinct URLs give six distinct paths' );
|
||||
|
||||
// `/a/` and `/a/index.html` are the same page in WordPress and are SUPPOSED to
|
||||
// share a path — this records that as intended, not as the collision above.
|
||||
T::is(
|
||||
SPE_Mapper::path_for_url_path( '/a/' ),
|
||||
SPE_Mapper::path_for_url_path( '/a/index.html' ),
|
||||
'a directory URL and its explicit index are one page'
|
||||
);
|
||||
|
||||
T::group( 'document-relative links' );
|
||||
|
||||
$relative = array(
|
||||
// from to expected
|
||||
array( 'index.html', 'about/index.html', 'about/index.html' ),
|
||||
array( 'about/index.html', 'index.html', '../index.html' ),
|
||||
array( 'blog/post/index.html', 'wp-content/app.css', '../../wp-content/app.css' ),
|
||||
array( 'blog/post/index.html', 'blog/other/index.html', '../other/index.html' ),
|
||||
array( 'a/b/c/index.html', 'a/b/c/style.css', 'style.css' ),
|
||||
array( 'index.html', 'index.html', 'index.html' ),
|
||||
);
|
||||
foreach ( $relative as $case ) {
|
||||
list( $from, $to, $expected ) = $case;
|
||||
T::is( SPE_Renderer::relative( $from, $to ), $expected, "{$from} -> {$to}" );
|
||||
}
|
||||
|
||||
T::group( 'rewriting a page' );
|
||||
|
||||
$html = '<!doctype html><html><head>'
|
||||
. '<link rel="stylesheet" href="https://example.test/wp-content/themes/x/style.css?ver=1.2">'
|
||||
. '<link rel="canonical" href="https://example.test/blog/hello/">'
|
||||
. '</head><body>'
|
||||
. '<a href="https://example.test/">Home</a>'
|
||||
. '<a href="https://example.test/about/">About</a>'
|
||||
. '<a href="https://elsewhere.example/offsite">Offsite</a>'
|
||||
. '<img src="//example.test/wp-content/uploads/pic.png" alt="">'
|
||||
. '<a href="https://example.test/blog/hello/#notes">Notes</a>'
|
||||
. '</body></html>';
|
||||
|
||||
$result = SPE_Renderer::rewrite( $html, 'blog/hello/index.html' );
|
||||
$out = $result['html'];
|
||||
|
||||
T::ok( false === strpos( $out, 'https://example.test' ), 'no absolute self-links remain' );
|
||||
T::ok( false !== strpos( $out, 'href="../../index.html"' ), 'the home link became document-relative' );
|
||||
T::ok( false !== strpos( $out, 'href="../../about/index.html"' ), 'an internal page link became relative' );
|
||||
T::ok( false !== strpos( $out, 'href="https://elsewhere.example/offsite"' ), 'an external link is left alone' );
|
||||
T::ok(
|
||||
false !== strpos( $out, 'href="../../wp-content/themes/x/style.css"' ),
|
||||
'the ?ver= query is dropped from an asset, which the bucket stores once'
|
||||
);
|
||||
T::ok( false !== strpos( $out, 'src="../../wp-content/uploads/pic.png"' ), 'a protocol-relative URL is rewritten too' );
|
||||
T::ok( false !== strpos( $out, 'href="index.html#notes"' ), 'a fragment survives rewriting' );
|
||||
|
||||
T::ok( isset( $result['assets']['wp-content/themes/x/style.css'] ), 'the stylesheet was collected for export' );
|
||||
T::ok( isset( $result['assets']['wp-content/uploads/pic.png'] ), 'the image was collected for export' );
|
||||
T::is(
|
||||
$result['assets']['wp-content/themes/x/style.css'],
|
||||
'https://example.test/wp-content/themes/x/style.css',
|
||||
'a collected asset carries the URL to fetch it from'
|
||||
);
|
||||
T::ok( ! isset( $result['assets']['about/index.html'] ), 'a page is not collected as an asset' );
|
||||
|
||||
T::group( 'rewriting when the site URL carries a port' );
|
||||
|
||||
/*
|
||||
* A local instance, a staging box behind a port, anything not on 80 or 443.
|
||||
* This case is worth its own group because two separate bugs lived here: a
|
||||
* naive protocol-relative pass that matched inside every absolute URL and
|
||||
* gave each one a second scheme, and a host-only match that left the port
|
||||
* stranded as `//host:8760:8760/`. Both produced links that looked almost
|
||||
* right and went nowhere.
|
||||
*/
|
||||
// Swap the stub's idea of where the site is for the length of this group.
|
||||
$GLOBALS['sirius_test_home'] = 'http://127.0.0.1:8760';
|
||||
|
||||
$with_port =
|
||||
'<a href="http://127.0.0.1:8760/?feed=rss2">feed</a>'
|
||||
. '<a href="http://127.0.0.1:8760/">home</a>'
|
||||
. '<a href="http://127.0.0.1:8760/about/">about</a>'
|
||||
. '<img src="//127.0.0.1:8760/wp-content/x.png">';
|
||||
|
||||
$ported_out = SPE_Renderer::rewrite( $with_port, 'index.html' )['html'];
|
||||
|
||||
T::ok( false === strpos( $ported_out, 'http:http' ), 'no doubled scheme' );
|
||||
T::ok( false === strpos( $ported_out, ':8760:8760' ), 'no doubled port' );
|
||||
T::ok( false === strpos( $ported_out, '127.0.0.1' ), 'no absolute self-links remain' );
|
||||
T::ok( false !== strpos( $ported_out, 'href="index.html?feed=rss2"' ), 'a query survives on the home page' );
|
||||
T::ok( false !== strpos( $ported_out, 'href="about/index.html"' ), 'an internal link is relative' );
|
||||
T::ok( false !== strpos( $ported_out, 'src="wp-content/x.png"' ), 'a protocol-relative asset keeps its port stripped cleanly' );
|
||||
|
||||
unset( $GLOBALS['sirius_test_home'] );
|
||||
|
||||
T::group( 'the export marker' );
|
||||
|
||||
// The exporter's own marker must never survive into a published page: every
|
||||
// internal link would carry it, and the published copy would then be asking
|
||||
// for the export-rendered variant of every page.
|
||||
$flagged = '<a href="https://example.test/about/?sirius_export=abc123">About</a>';
|
||||
$rewrote = SPE_Renderer::rewrite( $flagged, 'index.html' )['html'];
|
||||
T::ok( false === strpos( $rewrote, 'sirius_export' ), 'the export marker is stripped' );
|
||||
|
||||
exit( T::summary() );
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Addresses, derivation and the two signing envelopes.
|
||||
*
|
||||
* These are the pieces that decide who a user is. A bug here does not produce
|
||||
* a crash — it produces an account attached to the wrong key, or a login that
|
||||
* accepts a signature it should not. So the checks lean hard on cases where
|
||||
* a plausible-looking shortcut would be wrong: a flipped checksum character, a
|
||||
* token-aware address spelling, a signature over a slightly different message.
|
||||
*
|
||||
* Addresses and the derived wallet were produced with libauth, the same
|
||||
* library the Sirius portal uses.
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
T::group( 'CashAddress' );
|
||||
|
||||
$mainnet = 'bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h';
|
||||
$chipnet = 'bchtest:qp63uahgrxged4z5jswyt5dn5v3lzsem6cq85x00dt';
|
||||
$pub_one = SP_Secp256k1::public_key( hex2bin( str_repeat( '0', 63 ) . '1' ) );
|
||||
|
||||
T::is( SP_CashAddr::from_public_key( $pub_one, 'bitcoincash' ), $mainnet, 'mainnet address for key 1' );
|
||||
T::is( SP_CashAddr::from_public_key( $pub_one, 'bchtest' ), $chipnet, 'chipnet address for the same key' );
|
||||
|
||||
T::ok( SP_CashAddr::is_valid( $mainnet ), 'a good address validates' );
|
||||
T::ok( ! SP_CashAddr::is_valid( substr( $mainnet, 0, -1 ) . 'q' ), 'a corrupted checksum is rejected' );
|
||||
T::ok( ! SP_CashAddr::is_valid( 'bitcoincash:notanaddressatall' ), 'nonsense is rejected' );
|
||||
T::ok( ! SP_CashAddr::is_valid( '' ), 'an empty string is rejected' );
|
||||
T::ok( ! SP_CashAddr::is_valid( 'walerikus@example.com' ), 'an email address is not an address' );
|
||||
|
||||
// The prefix is part of the checksum, so a mainnet payload under a testnet
|
||||
// prefix must not validate. Without this, a chipnet signature could
|
||||
// authenticate a mainnet account.
|
||||
$payload = substr( $mainnet, strlen( 'bitcoincash:' ) );
|
||||
T::ok( ! SP_CashAddr::is_valid( 'bchtest:' . $payload ), 'a mainnet payload does not validate under the chipnet prefix' );
|
||||
|
||||
$decoded = SP_CashAddr::decode( $mainnet );
|
||||
T::is( $decoded['prefix'], 'bitcoincash', 'decode reports the prefix' );
|
||||
T::is( $decoded['type'], SP_CashAddr::TYPE_P2PKH, 'decode reports the type' );
|
||||
T::is( strlen( $decoded['hash'] ), 20, 'decode returns a 20-byte hash' );
|
||||
|
||||
// Token-aware and plain spellings of one key must resolve to one account.
|
||||
$token = SP_CashAddr::encode( 'bitcoincash', $decoded['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN );
|
||||
T::ok( $token !== $mainnet, 'the token-aware spelling differs' );
|
||||
T::is( SP_CashAddr::normalize( $token ), $mainnet, 'the token-aware spelling normalises to the plain one' );
|
||||
T::is( SP_CashAddr::normalize( strtoupper( $mainnet ) ), $mainnet, 'case does not create a second identity' );
|
||||
T::is( SP_CashAddr::normalize( 'nonsense' ), '', 'normalising a non-address gives an empty string' );
|
||||
|
||||
// A bare payload, which is how half the ecosystem displays an address.
|
||||
T::is( SP_CashAddr::normalize( $payload ), $mainnet, 'a prefix-less address is understood' );
|
||||
|
||||
T::group( 'BIP-39 and BIP-32' );
|
||||
|
||||
// Generated by libauth's BuiltInWallet; the address is what the Sirius portal
|
||||
// shows for this phrase.
|
||||
$phrase = 'trash key flip dawn impulse float medal rain sell hand neither hub';
|
||||
$key = SP_HD::publishing_key( $phrase, 'bchtest' );
|
||||
|
||||
T::is( $key['address'], 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy', 'derives the address the portal wallet derives' );
|
||||
T::is( strlen( $key['private'] ), 32, 'the private key is 32 bytes' );
|
||||
T::is( strlen( $key['public'] ), 33, 'the public key is compressed' );
|
||||
|
||||
// Normalisation must not change the key: a phrase pasted with odd spacing or
|
||||
// capitals is the same wallet, and treating it as a different one would look
|
||||
// to the user like their funds had vanished.
|
||||
T::is(
|
||||
SP_HD::publishing_key( " Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", 'bchtest' )['address'],
|
||||
$key['address'],
|
||||
'spacing and capitals do not change the wallet'
|
||||
);
|
||||
|
||||
// A different path is a different wallet — this is the single most likely
|
||||
// cause of an unexplained 403 from the gateway.
|
||||
T::ok(
|
||||
SP_HD::publishing_key( $phrase, 'bchtest', "m/44'/0'/0'/0/0" )['address'] !== $key['address'],
|
||||
'a different derivation path gives a different address'
|
||||
);
|
||||
|
||||
T::is( SP_HD::phrase_problem( $phrase ), '', 'a good phrase has no problem' );
|
||||
T::ok( '' !== SP_HD::phrase_problem( 'too few words' ), 'a short phrase is reported' );
|
||||
T::ok( '' !== SP_HD::phrase_problem( '' ), 'an empty phrase is reported' );
|
||||
|
||||
T::group( 'BIP-137 message signatures' );
|
||||
|
||||
$message = "Sign in to Example\n\nSite: https://example.test/\nNonce: 1.2.3";
|
||||
|
||||
// The digest a wallet computes. Checked against the Theseus wallet's own
|
||||
// implementation (bundled-addons/aegis/lib/chain-bch.js).
|
||||
T::is(
|
||||
bin2hex( SP_Message::bip137_digest( 'SIRIUS-PRESS-LOGIN1' . "\n" . 'https://example.bch' . "\n" . 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy' . "\n" . 'abc123' . "\n" . '1758412800000' ) ),
|
||||
'98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19',
|
||||
'the message digest matches the Theseus wallet'
|
||||
);
|
||||
|
||||
$signature = SP_Message::sign( $message, $key['private'] );
|
||||
T::ok( SP_Message::verify( $message, $signature, $key['address'] ), 'a signature verifies against its own address' );
|
||||
T::ok( ! SP_Message::verify( $message . ' ', $signature, $key['address'] ), 'a trailing space breaks verification' );
|
||||
T::ok( ! SP_Message::verify( $message, $signature, $mainnet ), 'the signature does not verify against another address' );
|
||||
T::ok( ! SP_Message::verify( $message, 'not base64 at all!!', $key['address'] ), 'garbage is not a signature' );
|
||||
T::ok( ! SP_Message::verify( $message, base64_encode( 'short' ), $key['address'] ), 'a short signature is refused' );
|
||||
T::ok( ! SP_Message::verify( $message, $signature, 'bitcoincash:notanaddress' ), 'an invalid claimed address is refused' );
|
||||
|
||||
// A token-aware address must still verify — a user may present either form.
|
||||
T::ok(
|
||||
SP_Message::verify(
|
||||
$message,
|
||||
$signature,
|
||||
SP_CashAddr::encode( 'bchtest', SP_CashAddr::decode( $key['address'] )['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN )
|
||||
),
|
||||
'the token-aware spelling of the signer verifies'
|
||||
);
|
||||
|
||||
// A message longer than 252 bytes crosses the varint boundary in the BIP-137
|
||||
// payload. Getting that wrong produces signatures no other wallet accepts.
|
||||
$long = str_repeat( 'x', 300 );
|
||||
T::ok(
|
||||
SP_Message::verify( $long, SP_Message::sign( $long, $key['private'] ), $key['address'] ),
|
||||
'a 300-byte message signs and verifies across the varint boundary'
|
||||
);
|
||||
|
||||
T::group( 'BNS-SITE1 upload envelope' );
|
||||
|
||||
T::is(
|
||||
bin2hex( SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 ) ),
|
||||
hash( 'sha256', "BNS-SITE1\nexample.bch\nindex.html\n" . hash( 'sha256', '<h1>hi</h1>' ) . "\n1758412800000" ),
|
||||
'the upload digest matches the gateway formula'
|
||||
);
|
||||
|
||||
$headers = SP_Message::site_headers( 'example.bch', 'index.html', '<h1>hi</h1>', $key['private'], 1758412800000 );
|
||||
T::is( $headers['x-bns-ts'], '1758412800000', 'the timestamp header is the one signed' );
|
||||
T::is( strlen( base64_decode( $headers['x-bns-sig'], true ) ), 65, 'the signature header decodes to 65 bytes' );
|
||||
|
||||
// The gateway recovers the signer and compares it to the on-chain owner.
|
||||
// Reproduce that here so a change to either side is caught.
|
||||
T::is(
|
||||
SP_CashAddr::from_public_key(
|
||||
SP_Secp256k1::recover(
|
||||
base64_decode( $headers['x-bns-sig'], true ),
|
||||
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 )
|
||||
),
|
||||
'bchtest'
|
||||
),
|
||||
$key['address'],
|
||||
'the gateway would recover the publishing address from the header'
|
||||
);
|
||||
|
||||
// Changing any signed component must invalidate it.
|
||||
T::ok(
|
||||
SP_CashAddr::from_public_key(
|
||||
SP_Secp256k1::recover(
|
||||
base64_decode( $headers['x-bns-sig'], true ),
|
||||
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>TAMPERED</h1>', 1758412800000 )
|
||||
),
|
||||
'bchtest'
|
||||
) !== $key['address'],
|
||||
'altered upload bytes no longer recover the publishing address'
|
||||
);
|
||||
|
||||
T::group( 'canonical JSON' );
|
||||
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'b' => 1, 'a' => 2 ) ),
|
||||
'{"a":2,"b":1}',
|
||||
'keys are sorted'
|
||||
);
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'a' => null, 'b' => 1 ) ),
|
||||
'{"b":1}',
|
||||
'null members are dropped'
|
||||
);
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'z' => array( 'y' => 1, 'x' => 2 ) ) ),
|
||||
'{"z":{"x":2,"y":1}}',
|
||||
'nested keys are sorted too'
|
||||
);
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'url' => 'https://a/b' ) ),
|
||||
'{"url":"https://a/b"}',
|
||||
'slashes are not escaped, matching JSON.stringify'
|
||||
);
|
||||
// Written as a concatenation so the expected value cannot itself be stored as
|
||||
// a literal e-acute, which would make this assertion test nothing.
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'note' => 'caf' . chr( 0xc3 ) . chr( 0xa9 ) ) ),
|
||||
'{"note":"caf' . chr( 92 ) . 'u00e9"}',
|
||||
'non-ASCII is backslash-u escaped'
|
||||
);
|
||||
T::is(
|
||||
SP_Message::canonical_json( array( 'list' => array( 3, 1, 2 ) ) ),
|
||||
'{"list":[3,1,2]}',
|
||||
'array order is preserved'
|
||||
);
|
||||
|
||||
exit( T::summary() );
|
||||
108
tools/build.sh
108
tools/build.sh
|
|
@ -1,108 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# build.sh — assemble a complete Sirius Press tree.
|
||||
#
|
||||
# Copies the vendored WordPress from wordpress/, drops this repository's
|
||||
# plugins and must-use plugins into it, and leaves the result in
|
||||
# dist/sirius-press/.
|
||||
#
|
||||
# With --zip it also produces dist/sirius-press-<version>.zip, which is the
|
||||
# artifact shared-hosting users upload.
|
||||
#
|
||||
# There is no download and no checksum step, because there is nothing to
|
||||
# fetch: core is in the repository, already patched, and what you build is
|
||||
# exactly what you can read in `git log wordpress/`. Moving to a new upstream
|
||||
# release is tools/update-wordpress.sh, not a flag here.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$here"
|
||||
|
||||
# shellcheck source=wordpress.lock
|
||||
source tools/wordpress.lock
|
||||
|
||||
VERSION="$(grep -m1 "^ \* Version:" plugins/sirius-press-core/sirius-press-core.php | awk '{print $3}')"
|
||||
DIST="$here/dist"
|
||||
TARGET="$DIST/sirius-press"
|
||||
SOURCE="$here/wordpress"
|
||||
MAKE_ZIP=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--zip) MAKE_ZIP=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -f "$SOURCE/wp-includes/version.php" ] \
|
||||
|| die "wordpress/ is missing or empty — check the whole repository was cloned."
|
||||
|
||||
vendored="$(grep -m1 'wp_version = ' "$SOURCE/wp-includes/version.php" | sed "s/.*'\(.*\)'.*/\1/")"
|
||||
if [ "$vendored" != "$WP_VERSION" ]; then
|
||||
die "wordpress/ holds $vendored but tools/wordpress.lock says $WP_VERSION.
|
||||
One of them is stale; tools/update-wordpress.sh keeps them in step."
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ assemble
|
||||
|
||||
say "copying WordPress $vendored out of wordpress/"
|
||||
rm -rf "$TARGET"
|
||||
mkdir -p "$TARGET"
|
||||
# The trailing /. copies the contents rather than nesting another directory.
|
||||
cp -a "$SOURCE/." "$TARGET/"
|
||||
|
||||
say "installing Sirius Press plugins"
|
||||
mkdir -p "$TARGET/wp-content/plugins" "$TARGET/wp-content/mu-plugins"
|
||||
for plugin in plugins/*/; do
|
||||
cp -R "$plugin" "$TARGET/wp-content/plugins/"
|
||||
done
|
||||
cp mu-plugins/*.php "$TARGET/wp-content/mu-plugins/"
|
||||
|
||||
say "built dist/sirius-press ($(du -sh "$TARGET" | cut -f1))"
|
||||
|
||||
# --------------------------------------------------------------------- zip
|
||||
|
||||
if [ "$MAKE_ZIP" -eq 1 ]; then
|
||||
archive="$DIST/sirius-press-$VERSION.zip"
|
||||
say "packing $(basename "$archive")"
|
||||
rm -f "$archive"
|
||||
|
||||
# `zip` is the obvious tool and is missing often enough — minimal container
|
||||
# images, Git Bash on Windows — that falling back to Python's zipfile is
|
||||
# worth nine lines. Both produce the same archive as far as anyone
|
||||
# unpacking it is concerned.
|
||||
if command -v zip >/dev/null; then
|
||||
( cd "$DIST" && zip -qr "$archive" sirius-press )
|
||||
elif command -v python3 >/dev/null || command -v python >/dev/null; then
|
||||
py="$(command -v python3 || command -v python)"
|
||||
"$py" - "$DIST" "$archive" <<'PYZIP'
|
||||
import os, sys, zipfile
|
||||
root, archive = sys.argv[1], sys.argv[2]
|
||||
base = os.path.join(root, 'sirius-press')
|
||||
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
||||
for folder, _dirs, files in os.walk(base):
|
||||
for name in files:
|
||||
full = os.path.join(folder, name)
|
||||
z.write(full, os.path.relpath(full, root).replace(os.sep, '/'))
|
||||
PYZIP
|
||||
else
|
||||
die "--zip needs either the zip command or Python."
|
||||
fi
|
||||
|
||||
sha256sum "$archive" | cut -d' ' -f1 > "$archive.sha256"
|
||||
say "$(basename "$archive") — $(du -h "$archive" | cut -f1), sha256 $(cat "$archive.sha256")"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Sirius Press $VERSION, on WordPress $vendored.
|
||||
|
||||
dist/sirius-press/ a complete tree, ready to serve
|
||||
$( [ "$MAKE_ZIP" -eq 1 ] && echo " dist/sirius-press-$VERSION.zip upload this to a shared host" )
|
||||
|
||||
To run it locally: cd docker && docker compose up -d
|
||||
To install on a VPS: see docs/install.md
|
||||
EOF
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# publish-release.sh — put a built release on both mirrors.
|
||||
#
|
||||
# tools/publish-release.sh # build, then publish
|
||||
# tools/publish-release.sh --dry-run # say what would happen
|
||||
#
|
||||
# A Silent Mode release is not shipped until it exists in both places:
|
||||
#
|
||||
# silentmode.st over scp, served by nginx on the VPS
|
||||
# silentmode.bch on Sia, reached through any BCNR resolver
|
||||
#
|
||||
# Publishing to only one is the recurring mistake this script exists to
|
||||
# prevent. A release on silentmode.st alone is a release that disappears when
|
||||
# the VPS does, which rather undercuts the argument the project is making.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$here"
|
||||
|
||||
SSH_HOST="${SIRIUS_SSH_HOST:-silentmode}"
|
||||
WEB_ROOT="${SIRIUS_WEB_ROOT:-/opt/silent-mode/site/sirius-press}"
|
||||
S3_ENDPOINT="${SIRIUS_S3_ENDPOINT:-https://s3.silentmode.st:8600}"
|
||||
S3_PREFIX="${SIRIUS_S3_PREFIX:-s3://bns/silentmode/sirius-press}"
|
||||
AWS_PROFILE_NAME="${SIRIUS_AWS_PROFILE:-sia-storage}"
|
||||
DRY=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
run() { if [ "$DRY" -eq 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
|
||||
|
||||
VERSION="$(grep -m1 "^ \* Version:" plugins/sirius-press-core/sirius-press-core.php | awk '{print $3}')"
|
||||
[ -n "$VERSION" ] || die "could not read the version out of sirius-press-core.php"
|
||||
|
||||
ARCHIVE="dist/sirius-press-${VERSION}.zip"
|
||||
|
||||
# ------------------------------------------------------------------- build
|
||||
|
||||
if [ ! -f "$ARCHIVE" ]; then
|
||||
say "building $VERSION"
|
||||
[ "$DRY" -eq 1 ] && { echo " would run: tools/build.sh --zip"; } || tools/build.sh --zip
|
||||
fi
|
||||
[ "$DRY" -eq 1 ] || [ -f "$ARCHIVE" ] || die "$ARCHIVE was not produced"
|
||||
|
||||
if [ -f "$ARCHIVE" ]; then
|
||||
SHA="$(cat "${ARCHIVE}.sha256" 2>/dev/null || sha256sum "$ARCHIVE" | cut -d' ' -f1)"
|
||||
say "$(basename "$ARCHIVE") — $(du -h "$ARCHIVE" | cut -f1), sha256 ${SHA}"
|
||||
else
|
||||
SHA="(not built)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- manifest
|
||||
|
||||
MANIFEST="dist/releases.json"
|
||||
say "writing $(basename "$MANIFEST")"
|
||||
if [ "$DRY" -eq 0 ]; then
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{
|
||||
"product": "sirius-press",
|
||||
"latest": "${VERSION}",
|
||||
"updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"releases": [
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"zip": "sirius-press-${VERSION}.zip",
|
||||
"sha256": "${SHA}",
|
||||
"wordpress": "$(grep -m1 '^WP_VERSION=' tools/wordpress.lock | cut -d= -f2)",
|
||||
"install": "https://silentmode.st/sirius-press/install.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------- mirror 1: the VPS
|
||||
|
||||
say "publishing to silentmode.st"
|
||||
command -v ssh >/dev/null || die "ssh is required"
|
||||
run ssh "$SSH_HOST" "mkdir -p ${WEB_ROOT}"
|
||||
run scp "$ARCHIVE" "${SSH_HOST}:${WEB_ROOT}/"
|
||||
run scp "${ARCHIVE}.sha256" "${SSH_HOST}:${WEB_ROOT}/"
|
||||
run scp "$MANIFEST" "${SSH_HOST}:${WEB_ROOT}/releases.json"
|
||||
run scp install.sh "${SSH_HOST}:${WEB_ROOT}/install.sh"
|
||||
|
||||
# --------------------------------------------------------- mirror 2: Sia
|
||||
|
||||
say "publishing to silentmode.bch"
|
||||
if ! command -v aws >/dev/null; then
|
||||
warn "the aws CLI is not installed, so the Sia mirror was skipped.
|
||||
This release is NOT shipped until it is on both. Install awscli and run:
|
||||
aws --profile ${AWS_PROFILE_NAME} --endpoint-url ${S3_ENDPOINT} s3 cp ${ARCHIVE} ${S3_PREFIX}/"
|
||||
else
|
||||
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||
s3 cp "$ARCHIVE" "${S3_PREFIX}/" --only-show-errors
|
||||
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||
s3 cp "${ARCHIVE}.sha256" "${S3_PREFIX}/" --only-show-errors
|
||||
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||
s3 cp "$MANIFEST" "${S3_PREFIX}/releases.json" --only-show-errors
|
||||
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||
s3 cp install.sh "${S3_PREFIX}/install.sh" --only-show-errors
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ verify
|
||||
|
||||
if [ "$DRY" -eq 0 ]; then
|
||||
say "checking both mirrors actually have it"
|
||||
st_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 20 \
|
||||
"https://silentmode.st/sirius-press/$(basename "$ARCHIVE")" 2>/dev/null || echo 000)"
|
||||
[ "$st_code" = "200" ] \
|
||||
&& echo " ok silentmode.st" \
|
||||
|| warn "silentmode.st returned ${st_code} for the archive"
|
||||
|
||||
if command -v aws >/dev/null; then
|
||||
aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||
s3 ls "${S3_PREFIX}/$(basename "$ARCHIVE")" >/dev/null 2>&1 \
|
||||
&& echo " ok silentmode.bch" \
|
||||
|| warn "the archive is not listed on the Sia mirror"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Sirius Press ${VERSION} published.
|
||||
|
||||
https://silentmode.st/sirius-press/
|
||||
silentmode.bch/sirius-press/
|
||||
|
||||
Still to do by hand:
|
||||
- tag the forge repo: git tag -a v${VERSION} -m "Sirius Press ${VERSION}"
|
||||
- attach $(basename "$ARCHIVE") to the release on
|
||||
https://code.silentmode.st/silentmode/sirius-press/releases
|
||||
|
||||
EOF
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# refresh-patches.sh — regenerate patches/ from what the fork actually changed.
|
||||
#
|
||||
# Since core is vendored, `patches/` is no longer how the build works — it is
|
||||
# documentation. It answers the question anyone auditing this fork asks first:
|
||||
# *what exactly did you change inside WordPress?* A directory of readable
|
||||
# diffs answers that in a minute; `git log wordpress/` does not, because it is
|
||||
# mostly upstream imports.
|
||||
#
|
||||
# Documentation that is generated stays true. Documentation that is maintained
|
||||
# by hand drifts, and a stale record of a fork's core diff is worse than none,
|
||||
# because people trust it.
|
||||
#
|
||||
# tools/refresh-patches.sh # rewrite patches/ from the tree
|
||||
# tools/refresh-patches.sh --check # fail if it would change anything (CI)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
repo="$(git -C "$here" rev-parse --show-toplevel)"
|
||||
# Ask git where we are rather than subtracting paths: on Windows
|
||||
# --show-toplevel answers "D:/..." while $PWD is "/d/...", and the
|
||||
# subtraction silently leaves an absolute path behind.
|
||||
prefix="$(cd "$here" && git rev-parse --show-prefix)wordpress"
|
||||
UPSTREAM_BRANCH="sirius-press/wordpress-upstream"
|
||||
CHECK=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) CHECK=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
git -C "$repo" rev-parse --verify --quiet "$UPSTREAM_BRANCH" >/dev/null \
|
||||
|| die "the branch $UPSTREAM_BRANCH is missing; there is nothing to diff against."
|
||||
|
||||
# shellcheck source=wordpress.lock
|
||||
source "$here/tools/wordpress.lock"
|
||||
|
||||
staging="$here/dist/.patch-refresh"
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging"
|
||||
|
||||
say "comparing ${prefix}/ against pristine WordPress ${WP_VERSION}"
|
||||
|
||||
# Every path that differs between the vendored tree and the pristine import.
|
||||
#
|
||||
# Compared tree to tree, not commit to commit: the upstream branch keeps
|
||||
# WordPress at its root while the subtree keeps it under a prefix, so a
|
||||
# plain `git diff BRANCH HEAD -- <prefix>` compares two different path
|
||||
# spaces and reports the whole distribution as added.
|
||||
UPSTREAM_TREE="${UPSTREAM_BRANCH}^{tree}"
|
||||
VENDORED_TREE="HEAD:${prefix}"
|
||||
|
||||
mapfile -t changed < <(
|
||||
git -C "$repo" diff --name-only "$UPSTREAM_TREE" "$VENDORED_TREE"
|
||||
)
|
||||
|
||||
if [ ${#changed[@]} -eq 0 ]; then
|
||||
say "the fork changes nothing in core — writing an empty series"
|
||||
fi
|
||||
|
||||
index=0
|
||||
for file in "${changed[@]}"; do
|
||||
[ -n "$file" ] || continue
|
||||
index=$((index + 1))
|
||||
slug="$(echo "$file" | tr '/.' '--' | tr -cd 'A-Za-z0-9-')"
|
||||
out="$staging/$(printf '%04d' "$index")-${slug}.patch"
|
||||
|
||||
{
|
||||
echo "Subject: [PATCH] ${file}"
|
||||
echo
|
||||
echo "The fork's change to this file, regenerated by tools/refresh-patches.sh."
|
||||
echo "It is a record, not the mechanism: core is vendored under wordpress/ and"
|
||||
echo "this diff is what distinguishes it from pristine upstream."
|
||||
echo
|
||||
echo "Applies to: WordPress ${WP_VERSION}"
|
||||
git -C "$repo" diff "$UPSTREAM_TREE" "$VENDORED_TREE" -- "$file" | tail -n +3
|
||||
} > "$out"
|
||||
done
|
||||
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
# --strip-trailing-cr because a Windows checkout may hold these with CRLF
|
||||
# while what we just generated has LF. .gitattributes marks *.patch as
|
||||
# binary to stop that happening, but a clone made before that landed — or
|
||||
# with the file copied around by hand — would otherwise report drift that
|
||||
# is not there, and a check that cries wolf gets switched off.
|
||||
if diff -rq --strip-trailing-cr "$here/patches" "$staging" >/dev/null 2>&1; then
|
||||
say "patches/ matches the tree"
|
||||
rm -rf "$staging"
|
||||
exit 0
|
||||
fi
|
||||
diff -ru --strip-trailing-cr "$here/patches" "$staging" || true
|
||||
rm -rf "$staging"
|
||||
die "patches/ is out of date. Run tools/refresh-patches.sh and commit the result."
|
||||
fi
|
||||
|
||||
rm -f "$here/patches"/*.patch
|
||||
if [ "$index" -gt 0 ]; then
|
||||
cp "$staging"/*.patch "$here/patches/"
|
||||
fi
|
||||
rm -rf "$staging"
|
||||
|
||||
say "wrote ${index} patch file(s) describing the fork's core diff"
|
||||
for f in "$here/patches"/*.patch; do
|
||||
[ -e "$f" ] || continue
|
||||
printf ' %s (%s lines)\n' "$(basename "$f")" "$(grep -c '' "$f")"
|
||||
done
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# update-wordpress.sh — bring a new WordPress release into the fork.
|
||||
#
|
||||
# tools/update-wordpress.sh 7.1.2
|
||||
#
|
||||
# Two steps, and the second is the one that matters:
|
||||
#
|
||||
# 1. Import the pristine release onto the `sirius-press/wordpress-upstream`
|
||||
# branch — one commit per version, nothing but upstream, never edited.
|
||||
# 2. `git subtree merge` that branch into wordpress/, which three-way merges
|
||||
# it against the fork's own commits on top of the last import.
|
||||
#
|
||||
# That second step is why core is vendored rather than patched at build time.
|
||||
# A three-way merge understands that upstream changed lines A and B while the
|
||||
# fork changed line C, and only stops when they overlap. It also leaves a
|
||||
# conflict you resolve once, in the file, instead of a patch you re-derive
|
||||
# every release.
|
||||
#
|
||||
# The merge happens in a temporary worktree, not in your checkout. git-subtree
|
||||
# refuses to run at all when anything in the repository is modified, and in a
|
||||
# monorepo shared with other work that is the normal state — the merge would
|
||||
# be blocked by uncommitted files it does not touch. A worktree is clean by
|
||||
# construction, and the result comes back as one ordinary merge commit.
|
||||
#
|
||||
# Nothing is pushed. Run it, look at the merge, run the tests, then push.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
repo="$(git -C "$here" rev-parse --show-toplevel)"
|
||||
# Ask git for the path rather than subtracting strings: on Windows
|
||||
# --show-toplevel answers "D:/…" while $PWD is "/d/…", and the subtraction
|
||||
# silently leaves an absolute path, which --prefix then rejects.
|
||||
prefix="$(cd "$here" && git rev-parse --show-prefix)wordpress"
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -n "$VERSION" ] || die "usage: tools/update-wordpress.sh <version>"
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || die "'$VERSION' does not look like a WordPress version"
|
||||
|
||||
# shellcheck source=wordpress.lock
|
||||
source "$here/tools/wordpress.lock"
|
||||
CURRENT="$WP_VERSION"
|
||||
[ "$VERSION" != "$CURRENT" ] || die "the fork is already on WordPress $VERSION"
|
||||
|
||||
UPSTREAM_BRANCH="sirius-press/wordpress-upstream"
|
||||
git -C "$repo" rev-parse --verify --quiet "$UPSTREAM_BRANCH" >/dev/null \
|
||||
|| die "the branch $UPSTREAM_BRANCH is missing. It holds the pristine upstream
|
||||
imports that the subtree merges against; without it this fork cannot take an
|
||||
upstream release. See docs/upstream-merges.md."
|
||||
|
||||
# The merge lands on top of whatever is committed, so anything staged or
|
||||
# modified under the subtree itself would be lost or fought over.
|
||||
if ! git -C "$repo" diff --quiet -- "$prefix" 2>/dev/null; then
|
||||
die "${prefix}/ has uncommitted changes. Commit or stash them first."
|
||||
fi
|
||||
|
||||
CACHE="$here/dist/.cache"
|
||||
mkdir -p "$CACHE"
|
||||
URL="https://wordpress.org/wordpress-${VERSION}.tar.gz"
|
||||
TARBALL="$CACHE/wordpress-${VERSION}.tar.gz"
|
||||
|
||||
# ------------------------------------------------------------------- fetch
|
||||
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
say "downloading WordPress $VERSION"
|
||||
curl -fsSL -o "$TARBALL.part" "$URL" || die "could not download $URL — does that version exist?"
|
||||
mv "$TARBALL.part" "$TARBALL"
|
||||
fi
|
||||
|
||||
say "checking it against the SHA-1 wordpress.org publishes"
|
||||
published="$(curl -fsSL "${URL}.sha1" 2>/dev/null || true)"
|
||||
actual_sha1="$(sha1sum "$TARBALL" | cut -d' ' -f1)"
|
||||
if [ -z "$published" ]; then
|
||||
warn "wordpress.org served no .sha1 for this release; recording the sha256 below unverified"
|
||||
elif [ "$published" != "$actual_sha1" ]; then
|
||||
rm -f "$TARBALL"
|
||||
die "SHA-1 mismatch — the download is not what wordpress.org publishes.
|
||||
published $published
|
||||
got $actual_sha1"
|
||||
fi
|
||||
NEW_SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||
|
||||
# ------------------------------------------- import onto the upstream branch
|
||||
|
||||
IMPORT_TREE="$here/dist/.vendor-import"
|
||||
MERGE_TREE="$here/dist/.vendor-merge"
|
||||
MERGE_BRANCH="sirius-press/_merge-${VERSION}"
|
||||
|
||||
drop_worktree() {
|
||||
git -C "$repo" worktree remove --force "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
say "importing the pristine tree onto $UPSTREAM_BRANCH"
|
||||
drop_worktree "$IMPORT_TREE"
|
||||
rm -rf "$IMPORT_TREE"
|
||||
git -C "$repo" worktree add --quiet "$IMPORT_TREE" "$UPSTREAM_BRANCH"
|
||||
trap 'drop_worktree "$IMPORT_TREE"' EXIT
|
||||
|
||||
# Replace the tree wholesale: files upstream deleted have to disappear, or the
|
||||
# subtree merge would keep resurrecting them.
|
||||
find "$IMPORT_TREE" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
tar -xzf "$TARBALL" -C "$IMPORT_TREE" --strip-components=1
|
||||
|
||||
git -C "$IMPORT_TREE" add -A
|
||||
if git -C "$IMPORT_TREE" diff --cached --quiet; then
|
||||
# Already imported — a previous run got this far and then stopped. Carry on
|
||||
# to the merge rather than refusing, so a failure is retryable.
|
||||
say "WordPress $VERSION was already imported; continuing to the merge"
|
||||
else
|
||||
git -C "$IMPORT_TREE" \
|
||||
-c user.name="Silent Mode" -c user.email="hephaestus@silentmode.st" \
|
||||
commit -q -m "WordPress ${VERSION}
|
||||
|
||||
Pristine upstream, unpacked from the official wordpress.org tarball.
|
||||
|
||||
${URL}
|
||||
sha1 ${actual_sha1} (published by wordpress.org)
|
||||
sha256 ${NEW_SHA256}
|
||||
|
||||
This branch carries nothing but upstream releases, one commit each, and is
|
||||
never edited."
|
||||
say "imported as $(git -C "$IMPORT_TREE" rev-parse --short HEAD)"
|
||||
fi
|
||||
|
||||
drop_worktree "$IMPORT_TREE"
|
||||
trap - EXIT
|
||||
|
||||
# ------------------------------------------------------------ subtree merge
|
||||
|
||||
say "merging into ${prefix}/ (in a scratch worktree)"
|
||||
drop_worktree "$MERGE_TREE"
|
||||
rm -rf "$MERGE_TREE"
|
||||
git -C "$repo" branch -D "$MERGE_BRANCH" >/dev/null 2>&1 || true
|
||||
git -C "$repo" worktree add --quiet -b "$MERGE_BRANCH" "$MERGE_TREE" HEAD
|
||||
|
||||
if git -C "$MERGE_TREE" \
|
||||
-c user.name="Silent Mode" -c user.email="hephaestus@silentmode.st" \
|
||||
subtree merge --prefix="$prefix" "$UPSTREAM_BRANCH" \
|
||||
-m "merge: WordPress ${VERSION} into the vendored subtree"
|
||||
then
|
||||
say "merged cleanly"
|
||||
else
|
||||
cat >&2 <<EOF
|
||||
|
||||
$(printf '\033[31mThe merge did not complete.\033[0m')
|
||||
|
||||
Usually this means upstream changed code the fork also changes — in practice
|
||||
wp-admin/install.php, the only file this fork patches. Your own checkout is
|
||||
untouched and still on WordPress ${CURRENT}; the half-done merge is in a
|
||||
scratch worktree:
|
||||
|
||||
cd ${MERGE_TREE}
|
||||
git status # what conflicted
|
||||
\$EDITOR ${prefix}/wp-admin/install.php
|
||||
git add ${prefix}/wp-admin/install.php
|
||||
git commit # finish the merge
|
||||
|
||||
Then bring it back and finish up:
|
||||
|
||||
cd ${repo}
|
||||
git merge --no-ff ${MERGE_BRANCH}
|
||||
git worktree remove ${MERGE_TREE}
|
||||
git branch -d ${MERGE_BRANCH}
|
||||
${here}/tools/refresh-patches.sh
|
||||
# set WP_VERSION / WP_URL / WP_SHA256 / WP_SHA1 in tools/wordpress.lock
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bring the merge home. This touches only ${prefix}/, so unrelated modified
|
||||
# files elsewhere in the repository are not in its way.
|
||||
say "bringing the merge into the working branch"
|
||||
git -C "$repo" merge --no-ff -q \
|
||||
-m "merge: WordPress ${VERSION} into ${prefix}/" "$MERGE_BRANCH" \
|
||||
|| die "could not merge ${MERGE_BRANCH} into the working branch. It is still
|
||||
there, with the subtree merge already done — resolve and merge it by hand."
|
||||
|
||||
drop_worktree "$MERGE_TREE"
|
||||
git -C "$repo" branch -D "$MERGE_BRANCH" >/dev/null 2>&1 || true
|
||||
|
||||
# --------------------------------------------------------------- the lock
|
||||
|
||||
say "updating tools/wordpress.lock"
|
||||
python3 - "$VERSION" "$URL" "$NEW_SHA256" "$actual_sha1" "$here/tools/wordpress.lock" <<'PY'
|
||||
import io, sys
|
||||
version, url, sha256, sha1, path = sys.argv[1:6]
|
||||
fields = {
|
||||
'WP_VERSION=': version,
|
||||
'WP_URL=': url,
|
||||
'WP_SHA256=': sha256,
|
||||
'WP_SHA1=': sha1,
|
||||
}
|
||||
out = []
|
||||
for line in io.open(path, encoding='utf-8'):
|
||||
for key, value in fields.items():
|
||||
if line.startswith(key):
|
||||
line = f'{key}{value}\n'
|
||||
break
|
||||
out.append(line)
|
||||
io.open(path, 'w', encoding='utf-8', newline='').write(''.join(out))
|
||||
PY
|
||||
|
||||
if [ -x "$here/tools/refresh-patches.sh" ]; then
|
||||
say "refreshing patches/ so it still describes the fork's core diff"
|
||||
"$here/tools/refresh-patches.sh" >/dev/null || warn "could not refresh patches/ — do it by hand"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
$(printf '\033[32mSirius Press now builds on WordPress %s.\033[0m' "$VERSION")
|
||||
|
||||
${CURRENT} -> ${VERSION}
|
||||
|
||||
The subtree merge is committed. tools/wordpress.lock and patches/ are changed
|
||||
but not committed — look at them, then commit. Before pushing:
|
||||
|
||||
tests/run.sh
|
||||
tools/build.sh
|
||||
then walk through wp-admin/install.php once — the setup wizard is the only
|
||||
file this fork patches, so it is the only thing this can have broken.
|
||||
|
||||
EOF
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# The exact WordPress this fork is built against.
|
||||
#
|
||||
# Sirius Press does not vendor a copy of WordPress. It pins one, verifies it,
|
||||
# and patches it — see docs/upstream-merges.md for why, and for how to move
|
||||
# this pin when a security release lands.
|
||||
#
|
||||
# sha256 was computed from the official tarball after checking it against the
|
||||
# SHA-1 wordpress.org publishes alongside it. Both are recorded so a future
|
||||
# reader can re-do that check without trusting this file alone.
|
||||
|
||||
WP_VERSION=7.1.2
|
||||
WP_URL=https://wordpress.org/wordpress-7.1.2.tar.gz
|
||||
WP_SHA256=c0c666689d66b870d8825500bb8e402ed04de52602c61a6f516c6236b8c9ac67
|
||||
WP_SHA1=761b8101538f0631a0bfc4fba7bc4abeea92f81c
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue