initial commit

This commit is contained in:
projectmoon 2023-03-20 22:03:36 +01:00
commit 10dc0401be
14 changed files with 2252 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
yarn-error.log
.parcel-cache/
dist/

4
.parcelrc Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "@parcel/config-webextension",
}

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@parcel/config-webextension": "^2.8.3",
"@parcel/transformer-graphql": "2.8.3",
"@parcel/transformer-inline-string": "2.8.3",
"@types/firefox-webext-browser": "^111.0.0",
"@types/level-ttl": "^3.1.2",
"buffer": "^5.5.0",
"events": "^3.1.0",
"parcel": "^2.8.3"
},
"scripts": {
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
"build": "parcel build src/manifest.json --no-content-hash --config @parcel/config-webextension",
"clean": "rm -rf dist/ .parcel-cache/"
},
"@parcel/bundler-default": {
"minBundles": 10000000,
"minBundleSize": 3000,
"maxParallelRequests": 20
},
"dependencies": {
"date-fns": "^2.29.3",
"graphql": "^16.6.0",
"graphql-request": "^5.2.0",
"level": "^8.0.0"
}
}

51
src/background.ts Normal file
View File

@ -0,0 +1,51 @@
const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] };
import { fetchAccounts } from './landsbankinn';
import { PartialStateUpdate, State } from './state';
type HttpHeaders = browser.webRequest.HttpHeaders;
function extractFromHeaders(headers: HttpHeaders): PartialStateUpdate {
let apiKey = '';
let gqlAuthToken = '';
let rsaFingerprint = '';
for (const header of headers) {
switch (header.name.toLowerCase()) {
case "authorization":
gqlAuthToken = header.value || '';
break;
case "apikey":
apiKey = header.value || '';
break;
case "rsa-fingerprint":
rsaFingerprint = header.value || '';
break;
}
}
return { apiKey, gqlAuthToken, rsaFingerprint };
}
browser.webRequest.onBeforeSendHeaders.addListener(e => {
// TODO if request errors, then clear state and try again.
(async function() {
const state = await State.current;
if (!state.ready && e.requestHeaders) {
console.info('Will update auth info from headers');
// Lock ready to true so we don't have multiple attempts
// at getting account info.
await State.update({ ready: true });
let newState = extractFromHeaders(e.requestHeaders);
State.update(newState)
.then(state => !state.accounts ? fetchAccounts() : state.accounts)
.then(accounts => State.update({ accounts }))
.then(() => console.log('done'));
}
})();
}, matchGraphQL, ["requestHeaders"]);
export { }

33
src/content-script.ts Normal file
View File

@ -0,0 +1,33 @@
import { setDefaultOptions, parse, format } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { fetchAccounts, OutputFormat, DownloadTransactionsRequest, downloadTransactions } from './landsbankinn';
setDefaultOptions({ locale: enUS });
//First attempt hardcoded data
async function doIt() {
console.log('doing it');
const from = parse('01 Jan 2023', 'dd MMM yyyy', new Date());
console.log(from);
const to = parse('31 Jan 2023', 'dd MMM yyyy', new Date());
const req: DownloadTransactionsRequest = {
accountNumber: '010126013792',
dateFrom: from,
dateTo: to,
outputFormat: OutputFormat.CSV,
};
const txns = await downloadTransactions(req);
console.log(txns);
}
//doIt().then(() => alert('did it'));
async function fetchIt() {
console.log('fetching it');
const data = fetchAccounts();
console.log(data);
}
//fetchIt().then(() => alert('fetched it'));

30
src/graphql/models.ts Normal file
View File

@ -0,0 +1,30 @@
export interface FetchAccountsResponse {
viewer: Viewer;
}
export interface Viewer {
id: string;
accounts: Accounts;
}
export interface Accounts {
nodes: Array<Node>;
totalCount: number;
}
export interface Node {
id: string;
accountNumber: string;
name: string;
kennitala: string;
owner: string;
state: string;
isVaxtareikningur30: boolean;
isNoticeAccount: boolean;
currency: string;
isCurrencyAccount: boolean;
availableAmount: number;
balance: number;
orderedAmountAvailable: number;
orderedAmountTotal: number;
}

39
src/graphql/queries.ts Normal file
View File

@ -0,0 +1,39 @@
import { gql } from 'graphql-request/build/esm';
// Currently not imported due to a bug with Parcel and the bundle-text
// importer, and complications with running the query from the
// imported text.
// Exports
export const fetchAccounts = gql`
query fetchAccounts($first: Int, $after: String, $filter: AccountFilterArgs, $orderBy: AccountOrderBy) {
viewer {
id
accounts(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
nodes {
...AccountSelectorAccountParts
__typename
}
totalCount
}
}
}
fragment AccountSelectorAccountParts on Account {
id
accountNumber
name
kennitala
owner
state
isVaxtareikningur30
isNoticeAccount
currency
isCurrencyAccount
availableAmount
balance
orderedAmountAvailable
orderedAmountTotal
__typename
}
`

140
src/landsbankinn.ts Normal file
View File

@ -0,0 +1,140 @@
import { GraphQLClient } from 'graphql-request/build/esm';
import { setDefaultOptions, format } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { RequestConfig } from 'graphql-request/build/esm/types';
import { State } from './state';
import * as queries from './graphql/queries';
import { FetchAccountsResponse } from '~graphql/models';
setDefaultOptions({ locale: enUS });
const apiEndpoint = 'https://netbanki.landsbankinn.is/services/accounts/$accountID/transactions';
export enum TransactionFormat { JSON };
export enum OutputFormat { CSV };
export interface DownloadTransactionsRequest {
accountNumber: string;
dateFrom: Date;
dateTo: Date;
outputFormat: OutputFormat;
}
export interface RawTransaction {
//txn id?
id: string;
//often null
accountNumber: string | null;
amount: Number;
currencyCode: string;
//ISO date
bookingDate: string;
//ISO date
valueDate: string;
balanceAfterTransaction: Number;
displayText: string;
//how do these relate to id?
reference: string;
referenceNumber: string;
//how does it relate to the booking/value dates?
//exact time of txn/
timestamp: string;
textCodeDescription: string;
payerDescription: string;
// e.g. Utteky med debitkorti
description: String;
batchID: string;
// only for individuals?
recipientKennitala: string;
recipientName: string;
//probably some kind of txn category (central bank?)
transactionCodekey: string;
bankBranch: string;
debitCardWarrantyNumber: string | null;
//currency to ISK exchange rate
exchangeRate: Number;
// what is displayed to the user in the table
customerDescription: string;
//don't know what this could be.
details: null;
}
//Sat Jan 03 2023
const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy');
function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) {
const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`;
const from = encodeURIComponent(formatDate(dateFrom));
const to = encodeURIComponent(formatDate(dateTo));
const params = `?from=${from}&to=${to}`
return url + params;
}
export async function downloadTransactions(req: DownloadTransactionsRequest): Promise<Array<RawTransaction>> {
const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo);
const options: RequestInit = {
method: "GET",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
};
const result = await fetch(url, options);
if (result.ok) {
return await result.json();
} else {
//attempt to read json.
const error = await result.json();
if (error.hasOwnProperty('message')) {
console.error(error['message']);
}
console.error(result);
return new Array();
}
}
export async function fetchAccounts(): Promise<FetchAccountsResponse> {
const state = await State.current;
const options: RequestConfig = {
mode: "cors",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
apikey: state.apiKey,
authorization: state.gqlAuthToken,
"rsa-fingerprint": state.rsaFingerprint
},
};
const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options);
const variables = {
"first": 100,
"filter": {
"includeClosed": true
},
};
const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables);
//TODO transform janky graphql response into nicer data structure for us.
return data;
}

19
src/manifest.json Normal file
View File

@ -0,0 +1,19 @@
{
"manifest_version": 2,
"name": "Sample Web Extension",
"version": "0.0.1",
"permissions": [
"https://netbanki.landsbankinn.is/",
"https://graphql.landsbankinn.is/*",
"webRequest",
"webRequestBlocking"
],
"background": {
"scripts": [ "background.ts" ],
"persistent": false
},
"content_scripts": [{
"matches": ["https://netbanki.landsbankinn.is/*", "https://graphql.landsbankinn.is/v2"],
"js": ["content-script.ts"]
}]
}

4
src/parcel.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module 'bundle-text:*' {
const value: string;
export default value;
}

76
src/state.ts Normal file
View File

@ -0,0 +1,76 @@
import { Level } from 'level';
import { FetchAccountsResponse } from '~graphql/models';
export interface ExporterState {
apiKey: string;
gqlAuthToken: string;
rsaFingerprint: string;
accounts: FetchAccountsResponse;
ready: boolean;
}
export interface PartialStateUpdate {
apiKey?: string;
gqlAuthToken?: string;
rsaFingerprint?: string;
accounts?: FetchAccountsResponse;
ready?: boolean;
}
const FIVE_MINUTES = 5 * 60 * 1000;
type LevelError = Error | null | undefined;
//@ts-ignore
const errorNotFound = (err: LevelError) => err.code && err.code == 'LEVEL_NOT_FOUND';
class LevelState {
private _db = new Level('landsbankinn-exporter', { valueEncoding: 'json' });
private currentTTLHandle: number | undefined = -1;
constructor() {
function creator(err: Error | null | undefined) {
if (err) {
if (errorNotFound(err)) {
console.info('Creating initial empty state');
//@ts-ignore
this._db.put('state', {});
}
}
}
const self = this;
this.prepTTL().then(() => self._db.get('state', {}, creator.bind(self)));
}
get current(): Promise<ExporterState> {
return this._db.get('state') as unknown as Promise<ExporterState>;
}
async clear() {
console.info('Cleared state');
return await this._db.put('state', {} as any);
}
private async clearTTL() {
if (this.currentTTLHandle) {
window.clearTimeout(this.currentTTLHandle);
}
}
private async prepTTL() {
console.info("Will clear state in 5 minutes");
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES);
}
async update(stateUpdate: ExporterState | PartialStateUpdate) {
this.clearTTL();
const currentState: ExporterState = await this.current;
let newState = { ...currentState, ...stateUpdate };
await this._db.put('state', newState as any);
this.prepTTL();
return this.current;
}
}
export const State: LevelState = new LevelState();

19
src/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"experimentalDecorators": true,
"target": "es6",
"module": "es6",
"allowSyntheticDefaultImports": true,
"lib": [ "dom", "es6" ],
"jsx": "react-jsx",
"esModuleInterop": true,
"jsxImportSource": "jsx-dom",
"isolatedModules": true,
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"~*": ["./*"]
}
}
}

10
todo.org Normal file
View File

@ -0,0 +1,10 @@
How to get it working:
- [X] Landsbankinn API client for arbitrary transaction lists
- [ ] Transform raw transactions into friendlier ones (i.e. date objects)
- [ ] Content script that can run all the time and puts a button somewhere (statements tab, and extension icon)
- [ ] Background page that downloads all the shit and combines into CSV, then 'downloads' file.
- [ ] Graphql API client to get information about user, like listing accounts.
- [ ] Get working client
- [ ] Set API key
- [ ] Set bearer token
- [ ] Intercept bearer token by yoinking it out of a request

1794
yarn.lock Normal file

File diff suppressed because it is too large Load Diff