Client-side, single user live journal backed by AT Protocol
0

Configure Feed

Select the types of activity you want to include in your feed.

preparing for liftoff โœจ๐Ÿš€

initial release

Signed-off-by: Amelia <amy+git@amogus.cloud>

author
Amelia
date (May 31, 2026, 6:19 PM -0400) commit f813102f
+611
+5
.gitignore
··· 1 + oauth-client-metadata.example.json 2 + oauth_reference.js 3 + stndrdsite_renderer.js 4 + .idea 5 + package.json
+7
LICENSE
··· 1 + Copyright 2026 Amelia Pรฉrez 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the โ€œSoftwareโ€), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 + 5 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 + 7 + THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+50
README.md
··· 1 + # bunny log ๐Ÿ‡๐Ÿ“œ 2 + 3 + ## what is it 4 + a micro blogging site that uses atproto to store shit 5 + 6 + ## can i use it 7 + yes, but 8 + 9 + ## but what 10 + but not from my website 11 + 12 + you will have to fork the project, edit `app.js`, and host it by yourself 13 + 14 + ## why 15 + bc the app is intended for my use only 16 + 17 + you can use it, sure, whatever, literally edit the `app.js` to allow your account's DID 18 + 19 + but 20 + 21 + don't go complaining to me about your logs not appearing on my site 22 + 23 + ## ok but like why did you make this 24 + two things 25 + 26 + i was bored 27 + 28 + and i am sick of mainstream microblogging sites 29 + 30 + hence 31 + 32 + bunny log โœจ 33 + 34 + ## alright, ok 35 + are we on the same page? 36 + 37 + ok 38 + 39 + lexicons (if you want to make your own bunny log) are published and are also available on `./lexicons/space/bunniesin/micro/log.json` 40 + 41 + 42 + ## licence 43 + 44 + mit, do whatever the fuck you want 45 + 46 + but please credit me 47 + 48 + or don't 49 + 50 + k thx
+147
app.js
··· 1 + import { defineNavigationHook } from "#app/router" 2 + 3 + const DEFAULT_PREVIEW_HANDLE = "bunniesin.space"; 4 + const DEFAULT_PREVIEW_DID = "did:plc:gijpvbkdbr56kazbdjhfvb3d"; 5 + const DEFAULT_PREVIEW_DID_PDS = "https://eurosky.social" 6 + export const ALLOWED_DIDS = ["did:plc:gijpvbkdbr56kazbdjhfvb3d"]; 7 + const INSTANCE_OPERATOR_HANDLE = "bunniesin.space"; 8 + const INSTANCE_OPERATOR_CONTACTS = [{ 9 + type: "stoat", 10 + value: "amybunnygirl#0122" 11 + }, 12 + { 13 + type: "irc", 14 + value: "amybunny" 15 + }, 16 + { 17 + type: "bluesky", 18 + value: "@bunniesin.space" 19 + } 20 + ]; 21 + let preview_cursor; 22 + 23 + const PLACEHOLDER_MAP = { 24 + "unauth-op-contact-list": INSTANCE_OPERATOR_CONTACTS.map(({type, value}) => `<li>${type}: ${value}</li>`).join("\n"), 25 + "unauth-op-contacts-msg": INSTANCE_OPERATOR_HANDLE, 26 + "preview-latest-handle": DEFAULT_PREVIEW_HANDLE, 27 + } 28 + 29 + const ROOT = document.getElementById('root'); 30 + const POST_LIST = document.getElementById('postlist-wrapper'); 31 + const DATE_FORMATTER = new Intl.DateTimeFormat("en-GB", { 32 + timeStyle: "long", 33 + dateStyle: "short" 34 + }) 35 + 36 + function replacePlaceholders() { 37 + ROOT.querySelectorAll('.with-holder').forEach(el => { 38 + const pKeys = el.innerHTML.match(/{([a-z-]+)}/g).map(k => k.substring(1, k.length - 1)) 39 + let replaced = el.innerHTML; 40 + 41 + for (const key of pKeys) { 42 + replaced = replaced.replace(`{${key}}`, PLACEHOLDER_MAP[key]) 43 + } 44 + 45 + el.innerHTML = replaced; 46 + }) 47 + } 48 + 49 + function toggleLoading() { 50 + const loader = document.getElementById('loader'); 51 + 52 + loader.classList.toggle('hidden'); 53 + } 54 + 55 + 56 + export function constructApiUrl(endpoint_nsid, options, api = DEFAULT_PREVIEW_DID_PDS) { 57 + const url = new URL(`${api}/xrpc/${endpoint_nsid}`); 58 + for (const [key, value] of Object.entries(options)) { 59 + if (!value) continue; 60 + url.searchParams.set(key, value) 61 + } 62 + 63 + return url.toString() 64 + } 65 + 66 + async function parseResponseBody(response) { 67 + return await response.json() 68 + } 69 + 70 + async function fetchPostsFromPreviewDID(previous_cursor) { 71 + const res = await fetch(constructApiUrl("com.atproto.repo.listRecords", { 72 + repo: DEFAULT_PREVIEW_DID, 73 + collection: "space.bunniesin.micro.log", 74 + cursor: previous_cursor, 75 + })) 76 + 77 + if (!res.ok) { 78 + console.error("[APP]", "failed to fetch latest logs:", res.statusText) 79 + return; 80 + } 81 + 82 + const {cursor, records} = await parseResponseBody(res) 83 + preview_cursor = cursor; 84 + 85 + return records.map(record => record.value); 86 + } 87 + 88 + function displayLog(record) { 89 + const logElement = document.createElement('div'); 90 + logElement.classList.add('log'); 91 + const rendered_log = record.content.split("\n\n").map(line => `<p>${line}</p>`).join("\n") 92 + logElement.innerHTML = `${rendered_log}\n<time datetime=${record.createdAt}>${DATE_FORMATTER.format(new Date(record.createdAt))}</time>`; 93 + 94 + return logElement 95 + } 96 + 97 + 98 + export async function fetchAndDisplayLatestLogs(cursor) { 99 + toggleLoading() 100 + POST_LIST.innerHTML = ""; 101 + 102 + try { 103 + const logs = await fetchPostsFromPreviewDID(cursor); 104 + 105 + for (const log of logs) { 106 + POST_LIST.appendChild(displayLog(log)); 107 + } 108 + } catch (err) { 109 + displayError("fetchPreview", err); 110 + POST_LIST.innerHTML = "<code>:(</code>"; 111 + } finally { 112 + toggleLoading() 113 + } 114 + 115 + } 116 + 117 + document.addEventListener("DOMContentLoaded", () => { 118 + replacePlaceholders(); 119 + fetchAndDisplayLatestLogs() 120 + }) 121 + 122 + defineNavigationHook("log-preview", () => { 123 + fetchAndDisplayLatestLogs() 124 + }) 125 + 126 + export function displayError(context, message) { 127 + let errorKind; 128 + switch (context) { 129 + case "login": 130 + errorKind = "Login error:"; 131 + break; 132 + case "oauth": 133 + errorKind = "OAuth Error:"; 134 + break; 135 + case "create": 136 + errorKind = "Error while creating micro.json:"; 137 + break; 138 + case "fetchPreview": 139 + errorKind = "Error while fetching latest logs:"; 140 + break; 141 + default: 142 + errorKind = "Unknown error:"; 143 + break; 144 + } 145 + 146 + alert(errorKind + " " + message) 147 + }
+106
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>bunny log</title> 6 + <meta name="description" content="atmospheric micro blog detached from the sky"> 7 + <meta name="viewport" content="width=device-width, initial-scale=1"> 8 + <meta name="referrer" content="origin-when-cross-origin"> 9 + 10 + <!-- Import map for debugging purposes --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "@atproto/oauth-client-browser": "https://esm.sh/@atproto/oauth-client-browser@0.3.29", 15 + "@atproto/api": "https://esm.sh/@atproto/api@0.16.4", 16 + "#app": "./app.js", 17 + "#app/router": "./router.js" 18 + } 19 + } 20 + </script> 21 + <script type="module" defer src="router.js"></script> 22 + <script type="module" defer src="oauth.js"></script> 23 + <script type="module" defer src="app.js"></script> 24 + </head> 25 + <body> 26 + <main id="root" data-currentpage="log-preview" data-state="unauthorized"> 27 + <header> 28 + <hgroup> 29 + <h1>bunny log</h1> 30 + <p>an atmospheric micro blog detached from the sky</p> 31 + </hgroup> 32 + <nav> 33 + <ul> 34 + <li><a class="router-link" href="#" data-action="navigate" data-argument="log-preview">logs</a></li> 35 + <li class="without-session"> 36 + <a class="router-link" href="#" data-action="navigate" data-argument="login">log in</a> 37 + </li> 38 + <li class="with-session"> 39 + <a class="router-link" href="#" data-action="broadcast" data-argument="destroy">log out</a> 40 + </li> 41 + <li class="with-session"> 42 + <a class="router-link" href="#" data-action="navigate" data-argument="log-new">post</a> 43 + </li> 44 + </ul> 45 + </nav> 46 + </header> 47 + <section class="without-session page" id="login"> 48 + <h2>log in</h2> 49 + <form id="login-form"> 50 + <input class="with-typeahead" type="text" name="handle" placeholder="bunniesin.space"> 51 + <button type="submit">log in</button> 52 + </form> 53 + </section> 54 + <section class="with-session page" id="log-new"> 55 + <h2>new log</h2> 56 + <form id="log-form"> 57 + <textarea id="log-content"></textarea> 58 + <button type="submit">subject netizens to your bullshit</button> 59 + </form> 60 + </section> 61 + <section class="page" id="log-preview"> 62 + <h2 class="with-holder">latest from {preview-latest-handle}</h2> 63 + <span class="hidden" id="loader">loading...</span> 64 + <div class="v-list" id="postlist-wrapper"></div> 65 + </section> 66 + <section class="page" id="log-unauthorized"> 67 + <h2>you are not authorized to use this instance of bunny log</h2> 68 + <p class="with-holder">Please contact {unauth-op-contacts-msg} on any of the following platforms:</p> 69 + <ul class="with-holder">{unauth-op-contact-list}</ul> 70 + </section> 71 + <footer> 72 + <div class="h-center"> 73 + <span class="unimportant">adapted from <a href="https://github.com/bluesky-social/cookbook/tree/main/vanillajs-oauth-web-app">vanillajs-oauth-web-app</a> ยท <a>code</a></span> 74 + </div> 75 + </footer> 76 + </main> 77 + </body> 78 + 79 + <style> 80 + main[data-currentpage="log-preview"] section.page:not([id="log-preview"]) { 81 + display: none; 82 + } 83 + 84 + main[data-currentpage="log-new"] section.page:not([id="log-new"]) { 85 + display: none; 86 + } 87 + 88 + main[data-currentpage="login"] section.page:not([id="login"]) { 89 + display: none; 90 + } 91 + 92 + main[data-currentpage="log-unauthorized"] section.page:not([id="log-unauthorized"]) { 93 + display: none; 94 + } 95 + 96 + main[data-state="authorized"] { 97 + & .without-session { display: none; } 98 + } 99 + 100 + main[data-state="unauthorized"] { 101 + & .with-session { display: none; } 102 + } 103 + 104 + .hidden { display: none; } 105 + </style> 106 + </html>
+24
lexicons/space/bunniesin/micro/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.bunniesin.micro.log", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "description": "Simple microblogging lexicon", 9 + "record": { 10 + "type": "object", 11 + "required": ["content", "createdAt"], 12 + "properties": { 13 + "content": { 14 + "type": "string" 15 + }, 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+13
oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://log.bunniesin.space/oauth-client-metadata.json", 3 + "dpop_bound_access_tokens": true, 4 + "application_type": "web", 5 + "redirect_uris": ["https://log.bunniesin.space"], 6 + "grant_types": ["authorization_code", "refresh_token"], 7 + "response_types": ["code"], 8 + "scope": "atproto repo:space.bunniesin.micro.log?action=create", 9 + "token_endpoint_auth_method": "private_key_jwt", 10 + "token_endpoint_auth_signing_alg": "ES256", 11 + "client_name": "bunny log @ log.bunniesin.space", 12 + "client_uri": "https://log.bunniesin.space" 13 + }
+169
oauth.js
··· 1 + import { BrowserOAuthClient } from '@atproto/oauth-client-browser' 2 + import { Agent } from '@atproto/api' 3 + import { displayError, constructApiUrl, ALLOWED_DIDS } from "#app" 4 + import { navigate } from "#app/router" 5 + 6 + const OAUTH_SCOPES = "atproto repo:space.bunniesin.micro.log?action=create"; 7 + const ROOT = document.querySelector("main[data-currentpage]"); 8 + 9 + function clientID() { 10 + const isLocal = ["localhost", "127.0.0.1"].includes(window.location.hostname); 11 + if (isLocal) { 12 + // see https://atproto.com/specs/oauth#localhost-client-development 13 + return `http://localhost?${new URLSearchParams({ 14 + scope: OAUTH_SCOPES, 15 + redirect_uri: Object.assign(new URL(window.location.origin), { hostname: '127.0.0.1' }).href, 16 + })}` 17 + } 18 + return `https://${window.location.host}/oauth-client-metadata.json` 19 + } 20 + 21 + const CLIENT_ID = clientID(); 22 + 23 + let oauthClient; 24 + let agent; 25 + 26 + async function beforeLogin(identifier) { 27 + console.debug("[OAUTH]", "performing beforeLogin check for handle", identifier); 28 + 29 + const res = await fetch( 30 + constructApiUrl("com.atproto.identity.resolveHandle", { 31 + handle: identifier 32 + }, "https://api.bsky.app") 33 + ).then(async (res) => await res.json()) 34 + 35 + 36 + if (!ALLOWED_DIDS.includes(res.did)) { 37 + console.warn("[OAUTH]", "Unauthorized DID", res.did); 38 + 39 + navigate("log-unauthorized"); 40 + return false 41 + } 42 + 43 + return true 44 + } 45 + 46 + async function setupOAuth() { 47 + try { 48 + oauthClient = await BrowserOAuthClient.load({ 49 + clientId: CLIENT_ID, 50 + handleResolver: "https://bsky.social", 51 + }) 52 + 53 + const result = await oauthClient.init(); 54 + 55 + if (!result) return 56 + 57 + const { session, state } = result 58 + if (state != null) { 59 + console.debug("[OAUTH]", "Authenticated", session.sub, `(state: ${state})`) 60 + } else { 61 + console.info("[OAUTH]", "Restored session", session.sub) 62 + } 63 + 64 + agent = new Agent(session); 65 + 66 + const res = await agent.com.atproto.server.getSession(); 67 + if (!res.success) { 68 + console.error("[OAUTH]", "Could not acquire session", res); 69 + throw new Error(JSON.stringify(res)); 70 + } 71 + 72 + console.info("[OAUTH]", "Agent initialized") 73 + ROOT.setAttribute("data-state", "authorized"); 74 + } catch (error) { 75 + displayError("oauth", error) 76 + return; 77 + } 78 + } 79 + 80 + async function performLogin(identifier, form) { 81 + form.querySelectorAll('button').forEach(input => { 82 + input.setAttribute('aria-busy', "true"); 83 + input.setAttribute("disabled", true); 84 + }) 85 + 86 + try { 87 + if (!await beforeLogin(identifier)) return; 88 + 89 + await oauthClient.signIn(identifier, { 90 + state: "bunny", 91 + signal: new AbortController().signal 92 + }) 93 + } catch (err) { 94 + displayError("login", err); 95 + console.error(err); 96 + 97 + } finally { 98 + form.querySelectorAll('button').forEach(input => { 99 + input.removeAttribute('aria-busy'); 100 + input.removeAttribute("disabled"); 101 + }) 102 + } 103 + } 104 + 105 + function revokeSession() { 106 + if (!agent?.did) return; // do not revoke if we aren't logged in lol 107 + 108 + oauthClient.revoke(agent.did); 109 + window.location.reload(); 110 + } 111 + 112 + async function createLog(content, form) { 113 + form.querySelectorAll('button').forEach(input => { 114 + input.setAttribute('aria-busy', "true"); 115 + input.setAttribute("disabled", true); 116 + }) 117 + 118 + try { 119 + await agent.com.atproto.repo.createRecord({ 120 + repo: agent.did, 121 + collection: 'space.bunniesin.micro.log', 122 + record: { 123 + "$type": "space.bunniesin.micro.log", 124 + "content": content, 125 + createdAt: new Date().toISOString(), 126 + } 127 + }) 128 + 129 + navigate("log-preview"); 130 + } catch (err) { 131 + displayError("create", err); 132 + console.error(err); 133 + 134 + } finally { 135 + form.querySelectorAll('button').forEach(input => { 136 + input.removeAttribute('aria-busy'); 137 + input.removeAttribute("disabled"); 138 + }) 139 + } 140 + } 141 + 142 + ROOT.addEventListener('broadcast', (ev) => { 143 + console.info("[ATPROTO]", "received", ev) 144 + switch (ev.detail.type) { 145 + case 'destroy': 146 + revokeSession(); 147 + break; 148 + default: 149 + console.warn("Unknown broadcasted type", ev.type); 150 + break; 151 + } 152 + }) 153 + 154 + document.getElementById('log-form').addEventListener('submit', (ev) => { 155 + ev.preventDefault(); 156 + const form = ROOT.querySelector('form#log-form'); 157 + const content = form.querySelector('textarea#log-content').value; 158 + 159 + createLog(content, form) 160 + }) 161 + 162 + document.getElementById('login-form').addEventListener('submit', (ev) => { 163 + ev.preventDefault(); 164 + const form = ROOT.querySelector('form#login-form'); 165 + const identifier = form.querySelector('input[name="handle"]').value; 166 + performLogin(identifier, form) 167 + }) 168 + 169 + setupOAuth()
+90
router.js
··· 1 + const ROOT = document.querySelector('main[data-currentpage]'); 2 + 3 + export function navigate(where) { 4 + ROOT.dispatchEvent(new CustomEvent('router-navigate', { 5 + detail: { 6 + where 7 + } 8 + })); 9 + } 10 + 11 + function broadcastMessage(message) { 12 + console.info("[ROUTER]", "broadcasting", { 13 + detail: { 14 + type: message 15 + } 16 + }) 17 + ROOT.dispatchEvent(new CustomEvent('broadcast', { 18 + detail: { 19 + type: message 20 + } 21 + })) 22 + } 23 + 24 + function receiveRouterBroadcast(data) { 25 + ROOT.setAttribute('data-currentpage', data.where); 26 + console.info("[ROUTER]", "Navigated to", data.where); 27 + triggerNavigationHooksInOrder(data.where) 28 + } 29 + 30 + /** @type {Map<string, Set<{id: number, fn: () => void}>>}*/ 31 + const NAV_HOOKS = new Map(); 32 + let nav_hook_idx = 0; 33 + 34 + function triggerNavigationHooksInOrder(route) { 35 + const hooks_for_route = NAV_HOOKS.get(route); 36 + 37 + hooks_for_route.keys().forEach(hook => hook.fn()) 38 + } 39 + 40 + function routerLinkHandler(action, data) { 41 + switch (action) { 42 + case 'navigate': 43 + console.info("[ROUTER]", "Navigated to", data); 44 + ROOT.setAttribute('data-currentpage', data); 45 + triggerNavigationHooksInOrder(data) 46 + break; 47 + case 'broadcast': 48 + broadcastMessage(data); 49 + break; 50 + default: 51 + console.warn("Unknown action type:", action) 52 + break; 53 + } 54 + } 55 + 56 + function registerLinks() { 57 + const anchors = ROOT.querySelectorAll('a.router-link'); 58 + 59 + for (const anchor of anchors) { 60 + const action_type = anchor.getAttribute('data-action'); 61 + const action_data = anchor.getAttribute('data-argument'); 62 + 63 + anchor.addEventListener('click', (event) => { 64 + event.preventDefault(); 65 + 66 + routerLinkHandler(action_type, action_data); 67 + }) 68 + } 69 + } 70 + 71 + function registerPages() { 72 + const pages = ROOT.querySelectorAll('.page'); 73 + 74 + for (const page of pages) { 75 + NAV_HOOKS.set(page.id, new Set()); 76 + } 77 + } 78 + 79 + export function defineNavigationHook(route, callback) { 80 + const hooks = NAV_HOOKS.get(route); 81 + hooks.add({ 82 + id: nav_hook_idx++, 83 + fn: callback 84 + }) 85 + } 86 + 87 + registerPages(); 88 + registerLinks(); 89 + 90 + ROOT.addEventListener('router-navigate', ({detail}) => receiveRouterBroadcast(detail))