├── logo48.png ├── logo128.png ├── logo48_gray.png ├── .gitignore ├── logo128_gray.png ├── .prettierrc ├── .github ├── dependabot.yml └── workflows │ └── lint.yml ├── .eslintrc.json ├── package.json ├── privacy_consent.js ├── manifest.json ├── manifest-firefox.json ├── LICENSE ├── README.md ├── CHANGELOG.md ├── privacy_consent.html └── background.js /logo48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessejanderson/skylink/HEAD/logo48.png -------------------------------------------------------------------------------- /logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessejanderson/skylink/HEAD/logo128.png -------------------------------------------------------------------------------- /logo48_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessejanderson/skylink/HEAD/logo48_gray.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | extension_packages/* 3 | extension_packages 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /logo128_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessejanderson/skylink/HEAD/logo128_gray.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 3 | "env": { 4 | "browser": true, 5 | "webextensions": true, 6 | "es6": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 2020, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | // Add any additional rules here 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "eslint": "^8.48.0", 4 | "eslint-config-prettier": "^9.0.0", 5 | "eslint-plugin-prettier": "^5.4.0", 6 | "prettier": "^3.0.3" 7 | }, 8 | "scripts": { 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "format": "prettier --write .", 12 | "prettier:check": "prettier --check ." 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /privacy_consent.js: -------------------------------------------------------------------------------- 1 | // Set up cross-browser compatibility 2 | const storage = 3 | typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local 4 | const management = 5 | typeof browser !== "undefined" ? browser.management : chrome.management 6 | 7 | document.getElementById("accept").addEventListener("click", function () { 8 | storage.set({ privacyConsentAccepted: true }) 9 | window.close() 10 | }) 11 | 12 | document.getElementById("decline").addEventListener("click", function () { 13 | management.uninstallSelf({ showConfirmDialog: true }) 14 | }) 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "SkyLink - Bluesky DID Detector", 4 | "short_name": "SkyLink", 5 | "version": "1.4.3", 6 | "author": "jesse@adhdjesse.com", 7 | "action": { 8 | "default_icon": { "48": "logo48_gray.png", "128": "logo128_gray.png" } 9 | }, 10 | "icons": { "48": "logo48.png", "128": "logo128.png" }, 11 | "description": "Detects Bluesky DIDs in domain TXT records and .well-known/atproto-did files, linking to the associated profile.", 12 | "permissions": ["tabs", "storage"], 13 | "host_permissions": ["https://*/*"], 14 | "background": { "service_worker": "background.js" } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out Git repository 11 | uses: actions/checkout@v2 12 | 13 | - name: Use Node.js 16.x 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 16.x 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 14 22 | 23 | - name: Install dependencies 24 | run: | 25 | npm ci || (echo "Debug log:" && cat /home/runner/.npm/_logs/*.log && exit 1) 26 | 27 | - name: Run ESLint 28 | run: npm run lint 29 | 30 | - name: Run Prettier 31 | run: npm run prettier:check 32 | 33 | - name: Run Build 34 | run: sh build_extension.sh --all 35 | -------------------------------------------------------------------------------- /manifest-firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "SkyLink - Bluesky DID Detector", 4 | "short_name": "SkyLink", 5 | "version": "1.4.3", 6 | "author": "jesse@adhdjesse.com", 7 | "action": { 8 | "default_icon": { 9 | "48": "logo48_gray.png", 10 | "128": "logo128_gray.png" 11 | } 12 | }, 13 | "icons": { 14 | "48": "logo48.png", 15 | "128": "logo128.png" 16 | }, 17 | "description": "Detects Bluesky DIDs in domain TXT records and .well-known/atproto-did files, linking to the associated profile.", 18 | "permissions": ["tabs", "storage"], 19 | "host_permissions": ["https://*/*"], 20 | "content_security_policy": { 21 | "extension_pages": "script-src 'self'; object-src 'self'" 22 | }, 23 | "background": { 24 | "scripts": ["background.js"], 25 | "persistent": false 26 | }, 27 | "browser_specific_settings": { 28 | "gecko": { 29 | "id": "jesse@adhdjesse.com", 30 | "strict_min_version": "109.0" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jesse J. Anderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SkyLink - Bluesky DID Detector 2 | 3 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/aflpfginfpjhanhkmdpohpggpolfopmb)](https://chrome.google.com/webstore/detail/skylink-bluesky-did-detector/aflpfginfpjhanhkmdpohpggpolfopmb) 4 | [![Firefox Add-ons](https://img.shields.io/amo/v/skylink-bluesky-did-detector)](https://addons.mozilla.org/en-US/firefox/addon/skylink-bluesky-did-detector) 5 | 6 | A simple web extension that detects if the current website is connected to a Bluesky user. 7 | 8 | Remember the good 'ol days of visiting someone's blog and being delighted when the "RSS" lit up in your browser? This is meant to capture that same magic. No more hunting on a page for a random bird icon to see if you can find their online profile. 9 | 10 | ![chrome_skyline_preview](https://user-images.githubusercontent.com/8367129/235382697-aedfda18-aab3-477b-b59c-c12cdd33bf9b.png) 11 | 12 | ## How it Works 13 | 14 | SkyLink detects Decentralized Identifiers (DIDs) by checking both: 15 | 16 | 1. **DNS TXT Records**: Looking for a DID in the `_atproto` subdomain's TXT records 17 | 2. **HTTPS Well-Known**: Checking via the [alternative HTTPS method](https://psky.app/profile/emily.bsky.team/post/3juuaipn3q424) at `/.well-known/atproto-did` 18 | 19 | When a profile is detected, the extension icon lights up blue. Click it to visit their Bluesky profile! 20 | 21 | --- 22 | 23 | Bluesky is now open to everyone! The web app is at https://bsky.app. 24 | 25 | You can find me there at [@adhdjesse.com](https://bsky.app/profile/adhdjesse.com) 26 | 27 | **Contributors:** 28 | 29 | - [@danielhuckmann.com](https://bsky.app/profile/danielhuckmann.com) - Firefox Support, Privacy & Security Enhancements 30 | - [@aliceisjustplaying](https://bsky.app/profile/alice.bsky.sh) - HTTPS Method of DID Detection 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [Unreleased] 11 | 12 | ## [1.4.3] - 2025-05-28 13 | 14 | ### Fixed 15 | 16 | - Fixed `.well-known/atproto-did` detection by adding required `host_permissions` to manifest files 17 | - Improved error handling and content-type validation for HTTPS DID fetching 18 | - Added whitespace trimming for DID values from `.well-known` files 19 | 20 | ### Changed 21 | 22 | - Updated manifest descriptions to explicitly mention both DID detection methods 23 | - Updated README to reflect current Bluesky status (now open to everyone) 24 | 25 | ## [1.4.2] 26 | 27 | - Updated the HTTPS method for detecting DID to use the new path (`/.well-known/atproto-did`) and format (served as `text/plain` and just the DID itself) 28 | 29 | ## [1.4.1] 30 | 31 | - Small fix for a bug introduced in `1.4.0` that occurs when the browser puts idle extensions to sleep 32 | 33 | ## [1.4.0] 34 | 35 | ### Changed 36 | 37 | - Migrate/refactor everything from `content.js` into a self-contained `background.js` 38 | - Swap `activeTab` permission for `tabs` so that we can drop the `` permission 39 | - Remove permissions for `management` as it is not needed 40 | - Migrate Firefox to Manifest v3 (but this means the minimum FF version is now 109) 41 | - Input validation for domains and DIDs (security enhancement) 42 | - Replace `staging.bsky.app` with `bsky.app` 43 | 44 | ## [1.3.0] 45 | 46 | ### Added 47 | 48 | - Support for alternative HTTPS method for detecting DID 49 | 50 | ### Changed 51 | 52 | - Remove permissions for "tabs" as it is not needed 53 | 54 | ## [1.2.0] - 2023-05-03 55 | 56 | ### Added 57 | 58 | - Support for Firefox 59 | - Privacy consent dialog for Google DNS (required by Mozilla) 60 | - Eslint and prettier config 61 | 62 | ## [1.1.0] - 2023-05-03 63 | 64 | ### Changed 65 | 66 | - Use DID for profile url instead of domain name 67 | 68 | ## [1.0.1] - 2023-04-30 69 | 70 | ### Fixed 71 | 72 | - Remove www when checking for TXT record 73 | 74 | ## [1.0.0] - 2023-04-30 75 | 76 | ### Added 77 | 78 | - Check for DID in current domain's TXT records 79 | - Light up icon blue if DID detected 80 | - Clicking icon opens tab to profile 81 | -------------------------------------------------------------------------------- /privacy_consent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Skylink Privacy Policy 7 | 8 | 118 | 119 | 120 |
121 |
122 | SkyLink Logo 123 |

SkyLink Privacy Policy

124 |
125 |

126 | While no information is collected by the extension author, this 127 | extension makes use of the 128 | Google DNS service 131 | for required functionality. The Google DNS service is used because 132 | browser extensions do not have native access to DNS, which is needed to 133 | lookup the AT Protocol TXT record. 134 |

135 |

A summary of what is collected by Google DNS:

136 | 162 |

163 | Please read and accept the full 164 | Google DNS privacy policy 169 | before using this extension. 170 |

171 | 172 | 175 | 176 |
177 | 178 | 179 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // Set up cross-browser compatibility 2 | const runtime = 3 | typeof browser !== "undefined" ? browser.runtime : chrome.runtime 4 | const tabs = typeof browser !== "undefined" ? browser.tabs : chrome.tabs 5 | const storage = 6 | typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local 7 | const action = typeof browser !== "undefined" ? browser.action : chrome.action 8 | 9 | // Make sure that we don't DoS the regex if someone supplies too large of a DID 10 | const MAX_DID_LENGTH = 255 11 | 12 | // Regular expression to validate the DID format 13 | // https://w3c.github.io/did-core/#did-syntax 14 | const didRegex = 15 | /^did:plc:([a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)*|((%[0-9A-Fa-f]{2})|[a-zA-Z0-9._-])+(:((%[0-9A-Fa-f]{2})|[a-zA-Z0-9._-])+)*$)/ 16 | 17 | // Function to validate the DID string 18 | function isValidDID(didString) { 19 | return didString.length <= MAX_DID_LENGTH && didRegex.test(didString) 20 | } 21 | 22 | // Function to get the domain name from the current hostname 23 | function getDomainName(url) { 24 | const hostname = new URL(url).hostname 25 | return hostname.replace(/^www\./, "") 26 | } 27 | 28 | // Function to validate the domain name 29 | function isValidDomain(domain) { 30 | const MAX_DOMAIN_LENGTH = 255 31 | 32 | if (domain.length > MAX_DOMAIN_LENGTH) { 33 | return false 34 | } 35 | 36 | try { 37 | // Use the build in URL constructor to validate the URL, if doesn't throw an error, the domain is valid 38 | // This is a better choice than a regex since it should properly support punycode/international domains 39 | new URL(`https://${domain}`) 40 | return true 41 | } catch (error) { 42 | // The URL constructor threw an error, so the domain is not valid 43 | return false 44 | } 45 | } 46 | 47 | // Function to check for a DID in the domain's TXT records 48 | async function checkForDIDDNS(domain) { 49 | try { 50 | const response = await fetch( 51 | `https://dns.google/resolve?name=_atproto.${domain}&type=TXT` 52 | ) 53 | const data = await response.json() 54 | 55 | // We use the TXT record type to avoid CORS issues 56 | const records = data?.Answer?.filter((record) => record.type === 16) || [] 57 | 58 | // We filter out all records that are not TXT records 59 | const didRecord = records.find((record) => 60 | record.data.includes("did=did:plc:") 61 | ) 62 | 63 | // We return the DID if we found one and it's valid 64 | return didRecord && isValidDID(didRecord.data.replace("did=", "")) 65 | ? didRecord.data.replace("did=", "") 66 | : null 67 | } catch (error) { 68 | return null 69 | } 70 | } 71 | 72 | // Function to check for a DID in the well-known location 73 | async function checkForDIDHTTPS(domain) { 74 | try { 75 | const response = await fetch(`https://${domain}/.well-known/atproto-did`) 76 | 77 | // Check if the response is successful 78 | if (!response.ok) { 79 | return null 80 | } 81 | 82 | // The content type check is optional since some servers might not set it correctly 83 | const contentType = response.headers.get("Content-Type") 84 | if (contentType && !contentType.includes("text/plain")) { 85 | console.warn( 86 | `Warning: .well-known/atproto-did has unexpected Content-Type: ${contentType}` 87 | ) 88 | } 89 | 90 | const data = await response.text() 91 | // Trim whitespace that might be present 92 | const trimmedData = data?.trim() 93 | return trimmedData && isValidDID(trimmedData) ? trimmedData : null 94 | } catch (error) { 95 | // Log error for debugging but don't expose it to the user 96 | console.debug( 97 | `Failed to fetch .well-known/atproto-did for ${domain}:`, 98 | error 99 | ) 100 | return null 101 | } 102 | } 103 | 104 | // Map to store tabs with DIDs 105 | const tabsWithDID = new Map() 106 | 107 | // URL of the Bluesky Web Applications 108 | const bskyAppUrl = "https://bsky.app" 109 | 110 | // Function to set the extension icon 111 | function setIcon(tabId, iconName) { 112 | action.setIcon({ path: iconName, tabId }) 113 | } 114 | 115 | // Cache for storing domain DIDs 116 | // We use caching to prevent creating multiple requests 117 | // for a tab/domain that has already returned a check 118 | // The cache is cleared when the tab is closed 119 | const didCache = new Map() 120 | 121 | async function performAction(tab) { 122 | return new Promise((resolve, reject) => { 123 | storage.get( 124 | "privacyConsentAccepted", 125 | async ({ privacyConsentAccepted }) => { 126 | if (privacyConsentAccepted) { 127 | const domain = getDomainName(tab.url) 128 | if (isValidDomain(domain)) { 129 | // Check if we have cached DID for this tab and domain 130 | const cachedDID = didCache.get(`${tab.id}:${domain}`) 131 | if (cachedDID !== undefined) { 132 | // If we have a cached DID or a cached "not found" state, use it 133 | if (cachedDID !== null) { 134 | setDID(tab, cachedDID) 135 | } else { 136 | setIcon(tab.id, "logo48_gray.png") 137 | tabsWithDID.delete(tab.id) 138 | } 139 | resolve() 140 | } else { 141 | // If not, proceed with the checks 142 | const domainDID = await checkForDIDDNS(domain) 143 | if (domainDID) { 144 | setDID(tab, domainDID) 145 | didCache.set(`${tab.id}:${domain}`, domainDID) 146 | resolve() 147 | } else { 148 | const httpsDID = await checkForDIDHTTPS(domain) 149 | if (httpsDID) { 150 | setDID(tab, httpsDID) 151 | didCache.set(`${tab.id}:${domain}`, httpsDID) 152 | } else { 153 | setIcon(tab.id, "logo48_gray.png") 154 | tabsWithDID.delete(tab.id) 155 | // Cache the "not found" state 156 | didCache.set(`${tab.id}:${domain}`, null) 157 | } 158 | resolve() 159 | } 160 | } 161 | } else { 162 | reject(new Error("Invalid domain")) 163 | } 164 | } else { 165 | reject(new Error("Privacy consent not accepted")) 166 | } 167 | } 168 | ) 169 | }) 170 | } 171 | 172 | // Function to set the DID 173 | function setDID(tab, did) { 174 | setIcon(tab.id, "logo48.png") 175 | tabsWithDID.set(tab.id, did) 176 | } 177 | 178 | // Execute performAction when a tab is updated and the tab is a website. 179 | tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 180 | if ( 181 | changeInfo.status === "loading" && 182 | tab.active && 183 | (tab.url.startsWith("http://") || tab.url.startsWith("https://")) 184 | ) { 185 | // Get the old domain from the cache 186 | const oldDomain = Array.from(didCache.keys()) 187 | .filter((key) => key.startsWith(`${tabId}:`)) 188 | .map((key) => key.split(":")[1])[0] 189 | 190 | // Get the new domain 191 | const newDomain = getDomainName(tab.url) 192 | 193 | // If the domain has changed, clear the DID state for this tab 194 | if (newDomain !== oldDomain) { 195 | didCache.delete(`${tabId}:${oldDomain}`) 196 | } 197 | // Perform the action 198 | performAction(tab) 199 | } 200 | }) 201 | 202 | // On extension installation, check if privacy consent was already accepted and show it if not 203 | runtime.onInstalled.addListener(() => { 204 | storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 205 | if ( 206 | typeof privacyConsentAccepted === "undefined" || 207 | !privacyConsentAccepted 208 | ) { 209 | tabs.create({ url: "privacy_consent.html" }) 210 | } 211 | }) 212 | }) 213 | 214 | // On extension installation, set the icon to gray for all tabs 215 | runtime.onInstalled.addListener(() => { 216 | tabs.query({}, (tabs) => { 217 | tabs.forEach((tab) => setIcon(tab.id, "logo48_gray.png")) 218 | }) 219 | }) 220 | 221 | // When the extension icon is clicked 222 | action.onClicked.addListener((tab) => { 223 | // Get privacyConsentAccepted from storage 224 | storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 225 | // If privacyConsentAccepted is undefined or false, open the consent page 226 | if ( 227 | typeof privacyConsentAccepted === "undefined" || 228 | !privacyConsentAccepted 229 | ) { 230 | tabs.create({ url: "privacy_consent.html" }) 231 | } else { 232 | // If there is a DID for this tab, open the profile page 233 | const did = tabsWithDID.get(tab.id) 234 | if (did) { 235 | const newUrl = `${bskyAppUrl}/profile/${did}` 236 | tabs.create({ url: newUrl }) 237 | } else { 238 | // If there is no DID for this tab in cache, run performAction 239 | const domain = getDomainName(tab.url) 240 | if (isValidDomain(domain)) { 241 | performAction(tab).then(() => { 242 | // If performAction returned a DID, open the profile page 243 | const didAfterPerformingAction = tabsWithDID.get(tab.id) 244 | if (didAfterPerformingAction) { 245 | const newUrl = `${bskyAppUrl}/profile/${didAfterPerformingAction}` 246 | tabs.create({ url: newUrl }) 247 | } 248 | }) 249 | } 250 | } 251 | } 252 | }) 253 | }) 254 | --------------------------------------------------------------------------------