The Appview for the kipclip.com atproto bookmarking service
at main 182 lines 5.5 kB view raw
1/** 2 * Shared utilities for route handlers. 3 */ 4 5import { getClearSessionCookie, getSessionFromRequest } from "./session.ts"; 6import { tagIncludes } from "../shared/tag-utils.ts"; 7 8/** AT Protocol collection names */ 9export const BOOKMARK_COLLECTION = "community.lexicon.bookmarks.bookmark"; 10export const TAG_COLLECTION = "com.kipclip.tag"; 11export const ANNOTATION_COLLECTION = "com.kipclip.annotation"; 12export const PREFERENCES_COLLECTION = "com.kipclip.preferences"; 13 14/** OAuth scopes - granular permissions for only the collections kipclip uses */ 15export const OAUTH_SCOPES = "atproto " + 16 "repo:community.lexicon.bookmarks.bookmark?action=create " + 17 "repo:community.lexicon.bookmarks.bookmark?action=read " + 18 "repo:community.lexicon.bookmarks.bookmark?action=update " + 19 "repo:community.lexicon.bookmarks.bookmark?action=delete " + 20 "repo:com.kipclip.tag?action=create " + 21 "repo:com.kipclip.tag?action=read " + 22 "repo:com.kipclip.tag?action=update " + 23 "repo:com.kipclip.tag?action=delete " + 24 "repo:com.kipclip.annotation?action=create " + 25 "repo:com.kipclip.annotation?action=read " + 26 "repo:com.kipclip.annotation?action=update " + 27 "repo:com.kipclip.annotation?action=delete " + 28 "repo:com.kipclip.preferences?action=create " + 29 "repo:com.kipclip.preferences?action=read " + 30 "repo:com.kipclip.preferences?action=update"; 31 32/** 33 * Set the session cookie header on a response if provided. 34 */ 35export function setSessionCookie( 36 response: Response, 37 setCookieHeader: string | undefined, 38): Response { 39 if (setCookieHeader) { 40 response.headers.set("Set-Cookie", setCookieHeader); 41 } 42 return response; 43} 44 45/** 46 * Create a 401 response for unauthenticated requests. 47 */ 48export function createAuthErrorResponse(error?: { 49 message?: string; 50 type?: string; 51}): Response { 52 const response = Response.json( 53 { 54 error: "Authentication required", 55 message: error?.message || "Please log in again", 56 code: error?.type || "SESSION_EXPIRED", 57 }, 58 { status: 401 }, 59 ); 60 response.headers.set("Set-Cookie", getClearSessionCookie()); 61 return response; 62} 63 64/** 65 * Helper to get session and return auth error if not authenticated. 66 * Returns null if not authenticated (response already sent). 67 */ 68export async function requireAuth(request: Request): Promise< 69 { 70 session: Awaited<ReturnType<typeof getSessionFromRequest>>["session"]; 71 setCookieHeader: string | undefined; 72 } | null 73> { 74 const result = await getSessionFromRequest(request); 75 if (!result.session) { 76 return null; 77 } 78 return { 79 session: result.session, 80 setCookieHeader: result.setCookieHeader, 81 }; 82} 83 84/** 85 * Create PDS tag records for tags that don't already exist. 86 * Comparison is case-insensitive — "swift" won't create a new record if "Swift" exists. 87 * Optionally pass pre-fetched tag records to avoid a redundant listRecords call. 88 */ 89export async function createNewTagRecords( 90 oauthSession: any, 91 tags: string[], 92 existingRecords?: any[], 93): Promise<void> { 94 const records = existingRecords ?? 95 await listAllRecords(oauthSession, TAG_COLLECTION); 96 const existingTagValues: string[] = records.map((rec: any) => 97 rec.value?.value 98 ).filter(Boolean); 99 const newTags = tags.filter((t) => !tagIncludes(existingTagValues, t)); 100 await Promise.all(newTags.map((tagValue) => 101 oauthSession.makeRequest( 102 "POST", 103 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.createRecord`, 104 { 105 headers: { "Content-Type": "application/json" }, 106 body: JSON.stringify({ 107 repo: oauthSession.did, 108 collection: TAG_COLLECTION, 109 record: { value: tagValue, createdAt: new Date().toISOString() }, 110 }), 111 }, 112 ).catch((err: any) => 113 console.error(`Failed to create tag "${tagValue}":`, err) 114 ) 115 )); 116} 117 118/** 119 * Fetch a single page of records from an AT Protocol collection. 120 * Returns records and an optional cursor for the next page. 121 */ 122export async function listOnePage( 123 oauthSession: any, 124 collection: string, 125 options?: { cursor?: string; reverse?: boolean; limit?: number }, 126): Promise<{ records: any[]; cursor?: string }> { 127 const params = new URLSearchParams({ 128 repo: oauthSession.did, 129 collection, 130 limit: String(options?.limit ?? 100), 131 }); 132 if (options?.cursor) params.set("cursor", options.cursor); 133 if (options?.reverse) params.set("reverse", "true"); 134 135 const res = await oauthSession.makeRequest( 136 "GET", 137 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, 138 ); 139 if (!res.ok) return { records: [] }; 140 141 const data = await res.json(); 142 return { 143 records: data.records || [], 144 cursor: data.cursor || undefined, 145 }; 146} 147 148/** 149 * Paginate through all records in an AT Protocol collection. 150 * Returns every record, following cursors until exhausted. 151 */ 152export async function listAllRecords( 153 oauthSession: any, 154 collection: string, 155): Promise<any[]> { 156 const all: any[] = []; 157 let cursor: string | undefined; 158 159 do { 160 const params = new URLSearchParams({ 161 repo: oauthSession.did, 162 collection, 163 limit: "100", 164 }); 165 if (cursor) params.set("cursor", cursor); 166 167 const res = await oauthSession.makeRequest( 168 "GET", 169 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, 170 ); 171 if (!res.ok) break; 172 173 const data = await res.json(); 174 all.push(...(data.records || [])); 175 cursor = data.cursor; 176 } while (cursor); 177 178 return all; 179} 180 181/** Re-export for convenience */ 182export { getClearSessionCookie, getSessionFromRequest };