From d5032d64392234d8906a2d73857c5f9a9bd2bb79 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 30 May 2023 11:17:13 +0100 Subject: [PATCH] feat: L402 for image/video --- packages/app/src/Cache/PaymentsCache.ts | 18 +++ packages/app/src/Db/index.ts | 11 +- packages/app/src/Element/MediaElement.tsx | 133 +++++++++++++++++++++- packages/app/src/Element/ProxyImg.tsx | 29 ++--- packages/app/src/Hooks/useImgProxy.ts | 1 + packages/app/src/Util.ts | 14 +++ 6 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 packages/app/src/Cache/PaymentsCache.ts diff --git a/packages/app/src/Cache/PaymentsCache.ts b/packages/app/src/Cache/PaymentsCache.ts new file mode 100644 index 000000000..a52543b0b --- /dev/null +++ b/packages/app/src/Cache/PaymentsCache.ts @@ -0,0 +1,18 @@ +import { Payment, db } from "Db"; +import FeedCache from "./FeedCache"; + +class Payments extends FeedCache { + constructor() { + super("PaymentsCache", db.payments); + } + + key(of: Payment): string { + return of.url; + } + + takeSnapshot(): Array { + return [...this.cache.values()]; + } +} + +export const PaymentsCache = new Payments(); diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index 4ed7ddafb..30be5c7c2 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -3,7 +3,7 @@ import { FullRelaySettings, HexKey, RawEvent, u256 } from "@snort/nostr"; import { MetadataCache } from "Cache"; export const NAME = "snortDB"; -export const VERSION = 8; +export const VERSION = 9; export interface SubCache { id: string; @@ -33,6 +33,13 @@ export interface EventInteraction { reposted: boolean; } +export interface Payment { + url: string; + pr: string; + preimage: string; + macaroon: string; +} + const STORES = { users: "++pubkey, name, display_name, picture, nip05, npub", relays: "++addr", @@ -40,6 +47,7 @@ const STORES = { events: "++id, pubkey, created_at", dms: "++id, pubkey", eventInteraction: "++id", + payments: "++url", }; export class SnortDB extends Dexie { @@ -50,6 +58,7 @@ export class SnortDB extends Dexie { events!: Table; dms!: Table; eventInteraction!: Table; + payments!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/MediaElement.tsx b/packages/app/src/Element/MediaElement.tsx index 15110254e..aa59eb1c9 100644 --- a/packages/app/src/Element/MediaElement.tsx +++ b/packages/app/src/Element/MediaElement.tsx @@ -1,9 +1,16 @@ import { ProxyImg } from "Element/ProxyImg"; -import React, { MouseEvent, useState } from "react"; +import React, { MouseEvent, useEffect, useState } from "react"; +import { FormattedMessage, FormattedNumber } from "react-intl"; import "./MediaElement.css"; import Modal from "Element/Modal"; import Icon from "Icons/Icon"; +import { decodeInvoice, InvoiceDetails, kvToObject } from "Util"; +import AsyncButton from "Element/AsyncButton"; +import { useWallet } from "Wallet"; +import { PaymentsCache } from "Cache/PaymentsCache"; +import { Payment } from "Db"; +import PageSpinner from "Element/PageSpinner"; /* [ @@ -21,19 +28,137 @@ interface MediaElementProps { blurHash?: string; } +interface L402Object { + macaroon: string; + invoice: string; +} + export function MediaElement(props: MediaElementProps) { + const [invoice, setInvoice] = useState(); + const [l402, setL402] = useState(); + const [auth, setAuth] = useState(); + const [error, setError] = useState(""); + const [url, setUrl] = useState(props.url); + const [loading, setLoading] = useState(false); + const wallet = useWallet(); + + async function probeFor402() { + const cached = await PaymentsCache.get(props.url); + if (cached) { + setAuth(cached); + return; + } + + const req = new Request(props.url, { + method: "OPTIONS", + headers: { + accept: "L402", + }, + }); + const rsp = await fetch(req); + if (rsp.status === 402) { + const auth = rsp.headers.get("www-authenticate"); + if (auth?.startsWith("L402")) { + const vals = kvToObject(auth.substring(5)); + console.debug(vals); + setL402(vals); + + if (vals.invoice) { + const decoded = decodeInvoice(vals.invoice); + setInvoice(decoded); + } + } + } + } + + async function payInvoice() { + if (wallet.wallet && l402) { + try { + const res = await wallet.wallet.payInvoice(l402.invoice); + console.debug(res); + if (res.preimage) { + const pmt = { + pr: l402.invoice, + url: props.url, + macaroon: l402.macaroon, + preimage: res.preimage, + }; + await PaymentsCache.set(pmt); + setAuth(pmt); + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } + } + } + + async function loadMedia() { + if (!auth) return; + setLoading(true); + + const mediaReq = new Request(props.url, { + headers: { + Authorization: `L402 ${auth.macaroon}:${auth.preimage}`, + }, + }); + const rsp = await fetch(mediaReq); + if (rsp.ok) { + const buf = await rsp.blob(); + setUrl(URL.createObjectURL(buf)); + } + setLoading(false); + } + + useEffect(() => { + if (auth) { + loadMedia().catch(console.error); + } + }, [auth]); + + if (auth && loading) { + return ; + } + + if (invoice) { + return ( +
+

+ +

+
+
+ , + }} + /> +
+
+ payInvoice()}> + + +
+
+ {error && {error}} +
+ ); + } + if (props.mime.startsWith("image/")) { return ( - + probeFor402()} /> ); } else if (props.mime.startsWith("audio/")) { - return