feat: Added experimental nip96 list support

This commit is contained in:
florian 2024-07-20 16:26:38 +02:00
parent 832db79f7d
commit 2a10b83c73
10 changed files with 201 additions and 31 deletions

View File

@ -32,3 +32,4 @@
}
}
}

View File

@ -47,6 +47,11 @@ const Server = ({
<div className="flex flex-col grow">
<div className="server-name">
{server.name}
<div
className={`badge ${selectedServer == server.name ? 'badge-primary' : 'badge-neutral'} ml-2 align-middle`}
>
{server.type}
</div>
{server.isLoading && <span className="ml-2 loading loading-spinner loading-sm"></span>}
</div>
{server.isError ? (

View File

@ -56,7 +56,7 @@ export const ServerList = ({
created_at: dayjs().unix(),
content: '',
pubkey: user?.pubkey || '',
tags: newServers.map(s => ['server', `${s.url}`]),
tags: newServers.filter(s => s.type == 'blossom').map(s => ['server', `${s.url}`]),
});
await ev.sign();
console.log(ev.rawEvent());

View File

@ -72,13 +72,25 @@ const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSa
{server.url} <div className="badge badge-neutral">{server.type}</div>
</span>
<div className="flex items-center space-x-2">
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveUp(index)}>
<button
className="btn btn-ghost btn-sm"
disabled={server.type != 'blossom'}
onClick={() => handleMoveUp(index)}
>
<ArrowUpIcon className="h-5 w-5" />
</button>
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveDown(index)}>
<button
className="btn btn-ghost btn-sm"
disabled={server.type != 'blossom'}
onClick={() => handleMoveDown(index)}
>
<ArrowDownIcon className="h-5 w-5" />
</button>
<button className="btn btn-ghost btn-sm" onClick={() => handleDeleteServer(server.url)}>
<button
className="btn btn-ghost btn-sm"
disabled={server.type != 'blossom'}
onClick={() => handleDeleteServer(server.url)}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>

View File

@ -165,7 +165,9 @@ export const Transfer = () => {
return transferSource ? (
<>
<ServerList
servers={Object.values(serverInfo).filter(s => s.name == transferSource)}
servers={Object.values(serverInfo)
.filter(s => s.type == 'blossom')
.filter(s => s.name == transferSource)}
onCancel={() => closeTransferMode()}
title={
<>
@ -175,6 +177,7 @@ export const Transfer = () => {
></ServerList>
<ServerList
servers={Object.values(serverInfo)
.filter(s => s.type == 'blossom')
.filter(s => s.name != transferSource)
.sort()}
selectedServer={transferTarget}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk';
import { useNDK } from '../utils/ndk';
import { useServerInfo } from '../utils/useServerInfo';
@ -207,14 +207,20 @@ function Upload() {
{}
)
);
setFileEventsToPublish([]);
setUploadStep(0);
};
const [transfersInitialized, setTransfersInitialized] = useState(false);
useEffect(() => {
clearTransfers();
setUploadStep(0);
}, [servers]);
if (servers.length > 0 && !transfersInitialized) {
clearTransfers();
setTransfersInitialized(true);
}
}, [servers, transfersInitialized]);
return (
<>
@ -228,7 +234,7 @@ function Upload() {
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-col">
{uploadStep == 0 && (
<UploadFileSelection
servers={servers}
servers={servers.filter(s => s.type == 'blossom')}
transfers={transfers}
setTransfers={setTransfers}
cleanPrivateData={cleanPrivateData}

View File

@ -1,3 +1,6 @@
import { BlobDescriptor, BlossomClient, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import dayjs from 'dayjs';
const blossomUrlRegex = /https?:\/\/(?:www\.)?[^\s/]+\/([a-fA-F0-9]{64})(?:\.[a-zA-Z0-9]+)?/g;
export function extractHashesFromContent(text: string) {
@ -15,3 +18,15 @@ export function extractHashFromUrl(url: string) {
return match[1];
}
}
export async function fetchBlossomList(
serverUrl: string,
pubkey: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
): Promise<BlobDescriptor[]> {
const listAuthEvent = await BlossomClient.getListAuth(signEventTemplate, 'List Blobs');
const blobs = await BlossomClient.listBlobs(serverUrl, pubkey!, undefined, listAuthEvent);
// fallback to deprecated created attibute for servers that are not using 'uploaded' yet
return blobs.map(b => ({ ...b, uploaded: b.uploaded || b.created || dayjs().unix() }));
}

98
src/utils/nip96.ts Normal file
View File

@ -0,0 +1,98 @@
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { Server } from './useUserServers';
import dayjs from 'dayjs';
type MediaTransformation = 'resizing' | 'format_conversion' | 'compression' | 'metadata_stripping';
interface Plan {
name: string;
is_nip98_required: boolean;
url: string;
max_byte_size: number;
file_expiration: [number, number];
media_transformations: {
image: MediaTransformation[];
video: MediaTransformation[];
};
}
export interface Nip96ServerConfig {
api_url: string;
download_url: string;
supported_nips: number[];
tos_url: string;
content_types: string[]; // MimeTypes
plans: {
[key: string]: Plan;
};
}
interface Nip96BlobDescriptor {
tags: string[][];
content: string;
created_at: number;
}
interface Nip96ListResponse {
count: number; // server page size, eg. max(1, min(server_max_page_size, arg_count))
total: number; // total number of files
page: number; // the current page number
files: Nip96BlobDescriptor[];
}
export async function fetchNip96ServerConfig(serverUrl: string): Promise<Nip96ServerConfig> {
const response = await fetch(serverUrl + '/.well-known/nostr/nip96.json');
return response.json();
}
const tenMinutesFromNow = () => dayjs().unix() + 10 * 60;
async function createNip98UploadAuthToken(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
): Promise<string> {
const authEvent = {
created_at: dayjs().unix(),
kind: 27235,
content: '',
tags: [
['u', url],
['method', method],
['expiration', `${tenMinutesFromNow()}`],
],
};
const signedEvent = await signEventTemplate(authEvent);
console.log(JSON.stringify(signedEvent));
return btoa(JSON.stringify(signedEvent));
}
export async function fetchNip96List(
server: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
) {
const page = 0;
const count = 100;
const baseUrl = server.nip96?.api_url || server.url;
const listUrl = `${baseUrl}?page=${page}&count=${count}`;
const response = await fetch(listUrl, {
headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(listUrl, 'GET', signEventTemplate)}` },
});
const list = (await response.json()) as Nip96ListResponse;
const getValueByTag = (tags: string[][], t: string) => tags.find(v => v[0] == t)?.[1];
return list.files.map(
file =>
({
created: file.created_at,
uploaded: file.created_at,
type: getValueByTag(file.tags, 'm'),
sha256: getValueByTag(file.tags, 'x'),
size: parseInt(getValueByTag(file.tags, 'size') || '0', 10),
url: getValueByTag(file.tags, 'url') || baseUrl + '/' + getValueByTag(file.tags, 'ox'),
}) as BlobDescriptor
);
}

View File

@ -5,6 +5,8 @@ import { useNDK } from '../utils/ndk';
import { nip19 } from 'nostr-tools';
import { Server, useUserServers } from './useUserServers';
import dayjs from 'dayjs';
import { fetchBlossomList } from './blossom';
import { fetchNip96List } from './nip96';
export interface ServerInfo extends Server {
virtual: boolean;
@ -51,11 +53,12 @@ export const useServerInfo = () => {
queries: servers.map(server => ({
queryKey: ['blobs', server.name],
queryFn: async () => {
const listAuthEvent = await BlossomClient.getListAuth(signEventTemplate, 'List Blobs');
const blobs = await BlossomClient.listBlobs(server.url, pubkey!, undefined, listAuthEvent);
// fallback to deprecated created attibute for servers that are not using 'uploaded' yet
return blobs.map(b => ({ ...b, uploaded: b.uploaded || b.created || dayjs().unix() }));
if (server.type === 'blossom') {
return fetchBlossomList(server.url, pubkey!, signEventTemplate);
} else if (server.type === 'nip96') {
return fetchNip96List(server, signEventTemplate);
}
return [];
},
enabled: !!pubkey && servers.length > 0,
staleTime: Infinity,

View File

@ -4,6 +4,8 @@ import { nip19 } from 'nostr-tools';
import { NDKKind } from '@nostr-dev-kit/ndk';
import { USER_BLOSSOM_SERVER_LIST_KIND } from 'blossom-client-sdk';
import useEvent from './useEvent';
import { useQueries } from '@tanstack/react-query';
import { Nip96ServerConfig, fetchNip96ServerConfig } from './nip96';
type ServerType = 'blossom' | 'nip96';
@ -11,13 +13,14 @@ export type Server = {
type: ServerType;
name: string;
url: string;
nip96?: Nip96ServerConfig;
};
const USER_NIP96_SERVER_LIST_KIND = 10096;
export const useUserServers = (): Server[] => {
const { user } = useNDK();
const { user } = useNDK();
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
const blossomServerListEvent = useEvent(
@ -30,25 +33,49 @@ export const useUserServers = (): Server[] => {
{ disable: !pubkey }
);
const servers = useMemo((): Server[] => {
const serverUrls = [
...(blossomServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => ({
url: s.toLocaleLowerCase().replace(/\/$/, ''),
const blossomServers = useMemo((): Server[] => {
return (blossomServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => {
const url = s.toLocaleLowerCase().replace(/\/$/, '');
return {
url,
name: url.replace(/https?:\/\//, ''),
type: 'blossom' as ServerType,
})),
/* ...(nip96ServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => ({
url: s.toLocaleLowerCase().replace(/\/$/, ''),
};
});
}, [blossomServerListEvent]);
const nip96Servers = useMemo((): Server[] => {
return [
/*...(nip96ServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => {
const url = s.toLocaleLowerCase().replace(/\/$/, '');
return {
url,
name: url.replace(/https?:\/\//, ''),
type: 'nip96' as ServerType,
})),*/
};
}),*/ {
url: 'https://nostrcheck.me',
name: 'nostrcheck.me',
type: 'nip96' as ServerType,
},
];
}, [nip96ServerListEvent]);
return serverUrls.map(s => ({
type: s.type,
name: s.url.replace(/https?:\/\//, ''),
url: s.url,
}));
}, [blossomServerListEvent, nip96ServerListEvent]);
const nip96InfoQueries = useQueries({
queries: nip96Servers.map(server => ({
queryKey: ['nip96info', server.url],
queryFn: async () => await fetchNip96ServerConfig(server.url),
})),
});
const servers = useMemo((): Server[] => {
return [
...blossomServers,
...nip96Servers.map((server, index) => ({ ...server, nip96: nip96InfoQueries[index].data })),
];
}, [blossomServers, nip96Servers, nip96InfoQueries]);
// console.log(servers);
return servers;
};