A small web tool for showing what Bluesky moderation labels are assigned to a profile
at master 8.6 kB view raw
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}