fix: default nip96

This commit is contained in:
kieran 2024-09-12 14:56:06 +01:00
parent 736189d0d2
commit ce10d920f4
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
18 changed files with 139 additions and 417 deletions

View File

@ -261,7 +261,7 @@ export function NoteCreator() {
async function uploadFile(file: File) {
try {
if (file) {
if (file && uploader) {
const rx = await uploader.upload(file, file.name);
note.update(v => {
if (rx.header) {

View File

@ -10,10 +10,11 @@ import useFileUpload from "@/Utils/Upload";
interface AvatarEditorProps {
picture?: string;
onPictureChange?: (newPicture: string) => void;
privKey?: string;
}
export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorProps) {
const uploader = useFileUpload();
export default function AvatarEditor({ picture, onPictureChange, privKey }: AvatarEditorProps) {
const uploader = useFileUpload(privKey);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@ -22,7 +23,7 @@ export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorP
setLoading(true);
try {
const f = await openFile();
if (f) {
if (f && uploader) {
const rsp = await uploader.upload(f, f.name);
console.log(rsp);
if (typeof rsp?.error === "string") {

View File

@ -0,0 +1,26 @@
import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
export default function useDiscoverMediaServers() {
const sub = useMemo(() => {
const rb = new RequestBuilder("media-servers-all");
rb.withFilter().kinds([EventKind.StorageServerList]);
return rb;
}, []);
const data = useRequestBuilder(sub);
return data.reduce(
(acc, v) => {
const servers = removeUndefined(v.tags.filter(a => a[0] === "server").map(a => sanitizeRelayUrl(a[1])));
for (const server of servers) {
acc[server] ??= 0;
acc[server]++;
}
return acc;
},
{} as Record<string, number>,
);
}

View File

@ -1,28 +1,34 @@
import { NotEncrypted } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { useContext, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation, useNavigate } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton";
import AvatarEditor from "@/Components/User/AvatarEditor";
import { trackEvent } from "@/Utils";
import { generateNewLogin } from "@/Utils/Login";
import { generateNewLogin, generateNewLoginKeys } from "@/Utils/Login";
import { NewUserState } from ".";
export function Profile() {
const system = useContext(SnortContext);
const [keys, setNewKeys] = useState<{ entropy: Uint8Array; privateKey: string }>();
const [picture, setPicture] = useState<string>();
const [error, setError] = useState("");
const navigate = useNavigate();
const location = useLocation();
const state = location.state as NewUserState;
async function makeRandomKey() {
useEffect(() => {
generateNewLoginKeys().then(setNewKeys);
}, []);
async function loginNewKeys() {
try {
if (!keys) return;
setError("");
await generateNewLogin(system, key => Promise.resolve(new NotEncrypted(key)), {
await generateNewLogin(keys, system, key => Promise.resolve(new NotEncrypted(key)), {
name: state.name,
picture,
});
@ -40,8 +46,8 @@ export function Profile() {
<h1>
<FormattedMessage defaultMessage="Profile Image" />
</h1>
<AvatarEditor picture={picture} onPictureChange={p => setPicture(p)} />
<AsyncButton className="primary" onClick={() => makeRandomKey()}>
<AvatarEditor picture={picture} onPictureChange={p => setPicture(p)} privKey={keys?.privateKey} />
<AsyncButton className="primary" onClick={() => loginNewKeys()}>
<FormattedMessage defaultMessage="Next" />
</AsyncButton>
{error && <b className="error">{error}</b>}

View File

@ -454,33 +454,6 @@ const PreferencesPage = () => {
/>
</div>
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage {...messages.FileUpload} />
</h4>
<small>
<FormattedMessage {...messages.FileUploadHelp} />
</small>
<select
value={pref.fileUploader}
onChange={e =>
setPref({
...pref,
fileUploader: e.target.value,
} as UserPreferences)
}>
<option value="nip96">
<FormattedMessage defaultMessage="NIP-96" />
</option>
<option value="void.cat">
void.cat <FormattedMessage {...messages.Default} />
</option>
<option value="void.cat-NIP96">void.cat (NIP-96)</option>
<option value="nostr.build">nostr.build</option>
<option value="nostrimg.com">nostrimg.com</option>
<option value="nostrcheck.me">nostrcheck.me (NIP-96)</option>
</select>
</div>
<div className="flex justify-between">
<div className="flex flex-col g8">
<h4>

View File

@ -131,7 +131,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
try {
setError(undefined);
const file = await openFile();
if (file) {
if (file && uploader) {
const rsp = await uploader.upload(file, file.name);
if (typeof rsp?.error === "string") {
throw new Error(`Upload failed ${rsp.error}`);

View File

@ -1,10 +1,13 @@
import { unwrap } from "@snort/shared";
import { EventKind, UnknownTag } from "@snort/system";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import IconButton from "@/Components/Button/IconButton";
import { CollapsedSection } from "@/Components/Collapsed";
import { RelayFavicon } from "@/Components/Relay/RelaysMetadata";
import useDiscoverMediaServers from "@/Hooks/useDiscoverMediaServers";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { Nip96Uploader } from "@/Utils/Upload/Nip96";
@ -15,13 +18,14 @@ export default function MediaSettingsPage() {
const list = state.getList(EventKind.StorageServerList);
const [newServer, setNewServer] = useState("");
const [error, setError] = useState("");
const knownServers = useDiscoverMediaServers();
async function validateServer() {
async function validateServer(url: string) {
if (!publisher) return;
setError("");
try {
const svc = new Nip96Uploader(newServer, publisher);
const svc = new Nip96Uploader(url, publisher);
await svc.loadInfo();
return true;
@ -79,7 +83,7 @@ export default function MediaSettingsPage() {
/>
<AsyncButton
onClick={async () => {
if (await validateServer()) {
if (await validateServer(newServer)) {
await state.addToList(
EventKind.StorageServerList,
[new UnknownTag(["server", new URL(newServer).toString()])],
@ -93,6 +97,57 @@ export default function MediaSettingsPage() {
</div>
{error && <b className="text-warning">{error}</b>}
</div>
<CollapsedSection
title={
<div className="text-xl font-medium">
<FormattedMessage defaultMessage="Popular Servers" />
</div>
}>
<small>
<FormattedMessage defaultMessage="Popular media servers." />
</small>
<table className="table">
<thead>
<tr className="uppercase text-secondary">
<th>
<FormattedMessage defaultMessage="Server" />
</th>
<th>
<FormattedMessage defaultMessage="Users" />
</th>
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(knownServers)
.sort((a, b) => (b[1] < a[1] ? -1 : 1))
.filter(([k]) => !list.some(b => b.equals(new UnknownTag(["server", k]))))
.slice(0, 20)
.map(([k, v]) => (
<tr key={k}>
<td className="flex gap-2 items-center">
<RelayFavicon url={k} />
{k}
</td>
<td className="text-center">
<FormattedNumber value={v} />
</td>
<td className="text-end">
<AsyncButton
className="!py-1 mb-1"
onClick={async () => {
if (await validateServer(k)) {
await state.addToList(EventKind.StorageServerList, [new UnknownTag(["server", k])], true);
}
}}>
<FormattedMessage defaultMessage="Add" />
</AsyncButton>
</td>
</tr>
))}
</tbody>
</table>
</CollapsedSection>
</div>
);
}

View File

@ -14,8 +14,7 @@ import {
} from "@snort/system";
import { GiftsCache } from "@/Cache";
import SnortApi from "@/External/SnortApi";
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/Utils";
import { bech32ToHex, dedupeById, deleteRefCode, unwrap } from "@/Utils";
import { Blasters } from "@/Utils/Const";
import { LoginSession, LoginSessionType, LoginStore, SnortAppData } from "@/Utils/Login/index";
import { entropyToPrivateKey, generateBip39Entropy } from "@/Utils/nip6";
@ -41,31 +40,26 @@ export function clearEntropy(state: LoginSession) {
}
/**
* Generate a new key and login with this generated key
* Generate a new key
*/
export async function generateNewLoginKeys() {
const entropy = generateBip39Entropy();
const privateKey = await entropyToPrivateKey(entropy);
return { entropy, privateKey };
}
/**
* Login with newly generated key
*/
export async function generateNewLogin(
keys: { entropy: Uint8Array; privateKey: string },
system: SystemInterface,
pin: (key: string) => Promise<KeyStorage>,
profile: UserMetadata,
) {
const entropy = generateBip39Entropy();
const privateKey = await entropyToPrivateKey(entropy);
const { entropy, privateKey } = keys;
const newRelays = {} as Record<string, RelaySettings>;
// Use current timezone info to determine approx location
// use closest 5 relays
const country = getCountry();
const api = new SnortApi();
const closeRelays = await api.closeRelays(country.lat, country.lon, 20);
for (const cr of closeRelays.sort((a, b) => (a.distance > b.distance ? 1 : -1)).filter(a => !a.is_paid)) {
const rr = sanitizeRelayUrl(cr.url);
if (rr) {
newRelays[rr] = { read: true, write: true };
if (Object.keys(newRelays).length >= 5) {
break;
}
}
}
for (const [k, v] of Object.entries(CONFIG.defaultRelays)) {
if (!newRelays[k]) {
newRelays[k] = v;
@ -75,8 +69,8 @@ export async function generateNewLogin(
// connect to new relays
await Promise.all(Object.entries(newRelays).map(([k, v]) => system.ConnectToRelay(k, v)));
const publicKey = utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
const publisher = EventPublisher.privateKey(privateKey);
const publicKey = publisher.pubKey;
// Create new contact list following self and site account
const contactList = [publicKey, ...CONFIG.signUp.defaultFollows.map(a => bech32ToHex(a))].map(a => ["p", a]) as Array<

View File

@ -43,11 +43,6 @@ export interface UserPreferences {
*/
showDebugMenus: boolean;
/**
* File uploading service to upload attachments to
*/
fileUploader: "void.cat" | "nostr.build" | "nostrimg.com" | "void.cat-NIP96" | "nostrcheck.me" | "nip96";
/**
* Use imgproxy to optimize images
*/
@ -117,7 +112,6 @@ export const DefaultPreferences = {
confirmReposts: false,
showDebugMenus: true,
autoShowLatest: false,
fileUploader: "nostr.build",
imgProxyConfig: DefaultImgProxy,
defaultRootTab: "following",
defaultZapAmount: 50,

View File

@ -45,16 +45,6 @@ export class Nip96Uploader {
const data = (await rsp.json()) as Nip96Result;
if (data.status === "success") {
const meta = readNip94Tags(data.nip94_event.tags);
if (
meta.dimensions === undefined ||
meta.dimensions.length !== 2 ||
meta.dimensions[0] === 0 ||
meta.dimensions[1] === 0
) {
return {
error: `Invalid dimensions: "${meta.dimensions?.join("x")}"`,
};
}
return {
url: addExtensionToNip94Url(meta),
header: data.nip94_event,

View File

@ -1,71 +0,0 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
import { UploadResult } from "@/Utils/Upload/index";
export default async function NostrBuild(file: File | Blob, publisher?: EventPublisher): Promise<UploadResult> {
const auth = publisher
? async (url: string, method: string) => {
const auth = await publisher.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
});
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
}
: undefined;
const fd = new FormData();
fd.append("fileToUpload", file);
fd.append("submit", "Upload Image");
const url = "https://nostr.build/api/v2/upload/files";
const headers = {
accept: "application/json",
} as Record<string, string>;
if (auth) {
headers["Authorization"] = await auth(url, "POST");
}
const rsp = await fetch(url, {
body: fd,
method: "POST",
headers,
});
if (rsp.ok) {
throwIfOffline();
const data = (await rsp.json()) as NostrBuildUploadResponse;
const res = data.data[0];
return {
url: res.url,
metadata: {
blurhash: res.blurhash,
width: res.dimensions.width,
height: res.dimensions.height,
hash: res.sha256,
},
};
}
return {
error: "Upload failed",
};
}
interface NostrBuildUploadResponse {
data: Array<NostrBuildUploadData>;
}
interface NostrBuildUploadData {
input_name: string;
name: string;
url: string;
thumbnail: string;
blurhash: string;
sha256: string;
type: string;
mime: string;
size: number;
metadata: Record<string, string>;
dimensions: {
width: number;
height: number;
};
}

View File

@ -1,44 +0,0 @@
import { throwIfOffline } from "@snort/shared";
import { UploadResult } from "@/Utils/Upload/index";
export default async function NostrImg(file: File | Blob): Promise<UploadResult> {
throwIfOffline();
const fd = new FormData();
fd.append("image", file);
const rsp = await fetch("https://nostrimg.com/api/upload", {
body: fd,
method: "POST",
headers: {
accept: "application/json",
},
});
if (rsp.ok) {
const data: UploadResponse = await rsp.json();
if (typeof data?.imageUrl === "string" && data.success) {
return {
url: new URL(data.imageUrl).toString(),
};
}
}
return {
error: "Upload failed",
};
}
interface UploadResponse {
fileID?: string;
fileName?: string;
imageUrl?: string;
lightningDestination?: string;
lightningPaymentLink?: string;
message?: string;
route?: string;
status: number;
success: boolean;
url?: string;
data?: {
url?: string;
};
}

View File

@ -1,102 +0,0 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
import { UploadState, VoidApi } from "@void-cat/api";
import { FileExtensionRegex } from "@/Utils/Const";
import { UploadResult } from "@/Utils/Upload/index";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
*/
export default async function VoidCatUpload(
file: File | Blob,
filename: string,
publisher?: EventPublisher,
progress?: (n: number) => void,
stage?: (n: "starting" | "hashing" | "uploading" | "done" | undefined) => void,
): Promise<UploadResult> {
throwIfOffline();
const auth = publisher
? async (url: string, method: string) => {
const auth = await publisher.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
});
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
}
: undefined;
const api = new VoidApi("https://void.cat", auth);
const uploader = api.getUploader(
file,
sx => {
stage?.(
(() => {
switch (sx) {
case UploadState.Starting:
return "starting";
case UploadState.Hashing:
return "hashing";
case UploadState.Uploading:
return "uploading";
case UploadState.Done:
return "done";
}
})(),
);
},
px => {
progress?.(px / file.size);
},
);
const rsp = await uploader.upload({
"V-Strip-Metadata": "true",
});
if (rsp.ok) {
let ext = filename.match(FileExtensionRegex);
if (rsp.file?.metadata?.mimeType === "image/webp") {
ext = ["", "webp"];
}
const resultUrl = rsp.file?.metadata?.url ?? `https://void.cat/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
const dim = rsp.file?.metadata?.mediaDimensions ? rsp.file.metadata.mediaDimensions.split("x") : undefined;
const ret = {
url: resultUrl,
metadata: {
hash: rsp.file?.metadata?.digest,
width: dim ? Number(dim[0]) : undefined,
height: dim ? Number(dim[1]) : undefined,
},
} as UploadResult;
if (publisher) {
// NIP-94
/*const tags = [
["url", resultUrl],
["x", rsp.file?.metadata?.digest ?? ""],
["m", rsp.file?.metadata?.mimeType ?? "application/octet-stream"],
];
if (rsp.file?.metadata?.size) {
tags.push(["size", rsp.file.metadata.size.toString()]);
}
if (rsp.file?.metadata?.magnetLink) {
tags.push(["magnet", rsp.file.metadata.magnetLink]);
const parsedMagnet = magnetURIDecode(rsp.file.metadata.magnetLink);
if (parsedMagnet?.infoHash) {
tags.push(["i", parsedMagnet?.infoHash]);
}
}
ret.header = await publisher.generic(eb => {
eb.kind(EventKind.FileHeader).content(filename);
tags.forEach(t => eb.tag(t));
return eb;
});*/
}
return ret;
} else {
return {
error: rsp.errorMessage,
};
}
}

View File

@ -1 +0,0 @@
export class BlossomClient {}

View File

@ -1,16 +1,9 @@
import { removeUndefined } from "@snort/shared";
import { EventKind, NostrEvent } from "@snort/system";
import { useState } from "react";
import { v4 as uuid } from "uuid";
import { EventPublisher, NostrEvent } from "@snort/system";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { useMediaServerList } from "@/Hooks/useMediaServerList";
import { bech32ToHex, randomSample, unwrap } from "@/Utils";
import { FileExtensionRegex, KieranPubKey } from "@/Utils/Const";
import NostrBuild from "@/Utils/Upload/NostrBuild";
import NostrImg from "@/Utils/Upload/NostrImg";
import VoidCat from "@/Utils/Upload/VoidCat";
import { Nip96Uploader } from "./Nip96";
@ -81,118 +74,19 @@ export interface UploadProgress {
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
export default function useFileUpload(): Uploader {
const fileUploader = usePreferences(s => s.fileUploader);
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
export default function useFileUpload(privKey?: string) {
const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
const [stage, setStage] = useState<UploadStage>();
const { servers } = useMediaServerList();
const defaultUploader = {
upload: async (f, n) => {
const id = uuid();
setProgress(s => [
...s,
{
id,
file: f,
progress: 0,
stage: undefined,
},
]);
const px = (n: number) => {
setProgress(s =>
s.map(v =>
v.id === id
? {
...v,
progress: n,
}
: v,
),
);
};
const ret = await VoidCat(f, n, publisher, px, s => setStage(s));
setProgress(s => s.filter(a => a.id !== id));
return ret;
},
progress,
stage,
} as Uploader;
switch (fileUploader) {
case "nostr.build": {
return {
upload: f => NostrBuild(f, publisher),
progress: [],
} as Uploader;
}
case "void.cat-NIP96": {
return new Nip96Uploader("https://void.cat/nostr", unwrap(publisher));
}
case "nostrcheck.me": {
return new Nip96Uploader("https://nostrcheck.me/api/v2/nip96", unwrap(publisher));
}
case "nostrimg.com": {
return {
upload: NostrImg,
progress: [],
} as Uploader;
}
case "nip96": {
const servers = removeUndefined(state.getList(EventKind.StorageServerList).map(a => a.toEventTag()?.at(1)));
if (servers.length > 0) {
const random = randomSample(servers, 1)[0];
return new Nip96Uploader(random, unwrap(publisher));
} else {
return defaultUploader;
}
}
default: {
return defaultUploader;
}
const pub = privKey ? EventPublisher.privateKey(privKey) : publisher;
if (servers.length > 0 && pub) {
const random = randomSample(servers, 1)[0];
return new Nip96Uploader(random, pub);
} else if (pub) {
return new Nip96Uploader("https://nostr.build", pub);
}
}
export const ProgressStream = (file: File | Blob, progress: (n: number) => void) => {
let offset = 0;
const DefaultChunkSize = 1024 * 32;
const readChunk = async (offset: number, size: number) => {
if (offset > file.size) {
return new Uint8Array(0);
}
const end = Math.min(offset + size, file.size);
const blob = file.slice(offset, end, file.type);
const data = await blob.arrayBuffer();
return new Uint8Array(data);
};
const rsBase = new ReadableStream(
{
start: async () => {},
pull: async controller => {
const chunk = await readChunk(offset, controller.desiredSize ?? DefaultChunkSize);
if (chunk.byteLength === 0) {
controller.close();
return;
}
progress((offset + chunk.byteLength) / file.size);
offset += chunk.byteLength;
controller.enqueue(chunk);
},
cancel: reason => {
console.log(reason);
},
type: "bytes",
},
{
highWaterMark: DefaultChunkSize,
},
);
return rsBase;
};
export function addExtensionToNip94Url(meta: Nip94Tags) {
if (!meta.url?.match(FileExtensionRegex) && meta.mimeType) {
switch (meta.mimeType) {

View File

@ -74,6 +74,9 @@
"08zn6O": {
"defaultMessage": "Export Keys"
},
"0AmhUh": {
"defaultMessage": "Popular media servers used by people you follow."
},
"0Azlrb": {
"defaultMessage": "Manage"
},
@ -476,6 +479,9 @@
"C81/uG": {
"defaultMessage": "Logout"
},
"C8FsOr": {
"defaultMessage": "Popular Servers"
},
"C8HhVE": {
"defaultMessage": "Suggested Follows"
},
@ -578,6 +584,9 @@
"EnCOBJ": {
"defaultMessage": "Buy"
},
"F/6VqP": {
"defaultMessage": "Server"
},
"F3l7xL": {
"defaultMessage": "Add Account"
},
@ -759,9 +768,6 @@
"JSx7y9": {
"defaultMessage": "Subscribe to {site_name} {plan} for {price} and receive the following rewards"
},
"JTht/T": {
"defaultMessage": "NIP-96"
},
"JeoS4y": {
"defaultMessage": "Repost"
},

View File

@ -24,6 +24,7 @@
"00LcfG": "Load more",
"01iNut": "Nostr address does not belong to you",
"08zn6O": "Export Keys",
"0AmhUh": "Popular media servers used by people you follow.",
"0Azlrb": "Manage",
"0BUTMv": "Search...",
"0MndVW": "Generic LNDHub wallet (BTCPayServer / Alby / LNBits)",
@ -157,6 +158,7 @@
"C1LjMx": "Lightning Donation",
"C7642/": "Quote Repost",
"C81/uG": "Logout",
"C8FsOr": "Popular Servers",
"C8HhVE": "Suggested Follows",
"CHTbO3": "Failed to load invoice",
"CM+Cfj": "Follow List",
@ -191,6 +193,7 @@
"EcglP9": "Key",
"EjFyoR": "On-chain Donation Address",
"EnCOBJ": "Buy",
"F/6VqP": "Server",
"F3l7xL": "Add Account",
"F4eJ/3": "Classified Listings",
"FDguSC": "{n} Zaps",
@ -251,7 +254,6 @@
"JIVWWA": "Sport",
"JPFYIM": "No lightning address",
"JSx7y9": "Subscribe to {site_name} {plan} for {price} and receive the following rewards",
"JTht/T": "NIP-96",
"JeoS4y": "Repost",
"JjGgXI": "Search users",
"JkLHGw": "Website",

View File

@ -24,7 +24,6 @@ import {
import { EventBuilder } from "./event-builder";
import { findTag } from "./utils";
import { Nip7Signer } from "./impl/nip7";
import { base64 } from "@scure/base";
import { Nip10 } from "./impl/nip10";
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;