The Appview for the kipclip.com atproto bookmarking service
at main 216 lines 5.5 kB view raw
1/** 2 * Instapaper Simple API client. 3 * Sends articles to Instapaper using the Simple API. 4 * https://www.instapaper.com/api/simple 5 */ 6 7export interface InstapaperCredentials { 8 username: string; 9 password: string; 10} 11 12export interface InstapaperAddResult { 13 success: boolean; 14 error?: string; 15} 16 17/** 18 * Send an article to Instapaper. 19 * Uses Simple API with basic authentication. 20 */ 21export async function sendToInstapaper( 22 url: string, 23 credentials: InstapaperCredentials, 24 title?: string, 25): Promise<InstapaperAddResult> { 26 try { 27 // Validate URL 28 const parsedUrl = new URL(url); 29 if (!parsedUrl.protocol.startsWith("http")) { 30 throw new Error("Only HTTP(S) URLs are supported"); 31 } 32 33 // Build API request 34 const apiUrl = new URL("https://www.instapaper.com/api/add"); 35 apiUrl.searchParams.set("url", url); 36 if (title) { 37 apiUrl.searchParams.set("title", title); 38 } 39 40 // Use Basic Authentication 41 const authHeader = `Basic ${ 42 btoa(`${credentials.username}:${credentials.password}`) 43 }`; 44 45 const controller = new AbortController(); 46 const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout 47 48 const response = await fetch(apiUrl.toString(), { 49 method: "GET", 50 signal: controller.signal, 51 headers: { 52 "Authorization": authHeader, 53 "User-Agent": "kipclip/1.0 (+https://kipclip.com)", 54 }, 55 }); 56 57 clearTimeout(timeoutId); 58 59 // Instapaper returns 201 on success 60 if (response.status === 201) { 61 return { success: true }; 62 } 63 64 // Handle error responses 65 const statusText = response.statusText || "Unknown error"; 66 67 if (response.status === 403) { 68 return { 69 success: false, 70 error: "Invalid Instapaper credentials", 71 }; 72 } 73 74 if (response.status === 400) { 75 return { 76 success: false, 77 error: "Invalid URL or request", 78 }; 79 } 80 81 if (response.status === 500) { 82 return { 83 success: false, 84 error: "Instapaper service error", 85 }; 86 } 87 88 return { 89 success: false, 90 error: `Instapaper API error: ${response.status} ${statusText}`, 91 }; 92 } catch (error: any) { 93 console.error("Failed to send to Instapaper:", error); 94 95 if (error.name === "AbortError") { 96 return { 97 success: false, 98 error: "Request to Instapaper timed out", 99 }; 100 } 101 102 return { 103 success: false, 104 error: error.message || "Failed to send to Instapaper", 105 }; 106 } 107} 108 109/** 110 * Send bookmark to Instapaper asynchronously using stored credentials. 111 * Fetches credentials from DB, decrypts them, and sends. 112 * Silent failure: logs errors but doesn't throw to caller. 113 */ 114export async function sendToInstapaperAsync( 115 did: string, 116 url: string, 117 title?: string, 118): Promise<void> { 119 // Dynamic imports to avoid circular dependencies 120 const { rawDb } = await import("./db.ts"); 121 const { decrypt } = await import("./encryption.ts"); 122 123 try { 124 const result = await rawDb.execute({ 125 sql: `SELECT instapaper_username_encrypted, instapaper_password_encrypted 126 FROM user_settings 127 WHERE did = ? AND instapaper_enabled = 1`, 128 args: [did], 129 }); 130 131 if (!result.rows?.[0]) { 132 console.warn("Instapaper credentials not found for user:", did); 133 return; 134 } 135 136 const [encryptedUsername, encryptedPassword] = result.rows[0] as string[]; 137 if (!encryptedUsername || !encryptedPassword) { 138 console.warn("Instapaper credentials incomplete for user:", did); 139 return; 140 } 141 142 const username = await decrypt(encryptedUsername); 143 const password = await decrypt(encryptedPassword); 144 145 const instapaperResult = await sendToInstapaper( 146 url, 147 { username, password }, 148 title, 149 ); 150 151 if (instapaperResult.success) { 152 console.log(`Successfully sent to Instapaper: ${url}`); 153 } else { 154 console.error( 155 `Failed to send to Instapaper: ${instapaperResult.error}`, 156 { url, did }, 157 ); 158 } 159 } catch (error) { 160 console.error("Error in sendToInstapaperAsync:", error); 161 throw error; 162 } 163} 164 165/** 166 * Validate Instapaper credentials by attempting authentication. 167 * Returns true if credentials are valid. 168 */ 169export async function validateInstapaperCredentials( 170 credentials: InstapaperCredentials, 171): Promise<{ valid: boolean; error?: string }> { 172 try { 173 const authUrl = "https://www.instapaper.com/api/authenticate"; 174 const authHeader = `Basic ${ 175 btoa(`${credentials.username}:${credentials.password}`) 176 }`; 177 178 const controller = new AbortController(); 179 const timeoutId = setTimeout(() => controller.abort(), 5000); 180 181 const response = await fetch(authUrl, { 182 method: "GET", 183 signal: controller.signal, 184 headers: { 185 "Authorization": authHeader, 186 "User-Agent": "kipclip/1.0 (+https://kipclip.com)", 187 }, 188 }); 189 190 clearTimeout(timeoutId); 191 192 if (response.status === 200) { 193 return { valid: true }; 194 } 195 196 if (response.status === 403) { 197 return { valid: false, error: "Invalid username or password" }; 198 } 199 200 return { 201 valid: false, 202 error: `Authentication failed: ${response.status}`, 203 }; 204 } catch (error: any) { 205 console.error("Failed to validate Instapaper credentials:", error); 206 207 if (error.name === "AbortError") { 208 return { valid: false, error: "Request timed out" }; 209 } 210 211 return { 212 valid: false, 213 error: error.message || "Validation failed", 214 }; 215 } 216}