diff --git a/index.html b/index.html index 276b23e..2d84bb8 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + @@ -14,7 +14,7 @@ - + LNVPS @@ -27,6 +27,5 @@
- diff --git a/package.json b/package.json index 2809d8a..ff42ec2 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@scure/base": "^1.2.1", "@snort/shared": "^1.0.17", - "@snort/system": "^1.5.1", - "@snort/system-react": "^1.5.1", + "@snort/system": "^1.5.7", + "@snort/system-react": "^1.5.7", + "classnames": "^2.5.1", + "qr-code-styling": "^1.8.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.0.1" }, "devDependencies": { "@eslint/js": "^9.8.0", diff --git a/public/logo.jpg b/public/logo.jpg new file mode 100644 index 0000000..4c14a80 Binary files /dev/null and b/public/logo.jpg differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index d9f48df..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { SnortContext } from "@snort/system-react"; -import { CostInterval, DiskType, MachineSpec } from "./api"; -import VpsCard from "./components/vps-card"; -import { GiB, NostrProfile } from "./const"; -import { NostrSystem } from "@snort/system"; -import Profile from "./components/profile"; -import LoginButton from "./components/login-button"; - -import pgp from "../public/lnvps.asc?url"; - -const Offers: Array = [ - { - id: "2x2x80", - location: "IE", - active: true, - cpu: 2, - ram: 2 * GiB, - disk: { - type: DiskType.SSD, - size: 80 * GiB, - }, - cost: { - interval: CostInterval.Month, - count: 3, - currency: "EUR", - }, - }, - { - id: "4x4x160", - location: "IE", - active: true, - cpu: 4, - ram: 4 * GiB, - disk: { - type: DiskType.SSD, - size: 160 * GiB, - }, - cost: { - interval: CostInterval.Month, - count: 5, - currency: "EUR", - }, - }, - { - id: "8x8x400", - location: "IE", - active: true, - cpu: 8, - ram: 8 * GiB, - disk: { - type: DiskType.SSD, - size: 400 * GiB, - }, - cost: { - interval: CostInterval.Month, - count: 12, - currency: "EUR", - }, - }, -]; - -const system = new NostrSystem({ - automaticOutboxModel: false, - buildFollowGraph: false, -}); -[ - "wss://relay.snort.social/", - "wss://relay.damus.io/", - "wss://relay.nostr.band/", - "wss://nos.lol/", -].forEach((a) => system.ConnectToRelay(a, { read: true, write: true })); - -export default function App() { - return ( - -
-
- LNVPS - -
- -

VPS Offers

-
-
- {Offers.map((a) => ( - - ))} -
- - - All VPS come with 1x IPv4 and 1x IPv6 address and unmetered - traffic - -
- - Please email sales after - paying the invoice with your order id, desired OS and ssh key. - - You can also find us on nostr: - - - -
- Speedtest | PGP -
-
-
-
-
- ); -} diff --git a/src/api.ts b/src/api.ts index f8d1421..bc119b5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,30 +1,215 @@ -export interface MachineSpec { - id: string; - active: boolean; +import { EventKind, EventPublisher } from "@snort/system"; +import { base64 } from "@scure/base"; + +export interface ApiResponseBase { + error?: string; +} + +export type ApiResponse = ApiResponseBase & { + data: T; +}; + +export interface VmCostPlan { + id: number; + name: string; + created: Date; + amount: number; + currency: "EUR" | "BTC"; + interval_amount: number; + interval_type: string; +} + +export interface VmHostRegion { + id: number; + name: string; + enabled: boolean; +} + +export interface VmTemplate { + id: number; + name: string; + enabled: boolean; + created: Date; + expires?: Date; cpu: number; - ram: number; - disk: { - type: DiskType; - size: number; - }; - cost: { - interval: CostInterval; - count: number; - currency: CostCurrency; - }; - location: string; + memory: number; + disk_size: number; + disk_type: string; + disk_interface: string; + cost_plan_id: number; + region_id: number; + + cost_plan?: VmCostPlan; + region?: VmHostRegion; } -export enum DiskType { - HDD, - SSD, +export interface VmStatus { + state: "running" | "stopped"; + cpu_usage: number; + mem_usage: number; + uptime: number; + net_in: number; + net_out: number; + disk_write: number; + disk_read: number; } -export enum CostInterval { - Hour, - Day, - Month, - Year, +export interface VmInstance { + id: number; + host_id: number; + user_id: number; + image_id: number; + template_id: number; + ssh_key_id: number; + created: Date; + expires: Date; + cpu: number; + memory: number; + disk_size: number; + disk_id: number; + status?: VmStatus; + + template?: VmTemplate; + image?: VmOsImage; + ssh_key?: UserSshKey; } -export type CostCurrency = "EUR" | "USD" | "BTC"; +export interface VmOsImage { + id: number; + distribution: string; + flavour: string; + version: string; + release_date: string; +} + +export interface UserSshKey { + id: number; + name: string; +} + +export interface VmPayment { + id: string; + invoice: string; + created: string; + expires: string; + amount: number; + is_paid: boolean; +} + +export class LNVpsApi { + constructor( + readonly url: string, + readonly publisher: EventPublisher | undefined, + ) {} + + async listVms() { + const { data } = await this.#handleResponse>>( + await this.#req("/api/v1/vm", "GET"), + ); + return data; + } + + async getVm(id: number) { + const { data } = await this.#handleResponse>( + await this.#req(`/api/v1/vm/${id}`, "GET"), + ); + return data; + } + + async listOffers() { + const { data } = await this.#handleResponse>>( + await this.#req("/api/v1/vm/templates", "GET"), + ); + return data; + } + + async listOsImages() { + const { data } = await this.#handleResponse>>( + await this.#req("/api/v1/image", "GET"), + ); + return data; + } + + async listSshKeys() { + const { data } = await this.#handleResponse>>( + await this.#req("/api/v1/ssh-key", "GET"), + ); + return data; + } + + async addSshKey(name: string, key: string) { + const { data } = await this.#handleResponse>( + await this.#req("/api/v1/ssh-key", "POST", { + name, + key_data: key, + }), + ); + return data; + } + + async orderVm(template_id: number, image_id: number, ssh_key_id: number) { + const { data } = await this.#handleResponse>( + await this.#req("/api/v1/vm", "POST", { + template_id, + image_id, + ssh_key_id, + }), + ); + return data; + } + + async renewVm(vm_id: number) { + const { data } = await this.#handleResponse>( + await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"), + ); + return data; + } + + async paymentStatus(id: string) { + const { data } = await this.#handleResponse>( + await this.#req(`/api/v1/payment/${id}`, "GET"), + ); + return data; + } + + async #handleResponse(rsp: Response) { + if (rsp.ok) { + return (await rsp.json()) as T; + } else { + const text = await rsp.text(); + try { + const obj = JSON.parse(text) as ApiResponseBase; + throw new Error(obj.error); + } catch { + throw new Error(text); + } + } + } + + async #req(path: string, method: "GET" | "POST" | "DELETE", body?: object) { + const auth = async (url: string, method: string) => { + const auth = await this.publisher?.generic((eb) => { + return eb + .kind(EventKind.HttpAuthentication) + .tag(["u", url]) + .tag(["method", method]); + }); + if (auth) { + return `Nostr ${base64.encode( + new TextEncoder().encode(JSON.stringify(auth)), + )}`; + } + }; + + const u = `${this.url}${path}`; + return await fetch(u, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + accept: "application/json", + "content-type": "application/json", + authorization: (await auth(u, method)) ?? "", + }, + }); + } +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/button.tsx b/src/components/button.tsx index 9662b3b..21e5882 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -1,15 +1,25 @@ +import classNames from "classnames"; import { forwardRef, HTMLProps } from "react"; export type AsyncButtonProps = { - onClick?: (e: React.MouseEvent) => Promise; + onClick?: (e: React.MouseEvent) => Promise | void; } & Omit, "type" | "ref" | "onClick">; const AsyncButton = forwardRef( - function AsyncButton(props, ref) { + function AsyncButton({ className, ...props }, ref) { + const hasBg = className?.includes("bg-"); return (