diff --git a/src/Dropdown.svelte b/src/Dropdown.svelte index f5fe65266..6a99b26db 100644 --- a/src/Dropdown.svelte +++ b/src/Dropdown.svelte @@ -2,7 +2,7 @@ import { createEventDispatcher } from "svelte"; export let items: { key: string; value: string; badge: string | null }[]; - export let selected: string | null; + export let selected: string | null = null; export let visible = false; const dispatch = createEventDispatcher(); diff --git a/src/SeedDropdown.svelte b/src/SeedDropdown.svelte new file mode 100644 index 000000000..2f1284c9b --- /dev/null +++ b/src/SeedDropdown.svelte @@ -0,0 +1,46 @@ + + + + +
+ + + {#await formatSeeds() then items} + { + seedDropdown = false; + navigate(`/seeds/${detail}`); + }} + /> + {/await} + +
diff --git a/src/api.ts b/src/api.ts index 4fa1ef13b..4978f0c49 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,50 +3,98 @@ export interface Host { port: number | null; } -export async function get( - path: string, - params: Record, - api: Host -): Promise { - const query: Record = {}; - for (const [key, val] of Object.entries(params)) { - if (val !== undefined && val !== null) { - query[key] = val.toString(); - } +export class Request { + path: string; + base: string; + protocol: string; + port: number; + + constructor(path: string, api: Host) { + this.port = api.port || 8777; + this.base = api.host; + this.path = path.startsWith("/") ? path.slice(1) : path; + this.protocol = api.host === "0.0.0.0" ? "http://" : "https://"; } - const base = api.host; - const port = api.port; - const search = new URLSearchParams(query).toString(); - // Allow using the functionality with local runned http-api - const isLocalhost = /^0.0.0.0$/.test(base); - const protocol = isLocalhost ? "http://" : "https://"; + async get( + params: Record = {}, + headers: Record = {}, + ): Promise { + const query = this.formatParams(params); + const search = new URLSearchParams(query).toString(); + const urlString = this.createUrl(search); - path = path.startsWith("/") ? path.slice(1) : path; - - const baseUrl = path - ? `${protocol}${base}/v1/${path}` - : `${protocol}${base}`; - const url = new URL(search ? `${baseUrl}?${search}` : baseUrl); - url.port = String(port); - - const urlString = String(url); - let response = null; - try { - response = await fetch(urlString, { + return await Request.exec(urlString, { method: 'GET', - headers: { - 'Accept': 'application/json', - } + headers: { ...headers, 'Accept': 'application/json' } }); - } catch (err) { - throw new ApiError("API request failed", urlString); } - if (! response.ok) { - throw new ApiError("Not found", urlString); + async post( + params: Record = {}, + headers: Record = {}, + ): Promise { + const body = this.formatParams(params); + const urlString = this.createUrl(); + + return await Request.exec(urlString, { + method: 'POST', + body: JSON.stringify(body), + headers: { ...headers, 'Content-Type': 'application/json' } + }); + } + + async put( + params: Record = {}, + headers: Record = {}, + ): Promise { + const body = this.formatParams(params); + const urlString = this.createUrl(); + + return await Request.exec(urlString, { + method: 'PUT', + body: JSON.stringify(body), + headers: { ...headers, 'Content-Type': 'application/json' } + }); + } + + // Executes a request and returns the response. + static async exec(urlString: string, props: Record): Promise { + let response = null; + try { + response = await fetch(urlString, props); + } catch (err) { + throw new ApiError("API request failed", urlString); + } + + if (! response.ok) { + throw new ApiError("Not found", urlString); + } + return response.json(); + } + + // Filters out undefined and null values. + private formatParams(params: Record): Record { + const query: Record = {}; + for (const [key, val] of Object.entries(params)) { + if (val !== undefined && val !== null) { + query[key] = val.toString(); + } + } + + return query; + } + + // Creates a URL with an eventual query string and port. + private createUrl(search?: string): string { + const baseUrl = this.path + ? `${this.protocol}${this.base}/v1/${this.path}` + : `${this.protocol}${this.base}`; + + const url = new URL(search ? `${baseUrl}?${search}` : baseUrl); + url.port = String(this.port); + return String(url); } - return response.json(); } export class ApiError extends Error { diff --git a/src/base/seeds/Seed.ts b/src/base/seeds/Seed.ts index 95438402c..74cc6bb52 100644 --- a/src/base/seeds/Seed.ts +++ b/src/base/seeds/Seed.ts @@ -1,4 +1,4 @@ -import * as api from '@app/api'; +import { Request, type Host } from '@app/api'; import type { Config } from '@app/config'; import * as proj from "@app/project"; import { isDomain } from '@app/utils'; @@ -108,12 +108,12 @@ export class Seed { return result.map((project: proj.ProjectInfo) => ({ ...project, id: project.urn })); } - static async getPeer({ host, port }: api.Host): Promise<{ id: string }> { - return api.get("/peer", {}, { host, port }); + static async getPeer(host: Host): Promise<{ id: string }> { + return new Request("/peer", host).get(); } - static async getInfo({ host, port }: api.Host): Promise<{ version: string }> { - return api.get("/", {}, { host, port }); + static async getInfo(host: Host): Promise<{ version: string }> { + return new Request("/", host).get(); } static async lookup(hostname: string, cfg: Config): Promise { diff --git a/src/project.ts b/src/project.ts index 0e0494cf9..1e8ad8abb 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,6 +1,6 @@ import { navigate } from 'svelte-routing'; import { get, writable } from 'svelte/store'; -import * as api from '@app/api'; +import { type Host, Request } from '@app/api'; import type { Commit, CommitHeader, CommitsHistory } from '@app/commit'; import { isOid, isRadicleId } from '@app/utils'; import { Profile, ProfileType } from '@app/profile'; @@ -249,8 +249,8 @@ export class Project implements ProjectInfo { return { tree, commit }; } - static async getInfo(nameOrUrn: string, host: api.Host): Promise { - const info = await api.get(`projects/${nameOrUrn}`, {}, host); + static async getInfo(nameOrUrn: string, host: Host): Promise { + const info = await new Request(`projects/${nameOrUrn}`, host).get(); return { ...info, @@ -258,25 +258,25 @@ export class Project implements ProjectInfo { }; } - static async getProjects(host: api.Host): Promise { - return api.get("projects", {}, host); + static async getProjects(host: Host): Promise { + return new Request("projects", host).get(); } - static async getDelegateProjects(delegate: string, host: api.Host): Promise { - return api.get(`delegates/${delegate}/projects`, {}, host); + static async getDelegateProjects(delegate: string, host: Host): Promise { + return new Request(`delegates/${delegate}/projects`, host).get(); } - static async getRemote(urn: string, peer: string, host: api.Host): Promise { - return api.get(`projects/${urn}/remotes/${peer}`, {}, host); + static async getRemote(urn: string, peer: string, host: Host): Promise { + return new Request(`projects/${urn}/remotes/${peer}`, host).get(); } - static async getRemotes(urn: string, host: api.Host): Promise { - return api.get(`projects/${urn}/remotes`, {}, host); + static async getRemotes(urn: string, host: Host): Promise { + return new Request(`projects/${urn}/remotes`, host).get(); } static async getCommits( urn: string, - host: api.Host, + host: Host, opts?: { parent?: string | null; since?: string; @@ -294,11 +294,11 @@ export class Project implements ProjectInfo { "page": opts?.page, "verified": opts?.verified }; - return api.get(`projects/${urn}/commits`, params, host); + return new Request(`projects/${urn}/commits`, host).get(params); } async getCommit(commit: string): Promise { - return api.get(`projects/${this.urn}/commits/${commit}`, {}, this.seed.api); + return new Request(`projects/${this.urn}/commits/${commit}`, this.seed.api).get(); } async getTree( @@ -306,7 +306,7 @@ export class Project implements ProjectInfo { path: string, ): Promise { if (path === "/") path = ""; - return api.get(`projects/${this.urn}/tree/${commit}/${path}`, {}, this.seed.api); + return new Request(`projects/${this.urn}/tree/${commit}/${path}`, this.seed.api).get(); } async getBlob( @@ -314,13 +314,13 @@ export class Project implements ProjectInfo { path: string, options: { highlight: boolean }, ): Promise { - return api.get(`projects/${this.urn}/blob/${commit}/${path}`, options, this.seed.api); + return new Request(`projects/${this.urn}/blob/${commit}/${path}`, this.seed.api).get(options); } async getReadme( commit: string, ): Promise { - return api.get(`projects/${this.urn}/readme/${commit}`, {}, this.seed.api); + return new Request(`projects/${this.urn}/readme/${commit}`, this.seed.api).get(); } navigateTo(browse: BrowseTo): void { diff --git a/src/siwe.ts b/src/siwe.ts new file mode 100644 index 000000000..e625d961b --- /dev/null +++ b/src/siwe.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { SiweMessage } from "siwe"; +import { Request, type Host } from '@app/api'; +import type { Config } from "@app/config"; +import { removePrefix } from "@app/utils"; +import { connectSeed } from "./session"; +import type { Seed } from "./base/seeds/Seed"; + +export interface SeedSession { + domain: string; + address: string; + statement: string; + uri: string; + version: string; + chain_id: string; + nonce: string; + issued_at: number; + expiration_time: number; + resources: string[]; +} + +export function createSiweMessage(seed: Seed, address: string, nonce: string, config: Config): string { + const date = new Date(); + const expirationTime = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7).toISOString(); + + const message = new SiweMessage({ + domain: seed.api.host, + address, + statement: `${seed.api.host} wants you to sign in with Ethereum`, + uri: window.location.origin, + nonce, + version: '1', + resources: [`rad:git:${seed.id}`], + expirationTime, + chainId: config.network.chainId + }); + + return message.prepareMessage(); +} + +export async function createUnauthorizedSession(host: Host): Promise<{ nonce: string; id: string }> { + return await new Request(`sessions`, host).post(); +} + +/// Signs the user into given seed and returns when succesfull a session id +export async function signInWithEthereum(seed: Seed, config: Config): Promise<{ id: string } | null> { + if (! config.signer) { + return null; + } + + const result = await createUnauthorizedSession(seed.api); + const address = await config.signer.getAddress(); + const message = createSiweMessage(seed, address, result.nonce, config); + const signature = await config.signer.signMessage(message); + + const auth: { id: string; session: SeedSession } = await new Request(`sessions/${result.id}`, seed.api).put({ message, signature: removePrefix(signature) }); + + await new Request(`sessions`, seed.api).get({}, { Authorization: result.id }); + connectSeed({ id: result.id, session: auth.session }); + + return { id: result.id }; +}