| 1 | import type { Agent } from "@atproto/api"; |
| 2 | import { escapeHtml, page } from "../lib/theme"; |
| 3 | |
| 4 | export const REDIRECT_DELAY_SECONDS = 5; |
| 5 | |
| 6 | // ============================================================================ |
| 7 | // Helpers |
| 8 | // ============================================================================ |
| 9 | |
| 10 | export function withReturnToParam( |
| 11 | returnTo: string | undefined, |
| 12 | key: string, |
| 13 | value: string, |
| 14 | ): string | undefined { |
| 15 | if (!returnTo) return undefined; |
| 16 | try { |
| 17 | const url = new URL(returnTo); |
| 18 | url.searchParams.set(key, value); |
| 19 | return url.toString(); |
| 20 | } catch { |
| 21 | return returnTo; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * Scan a repo for a record in `collection` where `record[field] === uri`. |
| 27 | * Returns the record AT-URI, or null if not found. |
| 28 | */ |
| 29 | export async function findExistingRecord( |
| 30 | agent: Agent, |
| 31 | did: string, |
| 32 | collection: string, |
| 33 | field: string, |
| 34 | uri: string, |
| 35 | ): Promise<string | null> { |
| 36 | let cursor: string | undefined; |
| 37 | |
| 38 | do { |
| 39 | const result = await agent.com.atproto.repo.listRecords({ |
| 40 | repo: did, |
| 41 | collection, |
| 42 | limit: 100, |
| 43 | cursor, |
| 44 | }); |
| 45 | |
| 46 | for (const record of result.data.records) { |
| 47 | const value = record.value as Record<string, unknown>; |
| 48 | if (value[field] === uri) { |
| 49 | return record.uri; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | cursor = result.data.cursor; |
| 54 | } while (cursor); |
| 55 | |
| 56 | return null; |
| 57 | } |
| 58 | |
| 59 | // ============================================================================ |
| 60 | // HTML rendering |
| 61 | // ============================================================================ |
| 62 | |
| 63 | export function renderHandleForm(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 | }): string { |
| 74 | const { |
| 75 | resourceUri, |
| 76 | resourceField, |
| 77 | loginPath, |
| 78 | title, |
| 79 | description, |
| 80 | buttonLabel, |
| 81 | returnTo, |
| 82 | error, |
| 83 | action, |
| 84 | } = params; |
| 85 | |
| 86 | const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : ""; |
| 87 | const returnToInput = returnTo |
| 88 | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
| 89 | : ""; |
| 90 | const actionInput = action |
| 91 | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
| 92 | : ""; |
| 93 | |
| 94 | return page(` |
| 95 | <h1>${escapeHtml(title)}</h1> |
| 96 | <p>${escapeHtml(description)}</p> |
| 97 | ${errorHtml} |
| 98 | <form method="POST" action="${escapeHtml(loginPath)}"> |
| 99 | <input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" /> |
| 100 | ${returnToInput} |
| 101 | ${actionInput} |
| 102 | <input |
| 103 | type="text" |
| 104 | name="handle" |
| 105 | placeholder="you.bsky.social" |
| 106 | autocomplete="username" |
| 107 | required |
| 108 | autofocus |
| 109 | /> |
| 110 | <button type="submit">${escapeHtml(buttonLabel)}</button> |
| 111 | </form> |
| 112 | `); |
| 113 | } |
| 114 | |
| 115 | export function renderSuccess(params: { |
| 116 | resourceUri: string; |
| 117 | resourceLabel: string; |
| 118 | recordUri: string | null; |
| 119 | heading: string; |
| 120 | msg: string; |
| 121 | returnTo?: string; |
| 122 | }): string { |
| 123 | const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = |
| 124 | params; |
| 125 | const escapedResourceUri = escapeHtml(resourceUri); |
| 126 | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
| 127 | |
| 128 | const redirectHtml = returnTo |
| 129 | ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
| 130 | <script> |
| 131 | (function(){ |
| 132 | var secs = ${REDIRECT_DELAY_SECONDS}; |
| 133 | var el = document.getElementById('countdown'); |
| 134 | var iv = setInterval(function(){ |
| 135 | secs--; |
| 136 | if (el) el.textContent = String(secs); |
| 137 | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
| 138 | }, 1000); |
| 139 | })(); |
| 140 | </script>` |
| 141 | : ""; |
| 142 | const headExtra = returnTo |
| 143 | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
| 144 | : ""; |
| 145 | |
| 146 | return page( |
| 147 | ` |
| 148 | <h1>${escapeHtml(heading)}</h1> |
| 149 | <p>${msg}</p> |
| 150 | ${redirectHtml} |
| 151 | <table> |
| 152 | <colgroup><col style="width:7rem;"><col></colgroup> |
| 153 | <tbody> |
| 154 | <tr> |
| 155 | <td>${escapeHtml(resourceLabel)}</td> |
| 156 | <td> |
| 157 | <div><code><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div> |
| 158 | </td> |
| 159 | </tr> |
| 160 | ${ |
| 161 | recordUri |
| 162 | ? `<tr> |
| 163 | <td>Record</td> |
| 164 | <td> |
| 165 | <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
| 166 | </td> |
| 167 | </tr>` |
| 168 | : "" |
| 169 | } |
| 170 | </tbody> |
| 171 | </table> |
| 172 | `, |
| 173 | headExtra, |
| 174 | ); |
| 175 | } |
| 176 | |
| 177 | export function renderError(message: string): string { |
| 178 | return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`); |
| 179 | } |