packages/server/src/routes/lib.ts 4.3 K raw
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
}