The Appview for the kipclip.com atproto bookmarking service
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}