zig pds
0

Configure Feed

Select the types of activity you want to include in your feed.

Point zds to zat.dev

+4 -8181
-42
Dockerfile
··· 1 - # syntax=docker/dockerfile:1.7 2 - 3 - FROM debian:bookworm-slim AS build 4 - 5 - RUN apt-get update \ 6 - && apt-get install -y --no-install-recommends ca-certificates curl git xz-utils libc6-dev \ 7 - && rm -rf /var/lib/apt/lists/* 8 - 9 - ARG ZIG_VERSION=0.16.0 10 - ARG ZIG_TARGET=x86_64-linux 11 - RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_TARGET}-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \ 12 - && mkdir -p /opt/zig \ 13 - && tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 \ 14 - && rm /tmp/zig.tar.xz 15 - 16 - ENV PATH="/opt/zig:${PATH}" 17 - ENV ZIG_GLOBAL_CACHE_DIR=/root/.cache/zig 18 - 19 - WORKDIR /work 20 - RUN git clone --depth=1 https://tangled.org/zat.dev/zat /work/zat.dev/zat 21 - 22 - WORKDIR /work/zzstoatzz.io/zds 23 - COPY . . 24 - RUN --mount=type=cache,target=/root/.cache/zig \ 25 - --mount=type=cache,target=/work/zzstoatzz.io/zds/.zig-cache \ 26 - zig build -Doptimize=ReleaseSafe 27 - 28 - FROM debian:bookworm-slim 29 - 30 - RUN apt-get update \ 31 - && apt-get install -y --no-install-recommends ca-certificates sqlite3 \ 32 - && rm -rf /var/lib/apt/lists/* 33 - 34 - WORKDIR /app 35 - COPY --from=build /work/zzstoatzz.io/zds/zig-out/bin/zds /usr/local/bin/zds 36 - 37 - ENV ZDS_HOST=0.0.0.0 38 - ENV ZDS_PORT=2583 39 - ENV ZDS_DB=/data/zds.sqlite3 40 - 41 - EXPOSE 2583 42 - CMD ["zds", "--host", "0.0.0.0", "--port", "2583", "--db", "/data/zds.sqlite3"]
+4 -120
README.md
··· 1 - # zds 2 - 3 - a zig implementation of an at protocol personal data server. 4 - 5 - > name credit: jim (`calabro.io`) suggested `zds`. 6 - 7 - `zds` stores atproto accounts, repos, records, blobs, sessions, and identity 8 - state. it uses [`zat`](../zat.dev/zat) for atproto primitives such as syntax, 9 - did resolution, jwt helpers, dag-cbor, car, mst, repo verification, and key 10 - encoding. 11 - 12 - ## references 13 - 14 - the implementation is informed by: 15 - 16 - - `tranquil.farm/tranquil-pds` 17 - - `futur.blue/pegasus` 18 - - `bluesky-social/pds` and `bluesky-social/atproto/packages/pds` 19 - - `alice.mosphere.at/atproto-smoke` 20 - - `chadtmiller.com/pds.js` 21 - - `zzstoatzz.io/pds-message-poc` 22 - - `zzstoatzz.io/pollz` 23 - - `haileyok/cocoon` 24 - 25 - see [docs/reference-study.md](docs/reference-study.md). 26 - 27 - ## run locally 28 - 29 - ```sh 30 - zig build test 31 - zig build run -- --port 2583 --db dev/zds.sqlite3 32 - caddy run --config dev/Caddyfile 33 - ``` 34 - 35 - before committing: 36 - 37 - ```sh 38 - zig build test 39 - tools/smoke.sh 40 - zig zen 41 - ``` 42 - 43 - ## endpoints 44 - 45 - current server surface includes: 46 - 47 - - `GET /` 48 - - `GET /.well-known/did.json` 49 - - `GET /.well-known/atproto-did` 50 - - `GET /xrpc/_health` 51 - - `GET /xrpc/com.atproto.server.describeServer` 52 - - `POST /xrpc/com.atproto.server.createAccount` 53 - - `POST /xrpc/com.atproto.server.createSession` 54 - - `POST /xrpc/com.atproto.server.refreshSession` 55 - - `GET /xrpc/com.atproto.server.getSession` 56 - - `GET /xrpc/com.atproto.server.getServiceAuth` 57 - - repo read/write/import/blob endpoints 58 - - sync repo/blob/status endpoints, including `subscribeRepos` 59 - - identity plc credential/sign/submit endpoints 60 - - generic XRPC service proxying for requests that include `atproto-proxy` 61 - 62 - blob uploads default to `100000000` bytes. set 63 - `ZDS_BLOB_UPLOAD_LIMIT` or `--blob-upload-limit` to change it. 64 - 65 - see [docs/smoke.md](docs/smoke.md) for the first `atproto-smoke` run. 66 - 67 - ## deploy 1 + # zds has moved 68 2 69 - run behind a public https origin: 3 + This repository has moved to [zat.dev/zds](https://tangled.org/zat.dev/zds). 70 4 71 - ```sh 72 - ZDS_RESEND_API_KEY=... \ 73 - ZDS_EMAIL_FROM='ZDS <pds@example.com>' \ 74 - ZDS_BLOB_UPLOAD_LIMIT=100000000 \ 75 - ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \ 76 - ZDS_HANDLE_DOMAINS='.example.com,example.com' \ 77 - ZDS_CRAWLERS='https://bsky.network,https://vsky.network' \ 78 - ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 79 - zig build run -- \ 80 - --port 2583 \ 81 - --db /var/lib/zds/zds.sqlite3 \ 82 - --public-url https://pds.example.com \ 83 - --server-did did:web:pds.example.com 5 + ```bash 6 + git clone https://tangled.org/zat.dev/zds 84 7 ``` 85 - 86 - configuration: 87 - 88 - - `--public-url`: public pds origin used in did credentials and blob urls 89 - - `--server-did`: pds service did, usually `did:web:<host>` 90 - - `ZDS_HANDLE_DOMAINS`: comma-separated handle domains advertised by 91 - `describeServer` 92 - - `ZDS_JWT_SECRET`: stable secret for access and refresh jwt signing 93 - - `ZDS_RESEND_API_KEY` and `ZDS_EMAIL_FROM`: email delivery for account and plc 94 - tokens 95 - - `ZDS_BLOB_UPLOAD_LIMIT`: generic `com.atproto.repo.uploadBlob` body limit 96 - - `ZDS_BLOBSTORE_PATH` or `--blobstore-path`: disk blobstore root 97 - - `ZDS_CRAWLERS`: comma-separated relay crawl targets 98 - 99 - ## account migration 100 - 101 - zds implements the pds endpoints used by browser-based migration tools such as 102 - [pds moover](https://pdsmoover.com/): 103 - 104 - - service-auth-gated `createAccount` for an existing did 105 - - repo car import/export 106 - - missing blob reporting and blob upload 107 - - recommended did credentials 108 - - plc operation email token, signing, and submission 109 - - account activation/deactivation and status checks 110 - 111 - see [docs/migration.md](docs/migration.md). 112 - 113 - ## storage 114 - 115 - sqlite stores accounts, records, repo blocks, commits, expected blobs, imported 116 - blob metadata, oauth requests, and auth tokens. uploaded blob bytes are stored 117 - in the disk blobstore rooted at `ZDS_BLOBSTORE_PATH`. 118 - 119 - ## next 120 - 121 - - password reset and account recovery flows 122 - - object-store blob backend 123 - - backup/restore docs for the sqlite database and blob store
-49
build.zig
··· 1 - const std = @import("std"); 2 - 3 - pub fn build(b: *std.Build) void { 4 - const target = b.standardTargetOptions(.{}); 5 - const optimize = b.standardOptimizeOption(.{}); 6 - 7 - const zat = b.dependency("zat", .{ 8 - .target = target, 9 - .optimize = optimize, 10 - }); 11 - const zqlite = b.dependency("zqlite", .{ 12 - .target = target, 13 - .optimize = optimize, 14 - }); 15 - 16 - const mod = b.addModule("zds", .{ 17 - .root_source_file = b.path("src/root.zig"), 18 - .target = target, 19 - .optimize = optimize, 20 - .imports = &.{ 21 - .{ .name = "zat", .module = zat.module("zat") }, 22 - .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 23 - }, 24 - }); 25 - 26 - const exe = b.addExecutable(.{ 27 - .name = "zds", 28 - .root_module = b.createModule(.{ 29 - .root_source_file = b.path("src/main.zig"), 30 - .target = target, 31 - .optimize = optimize, 32 - .imports = &.{.{ .name = "zds", .module = mod }}, 33 - }), 34 - }); 35 - b.installArtifact(exe); 36 - 37 - const run_cmd = b.addRunArtifact(exe); 38 - if (b.args) |args| run_cmd.addArgs(args); 39 - 40 - const run_step = b.step("run", "Run zds"); 41 - run_step.dependOn(&run_cmd.step); 42 - 43 - const tests = b.addTest(.{ .root_module = mod }); 44 - const run_tests = b.addRunArtifact(tests); 45 - 46 - const test_step = b.step("test", "Run unit tests"); 47 - test_step.dependOn(&exe.step); 48 - test_step.dependOn(&run_tests.step); 49 - }
-24
build.zig.zon
··· 1 - .{ 2 - .name = .zds, 3 - .version = "0.0.0", 4 - .fingerprint = 0x6ebabab1f62e1904, 5 - .minimum_zig_version = "0.16.0", 6 - .dependencies = .{ 7 - .zat = .{ 8 - .path = "../../zat.dev/zat", 9 - }, 10 - .zqlite = .{ 11 - .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#05a88d6758753e1c63fdd45b211dde2057094b0c", 12 - .hash = "zqlite-0.0.1-RWLaYz6bmAAT7E_jxopXf-j5Ea8VQldnxsd6TU8sa0Bb", 13 - }, 14 - }, 15 - .paths = .{ 16 - "build.zig", 17 - "build.zig.zon", 18 - "src", 19 - "docs", 20 - "tools", 21 - ".tangled", 22 - "README.md", 23 - }, 24 - }
-8
dev/Caddyfile
··· 1 - { 2 - skip_install_trust 3 - } 4 - 5 - localhost:3443 { 6 - tls internal 7 - reverse_proxy 127.0.0.1:2583 8 - }
-23
dev/atproto-smoke-dual.json
··· 1 - { 2 - "pdsUrl": "http://localhost:2583", 3 - "pdsHost": "localhost:2583", 4 - "publicApiUrl": "http://localhost:2583", 5 - "publicCheckTimeoutMs": 15000, 6 - "artifactsDir": "/tmp/zds-reference-study/atproto-smoke/.tmp/zds-artifacts", 7 - "browserExecutablePath": "/Users/nate/tangled.org/zzstoatzz.io/zds/dev/chromium-insecure.sh", 8 - "headless": true, 9 - "strictErrors": false, 10 - "publicChecks": false, 11 - "primary": { 12 - "handle": "alice.test", 13 - "loginIdentifier": "alice.test", 14 - "password": "password", 15 - "cleanupPostPrefixes": ["browser smoke"] 16 - }, 17 - "secondary": { 18 - "handle": "bob.test", 19 - "loginIdentifier": "bob.test", 20 - "password": "password", 21 - "cleanupPostPrefixes": ["browser smoke"] 22 - } 23 - }
-27
dev/chromium-insecure.sh
··· 1 - #!/bin/sh 2 - set -eu 3 - 4 - if [ -n "${PLAYWRIGHT_CHROMIUM_EXECUTABLE:-}" ]; then 5 - exec "$PLAYWRIGHT_CHROMIUM_EXECUTABLE" \ 6 - --user-data-dir=/tmp/zds-chromium-insecure-profile \ 7 - --disable-web-security \ 8 - --ignore-certificate-errors \ 9 - --allow-running-insecure-content \ 10 - --disable-features=BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults \ 11 - "$@" 12 - fi 13 - 14 - for exe in "$HOME"/Library/Caches/ms-playwright/chromium-*/chrome-mac-arm64/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing; do 15 - if [ -x "$exe" ]; then 16 - exec "$exe" \ 17 - --user-data-dir=/tmp/zds-chromium-insecure-profile \ 18 - --disable-web-security \ 19 - --ignore-certificate-errors \ 20 - --allow-running-insecure-content \ 21 - --disable-features=BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults \ 22 - "$@" 23 - fi 24 - done 25 - 26 - echo "Playwright Chromium executable not found; run: bunx playwright install chromium" >&2 27 - exit 127
-131
dev/migration-trace.mjs
··· 1 - #!/usr/bin/env node 2 - 3 - const oldHandle = mustEnv("OLD_HANDLE"); 4 - const password = mustEnv("OLD_PASSWORD"); 5 - const newEmail = mustEnv("NEW_EMAIL"); 6 - const newPds = process.env.NEW_PDS ?? "https://pds.zat.dev"; 7 - const newHandle = process.env.NEW_HANDLE ?? oldHandle; 8 - const sourcePdsOverride = process.env.SOURCE_PDS; 9 - 10 - function mustEnv(name) { 11 - const value = process.env[name]; 12 - if (!value) { 13 - console.error(`missing ${name}`); 14 - process.exit(2); 15 - } 16 - return value; 17 - } 18 - 19 - function cleanHandle(handle) { 20 - return handle.replace("@", "").trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, ""); 21 - } 22 - 23 - async function requestJson(label, method, baseUrl, nsid, { params, body, token } = {}) { 24 - const url = new URL(`/xrpc/${nsid}`, baseUrl); 25 - if (params) { 26 - for (const [key, value] of Object.entries(params)) { 27 - if (value != null) url.searchParams.set(key, value); 28 - } 29 - } 30 - const headers = { accept: "application/json" }; 31 - if (body !== undefined) headers["content-type"] = "application/json"; 32 - if (token) headers.authorization = `Bearer ${token}`; 33 - const started = Date.now(); 34 - const res = await fetch(url, { 35 - method, 36 - headers, 37 - body: body === undefined ? undefined : JSON.stringify(body), 38 - }); 39 - const text = await res.text(); 40 - let data = null; 41 - if (text) { 42 - try { 43 - data = JSON.parse(text); 44 - } catch { 45 - data = { raw: text.slice(0, 200) }; 46 - } 47 - } 48 - const error = data?.error ? ` error=${data.error}` : ""; 49 - console.log(`${label}: ${method} ${url} -> ${res.status} ${res.statusText}${error} (${Date.now() - started}ms)`); 50 - if (!res.ok) { 51 - const err = new Error(data?.message ?? `${res.status} ${res.statusText}`); 52 - err.status = res.status; 53 - err.data = data; 54 - throw err; 55 - } 56 - return data; 57 - } 58 - 59 - async function resolveMiniDoc(handle) { 60 - const url = new URL("https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc"); 61 - url.searchParams.set("identifier", handle); 62 - const res = await fetch(url); 63 - if (!res.ok) throw new Error(`resolveMiniDoc failed: ${res.status}`); 64 - return res.json(); 65 - } 66 - 67 - async function main() { 68 - const handle = cleanHandle(oldHandle); 69 - const mini = sourcePdsOverride 70 - ? { did: null, handle, pds: sourcePdsOverride } 71 - : await resolveMiniDoc(handle); 72 - 73 - console.log(`source handle=${handle}`); 74 - if (mini.did) console.log(`source did=${mini.did}`); 75 - console.log(`source pds=${mini.pds}`); 76 - console.log(`target pds=${newPds}`); 77 - console.log(`target handle=${newHandle}`); 78 - 79 - const oldSession = await requestJson("old login", "POST", mini.pds, "com.atproto.server.createSession", { 80 - body: { identifier: handle, password }, 81 - }); 82 - const did = oldSession.did; 83 - console.log(`old login ok did=${did} handle=${oldSession.handle}`); 84 - 85 - const newDesc = await requestJson("new describe", "GET", newPds, "com.atproto.server.describeServer"); 86 - console.log(`new pds did=${newDesc.did}`); 87 - 88 - const serviceAuth = await requestJson("old service auth", "GET", mini.pds, "com.atproto.server.getServiceAuth", { 89 - token: oldSession.accessJwt, 90 - params: { 91 - aud: newDesc.did, 92 - lxm: "com.atproto.server.createAccount", 93 - }, 94 - }); 95 - console.log("old service auth ok"); 96 - 97 - try { 98 - await requestJson("new repo status before create", "GET", newPds, "com.atproto.sync.getRepoStatus", { 99 - params: { did }, 100 - }); 101 - } catch (err) { 102 - console.log(`new repo status before create expected miss: ${err.status} ${err.data?.error ?? err.message}`); 103 - } 104 - 105 - const created = await requestJson("new create account", "POST", newPds, "com.atproto.server.createAccount", { 106 - token: serviceAuth.token, 107 - body: { 108 - did, 109 - handle: newHandle, 110 - email: newEmail, 111 - password, 112 - }, 113 - }); 114 - console.log(`new create account ok did=${created.did} handle=${created.handle}`); 115 - 116 - const byDid = await requestJson("new login by did", "POST", newPds, "com.atproto.server.createSession", { 117 - body: { identifier: did, password }, 118 - }); 119 - console.log(`new login by did ok active=${byDid.active}`); 120 - 121 - const byHandle = await requestJson("new login by handle", "POST", newPds, "com.atproto.server.createSession", { 122 - body: { identifier: newHandle, password }, 123 - }); 124 - console.log(`new login by handle ok active=${byHandle.active}`); 125 - } 126 - 127 - main().catch((err) => { 128 - console.error(`trace failed: ${err.message}`); 129 - if (err.data) console.error(JSON.stringify(err.data, null, 2)); 130 - process.exit(1); 131 - });
-101
docs/migration.md
··· 1 - # migration 2 - 3 - `zds` implements the pds endpoints used by browser-based account migration 4 - tools, including pds moover. 5 - 6 - ## pds moover flow 7 - 8 - pds moover asks for: 9 - 10 - - current handle and password 11 - - new pds url 12 - - new email 13 - - new handle 14 - - invite code 15 - 16 - the migration is not complete until the user enters a plc token delivered by 17 - email. 18 - 19 - ## implemented endpoints 20 - 21 - implemented: 22 - 23 - - `com.atproto.server.createAccount` 24 - - `com.atproto.server.checkAccountStatus` 25 - - `com.atproto.server.activateAccount` 26 - - `com.atproto.server.deactivateAccount` 27 - - `com.atproto.server.refreshSession` 28 - - `com.atproto.repo.importRepo` 29 - - `com.atproto.repo.listMissingBlobs` 30 - - `com.atproto.sync.getRepo` 31 - - `com.atproto.sync.listBlobs` 32 - - `com.atproto.sync.getRepoStatus` 33 - - `com.atproto.identity.getRecommendedDidCredentials` 34 - - `com.atproto.identity.requestPlcOperationSignature` 35 - - `com.atproto.identity.signPlcOperation` 36 - - `com.atproto.identity.submitPlcOperation` 37 - - `app.bsky.actor.getPreferences` 38 - - `app.bsky.actor.putPreferences` 39 - 40 - implementation details: 41 - 42 - - `createAccount` verifies migration service jwts against the migrating did 43 - - session tokens are signed with the configured `ZDS_JWT_SECRET` 44 - - stored passwords use salted pbkdf2-sha256 hashes 45 - - repo writes produce signed commit blocks and mst updates 46 - - imported repo cars are verified against the account did and signing key 47 - - each account has its own signing key for repo commits and plc credentials 48 - - `uploadBlob` defaults to 100,000,000 bytes and is configurable with 49 - `ZDS_BLOB_UPLOAD_LIMIT` or `--blob-upload-limit` 50 - - uploaded blob bytes are stored in the disk blobstore rooted at 51 - `ZDS_BLOBSTORE_PATH` or `--blobstore-path` 52 - - the server serves `/.well-known/did.json` for the pds service did and 53 - `/.well-known/atproto-did` for hosted handles 54 - 55 - ## not yet implemented 56 - 57 - - real sequenced account/identity events 58 - - password reset and account recovery paths 59 - - object-store blob backend with backup/restore tooling 60 - 61 - ## deployment 62 - 63 - run behind a public https origin: 64 - 65 - ```sh 66 - ZDS_RESEND_API_KEY=... \ 67 - ZDS_EMAIL_FROM='ZDS <pds@example.com>' \ 68 - ZDS_BLOB_UPLOAD_LIMIT=100000000 \ 69 - ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \ 70 - ZDS_HANDLE_DOMAINS='.example.com,example.com' \ 71 - ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 72 - zig build run -- \ 73 - --port 2583 \ 74 - --db /var/lib/zds/zds.sqlite3 \ 75 - --public-url https://pds.example.com \ 76 - --server-did did:web:pds.example.com 77 - ``` 78 - 79 - the public url is used for did credentials and blob views. `ZDS_RESEND_API_KEY` 80 - and `ZDS_EMAIL_FROM` enable real email delivery; without them, token emails are 81 - logged to stdout for local development. `ZDS_BLOB_UPLOAD_LIMIT` controls the 82 - generic `com.atproto.repo.uploadBlob` request body ceiling. 83 - `ZDS_BLOBSTORE_PATH` controls where uploaded blob bytes are stored. 84 - `ZDS_HANDLE_DOMAINS` is what `describeServer` advertises to clients. 85 - `ZDS_JWT_SECRET` signs app-session access and refresh jwts; keep it stable 86 - across deploys or existing sessions will be invalidated. 87 - 88 - the at protocol uploadBlob lexicon does not define one universal byte limit; it 89 - says blob restrictions are enforced when a record references the uploaded blob. 90 - the official pds exposes a configurable blob upload limit, while the bluesky 91 - video embed lexicon allows 100,000,000-byte mp4 blobs. `zds` uses that as the 92 - default generic upload ceiling so migration tests do not fail below bluesky's 93 - current video shape. 94 - 95 - deployment also needs sqlite data-directory backups and operational access to 96 - inspect account status, repo import, missing blobs, and plc submission failures. 97 - 98 - core atproto account, repo, sync, and identity code stays separate from app-view 99 - services. app-view XRPCs are reached through the generic `atproto-proxy` path; 100 - PDS-owned app state such as `app.bsky.actor.getPreferences` is stored locally 101 - because that is how the reference PDS and Tranquil treat it.
-139
docs/reference-study.md
··· 1 - # Reference Study 2 - 3 - ## Tranquil PDS 4 - 5 - Tranquil is the "full community PDS" reference. It has a broad Rust workspace, 6 - Postgres storage, passkeys, 2FA, scoped OAuth, delegation, account management, 7 - repo import, sync verification, and a large test surface. 8 - 9 - Things worth borrowing: 10 - 11 - - repo writes are serialized per actor 12 - - sync/firehose is a first-class subsystem 13 - - storage concerns are separated from API handlers 14 - - conformance and security tests are treated as core code 15 - 16 - Things to defer: 17 - 18 - - product/admin UI 19 - - multi-channel comms 20 - - delegation and advanced account management 21 - 22 - ## Pegasus 23 - 24 - Pegasus is the cleanest architectural reference for a Zig implementation. Its 25 - OCaml repo separates IPLD, MST, crypto, XRPC, and PDS logic into clear libraries. 26 - The PDS itself can stay small because the protocol primitives are real modules. 27 - 28 - Things worth borrowing: 29 - 30 - - separate IPLD/CAR/DAG-CBOR from repository logic 31 - - keep MST storage swappable 32 - - keep XRPC parsing/auth/rate-limit boundaries explicit 33 - - start with SQLite-shaped local storage 34 - 35 - ## Bluesky PDS 36 - 37 - `bluesky-social/pds` is mostly distribution and operations. The implementation 38 - reference lives in `bluesky-social/atproto/packages/pds`. 39 - 40 - Things worth borrowing: 41 - 42 - - endpoint behavior and compatibility expectations 43 - - record preparation rules 44 - - sequencer shape for `subscribeRepos` 45 - - account, actor, and sequencer store separation 46 - 47 - ## atproto-smoke 48 - 49 - `atproto-smoke` is not a PDS implementation. It is a future acceptance-test 50 - reference because it exercises real `bsky.app` behavior against arbitrary PDSes. 51 - 52 - Things worth borrowing: 53 - 54 - - bring-your-own credentials as the first test mode 55 - - artifact-heavy failures 56 - - social flows as end-to-end compatibility checks 57 - 58 - ## pds.js 59 - 60 - `pds.js` is the useful compact implementation reference. Its endpoint comparison 61 - also gives a pragmatic MVP map. 62 - 63 - Things worth borrowing: 64 - 65 - - hexagonal port shape: core logic, actor storage, shared storage, blobs, 66 - WebSocket/firehose 67 - - minimal endpoint set: repo writes, sync reads, auth sessions, blobs, generic 68 - service proxy 69 - - endpoint coverage docs as a living checklist 70 - 71 - ## pds-message-poc 72 - 73 - `pds-message-poc` is cloned as a sibling repo at 74 - `/Users/nate/tangled.org/zzstoatzz.io/pds-message-poc`. It demonstrates two 75 - pds.js deployments exchanging custom `xyz.fake.inbox.*` messages. 76 - 77 - Things worth borrowing: 78 - 79 - - `createSession` only needs `did`, `handle`, and `accessJwt` for the first 80 - browser client doorway 81 - - service auth (`com.atproto.server.getServiceAuth`) is the next useful auth 82 - surface after basic app sessions 83 - - custom XRPC endpoints can be a good early proving ground for PDS-to-PDS 84 - behavior before the full Bluesky app surface exists 85 - 86 - ## pollz 87 - 88 - `pollz` is cloned as a sibling repo at 89 - `/Users/nate/tangled.org/zzstoatzz.io/pollz`. It is a Zig + SvelteKit ATProto app 90 - with OAuth, SQLite, Jetstream, and PDS writes. 91 - 92 - Things worth borrowing: 93 - 94 - - `zat.oauth` for PKCE, DPoP proofs, private-key client assertions, token 95 - exchange, and token refresh 96 - - `zat.json` for dynamic JSON navigation instead of local nested object walking 97 - - `zat.HandleResolver` and `zat.DidResolver` for handle -> DID -> PDS discovery 98 - - the shape of authenticated PDS requests using DPoP-bound access tokens 99 - 100 - ## notes 101 - 102 - The sibling `notes` repo is the working protocol notebook. The PDS-relevant 103 - takeaways are: 104 - 105 - - XRPC should preserve HTTP status, ATProto error names, human messages, raw 106 - bodies, and rate-limit headers 107 - - repo paths are `<collection>/<rkey>` where collection is an NSID and rkey is 108 - user-controlled 109 - - commits are per-repo ordered transitions, not isolated record writes 110 - - `subscribeRepos` producers need strict frame fields such as `tooBig: false` 111 - and `since: null | tid`, never an empty string 112 - - sync verification is an inductive chain over previous `rev` and MST root 113 - 114 - ## zat 115 - 116 - The old `zzstoatzz.io/zat` repo has moved to `zat.dev/zat`. This is the Zig 117 - ATProto substrate: 118 - 119 - - syntax primitives: TID, DID, handle, NSID, rkey, AT-URI 120 - - identity resolution 121 - - XRPC client helpers 122 - - JSON path helpers 123 - - OAuth helpers: PKCE, DPoP, client assertions, JWKS, form encoding 124 - - DAG-CBOR, CAR, MST, repo verification 125 - - firehose and jetstream clients 126 - 127 - `zds` should depend on `zat` instead of reimplementing these pieces. The PDS 128 - work should start one layer above: account/session state, repo write policy, 129 - storage, server routes, sync production, and compatibility behavior. 130 - 131 - ## Initial zds Bias 132 - 133 - Start like Pegasus and pds.js, verify like atproto-smoke, and treat Bluesky PDS 134 - as the compatibility oracle. Tranquil is the reminder that the design should not 135 - paint us into a corner once the project grows past the toy phase. 136 - 137 - Because `zat` already owns the protocol primitives, `zds` can be smaller and 138 - more opinionated: a PDS built on those primitives rather than another general 139 - ATProto SDK.
-80
docs/smoke.md
··· 1 - # Smoke Testing 2 - 3 - `atproto-smoke` is cloned at: 4 - 5 - ```text 6 - /tmp/zds-reference-study/atproto-smoke 7 - ``` 8 - 9 - ## 2026-05-20 Run 10 - 11 - Local server: 12 - 13 - ```sh 14 - zig build run -- --port 2583 15 - ``` 16 - 17 - Local HTTPS proxy, modeled after `pds.js`: 18 - 19 - ```sh 20 - caddy run --config dev/Caddyfile 21 - ``` 22 - 23 - The Caddy install path on macOS is the documented Homebrew path: 24 - 25 - ```sh 26 - brew install caddy 27 - ``` 28 - 29 - Probe results: 30 - 31 - - `GET http://127.0.0.1:2583/xrpc/_health` returns `200` 32 - - `GET http://127.0.0.1:2583/xrpc/com.atproto.server.describeServer` returns 33 - `200` 34 - - `OPTIONS http://127.0.0.1:2583/xrpc/com.atproto.server.describeServer` 35 - returns `204` with CORS and private-network access headers 36 - - `GET https://localhost:3443/xrpc/_health` returns `200` through Caddy with 37 - `curl -k` 38 - - `POST http://127.0.0.1:2583/xrpc/com.atproto.server.createSession` returns 39 - `200` for `alice.test` and `bob.test` with password `password` 40 - - `GET http://127.0.0.1:2583/xrpc/com.atproto.server.getSession` returns 41 - `200` for the dev access token minted by `createSession` 42 - - `GET http://127.0.0.1:2583/xrpc/com.atproto.server.getServiceAuth` returns 43 - `200` with an ES256 service-auth JWT for an authenticated dev session 44 - 45 - Smoke command: 46 - 47 - ```sh 48 - cd /tmp/zds-reference-study/atproto-smoke 49 - bun install 50 - bunx playwright install chromium 51 - bunx tsx bin/atproto-smoke.ts validate --mode dual --config /Users/nate/tangled.org/zzstoatzz.io/zds/dev/atproto-smoke-dual.json 52 - bunx tsx src/browser/run-dual.ts /Users/nate/tangled.org/zzstoatzz.io/zds/dev/atproto-smoke-dual.json 53 - ``` 54 - 55 - Result: 56 - 57 - ```text 58 - [ok] primary-login 59 - [ok] primary-age-assurance 60 - [ok] secondary-login 61 - [ok] secondary-age-assurance 62 - [fail] primary-preclean-stale-artifacts listRecords failed for alice.test collection app.bsky.feed.post: 404 63 - ``` 64 - 65 - `bsky.app` treats a custom `localhost:port` service as a plain HTTP dev PDS. 66 - The smoke config therefore uses `http://localhost:2583` for the app login flow. 67 - The Caddy HTTPS proxy remains available at `https://localhost:3443` for clients 68 - that need local TLS termination. 69 - 70 - The Playwright browser wrapper in `dev/chromium-insecure.sh` is intentionally 71 - smoke-only. It disables browser web-security checks so a public origin 72 - (`https://bsky.app`) can call a loopback development service. 73 - 74 - ## Next Compatibility Step 75 - 76 - Implement the first repo read endpoint: 77 - `com.atproto.repo.listRecords?collection=app.bsky.feed.post`. The smoke suite 78 - now reaches this after successful session creation for both configured accounts. 79 - For the message POC direction, the next step is implementing the custom 80 - `xyz.fake.inbox.*` endpoints around the new `getServiceAuth` surface.
-31
fly.toml
··· 1 - app = "zds-pds" 2 - primary_region = "ord" 3 - 4 - [build] 5 - dockerfile = "Dockerfile" 6 - 7 - [env] 8 - ZDS_HOST = "0.0.0.0" 9 - ZDS_DB = "/data/zds.sqlite3" 10 - ZDS_PUBLIC_URL = "https://pds.zat.dev" 11 - ZDS_SERVER_DID = "did:web:pds.zat.dev" 12 - ZDS_HANDLE_DOMAINS = ".pds.zat.dev,pds.zat.dev" 13 - ZDS_CRAWLERS = "https://bsky.network,https://vsky.network" 14 - ZDS_BLOB_UPLOAD_LIMIT = "100000000" 15 - ZDS_BLOBSTORE_PATH = "/data/blobs" 16 - 17 - [[mounts]] 18 - source = "zds_data" 19 - destination = "/data" 20 - 21 - [http_service] 22 - internal_port = 2583 23 - force_https = true 24 - auto_stop_machines = "off" 25 - auto_start_machines = true 26 - min_machines_running = 1 27 - 28 - [[vm]] 29 - memory = "1gb" 30 - cpu_kind = "shared" 31 - cpus = 1
-364
src/atproto/identity.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const config = @import("../core/config.zig"); 4 - const mail = @import("../core/mail.zig"); 5 - const http_api = @import("../http/api.zig"); 6 - const store = @import("../storage/store.zig"); 7 - const zat = @import("zat"); 8 - 9 - const http = std.http; 10 - 11 - pub fn getRecommendedDidCredentials(request: *http.Server.Request) !void { 12 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 13 - defer arena.deinit(); 14 - const allocator = arena.allocator(); 15 - 16 - const account = requireAccount(request, allocator) catch return; 17 - var keypair = try store.signingKeypair(account.did); 18 - const did_key = try keypair.did(allocator); 19 - const body = try std.fmt.allocPrint( 20 - allocator, 21 - "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", 22 - .{ 23 - account.handle, 24 - std.json.fmt(did_key, .{}), 25 - std.json.fmt(did_key, .{}), 26 - std.json.fmt(config.publicUrl(), .{}), 27 - }, 28 - ); 29 - return http_api.json(request, .ok, body); 30 - } 31 - 32 - pub fn requestPlcOperationSignature(request: *http.Server.Request) !void { 33 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 34 - defer arena.deinit(); 35 - const allocator = arena.allocator(); 36 - const account = requireAccount(request, allocator) catch return; 37 - const info = store.getEmailInfo(allocator, account.did) orelse { 38 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 39 - }; 40 - var code_buf: [11]u8 = undefined; 41 - const code = makeCode(&code_buf); 42 - try store.setAuthCode(account.did, code, store.nowMs() + (10 * 60 * 1000)); 43 - try mail.sendCode(store.currentIo(), allocator, info.email, account.handle, "PLC operation", "PLC operation", code); 44 - return http_api.json(request, .ok, "{}"); 45 - } 46 - 47 - pub fn signPlcOperation(request: *http.Server.Request) !void { 48 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 49 - defer arena.deinit(); 50 - const allocator = arena.allocator(); 51 - 52 - const account = requireAccount(request, allocator) catch return; 53 - const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); 54 - const parsed = try http_api.parseJsonBody(request, allocator, body); 55 - const input = switch (parsed.value) { 56 - .object => |object| object, 57 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), 58 - }; 59 - const token = switch (input.get("token") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email confirmation token required to sign PLC operations")) { 60 - .string => |string| string, 61 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email confirmation token required to sign PLC operations"), 62 - }; 63 - switch (store.validateAuthCode(account.did, token, store.nowMs())) { 64 - .valid => {}, 65 - .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), 66 - .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), 67 - } 68 - 69 - const last_op = fetchLastPlcOp(allocator, account.did) catch { 70 - return http_api.xrpcError(request, .bad_gateway, "PlcFetchFailed", "failed to fetch current PLC operation"); 71 - }; 72 - if (jsonObjectStringEquals(last_op, "type", "plc_tombstone")) { 73 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID is tombstoned"); 74 - } 75 - 76 - const prev = try cidForJsonValue(allocator, last_op); 77 - const unsigned_json = try writeUnsignedPlcOperationJson(allocator, last_op, input, prev); 78 - const unsigned_parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); 79 - const unsigned_cbor = try jsonToCbor(allocator, unsigned_parsed.value); 80 - const encoded = try zat.cbor.encodeAlloc(allocator, unsigned_cbor); 81 - var keypair = try store.signingKeypair(account.did); 82 - const sig = try keypair.sign(encoded); 83 - const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); 84 - try store.clearAuthCode(account.did); 85 - 86 - var response_writer: std.Io.Writer.Allocating = .init(allocator); 87 - defer response_writer.deinit(); 88 - try response_writer.writer.writeAll("{\"operation\":"); 89 - try response_writer.writer.writeAll(unsigned_json[0 .. unsigned_json.len - 1]); 90 - try response_writer.writer.print(",\"sig\":{f}}}", .{std.json.fmt(sig_text, .{})}); 91 - const response = try response_writer.toOwnedSlice(); 92 - return http_api.json(request, .ok, response); 93 - } 94 - 95 - pub fn submitPlcOperation(request: *http.Server.Request) !void { 96 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 97 - defer arena.deinit(); 98 - const allocator = arena.allocator(); 99 - 100 - const account = requireAccount(request, allocator) catch return; 101 - const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); 102 - const parsed = try http_api.parseJsonBody(request, allocator, body); 103 - const operation = switch (parsed.value) { 104 - .object => |object| object.get("operation") orelse { 105 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing operation"); 106 - }, 107 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), 108 - }; 109 - try validatePlcOperation(request, allocator, account, operation); 110 - 111 - const op_json = try stringifyJson(allocator, operation); 112 - const escaped_did = try percentEncodeDid(allocator, account.did); 113 - var url_buf: [320]u8 = undefined; 114 - const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}", .{ config.plcDirectory(), escaped_did }); 115 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 116 - defer transport.deinit(); 117 - const result = transport.fetch(.{ 118 - .url = url, 119 - .method = .POST, 120 - .payload = op_json, 121 - .content_type = "application/json", 122 - .max_response_size = 64 * 1024, 123 - }) catch { 124 - return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "failed to submit PLC operation"); 125 - }; 126 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) { 127 - return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected operation"); 128 - } 129 - return http_api.json(request, .ok, "{}"); 130 - } 131 - 132 - pub fn resolveHandle(request: *http.Server.Request) !void { 133 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 134 - defer arena.deinit(); 135 - const allocator = arena.allocator(); 136 - 137 - var handle_buf: [256]u8 = undefined; 138 - const handle_param = http_api.queryParam(request.head.target, "handle", &handle_buf) orelse { 139 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); 140 - }; 141 - const handle = std.mem.trim(u8, handle_param, &std.ascii.whitespace); 142 - if (handle.len == 0) { 143 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); 144 - } 145 - 146 - const did = if (store.resolveRepo(handle)) |account| 147 - account.did 148 - else if (std.ascii.eqlIgnoreCase(handle, "mod-authority.test")) 149 - "did:plc:ar7c4by46qjdydhdevvrndac" 150 - else did: { 151 - const parsed_handle = zat.Handle.parse(handle) orelse { 152 - return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle format"); 153 - }; 154 - var resolver = zat.HandleResolver.init(store.currentIo(), allocator); 155 - defer resolver.deinit(); 156 - break :did resolver.resolve(parsed_handle) catch { 157 - return http_api.xrpcError(request, .not_found, "HandleNotFound", "Unable to resolve handle"); 158 - }; 159 - }; 160 - 161 - const body = try std.fmt.allocPrint(allocator, "{{\"did\":{f}}}", .{std.json.fmt(did, .{})}); 162 - return http_api.json(request, .ok, body); 163 - } 164 - 165 - fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 166 - return http_api.requireBearerAccount(request, allocator) catch |err| { 167 - switch (err) { 168 - error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 169 - error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 170 - } 171 - return error.HandledResponse; 172 - }; 173 - } 174 - 175 - fn validatePlcOperation( 176 - request: *http.Server.Request, 177 - allocator: std.mem.Allocator, 178 - account: auth.Account, 179 - operation: std.json.Value, 180 - ) !void { 181 - var keypair = try store.signingKeypair(account.did); 182 - const did_key = try keypair.did(allocator); 183 - const object = switch (operation) { 184 - .object => |object| object, 185 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid operation"), 186 - }; 187 - if (!jsonArrayContainsString(object.get("rotationKeys"), did_key)) { 188 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key"); 189 - } 190 - const verification_methods = object.get("verificationMethods") orelse { 191 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing verificationMethods"); 192 - }; 193 - if (!jsonObjectStringEquals(verification_methods, "atproto", did_key)) { 194 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect signing key"); 195 - } 196 - if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}))) { 197 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect handle in alsoKnownAs"); 198 - } 199 - const services = object.get("services") orelse { 200 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing services"); 201 - }; 202 - const atproto_pds = switch (services) { 203 - .object => |services_object| services_object.get("atproto_pds") orelse { 204 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing atproto_pds service"); 205 - }, 206 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid services"), 207 - }; 208 - if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or 209 - !jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl())) 210 - { 211 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect atproto_pds service"); 212 - } 213 - } 214 - 215 - fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 216 - var out: std.Io.Writer.Allocating = .init(allocator); 217 - defer out.deinit(); 218 - try out.writer.print("{f}", .{std.json.fmt(value, .{})}); 219 - return out.toOwnedSlice(); 220 - } 221 - 222 - fn fetchLastPlcOp(allocator: std.mem.Allocator, did: []const u8) !std.json.Value { 223 - const escaped_did = try percentEncodeDid(allocator, did); 224 - var url_buf: [340]u8 = undefined; 225 - const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}/log/last", .{ config.plcDirectory(), escaped_did }); 226 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 227 - defer transport.deinit(); 228 - const result = try transport.fetch(.{ 229 - .url = url, 230 - .method = .GET, 231 - .accept = "application/json", 232 - .max_response_size = 1024 * 1024, 233 - }); 234 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.PlcFetchFailed; 235 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 236 - return parsed.value; 237 - } 238 - 239 - fn writeUnsignedPlcOperationJson( 240 - allocator: std.mem.Allocator, 241 - last_op: std.json.Value, 242 - input: std.json.ObjectMap, 243 - prev: []const u8, 244 - ) ![]const u8 { 245 - const last = switch (last_op) { 246 - .object => |object| object, 247 - else => return error.InvalidPlcOperation, 248 - }; 249 - var out: std.Io.Writer.Allocating = .init(allocator); 250 - defer out.deinit(); 251 - try out.writer.writeAll("{\"type\":\"plc_operation\""); 252 - try writePlcField(allocator, &out.writer, "verificationMethods", input, last); 253 - try writePlcField(allocator, &out.writer, "rotationKeys", input, last); 254 - try writePlcField(allocator, &out.writer, "alsoKnownAs", input, last); 255 - try writePlcField(allocator, &out.writer, "services", input, last); 256 - try out.writer.print(",\"prev\":{f}}}", .{std.json.fmt(prev, .{})}); 257 - return out.toOwnedSlice(); 258 - } 259 - 260 - fn writePlcField( 261 - allocator: std.mem.Allocator, 262 - writer: anytype, 263 - name: []const u8, 264 - input: std.json.ObjectMap, 265 - last: std.json.ObjectMap, 266 - ) !void { 267 - const value = input.get(name) orelse last.get(name) orelse return error.InvalidPlcOperation; 268 - try writer.print(",{f}:{s}", .{ std.json.fmt(name, .{}), try stringifyJson(allocator, value) }); 269 - } 270 - 271 - fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 272 - const cbor_value = try jsonToCbor(allocator, value); 273 - const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); 274 - const cid = try zat.cbor.Cid.forDagCbor(allocator, encoded); 275 - return zat.multibase.base32lower.encode(allocator, cid.raw); 276 - } 277 - 278 - fn jsonToCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { 279 - return switch (value) { 280 - .null => .null, 281 - .bool => |boolean| .{ .boolean = boolean }, 282 - .integer => |integer| if (integer >= 0) 283 - .{ .unsigned = @intCast(integer) } 284 - else 285 - .{ .negative = integer }, 286 - .float => return error.InvalidDagCbor, 287 - .number_string => return error.InvalidDagCbor, 288 - .string => |string| .{ .text = string }, 289 - .array => |array| blk: { 290 - const items = try allocator.alloc(zat.cbor.Value, array.items.len); 291 - for (array.items, 0..) |item, idx| items[idx] = try jsonToCbor(allocator, item); 292 - break :blk .{ .array = items }; 293 - }, 294 - .object => |object| blk: { 295 - const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); 296 - var it = object.iterator(); 297 - var idx: usize = 0; 298 - while (it.next()) |entry| : (idx += 1) { 299 - entries[idx] = .{ 300 - .key = entry.key_ptr.*, 301 - .value = try jsonToCbor(allocator, entry.value_ptr.*), 302 - }; 303 - } 304 - break :blk .{ .map = entries }; 305 - }, 306 - }; 307 - } 308 - 309 - fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 310 - var out: std.Io.Writer.Allocating = .init(allocator); 311 - defer out.deinit(); 312 - for (did) |c| switch (c) { 313 - ':' => try out.writer.writeAll("%3A"), 314 - else => try out.writer.writeByte(c), 315 - }; 316 - return out.toOwnedSlice(); 317 - } 318 - 319 - fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool { 320 - const actual = switch (value) { 321 - .object => |object| switch (object.get(key) orelse return false) { 322 - .string => |string| string, 323 - else => return false, 324 - }, 325 - else => return false, 326 - }; 327 - return std.mem.eql(u8, actual, expected); 328 - } 329 - 330 - fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { 331 - const items = switch (value orelse return false) { 332 - .array => |array| array.items, 333 - else => return false, 334 - }; 335 - for (items) |item| switch (item) { 336 - .string => |string| if (std.mem.eql(u8, string, expected)) return true, 337 - else => {}, 338 - }; 339 - return false; 340 - } 341 - 342 - fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool { 343 - const items = switch (value orelse return false) { 344 - .array => |array| array.items, 345 - else => return false, 346 - }; 347 - if (items.len == 0) return false; 348 - return switch (items[0]) { 349 - .string => |string| std.mem.eql(u8, string, expected), 350 - else => false, 351 - }; 352 - } 353 - 354 - fn makeCode(out: *[11]u8) []const u8 { 355 - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; 356 - var random: [10]u8 = undefined; 357 - store.randomBytes(&random); 358 - for (random, 0..) |byte, idx| { 359 - const write_idx = if (idx < 5) idx else idx + 1; 360 - out[write_idx] = alphabet[byte % alphabet.len]; 361 - } 362 - out[5] = '-'; 363 - return out[0..]; 364 - }
-705
src/atproto/oauth.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const config = @import("../core/config.zig"); 4 - const http_api = @import("../http/api.zig"); 5 - const store = @import("../storage/store.zig"); 6 - const zat = @import("zat"); 7 - 8 - const http = std.http; 9 - const request_uri_prefix = "urn:ietf:params:oauth:request_uri:"; 10 - const par_expires_in: i64 = 600; 11 - const token_expires_in: i64 = 3600; 12 - 13 - const ClientDisplayInfo = struct { 14 - name: []const u8, 15 - uri: []const u8, 16 - }; 17 - 18 - pub fn protectedResource(request: *http.Server.Request) !void { 19 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 20 - defer arena.deinit(); 21 - var resource_buf: [512]u8 = undefined; 22 - const requested_resource = http_api.queryParam(request.head.target, "resource", &resource_buf); 23 - const resource = if (requested_resource) |requested| 24 - if (std.mem.eql(u8, trimTrailingSlash(requested), config.publicUrl())) requested else config.publicUrl() 25 - else 26 - config.publicUrl(); 27 - const body = try std.fmt.allocPrint(arena.allocator(), 28 - \\{{"resource":{f},"authorization_servers":[{f}],"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*"],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}} 29 - , .{ std.json.fmt(resource, .{}), std.json.fmt(config.publicUrl(), .{}) }); 30 - try http_api.json(request, .ok, body); 31 - } 32 - 33 - pub fn authorizationServer(request: *http.Server.Request) !void { 34 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 35 - defer arena.deinit(); 36 - const allocator = arena.allocator(); 37 - const issuer = config.publicUrl(); 38 - const body = try std.fmt.allocPrint(allocator, 39 - \\{{"issuer":{f},"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"require_pushed_authorization_requests":true,"token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["ES256","ES256K"],"dpop_signing_alg_values_supported":["ES256","ES256K"],"prompt_values_supported":["none","login","consent","select_account","create"],"protected_resources":[{f}],"jwks_uri":{f},"authorization_endpoint":{f},"token_endpoint":{f},"revocation_endpoint":{f},"introspection_endpoint":{f},"pushed_authorization_request_endpoint":{f}}} 40 - , .{ 41 - std.json.fmt(issuer, .{}), 42 - std.json.fmt(issuer, .{}), 43 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/jwks"), .{}), 44 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/authorize"), .{}), 45 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/token"), .{}), 46 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/revoke"), .{}), 47 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/introspect"), .{}), 48 - std.json.fmt(try joinUrl(allocator, issuer, "/oauth/par"), .{}), 49 - }); 50 - try http_api.json(request, .ok, body); 51 - } 52 - 53 - pub fn jwks(request: *http.Server.Request) !void { 54 - try http_api.json(request, .ok, "{\"keys\":[]}"); 55 - } 56 - 57 - pub fn par(request: *http.Server.Request) !void { 58 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 59 - defer arena.deinit(); 60 - const allocator = arena.allocator(); 61 - const content_type = http_api.headerValue(request, "content-type") orelse ""; 62 - const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 63 - if (isJsonContent(content_type)) { 64 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 65 - return parWithParams(request, allocator, JsonParams{ .json = parsed.value }); 66 - } 67 - return parWithParams(request, allocator, Form{ .body = body }); 68 - } 69 - 70 - fn parWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 71 - const response_type = try requireParam(request, params, allocator, "response_type"); 72 - if (!std.mem.eql(u8, response_type, "code")) return oauthError(request, .bad_request, "unsupported_response_type", "Only response_type=code is supported"); 73 - const client_id = try requireParam(request, params, allocator, "client_id"); 74 - try requireClientAuth(request, allocator, params, client_id); 75 - const redirect_uri = try requireParam(request, params, allocator, "redirect_uri"); 76 - const code_challenge = try requireParam(request, params, allocator, "code_challenge"); 77 - const code_challenge_method = try requireParam(request, params, allocator, "code_challenge_method"); 78 - if (!std.mem.eql(u8, code_challenge_method, "S256")) return oauthError(request, .bad_request, "invalid_request", "Only S256 PKCE is supported"); 79 - const scope = try params.value(allocator, "scope") orelse "atproto"; 80 - try validateScope(request, scope); 81 - const state = try params.value(allocator, "state") orelse ""; 82 - const login_hint = try params.value(allocator, "login_hint"); 83 - const dpop_jkt = try params.value(allocator, "dpop_jkt"); 84 - if (try params.value(allocator, "prompt")) |prompt| { 85 - if (!validPrompt(prompt)) return oauthError(request, .bad_request, "invalid_request", "Invalid prompt"); 86 - } 87 - 88 - const request_id = try store.randomToken(allocator, "", 16); 89 - const expires_at = now() + par_expires_in; 90 - try store.putOAuthRequest(request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at); 91 - const request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, request_id }); 92 - const response = try std.fmt.allocPrint(allocator, "{{\"expires_in\":{d},\"request_uri\":{f}}}", .{ par_expires_in, std.json.fmt(request_uri, .{}) }); 93 - try http_api.json(request, .created, response); 94 - } 95 - 96 - pub fn authorizeGet(request: *http.Server.Request) !void { 97 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 98 - defer arena.deinit(); 99 - const allocator = arena.allocator(); 100 - var request_id_buf: [512]u8 = undefined; 101 - const request_id = requestIdFromTarget(request.head.target, &request_id_buf) orelse return oauthError(request, .bad_request, "invalid_request", "Missing request_uri"); 102 - const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri"); 103 - if (oauth_request.expires_at < now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri"); 104 - const client = clientDisplayInfo(allocator, oauth_request.client_id) catch ClientDisplayInfo{ .name = oauth_request.client_id, .uri = oauth_request.client_id }; 105 - if (acceptsJson(request)) { 106 - const body = try authRequestInfoJson(allocator, oauth_request, client); 107 - return http_api.json(request, .ok, body); 108 - } 109 - const scopes_html = try scopesHtml(allocator, oauth_request.scope); 110 - const html = try std.fmt.allocPrint(allocator, 111 - \\<!doctype html> 112 - \\<html lang="en"> 113 - \\<head> 114 - \\<meta charset="utf-8"> 115 - \\<meta name="viewport" content="width=device-width, initial-scale=1"> 116 - \\<title>Sign in to zds</title> 117 - \\<style> 118 - \\:root{{color-scheme:light dark;--bg:#f8fafc;--panel:#fff;--text:#111827;--muted:#64748b;--border:#cbd5e1;--accent:#0f766e;--accent-ink:#fff;--danger:#b91c1c}} 119 - \\@media (prefers-color-scheme:dark){{:root{{--bg:#0f172a;--panel:#111827;--text:#f8fafc;--muted:#94a3b8;--border:#334155;--accent:#2dd4bf;--accent-ink:#042f2e;--danger:#fca5a5}}}} 120 - \\*{{box-sizing:border-box}} body{{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}} 121 - \\.shell{{width:min(100% - 32px,560px);background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:28px;box-shadow:0 18px 50px rgba(15,23,42,.12)}} 122 - \\.brand{{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin:0 0 18px}} 123 - \\h1{{font-size:26px;line-height:1.15;margin:0 0 8px}} p{{margin:0 0 18px;color:var(--muted)}} label{{display:block;font-weight:650;margin:16px 0 6px}} 124 - \\input{{width:100%;font:inherit;color:var(--text);background:transparent;border:1px solid var(--border);border-radius:8px;padding:11px 12px}} 125 - \\input:focus{{outline:3px solid color-mix(in srgb,var(--accent) 25%,transparent);border-color:var(--accent)}} button{{width:100%;margin-top:22px;border:0;border-radius:8px;background:var(--accent);color:var(--accent-ink);font:inherit;font-weight:750;padding:12px 14px;cursor:pointer}} 126 - \\.client{{border:1px solid var(--border);border-radius:8px;padding:14px;margin:18px 0;background:color-mix(in srgb,var(--panel) 88%,var(--accent) 12%)}} 127 - \\.client strong{{display:block}} \\.client span{{display:block;color:var(--muted);overflow-wrap:anywhere;font-size:14px}} 128 - \\.scopes{{margin:18px 0;padding:0;list-style:none;display:grid;gap:10px}} \\.scope{{border:1px solid var(--border);border-radius:8px;padding:12px}} 129 - \\.scope strong{{display:block}} \\.scope span{{display:block;color:var(--muted);font-size:14px}} \\.raw{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}} 130 - \\.actions{{display:grid;grid-template-columns:1fr 1fr;gap:12px}} \\.deny{{background:transparent;color:var(--danger);border:1px solid var(--border)}} 131 - \\.foot{{font-size:13px;margin-top:18px;color:var(--muted)}} 132 - \\</style> 133 - \\</head> 134 - \\<body> 135 - \\<main class="shell"> 136 - \\<p class="brand">zds</p> 137 - \\<h1>authorize {s}</h1> 138 - \\<p>Sign in to your AT Protocol account and review the permissions this app is requesting.</p> 139 - \\<section class="client"><strong>{s}</strong><span>{s}</span></section> 140 - \\<h2>requested access</h2> 141 - \\<ul class="scopes">{s}</ul> 142 - \\<form method="post" action="/oauth/authorize"> 143 - \\<input type="hidden" name="request_uri" value="{s}{s}"> 144 - \\<label for="username">Account</label> 145 - \\<input id="username" name="username" autocomplete="username" placeholder="bufo.uk" value="{s}" autofocus> 146 - \\<label for="password">Password</label> 147 - \\<input id="password" name="password" type="password" autocomplete="current-password"> 148 - \\<div class="actions"> 149 - \\<button class="deny" name="deny" value="1" formnovalidate>Deny</button> 150 - \\<button>Authorize</button> 151 - \\</div> 152 - \\</form> 153 - \\<p class="foot">This is the authorization server for pds.zat.dev.</p> 154 - \\</main> 155 - \\</body> 156 - \\</html> 157 - , .{ 158 - try htmlEscape(allocator, client.name), 159 - try htmlEscape(allocator, client.name), 160 - try htmlEscape(allocator, client.uri), 161 - scopes_html, 162 - request_uri_prefix, 163 - oauth_request.request_id, 164 - try htmlEscape(allocator, oauth_request.login_hint orelse ""), 165 - }); 166 - try respondHtml(request, .ok, html); 167 - } 168 - 169 - pub fn authorizePost(request: *http.Server.Request) !void { 170 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 171 - defer arena.deinit(); 172 - const allocator = arena.allocator(); 173 - const content_type = http_api.headerValue(request, "content-type") orelse ""; 174 - const wants_json = acceptsJson(request); 175 - const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 176 - 177 - var request_uri: []const u8 = undefined; 178 - var identifier: []const u8 = undefined; 179 - var password: []const u8 = undefined; 180 - var denied = false; 181 - if (std.mem.indexOf(u8, content_type, "application/json") != null) { 182 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 183 - request_uri = zat.json.getString(parsed.value, "request_uri") orelse return oauthError(request, .bad_request, "invalid_request", "Missing request_uri"); 184 - denied = zat.json.getBool(parsed.value, "deny") orelse false; 185 - identifier = zat.json.getString(parsed.value, "username") orelse zat.json.getString(parsed.value, "identifier") orelse return oauthError(request, .bad_request, "invalid_request", "Missing username"); 186 - password = zat.json.getString(parsed.value, "password") orelse return oauthError(request, .bad_request, "invalid_request", "Missing password"); 187 - } else { 188 - const form = Form{ .body = body }; 189 - request_uri = try requireForm(request, form, allocator, "request_uri"); 190 - denied = (try form.value(allocator, "deny")) != null; 191 - if (denied) { 192 - const request_id = requestIdFromUri(request_uri) orelse return oauthError(request, .bad_request, "invalid_request", "Invalid request_uri"); 193 - const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri"); 194 - const redirect_uri = try authorizationErrorRedirect(allocator, oauth_request.redirect_uri, "access_denied", "Authorization denied", oauth_request.state); 195 - return redirectTo(request, redirect_uri); 196 - } 197 - identifier = (try form.value(allocator, "username")) orelse try requireForm(request, form, allocator, "identifier"); 198 - password = try requireForm(request, form, allocator, "password"); 199 - } 200 - if (denied) return oauthError(request, .forbidden, "access_denied", "Authorization denied"); 201 - 202 - const request_id = requestIdFromUri(request_uri) orelse return oauthError(request, .bad_request, "invalid_request", "Invalid request_uri"); 203 - const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri"); 204 - if (oauth_request.expires_at < now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri"); 205 - const account = (try store.findAccount(allocator, identifier)) orelse return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password"); 206 - if (!auth.passwordMatches(account, password)) return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password"); 207 - 208 - const code = try store.randomToken(allocator, "", 16); 209 - try store.authorizeOAuthRequest(oauth_request.request_id, account.did, code); 210 - const redirect_uri = try authorizationRedirect(allocator, oauth_request.redirect_uri, code, oauth_request.state); 211 - if (wants_json or std.mem.indexOf(u8, content_type, "application/json") != null) { 212 - const response = try std.fmt.allocPrint(allocator, "{{\"redirect_uri\":{f}}}", .{std.json.fmt(redirect_uri, .{})}); 213 - return http_api.json(request, .ok, response); 214 - } 215 - try redirectTo(request, redirect_uri); 216 - } 217 - 218 - pub fn token(request: *http.Server.Request) !void { 219 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 220 - defer arena.deinit(); 221 - const allocator = arena.allocator(); 222 - const content_type = http_api.headerValue(request, "content-type") orelse ""; 223 - const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 224 - if (isJsonContent(content_type)) { 225 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 226 - return tokenWithParams(request, allocator, JsonParams{ .json = parsed.value }); 227 - } 228 - return tokenWithParams(request, allocator, Form{ .body = body }); 229 - } 230 - 231 - fn tokenWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 232 - const grant_type = try requireParam(request, params, allocator, "grant_type"); 233 - if (std.mem.eql(u8, grant_type, "authorization_code")) return authorizationCodeToken(request, allocator, params); 234 - if (std.mem.eql(u8, grant_type, "refresh_token")) return refreshToken(request, allocator, params); 235 - return oauthError(request, .bad_request, "unsupported_grant_type", "Unsupported grant_type"); 236 - } 237 - 238 - pub fn introspect(request: *http.Server.Request) !void { 239 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 240 - defer arena.deinit(); 241 - const allocator = arena.allocator(); 242 - const content_type = http_api.headerValue(request, "content-type") orelse ""; 243 - const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 244 - if (isJsonContent(content_type)) { 245 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 246 - return introspectWithParams(request, allocator, JsonParams{ .json = parsed.value }); 247 - } 248 - return introspectWithParams(request, allocator, Form{ .body = body }); 249 - } 250 - 251 - fn introspectWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 252 - const token_text = try requireParam(request, params, allocator, "token"); 253 - const row = try store.getOAuthToken(allocator, token_text); 254 - if (row == null or row.?.revoked or row.?.expires_at < now()) return http_api.json(request, .ok, "{\"active\":false}"); 255 - const response = try std.fmt.allocPrint(allocator, "{{\"active\":true,\"sub\":{f},\"client_id\":{f},\"scope\":{f},\"exp\":{d}}}", .{ 256 - std.json.fmt(row.?.did, .{}), 257 - std.json.fmt(row.?.client_id, .{}), 258 - std.json.fmt(row.?.scope, .{}), 259 - row.?.expires_at, 260 - }); 261 - try http_api.json(request, .ok, response); 262 - } 263 - 264 - pub fn revoke(request: *http.Server.Request) !void { 265 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 266 - defer arena.deinit(); 267 - const allocator = arena.allocator(); 268 - const content_type = http_api.headerValue(request, "content-type") orelse ""; 269 - const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 270 - if (isJsonContent(content_type)) { 271 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 272 - return revokeWithParams(request, allocator, JsonParams{ .json = parsed.value }); 273 - } 274 - return revokeWithParams(request, allocator, Form{ .body = body }); 275 - } 276 - 277 - fn revokeWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 278 - const token_text = try requireParam(request, params, allocator, "token"); 279 - try store.revokeOAuthToken(token_text); 280 - try http_api.json(request, .ok, "{}"); 281 - } 282 - 283 - fn authorizationCodeToken(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 284 - const code = try requireParam(request, params, allocator, "code"); 285 - const redirect_uri = try requireParam(request, params, allocator, "redirect_uri"); 286 - const code_verifier = try requireParam(request, params, allocator, "code_verifier"); 287 - const client_id = try requireParam(request, params, allocator, "client_id"); 288 - try requireClientAuth(request, allocator, params, client_id); 289 - const oauth_request = (try store.consumeOAuthCode(allocator, code)) orelse return oauthError(request, .bad_request, "invalid_grant", "Invalid code"); 290 - if (!std.mem.eql(u8, redirect_uri, oauth_request.redirect_uri) or !std.mem.eql(u8, client_id, oauth_request.client_id)) { 291 - return oauthError(request, .bad_request, "invalid_grant", "Mismatched token request"); 292 - } 293 - if (!try pkceMatches(allocator, code_verifier, oauth_request.code_challenge)) { 294 - return oauthError(request, .bad_request, "invalid_grant", "Invalid PKCE verifier"); 295 - } 296 - const did = oauth_request.sub orelse return oauthError(request, .bad_request, "invalid_grant", "Code was not authorized"); 297 - const account = (try store.findAccount(allocator, did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); 298 - try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null); 299 - } 300 - 301 - fn refreshToken(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void { 302 - const refresh = try requireParam(request, params, allocator, "refresh_token"); 303 - const client_id = try requireParam(request, params, allocator, "client_id"); 304 - try requireClientAuth(request, allocator, params, client_id); 305 - const token_row = (try store.getOAuthToken(allocator, refresh)) orelse return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 306 - if (token_row.revoked or token_row.expires_at < now() or !std.mem.eql(u8, token_row.client_id, client_id)) { 307 - return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 308 - } 309 - const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); 310 - try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh); 311 - } 312 - 313 - fn issueTokenResponse(request: *http.Server.Request, allocator: std.mem.Allocator, account: auth.Account, client_id: []const u8, scope: []const u8, revoke_old: ?[]const u8) !void { 314 - if (revoke_old) |old| try store.revokeOAuthToken(old); 315 - const access = try auth.createSessionJwt(allocator, "access", account); 316 - const refresh = try auth.createSessionJwt(allocator, "refresh", account); 317 - const expires_at = now() + token_expires_in; 318 - try store.putOAuthToken(account.did, client_id, scope, access, refresh, expires_at); 319 - const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":\"DPoP\",\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ 320 - std.json.fmt(access, .{}), 321 - std.json.fmt(refresh, .{}), 322 - token_expires_in, 323 - std.json.fmt(scope, .{}), 324 - std.json.fmt(account.did, .{}), 325 - }); 326 - try http_api.json(request, .ok, body); 327 - } 328 - 329 - fn requireClientAuth(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype, client_id: []const u8) !void { 330 - const metadata = fetchJson(allocator, client_id, 256 * 1024) catch { 331 - return oauthError(request, .bad_request, "invalid_client", "Could not fetch client metadata"); 332 - }; 333 - const method = zat.json.getString(metadata.value, "token_endpoint_auth_method") orelse "none"; 334 - if (std.mem.eql(u8, method, "none")) return; 335 - if (!std.mem.eql(u8, method, "private_key_jwt")) { 336 - return oauthError(request, .bad_request, "invalid_client", "Unsupported client authentication method"); 337 - } 338 - 339 - const assertion_type = try params.value(allocator, "client_assertion_type") orelse { 340 - return oauthError(request, .bad_request, "invalid_client", "Missing client assertion type"); 341 - }; 342 - if (!std.mem.eql(u8, assertion_type, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")) { 343 - return oauthError(request, .bad_request, "invalid_client", "Unsupported client assertion type"); 344 - } 345 - const assertion = try params.value(allocator, "client_assertion") orelse { 346 - return oauthError(request, .bad_request, "invalid_client", "Missing client assertion"); 347 - }; 348 - verifyClientAssertion(allocator, metadata.value, client_id, assertion) catch { 349 - return oauthError(request, .bad_request, "invalid_client", "Invalid client assertion"); 350 - }; 351 - } 352 - 353 - fn fetchJson(allocator: std.mem.Allocator, url: []const u8, max_response_size: usize) !std.json.Parsed(std.json.Value) { 354 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 355 - defer transport.deinit(); 356 - const result = try transport.fetch(.{ 357 - .url = url, 358 - .method = .GET, 359 - .accept = "application/json", 360 - .max_response_size = max_response_size, 361 - }); 362 - const status_code = @intFromEnum(result.status); 363 - if (status_code < 200 or status_code >= 300) return error.HttpStatus; 364 - return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 365 - } 366 - 367 - fn verifyClientAssertion(allocator: std.mem.Allocator, metadata: std.json.Value, client_id: []const u8, assertion: []const u8) !void { 368 - var parts = std.mem.splitScalar(u8, assertion, '.'); 369 - const header_part = parts.next() orelse return error.InvalidJwt; 370 - const payload_part = parts.next() orelse return error.InvalidJwt; 371 - const signature_part = parts.next() orelse return error.InvalidJwt; 372 - if (parts.next() != null) return error.InvalidJwt; 373 - const signing_input_len = header_part.len + 1 + payload_part.len; 374 - if (assertion.len < signing_input_len or assertion[header_part.len] != '.') return error.InvalidJwt; 375 - const signing_input = assertion[0..signing_input_len]; 376 - 377 - const header_json = try zat.jwt.base64UrlDecode(allocator, header_part); 378 - defer allocator.free(header_json); 379 - const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); 380 - defer header.deinit(); 381 - const alg = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt; 382 - if (!std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, alg, "ES256K")) return error.InvalidJwt; 383 - const kid = zat.json.getString(header.value, "kid"); 384 - 385 - const payload_json = try zat.jwt.base64UrlDecode(allocator, payload_part); 386 - defer allocator.free(payload_json); 387 - const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); 388 - defer payload.deinit(); 389 - const iss = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt; 390 - const sub = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt; 391 - const aud = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt; 392 - const exp = zat.json.getInt(payload.value, "exp") orelse return error.InvalidJwt; 393 - _ = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt; 394 - if (!std.mem.eql(u8, iss, client_id) or !std.mem.eql(u8, sub, client_id)) return error.InvalidJwt; 395 - if (!std.mem.eql(u8, aud, config.publicUrl())) return error.InvalidJwt; 396 - if (exp < now()) return error.InvalidJwt; 397 - 398 - const jwks_uri = zat.json.getString(metadata, "jwks_uri") orelse return error.InvalidClientMetadata; 399 - const jwks_doc = try fetchJson(allocator, jwks_uri, 256 * 1024); 400 - const public_key = try clientPublicKeyFromJwks(allocator, jwks_doc.value, kid, alg); 401 - defer allocator.free(public_key); 402 - const signature = try zat.jwt.base64UrlDecode(allocator, signature_part); 403 - defer allocator.free(signature); 404 - if (std.mem.eql(u8, alg, "ES256")) { 405 - try zat.jwt.verifyP256(signing_input, signature, public_key); 406 - } else { 407 - try zat.jwt.verifySecp256k1(signing_input, signature, public_key); 408 - } 409 - } 410 - 411 - fn clientPublicKeyFromJwks(allocator: std.mem.Allocator, jwks_doc: std.json.Value, kid: ?[]const u8, alg: []const u8) ![]u8 { 412 - const keys = zat.json.getArray(jwks_doc, "keys") orelse return error.InvalidJwks; 413 - for (keys) |key| { 414 - if (kid) |wanted| { 415 - const key_kid = zat.json.getString(key, "kid") orelse continue; 416 - if (!std.mem.eql(u8, key_kid, wanted)) continue; 417 - } 418 - if (!std.mem.eql(u8, zat.json.getString(key, "kty") orelse "", "EC")) continue; 419 - const crv = zat.json.getString(key, "crv") orelse continue; 420 - if (std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, crv, "P-256")) continue; 421 - if (std.mem.eql(u8, alg, "ES256K") and !std.mem.eql(u8, crv, "secp256k1")) continue; 422 - const x = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "x") orelse return error.InvalidJwks); 423 - defer allocator.free(x); 424 - const y = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "y") orelse return error.InvalidJwks); 425 - defer allocator.free(y); 426 - if (x.len != 32 or y.len != 32) return error.InvalidJwks; 427 - const public_key = try allocator.alloc(u8, 33); 428 - public_key[0] = if ((y[31] & 1) == 1) 0x03 else 0x02; 429 - @memcpy(public_key[1..33], x); 430 - return public_key; 431 - } 432 - return error.KeyNotFound; 433 - } 434 - 435 - const Form = struct { 436 - body: []const u8, 437 - 438 - fn value(self: Form, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 { 439 - var params = std.mem.splitScalar(u8, self.body, '&'); 440 - while (params.next()) |param| { 441 - const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue; 442 - var name_buf: [256]u8 = undefined; 443 - const decoded_name = http_api.percentDecode(param[0..eq], &name_buf) catch continue; 444 - if (!std.mem.eql(u8, decoded_name, name)) continue; 445 - const out = try allocator.alloc(u8, param.len - eq); 446 - return try http_api.percentDecode(param[eq + 1 ..], out); 447 - } 448 - return null; 449 - } 450 - }; 451 - 452 - const JsonParams = struct { 453 - json: std.json.Value, 454 - 455 - fn value(self: JsonParams, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 { 456 - const string = zat.json.getString(self.json, name) orelse return null; 457 - return try allocator.dupe(u8, string); 458 - } 459 - }; 460 - 461 - fn requireForm(request: *http.Server.Request, form: Form, allocator: std.mem.Allocator, name: []const u8) ![]const u8 { 462 - return requireParam(request, form, allocator, name); 463 - } 464 - 465 - fn requireParam(request: *http.Server.Request, params: anytype, allocator: std.mem.Allocator, name: []const u8) ![]const u8 { 466 - return (try params.value(allocator, name)) orelse { 467 - try oauthError(request, .bad_request, "invalid_request", "Missing required field"); 468 - return error.MissingFormField; 469 - }; 470 - } 471 - 472 - fn isJsonContent(content_type: []const u8) bool { 473 - return std.mem.indexOf(u8, content_type, "application/json") != null; 474 - } 475 - 476 - fn requestIdFromTarget(target: []const u8, out: []u8) ?[]const u8 { 477 - const value = http_api.queryParam(target, "request_uri", out) orelse return null; 478 - return requestIdFromUri(value); 479 - } 480 - 481 - fn requestIdFromUri(request_uri: []const u8) ?[]const u8 { 482 - if (!std.mem.startsWith(u8, request_uri, request_uri_prefix)) return null; 483 - return request_uri[request_uri_prefix.len..]; 484 - } 485 - 486 - fn authorizationRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, code: []const u8, state: []const u8) ![]const u8 { 487 - const sep: []const u8 = if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) "?" else "&"; 488 - const code_enc = try percentEncode(allocator, code); 489 - const state_enc = try percentEncode(allocator, state); 490 - const iss_enc = try percentEncode(allocator, config.publicUrl()); 491 - return std.fmt.allocPrint(allocator, "{s}{s}code={s}&state={s}&iss={s}", .{ redirect_uri, sep, code_enc, state_enc, iss_enc }); 492 - } 493 - 494 - fn authorizationErrorRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, err: []const u8, description: []const u8, state: []const u8) ![]const u8 { 495 - const sep: []const u8 = if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) "?" else "&"; 496 - return std.fmt.allocPrint(allocator, "{s}{s}error={s}&error_description={s}&state={s}&iss={s}", .{ 497 - redirect_uri, 498 - sep, 499 - try percentEncode(allocator, err), 500 - try percentEncode(allocator, description), 501 - try percentEncode(allocator, state), 502 - try percentEncode(allocator, config.publicUrl()), 503 - }); 504 - } 505 - 506 - fn redirectTo(request: *http.Server.Request, location: []const u8) !void { 507 - try request.respond("", .{ 508 - .status = .see_other, 509 - .extra_headers = &[_]http.Header{ 510 - .{ .name = "location", .value = location }, 511 - .{ .name = "access-control-allow-origin", .value = "*" }, 512 - .{ .name = "connection", .value = "close" }, 513 - }, 514 - }); 515 - } 516 - 517 - fn clientDisplayInfo(allocator: std.mem.Allocator, client_id: []const u8) !ClientDisplayInfo { 518 - const metadata = try fetchJson(allocator, client_id, 256 * 1024); 519 - const name = zat.json.getString(metadata.value, "client_name") orelse 520 - zat.json.getString(metadata.value, "client_id") orelse 521 - client_id; 522 - const uri = zat.json.getString(metadata.value, "client_uri") orelse client_id; 523 - return .{ 524 - .name = try allocator.dupe(u8, name), 525 - .uri = try allocator.dupe(u8, uri), 526 - }; 527 - } 528 - 529 - fn authRequestInfoJson(allocator: std.mem.Allocator, oauth_request: store.OAuthRequest, client: ClientDisplayInfo) ![]const u8 { 530 - const Response = struct { 531 - request_uri: []const u8, 532 - client_id: []const u8, 533 - client_name: []const u8, 534 - client_uri: []const u8, 535 - scope: []const u8, 536 - login_hint: []const u8, 537 - }; 538 - var out: std.Io.Writer.Allocating = .init(allocator); 539 - defer out.deinit(); 540 - try std.json.Stringify.value(Response{ 541 - .request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, oauth_request.request_id }), 542 - .client_id = oauth_request.client_id, 543 - .client_name = client.name, 544 - .client_uri = client.uri, 545 - .scope = oauth_request.scope, 546 - .login_hint = oauth_request.login_hint orelse "", 547 - }, .{}, &out.writer); 548 - return out.toOwnedSlice(); 549 - } 550 - 551 - fn scopesHtml(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { 552 - var out: std.Io.Writer.Allocating = .init(allocator); 553 - defer out.deinit(); 554 - var scopes = std.mem.splitScalar(u8, scope_text, ' '); 555 - var any = false; 556 - while (scopes.next()) |scope| { 557 - if (scope.len == 0) continue; 558 - any = true; 559 - const display = scopeDisplay(scope); 560 - try out.writer.print( 561 - "<li class=\"scope\"><strong>{s}</strong><span>{s}</span><span class=\"raw\">{s}</span></li>", 562 - .{ 563 - try htmlEscape(allocator, display.name), 564 - try htmlEscape(allocator, display.description), 565 - try htmlEscape(allocator, scope), 566 - }, 567 - ); 568 - } 569 - if (!any) try out.writer.writeAll("<li class=\"scope\"><strong>Basic account access</strong><span>Sign in as this account.</span><span class=\"raw\">atproto</span></li>"); 570 - return out.toOwnedSlice(); 571 - } 572 - 573 - const ScopeDisplay = struct { 574 - name: []const u8, 575 - description: []const u8, 576 - }; 577 - 578 - fn scopeDisplay(scope: []const u8) ScopeDisplay { 579 - if (std.mem.eql(u8, scope, "atproto")) return .{ .name = "AT Protocol access", .description = "Read and write AT Protocol data for this account." }; 580 - if (std.mem.eql(u8, scope, "transition:generic")) return .{ .name = "Legacy app access", .description = "Use generic transition APIs during OAuth migration." }; 581 - if (std.mem.eql(u8, scope, "transition:email")) return .{ .name = "Email access", .description = "Read account email information exposed by transition APIs." }; 582 - if (std.mem.eql(u8, scope, "transition:chat.bsky")) return .{ .name = "Bluesky chat transition access", .description = "Use chat transition APIs requested by compatible clients." }; 583 - if (std.mem.startsWith(u8, scope, "repo:")) return .{ .name = "Repository access", .description = "Access records in this account repository." }; 584 - if (std.mem.startsWith(u8, scope, "blob:")) return .{ .name = "Blob access", .description = "Access uploaded media and blob data." }; 585 - if (std.mem.startsWith(u8, scope, "rpc:")) return .{ .name = "XRPC access", .description = "Call AT Protocol API methods through this account." }; 586 - if (std.mem.startsWith(u8, scope, "account:")) return .{ .name = "Account access", .description = "Manage account-level settings requested by the app." }; 587 - if (std.mem.startsWith(u8, scope, "identity:")) return .{ .name = "Identity access", .description = "Manage identity information requested by the app." }; 588 - return .{ .name = "Requested scope", .description = "A scope requested by this OAuth client." }; 589 - } 590 - 591 - fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { 592 - var out: std.Io.Writer.Allocating = .init(allocator); 593 - defer out.deinit(); 594 - for (value) |byte| switch (byte) { 595 - '&' => try out.writer.writeAll("&amp;"), 596 - '<' => try out.writer.writeAll("&lt;"), 597 - '>' => try out.writer.writeAll("&gt;"), 598 - '"' => try out.writer.writeAll("&quot;"), 599 - '\'' => try out.writer.writeAll("&#39;"), 600 - else => try out.writer.writeByte(byte), 601 - }; 602 - return out.toOwnedSlice(); 603 - } 604 - 605 - fn pkceMatches(allocator: std.mem.Allocator, verifier: []const u8, challenge: []const u8) !bool { 606 - var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; 607 - std.crypto.hash.sha2.Sha256.hash(verifier, &digest, .{}); 608 - const encoded = try zat.jwt.base64UrlEncode(allocator, &digest); 609 - return std.mem.eql(u8, encoded, challenge); 610 - } 611 - 612 - fn validPrompt(prompt: []const u8) bool { 613 - var parts = std.mem.splitScalar(u8, prompt, ' '); 614 - while (parts.next()) |part| { 615 - if (part.len == 0) continue; 616 - if (!(std.mem.eql(u8, part, "none") or 617 - std.mem.eql(u8, part, "login") or 618 - std.mem.eql(u8, part, "consent") or 619 - std.mem.eql(u8, part, "select_account") or 620 - std.mem.eql(u8, part, "create"))) return false; 621 - } 622 - return true; 623 - } 624 - 625 - fn validateScope(request: *http.Server.Request, scope_text: []const u8) !void { 626 - var scopes = std.mem.splitScalar(u8, scope_text, ' '); 627 - var saw_scope = false; 628 - var has_transition = false; 629 - var has_granular = false; 630 - while (scopes.next()) |scope| { 631 - if (scope.len == 0) continue; 632 - saw_scope = true; 633 - if (std.mem.eql(u8, scope, "atproto")) continue; 634 - if (std.mem.eql(u8, scope, "transition:generic") or std.mem.eql(u8, scope, "transition:email") or std.mem.eql(u8, scope, "transition:chat.bsky")) { 635 - has_transition = true; 636 - continue; 637 - } 638 - if (std.mem.startsWith(u8, scope, "repo:") or 639 - std.mem.startsWith(u8, scope, "blob:") or 640 - std.mem.startsWith(u8, scope, "rpc:") or 641 - std.mem.startsWith(u8, scope, "account:") or 642 - std.mem.startsWith(u8, scope, "identity:") or 643 - std.mem.startsWith(u8, scope, "include:")) 644 - { 645 - has_granular = true; 646 - continue; 647 - } 648 - return oauthError(request, .bad_request, "invalid_scope", "Unsupported scope"); 649 - } 650 - if (!saw_scope) return oauthError(request, .bad_request, "invalid_scope", "Empty scope"); 651 - if (has_transition and has_granular) { 652 - return oauthError(request, .bad_request, "invalid_scope", "Cannot mix transition scopes with granular scopes"); 653 - } 654 - } 655 - 656 - fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { 657 - var out: std.Io.Writer.Allocating = .init(allocator); 658 - for (value) |c| { 659 - if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_' or c == '.' or c == '~') { 660 - try out.writer.writeByte(c); 661 - } else { 662 - try out.writer.print("%{X:0>2}", .{c}); 663 - } 664 - } 665 - return out.toOwnedSlice(); 666 - } 667 - 668 - fn joinUrl(allocator: std.mem.Allocator, base: []const u8, path: []const u8) ![]const u8 { 669 - return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, path }); 670 - } 671 - 672 - fn trimTrailingSlash(value: []const u8) []const u8 { 673 - if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1]; 674 - return value; 675 - } 676 - 677 - fn acceptsJson(request: *const http.Server.Request) bool { 678 - const accept = http_api.headerValue(request, "accept") orelse return false; 679 - return std.mem.indexOf(u8, accept, "application/json") != null; 680 - } 681 - 682 - fn now() i64 { 683 - var ts: std.posix.timespec = undefined; 684 - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 685 - .SUCCESS => ts.sec, 686 - else => 0, 687 - }; 688 - } 689 - 690 - fn oauthError(request: *http.Server.Request, status: http.Status, err: []const u8, description: []const u8) !void { 691 - var buf: [512]u8 = undefined; 692 - const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f},\"error_description\":{f}}}", .{ std.json.fmt(err, .{}), std.json.fmt(description, .{}) }); 693 - try http_api.json(request, status, body); 694 - } 695 - 696 - fn respondHtml(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 697 - try request.respond(body, .{ 698 - .status = status, 699 - .extra_headers = &[_]http.Header{ 700 - .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 701 - .{ .name = "access-control-allow-origin", .value = "*" }, 702 - .{ .name = "connection", .value = "close" }, 703 - }, 704 - }); 705 - }
-170
src/atproto/plc.zig
··· 1 - const std = @import("std"); 2 - const config = @import("../core/config.zig"); 3 - const store = @import("../storage/store.zig"); 4 - const zat = @import("zat"); 5 - 6 - pub const Operation = struct { 7 - did: []const u8, 8 - json: []const u8, 9 - }; 10 - 11 - pub fn createGenesisOperation( 12 - allocator: std.mem.Allocator, 13 - handle: []const u8, 14 - signing_key: *const zat.Keypair, 15 - rotation_key: *const zat.Keypair, 16 - ) !Operation { 17 - const signing_did_key = try signing_key.did(allocator); 18 - const rotation_did_key = try rotation_key.did(allocator); 19 - const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_key); 20 - const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned); 21 - const signature = try rotation_key.sign(unsigned_bytes); 22 - const signature_text = try zat.jwt.base64UrlEncode(allocator, &signature.bytes); 23 - 24 - const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); 25 - const signed_bytes = try zat.cbor.encodeAlloc(allocator, signed); 26 - var digest: [32]u8 = undefined; 27 - std.crypto.hash.sha2.Sha256.hash(signed_bytes, &digest, .{}); 28 - const encoded = try zat.multibase.base32lower.encode(allocator, &digest); 29 - const did_hash = if (encoded.len > 0 and encoded[0] == 'b') encoded[1..] else encoded; 30 - const did = try std.fmt.allocPrint(allocator, "did:plc:{s}", .{did_hash[0..24]}); 31 - 32 - return .{ 33 - .did = did, 34 - .json = try operationJson(allocator, handle, signing_did_key, rotation_did_key, signature_text), 35 - }; 36 - } 37 - 38 - pub fn submitOperation(allocator: std.mem.Allocator, did: []const u8, operation_json: []const u8) !void { 39 - const escaped_did = try percentEncodeDid(allocator, did); 40 - const url = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ config.plcDirectory(), escaped_did }); 41 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 42 - defer transport.deinit(); 43 - const result = try transport.fetch(.{ 44 - .url = url, 45 - .method = .POST, 46 - .payload = operation_json, 47 - .content_type = "application/json", 48 - .max_response_size = 64 * 1024, 49 - }); 50 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) { 51 - return error.PlcSubmissionRejected; 52 - } 53 - } 54 - 55 - fn unsignedOperationCbor( 56 - allocator: std.mem.Allocator, 57 - handle: []const u8, 58 - signing_did_key: []const u8, 59 - rotation_did_key: []const u8, 60 - ) !zat.cbor.Value { 61 - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, null); 62 - } 63 - 64 - fn signedOperationCbor( 65 - allocator: std.mem.Allocator, 66 - handle: []const u8, 67 - signing_did_key: []const u8, 68 - rotation_did_key: []const u8, 69 - signature_text: []const u8, 70 - ) !zat.cbor.Value { 71 - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); 72 - } 73 - 74 - fn operationCbor( 75 - allocator: std.mem.Allocator, 76 - handle: []const u8, 77 - signing_did_key: []const u8, 78 - rotation_did_key: []const u8, 79 - signature_text: ?[]const u8, 80 - ) !zat.cbor.Value { 81 - const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); 82 - const service_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 2); 83 - service_entries[0] = .{ .key = "type", .value = .{ .text = "AtprotoPersonalDataServer" } }; 84 - service_entries[1] = .{ .key = "endpoint", .value = .{ .text = config.publicUrl() } }; 85 - 86 - const services_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1); 87 - services_entries[0] = .{ .key = "atproto_pds", .value = .{ .map = service_entries } }; 88 - 89 - const verification_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1); 90 - verification_entries[0] = .{ .key = "atproto", .value = .{ .text = signing_did_key } }; 91 - 92 - const rotation_items = try allocator.alloc(zat.cbor.Value, 1); 93 - rotation_items[0] = .{ .text = rotation_did_key }; 94 - const aka_items = try allocator.alloc(zat.cbor.Value, 1); 95 - aka_items[0] = .{ .text = also_known_as }; 96 - 97 - const entry_count: usize = if (signature_text == null) 6 else 7; 98 - const entries = try allocator.alloc(zat.cbor.Value.MapEntry, entry_count); 99 - entries[0] = .{ .key = "type", .value = .{ .text = "plc_operation" } }; 100 - entries[1] = .{ .key = "rotationKeys", .value = .{ .array = rotation_items } }; 101 - entries[2] = .{ .key = "verificationMethods", .value = .{ .map = verification_entries } }; 102 - entries[3] = .{ .key = "alsoKnownAs", .value = .{ .array = aka_items } }; 103 - entries[4] = .{ .key = "services", .value = .{ .map = services_entries } }; 104 - entries[5] = .{ .key = "prev", .value = .null }; 105 - if (signature_text) |sig| entries[6] = .{ .key = "sig", .value = .{ .text = sig } }; 106 - return .{ .map = entries }; 107 - } 108 - 109 - fn operationJson( 110 - allocator: std.mem.Allocator, 111 - handle: []const u8, 112 - signing_did_key: []const u8, 113 - rotation_did_key: []const u8, 114 - signature_text: []const u8, 115 - ) ![]const u8 { 116 - const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); 117 - return std.fmt.allocPrint( 118 - allocator, 119 - "{{\"type\":\"plc_operation\",\"rotationKeys\":[{f}],\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", 120 - .{ 121 - std.json.fmt(rotation_did_key, .{}), 122 - std.json.fmt(signing_did_key, .{}), 123 - std.json.fmt(also_known_as, .{}), 124 - std.json.fmt(config.publicUrl(), .{}), 125 - std.json.fmt(signature_text, .{}), 126 - }, 127 - ); 128 - } 129 - 130 - fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 131 - var out: std.Io.Writer.Allocating = .init(allocator); 132 - defer out.deinit(); 133 - for (did) |c| switch (c) { 134 - ':' => try out.writer.writeAll("%3A"), 135 - else => try out.writer.writeByte(c), 136 - }; 137 - return out.toOwnedSlice(); 138 - } 139 - 140 - test "PLC genesis operation is signed and derives a did:plc" { 141 - const allocator = std.testing.allocator; 142 - var arena = std.heap.ArenaAllocator.init(allocator); 143 - defer arena.deinit(); 144 - const a = arena.allocator(); 145 - const before = config.publicUrl(); 146 - defer config.setPublicUrl(before); 147 - config.setPublicUrl("https://pds.example.com"); 148 - 149 - const secret: [32]u8 = .{ 150 - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 151 - 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 152 - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 153 - 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 154 - }; 155 - const keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret); 156 - const op = try createGenesisOperation(a, "alice.example.com", &keypair, &keypair); 157 - try std.testing.expect(std.mem.startsWith(u8, op.did, "did:plc:")); 158 - try std.testing.expectEqual(@as(usize, "did:plc:".len + 24), op.did.len); 159 - try std.testing.expect(std.mem.indexOf(u8, op.json, "\"type\":\"plc_operation\"") != null); 160 - try std.testing.expect(std.mem.indexOf(u8, op.json, "\"sig\"") != null); 161 - try std.testing.expect(std.mem.indexOf(u8, op.json, "https://pds.example.com") != null); 162 - 163 - const parsed = try std.json.parseFromSlice(std.json.Value, a, op.json, .{}); 164 - const sig_text = parsed.value.object.get("sig").?.string; 165 - const sig_bytes = try zat.jwt.base64UrlDecode(a, sig_text); 166 - const did_key = try keypair.did(a); 167 - const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, did_key); 168 - const unsigned_bytes = try zat.cbor.encodeAlloc(a, unsigned); 169 - try zat.multicodec.verifyDidKeySignature(a, did_key, unsigned_bytes, sig_bytes); 170 - }
-152
src/atproto/preferences.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const http_api = @import("../http/api.zig"); 4 - const store = @import("../storage/store.zig"); 5 - 6 - const http = std.http; 7 - 8 - const app_bsky_namespace = "app.bsky"; 9 - const max_preference_size = 10_000; 10 - const personal_details_pref = "app.bsky.actor.defs#personalDetailsPref"; 11 - const declared_age_pref = "app.bsky.actor.defs#declaredAgePref"; 12 - 13 - pub fn getPreferences(request: *http.Server.Request) !void { 14 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 15 - defer arena.deinit(); 16 - const allocator = arena.allocator(); 17 - 18 - const account = requireAccount(request, allocator) catch return; 19 - const rows = try store.getAppPreferences(allocator, account.did, app_bsky_namespace); 20 - 21 - var out: std.Io.Writer.Allocating = .init(allocator); 22 - defer out.deinit(); 23 - try out.writer.writeAll("{\"preferences\":["); 24 - var first = true; 25 - var personal_details: ?std.json.Value = null; 26 - for (rows) |row| { 27 - const parsed = std.json.parseFromSlice(std.json.Value, allocator, row.value_json, .{}) catch continue; 28 - if (std.mem.eql(u8, row.name, declared_age_pref)) continue; 29 - if (std.mem.eql(u8, row.name, personal_details_pref)) personal_details = parsed.value; 30 - if (!first) try out.writer.writeByte(','); 31 - first = false; 32 - try out.writer.writeAll(row.value_json); 33 - } 34 - if (declaredAgeJson(allocator, personal_details)) |age_json| { 35 - if (!first) try out.writer.writeByte(','); 36 - try out.writer.writeAll(age_json); 37 - } 38 - try out.writer.writeAll("]}"); 39 - const body = try out.toOwnedSlice(); 40 - return http_api.json(request, .ok, body); 41 - } 42 - 43 - pub fn putPreferences(request: *http.Server.Request) !void { 44 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 45 - defer arena.deinit(); 46 - const allocator = arena.allocator(); 47 - 48 - const account = requireAccount(request, allocator) catch return; 49 - const body = http_api.readBodyAlloc(request, allocator, 1024 * 1024) catch |err| switch (err) { 50 - error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "PayloadTooLarge", "Request body too large"), 51 - else => return err, 52 - }; 53 - const parsed = try http_api.parseJsonBody(request, allocator, body); 54 - const preferences = switch (parsed.value) { 55 - .object => |object| object.get("preferences") orelse { 56 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing preferences"); 57 - }, 58 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), 59 - }; 60 - if (preferences != .array) { 61 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "preferences must be an array"); 62 - } 63 - var preference_rows: std.ArrayList(store.AppPreference) = .empty; 64 - for (preferences.array.items) |preference| { 65 - if (preference != .object) { 66 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference must be an object"); 67 - } 68 - const type_name = http_api.valueString(preference, "$type") orelse { 69 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is missing a $type"); 70 - }; 71 - if (type_name.len == 0) { 72 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is missing a $type"); 73 - } 74 - if (!std.mem.startsWith(u8, type_name, app_bsky_namespace)) { 75 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Some preferences are not in the app.bsky namespace"); 76 - } 77 - const preference_json = try stringifyJson(allocator, preference); 78 - if (preference_json.len > max_preference_size) { 79 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is too large"); 80 - } 81 - if (std.mem.eql(u8, type_name, declared_age_pref)) continue; 82 - try preference_rows.append(allocator, .{ 83 - .name = type_name, 84 - .value_json = preference_json, 85 - }); 86 - } 87 - 88 - try store.replaceAppPreferences(allocator, account.did, app_bsky_namespace, preference_rows.items); 89 - return http_api.json(request, .ok, "{}"); 90 - } 91 - 92 - fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 93 - var out: std.Io.Writer.Allocating = .init(allocator); 94 - defer out.deinit(); 95 - try std.json.Stringify.value(value, .{}, &out.writer); 96 - return out.toOwnedSlice(); 97 - } 98 - 99 - fn declaredAgeJson(allocator: std.mem.Allocator, personal_details: ?std.json.Value) ?[]const u8 { 100 - const personal = personal_details orelse return null; 101 - const birth_date = http_api.valueString(personal, "birthDate") orelse return null; 102 - const age = ageFromDateString(birth_date) orelse return null; 103 - return std.fmt.allocPrint( 104 - allocator, 105 - "{{\"$type\":\"{s}\",\"isOverAge13\":{},\"isOverAge16\":{},\"isOverAge18\":{}}}", 106 - .{ declared_age_pref, age >= 13, age >= 16, age >= 18 }, 107 - ) catch null; 108 - } 109 - 110 - fn ageFromDateString(date: []const u8) ?i32 { 111 - if (date.len < 10) return null; 112 - const year = std.fmt.parseInt(i32, date[0..4], 10) catch return null; 113 - const month = std.fmt.parseInt(u8, date[5..7], 10) catch return null; 114 - const day = std.fmt.parseInt(u8, date[8..10], 10) catch return null; 115 - if (date[4] != '-' or date[7] != '-' or month == 0 or month > 12 or day == 0 or day > 31) return null; 116 - 117 - const today = civilFromDays(@divTrunc(@divTrunc(store.nowMs(), 1000), 86400)); 118 - var age = today.year - year; 119 - if (today.month < month or (today.month == month and today.day < day)) age -= 1; 120 - return age; 121 - } 122 - 123 - const CivilDate = struct { 124 - year: i32, 125 - month: u8, 126 - day: u8, 127 - }; 128 - 129 - fn civilFromDays(days_since_unix_epoch: i64) CivilDate { 130 - const z = days_since_unix_epoch + 719468; 131 - const era = if (z >= 0) @divTrunc(z, 146097) else @divTrunc(z - 146096, 146097); 132 - const doe: u32 = @intCast(z - era * 146097); 133 - const yoe: u32 = @intCast(@divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365)); 134 - var year: i32 = @intCast(yoe); 135 - year += @intCast(era * 400); 136 - const doy: u32 = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); 137 - const mp: u32 = @divTrunc(5 * doy + 2, 153); 138 - const day: u8 = @intCast(doy - @divTrunc(153 * mp + 2, 5) + 1); 139 - const month: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9); 140 - if (month <= 2) year += 1; 141 - return .{ .year = year, .month = month, .day = day }; 142 - } 143 - 144 - fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 145 - return http_api.requireBearerAccount(request, allocator) catch |err| { 146 - switch (err) { 147 - error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 148 - error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 149 - } 150 - return error.HandledResponse; 151 - }; 152 - }
-381
src/atproto/proxy.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const log = @import("../core/log.zig"); 4 - const http_api = @import("../http/api.zig"); 5 - const store = @import("../storage/store.zig"); 6 - const zat = @import("zat"); 7 - 8 - const http = std.http; 9 - 10 - pub fn xrpcProxy(request: *http.Server.Request) !void { 11 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 12 - defer arena.deinit(); 13 - const allocator = arena.allocator(); 14 - 15 - if (request.head.method != .GET and request.head.method != .POST) { 16 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "XRPC proxy supports GET and POST"); 17 - } 18 - 19 - const method = xrpcMethod(request.head.target) orelse { 20 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid XRPC path"); 21 - }; 22 - if (isProtectedMethod(method)) { 23 - return http_api.xrpcError(request, .bad_request, "InvalidToken", "Cannot proxy protected method"); 24 - } 25 - const proxy_to = http_api.headerValue(request, "atproto-proxy") orelse { 26 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing atproto-proxy header"); 27 - }; 28 - log.debug("xrpc proxy start method={s} target={s} proxy_to={s}\n", .{ method, request.head.target, proxy_to }); 29 - const service = resolveProxyService(allocator, proxy_to) catch |err| switch (err) { 30 - error.InvalidProxyHeader => { 31 - log.err("xrpc proxy invalid header method={s} proxy_to={s}\n", .{ method, proxy_to }); 32 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid atproto-proxy header"); 33 - }, 34 - error.UnsupportedDidMethod => { 35 - log.err("xrpc proxy unsupported did method={s} proxy_to={s}\n", .{ method, proxy_to }); 36 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Unsupported proxy DID method"); 37 - }, 38 - error.ServiceNotFound => { 39 - log.err("xrpc proxy service not found method={s} proxy_to={s}\n", .{ method, proxy_to }); 40 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Proxy service not found"); 41 - }, 42 - error.DidResolutionFailed => { 43 - log.err("xrpc proxy did resolution failed method={s} proxy_to={s}\n", .{ method, proxy_to }); 44 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve proxy DID"); 45 - }, 46 - else => |e| { 47 - log.err("xrpc proxy resolve failed method={s} proxy_to={s} err={s}\n", .{ method, proxy_to, @errorName(e) }); 48 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve proxy service"); 49 - }, 50 - }; 51 - 52 - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { 53 - error.AuthRequired => { 54 - log.debug("xrpc proxy auth missing method={s} proxy_to={s}\n", .{ method, proxy_to }); 55 - return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 56 - }, 57 - error.InvalidToken => { 58 - log.err("xrpc proxy auth invalid method={s} proxy_to={s}\n", .{ method, proxy_to }); 59 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 60 - }, 61 - }; 62 - log.debug("xrpc proxy auth ok method={s} account={s} service_did={s} endpoint={s}\n", .{ method, account.did, service.did, service.endpoint }); 63 - const upstream_url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ service.endpoint, request.head.target }); 64 - 65 - var extra_buf: [6]std.http.Header = undefined; 66 - var extra_count: usize = 0; 67 - inline for (.{ "accept-language", "atproto-accept-labelers", "atproto-content-labelers", "x-bsky-topics", "dpop" }) |name| { 68 - if (http_api.headerValue(request, name)) |value| { 69 - extra_buf[extra_count] = .{ .name = name, .value = try allocator.dupe(u8, value) }; 70 - extra_count += 1; 71 - } 72 - } 73 - 74 - const payload = if (request.head.method == .POST) 75 - try http_api.readBodyAlloc(request, allocator, 1024 * 1024) 76 - else 77 - null; 78 - 79 - var service_audience = service.did; 80 - var service_method: ?[]const u8 = method; 81 - if (std.ascii.eqlIgnoreCase(method, "app.bsky.feed.getFeed")) { 82 - const feed_did = resolveFeedGeneratorDid(allocator, service.endpoint, request.head.target) catch |err| switch (err) { 83 - error.MissingFeedParam, error.InvalidFeedUri => { 84 - log.err("xrpc proxy feed invalid target={s} err={s}\n", .{ request.head.target, @errorName(err) }); 85 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid feed URI"); 86 - }, 87 - error.FeedRecordUnavailable => { 88 - log.err("xrpc proxy feed record unavailable target={s}\n", .{request.head.target}); 89 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve feed generator record"); 90 - }, 91 - error.FeedDidMissing => { 92 - log.err("xrpc proxy feed record missing did target={s}\n", .{request.head.target}); 93 - return http_api.xrpcError(request, .bad_request, "UnknownFeed", "Could not resolve feed did"); 94 - }, 95 - else => |e| { 96 - log.err("xrpc proxy feed resolve failed target={s} err={s}\n", .{ request.head.target, @errorName(e) }); 97 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve feed generator"); 98 - }, 99 - }; 100 - service_audience = feed_did; 101 - service_method = "app.bsky.feed.getFeedSkeleton"; 102 - log.debug("xrpc proxy feed auth override feed_did={s} lxm={s}\n", .{ service_audience, service_method.? }); 103 - } 104 - 105 - var keypair = store.signingKeypair(account.did) catch { 106 - log.err("xrpc proxy signing key missing method={s} account={s}\n", .{ method, account.did }); 107 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not load account signing key"); 108 - }; 109 - const service_token = try auth.createServiceJwtWithKeypair(allocator, account, service_audience, service_method, &keypair); 110 - const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{service_token}); 111 - 112 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 113 - defer transport.deinit(); 114 - log.debug("xrpc proxy fetch method={s} upstream={s} extra_headers={d}\n", .{ method, upstream_url, extra_count }); 115 - const result = transport.fetch(.{ 116 - .url = upstream_url, 117 - .method = request.head.method, 118 - .payload = payload, 119 - .authorization = authorization, 120 - .accept = "application/json", 121 - .extra_headers = extra_buf[0..extra_count], 122 - .max_response_size = 10 * 1024 * 1024, 123 - }) catch |err| { 124 - log.err("xrpc proxy fetch failed method={s} upstream={s} err={s}\n", .{ method, upstream_url, @errorName(err) }); 125 - return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Failed to reach proxied service"); 126 - }; 127 - log.debug("xrpc proxy upstream method={s} status={d} body_len={d}\n", .{ method, @intFromEnum(result.status), result.body.len }); 128 - return http_api.json(request, result.status, result.body); 129 - } 130 - 131 - pub fn shouldProxy(request: *const http.Server.Request) bool { 132 - if (request.head.method != .GET and request.head.method != .POST) return false; 133 - if (http_api.headerValue(request, "atproto-proxy") == null) return false; 134 - const method = xrpcMethod(request.head.target) orelse return false; 135 - return !isProtectedMethod(method); 136 - } 137 - 138 - const ProxyService = struct { 139 - did: []const u8, 140 - endpoint: []const u8, 141 - }; 142 - 143 - fn resolveProxyService(allocator: std.mem.Allocator, proxy_to: []const u8) !ProxyService { 144 - const hash = std.mem.indexOfScalar(u8, proxy_to, '#') orelse return error.InvalidProxyHeader; 145 - if (hash == 0 or hash + 1 >= proxy_to.len) return error.InvalidProxyHeader; 146 - const did = proxy_to[0..hash]; 147 - const service_id = proxy_to[hash..]; 148 - const did_doc = try fetchDidDocument(allocator, did); 149 - const endpoint = serviceEndpoint(did_doc.value, service_id) orelse return error.ServiceNotFound; 150 - return .{ 151 - .did = try allocator.dupe(u8, did), 152 - .endpoint = try allocator.dupe(u8, std.mem.trim(u8, endpoint, "/")), 153 - }; 154 - } 155 - 156 - const FeedRef = struct { 157 - repo: []const u8, 158 - collection: []const u8, 159 - rkey: []const u8, 160 - }; 161 - 162 - fn resolveFeedGeneratorDid(allocator: std.mem.Allocator, appview_endpoint: []const u8, target: []const u8) ![]const u8 { 163 - const feed_buf = try allocator.alloc(u8, target.len); 164 - const feed_uri = http_api.queryParam(target, "feed", feed_buf) orelse return error.MissingFeedParam; 165 - const feed_ref = parseFeedUri(feed_uri) orelse return error.InvalidFeedUri; 166 - const record_url = try std.fmt.allocPrint( 167 - allocator, 168 - "{s}/xrpc/com.atproto.repo.getRecord?repo={s}&collection={s}&rkey={s}", 169 - .{ appview_endpoint, feed_ref.repo, feed_ref.collection, feed_ref.rkey }, 170 - ); 171 - 172 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 173 - defer transport.deinit(); 174 - const result = transport.fetch(.{ 175 - .url = record_url, 176 - .method = .GET, 177 - .accept = "application/json", 178 - .max_response_size = 512 * 1024, 179 - }) catch return error.FeedRecordUnavailable; 180 - const status_code = @intFromEnum(result.status); 181 - if (status_code < 200 or status_code >= 300) return error.FeedRecordUnavailable; 182 - 183 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 184 - const value = switch (parsed.value) { 185 - .object => |object| object.get("value") orelse return error.FeedDidMissing, 186 - else => return error.FeedDidMissing, 187 - }; 188 - const feed_did = zat.json.getString(value, "did") orelse return error.FeedDidMissing; 189 - if (zat.Did.parse(feed_did) == null) return error.FeedDidMissing; 190 - return try allocator.dupe(u8, feed_did); 191 - } 192 - 193 - fn parseFeedUri(uri: []const u8) ?FeedRef { 194 - if (!std.mem.startsWith(u8, uri, "at://")) return null; 195 - var parts = std.mem.splitScalar(u8, uri["at://".len..], '/'); 196 - const repo = parts.next() orelse return null; 197 - const collection = parts.next() orelse return null; 198 - const rkey = parts.next() orelse return null; 199 - if (repo.len == 0 or collection.len == 0 or rkey.len == 0 or parts.next() != null) return null; 200 - return .{ .repo = repo, .collection = collection, .rkey = rkey }; 201 - } 202 - 203 - fn fetchDidDocument(allocator: std.mem.Allocator, did: []const u8) !std.json.Parsed(std.json.Value) { 204 - const url = 205 - if (std.mem.startsWith(u8, did, "did:plc:")) 206 - try std.fmt.allocPrint(allocator, "https://plc.directory/{s}", .{did}) 207 - else if (std.mem.startsWith(u8, did, "did:web:")) 208 - try didWebUrl(allocator, did) 209 - else 210 - return error.UnsupportedDidMethod; 211 - 212 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 213 - defer transport.deinit(); 214 - const result = try transport.fetch(.{ 215 - .url = url, 216 - .method = .GET, 217 - .accept = "application/json", 218 - .max_response_size = 256 * 1024, 219 - }); 220 - const status_code = @intFromEnum(result.status); 221 - if (status_code < 200 or status_code >= 300) return error.DidResolutionFailed; 222 - return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 223 - } 224 - 225 - fn didWebUrl(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 226 - const body = did["did:web:".len..]; 227 - const decoded_buf = try allocator.alloc(u8, body.len); 228 - const decoded = try percentDecodeDidWeb(body, decoded_buf); 229 - const path = try allocator.dupe(u8, decoded); 230 - std.mem.replaceScalar(u8, path, ':', '/'); 231 - if (std.mem.indexOfScalar(u8, path, '/') == null) { 232 - return std.fmt.allocPrint(allocator, "https://{s}/.well-known/did.json", .{path}); 233 - } 234 - return std.fmt.allocPrint(allocator, "https://{s}/did.json", .{path}); 235 - } 236 - 237 - fn percentDecodeDidWeb(input: []const u8, out: []u8) ![]const u8 { 238 - var write: usize = 0; 239 - var read: usize = 0; 240 - while (read < input.len) { 241 - switch (input[read]) { 242 - '%' => { 243 - if (read + 2 >= input.len) return error.InvalidPercentEncoding; 244 - out[write] = try std.fmt.parseInt(u8, input[read + 1 .. read + 3], 16); 245 - read += 3; 246 - }, 247 - else => |c| { 248 - out[write] = c; 249 - read += 1; 250 - }, 251 - } 252 - write += 1; 253 - } 254 - return out[0..write]; 255 - } 256 - 257 - fn serviceEndpoint(did_doc: std.json.Value, service_id: []const u8) ?[]const u8 { 258 - const services = zat.json.getArray(did_doc, "service") orelse return null; 259 - for (services) |service_value| { 260 - if (zat.json.getString(service_value, "id")) |id| { 261 - if (std.mem.eql(u8, id, service_id)) { 262 - return zat.json.getString(service_value, "serviceEndpoint"); 263 - } 264 - } 265 - } 266 - return null; 267 - } 268 - 269 - fn xrpcMethod(target: []const u8) ?[]const u8 { 270 - if (!std.mem.startsWith(u8, target, "/xrpc/")) return null; 271 - const start = "/xrpc/".len; 272 - const end = std.mem.indexOfScalarPos(u8, target, start, '?') orelse target.len; 273 - return target[start..end]; 274 - } 275 - 276 - fn isProtectedMethod(method: []const u8) bool { 277 - const protected = [_][]const u8{ 278 - "app.bsky.actor.getPreferences", 279 - "app.bsky.actor.putPreferences", 280 - "com.atproto.admin.deleteAccount", 281 - "com.atproto.admin.disableAccountInvites", 282 - "com.atproto.admin.disableInviteCodes", 283 - "com.atproto.admin.enableAccountInvites", 284 - "com.atproto.admin.getAccountInfo", 285 - "com.atproto.admin.getAccountInfos", 286 - "com.atproto.admin.getInviteCodes", 287 - "com.atproto.admin.getSubjectStatus", 288 - "com.atproto.admin.searchAccounts", 289 - "com.atproto.admin.sendEmail", 290 - "com.atproto.admin.updateAccountEmail", 291 - "com.atproto.admin.updateAccountHandle", 292 - "com.atproto.admin.updateAccountPassword", 293 - "com.atproto.admin.updateSubjectStatus", 294 - "com.atproto.identity.getRecommendedDidCredentials", 295 - "com.atproto.identity.requestPlcOperationSignature", 296 - "com.atproto.identity.signPlcOperation", 297 - "com.atproto.identity.submitPlcOperation", 298 - "com.atproto.identity.updateHandle", 299 - "com.atproto.repo.applyWrites", 300 - "com.atproto.repo.createRecord", 301 - "com.atproto.repo.deleteRecord", 302 - "com.atproto.repo.importRepo", 303 - "com.atproto.repo.putRecord", 304 - "com.atproto.repo.uploadBlob", 305 - "com.atproto.server.activateAccount", 306 - "com.atproto.server.checkAccountStatus", 307 - "com.atproto.server.confirmEmail", 308 - "com.atproto.server.confirmSignup", 309 - "com.atproto.server.createAccount", 310 - "com.atproto.server.createAppPassword", 311 - "com.atproto.server.createInviteCode", 312 - "com.atproto.server.createInviteCodes", 313 - "com.atproto.server.createSession", 314 - "com.atproto.server.createTotpSecret", 315 - "com.atproto.server.deactivateAccount", 316 - "com.atproto.server.deleteAccount", 317 - "com.atproto.server.deletePasskey", 318 - "com.atproto.server.deleteSession", 319 - "com.atproto.server.describeServer", 320 - "com.atproto.server.disableTotp", 321 - "com.atproto.server.enableTotp", 322 - "com.atproto.server.finishPasskeyRegistration", 323 - "com.atproto.server.getAccountInviteCodes", 324 - "com.atproto.server.getServiceAuth", 325 - "com.atproto.server.getSession", 326 - "com.atproto.server.getTotpStatus", 327 - "com.atproto.server.listAppPasswords", 328 - "com.atproto.server.listPasskeys", 329 - "com.atproto.server.refreshSession", 330 - "com.atproto.server.regenerateBackupCodes", 331 - "com.atproto.server.requestAccountDelete", 332 - "com.atproto.server.requestEmailConfirmation", 333 - "com.atproto.server.requestEmailUpdate", 334 - "com.atproto.server.requestPasswordReset", 335 - "com.atproto.server.resendMigrationVerification", 336 - "com.atproto.server.resendVerification", 337 - "com.atproto.server.reserveSigningKey", 338 - "com.atproto.server.resetPassword", 339 - "com.atproto.server.revokeAppPassword", 340 - "com.atproto.server.startPasskeyRegistration", 341 - "com.atproto.server.updateEmail", 342 - "com.atproto.server.updatePasskey", 343 - "com.atproto.server.verifyMigrationEmail", 344 - "com.atproto.sync.getBlob", 345 - "com.atproto.sync.getBlocks", 346 - "com.atproto.sync.getCheckout", 347 - "com.atproto.sync.getHead", 348 - "com.atproto.sync.getLatestCommit", 349 - "com.atproto.sync.getRecord", 350 - "com.atproto.sync.getRepo", 351 - "com.atproto.sync.getRepoStatus", 352 - "com.atproto.sync.listBlobs", 353 - "com.atproto.sync.listRepos", 354 - "com.atproto.sync.notifyOfUpdate", 355 - "com.atproto.sync.requestCrawl", 356 - "com.atproto.sync.subscribeRepos", 357 - "com.atproto.temp.checkSignupQueue", 358 - "com.atproto.temp.dereferenceScope", 359 - }; 360 - for (protected) |item| { 361 - if (std.ascii.eqlIgnoreCase(method, item)) return true; 362 - } 363 - return false; 364 - } 365 - 366 - test "proxy protected methods are explicit" { 367 - try std.testing.expect(isProtectedMethod("com.atproto.repo.applyWrites")); 368 - try std.testing.expect(isProtectedMethod("com.atproto.sync.subscribeRepos")); 369 - try std.testing.expect(!isProtectedMethod("com.atproto.identity.resolveHandle")); 370 - try std.testing.expect(!isProtectedMethod("com.atproto.repo.getRecord")); 371 - try std.testing.expect(!isProtectedMethod("com.atproto.repo.listRecords")); 372 - } 373 - 374 - test "parses feed generator at-uri" { 375 - const feed = parseFeedUri("at://did:plc:abc/app.bsky.feed.generator/whats-hot").?; 376 - try std.testing.expectEqualStrings("did:plc:abc", feed.repo); 377 - try std.testing.expectEqualStrings("app.bsky.feed.generator", feed.collection); 378 - try std.testing.expectEqualStrings("whats-hot", feed.rkey); 379 - try std.testing.expect(parseFeedUri("at://did:plc:abc/app.bsky.feed.generator") == null); 380 - try std.testing.expect(parseFeedUri("https://example.com") == null); 381 - }
-567
src/atproto/repo.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const config = @import("../core/config.zig"); 4 - const http_api = @import("../http/api.zig"); 5 - const store = @import("../storage/store.zig"); 6 - const sync = @import("sync.zig"); 7 - const zat = @import("zat"); 8 - 9 - const http = std.http; 10 - 11 - pub fn createRecord(request: *http.Server.Request) !void { 12 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 13 - defer arena.deinit(); 14 - const allocator = arena.allocator(); 15 - 16 - const account = requireAccount(request, allocator) catch return; 17 - var body_buf: [65536]u8 = undefined; 18 - const body = try http_api.readBody(request, &body_buf); 19 - const parsed = try http_api.parseJsonBody(request, allocator, body); 20 - try requireRepoMatches(request, account, parsed.value); 21 - const collection = zat.json.getString(parsed.value, "collection") orelse { 22 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 23 - }; 24 - const rkey = zat.json.getString(parsed.value, "rkey"); 25 - const record_value = recordBodyValue(parsed.value) orelse { 26 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing record"); 27 - }; 28 - 29 - const result = store.applyWrites(allocator, account, &.{.{ .create = .{ 30 - .collection = collection, 31 - .rkey = rkey, 32 - .value = record_value, 33 - } }}) catch |err| switch (err) { 34 - error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"), 35 - error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"), 36 - error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"), 37 - else => return err, 38 - }; 39 - sync.requestConfiguredCrawls(allocator); 40 - return writeRecordRef(request, allocator, result.records[0], result.commit); 41 - } 42 - 43 - pub fn putRecord(request: *http.Server.Request) !void { 44 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 45 - defer arena.deinit(); 46 - const allocator = arena.allocator(); 47 - 48 - const account = requireAccount(request, allocator) catch return; 49 - var body_buf: [65536]u8 = undefined; 50 - const body = try http_api.readBody(request, &body_buf); 51 - const parsed = try http_api.parseJsonBody(request, allocator, body); 52 - try requireRepoMatches(request, account, parsed.value); 53 - const collection = zat.json.getString(parsed.value, "collection") orelse { 54 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 55 - }; 56 - const rkey = zat.json.getString(parsed.value, "rkey") orelse { 57 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 58 - }; 59 - const record_value = recordBodyValue(parsed.value) orelse { 60 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing record"); 61 - }; 62 - 63 - const result = store.applyWrites(allocator, account, &.{.{ .update = .{ 64 - .collection = collection, 65 - .rkey = rkey, 66 - .value = record_value, 67 - } }}) catch |err| switch (err) { 68 - error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"), 69 - error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"), 70 - error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"), 71 - else => return err, 72 - }; 73 - sync.requestConfiguredCrawls(allocator); 74 - return writeRecordRef(request, allocator, result.records[0], result.commit); 75 - } 76 - 77 - pub fn describeRepo(request: *http.Server.Request) !void { 78 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 79 - defer arena.deinit(); 80 - const allocator = arena.allocator(); 81 - 82 - var repo_buf: [256]u8 = undefined; 83 - const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse { 84 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 85 - }; 86 - const account = store.resolveRepo(repo) orelse { 87 - return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 88 - }; 89 - const collections = try store.listCollectionsJson(allocator, account.did); 90 - const body = try std.fmt.allocPrint( 91 - allocator, 92 - "{{\"handle\":{f},\"did\":{f},\"didDoc\":{{\"id\":{f},\"alsoKnownAs\":[{f}],\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}},\"collections\":{s},\"handleIsCorrect\":true}}", 93 - .{ 94 - std.json.fmt(account.handle, .{}), 95 - std.json.fmt(account.did, .{}), 96 - std.json.fmt(account.did, .{}), 97 - std.json.fmt(try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}), .{}), 98 - std.json.fmt(config.publicUrl(), .{}), 99 - collections, 100 - }, 101 - ); 102 - return http_api.json(request, .ok, body); 103 - } 104 - 105 - pub fn getRecord(request: *http.Server.Request) !void { 106 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 107 - defer arena.deinit(); 108 - const allocator = arena.allocator(); 109 - 110 - var repo_buf: [256]u8 = undefined; 111 - const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse { 112 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 113 - }; 114 - const account = store.resolveRepo(repo) orelse { 115 - return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 116 - }; 117 - var collection_buf: [256]u8 = undefined; 118 - const collection = http_api.queryParam(request.head.target, "collection", &collection_buf) orelse { 119 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 120 - }; 121 - var rkey_buf: [512]u8 = undefined; 122 - const rkey = http_api.queryParam(request.head.target, "rkey", &rkey_buf) orelse { 123 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 124 - }; 125 - 126 - const record = store.get(account.did, collection, rkey) orelse { 127 - return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"); 128 - }; 129 - const body = try store.writeRecordJson(allocator, record); 130 - return http_api.json(request, .ok, body); 131 - } 132 - 133 - pub fn listRecords(request: *http.Server.Request) !void { 134 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 135 - defer arena.deinit(); 136 - const allocator = arena.allocator(); 137 - 138 - var repo_buf: [256]u8 = undefined; 139 - const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse { 140 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 141 - }; 142 - const account = store.resolveRepo(repo) orelse { 143 - return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 144 - }; 145 - var collection_buf: [256]u8 = undefined; 146 - const collection = http_api.queryParam(request.head.target, "collection", &collection_buf) orelse { 147 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 148 - }; 149 - const body = try store.writeListJson(allocator, account.did, collection, @min(http_api.queryLimit(request.head.target, 50), 100)); 150 - return http_api.json(request, .ok, body); 151 - } 152 - 153 - pub fn deleteRecord(request: *http.Server.Request) !void { 154 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 155 - defer arena.deinit(); 156 - const allocator = arena.allocator(); 157 - 158 - const account = requireAccount(request, allocator) catch return; 159 - var body_buf: [4096]u8 = undefined; 160 - const body = try http_api.readBody(request, &body_buf); 161 - const parsed = try http_api.parseJsonBody(request, allocator, body); 162 - try requireRepoMatches(request, account, parsed.value); 163 - const collection = zat.json.getString(parsed.value, "collection") orelse { 164 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 165 - }; 166 - const rkey = zat.json.getString(parsed.value, "rkey") orelse { 167 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 168 - }; 169 - _ = store.delete(allocator, account, collection, rkey) catch |err| switch (err) { 170 - error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"), 171 - error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"), 172 - else => return err, 173 - }; 174 - sync.requestConfiguredCrawls(allocator); 175 - return http_api.json(request, .ok, "{}"); 176 - } 177 - 178 - pub fn applyWrites(request: *http.Server.Request) !void { 179 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 180 - defer arena.deinit(); 181 - const allocator = arena.allocator(); 182 - 183 - const account = requireAccount(request, allocator) catch return; 184 - var body_buf: [131072]u8 = undefined; 185 - const body = try http_api.readBody(request, &body_buf); 186 - const parsed = try http_api.parseJsonBody(request, allocator, body); 187 - try requireRepoMatches(request, account, parsed.value); 188 - const writes = switch (parsed.value) { 189 - .object => |object| object.get("writes") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing writes"), 190 - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), 191 - }; 192 - if (writes != .array) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "writes must be an array"); 193 - 194 - var staged: std.ArrayList(store.WriteOp) = .empty; 195 - for (writes.array.items, 0..) |write, idx| { 196 - if (write != .object) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid write"); 197 - const type_name = http_api.valueString(write, "$type") orelse { 198 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing write $type"); 199 - }; 200 - const collection = http_api.valueString(write, "collection") orelse { 201 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 202 - }; 203 - _ = idx; 204 - 205 - if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#create")) { 206 - const record_value = write.object.get("value") orelse { 207 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing value"); 208 - }; 209 - try staged.append(allocator, .{ .create = .{ 210 - .collection = collection, 211 - .rkey = http_api.valueString(write, "rkey"), 212 - .value = record_value, 213 - } }); 214 - } else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#update")) { 215 - const rkey = http_api.valueString(write, "rkey") orelse { 216 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 217 - }; 218 - const record_value = write.object.get("value") orelse { 219 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing value"); 220 - }; 221 - try staged.append(allocator, .{ .update = .{ 222 - .collection = collection, 223 - .rkey = rkey, 224 - .value = record_value, 225 - } }); 226 - } else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#delete")) { 227 - const rkey = http_api.valueString(write, "rkey") orelse { 228 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 229 - }; 230 - try staged.append(allocator, .{ .delete = .{ 231 - .collection = collection, 232 - .rkey = rkey, 233 - } }); 234 - } else { 235 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Unknown write operation type"); 236 - } 237 - } 238 - const result = store.applyWrites(allocator, account, staged.items) catch |err| switch (err) { 239 - error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"), 240 - error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"), 241 - error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"), 242 - else => return err, 243 - }; 244 - 245 - var out: std.Io.Writer.Allocating = .init(allocator); 246 - defer out.deinit(); 247 - try out.writer.writeByte('['); 248 - var record_idx: usize = 0; 249 - for (staged.items, 0..) |op, idx| { 250 - if (idx != 0) try out.writer.writeByte(','); 251 - switch (op) { 252 - .create => { 253 - const record = result.records[record_idx]; 254 - record_idx += 1; 255 - const uri = try record.uri(allocator); 256 - try out.writer.print( 257 - "{{\"$type\":\"com.atproto.repo.applyWrites#createResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}", 258 - .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) }, 259 - ); 260 - }, 261 - .update => { 262 - const record = result.records[record_idx]; 263 - record_idx += 1; 264 - const uri = try record.uri(allocator); 265 - try out.writer.print( 266 - "{{\"$type\":\"com.atproto.repo.applyWrites#updateResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}", 267 - .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) }, 268 - ); 269 - }, 270 - .delete => try out.writer.writeAll("{\"$type\":\"com.atproto.repo.applyWrites#deleteResult\"}"), 271 - } 272 - } 273 - try out.writer.writeByte(']'); 274 - const body_out = try std.fmt.allocPrint( 275 - allocator, 276 - "{{\"commit\":{{\"cid\":{f},\"rev\":{f}}},\"results\":{s}}}", 277 - .{ std.json.fmt(result.commit.cid, .{}), std.json.fmt(result.commit.rev, .{}), out.written() }, 278 - ); 279 - sync.requestConfiguredCrawls(allocator); 280 - return http_api.json(request, .ok, body_out); 281 - } 282 - 283 - pub fn importRepo(request: *http.Server.Request) !void { 284 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 285 - defer arena.deinit(); 286 - const allocator = arena.allocator(); 287 - 288 - const account = requireAccount(request, allocator) catch return; 289 - const body = http_api.readBodyAlloc(request, allocator, 128 * 1024 * 1024) catch |err| switch (err) { 290 - error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "repo CAR is too large"), 291 - else => return err, 292 - }; 293 - 294 - const loaded = zat.loadCommitFromCAR(allocator, body) catch { 295 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid repo CAR"); 296 - }; 297 - if (!std.mem.eql(u8, loaded.commit.did, account.did)) { 298 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo DID does not match session"); 299 - } 300 - try verifyImportedRepoCar(request, allocator, account.did, body); 301 - 302 - const tree = zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid) catch { 303 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR is missing MST blocks"); 304 - }; 305 - 306 - var imported_records: std.ArrayList(store.ImportedRecord) = .empty; 307 - collectImportedRecords(allocator, loaded.repo_car, tree.root, &imported_records) catch { 308 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR contains records outside the atproto data model"); 309 - }; 310 - 311 - var imported_blocks: std.ArrayList(store.ImportedBlock) = .empty; 312 - for (loaded.repo_car.blocks) |block| { 313 - try imported_blocks.append(allocator, .{ 314 - .cid = try cidString(allocator, block.cid_raw), 315 - .data = block.data, 316 - }); 317 - } 318 - 319 - try store.importRepo( 320 - allocator, 321 - account, 322 - try cidString(allocator, loaded.commit_cid), 323 - loaded.commit.rev, 324 - imported_records.items, 325 - imported_blocks.items, 326 - ); 327 - sync.requestConfiguredCrawls(allocator); 328 - return http_api.json(request, .ok, "{}"); 329 - } 330 - 331 - fn verifyImportedRepoCar(request: *http.Server.Request, allocator: std.mem.Allocator, did: []const u8, body: []const u8) !void { 332 - var resolver = zat.DidResolver.init(store.currentIo(), allocator); 333 - defer resolver.deinit(); 334 - var doc = resolver.resolve(zat.Did.parse(did).?) catch { 335 - return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve repo did"); 336 - }; 337 - defer doc.deinit(); 338 - const signing_key = doc.signingKey() orelse { 339 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document is missing atproto signing key"); 340 - }; 341 - const key_bytes = zat.multibase.decode(allocator, signing_key.public_key_multibase) catch { 342 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document has invalid signing key"); 343 - }; 344 - const public_key = zat.multicodec.parsePublicKey(key_bytes) catch { 345 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document has invalid signing key"); 346 - }; 347 - _ = zat.verifyCommitCar(allocator, body, public_key, .{ 348 - .expected_did = did, 349 - .max_car_size = body.len, 350 - .max_blocks = body.len, 351 - }) catch { 352 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR verification failed"); 353 - }; 354 - } 355 - 356 - pub fn uploadBlob(io: std.Io, request: *http.Server.Request) !void { 357 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 358 - defer arena.deinit(); 359 - const allocator = arena.allocator(); 360 - 361 - const account = requireAccount(request, allocator) catch return; 362 - const mime_type = try allocator.dupe(u8, http_api.headerValue(request, "content-type") orelse "application/octet-stream"); 363 - const body = http_api.readBodyAlloc(request, allocator, config.blobUploadLimit()) catch |err| switch (err) { 364 - error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "blob is too large"), 365 - else => return err, 366 - }; 367 - const cid = store.putBlob(allocator, io, account, body, mime_type) catch |err| { 368 - return http_api.xrpcError(request, .internal_server_error, "InternalServerError", @errorName(err)); 369 - }; 370 - const body_out = try uploadBlobResponseJson(allocator, cid, mime_type, body.len); 371 - return http_api.json(request, .ok, body_out); 372 - } 373 - 374 - fn uploadBlobResponseJson( 375 - allocator: std.mem.Allocator, 376 - cid: []const u8, 377 - mime_type: []const u8, 378 - size: usize, 379 - ) ![]const u8 { 380 - const Link = struct { 381 - @"$link": []const u8, 382 - }; 383 - const Blob = struct { 384 - @"$type": []const u8, 385 - @"ref": Link, 386 - mimeType: []const u8, 387 - size: usize, 388 - }; 389 - const Response = struct { 390 - blob: Blob, 391 - }; 392 - var out: std.Io.Writer.Allocating = .init(allocator); 393 - defer out.deinit(); 394 - try std.json.Stringify.value(Response{ 395 - .blob = .{ 396 - .@"$type" = "blob", 397 - .@"ref" = .{ .@"$link" = cid }, 398 - .mimeType = mime_type, 399 - .size = size, 400 - }, 401 - }, .{}, &out.writer); 402 - return out.toOwnedSlice(); 403 - } 404 - 405 - pub fn listMissingBlobs(request: *http.Server.Request) !void { 406 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 407 - defer arena.deinit(); 408 - const allocator = arena.allocator(); 409 - 410 - const account = requireAccount(request, allocator) catch return; 411 - const body = try store.writeMissingBlobsJson(allocator, account.did, @min(http_api.queryLimit(request.head.target, 500), 1000)); 412 - return http_api.json(request, .ok, body); 413 - } 414 - 415 - fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 416 - return http_api.requireBearerAccount(request, allocator) catch |err| { 417 - switch (err) { 418 - error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 419 - error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 420 - } 421 - return error.HandledResponse; 422 - }; 423 - } 424 - 425 - fn requireRepoMatches(request: *http.Server.Request, account: auth.Account, value: std.json.Value) !void { 426 - const repo = zat.json.getString(value, "repo") orelse { 427 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 428 - }; 429 - if (std.mem.eql(u8, repo, account.did) or std.ascii.eqlIgnoreCase(repo, account.handle)) return; 430 - return http_api.xrpcError(request, .bad_request, "InvalidRepo", "Repo does not match authenticated user"); 431 - } 432 - 433 - fn recordBodyValue(value: std.json.Value) ?std.json.Value { 434 - return switch (value) { 435 - .object => |object| object.get("record"), 436 - else => null, 437 - }; 438 - } 439 - 440 - fn writeRecordRef(request: *http.Server.Request, allocator: std.mem.Allocator, record: store.Record, commit: store.CommitInfo) !void { 441 - const uri = try record.uri(allocator); 442 - const body_out = try std.fmt.allocPrint( 443 - allocator, 444 - "{{\"uri\":{f},\"cid\":{f},\"commit\":{{\"cid\":{f},\"rev\":{f}}}}}", 445 - .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), std.json.fmt(commit.cid, .{}), std.json.fmt(commit.rev, .{}) }, 446 - ); 447 - return http_api.json(request, .ok, body_out); 448 - } 449 - 450 - fn collectImportedRecords( 451 - allocator: std.mem.Allocator, 452 - repo_car: zat.car.Car, 453 - maybe_node: ?*zat.mst.Node, 454 - out: *std.ArrayList(store.ImportedRecord), 455 - ) anyerror!void { 456 - const node = maybe_node orelse return; 457 - try collectFromChild(allocator, repo_car, node.left, out); 458 - for (node.entries.items) |entry| { 459 - const block = zat.car.findBlock(repo_car, entry.value.raw) orelse return error.MissingRecordBlock; 460 - const record_value = try zat.cbor.decodeAll(allocator, block); 461 - const record_json = try cborToJson(allocator, record_value); 462 - var blobs: std.ArrayList([]const u8) = .empty; 463 - try collectBlobCids(allocator, record_value, &blobs); 464 - const slash = std.mem.indexOfScalar(u8, entry.key, '/') orelse return error.InvalidRepoPath; 465 - try out.append(allocator, .{ 466 - .collection = try allocator.dupe(u8, entry.key[0..slash]), 467 - .rkey = try allocator.dupe(u8, entry.key[slash + 1 ..]), 468 - .cid = try cidString(allocator, entry.value.raw), 469 - .value_json = record_json, 470 - .blob_cids = try blobs.toOwnedSlice(allocator), 471 - }); 472 - try collectFromChild(allocator, repo_car, entry.right, out); 473 - } 474 - } 475 - 476 - fn collectFromChild( 477 - allocator: std.mem.Allocator, 478 - repo_car: zat.car.Car, 479 - child: zat.mst.ChildRef, 480 - out: *std.ArrayList(store.ImportedRecord), 481 - ) anyerror!void { 482 - switch (child) { 483 - .none => {}, 484 - .stub => return error.PartialTree, 485 - .node => |node| try collectImportedRecords(allocator, repo_car, node, out), 486 - } 487 - } 488 - 489 - fn cborToJson(allocator: std.mem.Allocator, value: zat.cbor.Value) ![]const u8 { 490 - var out: std.Io.Writer.Allocating = .init(allocator); 491 - defer out.deinit(); 492 - try writeCborJson(allocator, &out.writer, value); 493 - return out.toOwnedSlice(); 494 - } 495 - 496 - fn writeCborJson(allocator: std.mem.Allocator, writer: anytype, value: zat.cbor.Value) !void { 497 - switch (value) { 498 - .unsigned => |v| try writer.print("{d}", .{v}), 499 - .negative => |v| try writer.print("{d}", .{v}), 500 - .bytes => |bytes| { 501 - const encoded = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len)); 502 - defer allocator.free(encoded); 503 - _ = std.base64.standard.Encoder.encode(encoded, bytes); 504 - try writer.print("{{\"$bytes\":{f}}}", .{std.json.fmt(encoded, .{})}); 505 - }, 506 - .text => |text| try writer.print("{f}", .{std.json.fmt(text, .{})}), 507 - .array => |items| { 508 - try writer.writeByte('['); 509 - for (items, 0..) |item, idx| { 510 - if (idx != 0) try writer.writeByte(','); 511 - try writeCborJson(allocator, writer, item); 512 - } 513 - try writer.writeByte(']'); 514 - }, 515 - .map => |entries| { 516 - try writer.writeByte('{'); 517 - for (entries, 0..) |entry, idx| { 518 - if (idx != 0) try writer.writeByte(','); 519 - try writer.print("{f}:", .{std.json.fmt(entry.key, .{})}); 520 - try writeCborJson(allocator, writer, entry.value); 521 - } 522 - try writer.writeByte('}'); 523 - }, 524 - .boolean => |v| try writer.print("{}", .{v}), 525 - .null => try writer.writeAll("null"), 526 - .cid => |cid| try writer.print("{{\"$link\":{f}}}", .{std.json.fmt(try cidString(allocator, cid.raw), .{})}), 527 - } 528 - } 529 - 530 - fn collectBlobCids(allocator: std.mem.Allocator, value: zat.cbor.Value, out: *std.ArrayList([]const u8)) !void { 531 - switch (value) { 532 - .map => |entries| { 533 - if (mapText(entries, "$type")) |type_name| { 534 - if (std.mem.eql(u8, type_name, "blob")) { 535 - if (mapValue(entries, "ref")) |ref| switch (ref) { 536 - .cid => |cid| try out.append(allocator, try cidString(allocator, cid.raw)), 537 - .map => |ref_entries| { 538 - if (mapText(ref_entries, "$link")) |link| try out.append(allocator, try allocator.dupe(u8, link)); 539 - }, 540 - else => {}, 541 - }; 542 - } 543 - } 544 - for (entries) |entry| try collectBlobCids(allocator, entry.value, out); 545 - }, 546 - .array => |items| for (items) |item| try collectBlobCids(allocator, item, out), 547 - else => {}, 548 - } 549 - } 550 - 551 - fn mapValue(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?zat.cbor.Value { 552 - for (entries) |entry| { 553 - if (std.mem.eql(u8, entry.key, key)) return entry.value; 554 - } 555 - return null; 556 - } 557 - 558 - fn mapText(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?[]const u8 { 559 - return switch (mapValue(entries, key) orelse return null) { 560 - .text => |text| text, 561 - else => null, 562 - }; 563 - } 564 - 565 - fn cidString(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { 566 - return zat.multibase.base32lower.encode(allocator, raw); 567 - }
-544
src/atproto/server.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const config = @import("../core/config.zig"); 4 - const log = @import("../core/log.zig"); 5 - const mail = @import("../core/mail.zig"); 6 - const plc = @import("plc.zig"); 7 - const sync = @import("sync.zig"); 8 - const http_api = @import("../http/api.zig"); 9 - const store = @import("../storage/store.zig"); 10 - const zat = @import("zat"); 11 - 12 - const http = std.http; 13 - 14 - pub fn describeServer(request: *http.Server.Request) !void { 15 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 16 - defer arena.deinit(); 17 - const domains = try jsonStringArray(arena.allocator(), config.handleDomains()); 18 - const body = try std.fmt.allocPrint( 19 - arena.allocator(), 20 - "{{\"did\":{f},\"availableUserDomains\":{s},\"inviteCodeRequired\":false}}", 21 - .{ std.json.fmt(config.serverDid(), .{}), domains }, 22 - ); 23 - return http_api.json(request, .ok, body); 24 - } 25 - 26 - pub fn didJson(request: *http.Server.Request) !void { 27 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 28 - defer arena.deinit(); 29 - const body = try std.fmt.allocPrint( 30 - arena.allocator(), 31 - "{{\"@context\":[\"https://www.w3.org/ns/did/v1\"],\"id\":{f},\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}}", 32 - .{ std.json.fmt(config.serverDid(), .{}), std.json.fmt(config.publicUrl(), .{}) }, 33 - ); 34 - return http_api.json(request, .ok, body); 35 - } 36 - 37 - pub fn atprotoDid(request: *http.Server.Request) !void { 38 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 39 - defer arena.deinit(); 40 - const allocator = arena.allocator(); 41 - const host = requestHost(request) orelse { 42 - return plain(request, .not_found, "User not found"); 43 - }; 44 - const handle = stripPort(host); 45 - const account = store.findAccount(allocator, handle) catch null orelse { 46 - return plain(request, .not_found, "User not found"); 47 - }; 48 - return plain(request, .ok, account.did); 49 - } 50 - 51 - pub fn createAccount(request: *http.Server.Request) !void { 52 - const authorization = http_api.headerValue(request, "authorization"); 53 - var body_buf: [8192]u8 = undefined; 54 - const body = try http_api.readBody(request, &body_buf); 55 - 56 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 57 - defer arena.deinit(); 58 - const allocator = arena.allocator(); 59 - 60 - const parsed = try http_api.parseJsonBody(request, allocator, body); 61 - const handle = zat.json.getString(parsed.value, "handle") orelse { 62 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); 63 - }; 64 - const email = zat.json.getString(parsed.value, "email") orelse { 65 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); 66 - }; 67 - const password = zat.json.getString(parsed.value, "password") orelse { 68 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password"); 69 - }; 70 - if (!isValidEmail(email)) { 71 - return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email"); 72 - } 73 - if (zat.Handle.parse(handle) == null) { 74 - return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid handle"); 75 - } 76 - 77 - const existing_did = zat.json.getString(parsed.value, "did"); 78 - const account = if (existing_did) |did| account: { 79 - if (zat.Did.parse(did) == null) { 80 - log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); 81 - return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); 82 - } 83 - log.debug("xrpc createAccount migration attempt did={s} handle={s}\n", .{ did, handle }); 84 - try verifyCreateAccountServiceAuth(request, allocator, authorization, did); 85 - break :account store.createAccount(allocator, handle, email, password, did, false) catch { 86 - log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); 87 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists"); 88 - }; 89 - } else account: { 90 - log.debug("xrpc createAccount genesis attempt handle={s}\n", .{handle}); 91 - const signing_key = store.generateAccountSigningKey() catch { 92 - log.err("xrpc createAccount failed signing_key handle={s}\n", .{handle}); 93 - return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to generate account signing key"); 94 - }; 95 - const keypair = zat.Keypair.fromSecretKey(.secp256k1, signing_key) catch { 96 - log.err("xrpc createAccount failed signing_key_parse handle={s}\n", .{handle}); 97 - return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to prepare account signing key"); 98 - }; 99 - const operation = plc.createGenesisOperation(allocator, handle, &keypair, &keypair) catch { 100 - log.err("xrpc createAccount failed plc_operation handle={s}\n", .{handle}); 101 - return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to create DID operation"); 102 - }; 103 - plc.submitOperation(allocator, operation.did, operation.json) catch { 104 - log.err("xrpc createAccount failed plc_submit did={s} handle={s}\n", .{ operation.did, handle }); 105 - return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected account DID operation"); 106 - }; 107 - break :account store.createAccountWithSigningKey(allocator, handle, email, password, operation.did, true, signing_key) catch { 108 - log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ operation.did, handle }); 109 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists"); 110 - }; 111 - }; 112 - log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle }); 113 - 114 - const access = try auth.createSessionJwt(allocator, "access", account); 115 - const refresh = try auth.createSessionJwt(allocator, "refresh", account); 116 - const body_out = try std.fmt.allocPrint( 117 - allocator, 118 - "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\"}}", 119 - .{ account.did, account.handle, access, refresh }, 120 - ); 121 - try http_api.json(request, .ok, body_out); 122 - } 123 - 124 - fn jsonStringArray(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { 125 - var out: std.Io.Writer.Allocating = .init(allocator); 126 - defer out.deinit(); 127 - try out.writer.writeByte('['); 128 - var parts = std.mem.splitScalar(u8, raw, ','); 129 - var index: usize = 0; 130 - while (parts.next()) |part| { 131 - const trimmed = std.mem.trim(u8, part, " \t\r\n"); 132 - if (trimmed.len == 0) continue; 133 - if (index != 0) try out.writer.writeByte(','); 134 - try out.writer.print("{f}", .{std.json.fmt(trimmed, .{})}); 135 - index += 1; 136 - } 137 - try out.writer.writeByte(']'); 138 - return out.toOwnedSlice(); 139 - } 140 - 141 - fn requestHost(request: *const http.Server.Request) ?[]const u8 { 142 - return http_api.headerValue(request, "host") orelse http_api.headerValue(request, ":authority"); 143 - } 144 - 145 - fn stripPort(host: []const u8) []const u8 { 146 - if (std.mem.startsWith(u8, host, "[")) return host; 147 - if (std.mem.lastIndexOfScalar(u8, host, ':')) |idx| return host[0..idx]; 148 - return host; 149 - } 150 - 151 - fn plain(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 152 - try request.respond(body, .{ 153 - .status = status, 154 - .extra_headers = &plain_headers, 155 - }); 156 - } 157 - 158 - const plain_headers = [_]http.Header{ 159 - .{ .name = "content-type", .value = "text/plain; charset=utf-8" }, 160 - .{ .name = "access-control-allow-origin", .value = "*" }, 161 - .{ .name = "connection", .value = "close" }, 162 - }; 163 - 164 - fn verifyCreateAccountServiceAuth(request: *http.Server.Request, allocator: std.mem.Allocator, maybe_authorization: ?[]const u8, did: []const u8) !void { 165 - const raw_header = maybe_authorization orelse { 166 - log.debug("xrpc createAccount service_auth missing did={s}\n", .{did}); 167 - return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); 168 - }; 169 - if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) { 170 - log.debug("xrpc createAccount service_auth malformed did={s}\n", .{did}); 171 - return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); 172 - } 173 - const token = std.mem.trim(u8, raw_header["bearer ".len..], " \t"); 174 - var jwt = zat.Jwt.parse(allocator, token) catch { 175 - log.debug("xrpc createAccount service_auth invalid_jwt did={s}\n", .{did}); 176 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "invalid service auth token"); 177 - }; 178 - defer jwt.deinit(); 179 - 180 - const issuer_did = issuerDid(jwt.payload.iss); 181 - if (!std.mem.eql(u8, issuer_did, did)) { 182 - log.debug("xrpc createAccount service_auth issuer_mismatch did={s} issuer={s}\n", .{ did, issuer_did }); 183 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt issuer does not match did"); 184 - } 185 - if (!std.mem.eql(u8, jwt.payload.aud, config.serverDid())) { 186 - log.debug("xrpc createAccount service_auth aud_mismatch did={s} aud={s} expected={s}\n", .{ did, jwt.payload.aud, config.serverDid() }); 187 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt audience does not match service did"); 188 - } 189 - if (jwt.isExpired(store.currentIo())) { 190 - log.debug("xrpc createAccount service_auth expired did={s}\n", .{did}); 191 - return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"); 192 - } 193 - if (jwt.payload.lxm) |lxm| { 194 - if (!std.mem.eql(u8, lxm, "com.atproto.server.createAccount") and !std.mem.eql(u8, lxm, "*")) { 195 - log.debug("xrpc createAccount service_auth lxm_mismatch did={s} lxm={s}\n", .{ did, lxm }); 196 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm does not match com.atproto.server.createAccount"); 197 - } 198 - } else { 199 - log.debug("xrpc createAccount service_auth missing_lxm did={s}\n", .{did}); 200 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm is required"); 201 - } 202 - 203 - var resolver = zat.DidResolver.init(store.currentIo(), allocator); 204 - defer resolver.deinit(); 205 - var doc = resolver.resolve(zat.Did.parse(issuer_did).?) catch { 206 - log.debug("xrpc createAccount service_auth did_resolution_failed did={s}\n", .{did}); 207 - return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did"); 208 - }; 209 - defer doc.deinit(); 210 - const signing_key = doc.signingKey() orelse { 211 - log.debug("xrpc createAccount service_auth missing_signing_key did={s}\n", .{did}); 212 - return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "missing signing key in issuer did doc"); 213 - }; 214 - jwt.verify(signing_key.public_key_multibase) catch { 215 - log.debug("xrpc createAccount service_auth signature_mismatch did={s}\n", .{did}); 216 - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt signature does not match jwt issuer"); 217 - }; 218 - log.debug("xrpc createAccount service_auth ok did={s}\n", .{did}); 219 - } 220 - 221 - fn issuerDid(iss: []const u8) []const u8 { 222 - return if (std.mem.indexOfScalar(u8, iss, '#')) |idx| iss[0..idx] else iss; 223 - } 224 - 225 - pub fn createSession(request: *http.Server.Request) !void { 226 - var body_buf: [4096]u8 = undefined; 227 - const body = try http_api.readBody(request, &body_buf); 228 - 229 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 230 - defer arena.deinit(); 231 - 232 - const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), body, .{}) catch { 233 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected JSON body with identifier and password"); 234 - }; 235 - const identifier = zat.json.getString(parsed.value, "identifier") orelse { 236 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing identifier"); 237 - }; 238 - const password = zat.json.getString(parsed.value, "password") orelse { 239 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password"); 240 - }; 241 - 242 - log.debug("xrpc createSession attempt identifier={s}\n", .{identifier}); 243 - const account = (store.findAccount(arena.allocator(), identifier) catch null) orelse { 244 - log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); 245 - return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 246 - }; 247 - if (!auth.passwordMatches(account, password)) { 248 - log.debug("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 249 - return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 250 - } 251 - log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 252 - 253 - const access = try auth.createSessionJwt(arena.allocator(), "access", account); 254 - const refresh = try auth.createSessionJwt(arena.allocator(), "refresh", account); 255 - const info = store.getEmailInfo(arena.allocator(), account.did) orelse { 256 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 257 - }; 258 - const active = store.isAccountActive(account.did); 259 - 260 - const body_out = try std.fmt.allocPrint( 261 - arena.allocator(), 262 - "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}", 263 - .{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active }, 264 - ); 265 - try http_api.json(request, .ok, body_out); 266 - } 267 - 268 - pub fn refreshSession(request: *http.Server.Request) !void { 269 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 270 - defer arena.deinit(); 271 - const allocator = arena.allocator(); 272 - 273 - const account = http_api.requireBearerAccountWithScope(request, allocator, "com.atproto.refresh") catch |err| switch (err) { 274 - error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 275 - error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 276 - }; 277 - const access = try auth.createSessionJwt(allocator, "access", account); 278 - const refresh = try auth.createSessionJwt(allocator, "refresh", account); 279 - const info = store.getEmailInfo(allocator, account.did) orelse { 280 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 281 - }; 282 - const active = store.isAccountActive(account.did); 283 - const body_out = try std.fmt.allocPrint( 284 - allocator, 285 - "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}", 286 - .{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active }, 287 - ); 288 - try http_api.json(request, .ok, body_out); 289 - } 290 - 291 - pub fn getSession(request: *http.Server.Request) !void { 292 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 293 - defer arena.deinit(); 294 - const allocator = arena.allocator(); 295 - 296 - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { 297 - error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 298 - error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 299 - }; 300 - 301 - const info = store.getEmailInfo(allocator, account.did) orelse { 302 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 303 - }; 304 - const active = store.isAccountActive(account.did); 305 - const body_out = try std.fmt.allocPrint( 306 - allocator, 307 - "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"active\":{}}}", 308 - .{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, active }, 309 - ); 310 - return http_api.json(request, .ok, body_out); 311 - } 312 - 313 - pub fn activateAccount(request: *http.Server.Request) !void { 314 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 315 - defer arena.deinit(); 316 - const allocator = arena.allocator(); 317 - const account = requireAccount(request, allocator) catch return; 318 - try store.setAccountActive(account.did, true); 319 - try store.sequenceAccountEvent(allocator, account.did, true); 320 - try store.sequenceIdentityEvent(allocator, account.did, account.handle); 321 - try store.sequenceSyncEvent(allocator, account.did); 322 - sync.requestConfiguredCrawls(allocator); 323 - return http_api.json(request, .ok, "{}"); 324 - } 325 - 326 - pub fn deactivateAccount(request: *http.Server.Request) !void { 327 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 328 - defer arena.deinit(); 329 - const allocator = arena.allocator(); 330 - const account = requireAccount(request, allocator) catch return; 331 - try store.setAccountActive(account.did, false); 332 - try store.sequenceAccountEvent(allocator, account.did, false); 333 - sync.requestConfiguredCrawls(allocator); 334 - return http_api.json(request, .ok, "{}"); 335 - } 336 - 337 - pub fn getServiceAuth(request: *http.Server.Request) !void { 338 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 339 - defer arena.deinit(); 340 - const allocator = arena.allocator(); 341 - 342 - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { 343 - error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 344 - error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 345 - }; 346 - 347 - var aud_buf: [256]u8 = undefined; 348 - const audience = http_api.queryParam(request.head.target, "aud", &aud_buf) orelse { 349 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing aud"); 350 - }; 351 - if (zat.Did.parse(audience) == null) { 352 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid aud"); 353 - } 354 - 355 - var lxm_buf: [256]u8 = undefined; 356 - const lxm = http_api.queryParam(request.head.target, "lxm", &lxm_buf); 357 - if (lxm) |method| { 358 - if (zat.Nsid.parse(method) == null) { 359 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid lxm"); 360 - } 361 - if (serviceAuthProtectedMethod(method)) { 362 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot request service auth for protected method"); 363 - } 364 - } 365 - 366 - var keypair = try store.signingKeypair(account.did); 367 - const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, lxm, &keypair); 368 - const body_out = try std.fmt.allocPrint(allocator, "{{\"token\":\"{s}\"}}", .{token}); 369 - return http_api.json(request, .ok, body_out); 370 - } 371 - 372 - fn serviceAuthProtectedMethod(method: []const u8) bool { 373 - const protected = [_][]const u8{ 374 - "com.atproto.admin.sendEmail", 375 - "com.atproto.identity.requestPlcOperationSignature", 376 - "com.atproto.identity.signPlcOperation", 377 - "com.atproto.identity.updateHandle", 378 - "com.atproto.server.activateAccount", 379 - "com.atproto.server.confirmEmail", 380 - "com.atproto.server.createAppPassword", 381 - "com.atproto.server.deactivateAccount", 382 - "com.atproto.server.getAccountInviteCodes", 383 - "com.atproto.server.getSession", 384 - "com.atproto.server.listAppPasswords", 385 - "com.atproto.server.requestAccountDelete", 386 - "com.atproto.server.requestEmailConfirmation", 387 - "com.atproto.server.requestEmailUpdate", 388 - "com.atproto.server.revokeAppPassword", 389 - "com.atproto.server.updateEmail", 390 - }; 391 - for (protected) |item| { 392 - if (std.ascii.eqlIgnoreCase(method, item)) return true; 393 - } 394 - return false; 395 - } 396 - 397 - pub fn requestEmailConfirmation(request: *http.Server.Request) !void { 398 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 399 - defer arena.deinit(); 400 - const allocator = arena.allocator(); 401 - const account = requireAccount(request, allocator) catch return; 402 - const info = store.getEmailInfo(allocator, account.did) orelse { 403 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 404 - }; 405 - if (info.email_confirmed) { 406 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email already confirmed"); 407 - } 408 - var code_buf: [11]u8 = undefined; 409 - const code = makeCode(&code_buf); 410 - try store.setAuthCode(account.did, code, expiresInTenMinutes()); 411 - try sendCode(allocator, info.email, account.handle, "Confirm email", "email confirmation", code); 412 - return http_api.json(request, .ok, "{}"); 413 - } 414 - 415 - pub fn confirmEmail(request: *http.Server.Request) !void { 416 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 417 - defer arena.deinit(); 418 - const allocator = arena.allocator(); 419 - const account = requireAccount(request, allocator) catch return; 420 - var body_buf: [4096]u8 = undefined; 421 - const body = try http_api.readBody(request, &body_buf); 422 - const parsed = try http_api.parseJsonBody(request, allocator, body); 423 - const email = zat.json.getString(parsed.value, "email") orelse { 424 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); 425 - }; 426 - const token = zat.json.getString(parsed.value, "token") orelse { 427 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing token"); 428 - }; 429 - const info = store.getEmailInfo(allocator, account.did) orelse { 430 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 431 - }; 432 - if (!std.ascii.eqlIgnoreCase(info.email, email)) { 433 - return http_api.xrpcError(request, .bad_request, "InvalidEmail", "email does not match"); 434 - } 435 - switch (store.validateAuthCode(account.did, token, store.nowMs())) { 436 - .valid => try store.confirmEmail(account.did), 437 - .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), 438 - .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), 439 - } 440 - return http_api.json(request, .ok, "{}"); 441 - } 442 - 443 - pub fn requestEmailUpdate(request: *http.Server.Request) !void { 444 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 445 - defer arena.deinit(); 446 - const allocator = arena.allocator(); 447 - const account = requireAccount(request, allocator) catch return; 448 - const info = store.getEmailInfo(allocator, account.did) orelse { 449 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 450 - }; 451 - if (!info.email_confirmed) { 452 - return http_api.json(request, .ok, "{\"tokenRequired\":false}"); 453 - } 454 - var code_buf: [11]u8 = undefined; 455 - const code = makeCode(&code_buf); 456 - try store.setAuthCode(account.did, code, expiresInTenMinutes()); 457 - try sendCode(allocator, info.email, account.handle, "Update email", "email update", code); 458 - return http_api.json(request, .ok, "{\"tokenRequired\":true}"); 459 - } 460 - 461 - pub fn updateEmail(request: *http.Server.Request) !void { 462 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 463 - defer arena.deinit(); 464 - const allocator = arena.allocator(); 465 - const account = requireAccount(request, allocator) catch return; 466 - var body_buf: [4096]u8 = undefined; 467 - const body = try http_api.readBody(request, &body_buf); 468 - const parsed = try http_api.parseJsonBody(request, allocator, body); 469 - const email = zat.json.getString(parsed.value, "email") orelse { 470 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); 471 - }; 472 - if (!isValidEmail(email)) { 473 - return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email"); 474 - } 475 - const info = store.getEmailInfo(allocator, account.did) orelse { 476 - return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 477 - }; 478 - if (info.email_confirmed) { 479 - const token = zat.json.getString(parsed.value, "token") orelse { 480 - return http_api.xrpcError(request, .bad_request, "TokenRequired", "confirmation token required"); 481 - }; 482 - switch (store.validateAuthCode(account.did, token, store.nowMs())) { 483 - .valid => {}, 484 - .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), 485 - .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), 486 - } 487 - } 488 - try store.updateEmail(account.did, email); 489 - return http_api.json(request, .ok, "{}"); 490 - } 491 - 492 - pub fn checkAccountStatus(request: *http.Server.Request) !void { 493 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 494 - defer arena.deinit(); 495 - const allocator = arena.allocator(); 496 - const account = requireAccount(request, allocator) catch return; 497 - const body = try store.writeAccountStatusJson(allocator, account.did); 498 - return http_api.json(request, .ok, body); 499 - } 500 - 501 - fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 502 - return http_api.requireBearerAccount(request, allocator) catch |err| { 503 - switch (err) { 504 - error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 505 - error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 506 - } 507 - return error.HandledResponse; 508 - }; 509 - } 510 - 511 - fn expiresInTenMinutes() i64 { 512 - return store.nowMs() + (10 * 60 * 1000); 513 - } 514 - 515 - fn makeCode(out: *[11]u8) []const u8 { 516 - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; 517 - var random: [10]u8 = undefined; 518 - store.randomBytes(&random); 519 - for (random, 0..) |byte, idx| { 520 - const write_idx = if (idx < 5) idx else idx + 1; 521 - out[write_idx] = alphabet[byte % alphabet.len]; 522 - } 523 - out[5] = '-'; 524 - return out[0..]; 525 - } 526 - 527 - fn sendCode( 528 - allocator: std.mem.Allocator, 529 - email: []const u8, 530 - handle: []const u8, 531 - subject: []const u8, 532 - label: []const u8, 533 - code: []const u8, 534 - ) !void { 535 - return mail.sendCode(store.currentIo(), allocator, email, handle, subject, label, code) catch { 536 - return error.EmailDeliveryFailed; 537 - }; 538 - } 539 - 540 - fn isValidEmail(email: []const u8) bool { 541 - const at = std.mem.indexOfScalar(u8, email, '@') orelse return false; 542 - if (at == 0 or at + 1 >= email.len) return false; 543 - return std.mem.indexOfScalar(u8, email[at + 1 ..], '.') != null; 544 - }
-209
src/atproto/sync.zig
··· 1 - const std = @import("std"); 2 - const config = @import("../core/config.zig"); 3 - const log = @import("../core/log.zig"); 4 - const http_api = @import("../http/api.zig"); 5 - const store = @import("../storage/store.zig"); 6 - const zat = @import("zat"); 7 - 8 - const http = std.http; 9 - 10 - pub fn getBlob(request: *http.Server.Request) !void { 11 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 12 - defer arena.deinit(); 13 - const allocator = arena.allocator(); 14 - 15 - var did_buf: [256]u8 = undefined; 16 - const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse { 17 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 18 - }; 19 - var cid_buf: [256]u8 = undefined; 20 - const cid = http_api.queryParam(request.head.target, "cid", &cid_buf) orelse { 21 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid"); 22 - }; 23 - const blob = store.getBlob(allocator, did, cid) orelse { 24 - return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); 25 - }; 26 - const headers = [_]http.Header{ 27 - .{ .name = "content-type", .value = blob.mime_type }, 28 - .{ .name = "cache-control", .value = "public, max-age=31536000, immutable" }, 29 - .{ .name = "x-content-type-options", .value = "nosniff" }, 30 - .{ .name = "access-control-allow-origin", .value = "*" }, 31 - .{ .name = "access-control-allow-private-network", .value = "true" }, 32 - .{ .name = "connection", .value = "close" }, 33 - }; 34 - try request.respond(blob.data, .{ 35 - .status = .ok, 36 - .extra_headers = &headers, 37 - }); 38 - } 39 - 40 - pub fn getRepo(request: *http.Server.Request) !void { 41 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 42 - defer arena.deinit(); 43 - const allocator = arena.allocator(); 44 - 45 - var did_buf: [256]u8 = undefined; 46 - const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse { 47 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 48 - }; 49 - const body = store.writeRepoCar(allocator, did) catch { 50 - return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 51 - }; 52 - const headers = [_]http.Header{ 53 - .{ .name = "content-type", .value = "application/vnd.ipld.car" }, 54 - .{ .name = "access-control-allow-origin", .value = "*" }, 55 - .{ .name = "access-control-allow-private-network", .value = "true" }, 56 - .{ .name = "connection", .value = "close" }, 57 - }; 58 - try request.respond(body, .{ 59 - .status = .ok, 60 - .extra_headers = &headers, 61 - }); 62 - } 63 - 64 - pub fn listBlobs(request: *http.Server.Request) !void { 65 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 66 - defer arena.deinit(); 67 - const allocator = arena.allocator(); 68 - 69 - var did_buf: [256]u8 = undefined; 70 - const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse { 71 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 72 - }; 73 - const body = try store.writeBlobListJson(allocator, did, @min(http_api.queryLimit(request.head.target, 500), 1000)); 74 - return http_api.json(request, .ok, body); 75 - } 76 - 77 - pub fn getLatestCommit(request: *http.Server.Request) !void { 78 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 79 - defer arena.deinit(); 80 - const allocator = arena.allocator(); 81 - 82 - var did_buf: [256]u8 = undefined; 83 - const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse { 84 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 85 - }; 86 - const body = store.writeLatestCommitJson(allocator, did) catch |err| switch (err) { 87 - error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 88 - else => return err, 89 - }; 90 - return http_api.json(request, .ok, body); 91 - } 92 - 93 - pub fn listRepos(request: *http.Server.Request) !void { 94 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 95 - defer arena.deinit(); 96 - const allocator = arena.allocator(); 97 - 98 - const body = try store.writeRepoListJson(allocator, http_api.queryLimit(request.head.target, 500)); 99 - return http_api.json(request, .ok, body); 100 - } 101 - 102 - pub fn subscribeRepos(request: *http.Server.Request) !void { 103 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 104 - defer arena.deinit(); 105 - const allocator = arena.allocator(); 106 - 107 - const key = switch (request.upgradeRequested()) { 108 - .websocket => |maybe_key| maybe_key orelse { 109 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing Sec-WebSocket-Key"); 110 - }, 111 - else => return http_api.xrpcError(request, .upgrade_required, "InvalidRequest", "Expected WebSocket upgrade"), 112 - }; 113 - 114 - var ws = try request.respondWebSocket(.{ .key = key }); 115 - try ws.flush(); 116 - 117 - var cursor_buf: [32]u8 = undefined; 118 - var cursor: u64 = if (http_api.queryParam(request.head.target, "cursor", &cursor_buf)) |raw| 119 - std.fmt.parseInt(u64, raw, 10) catch 0 120 - else 121 - 0; 122 - 123 - while (true) { 124 - var sent = false; 125 - { 126 - var batch_arena = std.heap.ArenaAllocator.init(allocator); 127 - defer batch_arena.deinit(); 128 - const events = try store.listSeqEvents(batch_arena.allocator(), cursor, 100); 129 - for (events) |event| { 130 - ws.writeMessage(event.frame, .binary) catch return; 131 - cursor = event.seq; 132 - sent = true; 133 - } 134 - } 135 - if (!sent) { 136 - store.currentIo().sleep(.fromSeconds(1), .awake) catch {}; 137 - } 138 - } 139 - } 140 - 141 - pub fn getRepoStatus(request: *http.Server.Request) !void { 142 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 143 - defer arena.deinit(); 144 - const allocator = arena.allocator(); 145 - 146 - var did_buf: [256]u8 = undefined; 147 - const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse { 148 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 149 - }; 150 - const body = store.writeRepoStatusJson(allocator, did) catch |err| switch (err) { 151 - error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 152 - else => return err, 153 - }; 154 - return http_api.json(request, .ok, body); 155 - } 156 - 157 - pub fn notifyOfUpdate(request: *http.Server.Request) !void { 158 - return http_api.json(request, .ok, "{}"); 159 - } 160 - 161 - pub fn requestCrawl(request: *http.Server.Request) !void { 162 - return http_api.json(request, .ok, "{}"); 163 - } 164 - 165 - pub fn requestConfiguredCrawls(allocator: std.mem.Allocator) void { 166 - const host = publicHostname(config.publicUrl()) orelse { 167 - log.err("sync request_crawl skipped invalid_public_url url={s}\n", .{config.publicUrl()}); 168 - return; 169 - }; 170 - var crawlers = std.mem.splitScalar(u8, config.crawlers(), ','); 171 - while (crawlers.next()) |crawler_raw| { 172 - const crawler = std.mem.trim(u8, crawler_raw, " \t\r\n/"); 173 - if (crawler.len == 0) continue; 174 - requestCrawler(allocator, crawler, host) catch |err| { 175 - log.err("sync request_crawl failed crawler={s} host={s} err={s}\n", .{ crawler, host, @errorName(err) }); 176 - continue; 177 - }; 178 - } 179 - } 180 - 181 - fn requestCrawler(allocator: std.mem.Allocator, crawler: []const u8, host: []const u8) !void { 182 - const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/com.atproto.sync.requestCrawl", .{crawler}); 183 - const payload = try std.fmt.allocPrint(allocator, "{{\"hostname\":{f}}}", .{std.json.fmt(host, .{})}); 184 - var transport = zat.HttpTransport.init(store.currentIo(), allocator); 185 - defer transport.deinit(); 186 - const result = try transport.fetch(.{ 187 - .url = url, 188 - .method = .POST, 189 - .payload = payload, 190 - .content_type = "application/json", 191 - .max_response_size = 64 * 1024, 192 - }); 193 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.CrawlerRejected; 194 - log.info("sync request_crawl ok crawler={s} host={s}\n", .{ crawler, host }); 195 - } 196 - 197 - fn publicHostname(url: []const u8) ?[]const u8 { 198 - const scheme = std.mem.indexOf(u8, url, "://") orelse return null; 199 - var rest = url[scheme + 3 ..]; 200 - if (std.mem.indexOfScalar(u8, rest, '/')) |slash| rest = rest[0..slash]; 201 - if (std.mem.indexOfScalar(u8, rest, '@')) |at| rest = rest[at + 1 ..]; 202 - if (rest.len == 0) return null; 203 - if (std.mem.startsWith(u8, rest, "[")) { 204 - const close = std.mem.indexOfScalar(u8, rest, ']') orelse return null; 205 - return rest[0 .. close + 1]; 206 - } 207 - if (std.mem.indexOfScalar(u8, rest, ':')) |colon| return rest[0..colon]; 208 - return rest; 209 - }
-218
src/auth/tokens.zig
··· 1 - const std = @import("std"); 2 - const config = @import("../core/config.zig"); 3 - const zat = @import("zat"); 4 - 5 - pub const Account = struct { 6 - handle: []const u8, 7 - did: []const u8, 8 - email: []const u8, 9 - password: []const u8, 10 - }; 11 - 12 - pub const TokenClaims = struct { 13 - did: []const u8, 14 - scope: []const u8, 15 - }; 16 - 17 - var jwt_counter: usize = 0; 18 - 19 - pub fn passwordMatches(account: Account, password: []const u8) bool { 20 - if (verifyPasswordHash(account.password, password)) return true; 21 - return std.mem.eql(u8, password, account.password); 22 - } 23 - 24 - pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8, salt: [16]u8) ![]const u8 { 25 - var derived: [32]u8 = undefined; 26 - try std.crypto.pwhash.pbkdf2(&derived, password, &salt, password_hash_rounds, std.crypto.auth.hmac.sha2.HmacSha256); 27 - const salt_hex = std.fmt.bytesToHex(salt, .lower); 28 - const derived_hex = std.fmt.bytesToHex(derived, .lower); 29 - return std.fmt.allocPrint(allocator, "pbkdf2-sha256:{d}:{s}:{s}", .{ password_hash_rounds, &salt_hex, &derived_hex }); 30 - } 31 - 32 - fn verifyPasswordHash(stored: []const u8, password: []const u8) bool { 33 - var parts = std.mem.splitScalar(u8, stored, ':'); 34 - const scheme = parts.next() orelse return false; 35 - if (!std.mem.eql(u8, scheme, "pbkdf2-sha256")) return false; 36 - const rounds_text = parts.next() orelse return false; 37 - const salt_hex = parts.next() orelse return false; 38 - const derived_hex = parts.next() orelse return false; 39 - if (parts.next() != null or salt_hex.len != 32 or derived_hex.len != 64) return false; 40 - const rounds = std.fmt.parseInt(u32, rounds_text, 10) catch return false; 41 - var salt: [16]u8 = undefined; 42 - _ = std.fmt.hexToBytes(&salt, salt_hex) catch return false; 43 - var expected: [32]u8 = undefined; 44 - _ = std.fmt.hexToBytes(&expected, derived_hex) catch return false; 45 - var actual: [32]u8 = undefined; 46 - std.crypto.pwhash.pbkdf2(&actual, password, &salt, rounds, std.crypto.auth.hmac.sha2.HmacSha256) catch return false; 47 - return std.crypto.timing_safe.eql([32]u8, actual, expected); 48 - } 49 - 50 - const password_hash_rounds: u32 = 100_000; 51 - 52 - pub fn createSessionJwt(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) ![]const u8 { 53 - const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; 54 - const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 55 - var ts: std.posix.timespec = undefined; 56 - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 57 - .SUCCESS => ts, 58 - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, 59 - }; 60 - const iat = timestamp.sec; 61 - const lifetime: i64 = if (std.mem.eql(u8, kind, "refresh")) 14 * 24 * 60 * 60 else 2 * 60 * 60; 62 - const exp = iat + lifetime; 63 - const payload = try std.fmt.allocPrint( 64 - allocator, 65 - "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":{d},\"exp\":{d},\"jti\":\"zds-{d}-{d}-{d}\"}}", 66 - .{ account.did, kind, iat, exp, timestamp.sec, timestamp.nsec, jti }, 67 - ); 68 - defer allocator.free(payload); 69 - 70 - const header_b64 = try zat.jwt.base64UrlEncode(allocator, header); 71 - defer allocator.free(header_b64); 72 - const payload_b64 = try zat.jwt.base64UrlEncode(allocator, payload); 73 - defer allocator.free(payload_b64); 74 - 75 - const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 }); 76 - defer allocator.free(signing_input); 77 - var signature: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 78 - std.crypto.auth.hmac.sha2.HmacSha256.create(&signature, signing_input, config.jwtSecret()); 79 - const signature_b64 = try zat.jwt.base64UrlEncode(allocator, &signature); 80 - defer allocator.free(signature_b64); 81 - return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, signature_b64 }); 82 - } 83 - 84 - pub fn createServiceJwt( 85 - allocator: std.mem.Allocator, 86 - account: Account, 87 - audience: []const u8, 88 - lexicon_method: ?[]const u8, 89 - ) ![]const u8 { 90 - const keypair = try serviceKeypair(); 91 - return createServiceJwtWithKeypair(allocator, account, audience, lexicon_method, &keypair); 92 - } 93 - 94 - pub fn createServiceJwtWithKeypair( 95 - allocator: std.mem.Allocator, 96 - account: Account, 97 - audience: []const u8, 98 - lexicon_method: ?[]const u8, 99 - keypair: *const zat.Keypair, 100 - ) ![]const u8 { 101 - const header = try std.fmt.allocPrint( 102 - allocator, 103 - "{{\"typ\":\"JWT\",\"alg\":\"{s}\"}}", 104 - .{@tagName(keypair.algorithm())}, 105 - ); 106 - defer allocator.free(header); 107 - const iat = unixNow(); 108 - const exp = iat + 60; 109 - const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 110 - 111 - const payload = if (lexicon_method) |lxm| 112 - try std.fmt.allocPrint( 113 - allocator, 114 - "{{\"iss\":\"{s}\",\"sub\":\"{s}\",\"aud\":\"{s}\",\"exp\":{d},\"iat\":{d},\"jti\":\"zds-{d}-{d}\",\"lxm\":\"{s}\"}}", 115 - .{ account.did, account.did, audience, exp, iat, iat, jti, lxm }, 116 - ) 117 - else 118 - try std.fmt.allocPrint( 119 - allocator, 120 - "{{\"iss\":\"{s}\",\"sub\":\"{s}\",\"aud\":\"{s}\",\"exp\":{d},\"iat\":{d},\"jti\":\"zds-{d}-{d}\"}}", 121 - .{ account.did, account.did, audience, exp, iat, iat, jti }, 122 - ); 123 - defer allocator.free(payload); 124 - 125 - return zat.oauth.createJwt(allocator, header, payload, keypair); 126 - } 127 - 128 - fn unixNow() i64 { 129 - var ts: std.posix.timespec = undefined; 130 - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 131 - .SUCCESS => ts.sec, 132 - else => 0, 133 - }; 134 - } 135 - 136 - pub fn serviceKeypair() !zat.Keypair { 137 - return zat.Keypair.fromSecretKey(.p256, .{ 138 - 0x01, 0x7a, 0x64, 0x73, 0x2d, 0x64, 0x65, 0x76, 139 - 0x2d, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 140 - 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x6b, 0x65, 141 - 0x79, 0x2d, 0x30, 0x30, 0x30, 0x30, 0x30, 0x31, 142 - }); 143 - } 144 - 145 - pub fn subjectFromSessionJwt(allocator: std.mem.Allocator, token: []const u8) ?[]const u8 { 146 - const claims = claimsFromSessionJwt(allocator, token) orelse return null; 147 - allocator.free(claims.scope); 148 - return claims.did; 149 - } 150 - 151 - pub fn claimsFromSessionJwt(allocator: std.mem.Allocator, token: []const u8) ?TokenClaims { 152 - var parts = std.mem.splitScalar(u8, token, '.'); 153 - const header_part = parts.next() orelse return null; 154 - const payload_part = parts.next() orelse return null; 155 - const signature_part = parts.next() orelse return null; 156 - if (payload_part.len == 0 or signature_part.len == 0 or parts.next() != null) return null; 157 - 158 - const signing_input_len = header_part.len + 1 + payload_part.len; 159 - if (token.len < signing_input_len or token[header_part.len] != '.') return null; 160 - const signing_input = token[0..signing_input_len]; 161 - 162 - const header_json = zat.jwt.base64UrlDecode(allocator, header_part) catch return null; 163 - defer allocator.free(header_json); 164 - const header = std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}) catch return null; 165 - defer header.deinit(); 166 - const alg = zat.json.getString(header.value, "alg") orelse return null; 167 - if (!std.mem.eql(u8, alg, "HS256")) return null; 168 - 169 - const signature = zat.jwt.base64UrlDecode(allocator, signature_part) catch return null; 170 - defer allocator.free(signature); 171 - if (signature.len != std.crypto.auth.hmac.sha2.HmacSha256.mac_length) return null; 172 - var expected: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 173 - std.crypto.auth.hmac.sha2.HmacSha256.create(&expected, signing_input, config.jwtSecret()); 174 - var actual: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 175 - @memcpy(&actual, signature); 176 - if (!std.crypto.timing_safe.eql([std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8, actual, expected)) return null; 177 - 178 - const payload = zat.jwt.base64UrlDecode(allocator, payload_part) catch return null; 179 - defer allocator.free(payload); 180 - 181 - const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null; 182 - defer parsed.deinit(); 183 - const subject = zat.json.getString(parsed.value, "sub") orelse return null; 184 - if (zat.Did.parse(subject) == null) return null; 185 - const scope = zat.json.getString(parsed.value, "scope") orelse return null; 186 - if (!std.mem.startsWith(u8, scope, "com.atproto.")) return null; 187 - return .{ 188 - .did = allocator.dupe(u8, subject) catch return null, 189 - .scope = allocator.dupe(u8, scope) catch return null, 190 - }; 191 - } 192 - 193 - test "writes jwt-shaped session token" { 194 - const alice = testAccount(); 195 - const token = try createSessionJwt(std.testing.allocator, "access", alice); 196 - defer std.testing.allocator.free(token); 197 - try std.testing.expect(std.mem.count(u8, token, ".") == 2); 198 - const claims = claimsFromSessionJwt(std.testing.allocator, token).?; 199 - defer std.testing.allocator.free(claims.did); 200 - defer std.testing.allocator.free(claims.scope); 201 - try std.testing.expectEqualStrings(alice.did, claims.did); 202 - } 203 - 204 - test "creates signed service auth token" { 205 - const alice = testAccount(); 206 - const token = try createServiceJwt(std.testing.allocator, alice, "did:plc:service", "xyz.fake.inbox.send"); 207 - defer std.testing.allocator.free(token); 208 - try std.testing.expect(std.mem.count(u8, token, ".") == 2); 209 - } 210 - 211 - fn testAccount() Account { 212 - return .{ 213 - .handle = "alice.test", 214 - .did = "did:plc:cmadossymmii3izkabdbp5en", 215 - .email = "alice@test.com", 216 - .password = "password", 217 - }; 218 - }
-68
src/core/atid.zig
··· 1 - const std = @import("std"); 2 - const zat = @import("zat"); 3 - 4 - pub const Error = error{ 5 - ClockIdOutOfRange, 6 - TidTooShort, 7 - TidTooLong, 8 - InvalidTidChar, 9 - }; 10 - 11 - pub const encoded_len = 13; 12 - const alphabet = "234567abcdefghijklmnopqrstuvwxyz"; 13 - 14 - /// ATProto TID generator. 15 - /// 16 - /// A TID is a 13-character sortable base32 string containing a microsecond 17 - /// timestamp plus a 10-bit clock id. This module does not own clock state; the 18 - /// caller provides time and clock id so tests and storage layers can control 19 - /// monotonicity. 20 - pub fn encode(timestamp_us: u64, clock_id: u16) Error![encoded_len]u8 { 21 - if (clock_id > 1023) return Error.ClockIdOutOfRange; 22 - return zat.Tid.fromTimestamp(timestamp_us, @intCast(clock_id)).raw; 23 - } 24 - 25 - pub fn decode(tid: []const u8) Error!u64 { 26 - if (tid.len < encoded_len) return Error.TidTooShort; 27 - if (tid.len > encoded_len) return Error.TidTooLong; 28 - 29 - const parsed = zat.Tid.parse(tid) orelse return Error.InvalidTidChar; 30 - return (parsed.timestamp() << 10) | parsed.clockId(); 31 - } 32 - 33 - pub fn timestampMicros(tid: []const u8) Error!u64 { 34 - if (tid.len < encoded_len) return Error.TidTooShort; 35 - if (tid.len > encoded_len) return Error.TidTooLong; 36 - return (zat.Tid.parse(tid) orelse return Error.InvalidTidChar).timestamp(); 37 - } 38 - 39 - pub fn clockId(tid: []const u8) Error!u16 { 40 - if (tid.len < encoded_len) return Error.TidTooShort; 41 - if (tid.len > encoded_len) return Error.TidTooLong; 42 - return (zat.Tid.parse(tid) orelse return Error.InvalidTidChar).clockId(); 43 - } 44 - 45 - fn decodeChar(c: u8) Error!u6 { 46 - for (alphabet, 0..) |candidate, i| { 47 - if (candidate == c) return @intCast(i); 48 - } 49 - return Error.InvalidTidChar; 50 - } 51 - 52 - test "encodes sortable tids" { 53 - const a = try encode(1_700_000_000_000_000, 0); 54 - const b = try encode(1_700_000_000_000_001, 0); 55 - try std.testing.expect(std.mem.lessThan(u8, &a, &b)); 56 - } 57 - 58 - test "round trips timestamp and clock id" { 59 - const tid = try encode(1_700_000_123_456_789, 42); 60 - try std.testing.expectEqual(@as(u64, 1_700_000_123_456_789), try timestampMicros(&tid)); 61 - try std.testing.expectEqual(@as(u16, 42), try clockId(&tid)); 62 - } 63 - 64 - test "rejects malformed tids" { 65 - try std.testing.expectError(Error.TidTooShort, decode("abc")); 66 - try std.testing.expectError(Error.TidTooLong, decode("abcdefghijklmn")); 67 - try std.testing.expectError(Error.InvalidTidChar, decode("0000000000000")); 68 - }
-131
src/core/config.zig
··· 1 - const std = @import("std"); 2 - 3 - var public_url_value: []const u8 = "http://localhost:2583"; 4 - var server_did_value: []const u8 = "did:web:localhost"; 5 - var plc_directory_value: []const u8 = "https://plc.directory"; 6 - var resend_api_key_value: ?[]const u8 = null; 7 - var email_from_value: ?[]const u8 = null; 8 - var blob_upload_limit_value: usize = 100_000_000; 9 - var blobstore_path_value: []const u8 = "dev/blobs"; 10 - var handle_domains_value: []const u8 = ".test"; 11 - var crawlers_value: []const u8 = "https://bsky.network,https://vsky.network"; 12 - var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; 13 - var log_level_value: LogLevel = .info; 14 - 15 - pub const LogLevel = enum(u2) { 16 - err = 0, 17 - info = 1, 18 - debug = 2, 19 - }; 20 - 21 - pub fn publicUrl() []const u8 { 22 - return public_url_value; 23 - } 24 - 25 - pub fn serverDid() []const u8 { 26 - return server_did_value; 27 - } 28 - 29 - pub fn plcDirectory() []const u8 { 30 - return plc_directory_value; 31 - } 32 - 33 - pub fn resendApiKey() ?[]const u8 { 34 - return resend_api_key_value; 35 - } 36 - 37 - pub fn emailFrom() ?[]const u8 { 38 - return email_from_value; 39 - } 40 - 41 - pub fn blobUploadLimit() usize { 42 - return blob_upload_limit_value; 43 - } 44 - 45 - pub fn blobstorePath() []const u8 { 46 - return blobstore_path_value; 47 - } 48 - 49 - pub fn handleDomains() []const u8 { 50 - return handle_domains_value; 51 - } 52 - 53 - pub fn crawlers() []const u8 { 54 - return crawlers_value; 55 - } 56 - 57 - pub fn jwtSecret() []const u8 { 58 - return jwt_secret_value; 59 - } 60 - 61 - pub fn logLevel() LogLevel { 62 - return log_level_value; 63 - } 64 - 65 - pub fn logEnabled(level: LogLevel) bool { 66 - return @intFromEnum(level) <= @intFromEnum(log_level_value); 67 - } 68 - 69 - pub fn setPublicUrl(value: []const u8) void { 70 - public_url_value = trimTrailingSlash(value); 71 - } 72 - 73 - pub fn setServerDid(value: []const u8) void { 74 - server_did_value = value; 75 - } 76 - 77 - pub fn setPlcDirectory(value: []const u8) void { 78 - plc_directory_value = trimTrailingSlash(value); 79 - } 80 - 81 - pub fn setResendApiKey(value: ?[]const u8) void { 82 - resend_api_key_value = value; 83 - } 84 - 85 - pub fn setEmailFrom(value: ?[]const u8) void { 86 - email_from_value = value; 87 - } 88 - 89 - pub fn setBlobUploadLimit(value: usize) void { 90 - blob_upload_limit_value = value; 91 - } 92 - 93 - pub fn setBlobstorePath(value: []const u8) void { 94 - blobstore_path_value = trimTrailingSlash(value); 95 - } 96 - 97 - pub fn setHandleDomains(value: []const u8) void { 98 - handle_domains_value = value; 99 - } 100 - 101 - pub fn setCrawlers(value: []const u8) void { 102 - crawlers_value = value; 103 - } 104 - 105 - pub fn setJwtSecret(value: []const u8) void { 106 - jwt_secret_value = value; 107 - } 108 - 109 - pub fn setLogLevel(value: LogLevel) void { 110 - log_level_value = value; 111 - } 112 - 113 - pub fn parseLogLevel(value: []const u8) ?LogLevel { 114 - if (std.ascii.eqlIgnoreCase(value, "error")) return .err; 115 - if (std.ascii.eqlIgnoreCase(value, "err")) return .err; 116 - if (std.ascii.eqlIgnoreCase(value, "info")) return .info; 117 - if (std.ascii.eqlIgnoreCase(value, "debug")) return .debug; 118 - return null; 119 - } 120 - 121 - fn trimTrailingSlash(value: []const u8) []const u8 { 122 - if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1]; 123 - return value; 124 - } 125 - 126 - test "trims public URL slash" { 127 - const before = publicUrl(); 128 - defer setPublicUrl(before); 129 - setPublicUrl("https://example.com/"); 130 - try std.testing.expectEqualStrings("https://example.com", publicUrl()); 131 - }
-42
src/core/log.zig
··· 1 - const std = @import("std"); 2 - const config = @import("config.zig"); 3 - 4 - var mutex: std.atomic.Mutex = .unlocked; 5 - 6 - pub fn debug(comptime fmt: []const u8, args: anytype) void { 7 - write(.debug, "debug", fmt, args); 8 - } 9 - 10 - pub fn info(comptime fmt: []const u8, args: anytype) void { 11 - write(.info, "info", fmt, args); 12 - } 13 - 14 - pub fn err(comptime fmt: []const u8, args: anytype) void { 15 - write(.err, "error", fmt, args); 16 - } 17 - 18 - fn write(level: config.LogLevel, comptime label: []const u8, comptime fmt: []const u8, args: anytype) void { 19 - if (!config.logEnabled(level)) return; 20 - 21 - var buf: [2048]u8 = undefined; 22 - const message = std.fmt.bufPrint(&buf, label ++ " " ++ fmt, args) catch blk: { 23 - const prefix = label ++ " "; 24 - @memcpy(buf[0..prefix.len], prefix); 25 - const suffix = "log message too large\n"; 26 - @memcpy(buf[prefix.len..][0..suffix.len], suffix); 27 - break :blk buf[0 .. prefix.len + suffix.len]; 28 - }; 29 - 30 - while (!mutex.tryLock()) std.Thread.yield() catch {}; 31 - defer mutex.unlock(); 32 - writeAllStderr(message); 33 - } 34 - 35 - fn writeAllStderr(message: []const u8) void { 36 - var remaining = message; 37 - while (remaining.len > 0) { 38 - const written = std.c.write(std.posix.STDERR_FILENO, remaining.ptr, remaining.len); 39 - if (written <= 0) return; 40 - remaining = remaining[@intCast(written)..]; 41 - } 42 - }
-61
src/core/mail.zig
··· 1 - const std = @import("std"); 2 - const config = @import("config.zig"); 3 - const log = @import("log.zig"); 4 - const zat = @import("zat"); 5 - 6 - pub fn send( 7 - io: std.Io, 8 - allocator: std.mem.Allocator, 9 - to: []const u8, 10 - handle: []const u8, 11 - subject: []const u8, 12 - text: []const u8, 13 - ) !void { 14 - const api_key = config.resendApiKey() orelse { 15 - logMail(subject, to, handle, text); 16 - return; 17 - }; 18 - const from = config.emailFrom() orelse { 19 - logMail(subject, to, handle, text); 20 - return; 21 - }; 22 - 23 - const payload = try std.fmt.allocPrint( 24 - allocator, 25 - "{{\"from\":{f},\"to\":[{f}],\"subject\":{f},\"text\":{f}}}", 26 - .{ std.json.fmt(from, .{}), std.json.fmt(to, .{}), std.json.fmt(subject, .{}), std.json.fmt(text, .{}) }, 27 - ); 28 - const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key}); 29 - var transport = zat.HttpTransport.init(io, allocator); 30 - defer transport.deinit(); 31 - const result = try transport.fetch(.{ 32 - .url = "https://api.resend.com/emails", 33 - .method = .POST, 34 - .payload = payload, 35 - .authorization = authorization, 36 - .content_type = "application/json", 37 - .max_response_size = 64 * 1024, 38 - }); 39 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.EmailDeliveryFailed; 40 - } 41 - 42 - pub fn sendCode( 43 - io: std.Io, 44 - allocator: std.mem.Allocator, 45 - to: []const u8, 46 - handle: []const u8, 47 - subject: []const u8, 48 - label: []const u8, 49 - code: []const u8, 50 - ) !void { 51 - const text = try std.fmt.allocPrint( 52 - allocator, 53 - "Hello {s}. Your {s} code is {s}. This code will expire in ten minutes.", 54 - .{ handle, label, code }, 55 - ); 56 - return send(io, allocator, to, handle, subject, text); 57 - } 58 - 59 - fn logMail(subject: []const u8, email: []const u8, handle: []const u8, text: []const u8) void { 60 - log.info("[zds mail] {s} for {s} <{s}>: {s}\n", .{ subject, handle, email, text }); 61 - }
-88
src/core/repo.zig
··· 1 - const std = @import("std"); 2 - const zat = @import("zat"); 3 - 4 - pub const Error = error{ 5 - InvalidAtUri, 6 - InvalidCollection, 7 - InvalidRepoPath, 8 - InvalidRecordKey, 9 - MissingCollection, 10 - MissingRecordKey, 11 - }; 12 - 13 - pub const RecordRef = struct { 14 - uri: zat.AtUri, 15 - cid: ?[]const u8 = null, 16 - 17 - pub fn parse(uri: []const u8, cid: ?[]const u8) Error!RecordRef { 18 - const parsed = zat.AtUri.parse(uri) orelse return Error.InvalidAtUri; 19 - if (!parsed.hasCollection()) return Error.MissingCollection; 20 - if (!parsed.hasRkey()) return Error.MissingRecordKey; 21 - return .{ .uri = parsed, .cid = cid }; 22 - } 23 - }; 24 - 25 - pub const RepoPath = struct { 26 - raw: []const u8, 27 - slash: usize, 28 - 29 - pub fn parse(path: []const u8) Error!RepoPath { 30 - const slash = std.mem.indexOfScalar(u8, path, '/') orelse return Error.InvalidRepoPath; 31 - if (std.mem.indexOfScalarPos(u8, path, slash + 1, '/') != null) return Error.InvalidRepoPath; 32 - if (zat.Nsid.parse(path[0..slash]) == null) return Error.InvalidCollection; 33 - if (zat.Rkey.parse(path[slash + 1 ..]) == null) return Error.InvalidRecordKey; 34 - return .{ .raw = path, .slash = slash }; 35 - } 36 - 37 - pub fn collection(self: RepoPath) []const u8 { 38 - return self.raw[0..self.slash]; 39 - } 40 - 41 - pub fn rkey(self: RepoPath) []const u8 { 42 - return self.raw[self.slash + 1 ..]; 43 - } 44 - }; 45 - 46 - pub const WriteAction = enum { 47 - create, 48 - update, 49 - delete, 50 - }; 51 - 52 - pub const WriteOp = struct { 53 - action: WriteAction, 54 - path: RepoPath, 55 - cid: ?[]const u8 = null, 56 - prev: ?[]const u8 = null, 57 - 58 - pub fn create(path: []const u8, cid: []const u8) Error!WriteOp { 59 - return .{ .action = .create, .path = try RepoPath.parse(path), .cid = cid }; 60 - } 61 - 62 - pub fn update(path: []const u8, cid: []const u8, prev: []const u8) Error!WriteOp { 63 - return .{ .action = .update, .path = try RepoPath.parse(path), .cid = cid, .prev = prev }; 64 - } 65 - 66 - pub fn delete(path: []const u8, prev: []const u8) Error!WriteOp { 67 - return .{ .action = .delete, .path = try RepoPath.parse(path), .prev = prev }; 68 - } 69 - }; 70 - 71 - test "parses repo paths" { 72 - const path = try RepoPath.parse("app.bsky.feed.post/3jxtb5w2hkt2m"); 73 - try std.testing.expectEqualStrings("app.bsky.feed.post", path.collection()); 74 - try std.testing.expectEqualStrings("3jxtb5w2hkt2m", path.rkey()); 75 - } 76 - 77 - test "requires full record refs" { 78 - const ref = try RecordRef.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jxtb5w2hkt2m", "bafyreiblah"); 79 - try std.testing.expectEqualStrings("app.bsky.feed.post", ref.uri.collection().?); 80 - try std.testing.expectError(Error.MissingRecordKey, RecordRef.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post", null)); 81 - } 82 - 83 - test "models sync write ops" { 84 - const op = try WriteOp.update("app.bsky.feed.post/3jxtb5w2hkt2m", "new", "old"); 85 - try std.testing.expectEqual(.update, op.action); 86 - try std.testing.expectEqualStrings("old", op.prev.?); 87 - } 88 -
-52
src/core/syntax.zig
··· 1 - const std = @import("std"); 2 - const zat = @import("zat"); 3 - 4 - pub const Error = error{ 5 - Empty, 6 - TooLong, 7 - InvalidChar, 8 - InvalidDid, 9 - InvalidNsid, 10 - InvalidRecordKey, 11 - }; 12 - 13 - pub fn validateDid(did: []const u8) Error!void { 14 - if (did.len == 0) return Error.Empty; 15 - if (did.len > 2048) return Error.TooLong; 16 - if (zat.Did.parse(did) == null) return Error.InvalidDid; 17 - } 18 - 19 - pub fn validateNsid(nsid: []const u8) Error!void { 20 - if (nsid.len == 0) return Error.Empty; 21 - if (nsid.len > zat.Nsid.max_length) return Error.TooLong; 22 - if (zat.Nsid.parse(nsid) == null) return Error.InvalidNsid; 23 - } 24 - 25 - pub fn validateRecordKey(rkey: []const u8) Error!void { 26 - if (rkey.len == 0) return Error.Empty; 27 - if (rkey.len > zat.Rkey.max_length) return Error.TooLong; 28 - if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; 29 - } 30 - 31 - pub fn validateRepoPath(path: []const u8) Error!void { 32 - const slash = std.mem.indexOfScalar(u8, path, '/') orelse return Error.InvalidRecordKey; 33 - if (std.mem.indexOfScalarPos(u8, path, slash + 1, '/') != null) return Error.InvalidRecordKey; 34 - try validateNsid(path[0..slash]); 35 - try validateRecordKey(path[slash + 1 ..]); 36 - } 37 - 38 - test "validates common atproto syntax" { 39 - try validateDid("did:plc:abc123"); 40 - try validateDid("did:web:example.com"); 41 - try validateNsid("com.atproto.repo"); 42 - try validateNsid("app.bsky.feed.post"); 43 - try validateRecordKey("3jzfcijpj2z23"); 44 - try validateRepoPath("app.bsky.feed.post/3jzfcijpj2z23"); 45 - } 46 - 47 - test "rejects invalid syntax" { 48 - try std.testing.expectError(Error.InvalidDid, validateDid("plc:abc")); 49 - try std.testing.expectError(Error.InvalidNsid, validateNsid("com")); 50 - try std.testing.expectError(Error.InvalidNsid, validateNsid("com..repo")); 51 - try std.testing.expectError(Error.InvalidRecordKey, validateRecordKey("bad/key")); 52 - }
-68
src/core/xrpc.zig
··· 1 - const std = @import("std"); 2 - const syntax = @import("syntax.zig"); 3 - 4 - pub const healthPath = "/xrpc/_health"; 5 - 6 - pub const Error = error{ 7 - NotXrpc, 8 - MissingMethod, 9 - InvalidMethod, 10 - }; 11 - 12 - pub const Method = struct { 13 - nsid: []const u8, 14 - 15 - pub fn isHealth(self: Method) bool { 16 - return std.mem.eql(u8, self.nsid, "_health"); 17 - } 18 - }; 19 - 20 - pub fn parsePath(path: []const u8) Error!Method { 21 - const prefix = "/xrpc/"; 22 - if (!std.mem.startsWith(u8, path, prefix)) return Error.NotXrpc; 23 - const rest = path[prefix.len..]; 24 - if (rest.len == 0) return Error.MissingMethod; 25 - if (std.mem.indexOfScalar(u8, rest, '/') != null) return Error.InvalidMethod; 26 - if (std.mem.indexOfScalar(u8, rest, '?')) |query| { 27 - return parseMethod(rest[0..query]); 28 - } 29 - return parseMethod(rest); 30 - } 31 - 32 - fn parseMethod(method: []const u8) Error!Method { 33 - if (std.mem.eql(u8, method, "_health")) return .{ .nsid = method }; 34 - syntax.validateNsid(method) catch return Error.InvalidMethod; 35 - return .{ .nsid = method }; 36 - } 37 - 38 - pub fn methodKind(method: []const u8) Error!enum { query, procedure, subscription, health } { 39 - const parsed = try parseMethod(method); 40 - if (parsed.isHealth()) return .health; 41 - 42 - if (std.mem.endsWith(u8, method, "subscribeRepos")) return .subscription; 43 - if (std.mem.indexOf(u8, method, ".get") != null) return .query; 44 - if (std.mem.indexOf(u8, method, ".list") != null) return .query; 45 - if (std.mem.indexOf(u8, method, ".describe") != null) return .query; 46 - if (std.mem.indexOf(u8, method, ".resolve") != null) return .query; 47 - return .procedure; 48 - } 49 - 50 - test "parses xrpc paths" { 51 - try std.testing.expect((try parsePath("/xrpc/_health")).isHealth()); 52 - try std.testing.expectEqualStrings("com.atproto.repo.getRecord", (try parsePath("/xrpc/com.atproto.repo.getRecord")).nsid); 53 - } 54 - 55 - test "classifies method names" { 56 - try std.testing.expectEqual(.health, try methodKind("_health")); 57 - try std.testing.expectEqual(.query, try methodKind("com.atproto.repo.getRecord")); 58 - try std.testing.expectEqual(.query, try methodKind("com.atproto.repo.listRecords")); 59 - try std.testing.expectEqual(.subscription, try methodKind("com.atproto.sync.subscribeRepos")); 60 - try std.testing.expectEqual(.procedure, try methodKind("com.atproto.repo.createRecord")); 61 - } 62 - 63 - test "rejects non-xrpc paths" { 64 - try std.testing.expectError(Error.NotXrpc, parsePath("/")); 65 - try std.testing.expectError(Error.MissingMethod, parsePath("/xrpc/")); 66 - try std.testing.expectError(Error.InvalidMethod, parsePath("/xrpc/com")); 67 - } 68 -
-243
src/http/api.zig
··· 1 - const std = @import("std"); 2 - const auth = @import("../auth/tokens.zig"); 3 - const store = @import("../storage/store.zig"); 4 - 5 - const http = std.http; 6 - 7 - pub fn requireBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 8 - return requireBearerAccountWithScope(request, allocator, "com.atproto.access"); 9 - } 10 - 11 - pub fn requireBearerAccountWithScope(request: *const http.Server.Request, allocator: std.mem.Allocator, scope: []const u8) !auth.Account { 12 - const auth_header = headerValue(request, "authorization") orelse { 13 - return error.AuthRequired; 14 - }; 15 - 16 - const token_start = 17 - if (std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) 18 - "bearer ".len 19 - else if (std.ascii.startsWithIgnoreCase(auth_header, "dpop ")) 20 - "dpop ".len 21 - else 22 - return error.AuthRequired; 23 - 24 - const token = std.mem.trim(u8, auth_header[token_start..], " \t"); 25 - const claims = auth.claimsFromSessionJwt(allocator, token) orelse return error.InvalidToken; 26 - defer allocator.free(claims.did); 27 - defer allocator.free(claims.scope); 28 - if (!std.mem.eql(u8, claims.scope, scope)) return error.InvalidToken; 29 - return (store.findAccount(allocator, claims.did) catch null) orelse error.InvalidToken; 30 - } 31 - 32 - pub fn optionalBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) ?auth.Account { 33 - return requireBearerAccount(request, allocator) catch null; 34 - } 35 - 36 - pub fn parseJsonBody(request: *http.Server.Request, allocator: std.mem.Allocator, body: []const u8) !std.json.Parsed(std.json.Value) { 37 - return std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch { 38 - try xrpcError(request, .bad_request, "InvalidRequest", "Expected JSON body"); 39 - return error.InvalidJson; 40 - }; 41 - } 42 - 43 - pub fn valueString(value: std.json.Value, key: []const u8) ?[]const u8 { 44 - return switch (value) { 45 - .object => |object| switch (object.get(key) orelse return null) { 46 - .string => |string| string, 47 - else => null, 48 - }, 49 - else => null, 50 - }; 51 - } 52 - 53 - pub fn readBody(request: *http.Server.Request, buf: []u8) ![]const u8 { 54 - var reader_buf: [1024]u8 = undefined; 55 - const reader = try request.readerExpectContinue(&reader_buf); 56 - if (request.head.content_length) |len| { 57 - if (len > buf.len) return error.BodyTooLarge; 58 - var total: usize = 0; 59 - const expected: usize = @intCast(len); 60 - var chunk: [8192]u8 = undefined; 61 - while (total < expected) { 62 - const remaining = expected - total; 63 - const n = try reader.readSliceShort(chunk[0..@min(chunk.len, remaining)]); 64 - if (n == 0) return error.UnexpectedEof; 65 - @memcpy(buf[total..][0..n], chunk[0..n]); 66 - total += n; 67 - } 68 - return buf[0..expected]; 69 - } 70 - 71 - var total: usize = 0; 72 - while (true) { 73 - if (total == buf.len) { 74 - var extra: [1]u8 = undefined; 75 - const n = try reader.readSliceShort(&extra); 76 - if (n == 0) return buf[0..total]; 77 - return error.BodyTooLarge; 78 - } 79 - 80 - const n = try reader.readSliceShort(buf[total..]); 81 - if (n == 0) return buf[0..total]; 82 - total += n; 83 - } 84 - } 85 - 86 - pub fn readBodyAlloc(request: *http.Server.Request, allocator: std.mem.Allocator, max_len: usize) ![]const u8 { 87 - var reader_buf: [4096]u8 = undefined; 88 - const reader = try request.readerExpectContinue(&reader_buf); 89 - if (request.head.content_length) |len| { 90 - if (len > max_len) return error.BodyTooLarge; 91 - const body = try allocator.alloc(u8, @intCast(len)); 92 - var total: usize = 0; 93 - var chunk: [8192]u8 = undefined; 94 - while (total < body.len) { 95 - const remaining = body.len - total; 96 - const n = try reader.readSliceShort(chunk[0..@min(chunk.len, remaining)]); 97 - if (n == 0) return error.UnexpectedEof; 98 - @memcpy(body[total..][0..n], chunk[0..n]); 99 - total += n; 100 - } 101 - return body; 102 - } 103 - 104 - var body: std.ArrayList(u8) = .empty; 105 - var chunk: [8192]u8 = undefined; 106 - while (true) { 107 - const n = try reader.readSliceShort(&chunk); 108 - if (n == 0) return try body.toOwnedSlice(allocator); 109 - if (body.items.len + n > max_len) return error.BodyTooLarge; 110 - try body.appendSlice(allocator, chunk[0..n]); 111 - } 112 - } 113 - 114 - pub fn headerValue(request: *const http.Server.Request, name: []const u8) ?[]const u8 { 115 - var it = request.iterateHeaders(); 116 - while (it.next()) |header| { 117 - if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value; 118 - } 119 - return null; 120 - } 121 - 122 - pub fn queryParam(target: []const u8, name: []const u8, out: []u8) ?[]const u8 { 123 - const query_start = std.mem.indexOfScalar(u8, target, '?') orelse return null; 124 - var params = std.mem.splitScalar(u8, target[query_start + 1 ..], '&'); 125 - while (params.next()) |param| { 126 - const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue; 127 - if (!std.mem.eql(u8, param[0..eq], name)) continue; 128 - return percentDecode(param[eq + 1 ..], out) catch null; 129 - } 130 - return null; 131 - } 132 - 133 - pub fn queryLimit(target: []const u8, default: usize) usize { 134 - var buf: [16]u8 = undefined; 135 - const raw = queryParam(target, "limit", &buf) orelse return default; 136 - return std.fmt.parseInt(usize, raw, 10) catch default; 137 - } 138 - 139 - pub fn percentDecode(input: []const u8, out: []u8) ![]const u8 { 140 - var write: usize = 0; 141 - var read: usize = 0; 142 - while (read < input.len) { 143 - if (write >= out.len) return error.NoSpaceLeft; 144 - switch (input[read]) { 145 - '%' => { 146 - if (read + 2 >= input.len) return error.InvalidPercentEncoding; 147 - out[write] = try std.fmt.parseInt(u8, input[read + 1 .. read + 3], 16); 148 - read += 3; 149 - }, 150 - '+' => { 151 - out[write] = ' '; 152 - read += 1; 153 - }, 154 - else => |c| { 155 - out[write] = c; 156 - read += 1; 157 - }, 158 - } 159 - write += 1; 160 - } 161 - return out[0..write]; 162 - } 163 - 164 - pub fn corsPreflight(request: *http.Server.Request) !void { 165 - var headers = cors_headers; 166 - var allow_headers_buf: [1024]u8 = undefined; 167 - if (headerValue(request, "access-control-request-headers")) |requested| { 168 - const trimmed = std.mem.trim(u8, requested, " \t"); 169 - if (trimmed.len > 0 and trimmed.len < allow_headers_buf.len) { 170 - @memcpy(allow_headers_buf[0..trimmed.len], trimmed); 171 - headers[3].value = allow_headers_buf[0..trimmed.len]; 172 - } 173 - } 174 - try request.respond("", .{ 175 - .status = .no_content, 176 - .extra_headers = &headers, 177 - }); 178 - } 179 - 180 - pub fn json(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 181 - normalizeBodylessRequest(request); 182 - try request.respond(body, .{ 183 - .status = status, 184 - .extra_headers = &json_headers, 185 - }); 186 - } 187 - 188 - pub fn text(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 189 - normalizeBodylessRequest(request); 190 - try request.respond(body, .{ 191 - .status = status, 192 - .extra_headers = &text_headers, 193 - }); 194 - } 195 - 196 - pub fn xrpcError( 197 - request: *http.Server.Request, 198 - status: http.Status, 199 - error_name: []const u8, 200 - message: []const u8, 201 - ) !void { 202 - var buf: [512]u8 = undefined; 203 - const body = try std.fmt.bufPrint(&buf, "{{\"error\":\"{s}\",\"message\":\"{s}\"}}", .{ error_name, message }); 204 - try json(request, status, body); 205 - } 206 - 207 - const json_headers = [_]http.Header{ 208 - .{ .name = "content-type", .value = "application/json; charset=utf-8" }, 209 - .{ .name = "access-control-allow-origin", .value = "*" }, 210 - .{ .name = "access-control-allow-private-network", .value = "true" }, 211 - .{ .name = "connection", .value = "close" }, 212 - }; 213 - 214 - const text_headers = [_]http.Header{ 215 - .{ .name = "content-type", .value = "text/plain; charset=utf-8" }, 216 - .{ .name = "access-control-allow-origin", .value = "*" }, 217 - .{ .name = "access-control-allow-private-network", .value = "true" }, 218 - .{ .name = "connection", .value = "close" }, 219 - }; 220 - 221 - const cors_headers = [_]http.Header{ 222 - .{ .name = "access-control-allow-origin", .value = "*" }, 223 - .{ .name = "access-control-allow-methods", .value = "GET, POST, OPTIONS" }, 224 - .{ .name = "vary", .value = "Access-Control-Request-Headers" }, 225 - .{ .name = "access-control-allow-headers", .value = "atproto-accept-labelers, atproto-proxy, authorization, content-type, dpop, x-bsky-topics" }, 226 - .{ .name = "access-control-allow-private-network", .value = "true" }, 227 - .{ .name = "access-control-max-age", .value = "600" }, 228 - .{ .name = "connection", .value = "close" }, 229 - }; 230 - 231 - fn normalizeBodylessRequest(request: *http.Server.Request) void { 232 - if (request.head.method == .POST and 233 - request.head.content_length == null and 234 - request.head.transfer_encoding == .none) 235 - { 236 - request.head.content_length = 0; 237 - } 238 - } 239 - 240 - test "decodes query params" { 241 - var buf: [64]u8 = undefined; 242 - try std.testing.expectEqualStrings("did:plc:service", queryParam("/xrpc/foo?aud=did%3Aplc%3Aservice", "aud", &buf).?); 243 - }
-161
src/http/router.zig
··· 1 - const std = @import("std"); 2 - const xrpc = @import("../core/xrpc.zig"); 3 - 4 - const http = std.http; 5 - 6 - pub const Route = enum { 7 - cors_preflight, 8 - root, 9 - health, 10 - did_json, 11 - oauth_protected_resource, 12 - oauth_authorization_server, 13 - oauth_jwks, 14 - oauth_par, 15 - oauth_authorize, 16 - oauth_token, 17 - oauth_introspect, 18 - oauth_revoke, 19 - atproto_did, 20 - describe_server, 21 - create_account, 22 - create_session, 23 - refresh_session, 24 - get_session, 25 - get_service_auth, 26 - activate_account, 27 - deactivate_account, 28 - request_email_confirmation, 29 - confirm_email, 30 - request_email_update, 31 - update_email, 32 - check_account_status, 33 - app_preferences_get, 34 - app_preferences_put, 35 - repo_create_record, 36 - repo_put_record, 37 - repo_describe_repo, 38 - repo_get_record, 39 - repo_list_records, 40 - repo_delete_record, 41 - repo_apply_writes, 42 - repo_import_repo, 43 - repo_upload_blob, 44 - repo_list_missing_blobs, 45 - sync_get_blob, 46 - sync_get_repo, 47 - sync_get_latest_commit, 48 - sync_list_repos, 49 - sync_list_blobs, 50 - sync_subscribe_repos, 51 - sync_get_repo_status, 52 - sync_notify_of_update, 53 - sync_request_crawl, 54 - identity_get_recommended_did_credentials, 55 - identity_request_plc_operation_signature, 56 - identity_sign_plc_operation, 57 - identity_submit_plc_operation, 58 - identity_resolve_handle, 59 - not_found, 60 - }; 61 - 62 - pub fn route(method: http.Method, target: []const u8) Route { 63 - if (method == .OPTIONS) return .cors_preflight; 64 - 65 - const path = stripQuery(target); 66 - if (method == .GET and std.mem.eql(u8, path, "/")) return .root; 67 - if (method == .GET and std.mem.eql(u8, path, xrpc.healthPath)) return .health; 68 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/did.json")) return .did_json; 69 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-protected-resource")) return .oauth_protected_resource; 70 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-authorization-server")) return .oauth_authorization_server; 71 - if (method == .GET and std.mem.eql(u8, path, "/oauth/jwks")) return .oauth_jwks; 72 - if (method == .POST and std.mem.eql(u8, path, "/oauth/par")) return .oauth_par; 73 - if ((method == .GET or method == .POST) and std.mem.eql(u8, path, "/oauth/authorize")) return .oauth_authorize; 74 - if (method == .POST and std.mem.eql(u8, path, "/oauth/token")) return .oauth_token; 75 - if (method == .POST and std.mem.eql(u8, path, "/oauth/introspect")) return .oauth_introspect; 76 - if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke; 77 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 78 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 79 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account; 80 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createSession")) return .create_session; 81 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.refreshSession")) return .refresh_session; 82 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getSession")) return .get_session; 83 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getServiceAuth")) return .get_service_auth; 84 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.activateAccount")) return .activate_account; 85 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.deactivateAccount")) return .deactivate_account; 86 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailConfirmation")) return .request_email_confirmation; 87 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.confirmEmail")) return .confirm_email; 88 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailUpdate")) return .request_email_update; 89 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.updateEmail")) return .update_email; 90 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.checkAccountStatus")) return .check_account_status; 91 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getPreferences")) return .app_preferences_get; 92 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.putPreferences")) return .app_preferences_put; 93 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.createRecord")) return .repo_create_record; 94 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.putRecord")) return .repo_put_record; 95 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.describeRepo")) return .repo_describe_repo; 96 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.getRecord")) return .repo_get_record; 97 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listRecords")) return .repo_list_records; 98 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.deleteRecord")) return .repo_delete_record; 99 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.applyWrites")) return .repo_apply_writes; 100 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.importRepo")) return .repo_import_repo; 101 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.uploadBlob")) return .repo_upload_blob; 102 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listMissingBlobs")) return .repo_list_missing_blobs; 103 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getBlob")) return .sync_get_blob; 104 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepo")) return .sync_get_repo; 105 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getLatestCommit")) return .sync_get_latest_commit; 106 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listRepos")) return .sync_list_repos; 107 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listBlobs")) return .sync_list_blobs; 108 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.subscribeRepos")) return .sync_subscribe_repos; 109 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepoStatus")) return .sync_get_repo_status; 110 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.notifyOfUpdate")) return .sync_notify_of_update; 111 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.requestCrawl")) return .sync_request_crawl; 112 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.getRecommendedDidCredentials")) return .identity_get_recommended_did_credentials; 113 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.requestPlcOperationSignature")) return .identity_request_plc_operation_signature; 114 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.signPlcOperation")) return .identity_sign_plc_operation; 115 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.submitPlcOperation")) return .identity_submit_plc_operation; 116 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.resolveHandle")) return .identity_resolve_handle; 117 - 118 - return .not_found; 119 - } 120 - 121 - fn stripQuery(target: []const u8) []const u8 { 122 - const end = std.mem.indexOfScalar(u8, target, '?') orelse target.len; 123 - return target[0..end]; 124 - } 125 - 126 - test "routes pds probes" { 127 - try std.testing.expectEqual(Route.cors_preflight, route(.OPTIONS, "/anything")); 128 - try std.testing.expectEqual(Route.root, route(.GET, "/")); 129 - try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health")); 130 - try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health?x=1")); 131 - try std.testing.expectEqual(Route.did_json, route(.GET, "/.well-known/did.json")); 132 - try std.testing.expectEqual(Route.oauth_protected_resource, route(.GET, "/.well-known/oauth-protected-resource")); 133 - try std.testing.expectEqual(Route.oauth_authorization_server, route(.GET, "/.well-known/oauth-authorization-server")); 134 - try std.testing.expectEqual(Route.oauth_par, route(.POST, "/oauth/par")); 135 - try std.testing.expectEqual(Route.oauth_authorize, route(.GET, "/oauth/authorize?request_uri=x")); 136 - try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); 137 - try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 138 - try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 139 - try std.testing.expectEqual(Route.create_account, route(.POST, "/xrpc/com.atproto.server.createAccount")); 140 - try std.testing.expectEqual(Route.create_session, route(.POST, "/xrpc/com.atproto.server.createSession")); 141 - try std.testing.expectEqual(Route.refresh_session, route(.POST, "/xrpc/com.atproto.server.refreshSession")); 142 - try std.testing.expectEqual(Route.get_session, route(.GET, "/xrpc/com.atproto.server.getSession")); 143 - try std.testing.expectEqual(Route.get_service_auth, route(.GET, "/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aplc%3Aservice")); 144 - try std.testing.expectEqual(Route.app_preferences_get, route(.GET, "/xrpc/app.bsky.actor.getPreferences")); 145 - try std.testing.expectEqual(Route.app_preferences_put, route(.POST, "/xrpc/app.bsky.actor.putPreferences")); 146 - try std.testing.expectEqual(Route.repo_list_records, route(.GET, "/xrpc/com.atproto.repo.listRecords?repo=alice.test&collection=app.bsky.feed.post")); 147 - try std.testing.expectEqual(Route.repo_describe_repo, route(.GET, "/xrpc/com.atproto.repo.describeRepo?repo=alice.test")); 148 - try std.testing.expectEqual(Route.repo_create_record, route(.POST, "/xrpc/com.atproto.repo.createRecord")); 149 - try std.testing.expectEqual(Route.repo_upload_blob, route(.POST, "/xrpc/com.atproto.repo.uploadBlob")); 150 - try std.testing.expectEqual(Route.sync_list_repos, route(.GET, "/xrpc/com.atproto.sync.listRepos?limit=10")); 151 - try std.testing.expectEqual(Route.sync_get_latest_commit, route(.GET, "/xrpc/com.atproto.sync.getLatestCommit?did=did%3Aplc%3Aabc")); 152 - try std.testing.expectEqual(Route.sync_subscribe_repos, route(.GET, "/xrpc/com.atproto.sync.subscribeRepos?cursor=0")); 153 - try std.testing.expectEqual(Route.sync_request_crawl, route(.POST, "/xrpc/com.atproto.sync.requestCrawl")); 154 - try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US")); 155 - try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test")); 156 - } 157 - 158 - test "does not route wrong methods" { 159 - try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/_health")); 160 - try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.server.createSession")); 161 - }
-163
src/http/server.zig
··· 1 - const std = @import("std"); 2 - const atproto_identity = @import("../atproto/identity.zig"); 3 - const atproto_oauth = @import("../atproto/oauth.zig"); 4 - const atproto_preferences = @import("../atproto/preferences.zig"); 5 - const atproto_proxy = @import("../atproto/proxy.zig"); 6 - const atproto_repo = @import("../atproto/repo.zig"); 7 - const atproto_server = @import("../atproto/server.zig"); 8 - const atproto_sync = @import("../atproto/sync.zig"); 9 - const log = @import("../core/log.zig"); 10 - const http_api = @import("api.zig"); 11 - const router = @import("router.zig"); 12 - 13 - const http = std.http; 14 - const net = std.Io.net; 15 - const corsPreflight = http_api.corsPreflight; 16 - const json = http_api.json; 17 - const text = http_api.text; 18 - const xrpcError = http_api.xrpcError; 19 - 20 - pub const Options = struct { 21 - host: []const u8 = "127.0.0.1", 22 - port: u16 = 2583, 23 - }; 24 - 25 - pub fn listen(io: std.Io, options: Options) !void { 26 - const ip4 = try net.Ip4Address.parse(options.host, options.port); 27 - const address: net.IpAddress = .{ .ip4 = ip4 }; 28 - var tcp_server = try address.listen(io, .{ .reuse_address = true }); 29 - defer tcp_server.deinit(io); 30 - 31 - var connections: std.Io.Group = .init; 32 - defer connections.cancel(io); 33 - 34 - log.info("zds listening on http://{s}:{d}\n", .{ options.host, options.port }); 35 - while (true) { 36 - const stream = try tcp_server.accept(io); 37 - connections.concurrent(io, serveConnection, .{ io, stream }) catch |err| { 38 - log.err("failed to start connection handler: {s}\n", .{@errorName(err)}); 39 - var copy = stream; 40 - copy.close(io); 41 - continue; 42 - }; 43 - } 44 - } 45 - 46 - fn serveConnection(io: std.Io, stream: net.Stream) void { 47 - defer { 48 - var copy = stream; 49 - copy.close(io); 50 - } 51 - 52 - var send_buffer: [4096]u8 = undefined; 53 - var recv_buffer: [4096]u8 = undefined; 54 - var connection_reader = stream.reader(io, &recv_buffer); 55 - var connection_writer = stream.writer(io, &send_buffer); 56 - var http_server: http.Server = .init(&connection_reader.interface, &connection_writer.interface); 57 - 58 - var request = http_server.receiveHead() catch |err| switch (err) { 59 - error.HttpConnectionClosing => return, 60 - else => { 61 - log.err("failed to receive request: {s}\n", .{@errorName(err)}); 62 - return; 63 - }, 64 - }; 65 - var target_buf: [512]u8 = undefined; 66 - const target_len = @min(request.head.target.len, target_buf.len); 67 - @memcpy(target_buf[0..target_len], request.head.target[0..target_len]); 68 - const target = target_buf[0..target_len]; 69 - serveRequest(io, &request) catch |err| { 70 - log.err("failed to serve {s}: {s}\n", .{ target, @errorName(err) }); 71 - return; 72 - }; 73 - } 74 - 75 - fn serveRequest(io: std.Io, request: *http.Server.Request) !void { 76 - const route = router.route(request.head.method, request.head.target); 77 - const method = request.head.method; 78 - var target_buf: [512]u8 = undefined; 79 - const target_len = @min(request.head.target.len, target_buf.len); 80 - @memcpy(target_buf[0..target_len], request.head.target[0..target_len]); 81 - const target = target_buf[0..target_len]; 82 - log.debug("http {s} {s} route={s} start\n", .{ @tagName(method), target, @tagName(route) }); 83 - 84 - if (atproto_proxy.shouldProxy(request)) { 85 - return atproto_proxy.xrpcProxy(request); 86 - } 87 - 88 - switch (route) { 89 - .cors_preflight => try corsPreflight(request), 90 - .root => try text(request, .ok, 91 - \\ __ 92 - \\ ____ ____/ /____ 93 - \\/_ / / __ / ___/ 94 - \\ / /_/ /_/ (__ ) 95 - \\/___/\__,_/____/ 96 - \\ 97 - \\zds is an at protocol personal data server. 98 - \\ 99 - \\most api routes are under /xrpc/ 100 - \\ 101 - \\code: https://tangled.org/zzstoatzz.io/zds 102 - \\protocol: https://atproto.com 103 - \\health: /xrpc/_health 104 - \\server: /xrpc/com.atproto.server.describeServer 105 - \\ 106 - ), 107 - .health => try json(request, .ok, 108 - \\{"version":"0.0.0","status":"ok"} 109 - ), 110 - .did_json => try atproto_server.didJson(request), 111 - .oauth_protected_resource => try atproto_oauth.protectedResource(request), 112 - .oauth_authorization_server => try atproto_oauth.authorizationServer(request), 113 - .oauth_jwks => try atproto_oauth.jwks(request), 114 - .oauth_par => try atproto_oauth.par(request), 115 - .oauth_authorize => if (request.head.method == .GET) try atproto_oauth.authorizeGet(request) else try atproto_oauth.authorizePost(request), 116 - .oauth_token => try atproto_oauth.token(request), 117 - .oauth_introspect => try atproto_oauth.introspect(request), 118 - .oauth_revoke => try atproto_oauth.revoke(request), 119 - .atproto_did => try atproto_server.atprotoDid(request), 120 - .describe_server => try atproto_server.describeServer(request), 121 - .create_account => try atproto_server.createAccount(request), 122 - .create_session => try atproto_server.createSession(request), 123 - .refresh_session => try atproto_server.refreshSession(request), 124 - .get_session => try atproto_server.getSession(request), 125 - .get_service_auth => try atproto_server.getServiceAuth(request), 126 - .activate_account => try atproto_server.activateAccount(request), 127 - .deactivate_account => try atproto_server.deactivateAccount(request), 128 - .request_email_confirmation => try atproto_server.requestEmailConfirmation(request), 129 - .confirm_email => try atproto_server.confirmEmail(request), 130 - .request_email_update => try atproto_server.requestEmailUpdate(request), 131 - .update_email => try atproto_server.updateEmail(request), 132 - .check_account_status => try atproto_server.checkAccountStatus(request), 133 - .app_preferences_get => try atproto_preferences.getPreferences(request), 134 - .app_preferences_put => try atproto_preferences.putPreferences(request), 135 - .repo_create_record => try atproto_repo.createRecord(request), 136 - .repo_put_record => try atproto_repo.putRecord(request), 137 - .repo_describe_repo => try atproto_repo.describeRepo(request), 138 - .repo_get_record => try atproto_repo.getRecord(request), 139 - .repo_list_records => try atproto_repo.listRecords(request), 140 - .repo_delete_record => try atproto_repo.deleteRecord(request), 141 - .repo_apply_writes => try atproto_repo.applyWrites(request), 142 - .repo_import_repo => try atproto_repo.importRepo(request), 143 - .repo_upload_blob => try atproto_repo.uploadBlob(io, request), 144 - .repo_list_missing_blobs => try atproto_repo.listMissingBlobs(request), 145 - .sync_get_blob => try atproto_sync.getBlob(request), 146 - .sync_get_repo => try atproto_sync.getRepo(request), 147 - .sync_get_latest_commit => try atproto_sync.getLatestCommit(request), 148 - .sync_list_repos => try atproto_sync.listRepos(request), 149 - .sync_list_blobs => try atproto_sync.listBlobs(request), 150 - .sync_subscribe_repos => try atproto_sync.subscribeRepos(request), 151 - .sync_get_repo_status => try atproto_sync.getRepoStatus(request), 152 - .sync_notify_of_update => try atproto_sync.notifyOfUpdate(request), 153 - .sync_request_crawl => try atproto_sync.requestCrawl(request), 154 - .identity_get_recommended_did_credentials => try atproto_identity.getRecommendedDidCredentials(request), 155 - .identity_request_plc_operation_signature => try atproto_identity.requestPlcOperationSignature(request), 156 - .identity_sign_plc_operation => try atproto_identity.signPlcOperation(request), 157 - .identity_submit_plc_operation => try atproto_identity.submitPlcOperation(request), 158 - .identity_resolve_handle => try atproto_identity.resolveHandle(request), 159 - .not_found => { 160 - try xrpcError(request, .not_found, "UnknownMethod", "Unknown XRPC method"); 161 - }, 162 - } 163 - }
-207
src/main.zig
··· 1 - const std = @import("std"); 2 - const zds = @import("zds"); 3 - 4 - const Io = std.Io; 5 - 6 - var app_threaded_io: Io.Threaded = undefined; 7 - pub const std_options_debug_threaded_io: ?*Io.Threaded = &app_threaded_io; 8 - 9 - pub fn main(init: std.process.Init) !void { 10 - const allocator = std.heap.smp_allocator; 11 - app_threaded_io = Io.Threaded.init(allocator, .{}); 12 - const io = app_threaded_io.io(); 13 - 14 - var host: []const u8 = cGetenv("ZDS_HOST") orelse "127.0.0.1"; 15 - var port: u16 = 2583; 16 - var db_path: []const u8 = "dev/zds.sqlite3"; 17 - var public_url: ?[]const u8 = cGetenv("ZDS_PUBLIC_URL"); 18 - var server_did: ?[]const u8 = cGetenv("ZDS_SERVER_DID"); 19 - var plc_directory: ?[]const u8 = cGetenv("ZDS_PLC_DIRECTORY"); 20 - var email_from: ?[]const u8 = cGetenv("ZDS_EMAIL_FROM"); 21 - var resend_api_key: ?[]const u8 = cGetenv("ZDS_RESEND_API_KEY"); 22 - var blob_upload_limit: ?usize = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"); 23 - var blobstore_path: ?[]const u8 = cGetenv("ZDS_BLOBSTORE_PATH"); 24 - var handle_domains: ?[]const u8 = cGetenv("ZDS_HANDLE_DOMAINS"); 25 - var crawlers: ?[]const u8 = cGetenv("ZDS_CRAWLERS"); 26 - var jwt_secret: ?[]const u8 = cGetenv("ZDS_JWT_SECRET"); 27 - var log_level: ?[]const u8 = cGetenv("ZDS_LOG_LEVEL"); 28 - if (envBool("ZDS_DEBUG")) log_level = "debug"; 29 - 30 - var args = std.process.Args.Iterator.init(init.minimal.args); 31 - _ = args.next(); 32 - while (args.next()) |arg| { 33 - if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { 34 - usage(); 35 - return; 36 - } 37 - if (std.mem.eql(u8, arg, "--port")) { 38 - const value = args.next() orelse return error.MissingPort; 39 - port = try std.fmt.parseInt(u16, value, 10); 40 - continue; 41 - } 42 - if (std.mem.eql(u8, arg, "--host")) { 43 - host = args.next() orelse return error.MissingHost; 44 - continue; 45 - } 46 - if (std.mem.eql(u8, arg, "--db")) { 47 - db_path = args.next() orelse return error.MissingDatabasePath; 48 - continue; 49 - } 50 - if (std.mem.eql(u8, arg, "--public-url")) { 51 - public_url = args.next() orelse return error.MissingPublicUrl; 52 - continue; 53 - } 54 - if (std.mem.eql(u8, arg, "--server-did")) { 55 - server_did = args.next() orelse return error.MissingServerDid; 56 - continue; 57 - } 58 - if (std.mem.eql(u8, arg, "--plc-directory")) { 59 - plc_directory = args.next() orelse return error.MissingPlcDirectory; 60 - continue; 61 - } 62 - if (std.mem.eql(u8, arg, "--email-from")) { 63 - email_from = args.next() orelse return error.MissingEmailFrom; 64 - continue; 65 - } 66 - if (std.mem.eql(u8, arg, "--resend-api-key")) { 67 - resend_api_key = args.next() orelse return error.MissingResendApiKey; 68 - continue; 69 - } 70 - if (std.mem.eql(u8, arg, "--blob-upload-limit")) { 71 - const value = args.next() orelse return error.MissingBlobUploadLimit; 72 - blob_upload_limit = try std.fmt.parseInt(usize, value, 10); 73 - continue; 74 - } 75 - if (std.mem.eql(u8, arg, "--blobstore-path")) { 76 - blobstore_path = args.next() orelse return error.MissingBlobstorePath; 77 - continue; 78 - } 79 - if (std.mem.eql(u8, arg, "--handle-domains")) { 80 - handle_domains = args.next() orelse return error.MissingHandleDomains; 81 - continue; 82 - } 83 - if (std.mem.eql(u8, arg, "--crawlers")) { 84 - crawlers = args.next() orelse return error.MissingCrawlers; 85 - continue; 86 - } 87 - if (std.mem.eql(u8, arg, "--jwt-secret")) { 88 - jwt_secret = args.next() orelse return error.MissingJwtSecret; 89 - continue; 90 - } 91 - if (std.mem.eql(u8, arg, "--log-level")) { 92 - log_level = args.next() orelse return error.MissingLogLevel; 93 - continue; 94 - } 95 - if (std.mem.eql(u8, arg, "--debug")) { 96 - log_level = "debug"; 97 - continue; 98 - } 99 - if (std.mem.startsWith(u8, arg, "--port=")) { 100 - port = try std.fmt.parseInt(u16, arg["--port=".len..], 10); 101 - continue; 102 - } 103 - if (std.mem.startsWith(u8, arg, "--host=")) { 104 - host = arg["--host=".len..]; 105 - continue; 106 - } 107 - if (std.mem.startsWith(u8, arg, "--db=")) { 108 - db_path = arg["--db=".len..]; 109 - continue; 110 - } 111 - if (std.mem.startsWith(u8, arg, "--public-url=")) { 112 - public_url = arg["--public-url=".len..]; 113 - continue; 114 - } 115 - if (std.mem.startsWith(u8, arg, "--server-did=")) { 116 - server_did = arg["--server-did=".len..]; 117 - continue; 118 - } 119 - if (std.mem.startsWith(u8, arg, "--plc-directory=")) { 120 - plc_directory = arg["--plc-directory=".len..]; 121 - continue; 122 - } 123 - if (std.mem.startsWith(u8, arg, "--email-from=")) { 124 - email_from = arg["--email-from=".len..]; 125 - continue; 126 - } 127 - if (std.mem.startsWith(u8, arg, "--resend-api-key=")) { 128 - resend_api_key = arg["--resend-api-key=".len..]; 129 - continue; 130 - } 131 - if (std.mem.startsWith(u8, arg, "--blob-upload-limit=")) { 132 - blob_upload_limit = try std.fmt.parseInt(usize, arg["--blob-upload-limit=".len..], 10); 133 - continue; 134 - } 135 - if (std.mem.startsWith(u8, arg, "--blobstore-path=")) { 136 - blobstore_path = arg["--blobstore-path=".len..]; 137 - continue; 138 - } 139 - if (std.mem.startsWith(u8, arg, "--handle-domains=")) { 140 - handle_domains = arg["--handle-domains=".len..]; 141 - continue; 142 - } 143 - if (std.mem.startsWith(u8, arg, "--crawlers=")) { 144 - crawlers = arg["--crawlers=".len..]; 145 - continue; 146 - } 147 - if (std.mem.startsWith(u8, arg, "--jwt-secret=")) { 148 - jwt_secret = arg["--jwt-secret=".len..]; 149 - continue; 150 - } 151 - if (std.mem.startsWith(u8, arg, "--log-level=")) { 152 - log_level = arg["--log-level=".len..]; 153 - continue; 154 - } 155 - return error.UnknownArgument; 156 - } 157 - 158 - if (public_url) |value| zds.core.config.setPublicUrl(value); 159 - if (server_did) |value| zds.core.config.setServerDid(value); 160 - if (plc_directory) |value| zds.core.config.setPlcDirectory(value); 161 - zds.core.config.setEmailFrom(email_from); 162 - zds.core.config.setResendApiKey(resend_api_key); 163 - if (blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); 164 - if (blobstore_path) |value| zds.core.config.setBlobstorePath(value); 165 - if (handle_domains) |value| zds.core.config.setHandleDomains(value); 166 - if (crawlers) |value| zds.core.config.setCrawlers(value); 167 - if (jwt_secret) |value| zds.core.config.setJwtSecret(value); 168 - if (log_level) |value| { 169 - const parsed = zds.core.config.parseLogLevel(value) orelse return error.InvalidLogLevel; 170 - zds.core.config.setLogLevel(parsed); 171 - } 172 - 173 - zds.storage.blobstore.init(io, zds.core.config.blobstorePath()); 174 - try zds.storage.store.init(io, db_path); 175 - try zds.http.server.listen(io, .{ .host = host, .port = port }); 176 - } 177 - 178 - fn usage() void { 179 - std.debug.print( 180 - \\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] 181 - \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] 182 - \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] 183 - \\ [--log-level error|info|debug] [--debug] 184 - \\ 185 - \\Runs a local PDS-shaped HTTP server. 186 - \\DOMAINS is a comma-separated list such as ".example.com,example.com". 187 - \\URLS is a comma-separated crawler list, defaulting to bsky.network and vsky.network. 188 - \\ 189 - , .{}); 190 - } 191 - 192 - fn cGetenv(name: [*:0]const u8) ?[]const u8 { 193 - return if (std.c.getenv(name)) |value| std.mem.span(value) else null; 194 - } 195 - 196 - fn envUsize(name: [*:0]const u8) !?usize { 197 - const value = cGetenv(name) orelse return null; 198 - return try std.fmt.parseInt(usize, value, 10); 199 - } 200 - 201 - fn envBool(name: [*:0]const u8) bool { 202 - const value = cGetenv(name) orelse return false; 203 - return std.ascii.eqlIgnoreCase(value, "1") or 204 - std.ascii.eqlIgnoreCase(value, "true") or 205 - std.ascii.eqlIgnoreCase(value, "yes") or 206 - std.ascii.eqlIgnoreCase(value, "on"); 207 - }
-44
src/root.zig
··· 1 - pub const atproto = struct { 2 - pub const identity = @import("atproto/identity.zig"); 3 - pub const plc = @import("atproto/plc.zig"); 4 - pub const preferences = @import("atproto/preferences.zig"); 5 - pub const repo = @import("atproto/repo.zig"); 6 - pub const proxy = @import("atproto/proxy.zig"); 7 - pub const server = @import("atproto/server.zig"); 8 - pub const sync = @import("atproto/sync.zig"); 9 - }; 10 - 11 - pub const auth = struct { 12 - pub const tokens = @import("auth/tokens.zig"); 13 - }; 14 - 15 - pub const core = struct { 16 - pub const atid = @import("core/atid.zig"); 17 - pub const config = @import("core/config.zig"); 18 - pub const log = @import("core/log.zig"); 19 - pub const mail = @import("core/mail.zig"); 20 - pub const repo = @import("core/repo.zig"); 21 - pub const syntax = @import("core/syntax.zig"); 22 - pub const xrpc = @import("core/xrpc.zig"); 23 - }; 24 - 25 - pub const http = struct { 26 - pub const api = @import("http/api.zig"); 27 - pub const router = @import("http/router.zig"); 28 - pub const server = @import("http/server.zig"); 29 - }; 30 - 31 - pub const storage = struct { 32 - pub const blobstore = @import("storage/blobstore.zig"); 33 - pub const store = @import("storage/store.zig"); 34 - }; 35 - 36 - pub const zat = @import("zat"); 37 - 38 - test { 39 - _ = atproto; 40 - _ = auth; 41 - _ = core; 42 - _ = http; 43 - _ = storage; 44 - }
-93
src/storage/blobstore.zig
··· 1 - const std = @import("std"); 2 - const Io = std.Io; 3 - 4 - var blob_io: Io = undefined; 5 - var root_path: []const u8 = "dev/blobs"; 6 - var initialized = false; 7 - 8 - pub fn init(io: Io, path: []const u8) void { 9 - blob_io = io; 10 - root_path = trimTrailingSlash(path); 11 - initialized = true; 12 - } 13 - 14 - pub fn put(allocator: std.mem.Allocator, io: Io, did: []const u8, cid: []const u8, data: []const u8) !void { 15 - _ = io; 16 - try requireInitialized(); 17 - const dir_path = try actorDirPath(allocator, did); 18 - try mkdirPath(allocator, dir_path); 19 - const path = try blobPath(allocator, did, cid); 20 - try writeFileC(allocator, path, data); 21 - } 22 - 23 - pub fn get(allocator: std.mem.Allocator, did: []const u8, cid: []const u8, limit: usize) ![]u8 { 24 - try requireInitialized(); 25 - const path = try blobPath(allocator, did, cid); 26 - return Io.Dir.cwd().readFileAlloc(blob_io, path, allocator, .limited(limit)); 27 - } 28 - 29 - pub fn delete(allocator: std.mem.Allocator, did: []const u8, cid: []const u8) void { 30 - const path = blobPath(allocator, did, cid) catch return; 31 - Io.Dir.cwd().deleteFile(blob_io, path) catch {}; 32 - } 33 - 34 - fn actorDirPath(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 35 - return std.fs.path.join(allocator, &.{ root_path, did }); 36 - } 37 - 38 - fn blobPath(allocator: std.mem.Allocator, did: []const u8, cid: []const u8) ![]const u8 { 39 - return std.fs.path.join(allocator, &.{ root_path, did, cid }); 40 - } 41 - 42 - fn requireInitialized() !void { 43 - if (!initialized) return error.BlobstoreNotInitialized; 44 - } 45 - 46 - fn trimTrailingSlash(value: []const u8) []const u8 { 47 - if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1]; 48 - return value; 49 - } 50 - 51 - fn mkdirPath(allocator: std.mem.Allocator, path: []const u8) !void { 52 - var index: usize = 0; 53 - while (index < path.len) : (index += 1) { 54 - if (path[index] != std.fs.path.sep) continue; 55 - if (index == 0) continue; 56 - try mkdirOne(allocator, path[0..index]); 57 - } 58 - try mkdirOne(allocator, path); 59 - } 60 - 61 - fn mkdirOne(allocator: std.mem.Allocator, path: []const u8) !void { 62 - const path_z = try allocator.dupeZ(u8, path); 63 - if (std.c.mkdir(path_z.ptr, 0o755) == 0) return; 64 - const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); 65 - if (err == .EXIST) return; 66 - return error.CreateDirFailed; 67 - } 68 - 69 - fn writeFileC(allocator: std.mem.Allocator, path: []const u8, data: []const u8) !void { 70 - const path_z = try allocator.dupeZ(u8, path); 71 - const file = std.c.fopen(path_z.ptr, "wb") orelse return error.OpenBlobFailed; 72 - defer _ = std.c.fclose(file); 73 - if (data.len == 0) return; 74 - const written = std.c.fwrite(data.ptr, 1, data.len, file); 75 - if (written != data.len) return error.WriteBlobFailed; 76 - } 77 - 78 - test "disk blobstore writes and reads account blob bytes" { 79 - const allocator = std.testing.allocator; 80 - var tmp = std.testing.tmpDir(.{}); 81 - defer tmp.cleanup(); 82 - 83 - var path_buf: [std.fs.max_path_bytes]u8 = undefined; 84 - const path = try tmp.dir.realpath(".", &path_buf); 85 - init(std.Options.debug_io, path); 86 - 87 - var arena = std.heap.ArenaAllocator.init(allocator); 88 - defer arena.deinit(); 89 - const a = arena.allocator(); 90 - try put(a, std.Options.debug_io, "did:plc:test", "bafytest", "hello blobstore"); 91 - const data = try get(a, "did:plc:test", "bafytest", 1024); 92 - try std.testing.expectEqualStrings("hello blobstore", data); 93 - }
-2560
src/storage/store.zig
··· 1 - const std = @import("std"); 2 - const atid = @import("../core/atid.zig"); 3 - const auth = @import("../auth/tokens.zig"); 4 - const blobstore = @import("blobstore.zig"); 5 - const zat = @import("zat"); 6 - const zqlite = @import("zqlite"); 7 - const Io = std.Io; 8 - 9 - pub const Error = error{ 10 - InvalidCollection, 11 - InvalidRecordKey, 12 - MissingRecord, 13 - RepoNotFound, 14 - InvalidRepoPath, 15 - InvalidDagCbor, 16 - StoreNotInitialized, 17 - }; 18 - 19 - pub const Record = struct { 20 - did: []const u8, 21 - collection: []const u8, 22 - rkey: []const u8, 23 - cid: []const u8, 24 - value_json: []const u8, 25 - rev: []const u8, 26 - seq: u64, 27 - 28 - pub fn uri(self: Record, allocator: std.mem.Allocator) ![]const u8 { 29 - return std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ self.did, self.collection, self.rkey }); 30 - } 31 - }; 32 - 33 - pub const BlobRecord = struct { 34 - mime_type: []const u8, 35 - data: []const u8, 36 - }; 37 - 38 - pub const SeqEvent = struct { 39 - seq: u64, 40 - frame: []const u8, 41 - }; 42 - 43 - pub const EmailInfo = struct { 44 - email: []const u8, 45 - email_confirmed: bool, 46 - auth_code: ?[]const u8, 47 - auth_code_expires_at: ?i64, 48 - pending_email: ?[]const u8, 49 - }; 50 - 51 - pub const CodeStatus = enum { 52 - valid, 53 - invalid, 54 - expired, 55 - }; 56 - 57 - pub const OAuthRequest = struct { 58 - request_id: []const u8, 59 - client_id: []const u8, 60 - redirect_uri: []const u8, 61 - scope: []const u8, 62 - state: []const u8, 63 - code_challenge: []const u8, 64 - code_challenge_method: []const u8, 65 - login_hint: ?[]const u8, 66 - dpop_jkt: ?[]const u8, 67 - expires_at: i64, 68 - sub: ?[]const u8, 69 - code: ?[]const u8, 70 - }; 71 - 72 - pub const OAuthToken = struct { 73 - did: []const u8, 74 - client_id: []const u8, 75 - scope: []const u8, 76 - access_token: []const u8, 77 - refresh_token: []const u8, 78 - expires_at: i64, 79 - revoked: bool, 80 - }; 81 - 82 - pub const AppPreference = struct { 83 - name: []const u8, 84 - value_json: []const u8, 85 - }; 86 - 87 - pub const ImportedRecord = struct { 88 - collection: []const u8, 89 - rkey: []const u8, 90 - cid: []const u8, 91 - value_json: []const u8, 92 - blob_cids: []const []const u8, 93 - }; 94 - 95 - pub const ImportedBlock = struct { 96 - cid: []const u8, 97 - data: []const u8, 98 - }; 99 - 100 - pub const CommitInfo = struct { 101 - cid: []const u8, 102 - rev: []const u8, 103 - }; 104 - 105 - pub const WriteResult = struct { 106 - commit: CommitInfo, 107 - records: []Record, 108 - }; 109 - 110 - pub const WriteOp = union(enum) { 111 - create: struct { 112 - collection: []const u8, 113 - rkey: ?[]const u8, 114 - value: std.json.Value, 115 - }, 116 - update: struct { 117 - collection: []const u8, 118 - rkey: []const u8, 119 - value: std.json.Value, 120 - }, 121 - delete: struct { 122 - collection: []const u8, 123 - rkey: []const u8, 124 - }, 125 - }; 126 - 127 - const BlobRef = struct { 128 - cid: []const u8, 129 - uri: []const u8, 130 - }; 131 - 132 - var conn: zqlite.Conn = undefined; 133 - var initialized = false; 134 - var store_io: Io = undefined; 135 - var mutex: Io.Mutex = .init; 136 - 137 - pub fn init(io: Io, path: []const u8) !void { 138 - if (initialized) return; 139 - store_io = io; 140 - if (!std.mem.eql(u8, path, ":memory:") and !std.fs.path.isAbsolute(path)) { 141 - if (std.fs.path.dirname(path)) |dir| try Io.Dir.createDirPath(.cwd(), io, dir); 142 - } 143 - 144 - const path_z = try std.heap.page_allocator.dupeZ(u8, path); 145 - conn = try zqlite.open(path_z.ptr, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite); 146 - initialized = true; 147 - errdefer close(); 148 - 149 - try conn.busyTimeout(5000); 150 - try conn.execNoArgs("PRAGMA journal_mode=WAL"); 151 - try conn.execNoArgs("PRAGMA foreign_keys=ON"); 152 - try migrate(); 153 - } 154 - 155 - pub fn close() void { 156 - if (!initialized) return; 157 - conn.close(); 158 - initialized = false; 159 - } 160 - 161 - pub fn nowMs() i64 { 162 - const now = std.Io.Timestamp.now(store_io, .real); 163 - return @intCast(@divTrunc(now.nanoseconds, std.time.ns_per_ms)); 164 - } 165 - 166 - pub fn randomBytes(buffer: []u8) void { 167 - store_io.random(buffer); 168 - } 169 - 170 - pub fn randomToken(allocator: std.mem.Allocator, prefix: []const u8, comptime byte_len: usize) ![]const u8 { 171 - var bytes: [byte_len]u8 = undefined; 172 - randomBytes(&bytes); 173 - const hex = std.fmt.bytesToHex(bytes, .lower); 174 - return std.fmt.allocPrint(allocator, "{s}{s}", .{ prefix, &hex }); 175 - } 176 - 177 - pub fn currentIo() Io { 178 - return store_io; 179 - } 180 - 181 - pub fn resolveRepo(repo: []const u8) ?auth.Account { 182 - return findAccount(std.heap.page_allocator, repo) catch null; 183 - } 184 - 185 - pub fn findAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { 186 - mutex.lockUncancelable(store_io); 187 - defer mutex.unlock(store_io); 188 - try requireInitialized(); 189 - 190 - return try findAccountLocked(allocator, identifier); 191 - } 192 - 193 - pub fn searchAccounts(allocator: std.mem.Allocator, query: []const u8, limit: usize) ![]auth.Account { 194 - mutex.lockUncancelable(store_io); 195 - defer mutex.unlock(store_io); 196 - try requireInitialized(); 197 - 198 - const actual_limit = if (limit == 0) 25 else limit; 199 - const pattern = try std.fmt.allocPrint(allocator, "%{s}%", .{query}); 200 - var rows = if (query.len == 0) 201 - try conn.rows( 202 - \\SELECT handle, did, email, password_hash 203 - \\FROM accounts 204 - \\ORDER BY handle 205 - \\LIMIT ? 206 - , .{@as(i64, @intCast(actual_limit))}) 207 - else 208 - try conn.rows( 209 - \\SELECT handle, did, email, password_hash 210 - \\FROM accounts 211 - \\WHERE lower(handle) LIKE lower(?) OR did LIKE ? 212 - \\ORDER BY handle 213 - \\LIMIT ? 214 - , .{ pattern, pattern, @as(i64, @intCast(actual_limit)) }); 215 - defer rows.deinit(); 216 - 217 - var accounts: std.ArrayList(auth.Account) = .empty; 218 - while (rows.next()) |row| { 219 - try accounts.append(allocator, try accountFromRow(allocator, row)); 220 - } 221 - if (rows.err) |err| return err; 222 - return accounts.toOwnedSlice(allocator); 223 - } 224 - 225 - fn findAccountLocked(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { 226 - const row = try conn.row( 227 - \\SELECT handle, did, email, password_hash 228 - \\FROM accounts 229 - \\WHERE lower(handle) = lower(?) OR did = ? OR lower(email) = lower(?) 230 - \\LIMIT 1 231 - , .{ identifier, identifier, identifier }); 232 - if (row == null) return null; 233 - defer row.?.deinit(); 234 - return try accountFromRow(allocator, row.?); 235 - } 236 - 237 - fn accountFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !auth.Account { 238 - return .{ 239 - .handle = try allocator.dupe(u8, row.text(0)), 240 - .did = try allocator.dupe(u8, row.text(1)), 241 - .email = try allocator.dupe(u8, row.text(2)), 242 - .password = try allocator.dupe(u8, row.text(3)), 243 - }; 244 - } 245 - 246 - pub fn createAccount( 247 - allocator: std.mem.Allocator, 248 - handle: []const u8, 249 - email: []const u8, 250 - password: []const u8, 251 - did: []const u8, 252 - activated: bool, 253 - ) !auth.Account { 254 - const signing_key = try generateAccountSigningKey(); 255 - return createAccountWithSigningKey(allocator, handle, email, password, did, activated, signing_key); 256 - } 257 - 258 - pub fn createAccountWithSigningKey( 259 - allocator: std.mem.Allocator, 260 - handle: []const u8, 261 - email: []const u8, 262 - password: []const u8, 263 - did: []const u8, 264 - activated: bool, 265 - signing_key: [32]u8, 266 - ) !auth.Account { 267 - mutex.lockUncancelable(store_io); 268 - defer mutex.unlock(store_io); 269 - try requireInitialized(); 270 - 271 - var salt: [16]u8 = undefined; 272 - store_io.random(&salt); 273 - const password_hash = try auth.hashPassword(allocator, password, salt); 274 - try conn.exec( 275 - \\INSERT INTO accounts (did, handle, email, password_hash, activated_at, signing_key_type, signing_key) 276 - \\VALUES (?, ?, ?, ?, CASE WHEN ? THEN unixepoch() ELSE NULL END, 'secp256k1', ?) 277 - \\ON CONFLICT(did) DO UPDATE SET 278 - \\ handle = excluded.handle, 279 - \\ email = excluded.email, 280 - \\ password_hash = excluded.password_hash, 281 - \\ activated_at = excluded.activated_at, 282 - \\ deactivated_at = NULL, 283 - \\ signing_key_type = COALESCE(accounts.signing_key_type, excluded.signing_key_type), 284 - \\ signing_key = COALESCE(accounts.signing_key, excluded.signing_key) 285 - , .{ did, handle, email, password_hash, activated, zqlite.blob(&signing_key) }); 286 - return .{ 287 - .handle = try allocator.dupe(u8, handle), 288 - .did = try allocator.dupe(u8, did), 289 - .email = try allocator.dupe(u8, email), 290 - .password = try allocator.dupe(u8, password_hash), 291 - }; 292 - } 293 - 294 - pub fn generateAccountSigningKey() ![32]u8 { 295 - mutex.lockUncancelable(store_io); 296 - defer mutex.unlock(store_io); 297 - try requireInitialized(); 298 - return generateSigningKey(); 299 - } 300 - 301 - pub fn signingKeypair(did: []const u8) !zat.Keypair { 302 - mutex.lockUncancelable(store_io); 303 - defer mutex.unlock(store_io); 304 - try requireInitialized(); 305 - return signingKeypairLocked(did); 306 - } 307 - 308 - pub fn setAccountActive(did: []const u8, active: bool) !void { 309 - mutex.lockUncancelable(store_io); 310 - defer mutex.unlock(store_io); 311 - try requireInitialized(); 312 - if (active) { 313 - try conn.exec( 314 - \\UPDATE accounts 315 - \\SET activated_at = COALESCE(activated_at, unixepoch()), 316 - \\ deactivated_at = NULL 317 - \\WHERE did = ? 318 - , .{did}); 319 - } else { 320 - try conn.exec( 321 - \\UPDATE accounts 322 - \\SET deactivated_at = unixepoch() 323 - \\WHERE did = ? 324 - , .{did}); 325 - } 326 - } 327 - 328 - pub fn sequenceAccountEvent(allocator: std.mem.Allocator, did: []const u8, active: bool) !void { 329 - mutex.lockUncancelable(store_io); 330 - defer mutex.unlock(store_io); 331 - try requireInitialized(); 332 - const seq = try nextSeqLocked(); 333 - const frame = try accountEventFrame(allocator, seq, did, active); 334 - try insertSeqEventLocked(seq, did, "", frame); 335 - } 336 - 337 - pub fn sequenceIdentityEvent(allocator: std.mem.Allocator, did: []const u8, handle: []const u8) !void { 338 - mutex.lockUncancelable(store_io); 339 - defer mutex.unlock(store_io); 340 - try requireInitialized(); 341 - const seq = try nextSeqLocked(); 342 - const frame = try identityEventFrame(allocator, seq, did, handle); 343 - try insertSeqEventLocked(seq, did, "", frame); 344 - } 345 - 346 - pub fn sequenceSyncEvent(allocator: std.mem.Allocator, did: []const u8) !void { 347 - mutex.lockUncancelable(store_io); 348 - defer mutex.unlock(store_io); 349 - try requireInitialized(); 350 - const seq = try nextSeqLocked(); 351 - const commit = try latestCommitRawLocked(allocator, did) orelse return Error.RepoNotFound; 352 - const frame = try syncEventFrame(allocator, seq, did, commit.commit_cid_text, commit.data_cid_raw, commit.rev, commit.commit_data); 353 - try insertSeqEventLocked(seq, did, commit.commit_cid_text, frame); 354 - } 355 - 356 - pub fn isAccountActive(did: []const u8) bool { 357 - mutex.lockUncancelable(store_io); 358 - defer mutex.unlock(store_io); 359 - requireInitialized() catch return false; 360 - return accountActiveLocked(did) catch false; 361 - } 362 - 363 - pub fn create( 364 - allocator: std.mem.Allocator, 365 - account: auth.Account, 366 - collection: []const u8, 367 - maybe_rkey: ?[]const u8, 368 - value: std.json.Value, 369 - ) !Record { 370 - const rkey = maybe_rkey orelse try nextRkey(allocator); 371 - const result = try applyWrites(allocator, account, &.{.{ .create = .{ 372 - .collection = collection, 373 - .rkey = rkey, 374 - .value = value, 375 - } }}); 376 - if (result.records.len == 0) return Error.MissingRecord; 377 - return result.records[0]; 378 - } 379 - 380 - pub fn putOAuthRequest( 381 - request_id: []const u8, 382 - client_id: []const u8, 383 - redirect_uri: []const u8, 384 - scope: []const u8, 385 - state: []const u8, 386 - code_challenge: []const u8, 387 - code_challenge_method: []const u8, 388 - login_hint: ?[]const u8, 389 - dpop_jkt: ?[]const u8, 390 - expires_at: i64, 391 - ) !void { 392 - mutex.lockUncancelable(store_io); 393 - defer mutex.unlock(store_io); 394 - try requireInitialized(); 395 - try conn.exec( 396 - \\INSERT INTO oauth_requests 397 - \\ (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at) 398 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 399 - , .{ request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at }); 400 - } 401 - 402 - pub fn getOAuthRequest(allocator: std.mem.Allocator, request_id: []const u8) !?OAuthRequest { 403 - mutex.lockUncancelable(store_io); 404 - defer mutex.unlock(store_io); 405 - try requireInitialized(); 406 - const row = try conn.row( 407 - \\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at, sub, code 408 - \\FROM oauth_requests 409 - \\WHERE request_id = ? 410 - , .{request_id}); 411 - if (row == null) return null; 412 - defer row.?.deinit(); 413 - return try oauthRequestFromRow(row.?, allocator); 414 - } 415 - 416 - pub fn authorizeOAuthRequest(request_id: []const u8, did: []const u8, code: []const u8) !void { 417 - mutex.lockUncancelable(store_io); 418 - defer mutex.unlock(store_io); 419 - try requireInitialized(); 420 - try conn.exec( 421 - \\UPDATE oauth_requests 422 - \\SET sub = ?, code = ? 423 - \\WHERE request_id = ? 424 - , .{ did, code, request_id }); 425 - } 426 - 427 - pub fn consumeOAuthCode(allocator: std.mem.Allocator, code: []const u8) !?OAuthRequest { 428 - mutex.lockUncancelable(store_io); 429 - defer mutex.unlock(store_io); 430 - try requireInitialized(); 431 - const row = try conn.row( 432 - \\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at, sub, code 433 - \\FROM oauth_requests 434 - \\WHERE code = ? 435 - , .{code}); 436 - if (row == null) return null; 437 - defer row.?.deinit(); 438 - const request = try oauthRequestFromRow(row.?, allocator); 439 - try conn.exec("UPDATE oauth_requests SET code = NULL WHERE request_id = ?", .{request.request_id}); 440 - return request; 441 - } 442 - 443 - pub fn putOAuthToken( 444 - did: []const u8, 445 - client_id: []const u8, 446 - scope: []const u8, 447 - access_token: []const u8, 448 - refresh_token: []const u8, 449 - expires_at: i64, 450 - ) !void { 451 - mutex.lockUncancelable(store_io); 452 - defer mutex.unlock(store_io); 453 - try requireInitialized(); 454 - try conn.exec( 455 - \\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at) 456 - \\VALUES (?, ?, ?, ?, ?, ?) 457 - , .{ access_token, refresh_token, did, client_id, scope, expires_at }); 458 - } 459 - 460 - pub fn getOAuthToken(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken { 461 - mutex.lockUncancelable(store_io); 462 - defer mutex.unlock(store_io); 463 - try requireInitialized(); 464 - const row = try conn.row( 465 - \\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at 466 - \\FROM oauth_tokens 467 - \\WHERE access_token = ? OR refresh_token = ? 468 - \\ORDER BY created_at DESC 469 - \\LIMIT 1 470 - , .{ token, token }); 471 - if (row == null) return null; 472 - defer row.?.deinit(); 473 - return .{ 474 - .did = try allocator.dupe(u8, row.?.text(0)), 475 - .client_id = try allocator.dupe(u8, row.?.text(1)), 476 - .scope = try allocator.dupe(u8, row.?.text(2)), 477 - .access_token = try allocator.dupe(u8, row.?.text(3)), 478 - .refresh_token = try allocator.dupe(u8, row.?.text(4)), 479 - .expires_at = row.?.int(5), 480 - .revoked = row.?.nullableInt(6) != null, 481 - }; 482 - } 483 - 484 - pub fn revokeOAuthToken(token: []const u8) !void { 485 - mutex.lockUncancelable(store_io); 486 - defer mutex.unlock(store_io); 487 - try requireInitialized(); 488 - try conn.exec( 489 - \\UPDATE oauth_tokens 490 - \\SET revoked_at = unixepoch() 491 - \\WHERE access_token = ? OR refresh_token = ? 492 - , .{ token, token }); 493 - } 494 - 495 - pub fn put( 496 - allocator: std.mem.Allocator, 497 - account: auth.Account, 498 - collection: []const u8, 499 - rkey: []const u8, 500 - value: std.json.Value, 501 - ) !Record { 502 - const result = try applyWrites(allocator, account, &.{.{ .update = .{ 503 - .collection = collection, 504 - .rkey = rkey, 505 - .value = value, 506 - } }}); 507 - if (result.records.len == 0) return Error.MissingRecord; 508 - return result.records[0]; 509 - } 510 - 511 - pub fn delete(allocator: std.mem.Allocator, account: auth.Account, collection: []const u8, rkey: []const u8) !CommitInfo { 512 - const result = try applyWrites(allocator, account, &.{.{ .delete = .{ 513 - .collection = collection, 514 - .rkey = rkey, 515 - } }}); 516 - return result.commit; 517 - } 518 - 519 - pub fn applyWrites(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp) !WriteResult { 520 - if (ops.len == 0) return Error.MissingRecord; 521 - 522 - for (ops) |op| switch (op) { 523 - .create => |create_op| { 524 - if (zat.Nsid.parse(create_op.collection) == null) return Error.InvalidCollection; 525 - if (create_op.rkey) |rkey| if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; 526 - }, 527 - .update => |update_op| { 528 - if (zat.Nsid.parse(update_op.collection) == null) return Error.InvalidCollection; 529 - if (zat.Rkey.parse(update_op.rkey) == null) return Error.InvalidRecordKey; 530 - }, 531 - .delete => |delete_op| { 532 - if (zat.Nsid.parse(delete_op.collection) == null) return Error.InvalidCollection; 533 - if (zat.Rkey.parse(delete_op.rkey) == null) return Error.InvalidRecordKey; 534 - }, 535 - }; 536 - 537 - mutex.lockUncancelable(store_io); 538 - defer mutex.unlock(store_io); 539 - try requireInitialized(); 540 - 541 - const seq = try nextSeqLocked(); 542 - const current = try latestCommitRawLocked(allocator, account.did); 543 - const rev = try revForSeq(allocator, seq, if (current) |root| root.rev else null); 544 - const repo_car = try readRepoCarLocked(allocator, account.did); 545 - var tree = if (current) |root| 546 - try zat.mst.Mst.loadFromBlocks(allocator, repo_car, root.data_cid_raw) 547 - else 548 - zat.mst.Mst.init(allocator); 549 - 550 - var records: std.ArrayList(Record) = .empty; 551 - var record_blocks: std.ArrayList(ImportedBlock) = .empty; 552 - var mst_blocks: std.ArrayList(zat.car.Block) = .empty; 553 - var blob_refs: std.ArrayList(BlobRef) = .empty; 554 - 555 - for (ops) |op| switch (op) { 556 - .create => |create_op| { 557 - const rkey = create_op.rkey orelse try nextRkeyLocked(allocator); 558 - const record = try stageRecordWrite(allocator, &tree, account, create_op.collection, rkey, create_op.value, rev, seq, &record_blocks, &blob_refs); 559 - try records.append(allocator, record); 560 - }, 561 - .update => |update_op| { 562 - const record = try stageRecordWrite(allocator, &tree, account, update_op.collection, update_op.rkey, update_op.value, rev, seq, &record_blocks, &blob_refs); 563 - try records.append(allocator, record); 564 - }, 565 - .delete => |delete_op| { 566 - const path = try repoPath(allocator, delete_op.collection, delete_op.rkey); 567 - _ = try tree.deleteReturn(path); 568 - }, 569 - }; 570 - 571 - const data_cid = try tree.rootCid(); 572 - try writeMstBlocks(allocator, &tree, &mst_blocks); 573 - const commit = try signedCommit(allocator, account.did, rev, data_cid, if (current) |root| root.commit_cid_raw else null); 574 - 575 - try conn.exclusiveTransaction(); 576 - errdefer conn.rollback(); 577 - for (record_blocks.items) |block| { 578 - try conn.exec( 579 - \\INSERT INTO repo_blocks (did, cid, data) 580 - \\VALUES (?, ?, ?) 581 - \\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data 582 - , .{ account.did, block.cid, zqlite.blob(block.data) }); 583 - } 584 - for (mst_blocks.items) |block| { 585 - try conn.exec( 586 - \\INSERT INTO repo_blocks (did, cid, data) 587 - \\VALUES (?, ?, ?) 588 - \\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data 589 - , .{ account.did, try cidText(allocator, block.cid_raw), zqlite.blob(block.data) }); 590 - } 591 - try conn.exec( 592 - \\INSERT INTO repo_blocks (did, cid, data) 593 - \\VALUES (?, ?, ?) 594 - \\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data 595 - , .{ account.did, commit.cid, zqlite.blob(commit.data) }); 596 - 597 - for (ops) |op| switch (op) { 598 - .delete => |delete_op| { 599 - const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, delete_op.collection, delete_op.rkey }); 600 - try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri}); 601 - try conn.exec("DELETE FROM records WHERE did = ? AND collection = ? AND rkey = ?", .{ account.did, delete_op.collection, delete_op.rkey }); 602 - }, 603 - else => {}, 604 - }; 605 - for (records.items) |record| { 606 - const uri = try record.uri(allocator); 607 - try conn.exec( 608 - \\INSERT INTO records (did, collection, rkey, uri, cid, value_json, rev, seq) 609 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) 610 - \\ON CONFLICT(did, collection, rkey) DO UPDATE SET 611 - \\ uri = excluded.uri, 612 - \\ cid = excluded.cid, 613 - \\ value_json = excluded.value_json, 614 - \\ rev = excluded.rev, 615 - \\ seq = excluded.seq 616 - , .{ record.did, record.collection, record.rkey, uri, record.cid, record.value_json, record.rev, @as(i64, @intCast(seq)) }); 617 - try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri}); 618 - } 619 - for (blob_refs.items) |ref| { 620 - try conn.exec( 621 - \\INSERT INTO expected_blobs (blob_cid, record_uri) 622 - \\VALUES (?, ?) 623 - \\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid 624 - , .{ ref.cid, ref.uri }); 625 - } 626 - try conn.exec( 627 - \\INSERT INTO commits (seq, did, cid, rev, prev) 628 - \\VALUES (?, ?, ?, ?, ?) 629 - , .{ @as(i64, @intCast(seq)), account.did, commit.cid, rev, if (current) |root| root.commit_cid_text else null }); 630 - const event_frame = try commitEventFrame( 631 - allocator, 632 - seq, 633 - account.did, 634 - commit.cid, 635 - rev, 636 - if (current) |root| root.commit_cid_text else null, 637 - commit.data, 638 - record_blocks.items, 639 - mst_blocks.items, 640 - ops, 641 - records.items, 642 - ); 643 - try conn.exec( 644 - \\INSERT INTO seq_events (seq, did, commit_cid, evt) 645 - \\VALUES (?, ?, ?, ?) 646 - \\ON CONFLICT(seq) DO UPDATE SET 647 - \\ did = excluded.did, 648 - \\ commit_cid = excluded.commit_cid, 649 - \\ evt = excluded.evt 650 - , .{ @as(i64, @intCast(seq)), account.did, commit.cid, zqlite.blob(event_frame) }); 651 - try conn.commit(); 652 - 653 - return .{ 654 - .commit = .{ .cid = commit.cid, .rev = rev }, 655 - .records = try records.toOwnedSlice(allocator), 656 - }; 657 - } 658 - 659 - pub fn revForSeq(allocator: std.mem.Allocator, seq: u64, current_rev: ?[]const u8) ![]const u8 { 660 - var timestamp_us = nowMicros(); 661 - if (current_rev) |rev| { 662 - const current_timestamp = atid.timestampMicros(rev) catch 0; 663 - if (timestamp_us <= current_timestamp) timestamp_us = current_timestamp + 1; 664 - } 665 - const tid = try atid.encode(timestamp_us, @intCast(seq % 1024)); 666 - return allocator.dupe(u8, &tid); 667 - } 668 - 669 - fn nowMicros() u64 { 670 - var ts: std.posix.timespec = undefined; 671 - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 672 - .SUCCESS => ts, 673 - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, 674 - }; 675 - const seconds: u64 = if (timestamp.sec < 0) 0 else @intCast(timestamp.sec); 676 - const nanos: u64 = if (timestamp.nsec < 0) 0 else @intCast(timestamp.nsec); 677 - return seconds * std.time.us_per_s + nanos / std.time.ns_per_us; 678 - } 679 - 680 - pub fn get(did: []const u8, collection: []const u8, rkey: []const u8) ?Record { 681 - mutex.lockUncancelable(store_io); 682 - defer mutex.unlock(store_io); 683 - requireInitialized() catch return null; 684 - 685 - const row = conn.row( 686 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 687 - \\FROM records 688 - \\WHERE did = ? AND collection = ? AND rkey = ? 689 - , .{ did, collection, rkey }) catch return null; 690 - if (row == null) return null; 691 - defer row.?.deinit(); 692 - return recordFromRow(row.?, std.heap.page_allocator) catch null; 693 - } 694 - 695 - pub fn getByUri(uri: []const u8) ?Record { 696 - mutex.lockUncancelable(store_io); 697 - defer mutex.unlock(store_io); 698 - requireInitialized() catch return null; 699 - return getByUriLocked(uri); 700 - } 701 - 702 - pub fn listRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]Record { 703 - mutex.lockUncancelable(store_io); 704 - defer mutex.unlock(store_io); 705 - try requireInitialized(); 706 - 707 - var rows = try conn.rows( 708 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 709 - \\FROM records 710 - \\ORDER BY seq DESC 711 - \\LIMIT ? 712 - , .{@as(i64, @intCast(if (limit == 0) 100 else limit))}); 713 - defer rows.deinit(); 714 - 715 - var records: std.ArrayList(Record) = .empty; 716 - while (rows.next()) |row| { 717 - try records.append(allocator, try recordFromRow(row, allocator)); 718 - } 719 - if (rows.err) |err| return err; 720 - return records.toOwnedSlice(allocator); 721 - } 722 - 723 - pub fn listRecords( 724 - allocator: std.mem.Allocator, 725 - did: []const u8, 726 - collection: []const u8, 727 - limit: usize, 728 - ) ![]Record { 729 - mutex.lockUncancelable(store_io); 730 - defer mutex.unlock(store_io); 731 - try requireInitialized(); 732 - 733 - var rows = try conn.rows( 734 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 735 - \\FROM records 736 - \\WHERE did = ? AND collection = ? 737 - \\ORDER BY seq DESC 738 - \\LIMIT ? 739 - , .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); 740 - defer rows.deinit(); 741 - 742 - var records: std.ArrayList(Record) = .empty; 743 - while (rows.next()) |row| { 744 - try records.append(allocator, try recordFromRow(row, allocator)); 745 - } 746 - if (rows.err) |err| return err; 747 - return records.toOwnedSlice(allocator); 748 - } 749 - 750 - pub fn listRecordsContaining( 751 - allocator: std.mem.Allocator, 752 - collection: []const u8, 753 - needle: []const u8, 754 - limit: usize, 755 - ) ![]Record { 756 - mutex.lockUncancelable(store_io); 757 - defer mutex.unlock(store_io); 758 - try requireInitialized(); 759 - 760 - var rows = try conn.rows( 761 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 762 - \\FROM records 763 - \\WHERE collection = ? AND instr(value_json, ?) 764 - \\ORDER BY seq DESC 765 - \\LIMIT ? 766 - , .{ collection, needle, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); 767 - defer rows.deinit(); 768 - 769 - var records: std.ArrayList(Record) = .empty; 770 - while (rows.next()) |row| { 771 - try records.append(allocator, try recordFromRow(row, allocator)); 772 - } 773 - if (rows.err) |err| return err; 774 - return records.toOwnedSlice(allocator); 775 - } 776 - 777 - pub fn listRecordsByDidContaining( 778 - allocator: std.mem.Allocator, 779 - did: []const u8, 780 - collection: []const u8, 781 - needle: []const u8, 782 - limit: usize, 783 - ) ![]Record { 784 - mutex.lockUncancelable(store_io); 785 - defer mutex.unlock(store_io); 786 - try requireInitialized(); 787 - 788 - var rows = try conn.rows( 789 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 790 - \\FROM records 791 - \\WHERE did = ? AND collection = ? AND instr(value_json, ?) 792 - \\ORDER BY seq DESC 793 - \\LIMIT ? 794 - , .{ did, collection, needle, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); 795 - defer rows.deinit(); 796 - 797 - var records: std.ArrayList(Record) = .empty; 798 - while (rows.next()) |row| { 799 - try records.append(allocator, try recordFromRow(row, allocator)); 800 - } 801 - if (rows.err) |err| return err; 802 - return records.toOwnedSlice(allocator); 803 - } 804 - 805 - fn getByUriLocked(uri: []const u8) ?Record { 806 - if (getByStoredUriLocked(uri)) |record| return record; 807 - const prefix = "at://"; 808 - if (!std.mem.startsWith(u8, uri, prefix)) return null; 809 - const rest = uri[prefix.len..]; 810 - const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null; 811 - const repo = rest[0..slash]; 812 - const account = (findAccountLocked(std.heap.page_allocator, repo) catch null) orelse return null; 813 - const normalized = std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}", .{ account.did, rest[slash + 1 ..] }) catch return null; 814 - return getByStoredUriLocked(normalized); 815 - } 816 - 817 - fn getByStoredUriLocked(uri: []const u8) ?Record { 818 - const row = conn.row( 819 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 820 - \\FROM records 821 - \\WHERE uri = ? 822 - , .{uri}) catch return null; 823 - if (row == null) return null; 824 - defer row.?.deinit(); 825 - return recordFromRow(row.?, std.heap.page_allocator) catch null; 826 - } 827 - 828 - pub fn count(did: []const u8, collection: []const u8) usize { 829 - mutex.lockUncancelable(store_io); 830 - defer mutex.unlock(store_io); 831 - requireInitialized() catch return 0; 832 - const row = conn.row("SELECT count(*) FROM records WHERE did = ? AND collection = ?", .{ did, collection }) catch return 0; 833 - if (row == null) return 0; 834 - defer row.?.deinit(); 835 - return @intCast(row.?.int(0)); 836 - } 837 - 838 - pub fn countSubject(collection: []const u8, subject_did: []const u8) usize { 839 - mutex.lockUncancelable(store_io); 840 - defer mutex.unlock(store_io); 841 - requireInitialized() catch return 0; 842 - const row = conn.row("SELECT count(*) FROM records WHERE collection = ? AND instr(value_json, ?) > 0", .{ collection, subject_did }) catch return 0; 843 - if (row == null) return 0; 844 - defer row.?.deinit(); 845 - return @intCast(row.?.int(0)); 846 - } 847 - 848 - pub fn listCollectionsJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 849 - mutex.lockUncancelable(store_io); 850 - defer mutex.unlock(store_io); 851 - try requireInitialized(); 852 - 853 - var rows = try conn.rows( 854 - \\SELECT DISTINCT collection 855 - \\FROM records 856 - \\WHERE did = ? 857 - \\ORDER BY collection 858 - , .{did}); 859 - defer rows.deinit(); 860 - 861 - var out: std.Io.Writer.Allocating = .init(allocator); 862 - defer out.deinit(); 863 - try out.writer.writeByte('['); 864 - var first = true; 865 - while (rows.next()) |row| { 866 - if (!first) try out.writer.writeByte(','); 867 - first = false; 868 - try out.writer.print("{f}", .{std.json.fmt(row.text(0), .{})}); 869 - } 870 - if (rows.err) |err| return err; 871 - try out.writer.writeByte(']'); 872 - return out.toOwnedSlice(); 873 - } 874 - 875 - pub fn getAppPreferences(allocator: std.mem.Allocator, did: []const u8, namespace: []const u8) ![]AppPreference { 876 - mutex.lockUncancelable(store_io); 877 - defer mutex.unlock(store_io); 878 - try requireInitialized(); 879 - 880 - const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); 881 - var rows = try conn.rows( 882 - \\SELECT name, value_json 883 - \\FROM app_preferences 884 - \\WHERE did = ? AND (name = ? OR name LIKE ?) 885 - \\ORDER BY id ASC 886 - , .{ did, namespace, like_pattern }); 887 - defer rows.deinit(); 888 - 889 - var preferences: std.ArrayList(AppPreference) = .empty; 890 - while (rows.next()) |row| { 891 - try preferences.append(allocator, .{ 892 - .name = try allocator.dupe(u8, row.text(0)), 893 - .value_json = try allocator.dupe(u8, row.text(1)), 894 - }); 895 - } 896 - if (rows.err) |err| return err; 897 - return preferences.toOwnedSlice(allocator); 898 - } 899 - 900 - pub fn replaceAppPreferences( 901 - allocator: std.mem.Allocator, 902 - did: []const u8, 903 - namespace: []const u8, 904 - preferences: []const AppPreference, 905 - ) !void { 906 - mutex.lockUncancelable(store_io); 907 - defer mutex.unlock(store_io); 908 - try requireInitialized(); 909 - const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); 910 - try conn.exclusiveTransaction(); 911 - errdefer conn.rollback(); 912 - try conn.exec( 913 - \\DELETE FROM app_preferences 914 - \\WHERE did = ? AND (name = ? OR name LIKE ?) 915 - , .{ did, namespace, like_pattern }); 916 - for (preferences) |preference| { 917 - try conn.exec( 918 - \\INSERT INTO app_preferences (did, name, value_json, updated_at) 919 - \\VALUES (?, ?, ?, unixepoch()) 920 - , .{ did, preference.name, preference.value_json }); 921 - } 922 - try conn.commit(); 923 - } 924 - 925 - pub fn putBlob( 926 - allocator: std.mem.Allocator, 927 - io: Io, 928 - account: auth.Account, 929 - data: []const u8, 930 - mime_type: []const u8, 931 - ) ![]const u8 { 932 - const cid = try cidForBlob(allocator, data); 933 - 934 - mutex.lockUncancelable(store_io); 935 - defer mutex.unlock(store_io); 936 - try requireInitialized(); 937 - try blobstore.put(allocator, io, account.did, cid, data); 938 - try conn.exec( 939 - \\INSERT INTO blobs (cid, did, mime_type, size) 940 - \\VALUES (?, ?, ?, ?) 941 - \\ON CONFLICT(did, cid) DO UPDATE SET 942 - \\ mime_type = excluded.mime_type, 943 - \\ size = excluded.size 944 - , .{ cid, account.did, mime_type, @as(i64, @intCast(data.len)) }); 945 - return cid; 946 - } 947 - 948 - pub fn getBlob( 949 - allocator: std.mem.Allocator, 950 - did: []const u8, 951 - cid: []const u8, 952 - ) ?BlobRecord { 953 - mutex.lockUncancelable(store_io); 954 - defer mutex.unlock(store_io); 955 - requireInitialized() catch return null; 956 - const row = conn.row( 957 - \\SELECT mime_type, size 958 - \\FROM blobs 959 - \\WHERE did = ? AND cid = ? 960 - , .{ did, cid }) catch return null; 961 - const found = row orelse return null; 962 - defer found.deinit(); 963 - const size: usize = @intCast(found.int(1)); 964 - return .{ 965 - .mime_type = allocator.dupe(u8, found.text(0)) catch return null, 966 - .data = blobstore.get(allocator, did, cid, size + 1) catch return null, 967 - }; 968 - } 969 - 970 - pub fn writeBlobListJson(allocator: std.mem.Allocator, did: []const u8, limit: usize) ![]const u8 { 971 - mutex.lockUncancelable(store_io); 972 - defer mutex.unlock(store_io); 973 - try requireInitialized(); 974 - 975 - var rows = try conn.rows( 976 - \\SELECT cid 977 - \\FROM blobs 978 - \\WHERE did = ? 979 - \\ORDER BY created_at ASC, cid ASC 980 - \\LIMIT ? 981 - , .{ did, @as(i64, @intCast(if (limit == 0) 500 else limit)) }); 982 - defer rows.deinit(); 983 - 984 - var out: std.Io.Writer.Allocating = .init(allocator); 985 - defer out.deinit(); 986 - try out.writer.writeAll("{\"cids\":["); 987 - var first = true; 988 - var last_cid: ?[]const u8 = null; 989 - while (rows.next()) |row| { 990 - if (!first) try out.writer.writeByte(','); 991 - first = false; 992 - const cid = row.text(0); 993 - last_cid = cid; 994 - try out.writer.print("{f}", .{std.json.fmt(cid, .{})}); 995 - } 996 - if (rows.err) |err| return err; 997 - try out.writer.writeByte(']'); 998 - if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})}); 999 - try out.writer.writeByte('}'); 1000 - return out.toOwnedSlice(); 1001 - } 1002 - 1003 - pub fn writeMissingBlobsJson(allocator: std.mem.Allocator, did: []const u8, limit: usize) ![]const u8 { 1004 - mutex.lockUncancelable(store_io); 1005 - defer mutex.unlock(store_io); 1006 - try requireInitialized(); 1007 - 1008 - var rows = try conn.rows( 1009 - \\SELECT rb.blob_cid, rb.record_uri 1010 - \\FROM expected_blobs rb 1011 - \\JOIN records r ON r.uri = rb.record_uri 1012 - \\LEFT JOIN blobs b ON b.cid = rb.blob_cid AND b.did = r.did 1013 - \\WHERE r.did = ? AND b.cid IS NULL 1014 - \\ORDER BY rb.blob_cid ASC 1015 - \\LIMIT ? 1016 - , .{ did, @as(i64, @intCast(if (limit == 0) 500 else limit)) }); 1017 - defer rows.deinit(); 1018 - 1019 - var out: std.Io.Writer.Allocating = .init(allocator); 1020 - defer out.deinit(); 1021 - try out.writer.writeAll("{\"blobs\":["); 1022 - var first = true; 1023 - var last_cid: ?[]const u8 = null; 1024 - while (rows.next()) |row| { 1025 - if (!first) try out.writer.writeByte(','); 1026 - first = false; 1027 - const cid = row.text(0); 1028 - last_cid = cid; 1029 - try out.writer.print( 1030 - "{{\"cid\":{f},\"recordUri\":{f}}}", 1031 - .{ std.json.fmt(cid, .{}), std.json.fmt(row.text(1), .{}) }, 1032 - ); 1033 - } 1034 - if (rows.err) |err| return err; 1035 - try out.writer.writeByte(']'); 1036 - if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})}); 1037 - try out.writer.writeByte('}'); 1038 - return out.toOwnedSlice(); 1039 - } 1040 - 1041 - pub fn writeAccountStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 1042 - mutex.lockUncancelable(store_io); 1043 - defer mutex.unlock(store_io); 1044 - try requireInitialized(); 1045 - 1046 - const record_count = try scalarCountLocked( 1047 - "SELECT COUNT(*) FROM records WHERE did = ?", 1048 - did, 1049 - ); 1050 - const blob_count = try scalarCountLocked( 1051 - "SELECT COUNT(*) FROM blobs WHERE did = ?", 1052 - did, 1053 - ); 1054 - const expected_blob_count = try scalarCountLocked( 1055 - \\SELECT COUNT(*) 1056 - \\FROM expected_blobs rb 1057 - \\JOIN records r ON r.uri = rb.record_uri 1058 - \\WHERE r.did = ? 1059 - , 1060 - did, 1061 - ); 1062 - const block_count = try scalarCountLocked( 1063 - "SELECT COUNT(*) FROM repo_blocks WHERE did = ?", 1064 - did, 1065 - ); 1066 - const active = try accountActiveLocked(did); 1067 - const root = try latestRootLocked(allocator, did); 1068 - 1069 - return std.fmt.allocPrint( 1070 - allocator, 1071 - "{{\"activated\":{},\"validDid\":{},\"repoCommit\":{f},\"repoRev\":{f},\"repoBlocks\":{d},\"indexedRecords\":{d},\"privateStateValues\":0,\"expectedBlobs\":{d},\"importedBlobs\":{d}}}", 1072 - .{ 1073 - active, 1074 - active, 1075 - std.json.fmt(root.cid, .{}), 1076 - std.json.fmt(root.rev, .{}), 1077 - block_count, 1078 - record_count, 1079 - expected_blob_count, 1080 - blob_count, 1081 - }, 1082 - ); 1083 - } 1084 - 1085 - pub fn importRepo( 1086 - allocator: std.mem.Allocator, 1087 - account: auth.Account, 1088 - commit_cid: []const u8, 1089 - rev: []const u8, 1090 - records: []const ImportedRecord, 1091 - blocks: []const ImportedBlock, 1092 - ) !void { 1093 - mutex.lockUncancelable(store_io); 1094 - defer mutex.unlock(store_io); 1095 - try requireInitialized(); 1096 - 1097 - const seq = try nextSeqLocked(); 1098 - try conn.exclusiveTransaction(); 1099 - errdefer conn.rollback(); 1100 - try conn.exec("DELETE FROM expected_blobs WHERE record_uri IN (SELECT uri FROM records WHERE did = ?)", .{account.did}); 1101 - try conn.exec("DELETE FROM records WHERE did = ?", .{account.did}); 1102 - try conn.exec("DELETE FROM repo_blocks WHERE did = ?", .{account.did}); 1103 - try conn.exec("DELETE FROM commits WHERE did = ?", .{account.did}); 1104 - 1105 - for (blocks) |block| { 1106 - try conn.exec( 1107 - \\INSERT INTO repo_blocks (did, cid, data) 1108 - \\VALUES (?, ?, ?) 1109 - \\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data 1110 - , .{ account.did, block.cid, zqlite.blob(block.data) }); 1111 - } 1112 - for (records) |record| { 1113 - const uri = try std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}/{s}", .{ account.did, record.collection, record.rkey }); 1114 - defer std.heap.page_allocator.free(uri); 1115 - try conn.exec( 1116 - \\INSERT INTO records (did, collection, rkey, uri, cid, value_json, rev, seq) 1117 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) 1118 - , .{ account.did, record.collection, record.rkey, uri, record.cid, record.value_json, rev, @as(i64, @intCast(seq)) }); 1119 - for (record.blob_cids) |blob_cid| { 1120 - try conn.exec( 1121 - \\INSERT INTO expected_blobs (blob_cid, record_uri) 1122 - \\VALUES (?, ?) 1123 - \\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid 1124 - , .{ blob_cid, uri }); 1125 - } 1126 - } 1127 - try conn.exec( 1128 - \\INSERT INTO commits (seq, did, cid, rev, prev) 1129 - \\VALUES (?, ?, ?, ?, NULL) 1130 - , .{ @as(i64, @intCast(seq)), account.did, commit_cid, rev }); 1131 - const event_frame = try importedCommitEventFrame(allocator, seq, account.did, commit_cid, rev, blocks); 1132 - try conn.exec( 1133 - \\INSERT INTO seq_events (seq, did, commit_cid, evt) 1134 - \\VALUES (?, ?, ?, ?) 1135 - \\ON CONFLICT(seq) DO UPDATE SET 1136 - \\ did = excluded.did, 1137 - \\ commit_cid = excluded.commit_cid, 1138 - \\ evt = excluded.evt 1139 - , .{ @as(i64, @intCast(seq)), account.did, commit_cid, zqlite.blob(event_frame) }); 1140 - try conn.commit(); 1141 - } 1142 - 1143 - pub fn writeRepoCar(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 1144 - mutex.lockUncancelable(store_io); 1145 - defer mutex.unlock(store_io); 1146 - try requireInitialized(); 1147 - 1148 - const root = try latestRootLocked(allocator, did); 1149 - const root_raw = try zat.multibase.base32lower.decode(allocator, root.cid[1..]); 1150 - const car_root = zat.cbor.Cid{ .raw = root_raw }; 1151 - 1152 - var rows = try conn.rows( 1153 - \\SELECT cid, data 1154 - \\FROM repo_blocks 1155 - \\WHERE did = ? 1156 - \\ORDER BY cid ASC 1157 - , .{did}); 1158 - defer rows.deinit(); 1159 - 1160 - var blocks: std.ArrayList(zat.car.Block) = .empty; 1161 - while (rows.next()) |row| { 1162 - const cid_text = row.text(0); 1163 - const data = row.nullableBlob(1) orelse ""; 1164 - const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]); 1165 - try blocks.append(allocator, .{ 1166 - .cid_raw = cid_raw, 1167 - .data = try allocator.dupe(u8, data), 1168 - }); 1169 - } 1170 - if (rows.err) |err| return err; 1171 - const c: zat.car.Car = .{ 1172 - .roots = &.{car_root}, 1173 - .blocks = blocks.items, 1174 - }; 1175 - return zat.car.writeAlloc(allocator, c); 1176 - } 1177 - 1178 - pub fn writeRepoListJson(allocator: std.mem.Allocator, limit: usize) ![]const u8 { 1179 - mutex.lockUncancelable(store_io); 1180 - defer mutex.unlock(store_io); 1181 - try requireInitialized(); 1182 - 1183 - const actual_limit = if (limit == 0) 500 else @min(limit, 1000); 1184 - var rows = try conn.rows( 1185 - \\SELECT a.did, c.cid, c.rev, a.activated_at, a.deactivated_at 1186 - \\FROM accounts a 1187 - \\JOIN ( 1188 - \\ SELECT did, MAX(seq) AS seq 1189 - \\ FROM commits 1190 - \\ GROUP BY did 1191 - \\) latest ON latest.did = a.did 1192 - \\JOIN commits c ON c.did = latest.did AND c.seq = latest.seq 1193 - \\ORDER BY a.did ASC 1194 - \\LIMIT ? 1195 - , .{@as(i64, @intCast(actual_limit))}); 1196 - defer rows.deinit(); 1197 - 1198 - var out: std.Io.Writer.Allocating = .init(allocator); 1199 - defer out.deinit(); 1200 - try out.writer.writeAll("{\"repos\":["); 1201 - var first = true; 1202 - while (rows.next()) |row| { 1203 - if (!first) try out.writer.writeByte(','); 1204 - first = false; 1205 - const did = row.text(0); 1206 - const active = row.nullableInt(3) != null and row.nullableInt(4) == null; 1207 - const status = if (active) "active" else "deactivated"; 1208 - try out.writer.print( 1209 - "{{\"did\":{f},\"head\":{f},\"rev\":{f},\"active\":{},\"status\":{f}}}", 1210 - .{ 1211 - std.json.fmt(did, .{}), 1212 - std.json.fmt(row.text(1), .{}), 1213 - std.json.fmt(row.text(2), .{}), 1214 - active, 1215 - std.json.fmt(status, .{}), 1216 - }, 1217 - ); 1218 - } 1219 - if (rows.err) |err| return err; 1220 - try out.writer.writeByte(']'); 1221 - try out.writer.writeByte('}'); 1222 - return out.toOwnedSlice(); 1223 - } 1224 - 1225 - pub fn writeLatestCommitJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 1226 - mutex.lockUncancelable(store_io); 1227 - defer mutex.unlock(store_io); 1228 - try requireInitialized(); 1229 - const root = try latestRootLocked(allocator, did); 1230 - return std.fmt.allocPrint( 1231 - allocator, 1232 - "{{\"cid\":{f},\"rev\":{f}}}", 1233 - .{ std.json.fmt(root.cid, .{}), std.json.fmt(root.rev, .{}) }, 1234 - ); 1235 - } 1236 - 1237 - pub fn listSeqEvents(allocator: std.mem.Allocator, cursor: u64, limit: usize) ![]SeqEvent { 1238 - mutex.lockUncancelable(store_io); 1239 - defer mutex.unlock(store_io); 1240 - try requireInitialized(); 1241 - try backfillSeqEventsLocked(allocator); 1242 - 1243 - var rows = try conn.rows( 1244 - \\SELECT seq, evt 1245 - \\FROM seq_events 1246 - \\WHERE seq > ? 1247 - \\ORDER BY seq ASC 1248 - \\LIMIT ? 1249 - , .{ @as(i64, @intCast(cursor)), @as(i64, @intCast(@min(limit, 1000))) }); 1250 - defer rows.deinit(); 1251 - 1252 - var events: std.ArrayList(SeqEvent) = .empty; 1253 - while (rows.next()) |row| { 1254 - try events.append(allocator, .{ 1255 - .seq = @intCast(row.int(0)), 1256 - .frame = try allocator.dupe(u8, row.nullableBlob(1) orelse ""), 1257 - }); 1258 - } 1259 - if (rows.err) |err| return err; 1260 - return events.toOwnedSlice(allocator); 1261 - } 1262 - 1263 - fn insertSeqEventLocked(seq: u64, did: []const u8, commit_cid: []const u8, frame: []const u8) !void { 1264 - try conn.exec( 1265 - \\INSERT INTO seq_events (seq, did, commit_cid, evt) 1266 - \\VALUES (?, ?, ?, ?) 1267 - \\ON CONFLICT(seq) DO UPDATE SET 1268 - \\ did = excluded.did, 1269 - \\ commit_cid = excluded.commit_cid, 1270 - \\ evt = excluded.evt 1271 - , .{ @as(i64, @intCast(seq)), did, commit_cid, zqlite.blob(frame) }); 1272 - } 1273 - 1274 - pub fn writeRepoStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 1275 - mutex.lockUncancelable(store_io); 1276 - defer mutex.unlock(store_io); 1277 - try requireInitialized(); 1278 - const root = try latestRootLocked(allocator, did); 1279 - const active = try accountActiveLocked(did); 1280 - const status = if (active) "active" else "deactivated"; 1281 - return std.fmt.allocPrint( 1282 - allocator, 1283 - "{{\"did\":{f},\"active\":{},\"status\":{f},\"rev\":{f}}}", 1284 - .{ std.json.fmt(did, .{}), active, std.json.fmt(status, .{}), std.json.fmt(root.rev, .{}) }, 1285 - ); 1286 - } 1287 - 1288 - pub fn writeRecordJson(allocator: std.mem.Allocator, record: Record) ![]const u8 { 1289 - var out: std.Io.Writer.Allocating = .init(allocator); 1290 - defer out.deinit(); 1291 - const uri = try record.uri(allocator); 1292 - defer allocator.free(uri); 1293 - 1294 - try out.writer.print( 1295 - "{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}", 1296 - .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json }, 1297 - ); 1298 - return out.toOwnedSlice(); 1299 - } 1300 - 1301 - pub fn writeListJson( 1302 - allocator: std.mem.Allocator, 1303 - did: []const u8, 1304 - collection: []const u8, 1305 - limit: usize, 1306 - ) ![]const u8 { 1307 - mutex.lockUncancelable(store_io); 1308 - defer mutex.unlock(store_io); 1309 - try requireInitialized(); 1310 - 1311 - var rows = try conn.rows( 1312 - \\SELECT did, collection, rkey, cid, value_json, rev, seq 1313 - \\FROM records 1314 - \\WHERE did = ? AND collection = ? 1315 - \\ORDER BY seq DESC 1316 - \\LIMIT ? 1317 - , .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); 1318 - defer rows.deinit(); 1319 - 1320 - var out: std.Io.Writer.Allocating = .init(allocator); 1321 - defer out.deinit(); 1322 - try out.writer.writeAll("{\"records\":["); 1323 - var first = true; 1324 - while (rows.next()) |row| { 1325 - const record = try recordFromRow(row, std.heap.page_allocator); 1326 - if (!first) try out.writer.writeByte(','); 1327 - first = false; 1328 - const uri = try record.uri(allocator); 1329 - defer allocator.free(uri); 1330 - try out.writer.print( 1331 - "{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}", 1332 - .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json }, 1333 - ); 1334 - } 1335 - if (rows.err) |err| return err; 1336 - try out.writer.writeAll("]}"); 1337 - return out.toOwnedSlice(); 1338 - } 1339 - 1340 - fn migrate() !void { 1341 - inline for (schema_statements) |sql| try conn.execNoArgs(sql); 1342 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email TEXT") catch {}; 1343 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email_confirmed_at INTEGER") catch {}; 1344 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code TEXT") catch {}; 1345 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code_expires_at INTEGER") catch {}; 1346 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN pending_email TEXT") catch {}; 1347 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN activated_at INTEGER") catch {}; 1348 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN deactivated_at INTEGER") catch {}; 1349 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key_type TEXT") catch {}; 1350 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {}; 1351 - conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; 1352 - try migrateBlobTable(); 1353 - try migrateAppPreferencesTable(); 1354 - inline for (post_schema_statements) |sql| try conn.execNoArgs(sql); 1355 - } 1356 - 1357 - fn migrateAppPreferencesTable() !void { 1358 - if (!try appPreferencesTableHasPreferencesJsonColumn()) return; 1359 - try conn.execNoArgs( 1360 - \\CREATE TABLE IF NOT EXISTS app_preferences_next ( 1361 - \\ id INTEGER PRIMARY KEY AUTOINCREMENT, 1362 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 1363 - \\ name TEXT NOT NULL, 1364 - \\ value_json BLOB NOT NULL, 1365 - \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 1366 - \\) 1367 - ); 1368 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1369 - defer arena.deinit(); 1370 - const allocator = arena.allocator(); 1371 - 1372 - { 1373 - var rows = try conn.rows( 1374 - \\SELECT did, namespace, preferences_json 1375 - \\FROM app_preferences 1376 - , .{}); 1377 - defer rows.deinit(); 1378 - 1379 - var preferences: std.ArrayList(AppPreference) = .empty; 1380 - while (rows.next()) |row| { 1381 - const did = try allocator.dupe(u8, row.text(0)); 1382 - const namespace = try allocator.dupe(u8, row.text(1)); 1383 - const preferences_json = try allocator.dupe(u8, row.text(2)); 1384 - const parsed = std.json.parseFromSlice(std.json.Value, allocator, preferences_json, .{}) catch continue; 1385 - if (parsed.value != .array) continue; 1386 - for (parsed.value.array.items) |preference| { 1387 - if (preference != .object) continue; 1388 - const name = jsonValueString(preference, "$type") orelse continue; 1389 - if (!preferenceNameInNamespace(name, namespace)) continue; 1390 - const value_json = try stringifyValue(allocator, preference); 1391 - try preferences.append(allocator, .{ 1392 - .name = name, 1393 - .value_json = value_json, 1394 - }); 1395 - } 1396 - for (preferences.items) |preference| { 1397 - try conn.exec( 1398 - \\INSERT INTO app_preferences_next (did, name, value_json, updated_at) 1399 - \\VALUES (?, ?, ?, unixepoch()) 1400 - , .{ did, preference.name, preference.value_json }); 1401 - } 1402 - preferences.clearRetainingCapacity(); 1403 - } 1404 - if (rows.err) |err| return err; 1405 - } 1406 - 1407 - try conn.execNoArgs("DROP TABLE app_preferences"); 1408 - try conn.execNoArgs("ALTER TABLE app_preferences_next RENAME TO app_preferences"); 1409 - } 1410 - 1411 - fn appPreferencesTableHasPreferencesJsonColumn() !bool { 1412 - var rows = try conn.rows("PRAGMA table_info(app_preferences)", .{}); 1413 - defer rows.deinit(); 1414 - while (rows.next()) |row| { 1415 - if (std.mem.eql(u8, row.text(1), "preferences_json")) return true; 1416 - } 1417 - if (rows.err) |err| return err; 1418 - return false; 1419 - } 1420 - 1421 - fn preferenceNameInNamespace(name: []const u8, namespace: []const u8) bool { 1422 - return std.mem.eql(u8, name, namespace) or 1423 - (std.mem.startsWith(u8, name, namespace) and name.len > namespace.len and name[namespace.len] == '.'); 1424 - } 1425 - 1426 - fn jsonValueString(value: std.json.Value, key: []const u8) ?[]const u8 { 1427 - return switch (value) { 1428 - .object => |object| switch (object.get(key) orelse return null) { 1429 - .string => |string| string, 1430 - else => null, 1431 - }, 1432 - else => null, 1433 - }; 1434 - } 1435 - 1436 - fn migrateBlobTable() !void { 1437 - if (!try blobTableHasDataColumn()) return; 1438 - try conn.execNoArgs( 1439 - \\CREATE TABLE IF NOT EXISTS blobs_next ( 1440 - \\ cid TEXT NOT NULL, 1441 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 1442 - \\ mime_type TEXT NOT NULL, 1443 - \\ size INTEGER NOT NULL, 1444 - \\ storage TEXT NOT NULL DEFAULT 'disk', 1445 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 1446 - \\ PRIMARY KEY (did, cid) 1447 - \\) 1448 - ); 1449 - try conn.execNoArgs( 1450 - \\INSERT OR IGNORE INTO blobs_next (cid, did, mime_type, size, storage, created_at) 1451 - \\SELECT cid, did, mime_type, size, 'disk', created_at 1452 - \\FROM blobs 1453 - ); 1454 - try conn.execNoArgs("DROP TABLE blobs"); 1455 - try conn.execNoArgs("ALTER TABLE blobs_next RENAME TO blobs"); 1456 - } 1457 - 1458 - fn blobTableHasDataColumn() !bool { 1459 - var rows = try conn.rows("PRAGMA table_info(blobs)", .{}); 1460 - defer rows.deinit(); 1461 - while (rows.next()) |row| { 1462 - if (std.mem.eql(u8, row.text(1), "data")) return true; 1463 - } 1464 - if (rows.err) |err| return err; 1465 - return false; 1466 - } 1467 - 1468 - pub fn getEmailInfo(allocator: std.mem.Allocator, did: []const u8) ?EmailInfo { 1469 - mutex.lockUncancelable(store_io); 1470 - defer mutex.unlock(store_io); 1471 - requireInitialized() catch return null; 1472 - const row = conn.row( 1473 - \\SELECT email, email_confirmed_at, auth_code, auth_code_expires_at, pending_email 1474 - \\FROM accounts 1475 - \\WHERE did = ? 1476 - , .{did}) catch return null; 1477 - if (row == null) return null; 1478 - defer row.?.deinit(); 1479 - return .{ 1480 - .email = allocator.dupe(u8, row.?.text(0)) catch return null, 1481 - .email_confirmed = row.?.nullableInt(1) != null, 1482 - .auth_code = if (row.?.nullableText(2)) |text| allocator.dupe(u8, text) catch null else null, 1483 - .auth_code_expires_at = row.?.nullableInt(3), 1484 - .pending_email = if (row.?.nullableText(4)) |text| allocator.dupe(u8, text) catch null else null, 1485 - }; 1486 - } 1487 - 1488 - pub fn setAuthCode(did: []const u8, code: []const u8, expires_at_ms: i64) !void { 1489 - mutex.lockUncancelable(store_io); 1490 - defer mutex.unlock(store_io); 1491 - try requireInitialized(); 1492 - try conn.exec( 1493 - \\UPDATE accounts 1494 - \\SET auth_code = ?, auth_code_expires_at = ? 1495 - \\WHERE did = ? 1496 - , .{ code, expires_at_ms, did }); 1497 - } 1498 - 1499 - pub fn setPendingEmail(did: []const u8, pending_email: []const u8, code: []const u8, expires_at_ms: i64) !void { 1500 - mutex.lockUncancelable(store_io); 1501 - defer mutex.unlock(store_io); 1502 - try requireInitialized(); 1503 - try conn.exec( 1504 - \\UPDATE accounts 1505 - \\SET pending_email = ?, auth_code = ?, auth_code_expires_at = ? 1506 - \\WHERE did = ? 1507 - , .{ pending_email, code, expires_at_ms, did }); 1508 - } 1509 - 1510 - pub fn validateAuthCode(did: []const u8, code: []const u8, now_ms: i64) CodeStatus { 1511 - mutex.lockUncancelable(store_io); 1512 - defer mutex.unlock(store_io); 1513 - requireInitialized() catch return .invalid; 1514 - const row = conn.row( 1515 - \\SELECT auth_code, auth_code_expires_at 1516 - \\FROM accounts 1517 - \\WHERE did = ? 1518 - , .{did}) catch return .invalid; 1519 - if (row == null) return .invalid; 1520 - defer row.?.deinit(); 1521 - const stored = row.?.nullableText(0) orelse return .invalid; 1522 - const expires_at = row.?.nullableInt(1) orelse return .invalid; 1523 - if (now_ms >= expires_at) return .expired; 1524 - if (!std.ascii.eqlIgnoreCase(stored, code)) return .invalid; 1525 - return .valid; 1526 - } 1527 - 1528 - pub fn clearAuthCode(did: []const u8) !void { 1529 - mutex.lockUncancelable(store_io); 1530 - defer mutex.unlock(store_io); 1531 - try requireInitialized(); 1532 - try conn.exec( 1533 - \\UPDATE accounts 1534 - \\SET auth_code = NULL, 1535 - \\ auth_code_expires_at = NULL 1536 - \\WHERE did = ? 1537 - , .{did}); 1538 - } 1539 - 1540 - pub fn confirmEmail(did: []const u8) !void { 1541 - mutex.lockUncancelable(store_io); 1542 - defer mutex.unlock(store_io); 1543 - try requireInitialized(); 1544 - try conn.exec( 1545 - \\UPDATE accounts 1546 - \\SET email_confirmed_at = unixepoch(), 1547 - \\ auth_code = NULL, 1548 - \\ auth_code_expires_at = NULL 1549 - \\WHERE did = ? 1550 - , .{did}); 1551 - } 1552 - 1553 - pub fn updateEmail(did: []const u8, email: []const u8) !void { 1554 - mutex.lockUncancelable(store_io); 1555 - defer mutex.unlock(store_io); 1556 - try requireInitialized(); 1557 - try conn.exec( 1558 - \\UPDATE accounts 1559 - \\SET email = ?, 1560 - \\ email_confirmed_at = NULL, 1561 - \\ pending_email = NULL, 1562 - \\ auth_code = NULL, 1563 - \\ auth_code_expires_at = NULL 1564 - \\WHERE did = ? 1565 - , .{ email, did }); 1566 - } 1567 - 1568 - fn nextSeqLocked() !u64 { 1569 - const row = try conn.row( 1570 - \\SELECT COALESCE(MAX(seq), 0) + 1 1571 - \\FROM ( 1572 - \\ SELECT seq FROM commits 1573 - \\ UNION ALL 1574 - \\ SELECT seq FROM seq_events 1575 - \\) 1576 - , .{}); 1577 - if (row == null) return 1; 1578 - defer row.?.deinit(); 1579 - return @intCast(row.?.int(0)); 1580 - } 1581 - 1582 - fn nextRkey(allocator: std.mem.Allocator) ![]const u8 { 1583 - mutex.lockUncancelable(store_io); 1584 - defer mutex.unlock(store_io); 1585 - try requireInitialized(); 1586 - return nextRkeyLocked(allocator); 1587 - } 1588 - 1589 - fn nextRkeyLocked(allocator: std.mem.Allocator) ![]const u8 { 1590 - return std.fmt.allocPrint(allocator, "3zds{d}", .{try nextSeqLocked()}); 1591 - } 1592 - 1593 - fn repoPath(allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8) ![]const u8 { 1594 - if (zat.Nsid.parse(collection) == null) return Error.InvalidCollection; 1595 - if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; 1596 - return std.fmt.allocPrint(allocator, "{s}/{s}", .{ collection, rkey }); 1597 - } 1598 - 1599 - fn stageRecordWrite( 1600 - allocator: std.mem.Allocator, 1601 - tree: *zat.mst.Mst, 1602 - account: auth.Account, 1603 - collection: []const u8, 1604 - rkey: []const u8, 1605 - value: std.json.Value, 1606 - rev: []const u8, 1607 - seq: u64, 1608 - blocks: *std.ArrayList(ImportedBlock), 1609 - blob_refs: *std.ArrayList(BlobRef), 1610 - ) !Record { 1611 - const cbor_value = try jsonToDagCbor(allocator, value); 1612 - const record_bytes = try zat.cbor.encodeAlloc(allocator, cbor_value); 1613 - const record_cid = try zat.cbor.Cid.forDagCbor(allocator, record_bytes); 1614 - const record_cid_text = try cidText(allocator, record_cid.raw); 1615 - const path = try repoPath(allocator, collection, rkey); 1616 - _ = try tree.putReturn(path, record_cid); 1617 - 1618 - try blocks.append(allocator, .{ 1619 - .cid = record_cid_text, 1620 - .data = record_bytes, 1621 - }); 1622 - 1623 - const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, collection, rkey }); 1624 - var blobs: std.ArrayList([]const u8) = .empty; 1625 - try collectBlobCids(allocator, cbor_value, &blobs); 1626 - for (blobs.items) |blob_cid| { 1627 - try blob_refs.append(allocator, .{ .cid = blob_cid, .uri = uri }); 1628 - } 1629 - 1630 - return .{ 1631 - .did = try allocator.dupe(u8, account.did), 1632 - .collection = try allocator.dupe(u8, collection), 1633 - .rkey = try allocator.dupe(u8, rkey), 1634 - .cid = record_cid_text, 1635 - .value_json = try stringifyValue(allocator, value), 1636 - .rev = try allocator.dupe(u8, rev), 1637 - .seq = seq, 1638 - }; 1639 - } 1640 - 1641 - const CurrentCommit = struct { 1642 - commit_cid_text: []const u8, 1643 - commit_cid_raw: []const u8, 1644 - data_cid_raw: []const u8, 1645 - rev: []const u8, 1646 - commit_data: []const u8, 1647 - }; 1648 - 1649 - fn latestCommitRawLocked(allocator: std.mem.Allocator, did: []const u8) !?CurrentCommit { 1650 - const row = try conn.row( 1651 - \\SELECT c.cid, c.rev, rb.data 1652 - \\FROM commits c 1653 - \\JOIN repo_blocks rb ON rb.did = c.did AND rb.cid = c.cid 1654 - \\WHERE c.did = ? 1655 - \\ORDER BY c.seq DESC 1656 - \\LIMIT 1 1657 - , .{did}); 1658 - if (row == null) return null; 1659 - defer row.?.deinit(); 1660 - 1661 - const commit_cid_text = try allocator.dupe(u8, row.?.text(0)); 1662 - const commit_cid_raw = try cidRawFromText(allocator, commit_cid_text); 1663 - const rev = try allocator.dupe(u8, row.?.text(1)); 1664 - const commit_data = row.?.nullableBlob(2) orelse return null; 1665 - const commit_value = try zat.cbor.decodeAll(allocator, commit_data); 1666 - const data = commit_value.getCid("data") orelse return error.InvalidRepoPath; 1667 - return .{ 1668 - .commit_cid_text = commit_cid_text, 1669 - .commit_cid_raw = commit_cid_raw, 1670 - .data_cid_raw = try allocator.dupe(u8, data.raw), 1671 - .rev = rev, 1672 - .commit_data = try allocator.dupe(u8, commit_data), 1673 - }; 1674 - } 1675 - 1676 - fn readRepoCarLocked(allocator: std.mem.Allocator, did: []const u8) !zat.car.Car { 1677 - var rows = try conn.rows( 1678 - \\SELECT cid, data 1679 - \\FROM repo_blocks 1680 - \\WHERE did = ? 1681 - \\ORDER BY cid ASC 1682 - , .{did}); 1683 - defer rows.deinit(); 1684 - 1685 - var blocks: std.ArrayList(zat.car.Block) = .empty; 1686 - while (rows.next()) |row| { 1687 - const data = row.nullableBlob(1) orelse ""; 1688 - try blocks.append(allocator, .{ 1689 - .cid_raw = try cidRawFromText(allocator, row.text(0)), 1690 - .data = try allocator.dupe(u8, data), 1691 - }); 1692 - } 1693 - if (rows.err) |err| return err; 1694 - return .{ .roots = &.{}, .blocks = try blocks.toOwnedSlice(allocator) }; 1695 - } 1696 - 1697 - fn writeMstBlocks(allocator: std.mem.Allocator, tree: *zat.mst.Mst, out: *std.ArrayList(zat.car.Block)) !void { 1698 - if (tree.root) |root| { 1699 - try writeMstNodeBlocks(allocator, root, out); 1700 - return; 1701 - } 1702 - 1703 - const data = try encodeEmptyMstNode(allocator); 1704 - const cid = try zat.cbor.Cid.forDagCbor(allocator, data); 1705 - try out.append(allocator, .{ 1706 - .cid_raw = try allocator.dupe(u8, cid.raw), 1707 - .data = data, 1708 - }); 1709 - } 1710 - 1711 - fn writeMstNodeBlocks(allocator: std.mem.Allocator, node: *zat.mst.Node, out: *std.ArrayList(zat.car.Block)) !void { 1712 - switch (node.left) { 1713 - .node => |left| try writeMstNodeBlocks(allocator, left, out), 1714 - .stub, .none => {}, 1715 - } 1716 - for (node.entries.items) |entry| { 1717 - switch (entry.right) { 1718 - .node => |right| try writeMstNodeBlocks(allocator, right, out), 1719 - .stub, .none => {}, 1720 - } 1721 - } 1722 - 1723 - const data = try encodeMstNode(allocator, node); 1724 - const cid = try zat.cbor.Cid.forDagCbor(allocator, data); 1725 - try out.append(allocator, .{ 1726 - .cid_raw = try allocator.dupe(u8, cid.raw), 1727 - .data = data, 1728 - }); 1729 - } 1730 - 1731 - fn mstChildValue(allocator: std.mem.Allocator, child: zat.mst.ChildRef) anyerror!zat.cbor.Value { 1732 - return switch (child) { 1733 - .node => |node| blk: { 1734 - const data = try encodeMstNode(allocator, node); 1735 - const cid = try zat.cbor.Cid.forDagCbor(allocator, data); 1736 - break :blk .{ .cid = cid }; 1737 - }, 1738 - .stub => |cid| .{ .cid = cid }, 1739 - .none => .null, 1740 - }; 1741 - } 1742 - 1743 - fn encodeEmptyMstNode(allocator: std.mem.Allocator) ![]const u8 { 1744 - return zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 1745 - .{ .key = "e", .value = .{ .array = &.{} } }, 1746 - .{ .key = "l", .value = .null }, 1747 - } }); 1748 - } 1749 - 1750 - fn encodeMstNode(allocator: std.mem.Allocator, node: *zat.mst.Node) anyerror![]const u8 { 1751 - var entry_values: std.ArrayList(zat.cbor.Value) = .empty; 1752 - var prev_key: []const u8 = ""; 1753 - for (node.entries.items) |entry| { 1754 - const prefix_len = zat.mst.commonPrefixLen(prev_key, entry.key); 1755 - const map_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 4); 1756 - map_entries[0] = .{ .key = "k", .value = .{ .bytes = entry.key[prefix_len..] } }; 1757 - map_entries[1] = .{ .key = "p", .value = .{ .unsigned = prefix_len } }; 1758 - map_entries[2] = .{ .key = "t", .value = try mstChildValue(allocator, entry.right) }; 1759 - map_entries[3] = .{ .key = "v", .value = .{ .cid = entry.value } }; 1760 - try entry_values.append(allocator, .{ .map = map_entries }); 1761 - prev_key = entry.key; 1762 - } 1763 - 1764 - return zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 1765 - .{ .key = "e", .value = .{ .array = entry_values.items } }, 1766 - .{ .key = "l", .value = try mstChildValue(allocator, node.left) }, 1767 - } }); 1768 - } 1769 - 1770 - const SignedCommit = struct { 1771 - cid: []const u8, 1772 - data: []const u8, 1773 - }; 1774 - 1775 - fn signedCommit( 1776 - allocator: std.mem.Allocator, 1777 - did: []const u8, 1778 - rev: []const u8, 1779 - data_cid: zat.cbor.Cid, 1780 - prev_cid_raw: ?[]const u8, 1781 - ) !SignedCommit { 1782 - var unsigned_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 5); 1783 - unsigned_entries[0] = .{ .key = "did", .value = .{ .text = did } }; 1784 - unsigned_entries[1] = .{ .key = "version", .value = .{ .unsigned = 3 } }; 1785 - unsigned_entries[2] = .{ .key = "data", .value = .{ .cid = data_cid } }; 1786 - unsigned_entries[3] = .{ .key = "rev", .value = .{ .text = rev } }; 1787 - unsigned_entries[4] = .{ .key = "prev", .value = if (prev_cid_raw) |raw| .{ .cid = .{ .raw = raw } } else .null }; 1788 - const unsigned_value: zat.cbor.Value = .{ .map = unsigned_entries }; 1789 - const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned_value); 1790 - var keypair = try signingKeypairLocked(did); 1791 - const sig = try keypair.sign(unsigned_bytes); 1792 - 1793 - var signed_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 6); 1794 - @memcpy(signed_entries[0..5], unsigned_entries); 1795 - signed_entries[5] = .{ .key = "sig", .value = .{ .bytes = &sig.bytes } }; 1796 - const signed_bytes = try zat.cbor.encodeAlloc(allocator, .{ .map = signed_entries }); 1797 - const commit_cid = try zat.cbor.Cid.forDagCbor(allocator, signed_bytes); 1798 - return .{ 1799 - .cid = try cidText(allocator, commit_cid.raw), 1800 - .data = signed_bytes, 1801 - }; 1802 - } 1803 - 1804 - fn signingKeypairLocked(did: []const u8) !zat.Keypair { 1805 - const row = try conn.row( 1806 - \\SELECT signing_key_type, signing_key 1807 - \\FROM accounts 1808 - \\WHERE did = ? 1809 - , .{did}); 1810 - if (row == null) return Error.MissingRecord; 1811 - defer row.?.deinit(); 1812 - 1813 - const existing = row.?.nullableBlob(1); 1814 - const key_type_text = row.?.nullableText(0) orelse "secp256k1"; 1815 - const key_bytes = if (existing) |bytes| blk: { 1816 - if (bytes.len != 32) return Error.InvalidDagCbor; 1817 - break :blk bytes[0..32].*; 1818 - } else blk: { 1819 - const generated = try generateSigningKey(); 1820 - try conn.exec( 1821 - \\UPDATE accounts 1822 - \\SET signing_key_type = 'secp256k1', signing_key = ? 1823 - \\WHERE did = ? 1824 - , .{ zqlite.blob(&generated), did }); 1825 - break :blk generated; 1826 - }; 1827 - const key_type: zat.multicodec.KeyType = if (std.mem.eql(u8, key_type_text, "p256")) 1828 - .p256 1829 - else 1830 - .secp256k1; 1831 - return zat.Keypair.fromSecretKey(key_type, key_bytes); 1832 - } 1833 - 1834 - fn generateSigningKey() ![32]u8 { 1835 - var key: [32]u8 = undefined; 1836 - while (true) { 1837 - store_io.random(&key); 1838 - if (zat.Keypair.fromSecretKey(.secp256k1, key)) |_| return key else |_| {} 1839 - } 1840 - } 1841 - 1842 - fn jsonToDagCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { 1843 - return switch (value) { 1844 - .null => .null, 1845 - .bool => |boolean| .{ .boolean = boolean }, 1846 - .integer => |integer| if (integer >= 0) .{ .unsigned = @intCast(integer) } else .{ .negative = integer }, 1847 - .float, .number_string => Error.InvalidDagCbor, 1848 - .string => |string| .{ .text = string }, 1849 - .array => |array| blk: { 1850 - const items = try allocator.alloc(zat.cbor.Value, array.items.len); 1851 - for (array.items, 0..) |item, idx| items[idx] = try jsonToDagCbor(allocator, item); 1852 - break :blk .{ .array = items }; 1853 - }, 1854 - .object => |object| blk: { 1855 - if (object.count() == 1) { 1856 - if (object.get("$link")) |link_value| switch (link_value) { 1857 - .string => |link| break :blk .{ .cid = .{ .raw = try cidRawFromText(allocator, link) } }, 1858 - else => {}, 1859 - }; 1860 - if (object.get("$bytes")) |bytes_value| switch (bytes_value) { 1861 - .string => |encoded| { 1862 - const bytes = try allocator.alloc(u8, std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return Error.InvalidDagCbor); 1863 - try std.base64.standard.Decoder.decode(bytes, encoded); 1864 - break :blk .{ .bytes = bytes }; 1865 - }, 1866 - else => {}, 1867 - }; 1868 - } 1869 - const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); 1870 - var it = object.iterator(); 1871 - var idx: usize = 0; 1872 - while (it.next()) |entry| : (idx += 1) { 1873 - entries[idx] = .{ 1874 - .key = entry.key_ptr.*, 1875 - .value = try jsonToDagCbor(allocator, entry.value_ptr.*), 1876 - }; 1877 - } 1878 - break :blk .{ .map = entries }; 1879 - }, 1880 - }; 1881 - } 1882 - 1883 - fn collectBlobCids(allocator: std.mem.Allocator, value: zat.cbor.Value, out: *std.ArrayList([]const u8)) !void { 1884 - switch (value) { 1885 - .map => |entries| { 1886 - if (cborMapText(entries, "$type")) |type_name| { 1887 - if (std.mem.eql(u8, type_name, "blob")) { 1888 - if (cborMapValue(entries, "ref")) |ref| switch (ref) { 1889 - .cid => |cid| try out.append(allocator, try cidText(allocator, cid.raw)), 1890 - .map => |ref_entries| if (cborMapText(ref_entries, "$link")) |link| try out.append(allocator, try allocator.dupe(u8, link)), 1891 - else => {}, 1892 - }; 1893 - } 1894 - } 1895 - for (entries) |entry| try collectBlobCids(allocator, entry.value, out); 1896 - }, 1897 - .array => |items| for (items) |item| try collectBlobCids(allocator, item, out), 1898 - else => {}, 1899 - } 1900 - } 1901 - 1902 - fn cborMapValue(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?zat.cbor.Value { 1903 - for (entries) |entry| { 1904 - if (std.mem.eql(u8, entry.key, key)) return entry.value; 1905 - } 1906 - return null; 1907 - } 1908 - 1909 - fn cborMapText(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?[]const u8 { 1910 - return switch (cborMapValue(entries, key) orelse return null) { 1911 - .text => |text| text, 1912 - else => null, 1913 - }; 1914 - } 1915 - 1916 - fn cidRawFromText(allocator: std.mem.Allocator, cid: []const u8) ![]const u8 { 1917 - if (cid.len == 0 or cid[0] != 'b') return Error.InvalidDagCbor; 1918 - return zat.multibase.base32lower.decode(allocator, cid[1..]); 1919 - } 1920 - 1921 - fn cidText(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { 1922 - return zat.multibase.base32lower.encode(allocator, raw); 1923 - } 1924 - 1925 - fn recordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !Record { 1926 - return .{ 1927 - .did = try allocator.dupe(u8, row.text(0)), 1928 - .collection = try allocator.dupe(u8, row.text(1)), 1929 - .rkey = try allocator.dupe(u8, row.text(2)), 1930 - .cid = try allocator.dupe(u8, row.text(3)), 1931 - .value_json = try allocator.dupe(u8, row.text(4)), 1932 - .rev = try allocator.dupe(u8, row.text(5)), 1933 - .seq = @intCast(row.int(6)), 1934 - }; 1935 - } 1936 - 1937 - fn oauthRequestFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !OAuthRequest { 1938 - return .{ 1939 - .request_id = try allocator.dupe(u8, row.text(0)), 1940 - .client_id = try allocator.dupe(u8, row.text(1)), 1941 - .redirect_uri = try allocator.dupe(u8, row.text(2)), 1942 - .scope = try allocator.dupe(u8, row.text(3)), 1943 - .state = try allocator.dupe(u8, row.text(4)), 1944 - .code_challenge = try allocator.dupe(u8, row.text(5)), 1945 - .code_challenge_method = try allocator.dupe(u8, row.text(6)), 1946 - .login_hint = if (row.nullableText(7)) |text| try allocator.dupe(u8, text) else null, 1947 - .dpop_jkt = if (row.nullableText(8)) |text| try allocator.dupe(u8, text) else null, 1948 - .expires_at = row.int(9), 1949 - .sub = if (row.nullableText(10)) |text| try allocator.dupe(u8, text) else null, 1950 - .code = if (row.nullableText(11)) |text| try allocator.dupe(u8, text) else null, 1951 - }; 1952 - } 1953 - 1954 - fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 1955 - var out: std.Io.Writer.Allocating = .init(allocator); 1956 - defer out.deinit(); 1957 - try out.writer.print("{f}", .{std.json.fmt(value, .{})}); 1958 - return out.toOwnedSlice(); 1959 - } 1960 - 1961 - const Root = struct { 1962 - cid: []const u8, 1963 - rev: []const u8, 1964 - }; 1965 - 1966 - fn latestRootLocked(allocator: std.mem.Allocator, did: []const u8) !Root { 1967 - const row = try conn.row( 1968 - \\SELECT cid, rev 1969 - \\FROM commits 1970 - \\WHERE did = ? 1971 - \\ORDER BY seq DESC 1972 - \\LIMIT 1 1973 - , .{did}); 1974 - if (row == null) return Error.RepoNotFound; 1975 - defer row.?.deinit(); 1976 - return .{ 1977 - .cid = try allocator.dupe(u8, row.?.text(0)), 1978 - .rev = try allocator.dupe(u8, row.?.text(1)), 1979 - }; 1980 - } 1981 - 1982 - fn backfillSeqEventsLocked(allocator: std.mem.Allocator) !void { 1983 - var rows = try conn.rows( 1984 - \\SELECT c.seq, c.did, c.cid, c.rev, c.prev 1985 - \\FROM commits c 1986 - \\LEFT JOIN seq_events e ON e.seq = c.seq 1987 - \\WHERE e.seq IS NULL 1988 - \\ORDER BY c.seq ASC 1989 - , .{}); 1990 - defer rows.deinit(); 1991 - 1992 - var missing: std.ArrayList(struct { 1993 - seq: u64, 1994 - did: []const u8, 1995 - cid: []const u8, 1996 - rev: []const u8, 1997 - prev: ?[]const u8, 1998 - }) = .empty; 1999 - while (rows.next()) |row| { 2000 - try missing.append(allocator, .{ 2001 - .seq = @intCast(row.int(0)), 2002 - .did = try allocator.dupe(u8, row.text(1)), 2003 - .cid = try allocator.dupe(u8, row.text(2)), 2004 - .rev = try allocator.dupe(u8, row.text(3)), 2005 - .prev = if (row.nullableText(4)) |prev| try allocator.dupe(u8, prev) else null, 2006 - }); 2007 - } 2008 - if (rows.err) |err| return err; 2009 - 2010 - for (missing.items) |commit| { 2011 - const repo_car = writeRepoCarFromLocked(allocator, commit.did, commit.cid) catch continue; 2012 - const frame = commitEventFrameFromCar(allocator, commit.seq, commit.did, commit.cid, commit.rev, commit.prev, repo_car, &.{}) catch continue; 2013 - try conn.exec( 2014 - \\INSERT INTO seq_events (seq, did, commit_cid, evt) 2015 - \\VALUES (?, ?, ?, ?) 2016 - \\ON CONFLICT(seq) DO NOTHING 2017 - , .{ @as(i64, @intCast(commit.seq)), commit.did, commit.cid, zqlite.blob(frame) }); 2018 - } 2019 - } 2020 - 2021 - fn writeRepoCarFromLocked(allocator: std.mem.Allocator, did: []const u8, root_cid: []const u8) ![]const u8 { 2022 - const root_raw = try zat.multibase.base32lower.decode(allocator, root_cid[1..]); 2023 - const car_root = zat.cbor.Cid{ .raw = root_raw }; 2024 - 2025 - var rows = try conn.rows( 2026 - \\SELECT cid, data 2027 - \\FROM repo_blocks 2028 - \\WHERE did = ? 2029 - \\ORDER BY cid ASC 2030 - , .{did}); 2031 - defer rows.deinit(); 2032 - 2033 - var blocks: std.ArrayList(zat.car.Block) = .empty; 2034 - while (rows.next()) |row| { 2035 - const cid_text = row.text(0); 2036 - const data = row.nullableBlob(1) orelse ""; 2037 - const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]); 2038 - try blocks.append(allocator, .{ 2039 - .cid_raw = cid_raw, 2040 - .data = try allocator.dupe(u8, data), 2041 - }); 2042 - } 2043 - if (rows.err) |err| return err; 2044 - return zat.car.writeAlloc(allocator, .{ 2045 - .roots = &.{car_root}, 2046 - .blocks = blocks.items, 2047 - }); 2048 - } 2049 - 2050 - fn commitEventFrame( 2051 - allocator: std.mem.Allocator, 2052 - seq: u64, 2053 - did: []const u8, 2054 - commit_cid: []const u8, 2055 - rev: []const u8, 2056 - prev: ?[]const u8, 2057 - commit_data: []const u8, 2058 - record_blocks: []const ImportedBlock, 2059 - mst_blocks: []const zat.car.Block, 2060 - ops: []const WriteOp, 2061 - records: []const Record, 2062 - ) ![]const u8 { 2063 - const root_raw = try cidRawFromText(allocator, commit_cid); 2064 - var blocks: std.ArrayList(zat.car.Block) = .empty; 2065 - try blocks.append(allocator, .{ .cid_raw = root_raw, .data = commit_data }); 2066 - for (record_blocks) |block| { 2067 - try blocks.append(allocator, .{ 2068 - .cid_raw = try cidRawFromText(allocator, block.cid), 2069 - .data = block.data, 2070 - }); 2071 - } 2072 - for (mst_blocks) |block| try blocks.append(allocator, block); 2073 - const car_bytes = try zat.car.writeAlloc(allocator, .{ 2074 - .roots = &.{.{ .raw = root_raw }}, 2075 - .blocks = blocks.items, 2076 - }); 2077 - 2078 - var op_values = try allocator.alloc(zat.cbor.Value, ops.len); 2079 - var record_idx: usize = 0; 2080 - for (ops, 0..) |op, idx| switch (op) { 2081 - .create => |create_op| { 2082 - const record = records[record_idx]; 2083 - record_idx += 1; 2084 - op_values[idx] = try firehoseOp(allocator, "create", create_op.collection, record.rkey, record.cid); 2085 - }, 2086 - .update => |update_op| { 2087 - const record = records[record_idx]; 2088 - record_idx += 1; 2089 - op_values[idx] = try firehoseOp(allocator, "update", update_op.collection, update_op.rkey, record.cid); 2090 - }, 2091 - .delete => |delete_op| { 2092 - op_values[idx] = try firehoseOp(allocator, "delete", delete_op.collection, delete_op.rkey, null); 2093 - }, 2094 - }; 2095 - return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, prev, car_bytes, op_values); 2096 - } 2097 - 2098 - fn importedCommitEventFrame( 2099 - allocator: std.mem.Allocator, 2100 - seq: u64, 2101 - did: []const u8, 2102 - commit_cid: []const u8, 2103 - rev: []const u8, 2104 - blocks: []const ImportedBlock, 2105 - ) ![]const u8 { 2106 - var car_blocks: std.ArrayList(zat.car.Block) = .empty; 2107 - for (blocks) |block| { 2108 - try car_blocks.append(allocator, .{ 2109 - .cid_raw = try cidRawFromText(allocator, block.cid), 2110 - .data = block.data, 2111 - }); 2112 - } 2113 - const root_raw = try cidRawFromText(allocator, commit_cid); 2114 - const car_bytes = try zat.car.writeAlloc(allocator, .{ 2115 - .roots = &.{.{ .raw = root_raw }}, 2116 - .blocks = car_blocks.items, 2117 - }); 2118 - return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, null, car_bytes, &.{}); 2119 - } 2120 - 2121 - fn accountEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, active: bool) ![]const u8 { 2122 - const header = try eventHeader(allocator, "#account"); 2123 - const body = if (active) 2124 - try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2125 - .{ .key = "seq", .value = .{ .unsigned = seq } }, 2126 - .{ .key = "did", .value = .{ .text = did } }, 2127 - .{ .key = "active", .value = .{ .boolean = true } }, 2128 - .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 2129 - } }) 2130 - else 2131 - try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2132 - .{ .key = "seq", .value = .{ .unsigned = seq } }, 2133 - .{ .key = "did", .value = .{ .text = did } }, 2134 - .{ .key = "active", .value = .{ .boolean = false } }, 2135 - .{ .key = "status", .value = .{ .text = "deactivated" } }, 2136 - .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 2137 - } }); 2138 - return joinFrame(allocator, header, body); 2139 - } 2140 - 2141 - fn identityEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, handle: []const u8) ![]const u8 { 2142 - const header = try eventHeader(allocator, "#identity"); 2143 - const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2144 - .{ .key = "seq", .value = .{ .unsigned = seq } }, 2145 - .{ .key = "did", .value = .{ .text = did } }, 2146 - .{ .key = "handle", .value = .{ .text = handle } }, 2147 - .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 2148 - } }); 2149 - return joinFrame(allocator, header, body); 2150 - } 2151 - 2152 - fn syncEventFrame( 2153 - allocator: std.mem.Allocator, 2154 - seq: u64, 2155 - did: []const u8, 2156 - commit_cid: []const u8, 2157 - data_cid_raw: []const u8, 2158 - rev: []const u8, 2159 - commit_data: []const u8, 2160 - ) ![]const u8 { 2161 - const root_raw = try cidRawFromText(allocator, commit_cid); 2162 - const car_bytes = try zat.car.writeAlloc(allocator, .{ 2163 - .roots = &.{.{ .raw = data_cid_raw }}, 2164 - .blocks = &.{.{ .cid_raw = root_raw, .data = commit_data }}, 2165 - }); 2166 - const header = try eventHeader(allocator, "#sync"); 2167 - const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2168 - .{ .key = "seq", .value = .{ .unsigned = seq } }, 2169 - .{ .key = "did", .value = .{ .text = did } }, 2170 - .{ .key = "blocks", .value = .{ .bytes = car_bytes } }, 2171 - .{ .key = "rev", .value = .{ .text = rev } }, 2172 - .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 2173 - } }); 2174 - return joinFrame(allocator, header, body); 2175 - } 2176 - 2177 - fn commitEventFrameFromCar( 2178 - allocator: std.mem.Allocator, 2179 - seq: u64, 2180 - did: []const u8, 2181 - commit_cid: []const u8, 2182 - rev: []const u8, 2183 - prev: ?[]const u8, 2184 - car_bytes: []const u8, 2185 - ops: []const zat.cbor.Value, 2186 - ) ![]const u8 { 2187 - const header = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2188 - .{ .key = "op", .value = .{ .unsigned = 1 } }, 2189 - .{ .key = "t", .value = .{ .text = "#commit" } }, 2190 - } }); 2191 - const commit_raw = try cidRawFromText(allocator, commit_cid); 2192 - const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2193 - .{ .key = "seq", .value = .{ .unsigned = seq } }, 2194 - .{ .key = "rebase", .value = .{ .boolean = false } }, 2195 - .{ .key = "tooBig", .value = .{ .boolean = false } }, 2196 - .{ .key = "repo", .value = .{ .text = did } }, 2197 - .{ .key = "commit", .value = .{ .cid = .{ .raw = commit_raw } } }, 2198 - .{ .key = "rev", .value = .{ .text = rev } }, 2199 - .{ .key = "since", .value = if (prev) |p| .{ .text = p } else .null }, 2200 - .{ .key = "blocks", .value = .{ .bytes = car_bytes } }, 2201 - .{ .key = "ops", .value = .{ .array = ops } }, 2202 - .{ .key = "blobs", .value = .{ .array = &.{} } }, 2203 - .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 2204 - } }); 2205 - return joinFrame(allocator, header, body); 2206 - } 2207 - 2208 - fn eventHeader(allocator: std.mem.Allocator, tag: []const u8) ![]const u8 { 2209 - return zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 2210 - .{ .key = "op", .value = .{ .unsigned = 1 } }, 2211 - .{ .key = "t", .value = .{ .text = tag } }, 2212 - } }); 2213 - } 2214 - 2215 - fn joinFrame(allocator: std.mem.Allocator, header: []const u8, body: []const u8) ![]const u8 { 2216 - var frame = try allocator.alloc(u8, header.len + body.len); 2217 - @memcpy(frame[0..header.len], header); 2218 - @memcpy(frame[header.len..], body); 2219 - return frame; 2220 - } 2221 - 2222 - fn firehoseOp( 2223 - allocator: std.mem.Allocator, 2224 - action: []const u8, 2225 - collection: []const u8, 2226 - rkey: []const u8, 2227 - cid: ?[]const u8, 2228 - ) !zat.cbor.Value { 2229 - const path = try repoPath(allocator, collection, rkey); 2230 - const entries = try allocator.alloc(zat.cbor.Value.MapEntry, 3); 2231 - entries[0] = .{ .key = "action", .value = .{ .text = action } }; 2232 - entries[1] = .{ .key = "path", .value = .{ .text = path } }; 2233 - entries[2] = .{ .key = "cid", .value = if (cid) |c| .{ .cid = .{ .raw = try cidRawFromText(allocator, c) } } else .null }; 2234 - return .{ .map = entries }; 2235 - } 2236 - 2237 - fn nowIso(allocator: std.mem.Allocator) ![]const u8 { 2238 - var ts: std.posix.timespec = undefined; 2239 - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 2240 - .SUCCESS => ts, 2241 - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, 2242 - }; 2243 - const seconds_i64 = timestamp.sec; 2244 - const seconds: u64 = if (seconds_i64 < 0) 0 else @intCast(seconds_i64); 2245 - const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = seconds }; 2246 - const year_day = epoch_seconds.getEpochDay().calculateYearDay(); 2247 - const month_day = year_day.calculateMonthDay(); 2248 - const day_seconds = epoch_seconds.getDaySeconds(); 2249 - return std.fmt.allocPrint( 2250 - allocator, 2251 - "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", 2252 - .{ 2253 - year_day.year, 2254 - @intFromEnum(month_day.month), 2255 - month_day.day_index + 1, 2256 - day_seconds.getHoursIntoDay(), 2257 - day_seconds.getMinutesIntoHour(), 2258 - day_seconds.getSecondsIntoMinute(), 2259 - }, 2260 - ); 2261 - } 2262 - 2263 - fn scalarCountLocked(sql: [:0]const u8, did: []const u8) !u64 { 2264 - const row = try conn.row(sql, .{did}); 2265 - if (row == null) return 0; 2266 - defer row.?.deinit(); 2267 - return @intCast(row.?.int(0)); 2268 - } 2269 - 2270 - fn accountActiveLocked(did: []const u8) !bool { 2271 - const row = try conn.row( 2272 - \\SELECT activated_at, deactivated_at 2273 - \\FROM accounts 2274 - \\WHERE did = ? 2275 - , .{did}); 2276 - if (row == null) return false; 2277 - defer row.?.deinit(); 2278 - return row.?.nullableInt(0) != null and row.?.nullableInt(1) == null; 2279 - } 2280 - 2281 - fn cidForJson(allocator: std.mem.Allocator, json: []const u8) ![]const u8 { 2282 - const cid = try zat.cbor.Cid.forDagCbor(allocator, json); 2283 - defer allocator.free(cid.raw); 2284 - return zat.multibase.base32lower.encode(allocator, cid.raw); 2285 - } 2286 - 2287 - fn cidForBlob(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { 2288 - const cid = try zat.cbor.Cid.create(allocator, 1, 0x55, 0x12, data); 2289 - defer allocator.free(cid.raw); 2290 - return zat.multibase.base32lower.encode(allocator, cid.raw); 2291 - } 2292 - 2293 - fn requireInitialized() !void { 2294 - if (!initialized) return Error.StoreNotInitialized; 2295 - } 2296 - 2297 - const schema_statements = [_][*:0]const u8{ 2298 - \\CREATE TABLE IF NOT EXISTS accounts ( 2299 - \\ did TEXT PRIMARY KEY, 2300 - \\ handle TEXT NOT NULL UNIQUE, 2301 - \\ email TEXT NOT NULL UNIQUE, 2302 - \\ email_confirmed_at INTEGER, 2303 - \\ auth_code TEXT, 2304 - \\ auth_code_expires_at INTEGER, 2305 - \\ pending_email TEXT, 2306 - \\ activated_at INTEGER, 2307 - \\ deactivated_at INTEGER, 2308 - \\ signing_key_type TEXT, 2309 - \\ signing_key BLOB, 2310 - \\ password_hash TEXT NOT NULL, 2311 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 2312 - \\) 2313 - , 2314 - \\CREATE TABLE IF NOT EXISTS records ( 2315 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2316 - \\ collection TEXT NOT NULL, 2317 - \\ rkey TEXT NOT NULL, 2318 - \\ uri TEXT NOT NULL UNIQUE, 2319 - \\ cid TEXT NOT NULL, 2320 - \\ value_json BLOB NOT NULL, 2321 - \\ rev TEXT NOT NULL, 2322 - \\ seq INTEGER NOT NULL, 2323 - \\ PRIMARY KEY (did, collection, rkey) 2324 - \\) 2325 - , 2326 - "CREATE INDEX IF NOT EXISTS records_collection_idx ON records (did, collection, seq DESC)", 2327 - "CREATE INDEX IF NOT EXISTS records_cid_idx ON records (cid)", 2328 - \\CREATE TABLE IF NOT EXISTS commits ( 2329 - \\ seq INTEGER PRIMARY KEY, 2330 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2331 - \\ cid TEXT NOT NULL, 2332 - \\ rev TEXT NOT NULL, 2333 - \\ prev TEXT 2334 - \\) 2335 - , 2336 - "CREATE INDEX IF NOT EXISTS commits_did_idx ON commits (did, seq DESC)", 2337 - \\CREATE TABLE IF NOT EXISTS seq_events ( 2338 - \\ seq INTEGER PRIMARY KEY, 2339 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2340 - \\ commit_cid TEXT NOT NULL, 2341 - \\ evt BLOB NOT NULL 2342 - \\) 2343 - , 2344 - \\CREATE TABLE IF NOT EXISTS blocks ( 2345 - \\ cid TEXT PRIMARY KEY, 2346 - \\ data BLOB NOT NULL 2347 - \\) 2348 - , 2349 - \\CREATE TABLE IF NOT EXISTS blobs ( 2350 - \\ cid TEXT NOT NULL, 2351 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2352 - \\ mime_type TEXT NOT NULL, 2353 - \\ size INTEGER NOT NULL, 2354 - \\ storage TEXT NOT NULL DEFAULT 'disk', 2355 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 2356 - \\ PRIMARY KEY (did, cid) 2357 - \\) 2358 - , 2359 - \\CREATE TABLE IF NOT EXISTS record_blobs ( 2360 - \\ blob_cid TEXT NOT NULL, 2361 - \\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE, 2362 - \\ PRIMARY KEY (blob_cid, record_uri) 2363 - \\) 2364 - , 2365 - \\CREATE TABLE IF NOT EXISTS app_preferences ( 2366 - \\ id INTEGER PRIMARY KEY AUTOINCREMENT, 2367 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2368 - \\ name TEXT NOT NULL, 2369 - \\ value_json BLOB NOT NULL, 2370 - \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 2371 - \\) 2372 - , 2373 - \\CREATE TABLE IF NOT EXISTS oauth_requests ( 2374 - \\ request_id TEXT PRIMARY KEY, 2375 - \\ client_id TEXT NOT NULL, 2376 - \\ redirect_uri TEXT NOT NULL, 2377 - \\ scope TEXT NOT NULL, 2378 - \\ state TEXT NOT NULL, 2379 - \\ code_challenge TEXT NOT NULL, 2380 - \\ code_challenge_method TEXT NOT NULL, 2381 - \\ login_hint TEXT, 2382 - \\ dpop_jkt TEXT, 2383 - \\ expires_at INTEGER NOT NULL, 2384 - \\ sub TEXT, 2385 - \\ code TEXT UNIQUE, 2386 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 2387 - \\) 2388 - , 2389 - \\CREATE TABLE IF NOT EXISTS oauth_tokens ( 2390 - \\ access_token TEXT PRIMARY KEY, 2391 - \\ refresh_token TEXT NOT NULL UNIQUE, 2392 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2393 - \\ client_id TEXT NOT NULL, 2394 - \\ scope TEXT NOT NULL, 2395 - \\ expires_at INTEGER NOT NULL, 2396 - \\ revoked_at INTEGER, 2397 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 2398 - \\) 2399 - , 2400 - }; 2401 - 2402 - const post_schema_statements = [_][*:0]const u8{ 2403 - \\CREATE TABLE IF NOT EXISTS expected_blobs ( 2404 - \\ blob_cid TEXT NOT NULL, 2405 - \\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE, 2406 - \\ PRIMARY KEY (blob_cid, record_uri) 2407 - \\) 2408 - , 2409 - \\CREATE TABLE IF NOT EXISTS repo_blocks ( 2410 - \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2411 - \\ cid TEXT NOT NULL, 2412 - \\ data BLOB NOT NULL, 2413 - \\ PRIMARY KEY (did, cid) 2414 - \\) 2415 - , 2416 - }; 2417 - 2418 - test "persists records in sqlite" { 2419 - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 2420 - defer arena.deinit(); 2421 - const allocator = arena.allocator(); 2422 - 2423 - try init(std.Options.debug_io, ":memory:"); 2424 - defer close(); 2425 - 2426 - const account = try createAccount( 2427 - allocator, 2428 - "alice.test", 2429 - "alice@test.com", 2430 - "password", 2431 - "did:plc:cmadossymmii3izkabdbp5en", 2432 - true, 2433 - ); 2434 - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{}); 2435 - defer parsed.deinit(); 2436 - 2437 - const record = try create(allocator, account, "app.bsky.feed.post", "3ztest", parsed.value); 2438 - try std.testing.expectEqualStrings("3ztest", record.rkey); 2439 - 2440 - const fetched = get(account.did, "app.bsky.feed.post", "3ztest").?; 2441 - try std.testing.expectEqualStrings(record.cid, fetched.cid); 2442 - try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "hello") != null); 2443 - 2444 - const repo_car = try writeRepoCar(allocator, account.did); 2445 - const loaded = try zat.loadCommitFromCAR(allocator, repo_car); 2446 - try std.testing.expectEqualStrings(account.did, loaded.commit.did); 2447 - try std.testing.expectEqualStrings(fetched.rev, loaded.commit.rev); 2448 - try std.testing.expectEqualStrings((try latestRootLocked(allocator, account.did)).cid, try cidText(allocator, loaded.commit_cid)); 2449 - 2450 - const tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid); 2451 - const found = tree.get("app.bsky.feed.post/3ztest") orelse return error.MissingRecord; 2452 - try std.testing.expectEqualStrings(record.cid, try cidText(allocator, found.raw)); 2453 - } 2454 - 2455 - test "stores blob metadata in sqlite and bytes in disk blobstore" { 2456 - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 2457 - defer arena.deinit(); 2458 - const allocator = arena.allocator(); 2459 - 2460 - var tmp = std.testing.tmpDir(.{}); 2461 - defer tmp.cleanup(); 2462 - var path_buf: [std.fs.max_path_bytes]u8 = undefined; 2463 - const blob_root = try tmp.dir.realpath(".", &path_buf); 2464 - blobstore.init(std.Options.debug_io, blob_root); 2465 - 2466 - try init(std.Options.debug_io, ":memory:"); 2467 - defer close(); 2468 - 2469 - const account = try createAccount( 2470 - allocator, 2471 - "alice.test", 2472 - "alice@test.com", 2473 - "password", 2474 - "did:plc:cmadossymmii3izkabdbp5en", 2475 - true, 2476 - ); 2477 - const cid = try putBlob(allocator, std.Options.debug_io, account, "hello blob", "text/plain"); 2478 - const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid }); 2479 - try std.testing.expect(row != null); 2480 - defer row.?.deinit(); 2481 - try std.testing.expectEqualStrings("text/plain", row.?.text(0)); 2482 - try std.testing.expectEqual(@as(i64, 10), row.?.int(1)); 2483 - 2484 - const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord; 2485 - try std.testing.expectEqualStrings("text/plain", blob.mime_type); 2486 - try std.testing.expectEqualStrings("hello blob", blob.data); 2487 - } 2488 - 2489 - test "stores larger blob metadata in sqlite and bytes in disk blobstore" { 2490 - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 2491 - defer arena.deinit(); 2492 - const allocator = arena.allocator(); 2493 - 2494 - var tmp = std.testing.tmpDir(.{}); 2495 - defer tmp.cleanup(); 2496 - var path_buf: [std.fs.max_path_bytes]u8 = undefined; 2497 - const blob_root = try tmp.dir.realpath(".", &path_buf); 2498 - blobstore.init(std.Options.debug_io, blob_root); 2499 - 2500 - try init(std.Options.debug_io, ":memory:"); 2501 - defer close(); 2502 - 2503 - const account = try createAccount( 2504 - allocator, 2505 - "large-blob.test", 2506 - "large-blob@test.com", 2507 - "password", 2508 - "did:plc:largeblob", 2509 - true, 2510 - ); 2511 - 2512 - const data = try allocator.alloc(u8, 571_880); 2513 - @memset(data, 0xaa); 2514 - 2515 - const cid = try putBlob(allocator, std.Options.debug_io, account, data, "image/jpeg"); 2516 - const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid }); 2517 - try std.testing.expect(row != null); 2518 - defer row.?.deinit(); 2519 - try std.testing.expectEqualStrings("image/jpeg", row.?.text(0)); 2520 - try std.testing.expectEqual(@as(i64, 571_880), row.?.int(1)); 2521 - 2522 - const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord; 2523 - try std.testing.expectEqualStrings("image/jpeg", blob.mime_type); 2524 - try std.testing.expectEqualSlices(u8, data, blob.data); 2525 - } 2526 - 2527 - test "commit event encoding owns returned firehose op entries" { 2528 - const allocator = std.testing.allocator; 2529 - const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq"; 2530 - 2531 - const ops = try allocator.alloc(zat.cbor.Value, 1); 2532 - defer allocator.free(ops); 2533 - ops[0] = try firehoseOp(allocator, "create", "sh.tangled.string", "zds-test", cid); 2534 - defer if (ops[0] == .map) allocator.free(ops[0].map); 2535 - 2536 - const frame = try commitEventFrameFromCar( 2537 - allocator, 2538 - 1, 2539 - "did:plc:b64lsctzqnzpv6vd4ry3qktw", 2540 - cid, 2541 - "3zzzzzzzzzzzz", 2542 - null, 2543 - "not-a-real-car-for-this-encoding-test", 2544 - ops, 2545 - ); 2546 - defer allocator.free(frame); 2547 - try std.testing.expect(frame.len > 0); 2548 - } 2549 - 2550 - test "repo rev generation does not move behind current head" { 2551 - const allocator = std.testing.allocator; 2552 - const future_tid = try atid.encode(nowMicros() + std.time.us_per_s, 0); 2553 - const rev = try revForSeq(allocator, 42, &future_tid); 2554 - defer allocator.free(rev); 2555 - 2556 - const previous = try atid.timestampMicros(&future_tid); 2557 - const next = try atid.timestampMicros(rev); 2558 - try std.testing.expect(next > previous); 2559 - try std.testing.expect(std.mem.lessThan(u8, &future_tid, rev)); 2560 - }
-115
tools/smoke.sh
··· 1 - #!/usr/bin/env sh 2 - set -eu 3 - 4 - root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) 5 - port="${ZDS_SMOKE_PORT:-2585}" 6 - db="${TMPDIR:-/tmp}/zds-smoke.sqlite3" 7 - blob_root="${TMPDIR:-/tmp}/zds-smoke-blobs" 8 - log="${TMPDIR:-/tmp}/zds-smoke.log" 9 - base="http://127.0.0.1:${port}" 10 - 11 - cleanup() { 12 - if [ -n "${server_pid:-}" ]; then 13 - kill "$server_pid" 2>/dev/null || true 14 - wait "$server_pid" 2>/dev/null || true 15 - fi 16 - } 17 - trap cleanup EXIT INT TERM 18 - 19 - cd "$root" 20 - rm -f "$db" "$db-wal" "$db-shm" "$log" 21 - rm -rf "$blob_root" 22 - mkdir -p "$blob_root" 23 - 24 - zig build 25 - zig build run -- \ 26 - --host 127.0.0.1 \ 27 - --port "$port" \ 28 - --db "$db" \ 29 - --blobstore-path "$blob_root" \ 30 - --public-url "$base" \ 31 - --server-did did:web:localhost \ 32 - --handle-domains .test \ 33 - >"$log" 2>&1 & 34 - server_pid=$! 35 - 36 - i=0 37 - while [ "$i" -lt 80 ]; do 38 - if curl -fsS "$base/xrpc/_health" >/dev/null 2>&1; then 39 - break 40 - fi 41 - i=$((i + 1)) 42 - sleep 0.1 43 - done 44 - 45 - curl -fsS "$base/xrpc/_health" >/dev/null 46 - sqlite3 "$db" "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:smoketest', 'smoke.test', 'smoke@test.com', 'password', unixepoch(), unixepoch())" 47 - sqlite3 "$db" "insert into oauth_requests (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, expires_at) values ('smoke-oauth', 'https://client.example/oauth-client.json', 'https://client.example/callback', 'repo:*?action=create blob:*/*', 'state', 'challenge', 'S256', 'smoke.test', unixepoch() + 600)" 48 - 49 - resolved=$(curl -fsS "$base/xrpc/com.atproto.identity.resolveHandle?handle=smoke.test") 50 - printf '%s' "$resolved" | grep -q '"did":"did:plc:smoketest"' 51 - 52 - session=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createSession" \ 53 - -H 'content-type: application/json' \ 54 - --data '{"identifier":"smoke.test","password":"password"}') 55 - token=$(printf '%s' "$session" | sed -n 's/.*"accessJwt":"\([^"]*\)".*/\1/p') 56 - test -n "$token" 57 - 58 - curl -fsS -X POST "$base/xrpc/app.bsky.actor.putPreferences" \ 59 - -H "authorization: Bearer $token" \ 60 - -H 'content-type: application/json' \ 61 - --data '{"preferences":[{"$type":"app.bsky.actor.defs#contentLabelPref","label":"dogs","visibility":"show"},{"$type":"app.bsky.actor.defs#contentLabelPref","label":"cats","visibility":"warn"},{"$type":"app.bsky.actor.defs#personalDetailsPref","birthDate":"1970-01-01"},{"$type":"app.bsky.actor.defs#declaredAgePref","isOverAge13":false,"isOverAge16":false,"isOverAge18":false}]}' >/dev/null 62 - prefs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/app.bsky.actor.getPreferences") 63 - printf '%s' "$prefs" | grep -q '"label":"dogs"' 64 - printf '%s' "$prefs" | grep -q '"label":"cats"' 65 - printf '%s' "$prefs" | grep -q '"birthDate":"1970-01-01"' 66 - printf '%s' "$prefs" | grep -q '"isOverAge13":true' 67 - printf '%s' "$prefs" | grep -q '"isOverAge16":true' 68 - printf '%s' "$prefs" | grep -q '"isOverAge18":true' 69 - test "$(printf '%s' "$prefs" | grep -o 'declaredAgePref' | wc -l | tr -d ' ')" = "1" 70 - 71 - create=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.createRecord" \ 72 - -H "authorization: Bearer $token" \ 73 - -H 'content-type: application/json' \ 74 - --data '{"repo":"did:plc:smoketest","collection":"app.bsky.feed.post","rkey":"3smoketest","record":{"$type":"app.bsky.feed.post","text":"smoke","createdAt":"2026-05-22T00:00:00.000Z"}}') 75 - printf '%s' "$create" | grep -q '"uri":"at://did:plc:smoketest/app.bsky.feed.post/3smoketest"' 76 - 77 - records=$(curl -fsS "$base/xrpc/com.atproto.repo.listRecords?repo=did:plc:smoketest&collection=app.bsky.feed.post&limit=10") 78 - printf '%s' "$records" | grep -q '"text":"smoke"' 79 - 80 - latest=$(curl -fsS "$base/xrpc/com.atproto.sync.getLatestCommit?did=did:plc:smoketest") 81 - printf '%s' "$latest" | grep -q '"cid":"' 82 - printf '%s' "$latest" | grep -q '"rev":"' 83 - 84 - repo_car="${TMPDIR:-/tmp}/zds-smoke.car" 85 - code=$(curl -sS -o "$repo_car" -w '%{http_code}' "$base/xrpc/com.atproto.sync.getRepo?did=did:plc:smoketest") 86 - test "$code" = "200" 87 - test "$(wc -c < "$repo_car")" -gt 100 88 - 89 - blob_payload="${TMPDIR:-/tmp}/zds-smoke-blob.jpg" 90 - printf '\377\330\377\340zds-smoke' > "$blob_payload" 91 - blob=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.uploadBlob" \ 92 - -H "authorization: Bearer $token" \ 93 - -H 'content-type: image/jpeg' \ 94 - --data-binary "@$blob_payload") 95 - printf '%s' "$blob" | grep -q '"blob":' 96 - printf '%s' "$blob" | grep -q '"mimeType":"image/jpeg"' 97 - 98 - large_blob_payload="${TMPDIR:-/tmp}/zds-smoke-large-blob.jpg" 99 - dd if=/dev/zero bs=1024 count=600 of="$large_blob_payload" 2>/dev/null 100 - large_blob=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.uploadBlob" \ 101 - -H "authorization: Bearer $token" \ 102 - -H 'content-type: image/jpeg' \ 103 - --data-binary "@$large_blob_payload") 104 - printf '%s' "$large_blob" | grep -q '"blob":' 105 - printf '%s' "$large_blob" | grep -q '"size":614400' 106 - 107 - oauth_info=$(curl -fsS -H 'accept: application/json' "$base/oauth/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Asmoke-oauth") 108 - printf '%s' "$oauth_info" | grep -q '"login_hint":"smoke.test"' 109 - oauth_page="${TMPDIR:-/tmp}/zds-smoke-oauth.html" 110 - curl -fsS "$base/oauth/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Asmoke-oauth" > "$oauth_page" 111 - grep -q 'value="smoke.test"' "$oauth_page" 112 - grep -q 'Repository access' "$oauth_page" 113 - grep -q 'Blob access' "$oauth_page" 114 - 115 - echo "zds smoke ok"