The Appview for the kipclip.com atproto bookmarking service

Add Instapaper integration with automatic article sending

Features:
- Automatically send bookmarks to Instapaper when tagged with reading list tag
- Encrypted credential storage using AES-GCM encryption
- Credential validation before saving (tests Instapaper API connection)
- Settings UI with toggle, username/password fields
- Enhanced success feedback with checkmark and verification message
- Silent failure pattern: bookmark updates succeed even if Instapaper fails

Security:
- Separate ENCRYPTION_KEY environment variable for credential encryption
- AES-GCM with 256-bit key derived via PBKDF2 (100,000 iterations)
- Passwords never logged or returned in API responses
- Credentials only decrypted when actively needed

OAuth improvements:
- Fix BASE_URL auto-detection to properly handle ngrok HTTPS forwarding
- Check X-Forwarded-Proto header for correct protocol detection
- Support dynamic ngrok URLs without manual configuration

Database:
- Migration 002: Add instapaper_enabled, instapaper_username_encrypted,
instapaper_password_encrypted columns to user_settings

Testing:
- All 73 tests passing
- Unit tests for encryption round-trip
- Unit tests for Instapaper API client with mocked fetch
- Integration tests for settings API with credential validation

+5
.env.example
··· 11 11 # You can generate one with: openssl rand -base64 32 12 12 COOKIE_SECRET=your-secure-random-secret-minimum-32-chars-long 13 13 14 + # Encryption key for sensitive user data (e.g., Instapaper credentials) 15 + # IMPORTANT: Generate a secure random string (minimum 32 characters) 16 + # You can generate one with: openssl rand -base64 32 17 + ENCRYPTION_KEY=your-secure-encryption-key-minimum-32-chars-long 18 + 14 19 # Optional: Custom port for local dev server (default: 8000) 15 20 # PORT=8000 16 21
+1 -1
deno.json
··· 70 70 "preview": "deno run -A main.ts", 71 71 "quality": "deno fmt --check && deno lint", 72 72 "check": "deno check --allow-import main.ts", 73 - "test": "TURSO_DATABASE_URL=libsql://test.turso.io TURSO_AUTH_TOKEN=test COOKIE_SECRET=test-cookie-secret-32-characters-minimum deno test --allow-all --unstable-kv tests/", 73 + "test": "TURSO_DATABASE_URL=file::memory: COOKIE_SECRET=test-cookie-secret-32-characters-minimum ENCRYPTION_KEY=test-encryption-key-for-unit-tests-only-minimum-32-chars deno test --allow-all --unstable-kv tests/", 74 74 "test:watch": "COOKIE_SECRET=test-cookie-secret-32-characters-minimum deno test --allow-all --watch", 75 75 "fmt": "deno fmt", 76 76 "lint": "deno lint"
+166 -21
frontend/components/Settings.tsx
··· 4 4 export function Settings() { 5 5 const { settings, updateSettings } = useApp(); 6 6 const [readingListTag, setReadingListTag] = useState(settings.readingListTag); 7 + const [instapaperEnabled, setInstapaperEnabled] = useState( 8 + settings.instapaperEnabled, 9 + ); 10 + const [instapaperUsername, setInstapaperUsername] = useState( 11 + settings.instapaperUsername || "", 12 + ); 13 + const [instapaperPassword, setInstapaperPassword] = useState(""); 7 14 const [saving, setSaving] = useState(false); 8 15 const [error, setError] = useState<string | null>(null); 9 16 const [success, setSuccess] = useState(false); ··· 11 18 // Sync local state when settings from context change 12 19 useEffect(() => { 13 20 setReadingListTag(settings.readingListTag); 14 - }, [settings.readingListTag]); 21 + setInstapaperEnabled(settings.instapaperEnabled); 22 + setInstapaperUsername(settings.instapaperUsername || ""); 23 + }, [ 24 + settings.readingListTag, 25 + settings.instapaperEnabled, 26 + settings.instapaperUsername, 27 + ]); 15 28 16 29 async function handleSubmit(e: React.FormEvent) { 17 30 e.preventDefault(); ··· 20 33 setSaving(true); 21 34 22 35 try { 23 - await updateSettings({ readingListTag: readingListTag.trim() }); 36 + const updates: any = { 37 + readingListTag: readingListTag.trim(), 38 + instapaperEnabled, 39 + }; 40 + 41 + // Only send credentials if Instapaper is enabled 42 + if (instapaperEnabled) { 43 + // Always include username if enabled 44 + updates.instapaperUsername = instapaperUsername.trim(); 45 + 46 + // Only include password if changed (not empty) 47 + if (instapaperPassword.trim().length > 0) { 48 + updates.instapaperPassword = instapaperPassword; 49 + } 50 + } 51 + 52 + await updateSettings(updates); 24 53 setSuccess(true); 54 + setInstapaperPassword(""); // Clear password field after save 25 55 setTimeout(() => setSuccess(false), 3000); 26 56 } catch (err: any) { 27 57 setError(err.message || "Failed to save settings"); ··· 30 60 } 31 61 } 32 62 33 - const hasChanges = readingListTag.trim() !== settings.readingListTag; 63 + const hasChanges = readingListTag.trim() !== settings.readingListTag || 64 + instapaperEnabled !== settings.instapaperEnabled || 65 + (instapaperEnabled && 66 + instapaperUsername.trim() !== (settings.instapaperUsername || "")) || 67 + instapaperPassword.trim().length > 0; 34 68 35 69 return ( 36 70 <div className="min-h-screen bg-gray-50"> ··· 103 137 </p> 104 138 </div> 105 139 </div> 140 + </section> 106 141 107 - {error && ( 108 - <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> 109 - {error} 142 + {/* Instapaper Integration Section */} 143 + <section className="bg-white rounded-lg shadow-md p-6 space-y-6"> 144 + <div> 145 + <h3 className="text-xl font-bold text-gray-800 mb-4"> 146 + Instapaper Integration 147 + </h3> 148 + <p className="text-gray-600 mb-4"> 149 + Automatically send articles to Instapaper when you tag them with 150 + your reading list tag ("{readingListTag || "toread"}"). 151 + </p> 152 + 153 + {/* Enable toggle */} 154 + <div className="flex items-center space-x-3 mb-4"> 155 + <input 156 + type="checkbox" 157 + id="instapaperEnabled" 158 + checked={instapaperEnabled} 159 + onChange={(e) => setInstapaperEnabled(e.target.checked)} 160 + className="w-4 h-4 rounded border-gray-300 text-coral focus:ring-coral" 161 + /> 162 + <label 163 + htmlFor="instapaperEnabled" 164 + className="text-sm font-medium text-gray-700" 165 + > 166 + Send articles to Instapaper 167 + </label> 110 168 </div> 111 - )} 169 + 170 + {/* Credentials (shown when enabled) */} 171 + {instapaperEnabled && ( 172 + <div className="space-y-4 pl-7"> 173 + <div className="space-y-2"> 174 + <label 175 + htmlFor="instapaperUsername" 176 + className="block text-sm font-medium text-gray-700" 177 + > 178 + Instapaper Email/Username 179 + </label> 180 + <input 181 + type="text" 182 + id="instapaperUsername" 183 + value={instapaperUsername} 184 + onChange={(e) => setInstapaperUsername(e.target.value)} 185 + placeholder="your@email.com" 186 + className="w-full max-w-md px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-coral/50 focus:border-coral" 187 + required={instapaperEnabled} 188 + /> 189 + </div> 112 190 113 - {success && ( 114 - <div className="p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm"> 115 - Settings saved successfully! 116 - </div> 117 - )} 191 + <div className="space-y-2"> 192 + <label 193 + htmlFor="instapaperPassword" 194 + className="block text-sm font-medium text-gray-700" 195 + > 196 + Instapaper Password 197 + </label> 198 + <input 199 + type="password" 200 + id="instapaperPassword" 201 + value={instapaperPassword} 202 + onChange={(e) => setInstapaperPassword(e.target.value)} 203 + placeholder={settings.instapaperUsername 204 + ? "Leave blank to keep current password" 205 + : "Enter password"} 206 + className="w-full max-w-md px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-coral/50 focus:border-coral" 207 + required={instapaperEnabled && 208 + !settings.instapaperUsername} 209 + /> 210 + <p className="text-xs text-gray-500"> 211 + {settings.instapaperUsername 212 + ? "Leave blank to keep your current password" 213 + : "Your password is encrypted and stored securely"} 214 + </p> 215 + </div> 118 216 119 - <div className="pt-4 border-t border-gray-200"> 120 - <button 121 - type="submit" 122 - disabled={saving || !hasChanges} 123 - className="px-6 py-2 rounded-lg font-medium text-white transition-opacity disabled:opacity-50 disabled:cursor-not-allowed" 124 - style={{ backgroundColor: "var(--coral)" }} 125 - > 126 - {saving ? "Saving..." : "Save Settings"} 127 - </button> 217 + <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg"> 218 + <p className="text-sm text-blue-800"> 219 + When you tag a bookmark with "{readingListTag || "toread"} 220 + ", it will be automatically sent to your Instapaper 221 + account. 222 + </p> 223 + </div> 224 + </div> 225 + )} 128 226 </div> 129 227 </section> 228 + 229 + {error && ( 230 + <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> 231 + {error} 232 + </div> 233 + )} 234 + 235 + {success && ( 236 + <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> 237 + <div className="flex items-start gap-3"> 238 + <svg 239 + className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" 240 + fill="currentColor" 241 + viewBox="0 0 20 20" 242 + > 243 + <path 244 + fillRule="evenodd" 245 + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" 246 + clipRule="evenodd" 247 + /> 248 + </svg> 249 + <div className="flex-1"> 250 + <p className="text-sm font-medium text-green-800"> 251 + Settings saved successfully! 252 + </p> 253 + {instapaperEnabled && ( 254 + <p className="text-sm text-green-700 mt-1"> 255 + ✓ Instapaper connection verified - your articles will be sent 256 + automatically 257 + </p> 258 + )} 259 + </div> 260 + </div> 261 + </div> 262 + )} 263 + 264 + {/* Save Button */} 265 + <div className="pt-4 border-t border-gray-200"> 266 + <button 267 + type="submit" 268 + disabled={saving || !hasChanges} 269 + className="px-6 py-2 rounded-lg font-medium text-white transition-opacity disabled:opacity-50 disabled:cursor-not-allowed" 270 + style={{ backgroundColor: "var(--coral)" }} 271 + > 272 + {saving ? "Saving..." : "Save Settings"} 273 + </button> 274 + </div> 130 275 </form> 131 276 </main> 132 277 </div>
+1
frontend/context/AppContext.tsx
··· 16 16 17 17 const DEFAULT_SETTINGS: UserSettings = { 18 18 readingListTag: "toread", 19 + instapaperEnabled: false, 19 20 }; 20 21 21 22 interface AppState {
+100
lib/encryption.ts
··· 1 + /** 2 + * Encryption utilities for sensitive user data. 3 + * Uses Web Crypto API with AES-GCM for secure encryption. 4 + */ 5 + 6 + const ENCRYPTION_KEY = Deno.env.get("ENCRYPTION_KEY"); 7 + const ALGORITHM = "AES-GCM"; 8 + const KEY_LENGTH = 256; 9 + 10 + /** 11 + * Derive a CryptoKey from the ENCRYPTION_KEY environment variable. 12 + * Cached for performance. 13 + */ 14 + let cachedKey: CryptoKey | null = null; 15 + 16 + async function getEncryptionKey(): Promise<CryptoKey> { 17 + if (!ENCRYPTION_KEY) { 18 + throw new Error("ENCRYPTION_KEY environment variable is required"); 19 + } 20 + 21 + if (cachedKey) { 22 + return cachedKey; 23 + } 24 + 25 + // Derive key from environment variable using PBKDF2 26 + const encoder = new TextEncoder(); 27 + const keyMaterial = await crypto.subtle.importKey( 28 + "raw", 29 + encoder.encode(ENCRYPTION_KEY), 30 + "PBKDF2", 31 + false, 32 + ["deriveKey"], 33 + ); 34 + 35 + cachedKey = await crypto.subtle.deriveKey( 36 + { 37 + name: "PBKDF2", 38 + salt: encoder.encode("kipclip-instapaper-v1"), // Static salt for deterministic key 39 + iterations: 100000, 40 + hash: "SHA-256", 41 + }, 42 + keyMaterial, 43 + { name: ALGORITHM, length: KEY_LENGTH }, 44 + false, 45 + ["encrypt", "decrypt"], 46 + ); 47 + 48 + return cachedKey; 49 + } 50 + 51 + /** 52 + * Encrypt a plaintext string. 53 + * Returns base64-encoded ciphertext with IV prepended. 54 + */ 55 + export async function encrypt(plaintext: string): Promise<string> { 56 + const key = await getEncryptionKey(); 57 + const encoder = new TextEncoder(); 58 + const data = encoder.encode(plaintext); 59 + 60 + // Generate random IV (12 bytes for GCM) 61 + const iv = crypto.getRandomValues(new Uint8Array(12)); 62 + 63 + const ciphertext = await crypto.subtle.encrypt( 64 + { name: ALGORITHM, iv }, 65 + key, 66 + data, 67 + ); 68 + 69 + // Prepend IV to ciphertext (IV is not secret) 70 + const combined = new Uint8Array(iv.length + ciphertext.byteLength); 71 + combined.set(iv, 0); 72 + combined.set(new Uint8Array(ciphertext), iv.length); 73 + 74 + // Return base64-encoded string 75 + return btoa(String.fromCharCode(...combined)); 76 + } 77 + 78 + /** 79 + * Decrypt a ciphertext string. 80 + * Expects base64-encoded ciphertext with IV prepended. 81 + */ 82 + export async function decrypt(ciphertext: string): Promise<string> { 83 + const key = await getEncryptionKey(); 84 + 85 + // Decode base64 86 + const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)); 87 + 88 + // Extract IV (first 12 bytes) and ciphertext 89 + const iv = combined.slice(0, 12); 90 + const data = combined.slice(12); 91 + 92 + const plaintext = await crypto.subtle.decrypt( 93 + { name: ALGORITHM, iv }, 94 + key, 95 + data, 96 + ); 97 + 98 + const decoder = new TextDecoder(); 99 + return decoder.decode(plaintext); 100 + }
+160
lib/instapaper.ts
··· 1 + /** 2 + * Instapaper Simple API client. 3 + * Sends articles to Instapaper using the Simple API. 4 + * https://www.instapaper.com/api/simple 5 + */ 6 + 7 + export interface InstapaperCredentials { 8 + username: string; 9 + password: string; 10 + } 11 + 12 + export 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 + */ 21 + export 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 + * Validate Instapaper credentials by attempting authentication. 111 + * Returns true if credentials are valid. 112 + */ 113 + export async function validateInstapaperCredentials( 114 + credentials: InstapaperCredentials, 115 + ): Promise<{ valid: boolean; error?: string }> { 116 + try { 117 + const authUrl = "https://www.instapaper.com/api/authenticate"; 118 + const authHeader = `Basic ${ 119 + btoa(`${credentials.username}:${credentials.password}`) 120 + }`; 121 + 122 + const controller = new AbortController(); 123 + const timeoutId = setTimeout(() => controller.abort(), 5000); 124 + 125 + const response = await fetch(authUrl, { 126 + method: "GET", 127 + signal: controller.signal, 128 + headers: { 129 + "Authorization": authHeader, 130 + "User-Agent": "kipclip/1.0 (+https://kipclip.com)", 131 + }, 132 + }); 133 + 134 + clearTimeout(timeoutId); 135 + 136 + if (response.status === 200) { 137 + return { valid: true }; 138 + } 139 + 140 + if (response.status === 403) { 141 + return { valid: false, error: "Invalid username or password" }; 142 + } 143 + 144 + return { 145 + valid: false, 146 + error: `Authentication failed: ${response.status}`, 147 + }; 148 + } catch (error: any) { 149 + console.error("Failed to validate Instapaper credentials:", error); 150 + 151 + if (error.name === "AbortError") { 152 + return { valid: false, error: "Request timed out" }; 153 + } 154 + 155 + return { 156 + valid: false, 157 + error: error.message || "Validation failed", 158 + }; 159 + } 160 + }
+9
lib/migrations.ts
··· 35 35 CREATE INDEX IF NOT EXISTS idx_user_settings_did ON user_settings(did) 36 36 `, 37 37 }, 38 + { 39 + version: "002", 40 + description: "Add Instapaper integration fields to user_settings", 41 + sql: ` 42 + ALTER TABLE user_settings ADD COLUMN instapaper_enabled INTEGER DEFAULT 0; 43 + ALTER TABLE user_settings ADD COLUMN instapaper_username_encrypted TEXT; 44 + ALTER TABLE user_settings ADD COLUMN instapaper_password_encrypted TEXT 45 + `, 46 + }, 38 47 ]; 39 48 40 49 export async function runMigrations() {
+13 -10
lib/oauth-config.ts
··· 8 8 import { rawDb } from "./db.ts"; 9 9 import { OAUTH_SCOPES } from "./route-utils.ts"; 10 10 11 - // Cookie secret is always required 12 - const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 13 - if (!COOKIE_SECRET) { 14 - throw new Error("COOKIE_SECRET environment variable is required"); 15 - } 16 - 17 11 // OAuth instance and base URL - initialized lazily 18 12 let oauth: ReturnType<typeof createATProtoOAuth> | null = null; 19 13 let baseUrl: string | null = Deno.env.get("BASE_URL") || null; ··· 29 23 } 30 24 31 25 /** 32 - * Initialize OAuth with the given request URL. 26 + * Initialize OAuth with the given request. 33 27 * If BASE_URL env var is set, uses that. Otherwise derives from request. 34 28 * Safe to call multiple times - only initializes once. 35 29 */ 36 30 export function initOAuth( 37 - requestUrl: string, 31 + request: Request, 38 32 ): ReturnType<typeof createATProtoOAuth> { 39 33 if (oauth) return oauth; 40 34 35 + // Cookie secret is required 36 + const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 37 + if (!COOKIE_SECRET) { 38 + throw new Error("COOKIE_SECRET environment variable is required"); 39 + } 40 + 41 41 // Derive base URL from request if not set in environment 42 42 if (!baseUrl) { 43 - const url = new URL(requestUrl); 44 - baseUrl = `${url.protocol}//${url.host}`; 43 + const url = new URL(request.url); 44 + // Check for X-Forwarded-Proto header (set by ngrok and other proxies) 45 + const forwardedProto = request.headers.get("X-Forwarded-Proto"); 46 + const protocol = forwardedProto || url.protocol.replace(":", ""); 47 + baseUrl = `${protocol}://${url.host}`; 45 48 console.log(`Derived BASE_URL from request: ${baseUrl}`); 46 49 } 47 50
+23 -14
lib/session.ts
··· 24 24 testSessionProvider = provider; 25 25 } 26 26 27 - // Session configuration from environment 28 - const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 29 - if (!COOKIE_SECRET) { 30 - throw new Error("COOKIE_SECRET environment variable is required"); 27 + // Session configuration from environment (lazy-loaded) 28 + let sessions: SessionManager | null = null; 29 + 30 + function getSessionManager(): SessionManager { 31 + if (!sessions) { 32 + const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 33 + if (!COOKIE_SECRET) { 34 + throw new Error("COOKIE_SECRET environment variable is required"); 35 + } 36 + 37 + // Create session manager for cookie handling (framework-agnostic) 38 + sessions = new SessionManager({ 39 + cookieSecret: COOKIE_SECRET, 40 + cookieName: "sid", 41 + sessionTtl: 60 * 60 * 24 * 14, // 14 days 42 + logger: console, 43 + }); 44 + } 45 + return sessions; 31 46 } 32 - 33 - // Create session manager for cookie handling (framework-agnostic) 34 - const sessions = new SessionManager({ 35 - cookieSecret: COOKIE_SECRET, 36 - cookieName: "sid", 37 - sessionTtl: 60 * 60 * 24 * 14, // 14 days 38 - logger: console, 39 - }); 40 47 41 48 export interface SessionResult { 42 49 session: SessionInterface | null; ··· 85 92 86 93 try { 87 94 // Step 1: Extract session data from cookie using atproto-sessions 88 - const cookieResult = await sessions.getSessionFromRequest(request); 95 + const cookieResult = await getSessionManager().getSessionFromRequest( 96 + request, 97 + ); 89 98 90 99 if (!cookieResult.data) { 91 100 const errorType = cookieResult.error?.type || "NO_SESSION"; ··· 248 257 * Get clear cookie header for session cleanup. 249 258 */ 250 259 export function getClearSessionCookie(): string { 251 - return sessions.getClearCookieHeader(); 260 + return getSessionManager().getClearCookieHeader(); 252 261 }
+114 -18
lib/settings.ts
··· 5 5 6 6 import { rawDb } from "./db.ts"; 7 7 import type { UserSettings } from "../shared/types.ts"; 8 + import { decrypt, encrypt } from "./encryption.ts"; 8 9 9 10 const DEFAULT_READING_LIST_TAG = "toread"; 10 11 ··· 15 16 export async function getUserSettings(did: string): Promise<UserSettings> { 16 17 // Try to get existing settings 17 18 const result = await rawDb.execute({ 18 - sql: "SELECT reading_list_tag FROM user_settings WHERE did = ?", 19 + sql: `SELECT 20 + reading_list_tag, 21 + instapaper_enabled, 22 + instapaper_username_encrypted 23 + FROM user_settings 24 + WHERE did = ?`, 19 25 args: [did], 20 26 }); 21 27 22 28 if (result.rows && result.rows.length > 0) { 23 - const row = result.rows[0] as string[]; 29 + const row = result.rows[0] as (string | number | null)[]; 30 + const readingListTag = String(row[0] || DEFAULT_READING_LIST_TAG); 31 + const instapaperEnabled = row[1] === 1 || row[1] === "1"; 32 + const encryptedUsername = row[2] ? String(row[2]) : null; 33 + 34 + // Decrypt username if available 35 + let instapaperUsername: string | undefined; 36 + if (instapaperEnabled && encryptedUsername) { 37 + try { 38 + instapaperUsername = await decrypt(encryptedUsername); 39 + } catch (error) { 40 + console.error("Failed to decrypt Instapaper username:", error); 41 + } 42 + } 43 + 24 44 return { 25 - readingListTag: row[0] || DEFAULT_READING_LIST_TAG, 45 + readingListTag, 46 + instapaperEnabled, 47 + instapaperUsername, 26 48 }; 27 49 } 28 50 ··· 34 56 35 57 return { 36 58 readingListTag: DEFAULT_READING_LIST_TAG, 59 + instapaperEnabled: false, 37 60 }; 38 61 } 39 62 ··· 43 66 */ 44 67 export async function updateUserSettings( 45 68 did: string, 46 - updates: Partial<UserSettings>, 69 + updates: Partial<UserSettings> & { instapaperPassword?: string }, 47 70 ): Promise<UserSettings> { 48 71 // Validate reading list tag if provided 49 72 if (updates.readingListTag !== undefined) { ··· 59 82 updates.readingListTag = tag; 60 83 } 61 84 85 + // Validate Instapaper settings 86 + if (updates.instapaperEnabled) { 87 + // Check if credentials are provided or already exist 88 + if (!updates.instapaperUsername && !updates.instapaperPassword) { 89 + const existing = await rawDb.execute({ 90 + sql: `SELECT instapaper_username_encrypted 91 + FROM user_settings 92 + WHERE did = ?`, 93 + args: [did], 94 + }); 95 + 96 + if (!existing.rows?.[0]?.[0]) { 97 + throw new Error("Instapaper username and password are required"); 98 + } 99 + } 100 + 101 + if ( 102 + updates.instapaperUsername && 103 + updates.instapaperUsername.trim().length === 0 104 + ) { 105 + throw new Error("Instapaper username cannot be empty"); 106 + } 107 + 108 + if (updates.instapaperPassword && updates.instapaperPassword.length === 0) { 109 + throw new Error("Instapaper password cannot be empty"); 110 + } 111 + } 112 + 62 113 // Check if settings exist 63 114 const existing = await rawDb.execute({ 64 115 sql: "SELECT id FROM user_settings WHERE did = ?", 65 116 args: [did], 66 117 }); 67 118 68 - if (existing.rows && existing.rows.length > 0) { 69 - // Update existing settings 70 - if (updates.readingListTag !== undefined) { 71 - await rawDb.execute({ 72 - sql: `UPDATE user_settings 73 - SET reading_list_tag = ?, updated_at = CURRENT_TIMESTAMP 74 - WHERE did = ?`, 75 - args: [updates.readingListTag, did], 76 - }); 77 - } 78 - } else { 79 - // Create new settings with provided values or defaults 119 + const settingsExist = existing.rows && existing.rows.length > 0; 120 + 121 + // Build update query dynamically 122 + const updateFields: string[] = []; 123 + const updateValues: (string | number)[] = []; 124 + 125 + if (updates.readingListTag !== undefined) { 126 + updateFields.push("reading_list_tag = ?"); 127 + updateValues.push(updates.readingListTag); 128 + } 129 + 130 + if (updates.instapaperEnabled !== undefined) { 131 + updateFields.push("instapaper_enabled = ?"); 132 + updateValues.push(updates.instapaperEnabled ? 1 : 0); 133 + } 134 + 135 + // Encrypt and update credentials if provided 136 + if (updates.instapaperUsername !== undefined) { 137 + const encrypted = await encrypt(updates.instapaperUsername.trim()); 138 + updateFields.push("instapaper_username_encrypted = ?"); 139 + updateValues.push(encrypted); 140 + } 141 + 142 + if (updates.instapaperPassword !== undefined) { 143 + const encrypted = await encrypt(updates.instapaperPassword); 144 + updateFields.push("instapaper_password_encrypted = ?"); 145 + updateValues.push(encrypted); 146 + } 147 + 148 + if (settingsExist && updateFields.length > 0) { 149 + updateFields.push("updated_at = CURRENT_TIMESTAMP"); 150 + updateValues.push(did); // WHERE did = ? 151 + 80 152 await rawDb.execute({ 81 - sql: "INSERT INTO user_settings (did, reading_list_tag) VALUES (?, ?)", 82 - args: [did, updates.readingListTag || DEFAULT_READING_LIST_TAG], 153 + sql: `UPDATE user_settings 154 + SET ${updateFields.join(", ")} 155 + WHERE did = ?`, 156 + args: updateValues, 157 + }); 158 + } else if (!settingsExist) { 159 + // Create new settings 160 + const encryptedUsername = updates.instapaperUsername 161 + ? await encrypt(updates.instapaperUsername.trim()) 162 + : null; 163 + const encryptedPassword = updates.instapaperPassword 164 + ? await encrypt(updates.instapaperPassword) 165 + : null; 166 + 167 + await rawDb.execute({ 168 + sql: `INSERT INTO user_settings 169 + (did, reading_list_tag, instapaper_enabled, 170 + instapaper_username_encrypted, instapaper_password_encrypted) 171 + VALUES (?, ?, ?, ?, ?)`, 172 + args: [ 173 + did, 174 + updates.readingListTag || DEFAULT_READING_LIST_TAG, 175 + updates.instapaperEnabled ? 1 : 0, 176 + encryptedUsername, 177 + encryptedPassword, 178 + ], 83 179 }); 84 180 } 85 181
+10 -1
main.ts
··· 3 3 * Orchestrates route registration and middleware setup. 4 4 */ 5 5 6 + // Load environment variables from .env file (local development) 7 + import { load } from "@std/dotenv"; 8 + try { 9 + await load({ export: true }); 10 + console.log("✅ Loaded .env file"); 11 + } catch (error) { 12 + console.warn("⚠️ Failed to load .env file:", error.message); 13 + } 14 + 6 15 import { App, staticFiles } from "@fresh/core"; 7 16 import { initializeTables } from "./lib/db.ts"; 8 17 import { initOAuth } from "./lib/oauth-config.ts"; ··· 41 50 42 51 // Initialize OAuth on first request (derives BASE_URL from request if not set) 43 52 app = app.use(async (ctx) => { 44 - initOAuth(ctx.req.url); 53 + initOAuth(ctx.req); 45 54 return await ctx.next(); 46 55 }); 47 56
+77
routes/api/bookmarks.ts
··· 11 11 getSessionFromRequest, 12 12 setSessionCookie, 13 13 } from "../../lib/route-utils.ts"; 14 + import { getUserSettings } from "../../lib/settings.ts"; 15 + import { sendToInstapaper } from "../../lib/instapaper.ts"; 16 + import { decrypt } from "../../lib/encryption.ts"; 17 + import { rawDb } from "../../lib/db.ts"; 14 18 import type { 15 19 AddBookmarkRequest, 16 20 AddBookmarkResponse, ··· 244 248 favicon: record.$enriched?.favicon, 245 249 }; 246 250 251 + // Check if should send to Instapaper 252 + const settings = await getUserSettings(oauthSession.did); 253 + const hasReadingListTag = record.tags.includes(settings.readingListTag); 254 + const hadReadingListTag = 255 + currentRecord.value.tags?.includes(settings.readingListTag) || false; 256 + const isNewReadingListItem = hasReadingListTag && !hadReadingListTag; 257 + 258 + if (settings.instapaperEnabled && isNewReadingListItem) { 259 + sendToInstapaperAsync( 260 + oauthSession.did, 261 + record.subject, 262 + record.$enriched?.title, 263 + ).catch((error) => 264 + console.error("Failed to send bookmark to Instapaper:", error) 265 + ); 266 + } 267 + 247 268 const result: UpdateBookmarkTagsResponse = { success: true, bookmark }; 248 269 return setSessionCookie(Response.json(result), setCookieHeader); 249 270 } catch (error: any) { ··· 292 313 293 314 return app; 294 315 } 316 + 317 + /** 318 + * Helper function to send bookmark to Instapaper asynchronously. 319 + * Fetches credentials, decrypts them, and sends to Instapaper. 320 + * Silent failure: logs errors but doesn't throw. 321 + */ 322 + async function sendToInstapaperAsync( 323 + did: string, 324 + url: string, 325 + title?: string, 326 + ): Promise<void> { 327 + try { 328 + // Fetch encrypted credentials 329 + const result = await rawDb.execute({ 330 + sql: `SELECT instapaper_username_encrypted, instapaper_password_encrypted 331 + FROM user_settings 332 + WHERE did = ? AND instapaper_enabled = 1`, 333 + args: [did], 334 + }); 335 + 336 + if (!result.rows?.[0]) { 337 + console.warn("Instapaper credentials not found for user:", did); 338 + return; 339 + } 340 + 341 + const [encryptedUsername, encryptedPassword] = result.rows[0] as string[]; 342 + 343 + if (!encryptedUsername || !encryptedPassword) { 344 + console.warn("Instapaper credentials incomplete for user:", did); 345 + return; 346 + } 347 + 348 + // Decrypt credentials 349 + const username = await decrypt(encryptedUsername); 350 + const password = await decrypt(encryptedPassword); 351 + 352 + // Send to Instapaper 353 + const instapaperResult = await sendToInstapaper( 354 + url, 355 + { username, password }, 356 + title, 357 + ); 358 + 359 + if (instapaperResult.success) { 360 + console.log(`Successfully sent to Instapaper: ${url}`); 361 + } else { 362 + console.error( 363 + `Failed to send to Instapaper: ${instapaperResult.error}`, 364 + { url, did }, 365 + ); 366 + } 367 + } catch (error) { 368 + console.error("Error in sendToInstapaperAsync:", error); 369 + throw error; 370 + } 371 + }
+63 -1
routes/api/settings.ts
··· 10 10 setSessionCookie, 11 11 } from "../../lib/route-utils.ts"; 12 12 import { getUserSettings, updateUserSettings } from "../../lib/settings.ts"; 13 + import { validateInstapaperCredentials } from "../../lib/instapaper.ts"; 14 + import { decrypt } from "../../lib/encryption.ts"; 15 + import { rawDb } from "../../lib/db.ts"; 13 16 import type { 14 17 GetSettingsResponse, 15 18 UpdateSettingsRequest, ··· 46 49 return createAuthErrorResponse(error); 47 50 } 48 51 49 - const body = (await ctx.req.json()) as UpdateSettingsRequest; 52 + const body = (await ctx.req.json()) as UpdateSettingsRequest & { 53 + instapaperPassword?: string; 54 + }; 55 + 56 + // Validate Instapaper credentials if enabling or updating 57 + if ( 58 + body.instapaperEnabled && 59 + (body.instapaperUsername || body.instapaperPassword) 60 + ) { 61 + // Need both username and password for validation 62 + let username = body.instapaperUsername; 63 + let password = body.instapaperPassword; 64 + 65 + // If only one is provided, fetch the other from existing settings 66 + if (!username || !password) { 67 + const existingSettings = await getUserSettings(oauthSession.did); 68 + username = username || existingSettings.instapaperUsername; 69 + password = password || 70 + (await getInstapaperPassword(oauthSession.did)); 71 + } 72 + 73 + if (username && password) { 74 + const validation = await validateInstapaperCredentials({ 75 + username, 76 + password, 77 + }); 78 + 79 + if (!validation.valid) { 80 + const result: UpdateSettingsResponse = { 81 + success: false, 82 + error: validation.error || "Invalid Instapaper credentials", 83 + }; 84 + return Response.json(result, { status: 400 }); 85 + } 86 + } 87 + } 88 + 50 89 const settings = await updateUserSettings(oauthSession.did, body); 51 90 const result: UpdateSettingsResponse = { success: true, settings }; 52 91 return setSessionCookie(Response.json(result), setCookieHeader); ··· 62 101 63 102 return app; 64 103 } 104 + 105 + /** 106 + * Helper function to get encrypted password for validation. 107 + */ 108 + async function getInstapaperPassword( 109 + did: string, 110 + ): Promise<string | undefined> { 111 + const result = await rawDb.execute({ 112 + sql: 113 + "SELECT instapaper_password_encrypted FROM user_settings WHERE did = ?", 114 + args: [did], 115 + }); 116 + 117 + if (result.rows?.[0]?.[0]) { 118 + try { 119 + return await decrypt(result.rows[0][0] as string); 120 + } catch (error) { 121 + console.error("Failed to decrypt Instapaper password:", error); 122 + } 123 + } 124 + 125 + return undefined; 126 + }
+5
shared/types.ts
··· 111 111 // User settings (stored in database) 112 112 export interface UserSettings { 113 113 readingListTag: string; 114 + instapaperEnabled: boolean; 115 + instapaperUsername?: string; // Decrypted, only in memory (never includes password) 114 116 } 115 117 116 118 // Settings API response ··· 121 123 // Settings API update request 122 124 export interface UpdateSettingsRequest { 123 125 readingListTag?: string; 126 + instapaperEnabled?: boolean; 127 + instapaperUsername?: string; 128 + instapaperPassword?: string; // Only when updating credentials 124 129 } 125 130 126 131 // Settings API update response
+1 -1
tests/api.test.ts
··· 13 13 import { initOAuth } from "../lib/oauth-config.ts"; 14 14 15 15 // Initialize OAuth with test URL before running tests 16 - initOAuth("https://kipclip.com"); 16 + initOAuth(new Request("https://kipclip.com")); 17 17 18 18 // Create handler from app 19 19 const handler = app.handler();
+1 -1
tests/bookmarks.test.ts
··· 18 18 } from "./test-helpers.ts"; 19 19 20 20 // Initialize OAuth with test URL 21 - initOAuth("https://kipclip.com"); 21 + initOAuth(new Request("https://kipclip.com")); 22 22 const handler = app.handler(); 23 23 24 24 // Store original fetch
+72
tests/encryption.test.ts
··· 1 + /** 2 + * Tests for encryption utilities. 3 + * Verifies encryption/decryption with mocked environment. 4 + */ 5 + 6 + import { assertEquals, assertNotEquals, assertRejects } from "@std/assert"; 7 + import { decrypt, encrypt } from "../lib/encryption.ts"; 8 + 9 + // Set test encryption key 10 + Deno.env.set( 11 + "ENCRYPTION_KEY", 12 + "test-encryption-key-for-unit-tests-only-minimum-32-chars", 13 + ); 14 + 15 + Deno.test("encrypt - encrypts plaintext to base64 string", async () => { 16 + const plaintext = "my-secret-password"; 17 + const ciphertext = await encrypt(plaintext); 18 + 19 + // Should be base64 encoded 20 + assertEquals(typeof ciphertext, "string"); 21 + assertNotEquals(ciphertext, plaintext); 22 + 23 + // Should contain IV + ciphertext (at least 16 bytes base64) 24 + assertEquals(ciphertext.length > 16, true); 25 + }); 26 + 27 + Deno.test("decrypt - decrypts ciphertext back to plaintext", async () => { 28 + const plaintext = "my-secret-password"; 29 + const ciphertext = await encrypt(plaintext); 30 + const decrypted = await decrypt(ciphertext); 31 + 32 + assertEquals(decrypted, plaintext); 33 + }); 34 + 35 + Deno.test( 36 + "encrypt - produces different ciphertext each time (random IV)", 37 + async () => { 38 + const plaintext = "my-secret-password"; 39 + const ciphertext1 = await encrypt(plaintext); 40 + const ciphertext2 = await encrypt(plaintext); 41 + 42 + // Different IVs should produce different ciphertexts 43 + assertNotEquals(ciphertext1, ciphertext2); 44 + 45 + // But both should decrypt to same plaintext 46 + assertEquals(await decrypt(ciphertext1), plaintext); 47 + assertEquals(await decrypt(ciphertext2), plaintext); 48 + }, 49 + ); 50 + 51 + Deno.test("decrypt - throws on invalid ciphertext", async () => { 52 + await assertRejects( 53 + async () => await decrypt("invalid-base64!@#"), 54 + Error, 55 + ); 56 + }); 57 + 58 + Deno.test("encrypt/decrypt - handles special characters", async () => { 59 + const plaintext = "pässwörd with spëcial chàrs! 🔐"; 60 + const ciphertext = await encrypt(plaintext); 61 + const decrypted = await decrypt(ciphertext); 62 + 63 + assertEquals(decrypted, plaintext); 64 + }); 65 + 66 + Deno.test("encrypt/decrypt - handles empty string", async () => { 67 + const plaintext = ""; 68 + const ciphertext = await encrypt(plaintext); 69 + const decrypted = await decrypt(ciphertext); 70 + 71 + assertEquals(decrypted, plaintext); 72 + });
+144
tests/instapaper.test.ts
··· 1 + /** 2 + * Tests for Instapaper API client. 3 + * Uses mocked fetch to avoid external dependencies. 4 + */ 5 + 6 + import { assertEquals } from "@std/assert"; 7 + import { 8 + sendToInstapaper, 9 + validateInstapaperCredentials, 10 + } from "../lib/instapaper.ts"; 11 + 12 + // Mock fetch responses 13 + function createMockFetch( 14 + responses: Map<string, Response>, 15 + ): typeof fetch { 16 + return ( 17 + input: RequestInfo | URL, 18 + _init?: RequestInit, 19 + ): Promise<Response> => { 20 + const url = typeof input === "string" ? input : input.toString(); 21 + 22 + for (const [pattern, response] of responses) { 23 + if (url.includes(pattern)) { 24 + return Promise.resolve(response.clone()); 25 + } 26 + } 27 + 28 + return Promise.resolve(new Response("Not Found", { status: 404 })); 29 + }; 30 + } 31 + 32 + Deno.test("sendToInstapaper - success returns true", async () => { 33 + const originalFetch = globalThis.fetch; 34 + globalThis.fetch = createMockFetch( 35 + new Map([ 36 + ["instapaper.com/api/add", new Response("", { status: 201 })], 37 + ]), 38 + ); 39 + 40 + try { 41 + const result = await sendToInstapaper( 42 + "https://example.com/article", 43 + { username: "test@example.com", password: "testpass" }, 44 + ); 45 + 46 + assertEquals(result.success, true); 47 + assertEquals(result.error, undefined); 48 + } finally { 49 + globalThis.fetch = originalFetch; 50 + } 51 + }); 52 + 53 + Deno.test( 54 + "sendToInstapaper - invalid credentials returns error", 55 + async () => { 56 + const originalFetch = globalThis.fetch; 57 + globalThis.fetch = createMockFetch( 58 + new Map([ 59 + ["instapaper.com/api/add", new Response("", { status: 403 })], 60 + ]), 61 + ); 62 + 63 + try { 64 + const result = await sendToInstapaper( 65 + "https://example.com/article", 66 + { username: "wrong", password: "wrong" }, 67 + ); 68 + 69 + assertEquals(result.success, false); 70 + assertEquals(result.error, "Invalid Instapaper credentials"); 71 + } finally { 72 + globalThis.fetch = originalFetch; 73 + } 74 + }, 75 + ); 76 + 77 + Deno.test("sendToInstapaper - includes title in request", async () => { 78 + const originalFetch = globalThis.fetch; 79 + let capturedUrl = ""; 80 + 81 + globalThis.fetch = (input: RequestInfo | URL): Promise<Response> => { 82 + capturedUrl = input.toString(); 83 + return Promise.resolve(new Response("", { status: 201 })); 84 + }; 85 + 86 + try { 87 + await sendToInstapaper( 88 + "https://example.com/article", 89 + { username: "test", password: "test" }, 90 + "Test Article Title", 91 + ); 92 + 93 + assertEquals(capturedUrl.includes("title=Test+Article+Title"), true); 94 + } finally { 95 + globalThis.fetch = originalFetch; 96 + } 97 + }); 98 + 99 + Deno.test( 100 + "validateInstapaperCredentials - valid returns true", 101 + async () => { 102 + const originalFetch = globalThis.fetch; 103 + globalThis.fetch = createMockFetch( 104 + new Map([ 105 + ["instapaper.com/api/authenticate", new Response("", { status: 200 })], 106 + ]), 107 + ); 108 + 109 + try { 110 + const result = await validateInstapaperCredentials({ 111 + username: "test@example.com", 112 + password: "testpass", 113 + }); 114 + 115 + assertEquals(result.valid, true); 116 + } finally { 117 + globalThis.fetch = originalFetch; 118 + } 119 + }, 120 + ); 121 + 122 + Deno.test( 123 + "validateInstapaperCredentials - invalid returns false with error", 124 + async () => { 125 + const originalFetch = globalThis.fetch; 126 + globalThis.fetch = createMockFetch( 127 + new Map([ 128 + ["instapaper.com/api/authenticate", new Response("", { status: 403 })], 129 + ]), 130 + ); 131 + 132 + try { 133 + const result = await validateInstapaperCredentials({ 134 + username: "wrong", 135 + password: "wrong", 136 + }); 137 + 138 + assertEquals(result.valid, false); 139 + assertEquals(result.error, "Invalid username or password"); 140 + } finally { 141 + globalThis.fetch = originalFetch; 142 + } 143 + }, 144 + );
+70 -1
tests/settings.test.ts
··· 12 12 import { createMockSessionResult } from "./test-helpers.ts"; 13 13 14 14 // Initialize OAuth with test URL 15 - initOAuth("https://kipclip.com"); 15 + initOAuth(new Request("https://kipclip.com")); 16 16 const handler = app.handler(); 17 17 18 18 Deno.test("GET /api/settings - returns 401 when not authenticated", async () => { ··· 97 97 assertExists(body.error); 98 98 }, 99 99 }); 100 + 101 + Deno.test({ 102 + name: "PATCH /api/settings - accepts Instapaper settings", 103 + async fn() { 104 + // Mock successful credential validation 105 + const originalFetch = globalThis.fetch; 106 + globalThis.fetch = () => Promise.resolve(new Response("", { status: 200 })); 107 + 108 + try { 109 + setTestSessionProvider(() => 110 + Promise.resolve(createMockSessionResult({ pdsResponses: new Map() })) 111 + ); 112 + 113 + const req = new Request("https://kipclip.com/api/settings", { 114 + method: "PATCH", 115 + headers: { "Content-Type": "application/json" }, 116 + body: JSON.stringify({ 117 + instapaperEnabled: true, 118 + instapaperUsername: "test@example.com", 119 + instapaperPassword: "testpassword", 120 + }), 121 + }); 122 + const res = await handler(req); 123 + 124 + assertEquals(res.status, 200); 125 + const body = await res.json(); 126 + assertEquals(body.success, true); 127 + assertEquals(body.settings.instapaperEnabled, true); 128 + assertEquals(body.settings.instapaperUsername, "test@example.com"); 129 + // Password should never be returned 130 + assertEquals(body.settings.instapaperPassword, undefined); 131 + } finally { 132 + globalThis.fetch = originalFetch; 133 + } 134 + }, 135 + }); 136 + 137 + Deno.test({ 138 + name: "PATCH /api/settings - validates Instapaper credentials", 139 + async fn() { 140 + // Mock failed credential validation 141 + const originalFetch = globalThis.fetch; 142 + globalThis.fetch = () => Promise.resolve(new Response("", { status: 403 })); 143 + 144 + try { 145 + setTestSessionProvider(() => 146 + Promise.resolve(createMockSessionResult({ pdsResponses: new Map() })) 147 + ); 148 + 149 + const req = new Request("https://kipclip.com/api/settings", { 150 + method: "PATCH", 151 + headers: { "Content-Type": "application/json" }, 152 + body: JSON.stringify({ 153 + instapaperEnabled: true, 154 + instapaperUsername: "wrong@example.com", 155 + instapaperPassword: "wrongpass", 156 + }), 157 + }); 158 + const res = await handler(req); 159 + 160 + assertEquals(res.status, 400); 161 + const body = await res.json(); 162 + assertEquals(body.success, false); 163 + assertExists(body.error); 164 + } finally { 165 + globalThis.fetch = originalFetch; 166 + } 167 + }, 168 + });