tangled
alpha
login
or
join now
graham.systems
/
silhouette
0
fork
atom
Resolve your Bluesky avatar with a human URL
silhouette.town
atproto
deno
0
fork
atom
overview
issues
pulls
pipelines
init with basic implementation
graham.systems
1 month ago
3d9a2784
+389
7 changed files
expand all
collapse all
unified
split
.envrc
deno.json
deno.lock
flake.lock
flake.nix
main.ts
main_test.ts
+1
.envrc
···
1
1
+
use flake
+21
deno.json
···
1
1
+
{
2
2
+
"tasks": {
3
3
+
"dev": "deno run --watch main.ts"
4
4
+
},
5
5
+
"imports": {
6
6
+
"@atcute/atproto": "npm:@atcute/atproto@^3.1.10",
7
7
+
"@atcute/bluesky": "npm:@atcute/bluesky@^3.2.17",
8
8
+
"@atcute/client": "npm:@atcute/client@^4.2.1",
9
9
+
"@atcute/lexicons": "npm:@atcute/lexicons@^1.2.7",
10
10
+
"@atcute/microcosm": "npm:@atcute/microcosm@^1.0.1",
11
11
+
"@std/assert": "jsr:@std/assert@1",
12
12
+
"@std/http": "jsr:@std/http@^1.0.24"
13
13
+
},
14
14
+
"compilerOptions": {
15
15
+
"types": [
16
16
+
"@atcute/atproto",
17
17
+
"@atcute/microcosm",
18
18
+
"@atcute/bluesky"
19
19
+
]
20
20
+
}
21
21
+
}
+153
deno.lock
···
1
1
+
{
2
2
+
"version": "5",
3
3
+
"specifiers": {
4
4
+
"jsr:@std/assert@1": "1.0.18",
5
5
+
"jsr:@std/cli@^1.0.27": "1.0.27",
6
6
+
"jsr:@std/encoding@^1.0.10": "1.0.10",
7
7
+
"jsr:@std/fmt@^1.0.9": "1.0.9",
8
8
+
"jsr:@std/fs@^1.0.22": "1.0.22",
9
9
+
"jsr:@std/html@^1.0.5": "1.0.5",
10
10
+
"jsr:@std/http@^1.0.24": "1.0.24",
11
11
+
"jsr:@std/internal@^1.0.12": "1.0.12",
12
12
+
"jsr:@std/media-types@^1.1.0": "1.1.0",
13
13
+
"jsr:@std/net@^1.0.6": "1.0.6",
14
14
+
"jsr:@std/path@^1.1.4": "1.1.4",
15
15
+
"jsr:@std/streams@^1.0.17": "1.0.17",
16
16
+
"npm:@atcute/atproto@^3.1.10": "3.1.10",
17
17
+
"npm:@atcute/bluesky@^3.2.17": "3.2.17",
18
18
+
"npm:@atcute/client@^4.2.1": "4.2.1",
19
19
+
"npm:@atcute/lexicons@^1.2.7": "1.2.7",
20
20
+
"npm:@atcute/microcosm@^1.0.1": "1.0.1"
21
21
+
},
22
22
+
"jsr": {
23
23
+
"@std/assert@1.0.18": {
24
24
+
"integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78",
25
25
+
"dependencies": [
26
26
+
"jsr:@std/internal"
27
27
+
]
28
28
+
},
29
29
+
"@std/cli@1.0.27": {
30
30
+
"integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de"
31
31
+
},
32
32
+
"@std/encoding@1.0.10": {
33
33
+
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
34
34
+
},
35
35
+
"@std/fmt@1.0.9": {
36
36
+
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
37
37
+
},
38
38
+
"@std/fs@1.0.22": {
39
39
+
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308"
40
40
+
},
41
41
+
"@std/html@1.0.5": {
42
42
+
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
43
43
+
},
44
44
+
"@std/http@1.0.24": {
45
45
+
"integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4",
46
46
+
"dependencies": [
47
47
+
"jsr:@std/cli",
48
48
+
"jsr:@std/encoding",
49
49
+
"jsr:@std/fmt",
50
50
+
"jsr:@std/fs",
51
51
+
"jsr:@std/html",
52
52
+
"jsr:@std/media-types",
53
53
+
"jsr:@std/net",
54
54
+
"jsr:@std/path",
55
55
+
"jsr:@std/streams"
56
56
+
]
57
57
+
},
58
58
+
"@std/internal@1.0.12": {
59
59
+
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
60
60
+
},
61
61
+
"@std/media-types@1.1.0": {
62
62
+
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
63
63
+
},
64
64
+
"@std/net@1.0.6": {
65
65
+
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
66
66
+
},
67
67
+
"@std/path@1.1.4": {
68
68
+
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
69
69
+
"dependencies": [
70
70
+
"jsr:@std/internal"
71
71
+
]
72
72
+
},
73
73
+
"@std/streams@1.0.17": {
74
74
+
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
75
75
+
}
76
76
+
},
77
77
+
"npm": {
78
78
+
"@atcute/atproto@3.1.10": {
79
79
+
"integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==",
80
80
+
"dependencies": [
81
81
+
"@atcute/lexicons"
82
82
+
]
83
83
+
},
84
84
+
"@atcute/bluesky@3.2.17": {
85
85
+
"integrity": "sha512-Li+RsPkcRNC6AnNlqOGnlmAcjSwBdXIKFubJL1nwACDngKNXG4ooGL5cvzeekdDEfHmtFhS/tyZNaUx9QXYEUw==",
86
86
+
"dependencies": [
87
87
+
"@atcute/atproto",
88
88
+
"@atcute/lexicons"
89
89
+
]
90
90
+
},
91
91
+
"@atcute/client@4.2.1": {
92
92
+
"integrity": "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==",
93
93
+
"dependencies": [
94
94
+
"@atcute/identity",
95
95
+
"@atcute/lexicons"
96
96
+
]
97
97
+
},
98
98
+
"@atcute/identity@1.1.3": {
99
99
+
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
100
100
+
"dependencies": [
101
101
+
"@atcute/lexicons",
102
102
+
"@badrap/valita"
103
103
+
]
104
104
+
},
105
105
+
"@atcute/lexicons@1.2.7": {
106
106
+
"integrity": "sha512-gCvkSMI1F1zx7xXa59iPiSKMH3L5Hga6iurGqQjaQbE2V/np/2QuDqQzt96TNbWfaFAXE9f9oY+0z3ljf/bweA==",
107
107
+
"dependencies": [
108
108
+
"@atcute/uint8array",
109
109
+
"@atcute/util-text",
110
110
+
"@standard-schema/spec",
111
111
+
"esm-env"
112
112
+
]
113
113
+
},
114
114
+
"@atcute/microcosm@1.0.1": {
115
115
+
"integrity": "sha512-siyreLgOCZ6gT3x5tajTw1MrlR0s4SDNlUvaRYQZrAUZS1xuuLx1Ko/cwsf+/QQzEN6K1wgtTC0J6HqtRZwWVg==",
116
116
+
"dependencies": [
117
117
+
"@atcute/lexicons"
118
118
+
]
119
119
+
},
120
120
+
"@atcute/uint8array@1.1.0": {
121
121
+
"integrity": "sha512-JtHXIVW6LPU9FMWp7SgE4HbUs3uV2WdfkK/2RWdEGjr4EgMV50P3FdU6fPeGlTfDNBJVYMIsuD2wwaKRPV/Aqg=="
122
122
+
},
123
123
+
"@atcute/util-text@1.1.0": {
124
124
+
"integrity": "sha512-34G9KD5Z9f7oEdFpZOmqrMnU86p8ne6LlxJowfZzKNszRcl1GH+FtEPh3N1woelJT2SkPXMK2anwT8DESTluwA==",
125
125
+
"dependencies": [
126
126
+
"unicode-segmenter"
127
127
+
]
128
128
+
},
129
129
+
"@badrap/valita@0.4.6": {
130
130
+
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
131
131
+
},
132
132
+
"@standard-schema/spec@1.1.0": {
133
133
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
134
134
+
},
135
135
+
"esm-env@1.2.2": {
136
136
+
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
137
137
+
},
138
138
+
"unicode-segmenter@0.14.5": {
139
139
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
140
140
+
}
141
141
+
},
142
142
+
"workspace": {
143
143
+
"dependencies": [
144
144
+
"jsr:@std/assert@1",
145
145
+
"jsr:@std/http@^1.0.24",
146
146
+
"npm:@atcute/atproto@^3.1.10",
147
147
+
"npm:@atcute/bluesky@^3.2.17",
148
148
+
"npm:@atcute/client@^4.2.1",
149
149
+
"npm:@atcute/lexicons@^1.2.7",
150
150
+
"npm:@atcute/microcosm@^1.0.1"
151
151
+
]
152
152
+
}
153
153
+
}
+59
flake.lock
···
1
1
+
{
2
2
+
"nodes": {
3
3
+
"nixpkgs": {
4
4
+
"locked": {
5
5
+
"lastModified": 1770141374,
6
6
+
"narHash": "sha256-yD4K/vRHPwXbJf5CK3JkptBA6nFWUKNX/jlFp2eKEQc=",
7
7
+
"owner": "NixOS",
8
8
+
"repo": "nixpkgs",
9
9
+
"rev": "41965737c1797c1d83cfb0b644ed0840a6220bd1",
10
10
+
"type": "github"
11
11
+
},
12
12
+
"original": {
13
13
+
"id": "nixpkgs",
14
14
+
"type": "indirect"
15
15
+
}
16
16
+
},
17
17
+
"root": {
18
18
+
"inputs": {
19
19
+
"nixpkgs": "nixpkgs",
20
20
+
"utils": "utils"
21
21
+
}
22
22
+
},
23
23
+
"systems": {
24
24
+
"locked": {
25
25
+
"lastModified": 1681028828,
26
26
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
27
27
+
"owner": "nix-systems",
28
28
+
"repo": "default",
29
29
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
30
30
+
"type": "github"
31
31
+
},
32
32
+
"original": {
33
33
+
"owner": "nix-systems",
34
34
+
"repo": "default",
35
35
+
"type": "github"
36
36
+
}
37
37
+
},
38
38
+
"utils": {
39
39
+
"inputs": {
40
40
+
"systems": "systems"
41
41
+
},
42
42
+
"locked": {
43
43
+
"lastModified": 1731533236,
44
44
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
45
45
+
"owner": "numtide",
46
46
+
"repo": "flake-utils",
47
47
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
48
48
+
"type": "github"
49
49
+
},
50
50
+
"original": {
51
51
+
"owner": "numtide",
52
52
+
"repo": "flake-utils",
53
53
+
"type": "github"
54
54
+
}
55
55
+
}
56
56
+
},
57
57
+
"root": "root",
58
58
+
"version": 7
59
59
+
}
+17
flake.nix
···
1
1
+
{
2
2
+
inputs = {
3
3
+
utils.url = "github:numtide/flake-utils";
4
4
+
};
5
5
+
outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem (system:
6
6
+
let
7
7
+
pkgs = nixpkgs.legacyPackages.${system};
8
8
+
in
9
9
+
{
10
10
+
devShell = pkgs.mkShell {
11
11
+
buildInputs = with pkgs; [
12
12
+
deno
13
13
+
];
14
14
+
};
15
15
+
}
16
16
+
);
17
17
+
}
+132
main.ts
···
1
1
+
import { STATUS_CODE, STATUS_TEXT } from "@std/http";
2
2
+
import { Client, simpleFetchHandler } from "@atcute/client";
3
3
+
import { is } from "@atcute/lexicons";
4
4
+
import { isActorIdentifier } from "@atcute/lexicons/syntax";
5
5
+
import { AppBskyActorProfile } from "@atcute/bluesky";
6
6
+
7
7
+
const IDENTIFIER_ROUTE = new URLPattern({ pathname: "/avatar/:identifier" });
8
8
+
const slingshot = new Client({
9
9
+
handler: simpleFetchHandler({ service: "https://slingshot.microcosm.blue" }),
10
10
+
});
11
11
+
12
12
+
// Algorithm:
13
13
+
// 1. Resolve minidoc from identifier in URL
14
14
+
// 2. Resolve Bluesky actor profile from PDS
15
15
+
// 3. Return 303 with profile picture blob URL
16
16
+
17
17
+
export async function handler(req: Request): Promise<Response> {
18
18
+
const match = IDENTIFIER_ROUTE.exec(req.url);
19
19
+
20
20
+
if (!match) {
21
21
+
return new Response(null, {
22
22
+
status: STATUS_CODE.NotFound,
23
23
+
statusText: STATUS_TEXT[STATUS_CODE.NotFound],
24
24
+
});
25
25
+
} else if (req.method !== "GET") {
26
26
+
return new Response(null, {
27
27
+
status: STATUS_CODE.MethodNotAllowed,
28
28
+
statusText: STATUS_TEXT[STATUS_CODE.MethodNotAllowed],
29
29
+
});
30
30
+
}
31
31
+
32
32
+
const identifier = match.pathname.groups.identifier;
33
33
+
34
34
+
if (!isActorIdentifier(identifier)) {
35
35
+
return Response.json({
36
36
+
identifier,
37
37
+
message: `invalid identifier ${identifier}`,
38
38
+
}, {
39
39
+
status: STATUS_CODE.BadRequest,
40
40
+
statusText: STATUS_TEXT[STATUS_CODE.BadRequest],
41
41
+
});
42
42
+
}
43
43
+
44
44
+
const minidoc = await slingshot.get(
45
45
+
"com.bad-example.identity.resolveMiniDoc",
46
46
+
{
47
47
+
params: {
48
48
+
identifier,
49
49
+
},
50
50
+
},
51
51
+
);
52
52
+
53
53
+
if (!minidoc.ok) {
54
54
+
return Response.json({
55
55
+
identifier,
56
56
+
message: `failed to resolve identifier ${identifier}`,
57
57
+
}, {
58
58
+
status: STATUS_CODE.InternalServerError,
59
59
+
statusText: STATUS_TEXT[STATUS_CODE.InternalServerError],
60
60
+
});
61
61
+
} else if (minidoc.status > 400) {
62
62
+
return Response.json({
63
63
+
identifier,
64
64
+
message: `error from Slingshot`,
65
65
+
error: minidoc.data,
66
66
+
}, {
67
67
+
status: STATUS_CODE.InternalServerError,
68
68
+
statusText: STATUS_TEXT[STATUS_CODE.InternalServerError],
69
69
+
});
70
70
+
}
71
71
+
72
72
+
const pds = new Client({
73
73
+
handler: simpleFetchHandler({ service: minidoc.data.pds }),
74
74
+
});
75
75
+
76
76
+
const record = await pds.get("com.atproto.repo.getRecord", {
77
77
+
params: {
78
78
+
collection: "app.bsky.actor.profile",
79
79
+
rkey: "self",
80
80
+
repo: minidoc.data.did,
81
81
+
},
82
82
+
});
83
83
+
84
84
+
if (!record.ok || record.status > 400) {
85
85
+
return Response.json({
86
86
+
identifier,
87
87
+
pds: minidoc.data.pds,
88
88
+
message: `failed to resolve app.bsky.actor.profile for ${identifier}`,
89
89
+
}, {
90
90
+
status: record.status || STATUS_CODE.InternalServerError,
91
91
+
statusText: STATUS_TEXT[
92
92
+
STATUS_CODE.InternalServerError
93
93
+
],
94
94
+
});
95
95
+
}
96
96
+
97
97
+
const profile = record.data.value;
98
98
+
if (!is(AppBskyActorProfile.mainSchema, profile)) {
99
99
+
return Response.json({
100
100
+
identifier,
101
101
+
pds: minidoc.data.pds,
102
102
+
message: `profile record does not match app.bsky.actor.profile`,
103
103
+
profile,
104
104
+
}, {
105
105
+
status: STATUS_CODE.PreconditionFailed,
106
106
+
statusText: STATUS_TEXT[STATUS_CODE.PreconditionFailed],
107
107
+
});
108
108
+
} else if (!profile.avatar) {
109
109
+
return Response.json({
110
110
+
identifier,
111
111
+
pds: minidoc.data.pds,
112
112
+
message: "profile has no avatar",
113
113
+
}, {
114
114
+
status: STATUS_CODE.NotFound,
115
115
+
statusText: STATUS_TEXT[STATUS_CODE.NotFound],
116
116
+
});
117
117
+
}
118
118
+
119
119
+
const blobUrl = new URL("/xrpc/com.atproto.sync.getBlob", minidoc.data.pds);
120
120
+
const cid = "ref" in profile.avatar
121
121
+
? profile.avatar.ref.$link
122
122
+
: profile.avatar.cid;
123
123
+
124
124
+
blobUrl.searchParams.set("did", minidoc.data.did);
125
125
+
blobUrl.searchParams.set("cid", cid);
126
126
+
127
127
+
return Response.redirect(blobUrl, STATUS_CODE.SeeOther);
128
128
+
}
129
129
+
130
130
+
if (import.meta.main) {
131
131
+
Deno.serve(handler);
132
132
+
}
+6
main_test.ts
···
1
1
+
import { assertEquals } from "@std/assert";
2
2
+
import { add } from "./main.ts";
3
3
+
4
4
+
Deno.test(function addTest() {
5
5
+
assertEquals(add(2, 3), 5);
6
6
+
});