aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/routes')
-rw-r--r--app/src/routes/+page.svelte64
-rw-r--r--app/src/routes/actual.remote.ts17
-rw-r--r--app/src/routes/sb1-authorize/+server.ts46
-rw-r--r--app/src/routes/sb1.remote.ts184
4 files changed, 311 insertions, 0 deletions
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>;
+}