├── .gitignore ├── LICENSE ├── README.md ├── app_screenshot.png ├── backend ├── db.ts ├── deno.json ├── deno.lock ├── main.ts ├── plaid │ ├── plaidUtils.ts │ └── simpleTransactionObject.ts ├── types.ts └── utils │ ├── crypto.ts │ ├── pureFns.test.ts │ └── pureFns.ts ├── frontend ├── .gitignore ├── dist │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── index-BFrZwCUV.js │ │ └── index-DkEGxws9.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── index.html ├── eslint.config.js ├── index.html ├── mockup │ ├── README.md │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── design-mockup.html │ │ └── input.css ├── package-lock.json ├── package.json ├── public │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── src │ ├── App.tsx │ ├── api.ts │ ├── components │ │ ├── Header.tsx │ │ ├── item │ │ │ ├── DistanceSinceDownloaded.tsx │ │ │ ├── ItemCard.tsx │ │ │ ├── ItemCardAccount.tsx │ │ │ ├── ItemCardArea.tsx │ │ │ └── ItemHeader.tsx │ │ ├── logged-in │ │ │ ├── AddItemButtonArea.tsx │ │ │ ├── DownloadButtonArea.tsx │ │ │ ├── LoggedIn.tsx │ │ │ ├── NoItemsMessage.tsx │ │ │ └── ResetPw.tsx │ │ ├── logged-out │ │ │ ├── LoggedOut.tsx │ │ │ ├── LoggedOutCard.tsx │ │ │ ├── Login.tsx │ │ │ └── StartFromScratch.tsx │ │ └── shared │ │ │ ├── Button.tsx │ │ │ └── Modal.tsx │ ├── context │ │ ├── CryptoKeyContext.ts │ │ ├── DataContext.ts │ │ └── RefreshContext.ts │ ├── index.css │ ├── main.tsx │ ├── utils │ │ ├── crypto.ts │ │ └── download.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── sharedTypes.ts /.gitignore: -------------------------------------------------------------------------------- 1 | db.db 2 | .env 3 | .vscode 4 | frontend/mockup/node_modules/* 5 | frontend/mockup/src/output.css 6 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nate May 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Finfetch 2 | 3 | ![Finfetch app interface](./app_screenshot.png) 4 | 5 | _Download all of your bank and credit card transactions to CSVs in a simple web interface that runs locally._ 6 | 7 | Finfetch gives you access to the same transaction data that is used internally by many personal finance apps, including a unified auto-categorization system that works across institutions and accounts. Unlike those apps, though, Finfetch runs locally on your computer, allowing you to process that data in any way you'd like. This is particularly useful for those who use Plain Text Accounting software (Ledger, Beancount, Hledger, etc.), or anyone who prefers to track their income and expenses in spreadsheets. 8 | 9 | ## Transaction Data Format 10 | 11 | Each time you download your transactions you have the option to download all available data or only the data that's new since your last download. You'll receive a Zip file containing separate CSVs for added, removed, and modified transactions. 12 | 13 | Each added transaction contains up to 42 data fields, reliably including 14 | 15 | - date 16 | - amount 17 | - account 18 | - transaction ID (referenced when transactions are deleted) 19 | - merchant name 20 | - broad category of transaction (e.g. "FOOD_AND_DRINK") 21 | - narrow category of transaction (e.g. "FOOD_AND_DRINK_COFFEE") 22 | - confidence level of categorization (e.g. "VERY_HIGH") 23 | 24 | ## General Usage 25 | 26 | 1. In your terminal, start the server by running 27 | 28 | ```bash 29 | cd finfetch/backend 30 | deno run start 31 | ``` 32 | 33 | 2. Point your browser to [http://localhost:3002](http://localhost:3002). 34 | 3. Login and download your latest transactions with one click. 35 | 4. Stop the server (close the terminal window or use the key command for your system). 36 | 37 | ## Workflow Tips 38 | 39 | ### Hledger 40 | 41 | - Set the account's nickname to the account as it appears in your journal, e.g. `assets:bank:capital one` 42 | - Import the downloaded `added.csv` using a [rules file](https://hledger.org/1.42/hledger.html#csv). 43 | 44 | ## Setup (First Time Only) 45 | 46 | Finfetch is powered by Plaid, a service that connects with banks to retrieve your data. You'll need your own Plaid developer account to use it. **Please note that use of Plaid's API for real-world data requires payment after a limited number of free uses.** See below for pricing. 47 | 48 | 1. Follow the flow on [Plaid's Signup page](https://dashboard.plaid.com/signup) to make an account as a developer. You'll need to give them some information about your app and how you plan to use the API. 49 | 1. Within the Plaid dashboard, apply for production access. This will take a few days, but you can use Finfetch in Sandbox mode in the meantime. 50 | 1. [Set the use case](https://dashboard.plaid.com/link/data-transparency-v5) within the Plaid dashboard under Link > Link Customization. The default values are fine. 51 | 1. Clone or download this repo onto your computer. 52 | 1. If you don't have Deno installed, download and install it by running 53 | 54 | ```bash 55 | curl -fsSL https://deno.land/install.sh | sh 56 | ``` 57 | 58 | 1. Create a file named `.env` within the `backend` directory of Finfetch, and add the following: 59 | 60 | ```text 61 | PLAID_CLIENT_ID= 62 | PLAID_ENV= 63 | PLAID_SECRET= 64 | PLAID_COUNTRY_CODES= 65 | ``` 66 | 67 | 1. Find your API keys in the [Plaid Dashboard](https://dashboard.plaid.com/developers/keys) under Developer > Keys. 68 | 1. In the `.env` file, add the following: 69 | 70 | | Variable | Value(s) | 71 | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 72 | | `PLAID_CLIENT_ID` | Client ID listed in your Plaid dashboard | 73 | | `PLAID_ENV` | `sandbox` for test data or `production` for real data | 74 | | `PLAID_SECRET` | The secret listed in your Plaid dashboard. Be sure to use the one that corresponds to your environment (sandbox or production) | 75 | | `PLAID_COUNTRY_CODES` | Comma separated list of countries in which your banks appear. See [this list](https://plaid.com/docs/api/link/#link-token-create-request-country-codes) Example: `US,CA`. | 76 | 77 | 9. Start the Finfetch server by running 78 | 79 | ```bash 80 | cd backend 81 | deno run start 82 | ``` 83 | 84 | 10. Open a browser and navigate to [http://localhost:3002/](). 85 | 86 | Within the app, you can now click "Start from Scratch" and follow the prompts to create a password, add your bank accounts, and download your data (if you've been approved for production). 87 | 88 | Be sure to shut down the server at the end of your session (you can do this by closing the terminal window). 89 | 90 | ## Pricing 91 | 92 | While Finfetch is free and open source, data is provided through Plaid's API which is a paid product. At the time of writing (February 2025), their pricing for production access in the US is $0.30 per month per connected account, after 200 free API calls. When you sign up for production access you'll see the latest pricing and enter payment information. 93 | 94 | ## Security 95 | 96 | Finfetch keeps a small database on your machine with only enough information to 1) authenticate a single user, 2) display your bank names and account "masks" (last four digits of account numbers), 3) request transaction data from Plaid. No transaction data is kept within this database--that lives in the CSVs that you download. 97 | 98 | To prevent an attacker with access to your hard drive from gaining API privileges to your accounts, your Plaid access keys are stored in an encrypted form. If you forget your password you can delete the file `db.db` in the `backend` directory, but you'll need to contact Plaid's support if you want to remove these accounts from your billing. 99 | 100 | Since Finfetch runs a local server that is only accessible to your machine, others on your network will not be able to access your running process. 101 | 102 | To protect your data, don't separate the client and server without changing security practices. If you don't know what that sentence means, you're doing it right. 103 | 104 | ## Stack 105 | 106 | - **Frontend:** React/Tailwind/Vite 107 | - **Backend:** Deno/Express 108 | - **Database:** SQLite 109 | - **Bank Connection API:** Plaid 110 | 111 | ## Development 112 | 113 | To run in development mode you'll need 1) Node/NPM installed, and 2) to make the following changes: 114 | 115 | 1. Within `backend/.env`, change `PLAID_ENV` to `sandbox` and replace the `PLAID_SECRET` with your sandbox secret from your Plaid dashboard. 116 | 1. Within `backend/main.ts` uncomment the line `app.use(cors())`. This will allow your frontend and backend to run on different ports and still communicate (not recommended in production mode for security reasons). 117 | 1. Start the backend server: 118 | 119 | ```bash 120 | cd backend 121 | deno run dev 122 | ``` 123 | 124 | 1. Install Node modules and start the frontend server: 125 | 126 | ```bash 127 | cd frontend 128 | npm install 129 | npm run dev 130 | ``` 131 | 132 | 1. This will run the development server in Vite, which will tell you which port it's running on. 133 | 1. When you need to test with the backend, run `npm build` and switch to the backend server. 134 | -------------------------------------------------------------------------------- /app_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/app_screenshot.png -------------------------------------------------------------------------------- /backend/db.ts: -------------------------------------------------------------------------------- 1 | import { DB } from "https://deno.land/x/sqlite/mod.ts"; 2 | import * as path from "jsr:@std/path"; 3 | 4 | import { Account, ServerItem } from "../sharedTypes.ts"; 5 | import { camelToSnake } from "./utils/pureFns.ts"; 6 | import { encryptData, decryptData } from "./utils/crypto.ts"; 7 | 8 | const dbPath = path.resolve(import.meta.dirname || "", "db.db"); 9 | const db = new DB(dbPath); 10 | 11 | /** 12 | * Create the SQLite tables. 13 | * @returns 14 | */ 15 | export function initDb() { 16 | let itemsAlreadyCreated = false; 17 | 18 | try { 19 | db.execute(` 20 | CREATE TABLE items ( 21 | item_id TEXT PRIMARY KEY, 22 | access_token BLOB, 23 | cursor TEXT, 24 | name TEXT, 25 | iv BLOB 26 | ); 27 | `); 28 | console.log("Items table created."); 29 | } catch (_) { 30 | console.log("Items table not created (likely already exists)."); 31 | itemsAlreadyCreated = true; 32 | } 33 | 34 | try { 35 | db.execute(` 36 | CREATE TABLE accounts ( 37 | account_id TEXT PRIMARY KEY, 38 | item_id TEXT NOT NULL, 39 | name TEXT, 40 | nickname TEXT, 41 | last_downloaded INTEGER, 42 | FOREIGN KEY (item_id) 43 | REFERENCES items (item_id) 44 | ); 45 | `); 46 | console.log("Accounts table created."); 47 | } catch (_) { 48 | console.log("Accounts table not created (likely already exists)"); 49 | } 50 | 51 | try { 52 | db.execute(` 53 | CREATE TABLE users ( 54 | user_id NUMBER PRIMARY KEY, 55 | salt BLOB NOT NULL 56 | ); 57 | `); 58 | console.log("Users table created."); 59 | } catch (_) { 60 | console.log("Users is not created (likely already exists)."); 61 | } 62 | 63 | if (itemsAlreadyCreated) return; 64 | } 65 | 66 | /** 67 | * Add an item to the database. 68 | * @param item 69 | * @param cryptoKey 70 | */ 71 | export async function addItem( 72 | item: { 73 | item_id: string; 74 | access_token: string; 75 | name?: string; 76 | }, 77 | cryptoKey: CryptoKey 78 | ) { 79 | const { iv, encrypted } = await encryptData(item.access_token, cryptoKey); 80 | const encryptedAccessToken = new Uint8Array(encrypted); 81 | 82 | db.query( 83 | "INSERT INTO items (item_id, access_token, name, iv) VALUES(?, ?, ?, ?)", 84 | [item.item_id, encryptedAccessToken, item.name ?? null, iv] 85 | ); 86 | } 87 | /** 88 | * Add an account to the database. 89 | * @param account 90 | */ 91 | export function addAccount(account: { 92 | account_id: string; 93 | item_id: string; 94 | name?: string; 95 | nickname?: string; 96 | }) { 97 | const { account_id, item_id, name, nickname } = account; 98 | 99 | db.query( 100 | "INSERT INTO accounts (account_id, item_id, name, nickname) VALUES(?, ?, ?, ?)", 101 | [account_id, item_id, name ?? null, nickname ?? name ?? null] 102 | ); 103 | } 104 | 105 | /** 106 | * Get all items from database. 107 | * @returns array of items. Remove access token before sending to client. 108 | */ 109 | export async function getItems(cryptoKey: CryptoKey): Promise { 110 | const results = []; 111 | 112 | function uint8ArrayToArrayBuffer(uint8Array: Uint8Array): ArrayBuffer { 113 | return uint8Array.buffer.slice( 114 | uint8Array.byteOffset, 115 | uint8Array.byteOffset + uint8Array.byteLength 116 | ) as ArrayBuffer; 117 | } 118 | 119 | for (const [name, itemId, accessTokenEncrypted, iv, cursor] of db.query( 120 | "SELECT name, item_id as itemId, access_token as accessTokenEncrypted, iv, cursor FROM items" 121 | ) as Iterable<[string, string, Uint8Array, Uint8Array, string]>) { 122 | const accessTokenBuffer = uint8ArrayToArrayBuffer(accessTokenEncrypted); 123 | const accessToken = await decryptData(accessTokenBuffer, iv, cryptoKey); 124 | results.push({ name, itemId, accessToken, cursor }); 125 | } 126 | 127 | return results; 128 | } 129 | 130 | /** 131 | * Get accounts from the database. 132 | * @param requestedItemId If blank, get all accounts. 133 | * @returns 134 | */ 135 | export function getAccounts(requestedItemId?: string): Account[] { 136 | const results = []; 137 | 138 | let query = ` 139 | SELECT name, nickname, account_id as accountId, item_id as itemId, last_downloaded as lastDownloaded FROM accounts 140 | `; 141 | const queryParams = []; 142 | if (requestedItemId) { 143 | query += ` WHERE item_id = ?`; 144 | queryParams.push(requestedItemId); 145 | } 146 | 147 | for (const [name, nickname, accountId, itemId, lastDownloaded] of db.query( 148 | query, 149 | queryParams 150 | ) as Iterable<[string, string, string, string, number | null]>) { 151 | results.push({ name, nickname, accountId, itemId, lastDownloaded }); 152 | } 153 | 154 | return results; 155 | } 156 | 157 | /** 158 | * Update an account. 159 | * @param accountId 160 | * @param resourceIn updated account object. accountId property is used to locate. 161 | * @returns 162 | */ 163 | export function updateAccount(accountId: string, resourceIn: Account) { 164 | if (accountId !== resourceIn.accountId) 165 | throw new Error("Account ids don't match."); 166 | const resource: Partial = { ...resourceIn }; 167 | delete resource.accountId; 168 | 169 | const fields = Object.keys(resource).map((field) => camelToSnake(field)); 170 | const values = Object.values(resource); 171 | 172 | const setClause = fields.map((field) => `${field} = ?`).join(", "); 173 | const sql = `UPDATE accounts SET ${setClause} WHERE account_id = ?`; 174 | 175 | db.query(sql, [...values, accountId]); 176 | 177 | return 1; 178 | } 179 | 180 | /** 181 | * Update an Item. 182 | * @param itemId 183 | * @param resourceIn 184 | * @returns 185 | */ 186 | export function updateItem(itemId: string, resourceIn: ServerItem) { 187 | if (itemId !== resourceIn.itemId) throw new Error("Item ids don't match."); 188 | const resource: Partial = { ...resourceIn }; 189 | delete resource.itemId; 190 | // don't want to touch the accessToken because it won't change and we'd need the cryptoKey 191 | delete resource.accessToken; 192 | 193 | const fields = Object.keys(resource).map((field) => camelToSnake(field)); 194 | const values = Object.values(resource); 195 | 196 | const setClause = fields.map((field) => `${field} = ?`).join(", "); 197 | const sql = `UPDATE items SET ${setClause} WHERE item_id = ?`; 198 | 199 | db.query(sql, [...values, itemId]); 200 | 201 | // TODO return something that's not this 202 | return 1; 203 | } 204 | 205 | /** 206 | * Remove an item from the database. 207 | * @param itemId 208 | * @returns 209 | */ 210 | export function deleteItem(itemId: string) { 211 | const deletedRows = db.query(`DELETE from accounts WHERE item_id = ?`, [ 212 | itemId, 213 | ]); 214 | db.query(`DELETE from items WHERE item_id = ?`, [itemId]); 215 | return deletedRows.length; 216 | } 217 | 218 | /** 219 | * Lookup full info about an account. 220 | * @param accountId 221 | * @returns 222 | */ 223 | export function getAccountById(accountId: string): Account { 224 | const accountIdToQuery = accountId; 225 | 226 | const query = ` 227 | SELECT account_id as accountId, name, nickname, item_id as itemId, last_downloaded as lastDownloaded 228 | FROM accounts 229 | WHERE account_id = ? 230 | `; 231 | 232 | const results = []; 233 | 234 | for (const [accountId, name, nickname, itemId, lastDownloaded] of db.query( 235 | query, 236 | [accountIdToQuery] 237 | ) as Iterable<[string, string, string, string, number]>) { 238 | results.push({ accountId, name, nickname, itemId, lastDownloaded }); 239 | } 240 | 241 | return results[0]; 242 | } 243 | 244 | /** 245 | * Add a password salt to the user database. 246 | * @param salt 247 | */ 248 | export function addSalt(salt: Uint8Array) { 249 | db.query("REPLACE INTO users (user_id, salt) VALUES(1, ?)", [salt]); 250 | } 251 | 252 | /** 253 | * Retrieve password salt from the user database. 254 | * @param userId 255 | * @returns 256 | */ 257 | export function getSalt(userId: number) { 258 | const [[salt]] = db.query("SELECT salt from users WHERE user_id = ?", [ 259 | userId, 260 | ]) as Iterable<[Uint8Array]>; 261 | return salt; 262 | } 263 | 264 | /** 265 | * Clear all data in database. 266 | */ 267 | export function wipeData() { 268 | db.query("DELETE from accounts"); 269 | db.query("DELETE from items"); 270 | db.query("DELETE from users"); 271 | } 272 | 273 | Deno.addSignalListener("SIGINT", () => { 274 | db.close(); 275 | console.log("Database connection closed."); 276 | Deno.exit(); 277 | }); 278 | -------------------------------------------------------------------------------- /backend/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run --watch --allow-all main.ts", 4 | "start": "deno run --allow-all main.ts" 5 | }, 6 | "imports": { 7 | "@std/assert": "jsr:@std/assert@1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@std/assert@1": "1.0.8", 5 | "jsr:@std/assert@^1.0.11": "1.0.11", 6 | "jsr:@std/bytes@^1.0.3": "1.0.4", 7 | "jsr:@std/bytes@^1.0.5": "1.0.5", 8 | "jsr:@std/csv@*": "1.0.5", 9 | "jsr:@std/dotenv@*": "0.225.3", 10 | "jsr:@std/expect@*": "1.0.13", 11 | "jsr:@std/internal@^1.0.5": "1.0.5", 12 | "jsr:@std/io@*": "0.225.2", 13 | "jsr:@std/path@*": "1.0.8", 14 | "jsr:@std/streams@*": "1.0.8", 15 | "jsr:@std/streams@^1.0.4": "1.0.8", 16 | "jsr:@std/streams@^1.0.8": "1.0.9", 17 | "jsr:@zip-js/zip-js@*": "2.7.57", 18 | "npm:@types/express@*": "5.0.0", 19 | "npm:body-parser@*": "1.20.3", 20 | "npm:cors@*": "2.8.5", 21 | "npm:express@*": "4.21.1", 22 | "npm:plaid@*": "30.0.0" 23 | }, 24 | "jsr": { 25 | "@std/assert@1.0.8": { 26 | "integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b", 27 | "dependencies": [ 28 | "jsr:@std/internal" 29 | ] 30 | }, 31 | "@std/assert@1.0.11": { 32 | "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", 33 | "dependencies": [ 34 | "jsr:@std/internal" 35 | ] 36 | }, 37 | "@std/bytes@1.0.4": { 38 | "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" 39 | }, 40 | "@std/bytes@1.0.5": { 41 | "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" 42 | }, 43 | "@std/csv@1.0.3": { 44 | "integrity": "623acf0dcb88d62ba727c3611ad005df7f109ede8cac833e3986f540744562e5", 45 | "dependencies": [ 46 | "jsr:@std/streams@^1.0.4" 47 | ] 48 | }, 49 | "@std/csv@1.0.5": { 50 | "integrity": "02006ffd77e84b2bf01968d4e792f0b5cba057c73dc6a89accb96d803493675f", 51 | "dependencies": [ 52 | "jsr:@std/streams@^1.0.8" 53 | ] 54 | }, 55 | "@std/dotenv@0.225.2": { 56 | "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" 57 | }, 58 | "@std/dotenv@0.225.3": { 59 | "integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a" 60 | }, 61 | "@std/expect@1.0.13": { 62 | "integrity": "d8e236c7089cd9fcf5e6032f27dadc3db6349d0aee48c15bc71d717bca5baa42", 63 | "dependencies": [ 64 | "jsr:@std/assert@^1.0.11", 65 | "jsr:@std/internal" 66 | ] 67 | }, 68 | "@std/internal@1.0.5": { 69 | "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" 70 | }, 71 | "@std/io@0.225.2": { 72 | "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", 73 | "dependencies": [ 74 | "jsr:@std/bytes@^1.0.5" 75 | ] 76 | }, 77 | "@std/path@1.0.8": { 78 | "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" 79 | }, 80 | "@std/streams@1.0.8": { 81 | "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3", 82 | "dependencies": [ 83 | "jsr:@std/bytes@^1.0.3" 84 | ] 85 | }, 86 | "@std/streams@1.0.9": { 87 | "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035", 88 | "dependencies": [ 89 | "jsr:@std/bytes@^1.0.5" 90 | ] 91 | }, 92 | "@zip-js/zip-js@2.7.57": { 93 | "integrity": "15465ff627e321775870ad493bc043ca2d97940bda58d709c49908d39f4b0524" 94 | } 95 | }, 96 | "npm": { 97 | "@types/body-parser@1.19.5": { 98 | "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", 99 | "dependencies": [ 100 | "@types/connect", 101 | "@types/node" 102 | ] 103 | }, 104 | "@types/connect@3.4.38": { 105 | "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", 106 | "dependencies": [ 107 | "@types/node" 108 | ] 109 | }, 110 | "@types/express-serve-static-core@5.0.6": { 111 | "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", 112 | "dependencies": [ 113 | "@types/node", 114 | "@types/qs", 115 | "@types/range-parser", 116 | "@types/send" 117 | ] 118 | }, 119 | "@types/express@5.0.0": { 120 | "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", 121 | "dependencies": [ 122 | "@types/body-parser", 123 | "@types/express-serve-static-core", 124 | "@types/qs", 125 | "@types/serve-static" 126 | ] 127 | }, 128 | "@types/http-errors@2.0.4": { 129 | "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" 130 | }, 131 | "@types/mime@1.3.5": { 132 | "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" 133 | }, 134 | "@types/node@22.5.4": { 135 | "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", 136 | "dependencies": [ 137 | "undici-types" 138 | ] 139 | }, 140 | "@types/qs@6.9.18": { 141 | "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" 142 | }, 143 | "@types/range-parser@1.2.7": { 144 | "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" 145 | }, 146 | "@types/send@0.17.4": { 147 | "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", 148 | "dependencies": [ 149 | "@types/mime", 150 | "@types/node" 151 | ] 152 | }, 153 | "@types/serve-static@1.15.7": { 154 | "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", 155 | "dependencies": [ 156 | "@types/http-errors", 157 | "@types/node", 158 | "@types/send" 159 | ] 160 | }, 161 | "accepts@1.3.8": { 162 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 163 | "dependencies": [ 164 | "mime-types", 165 | "negotiator" 166 | ] 167 | }, 168 | "array-flatten@1.1.1": { 169 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 170 | }, 171 | "asynckit@0.4.0": { 172 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 173 | }, 174 | "axios@1.7.8": { 175 | "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", 176 | "dependencies": [ 177 | "follow-redirects", 178 | "form-data", 179 | "proxy-from-env" 180 | ] 181 | }, 182 | "body-parser@1.20.3": { 183 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 184 | "dependencies": [ 185 | "bytes", 186 | "content-type", 187 | "debug", 188 | "depd", 189 | "destroy", 190 | "http-errors", 191 | "iconv-lite", 192 | "on-finished", 193 | "qs", 194 | "raw-body", 195 | "type-is", 196 | "unpipe" 197 | ] 198 | }, 199 | "bytes@3.1.2": { 200 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 201 | }, 202 | "call-bind@1.0.7": { 203 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 204 | "dependencies": [ 205 | "es-define-property", 206 | "es-errors", 207 | "function-bind", 208 | "get-intrinsic", 209 | "set-function-length" 210 | ] 211 | }, 212 | "combined-stream@1.0.8": { 213 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 214 | "dependencies": [ 215 | "delayed-stream" 216 | ] 217 | }, 218 | "content-disposition@0.5.4": { 219 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 220 | "dependencies": [ 221 | "safe-buffer" 222 | ] 223 | }, 224 | "content-type@1.0.5": { 225 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" 226 | }, 227 | "cookie-signature@1.0.6": { 228 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 229 | }, 230 | "cookie@0.7.1": { 231 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" 232 | }, 233 | "cors@2.8.5": { 234 | "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", 235 | "dependencies": [ 236 | "object-assign", 237 | "vary" 238 | ] 239 | }, 240 | "debug@2.6.9": { 241 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 242 | "dependencies": [ 243 | "ms@2.0.0" 244 | ] 245 | }, 246 | "define-data-property@1.1.4": { 247 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 248 | "dependencies": [ 249 | "es-define-property", 250 | "es-errors", 251 | "gopd" 252 | ] 253 | }, 254 | "delayed-stream@1.0.0": { 255 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 256 | }, 257 | "depd@2.0.0": { 258 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 259 | }, 260 | "destroy@1.2.0": { 261 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" 262 | }, 263 | "ee-first@1.1.1": { 264 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 265 | }, 266 | "encodeurl@1.0.2": { 267 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" 268 | }, 269 | "encodeurl@2.0.0": { 270 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" 271 | }, 272 | "es-define-property@1.0.0": { 273 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 274 | "dependencies": [ 275 | "get-intrinsic" 276 | ] 277 | }, 278 | "es-errors@1.3.0": { 279 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 280 | }, 281 | "escape-html@1.0.3": { 282 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 283 | }, 284 | "etag@1.8.1": { 285 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 286 | }, 287 | "express@4.21.1": { 288 | "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", 289 | "dependencies": [ 290 | "accepts", 291 | "array-flatten", 292 | "body-parser", 293 | "content-disposition", 294 | "content-type", 295 | "cookie", 296 | "cookie-signature", 297 | "debug", 298 | "depd", 299 | "encodeurl@2.0.0", 300 | "escape-html", 301 | "etag", 302 | "finalhandler", 303 | "fresh", 304 | "http-errors", 305 | "merge-descriptors", 306 | "methods", 307 | "on-finished", 308 | "parseurl", 309 | "path-to-regexp", 310 | "proxy-addr", 311 | "qs", 312 | "range-parser", 313 | "safe-buffer", 314 | "send", 315 | "serve-static", 316 | "setprototypeof", 317 | "statuses", 318 | "type-is", 319 | "utils-merge", 320 | "vary" 321 | ] 322 | }, 323 | "finalhandler@1.3.1": { 324 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 325 | "dependencies": [ 326 | "debug", 327 | "encodeurl@2.0.0", 328 | "escape-html", 329 | "on-finished", 330 | "parseurl", 331 | "statuses", 332 | "unpipe" 333 | ] 334 | }, 335 | "follow-redirects@1.15.9": { 336 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" 337 | }, 338 | "form-data@4.0.1": { 339 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 340 | "dependencies": [ 341 | "asynckit", 342 | "combined-stream", 343 | "mime-types" 344 | ] 345 | }, 346 | "forwarded@0.2.0": { 347 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 348 | }, 349 | "fresh@0.5.2": { 350 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" 351 | }, 352 | "function-bind@1.1.2": { 353 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 354 | }, 355 | "get-intrinsic@1.2.4": { 356 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 357 | "dependencies": [ 358 | "es-errors", 359 | "function-bind", 360 | "has-proto", 361 | "has-symbols", 362 | "hasown" 363 | ] 364 | }, 365 | "gopd@1.0.1": { 366 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 367 | "dependencies": [ 368 | "get-intrinsic" 369 | ] 370 | }, 371 | "has-property-descriptors@1.0.2": { 372 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 373 | "dependencies": [ 374 | "es-define-property" 375 | ] 376 | }, 377 | "has-proto@1.0.3": { 378 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" 379 | }, 380 | "has-symbols@1.0.3": { 381 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" 382 | }, 383 | "hasown@2.0.2": { 384 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 385 | "dependencies": [ 386 | "function-bind" 387 | ] 388 | }, 389 | "http-errors@2.0.0": { 390 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 391 | "dependencies": [ 392 | "depd", 393 | "inherits", 394 | "setprototypeof", 395 | "statuses", 396 | "toidentifier" 397 | ] 398 | }, 399 | "iconv-lite@0.4.24": { 400 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 401 | "dependencies": [ 402 | "safer-buffer" 403 | ] 404 | }, 405 | "inherits@2.0.4": { 406 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 407 | }, 408 | "ipaddr.js@1.9.1": { 409 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 410 | }, 411 | "media-typer@0.3.0": { 412 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" 413 | }, 414 | "merge-descriptors@1.0.3": { 415 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" 416 | }, 417 | "methods@1.1.2": { 418 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" 419 | }, 420 | "mime-db@1.52.0": { 421 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 422 | }, 423 | "mime-types@2.1.35": { 424 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 425 | "dependencies": [ 426 | "mime-db" 427 | ] 428 | }, 429 | "mime@1.6.0": { 430 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 431 | }, 432 | "ms@2.0.0": { 433 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 434 | }, 435 | "ms@2.1.3": { 436 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 437 | }, 438 | "negotiator@0.6.3": { 439 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" 440 | }, 441 | "object-assign@4.1.1": { 442 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" 443 | }, 444 | "object-inspect@1.13.3": { 445 | "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==" 446 | }, 447 | "on-finished@2.4.1": { 448 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 449 | "dependencies": [ 450 | "ee-first" 451 | ] 452 | }, 453 | "parseurl@1.3.3": { 454 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 455 | }, 456 | "path-to-regexp@0.1.10": { 457 | "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" 458 | }, 459 | "plaid@30.0.0": { 460 | "integrity": "sha512-BO6xny2+jtMYFKu3gl3GycA7lFZL6rTIydbADrYXMlrCJlR+MwJy9R7RU8AyxqE6i6UGrojUm30QIGnZtGXCSg==", 461 | "dependencies": [ 462 | "axios" 463 | ] 464 | }, 465 | "proxy-addr@2.0.7": { 466 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 467 | "dependencies": [ 468 | "forwarded", 469 | "ipaddr.js" 470 | ] 471 | }, 472 | "proxy-from-env@1.1.0": { 473 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 474 | }, 475 | "qs@6.13.0": { 476 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 477 | "dependencies": [ 478 | "side-channel" 479 | ] 480 | }, 481 | "range-parser@1.2.1": { 482 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 483 | }, 484 | "raw-body@2.5.2": { 485 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 486 | "dependencies": [ 487 | "bytes", 488 | "http-errors", 489 | "iconv-lite", 490 | "unpipe" 491 | ] 492 | }, 493 | "safe-buffer@5.2.1": { 494 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 495 | }, 496 | "safer-buffer@2.1.2": { 497 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 498 | }, 499 | "send@0.19.0": { 500 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 501 | "dependencies": [ 502 | "debug", 503 | "depd", 504 | "destroy", 505 | "encodeurl@1.0.2", 506 | "escape-html", 507 | "etag", 508 | "fresh", 509 | "http-errors", 510 | "mime", 511 | "ms@2.1.3", 512 | "on-finished", 513 | "range-parser", 514 | "statuses" 515 | ] 516 | }, 517 | "serve-static@1.16.2": { 518 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 519 | "dependencies": [ 520 | "encodeurl@2.0.0", 521 | "escape-html", 522 | "parseurl", 523 | "send" 524 | ] 525 | }, 526 | "set-function-length@1.2.2": { 527 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 528 | "dependencies": [ 529 | "define-data-property", 530 | "es-errors", 531 | "function-bind", 532 | "get-intrinsic", 533 | "gopd", 534 | "has-property-descriptors" 535 | ] 536 | }, 537 | "setprototypeof@1.2.0": { 538 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 539 | }, 540 | "side-channel@1.0.6": { 541 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 542 | "dependencies": [ 543 | "call-bind", 544 | "es-errors", 545 | "get-intrinsic", 546 | "object-inspect" 547 | ] 548 | }, 549 | "statuses@2.0.1": { 550 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 551 | }, 552 | "toidentifier@1.0.1": { 553 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 554 | }, 555 | "type-is@1.6.18": { 556 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 557 | "dependencies": [ 558 | "media-typer", 559 | "mime-types" 560 | ] 561 | }, 562 | "undici-types@6.19.8": { 563 | "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" 564 | }, 565 | "unpipe@1.0.0": { 566 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 567 | }, 568 | "utils-merge@1.0.1": { 569 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" 570 | }, 571 | "vary@1.1.2": { 572 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 573 | } 574 | }, 575 | "redirects": { 576 | "https://deno.land/x/sqlite/mod.ts": "https://deno.land/x/sqlite@v3.9.1/mod.ts" 577 | }, 578 | "remote": { 579 | "https://deno.land/x/sqlite@v3.9.1/build/sqlite.js": "2afc7875c7b9c85d89730c4a311ab3a304e5d1bf761fbadd8c07bbdf130f5f9b", 580 | "https://deno.land/x/sqlite@v3.9.1/build/vfs.js": "7f7778a9fe499cd10738d6e43867340b50b67d3e39142b0065acd51a84cd2e03", 581 | "https://deno.land/x/sqlite@v3.9.1/mod.ts": "e09fc79d8065fe222578114b109b1fd60077bff1bb75448532077f784f4d6a83", 582 | "https://deno.land/x/sqlite@v3.9.1/src/constants.ts": "90f3be047ec0a89bcb5d6fc30db121685fc82cb00b1c476124ff47a4b0472aa9", 583 | "https://deno.land/x/sqlite@v3.9.1/src/db.ts": "03d0c860957496eadedd86e51a6e650670764630e64f56df0092e86c90752401", 584 | "https://deno.land/x/sqlite@v3.9.1/src/error.ts": "f7a15cb00d7c3797da1aefee3cf86d23e0ae92e73f0ba3165496c3816ab9503a", 585 | "https://deno.land/x/sqlite@v3.9.1/src/function.ts": "bc778cab7a6d771f690afa27264c524d22fcb96f1bb61959ade7922c15a4ab8d", 586 | "https://deno.land/x/sqlite@v3.9.1/src/query.ts": "d58abda928f6582d77bad685ecf551b1be8a15e8e38403e293ec38522e030cad", 587 | "https://deno.land/x/sqlite@v3.9.1/src/wasm.ts": "e79d0baa6e42423257fb3c7cc98091c54399254867e0f34a09b5bdef37bd9487" 588 | }, 589 | "workspace": { 590 | "dependencies": [ 591 | "jsr:@std/assert@1" 592 | ] 593 | } 594 | } 595 | -------------------------------------------------------------------------------- /backend/main.ts: -------------------------------------------------------------------------------- 1 | import express from "npm:express"; 2 | import type { Request, Response, NextFunction } from "npm:express"; 3 | import cors from "npm:cors"; 4 | import bodyParser from "npm:body-parser"; 5 | import { 6 | Configuration, 7 | PlaidApi, 8 | PlaidEnvironments, 9 | CountryCode, 10 | Products, 11 | } from "npm:plaid"; 12 | import "jsr:@std/dotenv/load"; 13 | import * as zip from "jsr:@zip-js/zip-js"; 14 | import { dirname, join } from "jsr:@std/path"; 15 | import { fromFileUrl } from "jsr:@std/path/from-file-url"; 16 | 17 | import { 18 | initDb, 19 | getItems, 20 | addItem, 21 | addAccount, 22 | getAccounts, 23 | deleteItem, 24 | updateAccount, 25 | addSalt, 26 | getSalt, 27 | wipeData, 28 | } from "./db.ts"; 29 | import { PlaidLinkOnSuccessMetadata } from "./types.ts"; 30 | import { syncTransactions } from "./plaid/plaidUtils.ts"; 31 | import { importKey } from "./utils/crypto.ts"; 32 | 33 | const app = express(); 34 | const port = 3002; 35 | 36 | const __filename = fromFileUrl(import.meta.url); 37 | const __dirname = dirname(__filename); 38 | const frontendDistPath = join(__dirname, "../frontend/dist"); 39 | 40 | app.use(bodyParser.json()); 41 | // For security reasons, only use CORS during development. It's not needed when serving files from the dist folder. 42 | app.use(cors()); 43 | 44 | initDb(); 45 | // TODO don't forget to close the db later 46 | 47 | const PLAID_CLIENT_ID = Deno.env.get("PLAID_CLIENT_ID"); 48 | const PLAID_SECRET = Deno.env.get("PLAID_SECRET"); 49 | const PLAID_ENV = Deno.env.get("PLAID_ENV"); 50 | const PLAID_COUNTRY_CODES = (Deno.env 51 | .get("PLAID_COUNTRY_CODES") 52 | ?.split(",") || ["US", "CA"]) as CountryCode[]; 53 | const PLAID_PRODUCTS = ["transactions"] as Products[]; 54 | 55 | const configuration = new Configuration({ 56 | basePath: PlaidEnvironments[PLAID_ENV!], 57 | baseOptions: { 58 | headers: { 59 | "PLAID-CLIENT-ID": PLAID_CLIENT_ID, 60 | "PLAID-SECRET": PLAID_SECRET, 61 | "Plaid-Version": "2020-09-14", 62 | }, 63 | }, 64 | }); 65 | 66 | const client = new PlaidApi(configuration); 67 | 68 | app.use("/", express.static(frontendDistPath)); 69 | 70 | app.get("/", (_: Request, res: Response) => { 71 | res.sendFile(join(frontendDistPath, "index.html")); 72 | }); 73 | 74 | app.get( 75 | "/api/sync", 76 | async (req: Request, res: Response, next: NextFunction) => { 77 | const cryptoKeyString = req.get("X-Crypto-Key-String"); 78 | const cryptoKey = await importKey(cryptoKeyString!); 79 | 80 | const items = await getItems(cryptoKey); 81 | 82 | const { dateQuery } = req.query as { dateQuery: "cursor" | "all" }; 83 | 84 | try { 85 | const { csvStrings, txnCount } = await syncTransactions( 86 | client, 87 | items, 88 | dateQuery === "cursor" 89 | ); 90 | 91 | const accounts = getAccounts(); 92 | const now = Date.now(); 93 | accounts.forEach((account) => 94 | updateAccount(account.accountId, { ...account, lastDownloaded: now }) 95 | ); 96 | 97 | const zipFileWriter = new zip.BlobWriter("application/zip"); 98 | const zipWriter = new zip.ZipWriter(zipFileWriter); 99 | 100 | await Promise.all( 101 | Object.entries(csvStrings).map(([category, csv]) => { 102 | if (csv) { 103 | const csvReader = new zip.TextReader(csv); 104 | zipWriter.add(`${category}.csv`, csvReader); 105 | } 106 | }) 107 | ); 108 | 109 | const zipBlobFile = await zipWriter.close(); 110 | const thingToSend = new Uint8Array(await zipBlobFile.arrayBuffer()); 111 | 112 | res.set("Content-Disposition", 'attachment; filename="transactions.zip"'); 113 | res.set("Content-Type", "application/zip"); 114 | res.set("X-AddedCount", String(txnCount.added)); 115 | res.set("X-ModifiedCount", String(txnCount.modified)); 116 | res.set("X-RemovedCount", String(txnCount.removed)); 117 | // required because of CORS 118 | res.set( 119 | "Access-Control-Expose-Headers", 120 | "X-AddedCount, X-ModifiedCount, X-RemovedCount, Content-Disposition" 121 | ); 122 | 123 | // have to use end method here instead of send to ensure it's received as binary 124 | res.end(thingToSend); 125 | } catch (err) { 126 | next(err); 127 | } 128 | } 129 | ); 130 | 131 | app.post( 132 | "/api/create_link_token", 133 | async function (_: Request, res: Response, next: NextFunction) { 134 | const configs = { 135 | user: { 136 | // currently hardcoded with the assumption of only one user 137 | client_user_id: "1", 138 | }, 139 | client_name: "Finfetch", 140 | products: PLAID_PRODUCTS, 141 | country_codes: PLAID_COUNTRY_CODES, 142 | language: "en", 143 | transactions: { 144 | days_requested: 730, 145 | }, 146 | }; 147 | 148 | try { 149 | const createTokenResponse = await client.linkTokenCreate(configs); 150 | res.json(createTokenResponse.data); 151 | } catch (error) { 152 | next(error); 153 | } 154 | } 155 | ); 156 | 157 | app.get( 158 | "/api/items", 159 | async function (req: Request, res: Response, next: NextFunction) { 160 | try { 161 | const cryptoKeyString = req.get("X-Crypto-Key-String"); 162 | const cryptoKey = await importKey(cryptoKeyString!); 163 | 164 | const items = await getItems(cryptoKey); 165 | const itemsFrontend = items.map((item) => ({ 166 | itemId: item.itemId, 167 | name: item.name, 168 | })); 169 | res.json(itemsFrontend); 170 | } catch (error) { 171 | res.status(400).send(); 172 | next(error); 173 | } 174 | } 175 | ); 176 | 177 | app.get("/api/items/:itemId/accounts", function (req: Request, res: Response) { 178 | const { itemId } = req.params; 179 | const accounts = getAccounts(itemId); 180 | res.json(accounts); 181 | }); 182 | 183 | app.post( 184 | "/api/create_access_token", 185 | async function (req: Request, res: Response, next: NextFunction) { 186 | const { publicToken, metadata, cryptoKeyString } = req.body as { 187 | publicToken: string; 188 | metadata: PlaidLinkOnSuccessMetadata; 189 | cryptoKeyString: string; 190 | }; 191 | 192 | try { 193 | const tokenResponse = await client.itemPublicTokenExchange({ 194 | public_token: publicToken, 195 | }); 196 | const { access_token, item_id } = tokenResponse.data; 197 | 198 | const cryptoKey = await importKey(cryptoKeyString); 199 | 200 | await addItem( 201 | { access_token, item_id, name: metadata.institution?.name }, 202 | cryptoKey 203 | ); 204 | 205 | for (const account of metadata.accounts) { 206 | addAccount({ 207 | account_id: account.id, 208 | item_id: item_id, 209 | name: account.name + " " + account.mask, 210 | nickname: account.name + " " + account.mask, 211 | }); 212 | } 213 | // Don't return the access token to the client for security reasons 214 | res.json({ 215 | itemId: item_id, 216 | }); 217 | } catch (error) { 218 | next(error); 219 | } 220 | } 221 | ); 222 | 223 | app.put( 224 | "/api/accounts/:accountId", 225 | function (req: Request, res: Response, next: NextFunction) { 226 | try { 227 | const resource = req.body; 228 | const { accountId } = req.params; 229 | const result = updateAccount(accountId, resource); 230 | res.json({ 231 | rowsAffected: result, 232 | }); 233 | } catch (error) { 234 | next(error); 235 | } 236 | } 237 | ); 238 | 239 | app.delete( 240 | "/api/items/:itemId", 241 | async function (req: Request, res: Response, next: NextFunction) { 242 | try { 243 | const cryptoKeyString = req.get("X-Crypto-Key-String"); 244 | const cryptoKey = await importKey(cryptoKeyString!); 245 | 246 | const { itemId } = req.params; 247 | 248 | const items = await getItems(cryptoKey); 249 | const item = items.find((item) => item.itemId === itemId); 250 | if (!item) throw new Error("Requested item does not exist"); 251 | 252 | await client.itemRemove({ 253 | access_token: item.accessToken, 254 | }); 255 | 256 | deleteItem(itemId); 257 | 258 | res.status(204).send("Deleted"); 259 | } catch (error) { 260 | next(error); 261 | } 262 | } 263 | ); 264 | 265 | app.post( 266 | "/api/users/create", 267 | function (_: Request, res: Response, next: NextFunction) { 268 | try { 269 | wipeData(); 270 | const salt = crypto.getRandomValues(new Uint8Array(16)); 271 | addSalt(salt); 272 | res.end(salt); 273 | } catch (error) { 274 | next(error); 275 | } 276 | } 277 | ); 278 | 279 | app.get( 280 | "/api/users/1/salt", 281 | function (_: Request, res: Response, next: NextFunction) { 282 | try { 283 | const salt = getSalt(1); 284 | res.end(salt); 285 | } catch (error) { 286 | next(error); 287 | } 288 | } 289 | ); 290 | 291 | app.delete( 292 | "/api/users/1", 293 | function (_: Request, res: Response, next: NextFunction) { 294 | try { 295 | wipeData(); 296 | res.status(200).send(); 297 | } catch (error) { 298 | next(error); 299 | } 300 | } 301 | ); 302 | 303 | // ------ BEGIN ENDPOINTS FOR TESTING 304 | // reset an item to logged out state 305 | app.post( 306 | "/api/sandbox/item/:accessToken/reset_login", 307 | async function (req: Request, res: Response) { 308 | const { accessToken } = req.params; 309 | const { data } = await client.sandboxItemResetLogin({ 310 | access_token: accessToken, 311 | }); 312 | res.json(data); 313 | } 314 | ); 315 | // --------- END EDPOINTS FOR TESTING 316 | 317 | app.use((err: Error, _: Request, res: Response, __: NextFunction) => { 318 | console.error(err.stack); 319 | res.status(500).send(err.message); 320 | }); 321 | 322 | // localhost IP set explicitly to prevent access from other devices on the network 323 | app.listen(port, "127.0.0.1", () => { 324 | console.log( 325 | `Finfetch server is running. Open up http://localhost:${port} . Remember to shut down this server when you're done.` 326 | ); 327 | }); 328 | -------------------------------------------------------------------------------- /backend/plaid/plaidUtils.ts: -------------------------------------------------------------------------------- 1 | import { RemovedTransaction, Transaction, PlaidApi } from "npm:plaid"; 2 | import { processTransaction } from "./simpleTransactionObject.ts"; 3 | import { updateItem } from "../db.ts"; 4 | import { stringify } from "jsr:@std/csv"; 5 | import { ServerItem } from "../../sharedTypes.ts"; 6 | 7 | async function fetchNewSyncData( 8 | client: PlaidApi, 9 | item: ServerItem, 10 | initialCursor: string, 11 | retriesLeft = 3 12 | ) { 13 | const allData = { 14 | added: [] as Transaction[], 15 | removed: [] as RemovedTransaction[], 16 | modified: [] as Transaction[], 17 | nextCursor: initialCursor, 18 | }; 19 | if (retriesLeft <= 0) { 20 | // console.error("Too many retries!"); 21 | // We're just going to return no data and keep our original cursor. We can try again later. 22 | throw new Error( 23 | `We were not able to get the transactions for ${item.name}. Please remove this bank and add it again.` 24 | ); 25 | } 26 | try { 27 | let keepGoing = false; 28 | do { 29 | const results = await client.transactionsSync({ 30 | access_token: item.accessToken, 31 | count: 500, 32 | options: { 33 | include_original_description: true, 34 | days_requested: 730, 35 | }, 36 | cursor: allData.nextCursor, 37 | }); 38 | const newData = results.data; 39 | allData.added = allData.added.concat(newData.added); 40 | allData.modified = allData.modified.concat(newData.modified); 41 | allData.removed = allData.removed.concat(newData.removed); 42 | allData.nextCursor = newData.next_cursor; 43 | keepGoing = newData.has_more; 44 | 45 | // if (Math.random() < 0.5) { 46 | // throw new Error("SIMULATED PLAID SYNC ERROR"); 47 | // } 48 | } while (keepGoing === true); 49 | return allData; 50 | } catch (error) { 51 | // If you want to see if this is a sync mutation error, you can look at 52 | // error?.response?.data?.error_code 53 | console.log( 54 | `Oh no! Error! ${JSON.stringify( 55 | error 56 | )} Let's try again from the beginning!\n` 57 | ); 58 | setTimeout(() => {}, 1000); 59 | return fetchNewSyncData(client, item, initialCursor, retriesLeft - 1); 60 | } 61 | } 62 | 63 | export async function syncTransactions( 64 | client: PlaidApi, 65 | items: ServerItem[], 66 | useCursor: boolean 67 | ) { 68 | const processAllTransactions = (transactions: Transaction[]) => { 69 | return transactions.map((transaction: Transaction) => { 70 | return processTransaction(transaction); 71 | }); 72 | }; 73 | 74 | const eachItemData = await Promise.all( 75 | items.map(async (item) => { 76 | const cursor = useCursor ? item.cursor : ""; 77 | const rawData = await fetchNewSyncData(client, item, cursor); 78 | updateItem(item.itemId, { ...item, cursor: rawData.nextCursor }); 79 | return { 80 | added: processAllTransactions(rawData.added), 81 | removed: rawData.removed, 82 | modified: processAllTransactions(rawData.modified), 83 | }; 84 | }) 85 | ); 86 | 87 | type Category = "added" | "removed" | "modified"; 88 | 89 | const combinedData: Record< 90 | Category, 91 | { [key: string]: string }[] | RemovedTransaction[] 92 | > = eachItemData.reduce( 93 | (acc, data) => ({ 94 | added: [...acc.added, ...data.added], 95 | removed: [...acc.removed, ...data.removed], 96 | modified: [...acc.modified, ...data.modified], 97 | }), 98 | { 99 | added: [], 100 | removed: [], 101 | modified: [], 102 | } 103 | ); 104 | 105 | const makeCsvString = ( 106 | dataIn: RemovedTransaction[] | { [key: string]: string }[] 107 | ) => { 108 | return stringify(dataIn as { [key: string]: string }[], { 109 | columns: Object.keys(dataIn[0]), 110 | }); 111 | }; 112 | 113 | const txnCount: Record = { 114 | added: 0, 115 | removed: 0, 116 | modified: 0, 117 | }; 118 | const csvStrings: Record = { 119 | added: null, 120 | removed: null, 121 | modified: null, 122 | }; 123 | 124 | for (const category of Object.keys(combinedData) as Category[]) { 125 | const thisTxnCount = combinedData[category as Category].length; 126 | txnCount[category] = thisTxnCount; 127 | 128 | if (thisTxnCount > 0) { 129 | csvStrings[category] = makeCsvString(combinedData[category]); 130 | } 131 | } 132 | 133 | return { txnCount, csvStrings }; 134 | } 135 | -------------------------------------------------------------------------------- /backend/plaid/simpleTransactionObject.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "npm:plaid"; 2 | 3 | import { getAccountById } from "../db.ts"; 4 | 5 | const getNickname = (accountId: string) => { 6 | const account = getAccountById(accountId); 7 | return account.nickname ?? account.name; 8 | }; 9 | 10 | export function processTransaction(txnObj: Transaction) { 11 | const newObj: { [key: string]: string } = {}; 12 | 13 | const deprecated = new Set([ 14 | "category", 15 | "category_id", 16 | "transaction_type", 17 | "counterparties", 18 | ]); 19 | 20 | newObj.account_nickname = getNickname(txnObj.account_id); 21 | 22 | for (const [key, value] of Object.entries(txnObj)) { 23 | if (deprecated.has(key)) continue; 24 | // need nullish test first because null is of type object 25 | if (!value) { 26 | newObj[key] = ""; 27 | } else if (typeof value === "object") { 28 | for (const [nestedKey, nestedValue] of Object.entries(value)) { 29 | newObj[key + "-" + nestedKey] = nestedValue ? String(nestedValue) : ""; 30 | } 31 | } else if (["string", "number", "boolean"].includes(typeof value)) { 32 | newObj[key] = value ? String(value) : ""; 33 | } else { 34 | throw new Error( 35 | `Transaction object includes an unexpected type. Type: ${typeof value}. Key: ${key}. Value: ${value}` 36 | ); 37 | } 38 | } 39 | 40 | return newObj; 41 | } 42 | -------------------------------------------------------------------------------- /backend/types.ts: -------------------------------------------------------------------------------- 1 | // copied from Plaid 2 | interface PlaidAccount { 3 | id: string; 4 | name: string; 5 | mask: string; 6 | type: string; 7 | subtype: string; 8 | verification_status: string; 9 | } 10 | interface PlaidInstitution { 11 | name: string; 12 | institution_id: string; 13 | } 14 | interface PlaidLinkError { 15 | error_type: string; 16 | error_code: string; 17 | error_message: string; 18 | display_message: string; 19 | } 20 | export interface PlaidLinkOnSuccessMetadata { 21 | institution: null | PlaidInstitution; 22 | accounts: Array; 23 | link_session_id: string; 24 | transfer_status?: string; 25 | } 26 | -------------------------------------------------------------------------------- /backend/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | export async function encryptData(plaintext: string, key: CryptoKey) { 2 | const encoder = new TextEncoder(); 3 | const iv = crypto.getRandomValues(new Uint8Array(12)); 4 | const encrypted = await crypto.subtle.encrypt( 5 | { name: "AES-GCM", iv }, 6 | key, 7 | encoder.encode(plaintext) 8 | ); 9 | return { iv, encrypted }; 10 | } 11 | 12 | export async function decryptData( 13 | encrypted: ArrayBuffer, 14 | iv: Uint8Array, 15 | key: CryptoKey 16 | ) { 17 | const decrypted = await crypto.subtle.decrypt( 18 | { name: "AES-GCM", iv }, 19 | key, 20 | encrypted 21 | ); 22 | return new TextDecoder().decode(decrypted); 23 | } 24 | 25 | export async function importKey(base64Key: string): Promise { 26 | const rawKey = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0)); 27 | return await crypto.subtle.importKey( 28 | "raw", 29 | rawKey, 30 | { name: "AES-GCM", length: 256 }, 31 | false, 32 | ["encrypt", "decrypt"] 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /backend/utils/pureFns.test.ts: -------------------------------------------------------------------------------- 1 | import { camelToSnake } from "./pureFns.ts"; 2 | import { expect } from "jsr:@std/expect"; 3 | 4 | Deno.test("Convert camel to snake case", () => { 5 | const input = [ 6 | "camelCase", 7 | "nomNomNom", 8 | "setHTML", 9 | "b", 10 | "singleton", 11 | "1numButNotAtEnd", 12 | ]; 13 | const expected = [ 14 | "camel_case", 15 | "nom_nom_nom", 16 | "set_html", 17 | "b", 18 | "singleton", 19 | "1num_but_not_at_end", 20 | ]; 21 | const output = input.map((e) => camelToSnake(e)); 22 | expect(output).toEqual(expected); 23 | }); 24 | -------------------------------------------------------------------------------- /backend/utils/pureFns.ts: -------------------------------------------------------------------------------- 1 | export function camelToSnake(camel: string) { 2 | const camelStyle = /([a-z])([A-Z])/g; 3 | const snake = camel.replace(camelStyle, "$1_$2").toLowerCase(); 4 | return snake; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | *.local 12 | 13 | # Editor directories and files 14 | .vscode/* 15 | !.vscode/extensions.json 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /frontend/dist/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Rhodium Libre 4 | - Font Author: undefined 5 | - Font Source: https://fonts.gstatic.com/s/rhodiumlibre/v19/1q2AY5adA0tn_ukeHcQHqpx6pETLeo2gm2U.ttf 6 | - Font License: undefined) 7 | -------------------------------------------------------------------------------- /frontend/dist/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/dist/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/dist/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/dist/assets/index-DkEGxws9.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v4.0.4 | MIT License | https://tailwindcss.com */@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-serif:ui-serif,Georgia,Cambria,"Times New Roman",Times,serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(.971 .013 17.38);--color-red-100:oklch(.936 .032 17.717);--color-red-200:oklch(.885 .062 18.334);--color-red-300:oklch(.808 .114 19.571);--color-red-400:oklch(.704 .191 22.216);--color-red-500:oklch(.637 .237 25.331);--color-red-600:oklch(.577 .245 27.325);--color-red-700:oklch(.505 .213 27.518);--color-red-800:oklch(.444 .177 26.899);--color-red-900:oklch(.396 .141 25.723);--color-red-950:oklch(.258 .092 26.042);--color-orange-50:oklch(.98 .016 73.684);--color-orange-100:oklch(.954 .038 75.164);--color-orange-200:oklch(.901 .076 70.697);--color-orange-300:oklch(.837 .128 66.29);--color-orange-400:oklch(.75 .183 55.934);--color-orange-500:oklch(.705 .213 47.604);--color-orange-600:oklch(.646 .222 41.116);--color-orange-700:oklch(.553 .195 38.402);--color-orange-800:oklch(.47 .157 37.304);--color-orange-900:oklch(.408 .123 38.172);--color-orange-950:oklch(.266 .079 36.259);--color-amber-50:oklch(.987 .022 95.277);--color-amber-100:oklch(.962 .059 95.617);--color-amber-200:oklch(.924 .12 95.746);--color-amber-300:oklch(.879 .169 91.605);--color-amber-400:oklch(.828 .189 84.429);--color-amber-500:oklch(.769 .188 70.08);--color-amber-600:oklch(.666 .179 58.318);--color-amber-700:oklch(.555 .163 48.998);--color-amber-800:oklch(.473 .137 46.201);--color-amber-900:oklch(.414 .112 45.904);--color-amber-950:oklch(.279 .077 45.635);--color-yellow-50:oklch(.987 .026 102.212);--color-yellow-100:oklch(.973 .071 103.193);--color-yellow-200:oklch(.945 .129 101.54);--color-yellow-300:oklch(.905 .182 98.111);--color-yellow-400:oklch(.852 .199 91.936);--color-yellow-500:oklch(.795 .184 86.047);--color-yellow-600:oklch(.681 .162 75.834);--color-yellow-700:oklch(.554 .135 66.442);--color-yellow-800:oklch(.476 .114 61.907);--color-yellow-900:oklch(.421 .095 57.708);--color-yellow-950:oklch(.286 .066 53.813);--color-lime-50:oklch(.986 .031 120.757);--color-lime-100:oklch(.967 .067 122.328);--color-lime-200:oklch(.938 .127 124.321);--color-lime-300:oklch(.897 .196 126.665);--color-lime-400:oklch(.841 .238 128.85);--color-lime-500:oklch(.768 .233 130.85);--color-lime-600:oklch(.648 .2 131.684);--color-lime-700:oklch(.532 .157 131.589);--color-lime-800:oklch(.453 .124 130.933);--color-lime-900:oklch(.405 .101 131.063);--color-lime-950:oklch(.274 .072 132.109);--color-green-50:oklch(.982 .018 155.826);--color-green-100:oklch(.962 .044 156.743);--color-green-200:oklch(.925 .084 155.995);--color-green-300:oklch(.871 .15 154.449);--color-green-400:oklch(.792 .209 151.711);--color-green-500:oklch(.723 .219 149.579);--color-green-600:oklch(.627 .194 149.214);--color-green-700:oklch(.527 .154 150.069);--color-green-800:oklch(.448 .119 151.328);--color-green-900:oklch(.393 .095 152.535);--color-green-950:oklch(.266 .065 152.934);--color-emerald-50:oklch(.979 .021 166.113);--color-emerald-100:oklch(.95 .052 163.051);--color-emerald-200:oklch(.905 .093 164.15);--color-emerald-300:oklch(.845 .143 164.978);--color-emerald-400:oklch(.765 .177 163.223);--color-emerald-500:oklch(.696 .17 162.48);--color-emerald-600:oklch(.596 .145 163.225);--color-emerald-700:oklch(.508 .118 165.612);--color-emerald-800:oklch(.432 .095 166.913);--color-emerald-900:oklch(.378 .077 168.94);--color-emerald-950:oklch(.262 .051 172.552);--color-teal-50:oklch(.984 .014 180.72);--color-teal-100:oklch(.953 .051 180.801);--color-teal-200:oklch(.91 .096 180.426);--color-teal-300:oklch(.855 .138 181.071);--color-teal-400:oklch(.777 .152 181.912);--color-teal-500:oklch(.704 .14 182.503);--color-teal-600:oklch(.6 .118 184.704);--color-teal-700:oklch(.511 .096 186.391);--color-teal-800:oklch(.437 .078 188.216);--color-teal-900:oklch(.386 .063 188.416);--color-teal-950:oklch(.277 .046 192.524);--color-cyan-50:oklch(.984 .019 200.873);--color-cyan-100:oklch(.956 .045 203.388);--color-cyan-200:oklch(.917 .08 205.041);--color-cyan-300:oklch(.865 .127 207.078);--color-cyan-400:oklch(.789 .154 211.53);--color-cyan-500:oklch(.715 .143 215.221);--color-cyan-600:oklch(.609 .126 221.723);--color-cyan-700:oklch(.52 .105 223.128);--color-cyan-800:oklch(.45 .085 224.283);--color-cyan-900:oklch(.398 .07 227.392);--color-cyan-950:oklch(.302 .056 229.695);--color-sky-50:oklch(.977 .013 236.62);--color-sky-100:oklch(.951 .026 236.824);--color-sky-200:oklch(.901 .058 230.902);--color-sky-300:oklch(.828 .111 230.318);--color-sky-400:oklch(.746 .16 232.661);--color-sky-500:oklch(.685 .169 237.323);--color-sky-600:oklch(.588 .158 241.966);--color-sky-700:oklch(.5 .134 242.749);--color-sky-800:oklch(.443 .11 240.79);--color-sky-900:oklch(.391 .09 240.876);--color-sky-950:oklch(.293 .066 243.157);--color-blue-50:oklch(.97 .014 254.604);--color-blue-100:oklch(.932 .032 255.585);--color-blue-200:oklch(.882 .059 254.128);--color-blue-300:oklch(.809 .105 251.813);--color-blue-400:oklch(.707 .165 254.624);--color-blue-500:oklch(.623 .214 259.815);--color-blue-600:oklch(.546 .245 262.881);--color-blue-700:oklch(.488 .243 264.376);--color-blue-800:oklch(.424 .199 265.638);--color-blue-900:oklch(.379 .146 265.522);--color-blue-950:oklch(.282 .091 267.935);--color-indigo-50:oklch(.962 .018 272.314);--color-indigo-100:oklch(.93 .034 272.788);--color-indigo-200:oklch(.87 .065 274.039);--color-indigo-300:oklch(.785 .115 274.713);--color-indigo-400:oklch(.673 .182 276.935);--color-indigo-500:oklch(.585 .233 277.117);--color-indigo-600:oklch(.511 .262 276.966);--color-indigo-700:oklch(.457 .24 277.023);--color-indigo-800:oklch(.398 .195 277.366);--color-indigo-900:oklch(.359 .144 278.697);--color-indigo-950:oklch(.257 .09 281.288);--color-violet-50:oklch(.969 .016 293.756);--color-violet-100:oklch(.943 .029 294.588);--color-violet-200:oklch(.894 .057 293.283);--color-violet-300:oklch(.811 .111 293.571);--color-violet-400:oklch(.702 .183 293.541);--color-violet-500:oklch(.606 .25 292.717);--color-violet-600:oklch(.541 .281 293.009);--color-violet-700:oklch(.491 .27 292.581);--color-violet-800:oklch(.432 .232 292.759);--color-violet-900:oklch(.38 .189 293.745);--color-violet-950:oklch(.283 .141 291.089);--color-purple-50:oklch(.977 .014 308.299);--color-purple-100:oklch(.946 .033 307.174);--color-purple-200:oklch(.902 .063 306.703);--color-purple-300:oklch(.827 .119 306.383);--color-purple-400:oklch(.714 .203 305.504);--color-purple-500:oklch(.627 .265 303.9);--color-purple-600:oklch(.558 .288 302.321);--color-purple-700:oklch(.496 .265 301.924);--color-purple-800:oklch(.438 .218 303.724);--color-purple-900:oklch(.381 .176 304.987);--color-purple-950:oklch(.291 .149 302.717);--color-fuchsia-50:oklch(.977 .017 320.058);--color-fuchsia-100:oklch(.952 .037 318.852);--color-fuchsia-200:oklch(.903 .076 319.62);--color-fuchsia-300:oklch(.833 .145 321.434);--color-fuchsia-400:oklch(.74 .238 322.16);--color-fuchsia-500:oklch(.667 .295 322.15);--color-fuchsia-600:oklch(.591 .293 322.896);--color-fuchsia-700:oklch(.518 .253 323.949);--color-fuchsia-800:oklch(.452 .211 324.591);--color-fuchsia-900:oklch(.401 .17 325.612);--color-fuchsia-950:oklch(.293 .136 325.661);--color-pink-50:oklch(.971 .014 343.198);--color-pink-100:oklch(.948 .028 342.258);--color-pink-200:oklch(.899 .061 343.231);--color-pink-300:oklch(.823 .12 346.018);--color-pink-400:oklch(.718 .202 349.761);--color-pink-500:oklch(.656 .241 354.308);--color-pink-600:oklch(.592 .249 .584);--color-pink-700:oklch(.525 .223 3.958);--color-pink-800:oklch(.459 .187 3.815);--color-pink-900:oklch(.408 .153 2.432);--color-pink-950:oklch(.284 .109 3.907);--color-rose-50:oklch(.969 .015 12.422);--color-rose-100:oklch(.941 .03 12.58);--color-rose-200:oklch(.892 .058 10.001);--color-rose-300:oklch(.81 .117 11.638);--color-rose-400:oklch(.712 .194 13.428);--color-rose-500:oklch(.645 .246 16.439);--color-rose-600:oklch(.586 .253 17.585);--color-rose-700:oklch(.514 .222 16.935);--color-rose-800:oklch(.455 .188 13.697);--color-rose-900:oklch(.41 .159 10.272);--color-rose-950:oklch(.271 .105 12.094);--color-slate-50:oklch(.984 .003 247.858);--color-slate-100:oklch(.968 .007 247.896);--color-slate-200:oklch(.929 .013 255.508);--color-slate-300:oklch(.869 .022 252.894);--color-slate-400:oklch(.704 .04 256.788);--color-slate-500:oklch(.554 .046 257.417);--color-slate-600:oklch(.446 .043 257.281);--color-slate-700:oklch(.372 .044 257.287);--color-slate-800:oklch(.279 .041 260.031);--color-slate-900:oklch(.208 .042 265.755);--color-slate-950:oklch(.129 .042 264.695);--color-gray-50:oklch(.985 .002 247.839);--color-gray-100:oklch(.967 .003 264.542);--color-gray-200:oklch(.928 .006 264.531);--color-gray-300:oklch(.872 .01 258.338);--color-gray-400:oklch(.707 .022 261.325);--color-gray-500:oklch(.551 .027 264.364);--color-gray-600:oklch(.446 .03 256.802);--color-gray-700:oklch(.373 .034 259.733);--color-gray-800:oklch(.278 .033 256.848);--color-gray-900:oklch(.21 .034 264.665);--color-gray-950:oklch(.13 .028 261.692);--color-zinc-50:oklch(.985 0 0);--color-zinc-100:oklch(.967 .001 286.375);--color-zinc-200:oklch(.92 .004 286.32);--color-zinc-300:oklch(.871 .006 286.286);--color-zinc-400:oklch(.705 .015 286.067);--color-zinc-500:oklch(.552 .016 285.938);--color-zinc-600:oklch(.442 .017 285.786);--color-zinc-700:oklch(.37 .013 285.805);--color-zinc-800:oklch(.274 .006 286.033);--color-zinc-900:oklch(.21 .006 285.885);--color-zinc-950:oklch(.141 .005 285.823);--color-neutral-50:oklch(.985 0 0);--color-neutral-100:oklch(.97 0 0);--color-neutral-200:oklch(.922 0 0);--color-neutral-300:oklch(.87 0 0);--color-neutral-400:oklch(.708 0 0);--color-neutral-500:oklch(.556 0 0);--color-neutral-600:oklch(.439 0 0);--color-neutral-700:oklch(.371 0 0);--color-neutral-800:oklch(.269 0 0);--color-neutral-900:oklch(.205 0 0);--color-neutral-950:oklch(.145 0 0);--color-stone-50:oklch(.985 .001 106.423);--color-stone-100:oklch(.97 .001 106.424);--color-stone-200:oklch(.923 .003 48.717);--color-stone-300:oklch(.869 .005 56.366);--color-stone-400:oklch(.709 .01 56.259);--color-stone-500:oklch(.553 .013 58.071);--color-stone-600:oklch(.444 .011 73.639);--color-stone-700:oklch(.374 .01 67.558);--color-stone-800:oklch(.268 .007 34.298);--color-stone-900:oklch(.216 .006 56.043);--color-stone-950:oklch(.147 .004 49.25);--color-black:#000;--color-white:#fff;--spacing:.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px #0000000d;--shadow-xs:0 1px 2px 0 #0000000d;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--shadow-xl:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--shadow-2xl:0 25px 50px -12px #00000040;--inset-shadow-2xs:inset 0 1px #0000000d;--inset-shadow-xs:inset 0 1px 1px #0000000d;--inset-shadow-sm:inset 0 2px 4px #0000000d;--drop-shadow-xs:0 1px 1px #0000000d;--drop-shadow-sm:0 1px 2px #00000026;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--drop-shadow-xl:0 9px 7px #0000001a;--drop-shadow-2xl:0 25px 25px #00000026;--ease-in:cubic-bezier(.4,0,1,1);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0,0,.2,1)infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.top-1\/2{top:50%}.left-1\/2{left:50%}.\!container{width:100%!important}@media (width>=40rem){.\!container{max-width:40rem!important}}@media (width>=48rem){.\!container{max-width:48rem!important}}@media (width>=64rem){.\!container{max-width:64rem!important}}@media (width>=80rem){.\!container{max-width:80rem!important}}@media (width>=96rem){.\!container{max-width:96rem!important}}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-5{margin:calc(var(--spacing)*5)}.m-15{margin:calc(var(--spacing)*15)}.mx-12{margin-inline:calc(var(--spacing)*12)}.my-3{margin-block:calc(var(--spacing)*3)}.my-24{margin-block:calc(var(--spacing)*24)}.mt-2{margin-top:calc(var(--spacing)*2)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.ml-1{margin-left:calc(var(--spacing)*1)}.flex{display:flex}.grid{display:grid}.hidden{display:none}.table{display:table}.h-7{height:calc(var(--spacing)*7)}.h-100{height:calc(var(--spacing)*100)}.h-\[calc\(100vh-68px\)\]{height:calc(100vh - 68px)}.h-full{height:100%}.max-h-full{max-height:100%}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-7{width:calc(var(--spacing)*7)}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.shrink{flex-shrink:1}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-3d{scale:var(--tw-scale-x)var(--tw-scale-y)var(--tw-scale-z)}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-decimal{list-style-type:decimal}.columns-2{columns:2}.columns-3{columns:3}.columns-4{columns:4}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border,.border-1{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-b-gray-200{border-bottom-color:var(--color-gray-200)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-5{padding:calc(var(--spacing)*5)}.p-10{padding:calc(var(--spacing)*10)}.px-5{padding-inline:calc(var(--spacing)*5)}.py-2{padding-block:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-5{padding-top:calc(var(--spacing)*5)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-5{padding-bottom:calc(var(--spacing)*5)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-10{padding-left:calc(var(--spacing)*10)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-2xl\/loose{font-size:var(--text-2xl);line-height:var(--leading-loose)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-gray-600{color:var(--color-gray-600)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.backdrop\:bg-black\/50::backdrop{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}@media (hover:hover){.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}.disabled\:cursor-default:disabled{cursor:default}.disabled\:bg-gray-500:disabled{background-color:var(--color-gray-500)}@media (width>=40rem){.sm\:w-2\/3{width:66.6667%}}@media (width>=64rem){.lg\:w-1\/2{width:50%}}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false} 2 | -------------------------------------------------------------------------------- /frontend/dist/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/dist/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/dist/favicon.ico -------------------------------------------------------------------------------- /frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Finfetch 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Finfetch 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/mockup/README.md: -------------------------------------------------------------------------------- 1 | # Mockup 2 | 3 | Just open the HTML file. 4 | 5 | To continuosly build the tailwind output css, run: 6 | 7 | ```bash 8 | npx @tailwindcss/cli -i ./frontend/mockup/src/input.css -o ./frontend/mockup/src/output.css --watch 9 | ``` 10 | -------------------------------------------------------------------------------- /frontend/mockup/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@tailwindcss/cli": "^4.0.1", 9 | "tailwindcss": "^4.0.1" 10 | } 11 | }, 12 | "node_modules/@parcel/watcher": { 13 | "version": "2.5.1", 14 | "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", 15 | "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", 16 | "hasInstallScript": true, 17 | "dependencies": { 18 | "detect-libc": "^1.0.3", 19 | "is-glob": "^4.0.3", 20 | "micromatch": "^4.0.5", 21 | "node-addon-api": "^7.0.0" 22 | }, 23 | "engines": { 24 | "node": ">= 10.0.0" 25 | }, 26 | "funding": { 27 | "type": "opencollective", 28 | "url": "https://opencollective.com/parcel" 29 | }, 30 | "optionalDependencies": { 31 | "@parcel/watcher-android-arm64": "2.5.1", 32 | "@parcel/watcher-darwin-arm64": "2.5.1", 33 | "@parcel/watcher-darwin-x64": "2.5.1", 34 | "@parcel/watcher-freebsd-x64": "2.5.1", 35 | "@parcel/watcher-linux-arm-glibc": "2.5.1", 36 | "@parcel/watcher-linux-arm-musl": "2.5.1", 37 | "@parcel/watcher-linux-arm64-glibc": "2.5.1", 38 | "@parcel/watcher-linux-arm64-musl": "2.5.1", 39 | "@parcel/watcher-linux-x64-glibc": "2.5.1", 40 | "@parcel/watcher-linux-x64-musl": "2.5.1", 41 | "@parcel/watcher-win32-arm64": "2.5.1", 42 | "@parcel/watcher-win32-ia32": "2.5.1", 43 | "@parcel/watcher-win32-x64": "2.5.1" 44 | } 45 | }, 46 | "node_modules/@parcel/watcher-android-arm64": { 47 | "version": "2.5.1", 48 | "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", 49 | "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", 50 | "cpu": [ 51 | "arm64" 52 | ], 53 | "optional": true, 54 | "os": [ 55 | "android" 56 | ], 57 | "engines": { 58 | "node": ">= 10.0.0" 59 | }, 60 | "funding": { 61 | "type": "opencollective", 62 | "url": "https://opencollective.com/parcel" 63 | } 64 | }, 65 | "node_modules/@parcel/watcher-darwin-arm64": { 66 | "version": "2.5.1", 67 | "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", 68 | "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", 69 | "cpu": [ 70 | "arm64" 71 | ], 72 | "optional": true, 73 | "os": [ 74 | "darwin" 75 | ], 76 | "engines": { 77 | "node": ">= 10.0.0" 78 | }, 79 | "funding": { 80 | "type": "opencollective", 81 | "url": "https://opencollective.com/parcel" 82 | } 83 | }, 84 | "node_modules/@parcel/watcher-darwin-x64": { 85 | "version": "2.5.1", 86 | "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", 87 | "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", 88 | "cpu": [ 89 | "x64" 90 | ], 91 | "optional": true, 92 | "os": [ 93 | "darwin" 94 | ], 95 | "engines": { 96 | "node": ">= 10.0.0" 97 | }, 98 | "funding": { 99 | "type": "opencollective", 100 | "url": "https://opencollective.com/parcel" 101 | } 102 | }, 103 | "node_modules/@parcel/watcher-freebsd-x64": { 104 | "version": "2.5.1", 105 | "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", 106 | "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", 107 | "cpu": [ 108 | "x64" 109 | ], 110 | "optional": true, 111 | "os": [ 112 | "freebsd" 113 | ], 114 | "engines": { 115 | "node": ">= 10.0.0" 116 | }, 117 | "funding": { 118 | "type": "opencollective", 119 | "url": "https://opencollective.com/parcel" 120 | } 121 | }, 122 | "node_modules/@parcel/watcher-linux-arm-glibc": { 123 | "version": "2.5.1", 124 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", 125 | "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", 126 | "cpu": [ 127 | "arm" 128 | ], 129 | "optional": true, 130 | "os": [ 131 | "linux" 132 | ], 133 | "engines": { 134 | "node": ">= 10.0.0" 135 | }, 136 | "funding": { 137 | "type": "opencollective", 138 | "url": "https://opencollective.com/parcel" 139 | } 140 | }, 141 | "node_modules/@parcel/watcher-linux-arm-musl": { 142 | "version": "2.5.1", 143 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", 144 | "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", 145 | "cpu": [ 146 | "arm" 147 | ], 148 | "optional": true, 149 | "os": [ 150 | "linux" 151 | ], 152 | "engines": { 153 | "node": ">= 10.0.0" 154 | }, 155 | "funding": { 156 | "type": "opencollective", 157 | "url": "https://opencollective.com/parcel" 158 | } 159 | }, 160 | "node_modules/@parcel/watcher-linux-arm64-glibc": { 161 | "version": "2.5.1", 162 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", 163 | "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", 164 | "cpu": [ 165 | "arm64" 166 | ], 167 | "optional": true, 168 | "os": [ 169 | "linux" 170 | ], 171 | "engines": { 172 | "node": ">= 10.0.0" 173 | }, 174 | "funding": { 175 | "type": "opencollective", 176 | "url": "https://opencollective.com/parcel" 177 | } 178 | }, 179 | "node_modules/@parcel/watcher-linux-arm64-musl": { 180 | "version": "2.5.1", 181 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", 182 | "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", 183 | "cpu": [ 184 | "arm64" 185 | ], 186 | "optional": true, 187 | "os": [ 188 | "linux" 189 | ], 190 | "engines": { 191 | "node": ">= 10.0.0" 192 | }, 193 | "funding": { 194 | "type": "opencollective", 195 | "url": "https://opencollective.com/parcel" 196 | } 197 | }, 198 | "node_modules/@parcel/watcher-linux-x64-glibc": { 199 | "version": "2.5.1", 200 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", 201 | "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", 202 | "cpu": [ 203 | "x64" 204 | ], 205 | "optional": true, 206 | "os": [ 207 | "linux" 208 | ], 209 | "engines": { 210 | "node": ">= 10.0.0" 211 | }, 212 | "funding": { 213 | "type": "opencollective", 214 | "url": "https://opencollective.com/parcel" 215 | } 216 | }, 217 | "node_modules/@parcel/watcher-linux-x64-musl": { 218 | "version": "2.5.1", 219 | "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", 220 | "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", 221 | "cpu": [ 222 | "x64" 223 | ], 224 | "optional": true, 225 | "os": [ 226 | "linux" 227 | ], 228 | "engines": { 229 | "node": ">= 10.0.0" 230 | }, 231 | "funding": { 232 | "type": "opencollective", 233 | "url": "https://opencollective.com/parcel" 234 | } 235 | }, 236 | "node_modules/@parcel/watcher-win32-arm64": { 237 | "version": "2.5.1", 238 | "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", 239 | "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", 240 | "cpu": [ 241 | "arm64" 242 | ], 243 | "optional": true, 244 | "os": [ 245 | "win32" 246 | ], 247 | "engines": { 248 | "node": ">= 10.0.0" 249 | }, 250 | "funding": { 251 | "type": "opencollective", 252 | "url": "https://opencollective.com/parcel" 253 | } 254 | }, 255 | "node_modules/@parcel/watcher-win32-ia32": { 256 | "version": "2.5.1", 257 | "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", 258 | "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", 259 | "cpu": [ 260 | "ia32" 261 | ], 262 | "optional": true, 263 | "os": [ 264 | "win32" 265 | ], 266 | "engines": { 267 | "node": ">= 10.0.0" 268 | }, 269 | "funding": { 270 | "type": "opencollective", 271 | "url": "https://opencollective.com/parcel" 272 | } 273 | }, 274 | "node_modules/@parcel/watcher-win32-x64": { 275 | "version": "2.5.1", 276 | "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", 277 | "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", 278 | "cpu": [ 279 | "x64" 280 | ], 281 | "optional": true, 282 | "os": [ 283 | "win32" 284 | ], 285 | "engines": { 286 | "node": ">= 10.0.0" 287 | }, 288 | "funding": { 289 | "type": "opencollective", 290 | "url": "https://opencollective.com/parcel" 291 | } 292 | }, 293 | "node_modules/@tailwindcss/cli": { 294 | "version": "4.0.1", 295 | "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.0.1.tgz", 296 | "integrity": "sha512-UuF9NNMdPAO3rSrTey33m6Qw0tX86t5Z+boxEqot8OBwqC8214ByV0/9ib0Y/ojzuZFWaeFldmvSEJsudNYvSg==", 297 | "dependencies": { 298 | "@parcel/watcher": "^2.5.0", 299 | "@tailwindcss/node": "^4.0.1", 300 | "@tailwindcss/oxide": "^4.0.1", 301 | "enhanced-resolve": "^5.18.0", 302 | "lightningcss": "^1.29.1", 303 | "mri": "^1.2.0", 304 | "picocolors": "^1.1.1", 305 | "tailwindcss": "4.0.1" 306 | }, 307 | "bin": { 308 | "tailwindcss": "dist/index.mjs" 309 | } 310 | }, 311 | "node_modules/@tailwindcss/node": { 312 | "version": "4.0.1", 313 | "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.1.tgz", 314 | "integrity": "sha512-lc+ly6PKHqgCVl7eO8D2JlV96Lks5bmL6pdtM6UasyUHLU2zmrOqU6jfgln120IVnCh3VC8GG/ca24xVTtSokw==", 315 | "dependencies": { 316 | "enhanced-resolve": "^5.18.0", 317 | "jiti": "^2.4.2", 318 | "tailwindcss": "4.0.1" 319 | } 320 | }, 321 | "node_modules/@tailwindcss/oxide": { 322 | "version": "4.0.1", 323 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.1.tgz", 324 | "integrity": "sha512-3z1SpWoDeaA6K6jd92CRrGyDghOcRILEgyWVHRhaUm/tcpiazwJpU9BSG0xB7GGGnl9capojaC+zme/nKsZd/w==", 325 | "engines": { 326 | "node": ">= 10" 327 | }, 328 | "optionalDependencies": { 329 | "@tailwindcss/oxide-android-arm64": "4.0.1", 330 | "@tailwindcss/oxide-darwin-arm64": "4.0.1", 331 | "@tailwindcss/oxide-darwin-x64": "4.0.1", 332 | "@tailwindcss/oxide-freebsd-x64": "4.0.1", 333 | "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.1", 334 | "@tailwindcss/oxide-linux-arm64-gnu": "4.0.1", 335 | "@tailwindcss/oxide-linux-arm64-musl": "4.0.1", 336 | "@tailwindcss/oxide-linux-x64-gnu": "4.0.1", 337 | "@tailwindcss/oxide-linux-x64-musl": "4.0.1", 338 | "@tailwindcss/oxide-win32-arm64-msvc": "4.0.1", 339 | "@tailwindcss/oxide-win32-x64-msvc": "4.0.1" 340 | } 341 | }, 342 | "node_modules/@tailwindcss/oxide-android-arm64": { 343 | "version": "4.0.1", 344 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.1.tgz", 345 | "integrity": "sha512-eP/rI9WaAElpeiiHDqGtDqga9iDsOClXxIqdHayHsw93F24F03b60CwgGhrGF9Io/EuWIpz3TMRhPVOLhoXivw==", 346 | "cpu": [ 347 | "arm64" 348 | ], 349 | "optional": true, 350 | "os": [ 351 | "android" 352 | ], 353 | "engines": { 354 | "node": ">= 10" 355 | } 356 | }, 357 | "node_modules/@tailwindcss/oxide-darwin-arm64": { 358 | "version": "4.0.1", 359 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.1.tgz", 360 | "integrity": "sha512-jZVUo0kNd1IjxdCYwg4dwegDNsq7PoUx4LM814RmgY3gfJ63Y6GlpJXHOpd5FLv1igpeZox5LzRk2oz8MQoJwQ==", 361 | "cpu": [ 362 | "arm64" 363 | ], 364 | "optional": true, 365 | "os": [ 366 | "darwin" 367 | ], 368 | "engines": { 369 | "node": ">= 10" 370 | } 371 | }, 372 | "node_modules/@tailwindcss/oxide-darwin-x64": { 373 | "version": "4.0.1", 374 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.1.tgz", 375 | "integrity": "sha512-E31wHiIf4LB0aKRohrS4U6XfFSACCL9ifUFfPQ16FhcBIL4wU5rcBidvWvT9TQFGPkpE69n5dyXUcqiMrnF/Ig==", 376 | "cpu": [ 377 | "x64" 378 | ], 379 | "optional": true, 380 | "os": [ 381 | "darwin" 382 | ], 383 | "engines": { 384 | "node": ">= 10" 385 | } 386 | }, 387 | "node_modules/@tailwindcss/oxide-freebsd-x64": { 388 | "version": "4.0.1", 389 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.1.tgz", 390 | "integrity": "sha512-8/3ZKLMYqgAsBzTeczOKWtT4geF02g9S7cntY5gvqQZ4E0ImX724cHcZJi9k6fkE6aLbvwxxHxaShFvRxblwKQ==", 391 | "cpu": [ 392 | "x64" 393 | ], 394 | "optional": true, 395 | "os": [ 396 | "freebsd" 397 | ], 398 | "engines": { 399 | "node": ">= 10" 400 | } 401 | }, 402 | "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { 403 | "version": "4.0.1", 404 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.1.tgz", 405 | "integrity": "sha512-EYjbh225klQfWzy6LeIAfdjHCK+p71yLV/GjdPNW47Bfkkq05fTzIhHhCgshUvNp78EIA33iQU+ktWpW06NgHw==", 406 | "cpu": [ 407 | "arm" 408 | ], 409 | "optional": true, 410 | "os": [ 411 | "linux" 412 | ], 413 | "engines": { 414 | "node": ">= 10" 415 | } 416 | }, 417 | "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { 418 | "version": "4.0.1", 419 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.1.tgz", 420 | "integrity": "sha512-PrX2SwIqWNP5cYeSyQfrhbk4ffOM338T6CrEwIAGvLPoUZiklt19yknlsBme6bReSw7TSAMy+8KFdLLi5fcWNQ==", 421 | "cpu": [ 422 | "arm64" 423 | ], 424 | "optional": true, 425 | "os": [ 426 | "linux" 427 | ], 428 | "engines": { 429 | "node": ">= 10" 430 | } 431 | }, 432 | "node_modules/@tailwindcss/oxide-linux-arm64-musl": { 433 | "version": "4.0.1", 434 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.1.tgz", 435 | "integrity": "sha512-iuoFGhKDojtfloi5uj6MIk4kxEOGcsAk/kPbZItF9Dp7TnzVhxo2U/718tXhxGrg6jSL3ST3cQHIjA6yw3OeXw==", 436 | "cpu": [ 437 | "arm64" 438 | ], 439 | "optional": true, 440 | "os": [ 441 | "linux" 442 | ], 443 | "engines": { 444 | "node": ">= 10" 445 | } 446 | }, 447 | "node_modules/@tailwindcss/oxide-linux-x64-gnu": { 448 | "version": "4.0.1", 449 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.1.tgz", 450 | "integrity": "sha512-pNUrGQYyE8RK+N9yvkPmHnlKDfFbni9A3lsi37u4RoA/6Yn+zWVoegvAQMZu3w+jqnpb2A/bYJ+LumcclUZ3yg==", 451 | "cpu": [ 452 | "x64" 453 | ], 454 | "optional": true, 455 | "os": [ 456 | "linux" 457 | ], 458 | "engines": { 459 | "node": ">= 10" 460 | } 461 | }, 462 | "node_modules/@tailwindcss/oxide-linux-x64-musl": { 463 | "version": "4.0.1", 464 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.1.tgz", 465 | "integrity": "sha512-xSGWaDcT6SJ75su9zWXj8GYb2jM/przXwZGH96RTS7HGDIoI1tvgpls88YajG5Sx7hXaqAWCufjw5L/dlu+lzg==", 466 | "cpu": [ 467 | "x64" 468 | ], 469 | "optional": true, 470 | "os": [ 471 | "linux" 472 | ], 473 | "engines": { 474 | "node": ">= 10" 475 | } 476 | }, 477 | "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { 478 | "version": "4.0.1", 479 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.1.tgz", 480 | "integrity": "sha512-BUNL2isUZ2yWnbplPddggJpZxsqGHPZ1RJAYpu63W4znUnKCzI4m/jiy0WpyYqqOKL9jDM5q0QdsQ9mc3aw5YQ==", 481 | "cpu": [ 482 | "arm64" 483 | ], 484 | "optional": true, 485 | "os": [ 486 | "win32" 487 | ], 488 | "engines": { 489 | "node": ">= 10" 490 | } 491 | }, 492 | "node_modules/@tailwindcss/oxide-win32-x64-msvc": { 493 | "version": "4.0.1", 494 | "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.1.tgz", 495 | "integrity": "sha512-ZtcVu+XXOddGsPlvO5nh2fnbKmwly2C07ZB1lcYCf/b8qIWF04QY9o6vy6/+6ioLRfbp3E7H/ipFio38DZX4oQ==", 496 | "cpu": [ 497 | "x64" 498 | ], 499 | "optional": true, 500 | "os": [ 501 | "win32" 502 | ], 503 | "engines": { 504 | "node": ">= 10" 505 | } 506 | }, 507 | "node_modules/braces": { 508 | "version": "3.0.3", 509 | "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 510 | "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 511 | "dependencies": { 512 | "fill-range": "^7.1.1" 513 | }, 514 | "engines": { 515 | "node": ">=8" 516 | } 517 | }, 518 | "node_modules/detect-libc": { 519 | "version": "1.0.3", 520 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", 521 | "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", 522 | "bin": { 523 | "detect-libc": "bin/detect-libc.js" 524 | }, 525 | "engines": { 526 | "node": ">=0.10" 527 | } 528 | }, 529 | "node_modules/enhanced-resolve": { 530 | "version": "5.18.0", 531 | "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", 532 | "integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==", 533 | "dependencies": { 534 | "graceful-fs": "^4.2.4", 535 | "tapable": "^2.2.0" 536 | }, 537 | "engines": { 538 | "node": ">=10.13.0" 539 | } 540 | }, 541 | "node_modules/fill-range": { 542 | "version": "7.1.1", 543 | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 544 | "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 545 | "dependencies": { 546 | "to-regex-range": "^5.0.1" 547 | }, 548 | "engines": { 549 | "node": ">=8" 550 | } 551 | }, 552 | "node_modules/graceful-fs": { 553 | "version": "4.2.11", 554 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 555 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" 556 | }, 557 | "node_modules/is-extglob": { 558 | "version": "2.1.1", 559 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 560 | "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 561 | "engines": { 562 | "node": ">=0.10.0" 563 | } 564 | }, 565 | "node_modules/is-glob": { 566 | "version": "4.0.3", 567 | "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 568 | "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 569 | "dependencies": { 570 | "is-extglob": "^2.1.1" 571 | }, 572 | "engines": { 573 | "node": ">=0.10.0" 574 | } 575 | }, 576 | "node_modules/is-number": { 577 | "version": "7.0.0", 578 | "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 579 | "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 580 | "engines": { 581 | "node": ">=0.12.0" 582 | } 583 | }, 584 | "node_modules/jiti": { 585 | "version": "2.4.2", 586 | "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", 587 | "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", 588 | "bin": { 589 | "jiti": "lib/jiti-cli.mjs" 590 | } 591 | }, 592 | "node_modules/lightningcss": { 593 | "version": "1.29.1", 594 | "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", 595 | "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", 596 | "dependencies": { 597 | "detect-libc": "^1.0.3" 598 | }, 599 | "engines": { 600 | "node": ">= 12.0.0" 601 | }, 602 | "funding": { 603 | "type": "opencollective", 604 | "url": "https://opencollective.com/parcel" 605 | }, 606 | "optionalDependencies": { 607 | "lightningcss-darwin-arm64": "1.29.1", 608 | "lightningcss-darwin-x64": "1.29.1", 609 | "lightningcss-freebsd-x64": "1.29.1", 610 | "lightningcss-linux-arm-gnueabihf": "1.29.1", 611 | "lightningcss-linux-arm64-gnu": "1.29.1", 612 | "lightningcss-linux-arm64-musl": "1.29.1", 613 | "lightningcss-linux-x64-gnu": "1.29.1", 614 | "lightningcss-linux-x64-musl": "1.29.1", 615 | "lightningcss-win32-arm64-msvc": "1.29.1", 616 | "lightningcss-win32-x64-msvc": "1.29.1" 617 | } 618 | }, 619 | "node_modules/lightningcss-darwin-arm64": { 620 | "version": "1.29.1", 621 | "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", 622 | "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", 623 | "cpu": [ 624 | "arm64" 625 | ], 626 | "optional": true, 627 | "os": [ 628 | "darwin" 629 | ], 630 | "engines": { 631 | "node": ">= 12.0.0" 632 | }, 633 | "funding": { 634 | "type": "opencollective", 635 | "url": "https://opencollective.com/parcel" 636 | } 637 | }, 638 | "node_modules/lightningcss-darwin-x64": { 639 | "version": "1.29.1", 640 | "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", 641 | "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", 642 | "cpu": [ 643 | "x64" 644 | ], 645 | "optional": true, 646 | "os": [ 647 | "darwin" 648 | ], 649 | "engines": { 650 | "node": ">= 12.0.0" 651 | }, 652 | "funding": { 653 | "type": "opencollective", 654 | "url": "https://opencollective.com/parcel" 655 | } 656 | }, 657 | "node_modules/lightningcss-freebsd-x64": { 658 | "version": "1.29.1", 659 | "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", 660 | "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", 661 | "cpu": [ 662 | "x64" 663 | ], 664 | "optional": true, 665 | "os": [ 666 | "freebsd" 667 | ], 668 | "engines": { 669 | "node": ">= 12.0.0" 670 | }, 671 | "funding": { 672 | "type": "opencollective", 673 | "url": "https://opencollective.com/parcel" 674 | } 675 | }, 676 | "node_modules/lightningcss-linux-arm-gnueabihf": { 677 | "version": "1.29.1", 678 | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", 679 | "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", 680 | "cpu": [ 681 | "arm" 682 | ], 683 | "optional": true, 684 | "os": [ 685 | "linux" 686 | ], 687 | "engines": { 688 | "node": ">= 12.0.0" 689 | }, 690 | "funding": { 691 | "type": "opencollective", 692 | "url": "https://opencollective.com/parcel" 693 | } 694 | }, 695 | "node_modules/lightningcss-linux-arm64-gnu": { 696 | "version": "1.29.1", 697 | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", 698 | "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", 699 | "cpu": [ 700 | "arm64" 701 | ], 702 | "optional": true, 703 | "os": [ 704 | "linux" 705 | ], 706 | "engines": { 707 | "node": ">= 12.0.0" 708 | }, 709 | "funding": { 710 | "type": "opencollective", 711 | "url": "https://opencollective.com/parcel" 712 | } 713 | }, 714 | "node_modules/lightningcss-linux-arm64-musl": { 715 | "version": "1.29.1", 716 | "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", 717 | "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", 718 | "cpu": [ 719 | "arm64" 720 | ], 721 | "optional": true, 722 | "os": [ 723 | "linux" 724 | ], 725 | "engines": { 726 | "node": ">= 12.0.0" 727 | }, 728 | "funding": { 729 | "type": "opencollective", 730 | "url": "https://opencollective.com/parcel" 731 | } 732 | }, 733 | "node_modules/lightningcss-linux-x64-gnu": { 734 | "version": "1.29.1", 735 | "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", 736 | "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", 737 | "cpu": [ 738 | "x64" 739 | ], 740 | "optional": true, 741 | "os": [ 742 | "linux" 743 | ], 744 | "engines": { 745 | "node": ">= 12.0.0" 746 | }, 747 | "funding": { 748 | "type": "opencollective", 749 | "url": "https://opencollective.com/parcel" 750 | } 751 | }, 752 | "node_modules/lightningcss-linux-x64-musl": { 753 | "version": "1.29.1", 754 | "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", 755 | "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", 756 | "cpu": [ 757 | "x64" 758 | ], 759 | "optional": true, 760 | "os": [ 761 | "linux" 762 | ], 763 | "engines": { 764 | "node": ">= 12.0.0" 765 | }, 766 | "funding": { 767 | "type": "opencollective", 768 | "url": "https://opencollective.com/parcel" 769 | } 770 | }, 771 | "node_modules/lightningcss-win32-arm64-msvc": { 772 | "version": "1.29.1", 773 | "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", 774 | "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", 775 | "cpu": [ 776 | "arm64" 777 | ], 778 | "optional": true, 779 | "os": [ 780 | "win32" 781 | ], 782 | "engines": { 783 | "node": ">= 12.0.0" 784 | }, 785 | "funding": { 786 | "type": "opencollective", 787 | "url": "https://opencollective.com/parcel" 788 | } 789 | }, 790 | "node_modules/lightningcss-win32-x64-msvc": { 791 | "version": "1.29.1", 792 | "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", 793 | "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", 794 | "cpu": [ 795 | "x64" 796 | ], 797 | "optional": true, 798 | "os": [ 799 | "win32" 800 | ], 801 | "engines": { 802 | "node": ">= 12.0.0" 803 | }, 804 | "funding": { 805 | "type": "opencollective", 806 | "url": "https://opencollective.com/parcel" 807 | } 808 | }, 809 | "node_modules/micromatch": { 810 | "version": "4.0.8", 811 | "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", 812 | "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 813 | "dependencies": { 814 | "braces": "^3.0.3", 815 | "picomatch": "^2.3.1" 816 | }, 817 | "engines": { 818 | "node": ">=8.6" 819 | } 820 | }, 821 | "node_modules/mri": { 822 | "version": "1.2.0", 823 | "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", 824 | "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", 825 | "engines": { 826 | "node": ">=4" 827 | } 828 | }, 829 | "node_modules/node-addon-api": { 830 | "version": "7.1.1", 831 | "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 832 | "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" 833 | }, 834 | "node_modules/picocolors": { 835 | "version": "1.1.1", 836 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 837 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" 838 | }, 839 | "node_modules/picomatch": { 840 | "version": "2.3.1", 841 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 842 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 843 | "engines": { 844 | "node": ">=8.6" 845 | }, 846 | "funding": { 847 | "url": "https://github.com/sponsors/jonschlinkert" 848 | } 849 | }, 850 | "node_modules/tailwindcss": { 851 | "version": "4.0.1", 852 | "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.1.tgz", 853 | "integrity": "sha512-UK5Biiit/e+r3i0O223bisoS5+y7ZT1PM8Ojn0MxRHzXN1VPZ2KY6Lo6fhu1dOfCfyUAlK7Lt6wSxowRabATBw==" 854 | }, 855 | "node_modules/tapable": { 856 | "version": "2.2.1", 857 | "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", 858 | "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", 859 | "engines": { 860 | "node": ">=6" 861 | } 862 | }, 863 | "node_modules/to-regex-range": { 864 | "version": "5.0.1", 865 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 866 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 867 | "dependencies": { 868 | "is-number": "^7.0.0" 869 | }, 870 | "engines": { 871 | "node": ">=8.0" 872 | } 873 | } 874 | } 875 | } 876 | -------------------------------------------------------------------------------- /frontend/mockup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tailwindcss/cli": "^4.0.1", 4 | "tailwindcss": "^4.0.1" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/mockup/src/design-mockup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Finfetch

11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 |

Cashmoney Bank

19 | Remove 20 |
21 |
22 |
23 |

Checking 2931

24 | Remove 25 |
26 |
27 |
28 |
Nickname
29 |
Cashmoney Checking Edit
30 |
31 |
32 |
Last Downloaded
33 |
5 Days Ago
34 |
35 |
36 |
37 | 38 |
39 |
40 |

Savings 4429

41 | Remove 42 |
43 |
44 |
45 |
Nickname
46 |
Cashmoney Savings Edit
47 |
48 |
49 |
Last Downloaded
50 |
5 Days Ago
51 |
52 |
53 |
54 | 55 |
56 | 57 |
58 |
59 |

Indiana United Bank of the Holy Americas

60 | Remove 61 |
62 |
63 |
64 |

Checking 4483

65 | Remove 66 |
67 |
68 |
69 |
Nickname
70 |
Indiana United Bank of the Holy Americas Checking Edit
71 |
72 |
73 |
Last Downloaded
74 |
5 Days Ago
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 | 83 |
84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /frontend/mockup/src/input.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tailwindcss/vite": "^4.0.1", 14 | "date-fns": "^4.1.0", 15 | "lucide-react": "^0.474.0", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0", 18 | "react-plaid-link": "^3.6.1", 19 | "tailwindcss": "^4.0.1" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.17.0", 23 | "@types/react": "^18.3.18", 24 | "@types/react-dom": "^18.3.5", 25 | "@vitejs/plugin-react": "^4.3.4", 26 | "eslint": "^9.17.0", 27 | "eslint-plugin-react-hooks": "^5.0.0", 28 | "eslint-plugin-react-refresh": "^0.4.16", 29 | "globals": "^15.14.0", 30 | "typescript": "~5.6.2", 31 | "typescript-eslint": "^8.18.2", 32 | "vite": "^6.0.5" 33 | }, 34 | "overrides": { 35 | "react-plaid-link": { 36 | "react": "$react", 37 | "react-dom": "$react-dom" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/public/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Rhodium Libre 4 | - Font Author: undefined 5 | - Font Source: https://fonts.gstatic.com/s/rhodiumlibre/v19/1q2AY5adA0tn_ukeHcQHqpx6pETLeo2gm2U.ttf 6 | - Font License: undefined) 7 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natepmay/finfetch/5d94f69121b3052f58946d29b57e29c8a8e2598d/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { Header } from "./components/Header"; 4 | import { LoggedOut } from "./components/logged-out/LoggedOut"; 5 | import { LoggedIn } from "./components/logged-in/LoggedIn"; 6 | 7 | import { CryptoKeyContext } from "./context/CryptoKeyContext"; 8 | 9 | function App() { 10 | const [cryptoKey, setCryptoKey] = useState(null as CryptoKey | null); 11 | 12 | return ( 13 |
14 | 15 |
16 |
{cryptoKey ? : }
17 |
18 |
19 | ); 20 | } 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import downloadAndSaveFile from "./utils/download.ts"; 2 | import { Item, Account } from "../../sharedTypes.ts"; 3 | import { deriveKey, exportKey } from "./utils/crypto.ts"; 4 | import { PlaidLinkOnSuccessMetadata } from "react-plaid-link"; 5 | 6 | const BASE_BACKEND_URL = "http://localhost:3002"; 7 | 8 | /** 9 | * Get all items from database. 10 | * @param cryptoKey 11 | * @returns 12 | */ 13 | export async function getItems(cryptoKey: CryptoKey): Promise { 14 | const cryptoKeyString = await exportKey(cryptoKey); 15 | 16 | const res = await fetch(BASE_BACKEND_URL + "/api/items", { 17 | headers: { 18 | "X-Crypto-Key-String": cryptoKeyString, 19 | "Access-Control-Expose-Headers": "X-Crypto-Key-String", 20 | }, 21 | }); 22 | 23 | if (!res.ok) { 24 | throw new Error("Failed to fetch items."); 25 | } 26 | 27 | const data: Item[] = await res.json(); 28 | return data; 29 | } 30 | 31 | /** 32 | * Get accounts from database by itemId. 33 | * @param itemId 34 | * @returns 35 | */ 36 | export async function getAccounts(itemId: string): Promise { 37 | const res = await fetch(`${BASE_BACKEND_URL}/api/items/${itemId}/accounts`); 38 | if (!res.ok) { 39 | throw new Error("Failed to fetch accounts."); 40 | } 41 | const data: Account[] = await res.json(); 42 | return data; 43 | } 44 | 45 | export interface TxnCount { 46 | added: number; 47 | removed: number; 48 | modified: number; 49 | } 50 | 51 | /** 52 | * Call the sync endpoint and download the file. 53 | * @param dateQuery Retrieve transactions since last download ("cursor") or as far back as possible ("all") 54 | * @param cryptoKey 55 | * @returns Object with counts of added, modified, and deleted transactions. 56 | */ 57 | export async function downloadWrapper( 58 | dateQuery: "cursor" | "all", 59 | cryptoKey: CryptoKey 60 | ): Promise { 61 | const cryptoKeyString = await exportKey(cryptoKey); 62 | 63 | const txnCount = await downloadAndSaveFile( 64 | { 65 | url: `${BASE_BACKEND_URL}/api/sync?dateQuery=${dateQuery}`, 66 | defaultFileName: "transactions.zip", 67 | }, 68 | cryptoKeyString 69 | ); 70 | return txnCount; 71 | } 72 | 73 | /** 74 | * Update an account in the database. This doesn't make any changes on the Plaid side. 75 | * @param resource account to change 76 | * @returns 77 | */ 78 | export async function updateAccount(resource: Account) { 79 | const res = await fetch( 80 | `${BASE_BACKEND_URL}/api/accounts/${resource.accountId}`, 81 | { 82 | method: "PUT", 83 | body: JSON.stringify(resource), 84 | headers: { 85 | "Content-Type": "application/json", 86 | }, 87 | } 88 | ); 89 | const numRows: number = await res.json(); 90 | return numRows; 91 | } 92 | 93 | /** 94 | * Delete an item from database and remove the item on the Plaid side. 95 | * @param itemId itemId of item to delete 96 | */ 97 | export async function deleteItem(itemId: string, cryptoKey: CryptoKey) { 98 | const cryptoKeyString = await exportKey(cryptoKey); 99 | 100 | try { 101 | await fetch(`${BASE_BACKEND_URL}/api/items/${itemId}`, { 102 | method: "DELETE", 103 | headers: { 104 | "X-Crypto-Key-String": cryptoKeyString, 105 | "Access-Control-Expose-Headers": "X-Crypto-Key-String", 106 | }, 107 | }); 108 | } catch (error) { 109 | throw new Error(String(error)); 110 | } 111 | } 112 | 113 | /** 114 | * Wipes data and initiates a new user with the given password. 115 | * @param password 116 | * @returns cryptoKey to add to the app's state. 117 | */ 118 | export async function initUser(password: string) { 119 | const resp = await fetch(`${BASE_BACKEND_URL}/api/users/create`, { 120 | method: "POST", 121 | }); 122 | const arrayBuffer = await resp.arrayBuffer(); 123 | const salt = new Uint8Array(arrayBuffer); 124 | 125 | const cryptoKey = await deriveKey(password, salt); 126 | 127 | return cryptoKey; 128 | } 129 | 130 | /** 131 | * Create an access token for an Item and add it to the database. Call on successful completion of a link flow to add an Item. 132 | * @param publicToken provided from Plaid for exchange purposes 133 | * @param metadata from Plaid 134 | * @param cryptoKey 135 | * @returns itemId of the item added 136 | */ 137 | export async function createAccessToken( 138 | publicToken: string, 139 | metadata: PlaidLinkOnSuccessMetadata, 140 | cryptoKey: CryptoKey 141 | ) { 142 | const cryptoKeyString = await exportKey(cryptoKey); 143 | 144 | const response = await fetch(BASE_BACKEND_URL + "/api/create_access_token", { 145 | method: "POST", 146 | headers: { 147 | "Content-Type": "application/json", 148 | }, 149 | body: JSON.stringify({ 150 | publicToken: publicToken, 151 | metadata: metadata, 152 | cryptoKeyString: cryptoKeyString, 153 | }), 154 | }); 155 | const data = (await response.json()) as { itemId: string }; 156 | return data.itemId; 157 | } 158 | 159 | /** 160 | * Create a token that allows Plaid Link to open. 161 | * @returns 162 | */ 163 | export async function createLinkToken() { 164 | const response = await fetch(BASE_BACKEND_URL + "/api/create_link_token", { 165 | method: "POST", 166 | }); 167 | const { link_token } = (await response.json()) as { link_token: string }; 168 | return link_token; 169 | } 170 | 171 | /** 172 | * Retrieve the password salt from the user database. 173 | * @returns 174 | */ 175 | export async function getSalt() { 176 | try { 177 | const saltRaw = await fetch(BASE_BACKEND_URL + "/api/users/1/salt", { 178 | method: "GET", 179 | }); 180 | if (!saltRaw.ok) throw new Error("no salt"); 181 | const saltBuffer = await saltRaw.arrayBuffer(); 182 | const salt = new Uint8Array(saltBuffer); 183 | return salt; 184 | } catch (err) { 185 | throw new Error(String(err)); 186 | } 187 | } 188 | 189 | /** 190 | * Check if the entered password is correct, and return the cryptoKey if so. 191 | * @param password 192 | * @returns 193 | */ 194 | export async function authenticate(password: string) { 195 | const salt = await getSalt(); 196 | 197 | const cryptoKey = await deriveKey(password, salt); 198 | 199 | try { 200 | // will error if decryption fails 201 | await getItems(cryptoKey); 202 | return cryptoKey; 203 | } catch { 204 | return null; 205 | } 206 | } 207 | 208 | /** 209 | * Remove all data from the database. This is done implicitly during user initiation, but should be called explicitly when a user runs out of password retries. 210 | */ 211 | export async function wipeData() { 212 | try { 213 | const res = await fetch(`${BASE_BACKEND_URL}/api/users/1`, { 214 | method: "DELETE", 215 | }); 216 | if (!res.ok) throw new Error("Unable to delete"); 217 | } catch (err) { 218 | throw new Error(String(err)); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /frontend/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | export function Header() { 2 | return ( 3 |
4 |

Finfetch

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/item/DistanceSinceDownloaded.tsx: -------------------------------------------------------------------------------- 1 | import { formatDistanceToNow } from "date-fns"; 2 | import { useState, useEffect } from "react"; 3 | 4 | export function DistanceSinceDownloaded({ 5 | lastDownloaded, 6 | }: { 7 | lastDownloaded: number | null; 8 | }) { 9 | const [text, setText] = useState(""); 10 | 11 | useEffect(() => { 12 | if (!lastDownloaded) return; 13 | 14 | const lastDownloadedDate = new Date(Number(lastDownloaded)); 15 | 16 | function updateText() { 17 | setText(formatDistanceToNow(lastDownloadedDate)); 18 | } 19 | 20 | updateText(); 21 | 22 | const interval = setInterval(() => updateText(), 60000); 23 | return () => clearInterval(interval); 24 | }, [lastDownloaded]); 25 | 26 | return <>{lastDownloaded ? text + " ago" : "Never"}; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/item/ItemCard.tsx: -------------------------------------------------------------------------------- 1 | import { ItemCardAccount } from "./ItemCardAccount"; 2 | import { ItemHeader } from "./ItemHeader"; 3 | import { ItemWithAccounts } from "../../context/DataContext"; 4 | import { RefreshContext } from "../../context/RefreshContext"; 5 | import { useContext } from "react"; 6 | 7 | export function ItemCard({ item }: { item: ItemWithAccounts }) { 8 | const { accounts } = item; 9 | 10 | const refreshData = useContext(RefreshContext); 11 | 12 | const accountDisplays = accounts.map((account) => { 13 | return ( 14 | 19 | ); 20 | }); 21 | 22 | return ( 23 |
24 | 25 | {accounts.length > 0 && accountDisplays} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/item/ItemCardAccount.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "../shared/Modal"; 2 | import { useState } from "react"; 3 | import { SquarePen } from "lucide-react"; 4 | import { Button } from "../shared/Button"; 5 | import { updateAccount } from "../../api"; 6 | import { Account } from "../../../../sharedTypes"; 7 | import { DistanceSinceDownloaded } from "./DistanceSinceDownloaded"; 8 | 9 | export function ItemCardAccount({ 10 | account, 11 | refreshAccounts, 12 | }: { 13 | account: Account; 14 | refreshAccounts: () => void; 15 | }) { 16 | const [isModalOpen, setIsModalOpen] = useState(false); 17 | 18 | async function handleNicknameSubmit(event: React.FormEvent) { 19 | event.preventDefault(); 20 | const formData = new FormData(event.currentTarget); 21 | const nextNickname = formData.get("nickname") as string; 22 | const nextAccount = { 23 | ...account, 24 | nickname: nextNickname, 25 | }; 26 | await updateAccount(nextAccount); 27 | setIsModalOpen(false); 28 | refreshAccounts(); 29 | } 30 | 31 | const { name, nickname, lastDownloaded } = account; 32 | 33 | return ( 34 |
35 |
36 |

{name}

37 |
38 |
39 |
40 |
Nickname
41 |
42 | {nickname}{" "} 43 | 50 |
51 |
52 |
53 |
Last Downloaded
54 |
55 | 56 |
57 |
58 |
59 | 60 | setIsModalOpen(false)}> 61 |
62 |

Account Nickname

63 |

64 | 65 |

66 | 73 | 76 |
77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/components/item/ItemCardArea.tsx: -------------------------------------------------------------------------------- 1 | import { ItemCard } from "./ItemCard"; 2 | import { useContext } from "react"; 3 | import { DataContext } from "../../context/DataContext"; 4 | 5 | export function ItemCardArea() { 6 | const items = useContext(DataContext); 7 | 8 | const itemCards = items.map((item) => ( 9 | 10 | )); 11 | 12 | return ( 13 |
14 | {items.length > 0 && itemCards} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/item/ItemHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from "react"; 2 | import { Trash2 } from "lucide-react"; 3 | 4 | import { Modal } from "../shared/Modal"; 5 | import { Button } from "../shared/Button"; 6 | 7 | import { deleteItem } from "../../api"; 8 | import { Item } from "../../../../sharedTypes"; 9 | 10 | import { RefreshContext } from "../../context/RefreshContext"; 11 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 12 | 13 | export function ItemHeader({ item }: { item: Item }) { 14 | const { cryptoKey } = useContext(CryptoKeyContext); 15 | 16 | type ModalState = "closed" | "removeConfirm"; 17 | 18 | const [modalState, setModalState] = useState("closed" as ModalState); 19 | const refreshData = useContext(RefreshContext); 20 | 21 | async function handleConfirm() { 22 | await deleteItem(item.itemId, cryptoKey!); 23 | refreshData(); 24 | setModalState("closed"); 25 | } 26 | 27 | return ( 28 | <> 29 |
30 |

{item.name}

31 | 38 |
39 | setModalState("closed")} 42 | > 43 |

Confirm Removal

44 |

45 | Are you sure you want to remove {item.name}? 46 |

47 |

(You can always add it back later.)

48 |
49 | 50 |
51 |
52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/components/logged-in/AddItemButtonArea.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../shared/Button"; 2 | import { Plus } from "lucide-react"; 3 | import { usePlaidLink, PlaidLinkOnSuccess } from "react-plaid-link"; 4 | import { useCallback, useState, useEffect, useContext } from "react"; 5 | import { RefreshContext } from "../../context/RefreshContext"; 6 | import { createAccessToken, createLinkToken } from "../../api"; 7 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 8 | 9 | export function AddItemButtonArea() { 10 | const [token, setToken] = useState(null); 11 | const refreshData = useContext(RefreshContext); 12 | const { cryptoKey } = useContext(CryptoKeyContext); 13 | 14 | useEffect(() => { 15 | const tokenWrapper = async () => { 16 | const linkToken = await createLinkToken(); 17 | setToken(linkToken); 18 | }; 19 | tokenWrapper(); 20 | }, []); 21 | 22 | const onSuccess = useCallback( 23 | async (publicToken, metadata) => { 24 | await createAccessToken(publicToken, metadata, cryptoKey!); 25 | refreshData(); 26 | }, 27 | [refreshData, cryptoKey] 28 | ); 29 | 30 | const { open, ready } = usePlaidLink({ 31 | token, 32 | onSuccess, 33 | // onEvent 34 | // onExit 35 | }); 36 | 37 | return ( 38 |
39 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/logged-in/DownloadButtonArea.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useContext, useState } from "react"; 2 | import { ArrowDownToLine } from "lucide-react"; 3 | 4 | import { Button } from "../shared/Button"; 5 | import { Modal } from "../shared/Modal"; 6 | import { downloadWrapper, TxnCount } from "../../api"; 7 | import { RefreshContext } from "../../context/RefreshContext"; 8 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 9 | 10 | type ModalState = "hidden" | "error" | "success"; 11 | 12 | export function DownloadButtonArea({ disabled }: { disabled: boolean }) { 13 | const refreshData = useContext(RefreshContext); 14 | const { cryptoKey } = useContext(CryptoKeyContext); 15 | const [modalState, setModalState] = useState("hidden" as ModalState); 16 | const [errorMessage, setErrorMessage] = useState(null as string | null); 17 | const [dateQuery, setDateQuery] = useState("cursor" as "cursor" | "all"); 18 | const [txnCount, setTxnCount] = useState({ 19 | added: 0, 20 | removed: 0, 21 | modified: 0, 22 | } as TxnCount); 23 | 24 | async function handleOnClick() { 25 | try { 26 | const txnsRaw = await downloadWrapper(dateQuery, cryptoKey!); 27 | setTxnCount(txnsRaw); 28 | refreshData(); 29 | setModalState("success"); 30 | } catch (err) { 31 | setErrorMessage((err as Error).message); 32 | setModalState("error"); 33 | } 34 | } 35 | 36 | function handleChange(e: ChangeEvent) { 37 | setDateQuery(e.target.value as "cursor" | "all"); 38 | } 39 | 40 | return ( 41 |
42 |
43 | 51 | 52 | 60 | 61 |
62 | 67 | 68 | setModalState("hidden")} 71 | > 72 |
73 |

Error

74 |
{errorMessage}
75 | 78 |
79 |
80 | 81 | setModalState("hidden")} 84 | > 85 |
86 |

Success

87 |
88 | {Object.values(txnCount).every((num) => num === 0) ? ( 89 | <>No new transactions. 90 | ) : ( 91 | <> 92 | Transactions added: {txnCount.added}, removed:{" "} 93 | {txnCount.removed}, modified: {txnCount.modified}. 94 | 95 | )} 96 |
97 | 100 |
101 |
102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /frontend/src/components/logged-in/LoggedIn.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | 3 | import { ItemCardArea } from "../item/ItemCardArea"; 4 | import { AddItemButtonArea } from "./AddItemButtonArea"; 5 | import { DownloadButtonArea } from "./DownloadButtonArea"; 6 | import { NoItemsMessage } from "./NoItemsMessage"; 7 | 8 | import { getItems, getAccounts } from "../../api"; 9 | 10 | import { DataContext, ItemWithAccounts } from "../../context/DataContext"; 11 | import { RefreshContext } from "../../context/RefreshContext"; 12 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 13 | import { ResetPw } from "./ResetPw"; 14 | 15 | export function LoggedIn() { 16 | const [appData, setAppData] = useState([] as ItemWithAccounts[]); 17 | const [refreshTrigger, setRefreshTrigger] = useState(0); 18 | 19 | const { cryptoKey } = useContext(CryptoKeyContext); 20 | 21 | useEffect(() => { 22 | let active = true; 23 | const startFetching = async () => { 24 | const items = await getItems(cryptoKey!); 25 | const data: ItemWithAccounts[] = await Promise.all( 26 | items.map(async (item) => ({ 27 | ...item, 28 | accounts: await getAccounts(item.itemId), 29 | })) 30 | ); 31 | if (active) setAppData(data); 32 | }; 33 | startFetching(); 34 | return () => { 35 | active = false; 36 | }; 37 | }, [refreshTrigger, cryptoKey]); 38 | 39 | function refreshData() { 40 | setRefreshTrigger((value) => value + 1); 41 | } 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 50 | {appData.length > 0 ? ( 51 | 52 | ) : ( 53 | 54 | )} 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/components/logged-in/NoItemsMessage.tsx: -------------------------------------------------------------------------------- 1 | export function NoItemsMessage() { 2 | return ( 3 |
4 | Add a bank to see your accounts listed here. 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/logged-in/ResetPw.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from "react"; 2 | 3 | import { Modal } from "../shared/Modal"; 4 | import { Button } from "../shared/Button"; 5 | 6 | import { DataContext } from "../../context/DataContext"; 7 | import { wipeData } from "../../api"; 8 | 9 | export function ResetPw() { 10 | const [modalState, setModalState] = useState( 11 | "hidden" as "hidden" | "items" | "noItems" 12 | ); 13 | 14 | const data = useContext(DataContext); 15 | 16 | const hasItems = data && data.length > 0; 17 | 18 | const handleOpenModal = () => { 19 | const nextModalState = hasItems ? "items" : "noItems"; 20 | setModalState(nextModalState); 21 | }; 22 | 23 | const handleResetPw = () => { 24 | wipeData(); 25 | window.location.reload(); 26 | }; 27 | 28 | return ( 29 | <> 30 |

31 | 34 |

35 | 36 | setModalState("hidden")} 39 | > 40 | <> 41 |

Reset Password

42 |

43 | To reset your password, you must first remove all of your banks. 44 |

45 | 46 | 47 |
48 | 49 | setModalState("hidden")} 52 | > 53 | <> 54 |

Reset Password

55 |

56 | Are you sure you want to reset your password? 57 |

58 | 59 | 60 |
61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/logged-out/LoggedOut.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | import { getSalt, getItems } from "../../api"; 4 | import { deriveKey } from "../../utils/crypto"; 5 | 6 | import { LoggedOutCard } from "./LoggedOutCard"; 7 | import { Login } from "./Login"; 8 | import { StartFromScratch } from "./StartFromScratch"; 9 | 10 | async function doesUserExist() { 11 | const isItemsEmpty = async (salt: Uint8Array) => { 12 | const tempKey = await deriveKey("", salt); 13 | try { 14 | const items = await getItems(tempKey); 15 | if (items.length === 0) return true; 16 | return false; 17 | } catch { 18 | return false; 19 | } 20 | }; 21 | 22 | try { 23 | const salt = await getSalt(); 24 | const empty = await isItemsEmpty(salt); 25 | // if there's a salt and decryption with an empty password 26 | // - errors, there's a user 27 | // - returns items, there's a user with a empty password 28 | // - returns an empty array, there's a user with no items, so we treat it as no user 29 | return !empty; 30 | } catch { 31 | // if getSalt() errors, there's no user 32 | return false; 33 | } 34 | } 35 | 36 | export function LoggedOut() { 37 | const [userStatus, setUserStatus] = useState( 38 | "loading" as "loading" | "existent" | "nonexistent" 39 | ); 40 | 41 | useEffect(() => { 42 | let active = true; 43 | const getStatus = async () => { 44 | const isUser = await doesUserExist(); 45 | const nextUserStatus = isUser ? "existent" : "nonexistent"; 46 | if (active) setUserStatus(nextUserStatus); 47 | }; 48 | getStatus(); 49 | return () => { 50 | active = false; 51 | }; 52 | }); 53 | 54 | return ( 55 |
56 | 57 | {userStatus === "existent" && } 58 | {userStatus === "nonexistent" && } 59 | 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/components/logged-out/LoggedOutCard.tsx: -------------------------------------------------------------------------------- 1 | export function LoggedOutCard({ children }: { children: React.ReactNode }) { 2 | return ( 3 |
4 |
5 | {children} 6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/logged-out/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | 3 | import { Button } from "../shared/Button"; 4 | import { Modal } from "../shared/Modal"; 5 | 6 | import { authenticate, wipeData } from "../../api"; 7 | 8 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 9 | 10 | export function Login() { 11 | const [modalState, setModalState] = useState( 12 | "hidden" as "hidden" | "wrongPw" | "wiped" | "forgotten" 13 | ); 14 | 15 | const [retries, setRetries] = useState(10); 16 | 17 | const { setCryptoKey } = useContext(CryptoKeyContext); 18 | 19 | const handleEnterPw = async (event: React.FormEvent) => { 20 | event.preventDefault(); 21 | const formData = new FormData(event.currentTarget); 22 | const password = formData.get("password") as string; 23 | 24 | const result = await authenticate(password); 25 | 26 | if (result) { 27 | setCryptoKey(result); 28 | } else { 29 | if (retries === 1) { 30 | wipeData(); 31 | setModalState("wiped"); 32 | // don't need to reset retries because they'll need to reload the page to get back to it 33 | return; 34 | } 35 | setRetries((previous) => previous - 1); 36 | setModalState("wrongPw"); 37 | } 38 | }; 39 | 40 | return ( 41 | <> 42 |
43 | 44 | 50 | 53 | 60 |
61 | 62 | setModalState("hidden")} 65 | > 66 |

Incorrect Password

67 |

68 | Try again or start from scratch. Tries remaining: {retries}. 69 |

70 | 71 |
72 | 73 | setModalState("hidden")} 76 | > 77 |

Too Many Retries

78 |

79 | You've entered the wrong password too many times. Please do the 80 | following: 1) delete the file 81 | finfetch/backend/db.db, 2) restart the server, 3) refresh 82 | to set a new password, 4) re-add your banks, 5) check in Plaid to make 83 | sure you're not being double-charged for any bank connections. 84 |

85 | 86 |
87 | 88 | setModalState("hidden")} 91 | > 92 |

Forgot Password

93 |

94 | If you've forgotten your password, 95 |

    96 |
  1. 97 | delete the file finfetch/backend/db.db 98 |
  2. 99 |
  3. restart the server
  4. 100 |
  5. refresh your browser to set a new password
  6. 101 |
  7. re-add your banks
  8. 102 |
  9. 103 | check in Plaid to make sure you're not being double-charged for 104 | any bank connections. 105 |
  10. 106 |
107 |

108 | 109 |
110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/components/logged-out/StartFromScratch.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { initUser } from "../../api"; 4 | import { Button } from "../shared/Button"; 5 | 6 | import { CryptoKeyContext } from "../../context/CryptoKeyContext"; 7 | 8 | export function StartFromScratch() { 9 | const { setCryptoKey } = useContext(CryptoKeyContext); 10 | 11 | const handleNicknameSubmit = async ( 12 | event: React.FormEvent 13 | ) => { 14 | event.preventDefault(); 15 | const formData = new FormData(event.currentTarget); 16 | const password = formData.get("newPassword") as string; 17 | 18 | const nextCryptoKey = await initUser(password); 19 | setCryptoKey(nextCryptoKey); 20 | }; 21 | return ( 22 | <> 23 |
24 |

25 | 26 |

27 | 33 | 36 |
37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | disabled?: boolean; 5 | type?: "button" | "submit" | "reset" | undefined; 6 | children: React.ReactNode; 7 | onClick: () => void; 8 | } 9 | 10 | export function Button({ 11 | disabled = false, 12 | type = "button", 13 | children, 14 | onClick, 15 | }: Props) { 16 | return ( 17 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { X } from "lucide-react"; 4 | 5 | export function Modal({ 6 | isOpen, 7 | onClose, 8 | children, 9 | }: { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | children: React.ReactNode; 13 | }) { 14 | const modalRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (isOpen && modalRef.current) { 18 | modalRef.current?.showModal(); 19 | document.body.style.overflow = "hidden"; 20 | } else { 21 | modalRef.current?.close(); 22 | } 23 | return () => { 24 | document.body.style.overflow = ""; 25 | }; 26 | }, [isOpen]); 27 | 28 | if (!isOpen) return null; 29 | 30 | return createPortal( 31 | 36 |
37 | 45 |
46 | {children} 47 |
, 48 | document.body 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/context/CryptoKeyContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const CryptoKeyContext = createContext({ 4 | cryptoKey: null as null | CryptoKey, 5 | setCryptoKey: (() => {}) as React.Dispatch< 6 | React.SetStateAction 7 | >, 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/context/DataContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Item, Account } from "../../../sharedTypes"; 3 | 4 | export interface ItemWithAccounts extends Item { 5 | accounts: Account[]; 6 | } 7 | 8 | export const DataContext = createContext([] as ItemWithAccounts[]); 9 | -------------------------------------------------------------------------------- /frontend/src/context/RefreshContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const RefreshContext = createContext<() => void>(() => 4 | Promise.resolve() 5 | ); 6 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /frontend/src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | export async function deriveKey( 2 | password: string, 3 | salt: Uint8Array 4 | ): Promise { 5 | const encoder = new TextEncoder(); 6 | const keyMaterial = await crypto.subtle.importKey( 7 | "raw", 8 | encoder.encode(password), 9 | { name: "PBKDF2" }, 10 | false, 11 | ["deriveKey"] 12 | ); 13 | 14 | return await crypto.subtle.deriveKey( 15 | { 16 | name: "PBKDF2", 17 | salt: salt, 18 | iterations: 100_000, // More iterations = more security (but slower) 19 | hash: "SHA-256", 20 | }, 21 | keyMaterial, 22 | { name: "AES-GCM", length: 256 }, 23 | true, 24 | ["encrypt", "decrypt"] 25 | ); 26 | } 27 | 28 | export async function exportKey(key: CryptoKey): Promise { 29 | const rawKey = await crypto.subtle.exportKey("raw", key); // Returns ArrayBuffer 30 | return btoa(String.fromCharCode(...new Uint8Array(rawKey))); // Convert to Base64 31 | } 32 | 33 | export async function encryptData(plaintext: string, key: CryptoKey) { 34 | const encoder = new TextEncoder(); 35 | const iv = crypto.getRandomValues(new Uint8Array(12)); 36 | const encrypted = await crypto.subtle.encrypt( 37 | { name: "AES-GCM", iv }, 38 | key, 39 | encoder.encode(plaintext) 40 | ); 41 | return { iv, encrypted }; 42 | } 43 | 44 | export async function decryptData( 45 | encrypted: ArrayBuffer, 46 | iv: Uint8Array, 47 | key: CryptoKey 48 | ) { 49 | const decrypted = await crypto.subtle.decrypt( 50 | { name: "AES-GCM", iv }, 51 | key, 52 | encrypted 53 | ); 54 | return new TextDecoder().decode(decrypted); 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/utils/download.ts: -------------------------------------------------------------------------------- 1 | // lightly adapted from RodolfoSilva's comment on this gist: https://gist.github.com/devloco/5f779216c988438777b76e7db113d05c 2 | 3 | function getFileNameFromContentDispostionHeader( 4 | contentDisposition: string 5 | ): string | undefined { 6 | const standardPattern = /filename=(["']?)(.+)\1/i; 7 | const wrongPattern = /filename=([^"'][^;"'\n]+)/i; 8 | 9 | if (standardPattern.test(contentDisposition)) { 10 | return contentDisposition.match(standardPattern)![2]; 11 | } 12 | 13 | if (wrongPattern.test(contentDisposition)) { 14 | return contentDisposition.match(wrongPattern)![1]; 15 | } 16 | } 17 | 18 | function saveBlob(fileName: string, blob: Blob) { 19 | // MS Edge and IE don't allow using a blob object directly as link href, instead it is necessary to use msSaveOrOpenBlob 20 | // @ts-expect-error msSaveorOpenBlob is legit if it's Edge 21 | if (window.navigator && window.navigator.msSaveOrOpenBlob) { 22 | // @ts-expect-error msSaveorOpenBlob is legit if it's Edge 23 | window.navigator.msSaveOrOpenBlob(blob); 24 | return; 25 | } 26 | 27 | // For other browsers: create a link pointing to the ObjectURL containing the blob. 28 | const objUrl = window.URL.createObjectURL(blob); 29 | 30 | const link = document.createElement("a"); 31 | link.href = objUrl; 32 | link.download = fileName; 33 | link.click(); 34 | 35 | // For Firefox it is necessary to delay revoking the ObjectURL. 36 | setTimeout(() => { 37 | window.URL.revokeObjectURL(objUrl); 38 | }, 250); 39 | } 40 | 41 | interface Options { 42 | url: string; 43 | body?: BodyInit; 44 | onDownloadProgress?: (receivedLength: number, contentLength: number) => void; 45 | fetchOptions?: 46 | | RequestInit 47 | | ((fetchOptions: RequestInit) => Promise); 48 | } 49 | 50 | export async function downloadFile(options: Options, cryptoKeyString: string) { 51 | const { url, onDownloadProgress, fetchOptions, body } = options; 52 | 53 | let requestInit: RequestInit = { 54 | method: "GET", 55 | headers: { 56 | "Content-Type": "application/json", 57 | "X-Crypto-Key-String": cryptoKeyString, 58 | }, 59 | body, 60 | }; 61 | 62 | if (typeof fetchOptions === "function") { 63 | requestInit = await fetchOptions(requestInit); 64 | } else if (typeof fetchOptions === "object") { 65 | requestInit = { ...requestInit, ...fetchOptions }; 66 | } 67 | 68 | const response = await fetch(url, requestInit); 69 | 70 | if (!response.ok) { 71 | const responseBody = await response.text(); 72 | throw new Error(responseBody ?? "Error try again"); 73 | } 74 | 75 | const reader = response.body!.getReader(); 76 | 77 | const contentLength = Number(response.headers.get("Content-Length")); 78 | 79 | let receivedLength = 0; 80 | const chunks = []; 81 | while (true) { 82 | const { done, value } = await reader.read(); 83 | 84 | if (done) break; 85 | 86 | chunks.push(value); 87 | receivedLength += value.length; 88 | 89 | if (typeof onDownloadProgress !== "undefined") { 90 | onDownloadProgress(receivedLength, contentLength); 91 | } 92 | } 93 | 94 | const type = response.headers.get("content-type")?.split(";")[0]; 95 | 96 | const coerceNum = (toCoerce: string | null) => { 97 | return toCoerce ? Number(toCoerce) : 0; 98 | }; 99 | 100 | const txnCount = { 101 | added: coerceNum(response.headers.get("x-addedcount")), 102 | modified: coerceNum(response.headers.get("x-modifiedcount")), 103 | removed: coerceNum(response.headers.get("x-removedcount")), 104 | }; 105 | 106 | // It is necessary to create a new blob object with mime-type explicitly set for all browsers except Chrome, but it works for Chrome too. 107 | const blob = new Blob(chunks, { type }); 108 | 109 | const contentDisposition = response.headers.get("content-disposition"); 110 | 111 | const fileName = getFileNameFromContentDispostionHeader(contentDisposition!); 112 | 113 | return { 114 | fileName, 115 | blob, 116 | txnCount, 117 | }; 118 | } 119 | 120 | interface DownloadAndSaveFileOptions extends Options { 121 | defaultFileName: string; 122 | } 123 | 124 | export default async function downloadAndSaveFile( 125 | options: DownloadAndSaveFileOptions, 126 | cryptoKeyString: string 127 | ) { 128 | const { defaultFileName, ...rest } = options; 129 | 130 | const { fileName, blob, txnCount } = await downloadFile( 131 | rest, 132 | cryptoKeyString 133 | ); 134 | 135 | if (!Object.values(txnCount).every((num) => num === 0)) { 136 | saveBlob(fileName ?? defaultFileName, blob); 137 | } 138 | 139 | return txnCount; 140 | } 141 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tailwindcss()], 8 | }); 9 | -------------------------------------------------------------------------------- /sharedTypes.ts: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | itemId: string; 3 | name: string; 4 | } 5 | 6 | export interface ServerItem extends Item { 7 | accessToken: string; 8 | cursor: string; 9 | } 10 | 11 | export interface Account { 12 | accountId: string; 13 | itemId: string; 14 | name: string; 15 | nickname: string; 16 | lastDownloaded: number | null; 17 | } 18 | --------------------------------------------------------------------------------