A small web tool for showing what Bluesky moderation labels are assigned to a profile
1const batchSize = 20;
2
3const acceptedHostnames = [
4 'bsky.app',
5 'main.bsky.dev',
6 'deer.social',
7];
8
9class URLError extends Error {}
10class AccountError extends Error {}
11class PostTakenDownError extends Error {}
12
13document.addEventListener("DOMContentLoaded", function() {
14 initScanner();
15});
16
17function initScanner() {
18 window.resultField = document.getElementById('result');
19 window.noteField = document.getElementById('note');
20 window.foundLabels = document.getElementById('found_labels');
21
22 window.blue = new Minisky('blue.mackuba.eu');
23 window.appView = new Minisky('api.bsky.app');
24
25 window.labellersPromise = loadLabellers();
26 labellersPromise.then(list => {
27 window.labellers = list;
28 });
29
30 let form = document.getElementById('search');
31
32 form.addEventListener('submit', submitSearch);
33 form.query.addEventListener('focus', function() {
34 setTimeout(() => { this.select() }, 10);
35 });
36
37 form.query.focus();
38}
39
40async function loadLabellers() {
41 let json = await blue.getRequest('blue.feeds.mod.getLabellers');
42 return json.labellers;
43}
44
45function submitSearch(event) {
46 event.preventDefault();
47 let query = this.query.value;
48
49 if (query.trim().length == 0) {
50 return;
51 }
52
53 let doScan;
54
55 if (query.includes('://')) {
56 doScan = scanURL(query);
57 } else if (query.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) {
58 query = query.replace(/^@/, '');
59 doScan = scanHandle(query);
60 } else if (query.match(/^did\:\w+\:/)) {
61 doScan = scanAccount(query);
62 } else {
63 resultField.innerText = '🤨 Enter a user handle or a post URL.';
64 foundLabels.innerHTML = '';
65 return;
66 }
67
68 this.query.blur();
69 this.search.disabled = true;
70 resultField.innerHTML = 'Scanning labels... <i class="loader fa-solid fa-spinner fa-spin fa-sm"></i>';
71 noteField.style.display = 'none';
72 foundLabels.innerHTML = '';
73
74 labellersPromise.then(() => {
75 doScan
76 .then((data) => {
77 showLabels(data.labels);
78
79 if (data.note) {
80 noteField.innerText = data.note;
81 noteField.style.display = 'block';
82 }
83 })
84 .catch((error) => {
85 displayError(error);
86 })
87 .finally(() => {
88 this.search.disabled = false;
89 });
90 });
91}
92
93function displayError(error) {
94 if (error instanceof APIError) {
95 if (error.code == 400) {
96 if (error.json.error == 'AccountTakedown') {
97 resultField.innerText = '🚫 Account was taken down';
98 return;
99 } else if (error.json.error == 'InvalidRequest') {
100 if (error.json.message == 'Profile not found') {
101 resultField.innerText = '🚫 Account not found';
102 return;
103 } else if (error.json.message == 'Unable to resolve handle') {
104 resultField.innerText = '👾 Unable to resolve handle';
105 return;
106 }
107 } else if (error.json.error == 'AccountDeactivated' || error.json.error == 'RepoDeactivated') {
108 resultField.innerText = '😶🌫️ Account is deactivated';
109 return;
110 } else if (error.json.error == 'RecordNotFound') {
111 resultField.innerText = '🚫 Post not found';
112 return;
113 } else if (error.json.error == 'RepoNotFound') {
114 resultField.innerText = '🚫 Account was deleted';
115 return;
116 }
117 }
118
119 resultField.innerText = error;
120 return;
121 } else if (error instanceof URLError) {
122 resultField.innerText = `⚠️ ${error.message}`;
123 return;
124 } else if (error instanceof PostTakenDownError) {
125 resultField.innerText = `🚫 Post was taken down on the AppView`;
126 return;
127 } else if (error instanceof AccountError) {
128 resultField.innerText = '🚫 Account not found';
129 return;
130 }
131
132 resultField.innerText = `${error.constructor.name}: ${error.message}`;
133}
134
135async function scanHandle(handle) {
136 let json = await appView.getRequest('com.atproto.identity.resolveHandle', { handle });
137 let userDID = json.did;
138
139 return await scanAccount(userDID);
140}
141
142async function scanAccount(userDID) {
143 let batches = [];
144
145 for (let i = 0; i < labellers.length; i += batchSize) {
146 let slice = labellers.slice(i, i + batchSize);
147 batches.push(checkProfileWithLabellers(userDID, slice));
148 }
149
150 let results = await Promise.all(batches);
151 let labels = results.flatMap(x => x.labels).filter(x => (x.src != userDID));
152
153 return { labels };
154}
155
156async function scanURL(string) {
157 let atURI, note, userDID, rkey;
158
159 let match = string.match(/^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.post\/([\w]+)$/);
160
161 if (match) {
162 atURI = string;
163 userDID = match[1];
164 rkey = match[2];
165 } else {
166 let url = new URL(string);
167
168 if (url.protocol != 'https:') {
169 throw new URLError('URL must start with https://');
170 }
171
172 if (!acceptedHostnames.includes(url.host)) {
173 note = "Note: URL domain not recognized. Returned labels might be incorrect."
174 }
175
176 window.webClientHost = url.host;
177
178 let match = url.pathname.match(/^\/profile\/([^/]+)\/?$/);
179
180 if (match && match[1].startsWith('did:')) {
181 return { note, ... await scanAccount(match[1]) };
182 } else if (match) {
183 return { note, ... await scanHandle(match[1]) };
184 } else {
185 let match = url.pathname.match(/^\/profile\/([^/]+)\/post\/([\w]+)\/?$/);
186
187 if (match && match[1].startsWith('did:')) {
188 userDID = match[1];
189 rkey = match[2];
190 } else if (match) {
191 let json = await appView.getRequest('com.atproto.identity.resolveHandle', { handle: match[1] });
192 userDID = json.did;
193 rkey = match[2];
194 } else {
195 throw new URLError('Unknown URL');
196 }
197
198 atURI = `at://${userDID}/app.bsky.feed.post/${rkey}`;
199 }
200 }
201
202 let batches = [];
203
204 for (let i = 0; i < labellers.length; i += batchSize) {
205 let slice = labellers.slice(i, i + batchSize);
206 batches.push(checkAtURIWithLabellers(atURI, slice));
207 }
208
209 let results = await Promise.all(batches);
210
211 if (results.every(x => !x)) {
212 // post not found, look it up on the origin PDS
213
214 let post = await loadPostFromPDS(userDID, rkey);
215
216 // post found, so it was taken down on the AppView
217
218 throw new PostTakenDownError();
219 }
220
221 let labels = results.flatMap(x => x.labels).filter(x => (x.src != userDID));
222 return { labels, note };
223}
224
225async function loadPostFromPDS(did, rkey) {
226 let didDocument = await fetchDidDocument(did);
227
228 if (didDocument.message?.startsWith("DID not registered:")) {
229 throw new AccountError("Account not found");
230 }
231
232 let pds = didDocument.service?.find(x => x.id == "#atproto_pds")?.serviceEndpoint;
233
234 if (!pds) {
235 throw new AccountError("Invalid DID document");
236 }
237
238 let pdsSky = new Minisky(pds);
239 let repo = await pdsSky.getRequest('com.atproto.repo.describeRepo', { repo: did });
240
241 return await pdsSky.getRequest('com.atproto.repo.getRecord', {
242 repo: did,
243 collection: 'app.bsky.feed.post',
244 rkey: rkey
245 });
246}
247
248async function fetchDidDocument(did) {
249 if (did.startsWith('did:plc:')) {
250 let response = await fetch(`https://plc.directory/${did}`);
251 return await response.json();
252 } else if (did.startsWith('did:web:')) {
253 let hostname = did.split(':')[2];
254 let response = await fetch(`https://${hostname}/.well-known/did.json`);
255 return await response.json();
256 } else {
257 throw new AccountError("DID not found");
258 }
259}
260
261async function checkProfileWithLabellers(handle, batch) {
262 let labellersList = batch.map(x => x.did).join(',');
263 let headers = { 'atproto-accept-labelers': labellersList };
264
265 return appView.getRequest('app.bsky.actor.getProfile', { actor: handle }, { headers });
266}
267
268async function checkAtURIWithLabellers(uri, batch) {
269 let labellersList = batch.map(x => x.did).join(',');
270 let headers = { 'atproto-accept-labelers': labellersList };
271
272 let result = await appView.getRequest('app.bsky.feed.getPosts', { uris: uri }, { headers });
273 return result.posts[0];
274}
275
276function showLabels(labels) {
277 if (labels.length == 0) {
278 resultField.innerText = '✅ No labels found';
279 return;
280 }
281
282 if (labels.length == 1) {
283 resultField.innerHTML = `<i class="tags fa-solid fa-tag"></i> 1 label found:`;
284 } else {
285 resultField.innerHTML = `<i class="tags fa-solid fa-tags"></i> ${labels.length} labels found:`;
286 }
287
288 let host = window.webClientHost ?? 'bsky.app';
289
290 for (let label of labels) {
291 let labeller = labellers.find(x => (x.did == label.src));
292
293 let p = document.createElement('p');
294 p.innerText = `“${label.val}” from `;
295
296 let a = document.createElement('a');
297 a.innerText = labeller.name || labeller.handle;
298 a.href = `https://${host}/profile/${labeller.handle}`;
299 p.append(a);
300
301 foundLabels.appendChild(p);
302 }
303}