this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial version.

alice 6d81e183 d2ed6116

+191 -96
+2 -1
.editorconfig
··· 2 2 root = true 3 3 4 4 [*] 5 - indent_style = tab 5 + indent_style = space 6 + indent_size = 2 6 7 end_of_line = lf 7 8 charset = utf-8 8 9 trim_trailing_whitespace = true
+4
.gitignore
··· 170 170 171 171 .dev.vars 172 172 .wrangler/ 173 + 174 + # other 175 + 176 + notes.md
+12
package-lock.json
··· 7 7 "": { 8 8 "name": "budget-edge", 9 9 "version": "0.0.0", 10 + "dependencies": { 11 + "jose": "^6.0.11" 12 + }, 10 13 "devDependencies": { 11 14 "@cloudflare/vitest-pool-workers": "^0.8.19", 12 15 "@types/node": "^22.15.18", ··· 1856 1859 "dev": true, 1857 1860 "license": "MIT", 1858 1861 "optional": true 1862 + }, 1863 + "node_modules/jose": { 1864 + "version": "6.0.11", 1865 + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", 1866 + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", 1867 + "license": "MIT", 1868 + "funding": { 1869 + "url": "https://github.com/sponsors/panva" 1870 + } 1859 1871 }, 1860 1872 "node_modules/loupe": { 1861 1873 "version": "3.1.3",
+3
package.json
··· 15 15 "typescript": "^5.5.2", 16 16 "vitest": "~3.0.7", 17 17 "wrangler": "^4.14.4" 18 + }, 19 + "dependencies": { 20 + "jose": "^6.0.11" 18 21 } 19 22 }
-31
public/index.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Hello, World!</title> 7 - </head> 8 - <body> 9 - <h1 id="heading"></h1> 10 - <button id="button" type="button">Fetch a random UUID</button> 11 - <output id="random" for="button"></output> 12 - <script> 13 - fetch('/message') 14 - .then((resp) => resp.text()) 15 - .then((text) => { 16 - const h1 = document.getElementById('heading'); 17 - h1.textContent = text; 18 - }); 19 - 20 - const button = document.getElementById("button"); 21 - button.addEventListener("click", () => { 22 - fetch('/random') 23 - .then((resp) => resp.text()) 24 - .then((text) => { 25 - const random = document.getElementById('random'); 26 - random.textContent = text; 27 - }); 28 - }); 29 - </script> 30 - </body> 31 - </html>
+147 -22
src/index.ts
··· 1 - /** 2 - * Welcome to Cloudflare Workers! This is your first worker. 3 - * 4 - * - Run `npm run dev` in your terminal to start a development server 5 - * - Open a browser tab at http://localhost:8787/ to see your worker in action 6 - * - Run `npm run deploy` to publish your worker 7 - * 8 - * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the 9 - * `Env` object can be regenerated with `npm run cf-typegen`. 10 - * 11 - * Learn more at https://developers.cloudflare.com/workers/ 12 - */ 1 + import { SignJWT, importPKCS8 } from 'jose'; 2 + import type { Env } from './types'; 3 + 4 + const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; 5 + const AUD = 'https://oauth2.googleapis.com/token'; 6 + 7 + /* --- in-memory token cache (per isolate) ----------------------- */ 8 + let tokenCache = { token: '', exp: 0 }; 13 9 14 10 export default { 15 - async fetch(request, env, ctx): Promise<Response> { 16 - const url = new URL(request.url); 17 - switch (url.pathname) { 18 - case '/message': 19 - return new Response('Hello, World!'); 20 - case '/random': 21 - return new Response(crypto.randomUUID()); 22 - default: 23 - return new Response('Not Found', { status: 404 }); 11 + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 12 + const url = new URL(req.url); 13 + 14 + /* 0 ── simple shared-secret gate */ 15 + if (url.searchParams.get('key') !== env.API_KEY) return new Response('forbidden', { status: 403 }); 16 + 17 + /* 1 ── GET /lists (cached 24 h in KV) ---------------------- */ 18 + if (url.pathname === '/lists' && req.method === 'GET') { 19 + const cached = await env.LIST_CACHE.get('v1', { type: 'json' }); 20 + if (cached) return json(cached); 21 + 22 + // Check for required environment variables 23 + if (typeof env.PURPOSE_TAB !== 'string' || !env.PURPOSE_TAB) { 24 + console.error('Server configuration error: PURPOSE_TAB environment variable is not defined or not a string.'); 25 + return new Response('Server configuration error: Missing PURPOSE_TAB setting.', { status: 500 }); 26 + } 27 + if (typeof env.ACCOUNT_TAB !== 'string' || !env.ACCOUNT_TAB) { 28 + console.error('Server configuration error: ACCOUNT_TAB environment variable is not defined or not a string.'); 29 + return new Response('Server configuration error: Missing ACCOUNT_TAB setting.', { status: 500 }); 30 + } 31 + 32 + const [purposes, accounts] = await batchGet([`${env.PURPOSE_TAB}!A2:A`, `${env.ACCOUNT_TAB}!A2:A`], env); 33 + 34 + const payload = { 35 + purposes: purposes.filter(Boolean), 36 + accounts: accounts.filter(Boolean), 37 + }; 38 + 39 + ctx.waitUntil( 40 + // async cache write 41 + env.LIST_CACHE.put('v1', JSON.stringify(payload), { expirationTtl: 60 * 60 * 24 }) // 24 h 42 + ); 43 + 44 + return json(payload); 45 + } 46 + 47 + /* 2 ── POST /add ------------------------------------------- */ 48 + if (url.pathname === '/add' && req.method === 'POST') { 49 + const body = (await req.json()) as { 50 + date: string; 51 + amount: number; 52 + currency: string; 53 + description: string; 54 + purpose: string; 55 + account: string; 56 + }; 57 + 58 + await appendRow([body.date, body.amount, body.currency, body.description, body.purpose, body.account], env); 59 + 60 + return json({ status: 'OK' }); 61 + } 62 + 63 + /* 3 ── POST /flush-cache ----------------------------------- */ 64 + if (url.pathname === '/flush-cache' && req.method === 'POST') { 65 + try { 66 + await env.LIST_CACHE.delete('v1'); 67 + return json({ status: 'OK', message: "Cache key 'v1' flushed successfully." }); 68 + } catch (error) { 69 + console.error('Error flushing cache:', error); 70 + return json({ status: 'Error', message: 'Failed to flush cache.' }, 500); 71 + } 24 72 } 73 + 74 + return new Response('not found', { status: 404 }); 25 75 }, 26 - } satisfies ExportedHandler<Env>; 76 + }; 77 + 78 + /* ---- Google Sheets helpers ----------------------------------- */ 79 + async function batchGet(ranges: string[], env: Env): Promise<string[][]> { 80 + const token = await accessToken(env); 81 + const q = ranges.map((r) => 'ranges=' + encodeURIComponent(r)).join('&'); 82 + 83 + const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values:batchGet?${q}`, { 84 + headers: { Authorization: `Bearer ${token}` }, 85 + }); 86 + 87 + if (!res.ok) { 88 + console.error(`Google Sheets API error: ${res.status} ${res.statusText}. Response body: ${await res.text()}`); 89 + // Return an array of empty arrays, one for each requested range 90 + return ranges.map(() => []); 91 + } 92 + 93 + // Make valueRanges optional in the type to handle cases where it might be missing 94 + const r = await res.json() as { valueRanges?: { values?: string[][] }[] }; 95 + 96 + // If valueRanges is not present in the response, or is not an array 97 + if (!r.valueRanges || !Array.isArray(r.valueRanges)) { 98 + console.error('Google Sheets API response did not contain a valid valueRanges array. Response:', r); 99 + // Return an array of empty arrays, matching the number of requested ranges 100 + return ranges.map(() => []); 101 + } 102 + 103 + console.log('Google Sheets API response:', JSON.stringify(r, null, 2)); 104 + return r.valueRanges.map((v) => v.values?.flat() ?? []); 105 + } 106 + 107 + async function appendRow(cells: (string | number)[], env: Env): Promise<void> { 108 + const token = await accessToken(env); 109 + await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values/${env.TX_RANGE}:append?valueInputOption=USER_ENTERED`, { 110 + method: 'POST', 111 + headers: { 112 + 'Content-Type': 'application/json', 113 + Authorization: `Bearer ${token}`, 114 + }, 115 + body: JSON.stringify({ values: [cells] }), 116 + }); 117 + } 118 + 119 + /* ---- Service-account JWT → OAuth 2 access-token -------------- */ 120 + async function accessToken(env: Env): Promise<string> { 121 + const now = Math.floor(Date.now() / 1000); 122 + if (tokenCache.token && now < tokenCache.exp - 60) return tokenCache.token; 123 + 124 + const privateKey = await importPKCS8(env.SA_PRIVATE_KEY, 'RS256'); 125 + 126 + const jwt = await new SignJWT({ scope: SCOPE }) 127 + .setProtectedHeader({ alg: 'RS256' }) 128 + .setIssuer(env.SA_EMAIL) 129 + .setSubject(env.SA_EMAIL) 130 + .setAudience(AUD) 131 + .setIssuedAt(now) 132 + .setExpirationTime(now + 3600) 133 + .sign(privateKey); 134 + 135 + const resp = await fetch(AUD, { 136 + method: 'POST', 137 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 138 + body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + encodeURIComponent(jwt), 139 + }).then((r) => r.json() as Promise<{ access_token: string; expires_in: number }>); 140 + 141 + tokenCache = { token: resp.access_token, exp: now + resp.expires_in }; 142 + return tokenCache.token; 143 + } 144 + 145 + /* ---- small helper -------------------------------------------- */ 146 + function json(obj: unknown, status = 200): Response { 147 + return new Response(JSON.stringify(obj), { 148 + status, 149 + headers: { 'Content-Type': 'application/json' }, 150 + }); 151 + }
+10
src/types.d.ts
··· 1 + export interface Env { 2 + API_KEY: string; 3 + SA_EMAIL: string; 4 + SA_PRIVATE_KEY: string; 5 + SHEET_ID: string; 6 + PURPOSE_TAB: string; // from vars 7 + ACCOUNT_TAB: string; 8 + TX_RANGE: string; 9 + LIST_CACHE: KVNamespace; 10 + }
+13 -42
wrangler.jsonc
··· 7 7 "name": "budget-edge", 8 8 "main": "src/index.ts", 9 9 "compatibility_date": "2025-05-14", 10 - "compatibility_flags": [ 11 - "nodejs_compat", 12 - "global_fetch_strictly_public" 13 - ], 14 - "assets": { 15 - "binding": "ASSETS", 16 - "directory": "./public" 17 - }, 10 + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], 18 11 "observability": { 19 12 "enabled": true 13 + }, 14 + "placement": { "mode": "smart" }, 15 + "kv_namespaces": [ 16 + { 17 + "binding": "LIST_CACHE", 18 + "id": "47ad2f322ad1433692d34804b9e975b2" 19 + } 20 + ], 21 + "vars": { 22 + "PURPOSE_TAB": "Purposes", 23 + "ACCOUNT_TAB": "Accounts", 24 + "TX_RANGE": "Transactions!A2:G" 20 25 } 21 - /** 22 - * Smart Placement 23 - * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 24 - */ 25 - // "placement": { "mode": "smart" }, 26 - 27 - /** 28 - * Bindings 29 - * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 30 - * databases, object storage, AI inference, real-time communication and more. 31 - * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 32 - */ 33 - 34 - /** 35 - * Environment Variables 36 - * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 37 - */ 38 - // "vars": { "MY_VARIABLE": "production_value" }, 39 - /** 40 - * Note: Use secrets to store sensitive data. 41 - * https://developers.cloudflare.com/workers/configuration/secrets/ 42 - */ 43 - 44 - /** 45 - * Static Assets 46 - * https://developers.cloudflare.com/workers/static-assets/binding/ 47 - */ 48 - // "assets": { "directory": "./public/", "binding": "ASSETS" }, 49 - 50 - /** 51 - * Service Bindings (communicate between multiple Workers) 52 - * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 53 - */ 54 - // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 55 26 }