An in browser local PDS localpds.at
20

Configure Feed

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

Can now login with oauth

authored by

Niall Bunting and committed by
Niall Bunting
(Jun 3, 2026, 2:05 AM +0100) 1765b6c1 7b8d8d1b

+311 -51
+16 -3
backend/firehose.go
··· 8 8 //"github.com/fxamacker/cbor/v2" 9 9 "github.com/gin-gonic/gin" 10 10 "github.com/gorilla/websocket" 11 + "io" 12 + "strings" 11 13 ) 12 14 13 15 // --------------------------------------------------------------------------- ··· 104 106 // --------------------------------------------------------------------------- 105 107 106 108 func handleSyncXRPC(c *gin.Context, did string) { 107 - method := c.FullPath()[len("/xrpc/"):] 108 - //did := c.Query("did") 109 + method := strings.TrimPrefix(c.FullPath(), "/xrpc/") 110 + 109 111 if did == "" { 110 112 c.JSON(http.StatusBadRequest, gin.H{"error": "MissingParam", "message": "did required"}) 111 113 return ··· 124 126 } 125 127 } 126 128 127 - resp, err := requestFromPeer(c.Request.Context(), peer, method, params) 129 + var reqBody []byte 130 + if c.Request.Body != nil { 131 + var err error 132 + reqBody, err = io.ReadAll(c.Request.Body) 133 + if err != nil { 134 + c.JSON(http.StatusInternalServerError, gin.H{"error": "InternalServerError", "message": "failed to read body"}) 135 + return 136 + } 137 + defer c.Request.Body.Close() 138 + } 139 + 140 + resp, err := requestFromPeer(c.Request.Context(), peer, method, params, reqBody) 128 141 if err != nil { 129 142 log.Printf("peer request failed did=%s method=%s: %v", did, method, err) 130 143 c.JSON(http.StatusBadGateway, gin.H{"error": "BadGateway", "message": err.Error()})
+70 -8
backend/main.go
··· 4 4 "log" 5 5 "github.com/gin-gonic/gin" 6 6 7 + "net/url" 8 + "strings" 7 9 "fmt" 8 10 "bytes" 9 11 "encoding/json" ··· 38 40 r.POST("/xrpc/com.atproto.sync.requestCrawl", getDidThenHandleSyncXRPC) 39 41 40 42 43 + // TODO validate sig & move some of this out of here 41 44 r.POST("/oauth/token", func(c *gin.Context) { 42 - // 1. Read the body bytes from Gin's request object 45 + // 1. Read the body bytes 43 46 bodyBytes, err := io.ReadAll(c.Request.Body) 44 47 if err != nil { 45 48 fmt.Printf("Error reading body: %v\n", err) 49 + c.AbortWithStatus(http.StatusBadRequest) 50 + return 46 51 } 52 + 53 + dpopHeader := c.GetHeader("DPoP") 54 + 55 + // 2. Parse the x-www-form-urlencoded body 56 + formValues, err := url.ParseQuery(string(bodyBytes)) 57 + if err != nil { 58 + fmt.Printf("Error parsing form data: %v\n", err) 59 + } 60 + 61 + // Add dpop and reencode 62 + formValues.Set("dpop", dpopHeader) 63 + bodyBytes = []byte(formValues.Encode()) 64 + 65 + codeJWT := formValues.Get("code") 66 + 67 + // Default fallback in case extraction fails 68 + fullHost := c.Request.Host 69 + did := "did:web:" + fullHost 70 + 71 + // 3. Extract the 'sub' claim from the code JWT 72 + if codeJWT != "" { 73 + parts := strings.Split(codeJWT, ".") 74 + if len(parts) == 3 { // A valid JWT has 3 parts 75 + // The payload is the second part (index 1) 76 + // JWT uses RawURLEncoding (no padding) 77 + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 78 + if err == nil { 79 + var payload struct { 80 + Sub string `json:"sub"` 81 + } 82 + if err := json.Unmarshal(payloadBytes, &payload); err == nil && payload.Sub != "" { 83 + did = payload.Sub // Successfully extracted did:web:... 84 + } 85 + } else { 86 + fmt.Printf("Error decoding JWT payload: %v\n", err) 87 + } 88 + } 89 + } 90 + 91 + // 4. Debug outputs 92 + //fmt.Printf("--- DEBUG REQUEST BODY ---\n%s\n---------------------\n", string(bodyBytes)) 93 + fmt.Printf("Routing sync to DID: %s\n", did) 94 + 95 + // 5. CRITICAL: Restore the stream back into Gin's request object 96 + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 97 + 98 + // 6. Hand over to your actual handler 99 + handleSyncXRPC(c, did) 100 + }) 101 + 102 + //r.POST("/oauth/token", func(c *gin.Context) { 103 + // // 1. Read the body bytes from Gin's request object 104 + // bodyBytes, err := io.ReadAll(c.Request.Body) 105 + // if err != nil { 106 + // fmt.Printf("Error reading body: %v\n", err) 107 + // } 47 108 48 - // 2. Print it out 49 - fmt.Printf("--- DEBUG REQUEST BODY ---\n%s\n---------------------\n", string(bodyBytes)) 109 + // // 2. Print it out 110 + // fmt.Printf("--- DEBUG REQUEST BODY ---\n%s\n---------------------\n", string(bodyBytes)) 50 111 51 - // 3. CRITICAL: Restore the stream back into Gin's request object 52 - c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 112 + // // 3. CRITICAL: Restore the stream back into Gin's request object 113 + // c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 53 114 54 - // 4. Now hand over the standard w and r to your actual handler 55 - getDidThenHandleSyncXRPC(c) 56 - }) 115 + // // 4. Now hand over the standard w and r to your actual handler 116 + // getDidThenHandleSyncXRPC(c) 117 + // handleSyncXRPC(c, "did:web:" + fullHost) 118 + //}) 57 119 58 120 r.POST("/xrpc/com.atproto.repo.uploadBlob", func(c *gin.Context) { 59 121 // Set the response header so the client knows it's getting base64 text
+21 -5
backend/peers.go
··· 29 29 SDP string `json:"sdp,omitempty"` 30 30 Candidate *iceCandidate `json:"candidate,omitempty"` 31 31 Message string `json:"message,omitempty"` 32 + Length int `json:"length,omitempty"` 32 33 // response fields (browser → server) 33 34 Body string `json:"body,omitempty"` 34 35 ContentType string `json:"contentType,omitempty"` ··· 41 42 UsernameFragment string `json:"usernameFragment,omitempty"` 42 43 } 43 44 45 + type wsMessage struct { 46 + messageType int 47 + data []byte 48 + } 49 + 44 50 // --------------------------------------------------------------------------- 45 51 // Peer — one authenticated browser extension connection 46 52 // --------------------------------------------------------------------------- ··· 49 55 did string 50 56 publicKeyJwk map[string]any 51 57 conn *websocket.Conn 52 - send chan []byte 58 + send chan wsMessage 53 59 54 60 mu sync.Mutex 55 61 pending map[string]chan signalMsg // requestId → text signal channel ··· 59 65 func (p *peer) sendMsg(msg signalMsg) { 60 66 b, _ := json.Marshal(msg) 61 67 select { 62 - case p.send <- b: 68 + case p.send <- wsMessage{messageType: websocket.TextMessage, data: b}: 63 69 default: 64 70 log.Printf("peer %s send buffer full, dropping message", p.did) 65 71 } 66 72 } 67 73 74 + func (p *peer) sendBinary(data []byte) { 75 + select { 76 + case p.send <- wsMessage{messageType: websocket.BinaryMessage, data: data}: 77 + default: 78 + log.Printf("peer %s send buffer full, dropping binary message", p.did) 79 + } 80 + } 81 + 68 82 // --------------------------------------------------------------------------- 69 83 // Peer registry 70 84 // --------------------------------------------------------------------------- ··· 131 145 wsPingPeriod = 45 * time.Second 132 146 ) 133 147 148 + 134 149 func runPeer(conn *websocket.Conn) { 135 150 challenge := randomHex(32) 136 151 p := &peer{ 137 152 conn: conn, 138 - send: make(chan []byte, 64), 153 + send: make(chan wsMessage, 64), // <-- Updated initialization 139 154 pending: make(map[string]chan signalMsg), 140 155 binaryPending: make(map[string]chan []byte), 141 156 } ··· 157 172 conn.WriteMessage(websocket.CloseMessage, []byte{}) 158 173 return 159 174 } 160 - if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { 175 + // Use the dynamic messageType instead of hardcoding TextMessage 176 + if err := conn.WriteMessage(msg.messageType, msg.data); err != nil { 161 177 return 162 178 } 163 179 case <-ticker.C: ··· 168 184 } 169 185 } 170 186 }() 171 - 187 + 172 188 // Read pump 173 189 conn.SetReadDeadline(time.Now().Add(wsPongWait)) 174 190 conn.SetPongHandler(func(string) error {
+23 -8
backend/webrtc.go
··· 20 20 // 21 21 // The pending channel carries signalMsg for the header frame, then the 22 22 // peer's readPump sends the binary body via a separate binaryPending channel. 23 - func requestFromPeer(ctx context.Context, p *peer, method string, params map[string]string) (dcResponse, error) { 23 + func requestFromPeer(ctx context.Context, p *peer, method string, params map[string]string, body []byte) (dcResponse, error) { 24 24 requestID := randomHex(16) 25 25 26 26 headerCh := make(chan signalMsg, 1) ··· 38 38 p.mu.Unlock() 39 39 }() 40 40 41 - p.sendMsg(signalMsg{ 42 - Type: "request", 43 - RequestID: requestID, 44 - Method: method, 45 - Params: params, 46 - }) 41 + // Initialize the message 42 + msg := signalMsg{ 43 + Type: "request", 44 + RequestID: requestID, 45 + Method: method, 46 + Params: params, 47 + } 48 + 49 + // Only set Length if it's above 0 50 + if len(body) > 0 { 51 + msg.Length = len(body) 52 + } 53 + 54 + // Send the prepared message 55 + p.sendMsg(msg) 56 + 57 + if len(body) > 0 { 58 + // Assuming you have or will create a sendBinary method on your peer struct. 59 + // This should wrap conn.WriteMessage(websocket.BinaryMessage, body) 60 + p.sendBinary(body) 61 + } 47 62 48 63 timeout := time.NewTimer(60 * time.Second) 49 64 defer timeout.Stop() ··· 98 113 return nil, false 99 114 } 100 115 return &h, true 101 - } 116 + }
+1 -1
extension/atproto-repo.js
··· 993 993 const out = new Uint8Array(binary.length); 994 994 for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); 995 995 return out; 996 - } 996 + }
+20 -13
extension/background.js
··· 109 109 return `did:web:${base58}.${DOMAIN}`; 110 110 } 111 111 112 - async function signJwtES256(payload, privateKey) { 112 + export async function signJwtES256(payload, privateKey) { 113 113 const b64url = (obj) => btoa(JSON.stringify(obj)) 114 114 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 115 115 const header = { alg: 'ES256', typ: 'JWT' }; ··· 179 179 180 180 let _authorizePageHtml = null; 181 181 182 - async function getAuthorizePage() { 182 + async function getAuthorizePage(code) { 183 183 if (_authorizePageHtml) return _authorizePageHtml; 184 184 try { 185 185 const url = chrome.runtime.getURL('oauth-authorize.html'); ··· 189 189 console.error('[OAuth] Failed to load authorize page:', e); 190 190 _authorizePageHtml = '<h1>Authorization error — could not load consent page.</h1>'; 191 191 } 192 - return _authorizePageHtml; 192 + return _authorizePageHtml.replace("SIGNED_CODE_STRING", code); 193 193 } 194 194 195 195 // --------------------------------------------------------------------------- ··· 294 294 return; 295 295 } 296 296 297 + const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 298 + const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 299 + const userId = userDid.split(":")[2].split(".")[0]; 300 + const mockedDid = `did:web:${DOMAIN}`; 301 + let queryParams = Object.fromEntries(searchParams.entries()); 302 + 297 303 // ------------------------------------------------------------------ 298 304 // /oauth/token — exchange an auth code for an access token. 299 305 // The request body is application/x-www-form-urlencoded. ··· 302 308 // the OAuth token response fields the ATProto OAuth client expects. 303 309 // ------------------------------------------------------------------ 304 310 if (request.url.includes(`https://${DOMAIN}/oauth/token`)) { 305 - const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 306 - const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 307 - const userId = userDid.split(":")[2].split(".")[0]; 308 - const mockedDid = `did:web:${DOMAIN}`; 309 311 const now = Math.floor(Date.now() / 1000); 310 312 311 313 const accessJwt = await signJwtES256({ ··· 343 345 // without needing a server round-trip. 344 346 // ------------------------------------------------------------------ 345 347 if (request.url.includes(`https://${DOMAIN}/oauth/authorize`)) { 346 - const html = await getAuthorizePage(); 348 + const { client_id } = queryParams; 349 + 350 + // TODO put the code verifier in here 351 + const signedCode = await signJwtES256({ 352 + sub: userDid, 353 + aud: client_id 354 + }, privateKey); 355 + 356 + console.log("CODE: ", signedCode, userDid, client_id); 357 + 358 + const html = await getAuthorizePage(signedCode); 347 359 await fulfillRaw(source, requestId, 200, [ 348 360 { name: "Content-Type", value: "text/html; charset=utf-8" }, 349 361 { name: "Access-Control-Allow-Origin", value: "*" }, ··· 357 369 } 358 370 359 371 const xrpcMethod = pathname.split("/xrpc/")[1]; 360 - const mockedDid = `did:web:${DOMAIN}`; 361 - const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 362 - const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 363 - const userId = userDid.split(":")[2].split(".")[0]; 364 372 365 - let queryParams = Object.fromEntries(searchParams.entries()); 366 373 let bodyPayload = {}; 367 374 if (request.postData) { 368 375 try { bodyPayload = JSON.parse(request.postData); } catch (_) {}
+1 -6
extension/manifest.json
··· 4 4 "description": "LocalPDS is a Personal Data Server that runs in your browser", 5 5 "version": "0.0.2", 6 6 "permissions": ["debugger", "storage", "tabs"], 7 - "host_permissions": [ 8 - "https://*.bsky.app/*", 9 - "https://*.tangled.org/*", 10 - "https://*.leaflet.pub/*", 11 - "https://*.localpds.at/*" 12 - ], 7 + "host_permissions": ["<all_urls>"], 13 8 "web_accessible_resources": [{ 14 9 "resources": ["oauth-authorize.html"], 15 10 "matches": ["https://localpds.at/*"]
+11 -3
extension/oauth-authorize.html
··· 230 230 <code id="redirectUri"></code> 231 231 </div> 232 232 233 + <div class="redirect-note"> 234 + Signed code: <span id="signedcode"></span> 235 + </div> 236 + 233 237 <div id="errorMsg" class="error-msg" style="display:none"></div> 234 238 235 239 <div class="actions"> ··· 275 279 // Generate a random auth code and build the redirect URL 276 280 // --------------------------------------------------------------------------- 277 281 function randomCode() { 278 - const arr = new Uint8Array(24); 279 - crypto.getRandomValues(arr); 280 - return btoa(String.fromCharCode(...arr)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); 282 + return "SIGNED_CODE_STRING"; 281 283 } 282 284 283 285 // --------------------------------------------------------------------------- ··· 300 302 301 303 // 1. Decode the PAR params from the request_uri 302 304 const parParams = reqUri ? decodeRequestUri(reqUri) : null; 305 + console.log(parParams); 303 306 const effectiveClientId = parParams?.client_id || clientId; 304 307 305 308 if (!effectiveClientId) { ··· 334 337 document.getElementById('clientName').textContent = appName; 335 338 document.getElementById('clientUri').textContent = appUri; 336 339 document.getElementById('redirectUri').textContent = redirectUri || '(none)'; 340 + document.getElementById('signedcode').textContent = randomCode().slice(0, 10) + "..."; 337 341 338 342 if (logoUri) { 339 343 const img = document.createElement('img'); ··· 359 363 360 364 loadingEl.style.display = 'none'; 361 365 contentEl.style.display = 'block'; 366 + 367 + if (randomCode() === "SIGNED_CODE_STRING") { 368 + showError(`We don't have a signed code. Login will fail. Code: ${randomCode()}`); 369 + } 362 370 363 371 // 4. Button handlers 364 372 document.getElementById('btnDeny').addEventListener('click', () => {
+71 -4
extension/pod-relay.js
··· 1 1 import { generateTid } from './atproto-repo.js'; 2 + import { signJwtES256 } from './background.js'; 3 + import { decodeProtectedHeader, calculateJwkThumbprint } from './utils.js'; 2 4 5 + const DOMAIN = 'https://localpds.at'; 3 6 const SIGNAL_URL = 'wss://localpds.at/signal'; 4 7 5 8 export class PodRelay { ··· 30 33 this._reconnectMs = Math.min(this._reconnectMs * 2, 30_000); 31 34 }; 32 35 ws.onmessage = async (event) => { 36 + if (event.data instanceof Blob || event.data instanceof ArrayBuffer) { 37 + if(this._headerFrame) { 38 + let binaryData = event.data; 39 + 40 + if (binaryData instanceof Blob) { 41 + binaryData = await binaryData.arrayBuffer(); 42 + } 43 + 44 + const decoder = new TextDecoder('utf-8'); 45 + const decodedText = decoder.decode(binaryData); 46 + 47 + let msg = this._headerFrame; 48 + msg.body = decodedText; 49 + try {msg.body = JSON.parse(decodedText); } catch {} 50 + 51 + try { await this._dispatch(msg); } 52 + catch (e) { console.error('[PodRelay] dispatch error:', e); } 53 + } 54 + return; 55 + } 56 + 33 57 let msg; 34 58 try { msg = JSON.parse(event.data); } catch { return; } 59 + 60 + this._headerFrame = msg; 61 + if(msg.length && msg.length > 0) { 62 + return; 63 + } 64 + 35 65 try { await this._dispatch(msg); } 36 66 catch (e) { console.error('[PodRelay] dispatch error:', e); } 37 67 }; ··· 75 105 }); 76 106 } 77 107 78 - async _handleRequest({ requestId, method, params }) { 108 + async _handleRequest({ requestId, method, params, body }) { 79 109 console.log(`[PodRelay] ${method} (${requestId})`); 80 110 const repo = await this.getRepo(this.did, this.privateKey); 81 - const { contentType, bytes } = await this._buildResponse(repo, method, params); 111 + const { contentType, bytes } = await this._buildResponse(repo, method, params, body); 82 112 83 113 this._send({ type: 'response-header', requestId, contentType, length: bytes.length }); 84 114 if (this._ws?.readyState === WebSocket.OPEN) { ··· 87 117 console.log(`[PodRelay] responded to ${requestId} (${bytes.length} bytes, ${contentType})`); 88 118 } 89 119 90 - async _buildResponse(repo, method, params) { 120 + async _buildResponse(repo, method, params, body) { 91 121 const json = (obj) => ({ 92 122 contentType: 'application/json', 93 123 bytes: new TextEncoder().encode(JSON.stringify(obj)), ··· 177 207 // ---------------------------------------------------------------- 178 208 // Default 179 209 // ---------------------------------------------------------------- 210 + case '/oauth/token': 211 + // TODO Need to verify code_verifier (put in code). validate client_assertion againt remote jwks. validate the code. Ensure it was signed by us (and is used the first time?). Check redirect_uri is the same as on the first step. 212 + const now = Math.floor(Date.now() / 1000); 180 213 214 + const bodyAsParms = Object.fromEntries(new URLSearchParams(body)); 215 + console.log("Token requested", body, bodyAsParms); 216 + 217 + const dpopHeader = decodeProtectedHeader(bodyAsParms.dpop); 218 + const jwkThumbprint = await calculateJwkThumbprint(dpopHeader.jwk, 'sha256'); 219 + 220 + return json( 221 + { 222 + access_token: await signJwtES256({ 223 + scope: "atproto", 224 + sub: this.did, 225 + iat: now, 226 + exp: now + 900, 227 + aud: DOMAIN, 228 + cnf: { 229 + jkt: jwkThumbprint 230 + } 231 + }, this.privateKey), 232 + refresh_token: await signJwtES256({ 233 + scope: "atproto", 234 + sub: this.did, 235 + iat: now, 236 + exp: now + 900, 237 + aud: DOMAIN, 238 + cnf: { 239 + jkt: jwkThumbprint 240 + } 241 + }, this.privateKey), 242 + "token_type": "DPoP", 243 + "expires_in": 900, 244 + "scope": "atproto", 245 + "sub": this.did, 246 + } 247 + ); 181 248 default: 182 249 return json({ error: 'MethodNotFound', message: method }); 183 250 } 184 251 } 185 - } 252 + }
+77
extension/utils.js
··· 1 + /** 2 + * 1. Decodes the protected header of a JWT using Web APIs 3 + */ 4 + export function decodeProtectedHeader(jwtString) { 5 + if (!jwtString || typeof jwtString !== 'string') { 6 + throw new Error('Invalid JWT string'); 7 + } 8 + 9 + const b64UrlHeader = jwtString.split('.')[0]; 10 + if (!b64UrlHeader) throw new Error('Invalid JWT format'); 11 + 12 + // Convert Base64URL to standard Base64 13 + let b64Header = b64UrlHeader.replace(/-/g, '+').replace(/_/g, '/'); 14 + 15 + // Add padding if necessary (atob can be strict about this) 16 + const pad = b64Header.length % 4; 17 + if (pad) b64Header += '='.repeat(4 - pad); 18 + 19 + // Decode base64 to a binary string 20 + const binaryString = atob(b64Header); 21 + 22 + // Safely handle UTF-8 characters using TextDecoder 23 + const bytes = new Uint8Array(binaryString.length); 24 + for (let i = 0; i < binaryString.length; i++) { 25 + bytes[i] = binaryString.charCodeAt(i); 26 + } 27 + 28 + const jsonHeader = new TextDecoder().decode(bytes); 29 + return JSON.parse(jsonHeader); 30 + } 31 + 32 + /** 33 + * 2. Calculates the SHA-256 JWK Thumbprint using Web Crypto API 34 + */ 35 + export async function calculateJwkThumbprint(jwk) { 36 + if (!jwk || !jwk.kty) throw new Error('Invalid JWK'); 37 + 38 + const canonicalJwk = {}; 39 + 40 + if (jwk.kty === 'EC') { 41 + if (!jwk.crv || !jwk.x || !jwk.y) throw new Error('Missing EC parameters'); 42 + canonicalJwk.crv = jwk.crv; 43 + canonicalJwk.kty = jwk.kty; 44 + canonicalJwk.x = jwk.x; 45 + canonicalJwk.y = jwk.y; 46 + } else if (jwk.kty === 'OKP') { 47 + if (!jwk.crv || !jwk.x) throw new Error('Missing OKP parameters'); 48 + canonicalJwk.crv = jwk.crv; 49 + canonicalJwk.kty = jwk.kty; 50 + canonicalJwk.x = jwk.x; 51 + } else if (jwk.kty === 'RSA') { 52 + if (!jwk.e || !jwk.n) throw new Error('Missing RSA parameters'); 53 + canonicalJwk.e = jwk.e; 54 + canonicalJwk.kty = jwk.kty; 55 + canonicalJwk.n = jwk.n; 56 + } else { 57 + throw new Error(`Unsupported key type: ${jwk.kty}`); 58 + } 59 + 60 + const jsonString = JSON.stringify(canonicalJwk); 61 + 62 + // Convert the string to a byte array for Web Crypto 63 + const encoder = new TextEncoder(); 64 + const data = encoder.encode(jsonString); 65 + 66 + // Hash using the native Web Crypto API 67 + const hashBuffer = await crypto.subtle.digest('SHA-256', data); 68 + 69 + // Convert the resulting ArrayBuffer to Base64URL 70 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 71 + const hashB64 = btoa(String.fromCharCode.apply(null, hashArray)); 72 + 73 + return hashB64 74 + .replace(/\+/g, '-') 75 + .replace(/\//g, '_') 76 + .replace(/=+$/, ''); 77 + }