Mbin joins the pasture; a magazine's own threads and locks are taken
Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.
What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
the magazine as its audience, never announced. A post whose group is
followed here and lives on the post's own server is now kept as if
announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
nothing about it; PrivaPub never decides by a server's software, so this
stays open as G-0008 for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
47d6e22988
commit
26346cde30
15 files changed
+493
-8
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# Mbin 1.10.1: the threadiverse in Symfony (magazines, threads, comments, microblog posts, votes up and down), from its
|
||||
# own image: FrankenPHP serving plain HTTP behind Caddy, and a messenger worker for its queues, on the shared Postgres
|
||||
# (database mbin) and Redis (db 12), with a RabbitMQ of its own (its transports carry AMQP options). Symfony's HTTP
|
||||
# client trusts the system bundle, so the pasture's is mounted over it. Its admin is mbuser, made by its console; its
|
||||
# API takes an OAuth2 token, which mbin_settle gets through the authorization-code flow as mbuser (a client-credentials
|
||||
# client acts as a bot, which may not vote).
|
||||
MBIN_IMAGE=${MBIN_IMAGE:-ghcr.io/mbinorg/mbin:v1.10.1}
|
||||
MBIN_RABBITMQ_IMAGE=${MBIN_RABBITMQ_IMAGE:-docker.io/library/rabbitmq:4-alpine}
|
||||
MBIN_PASSWORD=Mbin-Pasture-Pass-1
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
mbin_env() {
|
||||
local dir="$here/.state/mbin"
|
||||
[ -s "$dir/secret" ] || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/secret"
|
||||
[ -s "$dir/mercure" ] || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/mercure"
|
||||
[ -s "$dir/oauth-key" ] || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/oauth-key"
|
||||
cat <<ENV
|
||||
APP_ENV=prod
|
||||
APP_SECRET=$(cat "$dir/secret")
|
||||
MBIN_USER=root
|
||||
SERVER_NAME=:80
|
||||
KBIN_DOMAIN=mbin.test
|
||||
KBIN_TITLE=Pasture Mbin
|
||||
KBIN_DEFAULT_LANG=en
|
||||
KBIN_FEDERATION_ENABLED=true
|
||||
KBIN_CONTACT_EMAIL=contact@mbin.test
|
||||
KBIN_SENDER_EMAIL=noreply@mbin.test
|
||||
KBIN_JS_ENABLED=true
|
||||
KBIN_REGISTRATIONS_ENABLED=true
|
||||
KBIN_API_ITEMS_PER_PAGE=25
|
||||
KBIN_STORAGE_URL=https://mbin.test/media
|
||||
KBIN_CAPTCHA_ENABLED=false
|
||||
KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false
|
||||
MBIN_DOWNVOTES_MODE=enabled
|
||||
MBIN_NEW_USERS_NEED_APPROVAL=false
|
||||
MBIN_USE_FEDERATION_ALLOW_LIST=false
|
||||
DATABASE_URL=postgresql://pasture:pasture@postgres:5432/mbin?serverVersion=17&charset=utf8
|
||||
REDIS_DNS=redis://redis:6379/12
|
||||
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@pasture-mbin-rabbitmq:5672/%2f/messages
|
||||
MAILER_DSN=null://null
|
||||
MERCURE_URL=http://localhost/.well-known/mercure
|
||||
MERCURE_PUBLIC_URL=https://mbin.test/.well-known/mercure
|
||||
MERCURE_JWT_SECRET=$(cat "$dir/mercure")
|
||||
MERCURE_PUBLISHER_JWT_KEY=$(cat "$dir/mercure")
|
||||
MERCURE_SUBSCRIBER_JWT_KEY=$(cat "$dir/mercure")
|
||||
CORS_ALLOW_ORIGIN=^https?://mbin\.test$
|
||||
LOCK_DSN=flock
|
||||
TRUSTED_PROXIES=$subnet
|
||||
OAUTH_PRIVATE_KEY=/oauth2/private.pem
|
||||
OAUTH_PUBLIC_KEY=/oauth2/public.pem
|
||||
OAUTH_PASSPHRASE=$MBIN_PASSWORD
|
||||
OAUTH_ENCRYPTION_KEY=$(cat "$dir/oauth-key")
|
||||
ENV
|
||||
}
|
||||
|
||||
mbin_up() {
|
||||
shared_postgres_up
|
||||
shared_redis_up
|
||||
pg_db mbin
|
||||
mkdir -p "$here/.state/mbin/oauth2"
|
||||
if [ ! -s "$here/.state/mbin/oauth2/public.pem" ]; then
|
||||
openssl genrsa -aes256 -passout "pass:$MBIN_PASSWORD" -out "$here/.state/mbin/oauth2/private.pem" 4096 2>/dev/null
|
||||
openssl rsa -in "$here/.state/mbin/oauth2/private.pem" -passin "pass:$MBIN_PASSWORD" -pubout -out "$here/.state/mbin/oauth2/public.pem" 2>/dev/null
|
||||
chmod 644 "$here/.state/mbin/oauth2/"*.pem
|
||||
fi
|
||||
mbin_env > "$here/.state/mbin/env"
|
||||
# its API's own limits (two threads every six minutes) would throttle a scripted run: the same file, every limit raised
|
||||
podman run --rm --entrypoint cat "$MBIN_IMAGE" config/packages/rate_limiter.yaml \
|
||||
| sed -E 's/^( +limit:) [0-9]+$/\1 100000/' > "$here/.state/mbin/rate_limiter.yaml"
|
||||
# (as its own user on a volume it owns: started as root, it writes an .erlang.cookie it then cannot read)
|
||||
podman volume exists pasture-mbin-rabbitmq || podman volume create --label pasture=1 pasture-mbin-rabbitmq >/dev/null
|
||||
podman run -d --replace --name pasture-mbin-rabbitmq --network $net --label pasture=1 --user rabbitmq -v pasture-mbin-rabbitmq:/var/lib/rabbitmq:U \
|
||||
"$MBIN_RABBITMQ_IMAGE" >/dev/null
|
||||
for _ in $(seq 1 60); do podman exec pasture-mbin-rabbitmq rabbitmq-diagnostics -q ping >/dev/null 2>&1 && break; sleep 2; done
|
||||
podman volume exists pasture-mbin-media || podman volume create --label pasture=1 pasture-mbin-media >/dev/null
|
||||
local common=(--network $net --label pasture=1 --env-file "$here/.state/mbin/env" -v pasture-mbin-media:/app/public/media
|
||||
-v "$here/.state/mbin/oauth2:/oauth2:z,ro" -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro"
|
||||
-v "$here/.state/mbin/rate_limiter.yaml:/app/config/packages/rate_limiter.yaml:z,ro")
|
||||
podman run -d --replace --name pasture-mbin "${common[@]}" "$MBIN_IMAGE" >/dev/null
|
||||
# the web container runs the migrations as it starts; the worker waits for them
|
||||
for _ in $(seq 1 120); do
|
||||
podman logs pasture-mbin 2>&1 | grep -q "PHP app ready" && break
|
||||
sleep 2
|
||||
done
|
||||
podman run -d --replace --name pasture-mbin-worker "${common[@]}" "$MBIN_IMAGE" \
|
||||
php bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=86400 >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
site mbin.test -s -o /dev/null -w '%{http_code}' https://mbin.test:6443/api/instance 2>/dev/null | grep -q 200 && break
|
||||
sleep 2
|
||||
done
|
||||
mbin_settle
|
||||
echo "mbin: https://mbin.test:6443"
|
||||
}
|
||||
|
||||
mbin_console() { podman exec pasture-mbin php bin/console "$@"; }
|
||||
|
||||
# mbuser (admin, verified), the instance's keys, and mbuser's OAuth token from the authorization-code flow
|
||||
mbin_settle() {
|
||||
mbin_console mbin:ap:keys:update >/dev/null 2>&1 || true
|
||||
mbin_console mbin:user:create mbuser mbuser@mbin.test "$MBIN_PASSWORD" >/dev/null 2>&1 || true
|
||||
mbin_console mbin:user:admin mbuser >/dev/null 2>&1 || true
|
||||
mbin_console mbin:user:verify mbuser >/dev/null 2>&1 || true
|
||||
python3 "$here/peers/mbin_token.py" "$MBIN_PASSWORD" "$here/.state/mbin/client.json" > "$here/.state/mbin.token" 2>"$here/.state/mbin/token.log" || true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""mbuser's OAuth2 access token on the pasture's Mbin, through the authorization-code flow a person would follow: an
|
||||
OAuth client made through the API, mbuser signed in through the login form, consent given, the code exchanged.
|
||||
Prints the token. Usage: mbin_token.py <password> <file keeping the client>"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
HOST = "mbin.test"
|
||||
CA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".ca", "root.crt")
|
||||
REDIRECT = "https://pasture.invalid/callback"
|
||||
SCOPES = "read write delete subscribe block vote report user moderate"
|
||||
cookies = {}
|
||||
|
||||
|
||||
def request(method, path, body=None, headers=None):
|
||||
"""One request to Mbin through Caddy on 127.0.0.1:6443, named mbin.test (SNI and Host); the CA is the pasture's own"""
|
||||
context = ssl.create_default_context(cafile=CA)
|
||||
conn = http.client.HTTPSConnection(HOST, 6443, context=context, timeout=60)
|
||||
conn.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", 6443), timeout=60), server_hostname=HOST)
|
||||
# (Mbin names what it makes after the request's host: mbin.test, without the workstation's port)
|
||||
headers = dict(headers or {}, Host=HOST)
|
||||
if cookies:
|
||||
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
conn.request(method, path, body=body, headers=headers)
|
||||
response = conn.getresponse()
|
||||
data = response.read().decode("utf-8", "replace")
|
||||
for name, value in response.getheaders():
|
||||
if name.lower() == "set-cookie":
|
||||
key, _, rest = value.partition("=")
|
||||
cookies[key.strip()] = rest.split(";")[0]
|
||||
return response.status, dict((k.lower(), v) for k, v in response.getheaders()), data
|
||||
|
||||
|
||||
def follow(path):
|
||||
"""GETs path and follows redirects on Mbin, returning the last answer and where a redirect away from Mbin pointed"""
|
||||
for _ in range(10):
|
||||
status, headers, data = request("GET", path)
|
||||
location = headers.get("location")
|
||||
if status not in (301, 302, 303, 307, 308) or not location:
|
||||
return status, data, None
|
||||
if location.startswith(REDIRECT):
|
||||
return status, data, location
|
||||
parsed = urllib.parse.urlsplit(location)
|
||||
path = parsed.path + ("?" + parsed.query if parsed.query else "")
|
||||
raise SystemExit("too many redirects")
|
||||
|
||||
|
||||
def csrf(html):
|
||||
match = re.search(r'name="_csrf_token"\s+value="([^"]+)"', html)
|
||||
if not match:
|
||||
raise SystemExit("no CSRF token in the page")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def main(password, saved):
|
||||
# one client for every run (Mbin limits how many are made)
|
||||
client = json.load(open(saved)) if os.path.exists(saved) else None
|
||||
if client is None:
|
||||
status, _, data = request("POST", "/api/client", json.dumps({
|
||||
"name": "pasture", "contactEmail": "pasture@mbin.test", "description": "the pasture's scenarios", "public": False,
|
||||
"redirectUris": [REDIRECT], "grants": ["authorization_code", "refresh_token"], "scopes": SCOPES.split()
|
||||
}), {"Content-Type": "application/json"})
|
||||
if status >= 300:
|
||||
raise SystemExit(f"client: {status} {data[:300]}")
|
||||
client = json.loads(data)
|
||||
json.dump({"identifier": client["identifier"], "secret": client["secret"]}, open(saved, "w"))
|
||||
|
||||
status, _, page = request("GET", "/login")
|
||||
form = urllib.parse.urlencode({"email": "mbuser", "password": password, "_csrf_token": csrf(page)})
|
||||
status, headers, _ = request("POST", "/login", form, {"Content-Type": "application/x-www-form-urlencoded"})
|
||||
if status not in (302, 303) or "/login" in headers.get("location", ""):
|
||||
raise SystemExit(f"login refused: {status} {headers.get('location')}")
|
||||
|
||||
query = urllib.parse.urlencode({"response_type": "code", "client_id": client["identifier"], "redirect_uri": REDIRECT,
|
||||
"scope": SCOPES, "state": "pasture"})
|
||||
status, page, done = follow("/authorize?" + query)
|
||||
if done is None:
|
||||
consent = "/consent?" + query
|
||||
form = urllib.parse.urlencode({"consent": "yes", "_csrf_token": csrf(page)})
|
||||
status, headers, _ = request("POST", consent, form, {"Content-Type": "application/x-www-form-urlencoded"})
|
||||
location = headers.get("location", "")
|
||||
parsed = urllib.parse.urlsplit(location)
|
||||
status, page, done = follow(parsed.path + "?" + parsed.query)
|
||||
if done is None:
|
||||
raise SystemExit(f"no code: {status} {page[:300]}")
|
||||
code = urllib.parse.parse_qs(urllib.parse.urlsplit(done).query)["code"][0]
|
||||
|
||||
form = urllib.parse.urlencode({"grant_type": "authorization_code", "client_id": client["identifier"],
|
||||
"client_secret": client["secret"], "redirect_uri": REDIRECT, "code": code})
|
||||
status, _, data = request("POST", "/token", form, {"Content-Type": "application/x-www-form-urlencoded"})
|
||||
if status >= 300:
|
||||
raise SystemExit(f"token: {status} {data[:300]}")
|
||||
print(json.loads(data)["access_token"])
|
||||
|
||||
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
Reference in new issue
Block a user