| 1 | import type { Agent } from "@atproto/api"; |
| 2 | |
| 3 | export const REDIRECT_DELAY_SECONDS = 5; |
| 4 | |
| 5 | // ============================================================================ |
| 6 | // Helpers |
| 7 | // ============================================================================ |
| 8 | |
| 9 | export function withReturnToParam( |
| 10 | returnTo: string | undefined, |
| 11 | key: string, |
| 12 | value: string, |
| 13 | ): string | undefined { |
| 14 | if (!returnTo) return undefined; |
| 15 | try { |
| 16 | const url = new URL(returnTo); |
| 17 | url.searchParams.set(key, value); |
| 18 | return url.toString(); |
| 19 | } catch { |
| 20 | return returnTo; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * Scan a repo for a record in `collection` where `record[field] === uri`. |
| 26 | * Returns the record AT-URI, or null if not found. |
| 27 | */ |
| 28 | export async function findExistingRecord( |
| 29 | agent: Agent, |
| 30 | did: string, |
| 31 | collection: string, |
| 32 | field: string, |
| 33 | uri: string, |
| 34 | ): Promise<string | null> { |
| 35 | let cursor: string | undefined; |
| 36 | |
| 37 | do { |
| 38 | const result = await agent.com.atproto.repo.listRecords({ |
| 39 | repo: did, |
| 40 | collection, |
| 41 | limit: 100, |
| 42 | cursor, |
| 43 | }); |
| 44 | |
| 45 | for (const record of result.data.records) { |
| 46 | const value = record.value as Record<string, unknown>; |
| 47 | if (value[field] === uri) { |
| 48 | return record.uri; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | cursor = result.data.cursor; |
| 53 | } while (cursor); |
| 54 | |
| 55 | return null; |
| 56 | } |
| 57 | |
| 58 | // ============================================================================ |
| 59 | // HTML rendering |
| 60 | // ============================================================================ |
| 61 | |
| 62 | export function renderHandleForm( |
| 63 | params: { |
| 64 | resourceUri: string; |
| 65 | resourceField: string; |
| 66 | loginPath: string; |
| 67 | title: string; |
| 68 | description: string; |
| 69 | buttonLabel: string; |
| 70 | returnTo?: string; |
| 71 | error?: string; |
| 72 | action?: string; |
| 73 | }, |
| 74 | styleHref: string, |
| 75 | ): string { |
| 76 | const { |
| 77 | resourceUri, |
| 78 | resourceField, |
| 79 | loginPath, |
| 80 | title, |
| 81 | description, |
| 82 | buttonLabel, |
| 83 | returnTo, |
| 84 | error, |
| 85 | action, |
| 86 | } = params; |
| 87 | |
| 88 | const errorHtml = error |
| 89 | ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` |
| 90 | : ""; |
| 91 | const returnToInput = returnTo |
| 92 | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
| 93 | : ""; |
| 94 | const actionInput = action |
| 95 | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
| 96 | : ""; |
| 97 | |
| 98 | return page( |
| 99 | ` |
| 100 | <h1 class="vocs_H1 vocs_Heading">${escapeHtml(title)}</h1> |
| 101 | <p class="vocs_Paragraph">${escapeHtml(description)}</p> |
| 102 | ${errorHtml} |
| 103 | <form method="POST" action="${escapeHtml(loginPath)}"> |
| 104 | <input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" /> |
| 105 | ${returnToInput} |
| 106 | ${actionInput} |
| 107 | <input |
| 108 | type="text" |
| 109 | name="handle" |
| 110 | placeholder="you.bsky.social" |
| 111 | autocomplete="username" |
| 112 | required |
| 113 | autofocus |
| 114 | /> |
| 115 | <button type="submit" class="vocs_Button_button vocs_Button_button_accent">${escapeHtml(buttonLabel)}</button> |
| 116 | </form> |
| 117 | `, |
| 118 | styleHref, |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | export function renderSuccess( |
| 123 | params: { |
| 124 | resourceUri: string; |
| 125 | resourceLabel: string; |
| 126 | recordUri: string | null; |
| 127 | heading: string; |
| 128 | msg: string; |
| 129 | returnTo?: string; |
| 130 | }, |
| 131 | styleHref: string, |
| 132 | ): string { |
| 133 | const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = |
| 134 | params; |
| 135 | const escapedResourceUri = escapeHtml(resourceUri); |
| 136 | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
| 137 | |
| 138 | const redirectHtml = returnTo |
| 139 | ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
| 140 | <script> |
| 141 | (function(){ |
| 142 | var secs = ${REDIRECT_DELAY_SECONDS}; |
| 143 | var el = document.getElementById('countdown'); |
| 144 | var iv = setInterval(function(){ |
| 145 | secs--; |
| 146 | if (el) el.textContent = String(secs); |
| 147 | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
| 148 | }, 1000); |
| 149 | })(); |
| 150 | </script>` |
| 151 | : ""; |
| 152 | const headExtra = returnTo |
| 153 | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
| 154 | : ""; |
| 155 | |
| 156 | return page( |
| 157 | ` |
| 158 | <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1> |
| 159 | <p class="vocs_Paragraph">${msg}</p> |
| 160 | ${redirectHtml} |
| 161 | <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> |
| 162 | <colgroup><col style="width:7rem;"><col></colgroup> |
| 163 | <tbody> |
| 164 | <tr class="vocs_TableRow"> |
| 165 | <td class="vocs_TableCell">${escapeHtml(resourceLabel)}</td> |
| 166 | <td class="vocs_TableCell" style="overflow:hidden;"> |
| 167 | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div> |
| 168 | </td> |
| 169 | </tr> |
| 170 | ${ |
| 171 | recordUri |
| 172 | ? `<tr class="vocs_TableRow"> |
| 173 | <td class="vocs_TableCell">Record</td> |
| 174 | <td class="vocs_TableCell" style="overflow:hidden;"> |
| 175 | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
| 176 | </td> |
| 177 | </tr>` |
| 178 | : "" |
| 179 | } |
| 180 | </tbody> |
| 181 | </table> |
| 182 | `, |
| 183 | styleHref, |
| 184 | headExtra, |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | export function renderError(message: string, styleHref: string): string { |
| 189 | return page( |
| 190 | `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, |
| 191 | styleHref, |
| 192 | ); |
| 193 | } |
| 194 | |
| 195 | export function page(body: string, styleHref: string, headExtra = ""): string { |
| 196 | return `<!DOCTYPE html> |
| 197 | <html lang="en"> |
| 198 | <head> |
| 199 | <meta charset="UTF-8" /> |
| 200 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 201 | <title>Sequoia</title> |
| 202 | <link rel="stylesheet" href="${styleHref}" /> |
| 203 | <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> |
| 204 | ${headExtra} |
| 205 | <style> |
| 206 | .page-container { |
| 207 | max-width: calc(var(--vocs-content_width, 480px) / 1.6); |
| 208 | margin: 4rem auto; |
| 209 | padding: 0 var(--vocs-space_20, 1.25rem); |
| 210 | } |
| 211 | .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } |
| 212 | .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } |
| 213 | input[type="text"] { |
| 214 | padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); |
| 215 | border: 1px solid var(--vocs-color_border, #D5D1C8); |
| 216 | border-radius: var(--vocs-borderRadius_6, 6px); |
| 217 | margin-bottom: var(--vocs-space_20, 1.25rem); |
| 218 | min-width: 30vh; |
| 219 | width: 100%; |
| 220 | font-size: var(--vocs-fontSize_16, 1rem); |
| 221 | font-family: inherit; |
| 222 | background: var(--vocs-color_background, #F5F3EF); |
| 223 | color: var(--vocs-color_text, #2C2C2C); |
| 224 | } |
| 225 | input[type="text"]:focus { |
| 226 | border-color: var(--vocs-color_borderAccent, #3A5A40); |
| 227 | outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); |
| 228 | outline-offset: 2px; |
| 229 | } |
| 230 | .error { color: var(--vocs-color_dangerText, #8B3A3A); } |
| 231 | </style> |
| 232 | </head> |
| 233 | <body> |
| 234 | <div class="page-container"> |
| 235 | ${body} |
| 236 | </div> |
| 237 | </body> |
| 238 | </html>`; |
| 239 | } |
| 240 | |
| 241 | export function escapeHtml(text: string): string { |
| 242 | return text |
| 243 | .replace(/&/g, "&") |
| 244 | .replace(/</g, "<") |
| 245 | .replace(/>/g, ">") |
| 246 | .replace(/"/g, """); |
| 247 | } |