dev
docs
src
atproto
auth
core
http
storage
tools
···
1
1
-
# syntax=docker/dockerfile:1.7
2
2
-
3
3
-
FROM debian:bookworm-slim AS build
4
4
-
5
5
-
RUN apt-get update \
6
6
-
&& apt-get install -y --no-install-recommends ca-certificates curl git xz-utils libc6-dev \
7
7
-
&& rm -rf /var/lib/apt/lists/*
8
8
-
9
9
-
ARG ZIG_VERSION=0.16.0
10
10
-
ARG ZIG_TARGET=x86_64-linux
11
11
-
RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_TARGET}-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
12
12
-
&& mkdir -p /opt/zig \
13
13
-
&& tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 \
14
14
-
&& rm /tmp/zig.tar.xz
15
15
-
16
16
-
ENV PATH="/opt/zig:${PATH}"
17
17
-
ENV ZIG_GLOBAL_CACHE_DIR=/root/.cache/zig
18
18
-
19
19
-
WORKDIR /work
20
20
-
RUN git clone --depth=1 https://tangled.org/zat.dev/zat /work/zat.dev/zat
21
21
-
22
22
-
WORKDIR /work/zzstoatzz.io/zds
23
23
-
COPY . .
24
24
-
RUN --mount=type=cache,target=/root/.cache/zig \
25
25
-
--mount=type=cache,target=/work/zzstoatzz.io/zds/.zig-cache \
26
26
-
zig build -Doptimize=ReleaseSafe
27
27
-
28
28
-
FROM debian:bookworm-slim
29
29
-
30
30
-
RUN apt-get update \
31
31
-
&& apt-get install -y --no-install-recommends ca-certificates sqlite3 \
32
32
-
&& rm -rf /var/lib/apt/lists/*
33
33
-
34
34
-
WORKDIR /app
35
35
-
COPY --from=build /work/zzstoatzz.io/zds/zig-out/bin/zds /usr/local/bin/zds
36
36
-
37
37
-
ENV ZDS_HOST=0.0.0.0
38
38
-
ENV ZDS_PORT=2583
39
39
-
ENV ZDS_DB=/data/zds.sqlite3
40
40
-
41
41
-
EXPOSE 2583
42
42
-
CMD ["zds", "--host", "0.0.0.0", "--port", "2583", "--db", "/data/zds.sqlite3"]
···
1
1
-
# zds
2
2
-
3
3
-
a zig implementation of an at protocol personal data server.
4
4
-
5
5
-
> name credit: jim (`calabro.io`) suggested `zds`.
6
6
-
7
7
-
`zds` stores atproto accounts, repos, records, blobs, sessions, and identity
8
8
-
state. it uses [`zat`](../zat.dev/zat) for atproto primitives such as syntax,
9
9
-
did resolution, jwt helpers, dag-cbor, car, mst, repo verification, and key
10
10
-
encoding.
11
11
-
12
12
-
## references
13
13
-
14
14
-
the implementation is informed by:
15
15
-
16
16
-
- `tranquil.farm/tranquil-pds`
17
17
-
- `futur.blue/pegasus`
18
18
-
- `bluesky-social/pds` and `bluesky-social/atproto/packages/pds`
19
19
-
- `alice.mosphere.at/atproto-smoke`
20
20
-
- `chadtmiller.com/pds.js`
21
21
-
- `zzstoatzz.io/pds-message-poc`
22
22
-
- `zzstoatzz.io/pollz`
23
23
-
- `haileyok/cocoon`
24
24
-
25
25
-
see [docs/reference-study.md](docs/reference-study.md).
26
26
-
27
27
-
## run locally
28
28
-
29
29
-
```sh
30
30
-
zig build test
31
31
-
zig build run -- --port 2583 --db dev/zds.sqlite3
32
32
-
caddy run --config dev/Caddyfile
33
33
-
```
34
34
-
35
35
-
before committing:
36
36
-
37
37
-
```sh
38
38
-
zig build test
39
39
-
tools/smoke.sh
40
40
-
zig zen
41
41
-
```
42
42
-
43
43
-
## endpoints
44
44
-
45
45
-
current server surface includes:
46
46
-
47
47
-
- `GET /`
48
48
-
- `GET /.well-known/did.json`
49
49
-
- `GET /.well-known/atproto-did`
50
50
-
- `GET /xrpc/_health`
51
51
-
- `GET /xrpc/com.atproto.server.describeServer`
52
52
-
- `POST /xrpc/com.atproto.server.createAccount`
53
53
-
- `POST /xrpc/com.atproto.server.createSession`
54
54
-
- `POST /xrpc/com.atproto.server.refreshSession`
55
55
-
- `GET /xrpc/com.atproto.server.getSession`
56
56
-
- `GET /xrpc/com.atproto.server.getServiceAuth`
57
57
-
- repo read/write/import/blob endpoints
58
58
-
- sync repo/blob/status endpoints, including `subscribeRepos`
59
59
-
- identity plc credential/sign/submit endpoints
60
60
-
- generic XRPC service proxying for requests that include `atproto-proxy`
61
61
-
62
62
-
blob uploads default to `100000000` bytes. set
63
63
-
`ZDS_BLOB_UPLOAD_LIMIT` or `--blob-upload-limit` to change it.
64
64
-
65
65
-
see [docs/smoke.md](docs/smoke.md) for the first `atproto-smoke` run.
66
66
-
67
67
-
## deploy
1
1
+
# zds has moved
68
2
69
69
-
run behind a public https origin:
3
3
+
This repository has moved to [zat.dev/zds](https://tangled.org/zat.dev/zds).
70
4
71
71
-
```sh
72
72
-
ZDS_RESEND_API_KEY=... \
73
73
-
ZDS_EMAIL_FROM='ZDS <pds@example.com>' \
74
74
-
ZDS_BLOB_UPLOAD_LIMIT=100000000 \
75
75
-
ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \
76
76
-
ZDS_HANDLE_DOMAINS='.example.com,example.com' \
77
77
-
ZDS_CRAWLERS='https://bsky.network,https://vsky.network' \
78
78
-
ZDS_JWT_SECRET='at-least-32-random-bytes-here' \
79
79
-
zig build run -- \
80
80
-
--port 2583 \
81
81
-
--db /var/lib/zds/zds.sqlite3 \
82
82
-
--public-url https://pds.example.com \
83
83
-
--server-did did:web:pds.example.com
5
5
+
```bash
6
6
+
git clone https://tangled.org/zat.dev/zds
84
7
```
85
85
-
86
86
-
configuration:
87
87
-
88
88
-
- `--public-url`: public pds origin used in did credentials and blob urls
89
89
-
- `--server-did`: pds service did, usually `did:web:<host>`
90
90
-
- `ZDS_HANDLE_DOMAINS`: comma-separated handle domains advertised by
91
91
-
`describeServer`
92
92
-
- `ZDS_JWT_SECRET`: stable secret for access and refresh jwt signing
93
93
-
- `ZDS_RESEND_API_KEY` and `ZDS_EMAIL_FROM`: email delivery for account and plc
94
94
-
tokens
95
95
-
- `ZDS_BLOB_UPLOAD_LIMIT`: generic `com.atproto.repo.uploadBlob` body limit
96
96
-
- `ZDS_BLOBSTORE_PATH` or `--blobstore-path`: disk blobstore root
97
97
-
- `ZDS_CRAWLERS`: comma-separated relay crawl targets
98
98
-
99
99
-
## account migration
100
100
-
101
101
-
zds implements the pds endpoints used by browser-based migration tools such as
102
102
-
[pds moover](https://pdsmoover.com/):
103
103
-
104
104
-
- service-auth-gated `createAccount` for an existing did
105
105
-
- repo car import/export
106
106
-
- missing blob reporting and blob upload
107
107
-
- recommended did credentials
108
108
-
- plc operation email token, signing, and submission
109
109
-
- account activation/deactivation and status checks
110
110
-
111
111
-
see [docs/migration.md](docs/migration.md).
112
112
-
113
113
-
## storage
114
114
-
115
115
-
sqlite stores accounts, records, repo blocks, commits, expected blobs, imported
116
116
-
blob metadata, oauth requests, and auth tokens. uploaded blob bytes are stored
117
117
-
in the disk blobstore rooted at `ZDS_BLOBSTORE_PATH`.
118
118
-
119
119
-
## next
120
120
-
121
121
-
- password reset and account recovery flows
122
122
-
- object-store blob backend
123
123
-
- backup/restore docs for the sqlite database and blob store
···
1
1
-
const std = @import("std");
2
2
-
3
3
-
pub fn build(b: *std.Build) void {
4
4
-
const target = b.standardTargetOptions(.{});
5
5
-
const optimize = b.standardOptimizeOption(.{});
6
6
-
7
7
-
const zat = b.dependency("zat", .{
8
8
-
.target = target,
9
9
-
.optimize = optimize,
10
10
-
});
11
11
-
const zqlite = b.dependency("zqlite", .{
12
12
-
.target = target,
13
13
-
.optimize = optimize,
14
14
-
});
15
15
-
16
16
-
const mod = b.addModule("zds", .{
17
17
-
.root_source_file = b.path("src/root.zig"),
18
18
-
.target = target,
19
19
-
.optimize = optimize,
20
20
-
.imports = &.{
21
21
-
.{ .name = "zat", .module = zat.module("zat") },
22
22
-
.{ .name = "zqlite", .module = zqlite.module("zqlite") },
23
23
-
},
24
24
-
});
25
25
-
26
26
-
const exe = b.addExecutable(.{
27
27
-
.name = "zds",
28
28
-
.root_module = b.createModule(.{
29
29
-
.root_source_file = b.path("src/main.zig"),
30
30
-
.target = target,
31
31
-
.optimize = optimize,
32
32
-
.imports = &.{.{ .name = "zds", .module = mod }},
33
33
-
}),
34
34
-
});
35
35
-
b.installArtifact(exe);
36
36
-
37
37
-
const run_cmd = b.addRunArtifact(exe);
38
38
-
if (b.args) |args| run_cmd.addArgs(args);
39
39
-
40
40
-
const run_step = b.step("run", "Run zds");
41
41
-
run_step.dependOn(&run_cmd.step);
42
42
-
43
43
-
const tests = b.addTest(.{ .root_module = mod });
44
44
-
const run_tests = b.addRunArtifact(tests);
45
45
-
46
46
-
const test_step = b.step("test", "Run unit tests");
47
47
-
test_step.dependOn(&exe.step);
48
48
-
test_step.dependOn(&run_tests.step);
49
49
-
}
···
1
1
-
.{
2
2
-
.name = .zds,
3
3
-
.version = "0.0.0",
4
4
-
.fingerprint = 0x6ebabab1f62e1904,
5
5
-
.minimum_zig_version = "0.16.0",
6
6
-
.dependencies = .{
7
7
-
.zat = .{
8
8
-
.path = "../../zat.dev/zat",
9
9
-
},
10
10
-
.zqlite = .{
11
11
-
.url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#05a88d6758753e1c63fdd45b211dde2057094b0c",
12
12
-
.hash = "zqlite-0.0.1-RWLaYz6bmAAT7E_jxopXf-j5Ea8VQldnxsd6TU8sa0Bb",
13
13
-
},
14
14
-
},
15
15
-
.paths = .{
16
16
-
"build.zig",
17
17
-
"build.zig.zon",
18
18
-
"src",
19
19
-
"docs",
20
20
-
"tools",
21
21
-
".tangled",
22
22
-
"README.md",
23
23
-
},
24
24
-
}
···
1
1
-
{
2
2
-
skip_install_trust
3
3
-
}
4
4
-
5
5
-
localhost:3443 {
6
6
-
tls internal
7
7
-
reverse_proxy 127.0.0.1:2583
8
8
-
}
···
1
1
-
{
2
2
-
"pdsUrl": "http://localhost:2583",
3
3
-
"pdsHost": "localhost:2583",
4
4
-
"publicApiUrl": "http://localhost:2583",
5
5
-
"publicCheckTimeoutMs": 15000,
6
6
-
"artifactsDir": "/tmp/zds-reference-study/atproto-smoke/.tmp/zds-artifacts",
7
7
-
"browserExecutablePath": "/Users/nate/tangled.org/zzstoatzz.io/zds/dev/chromium-insecure.sh",
8
8
-
"headless": true,
9
9
-
"strictErrors": false,
10
10
-
"publicChecks": false,
11
11
-
"primary": {
12
12
-
"handle": "alice.test",
13
13
-
"loginIdentifier": "alice.test",
14
14
-
"password": "password",
15
15
-
"cleanupPostPrefixes": ["browser smoke"]
16
16
-
},
17
17
-
"secondary": {
18
18
-
"handle": "bob.test",
19
19
-
"loginIdentifier": "bob.test",
20
20
-
"password": "password",
21
21
-
"cleanupPostPrefixes": ["browser smoke"]
22
22
-
}
23
23
-
}
···
1
1
-
#!/bin/sh
2
2
-
set -eu
3
3
-
4
4
-
if [ -n "${PLAYWRIGHT_CHROMIUM_EXECUTABLE:-}" ]; then
5
5
-
exec "$PLAYWRIGHT_CHROMIUM_EXECUTABLE" \
6
6
-
--user-data-dir=/tmp/zds-chromium-insecure-profile \
7
7
-
--disable-web-security \
8
8
-
--ignore-certificate-errors \
9
9
-
--allow-running-insecure-content \
10
10
-
--disable-features=BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults \
11
11
-
"$@"
12
12
-
fi
13
13
-
14
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
15
-
if [ -x "$exe" ]; then
16
16
-
exec "$exe" \
17
17
-
--user-data-dir=/tmp/zds-chromium-insecure-profile \
18
18
-
--disable-web-security \
19
19
-
--ignore-certificate-errors \
20
20
-
--allow-running-insecure-content \
21
21
-
--disable-features=BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults \
22
22
-
"$@"
23
23
-
fi
24
24
-
done
25
25
-
26
26
-
echo "Playwright Chromium executable not found; run: bunx playwright install chromium" >&2
27
27
-
exit 127
···
1
1
-
#!/usr/bin/env node
2
2
-
3
3
-
const oldHandle = mustEnv("OLD_HANDLE");
4
4
-
const password = mustEnv("OLD_PASSWORD");
5
5
-
const newEmail = mustEnv("NEW_EMAIL");
6
6
-
const newPds = process.env.NEW_PDS ?? "https://pds.zat.dev";
7
7
-
const newHandle = process.env.NEW_HANDLE ?? oldHandle;
8
8
-
const sourcePdsOverride = process.env.SOURCE_PDS;
9
9
-
10
10
-
function mustEnv(name) {
11
11
-
const value = process.env[name];
12
12
-
if (!value) {
13
13
-
console.error(`missing ${name}`);
14
14
-
process.exit(2);
15
15
-
}
16
16
-
return value;
17
17
-
}
18
18
-
19
19
-
function cleanHandle(handle) {
20
20
-
return handle.replace("@", "").trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "");
21
21
-
}
22
22
-
23
23
-
async function requestJson(label, method, baseUrl, nsid, { params, body, token } = {}) {
24
24
-
const url = new URL(`/xrpc/${nsid}`, baseUrl);
25
25
-
if (params) {
26
26
-
for (const [key, value] of Object.entries(params)) {
27
27
-
if (value != null) url.searchParams.set(key, value);
28
28
-
}
29
29
-
}
30
30
-
const headers = { accept: "application/json" };
31
31
-
if (body !== undefined) headers["content-type"] = "application/json";
32
32
-
if (token) headers.authorization = `Bearer ${token}`;
33
33
-
const started = Date.now();
34
34
-
const res = await fetch(url, {
35
35
-
method,
36
36
-
headers,
37
37
-
body: body === undefined ? undefined : JSON.stringify(body),
38
38
-
});
39
39
-
const text = await res.text();
40
40
-
let data = null;
41
41
-
if (text) {
42
42
-
try {
43
43
-
data = JSON.parse(text);
44
44
-
} catch {
45
45
-
data = { raw: text.slice(0, 200) };
46
46
-
}
47
47
-
}
48
48
-
const error = data?.error ? ` error=${data.error}` : "";
49
49
-
console.log(`${label}: ${method} ${url} -> ${res.status} ${res.statusText}${error} (${Date.now() - started}ms)`);
50
50
-
if (!res.ok) {
51
51
-
const err = new Error(data?.message ?? `${res.status} ${res.statusText}`);
52
52
-
err.status = res.status;
53
53
-
err.data = data;
54
54
-
throw err;
55
55
-
}
56
56
-
return data;
57
57
-
}
58
58
-
59
59
-
async function resolveMiniDoc(handle) {
60
60
-
const url = new URL("https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc");
61
61
-
url.searchParams.set("identifier", handle);
62
62
-
const res = await fetch(url);
63
63
-
if (!res.ok) throw new Error(`resolveMiniDoc failed: ${res.status}`);
64
64
-
return res.json();
65
65
-
}
66
66
-
67
67
-
async function main() {
68
68
-
const handle = cleanHandle(oldHandle);
69
69
-
const mini = sourcePdsOverride
70
70
-
? { did: null, handle, pds: sourcePdsOverride }
71
71
-
: await resolveMiniDoc(handle);
72
72
-
73
73
-
console.log(`source handle=${handle}`);
74
74
-
if (mini.did) console.log(`source did=${mini.did}`);
75
75
-
console.log(`source pds=${mini.pds}`);
76
76
-
console.log(`target pds=${newPds}`);
77
77
-
console.log(`target handle=${newHandle}`);
78
78
-
79
79
-
const oldSession = await requestJson("old login", "POST", mini.pds, "com.atproto.server.createSession", {
80
80
-
body: { identifier: handle, password },
81
81
-
});
82
82
-
const did = oldSession.did;
83
83
-
console.log(`old login ok did=${did} handle=${oldSession.handle}`);
84
84
-
85
85
-
const newDesc = await requestJson("new describe", "GET", newPds, "com.atproto.server.describeServer");
86
86
-
console.log(`new pds did=${newDesc.did}`);
87
87
-
88
88
-
const serviceAuth = await requestJson("old service auth", "GET", mini.pds, "com.atproto.server.getServiceAuth", {
89
89
-
token: oldSession.accessJwt,
90
90
-
params: {
91
91
-
aud: newDesc.did,
92
92
-
lxm: "com.atproto.server.createAccount",
93
93
-
},
94
94
-
});
95
95
-
console.log("old service auth ok");
96
96
-
97
97
-
try {
98
98
-
await requestJson("new repo status before create", "GET", newPds, "com.atproto.sync.getRepoStatus", {
99
99
-
params: { did },
100
100
-
});
101
101
-
} catch (err) {
102
102
-
console.log(`new repo status before create expected miss: ${err.status} ${err.data?.error ?? err.message}`);
103
103
-
}
104
104
-
105
105
-
const created = await requestJson("new create account", "POST", newPds, "com.atproto.server.createAccount", {
106
106
-
token: serviceAuth.token,
107
107
-
body: {
108
108
-
did,
109
109
-
handle: newHandle,
110
110
-
email: newEmail,
111
111
-
password,
112
112
-
},
113
113
-
});
114
114
-
console.log(`new create account ok did=${created.did} handle=${created.handle}`);
115
115
-
116
116
-
const byDid = await requestJson("new login by did", "POST", newPds, "com.atproto.server.createSession", {
117
117
-
body: { identifier: did, password },
118
118
-
});
119
119
-
console.log(`new login by did ok active=${byDid.active}`);
120
120
-
121
121
-
const byHandle = await requestJson("new login by handle", "POST", newPds, "com.atproto.server.createSession", {
122
122
-
body: { identifier: newHandle, password },
123
123
-
});
124
124
-
console.log(`new login by handle ok active=${byHandle.active}`);
125
125
-
}
126
126
-
127
127
-
main().catch((err) => {
128
128
-
console.error(`trace failed: ${err.message}`);
129
129
-
if (err.data) console.error(JSON.stringify(err.data, null, 2));
130
130
-
process.exit(1);
131
131
-
});
···
1
1
-
# migration
2
2
-
3
3
-
`zds` implements the pds endpoints used by browser-based account migration
4
4
-
tools, including pds moover.
5
5
-
6
6
-
## pds moover flow
7
7
-
8
8
-
pds moover asks for:
9
9
-
10
10
-
- current handle and password
11
11
-
- new pds url
12
12
-
- new email
13
13
-
- new handle
14
14
-
- invite code
15
15
-
16
16
-
the migration is not complete until the user enters a plc token delivered by
17
17
-
email.
18
18
-
19
19
-
## implemented endpoints
20
20
-
21
21
-
implemented:
22
22
-
23
23
-
- `com.atproto.server.createAccount`
24
24
-
- `com.atproto.server.checkAccountStatus`
25
25
-
- `com.atproto.server.activateAccount`
26
26
-
- `com.atproto.server.deactivateAccount`
27
27
-
- `com.atproto.server.refreshSession`
28
28
-
- `com.atproto.repo.importRepo`
29
29
-
- `com.atproto.repo.listMissingBlobs`
30
30
-
- `com.atproto.sync.getRepo`
31
31
-
- `com.atproto.sync.listBlobs`
32
32
-
- `com.atproto.sync.getRepoStatus`
33
33
-
- `com.atproto.identity.getRecommendedDidCredentials`
34
34
-
- `com.atproto.identity.requestPlcOperationSignature`
35
35
-
- `com.atproto.identity.signPlcOperation`
36
36
-
- `com.atproto.identity.submitPlcOperation`
37
37
-
- `app.bsky.actor.getPreferences`
38
38
-
- `app.bsky.actor.putPreferences`
39
39
-
40
40
-
implementation details:
41
41
-
42
42
-
- `createAccount` verifies migration service jwts against the migrating did
43
43
-
- session tokens are signed with the configured `ZDS_JWT_SECRET`
44
44
-
- stored passwords use salted pbkdf2-sha256 hashes
45
45
-
- repo writes produce signed commit blocks and mst updates
46
46
-
- imported repo cars are verified against the account did and signing key
47
47
-
- each account has its own signing key for repo commits and plc credentials
48
48
-
- `uploadBlob` defaults to 100,000,000 bytes and is configurable with
49
49
-
`ZDS_BLOB_UPLOAD_LIMIT` or `--blob-upload-limit`
50
50
-
- uploaded blob bytes are stored in the disk blobstore rooted at
51
51
-
`ZDS_BLOBSTORE_PATH` or `--blobstore-path`
52
52
-
- the server serves `/.well-known/did.json` for the pds service did and
53
53
-
`/.well-known/atproto-did` for hosted handles
54
54
-
55
55
-
## not yet implemented
56
56
-
57
57
-
- real sequenced account/identity events
58
58
-
- password reset and account recovery paths
59
59
-
- object-store blob backend with backup/restore tooling
60
60
-
61
61
-
## deployment
62
62
-
63
63
-
run behind a public https origin:
64
64
-
65
65
-
```sh
66
66
-
ZDS_RESEND_API_KEY=... \
67
67
-
ZDS_EMAIL_FROM='ZDS <pds@example.com>' \
68
68
-
ZDS_BLOB_UPLOAD_LIMIT=100000000 \
69
69
-
ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \
70
70
-
ZDS_HANDLE_DOMAINS='.example.com,example.com' \
71
71
-
ZDS_JWT_SECRET='at-least-32-random-bytes-here' \
72
72
-
zig build run -- \
73
73
-
--port 2583 \
74
74
-
--db /var/lib/zds/zds.sqlite3 \
75
75
-
--public-url https://pds.example.com \
76
76
-
--server-did did:web:pds.example.com
77
77
-
```
78
78
-
79
79
-
the public url is used for did credentials and blob views. `ZDS_RESEND_API_KEY`
80
80
-
and `ZDS_EMAIL_FROM` enable real email delivery; without them, token emails are
81
81
-
logged to stdout for local development. `ZDS_BLOB_UPLOAD_LIMIT` controls the
82
82
-
generic `com.atproto.repo.uploadBlob` request body ceiling.
83
83
-
`ZDS_BLOBSTORE_PATH` controls where uploaded blob bytes are stored.
84
84
-
`ZDS_HANDLE_DOMAINS` is what `describeServer` advertises to clients.
85
85
-
`ZDS_JWT_SECRET` signs app-session access and refresh jwts; keep it stable
86
86
-
across deploys or existing sessions will be invalidated.
87
87
-
88
88
-
the at protocol uploadBlob lexicon does not define one universal byte limit; it
89
89
-
says blob restrictions are enforced when a record references the uploaded blob.
90
90
-
the official pds exposes a configurable blob upload limit, while the bluesky
91
91
-
video embed lexicon allows 100,000,000-byte mp4 blobs. `zds` uses that as the
92
92
-
default generic upload ceiling so migration tests do not fail below bluesky's
93
93
-
current video shape.
94
94
-
95
95
-
deployment also needs sqlite data-directory backups and operational access to
96
96
-
inspect account status, repo import, missing blobs, and plc submission failures.
97
97
-
98
98
-
core atproto account, repo, sync, and identity code stays separate from app-view
99
99
-
services. app-view XRPCs are reached through the generic `atproto-proxy` path;
100
100
-
PDS-owned app state such as `app.bsky.actor.getPreferences` is stored locally
101
101
-
because that is how the reference PDS and Tranquil treat it.
···
1
1
-
# Reference Study
2
2
-
3
3
-
## Tranquil PDS
4
4
-
5
5
-
Tranquil is the "full community PDS" reference. It has a broad Rust workspace,
6
6
-
Postgres storage, passkeys, 2FA, scoped OAuth, delegation, account management,
7
7
-
repo import, sync verification, and a large test surface.
8
8
-
9
9
-
Things worth borrowing:
10
10
-
11
11
-
- repo writes are serialized per actor
12
12
-
- sync/firehose is a first-class subsystem
13
13
-
- storage concerns are separated from API handlers
14
14
-
- conformance and security tests are treated as core code
15
15
-
16
16
-
Things to defer:
17
17
-
18
18
-
- product/admin UI
19
19
-
- multi-channel comms
20
20
-
- delegation and advanced account management
21
21
-
22
22
-
## Pegasus
23
23
-
24
24
-
Pegasus is the cleanest architectural reference for a Zig implementation. Its
25
25
-
OCaml repo separates IPLD, MST, crypto, XRPC, and PDS logic into clear libraries.
26
26
-
The PDS itself can stay small because the protocol primitives are real modules.
27
27
-
28
28
-
Things worth borrowing:
29
29
-
30
30
-
- separate IPLD/CAR/DAG-CBOR from repository logic
31
31
-
- keep MST storage swappable
32
32
-
- keep XRPC parsing/auth/rate-limit boundaries explicit
33
33
-
- start with SQLite-shaped local storage
34
34
-
35
35
-
## Bluesky PDS
36
36
-
37
37
-
`bluesky-social/pds` is mostly distribution and operations. The implementation
38
38
-
reference lives in `bluesky-social/atproto/packages/pds`.
39
39
-
40
40
-
Things worth borrowing:
41
41
-
42
42
-
- endpoint behavior and compatibility expectations
43
43
-
- record preparation rules
44
44
-
- sequencer shape for `subscribeRepos`
45
45
-
- account, actor, and sequencer store separation
46
46
-
47
47
-
## atproto-smoke
48
48
-
49
49
-
`atproto-smoke` is not a PDS implementation. It is a future acceptance-test
50
50
-
reference because it exercises real `bsky.app` behavior against arbitrary PDSes.
51
51
-
52
52
-
Things worth borrowing:
53
53
-
54
54
-
- bring-your-own credentials as the first test mode
55
55
-
- artifact-heavy failures
56
56
-
- social flows as end-to-end compatibility checks
57
57
-
58
58
-
## pds.js
59
59
-
60
60
-
`pds.js` is the useful compact implementation reference. Its endpoint comparison
61
61
-
also gives a pragmatic MVP map.
62
62
-
63
63
-
Things worth borrowing:
64
64
-
65
65
-
- hexagonal port shape: core logic, actor storage, shared storage, blobs,
66
66
-
WebSocket/firehose
67
67
-
- minimal endpoint set: repo writes, sync reads, auth sessions, blobs, generic
68
68
-
service proxy
69
69
-
- endpoint coverage docs as a living checklist
70
70
-
71
71
-
## pds-message-poc
72
72
-
73
73
-
`pds-message-poc` is cloned as a sibling repo at
74
74
-
`/Users/nate/tangled.org/zzstoatzz.io/pds-message-poc`. It demonstrates two
75
75
-
pds.js deployments exchanging custom `xyz.fake.inbox.*` messages.
76
76
-
77
77
-
Things worth borrowing:
78
78
-
79
79
-
- `createSession` only needs `did`, `handle`, and `accessJwt` for the first
80
80
-
browser client doorway
81
81
-
- service auth (`com.atproto.server.getServiceAuth`) is the next useful auth
82
82
-
surface after basic app sessions
83
83
-
- custom XRPC endpoints can be a good early proving ground for PDS-to-PDS
84
84
-
behavior before the full Bluesky app surface exists
85
85
-
86
86
-
## pollz
87
87
-
88
88
-
`pollz` is cloned as a sibling repo at
89
89
-
`/Users/nate/tangled.org/zzstoatzz.io/pollz`. It is a Zig + SvelteKit ATProto app
90
90
-
with OAuth, SQLite, Jetstream, and PDS writes.
91
91
-
92
92
-
Things worth borrowing:
93
93
-
94
94
-
- `zat.oauth` for PKCE, DPoP proofs, private-key client assertions, token
95
95
-
exchange, and token refresh
96
96
-
- `zat.json` for dynamic JSON navigation instead of local nested object walking
97
97
-
- `zat.HandleResolver` and `zat.DidResolver` for handle -> DID -> PDS discovery
98
98
-
- the shape of authenticated PDS requests using DPoP-bound access tokens
99
99
-
100
100
-
## notes
101
101
-
102
102
-
The sibling `notes` repo is the working protocol notebook. The PDS-relevant
103
103
-
takeaways are:
104
104
-
105
105
-
- XRPC should preserve HTTP status, ATProto error names, human messages, raw
106
106
-
bodies, and rate-limit headers
107
107
-
- repo paths are `<collection>/<rkey>` where collection is an NSID and rkey is
108
108
-
user-controlled
109
109
-
- commits are per-repo ordered transitions, not isolated record writes
110
110
-
- `subscribeRepos` producers need strict frame fields such as `tooBig: false`
111
111
-
and `since: null | tid`, never an empty string
112
112
-
- sync verification is an inductive chain over previous `rev` and MST root
113
113
-
114
114
-
## zat
115
115
-
116
116
-
The old `zzstoatzz.io/zat` repo has moved to `zat.dev/zat`. This is the Zig
117
117
-
ATProto substrate:
118
118
-
119
119
-
- syntax primitives: TID, DID, handle, NSID, rkey, AT-URI
120
120
-
- identity resolution
121
121
-
- XRPC client helpers
122
122
-
- JSON path helpers
123
123
-
- OAuth helpers: PKCE, DPoP, client assertions, JWKS, form encoding
124
124
-
- DAG-CBOR, CAR, MST, repo verification
125
125
-
- firehose and jetstream clients
126
126
-
127
127
-
`zds` should depend on `zat` instead of reimplementing these pieces. The PDS
128
128
-
work should start one layer above: account/session state, repo write policy,
129
129
-
storage, server routes, sync production, and compatibility behavior.
130
130
-
131
131
-
## Initial zds Bias
132
132
-
133
133
-
Start like Pegasus and pds.js, verify like atproto-smoke, and treat Bluesky PDS
134
134
-
as the compatibility oracle. Tranquil is the reminder that the design should not
135
135
-
paint us into a corner once the project grows past the toy phase.
136
136
-
137
137
-
Because `zat` already owns the protocol primitives, `zds` can be smaller and
138
138
-
more opinionated: a PDS built on those primitives rather than another general
139
139
-
ATProto SDK.
···
1
1
-
# Smoke Testing
2
2
-
3
3
-
`atproto-smoke` is cloned at:
4
4
-
5
5
-
```text
6
6
-
/tmp/zds-reference-study/atproto-smoke
7
7
-
```
8
8
-
9
9
-
## 2026-05-20 Run
10
10
-
11
11
-
Local server:
12
12
-
13
13
-
```sh
14
14
-
zig build run -- --port 2583
15
15
-
```
16
16
-
17
17
-
Local HTTPS proxy, modeled after `pds.js`:
18
18
-
19
19
-
```sh
20
20
-
caddy run --config dev/Caddyfile
21
21
-
```
22
22
-
23
23
-
The Caddy install path on macOS is the documented Homebrew path:
24
24
-
25
25
-
```sh
26
26
-
brew install caddy
27
27
-
```
28
28
-
29
29
-
Probe results:
30
30
-
31
31
-
- `GET http://127.0.0.1:2583/xrpc/_health` returns `200`
32
32
-
- `GET http://127.0.0.1:2583/xrpc/com.atproto.server.describeServer` returns
33
33
-
`200`
34
34
-
- `OPTIONS http://127.0.0.1:2583/xrpc/com.atproto.server.describeServer`
35
35
-
returns `204` with CORS and private-network access headers
36
36
-
- `GET https://localhost:3443/xrpc/_health` returns `200` through Caddy with
37
37
-
`curl -k`
38
38
-
- `POST http://127.0.0.1:2583/xrpc/com.atproto.server.createSession` returns
39
39
-
`200` for `alice.test` and `bob.test` with password `password`
40
40
-
- `GET http://127.0.0.1:2583/xrpc/com.atproto.server.getSession` returns
41
41
-
`200` for the dev access token minted by `createSession`
42
42
-
- `GET http://127.0.0.1:2583/xrpc/com.atproto.server.getServiceAuth` returns
43
43
-
`200` with an ES256 service-auth JWT for an authenticated dev session
44
44
-
45
45
-
Smoke command:
46
46
-
47
47
-
```sh
48
48
-
cd /tmp/zds-reference-study/atproto-smoke
49
49
-
bun install
50
50
-
bunx playwright install chromium
51
51
-
bunx tsx bin/atproto-smoke.ts validate --mode dual --config /Users/nate/tangled.org/zzstoatzz.io/zds/dev/atproto-smoke-dual.json
52
52
-
bunx tsx src/browser/run-dual.ts /Users/nate/tangled.org/zzstoatzz.io/zds/dev/atproto-smoke-dual.json
53
53
-
```
54
54
-
55
55
-
Result:
56
56
-
57
57
-
```text
58
58
-
[ok] primary-login
59
59
-
[ok] primary-age-assurance
60
60
-
[ok] secondary-login
61
61
-
[ok] secondary-age-assurance
62
62
-
[fail] primary-preclean-stale-artifacts listRecords failed for alice.test collection app.bsky.feed.post: 404
63
63
-
```
64
64
-
65
65
-
`bsky.app` treats a custom `localhost:port` service as a plain HTTP dev PDS.
66
66
-
The smoke config therefore uses `http://localhost:2583` for the app login flow.
67
67
-
The Caddy HTTPS proxy remains available at `https://localhost:3443` for clients
68
68
-
that need local TLS termination.
69
69
-
70
70
-
The Playwright browser wrapper in `dev/chromium-insecure.sh` is intentionally
71
71
-
smoke-only. It disables browser web-security checks so a public origin
72
72
-
(`https://bsky.app`) can call a loopback development service.
73
73
-
74
74
-
## Next Compatibility Step
75
75
-
76
76
-
Implement the first repo read endpoint:
77
77
-
`com.atproto.repo.listRecords?collection=app.bsky.feed.post`. The smoke suite
78
78
-
now reaches this after successful session creation for both configured accounts.
79
79
-
For the message POC direction, the next step is implementing the custom
80
80
-
`xyz.fake.inbox.*` endpoints around the new `getServiceAuth` surface.
···
1
1
-
app = "zds-pds"
2
2
-
primary_region = "ord"
3
3
-
4
4
-
[build]
5
5
-
dockerfile = "Dockerfile"
6
6
-
7
7
-
[env]
8
8
-
ZDS_HOST = "0.0.0.0"
9
9
-
ZDS_DB = "/data/zds.sqlite3"
10
10
-
ZDS_PUBLIC_URL = "https://pds.zat.dev"
11
11
-
ZDS_SERVER_DID = "did:web:pds.zat.dev"
12
12
-
ZDS_HANDLE_DOMAINS = ".pds.zat.dev,pds.zat.dev"
13
13
-
ZDS_CRAWLERS = "https://bsky.network,https://vsky.network"
14
14
-
ZDS_BLOB_UPLOAD_LIMIT = "100000000"
15
15
-
ZDS_BLOBSTORE_PATH = "/data/blobs"
16
16
-
17
17
-
[[mounts]]
18
18
-
source = "zds_data"
19
19
-
destination = "/data"
20
20
-
21
21
-
[http_service]
22
22
-
internal_port = 2583
23
23
-
force_https = true
24
24
-
auto_stop_machines = "off"
25
25
-
auto_start_machines = true
26
26
-
min_machines_running = 1
27
27
-
28
28
-
[[vm]]
29
29
-
memory = "1gb"
30
30
-
cpu_kind = "shared"
31
31
-
cpus = 1
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const config = @import("../core/config.zig");
4
4
-
const mail = @import("../core/mail.zig");
5
5
-
const http_api = @import("../http/api.zig");
6
6
-
const store = @import("../storage/store.zig");
7
7
-
const zat = @import("zat");
8
8
-
9
9
-
const http = std.http;
10
10
-
11
11
-
pub fn getRecommendedDidCredentials(request: *http.Server.Request) !void {
12
12
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
13
13
-
defer arena.deinit();
14
14
-
const allocator = arena.allocator();
15
15
-
16
16
-
const account = requireAccount(request, allocator) catch return;
17
17
-
var keypair = try store.signingKeypair(account.did);
18
18
-
const did_key = try keypair.did(allocator);
19
19
-
const body = try std.fmt.allocPrint(
20
20
-
allocator,
21
21
-
"{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}",
22
22
-
.{
23
23
-
account.handle,
24
24
-
std.json.fmt(did_key, .{}),
25
25
-
std.json.fmt(did_key, .{}),
26
26
-
std.json.fmt(config.publicUrl(), .{}),
27
27
-
},
28
28
-
);
29
29
-
return http_api.json(request, .ok, body);
30
30
-
}
31
31
-
32
32
-
pub fn requestPlcOperationSignature(request: *http.Server.Request) !void {
33
33
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
34
34
-
defer arena.deinit();
35
35
-
const allocator = arena.allocator();
36
36
-
const account = requireAccount(request, allocator) catch return;
37
37
-
const info = store.getEmailInfo(allocator, account.did) orelse {
38
38
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
39
39
-
};
40
40
-
var code_buf: [11]u8 = undefined;
41
41
-
const code = makeCode(&code_buf);
42
42
-
try store.setAuthCode(account.did, code, store.nowMs() + (10 * 60 * 1000));
43
43
-
try mail.sendCode(store.currentIo(), allocator, info.email, account.handle, "PLC operation", "PLC operation", code);
44
44
-
return http_api.json(request, .ok, "{}");
45
45
-
}
46
46
-
47
47
-
pub fn signPlcOperation(request: *http.Server.Request) !void {
48
48
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
49
49
-
defer arena.deinit();
50
50
-
const allocator = arena.allocator();
51
51
-
52
52
-
const account = requireAccount(request, allocator) catch return;
53
53
-
const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024);
54
54
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
55
55
-
const input = switch (parsed.value) {
56
56
-
.object => |object| object,
57
57
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
58
58
-
};
59
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
60
-
.string => |string| string,
61
61
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email confirmation token required to sign PLC operations"),
62
62
-
};
63
63
-
switch (store.validateAuthCode(account.did, token, store.nowMs())) {
64
64
-
.valid => {},
65
65
-
.expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"),
66
66
-
.invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"),
67
67
-
}
68
68
-
69
69
-
const last_op = fetchLastPlcOp(allocator, account.did) catch {
70
70
-
return http_api.xrpcError(request, .bad_gateway, "PlcFetchFailed", "failed to fetch current PLC operation");
71
71
-
};
72
72
-
if (jsonObjectStringEquals(last_op, "type", "plc_tombstone")) {
73
73
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID is tombstoned");
74
74
-
}
75
75
-
76
76
-
const prev = try cidForJsonValue(allocator, last_op);
77
77
-
const unsigned_json = try writeUnsignedPlcOperationJson(allocator, last_op, input, prev);
78
78
-
const unsigned_parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{});
79
79
-
const unsigned_cbor = try jsonToCbor(allocator, unsigned_parsed.value);
80
80
-
const encoded = try zat.cbor.encodeAlloc(allocator, unsigned_cbor);
81
81
-
var keypair = try store.signingKeypair(account.did);
82
82
-
const sig = try keypair.sign(encoded);
83
83
-
const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes);
84
84
-
try store.clearAuthCode(account.did);
85
85
-
86
86
-
var response_writer: std.Io.Writer.Allocating = .init(allocator);
87
87
-
defer response_writer.deinit();
88
88
-
try response_writer.writer.writeAll("{\"operation\":");
89
89
-
try response_writer.writer.writeAll(unsigned_json[0 .. unsigned_json.len - 1]);
90
90
-
try response_writer.writer.print(",\"sig\":{f}}}", .{std.json.fmt(sig_text, .{})});
91
91
-
const response = try response_writer.toOwnedSlice();
92
92
-
return http_api.json(request, .ok, response);
93
93
-
}
94
94
-
95
95
-
pub fn submitPlcOperation(request: *http.Server.Request) !void {
96
96
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
97
97
-
defer arena.deinit();
98
98
-
const allocator = arena.allocator();
99
99
-
100
100
-
const account = requireAccount(request, allocator) catch return;
101
101
-
const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024);
102
102
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
103
103
-
const operation = switch (parsed.value) {
104
104
-
.object => |object| object.get("operation") orelse {
105
105
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing operation");
106
106
-
},
107
107
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
108
108
-
};
109
109
-
try validatePlcOperation(request, allocator, account, operation);
110
110
-
111
111
-
const op_json = try stringifyJson(allocator, operation);
112
112
-
const escaped_did = try percentEncodeDid(allocator, account.did);
113
113
-
var url_buf: [320]u8 = undefined;
114
114
-
const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}", .{ config.plcDirectory(), escaped_did });
115
115
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
116
116
-
defer transport.deinit();
117
117
-
const result = transport.fetch(.{
118
118
-
.url = url,
119
119
-
.method = .POST,
120
120
-
.payload = op_json,
121
121
-
.content_type = "application/json",
122
122
-
.max_response_size = 64 * 1024,
123
123
-
}) catch {
124
124
-
return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "failed to submit PLC operation");
125
125
-
};
126
126
-
if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) {
127
127
-
return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected operation");
128
128
-
}
129
129
-
return http_api.json(request, .ok, "{}");
130
130
-
}
131
131
-
132
132
-
pub fn resolveHandle(request: *http.Server.Request) !void {
133
133
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
134
134
-
defer arena.deinit();
135
135
-
const allocator = arena.allocator();
136
136
-
137
137
-
var handle_buf: [256]u8 = undefined;
138
138
-
const handle_param = http_api.queryParam(request.head.target, "handle", &handle_buf) orelse {
139
139
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle");
140
140
-
};
141
141
-
const handle = std.mem.trim(u8, handle_param, &std.ascii.whitespace);
142
142
-
if (handle.len == 0) {
143
143
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle");
144
144
-
}
145
145
-
146
146
-
const did = if (store.resolveRepo(handle)) |account|
147
147
-
account.did
148
148
-
else if (std.ascii.eqlIgnoreCase(handle, "mod-authority.test"))
149
149
-
"did:plc:ar7c4by46qjdydhdevvrndac"
150
150
-
else did: {
151
151
-
const parsed_handle = zat.Handle.parse(handle) orelse {
152
152
-
return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle format");
153
153
-
};
154
154
-
var resolver = zat.HandleResolver.init(store.currentIo(), allocator);
155
155
-
defer resolver.deinit();
156
156
-
break :did resolver.resolve(parsed_handle) catch {
157
157
-
return http_api.xrpcError(request, .not_found, "HandleNotFound", "Unable to resolve handle");
158
158
-
};
159
159
-
};
160
160
-
161
161
-
const body = try std.fmt.allocPrint(allocator, "{{\"did\":{f}}}", .{std.json.fmt(did, .{})});
162
162
-
return http_api.json(request, .ok, body);
163
163
-
}
164
164
-
165
165
-
fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
166
166
-
return http_api.requireBearerAccount(request, allocator) catch |err| {
167
167
-
switch (err) {
168
168
-
error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
169
169
-
error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
170
170
-
}
171
171
-
return error.HandledResponse;
172
172
-
};
173
173
-
}
174
174
-
175
175
-
fn validatePlcOperation(
176
176
-
request: *http.Server.Request,
177
177
-
allocator: std.mem.Allocator,
178
178
-
account: auth.Account,
179
179
-
operation: std.json.Value,
180
180
-
) !void {
181
181
-
var keypair = try store.signingKeypair(account.did);
182
182
-
const did_key = try keypair.did(allocator);
183
183
-
const object = switch (operation) {
184
184
-
.object => |object| object,
185
185
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid operation"),
186
186
-
};
187
187
-
if (!jsonArrayContainsString(object.get("rotationKeys"), did_key)) {
188
188
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key");
189
189
-
}
190
190
-
const verification_methods = object.get("verificationMethods") orelse {
191
191
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing verificationMethods");
192
192
-
};
193
193
-
if (!jsonObjectStringEquals(verification_methods, "atproto", did_key)) {
194
194
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect signing key");
195
195
-
}
196
196
-
if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}))) {
197
197
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect handle in alsoKnownAs");
198
198
-
}
199
199
-
const services = object.get("services") orelse {
200
200
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing services");
201
201
-
};
202
202
-
const atproto_pds = switch (services) {
203
203
-
.object => |services_object| services_object.get("atproto_pds") orelse {
204
204
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing atproto_pds service");
205
205
-
},
206
206
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid services"),
207
207
-
};
208
208
-
if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or
209
209
-
!jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl()))
210
210
-
{
211
211
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect atproto_pds service");
212
212
-
}
213
213
-
}
214
214
-
215
215
-
fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
216
216
-
var out: std.Io.Writer.Allocating = .init(allocator);
217
217
-
defer out.deinit();
218
218
-
try out.writer.print("{f}", .{std.json.fmt(value, .{})});
219
219
-
return out.toOwnedSlice();
220
220
-
}
221
221
-
222
222
-
fn fetchLastPlcOp(allocator: std.mem.Allocator, did: []const u8) !std.json.Value {
223
223
-
const escaped_did = try percentEncodeDid(allocator, did);
224
224
-
var url_buf: [340]u8 = undefined;
225
225
-
const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}/log/last", .{ config.plcDirectory(), escaped_did });
226
226
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
227
227
-
defer transport.deinit();
228
228
-
const result = try transport.fetch(.{
229
229
-
.url = url,
230
230
-
.method = .GET,
231
231
-
.accept = "application/json",
232
232
-
.max_response_size = 1024 * 1024,
233
233
-
});
234
234
-
if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.PlcFetchFailed;
235
235
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{});
236
236
-
return parsed.value;
237
237
-
}
238
238
-
239
239
-
fn writeUnsignedPlcOperationJson(
240
240
-
allocator: std.mem.Allocator,
241
241
-
last_op: std.json.Value,
242
242
-
input: std.json.ObjectMap,
243
243
-
prev: []const u8,
244
244
-
) ![]const u8 {
245
245
-
const last = switch (last_op) {
246
246
-
.object => |object| object,
247
247
-
else => return error.InvalidPlcOperation,
248
248
-
};
249
249
-
var out: std.Io.Writer.Allocating = .init(allocator);
250
250
-
defer out.deinit();
251
251
-
try out.writer.writeAll("{\"type\":\"plc_operation\"");
252
252
-
try writePlcField(allocator, &out.writer, "verificationMethods", input, last);
253
253
-
try writePlcField(allocator, &out.writer, "rotationKeys", input, last);
254
254
-
try writePlcField(allocator, &out.writer, "alsoKnownAs", input, last);
255
255
-
try writePlcField(allocator, &out.writer, "services", input, last);
256
256
-
try out.writer.print(",\"prev\":{f}}}", .{std.json.fmt(prev, .{})});
257
257
-
return out.toOwnedSlice();
258
258
-
}
259
259
-
260
260
-
fn writePlcField(
261
261
-
allocator: std.mem.Allocator,
262
262
-
writer: anytype,
263
263
-
name: []const u8,
264
264
-
input: std.json.ObjectMap,
265
265
-
last: std.json.ObjectMap,
266
266
-
) !void {
267
267
-
const value = input.get(name) orelse last.get(name) orelse return error.InvalidPlcOperation;
268
268
-
try writer.print(",{f}:{s}", .{ std.json.fmt(name, .{}), try stringifyJson(allocator, value) });
269
269
-
}
270
270
-
271
271
-
fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
272
272
-
const cbor_value = try jsonToCbor(allocator, value);
273
273
-
const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value);
274
274
-
const cid = try zat.cbor.Cid.forDagCbor(allocator, encoded);
275
275
-
return zat.multibase.base32lower.encode(allocator, cid.raw);
276
276
-
}
277
277
-
278
278
-
fn jsonToCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value {
279
279
-
return switch (value) {
280
280
-
.null => .null,
281
281
-
.bool => |boolean| .{ .boolean = boolean },
282
282
-
.integer => |integer| if (integer >= 0)
283
283
-
.{ .unsigned = @intCast(integer) }
284
284
-
else
285
285
-
.{ .negative = integer },
286
286
-
.float => return error.InvalidDagCbor,
287
287
-
.number_string => return error.InvalidDagCbor,
288
288
-
.string => |string| .{ .text = string },
289
289
-
.array => |array| blk: {
290
290
-
const items = try allocator.alloc(zat.cbor.Value, array.items.len);
291
291
-
for (array.items, 0..) |item, idx| items[idx] = try jsonToCbor(allocator, item);
292
292
-
break :blk .{ .array = items };
293
293
-
},
294
294
-
.object => |object| blk: {
295
295
-
const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count());
296
296
-
var it = object.iterator();
297
297
-
var idx: usize = 0;
298
298
-
while (it.next()) |entry| : (idx += 1) {
299
299
-
entries[idx] = .{
300
300
-
.key = entry.key_ptr.*,
301
301
-
.value = try jsonToCbor(allocator, entry.value_ptr.*),
302
302
-
};
303
303
-
}
304
304
-
break :blk .{ .map = entries };
305
305
-
},
306
306
-
};
307
307
-
}
308
308
-
309
309
-
fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
310
310
-
var out: std.Io.Writer.Allocating = .init(allocator);
311
311
-
defer out.deinit();
312
312
-
for (did) |c| switch (c) {
313
313
-
':' => try out.writer.writeAll("%3A"),
314
314
-
else => try out.writer.writeByte(c),
315
315
-
};
316
316
-
return out.toOwnedSlice();
317
317
-
}
318
318
-
319
319
-
fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool {
320
320
-
const actual = switch (value) {
321
321
-
.object => |object| switch (object.get(key) orelse return false) {
322
322
-
.string => |string| string,
323
323
-
else => return false,
324
324
-
},
325
325
-
else => return false,
326
326
-
};
327
327
-
return std.mem.eql(u8, actual, expected);
328
328
-
}
329
329
-
330
330
-
fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool {
331
331
-
const items = switch (value orelse return false) {
332
332
-
.array => |array| array.items,
333
333
-
else => return false,
334
334
-
};
335
335
-
for (items) |item| switch (item) {
336
336
-
.string => |string| if (std.mem.eql(u8, string, expected)) return true,
337
337
-
else => {},
338
338
-
};
339
339
-
return false;
340
340
-
}
341
341
-
342
342
-
fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool {
343
343
-
const items = switch (value orelse return false) {
344
344
-
.array => |array| array.items,
345
345
-
else => return false,
346
346
-
};
347
347
-
if (items.len == 0) return false;
348
348
-
return switch (items[0]) {
349
349
-
.string => |string| std.mem.eql(u8, string, expected),
350
350
-
else => false,
351
351
-
};
352
352
-
}
353
353
-
354
354
-
fn makeCode(out: *[11]u8) []const u8 {
355
355
-
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
356
356
-
var random: [10]u8 = undefined;
357
357
-
store.randomBytes(&random);
358
358
-
for (random, 0..) |byte, idx| {
359
359
-
const write_idx = if (idx < 5) idx else idx + 1;
360
360
-
out[write_idx] = alphabet[byte % alphabet.len];
361
361
-
}
362
362
-
out[5] = '-';
363
363
-
return out[0..];
364
364
-
}
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const config = @import("../core/config.zig");
4
4
-
const http_api = @import("../http/api.zig");
5
5
-
const store = @import("../storage/store.zig");
6
6
-
const zat = @import("zat");
7
7
-
8
8
-
const http = std.http;
9
9
-
const request_uri_prefix = "urn:ietf:params:oauth:request_uri:";
10
10
-
const par_expires_in: i64 = 600;
11
11
-
const token_expires_in: i64 = 3600;
12
12
-
13
13
-
const ClientDisplayInfo = struct {
14
14
-
name: []const u8,
15
15
-
uri: []const u8,
16
16
-
};
17
17
-
18
18
-
pub fn protectedResource(request: *http.Server.Request) !void {
19
19
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
20
20
-
defer arena.deinit();
21
21
-
var resource_buf: [512]u8 = undefined;
22
22
-
const requested_resource = http_api.queryParam(request.head.target, "resource", &resource_buf);
23
23
-
const resource = if (requested_resource) |requested|
24
24
-
if (std.mem.eql(u8, trimTrailingSlash(requested), config.publicUrl())) requested else config.publicUrl()
25
25
-
else
26
26
-
config.publicUrl();
27
27
-
const body = try std.fmt.allocPrint(arena.allocator(),
28
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
29
-
, .{ std.json.fmt(resource, .{}), std.json.fmt(config.publicUrl(), .{}) });
30
30
-
try http_api.json(request, .ok, body);
31
31
-
}
32
32
-
33
33
-
pub fn authorizationServer(request: *http.Server.Request) !void {
34
34
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
35
35
-
defer arena.deinit();
36
36
-
const allocator = arena.allocator();
37
37
-
const issuer = config.publicUrl();
38
38
-
const body = try std.fmt.allocPrint(allocator,
39
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
40
-
, .{
41
41
-
std.json.fmt(issuer, .{}),
42
42
-
std.json.fmt(issuer, .{}),
43
43
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/jwks"), .{}),
44
44
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/authorize"), .{}),
45
45
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/token"), .{}),
46
46
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/revoke"), .{}),
47
47
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/introspect"), .{}),
48
48
-
std.json.fmt(try joinUrl(allocator, issuer, "/oauth/par"), .{}),
49
49
-
});
50
50
-
try http_api.json(request, .ok, body);
51
51
-
}
52
52
-
53
53
-
pub fn jwks(request: *http.Server.Request) !void {
54
54
-
try http_api.json(request, .ok, "{\"keys\":[]}");
55
55
-
}
56
56
-
57
57
-
pub fn par(request: *http.Server.Request) !void {
58
58
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
59
59
-
defer arena.deinit();
60
60
-
const allocator = arena.allocator();
61
61
-
const content_type = http_api.headerValue(request, "content-type") orelse "";
62
62
-
const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024);
63
63
-
if (isJsonContent(content_type)) {
64
64
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
65
65
-
return parWithParams(request, allocator, JsonParams{ .json = parsed.value });
66
66
-
}
67
67
-
return parWithParams(request, allocator, Form{ .body = body });
68
68
-
}
69
69
-
70
70
-
fn parWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
71
71
-
const response_type = try requireParam(request, params, allocator, "response_type");
72
72
-
if (!std.mem.eql(u8, response_type, "code")) return oauthError(request, .bad_request, "unsupported_response_type", "Only response_type=code is supported");
73
73
-
const client_id = try requireParam(request, params, allocator, "client_id");
74
74
-
try requireClientAuth(request, allocator, params, client_id);
75
75
-
const redirect_uri = try requireParam(request, params, allocator, "redirect_uri");
76
76
-
const code_challenge = try requireParam(request, params, allocator, "code_challenge");
77
77
-
const code_challenge_method = try requireParam(request, params, allocator, "code_challenge_method");
78
78
-
if (!std.mem.eql(u8, code_challenge_method, "S256")) return oauthError(request, .bad_request, "invalid_request", "Only S256 PKCE is supported");
79
79
-
const scope = try params.value(allocator, "scope") orelse "atproto";
80
80
-
try validateScope(request, scope);
81
81
-
const state = try params.value(allocator, "state") orelse "";
82
82
-
const login_hint = try params.value(allocator, "login_hint");
83
83
-
const dpop_jkt = try params.value(allocator, "dpop_jkt");
84
84
-
if (try params.value(allocator, "prompt")) |prompt| {
85
85
-
if (!validPrompt(prompt)) return oauthError(request, .bad_request, "invalid_request", "Invalid prompt");
86
86
-
}
87
87
-
88
88
-
const request_id = try store.randomToken(allocator, "", 16);
89
89
-
const expires_at = now() + par_expires_in;
90
90
-
try store.putOAuthRequest(request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at);
91
91
-
const request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, request_id });
92
92
-
const response = try std.fmt.allocPrint(allocator, "{{\"expires_in\":{d},\"request_uri\":{f}}}", .{ par_expires_in, std.json.fmt(request_uri, .{}) });
93
93
-
try http_api.json(request, .created, response);
94
94
-
}
95
95
-
96
96
-
pub fn authorizeGet(request: *http.Server.Request) !void {
97
97
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
98
98
-
defer arena.deinit();
99
99
-
const allocator = arena.allocator();
100
100
-
var request_id_buf: [512]u8 = undefined;
101
101
-
const request_id = requestIdFromTarget(request.head.target, &request_id_buf) orelse return oauthError(request, .bad_request, "invalid_request", "Missing request_uri");
102
102
-
const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri");
103
103
-
if (oauth_request.expires_at < now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri");
104
104
-
const client = clientDisplayInfo(allocator, oauth_request.client_id) catch ClientDisplayInfo{ .name = oauth_request.client_id, .uri = oauth_request.client_id };
105
105
-
if (acceptsJson(request)) {
106
106
-
const body = try authRequestInfoJson(allocator, oauth_request, client);
107
107
-
return http_api.json(request, .ok, body);
108
108
-
}
109
109
-
const scopes_html = try scopesHtml(allocator, oauth_request.scope);
110
110
-
const html = try std.fmt.allocPrint(allocator,
111
111
-
\\<!doctype html>
112
112
-
\\<html lang="en">
113
113
-
\\<head>
114
114
-
\\<meta charset="utf-8">
115
115
-
\\<meta name="viewport" content="width=device-width, initial-scale=1">
116
116
-
\\<title>Sign in to zds</title>
117
117
-
\\<style>
118
118
-
\\:root{{color-scheme:light dark;--bg:#f8fafc;--panel:#fff;--text:#111827;--muted:#64748b;--border:#cbd5e1;--accent:#0f766e;--accent-ink:#fff;--danger:#b91c1c}}
119
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
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
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
122
-
\\.brand{{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin:0 0 18px}}
123
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
124
-
\\input{{width:100%;font:inherit;color:var(--text);background:transparent;border:1px solid var(--border);border-radius:8px;padding:11px 12px}}
125
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
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
127
-
\\.client strong{{display:block}} \\.client span{{display:block;color:var(--muted);overflow-wrap:anywhere;font-size:14px}}
128
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
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
130
-
\\.actions{{display:grid;grid-template-columns:1fr 1fr;gap:12px}} \\.deny{{background:transparent;color:var(--danger);border:1px solid var(--border)}}
131
131
-
\\.foot{{font-size:13px;margin-top:18px;color:var(--muted)}}
132
132
-
\\</style>
133
133
-
\\</head>
134
134
-
\\<body>
135
135
-
\\<main class="shell">
136
136
-
\\<p class="brand">zds</p>
137
137
-
\\<h1>authorize {s}</h1>
138
138
-
\\<p>Sign in to your AT Protocol account and review the permissions this app is requesting.</p>
139
139
-
\\<section class="client"><strong>{s}</strong><span>{s}</span></section>
140
140
-
\\<h2>requested access</h2>
141
141
-
\\<ul class="scopes">{s}</ul>
142
142
-
\\<form method="post" action="/oauth/authorize">
143
143
-
\\<input type="hidden" name="request_uri" value="{s}{s}">
144
144
-
\\<label for="username">Account</label>
145
145
-
\\<input id="username" name="username" autocomplete="username" placeholder="bufo.uk" value="{s}" autofocus>
146
146
-
\\<label for="password">Password</label>
147
147
-
\\<input id="password" name="password" type="password" autocomplete="current-password">
148
148
-
\\<div class="actions">
149
149
-
\\<button class="deny" name="deny" value="1" formnovalidate>Deny</button>
150
150
-
\\<button>Authorize</button>
151
151
-
\\</div>
152
152
-
\\</form>
153
153
-
\\<p class="foot">This is the authorization server for pds.zat.dev.</p>
154
154
-
\\</main>
155
155
-
\\</body>
156
156
-
\\</html>
157
157
-
, .{
158
158
-
try htmlEscape(allocator, client.name),
159
159
-
try htmlEscape(allocator, client.name),
160
160
-
try htmlEscape(allocator, client.uri),
161
161
-
scopes_html,
162
162
-
request_uri_prefix,
163
163
-
oauth_request.request_id,
164
164
-
try htmlEscape(allocator, oauth_request.login_hint orelse ""),
165
165
-
});
166
166
-
try respondHtml(request, .ok, html);
167
167
-
}
168
168
-
169
169
-
pub fn authorizePost(request: *http.Server.Request) !void {
170
170
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
171
171
-
defer arena.deinit();
172
172
-
const allocator = arena.allocator();
173
173
-
const content_type = http_api.headerValue(request, "content-type") orelse "";
174
174
-
const wants_json = acceptsJson(request);
175
175
-
const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024);
176
176
-
177
177
-
var request_uri: []const u8 = undefined;
178
178
-
var identifier: []const u8 = undefined;
179
179
-
var password: []const u8 = undefined;
180
180
-
var denied = false;
181
181
-
if (std.mem.indexOf(u8, content_type, "application/json") != null) {
182
182
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
183
183
-
request_uri = zat.json.getString(parsed.value, "request_uri") orelse return oauthError(request, .bad_request, "invalid_request", "Missing request_uri");
184
184
-
denied = zat.json.getBool(parsed.value, "deny") orelse false;
185
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
186
-
password = zat.json.getString(parsed.value, "password") orelse return oauthError(request, .bad_request, "invalid_request", "Missing password");
187
187
-
} else {
188
188
-
const form = Form{ .body = body };
189
189
-
request_uri = try requireForm(request, form, allocator, "request_uri");
190
190
-
denied = (try form.value(allocator, "deny")) != null;
191
191
-
if (denied) {
192
192
-
const request_id = requestIdFromUri(request_uri) orelse return oauthError(request, .bad_request, "invalid_request", "Invalid request_uri");
193
193
-
const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri");
194
194
-
const redirect_uri = try authorizationErrorRedirect(allocator, oauth_request.redirect_uri, "access_denied", "Authorization denied", oauth_request.state);
195
195
-
return redirectTo(request, redirect_uri);
196
196
-
}
197
197
-
identifier = (try form.value(allocator, "username")) orelse try requireForm(request, form, allocator, "identifier");
198
198
-
password = try requireForm(request, form, allocator, "password");
199
199
-
}
200
200
-
if (denied) return oauthError(request, .forbidden, "access_denied", "Authorization denied");
201
201
-
202
202
-
const request_id = requestIdFromUri(request_uri) orelse return oauthError(request, .bad_request, "invalid_request", "Invalid request_uri");
203
203
-
const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri");
204
204
-
if (oauth_request.expires_at < now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri");
205
205
-
const account = (try store.findAccount(allocator, identifier)) orelse return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password");
206
206
-
if (!auth.passwordMatches(account, password)) return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password");
207
207
-
208
208
-
const code = try store.randomToken(allocator, "", 16);
209
209
-
try store.authorizeOAuthRequest(oauth_request.request_id, account.did, code);
210
210
-
const redirect_uri = try authorizationRedirect(allocator, oauth_request.redirect_uri, code, oauth_request.state);
211
211
-
if (wants_json or std.mem.indexOf(u8, content_type, "application/json") != null) {
212
212
-
const response = try std.fmt.allocPrint(allocator, "{{\"redirect_uri\":{f}}}", .{std.json.fmt(redirect_uri, .{})});
213
213
-
return http_api.json(request, .ok, response);
214
214
-
}
215
215
-
try redirectTo(request, redirect_uri);
216
216
-
}
217
217
-
218
218
-
pub fn token(request: *http.Server.Request) !void {
219
219
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
220
220
-
defer arena.deinit();
221
221
-
const allocator = arena.allocator();
222
222
-
const content_type = http_api.headerValue(request, "content-type") orelse "";
223
223
-
const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024);
224
224
-
if (isJsonContent(content_type)) {
225
225
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
226
226
-
return tokenWithParams(request, allocator, JsonParams{ .json = parsed.value });
227
227
-
}
228
228
-
return tokenWithParams(request, allocator, Form{ .body = body });
229
229
-
}
230
230
-
231
231
-
fn tokenWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
232
232
-
const grant_type = try requireParam(request, params, allocator, "grant_type");
233
233
-
if (std.mem.eql(u8, grant_type, "authorization_code")) return authorizationCodeToken(request, allocator, params);
234
234
-
if (std.mem.eql(u8, grant_type, "refresh_token")) return refreshToken(request, allocator, params);
235
235
-
return oauthError(request, .bad_request, "unsupported_grant_type", "Unsupported grant_type");
236
236
-
}
237
237
-
238
238
-
pub fn introspect(request: *http.Server.Request) !void {
239
239
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
240
240
-
defer arena.deinit();
241
241
-
const allocator = arena.allocator();
242
242
-
const content_type = http_api.headerValue(request, "content-type") orelse "";
243
243
-
const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024);
244
244
-
if (isJsonContent(content_type)) {
245
245
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
246
246
-
return introspectWithParams(request, allocator, JsonParams{ .json = parsed.value });
247
247
-
}
248
248
-
return introspectWithParams(request, allocator, Form{ .body = body });
249
249
-
}
250
250
-
251
251
-
fn introspectWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
252
252
-
const token_text = try requireParam(request, params, allocator, "token");
253
253
-
const row = try store.getOAuthToken(allocator, token_text);
254
254
-
if (row == null or row.?.revoked or row.?.expires_at < now()) return http_api.json(request, .ok, "{\"active\":false}");
255
255
-
const response = try std.fmt.allocPrint(allocator, "{{\"active\":true,\"sub\":{f},\"client_id\":{f},\"scope\":{f},\"exp\":{d}}}", .{
256
256
-
std.json.fmt(row.?.did, .{}),
257
257
-
std.json.fmt(row.?.client_id, .{}),
258
258
-
std.json.fmt(row.?.scope, .{}),
259
259
-
row.?.expires_at,
260
260
-
});
261
261
-
try http_api.json(request, .ok, response);
262
262
-
}
263
263
-
264
264
-
pub fn revoke(request: *http.Server.Request) !void {
265
265
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
266
266
-
defer arena.deinit();
267
267
-
const allocator = arena.allocator();
268
268
-
const content_type = http_api.headerValue(request, "content-type") orelse "";
269
269
-
const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024);
270
270
-
if (isJsonContent(content_type)) {
271
271
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
272
272
-
return revokeWithParams(request, allocator, JsonParams{ .json = parsed.value });
273
273
-
}
274
274
-
return revokeWithParams(request, allocator, Form{ .body = body });
275
275
-
}
276
276
-
277
277
-
fn revokeWithParams(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
278
278
-
const token_text = try requireParam(request, params, allocator, "token");
279
279
-
try store.revokeOAuthToken(token_text);
280
280
-
try http_api.json(request, .ok, "{}");
281
281
-
}
282
282
-
283
283
-
fn authorizationCodeToken(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
284
284
-
const code = try requireParam(request, params, allocator, "code");
285
285
-
const redirect_uri = try requireParam(request, params, allocator, "redirect_uri");
286
286
-
const code_verifier = try requireParam(request, params, allocator, "code_verifier");
287
287
-
const client_id = try requireParam(request, params, allocator, "client_id");
288
288
-
try requireClientAuth(request, allocator, params, client_id);
289
289
-
const oauth_request = (try store.consumeOAuthCode(allocator, code)) orelse return oauthError(request, .bad_request, "invalid_grant", "Invalid code");
290
290
-
if (!std.mem.eql(u8, redirect_uri, oauth_request.redirect_uri) or !std.mem.eql(u8, client_id, oauth_request.client_id)) {
291
291
-
return oauthError(request, .bad_request, "invalid_grant", "Mismatched token request");
292
292
-
}
293
293
-
if (!try pkceMatches(allocator, code_verifier, oauth_request.code_challenge)) {
294
294
-
return oauthError(request, .bad_request, "invalid_grant", "Invalid PKCE verifier");
295
295
-
}
296
296
-
const did = oauth_request.sub orelse return oauthError(request, .bad_request, "invalid_grant", "Code was not authorized");
297
297
-
const account = (try store.findAccount(allocator, did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found");
298
298
-
try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null);
299
299
-
}
300
300
-
301
301
-
fn refreshToken(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype) !void {
302
302
-
const refresh = try requireParam(request, params, allocator, "refresh_token");
303
303
-
const client_id = try requireParam(request, params, allocator, "client_id");
304
304
-
try requireClientAuth(request, allocator, params, client_id);
305
305
-
const token_row = (try store.getOAuthToken(allocator, refresh)) orelse return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token");
306
306
-
if (token_row.revoked or token_row.expires_at < now() or !std.mem.eql(u8, token_row.client_id, client_id)) {
307
307
-
return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token");
308
308
-
}
309
309
-
const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found");
310
310
-
try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh);
311
311
-
}
312
312
-
313
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
314
-
if (revoke_old) |old| try store.revokeOAuthToken(old);
315
315
-
const access = try auth.createSessionJwt(allocator, "access", account);
316
316
-
const refresh = try auth.createSessionJwt(allocator, "refresh", account);
317
317
-
const expires_at = now() + token_expires_in;
318
318
-
try store.putOAuthToken(account.did, client_id, scope, access, refresh, expires_at);
319
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
320
-
std.json.fmt(access, .{}),
321
321
-
std.json.fmt(refresh, .{}),
322
322
-
token_expires_in,
323
323
-
std.json.fmt(scope, .{}),
324
324
-
std.json.fmt(account.did, .{}),
325
325
-
});
326
326
-
try http_api.json(request, .ok, body);
327
327
-
}
328
328
-
329
329
-
fn requireClientAuth(request: *http.Server.Request, allocator: std.mem.Allocator, params: anytype, client_id: []const u8) !void {
330
330
-
const metadata = fetchJson(allocator, client_id, 256 * 1024) catch {
331
331
-
return oauthError(request, .bad_request, "invalid_client", "Could not fetch client metadata");
332
332
-
};
333
333
-
const method = zat.json.getString(metadata.value, "token_endpoint_auth_method") orelse "none";
334
334
-
if (std.mem.eql(u8, method, "none")) return;
335
335
-
if (!std.mem.eql(u8, method, "private_key_jwt")) {
336
336
-
return oauthError(request, .bad_request, "invalid_client", "Unsupported client authentication method");
337
337
-
}
338
338
-
339
339
-
const assertion_type = try params.value(allocator, "client_assertion_type") orelse {
340
340
-
return oauthError(request, .bad_request, "invalid_client", "Missing client assertion type");
341
341
-
};
342
342
-
if (!std.mem.eql(u8, assertion_type, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")) {
343
343
-
return oauthError(request, .bad_request, "invalid_client", "Unsupported client assertion type");
344
344
-
}
345
345
-
const assertion = try params.value(allocator, "client_assertion") orelse {
346
346
-
return oauthError(request, .bad_request, "invalid_client", "Missing client assertion");
347
347
-
};
348
348
-
verifyClientAssertion(allocator, metadata.value, client_id, assertion) catch {
349
349
-
return oauthError(request, .bad_request, "invalid_client", "Invalid client assertion");
350
350
-
};
351
351
-
}
352
352
-
353
353
-
fn fetchJson(allocator: std.mem.Allocator, url: []const u8, max_response_size: usize) !std.json.Parsed(std.json.Value) {
354
354
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
355
355
-
defer transport.deinit();
356
356
-
const result = try transport.fetch(.{
357
357
-
.url = url,
358
358
-
.method = .GET,
359
359
-
.accept = "application/json",
360
360
-
.max_response_size = max_response_size,
361
361
-
});
362
362
-
const status_code = @intFromEnum(result.status);
363
363
-
if (status_code < 200 or status_code >= 300) return error.HttpStatus;
364
364
-
return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{});
365
365
-
}
366
366
-
367
367
-
fn verifyClientAssertion(allocator: std.mem.Allocator, metadata: std.json.Value, client_id: []const u8, assertion: []const u8) !void {
368
368
-
var parts = std.mem.splitScalar(u8, assertion, '.');
369
369
-
const header_part = parts.next() orelse return error.InvalidJwt;
370
370
-
const payload_part = parts.next() orelse return error.InvalidJwt;
371
371
-
const signature_part = parts.next() orelse return error.InvalidJwt;
372
372
-
if (parts.next() != null) return error.InvalidJwt;
373
373
-
const signing_input_len = header_part.len + 1 + payload_part.len;
374
374
-
if (assertion.len < signing_input_len or assertion[header_part.len] != '.') return error.InvalidJwt;
375
375
-
const signing_input = assertion[0..signing_input_len];
376
376
-
377
377
-
const header_json = try zat.jwt.base64UrlDecode(allocator, header_part);
378
378
-
defer allocator.free(header_json);
379
379
-
const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{});
380
380
-
defer header.deinit();
381
381
-
const alg = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt;
382
382
-
if (!std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, alg, "ES256K")) return error.InvalidJwt;
383
383
-
const kid = zat.json.getString(header.value, "kid");
384
384
-
385
385
-
const payload_json = try zat.jwt.base64UrlDecode(allocator, payload_part);
386
386
-
defer allocator.free(payload_json);
387
387
-
const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{});
388
388
-
defer payload.deinit();
389
389
-
const iss = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt;
390
390
-
const sub = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt;
391
391
-
const aud = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt;
392
392
-
const exp = zat.json.getInt(payload.value, "exp") orelse return error.InvalidJwt;
393
393
-
_ = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt;
394
394
-
if (!std.mem.eql(u8, iss, client_id) or !std.mem.eql(u8, sub, client_id)) return error.InvalidJwt;
395
395
-
if (!std.mem.eql(u8, aud, config.publicUrl())) return error.InvalidJwt;
396
396
-
if (exp < now()) return error.InvalidJwt;
397
397
-
398
398
-
const jwks_uri = zat.json.getString(metadata, "jwks_uri") orelse return error.InvalidClientMetadata;
399
399
-
const jwks_doc = try fetchJson(allocator, jwks_uri, 256 * 1024);
400
400
-
const public_key = try clientPublicKeyFromJwks(allocator, jwks_doc.value, kid, alg);
401
401
-
defer allocator.free(public_key);
402
402
-
const signature = try zat.jwt.base64UrlDecode(allocator, signature_part);
403
403
-
defer allocator.free(signature);
404
404
-
if (std.mem.eql(u8, alg, "ES256")) {
405
405
-
try zat.jwt.verifyP256(signing_input, signature, public_key);
406
406
-
} else {
407
407
-
try zat.jwt.verifySecp256k1(signing_input, signature, public_key);
408
408
-
}
409
409
-
}
410
410
-
411
411
-
fn clientPublicKeyFromJwks(allocator: std.mem.Allocator, jwks_doc: std.json.Value, kid: ?[]const u8, alg: []const u8) ![]u8 {
412
412
-
const keys = zat.json.getArray(jwks_doc, "keys") orelse return error.InvalidJwks;
413
413
-
for (keys) |key| {
414
414
-
if (kid) |wanted| {
415
415
-
const key_kid = zat.json.getString(key, "kid") orelse continue;
416
416
-
if (!std.mem.eql(u8, key_kid, wanted)) continue;
417
417
-
}
418
418
-
if (!std.mem.eql(u8, zat.json.getString(key, "kty") orelse "", "EC")) continue;
419
419
-
const crv = zat.json.getString(key, "crv") orelse continue;
420
420
-
if (std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, crv, "P-256")) continue;
421
421
-
if (std.mem.eql(u8, alg, "ES256K") and !std.mem.eql(u8, crv, "secp256k1")) continue;
422
422
-
const x = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "x") orelse return error.InvalidJwks);
423
423
-
defer allocator.free(x);
424
424
-
const y = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "y") orelse return error.InvalidJwks);
425
425
-
defer allocator.free(y);
426
426
-
if (x.len != 32 or y.len != 32) return error.InvalidJwks;
427
427
-
const public_key = try allocator.alloc(u8, 33);
428
428
-
public_key[0] = if ((y[31] & 1) == 1) 0x03 else 0x02;
429
429
-
@memcpy(public_key[1..33], x);
430
430
-
return public_key;
431
431
-
}
432
432
-
return error.KeyNotFound;
433
433
-
}
434
434
-
435
435
-
const Form = struct {
436
436
-
body: []const u8,
437
437
-
438
438
-
fn value(self: Form, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 {
439
439
-
var params = std.mem.splitScalar(u8, self.body, '&');
440
440
-
while (params.next()) |param| {
441
441
-
const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue;
442
442
-
var name_buf: [256]u8 = undefined;
443
443
-
const decoded_name = http_api.percentDecode(param[0..eq], &name_buf) catch continue;
444
444
-
if (!std.mem.eql(u8, decoded_name, name)) continue;
445
445
-
const out = try allocator.alloc(u8, param.len - eq);
446
446
-
return try http_api.percentDecode(param[eq + 1 ..], out);
447
447
-
}
448
448
-
return null;
449
449
-
}
450
450
-
};
451
451
-
452
452
-
const JsonParams = struct {
453
453
-
json: std.json.Value,
454
454
-
455
455
-
fn value(self: JsonParams, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 {
456
456
-
const string = zat.json.getString(self.json, name) orelse return null;
457
457
-
return try allocator.dupe(u8, string);
458
458
-
}
459
459
-
};
460
460
-
461
461
-
fn requireForm(request: *http.Server.Request, form: Form, allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
462
462
-
return requireParam(request, form, allocator, name);
463
463
-
}
464
464
-
465
465
-
fn requireParam(request: *http.Server.Request, params: anytype, allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
466
466
-
return (try params.value(allocator, name)) orelse {
467
467
-
try oauthError(request, .bad_request, "invalid_request", "Missing required field");
468
468
-
return error.MissingFormField;
469
469
-
};
470
470
-
}
471
471
-
472
472
-
fn isJsonContent(content_type: []const u8) bool {
473
473
-
return std.mem.indexOf(u8, content_type, "application/json") != null;
474
474
-
}
475
475
-
476
476
-
fn requestIdFromTarget(target: []const u8, out: []u8) ?[]const u8 {
477
477
-
const value = http_api.queryParam(target, "request_uri", out) orelse return null;
478
478
-
return requestIdFromUri(value);
479
479
-
}
480
480
-
481
481
-
fn requestIdFromUri(request_uri: []const u8) ?[]const u8 {
482
482
-
if (!std.mem.startsWith(u8, request_uri, request_uri_prefix)) return null;
483
483
-
return request_uri[request_uri_prefix.len..];
484
484
-
}
485
485
-
486
486
-
fn authorizationRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, code: []const u8, state: []const u8) ![]const u8 {
487
487
-
const sep: []const u8 = if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) "?" else "&";
488
488
-
const code_enc = try percentEncode(allocator, code);
489
489
-
const state_enc = try percentEncode(allocator, state);
490
490
-
const iss_enc = try percentEncode(allocator, config.publicUrl());
491
491
-
return std.fmt.allocPrint(allocator, "{s}{s}code={s}&state={s}&iss={s}", .{ redirect_uri, sep, code_enc, state_enc, iss_enc });
492
492
-
}
493
493
-
494
494
-
fn authorizationErrorRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, err: []const u8, description: []const u8, state: []const u8) ![]const u8 {
495
495
-
const sep: []const u8 = if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) "?" else "&";
496
496
-
return std.fmt.allocPrint(allocator, "{s}{s}error={s}&error_description={s}&state={s}&iss={s}", .{
497
497
-
redirect_uri,
498
498
-
sep,
499
499
-
try percentEncode(allocator, err),
500
500
-
try percentEncode(allocator, description),
501
501
-
try percentEncode(allocator, state),
502
502
-
try percentEncode(allocator, config.publicUrl()),
503
503
-
});
504
504
-
}
505
505
-
506
506
-
fn redirectTo(request: *http.Server.Request, location: []const u8) !void {
507
507
-
try request.respond("", .{
508
508
-
.status = .see_other,
509
509
-
.extra_headers = &[_]http.Header{
510
510
-
.{ .name = "location", .value = location },
511
511
-
.{ .name = "access-control-allow-origin", .value = "*" },
512
512
-
.{ .name = "connection", .value = "close" },
513
513
-
},
514
514
-
});
515
515
-
}
516
516
-
517
517
-
fn clientDisplayInfo(allocator: std.mem.Allocator, client_id: []const u8) !ClientDisplayInfo {
518
518
-
const metadata = try fetchJson(allocator, client_id, 256 * 1024);
519
519
-
const name = zat.json.getString(metadata.value, "client_name") orelse
520
520
-
zat.json.getString(metadata.value, "client_id") orelse
521
521
-
client_id;
522
522
-
const uri = zat.json.getString(metadata.value, "client_uri") orelse client_id;
523
523
-
return .{
524
524
-
.name = try allocator.dupe(u8, name),
525
525
-
.uri = try allocator.dupe(u8, uri),
526
526
-
};
527
527
-
}
528
528
-
529
529
-
fn authRequestInfoJson(allocator: std.mem.Allocator, oauth_request: store.OAuthRequest, client: ClientDisplayInfo) ![]const u8 {
530
530
-
const Response = struct {
531
531
-
request_uri: []const u8,
532
532
-
client_id: []const u8,
533
533
-
client_name: []const u8,
534
534
-
client_uri: []const u8,
535
535
-
scope: []const u8,
536
536
-
login_hint: []const u8,
537
537
-
};
538
538
-
var out: std.Io.Writer.Allocating = .init(allocator);
539
539
-
defer out.deinit();
540
540
-
try std.json.Stringify.value(Response{
541
541
-
.request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, oauth_request.request_id }),
542
542
-
.client_id = oauth_request.client_id,
543
543
-
.client_name = client.name,
544
544
-
.client_uri = client.uri,
545
545
-
.scope = oauth_request.scope,
546
546
-
.login_hint = oauth_request.login_hint orelse "",
547
547
-
}, .{}, &out.writer);
548
548
-
return out.toOwnedSlice();
549
549
-
}
550
550
-
551
551
-
fn scopesHtml(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 {
552
552
-
var out: std.Io.Writer.Allocating = .init(allocator);
553
553
-
defer out.deinit();
554
554
-
var scopes = std.mem.splitScalar(u8, scope_text, ' ');
555
555
-
var any = false;
556
556
-
while (scopes.next()) |scope| {
557
557
-
if (scope.len == 0) continue;
558
558
-
any = true;
559
559
-
const display = scopeDisplay(scope);
560
560
-
try out.writer.print(
561
561
-
"<li class=\"scope\"><strong>{s}</strong><span>{s}</span><span class=\"raw\">{s}</span></li>",
562
562
-
.{
563
563
-
try htmlEscape(allocator, display.name),
564
564
-
try htmlEscape(allocator, display.description),
565
565
-
try htmlEscape(allocator, scope),
566
566
-
},
567
567
-
);
568
568
-
}
569
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
570
-
return out.toOwnedSlice();
571
571
-
}
572
572
-
573
573
-
const ScopeDisplay = struct {
574
574
-
name: []const u8,
575
575
-
description: []const u8,
576
576
-
};
577
577
-
578
578
-
fn scopeDisplay(scope: []const u8) ScopeDisplay {
579
579
-
if (std.mem.eql(u8, scope, "atproto")) return .{ .name = "AT Protocol access", .description = "Read and write AT Protocol data for this account." };
580
580
-
if (std.mem.eql(u8, scope, "transition:generic")) return .{ .name = "Legacy app access", .description = "Use generic transition APIs during OAuth migration." };
581
581
-
if (std.mem.eql(u8, scope, "transition:email")) return .{ .name = "Email access", .description = "Read account email information exposed by transition APIs." };
582
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
583
-
if (std.mem.startsWith(u8, scope, "repo:")) return .{ .name = "Repository access", .description = "Access records in this account repository." };
584
584
-
if (std.mem.startsWith(u8, scope, "blob:")) return .{ .name = "Blob access", .description = "Access uploaded media and blob data." };
585
585
-
if (std.mem.startsWith(u8, scope, "rpc:")) return .{ .name = "XRPC access", .description = "Call AT Protocol API methods through this account." };
586
586
-
if (std.mem.startsWith(u8, scope, "account:")) return .{ .name = "Account access", .description = "Manage account-level settings requested by the app." };
587
587
-
if (std.mem.startsWith(u8, scope, "identity:")) return .{ .name = "Identity access", .description = "Manage identity information requested by the app." };
588
588
-
return .{ .name = "Requested scope", .description = "A scope requested by this OAuth client." };
589
589
-
}
590
590
-
591
591
-
fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 {
592
592
-
var out: std.Io.Writer.Allocating = .init(allocator);
593
593
-
defer out.deinit();
594
594
-
for (value) |byte| switch (byte) {
595
595
-
'&' => try out.writer.writeAll("&"),
596
596
-
'<' => try out.writer.writeAll("<"),
597
597
-
'>' => try out.writer.writeAll(">"),
598
598
-
'"' => try out.writer.writeAll("""),
599
599
-
'\'' => try out.writer.writeAll("'"),
600
600
-
else => try out.writer.writeByte(byte),
601
601
-
};
602
602
-
return out.toOwnedSlice();
603
603
-
}
604
604
-
605
605
-
fn pkceMatches(allocator: std.mem.Allocator, verifier: []const u8, challenge: []const u8) !bool {
606
606
-
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
607
607
-
std.crypto.hash.sha2.Sha256.hash(verifier, &digest, .{});
608
608
-
const encoded = try zat.jwt.base64UrlEncode(allocator, &digest);
609
609
-
return std.mem.eql(u8, encoded, challenge);
610
610
-
}
611
611
-
612
612
-
fn validPrompt(prompt: []const u8) bool {
613
613
-
var parts = std.mem.splitScalar(u8, prompt, ' ');
614
614
-
while (parts.next()) |part| {
615
615
-
if (part.len == 0) continue;
616
616
-
if (!(std.mem.eql(u8, part, "none") or
617
617
-
std.mem.eql(u8, part, "login") or
618
618
-
std.mem.eql(u8, part, "consent") or
619
619
-
std.mem.eql(u8, part, "select_account") or
620
620
-
std.mem.eql(u8, part, "create"))) return false;
621
621
-
}
622
622
-
return true;
623
623
-
}
624
624
-
625
625
-
fn validateScope(request: *http.Server.Request, scope_text: []const u8) !void {
626
626
-
var scopes = std.mem.splitScalar(u8, scope_text, ' ');
627
627
-
var saw_scope = false;
628
628
-
var has_transition = false;
629
629
-
var has_granular = false;
630
630
-
while (scopes.next()) |scope| {
631
631
-
if (scope.len == 0) continue;
632
632
-
saw_scope = true;
633
633
-
if (std.mem.eql(u8, scope, "atproto")) continue;
634
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
635
-
has_transition = true;
636
636
-
continue;
637
637
-
}
638
638
-
if (std.mem.startsWith(u8, scope, "repo:") or
639
639
-
std.mem.startsWith(u8, scope, "blob:") or
640
640
-
std.mem.startsWith(u8, scope, "rpc:") or
641
641
-
std.mem.startsWith(u8, scope, "account:") or
642
642
-
std.mem.startsWith(u8, scope, "identity:") or
643
643
-
std.mem.startsWith(u8, scope, "include:"))
644
644
-
{
645
645
-
has_granular = true;
646
646
-
continue;
647
647
-
}
648
648
-
return oauthError(request, .bad_request, "invalid_scope", "Unsupported scope");
649
649
-
}
650
650
-
if (!saw_scope) return oauthError(request, .bad_request, "invalid_scope", "Empty scope");
651
651
-
if (has_transition and has_granular) {
652
652
-
return oauthError(request, .bad_request, "invalid_scope", "Cannot mix transition scopes with granular scopes");
653
653
-
}
654
654
-
}
655
655
-
656
656
-
fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 {
657
657
-
var out: std.Io.Writer.Allocating = .init(allocator);
658
658
-
for (value) |c| {
659
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
660
-
try out.writer.writeByte(c);
661
661
-
} else {
662
662
-
try out.writer.print("%{X:0>2}", .{c});
663
663
-
}
664
664
-
}
665
665
-
return out.toOwnedSlice();
666
666
-
}
667
667
-
668
668
-
fn joinUrl(allocator: std.mem.Allocator, base: []const u8, path: []const u8) ![]const u8 {
669
669
-
return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, path });
670
670
-
}
671
671
-
672
672
-
fn trimTrailingSlash(value: []const u8) []const u8 {
673
673
-
if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1];
674
674
-
return value;
675
675
-
}
676
676
-
677
677
-
fn acceptsJson(request: *const http.Server.Request) bool {
678
678
-
const accept = http_api.headerValue(request, "accept") orelse return false;
679
679
-
return std.mem.indexOf(u8, accept, "application/json") != null;
680
680
-
}
681
681
-
682
682
-
fn now() i64 {
683
683
-
var ts: std.posix.timespec = undefined;
684
684
-
return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) {
685
685
-
.SUCCESS => ts.sec,
686
686
-
else => 0,
687
687
-
};
688
688
-
}
689
689
-
690
690
-
fn oauthError(request: *http.Server.Request, status: http.Status, err: []const u8, description: []const u8) !void {
691
691
-
var buf: [512]u8 = undefined;
692
692
-
const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f},\"error_description\":{f}}}", .{ std.json.fmt(err, .{}), std.json.fmt(description, .{}) });
693
693
-
try http_api.json(request, status, body);
694
694
-
}
695
695
-
696
696
-
fn respondHtml(request: *http.Server.Request, status: http.Status, body: []const u8) !void {
697
697
-
try request.respond(body, .{
698
698
-
.status = status,
699
699
-
.extra_headers = &[_]http.Header{
700
700
-
.{ .name = "content-type", .value = "text/html; charset=utf-8" },
701
701
-
.{ .name = "access-control-allow-origin", .value = "*" },
702
702
-
.{ .name = "connection", .value = "close" },
703
703
-
},
704
704
-
});
705
705
-
}
···
1
1
-
const std = @import("std");
2
2
-
const config = @import("../core/config.zig");
3
3
-
const store = @import("../storage/store.zig");
4
4
-
const zat = @import("zat");
5
5
-
6
6
-
pub const Operation = struct {
7
7
-
did: []const u8,
8
8
-
json: []const u8,
9
9
-
};
10
10
-
11
11
-
pub fn createGenesisOperation(
12
12
-
allocator: std.mem.Allocator,
13
13
-
handle: []const u8,
14
14
-
signing_key: *const zat.Keypair,
15
15
-
rotation_key: *const zat.Keypair,
16
16
-
) !Operation {
17
17
-
const signing_did_key = try signing_key.did(allocator);
18
18
-
const rotation_did_key = try rotation_key.did(allocator);
19
19
-
const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_key);
20
20
-
const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned);
21
21
-
const signature = try rotation_key.sign(unsigned_bytes);
22
22
-
const signature_text = try zat.jwt.base64UrlEncode(allocator, &signature.bytes);
23
23
-
24
24
-
const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text);
25
25
-
const signed_bytes = try zat.cbor.encodeAlloc(allocator, signed);
26
26
-
var digest: [32]u8 = undefined;
27
27
-
std.crypto.hash.sha2.Sha256.hash(signed_bytes, &digest, .{});
28
28
-
const encoded = try zat.multibase.base32lower.encode(allocator, &digest);
29
29
-
const did_hash = if (encoded.len > 0 and encoded[0] == 'b') encoded[1..] else encoded;
30
30
-
const did = try std.fmt.allocPrint(allocator, "did:plc:{s}", .{did_hash[0..24]});
31
31
-
32
32
-
return .{
33
33
-
.did = did,
34
34
-
.json = try operationJson(allocator, handle, signing_did_key, rotation_did_key, signature_text),
35
35
-
};
36
36
-
}
37
37
-
38
38
-
pub fn submitOperation(allocator: std.mem.Allocator, did: []const u8, operation_json: []const u8) !void {
39
39
-
const escaped_did = try percentEncodeDid(allocator, did);
40
40
-
const url = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ config.plcDirectory(), escaped_did });
41
41
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
42
42
-
defer transport.deinit();
43
43
-
const result = try transport.fetch(.{
44
44
-
.url = url,
45
45
-
.method = .POST,
46
46
-
.payload = operation_json,
47
47
-
.content_type = "application/json",
48
48
-
.max_response_size = 64 * 1024,
49
49
-
});
50
50
-
if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) {
51
51
-
return error.PlcSubmissionRejected;
52
52
-
}
53
53
-
}
54
54
-
55
55
-
fn unsignedOperationCbor(
56
56
-
allocator: std.mem.Allocator,
57
57
-
handle: []const u8,
58
58
-
signing_did_key: []const u8,
59
59
-
rotation_did_key: []const u8,
60
60
-
) !zat.cbor.Value {
61
61
-
return operationCbor(allocator, handle, signing_did_key, rotation_did_key, null);
62
62
-
}
63
63
-
64
64
-
fn signedOperationCbor(
65
65
-
allocator: std.mem.Allocator,
66
66
-
handle: []const u8,
67
67
-
signing_did_key: []const u8,
68
68
-
rotation_did_key: []const u8,
69
69
-
signature_text: []const u8,
70
70
-
) !zat.cbor.Value {
71
71
-
return operationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text);
72
72
-
}
73
73
-
74
74
-
fn operationCbor(
75
75
-
allocator: std.mem.Allocator,
76
76
-
handle: []const u8,
77
77
-
signing_did_key: []const u8,
78
78
-
rotation_did_key: []const u8,
79
79
-
signature_text: ?[]const u8,
80
80
-
) !zat.cbor.Value {
81
81
-
const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle});
82
82
-
const service_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 2);
83
83
-
service_entries[0] = .{ .key = "type", .value = .{ .text = "AtprotoPersonalDataServer" } };
84
84
-
service_entries[1] = .{ .key = "endpoint", .value = .{ .text = config.publicUrl() } };
85
85
-
86
86
-
const services_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1);
87
87
-
services_entries[0] = .{ .key = "atproto_pds", .value = .{ .map = service_entries } };
88
88
-
89
89
-
const verification_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1);
90
90
-
verification_entries[0] = .{ .key = "atproto", .value = .{ .text = signing_did_key } };
91
91
-
92
92
-
const rotation_items = try allocator.alloc(zat.cbor.Value, 1);
93
93
-
rotation_items[0] = .{ .text = rotation_did_key };
94
94
-
const aka_items = try allocator.alloc(zat.cbor.Value, 1);
95
95
-
aka_items[0] = .{ .text = also_known_as };
96
96
-
97
97
-
const entry_count: usize = if (signature_text == null) 6 else 7;
98
98
-
const entries = try allocator.alloc(zat.cbor.Value.MapEntry, entry_count);
99
99
-
entries[0] = .{ .key = "type", .value = .{ .text = "plc_operation" } };
100
100
-
entries[1] = .{ .key = "rotationKeys", .value = .{ .array = rotation_items } };
101
101
-
entries[2] = .{ .key = "verificationMethods", .value = .{ .map = verification_entries } };
102
102
-
entries[3] = .{ .key = "alsoKnownAs", .value = .{ .array = aka_items } };
103
103
-
entries[4] = .{ .key = "services", .value = .{ .map = services_entries } };
104
104
-
entries[5] = .{ .key = "prev", .value = .null };
105
105
-
if (signature_text) |sig| entries[6] = .{ .key = "sig", .value = .{ .text = sig } };
106
106
-
return .{ .map = entries };
107
107
-
}
108
108
-
109
109
-
fn operationJson(
110
110
-
allocator: std.mem.Allocator,
111
111
-
handle: []const u8,
112
112
-
signing_did_key: []const u8,
113
113
-
rotation_did_key: []const u8,
114
114
-
signature_text: []const u8,
115
115
-
) ![]const u8 {
116
116
-
const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle});
117
117
-
return std.fmt.allocPrint(
118
118
-
allocator,
119
119
-
"{{\"type\":\"plc_operation\",\"rotationKeys\":[{f}],\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}",
120
120
-
.{
121
121
-
std.json.fmt(rotation_did_key, .{}),
122
122
-
std.json.fmt(signing_did_key, .{}),
123
123
-
std.json.fmt(also_known_as, .{}),
124
124
-
std.json.fmt(config.publicUrl(), .{}),
125
125
-
std.json.fmt(signature_text, .{}),
126
126
-
},
127
127
-
);
128
128
-
}
129
129
-
130
130
-
fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
131
131
-
var out: std.Io.Writer.Allocating = .init(allocator);
132
132
-
defer out.deinit();
133
133
-
for (did) |c| switch (c) {
134
134
-
':' => try out.writer.writeAll("%3A"),
135
135
-
else => try out.writer.writeByte(c),
136
136
-
};
137
137
-
return out.toOwnedSlice();
138
138
-
}
139
139
-
140
140
-
test "PLC genesis operation is signed and derives a did:plc" {
141
141
-
const allocator = std.testing.allocator;
142
142
-
var arena = std.heap.ArenaAllocator.init(allocator);
143
143
-
defer arena.deinit();
144
144
-
const a = arena.allocator();
145
145
-
const before = config.publicUrl();
146
146
-
defer config.setPublicUrl(before);
147
147
-
config.setPublicUrl("https://pds.example.com");
148
148
-
149
149
-
const secret: [32]u8 = .{
150
150
-
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
151
151
-
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
152
152
-
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
153
153
-
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
154
154
-
};
155
155
-
const keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret);
156
156
-
const op = try createGenesisOperation(a, "alice.example.com", &keypair, &keypair);
157
157
-
try std.testing.expect(std.mem.startsWith(u8, op.did, "did:plc:"));
158
158
-
try std.testing.expectEqual(@as(usize, "did:plc:".len + 24), op.did.len);
159
159
-
try std.testing.expect(std.mem.indexOf(u8, op.json, "\"type\":\"plc_operation\"") != null);
160
160
-
try std.testing.expect(std.mem.indexOf(u8, op.json, "\"sig\"") != null);
161
161
-
try std.testing.expect(std.mem.indexOf(u8, op.json, "https://pds.example.com") != null);
162
162
-
163
163
-
const parsed = try std.json.parseFromSlice(std.json.Value, a, op.json, .{});
164
164
-
const sig_text = parsed.value.object.get("sig").?.string;
165
165
-
const sig_bytes = try zat.jwt.base64UrlDecode(a, sig_text);
166
166
-
const did_key = try keypair.did(a);
167
167
-
const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, did_key);
168
168
-
const unsigned_bytes = try zat.cbor.encodeAlloc(a, unsigned);
169
169
-
try zat.multicodec.verifyDidKeySignature(a, did_key, unsigned_bytes, sig_bytes);
170
170
-
}
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const http_api = @import("../http/api.zig");
4
4
-
const store = @import("../storage/store.zig");
5
5
-
6
6
-
const http = std.http;
7
7
-
8
8
-
const app_bsky_namespace = "app.bsky";
9
9
-
const max_preference_size = 10_000;
10
10
-
const personal_details_pref = "app.bsky.actor.defs#personalDetailsPref";
11
11
-
const declared_age_pref = "app.bsky.actor.defs#declaredAgePref";
12
12
-
13
13
-
pub fn getPreferences(request: *http.Server.Request) !void {
14
14
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
15
15
-
defer arena.deinit();
16
16
-
const allocator = arena.allocator();
17
17
-
18
18
-
const account = requireAccount(request, allocator) catch return;
19
19
-
const rows = try store.getAppPreferences(allocator, account.did, app_bsky_namespace);
20
20
-
21
21
-
var out: std.Io.Writer.Allocating = .init(allocator);
22
22
-
defer out.deinit();
23
23
-
try out.writer.writeAll("{\"preferences\":[");
24
24
-
var first = true;
25
25
-
var personal_details: ?std.json.Value = null;
26
26
-
for (rows) |row| {
27
27
-
const parsed = std.json.parseFromSlice(std.json.Value, allocator, row.value_json, .{}) catch continue;
28
28
-
if (std.mem.eql(u8, row.name, declared_age_pref)) continue;
29
29
-
if (std.mem.eql(u8, row.name, personal_details_pref)) personal_details = parsed.value;
30
30
-
if (!first) try out.writer.writeByte(',');
31
31
-
first = false;
32
32
-
try out.writer.writeAll(row.value_json);
33
33
-
}
34
34
-
if (declaredAgeJson(allocator, personal_details)) |age_json| {
35
35
-
if (!first) try out.writer.writeByte(',');
36
36
-
try out.writer.writeAll(age_json);
37
37
-
}
38
38
-
try out.writer.writeAll("]}");
39
39
-
const body = try out.toOwnedSlice();
40
40
-
return http_api.json(request, .ok, body);
41
41
-
}
42
42
-
43
43
-
pub fn putPreferences(request: *http.Server.Request) !void {
44
44
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
45
45
-
defer arena.deinit();
46
46
-
const allocator = arena.allocator();
47
47
-
48
48
-
const account = requireAccount(request, allocator) catch return;
49
49
-
const body = http_api.readBodyAlloc(request, allocator, 1024 * 1024) catch |err| switch (err) {
50
50
-
error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "PayloadTooLarge", "Request body too large"),
51
51
-
else => return err,
52
52
-
};
53
53
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
54
54
-
const preferences = switch (parsed.value) {
55
55
-
.object => |object| object.get("preferences") orelse {
56
56
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing preferences");
57
57
-
},
58
58
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
59
59
-
};
60
60
-
if (preferences != .array) {
61
61
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "preferences must be an array");
62
62
-
}
63
63
-
var preference_rows: std.ArrayList(store.AppPreference) = .empty;
64
64
-
for (preferences.array.items) |preference| {
65
65
-
if (preference != .object) {
66
66
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference must be an object");
67
67
-
}
68
68
-
const type_name = http_api.valueString(preference, "$type") orelse {
69
69
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is missing a $type");
70
70
-
};
71
71
-
if (type_name.len == 0) {
72
72
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is missing a $type");
73
73
-
}
74
74
-
if (!std.mem.startsWith(u8, type_name, app_bsky_namespace)) {
75
75
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Some preferences are not in the app.bsky namespace");
76
76
-
}
77
77
-
const preference_json = try stringifyJson(allocator, preference);
78
78
-
if (preference_json.len > max_preference_size) {
79
79
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is too large");
80
80
-
}
81
81
-
if (std.mem.eql(u8, type_name, declared_age_pref)) continue;
82
82
-
try preference_rows.append(allocator, .{
83
83
-
.name = type_name,
84
84
-
.value_json = preference_json,
85
85
-
});
86
86
-
}
87
87
-
88
88
-
try store.replaceAppPreferences(allocator, account.did, app_bsky_namespace, preference_rows.items);
89
89
-
return http_api.json(request, .ok, "{}");
90
90
-
}
91
91
-
92
92
-
fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
93
93
-
var out: std.Io.Writer.Allocating = .init(allocator);
94
94
-
defer out.deinit();
95
95
-
try std.json.Stringify.value(value, .{}, &out.writer);
96
96
-
return out.toOwnedSlice();
97
97
-
}
98
98
-
99
99
-
fn declaredAgeJson(allocator: std.mem.Allocator, personal_details: ?std.json.Value) ?[]const u8 {
100
100
-
const personal = personal_details orelse return null;
101
101
-
const birth_date = http_api.valueString(personal, "birthDate") orelse return null;
102
102
-
const age = ageFromDateString(birth_date) orelse return null;
103
103
-
return std.fmt.allocPrint(
104
104
-
allocator,
105
105
-
"{{\"$type\":\"{s}\",\"isOverAge13\":{},\"isOverAge16\":{},\"isOverAge18\":{}}}",
106
106
-
.{ declared_age_pref, age >= 13, age >= 16, age >= 18 },
107
107
-
) catch null;
108
108
-
}
109
109
-
110
110
-
fn ageFromDateString(date: []const u8) ?i32 {
111
111
-
if (date.len < 10) return null;
112
112
-
const year = std.fmt.parseInt(i32, date[0..4], 10) catch return null;
113
113
-
const month = std.fmt.parseInt(u8, date[5..7], 10) catch return null;
114
114
-
const day = std.fmt.parseInt(u8, date[8..10], 10) catch return null;
115
115
-
if (date[4] != '-' or date[7] != '-' or month == 0 or month > 12 or day == 0 or day > 31) return null;
116
116
-
117
117
-
const today = civilFromDays(@divTrunc(@divTrunc(store.nowMs(), 1000), 86400));
118
118
-
var age = today.year - year;
119
119
-
if (today.month < month or (today.month == month and today.day < day)) age -= 1;
120
120
-
return age;
121
121
-
}
122
122
-
123
123
-
const CivilDate = struct {
124
124
-
year: i32,
125
125
-
month: u8,
126
126
-
day: u8,
127
127
-
};
128
128
-
129
129
-
fn civilFromDays(days_since_unix_epoch: i64) CivilDate {
130
130
-
const z = days_since_unix_epoch + 719468;
131
131
-
const era = if (z >= 0) @divTrunc(z, 146097) else @divTrunc(z - 146096, 146097);
132
132
-
const doe: u32 = @intCast(z - era * 146097);
133
133
-
const yoe: u32 = @intCast(@divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365));
134
134
-
var year: i32 = @intCast(yoe);
135
135
-
year += @intCast(era * 400);
136
136
-
const doy: u32 = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100));
137
137
-
const mp: u32 = @divTrunc(5 * doy + 2, 153);
138
138
-
const day: u8 = @intCast(doy - @divTrunc(153 * mp + 2, 5) + 1);
139
139
-
const month: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9);
140
140
-
if (month <= 2) year += 1;
141
141
-
return .{ .year = year, .month = month, .day = day };
142
142
-
}
143
143
-
144
144
-
fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
145
145
-
return http_api.requireBearerAccount(request, allocator) catch |err| {
146
146
-
switch (err) {
147
147
-
error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
148
148
-
error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
149
149
-
}
150
150
-
return error.HandledResponse;
151
151
-
};
152
152
-
}
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const log = @import("../core/log.zig");
4
4
-
const http_api = @import("../http/api.zig");
5
5
-
const store = @import("../storage/store.zig");
6
6
-
const zat = @import("zat");
7
7
-
8
8
-
const http = std.http;
9
9
-
10
10
-
pub fn xrpcProxy(request: *http.Server.Request) !void {
11
11
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
12
12
-
defer arena.deinit();
13
13
-
const allocator = arena.allocator();
14
14
-
15
15
-
if (request.head.method != .GET and request.head.method != .POST) {
16
16
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "XRPC proxy supports GET and POST");
17
17
-
}
18
18
-
19
19
-
const method = xrpcMethod(request.head.target) orelse {
20
20
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid XRPC path");
21
21
-
};
22
22
-
if (isProtectedMethod(method)) {
23
23
-
return http_api.xrpcError(request, .bad_request, "InvalidToken", "Cannot proxy protected method");
24
24
-
}
25
25
-
const proxy_to = http_api.headerValue(request, "atproto-proxy") orelse {
26
26
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing atproto-proxy header");
27
27
-
};
28
28
-
log.debug("xrpc proxy start method={s} target={s} proxy_to={s}\n", .{ method, request.head.target, proxy_to });
29
29
-
const service = resolveProxyService(allocator, proxy_to) catch |err| switch (err) {
30
30
-
error.InvalidProxyHeader => {
31
31
-
log.err("xrpc proxy invalid header method={s} proxy_to={s}\n", .{ method, proxy_to });
32
32
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid atproto-proxy header");
33
33
-
},
34
34
-
error.UnsupportedDidMethod => {
35
35
-
log.err("xrpc proxy unsupported did method={s} proxy_to={s}\n", .{ method, proxy_to });
36
36
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Unsupported proxy DID method");
37
37
-
},
38
38
-
error.ServiceNotFound => {
39
39
-
log.err("xrpc proxy service not found method={s} proxy_to={s}\n", .{ method, proxy_to });
40
40
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Proxy service not found");
41
41
-
},
42
42
-
error.DidResolutionFailed => {
43
43
-
log.err("xrpc proxy did resolution failed method={s} proxy_to={s}\n", .{ method, proxy_to });
44
44
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve proxy DID");
45
45
-
},
46
46
-
else => |e| {
47
47
-
log.err("xrpc proxy resolve failed method={s} proxy_to={s} err={s}\n", .{ method, proxy_to, @errorName(e) });
48
48
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve proxy service");
49
49
-
},
50
50
-
};
51
51
-
52
52
-
const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) {
53
53
-
error.AuthRequired => {
54
54
-
log.debug("xrpc proxy auth missing method={s} proxy_to={s}\n", .{ method, proxy_to });
55
55
-
return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required");
56
56
-
},
57
57
-
error.InvalidToken => {
58
58
-
log.err("xrpc proxy auth invalid method={s} proxy_to={s}\n", .{ method, proxy_to });
59
59
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token");
60
60
-
},
61
61
-
};
62
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
63
-
const upstream_url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ service.endpoint, request.head.target });
64
64
-
65
65
-
var extra_buf: [6]std.http.Header = undefined;
66
66
-
var extra_count: usize = 0;
67
67
-
inline for (.{ "accept-language", "atproto-accept-labelers", "atproto-content-labelers", "x-bsky-topics", "dpop" }) |name| {
68
68
-
if (http_api.headerValue(request, name)) |value| {
69
69
-
extra_buf[extra_count] = .{ .name = name, .value = try allocator.dupe(u8, value) };
70
70
-
extra_count += 1;
71
71
-
}
72
72
-
}
73
73
-
74
74
-
const payload = if (request.head.method == .POST)
75
75
-
try http_api.readBodyAlloc(request, allocator, 1024 * 1024)
76
76
-
else
77
77
-
null;
78
78
-
79
79
-
var service_audience = service.did;
80
80
-
var service_method: ?[]const u8 = method;
81
81
-
if (std.ascii.eqlIgnoreCase(method, "app.bsky.feed.getFeed")) {
82
82
-
const feed_did = resolveFeedGeneratorDid(allocator, service.endpoint, request.head.target) catch |err| switch (err) {
83
83
-
error.MissingFeedParam, error.InvalidFeedUri => {
84
84
-
log.err("xrpc proxy feed invalid target={s} err={s}\n", .{ request.head.target, @errorName(err) });
85
85
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid feed URI");
86
86
-
},
87
87
-
error.FeedRecordUnavailable => {
88
88
-
log.err("xrpc proxy feed record unavailable target={s}\n", .{request.head.target});
89
89
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve feed generator record");
90
90
-
},
91
91
-
error.FeedDidMissing => {
92
92
-
log.err("xrpc proxy feed record missing did target={s}\n", .{request.head.target});
93
93
-
return http_api.xrpcError(request, .bad_request, "UnknownFeed", "Could not resolve feed did");
94
94
-
},
95
95
-
else => |e| {
96
96
-
log.err("xrpc proxy feed resolve failed target={s} err={s}\n", .{ request.head.target, @errorName(e) });
97
97
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not resolve feed generator");
98
98
-
},
99
99
-
};
100
100
-
service_audience = feed_did;
101
101
-
service_method = "app.bsky.feed.getFeedSkeleton";
102
102
-
log.debug("xrpc proxy feed auth override feed_did={s} lxm={s}\n", .{ service_audience, service_method.? });
103
103
-
}
104
104
-
105
105
-
var keypair = store.signingKeypair(account.did) catch {
106
106
-
log.err("xrpc proxy signing key missing method={s} account={s}\n", .{ method, account.did });
107
107
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not load account signing key");
108
108
-
};
109
109
-
const service_token = try auth.createServiceJwtWithKeypair(allocator, account, service_audience, service_method, &keypair);
110
110
-
const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{service_token});
111
111
-
112
112
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
113
113
-
defer transport.deinit();
114
114
-
log.debug("xrpc proxy fetch method={s} upstream={s} extra_headers={d}\n", .{ method, upstream_url, extra_count });
115
115
-
const result = transport.fetch(.{
116
116
-
.url = upstream_url,
117
117
-
.method = request.head.method,
118
118
-
.payload = payload,
119
119
-
.authorization = authorization,
120
120
-
.accept = "application/json",
121
121
-
.extra_headers = extra_buf[0..extra_count],
122
122
-
.max_response_size = 10 * 1024 * 1024,
123
123
-
}) catch |err| {
124
124
-
log.err("xrpc proxy fetch failed method={s} upstream={s} err={s}\n", .{ method, upstream_url, @errorName(err) });
125
125
-
return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Failed to reach proxied service");
126
126
-
};
127
127
-
log.debug("xrpc proxy upstream method={s} status={d} body_len={d}\n", .{ method, @intFromEnum(result.status), result.body.len });
128
128
-
return http_api.json(request, result.status, result.body);
129
129
-
}
130
130
-
131
131
-
pub fn shouldProxy(request: *const http.Server.Request) bool {
132
132
-
if (request.head.method != .GET and request.head.method != .POST) return false;
133
133
-
if (http_api.headerValue(request, "atproto-proxy") == null) return false;
134
134
-
const method = xrpcMethod(request.head.target) orelse return false;
135
135
-
return !isProtectedMethod(method);
136
136
-
}
137
137
-
138
138
-
const ProxyService = struct {
139
139
-
did: []const u8,
140
140
-
endpoint: []const u8,
141
141
-
};
142
142
-
143
143
-
fn resolveProxyService(allocator: std.mem.Allocator, proxy_to: []const u8) !ProxyService {
144
144
-
const hash = std.mem.indexOfScalar(u8, proxy_to, '#') orelse return error.InvalidProxyHeader;
145
145
-
if (hash == 0 or hash + 1 >= proxy_to.len) return error.InvalidProxyHeader;
146
146
-
const did = proxy_to[0..hash];
147
147
-
const service_id = proxy_to[hash..];
148
148
-
const did_doc = try fetchDidDocument(allocator, did);
149
149
-
const endpoint = serviceEndpoint(did_doc.value, service_id) orelse return error.ServiceNotFound;
150
150
-
return .{
151
151
-
.did = try allocator.dupe(u8, did),
152
152
-
.endpoint = try allocator.dupe(u8, std.mem.trim(u8, endpoint, "/")),
153
153
-
};
154
154
-
}
155
155
-
156
156
-
const FeedRef = struct {
157
157
-
repo: []const u8,
158
158
-
collection: []const u8,
159
159
-
rkey: []const u8,
160
160
-
};
161
161
-
162
162
-
fn resolveFeedGeneratorDid(allocator: std.mem.Allocator, appview_endpoint: []const u8, target: []const u8) ![]const u8 {
163
163
-
const feed_buf = try allocator.alloc(u8, target.len);
164
164
-
const feed_uri = http_api.queryParam(target, "feed", feed_buf) orelse return error.MissingFeedParam;
165
165
-
const feed_ref = parseFeedUri(feed_uri) orelse return error.InvalidFeedUri;
166
166
-
const record_url = try std.fmt.allocPrint(
167
167
-
allocator,
168
168
-
"{s}/xrpc/com.atproto.repo.getRecord?repo={s}&collection={s}&rkey={s}",
169
169
-
.{ appview_endpoint, feed_ref.repo, feed_ref.collection, feed_ref.rkey },
170
170
-
);
171
171
-
172
172
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
173
173
-
defer transport.deinit();
174
174
-
const result = transport.fetch(.{
175
175
-
.url = record_url,
176
176
-
.method = .GET,
177
177
-
.accept = "application/json",
178
178
-
.max_response_size = 512 * 1024,
179
179
-
}) catch return error.FeedRecordUnavailable;
180
180
-
const status_code = @intFromEnum(result.status);
181
181
-
if (status_code < 200 or status_code >= 300) return error.FeedRecordUnavailable;
182
182
-
183
183
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{});
184
184
-
const value = switch (parsed.value) {
185
185
-
.object => |object| object.get("value") orelse return error.FeedDidMissing,
186
186
-
else => return error.FeedDidMissing,
187
187
-
};
188
188
-
const feed_did = zat.json.getString(value, "did") orelse return error.FeedDidMissing;
189
189
-
if (zat.Did.parse(feed_did) == null) return error.FeedDidMissing;
190
190
-
return try allocator.dupe(u8, feed_did);
191
191
-
}
192
192
-
193
193
-
fn parseFeedUri(uri: []const u8) ?FeedRef {
194
194
-
if (!std.mem.startsWith(u8, uri, "at://")) return null;
195
195
-
var parts = std.mem.splitScalar(u8, uri["at://".len..], '/');
196
196
-
const repo = parts.next() orelse return null;
197
197
-
const collection = parts.next() orelse return null;
198
198
-
const rkey = parts.next() orelse return null;
199
199
-
if (repo.len == 0 or collection.len == 0 or rkey.len == 0 or parts.next() != null) return null;
200
200
-
return .{ .repo = repo, .collection = collection, .rkey = rkey };
201
201
-
}
202
202
-
203
203
-
fn fetchDidDocument(allocator: std.mem.Allocator, did: []const u8) !std.json.Parsed(std.json.Value) {
204
204
-
const url =
205
205
-
if (std.mem.startsWith(u8, did, "did:plc:"))
206
206
-
try std.fmt.allocPrint(allocator, "https://plc.directory/{s}", .{did})
207
207
-
else if (std.mem.startsWith(u8, did, "did:web:"))
208
208
-
try didWebUrl(allocator, did)
209
209
-
else
210
210
-
return error.UnsupportedDidMethod;
211
211
-
212
212
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
213
213
-
defer transport.deinit();
214
214
-
const result = try transport.fetch(.{
215
215
-
.url = url,
216
216
-
.method = .GET,
217
217
-
.accept = "application/json",
218
218
-
.max_response_size = 256 * 1024,
219
219
-
});
220
220
-
const status_code = @intFromEnum(result.status);
221
221
-
if (status_code < 200 or status_code >= 300) return error.DidResolutionFailed;
222
222
-
return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{});
223
223
-
}
224
224
-
225
225
-
fn didWebUrl(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
226
226
-
const body = did["did:web:".len..];
227
227
-
const decoded_buf = try allocator.alloc(u8, body.len);
228
228
-
const decoded = try percentDecodeDidWeb(body, decoded_buf);
229
229
-
const path = try allocator.dupe(u8, decoded);
230
230
-
std.mem.replaceScalar(u8, path, ':', '/');
231
231
-
if (std.mem.indexOfScalar(u8, path, '/') == null) {
232
232
-
return std.fmt.allocPrint(allocator, "https://{s}/.well-known/did.json", .{path});
233
233
-
}
234
234
-
return std.fmt.allocPrint(allocator, "https://{s}/did.json", .{path});
235
235
-
}
236
236
-
237
237
-
fn percentDecodeDidWeb(input: []const u8, out: []u8) ![]const u8 {
238
238
-
var write: usize = 0;
239
239
-
var read: usize = 0;
240
240
-
while (read < input.len) {
241
241
-
switch (input[read]) {
242
242
-
'%' => {
243
243
-
if (read + 2 >= input.len) return error.InvalidPercentEncoding;
244
244
-
out[write] = try std.fmt.parseInt(u8, input[read + 1 .. read + 3], 16);
245
245
-
read += 3;
246
246
-
},
247
247
-
else => |c| {
248
248
-
out[write] = c;
249
249
-
read += 1;
250
250
-
},
251
251
-
}
252
252
-
write += 1;
253
253
-
}
254
254
-
return out[0..write];
255
255
-
}
256
256
-
257
257
-
fn serviceEndpoint(did_doc: std.json.Value, service_id: []const u8) ?[]const u8 {
258
258
-
const services = zat.json.getArray(did_doc, "service") orelse return null;
259
259
-
for (services) |service_value| {
260
260
-
if (zat.json.getString(service_value, "id")) |id| {
261
261
-
if (std.mem.eql(u8, id, service_id)) {
262
262
-
return zat.json.getString(service_value, "serviceEndpoint");
263
263
-
}
264
264
-
}
265
265
-
}
266
266
-
return null;
267
267
-
}
268
268
-
269
269
-
fn xrpcMethod(target: []const u8) ?[]const u8 {
270
270
-
if (!std.mem.startsWith(u8, target, "/xrpc/")) return null;
271
271
-
const start = "/xrpc/".len;
272
272
-
const end = std.mem.indexOfScalarPos(u8, target, start, '?') orelse target.len;
273
273
-
return target[start..end];
274
274
-
}
275
275
-
276
276
-
fn isProtectedMethod(method: []const u8) bool {
277
277
-
const protected = [_][]const u8{
278
278
-
"app.bsky.actor.getPreferences",
279
279
-
"app.bsky.actor.putPreferences",
280
280
-
"com.atproto.admin.deleteAccount",
281
281
-
"com.atproto.admin.disableAccountInvites",
282
282
-
"com.atproto.admin.disableInviteCodes",
283
283
-
"com.atproto.admin.enableAccountInvites",
284
284
-
"com.atproto.admin.getAccountInfo",
285
285
-
"com.atproto.admin.getAccountInfos",
286
286
-
"com.atproto.admin.getInviteCodes",
287
287
-
"com.atproto.admin.getSubjectStatus",
288
288
-
"com.atproto.admin.searchAccounts",
289
289
-
"com.atproto.admin.sendEmail",
290
290
-
"com.atproto.admin.updateAccountEmail",
291
291
-
"com.atproto.admin.updateAccountHandle",
292
292
-
"com.atproto.admin.updateAccountPassword",
293
293
-
"com.atproto.admin.updateSubjectStatus",
294
294
-
"com.atproto.identity.getRecommendedDidCredentials",
295
295
-
"com.atproto.identity.requestPlcOperationSignature",
296
296
-
"com.atproto.identity.signPlcOperation",
297
297
-
"com.atproto.identity.submitPlcOperation",
298
298
-
"com.atproto.identity.updateHandle",
299
299
-
"com.atproto.repo.applyWrites",
300
300
-
"com.atproto.repo.createRecord",
301
301
-
"com.atproto.repo.deleteRecord",
302
302
-
"com.atproto.repo.importRepo",
303
303
-
"com.atproto.repo.putRecord",
304
304
-
"com.atproto.repo.uploadBlob",
305
305
-
"com.atproto.server.activateAccount",
306
306
-
"com.atproto.server.checkAccountStatus",
307
307
-
"com.atproto.server.confirmEmail",
308
308
-
"com.atproto.server.confirmSignup",
309
309
-
"com.atproto.server.createAccount",
310
310
-
"com.atproto.server.createAppPassword",
311
311
-
"com.atproto.server.createInviteCode",
312
312
-
"com.atproto.server.createInviteCodes",
313
313
-
"com.atproto.server.createSession",
314
314
-
"com.atproto.server.createTotpSecret",
315
315
-
"com.atproto.server.deactivateAccount",
316
316
-
"com.atproto.server.deleteAccount",
317
317
-
"com.atproto.server.deletePasskey",
318
318
-
"com.atproto.server.deleteSession",
319
319
-
"com.atproto.server.describeServer",
320
320
-
"com.atproto.server.disableTotp",
321
321
-
"com.atproto.server.enableTotp",
322
322
-
"com.atproto.server.finishPasskeyRegistration",
323
323
-
"com.atproto.server.getAccountInviteCodes",
324
324
-
"com.atproto.server.getServiceAuth",
325
325
-
"com.atproto.server.getSession",
326
326
-
"com.atproto.server.getTotpStatus",
327
327
-
"com.atproto.server.listAppPasswords",
328
328
-
"com.atproto.server.listPasskeys",
329
329
-
"com.atproto.server.refreshSession",
330
330
-
"com.atproto.server.regenerateBackupCodes",
331
331
-
"com.atproto.server.requestAccountDelete",
332
332
-
"com.atproto.server.requestEmailConfirmation",
333
333
-
"com.atproto.server.requestEmailUpdate",
334
334
-
"com.atproto.server.requestPasswordReset",
335
335
-
"com.atproto.server.resendMigrationVerification",
336
336
-
"com.atproto.server.resendVerification",
337
337
-
"com.atproto.server.reserveSigningKey",
338
338
-
"com.atproto.server.resetPassword",
339
339
-
"com.atproto.server.revokeAppPassword",
340
340
-
"com.atproto.server.startPasskeyRegistration",
341
341
-
"com.atproto.server.updateEmail",
342
342
-
"com.atproto.server.updatePasskey",
343
343
-
"com.atproto.server.verifyMigrationEmail",
344
344
-
"com.atproto.sync.getBlob",
345
345
-
"com.atproto.sync.getBlocks",
346
346
-
"com.atproto.sync.getCheckout",
347
347
-
"com.atproto.sync.getHead",
348
348
-
"com.atproto.sync.getLatestCommit",
349
349
-
"com.atproto.sync.getRecord",
350
350
-
"com.atproto.sync.getRepo",
351
351
-
"com.atproto.sync.getRepoStatus",
352
352
-
"com.atproto.sync.listBlobs",
353
353
-
"com.atproto.sync.listRepos",
354
354
-
"com.atproto.sync.notifyOfUpdate",
355
355
-
"com.atproto.sync.requestCrawl",
356
356
-
"com.atproto.sync.subscribeRepos",
357
357
-
"com.atproto.temp.checkSignupQueue",
358
358
-
"com.atproto.temp.dereferenceScope",
359
359
-
};
360
360
-
for (protected) |item| {
361
361
-
if (std.ascii.eqlIgnoreCase(method, item)) return true;
362
362
-
}
363
363
-
return false;
364
364
-
}
365
365
-
366
366
-
test "proxy protected methods are explicit" {
367
367
-
try std.testing.expect(isProtectedMethod("com.atproto.repo.applyWrites"));
368
368
-
try std.testing.expect(isProtectedMethod("com.atproto.sync.subscribeRepos"));
369
369
-
try std.testing.expect(!isProtectedMethod("com.atproto.identity.resolveHandle"));
370
370
-
try std.testing.expect(!isProtectedMethod("com.atproto.repo.getRecord"));
371
371
-
try std.testing.expect(!isProtectedMethod("com.atproto.repo.listRecords"));
372
372
-
}
373
373
-
374
374
-
test "parses feed generator at-uri" {
375
375
-
const feed = parseFeedUri("at://did:plc:abc/app.bsky.feed.generator/whats-hot").?;
376
376
-
try std.testing.expectEqualStrings("did:plc:abc", feed.repo);
377
377
-
try std.testing.expectEqualStrings("app.bsky.feed.generator", feed.collection);
378
378
-
try std.testing.expectEqualStrings("whats-hot", feed.rkey);
379
379
-
try std.testing.expect(parseFeedUri("at://did:plc:abc/app.bsky.feed.generator") == null);
380
380
-
try std.testing.expect(parseFeedUri("https://example.com") == null);
381
381
-
}
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const config = @import("../core/config.zig");
4
4
-
const http_api = @import("../http/api.zig");
5
5
-
const store = @import("../storage/store.zig");
6
6
-
const sync = @import("sync.zig");
7
7
-
const zat = @import("zat");
8
8
-
9
9
-
const http = std.http;
10
10
-
11
11
-
pub fn createRecord(request: *http.Server.Request) !void {
12
12
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
13
13
-
defer arena.deinit();
14
14
-
const allocator = arena.allocator();
15
15
-
16
16
-
const account = requireAccount(request, allocator) catch return;
17
17
-
var body_buf: [65536]u8 = undefined;
18
18
-
const body = try http_api.readBody(request, &body_buf);
19
19
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
20
20
-
try requireRepoMatches(request, account, parsed.value);
21
21
-
const collection = zat.json.getString(parsed.value, "collection") orelse {
22
22
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
23
23
-
};
24
24
-
const rkey = zat.json.getString(parsed.value, "rkey");
25
25
-
const record_value = recordBodyValue(parsed.value) orelse {
26
26
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing record");
27
27
-
};
28
28
-
29
29
-
const result = store.applyWrites(allocator, account, &.{.{ .create = .{
30
30
-
.collection = collection,
31
31
-
.rkey = rkey,
32
32
-
.value = record_value,
33
33
-
} }}) catch |err| switch (err) {
34
34
-
error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
35
35
-
error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
36
36
-
error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"),
37
37
-
else => return err,
38
38
-
};
39
39
-
sync.requestConfiguredCrawls(allocator);
40
40
-
return writeRecordRef(request, allocator, result.records[0], result.commit);
41
41
-
}
42
42
-
43
43
-
pub fn putRecord(request: *http.Server.Request) !void {
44
44
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
45
45
-
defer arena.deinit();
46
46
-
const allocator = arena.allocator();
47
47
-
48
48
-
const account = requireAccount(request, allocator) catch return;
49
49
-
var body_buf: [65536]u8 = undefined;
50
50
-
const body = try http_api.readBody(request, &body_buf);
51
51
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
52
52
-
try requireRepoMatches(request, account, parsed.value);
53
53
-
const collection = zat.json.getString(parsed.value, "collection") orelse {
54
54
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
55
55
-
};
56
56
-
const rkey = zat.json.getString(parsed.value, "rkey") orelse {
57
57
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
58
58
-
};
59
59
-
const record_value = recordBodyValue(parsed.value) orelse {
60
60
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing record");
61
61
-
};
62
62
-
63
63
-
const result = store.applyWrites(allocator, account, &.{.{ .update = .{
64
64
-
.collection = collection,
65
65
-
.rkey = rkey,
66
66
-
.value = record_value,
67
67
-
} }}) catch |err| switch (err) {
68
68
-
error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
69
69
-
error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
70
70
-
error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"),
71
71
-
else => return err,
72
72
-
};
73
73
-
sync.requestConfiguredCrawls(allocator);
74
74
-
return writeRecordRef(request, allocator, result.records[0], result.commit);
75
75
-
}
76
76
-
77
77
-
pub fn describeRepo(request: *http.Server.Request) !void {
78
78
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
79
79
-
defer arena.deinit();
80
80
-
const allocator = arena.allocator();
81
81
-
82
82
-
var repo_buf: [256]u8 = undefined;
83
83
-
const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse {
84
84
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo");
85
85
-
};
86
86
-
const account = store.resolveRepo(repo) orelse {
87
87
-
return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found");
88
88
-
};
89
89
-
const collections = try store.listCollectionsJson(allocator, account.did);
90
90
-
const body = try std.fmt.allocPrint(
91
91
-
allocator,
92
92
-
"{{\"handle\":{f},\"did\":{f},\"didDoc\":{{\"id\":{f},\"alsoKnownAs\":[{f}],\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}},\"collections\":{s},\"handleIsCorrect\":true}}",
93
93
-
.{
94
94
-
std.json.fmt(account.handle, .{}),
95
95
-
std.json.fmt(account.did, .{}),
96
96
-
std.json.fmt(account.did, .{}),
97
97
-
std.json.fmt(try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}), .{}),
98
98
-
std.json.fmt(config.publicUrl(), .{}),
99
99
-
collections,
100
100
-
},
101
101
-
);
102
102
-
return http_api.json(request, .ok, body);
103
103
-
}
104
104
-
105
105
-
pub fn getRecord(request: *http.Server.Request) !void {
106
106
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
107
107
-
defer arena.deinit();
108
108
-
const allocator = arena.allocator();
109
109
-
110
110
-
var repo_buf: [256]u8 = undefined;
111
111
-
const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse {
112
112
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo");
113
113
-
};
114
114
-
const account = store.resolveRepo(repo) orelse {
115
115
-
return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found");
116
116
-
};
117
117
-
var collection_buf: [256]u8 = undefined;
118
118
-
const collection = http_api.queryParam(request.head.target, "collection", &collection_buf) orelse {
119
119
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
120
120
-
};
121
121
-
var rkey_buf: [512]u8 = undefined;
122
122
-
const rkey = http_api.queryParam(request.head.target, "rkey", &rkey_buf) orelse {
123
123
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
124
124
-
};
125
125
-
126
126
-
const record = store.get(account.did, collection, rkey) orelse {
127
127
-
return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found");
128
128
-
};
129
129
-
const body = try store.writeRecordJson(allocator, record);
130
130
-
return http_api.json(request, .ok, body);
131
131
-
}
132
132
-
133
133
-
pub fn listRecords(request: *http.Server.Request) !void {
134
134
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
135
135
-
defer arena.deinit();
136
136
-
const allocator = arena.allocator();
137
137
-
138
138
-
var repo_buf: [256]u8 = undefined;
139
139
-
const repo = http_api.queryParam(request.head.target, "repo", &repo_buf) orelse {
140
140
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo");
141
141
-
};
142
142
-
const account = store.resolveRepo(repo) orelse {
143
143
-
return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found");
144
144
-
};
145
145
-
var collection_buf: [256]u8 = undefined;
146
146
-
const collection = http_api.queryParam(request.head.target, "collection", &collection_buf) orelse {
147
147
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
148
148
-
};
149
149
-
const body = try store.writeListJson(allocator, account.did, collection, @min(http_api.queryLimit(request.head.target, 50), 100));
150
150
-
return http_api.json(request, .ok, body);
151
151
-
}
152
152
-
153
153
-
pub fn deleteRecord(request: *http.Server.Request) !void {
154
154
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
155
155
-
defer arena.deinit();
156
156
-
const allocator = arena.allocator();
157
157
-
158
158
-
const account = requireAccount(request, allocator) catch return;
159
159
-
var body_buf: [4096]u8 = undefined;
160
160
-
const body = try http_api.readBody(request, &body_buf);
161
161
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
162
162
-
try requireRepoMatches(request, account, parsed.value);
163
163
-
const collection = zat.json.getString(parsed.value, "collection") orelse {
164
164
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
165
165
-
};
166
166
-
const rkey = zat.json.getString(parsed.value, "rkey") orelse {
167
167
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
168
168
-
};
169
169
-
_ = store.delete(allocator, account, collection, rkey) catch |err| switch (err) {
170
170
-
error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
171
171
-
error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
172
172
-
else => return err,
173
173
-
};
174
174
-
sync.requestConfiguredCrawls(allocator);
175
175
-
return http_api.json(request, .ok, "{}");
176
176
-
}
177
177
-
178
178
-
pub fn applyWrites(request: *http.Server.Request) !void {
179
179
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
180
180
-
defer arena.deinit();
181
181
-
const allocator = arena.allocator();
182
182
-
183
183
-
const account = requireAccount(request, allocator) catch return;
184
184
-
var body_buf: [131072]u8 = undefined;
185
185
-
const body = try http_api.readBody(request, &body_buf);
186
186
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
187
187
-
try requireRepoMatches(request, account, parsed.value);
188
188
-
const writes = switch (parsed.value) {
189
189
-
.object => |object| object.get("writes") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing writes"),
190
190
-
else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
191
191
-
};
192
192
-
if (writes != .array) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "writes must be an array");
193
193
-
194
194
-
var staged: std.ArrayList(store.WriteOp) = .empty;
195
195
-
for (writes.array.items, 0..) |write, idx| {
196
196
-
if (write != .object) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid write");
197
197
-
const type_name = http_api.valueString(write, "$type") orelse {
198
198
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing write $type");
199
199
-
};
200
200
-
const collection = http_api.valueString(write, "collection") orelse {
201
201
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
202
202
-
};
203
203
-
_ = idx;
204
204
-
205
205
-
if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#create")) {
206
206
-
const record_value = write.object.get("value") orelse {
207
207
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing value");
208
208
-
};
209
209
-
try staged.append(allocator, .{ .create = .{
210
210
-
.collection = collection,
211
211
-
.rkey = http_api.valueString(write, "rkey"),
212
212
-
.value = record_value,
213
213
-
} });
214
214
-
} else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#update")) {
215
215
-
const rkey = http_api.valueString(write, "rkey") orelse {
216
216
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
217
217
-
};
218
218
-
const record_value = write.object.get("value") orelse {
219
219
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing value");
220
220
-
};
221
221
-
try staged.append(allocator, .{ .update = .{
222
222
-
.collection = collection,
223
223
-
.rkey = rkey,
224
224
-
.value = record_value,
225
225
-
} });
226
226
-
} else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#delete")) {
227
227
-
const rkey = http_api.valueString(write, "rkey") orelse {
228
228
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
229
229
-
};
230
230
-
try staged.append(allocator, .{ .delete = .{
231
231
-
.collection = collection,
232
232
-
.rkey = rkey,
233
233
-
} });
234
234
-
} else {
235
235
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Unknown write operation type");
236
236
-
}
237
237
-
}
238
238
-
const result = store.applyWrites(allocator, account, staged.items) catch |err| switch (err) {
239
239
-
error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
240
240
-
error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
241
241
-
error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"),
242
242
-
else => return err,
243
243
-
};
244
244
-
245
245
-
var out: std.Io.Writer.Allocating = .init(allocator);
246
246
-
defer out.deinit();
247
247
-
try out.writer.writeByte('[');
248
248
-
var record_idx: usize = 0;
249
249
-
for (staged.items, 0..) |op, idx| {
250
250
-
if (idx != 0) try out.writer.writeByte(',');
251
251
-
switch (op) {
252
252
-
.create => {
253
253
-
const record = result.records[record_idx];
254
254
-
record_idx += 1;
255
255
-
const uri = try record.uri(allocator);
256
256
-
try out.writer.print(
257
257
-
"{{\"$type\":\"com.atproto.repo.applyWrites#createResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}",
258
258
-
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) },
259
259
-
);
260
260
-
},
261
261
-
.update => {
262
262
-
const record = result.records[record_idx];
263
263
-
record_idx += 1;
264
264
-
const uri = try record.uri(allocator);
265
265
-
try out.writer.print(
266
266
-
"{{\"$type\":\"com.atproto.repo.applyWrites#updateResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}",
267
267
-
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) },
268
268
-
);
269
269
-
},
270
270
-
.delete => try out.writer.writeAll("{\"$type\":\"com.atproto.repo.applyWrites#deleteResult\"}"),
271
271
-
}
272
272
-
}
273
273
-
try out.writer.writeByte(']');
274
274
-
const body_out = try std.fmt.allocPrint(
275
275
-
allocator,
276
276
-
"{{\"commit\":{{\"cid\":{f},\"rev\":{f}}},\"results\":{s}}}",
277
277
-
.{ std.json.fmt(result.commit.cid, .{}), std.json.fmt(result.commit.rev, .{}), out.written() },
278
278
-
);
279
279
-
sync.requestConfiguredCrawls(allocator);
280
280
-
return http_api.json(request, .ok, body_out);
281
281
-
}
282
282
-
283
283
-
pub fn importRepo(request: *http.Server.Request) !void {
284
284
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
285
285
-
defer arena.deinit();
286
286
-
const allocator = arena.allocator();
287
287
-
288
288
-
const account = requireAccount(request, allocator) catch return;
289
289
-
const body = http_api.readBodyAlloc(request, allocator, 128 * 1024 * 1024) catch |err| switch (err) {
290
290
-
error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "repo CAR is too large"),
291
291
-
else => return err,
292
292
-
};
293
293
-
294
294
-
const loaded = zat.loadCommitFromCAR(allocator, body) catch {
295
295
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid repo CAR");
296
296
-
};
297
297
-
if (!std.mem.eql(u8, loaded.commit.did, account.did)) {
298
298
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo DID does not match session");
299
299
-
}
300
300
-
try verifyImportedRepoCar(request, allocator, account.did, body);
301
301
-
302
302
-
const tree = zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid) catch {
303
303
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR is missing MST blocks");
304
304
-
};
305
305
-
306
306
-
var imported_records: std.ArrayList(store.ImportedRecord) = .empty;
307
307
-
collectImportedRecords(allocator, loaded.repo_car, tree.root, &imported_records) catch {
308
308
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR contains records outside the atproto data model");
309
309
-
};
310
310
-
311
311
-
var imported_blocks: std.ArrayList(store.ImportedBlock) = .empty;
312
312
-
for (loaded.repo_car.blocks) |block| {
313
313
-
try imported_blocks.append(allocator, .{
314
314
-
.cid = try cidString(allocator, block.cid_raw),
315
315
-
.data = block.data,
316
316
-
});
317
317
-
}
318
318
-
319
319
-
try store.importRepo(
320
320
-
allocator,
321
321
-
account,
322
322
-
try cidString(allocator, loaded.commit_cid),
323
323
-
loaded.commit.rev,
324
324
-
imported_records.items,
325
325
-
imported_blocks.items,
326
326
-
);
327
327
-
sync.requestConfiguredCrawls(allocator);
328
328
-
return http_api.json(request, .ok, "{}");
329
329
-
}
330
330
-
331
331
-
fn verifyImportedRepoCar(request: *http.Server.Request, allocator: std.mem.Allocator, did: []const u8, body: []const u8) !void {
332
332
-
var resolver = zat.DidResolver.init(store.currentIo(), allocator);
333
333
-
defer resolver.deinit();
334
334
-
var doc = resolver.resolve(zat.Did.parse(did).?) catch {
335
335
-
return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve repo did");
336
336
-
};
337
337
-
defer doc.deinit();
338
338
-
const signing_key = doc.signingKey() orelse {
339
339
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document is missing atproto signing key");
340
340
-
};
341
341
-
const key_bytes = zat.multibase.decode(allocator, signing_key.public_key_multibase) catch {
342
342
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document has invalid signing key");
343
343
-
};
344
344
-
const public_key = zat.multicodec.parsePublicKey(key_bytes) catch {
345
345
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID document has invalid signing key");
346
346
-
};
347
347
-
_ = zat.verifyCommitCar(allocator, body, public_key, .{
348
348
-
.expected_did = did,
349
349
-
.max_car_size = body.len,
350
350
-
.max_blocks = body.len,
351
351
-
}) catch {
352
352
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "repo CAR verification failed");
353
353
-
};
354
354
-
}
355
355
-
356
356
-
pub fn uploadBlob(io: std.Io, request: *http.Server.Request) !void {
357
357
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
358
358
-
defer arena.deinit();
359
359
-
const allocator = arena.allocator();
360
360
-
361
361
-
const account = requireAccount(request, allocator) catch return;
362
362
-
const mime_type = try allocator.dupe(u8, http_api.headerValue(request, "content-type") orelse "application/octet-stream");
363
363
-
const body = http_api.readBodyAlloc(request, allocator, config.blobUploadLimit()) catch |err| switch (err) {
364
364
-
error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "blob is too large"),
365
365
-
else => return err,
366
366
-
};
367
367
-
const cid = store.putBlob(allocator, io, account, body, mime_type) catch |err| {
368
368
-
return http_api.xrpcError(request, .internal_server_error, "InternalServerError", @errorName(err));
369
369
-
};
370
370
-
const body_out = try uploadBlobResponseJson(allocator, cid, mime_type, body.len);
371
371
-
return http_api.json(request, .ok, body_out);
372
372
-
}
373
373
-
374
374
-
fn uploadBlobResponseJson(
375
375
-
allocator: std.mem.Allocator,
376
376
-
cid: []const u8,
377
377
-
mime_type: []const u8,
378
378
-
size: usize,
379
379
-
) ![]const u8 {
380
380
-
const Link = struct {
381
381
-
@"$link": []const u8,
382
382
-
};
383
383
-
const Blob = struct {
384
384
-
@"$type": []const u8,
385
385
-
@"ref": Link,
386
386
-
mimeType: []const u8,
387
387
-
size: usize,
388
388
-
};
389
389
-
const Response = struct {
390
390
-
blob: Blob,
391
391
-
};
392
392
-
var out: std.Io.Writer.Allocating = .init(allocator);
393
393
-
defer out.deinit();
394
394
-
try std.json.Stringify.value(Response{
395
395
-
.blob = .{
396
396
-
.@"$type" = "blob",
397
397
-
.@"ref" = .{ .@"$link" = cid },
398
398
-
.mimeType = mime_type,
399
399
-
.size = size,
400
400
-
},
401
401
-
}, .{}, &out.writer);
402
402
-
return out.toOwnedSlice();
403
403
-
}
404
404
-
405
405
-
pub fn listMissingBlobs(request: *http.Server.Request) !void {
406
406
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
407
407
-
defer arena.deinit();
408
408
-
const allocator = arena.allocator();
409
409
-
410
410
-
const account = requireAccount(request, allocator) catch return;
411
411
-
const body = try store.writeMissingBlobsJson(allocator, account.did, @min(http_api.queryLimit(request.head.target, 500), 1000));
412
412
-
return http_api.json(request, .ok, body);
413
413
-
}
414
414
-
415
415
-
fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
416
416
-
return http_api.requireBearerAccount(request, allocator) catch |err| {
417
417
-
switch (err) {
418
418
-
error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
419
419
-
error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
420
420
-
}
421
421
-
return error.HandledResponse;
422
422
-
};
423
423
-
}
424
424
-
425
425
-
fn requireRepoMatches(request: *http.Server.Request, account: auth.Account, value: std.json.Value) !void {
426
426
-
const repo = zat.json.getString(value, "repo") orelse {
427
427
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo");
428
428
-
};
429
429
-
if (std.mem.eql(u8, repo, account.did) or std.ascii.eqlIgnoreCase(repo, account.handle)) return;
430
430
-
return http_api.xrpcError(request, .bad_request, "InvalidRepo", "Repo does not match authenticated user");
431
431
-
}
432
432
-
433
433
-
fn recordBodyValue(value: std.json.Value) ?std.json.Value {
434
434
-
return switch (value) {
435
435
-
.object => |object| object.get("record"),
436
436
-
else => null,
437
437
-
};
438
438
-
}
439
439
-
440
440
-
fn writeRecordRef(request: *http.Server.Request, allocator: std.mem.Allocator, record: store.Record, commit: store.CommitInfo) !void {
441
441
-
const uri = try record.uri(allocator);
442
442
-
const body_out = try std.fmt.allocPrint(
443
443
-
allocator,
444
444
-
"{{\"uri\":{f},\"cid\":{f},\"commit\":{{\"cid\":{f},\"rev\":{f}}}}}",
445
445
-
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), std.json.fmt(commit.cid, .{}), std.json.fmt(commit.rev, .{}) },
446
446
-
);
447
447
-
return http_api.json(request, .ok, body_out);
448
448
-
}
449
449
-
450
450
-
fn collectImportedRecords(
451
451
-
allocator: std.mem.Allocator,
452
452
-
repo_car: zat.car.Car,
453
453
-
maybe_node: ?*zat.mst.Node,
454
454
-
out: *std.ArrayList(store.ImportedRecord),
455
455
-
) anyerror!void {
456
456
-
const node = maybe_node orelse return;
457
457
-
try collectFromChild(allocator, repo_car, node.left, out);
458
458
-
for (node.entries.items) |entry| {
459
459
-
const block = zat.car.findBlock(repo_car, entry.value.raw) orelse return error.MissingRecordBlock;
460
460
-
const record_value = try zat.cbor.decodeAll(allocator, block);
461
461
-
const record_json = try cborToJson(allocator, record_value);
462
462
-
var blobs: std.ArrayList([]const u8) = .empty;
463
463
-
try collectBlobCids(allocator, record_value, &blobs);
464
464
-
const slash = std.mem.indexOfScalar(u8, entry.key, '/') orelse return error.InvalidRepoPath;
465
465
-
try out.append(allocator, .{
466
466
-
.collection = try allocator.dupe(u8, entry.key[0..slash]),
467
467
-
.rkey = try allocator.dupe(u8, entry.key[slash + 1 ..]),
468
468
-
.cid = try cidString(allocator, entry.value.raw),
469
469
-
.value_json = record_json,
470
470
-
.blob_cids = try blobs.toOwnedSlice(allocator),
471
471
-
});
472
472
-
try collectFromChild(allocator, repo_car, entry.right, out);
473
473
-
}
474
474
-
}
475
475
-
476
476
-
fn collectFromChild(
477
477
-
allocator: std.mem.Allocator,
478
478
-
repo_car: zat.car.Car,
479
479
-
child: zat.mst.ChildRef,
480
480
-
out: *std.ArrayList(store.ImportedRecord),
481
481
-
) anyerror!void {
482
482
-
switch (child) {
483
483
-
.none => {},
484
484
-
.stub => return error.PartialTree,
485
485
-
.node => |node| try collectImportedRecords(allocator, repo_car, node, out),
486
486
-
}
487
487
-
}
488
488
-
489
489
-
fn cborToJson(allocator: std.mem.Allocator, value: zat.cbor.Value) ![]const u8 {
490
490
-
var out: std.Io.Writer.Allocating = .init(allocator);
491
491
-
defer out.deinit();
492
492
-
try writeCborJson(allocator, &out.writer, value);
493
493
-
return out.toOwnedSlice();
494
494
-
}
495
495
-
496
496
-
fn writeCborJson(allocator: std.mem.Allocator, writer: anytype, value: zat.cbor.Value) !void {
497
497
-
switch (value) {
498
498
-
.unsigned => |v| try writer.print("{d}", .{v}),
499
499
-
.negative => |v| try writer.print("{d}", .{v}),
500
500
-
.bytes => |bytes| {
501
501
-
const encoded = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len));
502
502
-
defer allocator.free(encoded);
503
503
-
_ = std.base64.standard.Encoder.encode(encoded, bytes);
504
504
-
try writer.print("{{\"$bytes\":{f}}}", .{std.json.fmt(encoded, .{})});
505
505
-
},
506
506
-
.text => |text| try writer.print("{f}", .{std.json.fmt(text, .{})}),
507
507
-
.array => |items| {
508
508
-
try writer.writeByte('[');
509
509
-
for (items, 0..) |item, idx| {
510
510
-
if (idx != 0) try writer.writeByte(',');
511
511
-
try writeCborJson(allocator, writer, item);
512
512
-
}
513
513
-
try writer.writeByte(']');
514
514
-
},
515
515
-
.map => |entries| {
516
516
-
try writer.writeByte('{');
517
517
-
for (entries, 0..) |entry, idx| {
518
518
-
if (idx != 0) try writer.writeByte(',');
519
519
-
try writer.print("{f}:", .{std.json.fmt(entry.key, .{})});
520
520
-
try writeCborJson(allocator, writer, entry.value);
521
521
-
}
522
522
-
try writer.writeByte('}');
523
523
-
},
524
524
-
.boolean => |v| try writer.print("{}", .{v}),
525
525
-
.null => try writer.writeAll("null"),
526
526
-
.cid => |cid| try writer.print("{{\"$link\":{f}}}", .{std.json.fmt(try cidString(allocator, cid.raw), .{})}),
527
527
-
}
528
528
-
}
529
529
-
530
530
-
fn collectBlobCids(allocator: std.mem.Allocator, value: zat.cbor.Value, out: *std.ArrayList([]const u8)) !void {
531
531
-
switch (value) {
532
532
-
.map => |entries| {
533
533
-
if (mapText(entries, "$type")) |type_name| {
534
534
-
if (std.mem.eql(u8, type_name, "blob")) {
535
535
-
if (mapValue(entries, "ref")) |ref| switch (ref) {
536
536
-
.cid => |cid| try out.append(allocator, try cidString(allocator, cid.raw)),
537
537
-
.map => |ref_entries| {
538
538
-
if (mapText(ref_entries, "$link")) |link| try out.append(allocator, try allocator.dupe(u8, link));
539
539
-
},
540
540
-
else => {},
541
541
-
};
542
542
-
}
543
543
-
}
544
544
-
for (entries) |entry| try collectBlobCids(allocator, entry.value, out);
545
545
-
},
546
546
-
.array => |items| for (items) |item| try collectBlobCids(allocator, item, out),
547
547
-
else => {},
548
548
-
}
549
549
-
}
550
550
-
551
551
-
fn mapValue(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?zat.cbor.Value {
552
552
-
for (entries) |entry| {
553
553
-
if (std.mem.eql(u8, entry.key, key)) return entry.value;
554
554
-
}
555
555
-
return null;
556
556
-
}
557
557
-
558
558
-
fn mapText(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?[]const u8 {
559
559
-
return switch (mapValue(entries, key) orelse return null) {
560
560
-
.text => |text| text,
561
561
-
else => null,
562
562
-
};
563
563
-
}
564
564
-
565
565
-
fn cidString(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 {
566
566
-
return zat.multibase.base32lower.encode(allocator, raw);
567
567
-
}
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const config = @import("../core/config.zig");
4
4
-
const log = @import("../core/log.zig");
5
5
-
const mail = @import("../core/mail.zig");
6
6
-
const plc = @import("plc.zig");
7
7
-
const sync = @import("sync.zig");
8
8
-
const http_api = @import("../http/api.zig");
9
9
-
const store = @import("../storage/store.zig");
10
10
-
const zat = @import("zat");
11
11
-
12
12
-
const http = std.http;
13
13
-
14
14
-
pub fn describeServer(request: *http.Server.Request) !void {
15
15
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
16
16
-
defer arena.deinit();
17
17
-
const domains = try jsonStringArray(arena.allocator(), config.handleDomains());
18
18
-
const body = try std.fmt.allocPrint(
19
19
-
arena.allocator(),
20
20
-
"{{\"did\":{f},\"availableUserDomains\":{s},\"inviteCodeRequired\":false}}",
21
21
-
.{ std.json.fmt(config.serverDid(), .{}), domains },
22
22
-
);
23
23
-
return http_api.json(request, .ok, body);
24
24
-
}
25
25
-
26
26
-
pub fn didJson(request: *http.Server.Request) !void {
27
27
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
28
28
-
defer arena.deinit();
29
29
-
const body = try std.fmt.allocPrint(
30
30
-
arena.allocator(),
31
31
-
"{{\"@context\":[\"https://www.w3.org/ns/did/v1\"],\"id\":{f},\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}}",
32
32
-
.{ std.json.fmt(config.serverDid(), .{}), std.json.fmt(config.publicUrl(), .{}) },
33
33
-
);
34
34
-
return http_api.json(request, .ok, body);
35
35
-
}
36
36
-
37
37
-
pub fn atprotoDid(request: *http.Server.Request) !void {
38
38
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
39
39
-
defer arena.deinit();
40
40
-
const allocator = arena.allocator();
41
41
-
const host = requestHost(request) orelse {
42
42
-
return plain(request, .not_found, "User not found");
43
43
-
};
44
44
-
const handle = stripPort(host);
45
45
-
const account = store.findAccount(allocator, handle) catch null orelse {
46
46
-
return plain(request, .not_found, "User not found");
47
47
-
};
48
48
-
return plain(request, .ok, account.did);
49
49
-
}
50
50
-
51
51
-
pub fn createAccount(request: *http.Server.Request) !void {
52
52
-
const authorization = http_api.headerValue(request, "authorization");
53
53
-
var body_buf: [8192]u8 = undefined;
54
54
-
const body = try http_api.readBody(request, &body_buf);
55
55
-
56
56
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
57
57
-
defer arena.deinit();
58
58
-
const allocator = arena.allocator();
59
59
-
60
60
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
61
61
-
const handle = zat.json.getString(parsed.value, "handle") orelse {
62
62
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle");
63
63
-
};
64
64
-
const email = zat.json.getString(parsed.value, "email") orelse {
65
65
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email");
66
66
-
};
67
67
-
const password = zat.json.getString(parsed.value, "password") orelse {
68
68
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password");
69
69
-
};
70
70
-
if (!isValidEmail(email)) {
71
71
-
return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email");
72
72
-
}
73
73
-
if (zat.Handle.parse(handle) == null) {
74
74
-
return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid handle");
75
75
-
}
76
76
-
77
77
-
const existing_did = zat.json.getString(parsed.value, "did");
78
78
-
const account = if (existing_did) |did| account: {
79
79
-
if (zat.Did.parse(did) == null) {
80
80
-
log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle });
81
81
-
return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did");
82
82
-
}
83
83
-
log.debug("xrpc createAccount migration attempt did={s} handle={s}\n", .{ did, handle });
84
84
-
try verifyCreateAccountServiceAuth(request, allocator, authorization, did);
85
85
-
break :account store.createAccount(allocator, handle, email, password, did, false) catch {
86
86
-
log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle });
87
87
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists");
88
88
-
};
89
89
-
} else account: {
90
90
-
log.debug("xrpc createAccount genesis attempt handle={s}\n", .{handle});
91
91
-
const signing_key = store.generateAccountSigningKey() catch {
92
92
-
log.err("xrpc createAccount failed signing_key handle={s}\n", .{handle});
93
93
-
return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to generate account signing key");
94
94
-
};
95
95
-
const keypair = zat.Keypair.fromSecretKey(.secp256k1, signing_key) catch {
96
96
-
log.err("xrpc createAccount failed signing_key_parse handle={s}\n", .{handle});
97
97
-
return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to prepare account signing key");
98
98
-
};
99
99
-
const operation = plc.createGenesisOperation(allocator, handle, &keypair, &keypair) catch {
100
100
-
log.err("xrpc createAccount failed plc_operation handle={s}\n", .{handle});
101
101
-
return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to create DID operation");
102
102
-
};
103
103
-
plc.submitOperation(allocator, operation.did, operation.json) catch {
104
104
-
log.err("xrpc createAccount failed plc_submit did={s} handle={s}\n", .{ operation.did, handle });
105
105
-
return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected account DID operation");
106
106
-
};
107
107
-
break :account store.createAccountWithSigningKey(allocator, handle, email, password, operation.did, true, signing_key) catch {
108
108
-
log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ operation.did, handle });
109
109
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists");
110
110
-
};
111
111
-
};
112
112
-
log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle });
113
113
-
114
114
-
const access = try auth.createSessionJwt(allocator, "access", account);
115
115
-
const refresh = try auth.createSessionJwt(allocator, "refresh", account);
116
116
-
const body_out = try std.fmt.allocPrint(
117
117
-
allocator,
118
118
-
"{{\"did\":\"{s}\",\"handle\":\"{s}\",\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\"}}",
119
119
-
.{ account.did, account.handle, access, refresh },
120
120
-
);
121
121
-
try http_api.json(request, .ok, body_out);
122
122
-
}
123
123
-
124
124
-
fn jsonStringArray(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 {
125
125
-
var out: std.Io.Writer.Allocating = .init(allocator);
126
126
-
defer out.deinit();
127
127
-
try out.writer.writeByte('[');
128
128
-
var parts = std.mem.splitScalar(u8, raw, ',');
129
129
-
var index: usize = 0;
130
130
-
while (parts.next()) |part| {
131
131
-
const trimmed = std.mem.trim(u8, part, " \t\r\n");
132
132
-
if (trimmed.len == 0) continue;
133
133
-
if (index != 0) try out.writer.writeByte(',');
134
134
-
try out.writer.print("{f}", .{std.json.fmt(trimmed, .{})});
135
135
-
index += 1;
136
136
-
}
137
137
-
try out.writer.writeByte(']');
138
138
-
return out.toOwnedSlice();
139
139
-
}
140
140
-
141
141
-
fn requestHost(request: *const http.Server.Request) ?[]const u8 {
142
142
-
return http_api.headerValue(request, "host") orelse http_api.headerValue(request, ":authority");
143
143
-
}
144
144
-
145
145
-
fn stripPort(host: []const u8) []const u8 {
146
146
-
if (std.mem.startsWith(u8, host, "[")) return host;
147
147
-
if (std.mem.lastIndexOfScalar(u8, host, ':')) |idx| return host[0..idx];
148
148
-
return host;
149
149
-
}
150
150
-
151
151
-
fn plain(request: *http.Server.Request, status: http.Status, body: []const u8) !void {
152
152
-
try request.respond(body, .{
153
153
-
.status = status,
154
154
-
.extra_headers = &plain_headers,
155
155
-
});
156
156
-
}
157
157
-
158
158
-
const plain_headers = [_]http.Header{
159
159
-
.{ .name = "content-type", .value = "text/plain; charset=utf-8" },
160
160
-
.{ .name = "access-control-allow-origin", .value = "*" },
161
161
-
.{ .name = "connection", .value = "close" },
162
162
-
};
163
163
-
164
164
-
fn verifyCreateAccountServiceAuth(request: *http.Server.Request, allocator: std.mem.Allocator, maybe_authorization: ?[]const u8, did: []const u8) !void {
165
165
-
const raw_header = maybe_authorization orelse {
166
166
-
log.debug("xrpc createAccount service_auth missing did={s}\n", .{did});
167
167
-
return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did");
168
168
-
};
169
169
-
if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) {
170
170
-
log.debug("xrpc createAccount service_auth malformed did={s}\n", .{did});
171
171
-
return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did");
172
172
-
}
173
173
-
const token = std.mem.trim(u8, raw_header["bearer ".len..], " \t");
174
174
-
var jwt = zat.Jwt.parse(allocator, token) catch {
175
175
-
log.debug("xrpc createAccount service_auth invalid_jwt did={s}\n", .{did});
176
176
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "invalid service auth token");
177
177
-
};
178
178
-
defer jwt.deinit();
179
179
-
180
180
-
const issuer_did = issuerDid(jwt.payload.iss);
181
181
-
if (!std.mem.eql(u8, issuer_did, did)) {
182
182
-
log.debug("xrpc createAccount service_auth issuer_mismatch did={s} issuer={s}\n", .{ did, issuer_did });
183
183
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt issuer does not match did");
184
184
-
}
185
185
-
if (!std.mem.eql(u8, jwt.payload.aud, config.serverDid())) {
186
186
-
log.debug("xrpc createAccount service_auth aud_mismatch did={s} aud={s} expected={s}\n", .{ did, jwt.payload.aud, config.serverDid() });
187
187
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt audience does not match service did");
188
188
-
}
189
189
-
if (jwt.isExpired(store.currentIo())) {
190
190
-
log.debug("xrpc createAccount service_auth expired did={s}\n", .{did});
191
191
-
return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired");
192
192
-
}
193
193
-
if (jwt.payload.lxm) |lxm| {
194
194
-
if (!std.mem.eql(u8, lxm, "com.atproto.server.createAccount") and !std.mem.eql(u8, lxm, "*")) {
195
195
-
log.debug("xrpc createAccount service_auth lxm_mismatch did={s} lxm={s}\n", .{ did, lxm });
196
196
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm does not match com.atproto.server.createAccount");
197
197
-
}
198
198
-
} else {
199
199
-
log.debug("xrpc createAccount service_auth missing_lxm did={s}\n", .{did});
200
200
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm is required");
201
201
-
}
202
202
-
203
203
-
var resolver = zat.DidResolver.init(store.currentIo(), allocator);
204
204
-
defer resolver.deinit();
205
205
-
var doc = resolver.resolve(zat.Did.parse(issuer_did).?) catch {
206
206
-
log.debug("xrpc createAccount service_auth did_resolution_failed did={s}\n", .{did});
207
207
-
return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did");
208
208
-
};
209
209
-
defer doc.deinit();
210
210
-
const signing_key = doc.signingKey() orelse {
211
211
-
log.debug("xrpc createAccount service_auth missing_signing_key did={s}\n", .{did});
212
212
-
return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "missing signing key in issuer did doc");
213
213
-
};
214
214
-
jwt.verify(signing_key.public_key_multibase) catch {
215
215
-
log.debug("xrpc createAccount service_auth signature_mismatch did={s}\n", .{did});
216
216
-
return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt signature does not match jwt issuer");
217
217
-
};
218
218
-
log.debug("xrpc createAccount service_auth ok did={s}\n", .{did});
219
219
-
}
220
220
-
221
221
-
fn issuerDid(iss: []const u8) []const u8 {
222
222
-
return if (std.mem.indexOfScalar(u8, iss, '#')) |idx| iss[0..idx] else iss;
223
223
-
}
224
224
-
225
225
-
pub fn createSession(request: *http.Server.Request) !void {
226
226
-
var body_buf: [4096]u8 = undefined;
227
227
-
const body = try http_api.readBody(request, &body_buf);
228
228
-
229
229
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
230
230
-
defer arena.deinit();
231
231
-
232
232
-
const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), body, .{}) catch {
233
233
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected JSON body with identifier and password");
234
234
-
};
235
235
-
const identifier = zat.json.getString(parsed.value, "identifier") orelse {
236
236
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing identifier");
237
237
-
};
238
238
-
const password = zat.json.getString(parsed.value, "password") orelse {
239
239
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password");
240
240
-
};
241
241
-
242
242
-
log.debug("xrpc createSession attempt identifier={s}\n", .{identifier});
243
243
-
const account = (store.findAccount(arena.allocator(), identifier) catch null) orelse {
244
244
-
log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier});
245
245
-
return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password");
246
246
-
};
247
247
-
if (!auth.passwordMatches(account, password)) {
248
248
-
log.debug("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle });
249
249
-
return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password");
250
250
-
}
251
251
-
log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle });
252
252
-
253
253
-
const access = try auth.createSessionJwt(arena.allocator(), "access", account);
254
254
-
const refresh = try auth.createSessionJwt(arena.allocator(), "refresh", account);
255
255
-
const info = store.getEmailInfo(arena.allocator(), account.did) orelse {
256
256
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
257
257
-
};
258
258
-
const active = store.isAccountActive(account.did);
259
259
-
260
260
-
const body_out = try std.fmt.allocPrint(
261
261
-
arena.allocator(),
262
262
-
"{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}",
263
263
-
.{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active },
264
264
-
);
265
265
-
try http_api.json(request, .ok, body_out);
266
266
-
}
267
267
-
268
268
-
pub fn refreshSession(request: *http.Server.Request) !void {
269
269
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
270
270
-
defer arena.deinit();
271
271
-
const allocator = arena.allocator();
272
272
-
273
273
-
const account = http_api.requireBearerAccountWithScope(request, allocator, "com.atproto.refresh") catch |err| switch (err) {
274
274
-
error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
275
275
-
error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
276
276
-
};
277
277
-
const access = try auth.createSessionJwt(allocator, "access", account);
278
278
-
const refresh = try auth.createSessionJwt(allocator, "refresh", account);
279
279
-
const info = store.getEmailInfo(allocator, account.did) orelse {
280
280
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
281
281
-
};
282
282
-
const active = store.isAccountActive(account.did);
283
283
-
const body_out = try std.fmt.allocPrint(
284
284
-
allocator,
285
285
-
"{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}",
286
286
-
.{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active },
287
287
-
);
288
288
-
try http_api.json(request, .ok, body_out);
289
289
-
}
290
290
-
291
291
-
pub fn getSession(request: *http.Server.Request) !void {
292
292
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
293
293
-
defer arena.deinit();
294
294
-
const allocator = arena.allocator();
295
295
-
296
296
-
const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) {
297
297
-
error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
298
298
-
error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
299
299
-
};
300
300
-
301
301
-
const info = store.getEmailInfo(allocator, account.did) orelse {
302
302
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
303
303
-
};
304
304
-
const active = store.isAccountActive(account.did);
305
305
-
const body_out = try std.fmt.allocPrint(
306
306
-
allocator,
307
307
-
"{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"active\":{}}}",
308
308
-
.{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, active },
309
309
-
);
310
310
-
return http_api.json(request, .ok, body_out);
311
311
-
}
312
312
-
313
313
-
pub fn activateAccount(request: *http.Server.Request) !void {
314
314
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
315
315
-
defer arena.deinit();
316
316
-
const allocator = arena.allocator();
317
317
-
const account = requireAccount(request, allocator) catch return;
318
318
-
try store.setAccountActive(account.did, true);
319
319
-
try store.sequenceAccountEvent(allocator, account.did, true);
320
320
-
try store.sequenceIdentityEvent(allocator, account.did, account.handle);
321
321
-
try store.sequenceSyncEvent(allocator, account.did);
322
322
-
sync.requestConfiguredCrawls(allocator);
323
323
-
return http_api.json(request, .ok, "{}");
324
324
-
}
325
325
-
326
326
-
pub fn deactivateAccount(request: *http.Server.Request) !void {
327
327
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
328
328
-
defer arena.deinit();
329
329
-
const allocator = arena.allocator();
330
330
-
const account = requireAccount(request, allocator) catch return;
331
331
-
try store.setAccountActive(account.did, false);
332
332
-
try store.sequenceAccountEvent(allocator, account.did, false);
333
333
-
sync.requestConfiguredCrawls(allocator);
334
334
-
return http_api.json(request, .ok, "{}");
335
335
-
}
336
336
-
337
337
-
pub fn getServiceAuth(request: *http.Server.Request) !void {
338
338
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
339
339
-
defer arena.deinit();
340
340
-
const allocator = arena.allocator();
341
341
-
342
342
-
const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) {
343
343
-
error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
344
344
-
error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
345
345
-
};
346
346
-
347
347
-
var aud_buf: [256]u8 = undefined;
348
348
-
const audience = http_api.queryParam(request.head.target, "aud", &aud_buf) orelse {
349
349
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing aud");
350
350
-
};
351
351
-
if (zat.Did.parse(audience) == null) {
352
352
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid aud");
353
353
-
}
354
354
-
355
355
-
var lxm_buf: [256]u8 = undefined;
356
356
-
const lxm = http_api.queryParam(request.head.target, "lxm", &lxm_buf);
357
357
-
if (lxm) |method| {
358
358
-
if (zat.Nsid.parse(method) == null) {
359
359
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid lxm");
360
360
-
}
361
361
-
if (serviceAuthProtectedMethod(method)) {
362
362
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot request service auth for protected method");
363
363
-
}
364
364
-
}
365
365
-
366
366
-
var keypair = try store.signingKeypair(account.did);
367
367
-
const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, lxm, &keypair);
368
368
-
const body_out = try std.fmt.allocPrint(allocator, "{{\"token\":\"{s}\"}}", .{token});
369
369
-
return http_api.json(request, .ok, body_out);
370
370
-
}
371
371
-
372
372
-
fn serviceAuthProtectedMethod(method: []const u8) bool {
373
373
-
const protected = [_][]const u8{
374
374
-
"com.atproto.admin.sendEmail",
375
375
-
"com.atproto.identity.requestPlcOperationSignature",
376
376
-
"com.atproto.identity.signPlcOperation",
377
377
-
"com.atproto.identity.updateHandle",
378
378
-
"com.atproto.server.activateAccount",
379
379
-
"com.atproto.server.confirmEmail",
380
380
-
"com.atproto.server.createAppPassword",
381
381
-
"com.atproto.server.deactivateAccount",
382
382
-
"com.atproto.server.getAccountInviteCodes",
383
383
-
"com.atproto.server.getSession",
384
384
-
"com.atproto.server.listAppPasswords",
385
385
-
"com.atproto.server.requestAccountDelete",
386
386
-
"com.atproto.server.requestEmailConfirmation",
387
387
-
"com.atproto.server.requestEmailUpdate",
388
388
-
"com.atproto.server.revokeAppPassword",
389
389
-
"com.atproto.server.updateEmail",
390
390
-
};
391
391
-
for (protected) |item| {
392
392
-
if (std.ascii.eqlIgnoreCase(method, item)) return true;
393
393
-
}
394
394
-
return false;
395
395
-
}
396
396
-
397
397
-
pub fn requestEmailConfirmation(request: *http.Server.Request) !void {
398
398
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
399
399
-
defer arena.deinit();
400
400
-
const allocator = arena.allocator();
401
401
-
const account = requireAccount(request, allocator) catch return;
402
402
-
const info = store.getEmailInfo(allocator, account.did) orelse {
403
403
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
404
404
-
};
405
405
-
if (info.email_confirmed) {
406
406
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email already confirmed");
407
407
-
}
408
408
-
var code_buf: [11]u8 = undefined;
409
409
-
const code = makeCode(&code_buf);
410
410
-
try store.setAuthCode(account.did, code, expiresInTenMinutes());
411
411
-
try sendCode(allocator, info.email, account.handle, "Confirm email", "email confirmation", code);
412
412
-
return http_api.json(request, .ok, "{}");
413
413
-
}
414
414
-
415
415
-
pub fn confirmEmail(request: *http.Server.Request) !void {
416
416
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
417
417
-
defer arena.deinit();
418
418
-
const allocator = arena.allocator();
419
419
-
const account = requireAccount(request, allocator) catch return;
420
420
-
var body_buf: [4096]u8 = undefined;
421
421
-
const body = try http_api.readBody(request, &body_buf);
422
422
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
423
423
-
const email = zat.json.getString(parsed.value, "email") orelse {
424
424
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email");
425
425
-
};
426
426
-
const token = zat.json.getString(parsed.value, "token") orelse {
427
427
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing token");
428
428
-
};
429
429
-
const info = store.getEmailInfo(allocator, account.did) orelse {
430
430
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
431
431
-
};
432
432
-
if (!std.ascii.eqlIgnoreCase(info.email, email)) {
433
433
-
return http_api.xrpcError(request, .bad_request, "InvalidEmail", "email does not match");
434
434
-
}
435
435
-
switch (store.validateAuthCode(account.did, token, store.nowMs())) {
436
436
-
.valid => try store.confirmEmail(account.did),
437
437
-
.expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"),
438
438
-
.invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"),
439
439
-
}
440
440
-
return http_api.json(request, .ok, "{}");
441
441
-
}
442
442
-
443
443
-
pub fn requestEmailUpdate(request: *http.Server.Request) !void {
444
444
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
445
445
-
defer arena.deinit();
446
446
-
const allocator = arena.allocator();
447
447
-
const account = requireAccount(request, allocator) catch return;
448
448
-
const info = store.getEmailInfo(allocator, account.did) orelse {
449
449
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
450
450
-
};
451
451
-
if (!info.email_confirmed) {
452
452
-
return http_api.json(request, .ok, "{\"tokenRequired\":false}");
453
453
-
}
454
454
-
var code_buf: [11]u8 = undefined;
455
455
-
const code = makeCode(&code_buf);
456
456
-
try store.setAuthCode(account.did, code, expiresInTenMinutes());
457
457
-
try sendCode(allocator, info.email, account.handle, "Update email", "email update", code);
458
458
-
return http_api.json(request, .ok, "{\"tokenRequired\":true}");
459
459
-
}
460
460
-
461
461
-
pub fn updateEmail(request: *http.Server.Request) !void {
462
462
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
463
463
-
defer arena.deinit();
464
464
-
const allocator = arena.allocator();
465
465
-
const account = requireAccount(request, allocator) catch return;
466
466
-
var body_buf: [4096]u8 = undefined;
467
467
-
const body = try http_api.readBody(request, &body_buf);
468
468
-
const parsed = try http_api.parseJsonBody(request, allocator, body);
469
469
-
const email = zat.json.getString(parsed.value, "email") orelse {
470
470
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email");
471
471
-
};
472
472
-
if (!isValidEmail(email)) {
473
473
-
return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email");
474
474
-
}
475
475
-
const info = store.getEmailInfo(allocator, account.did) orelse {
476
476
-
return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found");
477
477
-
};
478
478
-
if (info.email_confirmed) {
479
479
-
const token = zat.json.getString(parsed.value, "token") orelse {
480
480
-
return http_api.xrpcError(request, .bad_request, "TokenRequired", "confirmation token required");
481
481
-
};
482
482
-
switch (store.validateAuthCode(account.did, token, store.nowMs())) {
483
483
-
.valid => {},
484
484
-
.expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"),
485
485
-
.invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"),
486
486
-
}
487
487
-
}
488
488
-
try store.updateEmail(account.did, email);
489
489
-
return http_api.json(request, .ok, "{}");
490
490
-
}
491
491
-
492
492
-
pub fn checkAccountStatus(request: *http.Server.Request) !void {
493
493
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
494
494
-
defer arena.deinit();
495
495
-
const allocator = arena.allocator();
496
496
-
const account = requireAccount(request, allocator) catch return;
497
497
-
const body = try store.writeAccountStatusJson(allocator, account.did);
498
498
-
return http_api.json(request, .ok, body);
499
499
-
}
500
500
-
501
501
-
fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
502
502
-
return http_api.requireBearerAccount(request, allocator) catch |err| {
503
503
-
switch (err) {
504
504
-
error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
505
505
-
error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
506
506
-
}
507
507
-
return error.HandledResponse;
508
508
-
};
509
509
-
}
510
510
-
511
511
-
fn expiresInTenMinutes() i64 {
512
512
-
return store.nowMs() + (10 * 60 * 1000);
513
513
-
}
514
514
-
515
515
-
fn makeCode(out: *[11]u8) []const u8 {
516
516
-
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
517
517
-
var random: [10]u8 = undefined;
518
518
-
store.randomBytes(&random);
519
519
-
for (random, 0..) |byte, idx| {
520
520
-
const write_idx = if (idx < 5) idx else idx + 1;
521
521
-
out[write_idx] = alphabet[byte % alphabet.len];
522
522
-
}
523
523
-
out[5] = '-';
524
524
-
return out[0..];
525
525
-
}
526
526
-
527
527
-
fn sendCode(
528
528
-
allocator: std.mem.Allocator,
529
529
-
email: []const u8,
530
530
-
handle: []const u8,
531
531
-
subject: []const u8,
532
532
-
label: []const u8,
533
533
-
code: []const u8,
534
534
-
) !void {
535
535
-
return mail.sendCode(store.currentIo(), allocator, email, handle, subject, label, code) catch {
536
536
-
return error.EmailDeliveryFailed;
537
537
-
};
538
538
-
}
539
539
-
540
540
-
fn isValidEmail(email: []const u8) bool {
541
541
-
const at = std.mem.indexOfScalar(u8, email, '@') orelse return false;
542
542
-
if (at == 0 or at + 1 >= email.len) return false;
543
543
-
return std.mem.indexOfScalar(u8, email[at + 1 ..], '.') != null;
544
544
-
}
···
1
1
-
const std = @import("std");
2
2
-
const config = @import("../core/config.zig");
3
3
-
const log = @import("../core/log.zig");
4
4
-
const http_api = @import("../http/api.zig");
5
5
-
const store = @import("../storage/store.zig");
6
6
-
const zat = @import("zat");
7
7
-
8
8
-
const http = std.http;
9
9
-
10
10
-
pub fn getBlob(request: *http.Server.Request) !void {
11
11
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
12
12
-
defer arena.deinit();
13
13
-
const allocator = arena.allocator();
14
14
-
15
15
-
var did_buf: [256]u8 = undefined;
16
16
-
const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse {
17
17
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did");
18
18
-
};
19
19
-
var cid_buf: [256]u8 = undefined;
20
20
-
const cid = http_api.queryParam(request.head.target, "cid", &cid_buf) orelse {
21
21
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid");
22
22
-
};
23
23
-
const blob = store.getBlob(allocator, did, cid) orelse {
24
24
-
return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found");
25
25
-
};
26
26
-
const headers = [_]http.Header{
27
27
-
.{ .name = "content-type", .value = blob.mime_type },
28
28
-
.{ .name = "cache-control", .value = "public, max-age=31536000, immutable" },
29
29
-
.{ .name = "x-content-type-options", .value = "nosniff" },
30
30
-
.{ .name = "access-control-allow-origin", .value = "*" },
31
31
-
.{ .name = "access-control-allow-private-network", .value = "true" },
32
32
-
.{ .name = "connection", .value = "close" },
33
33
-
};
34
34
-
try request.respond(blob.data, .{
35
35
-
.status = .ok,
36
36
-
.extra_headers = &headers,
37
37
-
});
38
38
-
}
39
39
-
40
40
-
pub fn getRepo(request: *http.Server.Request) !void {
41
41
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
42
42
-
defer arena.deinit();
43
43
-
const allocator = arena.allocator();
44
44
-
45
45
-
var did_buf: [256]u8 = undefined;
46
46
-
const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse {
47
47
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did");
48
48
-
};
49
49
-
const body = store.writeRepoCar(allocator, did) catch {
50
50
-
return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found");
51
51
-
};
52
52
-
const headers = [_]http.Header{
53
53
-
.{ .name = "content-type", .value = "application/vnd.ipld.car" },
54
54
-
.{ .name = "access-control-allow-origin", .value = "*" },
55
55
-
.{ .name = "access-control-allow-private-network", .value = "true" },
56
56
-
.{ .name = "connection", .value = "close" },
57
57
-
};
58
58
-
try request.respond(body, .{
59
59
-
.status = .ok,
60
60
-
.extra_headers = &headers,
61
61
-
});
62
62
-
}
63
63
-
64
64
-
pub fn listBlobs(request: *http.Server.Request) !void {
65
65
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
66
66
-
defer arena.deinit();
67
67
-
const allocator = arena.allocator();
68
68
-
69
69
-
var did_buf: [256]u8 = undefined;
70
70
-
const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse {
71
71
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did");
72
72
-
};
73
73
-
const body = try store.writeBlobListJson(allocator, did, @min(http_api.queryLimit(request.head.target, 500), 1000));
74
74
-
return http_api.json(request, .ok, body);
75
75
-
}
76
76
-
77
77
-
pub fn getLatestCommit(request: *http.Server.Request) !void {
78
78
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
79
79
-
defer arena.deinit();
80
80
-
const allocator = arena.allocator();
81
81
-
82
82
-
var did_buf: [256]u8 = undefined;
83
83
-
const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse {
84
84
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did");
85
85
-
};
86
86
-
const body = store.writeLatestCommitJson(allocator, did) catch |err| switch (err) {
87
87
-
error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"),
88
88
-
else => return err,
89
89
-
};
90
90
-
return http_api.json(request, .ok, body);
91
91
-
}
92
92
-
93
93
-
pub fn listRepos(request: *http.Server.Request) !void {
94
94
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
95
95
-
defer arena.deinit();
96
96
-
const allocator = arena.allocator();
97
97
-
98
98
-
const body = try store.writeRepoListJson(allocator, http_api.queryLimit(request.head.target, 500));
99
99
-
return http_api.json(request, .ok, body);
100
100
-
}
101
101
-
102
102
-
pub fn subscribeRepos(request: *http.Server.Request) !void {
103
103
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
104
104
-
defer arena.deinit();
105
105
-
const allocator = arena.allocator();
106
106
-
107
107
-
const key = switch (request.upgradeRequested()) {
108
108
-
.websocket => |maybe_key| maybe_key orelse {
109
109
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing Sec-WebSocket-Key");
110
110
-
},
111
111
-
else => return http_api.xrpcError(request, .upgrade_required, "InvalidRequest", "Expected WebSocket upgrade"),
112
112
-
};
113
113
-
114
114
-
var ws = try request.respondWebSocket(.{ .key = key });
115
115
-
try ws.flush();
116
116
-
117
117
-
var cursor_buf: [32]u8 = undefined;
118
118
-
var cursor: u64 = if (http_api.queryParam(request.head.target, "cursor", &cursor_buf)) |raw|
119
119
-
std.fmt.parseInt(u64, raw, 10) catch 0
120
120
-
else
121
121
-
0;
122
122
-
123
123
-
while (true) {
124
124
-
var sent = false;
125
125
-
{
126
126
-
var batch_arena = std.heap.ArenaAllocator.init(allocator);
127
127
-
defer batch_arena.deinit();
128
128
-
const events = try store.listSeqEvents(batch_arena.allocator(), cursor, 100);
129
129
-
for (events) |event| {
130
130
-
ws.writeMessage(event.frame, .binary) catch return;
131
131
-
cursor = event.seq;
132
132
-
sent = true;
133
133
-
}
134
134
-
}
135
135
-
if (!sent) {
136
136
-
store.currentIo().sleep(.fromSeconds(1), .awake) catch {};
137
137
-
}
138
138
-
}
139
139
-
}
140
140
-
141
141
-
pub fn getRepoStatus(request: *http.Server.Request) !void {
142
142
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
143
143
-
defer arena.deinit();
144
144
-
const allocator = arena.allocator();
145
145
-
146
146
-
var did_buf: [256]u8 = undefined;
147
147
-
const did = http_api.queryParam(request.head.target, "did", &did_buf) orelse {
148
148
-
return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did");
149
149
-
};
150
150
-
const body = store.writeRepoStatusJson(allocator, did) catch |err| switch (err) {
151
151
-
error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"),
152
152
-
else => return err,
153
153
-
};
154
154
-
return http_api.json(request, .ok, body);
155
155
-
}
156
156
-
157
157
-
pub fn notifyOfUpdate(request: *http.Server.Request) !void {
158
158
-
return http_api.json(request, .ok, "{}");
159
159
-
}
160
160
-
161
161
-
pub fn requestCrawl(request: *http.Server.Request) !void {
162
162
-
return http_api.json(request, .ok, "{}");
163
163
-
}
164
164
-
165
165
-
pub fn requestConfiguredCrawls(allocator: std.mem.Allocator) void {
166
166
-
const host = publicHostname(config.publicUrl()) orelse {
167
167
-
log.err("sync request_crawl skipped invalid_public_url url={s}\n", .{config.publicUrl()});
168
168
-
return;
169
169
-
};
170
170
-
var crawlers = std.mem.splitScalar(u8, config.crawlers(), ',');
171
171
-
while (crawlers.next()) |crawler_raw| {
172
172
-
const crawler = std.mem.trim(u8, crawler_raw, " \t\r\n/");
173
173
-
if (crawler.len == 0) continue;
174
174
-
requestCrawler(allocator, crawler, host) catch |err| {
175
175
-
log.err("sync request_crawl failed crawler={s} host={s} err={s}\n", .{ crawler, host, @errorName(err) });
176
176
-
continue;
177
177
-
};
178
178
-
}
179
179
-
}
180
180
-
181
181
-
fn requestCrawler(allocator: std.mem.Allocator, crawler: []const u8, host: []const u8) !void {
182
182
-
const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/com.atproto.sync.requestCrawl", .{crawler});
183
183
-
const payload = try std.fmt.allocPrint(allocator, "{{\"hostname\":{f}}}", .{std.json.fmt(host, .{})});
184
184
-
var transport = zat.HttpTransport.init(store.currentIo(), allocator);
185
185
-
defer transport.deinit();
186
186
-
const result = try transport.fetch(.{
187
187
-
.url = url,
188
188
-
.method = .POST,
189
189
-
.payload = payload,
190
190
-
.content_type = "application/json",
191
191
-
.max_response_size = 64 * 1024,
192
192
-
});
193
193
-
if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.CrawlerRejected;
194
194
-
log.info("sync request_crawl ok crawler={s} host={s}\n", .{ crawler, host });
195
195
-
}
196
196
-
197
197
-
fn publicHostname(url: []const u8) ?[]const u8 {
198
198
-
const scheme = std.mem.indexOf(u8, url, "://") orelse return null;
199
199
-
var rest = url[scheme + 3 ..];
200
200
-
if (std.mem.indexOfScalar(u8, rest, '/')) |slash| rest = rest[0..slash];
201
201
-
if (std.mem.indexOfScalar(u8, rest, '@')) |at| rest = rest[at + 1 ..];
202
202
-
if (rest.len == 0) return null;
203
203
-
if (std.mem.startsWith(u8, rest, "[")) {
204
204
-
const close = std.mem.indexOfScalar(u8, rest, ']') orelse return null;
205
205
-
return rest[0 .. close + 1];
206
206
-
}
207
207
-
if (std.mem.indexOfScalar(u8, rest, ':')) |colon| return rest[0..colon];
208
208
-
return rest;
209
209
-
}
···
1
1
-
const std = @import("std");
2
2
-
const config = @import("../core/config.zig");
3
3
-
const zat = @import("zat");
4
4
-
5
5
-
pub const Account = struct {
6
6
-
handle: []const u8,
7
7
-
did: []const u8,
8
8
-
email: []const u8,
9
9
-
password: []const u8,
10
10
-
};
11
11
-
12
12
-
pub const TokenClaims = struct {
13
13
-
did: []const u8,
14
14
-
scope: []const u8,
15
15
-
};
16
16
-
17
17
-
var jwt_counter: usize = 0;
18
18
-
19
19
-
pub fn passwordMatches(account: Account, password: []const u8) bool {
20
20
-
if (verifyPasswordHash(account.password, password)) return true;
21
21
-
return std.mem.eql(u8, password, account.password);
22
22
-
}
23
23
-
24
24
-
pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8, salt: [16]u8) ![]const u8 {
25
25
-
var derived: [32]u8 = undefined;
26
26
-
try std.crypto.pwhash.pbkdf2(&derived, password, &salt, password_hash_rounds, std.crypto.auth.hmac.sha2.HmacSha256);
27
27
-
const salt_hex = std.fmt.bytesToHex(salt, .lower);
28
28
-
const derived_hex = std.fmt.bytesToHex(derived, .lower);
29
29
-
return std.fmt.allocPrint(allocator, "pbkdf2-sha256:{d}:{s}:{s}", .{ password_hash_rounds, &salt_hex, &derived_hex });
30
30
-
}
31
31
-
32
32
-
fn verifyPasswordHash(stored: []const u8, password: []const u8) bool {
33
33
-
var parts = std.mem.splitScalar(u8, stored, ':');
34
34
-
const scheme = parts.next() orelse return false;
35
35
-
if (!std.mem.eql(u8, scheme, "pbkdf2-sha256")) return false;
36
36
-
const rounds_text = parts.next() orelse return false;
37
37
-
const salt_hex = parts.next() orelse return false;
38
38
-
const derived_hex = parts.next() orelse return false;
39
39
-
if (parts.next() != null or salt_hex.len != 32 or derived_hex.len != 64) return false;
40
40
-
const rounds = std.fmt.parseInt(u32, rounds_text, 10) catch return false;
41
41
-
var salt: [16]u8 = undefined;
42
42
-
_ = std.fmt.hexToBytes(&salt, salt_hex) catch return false;
43
43
-
var expected: [32]u8 = undefined;
44
44
-
_ = std.fmt.hexToBytes(&expected, derived_hex) catch return false;
45
45
-
var actual: [32]u8 = undefined;
46
46
-
std.crypto.pwhash.pbkdf2(&actual, password, &salt, rounds, std.crypto.auth.hmac.sha2.HmacSha256) catch return false;
47
47
-
return std.crypto.timing_safe.eql([32]u8, actual, expected);
48
48
-
}
49
49
-
50
50
-
const password_hash_rounds: u32 = 100_000;
51
51
-
52
52
-
pub fn createSessionJwt(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) ![]const u8 {
53
53
-
const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
54
54
-
const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic);
55
55
-
var ts: std.posix.timespec = undefined;
56
56
-
const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) {
57
57
-
.SUCCESS => ts,
58
58
-
else => std.posix.timespec{ .sec = 0, .nsec = 0 },
59
59
-
};
60
60
-
const iat = timestamp.sec;
61
61
-
const lifetime: i64 = if (std.mem.eql(u8, kind, "refresh")) 14 * 24 * 60 * 60 else 2 * 60 * 60;
62
62
-
const exp = iat + lifetime;
63
63
-
const payload = try std.fmt.allocPrint(
64
64
-
allocator,
65
65
-
"{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":{d},\"exp\":{d},\"jti\":\"zds-{d}-{d}-{d}\"}}",
66
66
-
.{ account.did, kind, iat, exp, timestamp.sec, timestamp.nsec, jti },
67
67
-
);
68
68
-
defer allocator.free(payload);
69
69
-
70
70
-
const header_b64 = try zat.jwt.base64UrlEncode(allocator, header);
71
71
-
defer allocator.free(header_b64);
72
72
-
const payload_b64 = try zat.jwt.base64UrlEncode(allocator, payload);
73
73
-
defer allocator.free(payload_b64);
74
74
-
75
75
-
const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 });
76
76
-
defer allocator.free(signing_input);
77
77
-
var signature: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined;
78
78
-
std.crypto.auth.hmac.sha2.HmacSha256.create(&signature, signing_input, config.jwtSecret());
79
79
-
const signature_b64 = try zat.jwt.base64UrlEncode(allocator, &signature);
80
80
-
defer allocator.free(signature_b64);
81
81
-
return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, signature_b64 });
82
82
-
}
83
83
-
84
84
-
pub fn createServiceJwt(
85
85
-
allocator: std.mem.Allocator,
86
86
-
account: Account,
87
87
-
audience: []const u8,
88
88
-
lexicon_method: ?[]const u8,
89
89
-
) ![]const u8 {
90
90
-
const keypair = try serviceKeypair();
91
91
-
return createServiceJwtWithKeypair(allocator, account, audience, lexicon_method, &keypair);
92
92
-
}
93
93
-
94
94
-
pub fn createServiceJwtWithKeypair(
95
95
-
allocator: std.mem.Allocator,
96
96
-
account: Account,
97
97
-
audience: []const u8,
98
98
-
lexicon_method: ?[]const u8,
99
99
-
keypair: *const zat.Keypair,
100
100
-
) ![]const u8 {
101
101
-
const header = try std.fmt.allocPrint(
102
102
-
allocator,
103
103
-
"{{\"typ\":\"JWT\",\"alg\":\"{s}\"}}",
104
104
-
.{@tagName(keypair.algorithm())},
105
105
-
);
106
106
-
defer allocator.free(header);
107
107
-
const iat = unixNow();
108
108
-
const exp = iat + 60;
109
109
-
const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic);
110
110
-
111
111
-
const payload = if (lexicon_method) |lxm|
112
112
-
try std.fmt.allocPrint(
113
113
-
allocator,
114
114
-
"{{\"iss\":\"{s}\",\"sub\":\"{s}\",\"aud\":\"{s}\",\"exp\":{d},\"iat\":{d},\"jti\":\"zds-{d}-{d}\",\"lxm\":\"{s}\"}}",
115
115
-
.{ account.did, account.did, audience, exp, iat, iat, jti, lxm },
116
116
-
)
117
117
-
else
118
118
-
try std.fmt.allocPrint(
119
119
-
allocator,
120
120
-
"{{\"iss\":\"{s}\",\"sub\":\"{s}\",\"aud\":\"{s}\",\"exp\":{d},\"iat\":{d},\"jti\":\"zds-{d}-{d}\"}}",
121
121
-
.{ account.did, account.did, audience, exp, iat, iat, jti },
122
122
-
);
123
123
-
defer allocator.free(payload);
124
124
-
125
125
-
return zat.oauth.createJwt(allocator, header, payload, keypair);
126
126
-
}
127
127
-
128
128
-
fn unixNow() i64 {
129
129
-
var ts: std.posix.timespec = undefined;
130
130
-
return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) {
131
131
-
.SUCCESS => ts.sec,
132
132
-
else => 0,
133
133
-
};
134
134
-
}
135
135
-
136
136
-
pub fn serviceKeypair() !zat.Keypair {
137
137
-
return zat.Keypair.fromSecretKey(.p256, .{
138
138
-
0x01, 0x7a, 0x64, 0x73, 0x2d, 0x64, 0x65, 0x76,
139
139
-
0x2d, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
140
140
-
0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x6b, 0x65,
141
141
-
0x79, 0x2d, 0x30, 0x30, 0x30, 0x30, 0x30, 0x31,
142
142
-
});
143
143
-
}
144
144
-
145
145
-
pub fn subjectFromSessionJwt(allocator: std.mem.Allocator, token: []const u8) ?[]const u8 {
146
146
-
const claims = claimsFromSessionJwt(allocator, token) orelse return null;
147
147
-
allocator.free(claims.scope);
148
148
-
return claims.did;
149
149
-
}
150
150
-
151
151
-
pub fn claimsFromSessionJwt(allocator: std.mem.Allocator, token: []const u8) ?TokenClaims {
152
152
-
var parts = std.mem.splitScalar(u8, token, '.');
153
153
-
const header_part = parts.next() orelse return null;
154
154
-
const payload_part = parts.next() orelse return null;
155
155
-
const signature_part = parts.next() orelse return null;
156
156
-
if (payload_part.len == 0 or signature_part.len == 0 or parts.next() != null) return null;
157
157
-
158
158
-
const signing_input_len = header_part.len + 1 + payload_part.len;
159
159
-
if (token.len < signing_input_len or token[header_part.len] != '.') return null;
160
160
-
const signing_input = token[0..signing_input_len];
161
161
-
162
162
-
const header_json = zat.jwt.base64UrlDecode(allocator, header_part) catch return null;
163
163
-
defer allocator.free(header_json);
164
164
-
const header = std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}) catch return null;
165
165
-
defer header.deinit();
166
166
-
const alg = zat.json.getString(header.value, "alg") orelse return null;
167
167
-
if (!std.mem.eql(u8, alg, "HS256")) return null;
168
168
-
169
169
-
const signature = zat.jwt.base64UrlDecode(allocator, signature_part) catch return null;
170
170
-
defer allocator.free(signature);
171
171
-
if (signature.len != std.crypto.auth.hmac.sha2.HmacSha256.mac_length) return null;
172
172
-
var expected: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined;
173
173
-
std.crypto.auth.hmac.sha2.HmacSha256.create(&expected, signing_input, config.jwtSecret());
174
174
-
var actual: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined;
175
175
-
@memcpy(&actual, signature);
176
176
-
if (!std.crypto.timing_safe.eql([std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8, actual, expected)) return null;
177
177
-
178
178
-
const payload = zat.jwt.base64UrlDecode(allocator, payload_part) catch return null;
179
179
-
defer allocator.free(payload);
180
180
-
181
181
-
const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null;
182
182
-
defer parsed.deinit();
183
183
-
const subject = zat.json.getString(parsed.value, "sub") orelse return null;
184
184
-
if (zat.Did.parse(subject) == null) return null;
185
185
-
const scope = zat.json.getString(parsed.value, "scope") orelse return null;
186
186
-
if (!std.mem.startsWith(u8, scope, "com.atproto.")) return null;
187
187
-
return .{
188
188
-
.did = allocator.dupe(u8, subject) catch return null,
189
189
-
.scope = allocator.dupe(u8, scope) catch return null,
190
190
-
};
191
191
-
}
192
192
-
193
193
-
test "writes jwt-shaped session token" {
194
194
-
const alice = testAccount();
195
195
-
const token = try createSessionJwt(std.testing.allocator, "access", alice);
196
196
-
defer std.testing.allocator.free(token);
197
197
-
try std.testing.expect(std.mem.count(u8, token, ".") == 2);
198
198
-
const claims = claimsFromSessionJwt(std.testing.allocator, token).?;
199
199
-
defer std.testing.allocator.free(claims.did);
200
200
-
defer std.testing.allocator.free(claims.scope);
201
201
-
try std.testing.expectEqualStrings(alice.did, claims.did);
202
202
-
}
203
203
-
204
204
-
test "creates signed service auth token" {
205
205
-
const alice = testAccount();
206
206
-
const token = try createServiceJwt(std.testing.allocator, alice, "did:plc:service", "xyz.fake.inbox.send");
207
207
-
defer std.testing.allocator.free(token);
208
208
-
try std.testing.expect(std.mem.count(u8, token, ".") == 2);
209
209
-
}
210
210
-
211
211
-
fn testAccount() Account {
212
212
-
return .{
213
213
-
.handle = "alice.test",
214
214
-
.did = "did:plc:cmadossymmii3izkabdbp5en",
215
215
-
.email = "alice@test.com",
216
216
-
.password = "password",
217
217
-
};
218
218
-
}
···
1
1
-
const std = @import("std");
2
2
-
const zat = @import("zat");
3
3
-
4
4
-
pub const Error = error{
5
5
-
ClockIdOutOfRange,
6
6
-
TidTooShort,
7
7
-
TidTooLong,
8
8
-
InvalidTidChar,
9
9
-
};
10
10
-
11
11
-
pub const encoded_len = 13;
12
12
-
const alphabet = "234567abcdefghijklmnopqrstuvwxyz";
13
13
-
14
14
-
/// ATProto TID generator.
15
15
-
///
16
16
-
/// A TID is a 13-character sortable base32 string containing a microsecond
17
17
-
/// timestamp plus a 10-bit clock id. This module does not own clock state; the
18
18
-
/// caller provides time and clock id so tests and storage layers can control
19
19
-
/// monotonicity.
20
20
-
pub fn encode(timestamp_us: u64, clock_id: u16) Error![encoded_len]u8 {
21
21
-
if (clock_id > 1023) return Error.ClockIdOutOfRange;
22
22
-
return zat.Tid.fromTimestamp(timestamp_us, @intCast(clock_id)).raw;
23
23
-
}
24
24
-
25
25
-
pub fn decode(tid: []const u8) Error!u64 {
26
26
-
if (tid.len < encoded_len) return Error.TidTooShort;
27
27
-
if (tid.len > encoded_len) return Error.TidTooLong;
28
28
-
29
29
-
const parsed = zat.Tid.parse(tid) orelse return Error.InvalidTidChar;
30
30
-
return (parsed.timestamp() << 10) | parsed.clockId();
31
31
-
}
32
32
-
33
33
-
pub fn timestampMicros(tid: []const u8) Error!u64 {
34
34
-
if (tid.len < encoded_len) return Error.TidTooShort;
35
35
-
if (tid.len > encoded_len) return Error.TidTooLong;
36
36
-
return (zat.Tid.parse(tid) orelse return Error.InvalidTidChar).timestamp();
37
37
-
}
38
38
-
39
39
-
pub fn clockId(tid: []const u8) Error!u16 {
40
40
-
if (tid.len < encoded_len) return Error.TidTooShort;
41
41
-
if (tid.len > encoded_len) return Error.TidTooLong;
42
42
-
return (zat.Tid.parse(tid) orelse return Error.InvalidTidChar).clockId();
43
43
-
}
44
44
-
45
45
-
fn decodeChar(c: u8) Error!u6 {
46
46
-
for (alphabet, 0..) |candidate, i| {
47
47
-
if (candidate == c) return @intCast(i);
48
48
-
}
49
49
-
return Error.InvalidTidChar;
50
50
-
}
51
51
-
52
52
-
test "encodes sortable tids" {
53
53
-
const a = try encode(1_700_000_000_000_000, 0);
54
54
-
const b = try encode(1_700_000_000_000_001, 0);
55
55
-
try std.testing.expect(std.mem.lessThan(u8, &a, &b));
56
56
-
}
57
57
-
58
58
-
test "round trips timestamp and clock id" {
59
59
-
const tid = try encode(1_700_000_123_456_789, 42);
60
60
-
try std.testing.expectEqual(@as(u64, 1_700_000_123_456_789), try timestampMicros(&tid));
61
61
-
try std.testing.expectEqual(@as(u16, 42), try clockId(&tid));
62
62
-
}
63
63
-
64
64
-
test "rejects malformed tids" {
65
65
-
try std.testing.expectError(Error.TidTooShort, decode("abc"));
66
66
-
try std.testing.expectError(Error.TidTooLong, decode("abcdefghijklmn"));
67
67
-
try std.testing.expectError(Error.InvalidTidChar, decode("0000000000000"));
68
68
-
}
···
1
1
-
const std = @import("std");
2
2
-
3
3
-
var public_url_value: []const u8 = "http://localhost:2583";
4
4
-
var server_did_value: []const u8 = "did:web:localhost";
5
5
-
var plc_directory_value: []const u8 = "https://plc.directory";
6
6
-
var resend_api_key_value: ?[]const u8 = null;
7
7
-
var email_from_value: ?[]const u8 = null;
8
8
-
var blob_upload_limit_value: usize = 100_000_000;
9
9
-
var blobstore_path_value: []const u8 = "dev/blobs";
10
10
-
var handle_domains_value: []const u8 = ".test";
11
11
-
var crawlers_value: []const u8 = "https://bsky.network,https://vsky.network";
12
12
-
var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me";
13
13
-
var log_level_value: LogLevel = .info;
14
14
-
15
15
-
pub const LogLevel = enum(u2) {
16
16
-
err = 0,
17
17
-
info = 1,
18
18
-
debug = 2,
19
19
-
};
20
20
-
21
21
-
pub fn publicUrl() []const u8 {
22
22
-
return public_url_value;
23
23
-
}
24
24
-
25
25
-
pub fn serverDid() []const u8 {
26
26
-
return server_did_value;
27
27
-
}
28
28
-
29
29
-
pub fn plcDirectory() []const u8 {
30
30
-
return plc_directory_value;
31
31
-
}
32
32
-
33
33
-
pub fn resendApiKey() ?[]const u8 {
34
34
-
return resend_api_key_value;
35
35
-
}
36
36
-
37
37
-
pub fn emailFrom() ?[]const u8 {
38
38
-
return email_from_value;
39
39
-
}
40
40
-
41
41
-
pub fn blobUploadLimit() usize {
42
42
-
return blob_upload_limit_value;
43
43
-
}
44
44
-
45
45
-
pub fn blobstorePath() []const u8 {
46
46
-
return blobstore_path_value;
47
47
-
}
48
48
-
49
49
-
pub fn handleDomains() []const u8 {
50
50
-
return handle_domains_value;
51
51
-
}
52
52
-
53
53
-
pub fn crawlers() []const u8 {
54
54
-
return crawlers_value;
55
55
-
}
56
56
-
57
57
-
pub fn jwtSecret() []const u8 {
58
58
-
return jwt_secret_value;
59
59
-
}
60
60
-
61
61
-
pub fn logLevel() LogLevel {
62
62
-
return log_level_value;
63
63
-
}
64
64
-
65
65
-
pub fn logEnabled(level: LogLevel) bool {
66
66
-
return @intFromEnum(level) <= @intFromEnum(log_level_value);
67
67
-
}
68
68
-
69
69
-
pub fn setPublicUrl(value: []const u8) void {
70
70
-
public_url_value = trimTrailingSlash(value);
71
71
-
}
72
72
-
73
73
-
pub fn setServerDid(value: []const u8) void {
74
74
-
server_did_value = value;
75
75
-
}
76
76
-
77
77
-
pub fn setPlcDirectory(value: []const u8) void {
78
78
-
plc_directory_value = trimTrailingSlash(value);
79
79
-
}
80
80
-
81
81
-
pub fn setResendApiKey(value: ?[]const u8) void {
82
82
-
resend_api_key_value = value;
83
83
-
}
84
84
-
85
85
-
pub fn setEmailFrom(value: ?[]const u8) void {
86
86
-
email_from_value = value;
87
87
-
}
88
88
-
89
89
-
pub fn setBlobUploadLimit(value: usize) void {
90
90
-
blob_upload_limit_value = value;
91
91
-
}
92
92
-
93
93
-
pub fn setBlobstorePath(value: []const u8) void {
94
94
-
blobstore_path_value = trimTrailingSlash(value);
95
95
-
}
96
96
-
97
97
-
pub fn setHandleDomains(value: []const u8) void {
98
98
-
handle_domains_value = value;
99
99
-
}
100
100
-
101
101
-
pub fn setCrawlers(value: []const u8) void {
102
102
-
crawlers_value = value;
103
103
-
}
104
104
-
105
105
-
pub fn setJwtSecret(value: []const u8) void {
106
106
-
jwt_secret_value = value;
107
107
-
}
108
108
-
109
109
-
pub fn setLogLevel(value: LogLevel) void {
110
110
-
log_level_value = value;
111
111
-
}
112
112
-
113
113
-
pub fn parseLogLevel(value: []const u8) ?LogLevel {
114
114
-
if (std.ascii.eqlIgnoreCase(value, "error")) return .err;
115
115
-
if (std.ascii.eqlIgnoreCase(value, "err")) return .err;
116
116
-
if (std.ascii.eqlIgnoreCase(value, "info")) return .info;
117
117
-
if (std.ascii.eqlIgnoreCase(value, "debug")) return .debug;
118
118
-
return null;
119
119
-
}
120
120
-
121
121
-
fn trimTrailingSlash(value: []const u8) []const u8 {
122
122
-
if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1];
123
123
-
return value;
124
124
-
}
125
125
-
126
126
-
test "trims public URL slash" {
127
127
-
const before = publicUrl();
128
128
-
defer setPublicUrl(before);
129
129
-
setPublicUrl("https://example.com/");
130
130
-
try std.testing.expectEqualStrings("https://example.com", publicUrl());
131
131
-
}
···
1
1
-
const std = @import("std");
2
2
-
const config = @import("config.zig");
3
3
-
4
4
-
var mutex: std.atomic.Mutex = .unlocked;
5
5
-
6
6
-
pub fn debug(comptime fmt: []const u8, args: anytype) void {
7
7
-
write(.debug, "debug", fmt, args);
8
8
-
}
9
9
-
10
10
-
pub fn info(comptime fmt: []const u8, args: anytype) void {
11
11
-
write(.info, "info", fmt, args);
12
12
-
}
13
13
-
14
14
-
pub fn err(comptime fmt: []const u8, args: anytype) void {
15
15
-
write(.err, "error", fmt, args);
16
16
-
}
17
17
-
18
18
-
fn write(level: config.LogLevel, comptime label: []const u8, comptime fmt: []const u8, args: anytype) void {
19
19
-
if (!config.logEnabled(level)) return;
20
20
-
21
21
-
var buf: [2048]u8 = undefined;
22
22
-
const message = std.fmt.bufPrint(&buf, label ++ " " ++ fmt, args) catch blk: {
23
23
-
const prefix = label ++ " ";
24
24
-
@memcpy(buf[0..prefix.len], prefix);
25
25
-
const suffix = "log message too large\n";
26
26
-
@memcpy(buf[prefix.len..][0..suffix.len], suffix);
27
27
-
break :blk buf[0 .. prefix.len + suffix.len];
28
28
-
};
29
29
-
30
30
-
while (!mutex.tryLock()) std.Thread.yield() catch {};
31
31
-
defer mutex.unlock();
32
32
-
writeAllStderr(message);
33
33
-
}
34
34
-
35
35
-
fn writeAllStderr(message: []const u8) void {
36
36
-
var remaining = message;
37
37
-
while (remaining.len > 0) {
38
38
-
const written = std.c.write(std.posix.STDERR_FILENO, remaining.ptr, remaining.len);
39
39
-
if (written <= 0) return;
40
40
-
remaining = remaining[@intCast(written)..];
41
41
-
}
42
42
-
}
···
1
1
-
const std = @import("std");
2
2
-
const config = @import("config.zig");
3
3
-
const log = @import("log.zig");
4
4
-
const zat = @import("zat");
5
5
-
6
6
-
pub fn send(
7
7
-
io: std.Io,
8
8
-
allocator: std.mem.Allocator,
9
9
-
to: []const u8,
10
10
-
handle: []const u8,
11
11
-
subject: []const u8,
12
12
-
text: []const u8,
13
13
-
) !void {
14
14
-
const api_key = config.resendApiKey() orelse {
15
15
-
logMail(subject, to, handle, text);
16
16
-
return;
17
17
-
};
18
18
-
const from = config.emailFrom() orelse {
19
19
-
logMail(subject, to, handle, text);
20
20
-
return;
21
21
-
};
22
22
-
23
23
-
const payload = try std.fmt.allocPrint(
24
24
-
allocator,
25
25
-
"{{\"from\":{f},\"to\":[{f}],\"subject\":{f},\"text\":{f}}}",
26
26
-
.{ std.json.fmt(from, .{}), std.json.fmt(to, .{}), std.json.fmt(subject, .{}), std.json.fmt(text, .{}) },
27
27
-
);
28
28
-
const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key});
29
29
-
var transport = zat.HttpTransport.init(io, allocator);
30
30
-
defer transport.deinit();
31
31
-
const result = try transport.fetch(.{
32
32
-
.url = "https://api.resend.com/emails",
33
33
-
.method = .POST,
34
34
-
.payload = payload,
35
35
-
.authorization = authorization,
36
36
-
.content_type = "application/json",
37
37
-
.max_response_size = 64 * 1024,
38
38
-
});
39
39
-
if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.EmailDeliveryFailed;
40
40
-
}
41
41
-
42
42
-
pub fn sendCode(
43
43
-
io: std.Io,
44
44
-
allocator: std.mem.Allocator,
45
45
-
to: []const u8,
46
46
-
handle: []const u8,
47
47
-
subject: []const u8,
48
48
-
label: []const u8,
49
49
-
code: []const u8,
50
50
-
) !void {
51
51
-
const text = try std.fmt.allocPrint(
52
52
-
allocator,
53
53
-
"Hello {s}. Your {s} code is {s}. This code will expire in ten minutes.",
54
54
-
.{ handle, label, code },
55
55
-
);
56
56
-
return send(io, allocator, to, handle, subject, text);
57
57
-
}
58
58
-
59
59
-
fn logMail(subject: []const u8, email: []const u8, handle: []const u8, text: []const u8) void {
60
60
-
log.info("[zds mail] {s} for {s} <{s}>: {s}\n", .{ subject, handle, email, text });
61
61
-
}
···
1
1
-
const std = @import("std");
2
2
-
const zat = @import("zat");
3
3
-
4
4
-
pub const Error = error{
5
5
-
InvalidAtUri,
6
6
-
InvalidCollection,
7
7
-
InvalidRepoPath,
8
8
-
InvalidRecordKey,
9
9
-
MissingCollection,
10
10
-
MissingRecordKey,
11
11
-
};
12
12
-
13
13
-
pub const RecordRef = struct {
14
14
-
uri: zat.AtUri,
15
15
-
cid: ?[]const u8 = null,
16
16
-
17
17
-
pub fn parse(uri: []const u8, cid: ?[]const u8) Error!RecordRef {
18
18
-
const parsed = zat.AtUri.parse(uri) orelse return Error.InvalidAtUri;
19
19
-
if (!parsed.hasCollection()) return Error.MissingCollection;
20
20
-
if (!parsed.hasRkey()) return Error.MissingRecordKey;
21
21
-
return .{ .uri = parsed, .cid = cid };
22
22
-
}
23
23
-
};
24
24
-
25
25
-
pub const RepoPath = struct {
26
26
-
raw: []const u8,
27
27
-
slash: usize,
28
28
-
29
29
-
pub fn parse(path: []const u8) Error!RepoPath {
30
30
-
const slash = std.mem.indexOfScalar(u8, path, '/') orelse return Error.InvalidRepoPath;
31
31
-
if (std.mem.indexOfScalarPos(u8, path, slash + 1, '/') != null) return Error.InvalidRepoPath;
32
32
-
if (zat.Nsid.parse(path[0..slash]) == null) return Error.InvalidCollection;
33
33
-
if (zat.Rkey.parse(path[slash + 1 ..]) == null) return Error.InvalidRecordKey;
34
34
-
return .{ .raw = path, .slash = slash };
35
35
-
}
36
36
-
37
37
-
pub fn collection(self: RepoPath) []const u8 {
38
38
-
return self.raw[0..self.slash];
39
39
-
}
40
40
-
41
41
-
pub fn rkey(self: RepoPath) []const u8 {
42
42
-
return self.raw[self.slash + 1 ..];
43
43
-
}
44
44
-
};
45
45
-
46
46
-
pub const WriteAction = enum {
47
47
-
create,
48
48
-
update,
49
49
-
delete,
50
50
-
};
51
51
-
52
52
-
pub const WriteOp = struct {
53
53
-
action: WriteAction,
54
54
-
path: RepoPath,
55
55
-
cid: ?[]const u8 = null,
56
56
-
prev: ?[]const u8 = null,
57
57
-
58
58
-
pub fn create(path: []const u8, cid: []const u8) Error!WriteOp {
59
59
-
return .{ .action = .create, .path = try RepoPath.parse(path), .cid = cid };
60
60
-
}
61
61
-
62
62
-
pub fn update(path: []const u8, cid: []const u8, prev: []const u8) Error!WriteOp {
63
63
-
return .{ .action = .update, .path = try RepoPath.parse(path), .cid = cid, .prev = prev };
64
64
-
}
65
65
-
66
66
-
pub fn delete(path: []const u8, prev: []const u8) Error!WriteOp {
67
67
-
return .{ .action = .delete, .path = try RepoPath.parse(path), .prev = prev };
68
68
-
}
69
69
-
};
70
70
-
71
71
-
test "parses repo paths" {
72
72
-
const path = try RepoPath.parse("app.bsky.feed.post/3jxtb5w2hkt2m");
73
73
-
try std.testing.expectEqualStrings("app.bsky.feed.post", path.collection());
74
74
-
try std.testing.expectEqualStrings("3jxtb5w2hkt2m", path.rkey());
75
75
-
}
76
76
-
77
77
-
test "requires full record refs" {
78
78
-
const ref = try RecordRef.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jxtb5w2hkt2m", "bafyreiblah");
79
79
-
try std.testing.expectEqualStrings("app.bsky.feed.post", ref.uri.collection().?);
80
80
-
try std.testing.expectError(Error.MissingRecordKey, RecordRef.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post", null));
81
81
-
}
82
82
-
83
83
-
test "models sync write ops" {
84
84
-
const op = try WriteOp.update("app.bsky.feed.post/3jxtb5w2hkt2m", "new", "old");
85
85
-
try std.testing.expectEqual(.update, op.action);
86
86
-
try std.testing.expectEqualStrings("old", op.prev.?);
87
87
-
}
88
88
-
···
1
1
-
const std = @import("std");
2
2
-
const zat = @import("zat");
3
3
-
4
4
-
pub const Error = error{
5
5
-
Empty,
6
6
-
TooLong,
7
7
-
InvalidChar,
8
8
-
InvalidDid,
9
9
-
InvalidNsid,
10
10
-
InvalidRecordKey,
11
11
-
};
12
12
-
13
13
-
pub fn validateDid(did: []const u8) Error!void {
14
14
-
if (did.len == 0) return Error.Empty;
15
15
-
if (did.len > 2048) return Error.TooLong;
16
16
-
if (zat.Did.parse(did) == null) return Error.InvalidDid;
17
17
-
}
18
18
-
19
19
-
pub fn validateNsid(nsid: []const u8) Error!void {
20
20
-
if (nsid.len == 0) return Error.Empty;
21
21
-
if (nsid.len > zat.Nsid.max_length) return Error.TooLong;
22
22
-
if (zat.Nsid.parse(nsid) == null) return Error.InvalidNsid;
23
23
-
}
24
24
-
25
25
-
pub fn validateRecordKey(rkey: []const u8) Error!void {
26
26
-
if (rkey.len == 0) return Error.Empty;
27
27
-
if (rkey.len > zat.Rkey.max_length) return Error.TooLong;
28
28
-
if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey;
29
29
-
}
30
30
-
31
31
-
pub fn validateRepoPath(path: []const u8) Error!void {
32
32
-
const slash = std.mem.indexOfScalar(u8, path, '/') orelse return Error.InvalidRecordKey;
33
33
-
if (std.mem.indexOfScalarPos(u8, path, slash + 1, '/') != null) return Error.InvalidRecordKey;
34
34
-
try validateNsid(path[0..slash]);
35
35
-
try validateRecordKey(path[slash + 1 ..]);
36
36
-
}
37
37
-
38
38
-
test "validates common atproto syntax" {
39
39
-
try validateDid("did:plc:abc123");
40
40
-
try validateDid("did:web:example.com");
41
41
-
try validateNsid("com.atproto.repo");
42
42
-
try validateNsid("app.bsky.feed.post");
43
43
-
try validateRecordKey("3jzfcijpj2z23");
44
44
-
try validateRepoPath("app.bsky.feed.post/3jzfcijpj2z23");
45
45
-
}
46
46
-
47
47
-
test "rejects invalid syntax" {
48
48
-
try std.testing.expectError(Error.InvalidDid, validateDid("plc:abc"));
49
49
-
try std.testing.expectError(Error.InvalidNsid, validateNsid("com"));
50
50
-
try std.testing.expectError(Error.InvalidNsid, validateNsid("com..repo"));
51
51
-
try std.testing.expectError(Error.InvalidRecordKey, validateRecordKey("bad/key"));
52
52
-
}
···
1
1
-
const std = @import("std");
2
2
-
const syntax = @import("syntax.zig");
3
3
-
4
4
-
pub const healthPath = "/xrpc/_health";
5
5
-
6
6
-
pub const Error = error{
7
7
-
NotXrpc,
8
8
-
MissingMethod,
9
9
-
InvalidMethod,
10
10
-
};
11
11
-
12
12
-
pub const Method = struct {
13
13
-
nsid: []const u8,
14
14
-
15
15
-
pub fn isHealth(self: Method) bool {
16
16
-
return std.mem.eql(u8, self.nsid, "_health");
17
17
-
}
18
18
-
};
19
19
-
20
20
-
pub fn parsePath(path: []const u8) Error!Method {
21
21
-
const prefix = "/xrpc/";
22
22
-
if (!std.mem.startsWith(u8, path, prefix)) return Error.NotXrpc;
23
23
-
const rest = path[prefix.len..];
24
24
-
if (rest.len == 0) return Error.MissingMethod;
25
25
-
if (std.mem.indexOfScalar(u8, rest, '/') != null) return Error.InvalidMethod;
26
26
-
if (std.mem.indexOfScalar(u8, rest, '?')) |query| {
27
27
-
return parseMethod(rest[0..query]);
28
28
-
}
29
29
-
return parseMethod(rest);
30
30
-
}
31
31
-
32
32
-
fn parseMethod(method: []const u8) Error!Method {
33
33
-
if (std.mem.eql(u8, method, "_health")) return .{ .nsid = method };
34
34
-
syntax.validateNsid(method) catch return Error.InvalidMethod;
35
35
-
return .{ .nsid = method };
36
36
-
}
37
37
-
38
38
-
pub fn methodKind(method: []const u8) Error!enum { query, procedure, subscription, health } {
39
39
-
const parsed = try parseMethod(method);
40
40
-
if (parsed.isHealth()) return .health;
41
41
-
42
42
-
if (std.mem.endsWith(u8, method, "subscribeRepos")) return .subscription;
43
43
-
if (std.mem.indexOf(u8, method, ".get") != null) return .query;
44
44
-
if (std.mem.indexOf(u8, method, ".list") != null) return .query;
45
45
-
if (std.mem.indexOf(u8, method, ".describe") != null) return .query;
46
46
-
if (std.mem.indexOf(u8, method, ".resolve") != null) return .query;
47
47
-
return .procedure;
48
48
-
}
49
49
-
50
50
-
test "parses xrpc paths" {
51
51
-
try std.testing.expect((try parsePath("/xrpc/_health")).isHealth());
52
52
-
try std.testing.expectEqualStrings("com.atproto.repo.getRecord", (try parsePath("/xrpc/com.atproto.repo.getRecord")).nsid);
53
53
-
}
54
54
-
55
55
-
test "classifies method names" {
56
56
-
try std.testing.expectEqual(.health, try methodKind("_health"));
57
57
-
try std.testing.expectEqual(.query, try methodKind("com.atproto.repo.getRecord"));
58
58
-
try std.testing.expectEqual(.query, try methodKind("com.atproto.repo.listRecords"));
59
59
-
try std.testing.expectEqual(.subscription, try methodKind("com.atproto.sync.subscribeRepos"));
60
60
-
try std.testing.expectEqual(.procedure, try methodKind("com.atproto.repo.createRecord"));
61
61
-
}
62
62
-
63
63
-
test "rejects non-xrpc paths" {
64
64
-
try std.testing.expectError(Error.NotXrpc, parsePath("/"));
65
65
-
try std.testing.expectError(Error.MissingMethod, parsePath("/xrpc/"));
66
66
-
try std.testing.expectError(Error.InvalidMethod, parsePath("/xrpc/com"));
67
67
-
}
68
68
-
···
1
1
-
const std = @import("std");
2
2
-
const auth = @import("../auth/tokens.zig");
3
3
-
const store = @import("../storage/store.zig");
4
4
-
5
5
-
const http = std.http;
6
6
-
7
7
-
pub fn requireBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
8
8
-
return requireBearerAccountWithScope(request, allocator, "com.atproto.access");
9
9
-
}
10
10
-
11
11
-
pub fn requireBearerAccountWithScope(request: *const http.Server.Request, allocator: std.mem.Allocator, scope: []const u8) !auth.Account {
12
12
-
const auth_header = headerValue(request, "authorization") orelse {
13
13
-
return error.AuthRequired;
14
14
-
};
15
15
-
16
16
-
const token_start =
17
17
-
if (std.ascii.startsWithIgnoreCase(auth_header, "bearer "))
18
18
-
"bearer ".len
19
19
-
else if (std.ascii.startsWithIgnoreCase(auth_header, "dpop "))
20
20
-
"dpop ".len
21
21
-
else
22
22
-
return error.AuthRequired;
23
23
-
24
24
-
const token = std.mem.trim(u8, auth_header[token_start..], " \t");
25
25
-
const claims = auth.claimsFromSessionJwt(allocator, token) orelse return error.InvalidToken;
26
26
-
defer allocator.free(claims.did);
27
27
-
defer allocator.free(claims.scope);
28
28
-
if (!std.mem.eql(u8, claims.scope, scope)) return error.InvalidToken;
29
29
-
return (store.findAccount(allocator, claims.did) catch null) orelse error.InvalidToken;
30
30
-
}
31
31
-
32
32
-
pub fn optionalBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) ?auth.Account {
33
33
-
return requireBearerAccount(request, allocator) catch null;
34
34
-
}
35
35
-
36
36
-
pub fn parseJsonBody(request: *http.Server.Request, allocator: std.mem.Allocator, body: []const u8) !std.json.Parsed(std.json.Value) {
37
37
-
return std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch {
38
38
-
try xrpcError(request, .bad_request, "InvalidRequest", "Expected JSON body");
39
39
-
return error.InvalidJson;
40
40
-
};
41
41
-
}
42
42
-
43
43
-
pub fn valueString(value: std.json.Value, key: []const u8) ?[]const u8 {
44
44
-
return switch (value) {
45
45
-
.object => |object| switch (object.get(key) orelse return null) {
46
46
-
.string => |string| string,
47
47
-
else => null,
48
48
-
},
49
49
-
else => null,
50
50
-
};
51
51
-
}
52
52
-
53
53
-
pub fn readBody(request: *http.Server.Request, buf: []u8) ![]const u8 {
54
54
-
var reader_buf: [1024]u8 = undefined;
55
55
-
const reader = try request.readerExpectContinue(&reader_buf);
56
56
-
if (request.head.content_length) |len| {
57
57
-
if (len > buf.len) return error.BodyTooLarge;
58
58
-
var total: usize = 0;
59
59
-
const expected: usize = @intCast(len);
60
60
-
var chunk: [8192]u8 = undefined;
61
61
-
while (total < expected) {
62
62
-
const remaining = expected - total;
63
63
-
const n = try reader.readSliceShort(chunk[0..@min(chunk.len, remaining)]);
64
64
-
if (n == 0) return error.UnexpectedEof;
65
65
-
@memcpy(buf[total..][0..n], chunk[0..n]);
66
66
-
total += n;
67
67
-
}
68
68
-
return buf[0..expected];
69
69
-
}
70
70
-
71
71
-
var total: usize = 0;
72
72
-
while (true) {
73
73
-
if (total == buf.len) {
74
74
-
var extra: [1]u8 = undefined;
75
75
-
const n = try reader.readSliceShort(&extra);
76
76
-
if (n == 0) return buf[0..total];
77
77
-
return error.BodyTooLarge;
78
78
-
}
79
79
-
80
80
-
const n = try reader.readSliceShort(buf[total..]);
81
81
-
if (n == 0) return buf[0..total];
82
82
-
total += n;
83
83
-
}
84
84
-
}
85
85
-
86
86
-
pub fn readBodyAlloc(request: *http.Server.Request, allocator: std.mem.Allocator, max_len: usize) ![]const u8 {
87
87
-
var reader_buf: [4096]u8 = undefined;
88
88
-
const reader = try request.readerExpectContinue(&reader_buf);
89
89
-
if (request.head.content_length) |len| {
90
90
-
if (len > max_len) return error.BodyTooLarge;
91
91
-
const body = try allocator.alloc(u8, @intCast(len));
92
92
-
var total: usize = 0;
93
93
-
var chunk: [8192]u8 = undefined;
94
94
-
while (total < body.len) {
95
95
-
const remaining = body.len - total;
96
96
-
const n = try reader.readSliceShort(chunk[0..@min(chunk.len, remaining)]);
97
97
-
if (n == 0) return error.UnexpectedEof;
98
98
-
@memcpy(body[total..][0..n], chunk[0..n]);
99
99
-
total += n;
100
100
-
}
101
101
-
return body;
102
102
-
}
103
103
-
104
104
-
var body: std.ArrayList(u8) = .empty;
105
105
-
var chunk: [8192]u8 = undefined;
106
106
-
while (true) {
107
107
-
const n = try reader.readSliceShort(&chunk);
108
108
-
if (n == 0) return try body.toOwnedSlice(allocator);
109
109
-
if (body.items.len + n > max_len) return error.BodyTooLarge;
110
110
-
try body.appendSlice(allocator, chunk[0..n]);
111
111
-
}
112
112
-
}
113
113
-
114
114
-
pub fn headerValue(request: *const http.Server.Request, name: []const u8) ?[]const u8 {
115
115
-
var it = request.iterateHeaders();
116
116
-
while (it.next()) |header| {
117
117
-
if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
118
118
-
}
119
119
-
return null;
120
120
-
}
121
121
-
122
122
-
pub fn queryParam(target: []const u8, name: []const u8, out: []u8) ?[]const u8 {
123
123
-
const query_start = std.mem.indexOfScalar(u8, target, '?') orelse return null;
124
124
-
var params = std.mem.splitScalar(u8, target[query_start + 1 ..], '&');
125
125
-
while (params.next()) |param| {
126
126
-
const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue;
127
127
-
if (!std.mem.eql(u8, param[0..eq], name)) continue;
128
128
-
return percentDecode(param[eq + 1 ..], out) catch null;
129
129
-
}
130
130
-
return null;
131
131
-
}
132
132
-
133
133
-
pub fn queryLimit(target: []const u8, default: usize) usize {
134
134
-
var buf: [16]u8 = undefined;
135
135
-
const raw = queryParam(target, "limit", &buf) orelse return default;
136
136
-
return std.fmt.parseInt(usize, raw, 10) catch default;
137
137
-
}
138
138
-
139
139
-
pub fn percentDecode(input: []const u8, out: []u8) ![]const u8 {
140
140
-
var write: usize = 0;
141
141
-
var read: usize = 0;
142
142
-
while (read < input.len) {
143
143
-
if (write >= out.len) return error.NoSpaceLeft;
144
144
-
switch (input[read]) {
145
145
-
'%' => {
146
146
-
if (read + 2 >= input.len) return error.InvalidPercentEncoding;
147
147
-
out[write] = try std.fmt.parseInt(u8, input[read + 1 .. read + 3], 16);
148
148
-
read += 3;
149
149
-
},
150
150
-
'+' => {
151
151
-
out[write] = ' ';
152
152
-
read += 1;
153
153
-
},
154
154
-
else => |c| {
155
155
-
out[write] = c;
156
156
-
read += 1;
157
157
-
},
158
158
-
}
159
159
-
write += 1;
160
160
-
}
161
161
-
return out[0..write];
162
162
-
}
163
163
-
164
164
-
pub fn corsPreflight(request: *http.Server.Request) !void {
165
165
-
var headers = cors_headers;
166
166
-
var allow_headers_buf: [1024]u8 = undefined;
167
167
-
if (headerValue(request, "access-control-request-headers")) |requested| {
168
168
-
const trimmed = std.mem.trim(u8, requested, " \t");
169
169
-
if (trimmed.len > 0 and trimmed.len < allow_headers_buf.len) {
170
170
-
@memcpy(allow_headers_buf[0..trimmed.len], trimmed);
171
171
-
headers[3].value = allow_headers_buf[0..trimmed.len];
172
172
-
}
173
173
-
}
174
174
-
try request.respond("", .{
175
175
-
.status = .no_content,
176
176
-
.extra_headers = &headers,
177
177
-
});
178
178
-
}
179
179
-
180
180
-
pub fn json(request: *http.Server.Request, status: http.Status, body: []const u8) !void {
181
181
-
normalizeBodylessRequest(request);
182
182
-
try request.respond(body, .{
183
183
-
.status = status,
184
184
-
.extra_headers = &json_headers,
185
185
-
});
186
186
-
}
187
187
-
188
188
-
pub fn text(request: *http.Server.Request, status: http.Status, body: []const u8) !void {
189
189
-
normalizeBodylessRequest(request);
190
190
-
try request.respond(body, .{
191
191
-
.status = status,
192
192
-
.extra_headers = &text_headers,
193
193
-
});
194
194
-
}
195
195
-
196
196
-
pub fn xrpcError(
197
197
-
request: *http.Server.Request,
198
198
-
status: http.Status,
199
199
-
error_name: []const u8,
200
200
-
message: []const u8,
201
201
-
) !void {
202
202
-
var buf: [512]u8 = undefined;
203
203
-
const body = try std.fmt.bufPrint(&buf, "{{\"error\":\"{s}\",\"message\":\"{s}\"}}", .{ error_name, message });
204
204
-
try json(request, status, body);
205
205
-
}
206
206
-
207
207
-
const json_headers = [_]http.Header{
208
208
-
.{ .name = "content-type", .value = "application/json; charset=utf-8" },
209
209
-
.{ .name = "access-control-allow-origin", .value = "*" },
210
210
-
.{ .name = "access-control-allow-private-network", .value = "true" },
211
211
-
.{ .name = "connection", .value = "close" },
212
212
-
};
213
213
-
214
214
-
const text_headers = [_]http.Header{
215
215
-
.{ .name = "content-type", .value = "text/plain; charset=utf-8" },
216
216
-
.{ .name = "access-control-allow-origin", .value = "*" },
217
217
-
.{ .name = "access-control-allow-private-network", .value = "true" },
218
218
-
.{ .name = "connection", .value = "close" },
219
219
-
};
220
220
-
221
221
-
const cors_headers = [_]http.Header{
222
222
-
.{ .name = "access-control-allow-origin", .value = "*" },
223
223
-
.{ .name = "access-control-allow-methods", .value = "GET, POST, OPTIONS" },
224
224
-
.{ .name = "vary", .value = "Access-Control-Request-Headers" },
225
225
-
.{ .name = "access-control-allow-headers", .value = "atproto-accept-labelers, atproto-proxy, authorization, content-type, dpop, x-bsky-topics" },
226
226
-
.{ .name = "access-control-allow-private-network", .value = "true" },
227
227
-
.{ .name = "access-control-max-age", .value = "600" },
228
228
-
.{ .name = "connection", .value = "close" },
229
229
-
};
230
230
-
231
231
-
fn normalizeBodylessRequest(request: *http.Server.Request) void {
232
232
-
if (request.head.method == .POST and
233
233
-
request.head.content_length == null and
234
234
-
request.head.transfer_encoding == .none)
235
235
-
{
236
236
-
request.head.content_length = 0;
237
237
-
}
238
238
-
}
239
239
-
240
240
-
test "decodes query params" {
241
241
-
var buf: [64]u8 = undefined;
242
242
-
try std.testing.expectEqualStrings("did:plc:service", queryParam("/xrpc/foo?aud=did%3Aplc%3Aservice", "aud", &buf).?);
243
243
-
}
···
1
1
-
const std = @import("std");
2
2
-
const xrpc = @import("../core/xrpc.zig");
3
3
-
4
4
-
const http = std.http;
5
5
-
6
6
-
pub const Route = enum {
7
7
-
cors_preflight,
8
8
-
root,
9
9
-
health,
10
10
-
did_json,
11
11
-
oauth_protected_resource,
12
12
-
oauth_authorization_server,
13
13
-
oauth_jwks,
14
14
-
oauth_par,
15
15
-
oauth_authorize,
16
16
-
oauth_token,
17
17
-
oauth_introspect,
18
18
-
oauth_revoke,
19
19
-
atproto_did,
20
20
-
describe_server,
21
21
-
create_account,
22
22
-
create_session,
23
23
-
refresh_session,
24
24
-
get_session,
25
25
-
get_service_auth,
26
26
-
activate_account,
27
27
-
deactivate_account,
28
28
-
request_email_confirmation,
29
29
-
confirm_email,
30
30
-
request_email_update,
31
31
-
update_email,
32
32
-
check_account_status,
33
33
-
app_preferences_get,
34
34
-
app_preferences_put,
35
35
-
repo_create_record,
36
36
-
repo_put_record,
37
37
-
repo_describe_repo,
38
38
-
repo_get_record,
39
39
-
repo_list_records,
40
40
-
repo_delete_record,
41
41
-
repo_apply_writes,
42
42
-
repo_import_repo,
43
43
-
repo_upload_blob,
44
44
-
repo_list_missing_blobs,
45
45
-
sync_get_blob,
46
46
-
sync_get_repo,
47
47
-
sync_get_latest_commit,
48
48
-
sync_list_repos,
49
49
-
sync_list_blobs,
50
50
-
sync_subscribe_repos,
51
51
-
sync_get_repo_status,
52
52
-
sync_notify_of_update,
53
53
-
sync_request_crawl,
54
54
-
identity_get_recommended_did_credentials,
55
55
-
identity_request_plc_operation_signature,
56
56
-
identity_sign_plc_operation,
57
57
-
identity_submit_plc_operation,
58
58
-
identity_resolve_handle,
59
59
-
not_found,
60
60
-
};
61
61
-
62
62
-
pub fn route(method: http.Method, target: []const u8) Route {
63
63
-
if (method == .OPTIONS) return .cors_preflight;
64
64
-
65
65
-
const path = stripQuery(target);
66
66
-
if (method == .GET and std.mem.eql(u8, path, "/")) return .root;
67
67
-
if (method == .GET and std.mem.eql(u8, path, xrpc.healthPath)) return .health;
68
68
-
if (method == .GET and std.mem.eql(u8, path, "/.well-known/did.json")) return .did_json;
69
69
-
if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-protected-resource")) return .oauth_protected_resource;
70
70
-
if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-authorization-server")) return .oauth_authorization_server;
71
71
-
if (method == .GET and std.mem.eql(u8, path, "/oauth/jwks")) return .oauth_jwks;
72
72
-
if (method == .POST and std.mem.eql(u8, path, "/oauth/par")) return .oauth_par;
73
73
-
if ((method == .GET or method == .POST) and std.mem.eql(u8, path, "/oauth/authorize")) return .oauth_authorize;
74
74
-
if (method == .POST and std.mem.eql(u8, path, "/oauth/token")) return .oauth_token;
75
75
-
if (method == .POST and std.mem.eql(u8, path, "/oauth/introspect")) return .oauth_introspect;
76
76
-
if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke;
77
77
-
if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did;
78
78
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server;
79
79
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account;
80
80
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createSession")) return .create_session;
81
81
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.refreshSession")) return .refresh_session;
82
82
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getSession")) return .get_session;
83
83
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getServiceAuth")) return .get_service_auth;
84
84
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.activateAccount")) return .activate_account;
85
85
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.deactivateAccount")) return .deactivate_account;
86
86
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailConfirmation")) return .request_email_confirmation;
87
87
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.confirmEmail")) return .confirm_email;
88
88
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailUpdate")) return .request_email_update;
89
89
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.updateEmail")) return .update_email;
90
90
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.checkAccountStatus")) return .check_account_status;
91
91
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getPreferences")) return .app_preferences_get;
92
92
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.putPreferences")) return .app_preferences_put;
93
93
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.createRecord")) return .repo_create_record;
94
94
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.putRecord")) return .repo_put_record;
95
95
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.describeRepo")) return .repo_describe_repo;
96
96
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.getRecord")) return .repo_get_record;
97
97
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listRecords")) return .repo_list_records;
98
98
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.deleteRecord")) return .repo_delete_record;
99
99
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.applyWrites")) return .repo_apply_writes;
100
100
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.importRepo")) return .repo_import_repo;
101
101
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.uploadBlob")) return .repo_upload_blob;
102
102
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listMissingBlobs")) return .repo_list_missing_blobs;
103
103
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getBlob")) return .sync_get_blob;
104
104
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepo")) return .sync_get_repo;
105
105
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getLatestCommit")) return .sync_get_latest_commit;
106
106
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listRepos")) return .sync_list_repos;
107
107
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listBlobs")) return .sync_list_blobs;
108
108
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.subscribeRepos")) return .sync_subscribe_repos;
109
109
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepoStatus")) return .sync_get_repo_status;
110
110
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.notifyOfUpdate")) return .sync_notify_of_update;
111
111
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.requestCrawl")) return .sync_request_crawl;
112
112
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.getRecommendedDidCredentials")) return .identity_get_recommended_did_credentials;
113
113
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.requestPlcOperationSignature")) return .identity_request_plc_operation_signature;
114
114
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.signPlcOperation")) return .identity_sign_plc_operation;
115
115
-
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.submitPlcOperation")) return .identity_submit_plc_operation;
116
116
-
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.resolveHandle")) return .identity_resolve_handle;
117
117
-
118
118
-
return .not_found;
119
119
-
}
120
120
-
121
121
-
fn stripQuery(target: []const u8) []const u8 {
122
122
-
const end = std.mem.indexOfScalar(u8, target, '?') orelse target.len;
123
123
-
return target[0..end];
124
124
-
}
125
125
-
126
126
-
test "routes pds probes" {
127
127
-
try std.testing.expectEqual(Route.cors_preflight, route(.OPTIONS, "/anything"));
128
128
-
try std.testing.expectEqual(Route.root, route(.GET, "/"));
129
129
-
try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health"));
130
130
-
try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health?x=1"));
131
131
-
try std.testing.expectEqual(Route.did_json, route(.GET, "/.well-known/did.json"));
132
132
-
try std.testing.expectEqual(Route.oauth_protected_resource, route(.GET, "/.well-known/oauth-protected-resource"));
133
133
-
try std.testing.expectEqual(Route.oauth_authorization_server, route(.GET, "/.well-known/oauth-authorization-server"));
134
134
-
try std.testing.expectEqual(Route.oauth_par, route(.POST, "/oauth/par"));
135
135
-
try std.testing.expectEqual(Route.oauth_authorize, route(.GET, "/oauth/authorize?request_uri=x"));
136
136
-
try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token"));
137
137
-
try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did"));
138
138
-
try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer"));
139
139
-
try std.testing.expectEqual(Route.create_account, route(.POST, "/xrpc/com.atproto.server.createAccount"));
140
140
-
try std.testing.expectEqual(Route.create_session, route(.POST, "/xrpc/com.atproto.server.createSession"));
141
141
-
try std.testing.expectEqual(Route.refresh_session, route(.POST, "/xrpc/com.atproto.server.refreshSession"));
142
142
-
try std.testing.expectEqual(Route.get_session, route(.GET, "/xrpc/com.atproto.server.getSession"));
143
143
-
try std.testing.expectEqual(Route.get_service_auth, route(.GET, "/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aplc%3Aservice"));
144
144
-
try std.testing.expectEqual(Route.app_preferences_get, route(.GET, "/xrpc/app.bsky.actor.getPreferences"));
145
145
-
try std.testing.expectEqual(Route.app_preferences_put, route(.POST, "/xrpc/app.bsky.actor.putPreferences"));
146
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
147
-
try std.testing.expectEqual(Route.repo_describe_repo, route(.GET, "/xrpc/com.atproto.repo.describeRepo?repo=alice.test"));
148
148
-
try std.testing.expectEqual(Route.repo_create_record, route(.POST, "/xrpc/com.atproto.repo.createRecord"));
149
149
-
try std.testing.expectEqual(Route.repo_upload_blob, route(.POST, "/xrpc/com.atproto.repo.uploadBlob"));
150
150
-
try std.testing.expectEqual(Route.sync_list_repos, route(.GET, "/xrpc/com.atproto.sync.listRepos?limit=10"));
151
151
-
try std.testing.expectEqual(Route.sync_get_latest_commit, route(.GET, "/xrpc/com.atproto.sync.getLatestCommit?did=did%3Aplc%3Aabc"));
152
152
-
try std.testing.expectEqual(Route.sync_subscribe_repos, route(.GET, "/xrpc/com.atproto.sync.subscribeRepos?cursor=0"));
153
153
-
try std.testing.expectEqual(Route.sync_request_crawl, route(.POST, "/xrpc/com.atproto.sync.requestCrawl"));
154
154
-
try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US"));
155
155
-
try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test"));
156
156
-
}
157
157
-
158
158
-
test "does not route wrong methods" {
159
159
-
try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/_health"));
160
160
-
try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.server.createSession"));
161
161
-
}
···
1
1
-
const std = @import("std");
2
2
-
const atproto_identity = @import("../atproto/identity.zig");
3
3
-
const atproto_oauth = @import("../atproto/oauth.zig");
4
4
-
const atproto_preferences = @import("../atproto/preferences.zig");
5
5
-
const atproto_proxy = @import("../atproto/proxy.zig");
6
6
-
const atproto_repo = @import("../atproto/repo.zig");
7
7
-
const atproto_server = @import("../atproto/server.zig");
8
8
-
const atproto_sync = @import("../atproto/sync.zig");
9
9
-
const log = @import("../core/log.zig");
10
10
-
const http_api = @import("api.zig");
11
11
-
const router = @import("router.zig");
12
12
-
13
13
-
const http = std.http;
14
14
-
const net = std.Io.net;
15
15
-
const corsPreflight = http_api.corsPreflight;
16
16
-
const json = http_api.json;
17
17
-
const text = http_api.text;
18
18
-
const xrpcError = http_api.xrpcError;
19
19
-
20
20
-
pub const Options = struct {
21
21
-
host: []const u8 = "127.0.0.1",
22
22
-
port: u16 = 2583,
23
23
-
};
24
24
-
25
25
-
pub fn listen(io: std.Io, options: Options) !void {
26
26
-
const ip4 = try net.Ip4Address.parse(options.host, options.port);
27
27
-
const address: net.IpAddress = .{ .ip4 = ip4 };
28
28
-
var tcp_server = try address.listen(io, .{ .reuse_address = true });
29
29
-
defer tcp_server.deinit(io);
30
30
-
31
31
-
var connections: std.Io.Group = .init;
32
32
-
defer connections.cancel(io);
33
33
-
34
34
-
log.info("zds listening on http://{s}:{d}\n", .{ options.host, options.port });
35
35
-
while (true) {
36
36
-
const stream = try tcp_server.accept(io);
37
37
-
connections.concurrent(io, serveConnection, .{ io, stream }) catch |err| {
38
38
-
log.err("failed to start connection handler: {s}\n", .{@errorName(err)});
39
39
-
var copy = stream;
40
40
-
copy.close(io);
41
41
-
continue;
42
42
-
};
43
43
-
}
44
44
-
}
45
45
-
46
46
-
fn serveConnection(io: std.Io, stream: net.Stream) void {
47
47
-
defer {
48
48
-
var copy = stream;
49
49
-
copy.close(io);
50
50
-
}
51
51
-
52
52
-
var send_buffer: [4096]u8 = undefined;
53
53
-
var recv_buffer: [4096]u8 = undefined;
54
54
-
var connection_reader = stream.reader(io, &recv_buffer);
55
55
-
var connection_writer = stream.writer(io, &send_buffer);
56
56
-
var http_server: http.Server = .init(&connection_reader.interface, &connection_writer.interface);
57
57
-
58
58
-
var request = http_server.receiveHead() catch |err| switch (err) {
59
59
-
error.HttpConnectionClosing => return,
60
60
-
else => {
61
61
-
log.err("failed to receive request: {s}\n", .{@errorName(err)});
62
62
-
return;
63
63
-
},
64
64
-
};
65
65
-
var target_buf: [512]u8 = undefined;
66
66
-
const target_len = @min(request.head.target.len, target_buf.len);
67
67
-
@memcpy(target_buf[0..target_len], request.head.target[0..target_len]);
68
68
-
const target = target_buf[0..target_len];
69
69
-
serveRequest(io, &request) catch |err| {
70
70
-
log.err("failed to serve {s}: {s}\n", .{ target, @errorName(err) });
71
71
-
return;
72
72
-
};
73
73
-
}
74
74
-
75
75
-
fn serveRequest(io: std.Io, request: *http.Server.Request) !void {
76
76
-
const route = router.route(request.head.method, request.head.target);
77
77
-
const method = request.head.method;
78
78
-
var target_buf: [512]u8 = undefined;
79
79
-
const target_len = @min(request.head.target.len, target_buf.len);
80
80
-
@memcpy(target_buf[0..target_len], request.head.target[0..target_len]);
81
81
-
const target = target_buf[0..target_len];
82
82
-
log.debug("http {s} {s} route={s} start\n", .{ @tagName(method), target, @tagName(route) });
83
83
-
84
84
-
if (atproto_proxy.shouldProxy(request)) {
85
85
-
return atproto_proxy.xrpcProxy(request);
86
86
-
}
87
87
-
88
88
-
switch (route) {
89
89
-
.cors_preflight => try corsPreflight(request),
90
90
-
.root => try text(request, .ok,
91
91
-
\\ __
92
92
-
\\ ____ ____/ /____
93
93
-
\\/_ / / __ / ___/
94
94
-
\\ / /_/ /_/ (__ )
95
95
-
\\/___/\__,_/____/
96
96
-
\\
97
97
-
\\zds is an at protocol personal data server.
98
98
-
\\
99
99
-
\\most api routes are under /xrpc/
100
100
-
\\
101
101
-
\\code: https://tangled.org/zzstoatzz.io/zds
102
102
-
\\protocol: https://atproto.com
103
103
-
\\health: /xrpc/_health
104
104
-
\\server: /xrpc/com.atproto.server.describeServer
105
105
-
\\
106
106
-
),
107
107
-
.health => try json(request, .ok,
108
108
-
\\{"version":"0.0.0","status":"ok"}
109
109
-
),
110
110
-
.did_json => try atproto_server.didJson(request),
111
111
-
.oauth_protected_resource => try atproto_oauth.protectedResource(request),
112
112
-
.oauth_authorization_server => try atproto_oauth.authorizationServer(request),
113
113
-
.oauth_jwks => try atproto_oauth.jwks(request),
114
114
-
.oauth_par => try atproto_oauth.par(request),
115
115
-
.oauth_authorize => if (request.head.method == .GET) try atproto_oauth.authorizeGet(request) else try atproto_oauth.authorizePost(request),
116
116
-
.oauth_token => try atproto_oauth.token(request),
117
117
-
.oauth_introspect => try atproto_oauth.introspect(request),
118
118
-
.oauth_revoke => try atproto_oauth.revoke(request),
119
119
-
.atproto_did => try atproto_server.atprotoDid(request),
120
120
-
.describe_server => try atproto_server.describeServer(request),
121
121
-
.create_account => try atproto_server.createAccount(request),
122
122
-
.create_session => try atproto_server.createSession(request),
123
123
-
.refresh_session => try atproto_server.refreshSession(request),
124
124
-
.get_session => try atproto_server.getSession(request),
125
125
-
.get_service_auth => try atproto_server.getServiceAuth(request),
126
126
-
.activate_account => try atproto_server.activateAccount(request),
127
127
-
.deactivate_account => try atproto_server.deactivateAccount(request),
128
128
-
.request_email_confirmation => try atproto_server.requestEmailConfirmation(request),
129
129
-
.confirm_email => try atproto_server.confirmEmail(request),
130
130
-
.request_email_update => try atproto_server.requestEmailUpdate(request),
131
131
-
.update_email => try atproto_server.updateEmail(request),
132
132
-
.check_account_status => try atproto_server.checkAccountStatus(request),
133
133
-
.app_preferences_get => try atproto_preferences.getPreferences(request),
134
134
-
.app_preferences_put => try atproto_preferences.putPreferences(request),
135
135
-
.repo_create_record => try atproto_repo.createRecord(request),
136
136
-
.repo_put_record => try atproto_repo.putRecord(request),
137
137
-
.repo_describe_repo => try atproto_repo.describeRepo(request),
138
138
-
.repo_get_record => try atproto_repo.getRecord(request),
139
139
-
.repo_list_records => try atproto_repo.listRecords(request),
140
140
-
.repo_delete_record => try atproto_repo.deleteRecord(request),
141
141
-
.repo_apply_writes => try atproto_repo.applyWrites(request),
142
142
-
.repo_import_repo => try atproto_repo.importRepo(request),
143
143
-
.repo_upload_blob => try atproto_repo.uploadBlob(io, request),
144
144
-
.repo_list_missing_blobs => try atproto_repo.listMissingBlobs(request),
145
145
-
.sync_get_blob => try atproto_sync.getBlob(request),
146
146
-
.sync_get_repo => try atproto_sync.getRepo(request),
147
147
-
.sync_get_latest_commit => try atproto_sync.getLatestCommit(request),
148
148
-
.sync_list_repos => try atproto_sync.listRepos(request),
149
149
-
.sync_list_blobs => try atproto_sync.listBlobs(request),
150
150
-
.sync_subscribe_repos => try atproto_sync.subscribeRepos(request),
151
151
-
.sync_get_repo_status => try atproto_sync.getRepoStatus(request),
152
152
-
.sync_notify_of_update => try atproto_sync.notifyOfUpdate(request),
153
153
-
.sync_request_crawl => try atproto_sync.requestCrawl(request),
154
154
-
.identity_get_recommended_did_credentials => try atproto_identity.getRecommendedDidCredentials(request),
155
155
-
.identity_request_plc_operation_signature => try atproto_identity.requestPlcOperationSignature(request),
156
156
-
.identity_sign_plc_operation => try atproto_identity.signPlcOperation(request),
157
157
-
.identity_submit_plc_operation => try atproto_identity.submitPlcOperation(request),
158
158
-
.identity_resolve_handle => try atproto_identity.resolveHandle(request),
159
159
-
.not_found => {
160
160
-
try xrpcError(request, .not_found, "UnknownMethod", "Unknown XRPC method");
161
161
-
},
162
162
-
}
163
163
-
}
···
1
1
-
const std = @import("std");
2
2
-
const zds = @import("zds");
3
3
-
4
4
-
const Io = std.Io;
5
5
-
6
6
-
var app_threaded_io: Io.Threaded = undefined;
7
7
-
pub const std_options_debug_threaded_io: ?*Io.Threaded = &app_threaded_io;
8
8
-
9
9
-
pub fn main(init: std.process.Init) !void {
10
10
-
const allocator = std.heap.smp_allocator;
11
11
-
app_threaded_io = Io.Threaded.init(allocator, .{});
12
12
-
const io = app_threaded_io.io();
13
13
-
14
14
-
var host: []const u8 = cGetenv("ZDS_HOST") orelse "127.0.0.1";
15
15
-
var port: u16 = 2583;
16
16
-
var db_path: []const u8 = "dev/zds.sqlite3";
17
17
-
var public_url: ?[]const u8 = cGetenv("ZDS_PUBLIC_URL");
18
18
-
var server_did: ?[]const u8 = cGetenv("ZDS_SERVER_DID");
19
19
-
var plc_directory: ?[]const u8 = cGetenv("ZDS_PLC_DIRECTORY");
20
20
-
var email_from: ?[]const u8 = cGetenv("ZDS_EMAIL_FROM");
21
21
-
var resend_api_key: ?[]const u8 = cGetenv("ZDS_RESEND_API_KEY");
22
22
-
var blob_upload_limit: ?usize = try envUsize("ZDS_BLOB_UPLOAD_LIMIT");
23
23
-
var blobstore_path: ?[]const u8 = cGetenv("ZDS_BLOBSTORE_PATH");
24
24
-
var handle_domains: ?[]const u8 = cGetenv("ZDS_HANDLE_DOMAINS");
25
25
-
var crawlers: ?[]const u8 = cGetenv("ZDS_CRAWLERS");
26
26
-
var jwt_secret: ?[]const u8 = cGetenv("ZDS_JWT_SECRET");
27
27
-
var log_level: ?[]const u8 = cGetenv("ZDS_LOG_LEVEL");
28
28
-
if (envBool("ZDS_DEBUG")) log_level = "debug";
29
29
-
30
30
-
var args = std.process.Args.Iterator.init(init.minimal.args);
31
31
-
_ = args.next();
32
32
-
while (args.next()) |arg| {
33
33
-
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
34
34
-
usage();
35
35
-
return;
36
36
-
}
37
37
-
if (std.mem.eql(u8, arg, "--port")) {
38
38
-
const value = args.next() orelse return error.MissingPort;
39
39
-
port = try std.fmt.parseInt(u16, value, 10);
40
40
-
continue;
41
41
-
}
42
42
-
if (std.mem.eql(u8, arg, "--host")) {
43
43
-
host = args.next() orelse return error.MissingHost;
44
44
-
continue;
45
45
-
}
46
46
-
if (std.mem.eql(u8, arg, "--db")) {
47
47
-
db_path = args.next() orelse return error.MissingDatabasePath;
48
48
-
continue;
49
49
-
}
50
50
-
if (std.mem.eql(u8, arg, "--public-url")) {
51
51
-
public_url = args.next() orelse return error.MissingPublicUrl;
52
52
-
continue;
53
53
-
}
54
54
-
if (std.mem.eql(u8, arg, "--server-did")) {
55
55
-
server_did = args.next() orelse return error.MissingServerDid;
56
56
-
continue;
57
57
-
}
58
58
-
if (std.mem.eql(u8, arg, "--plc-directory")) {
59
59
-
plc_directory = args.next() orelse return error.MissingPlcDirectory;
60
60
-
continue;
61
61
-
}
62
62
-
if (std.mem.eql(u8, arg, "--email-from")) {
63
63
-
email_from = args.next() orelse return error.MissingEmailFrom;
64
64
-
continue;
65
65
-
}
66
66
-
if (std.mem.eql(u8, arg, "--resend-api-key")) {
67
67
-
resend_api_key = args.next() orelse return error.MissingResendApiKey;
68
68
-
continue;
69
69
-
}
70
70
-
if (std.mem.eql(u8, arg, "--blob-upload-limit")) {
71
71
-
const value = args.next() orelse return error.MissingBlobUploadLimit;
72
72
-
blob_upload_limit = try std.fmt.parseInt(usize, value, 10);
73
73
-
continue;
74
74
-
}
75
75
-
if (std.mem.eql(u8, arg, "--blobstore-path")) {
76
76
-
blobstore_path = args.next() orelse return error.MissingBlobstorePath;
77
77
-
continue;
78
78
-
}
79
79
-
if (std.mem.eql(u8, arg, "--handle-domains")) {
80
80
-
handle_domains = args.next() orelse return error.MissingHandleDomains;
81
81
-
continue;
82
82
-
}
83
83
-
if (std.mem.eql(u8, arg, "--crawlers")) {
84
84
-
crawlers = args.next() orelse return error.MissingCrawlers;
85
85
-
continue;
86
86
-
}
87
87
-
if (std.mem.eql(u8, arg, "--jwt-secret")) {
88
88
-
jwt_secret = args.next() orelse return error.MissingJwtSecret;
89
89
-
continue;
90
90
-
}
91
91
-
if (std.mem.eql(u8, arg, "--log-level")) {
92
92
-
log_level = args.next() orelse return error.MissingLogLevel;
93
93
-
continue;
94
94
-
}
95
95
-
if (std.mem.eql(u8, arg, "--debug")) {
96
96
-
log_level = "debug";
97
97
-
continue;
98
98
-
}
99
99
-
if (std.mem.startsWith(u8, arg, "--port=")) {
100
100
-
port = try std.fmt.parseInt(u16, arg["--port=".len..], 10);
101
101
-
continue;
102
102
-
}
103
103
-
if (std.mem.startsWith(u8, arg, "--host=")) {
104
104
-
host = arg["--host=".len..];
105
105
-
continue;
106
106
-
}
107
107
-
if (std.mem.startsWith(u8, arg, "--db=")) {
108
108
-
db_path = arg["--db=".len..];
109
109
-
continue;
110
110
-
}
111
111
-
if (std.mem.startsWith(u8, arg, "--public-url=")) {
112
112
-
public_url = arg["--public-url=".len..];
113
113
-
continue;
114
114
-
}
115
115
-
if (std.mem.startsWith(u8, arg, "--server-did=")) {
116
116
-
server_did = arg["--server-did=".len..];
117
117
-
continue;
118
118
-
}
119
119
-
if (std.mem.startsWith(u8, arg, "--plc-directory=")) {
120
120
-
plc_directory = arg["--plc-directory=".len..];
121
121
-
continue;
122
122
-
}
123
123
-
if (std.mem.startsWith(u8, arg, "--email-from=")) {
124
124
-
email_from = arg["--email-from=".len..];
125
125
-
continue;
126
126
-
}
127
127
-
if (std.mem.startsWith(u8, arg, "--resend-api-key=")) {
128
128
-
resend_api_key = arg["--resend-api-key=".len..];
129
129
-
continue;
130
130
-
}
131
131
-
if (std.mem.startsWith(u8, arg, "--blob-upload-limit=")) {
132
132
-
blob_upload_limit = try std.fmt.parseInt(usize, arg["--blob-upload-limit=".len..], 10);
133
133
-
continue;
134
134
-
}
135
135
-
if (std.mem.startsWith(u8, arg, "--blobstore-path=")) {
136
136
-
blobstore_path = arg["--blobstore-path=".len..];
137
137
-
continue;
138
138
-
}
139
139
-
if (std.mem.startsWith(u8, arg, "--handle-domains=")) {
140
140
-
handle_domains = arg["--handle-domains=".len..];
141
141
-
continue;
142
142
-
}
143
143
-
if (std.mem.startsWith(u8, arg, "--crawlers=")) {
144
144
-
crawlers = arg["--crawlers=".len..];
145
145
-
continue;
146
146
-
}
147
147
-
if (std.mem.startsWith(u8, arg, "--jwt-secret=")) {
148
148
-
jwt_secret = arg["--jwt-secret=".len..];
149
149
-
continue;
150
150
-
}
151
151
-
if (std.mem.startsWith(u8, arg, "--log-level=")) {
152
152
-
log_level = arg["--log-level=".len..];
153
153
-
continue;
154
154
-
}
155
155
-
return error.UnknownArgument;
156
156
-
}
157
157
-
158
158
-
if (public_url) |value| zds.core.config.setPublicUrl(value);
159
159
-
if (server_did) |value| zds.core.config.setServerDid(value);
160
160
-
if (plc_directory) |value| zds.core.config.setPlcDirectory(value);
161
161
-
zds.core.config.setEmailFrom(email_from);
162
162
-
zds.core.config.setResendApiKey(resend_api_key);
163
163
-
if (blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value);
164
164
-
if (blobstore_path) |value| zds.core.config.setBlobstorePath(value);
165
165
-
if (handle_domains) |value| zds.core.config.setHandleDomains(value);
166
166
-
if (crawlers) |value| zds.core.config.setCrawlers(value);
167
167
-
if (jwt_secret) |value| zds.core.config.setJwtSecret(value);
168
168
-
if (log_level) |value| {
169
169
-
const parsed = zds.core.config.parseLogLevel(value) orelse return error.InvalidLogLevel;
170
170
-
zds.core.config.setLogLevel(parsed);
171
171
-
}
172
172
-
173
173
-
zds.storage.blobstore.init(io, zds.core.config.blobstorePath());
174
174
-
try zds.storage.store.init(io, db_path);
175
175
-
try zds.http.server.listen(io, .{ .host = host, .port = port });
176
176
-
}
177
177
-
178
178
-
fn usage() void {
179
179
-
std.debug.print(
180
180
-
\\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID]
181
181
-
\\ [--blob-upload-limit BYTES] [--blobstore-path PATH]
182
182
-
\\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET]
183
183
-
\\ [--log-level error|info|debug] [--debug]
184
184
-
\\
185
185
-
\\Runs a local PDS-shaped HTTP server.
186
186
-
\\DOMAINS is a comma-separated list such as ".example.com,example.com".
187
187
-
\\URLS is a comma-separated crawler list, defaulting to bsky.network and vsky.network.
188
188
-
\\
189
189
-
, .{});
190
190
-
}
191
191
-
192
192
-
fn cGetenv(name: [*:0]const u8) ?[]const u8 {
193
193
-
return if (std.c.getenv(name)) |value| std.mem.span(value) else null;
194
194
-
}
195
195
-
196
196
-
fn envUsize(name: [*:0]const u8) !?usize {
197
197
-
const value = cGetenv(name) orelse return null;
198
198
-
return try std.fmt.parseInt(usize, value, 10);
199
199
-
}
200
200
-
201
201
-
fn envBool(name: [*:0]const u8) bool {
202
202
-
const value = cGetenv(name) orelse return false;
203
203
-
return std.ascii.eqlIgnoreCase(value, "1") or
204
204
-
std.ascii.eqlIgnoreCase(value, "true") or
205
205
-
std.ascii.eqlIgnoreCase(value, "yes") or
206
206
-
std.ascii.eqlIgnoreCase(value, "on");
207
207
-
}
···
1
1
-
pub const atproto = struct {
2
2
-
pub const identity = @import("atproto/identity.zig");
3
3
-
pub const plc = @import("atproto/plc.zig");
4
4
-
pub const preferences = @import("atproto/preferences.zig");
5
5
-
pub const repo = @import("atproto/repo.zig");
6
6
-
pub const proxy = @import("atproto/proxy.zig");
7
7
-
pub const server = @import("atproto/server.zig");
8
8
-
pub const sync = @import("atproto/sync.zig");
9
9
-
};
10
10
-
11
11
-
pub const auth = struct {
12
12
-
pub const tokens = @import("auth/tokens.zig");
13
13
-
};
14
14
-
15
15
-
pub const core = struct {
16
16
-
pub const atid = @import("core/atid.zig");
17
17
-
pub const config = @import("core/config.zig");
18
18
-
pub const log = @import("core/log.zig");
19
19
-
pub const mail = @import("core/mail.zig");
20
20
-
pub const repo = @import("core/repo.zig");
21
21
-
pub const syntax = @import("core/syntax.zig");
22
22
-
pub const xrpc = @import("core/xrpc.zig");
23
23
-
};
24
24
-
25
25
-
pub const http = struct {
26
26
-
pub const api = @import("http/api.zig");
27
27
-
pub const router = @import("http/router.zig");
28
28
-
pub const server = @import("http/server.zig");
29
29
-
};
30
30
-
31
31
-
pub const storage = struct {
32
32
-
pub const blobstore = @import("storage/blobstore.zig");
33
33
-
pub const store = @import("storage/store.zig");
34
34
-
};
35
35
-
36
36
-
pub const zat = @import("zat");
37
37
-
38
38
-
test {
39
39
-
_ = atproto;
40
40
-
_ = auth;
41
41
-
_ = core;
42
42
-
_ = http;
43
43
-
_ = storage;
44
44
-
}
···
1
1
-
const std = @import("std");
2
2
-
const Io = std.Io;
3
3
-
4
4
-
var blob_io: Io = undefined;
5
5
-
var root_path: []const u8 = "dev/blobs";
6
6
-
var initialized = false;
7
7
-
8
8
-
pub fn init(io: Io, path: []const u8) void {
9
9
-
blob_io = io;
10
10
-
root_path = trimTrailingSlash(path);
11
11
-
initialized = true;
12
12
-
}
13
13
-
14
14
-
pub fn put(allocator: std.mem.Allocator, io: Io, did: []const u8, cid: []const u8, data: []const u8) !void {
15
15
-
_ = io;
16
16
-
try requireInitialized();
17
17
-
const dir_path = try actorDirPath(allocator, did);
18
18
-
try mkdirPath(allocator, dir_path);
19
19
-
const path = try blobPath(allocator, did, cid);
20
20
-
try writeFileC(allocator, path, data);
21
21
-
}
22
22
-
23
23
-
pub fn get(allocator: std.mem.Allocator, did: []const u8, cid: []const u8, limit: usize) ![]u8 {
24
24
-
try requireInitialized();
25
25
-
const path = try blobPath(allocator, did, cid);
26
26
-
return Io.Dir.cwd().readFileAlloc(blob_io, path, allocator, .limited(limit));
27
27
-
}
28
28
-
29
29
-
pub fn delete(allocator: std.mem.Allocator, did: []const u8, cid: []const u8) void {
30
30
-
const path = blobPath(allocator, did, cid) catch return;
31
31
-
Io.Dir.cwd().deleteFile(blob_io, path) catch {};
32
32
-
}
33
33
-
34
34
-
fn actorDirPath(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
35
35
-
return std.fs.path.join(allocator, &.{ root_path, did });
36
36
-
}
37
37
-
38
38
-
fn blobPath(allocator: std.mem.Allocator, did: []const u8, cid: []const u8) ![]const u8 {
39
39
-
return std.fs.path.join(allocator, &.{ root_path, did, cid });
40
40
-
}
41
41
-
42
42
-
fn requireInitialized() !void {
43
43
-
if (!initialized) return error.BlobstoreNotInitialized;
44
44
-
}
45
45
-
46
46
-
fn trimTrailingSlash(value: []const u8) []const u8 {
47
47
-
if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1];
48
48
-
return value;
49
49
-
}
50
50
-
51
51
-
fn mkdirPath(allocator: std.mem.Allocator, path: []const u8) !void {
52
52
-
var index: usize = 0;
53
53
-
while (index < path.len) : (index += 1) {
54
54
-
if (path[index] != std.fs.path.sep) continue;
55
55
-
if (index == 0) continue;
56
56
-
try mkdirOne(allocator, path[0..index]);
57
57
-
}
58
58
-
try mkdirOne(allocator, path);
59
59
-
}
60
60
-
61
61
-
fn mkdirOne(allocator: std.mem.Allocator, path: []const u8) !void {
62
62
-
const path_z = try allocator.dupeZ(u8, path);
63
63
-
if (std.c.mkdir(path_z.ptr, 0o755) == 0) return;
64
64
-
const err: std.posix.E = @enumFromInt(std.posix.system._errno().*);
65
65
-
if (err == .EXIST) return;
66
66
-
return error.CreateDirFailed;
67
67
-
}
68
68
-
69
69
-
fn writeFileC(allocator: std.mem.Allocator, path: []const u8, data: []const u8) !void {
70
70
-
const path_z = try allocator.dupeZ(u8, path);
71
71
-
const file = std.c.fopen(path_z.ptr, "wb") orelse return error.OpenBlobFailed;
72
72
-
defer _ = std.c.fclose(file);
73
73
-
if (data.len == 0) return;
74
74
-
const written = std.c.fwrite(data.ptr, 1, data.len, file);
75
75
-
if (written != data.len) return error.WriteBlobFailed;
76
76
-
}
77
77
-
78
78
-
test "disk blobstore writes and reads account blob bytes" {
79
79
-
const allocator = std.testing.allocator;
80
80
-
var tmp = std.testing.tmpDir(.{});
81
81
-
defer tmp.cleanup();
82
82
-
83
83
-
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
84
84
-
const path = try tmp.dir.realpath(".", &path_buf);
85
85
-
init(std.Options.debug_io, path);
86
86
-
87
87
-
var arena = std.heap.ArenaAllocator.init(allocator);
88
88
-
defer arena.deinit();
89
89
-
const a = arena.allocator();
90
90
-
try put(a, std.Options.debug_io, "did:plc:test", "bafytest", "hello blobstore");
91
91
-
const data = try get(a, "did:plc:test", "bafytest", 1024);
92
92
-
try std.testing.expectEqualStrings("hello blobstore", data);
93
93
-
}
···
1
1
-
const std = @import("std");
2
2
-
const atid = @import("../core/atid.zig");
3
3
-
const auth = @import("../auth/tokens.zig");
4
4
-
const blobstore = @import("blobstore.zig");
5
5
-
const zat = @import("zat");
6
6
-
const zqlite = @import("zqlite");
7
7
-
const Io = std.Io;
8
8
-
9
9
-
pub const Error = error{
10
10
-
InvalidCollection,
11
11
-
InvalidRecordKey,
12
12
-
MissingRecord,
13
13
-
RepoNotFound,
14
14
-
InvalidRepoPath,
15
15
-
InvalidDagCbor,
16
16
-
StoreNotInitialized,
17
17
-
};
18
18
-
19
19
-
pub const Record = struct {
20
20
-
did: []const u8,
21
21
-
collection: []const u8,
22
22
-
rkey: []const u8,
23
23
-
cid: []const u8,
24
24
-
value_json: []const u8,
25
25
-
rev: []const u8,
26
26
-
seq: u64,
27
27
-
28
28
-
pub fn uri(self: Record, allocator: std.mem.Allocator) ![]const u8 {
29
29
-
return std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ self.did, self.collection, self.rkey });
30
30
-
}
31
31
-
};
32
32
-
33
33
-
pub const BlobRecord = struct {
34
34
-
mime_type: []const u8,
35
35
-
data: []const u8,
36
36
-
};
37
37
-
38
38
-
pub const SeqEvent = struct {
39
39
-
seq: u64,
40
40
-
frame: []const u8,
41
41
-
};
42
42
-
43
43
-
pub const EmailInfo = struct {
44
44
-
email: []const u8,
45
45
-
email_confirmed: bool,
46
46
-
auth_code: ?[]const u8,
47
47
-
auth_code_expires_at: ?i64,
48
48
-
pending_email: ?[]const u8,
49
49
-
};
50
50
-
51
51
-
pub const CodeStatus = enum {
52
52
-
valid,
53
53
-
invalid,
54
54
-
expired,
55
55
-
};
56
56
-
57
57
-
pub const OAuthRequest = struct {
58
58
-
request_id: []const u8,
59
59
-
client_id: []const u8,
60
60
-
redirect_uri: []const u8,
61
61
-
scope: []const u8,
62
62
-
state: []const u8,
63
63
-
code_challenge: []const u8,
64
64
-
code_challenge_method: []const u8,
65
65
-
login_hint: ?[]const u8,
66
66
-
dpop_jkt: ?[]const u8,
67
67
-
expires_at: i64,
68
68
-
sub: ?[]const u8,
69
69
-
code: ?[]const u8,
70
70
-
};
71
71
-
72
72
-
pub const OAuthToken = struct {
73
73
-
did: []const u8,
74
74
-
client_id: []const u8,
75
75
-
scope: []const u8,
76
76
-
access_token: []const u8,
77
77
-
refresh_token: []const u8,
78
78
-
expires_at: i64,
79
79
-
revoked: bool,
80
80
-
};
81
81
-
82
82
-
pub const AppPreference = struct {
83
83
-
name: []const u8,
84
84
-
value_json: []const u8,
85
85
-
};
86
86
-
87
87
-
pub const ImportedRecord = struct {
88
88
-
collection: []const u8,
89
89
-
rkey: []const u8,
90
90
-
cid: []const u8,
91
91
-
value_json: []const u8,
92
92
-
blob_cids: []const []const u8,
93
93
-
};
94
94
-
95
95
-
pub const ImportedBlock = struct {
96
96
-
cid: []const u8,
97
97
-
data: []const u8,
98
98
-
};
99
99
-
100
100
-
pub const CommitInfo = struct {
101
101
-
cid: []const u8,
102
102
-
rev: []const u8,
103
103
-
};
104
104
-
105
105
-
pub const WriteResult = struct {
106
106
-
commit: CommitInfo,
107
107
-
records: []Record,
108
108
-
};
109
109
-
110
110
-
pub const WriteOp = union(enum) {
111
111
-
create: struct {
112
112
-
collection: []const u8,
113
113
-
rkey: ?[]const u8,
114
114
-
value: std.json.Value,
115
115
-
},
116
116
-
update: struct {
117
117
-
collection: []const u8,
118
118
-
rkey: []const u8,
119
119
-
value: std.json.Value,
120
120
-
},
121
121
-
delete: struct {
122
122
-
collection: []const u8,
123
123
-
rkey: []const u8,
124
124
-
},
125
125
-
};
126
126
-
127
127
-
const BlobRef = struct {
128
128
-
cid: []const u8,
129
129
-
uri: []const u8,
130
130
-
};
131
131
-
132
132
-
var conn: zqlite.Conn = undefined;
133
133
-
var initialized = false;
134
134
-
var store_io: Io = undefined;
135
135
-
var mutex: Io.Mutex = .init;
136
136
-
137
137
-
pub fn init(io: Io, path: []const u8) !void {
138
138
-
if (initialized) return;
139
139
-
store_io = io;
140
140
-
if (!std.mem.eql(u8, path, ":memory:") and !std.fs.path.isAbsolute(path)) {
141
141
-
if (std.fs.path.dirname(path)) |dir| try Io.Dir.createDirPath(.cwd(), io, dir);
142
142
-
}
143
143
-
144
144
-
const path_z = try std.heap.page_allocator.dupeZ(u8, path);
145
145
-
conn = try zqlite.open(path_z.ptr, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite);
146
146
-
initialized = true;
147
147
-
errdefer close();
148
148
-
149
149
-
try conn.busyTimeout(5000);
150
150
-
try conn.execNoArgs("PRAGMA journal_mode=WAL");
151
151
-
try conn.execNoArgs("PRAGMA foreign_keys=ON");
152
152
-
try migrate();
153
153
-
}
154
154
-
155
155
-
pub fn close() void {
156
156
-
if (!initialized) return;
157
157
-
conn.close();
158
158
-
initialized = false;
159
159
-
}
160
160
-
161
161
-
pub fn nowMs() i64 {
162
162
-
const now = std.Io.Timestamp.now(store_io, .real);
163
163
-
return @intCast(@divTrunc(now.nanoseconds, std.time.ns_per_ms));
164
164
-
}
165
165
-
166
166
-
pub fn randomBytes(buffer: []u8) void {
167
167
-
store_io.random(buffer);
168
168
-
}
169
169
-
170
170
-
pub fn randomToken(allocator: std.mem.Allocator, prefix: []const u8, comptime byte_len: usize) ![]const u8 {
171
171
-
var bytes: [byte_len]u8 = undefined;
172
172
-
randomBytes(&bytes);
173
173
-
const hex = std.fmt.bytesToHex(bytes, .lower);
174
174
-
return std.fmt.allocPrint(allocator, "{s}{s}", .{ prefix, &hex });
175
175
-
}
176
176
-
177
177
-
pub fn currentIo() Io {
178
178
-
return store_io;
179
179
-
}
180
180
-
181
181
-
pub fn resolveRepo(repo: []const u8) ?auth.Account {
182
182
-
return findAccount(std.heap.page_allocator, repo) catch null;
183
183
-
}
184
184
-
185
185
-
pub fn findAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account {
186
186
-
mutex.lockUncancelable(store_io);
187
187
-
defer mutex.unlock(store_io);
188
188
-
try requireInitialized();
189
189
-
190
190
-
return try findAccountLocked(allocator, identifier);
191
191
-
}
192
192
-
193
193
-
pub fn searchAccounts(allocator: std.mem.Allocator, query: []const u8, limit: usize) ![]auth.Account {
194
194
-
mutex.lockUncancelable(store_io);
195
195
-
defer mutex.unlock(store_io);
196
196
-
try requireInitialized();
197
197
-
198
198
-
const actual_limit = if (limit == 0) 25 else limit;
199
199
-
const pattern = try std.fmt.allocPrint(allocator, "%{s}%", .{query});
200
200
-
var rows = if (query.len == 0)
201
201
-
try conn.rows(
202
202
-
\\SELECT handle, did, email, password_hash
203
203
-
\\FROM accounts
204
204
-
\\ORDER BY handle
205
205
-
\\LIMIT ?
206
206
-
, .{@as(i64, @intCast(actual_limit))})
207
207
-
else
208
208
-
try conn.rows(
209
209
-
\\SELECT handle, did, email, password_hash
210
210
-
\\FROM accounts
211
211
-
\\WHERE lower(handle) LIKE lower(?) OR did LIKE ?
212
212
-
\\ORDER BY handle
213
213
-
\\LIMIT ?
214
214
-
, .{ pattern, pattern, @as(i64, @intCast(actual_limit)) });
215
215
-
defer rows.deinit();
216
216
-
217
217
-
var accounts: std.ArrayList(auth.Account) = .empty;
218
218
-
while (rows.next()) |row| {
219
219
-
try accounts.append(allocator, try accountFromRow(allocator, row));
220
220
-
}
221
221
-
if (rows.err) |err| return err;
222
222
-
return accounts.toOwnedSlice(allocator);
223
223
-
}
224
224
-
225
225
-
fn findAccountLocked(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account {
226
226
-
const row = try conn.row(
227
227
-
\\SELECT handle, did, email, password_hash
228
228
-
\\FROM accounts
229
229
-
\\WHERE lower(handle) = lower(?) OR did = ? OR lower(email) = lower(?)
230
230
-
\\LIMIT 1
231
231
-
, .{ identifier, identifier, identifier });
232
232
-
if (row == null) return null;
233
233
-
defer row.?.deinit();
234
234
-
return try accountFromRow(allocator, row.?);
235
235
-
}
236
236
-
237
237
-
fn accountFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !auth.Account {
238
238
-
return .{
239
239
-
.handle = try allocator.dupe(u8, row.text(0)),
240
240
-
.did = try allocator.dupe(u8, row.text(1)),
241
241
-
.email = try allocator.dupe(u8, row.text(2)),
242
242
-
.password = try allocator.dupe(u8, row.text(3)),
243
243
-
};
244
244
-
}
245
245
-
246
246
-
pub fn createAccount(
247
247
-
allocator: std.mem.Allocator,
248
248
-
handle: []const u8,
249
249
-
email: []const u8,
250
250
-
password: []const u8,
251
251
-
did: []const u8,
252
252
-
activated: bool,
253
253
-
) !auth.Account {
254
254
-
const signing_key = try generateAccountSigningKey();
255
255
-
return createAccountWithSigningKey(allocator, handle, email, password, did, activated, signing_key);
256
256
-
}
257
257
-
258
258
-
pub fn createAccountWithSigningKey(
259
259
-
allocator: std.mem.Allocator,
260
260
-
handle: []const u8,
261
261
-
email: []const u8,
262
262
-
password: []const u8,
263
263
-
did: []const u8,
264
264
-
activated: bool,
265
265
-
signing_key: [32]u8,
266
266
-
) !auth.Account {
267
267
-
mutex.lockUncancelable(store_io);
268
268
-
defer mutex.unlock(store_io);
269
269
-
try requireInitialized();
270
270
-
271
271
-
var salt: [16]u8 = undefined;
272
272
-
store_io.random(&salt);
273
273
-
const password_hash = try auth.hashPassword(allocator, password, salt);
274
274
-
try conn.exec(
275
275
-
\\INSERT INTO accounts (did, handle, email, password_hash, activated_at, signing_key_type, signing_key)
276
276
-
\\VALUES (?, ?, ?, ?, CASE WHEN ? THEN unixepoch() ELSE NULL END, 'secp256k1', ?)
277
277
-
\\ON CONFLICT(did) DO UPDATE SET
278
278
-
\\ handle = excluded.handle,
279
279
-
\\ email = excluded.email,
280
280
-
\\ password_hash = excluded.password_hash,
281
281
-
\\ activated_at = excluded.activated_at,
282
282
-
\\ deactivated_at = NULL,
283
283
-
\\ signing_key_type = COALESCE(accounts.signing_key_type, excluded.signing_key_type),
284
284
-
\\ signing_key = COALESCE(accounts.signing_key, excluded.signing_key)
285
285
-
, .{ did, handle, email, password_hash, activated, zqlite.blob(&signing_key) });
286
286
-
return .{
287
287
-
.handle = try allocator.dupe(u8, handle),
288
288
-
.did = try allocator.dupe(u8, did),
289
289
-
.email = try allocator.dupe(u8, email),
290
290
-
.password = try allocator.dupe(u8, password_hash),
291
291
-
};
292
292
-
}
293
293
-
294
294
-
pub fn generateAccountSigningKey() ![32]u8 {
295
295
-
mutex.lockUncancelable(store_io);
296
296
-
defer mutex.unlock(store_io);
297
297
-
try requireInitialized();
298
298
-
return generateSigningKey();
299
299
-
}
300
300
-
301
301
-
pub fn signingKeypair(did: []const u8) !zat.Keypair {
302
302
-
mutex.lockUncancelable(store_io);
303
303
-
defer mutex.unlock(store_io);
304
304
-
try requireInitialized();
305
305
-
return signingKeypairLocked(did);
306
306
-
}
307
307
-
308
308
-
pub fn setAccountActive(did: []const u8, active: bool) !void {
309
309
-
mutex.lockUncancelable(store_io);
310
310
-
defer mutex.unlock(store_io);
311
311
-
try requireInitialized();
312
312
-
if (active) {
313
313
-
try conn.exec(
314
314
-
\\UPDATE accounts
315
315
-
\\SET activated_at = COALESCE(activated_at, unixepoch()),
316
316
-
\\ deactivated_at = NULL
317
317
-
\\WHERE did = ?
318
318
-
, .{did});
319
319
-
} else {
320
320
-
try conn.exec(
321
321
-
\\UPDATE accounts
322
322
-
\\SET deactivated_at = unixepoch()
323
323
-
\\WHERE did = ?
324
324
-
, .{did});
325
325
-
}
326
326
-
}
327
327
-
328
328
-
pub fn sequenceAccountEvent(allocator: std.mem.Allocator, did: []const u8, active: bool) !void {
329
329
-
mutex.lockUncancelable(store_io);
330
330
-
defer mutex.unlock(store_io);
331
331
-
try requireInitialized();
332
332
-
const seq = try nextSeqLocked();
333
333
-
const frame = try accountEventFrame(allocator, seq, did, active);
334
334
-
try insertSeqEventLocked(seq, did, "", frame);
335
335
-
}
336
336
-
337
337
-
pub fn sequenceIdentityEvent(allocator: std.mem.Allocator, did: []const u8, handle: []const u8) !void {
338
338
-
mutex.lockUncancelable(store_io);
339
339
-
defer mutex.unlock(store_io);
340
340
-
try requireInitialized();
341
341
-
const seq = try nextSeqLocked();
342
342
-
const frame = try identityEventFrame(allocator, seq, did, handle);
343
343
-
try insertSeqEventLocked(seq, did, "", frame);
344
344
-
}
345
345
-
346
346
-
pub fn sequenceSyncEvent(allocator: std.mem.Allocator, did: []const u8) !void {
347
347
-
mutex.lockUncancelable(store_io);
348
348
-
defer mutex.unlock(store_io);
349
349
-
try requireInitialized();
350
350
-
const seq = try nextSeqLocked();
351
351
-
const commit = try latestCommitRawLocked(allocator, did) orelse return Error.RepoNotFound;
352
352
-
const frame = try syncEventFrame(allocator, seq, did, commit.commit_cid_text, commit.data_cid_raw, commit.rev, commit.commit_data);
353
353
-
try insertSeqEventLocked(seq, did, commit.commit_cid_text, frame);
354
354
-
}
355
355
-
356
356
-
pub fn isAccountActive(did: []const u8) bool {
357
357
-
mutex.lockUncancelable(store_io);
358
358
-
defer mutex.unlock(store_io);
359
359
-
requireInitialized() catch return false;
360
360
-
return accountActiveLocked(did) catch false;
361
361
-
}
362
362
-
363
363
-
pub fn create(
364
364
-
allocator: std.mem.Allocator,
365
365
-
account: auth.Account,
366
366
-
collection: []const u8,
367
367
-
maybe_rkey: ?[]const u8,
368
368
-
value: std.json.Value,
369
369
-
) !Record {
370
370
-
const rkey = maybe_rkey orelse try nextRkey(allocator);
371
371
-
const result = try applyWrites(allocator, account, &.{.{ .create = .{
372
372
-
.collection = collection,
373
373
-
.rkey = rkey,
374
374
-
.value = value,
375
375
-
} }});
376
376
-
if (result.records.len == 0) return Error.MissingRecord;
377
377
-
return result.records[0];
378
378
-
}
379
379
-
380
380
-
pub fn putOAuthRequest(
381
381
-
request_id: []const u8,
382
382
-
client_id: []const u8,
383
383
-
redirect_uri: []const u8,
384
384
-
scope: []const u8,
385
385
-
state: []const u8,
386
386
-
code_challenge: []const u8,
387
387
-
code_challenge_method: []const u8,
388
388
-
login_hint: ?[]const u8,
389
389
-
dpop_jkt: ?[]const u8,
390
390
-
expires_at: i64,
391
391
-
) !void {
392
392
-
mutex.lockUncancelable(store_io);
393
393
-
defer mutex.unlock(store_io);
394
394
-
try requireInitialized();
395
395
-
try conn.exec(
396
396
-
\\INSERT INTO oauth_requests
397
397
-
\\ (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at)
398
398
-
\\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
399
399
-
, .{ request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at });
400
400
-
}
401
401
-
402
402
-
pub fn getOAuthRequest(allocator: std.mem.Allocator, request_id: []const u8) !?OAuthRequest {
403
403
-
mutex.lockUncancelable(store_io);
404
404
-
defer mutex.unlock(store_io);
405
405
-
try requireInitialized();
406
406
-
const row = try conn.row(
407
407
-
\\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at, sub, code
408
408
-
\\FROM oauth_requests
409
409
-
\\WHERE request_id = ?
410
410
-
, .{request_id});
411
411
-
if (row == null) return null;
412
412
-
defer row.?.deinit();
413
413
-
return try oauthRequestFromRow(row.?, allocator);
414
414
-
}
415
415
-
416
416
-
pub fn authorizeOAuthRequest(request_id: []const u8, did: []const u8, code: []const u8) !void {
417
417
-
mutex.lockUncancelable(store_io);
418
418
-
defer mutex.unlock(store_io);
419
419
-
try requireInitialized();
420
420
-
try conn.exec(
421
421
-
\\UPDATE oauth_requests
422
422
-
\\SET sub = ?, code = ?
423
423
-
\\WHERE request_id = ?
424
424
-
, .{ did, code, request_id });
425
425
-
}
426
426
-
427
427
-
pub fn consumeOAuthCode(allocator: std.mem.Allocator, code: []const u8) !?OAuthRequest {
428
428
-
mutex.lockUncancelable(store_io);
429
429
-
defer mutex.unlock(store_io);
430
430
-
try requireInitialized();
431
431
-
const row = try conn.row(
432
432
-
\\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, dpop_jkt, expires_at, sub, code
433
433
-
\\FROM oauth_requests
434
434
-
\\WHERE code = ?
435
435
-
, .{code});
436
436
-
if (row == null) return null;
437
437
-
defer row.?.deinit();
438
438
-
const request = try oauthRequestFromRow(row.?, allocator);
439
439
-
try conn.exec("UPDATE oauth_requests SET code = NULL WHERE request_id = ?", .{request.request_id});
440
440
-
return request;
441
441
-
}
442
442
-
443
443
-
pub fn putOAuthToken(
444
444
-
did: []const u8,
445
445
-
client_id: []const u8,
446
446
-
scope: []const u8,
447
447
-
access_token: []const u8,
448
448
-
refresh_token: []const u8,
449
449
-
expires_at: i64,
450
450
-
) !void {
451
451
-
mutex.lockUncancelable(store_io);
452
452
-
defer mutex.unlock(store_io);
453
453
-
try requireInitialized();
454
454
-
try conn.exec(
455
455
-
\\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at)
456
456
-
\\VALUES (?, ?, ?, ?, ?, ?)
457
457
-
, .{ access_token, refresh_token, did, client_id, scope, expires_at });
458
458
-
}
459
459
-
460
460
-
pub fn getOAuthToken(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken {
461
461
-
mutex.lockUncancelable(store_io);
462
462
-
defer mutex.unlock(store_io);
463
463
-
try requireInitialized();
464
464
-
const row = try conn.row(
465
465
-
\\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at
466
466
-
\\FROM oauth_tokens
467
467
-
\\WHERE access_token = ? OR refresh_token = ?
468
468
-
\\ORDER BY created_at DESC
469
469
-
\\LIMIT 1
470
470
-
, .{ token, token });
471
471
-
if (row == null) return null;
472
472
-
defer row.?.deinit();
473
473
-
return .{
474
474
-
.did = try allocator.dupe(u8, row.?.text(0)),
475
475
-
.client_id = try allocator.dupe(u8, row.?.text(1)),
476
476
-
.scope = try allocator.dupe(u8, row.?.text(2)),
477
477
-
.access_token = try allocator.dupe(u8, row.?.text(3)),
478
478
-
.refresh_token = try allocator.dupe(u8, row.?.text(4)),
479
479
-
.expires_at = row.?.int(5),
480
480
-
.revoked = row.?.nullableInt(6) != null,
481
481
-
};
482
482
-
}
483
483
-
484
484
-
pub fn revokeOAuthToken(token: []const u8) !void {
485
485
-
mutex.lockUncancelable(store_io);
486
486
-
defer mutex.unlock(store_io);
487
487
-
try requireInitialized();
488
488
-
try conn.exec(
489
489
-
\\UPDATE oauth_tokens
490
490
-
\\SET revoked_at = unixepoch()
491
491
-
\\WHERE access_token = ? OR refresh_token = ?
492
492
-
, .{ token, token });
493
493
-
}
494
494
-
495
495
-
pub fn put(
496
496
-
allocator: std.mem.Allocator,
497
497
-
account: auth.Account,
498
498
-
collection: []const u8,
499
499
-
rkey: []const u8,
500
500
-
value: std.json.Value,
501
501
-
) !Record {
502
502
-
const result = try applyWrites(allocator, account, &.{.{ .update = .{
503
503
-
.collection = collection,
504
504
-
.rkey = rkey,
505
505
-
.value = value,
506
506
-
} }});
507
507
-
if (result.records.len == 0) return Error.MissingRecord;
508
508
-
return result.records[0];
509
509
-
}
510
510
-
511
511
-
pub fn delete(allocator: std.mem.Allocator, account: auth.Account, collection: []const u8, rkey: []const u8) !CommitInfo {
512
512
-
const result = try applyWrites(allocator, account, &.{.{ .delete = .{
513
513
-
.collection = collection,
514
514
-
.rkey = rkey,
515
515
-
} }});
516
516
-
return result.commit;
517
517
-
}
518
518
-
519
519
-
pub fn applyWrites(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp) !WriteResult {
520
520
-
if (ops.len == 0) return Error.MissingRecord;
521
521
-
522
522
-
for (ops) |op| switch (op) {
523
523
-
.create => |create_op| {
524
524
-
if (zat.Nsid.parse(create_op.collection) == null) return Error.InvalidCollection;
525
525
-
if (create_op.rkey) |rkey| if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey;
526
526
-
},
527
527
-
.update => |update_op| {
528
528
-
if (zat.Nsid.parse(update_op.collection) == null) return Error.InvalidCollection;
529
529
-
if (zat.Rkey.parse(update_op.rkey) == null) return Error.InvalidRecordKey;
530
530
-
},
531
531
-
.delete => |delete_op| {
532
532
-
if (zat.Nsid.parse(delete_op.collection) == null) return Error.InvalidCollection;
533
533
-
if (zat.Rkey.parse(delete_op.rkey) == null) return Error.InvalidRecordKey;
534
534
-
},
535
535
-
};
536
536
-
537
537
-
mutex.lockUncancelable(store_io);
538
538
-
defer mutex.unlock(store_io);
539
539
-
try requireInitialized();
540
540
-
541
541
-
const seq = try nextSeqLocked();
542
542
-
const current = try latestCommitRawLocked(allocator, account.did);
543
543
-
const rev = try revForSeq(allocator, seq, if (current) |root| root.rev else null);
544
544
-
const repo_car = try readRepoCarLocked(allocator, account.did);
545
545
-
var tree = if (current) |root|
546
546
-
try zat.mst.Mst.loadFromBlocks(allocator, repo_car, root.data_cid_raw)
547
547
-
else
548
548
-
zat.mst.Mst.init(allocator);
549
549
-
550
550
-
var records: std.ArrayList(Record) = .empty;
551
551
-
var record_blocks: std.ArrayList(ImportedBlock) = .empty;
552
552
-
var mst_blocks: std.ArrayList(zat.car.Block) = .empty;
553
553
-
var blob_refs: std.ArrayList(BlobRef) = .empty;
554
554
-
555
555
-
for (ops) |op| switch (op) {
556
556
-
.create => |create_op| {
557
557
-
const rkey = create_op.rkey orelse try nextRkeyLocked(allocator);
558
558
-
const record = try stageRecordWrite(allocator, &tree, account, create_op.collection, rkey, create_op.value, rev, seq, &record_blocks, &blob_refs);
559
559
-
try records.append(allocator, record);
560
560
-
},
561
561
-
.update => |update_op| {
562
562
-
const record = try stageRecordWrite(allocator, &tree, account, update_op.collection, update_op.rkey, update_op.value, rev, seq, &record_blocks, &blob_refs);
563
563
-
try records.append(allocator, record);
564
564
-
},
565
565
-
.delete => |delete_op| {
566
566
-
const path = try repoPath(allocator, delete_op.collection, delete_op.rkey);
567
567
-
_ = try tree.deleteReturn(path);
568
568
-
},
569
569
-
};
570
570
-
571
571
-
const data_cid = try tree.rootCid();
572
572
-
try writeMstBlocks(allocator, &tree, &mst_blocks);
573
573
-
const commit = try signedCommit(allocator, account.did, rev, data_cid, if (current) |root| root.commit_cid_raw else null);
574
574
-
575
575
-
try conn.exclusiveTransaction();
576
576
-
errdefer conn.rollback();
577
577
-
for (record_blocks.items) |block| {
578
578
-
try conn.exec(
579
579
-
\\INSERT INTO repo_blocks (did, cid, data)
580
580
-
\\VALUES (?, ?, ?)
581
581
-
\\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data
582
582
-
, .{ account.did, block.cid, zqlite.blob(block.data) });
583
583
-
}
584
584
-
for (mst_blocks.items) |block| {
585
585
-
try conn.exec(
586
586
-
\\INSERT INTO repo_blocks (did, cid, data)
587
587
-
\\VALUES (?, ?, ?)
588
588
-
\\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data
589
589
-
, .{ account.did, try cidText(allocator, block.cid_raw), zqlite.blob(block.data) });
590
590
-
}
591
591
-
try conn.exec(
592
592
-
\\INSERT INTO repo_blocks (did, cid, data)
593
593
-
\\VALUES (?, ?, ?)
594
594
-
\\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data
595
595
-
, .{ account.did, commit.cid, zqlite.blob(commit.data) });
596
596
-
597
597
-
for (ops) |op| switch (op) {
598
598
-
.delete => |delete_op| {
599
599
-
const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, delete_op.collection, delete_op.rkey });
600
600
-
try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri});
601
601
-
try conn.exec("DELETE FROM records WHERE did = ? AND collection = ? AND rkey = ?", .{ account.did, delete_op.collection, delete_op.rkey });
602
602
-
},
603
603
-
else => {},
604
604
-
};
605
605
-
for (records.items) |record| {
606
606
-
const uri = try record.uri(allocator);
607
607
-
try conn.exec(
608
608
-
\\INSERT INTO records (did, collection, rkey, uri, cid, value_json, rev, seq)
609
609
-
\\VALUES (?, ?, ?, ?, ?, ?, ?, ?)
610
610
-
\\ON CONFLICT(did, collection, rkey) DO UPDATE SET
611
611
-
\\ uri = excluded.uri,
612
612
-
\\ cid = excluded.cid,
613
613
-
\\ value_json = excluded.value_json,
614
614
-
\\ rev = excluded.rev,
615
615
-
\\ seq = excluded.seq
616
616
-
, .{ record.did, record.collection, record.rkey, uri, record.cid, record.value_json, record.rev, @as(i64, @intCast(seq)) });
617
617
-
try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri});
618
618
-
}
619
619
-
for (blob_refs.items) |ref| {
620
620
-
try conn.exec(
621
621
-
\\INSERT INTO expected_blobs (blob_cid, record_uri)
622
622
-
\\VALUES (?, ?)
623
623
-
\\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid
624
624
-
, .{ ref.cid, ref.uri });
625
625
-
}
626
626
-
try conn.exec(
627
627
-
\\INSERT INTO commits (seq, did, cid, rev, prev)
628
628
-
\\VALUES (?, ?, ?, ?, ?)
629
629
-
, .{ @as(i64, @intCast(seq)), account.did, commit.cid, rev, if (current) |root| root.commit_cid_text else null });
630
630
-
const event_frame = try commitEventFrame(
631
631
-
allocator,
632
632
-
seq,
633
633
-
account.did,
634
634
-
commit.cid,
635
635
-
rev,
636
636
-
if (current) |root| root.commit_cid_text else null,
637
637
-
commit.data,
638
638
-
record_blocks.items,
639
639
-
mst_blocks.items,
640
640
-
ops,
641
641
-
records.items,
642
642
-
);
643
643
-
try conn.exec(
644
644
-
\\INSERT INTO seq_events (seq, did, commit_cid, evt)
645
645
-
\\VALUES (?, ?, ?, ?)
646
646
-
\\ON CONFLICT(seq) DO UPDATE SET
647
647
-
\\ did = excluded.did,
648
648
-
\\ commit_cid = excluded.commit_cid,
649
649
-
\\ evt = excluded.evt
650
650
-
, .{ @as(i64, @intCast(seq)), account.did, commit.cid, zqlite.blob(event_frame) });
651
651
-
try conn.commit();
652
652
-
653
653
-
return .{
654
654
-
.commit = .{ .cid = commit.cid, .rev = rev },
655
655
-
.records = try records.toOwnedSlice(allocator),
656
656
-
};
657
657
-
}
658
658
-
659
659
-
pub fn revForSeq(allocator: std.mem.Allocator, seq: u64, current_rev: ?[]const u8) ![]const u8 {
660
660
-
var timestamp_us = nowMicros();
661
661
-
if (current_rev) |rev| {
662
662
-
const current_timestamp = atid.timestampMicros(rev) catch 0;
663
663
-
if (timestamp_us <= current_timestamp) timestamp_us = current_timestamp + 1;
664
664
-
}
665
665
-
const tid = try atid.encode(timestamp_us, @intCast(seq % 1024));
666
666
-
return allocator.dupe(u8, &tid);
667
667
-
}
668
668
-
669
669
-
fn nowMicros() u64 {
670
670
-
var ts: std.posix.timespec = undefined;
671
671
-
const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) {
672
672
-
.SUCCESS => ts,
673
673
-
else => std.posix.timespec{ .sec = 0, .nsec = 0 },
674
674
-
};
675
675
-
const seconds: u64 = if (timestamp.sec < 0) 0 else @intCast(timestamp.sec);
676
676
-
const nanos: u64 = if (timestamp.nsec < 0) 0 else @intCast(timestamp.nsec);
677
677
-
return seconds * std.time.us_per_s + nanos / std.time.ns_per_us;
678
678
-
}
679
679
-
680
680
-
pub fn get(did: []const u8, collection: []const u8, rkey: []const u8) ?Record {
681
681
-
mutex.lockUncancelable(store_io);
682
682
-
defer mutex.unlock(store_io);
683
683
-
requireInitialized() catch return null;
684
684
-
685
685
-
const row = conn.row(
686
686
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
687
687
-
\\FROM records
688
688
-
\\WHERE did = ? AND collection = ? AND rkey = ?
689
689
-
, .{ did, collection, rkey }) catch return null;
690
690
-
if (row == null) return null;
691
691
-
defer row.?.deinit();
692
692
-
return recordFromRow(row.?, std.heap.page_allocator) catch null;
693
693
-
}
694
694
-
695
695
-
pub fn getByUri(uri: []const u8) ?Record {
696
696
-
mutex.lockUncancelable(store_io);
697
697
-
defer mutex.unlock(store_io);
698
698
-
requireInitialized() catch return null;
699
699
-
return getByUriLocked(uri);
700
700
-
}
701
701
-
702
702
-
pub fn listRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]Record {
703
703
-
mutex.lockUncancelable(store_io);
704
704
-
defer mutex.unlock(store_io);
705
705
-
try requireInitialized();
706
706
-
707
707
-
var rows = try conn.rows(
708
708
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
709
709
-
\\FROM records
710
710
-
\\ORDER BY seq DESC
711
711
-
\\LIMIT ?
712
712
-
, .{@as(i64, @intCast(if (limit == 0) 100 else limit))});
713
713
-
defer rows.deinit();
714
714
-
715
715
-
var records: std.ArrayList(Record) = .empty;
716
716
-
while (rows.next()) |row| {
717
717
-
try records.append(allocator, try recordFromRow(row, allocator));
718
718
-
}
719
719
-
if (rows.err) |err| return err;
720
720
-
return records.toOwnedSlice(allocator);
721
721
-
}
722
722
-
723
723
-
pub fn listRecords(
724
724
-
allocator: std.mem.Allocator,
725
725
-
did: []const u8,
726
726
-
collection: []const u8,
727
727
-
limit: usize,
728
728
-
) ![]Record {
729
729
-
mutex.lockUncancelable(store_io);
730
730
-
defer mutex.unlock(store_io);
731
731
-
try requireInitialized();
732
732
-
733
733
-
var rows = try conn.rows(
734
734
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
735
735
-
\\FROM records
736
736
-
\\WHERE did = ? AND collection = ?
737
737
-
\\ORDER BY seq DESC
738
738
-
\\LIMIT ?
739
739
-
, .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
740
740
-
defer rows.deinit();
741
741
-
742
742
-
var records: std.ArrayList(Record) = .empty;
743
743
-
while (rows.next()) |row| {
744
744
-
try records.append(allocator, try recordFromRow(row, allocator));
745
745
-
}
746
746
-
if (rows.err) |err| return err;
747
747
-
return records.toOwnedSlice(allocator);
748
748
-
}
749
749
-
750
750
-
pub fn listRecordsContaining(
751
751
-
allocator: std.mem.Allocator,
752
752
-
collection: []const u8,
753
753
-
needle: []const u8,
754
754
-
limit: usize,
755
755
-
) ![]Record {
756
756
-
mutex.lockUncancelable(store_io);
757
757
-
defer mutex.unlock(store_io);
758
758
-
try requireInitialized();
759
759
-
760
760
-
var rows = try conn.rows(
761
761
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
762
762
-
\\FROM records
763
763
-
\\WHERE collection = ? AND instr(value_json, ?)
764
764
-
\\ORDER BY seq DESC
765
765
-
\\LIMIT ?
766
766
-
, .{ collection, needle, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
767
767
-
defer rows.deinit();
768
768
-
769
769
-
var records: std.ArrayList(Record) = .empty;
770
770
-
while (rows.next()) |row| {
771
771
-
try records.append(allocator, try recordFromRow(row, allocator));
772
772
-
}
773
773
-
if (rows.err) |err| return err;
774
774
-
return records.toOwnedSlice(allocator);
775
775
-
}
776
776
-
777
777
-
pub fn listRecordsByDidContaining(
778
778
-
allocator: std.mem.Allocator,
779
779
-
did: []const u8,
780
780
-
collection: []const u8,
781
781
-
needle: []const u8,
782
782
-
limit: usize,
783
783
-
) ![]Record {
784
784
-
mutex.lockUncancelable(store_io);
785
785
-
defer mutex.unlock(store_io);
786
786
-
try requireInitialized();
787
787
-
788
788
-
var rows = try conn.rows(
789
789
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
790
790
-
\\FROM records
791
791
-
\\WHERE did = ? AND collection = ? AND instr(value_json, ?)
792
792
-
\\ORDER BY seq DESC
793
793
-
\\LIMIT ?
794
794
-
, .{ did, collection, needle, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
795
795
-
defer rows.deinit();
796
796
-
797
797
-
var records: std.ArrayList(Record) = .empty;
798
798
-
while (rows.next()) |row| {
799
799
-
try records.append(allocator, try recordFromRow(row, allocator));
800
800
-
}
801
801
-
if (rows.err) |err| return err;
802
802
-
return records.toOwnedSlice(allocator);
803
803
-
}
804
804
-
805
805
-
fn getByUriLocked(uri: []const u8) ?Record {
806
806
-
if (getByStoredUriLocked(uri)) |record| return record;
807
807
-
const prefix = "at://";
808
808
-
if (!std.mem.startsWith(u8, uri, prefix)) return null;
809
809
-
const rest = uri[prefix.len..];
810
810
-
const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null;
811
811
-
const repo = rest[0..slash];
812
812
-
const account = (findAccountLocked(std.heap.page_allocator, repo) catch null) orelse return null;
813
813
-
const normalized = std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}", .{ account.did, rest[slash + 1 ..] }) catch return null;
814
814
-
return getByStoredUriLocked(normalized);
815
815
-
}
816
816
-
817
817
-
fn getByStoredUriLocked(uri: []const u8) ?Record {
818
818
-
const row = conn.row(
819
819
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
820
820
-
\\FROM records
821
821
-
\\WHERE uri = ?
822
822
-
, .{uri}) catch return null;
823
823
-
if (row == null) return null;
824
824
-
defer row.?.deinit();
825
825
-
return recordFromRow(row.?, std.heap.page_allocator) catch null;
826
826
-
}
827
827
-
828
828
-
pub fn count(did: []const u8, collection: []const u8) usize {
829
829
-
mutex.lockUncancelable(store_io);
830
830
-
defer mutex.unlock(store_io);
831
831
-
requireInitialized() catch return 0;
832
832
-
const row = conn.row("SELECT count(*) FROM records WHERE did = ? AND collection = ?", .{ did, collection }) catch return 0;
833
833
-
if (row == null) return 0;
834
834
-
defer row.?.deinit();
835
835
-
return @intCast(row.?.int(0));
836
836
-
}
837
837
-
838
838
-
pub fn countSubject(collection: []const u8, subject_did: []const u8) usize {
839
839
-
mutex.lockUncancelable(store_io);
840
840
-
defer mutex.unlock(store_io);
841
841
-
requireInitialized() catch return 0;
842
842
-
const row = conn.row("SELECT count(*) FROM records WHERE collection = ? AND instr(value_json, ?) > 0", .{ collection, subject_did }) catch return 0;
843
843
-
if (row == null) return 0;
844
844
-
defer row.?.deinit();
845
845
-
return @intCast(row.?.int(0));
846
846
-
}
847
847
-
848
848
-
pub fn listCollectionsJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
849
849
-
mutex.lockUncancelable(store_io);
850
850
-
defer mutex.unlock(store_io);
851
851
-
try requireInitialized();
852
852
-
853
853
-
var rows = try conn.rows(
854
854
-
\\SELECT DISTINCT collection
855
855
-
\\FROM records
856
856
-
\\WHERE did = ?
857
857
-
\\ORDER BY collection
858
858
-
, .{did});
859
859
-
defer rows.deinit();
860
860
-
861
861
-
var out: std.Io.Writer.Allocating = .init(allocator);
862
862
-
defer out.deinit();
863
863
-
try out.writer.writeByte('[');
864
864
-
var first = true;
865
865
-
while (rows.next()) |row| {
866
866
-
if (!first) try out.writer.writeByte(',');
867
867
-
first = false;
868
868
-
try out.writer.print("{f}", .{std.json.fmt(row.text(0), .{})});
869
869
-
}
870
870
-
if (rows.err) |err| return err;
871
871
-
try out.writer.writeByte(']');
872
872
-
return out.toOwnedSlice();
873
873
-
}
874
874
-
875
875
-
pub fn getAppPreferences(allocator: std.mem.Allocator, did: []const u8, namespace: []const u8) ![]AppPreference {
876
876
-
mutex.lockUncancelable(store_io);
877
877
-
defer mutex.unlock(store_io);
878
878
-
try requireInitialized();
879
879
-
880
880
-
const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace});
881
881
-
var rows = try conn.rows(
882
882
-
\\SELECT name, value_json
883
883
-
\\FROM app_preferences
884
884
-
\\WHERE did = ? AND (name = ? OR name LIKE ?)
885
885
-
\\ORDER BY id ASC
886
886
-
, .{ did, namespace, like_pattern });
887
887
-
defer rows.deinit();
888
888
-
889
889
-
var preferences: std.ArrayList(AppPreference) = .empty;
890
890
-
while (rows.next()) |row| {
891
891
-
try preferences.append(allocator, .{
892
892
-
.name = try allocator.dupe(u8, row.text(0)),
893
893
-
.value_json = try allocator.dupe(u8, row.text(1)),
894
894
-
});
895
895
-
}
896
896
-
if (rows.err) |err| return err;
897
897
-
return preferences.toOwnedSlice(allocator);
898
898
-
}
899
899
-
900
900
-
pub fn replaceAppPreferences(
901
901
-
allocator: std.mem.Allocator,
902
902
-
did: []const u8,
903
903
-
namespace: []const u8,
904
904
-
preferences: []const AppPreference,
905
905
-
) !void {
906
906
-
mutex.lockUncancelable(store_io);
907
907
-
defer mutex.unlock(store_io);
908
908
-
try requireInitialized();
909
909
-
const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace});
910
910
-
try conn.exclusiveTransaction();
911
911
-
errdefer conn.rollback();
912
912
-
try conn.exec(
913
913
-
\\DELETE FROM app_preferences
914
914
-
\\WHERE did = ? AND (name = ? OR name LIKE ?)
915
915
-
, .{ did, namespace, like_pattern });
916
916
-
for (preferences) |preference| {
917
917
-
try conn.exec(
918
918
-
\\INSERT INTO app_preferences (did, name, value_json, updated_at)
919
919
-
\\VALUES (?, ?, ?, unixepoch())
920
920
-
, .{ did, preference.name, preference.value_json });
921
921
-
}
922
922
-
try conn.commit();
923
923
-
}
924
924
-
925
925
-
pub fn putBlob(
926
926
-
allocator: std.mem.Allocator,
927
927
-
io: Io,
928
928
-
account: auth.Account,
929
929
-
data: []const u8,
930
930
-
mime_type: []const u8,
931
931
-
) ![]const u8 {
932
932
-
const cid = try cidForBlob(allocator, data);
933
933
-
934
934
-
mutex.lockUncancelable(store_io);
935
935
-
defer mutex.unlock(store_io);
936
936
-
try requireInitialized();
937
937
-
try blobstore.put(allocator, io, account.did, cid, data);
938
938
-
try conn.exec(
939
939
-
\\INSERT INTO blobs (cid, did, mime_type, size)
940
940
-
\\VALUES (?, ?, ?, ?)
941
941
-
\\ON CONFLICT(did, cid) DO UPDATE SET
942
942
-
\\ mime_type = excluded.mime_type,
943
943
-
\\ size = excluded.size
944
944
-
, .{ cid, account.did, mime_type, @as(i64, @intCast(data.len)) });
945
945
-
return cid;
946
946
-
}
947
947
-
948
948
-
pub fn getBlob(
949
949
-
allocator: std.mem.Allocator,
950
950
-
did: []const u8,
951
951
-
cid: []const u8,
952
952
-
) ?BlobRecord {
953
953
-
mutex.lockUncancelable(store_io);
954
954
-
defer mutex.unlock(store_io);
955
955
-
requireInitialized() catch return null;
956
956
-
const row = conn.row(
957
957
-
\\SELECT mime_type, size
958
958
-
\\FROM blobs
959
959
-
\\WHERE did = ? AND cid = ?
960
960
-
, .{ did, cid }) catch return null;
961
961
-
const found = row orelse return null;
962
962
-
defer found.deinit();
963
963
-
const size: usize = @intCast(found.int(1));
964
964
-
return .{
965
965
-
.mime_type = allocator.dupe(u8, found.text(0)) catch return null,
966
966
-
.data = blobstore.get(allocator, did, cid, size + 1) catch return null,
967
967
-
};
968
968
-
}
969
969
-
970
970
-
pub fn writeBlobListJson(allocator: std.mem.Allocator, did: []const u8, limit: usize) ![]const u8 {
971
971
-
mutex.lockUncancelable(store_io);
972
972
-
defer mutex.unlock(store_io);
973
973
-
try requireInitialized();
974
974
-
975
975
-
var rows = try conn.rows(
976
976
-
\\SELECT cid
977
977
-
\\FROM blobs
978
978
-
\\WHERE did = ?
979
979
-
\\ORDER BY created_at ASC, cid ASC
980
980
-
\\LIMIT ?
981
981
-
, .{ did, @as(i64, @intCast(if (limit == 0) 500 else limit)) });
982
982
-
defer rows.deinit();
983
983
-
984
984
-
var out: std.Io.Writer.Allocating = .init(allocator);
985
985
-
defer out.deinit();
986
986
-
try out.writer.writeAll("{\"cids\":[");
987
987
-
var first = true;
988
988
-
var last_cid: ?[]const u8 = null;
989
989
-
while (rows.next()) |row| {
990
990
-
if (!first) try out.writer.writeByte(',');
991
991
-
first = false;
992
992
-
const cid = row.text(0);
993
993
-
last_cid = cid;
994
994
-
try out.writer.print("{f}", .{std.json.fmt(cid, .{})});
995
995
-
}
996
996
-
if (rows.err) |err| return err;
997
997
-
try out.writer.writeByte(']');
998
998
-
if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})});
999
999
-
try out.writer.writeByte('}');
1000
1000
-
return out.toOwnedSlice();
1001
1001
-
}
1002
1002
-
1003
1003
-
pub fn writeMissingBlobsJson(allocator: std.mem.Allocator, did: []const u8, limit: usize) ![]const u8 {
1004
1004
-
mutex.lockUncancelable(store_io);
1005
1005
-
defer mutex.unlock(store_io);
1006
1006
-
try requireInitialized();
1007
1007
-
1008
1008
-
var rows = try conn.rows(
1009
1009
-
\\SELECT rb.blob_cid, rb.record_uri
1010
1010
-
\\FROM expected_blobs rb
1011
1011
-
\\JOIN records r ON r.uri = rb.record_uri
1012
1012
-
\\LEFT JOIN blobs b ON b.cid = rb.blob_cid AND b.did = r.did
1013
1013
-
\\WHERE r.did = ? AND b.cid IS NULL
1014
1014
-
\\ORDER BY rb.blob_cid ASC
1015
1015
-
\\LIMIT ?
1016
1016
-
, .{ did, @as(i64, @intCast(if (limit == 0) 500 else limit)) });
1017
1017
-
defer rows.deinit();
1018
1018
-
1019
1019
-
var out: std.Io.Writer.Allocating = .init(allocator);
1020
1020
-
defer out.deinit();
1021
1021
-
try out.writer.writeAll("{\"blobs\":[");
1022
1022
-
var first = true;
1023
1023
-
var last_cid: ?[]const u8 = null;
1024
1024
-
while (rows.next()) |row| {
1025
1025
-
if (!first) try out.writer.writeByte(',');
1026
1026
-
first = false;
1027
1027
-
const cid = row.text(0);
1028
1028
-
last_cid = cid;
1029
1029
-
try out.writer.print(
1030
1030
-
"{{\"cid\":{f},\"recordUri\":{f}}}",
1031
1031
-
.{ std.json.fmt(cid, .{}), std.json.fmt(row.text(1), .{}) },
1032
1032
-
);
1033
1033
-
}
1034
1034
-
if (rows.err) |err| return err;
1035
1035
-
try out.writer.writeByte(']');
1036
1036
-
if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})});
1037
1037
-
try out.writer.writeByte('}');
1038
1038
-
return out.toOwnedSlice();
1039
1039
-
}
1040
1040
-
1041
1041
-
pub fn writeAccountStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
1042
1042
-
mutex.lockUncancelable(store_io);
1043
1043
-
defer mutex.unlock(store_io);
1044
1044
-
try requireInitialized();
1045
1045
-
1046
1046
-
const record_count = try scalarCountLocked(
1047
1047
-
"SELECT COUNT(*) FROM records WHERE did = ?",
1048
1048
-
did,
1049
1049
-
);
1050
1050
-
const blob_count = try scalarCountLocked(
1051
1051
-
"SELECT COUNT(*) FROM blobs WHERE did = ?",
1052
1052
-
did,
1053
1053
-
);
1054
1054
-
const expected_blob_count = try scalarCountLocked(
1055
1055
-
\\SELECT COUNT(*)
1056
1056
-
\\FROM expected_blobs rb
1057
1057
-
\\JOIN records r ON r.uri = rb.record_uri
1058
1058
-
\\WHERE r.did = ?
1059
1059
-
,
1060
1060
-
did,
1061
1061
-
);
1062
1062
-
const block_count = try scalarCountLocked(
1063
1063
-
"SELECT COUNT(*) FROM repo_blocks WHERE did = ?",
1064
1064
-
did,
1065
1065
-
);
1066
1066
-
const active = try accountActiveLocked(did);
1067
1067
-
const root = try latestRootLocked(allocator, did);
1068
1068
-
1069
1069
-
return std.fmt.allocPrint(
1070
1070
-
allocator,
1071
1071
-
"{{\"activated\":{},\"validDid\":{},\"repoCommit\":{f},\"repoRev\":{f},\"repoBlocks\":{d},\"indexedRecords\":{d},\"privateStateValues\":0,\"expectedBlobs\":{d},\"importedBlobs\":{d}}}",
1072
1072
-
.{
1073
1073
-
active,
1074
1074
-
active,
1075
1075
-
std.json.fmt(root.cid, .{}),
1076
1076
-
std.json.fmt(root.rev, .{}),
1077
1077
-
block_count,
1078
1078
-
record_count,
1079
1079
-
expected_blob_count,
1080
1080
-
blob_count,
1081
1081
-
},
1082
1082
-
);
1083
1083
-
}
1084
1084
-
1085
1085
-
pub fn importRepo(
1086
1086
-
allocator: std.mem.Allocator,
1087
1087
-
account: auth.Account,
1088
1088
-
commit_cid: []const u8,
1089
1089
-
rev: []const u8,
1090
1090
-
records: []const ImportedRecord,
1091
1091
-
blocks: []const ImportedBlock,
1092
1092
-
) !void {
1093
1093
-
mutex.lockUncancelable(store_io);
1094
1094
-
defer mutex.unlock(store_io);
1095
1095
-
try requireInitialized();
1096
1096
-
1097
1097
-
const seq = try nextSeqLocked();
1098
1098
-
try conn.exclusiveTransaction();
1099
1099
-
errdefer conn.rollback();
1100
1100
-
try conn.exec("DELETE FROM expected_blobs WHERE record_uri IN (SELECT uri FROM records WHERE did = ?)", .{account.did});
1101
1101
-
try conn.exec("DELETE FROM records WHERE did = ?", .{account.did});
1102
1102
-
try conn.exec("DELETE FROM repo_blocks WHERE did = ?", .{account.did});
1103
1103
-
try conn.exec("DELETE FROM commits WHERE did = ?", .{account.did});
1104
1104
-
1105
1105
-
for (blocks) |block| {
1106
1106
-
try conn.exec(
1107
1107
-
\\INSERT INTO repo_blocks (did, cid, data)
1108
1108
-
\\VALUES (?, ?, ?)
1109
1109
-
\\ON CONFLICT(did, cid) DO UPDATE SET data = excluded.data
1110
1110
-
, .{ account.did, block.cid, zqlite.blob(block.data) });
1111
1111
-
}
1112
1112
-
for (records) |record| {
1113
1113
-
const uri = try std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}/{s}", .{ account.did, record.collection, record.rkey });
1114
1114
-
defer std.heap.page_allocator.free(uri);
1115
1115
-
try conn.exec(
1116
1116
-
\\INSERT INTO records (did, collection, rkey, uri, cid, value_json, rev, seq)
1117
1117
-
\\VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1118
1118
-
, .{ account.did, record.collection, record.rkey, uri, record.cid, record.value_json, rev, @as(i64, @intCast(seq)) });
1119
1119
-
for (record.blob_cids) |blob_cid| {
1120
1120
-
try conn.exec(
1121
1121
-
\\INSERT INTO expected_blobs (blob_cid, record_uri)
1122
1122
-
\\VALUES (?, ?)
1123
1123
-
\\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid
1124
1124
-
, .{ blob_cid, uri });
1125
1125
-
}
1126
1126
-
}
1127
1127
-
try conn.exec(
1128
1128
-
\\INSERT INTO commits (seq, did, cid, rev, prev)
1129
1129
-
\\VALUES (?, ?, ?, ?, NULL)
1130
1130
-
, .{ @as(i64, @intCast(seq)), account.did, commit_cid, rev });
1131
1131
-
const event_frame = try importedCommitEventFrame(allocator, seq, account.did, commit_cid, rev, blocks);
1132
1132
-
try conn.exec(
1133
1133
-
\\INSERT INTO seq_events (seq, did, commit_cid, evt)
1134
1134
-
\\VALUES (?, ?, ?, ?)
1135
1135
-
\\ON CONFLICT(seq) DO UPDATE SET
1136
1136
-
\\ did = excluded.did,
1137
1137
-
\\ commit_cid = excluded.commit_cid,
1138
1138
-
\\ evt = excluded.evt
1139
1139
-
, .{ @as(i64, @intCast(seq)), account.did, commit_cid, zqlite.blob(event_frame) });
1140
1140
-
try conn.commit();
1141
1141
-
}
1142
1142
-
1143
1143
-
pub fn writeRepoCar(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
1144
1144
-
mutex.lockUncancelable(store_io);
1145
1145
-
defer mutex.unlock(store_io);
1146
1146
-
try requireInitialized();
1147
1147
-
1148
1148
-
const root = try latestRootLocked(allocator, did);
1149
1149
-
const root_raw = try zat.multibase.base32lower.decode(allocator, root.cid[1..]);
1150
1150
-
const car_root = zat.cbor.Cid{ .raw = root_raw };
1151
1151
-
1152
1152
-
var rows = try conn.rows(
1153
1153
-
\\SELECT cid, data
1154
1154
-
\\FROM repo_blocks
1155
1155
-
\\WHERE did = ?
1156
1156
-
\\ORDER BY cid ASC
1157
1157
-
, .{did});
1158
1158
-
defer rows.deinit();
1159
1159
-
1160
1160
-
var blocks: std.ArrayList(zat.car.Block) = .empty;
1161
1161
-
while (rows.next()) |row| {
1162
1162
-
const cid_text = row.text(0);
1163
1163
-
const data = row.nullableBlob(1) orelse "";
1164
1164
-
const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]);
1165
1165
-
try blocks.append(allocator, .{
1166
1166
-
.cid_raw = cid_raw,
1167
1167
-
.data = try allocator.dupe(u8, data),
1168
1168
-
});
1169
1169
-
}
1170
1170
-
if (rows.err) |err| return err;
1171
1171
-
const c: zat.car.Car = .{
1172
1172
-
.roots = &.{car_root},
1173
1173
-
.blocks = blocks.items,
1174
1174
-
};
1175
1175
-
return zat.car.writeAlloc(allocator, c);
1176
1176
-
}
1177
1177
-
1178
1178
-
pub fn writeRepoListJson(allocator: std.mem.Allocator, limit: usize) ![]const u8 {
1179
1179
-
mutex.lockUncancelable(store_io);
1180
1180
-
defer mutex.unlock(store_io);
1181
1181
-
try requireInitialized();
1182
1182
-
1183
1183
-
const actual_limit = if (limit == 0) 500 else @min(limit, 1000);
1184
1184
-
var rows = try conn.rows(
1185
1185
-
\\SELECT a.did, c.cid, c.rev, a.activated_at, a.deactivated_at
1186
1186
-
\\FROM accounts a
1187
1187
-
\\JOIN (
1188
1188
-
\\ SELECT did, MAX(seq) AS seq
1189
1189
-
\\ FROM commits
1190
1190
-
\\ GROUP BY did
1191
1191
-
\\) latest ON latest.did = a.did
1192
1192
-
\\JOIN commits c ON c.did = latest.did AND c.seq = latest.seq
1193
1193
-
\\ORDER BY a.did ASC
1194
1194
-
\\LIMIT ?
1195
1195
-
, .{@as(i64, @intCast(actual_limit))});
1196
1196
-
defer rows.deinit();
1197
1197
-
1198
1198
-
var out: std.Io.Writer.Allocating = .init(allocator);
1199
1199
-
defer out.deinit();
1200
1200
-
try out.writer.writeAll("{\"repos\":[");
1201
1201
-
var first = true;
1202
1202
-
while (rows.next()) |row| {
1203
1203
-
if (!first) try out.writer.writeByte(',');
1204
1204
-
first = false;
1205
1205
-
const did = row.text(0);
1206
1206
-
const active = row.nullableInt(3) != null and row.nullableInt(4) == null;
1207
1207
-
const status = if (active) "active" else "deactivated";
1208
1208
-
try out.writer.print(
1209
1209
-
"{{\"did\":{f},\"head\":{f},\"rev\":{f},\"active\":{},\"status\":{f}}}",
1210
1210
-
.{
1211
1211
-
std.json.fmt(did, .{}),
1212
1212
-
std.json.fmt(row.text(1), .{}),
1213
1213
-
std.json.fmt(row.text(2), .{}),
1214
1214
-
active,
1215
1215
-
std.json.fmt(status, .{}),
1216
1216
-
},
1217
1217
-
);
1218
1218
-
}
1219
1219
-
if (rows.err) |err| return err;
1220
1220
-
try out.writer.writeByte(']');
1221
1221
-
try out.writer.writeByte('}');
1222
1222
-
return out.toOwnedSlice();
1223
1223
-
}
1224
1224
-
1225
1225
-
pub fn writeLatestCommitJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
1226
1226
-
mutex.lockUncancelable(store_io);
1227
1227
-
defer mutex.unlock(store_io);
1228
1228
-
try requireInitialized();
1229
1229
-
const root = try latestRootLocked(allocator, did);
1230
1230
-
return std.fmt.allocPrint(
1231
1231
-
allocator,
1232
1232
-
"{{\"cid\":{f},\"rev\":{f}}}",
1233
1233
-
.{ std.json.fmt(root.cid, .{}), std.json.fmt(root.rev, .{}) },
1234
1234
-
);
1235
1235
-
}
1236
1236
-
1237
1237
-
pub fn listSeqEvents(allocator: std.mem.Allocator, cursor: u64, limit: usize) ![]SeqEvent {
1238
1238
-
mutex.lockUncancelable(store_io);
1239
1239
-
defer mutex.unlock(store_io);
1240
1240
-
try requireInitialized();
1241
1241
-
try backfillSeqEventsLocked(allocator);
1242
1242
-
1243
1243
-
var rows = try conn.rows(
1244
1244
-
\\SELECT seq, evt
1245
1245
-
\\FROM seq_events
1246
1246
-
\\WHERE seq > ?
1247
1247
-
\\ORDER BY seq ASC
1248
1248
-
\\LIMIT ?
1249
1249
-
, .{ @as(i64, @intCast(cursor)), @as(i64, @intCast(@min(limit, 1000))) });
1250
1250
-
defer rows.deinit();
1251
1251
-
1252
1252
-
var events: std.ArrayList(SeqEvent) = .empty;
1253
1253
-
while (rows.next()) |row| {
1254
1254
-
try events.append(allocator, .{
1255
1255
-
.seq = @intCast(row.int(0)),
1256
1256
-
.frame = try allocator.dupe(u8, row.nullableBlob(1) orelse ""),
1257
1257
-
});
1258
1258
-
}
1259
1259
-
if (rows.err) |err| return err;
1260
1260
-
return events.toOwnedSlice(allocator);
1261
1261
-
}
1262
1262
-
1263
1263
-
fn insertSeqEventLocked(seq: u64, did: []const u8, commit_cid: []const u8, frame: []const u8) !void {
1264
1264
-
try conn.exec(
1265
1265
-
\\INSERT INTO seq_events (seq, did, commit_cid, evt)
1266
1266
-
\\VALUES (?, ?, ?, ?)
1267
1267
-
\\ON CONFLICT(seq) DO UPDATE SET
1268
1268
-
\\ did = excluded.did,
1269
1269
-
\\ commit_cid = excluded.commit_cid,
1270
1270
-
\\ evt = excluded.evt
1271
1271
-
, .{ @as(i64, @intCast(seq)), did, commit_cid, zqlite.blob(frame) });
1272
1272
-
}
1273
1273
-
1274
1274
-
pub fn writeRepoStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
1275
1275
-
mutex.lockUncancelable(store_io);
1276
1276
-
defer mutex.unlock(store_io);
1277
1277
-
try requireInitialized();
1278
1278
-
const root = try latestRootLocked(allocator, did);
1279
1279
-
const active = try accountActiveLocked(did);
1280
1280
-
const status = if (active) "active" else "deactivated";
1281
1281
-
return std.fmt.allocPrint(
1282
1282
-
allocator,
1283
1283
-
"{{\"did\":{f},\"active\":{},\"status\":{f},\"rev\":{f}}}",
1284
1284
-
.{ std.json.fmt(did, .{}), active, std.json.fmt(status, .{}), std.json.fmt(root.rev, .{}) },
1285
1285
-
);
1286
1286
-
}
1287
1287
-
1288
1288
-
pub fn writeRecordJson(allocator: std.mem.Allocator, record: Record) ![]const u8 {
1289
1289
-
var out: std.Io.Writer.Allocating = .init(allocator);
1290
1290
-
defer out.deinit();
1291
1291
-
const uri = try record.uri(allocator);
1292
1292
-
defer allocator.free(uri);
1293
1293
-
1294
1294
-
try out.writer.print(
1295
1295
-
"{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}",
1296
1296
-
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json },
1297
1297
-
);
1298
1298
-
return out.toOwnedSlice();
1299
1299
-
}
1300
1300
-
1301
1301
-
pub fn writeListJson(
1302
1302
-
allocator: std.mem.Allocator,
1303
1303
-
did: []const u8,
1304
1304
-
collection: []const u8,
1305
1305
-
limit: usize,
1306
1306
-
) ![]const u8 {
1307
1307
-
mutex.lockUncancelable(store_io);
1308
1308
-
defer mutex.unlock(store_io);
1309
1309
-
try requireInitialized();
1310
1310
-
1311
1311
-
var rows = try conn.rows(
1312
1312
-
\\SELECT did, collection, rkey, cid, value_json, rev, seq
1313
1313
-
\\FROM records
1314
1314
-
\\WHERE did = ? AND collection = ?
1315
1315
-
\\ORDER BY seq DESC
1316
1316
-
\\LIMIT ?
1317
1317
-
, .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
1318
1318
-
defer rows.deinit();
1319
1319
-
1320
1320
-
var out: std.Io.Writer.Allocating = .init(allocator);
1321
1321
-
defer out.deinit();
1322
1322
-
try out.writer.writeAll("{\"records\":[");
1323
1323
-
var first = true;
1324
1324
-
while (rows.next()) |row| {
1325
1325
-
const record = try recordFromRow(row, std.heap.page_allocator);
1326
1326
-
if (!first) try out.writer.writeByte(',');
1327
1327
-
first = false;
1328
1328
-
const uri = try record.uri(allocator);
1329
1329
-
defer allocator.free(uri);
1330
1330
-
try out.writer.print(
1331
1331
-
"{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}",
1332
1332
-
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json },
1333
1333
-
);
1334
1334
-
}
1335
1335
-
if (rows.err) |err| return err;
1336
1336
-
try out.writer.writeAll("]}");
1337
1337
-
return out.toOwnedSlice();
1338
1338
-
}
1339
1339
-
1340
1340
-
fn migrate() !void {
1341
1341
-
inline for (schema_statements) |sql| try conn.execNoArgs(sql);
1342
1342
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email TEXT") catch {};
1343
1343
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email_confirmed_at INTEGER") catch {};
1344
1344
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code TEXT") catch {};
1345
1345
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code_expires_at INTEGER") catch {};
1346
1346
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN pending_email TEXT") catch {};
1347
1347
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN activated_at INTEGER") catch {};
1348
1348
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN deactivated_at INTEGER") catch {};
1349
1349
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key_type TEXT") catch {};
1350
1350
-
conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {};
1351
1351
-
conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {};
1352
1352
-
try migrateBlobTable();
1353
1353
-
try migrateAppPreferencesTable();
1354
1354
-
inline for (post_schema_statements) |sql| try conn.execNoArgs(sql);
1355
1355
-
}
1356
1356
-
1357
1357
-
fn migrateAppPreferencesTable() !void {
1358
1358
-
if (!try appPreferencesTableHasPreferencesJsonColumn()) return;
1359
1359
-
try conn.execNoArgs(
1360
1360
-
\\CREATE TABLE IF NOT EXISTS app_preferences_next (
1361
1361
-
\\ id INTEGER PRIMARY KEY AUTOINCREMENT,
1362
1362
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
1363
1363
-
\\ name TEXT NOT NULL,
1364
1364
-
\\ value_json BLOB NOT NULL,
1365
1365
-
\\ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1366
1366
-
\\)
1367
1367
-
);
1368
1368
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1369
1369
-
defer arena.deinit();
1370
1370
-
const allocator = arena.allocator();
1371
1371
-
1372
1372
-
{
1373
1373
-
var rows = try conn.rows(
1374
1374
-
\\SELECT did, namespace, preferences_json
1375
1375
-
\\FROM app_preferences
1376
1376
-
, .{});
1377
1377
-
defer rows.deinit();
1378
1378
-
1379
1379
-
var preferences: std.ArrayList(AppPreference) = .empty;
1380
1380
-
while (rows.next()) |row| {
1381
1381
-
const did = try allocator.dupe(u8, row.text(0));
1382
1382
-
const namespace = try allocator.dupe(u8, row.text(1));
1383
1383
-
const preferences_json = try allocator.dupe(u8, row.text(2));
1384
1384
-
const parsed = std.json.parseFromSlice(std.json.Value, allocator, preferences_json, .{}) catch continue;
1385
1385
-
if (parsed.value != .array) continue;
1386
1386
-
for (parsed.value.array.items) |preference| {
1387
1387
-
if (preference != .object) continue;
1388
1388
-
const name = jsonValueString(preference, "$type") orelse continue;
1389
1389
-
if (!preferenceNameInNamespace(name, namespace)) continue;
1390
1390
-
const value_json = try stringifyValue(allocator, preference);
1391
1391
-
try preferences.append(allocator, .{
1392
1392
-
.name = name,
1393
1393
-
.value_json = value_json,
1394
1394
-
});
1395
1395
-
}
1396
1396
-
for (preferences.items) |preference| {
1397
1397
-
try conn.exec(
1398
1398
-
\\INSERT INTO app_preferences_next (did, name, value_json, updated_at)
1399
1399
-
\\VALUES (?, ?, ?, unixepoch())
1400
1400
-
, .{ did, preference.name, preference.value_json });
1401
1401
-
}
1402
1402
-
preferences.clearRetainingCapacity();
1403
1403
-
}
1404
1404
-
if (rows.err) |err| return err;
1405
1405
-
}
1406
1406
-
1407
1407
-
try conn.execNoArgs("DROP TABLE app_preferences");
1408
1408
-
try conn.execNoArgs("ALTER TABLE app_preferences_next RENAME TO app_preferences");
1409
1409
-
}
1410
1410
-
1411
1411
-
fn appPreferencesTableHasPreferencesJsonColumn() !bool {
1412
1412
-
var rows = try conn.rows("PRAGMA table_info(app_preferences)", .{});
1413
1413
-
defer rows.deinit();
1414
1414
-
while (rows.next()) |row| {
1415
1415
-
if (std.mem.eql(u8, row.text(1), "preferences_json")) return true;
1416
1416
-
}
1417
1417
-
if (rows.err) |err| return err;
1418
1418
-
return false;
1419
1419
-
}
1420
1420
-
1421
1421
-
fn preferenceNameInNamespace(name: []const u8, namespace: []const u8) bool {
1422
1422
-
return std.mem.eql(u8, name, namespace) or
1423
1423
-
(std.mem.startsWith(u8, name, namespace) and name.len > namespace.len and name[namespace.len] == '.');
1424
1424
-
}
1425
1425
-
1426
1426
-
fn jsonValueString(value: std.json.Value, key: []const u8) ?[]const u8 {
1427
1427
-
return switch (value) {
1428
1428
-
.object => |object| switch (object.get(key) orelse return null) {
1429
1429
-
.string => |string| string,
1430
1430
-
else => null,
1431
1431
-
},
1432
1432
-
else => null,
1433
1433
-
};
1434
1434
-
}
1435
1435
-
1436
1436
-
fn migrateBlobTable() !void {
1437
1437
-
if (!try blobTableHasDataColumn()) return;
1438
1438
-
try conn.execNoArgs(
1439
1439
-
\\CREATE TABLE IF NOT EXISTS blobs_next (
1440
1440
-
\\ cid TEXT NOT NULL,
1441
1441
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
1442
1442
-
\\ mime_type TEXT NOT NULL,
1443
1443
-
\\ size INTEGER NOT NULL,
1444
1444
-
\\ storage TEXT NOT NULL DEFAULT 'disk',
1445
1445
-
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1446
1446
-
\\ PRIMARY KEY (did, cid)
1447
1447
-
\\)
1448
1448
-
);
1449
1449
-
try conn.execNoArgs(
1450
1450
-
\\INSERT OR IGNORE INTO blobs_next (cid, did, mime_type, size, storage, created_at)
1451
1451
-
\\SELECT cid, did, mime_type, size, 'disk', created_at
1452
1452
-
\\FROM blobs
1453
1453
-
);
1454
1454
-
try conn.execNoArgs("DROP TABLE blobs");
1455
1455
-
try conn.execNoArgs("ALTER TABLE blobs_next RENAME TO blobs");
1456
1456
-
}
1457
1457
-
1458
1458
-
fn blobTableHasDataColumn() !bool {
1459
1459
-
var rows = try conn.rows("PRAGMA table_info(blobs)", .{});
1460
1460
-
defer rows.deinit();
1461
1461
-
while (rows.next()) |row| {
1462
1462
-
if (std.mem.eql(u8, row.text(1), "data")) return true;
1463
1463
-
}
1464
1464
-
if (rows.err) |err| return err;
1465
1465
-
return false;
1466
1466
-
}
1467
1467
-
1468
1468
-
pub fn getEmailInfo(allocator: std.mem.Allocator, did: []const u8) ?EmailInfo {
1469
1469
-
mutex.lockUncancelable(store_io);
1470
1470
-
defer mutex.unlock(store_io);
1471
1471
-
requireInitialized() catch return null;
1472
1472
-
const row = conn.row(
1473
1473
-
\\SELECT email, email_confirmed_at, auth_code, auth_code_expires_at, pending_email
1474
1474
-
\\FROM accounts
1475
1475
-
\\WHERE did = ?
1476
1476
-
, .{did}) catch return null;
1477
1477
-
if (row == null) return null;
1478
1478
-
defer row.?.deinit();
1479
1479
-
return .{
1480
1480
-
.email = allocator.dupe(u8, row.?.text(0)) catch return null,
1481
1481
-
.email_confirmed = row.?.nullableInt(1) != null,
1482
1482
-
.auth_code = if (row.?.nullableText(2)) |text| allocator.dupe(u8, text) catch null else null,
1483
1483
-
.auth_code_expires_at = row.?.nullableInt(3),
1484
1484
-
.pending_email = if (row.?.nullableText(4)) |text| allocator.dupe(u8, text) catch null else null,
1485
1485
-
};
1486
1486
-
}
1487
1487
-
1488
1488
-
pub fn setAuthCode(did: []const u8, code: []const u8, expires_at_ms: i64) !void {
1489
1489
-
mutex.lockUncancelable(store_io);
1490
1490
-
defer mutex.unlock(store_io);
1491
1491
-
try requireInitialized();
1492
1492
-
try conn.exec(
1493
1493
-
\\UPDATE accounts
1494
1494
-
\\SET auth_code = ?, auth_code_expires_at = ?
1495
1495
-
\\WHERE did = ?
1496
1496
-
, .{ code, expires_at_ms, did });
1497
1497
-
}
1498
1498
-
1499
1499
-
pub fn setPendingEmail(did: []const u8, pending_email: []const u8, code: []const u8, expires_at_ms: i64) !void {
1500
1500
-
mutex.lockUncancelable(store_io);
1501
1501
-
defer mutex.unlock(store_io);
1502
1502
-
try requireInitialized();
1503
1503
-
try conn.exec(
1504
1504
-
\\UPDATE accounts
1505
1505
-
\\SET pending_email = ?, auth_code = ?, auth_code_expires_at = ?
1506
1506
-
\\WHERE did = ?
1507
1507
-
, .{ pending_email, code, expires_at_ms, did });
1508
1508
-
}
1509
1509
-
1510
1510
-
pub fn validateAuthCode(did: []const u8, code: []const u8, now_ms: i64) CodeStatus {
1511
1511
-
mutex.lockUncancelable(store_io);
1512
1512
-
defer mutex.unlock(store_io);
1513
1513
-
requireInitialized() catch return .invalid;
1514
1514
-
const row = conn.row(
1515
1515
-
\\SELECT auth_code, auth_code_expires_at
1516
1516
-
\\FROM accounts
1517
1517
-
\\WHERE did = ?
1518
1518
-
, .{did}) catch return .invalid;
1519
1519
-
if (row == null) return .invalid;
1520
1520
-
defer row.?.deinit();
1521
1521
-
const stored = row.?.nullableText(0) orelse return .invalid;
1522
1522
-
const expires_at = row.?.nullableInt(1) orelse return .invalid;
1523
1523
-
if (now_ms >= expires_at) return .expired;
1524
1524
-
if (!std.ascii.eqlIgnoreCase(stored, code)) return .invalid;
1525
1525
-
return .valid;
1526
1526
-
}
1527
1527
-
1528
1528
-
pub fn clearAuthCode(did: []const u8) !void {
1529
1529
-
mutex.lockUncancelable(store_io);
1530
1530
-
defer mutex.unlock(store_io);
1531
1531
-
try requireInitialized();
1532
1532
-
try conn.exec(
1533
1533
-
\\UPDATE accounts
1534
1534
-
\\SET auth_code = NULL,
1535
1535
-
\\ auth_code_expires_at = NULL
1536
1536
-
\\WHERE did = ?
1537
1537
-
, .{did});
1538
1538
-
}
1539
1539
-
1540
1540
-
pub fn confirmEmail(did: []const u8) !void {
1541
1541
-
mutex.lockUncancelable(store_io);
1542
1542
-
defer mutex.unlock(store_io);
1543
1543
-
try requireInitialized();
1544
1544
-
try conn.exec(
1545
1545
-
\\UPDATE accounts
1546
1546
-
\\SET email_confirmed_at = unixepoch(),
1547
1547
-
\\ auth_code = NULL,
1548
1548
-
\\ auth_code_expires_at = NULL
1549
1549
-
\\WHERE did = ?
1550
1550
-
, .{did});
1551
1551
-
}
1552
1552
-
1553
1553
-
pub fn updateEmail(did: []const u8, email: []const u8) !void {
1554
1554
-
mutex.lockUncancelable(store_io);
1555
1555
-
defer mutex.unlock(store_io);
1556
1556
-
try requireInitialized();
1557
1557
-
try conn.exec(
1558
1558
-
\\UPDATE accounts
1559
1559
-
\\SET email = ?,
1560
1560
-
\\ email_confirmed_at = NULL,
1561
1561
-
\\ pending_email = NULL,
1562
1562
-
\\ auth_code = NULL,
1563
1563
-
\\ auth_code_expires_at = NULL
1564
1564
-
\\WHERE did = ?
1565
1565
-
, .{ email, did });
1566
1566
-
}
1567
1567
-
1568
1568
-
fn nextSeqLocked() !u64 {
1569
1569
-
const row = try conn.row(
1570
1570
-
\\SELECT COALESCE(MAX(seq), 0) + 1
1571
1571
-
\\FROM (
1572
1572
-
\\ SELECT seq FROM commits
1573
1573
-
\\ UNION ALL
1574
1574
-
\\ SELECT seq FROM seq_events
1575
1575
-
\\)
1576
1576
-
, .{});
1577
1577
-
if (row == null) return 1;
1578
1578
-
defer row.?.deinit();
1579
1579
-
return @intCast(row.?.int(0));
1580
1580
-
}
1581
1581
-
1582
1582
-
fn nextRkey(allocator: std.mem.Allocator) ![]const u8 {
1583
1583
-
mutex.lockUncancelable(store_io);
1584
1584
-
defer mutex.unlock(store_io);
1585
1585
-
try requireInitialized();
1586
1586
-
return nextRkeyLocked(allocator);
1587
1587
-
}
1588
1588
-
1589
1589
-
fn nextRkeyLocked(allocator: std.mem.Allocator) ![]const u8 {
1590
1590
-
return std.fmt.allocPrint(allocator, "3zds{d}", .{try nextSeqLocked()});
1591
1591
-
}
1592
1592
-
1593
1593
-
fn repoPath(allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8) ![]const u8 {
1594
1594
-
if (zat.Nsid.parse(collection) == null) return Error.InvalidCollection;
1595
1595
-
if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey;
1596
1596
-
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ collection, rkey });
1597
1597
-
}
1598
1598
-
1599
1599
-
fn stageRecordWrite(
1600
1600
-
allocator: std.mem.Allocator,
1601
1601
-
tree: *zat.mst.Mst,
1602
1602
-
account: auth.Account,
1603
1603
-
collection: []const u8,
1604
1604
-
rkey: []const u8,
1605
1605
-
value: std.json.Value,
1606
1606
-
rev: []const u8,
1607
1607
-
seq: u64,
1608
1608
-
blocks: *std.ArrayList(ImportedBlock),
1609
1609
-
blob_refs: *std.ArrayList(BlobRef),
1610
1610
-
) !Record {
1611
1611
-
const cbor_value = try jsonToDagCbor(allocator, value);
1612
1612
-
const record_bytes = try zat.cbor.encodeAlloc(allocator, cbor_value);
1613
1613
-
const record_cid = try zat.cbor.Cid.forDagCbor(allocator, record_bytes);
1614
1614
-
const record_cid_text = try cidText(allocator, record_cid.raw);
1615
1615
-
const path = try repoPath(allocator, collection, rkey);
1616
1616
-
_ = try tree.putReturn(path, record_cid);
1617
1617
-
1618
1618
-
try blocks.append(allocator, .{
1619
1619
-
.cid = record_cid_text,
1620
1620
-
.data = record_bytes,
1621
1621
-
});
1622
1622
-
1623
1623
-
const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, collection, rkey });
1624
1624
-
var blobs: std.ArrayList([]const u8) = .empty;
1625
1625
-
try collectBlobCids(allocator, cbor_value, &blobs);
1626
1626
-
for (blobs.items) |blob_cid| {
1627
1627
-
try blob_refs.append(allocator, .{ .cid = blob_cid, .uri = uri });
1628
1628
-
}
1629
1629
-
1630
1630
-
return .{
1631
1631
-
.did = try allocator.dupe(u8, account.did),
1632
1632
-
.collection = try allocator.dupe(u8, collection),
1633
1633
-
.rkey = try allocator.dupe(u8, rkey),
1634
1634
-
.cid = record_cid_text,
1635
1635
-
.value_json = try stringifyValue(allocator, value),
1636
1636
-
.rev = try allocator.dupe(u8, rev),
1637
1637
-
.seq = seq,
1638
1638
-
};
1639
1639
-
}
1640
1640
-
1641
1641
-
const CurrentCommit = struct {
1642
1642
-
commit_cid_text: []const u8,
1643
1643
-
commit_cid_raw: []const u8,
1644
1644
-
data_cid_raw: []const u8,
1645
1645
-
rev: []const u8,
1646
1646
-
commit_data: []const u8,
1647
1647
-
};
1648
1648
-
1649
1649
-
fn latestCommitRawLocked(allocator: std.mem.Allocator, did: []const u8) !?CurrentCommit {
1650
1650
-
const row = try conn.row(
1651
1651
-
\\SELECT c.cid, c.rev, rb.data
1652
1652
-
\\FROM commits c
1653
1653
-
\\JOIN repo_blocks rb ON rb.did = c.did AND rb.cid = c.cid
1654
1654
-
\\WHERE c.did = ?
1655
1655
-
\\ORDER BY c.seq DESC
1656
1656
-
\\LIMIT 1
1657
1657
-
, .{did});
1658
1658
-
if (row == null) return null;
1659
1659
-
defer row.?.deinit();
1660
1660
-
1661
1661
-
const commit_cid_text = try allocator.dupe(u8, row.?.text(0));
1662
1662
-
const commit_cid_raw = try cidRawFromText(allocator, commit_cid_text);
1663
1663
-
const rev = try allocator.dupe(u8, row.?.text(1));
1664
1664
-
const commit_data = row.?.nullableBlob(2) orelse return null;
1665
1665
-
const commit_value = try zat.cbor.decodeAll(allocator, commit_data);
1666
1666
-
const data = commit_value.getCid("data") orelse return error.InvalidRepoPath;
1667
1667
-
return .{
1668
1668
-
.commit_cid_text = commit_cid_text,
1669
1669
-
.commit_cid_raw = commit_cid_raw,
1670
1670
-
.data_cid_raw = try allocator.dupe(u8, data.raw),
1671
1671
-
.rev = rev,
1672
1672
-
.commit_data = try allocator.dupe(u8, commit_data),
1673
1673
-
};
1674
1674
-
}
1675
1675
-
1676
1676
-
fn readRepoCarLocked(allocator: std.mem.Allocator, did: []const u8) !zat.car.Car {
1677
1677
-
var rows = try conn.rows(
1678
1678
-
\\SELECT cid, data
1679
1679
-
\\FROM repo_blocks
1680
1680
-
\\WHERE did = ?
1681
1681
-
\\ORDER BY cid ASC
1682
1682
-
, .{did});
1683
1683
-
defer rows.deinit();
1684
1684
-
1685
1685
-
var blocks: std.ArrayList(zat.car.Block) = .empty;
1686
1686
-
while (rows.next()) |row| {
1687
1687
-
const data = row.nullableBlob(1) orelse "";
1688
1688
-
try blocks.append(allocator, .{
1689
1689
-
.cid_raw = try cidRawFromText(allocator, row.text(0)),
1690
1690
-
.data = try allocator.dupe(u8, data),
1691
1691
-
});
1692
1692
-
}
1693
1693
-
if (rows.err) |err| return err;
1694
1694
-
return .{ .roots = &.{}, .blocks = try blocks.toOwnedSlice(allocator) };
1695
1695
-
}
1696
1696
-
1697
1697
-
fn writeMstBlocks(allocator: std.mem.Allocator, tree: *zat.mst.Mst, out: *std.ArrayList(zat.car.Block)) !void {
1698
1698
-
if (tree.root) |root| {
1699
1699
-
try writeMstNodeBlocks(allocator, root, out);
1700
1700
-
return;
1701
1701
-
}
1702
1702
-
1703
1703
-
const data = try encodeEmptyMstNode(allocator);
1704
1704
-
const cid = try zat.cbor.Cid.forDagCbor(allocator, data);
1705
1705
-
try out.append(allocator, .{
1706
1706
-
.cid_raw = try allocator.dupe(u8, cid.raw),
1707
1707
-
.data = data,
1708
1708
-
});
1709
1709
-
}
1710
1710
-
1711
1711
-
fn writeMstNodeBlocks(allocator: std.mem.Allocator, node: *zat.mst.Node, out: *std.ArrayList(zat.car.Block)) !void {
1712
1712
-
switch (node.left) {
1713
1713
-
.node => |left| try writeMstNodeBlocks(allocator, left, out),
1714
1714
-
.stub, .none => {},
1715
1715
-
}
1716
1716
-
for (node.entries.items) |entry| {
1717
1717
-
switch (entry.right) {
1718
1718
-
.node => |right| try writeMstNodeBlocks(allocator, right, out),
1719
1719
-
.stub, .none => {},
1720
1720
-
}
1721
1721
-
}
1722
1722
-
1723
1723
-
const data = try encodeMstNode(allocator, node);
1724
1724
-
const cid = try zat.cbor.Cid.forDagCbor(allocator, data);
1725
1725
-
try out.append(allocator, .{
1726
1726
-
.cid_raw = try allocator.dupe(u8, cid.raw),
1727
1727
-
.data = data,
1728
1728
-
});
1729
1729
-
}
1730
1730
-
1731
1731
-
fn mstChildValue(allocator: std.mem.Allocator, child: zat.mst.ChildRef) anyerror!zat.cbor.Value {
1732
1732
-
return switch (child) {
1733
1733
-
.node => |node| blk: {
1734
1734
-
const data = try encodeMstNode(allocator, node);
1735
1735
-
const cid = try zat.cbor.Cid.forDagCbor(allocator, data);
1736
1736
-
break :blk .{ .cid = cid };
1737
1737
-
},
1738
1738
-
.stub => |cid| .{ .cid = cid },
1739
1739
-
.none => .null,
1740
1740
-
};
1741
1741
-
}
1742
1742
-
1743
1743
-
fn encodeEmptyMstNode(allocator: std.mem.Allocator) ![]const u8 {
1744
1744
-
return zat.cbor.encodeAlloc(allocator, .{ .map = &.{
1745
1745
-
.{ .key = "e", .value = .{ .array = &.{} } },
1746
1746
-
.{ .key = "l", .value = .null },
1747
1747
-
} });
1748
1748
-
}
1749
1749
-
1750
1750
-
fn encodeMstNode(allocator: std.mem.Allocator, node: *zat.mst.Node) anyerror![]const u8 {
1751
1751
-
var entry_values: std.ArrayList(zat.cbor.Value) = .empty;
1752
1752
-
var prev_key: []const u8 = "";
1753
1753
-
for (node.entries.items) |entry| {
1754
1754
-
const prefix_len = zat.mst.commonPrefixLen(prev_key, entry.key);
1755
1755
-
const map_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 4);
1756
1756
-
map_entries[0] = .{ .key = "k", .value = .{ .bytes = entry.key[prefix_len..] } };
1757
1757
-
map_entries[1] = .{ .key = "p", .value = .{ .unsigned = prefix_len } };
1758
1758
-
map_entries[2] = .{ .key = "t", .value = try mstChildValue(allocator, entry.right) };
1759
1759
-
map_entries[3] = .{ .key = "v", .value = .{ .cid = entry.value } };
1760
1760
-
try entry_values.append(allocator, .{ .map = map_entries });
1761
1761
-
prev_key = entry.key;
1762
1762
-
}
1763
1763
-
1764
1764
-
return zat.cbor.encodeAlloc(allocator, .{ .map = &.{
1765
1765
-
.{ .key = "e", .value = .{ .array = entry_values.items } },
1766
1766
-
.{ .key = "l", .value = try mstChildValue(allocator, node.left) },
1767
1767
-
} });
1768
1768
-
}
1769
1769
-
1770
1770
-
const SignedCommit = struct {
1771
1771
-
cid: []const u8,
1772
1772
-
data: []const u8,
1773
1773
-
};
1774
1774
-
1775
1775
-
fn signedCommit(
1776
1776
-
allocator: std.mem.Allocator,
1777
1777
-
did: []const u8,
1778
1778
-
rev: []const u8,
1779
1779
-
data_cid: zat.cbor.Cid,
1780
1780
-
prev_cid_raw: ?[]const u8,
1781
1781
-
) !SignedCommit {
1782
1782
-
var unsigned_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 5);
1783
1783
-
unsigned_entries[0] = .{ .key = "did", .value = .{ .text = did } };
1784
1784
-
unsigned_entries[1] = .{ .key = "version", .value = .{ .unsigned = 3 } };
1785
1785
-
unsigned_entries[2] = .{ .key = "data", .value = .{ .cid = data_cid } };
1786
1786
-
unsigned_entries[3] = .{ .key = "rev", .value = .{ .text = rev } };
1787
1787
-
unsigned_entries[4] = .{ .key = "prev", .value = if (prev_cid_raw) |raw| .{ .cid = .{ .raw = raw } } else .null };
1788
1788
-
const unsigned_value: zat.cbor.Value = .{ .map = unsigned_entries };
1789
1789
-
const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned_value);
1790
1790
-
var keypair = try signingKeypairLocked(did);
1791
1791
-
const sig = try keypair.sign(unsigned_bytes);
1792
1792
-
1793
1793
-
var signed_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 6);
1794
1794
-
@memcpy(signed_entries[0..5], unsigned_entries);
1795
1795
-
signed_entries[5] = .{ .key = "sig", .value = .{ .bytes = &sig.bytes } };
1796
1796
-
const signed_bytes = try zat.cbor.encodeAlloc(allocator, .{ .map = signed_entries });
1797
1797
-
const commit_cid = try zat.cbor.Cid.forDagCbor(allocator, signed_bytes);
1798
1798
-
return .{
1799
1799
-
.cid = try cidText(allocator, commit_cid.raw),
1800
1800
-
.data = signed_bytes,
1801
1801
-
};
1802
1802
-
}
1803
1803
-
1804
1804
-
fn signingKeypairLocked(did: []const u8) !zat.Keypair {
1805
1805
-
const row = try conn.row(
1806
1806
-
\\SELECT signing_key_type, signing_key
1807
1807
-
\\FROM accounts
1808
1808
-
\\WHERE did = ?
1809
1809
-
, .{did});
1810
1810
-
if (row == null) return Error.MissingRecord;
1811
1811
-
defer row.?.deinit();
1812
1812
-
1813
1813
-
const existing = row.?.nullableBlob(1);
1814
1814
-
const key_type_text = row.?.nullableText(0) orelse "secp256k1";
1815
1815
-
const key_bytes = if (existing) |bytes| blk: {
1816
1816
-
if (bytes.len != 32) return Error.InvalidDagCbor;
1817
1817
-
break :blk bytes[0..32].*;
1818
1818
-
} else blk: {
1819
1819
-
const generated = try generateSigningKey();
1820
1820
-
try conn.exec(
1821
1821
-
\\UPDATE accounts
1822
1822
-
\\SET signing_key_type = 'secp256k1', signing_key = ?
1823
1823
-
\\WHERE did = ?
1824
1824
-
, .{ zqlite.blob(&generated), did });
1825
1825
-
break :blk generated;
1826
1826
-
};
1827
1827
-
const key_type: zat.multicodec.KeyType = if (std.mem.eql(u8, key_type_text, "p256"))
1828
1828
-
.p256
1829
1829
-
else
1830
1830
-
.secp256k1;
1831
1831
-
return zat.Keypair.fromSecretKey(key_type, key_bytes);
1832
1832
-
}
1833
1833
-
1834
1834
-
fn generateSigningKey() ![32]u8 {
1835
1835
-
var key: [32]u8 = undefined;
1836
1836
-
while (true) {
1837
1837
-
store_io.random(&key);
1838
1838
-
if (zat.Keypair.fromSecretKey(.secp256k1, key)) |_| return key else |_| {}
1839
1839
-
}
1840
1840
-
}
1841
1841
-
1842
1842
-
fn jsonToDagCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value {
1843
1843
-
return switch (value) {
1844
1844
-
.null => .null,
1845
1845
-
.bool => |boolean| .{ .boolean = boolean },
1846
1846
-
.integer => |integer| if (integer >= 0) .{ .unsigned = @intCast(integer) } else .{ .negative = integer },
1847
1847
-
.float, .number_string => Error.InvalidDagCbor,
1848
1848
-
.string => |string| .{ .text = string },
1849
1849
-
.array => |array| blk: {
1850
1850
-
const items = try allocator.alloc(zat.cbor.Value, array.items.len);
1851
1851
-
for (array.items, 0..) |item, idx| items[idx] = try jsonToDagCbor(allocator, item);
1852
1852
-
break :blk .{ .array = items };
1853
1853
-
},
1854
1854
-
.object => |object| blk: {
1855
1855
-
if (object.count() == 1) {
1856
1856
-
if (object.get("$link")) |link_value| switch (link_value) {
1857
1857
-
.string => |link| break :blk .{ .cid = .{ .raw = try cidRawFromText(allocator, link) } },
1858
1858
-
else => {},
1859
1859
-
};
1860
1860
-
if (object.get("$bytes")) |bytes_value| switch (bytes_value) {
1861
1861
-
.string => |encoded| {
1862
1862
-
const bytes = try allocator.alloc(u8, std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return Error.InvalidDagCbor);
1863
1863
-
try std.base64.standard.Decoder.decode(bytes, encoded);
1864
1864
-
break :blk .{ .bytes = bytes };
1865
1865
-
},
1866
1866
-
else => {},
1867
1867
-
};
1868
1868
-
}
1869
1869
-
const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count());
1870
1870
-
var it = object.iterator();
1871
1871
-
var idx: usize = 0;
1872
1872
-
while (it.next()) |entry| : (idx += 1) {
1873
1873
-
entries[idx] = .{
1874
1874
-
.key = entry.key_ptr.*,
1875
1875
-
.value = try jsonToDagCbor(allocator, entry.value_ptr.*),
1876
1876
-
};
1877
1877
-
}
1878
1878
-
break :blk .{ .map = entries };
1879
1879
-
},
1880
1880
-
};
1881
1881
-
}
1882
1882
-
1883
1883
-
fn collectBlobCids(allocator: std.mem.Allocator, value: zat.cbor.Value, out: *std.ArrayList([]const u8)) !void {
1884
1884
-
switch (value) {
1885
1885
-
.map => |entries| {
1886
1886
-
if (cborMapText(entries, "$type")) |type_name| {
1887
1887
-
if (std.mem.eql(u8, type_name, "blob")) {
1888
1888
-
if (cborMapValue(entries, "ref")) |ref| switch (ref) {
1889
1889
-
.cid => |cid| try out.append(allocator, try cidText(allocator, cid.raw)),
1890
1890
-
.map => |ref_entries| if (cborMapText(ref_entries, "$link")) |link| try out.append(allocator, try allocator.dupe(u8, link)),
1891
1891
-
else => {},
1892
1892
-
};
1893
1893
-
}
1894
1894
-
}
1895
1895
-
for (entries) |entry| try collectBlobCids(allocator, entry.value, out);
1896
1896
-
},
1897
1897
-
.array => |items| for (items) |item| try collectBlobCids(allocator, item, out),
1898
1898
-
else => {},
1899
1899
-
}
1900
1900
-
}
1901
1901
-
1902
1902
-
fn cborMapValue(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?zat.cbor.Value {
1903
1903
-
for (entries) |entry| {
1904
1904
-
if (std.mem.eql(u8, entry.key, key)) return entry.value;
1905
1905
-
}
1906
1906
-
return null;
1907
1907
-
}
1908
1908
-
1909
1909
-
fn cborMapText(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?[]const u8 {
1910
1910
-
return switch (cborMapValue(entries, key) orelse return null) {
1911
1911
-
.text => |text| text,
1912
1912
-
else => null,
1913
1913
-
};
1914
1914
-
}
1915
1915
-
1916
1916
-
fn cidRawFromText(allocator: std.mem.Allocator, cid: []const u8) ![]const u8 {
1917
1917
-
if (cid.len == 0 or cid[0] != 'b') return Error.InvalidDagCbor;
1918
1918
-
return zat.multibase.base32lower.decode(allocator, cid[1..]);
1919
1919
-
}
1920
1920
-
1921
1921
-
fn cidText(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 {
1922
1922
-
return zat.multibase.base32lower.encode(allocator, raw);
1923
1923
-
}
1924
1924
-
1925
1925
-
fn recordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !Record {
1926
1926
-
return .{
1927
1927
-
.did = try allocator.dupe(u8, row.text(0)),
1928
1928
-
.collection = try allocator.dupe(u8, row.text(1)),
1929
1929
-
.rkey = try allocator.dupe(u8, row.text(2)),
1930
1930
-
.cid = try allocator.dupe(u8, row.text(3)),
1931
1931
-
.value_json = try allocator.dupe(u8, row.text(4)),
1932
1932
-
.rev = try allocator.dupe(u8, row.text(5)),
1933
1933
-
.seq = @intCast(row.int(6)),
1934
1934
-
};
1935
1935
-
}
1936
1936
-
1937
1937
-
fn oauthRequestFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !OAuthRequest {
1938
1938
-
return .{
1939
1939
-
.request_id = try allocator.dupe(u8, row.text(0)),
1940
1940
-
.client_id = try allocator.dupe(u8, row.text(1)),
1941
1941
-
.redirect_uri = try allocator.dupe(u8, row.text(2)),
1942
1942
-
.scope = try allocator.dupe(u8, row.text(3)),
1943
1943
-
.state = try allocator.dupe(u8, row.text(4)),
1944
1944
-
.code_challenge = try allocator.dupe(u8, row.text(5)),
1945
1945
-
.code_challenge_method = try allocator.dupe(u8, row.text(6)),
1946
1946
-
.login_hint = if (row.nullableText(7)) |text| try allocator.dupe(u8, text) else null,
1947
1947
-
.dpop_jkt = if (row.nullableText(8)) |text| try allocator.dupe(u8, text) else null,
1948
1948
-
.expires_at = row.int(9),
1949
1949
-
.sub = if (row.nullableText(10)) |text| try allocator.dupe(u8, text) else null,
1950
1950
-
.code = if (row.nullableText(11)) |text| try allocator.dupe(u8, text) else null,
1951
1951
-
};
1952
1952
-
}
1953
1953
-
1954
1954
-
fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
1955
1955
-
var out: std.Io.Writer.Allocating = .init(allocator);
1956
1956
-
defer out.deinit();
1957
1957
-
try out.writer.print("{f}", .{std.json.fmt(value, .{})});
1958
1958
-
return out.toOwnedSlice();
1959
1959
-
}
1960
1960
-
1961
1961
-
const Root = struct {
1962
1962
-
cid: []const u8,
1963
1963
-
rev: []const u8,
1964
1964
-
};
1965
1965
-
1966
1966
-
fn latestRootLocked(allocator: std.mem.Allocator, did: []const u8) !Root {
1967
1967
-
const row = try conn.row(
1968
1968
-
\\SELECT cid, rev
1969
1969
-
\\FROM commits
1970
1970
-
\\WHERE did = ?
1971
1971
-
\\ORDER BY seq DESC
1972
1972
-
\\LIMIT 1
1973
1973
-
, .{did});
1974
1974
-
if (row == null) return Error.RepoNotFound;
1975
1975
-
defer row.?.deinit();
1976
1976
-
return .{
1977
1977
-
.cid = try allocator.dupe(u8, row.?.text(0)),
1978
1978
-
.rev = try allocator.dupe(u8, row.?.text(1)),
1979
1979
-
};
1980
1980
-
}
1981
1981
-
1982
1982
-
fn backfillSeqEventsLocked(allocator: std.mem.Allocator) !void {
1983
1983
-
var rows = try conn.rows(
1984
1984
-
\\SELECT c.seq, c.did, c.cid, c.rev, c.prev
1985
1985
-
\\FROM commits c
1986
1986
-
\\LEFT JOIN seq_events e ON e.seq = c.seq
1987
1987
-
\\WHERE e.seq IS NULL
1988
1988
-
\\ORDER BY c.seq ASC
1989
1989
-
, .{});
1990
1990
-
defer rows.deinit();
1991
1991
-
1992
1992
-
var missing: std.ArrayList(struct {
1993
1993
-
seq: u64,
1994
1994
-
did: []const u8,
1995
1995
-
cid: []const u8,
1996
1996
-
rev: []const u8,
1997
1997
-
prev: ?[]const u8,
1998
1998
-
}) = .empty;
1999
1999
-
while (rows.next()) |row| {
2000
2000
-
try missing.append(allocator, .{
2001
2001
-
.seq = @intCast(row.int(0)),
2002
2002
-
.did = try allocator.dupe(u8, row.text(1)),
2003
2003
-
.cid = try allocator.dupe(u8, row.text(2)),
2004
2004
-
.rev = try allocator.dupe(u8, row.text(3)),
2005
2005
-
.prev = if (row.nullableText(4)) |prev| try allocator.dupe(u8, prev) else null,
2006
2006
-
});
2007
2007
-
}
2008
2008
-
if (rows.err) |err| return err;
2009
2009
-
2010
2010
-
for (missing.items) |commit| {
2011
2011
-
const repo_car = writeRepoCarFromLocked(allocator, commit.did, commit.cid) catch continue;
2012
2012
-
const frame = commitEventFrameFromCar(allocator, commit.seq, commit.did, commit.cid, commit.rev, commit.prev, repo_car, &.{}) catch continue;
2013
2013
-
try conn.exec(
2014
2014
-
\\INSERT INTO seq_events (seq, did, commit_cid, evt)
2015
2015
-
\\VALUES (?, ?, ?, ?)
2016
2016
-
\\ON CONFLICT(seq) DO NOTHING
2017
2017
-
, .{ @as(i64, @intCast(commit.seq)), commit.did, commit.cid, zqlite.blob(frame) });
2018
2018
-
}
2019
2019
-
}
2020
2020
-
2021
2021
-
fn writeRepoCarFromLocked(allocator: std.mem.Allocator, did: []const u8, root_cid: []const u8) ![]const u8 {
2022
2022
-
const root_raw = try zat.multibase.base32lower.decode(allocator, root_cid[1..]);
2023
2023
-
const car_root = zat.cbor.Cid{ .raw = root_raw };
2024
2024
-
2025
2025
-
var rows = try conn.rows(
2026
2026
-
\\SELECT cid, data
2027
2027
-
\\FROM repo_blocks
2028
2028
-
\\WHERE did = ?
2029
2029
-
\\ORDER BY cid ASC
2030
2030
-
, .{did});
2031
2031
-
defer rows.deinit();
2032
2032
-
2033
2033
-
var blocks: std.ArrayList(zat.car.Block) = .empty;
2034
2034
-
while (rows.next()) |row| {
2035
2035
-
const cid_text = row.text(0);
2036
2036
-
const data = row.nullableBlob(1) orelse "";
2037
2037
-
const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]);
2038
2038
-
try blocks.append(allocator, .{
2039
2039
-
.cid_raw = cid_raw,
2040
2040
-
.data = try allocator.dupe(u8, data),
2041
2041
-
});
2042
2042
-
}
2043
2043
-
if (rows.err) |err| return err;
2044
2044
-
return zat.car.writeAlloc(allocator, .{
2045
2045
-
.roots = &.{car_root},
2046
2046
-
.blocks = blocks.items,
2047
2047
-
});
2048
2048
-
}
2049
2049
-
2050
2050
-
fn commitEventFrame(
2051
2051
-
allocator: std.mem.Allocator,
2052
2052
-
seq: u64,
2053
2053
-
did: []const u8,
2054
2054
-
commit_cid: []const u8,
2055
2055
-
rev: []const u8,
2056
2056
-
prev: ?[]const u8,
2057
2057
-
commit_data: []const u8,
2058
2058
-
record_blocks: []const ImportedBlock,
2059
2059
-
mst_blocks: []const zat.car.Block,
2060
2060
-
ops: []const WriteOp,
2061
2061
-
records: []const Record,
2062
2062
-
) ![]const u8 {
2063
2063
-
const root_raw = try cidRawFromText(allocator, commit_cid);
2064
2064
-
var blocks: std.ArrayList(zat.car.Block) = .empty;
2065
2065
-
try blocks.append(allocator, .{ .cid_raw = root_raw, .data = commit_data });
2066
2066
-
for (record_blocks) |block| {
2067
2067
-
try blocks.append(allocator, .{
2068
2068
-
.cid_raw = try cidRawFromText(allocator, block.cid),
2069
2069
-
.data = block.data,
2070
2070
-
});
2071
2071
-
}
2072
2072
-
for (mst_blocks) |block| try blocks.append(allocator, block);
2073
2073
-
const car_bytes = try zat.car.writeAlloc(allocator, .{
2074
2074
-
.roots = &.{.{ .raw = root_raw }},
2075
2075
-
.blocks = blocks.items,
2076
2076
-
});
2077
2077
-
2078
2078
-
var op_values = try allocator.alloc(zat.cbor.Value, ops.len);
2079
2079
-
var record_idx: usize = 0;
2080
2080
-
for (ops, 0..) |op, idx| switch (op) {
2081
2081
-
.create => |create_op| {
2082
2082
-
const record = records[record_idx];
2083
2083
-
record_idx += 1;
2084
2084
-
op_values[idx] = try firehoseOp(allocator, "create", create_op.collection, record.rkey, record.cid);
2085
2085
-
},
2086
2086
-
.update => |update_op| {
2087
2087
-
const record = records[record_idx];
2088
2088
-
record_idx += 1;
2089
2089
-
op_values[idx] = try firehoseOp(allocator, "update", update_op.collection, update_op.rkey, record.cid);
2090
2090
-
},
2091
2091
-
.delete => |delete_op| {
2092
2092
-
op_values[idx] = try firehoseOp(allocator, "delete", delete_op.collection, delete_op.rkey, null);
2093
2093
-
},
2094
2094
-
};
2095
2095
-
return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, prev, car_bytes, op_values);
2096
2096
-
}
2097
2097
-
2098
2098
-
fn importedCommitEventFrame(
2099
2099
-
allocator: std.mem.Allocator,
2100
2100
-
seq: u64,
2101
2101
-
did: []const u8,
2102
2102
-
commit_cid: []const u8,
2103
2103
-
rev: []const u8,
2104
2104
-
blocks: []const ImportedBlock,
2105
2105
-
) ![]const u8 {
2106
2106
-
var car_blocks: std.ArrayList(zat.car.Block) = .empty;
2107
2107
-
for (blocks) |block| {
2108
2108
-
try car_blocks.append(allocator, .{
2109
2109
-
.cid_raw = try cidRawFromText(allocator, block.cid),
2110
2110
-
.data = block.data,
2111
2111
-
});
2112
2112
-
}
2113
2113
-
const root_raw = try cidRawFromText(allocator, commit_cid);
2114
2114
-
const car_bytes = try zat.car.writeAlloc(allocator, .{
2115
2115
-
.roots = &.{.{ .raw = root_raw }},
2116
2116
-
.blocks = car_blocks.items,
2117
2117
-
});
2118
2118
-
return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, null, car_bytes, &.{});
2119
2119
-
}
2120
2120
-
2121
2121
-
fn accountEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, active: bool) ![]const u8 {
2122
2122
-
const header = try eventHeader(allocator, "#account");
2123
2123
-
const body = if (active)
2124
2124
-
try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2125
2125
-
.{ .key = "seq", .value = .{ .unsigned = seq } },
2126
2126
-
.{ .key = "did", .value = .{ .text = did } },
2127
2127
-
.{ .key = "active", .value = .{ .boolean = true } },
2128
2128
-
.{ .key = "time", .value = .{ .text = try nowIso(allocator) } },
2129
2129
-
} })
2130
2130
-
else
2131
2131
-
try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2132
2132
-
.{ .key = "seq", .value = .{ .unsigned = seq } },
2133
2133
-
.{ .key = "did", .value = .{ .text = did } },
2134
2134
-
.{ .key = "active", .value = .{ .boolean = false } },
2135
2135
-
.{ .key = "status", .value = .{ .text = "deactivated" } },
2136
2136
-
.{ .key = "time", .value = .{ .text = try nowIso(allocator) } },
2137
2137
-
} });
2138
2138
-
return joinFrame(allocator, header, body);
2139
2139
-
}
2140
2140
-
2141
2141
-
fn identityEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, handle: []const u8) ![]const u8 {
2142
2142
-
const header = try eventHeader(allocator, "#identity");
2143
2143
-
const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2144
2144
-
.{ .key = "seq", .value = .{ .unsigned = seq } },
2145
2145
-
.{ .key = "did", .value = .{ .text = did } },
2146
2146
-
.{ .key = "handle", .value = .{ .text = handle } },
2147
2147
-
.{ .key = "time", .value = .{ .text = try nowIso(allocator) } },
2148
2148
-
} });
2149
2149
-
return joinFrame(allocator, header, body);
2150
2150
-
}
2151
2151
-
2152
2152
-
fn syncEventFrame(
2153
2153
-
allocator: std.mem.Allocator,
2154
2154
-
seq: u64,
2155
2155
-
did: []const u8,
2156
2156
-
commit_cid: []const u8,
2157
2157
-
data_cid_raw: []const u8,
2158
2158
-
rev: []const u8,
2159
2159
-
commit_data: []const u8,
2160
2160
-
) ![]const u8 {
2161
2161
-
const root_raw = try cidRawFromText(allocator, commit_cid);
2162
2162
-
const car_bytes = try zat.car.writeAlloc(allocator, .{
2163
2163
-
.roots = &.{.{ .raw = data_cid_raw }},
2164
2164
-
.blocks = &.{.{ .cid_raw = root_raw, .data = commit_data }},
2165
2165
-
});
2166
2166
-
const header = try eventHeader(allocator, "#sync");
2167
2167
-
const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2168
2168
-
.{ .key = "seq", .value = .{ .unsigned = seq } },
2169
2169
-
.{ .key = "did", .value = .{ .text = did } },
2170
2170
-
.{ .key = "blocks", .value = .{ .bytes = car_bytes } },
2171
2171
-
.{ .key = "rev", .value = .{ .text = rev } },
2172
2172
-
.{ .key = "time", .value = .{ .text = try nowIso(allocator) } },
2173
2173
-
} });
2174
2174
-
return joinFrame(allocator, header, body);
2175
2175
-
}
2176
2176
-
2177
2177
-
fn commitEventFrameFromCar(
2178
2178
-
allocator: std.mem.Allocator,
2179
2179
-
seq: u64,
2180
2180
-
did: []const u8,
2181
2181
-
commit_cid: []const u8,
2182
2182
-
rev: []const u8,
2183
2183
-
prev: ?[]const u8,
2184
2184
-
car_bytes: []const u8,
2185
2185
-
ops: []const zat.cbor.Value,
2186
2186
-
) ![]const u8 {
2187
2187
-
const header = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2188
2188
-
.{ .key = "op", .value = .{ .unsigned = 1 } },
2189
2189
-
.{ .key = "t", .value = .{ .text = "#commit" } },
2190
2190
-
} });
2191
2191
-
const commit_raw = try cidRawFromText(allocator, commit_cid);
2192
2192
-
const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2193
2193
-
.{ .key = "seq", .value = .{ .unsigned = seq } },
2194
2194
-
.{ .key = "rebase", .value = .{ .boolean = false } },
2195
2195
-
.{ .key = "tooBig", .value = .{ .boolean = false } },
2196
2196
-
.{ .key = "repo", .value = .{ .text = did } },
2197
2197
-
.{ .key = "commit", .value = .{ .cid = .{ .raw = commit_raw } } },
2198
2198
-
.{ .key = "rev", .value = .{ .text = rev } },
2199
2199
-
.{ .key = "since", .value = if (prev) |p| .{ .text = p } else .null },
2200
2200
-
.{ .key = "blocks", .value = .{ .bytes = car_bytes } },
2201
2201
-
.{ .key = "ops", .value = .{ .array = ops } },
2202
2202
-
.{ .key = "blobs", .value = .{ .array = &.{} } },
2203
2203
-
.{ .key = "time", .value = .{ .text = try nowIso(allocator) } },
2204
2204
-
} });
2205
2205
-
return joinFrame(allocator, header, body);
2206
2206
-
}
2207
2207
-
2208
2208
-
fn eventHeader(allocator: std.mem.Allocator, tag: []const u8) ![]const u8 {
2209
2209
-
return zat.cbor.encodeAlloc(allocator, .{ .map = &.{
2210
2210
-
.{ .key = "op", .value = .{ .unsigned = 1 } },
2211
2211
-
.{ .key = "t", .value = .{ .text = tag } },
2212
2212
-
} });
2213
2213
-
}
2214
2214
-
2215
2215
-
fn joinFrame(allocator: std.mem.Allocator, header: []const u8, body: []const u8) ![]const u8 {
2216
2216
-
var frame = try allocator.alloc(u8, header.len + body.len);
2217
2217
-
@memcpy(frame[0..header.len], header);
2218
2218
-
@memcpy(frame[header.len..], body);
2219
2219
-
return frame;
2220
2220
-
}
2221
2221
-
2222
2222
-
fn firehoseOp(
2223
2223
-
allocator: std.mem.Allocator,
2224
2224
-
action: []const u8,
2225
2225
-
collection: []const u8,
2226
2226
-
rkey: []const u8,
2227
2227
-
cid: ?[]const u8,
2228
2228
-
) !zat.cbor.Value {
2229
2229
-
const path = try repoPath(allocator, collection, rkey);
2230
2230
-
const entries = try allocator.alloc(zat.cbor.Value.MapEntry, 3);
2231
2231
-
entries[0] = .{ .key = "action", .value = .{ .text = action } };
2232
2232
-
entries[1] = .{ .key = "path", .value = .{ .text = path } };
2233
2233
-
entries[2] = .{ .key = "cid", .value = if (cid) |c| .{ .cid = .{ .raw = try cidRawFromText(allocator, c) } } else .null };
2234
2234
-
return .{ .map = entries };
2235
2235
-
}
2236
2236
-
2237
2237
-
fn nowIso(allocator: std.mem.Allocator) ![]const u8 {
2238
2238
-
var ts: std.posix.timespec = undefined;
2239
2239
-
const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) {
2240
2240
-
.SUCCESS => ts,
2241
2241
-
else => std.posix.timespec{ .sec = 0, .nsec = 0 },
2242
2242
-
};
2243
2243
-
const seconds_i64 = timestamp.sec;
2244
2244
-
const seconds: u64 = if (seconds_i64 < 0) 0 else @intCast(seconds_i64);
2245
2245
-
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = seconds };
2246
2246
-
const year_day = epoch_seconds.getEpochDay().calculateYearDay();
2247
2247
-
const month_day = year_day.calculateMonthDay();
2248
2248
-
const day_seconds = epoch_seconds.getDaySeconds();
2249
2249
-
return std.fmt.allocPrint(
2250
2250
-
allocator,
2251
2251
-
"{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z",
2252
2252
-
.{
2253
2253
-
year_day.year,
2254
2254
-
@intFromEnum(month_day.month),
2255
2255
-
month_day.day_index + 1,
2256
2256
-
day_seconds.getHoursIntoDay(),
2257
2257
-
day_seconds.getMinutesIntoHour(),
2258
2258
-
day_seconds.getSecondsIntoMinute(),
2259
2259
-
},
2260
2260
-
);
2261
2261
-
}
2262
2262
-
2263
2263
-
fn scalarCountLocked(sql: [:0]const u8, did: []const u8) !u64 {
2264
2264
-
const row = try conn.row(sql, .{did});
2265
2265
-
if (row == null) return 0;
2266
2266
-
defer row.?.deinit();
2267
2267
-
return @intCast(row.?.int(0));
2268
2268
-
}
2269
2269
-
2270
2270
-
fn accountActiveLocked(did: []const u8) !bool {
2271
2271
-
const row = try conn.row(
2272
2272
-
\\SELECT activated_at, deactivated_at
2273
2273
-
\\FROM accounts
2274
2274
-
\\WHERE did = ?
2275
2275
-
, .{did});
2276
2276
-
if (row == null) return false;
2277
2277
-
defer row.?.deinit();
2278
2278
-
return row.?.nullableInt(0) != null and row.?.nullableInt(1) == null;
2279
2279
-
}
2280
2280
-
2281
2281
-
fn cidForJson(allocator: std.mem.Allocator, json: []const u8) ![]const u8 {
2282
2282
-
const cid = try zat.cbor.Cid.forDagCbor(allocator, json);
2283
2283
-
defer allocator.free(cid.raw);
2284
2284
-
return zat.multibase.base32lower.encode(allocator, cid.raw);
2285
2285
-
}
2286
2286
-
2287
2287
-
fn cidForBlob(allocator: std.mem.Allocator, data: []const u8) ![]const u8 {
2288
2288
-
const cid = try zat.cbor.Cid.create(allocator, 1, 0x55, 0x12, data);
2289
2289
-
defer allocator.free(cid.raw);
2290
2290
-
return zat.multibase.base32lower.encode(allocator, cid.raw);
2291
2291
-
}
2292
2292
-
2293
2293
-
fn requireInitialized() !void {
2294
2294
-
if (!initialized) return Error.StoreNotInitialized;
2295
2295
-
}
2296
2296
-
2297
2297
-
const schema_statements = [_][*:0]const u8{
2298
2298
-
\\CREATE TABLE IF NOT EXISTS accounts (
2299
2299
-
\\ did TEXT PRIMARY KEY,
2300
2300
-
\\ handle TEXT NOT NULL UNIQUE,
2301
2301
-
\\ email TEXT NOT NULL UNIQUE,
2302
2302
-
\\ email_confirmed_at INTEGER,
2303
2303
-
\\ auth_code TEXT,
2304
2304
-
\\ auth_code_expires_at INTEGER,
2305
2305
-
\\ pending_email TEXT,
2306
2306
-
\\ activated_at INTEGER,
2307
2307
-
\\ deactivated_at INTEGER,
2308
2308
-
\\ signing_key_type TEXT,
2309
2309
-
\\ signing_key BLOB,
2310
2310
-
\\ password_hash TEXT NOT NULL,
2311
2311
-
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
2312
2312
-
\\)
2313
2313
-
,
2314
2314
-
\\CREATE TABLE IF NOT EXISTS records (
2315
2315
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2316
2316
-
\\ collection TEXT NOT NULL,
2317
2317
-
\\ rkey TEXT NOT NULL,
2318
2318
-
\\ uri TEXT NOT NULL UNIQUE,
2319
2319
-
\\ cid TEXT NOT NULL,
2320
2320
-
\\ value_json BLOB NOT NULL,
2321
2321
-
\\ rev TEXT NOT NULL,
2322
2322
-
\\ seq INTEGER NOT NULL,
2323
2323
-
\\ PRIMARY KEY (did, collection, rkey)
2324
2324
-
\\)
2325
2325
-
,
2326
2326
-
"CREATE INDEX IF NOT EXISTS records_collection_idx ON records (did, collection, seq DESC)",
2327
2327
-
"CREATE INDEX IF NOT EXISTS records_cid_idx ON records (cid)",
2328
2328
-
\\CREATE TABLE IF NOT EXISTS commits (
2329
2329
-
\\ seq INTEGER PRIMARY KEY,
2330
2330
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2331
2331
-
\\ cid TEXT NOT NULL,
2332
2332
-
\\ rev TEXT NOT NULL,
2333
2333
-
\\ prev TEXT
2334
2334
-
\\)
2335
2335
-
,
2336
2336
-
"CREATE INDEX IF NOT EXISTS commits_did_idx ON commits (did, seq DESC)",
2337
2337
-
\\CREATE TABLE IF NOT EXISTS seq_events (
2338
2338
-
\\ seq INTEGER PRIMARY KEY,
2339
2339
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2340
2340
-
\\ commit_cid TEXT NOT NULL,
2341
2341
-
\\ evt BLOB NOT NULL
2342
2342
-
\\)
2343
2343
-
,
2344
2344
-
\\CREATE TABLE IF NOT EXISTS blocks (
2345
2345
-
\\ cid TEXT PRIMARY KEY,
2346
2346
-
\\ data BLOB NOT NULL
2347
2347
-
\\)
2348
2348
-
,
2349
2349
-
\\CREATE TABLE IF NOT EXISTS blobs (
2350
2350
-
\\ cid TEXT NOT NULL,
2351
2351
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2352
2352
-
\\ mime_type TEXT NOT NULL,
2353
2353
-
\\ size INTEGER NOT NULL,
2354
2354
-
\\ storage TEXT NOT NULL DEFAULT 'disk',
2355
2355
-
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
2356
2356
-
\\ PRIMARY KEY (did, cid)
2357
2357
-
\\)
2358
2358
-
,
2359
2359
-
\\CREATE TABLE IF NOT EXISTS record_blobs (
2360
2360
-
\\ blob_cid TEXT NOT NULL,
2361
2361
-
\\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE,
2362
2362
-
\\ PRIMARY KEY (blob_cid, record_uri)
2363
2363
-
\\)
2364
2364
-
,
2365
2365
-
\\CREATE TABLE IF NOT EXISTS app_preferences (
2366
2366
-
\\ id INTEGER PRIMARY KEY AUTOINCREMENT,
2367
2367
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2368
2368
-
\\ name TEXT NOT NULL,
2369
2369
-
\\ value_json BLOB NOT NULL,
2370
2370
-
\\ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
2371
2371
-
\\)
2372
2372
-
,
2373
2373
-
\\CREATE TABLE IF NOT EXISTS oauth_requests (
2374
2374
-
\\ request_id TEXT PRIMARY KEY,
2375
2375
-
\\ client_id TEXT NOT NULL,
2376
2376
-
\\ redirect_uri TEXT NOT NULL,
2377
2377
-
\\ scope TEXT NOT NULL,
2378
2378
-
\\ state TEXT NOT NULL,
2379
2379
-
\\ code_challenge TEXT NOT NULL,
2380
2380
-
\\ code_challenge_method TEXT NOT NULL,
2381
2381
-
\\ login_hint TEXT,
2382
2382
-
\\ dpop_jkt TEXT,
2383
2383
-
\\ expires_at INTEGER NOT NULL,
2384
2384
-
\\ sub TEXT,
2385
2385
-
\\ code TEXT UNIQUE,
2386
2386
-
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
2387
2387
-
\\)
2388
2388
-
,
2389
2389
-
\\CREATE TABLE IF NOT EXISTS oauth_tokens (
2390
2390
-
\\ access_token TEXT PRIMARY KEY,
2391
2391
-
\\ refresh_token TEXT NOT NULL UNIQUE,
2392
2392
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2393
2393
-
\\ client_id TEXT NOT NULL,
2394
2394
-
\\ scope TEXT NOT NULL,
2395
2395
-
\\ expires_at INTEGER NOT NULL,
2396
2396
-
\\ revoked_at INTEGER,
2397
2397
-
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
2398
2398
-
\\)
2399
2399
-
,
2400
2400
-
};
2401
2401
-
2402
2402
-
const post_schema_statements = [_][*:0]const u8{
2403
2403
-
\\CREATE TABLE IF NOT EXISTS expected_blobs (
2404
2404
-
\\ blob_cid TEXT NOT NULL,
2405
2405
-
\\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE,
2406
2406
-
\\ PRIMARY KEY (blob_cid, record_uri)
2407
2407
-
\\)
2408
2408
-
,
2409
2409
-
\\CREATE TABLE IF NOT EXISTS repo_blocks (
2410
2410
-
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
2411
2411
-
\\ cid TEXT NOT NULL,
2412
2412
-
\\ data BLOB NOT NULL,
2413
2413
-
\\ PRIMARY KEY (did, cid)
2414
2414
-
\\)
2415
2415
-
,
2416
2416
-
};
2417
2417
-
2418
2418
-
test "persists records in sqlite" {
2419
2419
-
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2420
2420
-
defer arena.deinit();
2421
2421
-
const allocator = arena.allocator();
2422
2422
-
2423
2423
-
try init(std.Options.debug_io, ":memory:");
2424
2424
-
defer close();
2425
2425
-
2426
2426
-
const account = try createAccount(
2427
2427
-
allocator,
2428
2428
-
"alice.test",
2429
2429
-
"alice@test.com",
2430
2430
-
"password",
2431
2431
-
"did:plc:cmadossymmii3izkabdbp5en",
2432
2432
-
true,
2433
2433
-
);
2434
2434
-
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{});
2435
2435
-
defer parsed.deinit();
2436
2436
-
2437
2437
-
const record = try create(allocator, account, "app.bsky.feed.post", "3ztest", parsed.value);
2438
2438
-
try std.testing.expectEqualStrings("3ztest", record.rkey);
2439
2439
-
2440
2440
-
const fetched = get(account.did, "app.bsky.feed.post", "3ztest").?;
2441
2441
-
try std.testing.expectEqualStrings(record.cid, fetched.cid);
2442
2442
-
try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "hello") != null);
2443
2443
-
2444
2444
-
const repo_car = try writeRepoCar(allocator, account.did);
2445
2445
-
const loaded = try zat.loadCommitFromCAR(allocator, repo_car);
2446
2446
-
try std.testing.expectEqualStrings(account.did, loaded.commit.did);
2447
2447
-
try std.testing.expectEqualStrings(fetched.rev, loaded.commit.rev);
2448
2448
-
try std.testing.expectEqualStrings((try latestRootLocked(allocator, account.did)).cid, try cidText(allocator, loaded.commit_cid));
2449
2449
-
2450
2450
-
const tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid);
2451
2451
-
const found = tree.get("app.bsky.feed.post/3ztest") orelse return error.MissingRecord;
2452
2452
-
try std.testing.expectEqualStrings(record.cid, try cidText(allocator, found.raw));
2453
2453
-
}
2454
2454
-
2455
2455
-
test "stores blob metadata in sqlite and bytes in disk blobstore" {
2456
2456
-
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2457
2457
-
defer arena.deinit();
2458
2458
-
const allocator = arena.allocator();
2459
2459
-
2460
2460
-
var tmp = std.testing.tmpDir(.{});
2461
2461
-
defer tmp.cleanup();
2462
2462
-
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
2463
2463
-
const blob_root = try tmp.dir.realpath(".", &path_buf);
2464
2464
-
blobstore.init(std.Options.debug_io, blob_root);
2465
2465
-
2466
2466
-
try init(std.Options.debug_io, ":memory:");
2467
2467
-
defer close();
2468
2468
-
2469
2469
-
const account = try createAccount(
2470
2470
-
allocator,
2471
2471
-
"alice.test",
2472
2472
-
"alice@test.com",
2473
2473
-
"password",
2474
2474
-
"did:plc:cmadossymmii3izkabdbp5en",
2475
2475
-
true,
2476
2476
-
);
2477
2477
-
const cid = try putBlob(allocator, std.Options.debug_io, account, "hello blob", "text/plain");
2478
2478
-
const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid });
2479
2479
-
try std.testing.expect(row != null);
2480
2480
-
defer row.?.deinit();
2481
2481
-
try std.testing.expectEqualStrings("text/plain", row.?.text(0));
2482
2482
-
try std.testing.expectEqual(@as(i64, 10), row.?.int(1));
2483
2483
-
2484
2484
-
const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord;
2485
2485
-
try std.testing.expectEqualStrings("text/plain", blob.mime_type);
2486
2486
-
try std.testing.expectEqualStrings("hello blob", blob.data);
2487
2487
-
}
2488
2488
-
2489
2489
-
test "stores larger blob metadata in sqlite and bytes in disk blobstore" {
2490
2490
-
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2491
2491
-
defer arena.deinit();
2492
2492
-
const allocator = arena.allocator();
2493
2493
-
2494
2494
-
var tmp = std.testing.tmpDir(.{});
2495
2495
-
defer tmp.cleanup();
2496
2496
-
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
2497
2497
-
const blob_root = try tmp.dir.realpath(".", &path_buf);
2498
2498
-
blobstore.init(std.Options.debug_io, blob_root);
2499
2499
-
2500
2500
-
try init(std.Options.debug_io, ":memory:");
2501
2501
-
defer close();
2502
2502
-
2503
2503
-
const account = try createAccount(
2504
2504
-
allocator,
2505
2505
-
"large-blob.test",
2506
2506
-
"large-blob@test.com",
2507
2507
-
"password",
2508
2508
-
"did:plc:largeblob",
2509
2509
-
true,
2510
2510
-
);
2511
2511
-
2512
2512
-
const data = try allocator.alloc(u8, 571_880);
2513
2513
-
@memset(data, 0xaa);
2514
2514
-
2515
2515
-
const cid = try putBlob(allocator, std.Options.debug_io, account, data, "image/jpeg");
2516
2516
-
const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid });
2517
2517
-
try std.testing.expect(row != null);
2518
2518
-
defer row.?.deinit();
2519
2519
-
try std.testing.expectEqualStrings("image/jpeg", row.?.text(0));
2520
2520
-
try std.testing.expectEqual(@as(i64, 571_880), row.?.int(1));
2521
2521
-
2522
2522
-
const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord;
2523
2523
-
try std.testing.expectEqualStrings("image/jpeg", blob.mime_type);
2524
2524
-
try std.testing.expectEqualSlices(u8, data, blob.data);
2525
2525
-
}
2526
2526
-
2527
2527
-
test "commit event encoding owns returned firehose op entries" {
2528
2528
-
const allocator = std.testing.allocator;
2529
2529
-
const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq";
2530
2530
-
2531
2531
-
const ops = try allocator.alloc(zat.cbor.Value, 1);
2532
2532
-
defer allocator.free(ops);
2533
2533
-
ops[0] = try firehoseOp(allocator, "create", "sh.tangled.string", "zds-test", cid);
2534
2534
-
defer if (ops[0] == .map) allocator.free(ops[0].map);
2535
2535
-
2536
2536
-
const frame = try commitEventFrameFromCar(
2537
2537
-
allocator,
2538
2538
-
1,
2539
2539
-
"did:plc:b64lsctzqnzpv6vd4ry3qktw",
2540
2540
-
cid,
2541
2541
-
"3zzzzzzzzzzzz",
2542
2542
-
null,
2543
2543
-
"not-a-real-car-for-this-encoding-test",
2544
2544
-
ops,
2545
2545
-
);
2546
2546
-
defer allocator.free(frame);
2547
2547
-
try std.testing.expect(frame.len > 0);
2548
2548
-
}
2549
2549
-
2550
2550
-
test "repo rev generation does not move behind current head" {
2551
2551
-
const allocator = std.testing.allocator;
2552
2552
-
const future_tid = try atid.encode(nowMicros() + std.time.us_per_s, 0);
2553
2553
-
const rev = try revForSeq(allocator, 42, &future_tid);
2554
2554
-
defer allocator.free(rev);
2555
2555
-
2556
2556
-
const previous = try atid.timestampMicros(&future_tid);
2557
2557
-
const next = try atid.timestampMicros(rev);
2558
2558
-
try std.testing.expect(next > previous);
2559
2559
-
try std.testing.expect(std.mem.lessThan(u8, &future_tid, rev));
2560
2560
-
}
···
1
1
-
#!/usr/bin/env sh
2
2
-
set -eu
3
3
-
4
4
-
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
5
5
-
port="${ZDS_SMOKE_PORT:-2585}"
6
6
-
db="${TMPDIR:-/tmp}/zds-smoke.sqlite3"
7
7
-
blob_root="${TMPDIR:-/tmp}/zds-smoke-blobs"
8
8
-
log="${TMPDIR:-/tmp}/zds-smoke.log"
9
9
-
base="http://127.0.0.1:${port}"
10
10
-
11
11
-
cleanup() {
12
12
-
if [ -n "${server_pid:-}" ]; then
13
13
-
kill "$server_pid" 2>/dev/null || true
14
14
-
wait "$server_pid" 2>/dev/null || true
15
15
-
fi
16
16
-
}
17
17
-
trap cleanup EXIT INT TERM
18
18
-
19
19
-
cd "$root"
20
20
-
rm -f "$db" "$db-wal" "$db-shm" "$log"
21
21
-
rm -rf "$blob_root"
22
22
-
mkdir -p "$blob_root"
23
23
-
24
24
-
zig build
25
25
-
zig build run -- \
26
26
-
--host 127.0.0.1 \
27
27
-
--port "$port" \
28
28
-
--db "$db" \
29
29
-
--blobstore-path "$blob_root" \
30
30
-
--public-url "$base" \
31
31
-
--server-did did:web:localhost \
32
32
-
--handle-domains .test \
33
33
-
>"$log" 2>&1 &
34
34
-
server_pid=$!
35
35
-
36
36
-
i=0
37
37
-
while [ "$i" -lt 80 ]; do
38
38
-
if curl -fsS "$base/xrpc/_health" >/dev/null 2>&1; then
39
39
-
break
40
40
-
fi
41
41
-
i=$((i + 1))
42
42
-
sleep 0.1
43
43
-
done
44
44
-
45
45
-
curl -fsS "$base/xrpc/_health" >/dev/null
46
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
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
48
-
49
49
-
resolved=$(curl -fsS "$base/xrpc/com.atproto.identity.resolveHandle?handle=smoke.test")
50
50
-
printf '%s' "$resolved" | grep -q '"did":"did:plc:smoketest"'
51
51
-
52
52
-
session=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createSession" \
53
53
-
-H 'content-type: application/json' \
54
54
-
--data '{"identifier":"smoke.test","password":"password"}')
55
55
-
token=$(printf '%s' "$session" | sed -n 's/.*"accessJwt":"\([^"]*\)".*/\1/p')
56
56
-
test -n "$token"
57
57
-
58
58
-
curl -fsS -X POST "$base/xrpc/app.bsky.actor.putPreferences" \
59
59
-
-H "authorization: Bearer $token" \
60
60
-
-H 'content-type: application/json' \
61
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
62
-
prefs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/app.bsky.actor.getPreferences")
63
63
-
printf '%s' "$prefs" | grep -q '"label":"dogs"'
64
64
-
printf '%s' "$prefs" | grep -q '"label":"cats"'
65
65
-
printf '%s' "$prefs" | grep -q '"birthDate":"1970-01-01"'
66
66
-
printf '%s' "$prefs" | grep -q '"isOverAge13":true'
67
67
-
printf '%s' "$prefs" | grep -q '"isOverAge16":true'
68
68
-
printf '%s' "$prefs" | grep -q '"isOverAge18":true'
69
69
-
test "$(printf '%s' "$prefs" | grep -o 'declaredAgePref' | wc -l | tr -d ' ')" = "1"
70
70
-
71
71
-
create=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.createRecord" \
72
72
-
-H "authorization: Bearer $token" \
73
73
-
-H 'content-type: application/json' \
74
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
75
-
printf '%s' "$create" | grep -q '"uri":"at://did:plc:smoketest/app.bsky.feed.post/3smoketest"'
76
76
-
77
77
-
records=$(curl -fsS "$base/xrpc/com.atproto.repo.listRecords?repo=did:plc:smoketest&collection=app.bsky.feed.post&limit=10")
78
78
-
printf '%s' "$records" | grep -q '"text":"smoke"'
79
79
-
80
80
-
latest=$(curl -fsS "$base/xrpc/com.atproto.sync.getLatestCommit?did=did:plc:smoketest")
81
81
-
printf '%s' "$latest" | grep -q '"cid":"'
82
82
-
printf '%s' "$latest" | grep -q '"rev":"'
83
83
-
84
84
-
repo_car="${TMPDIR:-/tmp}/zds-smoke.car"
85
85
-
code=$(curl -sS -o "$repo_car" -w '%{http_code}' "$base/xrpc/com.atproto.sync.getRepo?did=did:plc:smoketest")
86
86
-
test "$code" = "200"
87
87
-
test "$(wc -c < "$repo_car")" -gt 100
88
88
-
89
89
-
blob_payload="${TMPDIR:-/tmp}/zds-smoke-blob.jpg"
90
90
-
printf '\377\330\377\340zds-smoke' > "$blob_payload"
91
91
-
blob=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.uploadBlob" \
92
92
-
-H "authorization: Bearer $token" \
93
93
-
-H 'content-type: image/jpeg' \
94
94
-
--data-binary "@$blob_payload")
95
95
-
printf '%s' "$blob" | grep -q '"blob":'
96
96
-
printf '%s' "$blob" | grep -q '"mimeType":"image/jpeg"'
97
97
-
98
98
-
large_blob_payload="${TMPDIR:-/tmp}/zds-smoke-large-blob.jpg"
99
99
-
dd if=/dev/zero bs=1024 count=600 of="$large_blob_payload" 2>/dev/null
100
100
-
large_blob=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.uploadBlob" \
101
101
-
-H "authorization: Bearer $token" \
102
102
-
-H 'content-type: image/jpeg' \
103
103
-
--data-binary "@$large_blob_payload")
104
104
-
printf '%s' "$large_blob" | grep -q '"blob":'
105
105
-
printf '%s' "$large_blob" | grep -q '"size":614400'
106
106
-
107
107
-
oauth_info=$(curl -fsS -H 'accept: application/json' "$base/oauth/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Asmoke-oauth")
108
108
-
printf '%s' "$oauth_info" | grep -q '"login_hint":"smoke.test"'
109
109
-
oauth_page="${TMPDIR:-/tmp}/zds-smoke-oauth.html"
110
110
-
curl -fsS "$base/oauth/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Asmoke-oauth" > "$oauth_page"
111
111
-
grep -q 'value="smoke.test"' "$oauth_page"
112
112
-
grep -q 'Repository access' "$oauth_page"
113
113
-
grep -q 'Blob access' "$oauth_page"
114
114
-
115
115
-
echo "zds smoke ok"