mirror of
https://github.com/v0l/route96.git
synced 2025-06-20 07:10:30 +00:00
feat: new UI
This commit is contained in:
71
ui_src/src/upload/blossom.ts
Normal file
71
ui_src/src/upload/blossom.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { base64, bytesToString } from "@scure/base";
|
||||
import { throwIfOffline, unixNow } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
|
||||
export interface BlobDescriptor {
|
||||
url?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
uploaded?: number;
|
||||
}
|
||||
|
||||
export class Blossom {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
const hash = await window.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||
|
||||
const rsp = await this.#req("/upload", "PUT", file, tags);
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as BlobDescriptor;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async #req(
|
||||
path: string,
|
||||
method: "GET" | "POST" | "DELETE" | "PUT",
|
||||
body?: BodyInit,
|
||||
tags?: Array<Array<string>>,
|
||||
) {
|
||||
throwIfOffline();
|
||||
|
||||
const url = `${this.url}upload`;
|
||||
const now = unixNow();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic((eb) => {
|
||||
eb.kind(24_242 as EventKind)
|
||||
.tag(["u", url])
|
||||
.tag(["method", method])
|
||||
.tag(["t", path.slice(1)])
|
||||
.tag(["expiration", (now + 10).toString()]);
|
||||
tags?.forEach((t) => eb.tag(t));
|
||||
return eb;
|
||||
});
|
||||
return `Nostr ${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`;
|
||||
};
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(url, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
30
ui_src/src/upload/index.ts
Normal file
30
ui_src/src/upload/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export async function openFile(): Promise<File | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const elm = document.createElement("input");
|
||||
let lock = false;
|
||||
elm.type = "file";
|
||||
const handleInput = (e: Event) => {
|
||||
lock = true;
|
||||
const elm = e.target as HTMLInputElement;
|
||||
if ((elm.files?.length ?? 0) > 0) {
|
||||
resolve(elm.files![0]);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
elm.onchange = (e) => handleInput(e);
|
||||
elm.click();
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!lock) {
|
||||
resolve(undefined);
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
108
ui_src/src/upload/nip96.ts
Normal file
108
ui_src/src/upload/nip96.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { base64 } from "@scure/base";
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
|
||||
|
||||
export class Nip96 {
|
||||
#info?: Nip96Info;
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
async loadInfo() {
|
||||
const u = new URL(this.url);
|
||||
|
||||
const rsp = await fetch(
|
||||
`${u.protocol}//${u.host}/.well-known/nostr/nip96.json`,
|
||||
);
|
||||
this.#info = (await rsp.json()) as Nip96Info;
|
||||
return this.#info;
|
||||
}
|
||||
|
||||
async listFiles(page = 0, count = 50) {
|
||||
const rsp = await this.#req(`?page=${page}&count=${count}`, "GET");
|
||||
const data = await this.#handleResponse<Nip96FileList>(rsp);
|
||||
return data;
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append("size", file.size.toString());
|
||||
fd.append("caption", file.name);
|
||||
fd.append("media_type", file.type);
|
||||
fd.append("file", file);
|
||||
|
||||
const rsp = await this.#req("", "POST", fd);
|
||||
const data = await this.#handleResponse<Nip96Result>(rsp);
|
||||
if(data.status !== "success") {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async #handleResponse<T extends Nip96Status>(rsp: Response) {
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as T;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
try {
|
||||
const obj = JSON.parse(text) as Nip96Result;
|
||||
throw new Error(obj.message);
|
||||
} catch {
|
||||
throw new Error(`Upload failed: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) {
|
||||
throwIfOffline();
|
||||
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]);
|
||||
});
|
||||
return `Nostr ${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`;
|
||||
};
|
||||
|
||||
const info = this.#info ?? (await this.loadInfo());
|
||||
let u = info.api_url;
|
||||
if (u.startsWith("/")) {
|
||||
u = `${this.url}${u.slice(1)}`;
|
||||
}
|
||||
u += path;
|
||||
return await fetch(u, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(u, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface Nip96Info {
|
||||
api_url: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface Nip96Status {status: string, message?: string}
|
||||
|
||||
export type Nip96Result = Nip96Status & {
|
||||
processing_url?: string;
|
||||
nip94_event: NostrEvent;
|
||||
}
|
||||
|
||||
export type Nip96FileList = Nip96Status & {
|
||||
count: number;
|
||||
total: number;
|
||||
page: number;
|
||||
files: Array<NostrEvent>;
|
||||
}
|
Reference in New Issue
Block a user