diff --git a/package-lock.json b/package-lock.json index 1a0d03e..d7a567b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "bits-ui": "^0.0.32", "clsx": "^2.0.0", "lucide-svelte": "^0.268.0", + "svelte-french-toast": "^1.2.0", "tailwind-merge": "^1.14.0", "tailwind-variants": "^0.1.13", "tailwindcss-animate": "^1.0.6" @@ -2500,6 +2501,17 @@ "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0" } }, + "node_modules/svelte-french-toast": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/svelte-french-toast/-/svelte-french-toast-1.2.0.tgz", + "integrity": "sha512-5PW+6RFX3xQPbR44CngYAP1Sd9oCq9P2FOox4FZffzJuZI2mHOB7q5gJBVnOiLF5y3moVGZ7u2bYt7+yPAgcEQ==", + "dependencies": { + "svelte-writable-derived": "^3.1.0" + }, + "peerDependencies": { + "svelte": "^3.57.0 || ^4.0.0" + } + }, "node_modules/svelte-hmr": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", @@ -2585,6 +2597,17 @@ "node": ">=12" } }, + "node_modules/svelte-writable-derived": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/svelte-writable-derived/-/svelte-writable-derived-3.1.0.tgz", + "integrity": "sha512-cTvaVFNIJ036vSDIyPxJYivKC7ZLtcFOPm1Iq6qWBDo1fOHzfk6ZSbwaKrxhjgy52Rbl5IHzRcWgos6Zqn9/rg==", + "funding": { + "url": "https://ko-fi.com/pixievoltno1" + }, + "peerDependencies": { + "svelte": "^3.2.1 || ^4.0.0-next.1" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", diff --git a/package.json b/package.json index d0bcff3..c598dd7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bits-ui": "^0.0.32", "clsx": "^2.0.0", "lucide-svelte": "^0.268.0", + "svelte-french-toast": "^1.2.0", "tailwind-merge": "^1.14.0", "tailwind-variants": "^0.1.13", "tailwindcss-animate": "^1.0.6" diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..eae0b81 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,52 @@ +import { redirect, type Handle } from '@sveltejs/kit'; +import type { CapitalComUserAccounts } from "$lib/types" +import { UserCST, UserXSecurityToken } from "$lib/stores"; + +let userCST: string; +UserCST.subscribe((value: string) => { + userCST = value; +}); + +let userXSecurityToken: string; +UserXSecurityToken.subscribe((value: string) => { + userXSecurityToken = value; +}); + +export const handle: Handle = (async ({ event, resolve }) => { + if (event.url.pathname.startsWith("/dashboard") || event.url.pathname.startsWith("/api") || event.url.pathname === "/") { + const capitalComCST = event.cookies.get("CAPITALCOM-CST"); + const capitalComSecurityToken = event.cookies.get("CAPITALCOM-X-SECURITY-TOKEN"); + + if(capitalComCST === undefined || capitalComSecurityToken === undefined) { + throw redirect(302, "/login"); + } + const response: Response = await fetch("https://api-capital.backend-capital.com/api/v1/accounts", { + method: "GET", + headers: { + "X-SECURITY-TOKEN": capitalComSecurityToken!, + "CST" : capitalComCST!, + "Content-Type" : "application/json" + }, + redirect: "follow" + }); + + let parsedResponse: CapitalComUserAccounts = await response.json(); + + if(parsedResponse.errorCode !== undefined) { + throw redirect(302, "/login"); + } + + if(parsedResponse.errorCode === undefined && capitalComCST !== userCST || parsedResponse.errorCode === undefined && capitalComSecurityToken !== userXSecurityToken) { + UserCST.set(capitalComCST); + UserXSecurityToken.set(capitalComSecurityToken); + } + + if(event.url.pathname === "/") { + throw redirect(303, "/dashboard"); + } + } + + const response = await resolve(event); + return response; + +}); \ No newline at end of file diff --git a/src/lib/stores.ts b/src/lib/stores.ts new file mode 100644 index 0000000..8e3ae91 --- /dev/null +++ b/src/lib/stores.ts @@ -0,0 +1,4 @@ +import { writable, type Writable } from "svelte/store"; + +export const UserCST: Writable = writable(""); +export const UserXSecurityToken: Writable = writable(""); \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..871ceaf --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,122 @@ +export interface CapitalComCreateSessionResponse { + accountType?: string; + accountInfo?: AccountInfo; + currencyIsoCode?: string; + currencySymbol?: string; + currentAccountId?: string; + streamingHost?: string; + accounts?: (Account)[]; + clientId?: string; + timezoneOffset?: number; + hasActiveDemoAccounts?: boolean; + hasActiveLiveAccounts?: boolean; + trailingStopsEnabled?: boolean; + errorCode?: string; +} + +export interface AccountInfo { + balance: number; + deposit: number; + profitLoss: number; + available: number; +} + +export interface Account { + accountId: string; + accountName: string; + preferred: boolean; + accountType: string; +} + +export interface CapitalComTradeHistoryResponse { + activities?: Activity[]; + errorCode?: string; +} + +export interface Activity { + date: string + dateUTC: string + epic: string + dealId: string + source: string + type: string + status: string +} + + +export interface CapitalComTradeDetailsResponse { + position?: Position; + market?: Market; + errorCode?: string; +} + +export interface Position { + contractSize: number; + createdDate: string; + createdDateUTC: string; + dealId: string; + dealReference: string; + workingOrderId: string; + size: number; + leverage: number; + upl: number; + direction: "BUY" | "SELL"; + level: number; + currency: string; + guaranteedStop: boolean; +} + +export interface Market { + instrumentName: string; + expiry: string; + marketStatus: string; + epic: string; + instrumentType: string; + lotSize: number; + high: number; + low: number; + percentageChange: number; + netChange: number; + bid: number; + offer: number; + updateTime: string; + updateTimeUTC: string; + delayTime: number; + streamingPricesAvailable: boolean; + scalingFactor: number; +} + +export interface CapitalComPingResponse { + status?: "ok" | string, + errorCode?: string +} + +export interface CapitalComUserAccounts { + accounts?: Account[]; + errorCode?: string +} + +export interface Account { + accountId: string + accountName: string + status: string + accountType: string + preferred: boolean + balance: Balance + currency: string +} + +export interface Balance { + balance: number + deposit: number + profitLoss: number + available: number +} + +export interface SwitchAccountsResponse { + trailingStopsEnabled?: boolean + dealingEnabled?: boolean + hasActiveDemoAccounts?: boolean + hasActiveLiveAccounts?: boolean + errorCode?: string +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..fc679ab --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,31 @@ + + + + diff --git a/src/routes/api/getrecenttrades/+server.ts b/src/routes/api/getrecenttrades/+server.ts new file mode 100644 index 0000000..2c5c981 --- /dev/null +++ b/src/routes/api/getrecenttrades/+server.ts @@ -0,0 +1,58 @@ +import { json, redirect, type RequestHandler } from '@sveltejs/kit'; +import type { CapitalComTradeDetailsResponse, CapitalComTradeHistoryResponse } from "$lib/types"; +import { UserCST, UserXSecurityToken } from "$lib/stores"; + +let userCST: string; +UserCST.subscribe((value: string) => { + userCST = value; +}); + +let userXSecurityToken: string; +UserXSecurityToken.subscribe((value: string) => { + userXSecurityToken = value; +}); + + +export const GET = (async ({ cookies }) => { + const capitalComCST = cookies.get("CAPITALCOM-CST"); + const capitalComSecurityToken = cookies.get("CAPITALCOM-X-SECURITY-TOKEN"); + if(userCST !== capitalComCST || userXSecurityToken !== capitalComSecurityToken) { throw redirect(302, "/login") } + const response: Response = await fetch("https://api-capital.backend-capital.com/api/v1/history/activity?type=POSITION&lastPeriod=86400", { + method: "GET", + headers: { + "X-SECURITY-TOKEN": userXSecurityToken, + "CST": userCST, + "Content-Type" : "application/json" + } + }); + + const parsedResponse: CapitalComTradeHistoryResponse = await response.json(); + if(parsedResponse.errorCode !== undefined) { return json([{ error: parsedResponse.errorCode }]); } + let tradeArrayToReturn: Array<{ title?: string; description?: string; error?: string }> = []; + + await Promise.all(parsedResponse.activities!.map(async trade => { + if(trade.source === "USER" && trade.type === "POSITION") { + const tradeDetailsResponse: Response = await fetch(`https://api-capital.backend-capital.com/api/v1/positions/${trade.dealId}`, { + method: "GET", + headers: { + "X-SECURITY-TOKEN": userXSecurityToken, + "CST": userCST, + "Content-Type" : "application/json" + } + }); + + const parsedTradeDetailsResponse: CapitalComTradeDetailsResponse = await tradeDetailsResponse.json(); + console.log(parsedTradeDetailsResponse); + if(parsedTradeDetailsResponse.errorCode === undefined) { + tradeArrayToReturn.push({ + title: `${parsedTradeDetailsResponse.position!.direction! === "BUY" ? "Bought" : "Sold"} ${parsedTradeDetailsResponse.position!.size!} ${parsedTradeDetailsResponse!.market?.instrumentType.toLowerCase()} of ${parsedTradeDetailsResponse.market!.instrumentName}`, + description: `${new Date(new Date().getTime() - new Date(parsedTradeDetailsResponse.position!.createdDate).getTime()).getMinutes()} ${(new Date(new Date().getTime() - new Date(parsedTradeDetailsResponse.position!.createdDate).getTime()).getMinutes()) === 1 ? "minute" : "minutes"} ago` + }) + } else { + console.log(`Error when getting details of one trade: ${parsedTradeDetailsResponse.errorCode}`); + } + } + })); + console.log(tradeArrayToReturn); + return json(tradeArrayToReturn, { status: 200 }); +}) satisfies RequestHandler \ No newline at end of file diff --git a/src/routes/api/selectcapitalcomaccount/+server.ts b/src/routes/api/selectcapitalcomaccount/+server.ts new file mode 100644 index 0000000..d1e98d0 --- /dev/null +++ b/src/routes/api/selectcapitalcomaccount/+server.ts @@ -0,0 +1,61 @@ +import { json, redirect, type RequestHandler } from '@sveltejs/kit'; +import type { CapitalComUserAccounts, SwitchAccountsResponse } from "$lib/types"; +import { UserCST, UserXSecurityToken } from "$lib/stores"; + +let userCST: string; +UserCST.subscribe((value: string) => { + userCST = value; +}); + +let userXSecurityToken: string; +UserXSecurityToken.subscribe((value: string) => { + userXSecurityToken = value; +}); + + +export const POST: RequestHandler = (async ({ cookies, request }) => { + const capitalComCST = cookies.get("CAPITALCOM-CST"); + const capitalComSecurityToken = cookies.get("CAPITALCOM-X-SECURITY-TOKEN"); + if(userCST !== capitalComCST || userXSecurityToken !== capitalComSecurityToken) { throw redirect(302, "/login") } + + const userAccountsResponse: Response = await fetch("https://api-capital.backend-capital.com/api/v1/accounts", { + method: "GET", + headers: { + "X-SECURITY-TOKEN": userXSecurityToken, + "CST": userCST, + "Content-Type" : "application/json" + }, + redirect: "follow" + }); + + let parsedUserAccountsResponse: CapitalComUserAccounts = await userAccountsResponse.json(); + if(parsedUserAccountsResponse.errorCode !== undefined) { console.log(`Error while getting all acounts for user: ${parsedUserAccountsResponse.errorCode}`); return json({ error: `Error while getting all acounts for user: ${parsedUserAccountsResponse.errorCode}` }, { status: 500 }); } + if(parsedUserAccountsResponse.accounts?.length === 0) { console.log(`User has no accounts!: ${parsedUserAccountsResponse}`); return json({ error: "The selected account does not have any trading accounts." }, { status: 500 }); } + + let submittedAccountName = JSON.parse((await request.text())).selectedAccount; + if(parsedUserAccountsResponse.accounts!.find(account => account.accountName === submittedAccountName) !== undefined) { + const parsedSwitchAccountResponse: SwitchAccountsResponse = await (await fetch("https://api-capital.backend-capital.com/api/v1/session", { + method: "PUT", + headers: { + "X-SECURITY-TOKEN": userXSecurityToken, + "CST": userCST, + "Content-Type" : "application/json" + }, + body: JSON.stringify({ + accountId: parsedUserAccountsResponse.accounts!.find(account => account.accountName === submittedAccountName)?.accountId + }), + redirect: "follow" + })).json(); + + if(parsedSwitchAccountResponse.errorCode !== undefined && parsedSwitchAccountResponse.errorCode !== "error.not-different.accountId") { + console.log(`Error while selecting account: ${parsedSwitchAccountResponse.errorCode!}`); return json({ error: `Error while switching account: ${parsedSwitchAccountResponse.errorCode!}` }, { status: 500 }); + } else if(parsedSwitchAccountResponse.errorCode === "error.not-different.accountId") { + return json({ success: true, msg: `Already signed in to ${submittedAccountName}.` }, { status: 200 }); + } + + return json({ success: true }, { status: 200 }); + } else { + return json({ error: `The selected trading account "${submittedAccountName}" does not exist.`.replace("\"", "") }, { status: 500 }); + } + +}); \ No newline at end of file diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..e2d9be0 --- /dev/null +++ b/src/routes/dashboard/+page.server.ts @@ -0,0 +1 @@ +export const ssr = false \ No newline at end of file diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..bec1116 --- /dev/null +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,84 @@ + + + + +
+ + + Trades + + within the last 10 minutes + + + + {#if errorWhenGettingTrades} +

Error when getting recent trades: "{trades[0].error}"

+ {:else} +
+ {#each trades as trade, index (index)} +
+ + +
+

+ {trade.title} +

+

+ {trade.description} +

+
+
+ {/each} +
+ {/if} + +
+ + +
+
\ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..75b4e69 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,92 @@ +import type { CapitalComCreateSessionResponse, CapitalComUserAccounts } from "$lib/types"; +import type { Actions } from "./$types"; +import { UserCST, UserXSecurityToken } from "$lib/stores" + +// export const ssr = false; + +let userCST: string; +UserCST.subscribe((value: string) => { + userCST = value; +}); + +let userXSecurityToken: string; +UserXSecurityToken.subscribe((value: string) => { + userXSecurityToken = value; +}); + +export const actions = { + default: async ({ cookies, request }) => { + const formData = await request.formData(); + const userEmail = formData.get("user_email")!.toString(); + const userPassword = formData.get("user_password")!.toString(); + const userAPIKey = formData.get("user_api_key")!.toString(); + + const response: Response = await fetch("https://api-capital.backend-capital.com/api/v1/session", { + method: "POST", + headers: { + "X-CAP-API-KEY": userAPIKey, //SO6ZbQW5AIXmqE7A + "Content-Type" : "application/json" + }, + body: JSON.stringify({ + "identifier": userEmail, //dh2jttwtj5@privaterelay.appleid.com + "password": userPassword //Neu9Sept! + }) + }); + + let parsedResponse: CapitalComCreateSessionResponse = await response.json(); + + if(parsedResponse.errorCode !== undefined) { + if(parsedResponse.errorCode === "error.invalid.api.key") { + return { + error: "invalid_api_key" + } + } else if(parsedResponse.errorCode === "error.invalid.details") { + return { + error: "invalid_details" + } + } else if(parsedResponse.errorCode === "error.null.api.key") { + return { + error: "invalid_api_key" + } + } else { + return { + error: "invalid_unknown" + } + } + } else if(parsedResponse.errorCode === undefined) { + UserCST.set(response.headers.get("CST")!); + UserXSecurityToken.set(response.headers.get("X-SECURITY-TOKEN")!); + + cookies.set("CAPITALCOM-CST", response.headers.get("CST")!, { + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + + cookies.set("CAPITALCOM-X-SECURITY-TOKEN", response.headers.get("X-SECURITY-TOKEN")!, { + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + + + //get all available accounts for user + const userAccountsResponse: Response = await fetch("https://api-capital.backend-capital.com/api/v1/accounts", { + method: "GET", + headers: { + "X-SECURITY-TOKEN": userXSecurityToken, + "CST": userCST + }, + redirect: "follow" + }); + + let parsedUserAccountsResponse: CapitalComUserAccounts = await userAccountsResponse.json(); + if(parsedUserAccountsResponse.errorCode !== undefined) { console.log(`Error while getting all acounts for user: ${parsedUserAccountsResponse.errorCode}`); } + + return { + "showSelectAccountDialog": true, + "accounts": parsedUserAccountsResponse.accounts + }; + + // throw redirect(303, "/dashboard"); + } + } +} satisfies Actions; \ No newline at end of file diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..9a72171 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,204 @@ + + +
+ + + Login + Sign in to your capital.com account. + +
showLoadingButton = true} + use:enhance={({ formElement, formData, action, cancel, submitter }) => { + showLoadingButton = true; + // `formElement` is this `` element // `formData` is its `FormData` object that's about to be submitted // `action` is the URL to which the form is posted // calling `cancel()` will prevent the submission // `submitter` is the `HTMLElement` that caused the form to be submitted + const formDataObjects = Object.fromEntries(formData); + + const isEmail = new RegExp(/^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/).test(String(formDataObjects.user_email).toLowerCase()); + if(!isEmail) { + progressiveWrongEmail = true; + showLoadingButton = false; + cancel(); + } else { + progressiveWrongEmail = false; + } + + if(formDataObjects.user_password.toString().length < 8 || !(/[`!@#$%^&*()_+\-=\[\]{};':"\\|,.\/?~]/.test(formDataObjects.user_password.toString())) || !(/\d/.test(formDataObjects.user_password.toString())) || formDataObjects.user_password.toString().toUpperCase() === formDataObjects.user_password.toString() || formDataObjects.user_password.toString().toLowerCase() === formDataObjects.user_password.toString()) { + progressiveWrongPassword = true; + showLoadingButton = false; + cancel(); + } else { + progressiveWrongPassword = false; + } + + if(formDataObjects.user_password.toString().length < 1) { + progressiveWrongAPIKey = true; + } + return async ({ result, update }) => { + // `result` is an `ActionResult` object // `update` is a function which triggers the default logic that would be triggered if this callback wasn't set }; + await update({ reset: false }); + showLoadingButton = false; + } + + }} + > + +
+ + + {#if progressiveWrongEmail || form?.error === "invalid_details" || form?.error === "invalid_unknown"} +

Please provide a valid E-Mail address.

+ {/if} +
+
+ + + {#if progressiveWrongPassword || form?.error === "invalid_details" || form?.error === "invalid_unknown"} +

Please provide a valid Password.

+ {/if} +
+
+ + + {#if progressiveWrongAPIKey || form?.error === "invalid_api_key" || form?.error === "invalid_unknown"} +

Please provide a valid API Key.

+ {/if} +
+
+ +
+ +

I (the user) acknowledge that the deveopler of this software is not in any way affiliated with capital.com.

+
+
+
+ + {#if disclaimerAcknowledged} + {#if showLoadingButton} + + {:else} + + {/if} + {:else} + + {/if} + + +
+
+ +
+ { if(open === false) { selectedAccount = undefined; dialogOpen = false } else if(open === true) { dialogOpen = true } } }> + + + Select capital.com account + + Select the trading account, which you want to use for trading. + + + {#if form?.accounts} +
+ selectedAccount = selectedValue}> + {#each (form?.accounts ?? []) as account, index} +
+ + + {account.balance.balance} {account.currency} + {#if account.preferred} + preferred + {/if} +
+ {/each} +
+
+ {/if} + + + {#if selectedAccount !== undefined} + {#if showDialogLoadingButton} + + {:else} + + {/if} + {:else} + + {/if} + +
+
+
\ No newline at end of file diff --git a/static/favicon.png b/static/favicon.png index 825b9e6..4df7e42 100644 Binary files a/static/favicon.png and b/static/favicon.png differ diff --git a/svelte.config.js b/svelte.config.js index ead8195..abec7fb 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -3,16 +3,20 @@ import { vitePreprocess } from "@sveltejs/kit/vite"; /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://kit.svelte.dev/docs/integrations#preprocessors - // for more information about preprocessors - preprocess: [vitePreprocess({})], + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: [vitePreprocess({})], - kit: { - // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. - // If your environment is not supported or you settled on a specific environment, switch out the adapter. - // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter(), - }, + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + // alias: { + // $lib: "./src/lib", + // "$lib/*": "./src/lib/*", + // } + }, }; export default config;