diff options
Diffstat (limited to 'app/src')
| -rw-r--r-- | app/src/app.d.ts | 13 | ||||
| -rw-r--r-- | app/src/app.html | 14 | ||||
| -rw-r--r-- | app/src/lib/server/db/index.ts | 10 | ||||
| -rw-r--r-- | app/src/lib/server/db/schema.ts | 28 | ||||
| -rw-r--r-- | app/src/lib/ui/button.svelte | 41 | ||||
| -rw-r--r-- | app/src/routes/+page.svelte | 64 | ||||
| -rw-r--r-- | app/src/routes/actual.remote.ts | 17 | ||||
| -rw-r--r-- | app/src/routes/sb1-authorize/+server.ts | 46 | ||||
| -rw-r--r-- | app/src/routes/sb1.remote.ts | 184 |
9 files changed, 417 insertions, 0 deletions
diff --git a/app/src/app.d.ts b/app/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/app/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/app/src/app.html b/app/src/app.html new file mode 100644 index 0000000..91f0602 --- /dev/null +++ b/app/src/app.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% +</head> + +<body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> +</body> + +</html>
\ No newline at end of file diff --git a/app/src/lib/server/db/index.ts b/app/src/lib/server/db/index.ts new file mode 100644 index 0000000..b3c877b --- /dev/null +++ b/app/src/lib/server/db/index.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import Database from 'better-sqlite3'; +import * as schema from './schema'; +import { env } from '$env/dynamic/private'; + +if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); + +const client = new Database(env.DATABASE_URL); + +export const db = drizzle(client, { schema }); diff --git a/app/src/lib/server/db/schema.ts b/app/src/lib/server/db/schema.ts new file mode 100644 index 0000000..c1bea43 --- /dev/null +++ b/app/src/lib/server/db/schema.ts @@ -0,0 +1,28 @@ +import { relations } from 'drizzle-orm'; +import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const syncSession = sqliteTable("session", { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + authzState: text("authzState"), + accessTokenCreated: int("accessTokenCreated"), + refreshTokenCreated: int("refreshTokenCreated"), + tokens: text("tokens") +}) + +export const syncLog = sqliteTable("session_log", { + id: text("id").primaryKey(), + sessionId: text("session_id"), + dateTime: text("date_time"), + msg: text("msg") +}) + +export const syncLogRelation = relations(syncLog, ({ one }) => ({ + author: one(syncSession, { + fields: [syncLog.sessionId], + references: [syncSession.id], + }) +})) + +export const syncSessionLogRelation = relations(syncSession, ({ many }) => ({ + logs: many(syncLog) +})) diff --git a/app/src/lib/ui/button.svelte b/app/src/lib/ui/button.svelte new file mode 100644 index 0000000..313cd90 --- /dev/null +++ b/app/src/lib/ui/button.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { HTMLButtonAttributes } from "svelte/elements"; + import { Spinner } from "phosphor-svelte"; + let { children, loading, type = "button", ...restProps }: Props = $props(); + + type Props = { + loading?: boolean; + } & HTMLButtonAttributes; +</script> + +<button {...restProps} {type}> + {@render children?.()} + {#if loading} + <Spinner /> + {/if} +</button> + +<style> + button { + border: 1px solid rgba(0, 0, 0, 0.3); + background-color: rgba(0, 0, 0, 0.1); + align-items: center; + border-radius: 3px; + padding: 2px 4px; + cursor: pointer; + display: flex; + gap: 3px; + transition: 0.1s all ease; + + &:hover, + &:focus { + background-color: rgba(0, 0, 0, 0.15); + } + + &:active { + background-color: rgba(0, 0, 0, 0.2); + transform: scale(0.96); + transition: 0.2s all ease; + } + } +</style> diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte new file mode 100644 index 0000000..ed35a84 --- /dev/null +++ b/app/src/routes/+page.svelte @@ -0,0 +1,64 @@ +<script lang="ts"> + import Button from "$lib/ui/button.svelte"; + import { + clearTokens, + createSb1SyncSessionAndReturnLoginUrl, + getAccounts, + getTokenExpires, + getTransactions, + refreshSB1Token, + } from "./sb1.remote"; + + let navigating = $state(false); + + async function authorize() { + navigating = true; + const url = await createSb1SyncSessionAndReturnLoginUrl(); + location.href = url; + navigating = false; + } + + async function clearAuth() { + await clearTokens(); + getTokenExpires().refresh(); + } + + async function refreshAuth() { + await refreshSB1Token(); + getTokenExpires().refresh(); + } +</script> + +<main> + {#if await getTokenExpires()} + {@const tokens = await getTokenExpires()} + {@const accounts = await getAccounts()} + {#if tokens} + <pre>accessToken: {tokens.accessToken.created.add({ seconds: tokens.accessToken.expires }).toLocaleString()} +refreshToken: {tokens.refreshToken.created.add({ seconds: tokens.refreshToken.expires }).toLocaleString()}</pre> + <ul> + {#each accounts.accounts as account} + {@const transactions = await getTransactions(account.key)} + <li>{account.name}</li> + <ul> + {#each transactions?.transactions as transaction} + <li>{JSON.stringify(transaction)}</li> + {/each} + </ul> + {/each} + </ul> + {:else} + <Button onclick={clearAuth}>Slett autorisasjon</Button> + <Button onclick={refreshAuth}>Oppdater autorisasjon</Button> + {/if} + {:else} + <Button onclick={authorize} loading={navigating}>Autentisér hos Sparebanken 1</Button> + {/if} +</main> + +<style> + pre { + max-width: 50vw; + overflow: auto; + } +</style> diff --git a/app/src/routes/actual.remote.ts b/app/src/routes/actual.remote.ts new file mode 100644 index 0000000..bcf5d37 --- /dev/null +++ b/app/src/routes/actual.remote.ts @@ -0,0 +1,17 @@ +import { command } from "$app/server"; +import { ACTUAL_HOST, ACTUAL_PASS } from "$env/static/private"; +import * as actual from "@actual-app/api" +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +export const initActual = command(async () => { + const dataDir = path.resolve(__dirname, "actualDataDir"); + + if (!existsSync(dataDir)) mkdirSync(dataDir); + + return actual.init({ + password: ACTUAL_PASS, + serverURL: ACTUAL_HOST, + dataDir: dataDir + }) +})
\ No newline at end of file diff --git a/app/src/routes/sb1-authorize/+server.ts b/app/src/routes/sb1-authorize/+server.ts new file mode 100644 index 0000000..e08db3e --- /dev/null +++ b/app/src/routes/sb1-authorize/+server.ts @@ -0,0 +1,46 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { syncSession } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from '$env/static/private'; +import { Temporal } from "temporal-polyfill" + +export const GET: RequestHandler = async ({ url }) => { + const code = url.searchParams.get('code') + const state = url.searchParams.get('state'); + + if (!code) error(400, "?code is missing") + if (!state) error(400, "?state is missing") + + const session = await db.select().from(syncSession).where(eq(syncSession.authzState, state)) + const { id } = session[0] + + const fd = new URLSearchParams() + fd.set("client_id", SB1_ID) + fd.set("client_secret", SB1_SECRET) + fd.set("redirect_uri", SB1_REDIRECT_URI) + fd.set("code", code) + fd.set("state", state) + fd.set("grant_type", "authorization_code") + const response = await fetch("https://api.sparebank1.no/oauth/token", { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: fd + }) + + const text = await response.text() + + if (response.ok) { + const epoch = Temporal.Now.instant().epochMilliseconds + await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) + redirect(302, "/") + } else { + console.error(text) + return new Response(text) + } + + return new Response() +}
\ No newline at end of file diff --git a/app/src/routes/sb1.remote.ts b/app/src/routes/sb1.remote.ts new file mode 100644 index 0000000..3d5763e --- /dev/null +++ b/app/src/routes/sb1.remote.ts @@ -0,0 +1,184 @@ +import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private"; +import { randomUUID } from "node:crypto"; +import { db } from "../lib/server/db"; +import { syncSession } from "../lib/server/db/schema"; +import * as v from "valibot" +import { command, query } from "$app/server"; +import { eq } from "drizzle-orm"; +import { Temporal } from "temporal-polyfill"; + +export const createSb1SyncSessionAndReturnLoginUrl = command(async () => { + return createSb1Auth() +}) + +async function createSb1Auth() { + const state = randomUUID() + + await db.insert(syncSession).values({ + authzState: state + }) + + const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize"); + + authorizeUrl.searchParams.set("client_id", SB1_ID); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("redirect_uri", SB1_REDIRECT_URI); + authorizeUrl.searchParams.set("finInst", SB1_FIN_INST); + authorizeUrl.searchParams.set("response_type", "code"); + + return authorizeUrl.toString() +} + +export const getAccounts = query(async () => { + const token = await getSb1AccessToken() + if (!token) return [] + const url = new URL( + "https://api.sparebank1.no/personal/banking/accounts", + ); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}` + }, + }); + if (response.ok) { + const json = await response.json() + console.log(json) + return json + } + else console.error(await response.text()) +}) + +export const getTransactions = query(v.string(), async (accountKey: string) => { + const token = await getSb1AccessToken() + if (token) return undefined + const url = new URL( + "https://api.sparebank1.no/personal/banking/transactions", + ); + url.searchParams.set("accountKey", accountKey); + console.log(token) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return (await response.json()) as TransactionsResponse; +}) + +async function getSb1AccessToken() { + const entity = await db.select({ + tokens: syncSession.tokens + }).from(syncSession) + const { tokens } = entity[0] + if (!tokens) return null + const parsed = JSON.parse(tokens) + return parsed.access_token as string +} + +export const clearTokens = query(async () => { + await db.delete(syncSession) +}) + +export const getTokenExpires = query(async () => { + const entity = await db.select({ + refreshTokenCreated: syncSession.refreshTokenCreated, + accessTokenCreated: syncSession.accessTokenCreated, + tokens: syncSession.tokens + }).from(syncSession) + if (!entity[0]) return undefined + const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] + const tokensParsed = JSON.parse(tokens ?? "") + return { + accessToken: { + expires: tokensParsed?.expires_in ?? 0, + created: Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)) + }, + refreshToken: { + expires: tokensParsed?.refresh_token_expires_in ?? 0, + created: Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)) + }, + } +}) + +type Sb1Tokens = { + access_token: string, + expires_in: number, + refresh_token_expires_in: number, + refresh_token_absolute_expires_in: number, + token_type: string, + refresh_token: string +} + +type TokenAction = "Empty" + +const auth = { + async ready(): Promise<boolean> { + const token = await this.tokenOrAction() + const ping = await fetch("https://developer.sparebank1.no/helloworld/ping", { + headers: { + "Authorization": "Bearer " + token + } + }) + return ping.ok + }, + async tokenOrAction(): Promise<TokenAction | string> { + const entity = await db.select({ + refreshTokenCreated: syncSession.refreshTokenCreated, + accessTokenCreated: syncSession.accessTokenCreated, + tokens: syncSession.tokens + }).from(syncSession) + const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] + if (!tokens) return "Empty" + const json = JSON.parse(tokens) as Sb1Tokens + if (!Object.hasOwn(json, "access_token")) return TokenAction.Empty + return json.access_token + }, + getAccessToken() { }, + async getRefreshToken() { + const entity = await db.select({ + tokens: syncSession.tokens, + id: syncSession.id + }).from(syncSession) + + const { tokens, id } = entity[0] + + if (!tokens) return null + + const parsed = JSON.parse(tokens) + + if (!parsed.refresh_token) throw new Error("No refresh token"); + + const fd = new URLSearchParams(); + + fd.set("client_id", SB1_ID); + fd.set("client_secret", SB1_SECRET); + fd.set("refresh_token", parsed.refresh_token); + fd.set("grant_type", "refresh_token"); + + const res = await fetch("https://api.sparebank1.no/oauth/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "post", + body: fd, + }); + + const text = await res.text(); + const epoch = Temporal.Now.instant().epochMilliseconds + await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) + } +} + +export const refreshSB1Token = command(async () => { + auth.getRefreshToken() +}); + +export type Transaction = { + description: string; + amount: number; + date: string; + mcc: string; +} + +export type TransactionsResponse = { + transactions: Array<Transaction>; +} |
