feat: Added nip96 upload

This commit is contained in:
florian 2024-07-20 21:56:46 +02:00
parent 2a10b83c73
commit 74b0f086f9
8 changed files with 247 additions and 95 deletions

View File

@ -7,6 +7,8 @@ import BlobList from '../components/BlobList/BlobList';
import { useServerInfo } from '../utils/useServerInfo';
import { ServerList } from '../components/ServerList/ServerList';
import { useNavigate } from 'react-router-dom';
import { Server } from '../utils/useUserServers';
import { deleteNip96File } from '../utils/nip96';
/* BOUQUET Blob Organizer Update Quality Use Enhancement Tool */
@ -30,12 +32,16 @@ function Home() {
const queryClient = useQueryClient();
const deleteBlob = useMutation({
mutationFn: async ({ serverUrl, hash }: { serverName: string; serverUrl: string; hash: string }) => {
const deleteAuth = await BlossomClient.getDeleteAuth(hash, signEventTemplate, 'Delete Blob');
return BlossomClient.deleteBlob(serverUrl, hash, deleteAuth);
mutationFn: async ({ server, hash }: { server: Server; hash: string }) => {
if (server.type === 'blossom') {
const deleteAuth = await BlossomClient.getDeleteAuth(hash, signEventTemplate, 'Delete Blob');
return BlossomClient.deleteBlob(server.url, hash, deleteAuth);
} else {
return await deleteNip96File(server, hash, signEventTemplate);
}
},
onSuccess(_, variables) {
queryClient.setQueryData(['blobs', variables.serverName], (oldData: BlobDescriptor[]) =>
queryClient.setQueryData(['blobs', variables.server.name], (oldData: BlobDescriptor[]) =>
oldData ? oldData.filter(b => b.sha256 !== variables.hash) : oldData
);
// console.log({ key: ['blobs', variables.serverName] });
@ -73,8 +79,7 @@ function Home() {
onDelete={async blobs => {
for (const blob of blobs) {
await deleteBlob.mutateAsync({
serverName: serverInfo[selectedServer].name,
serverUrl: serverInfo[selectedServer].url,
server: serverInfo[selectedServer],
hash: blob.sha256,
});
}

View File

@ -16,7 +16,7 @@ import BlobList from '../components/BlobList/BlobList';
import './Transfer.css';
import { useNavigate, useParams } from 'react-router-dom';
import ProgressBar from '../components/ProgressBar/ProgressBar';
import { downloadBlob, uploadBlob } from '../utils/transfer';
import { downloadBlossomBlob, uploadBlossomBlob } from '../utils/blossom';
type TransferStatus = {
[key: string]: {
@ -82,7 +82,7 @@ export const Transfer = () => {
},
}));
const result = await downloadBlob(`${serverInfo[sourceServer].url}/${b.sha256}`, progressEvent => {
const result = await downloadBlossomBlob(`${serverInfo[sourceServer].url}/${b.sha256}`, progressEvent => {
setTransferLog(ts => ({
...ts,
[b.sha256]: {
@ -111,7 +111,7 @@ export const Transfer = () => {
const file = new File([result.data], b.sha256, { type: b.type, lastModified: b.created });
await uploadBlob(serverInfo[targetServer].url, file, signEventTemplate, progressEvent => {
await uploadBlossomBlob(serverInfo[targetServer].url, file, signEventTemplate, progressEvent => {
setTransferLog(ts => ({
...ts,
[b.sha256]: {

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';
@ -12,6 +12,7 @@ import { resizeImage } from '../utils/resize';
import { getBlurhashAndSizeFromFile } from '../utils/blur';
import UploadFileSelection, { ResizeOptions, TransferStats } from '../components/UploadFileSelection';
import UploadProgress from '../components/UploadProgress';
import { uploadNip96File } from '../utils/nip96';
function Upload() {
const servers = useUserServers();
@ -116,17 +117,21 @@ function Upload() {
console.log(`Created auth event in ${Date.now() - authStartTime} ms`, uploadAuth);
try {
const newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => {
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
});
let newBlob: BlobDescriptor;
if (server.type == 'blossom') {
newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => {
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
});
} else {
newBlob = await uploadNip96File(server, file, '', signEventTemplate);
}
serverTransferred += file.size;
setTransfers(ut => ({
...ut,
@ -149,7 +154,7 @@ function Upload() {
// Record error in transfer log
setTransfers(ut => ({
...ut,
[server.name]: { ...ut[server.name], error: `${axiosError.message} / ${response.message}` },
[server.name]: { ...ut[server.name], error: `${axiosError.message} / ${response?.message}` },
}));
}
}
@ -214,7 +219,6 @@ function Upload() {
const [transfersInitialized, setTransfersInitialized] = useState(false);
useEffect(() => {
if (servers.length > 0 && !transfersInitialized) {
clearTransfers();
@ -234,7 +238,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.filter(s => s.type == 'blossom')}
servers={servers}
transfers={transfers}
setTransfers={setTransfers}
cleanPrivateData={cleanPrivateData}

View File

@ -1,3 +1,4 @@
import axios, { AxiosProgressEvent } from 'axios';
import { BlobDescriptor, BlossomClient, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import dayjs from 'dayjs';
@ -30,3 +31,65 @@ export async function fetchBlossomList(
// 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() }));
}
export const uploadBlossomBlob = async (
server: string,
file: File,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
) => {
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
const headers = {
Accept: 'application/json',
'Content-Type': file.type,
};
const res = await axios.put<BlobDescriptor>(`${server}/upload`, file, {
headers: uploadAuth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(uploadAuth) } : headers,
onUploadProgress,
});
return res.data;
};
export const downloadBlossomBlob = async (
url: string,
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
) => {
const response = await axios.get(url, {
responseType: 'blob',
onDownloadProgress,
});
return { data: response.data, type: response.headers['Content-Type']?.toString() };
};
export const mirrordBlossomBlob = async (
targetServer: string,
sourceUrl: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
) => {
console.log({ sourceUrl });
const hash = extractHashFromUrl(sourceUrl);
if (!hash) throw 'The soureUrl does not contain a blossom hash.';
const blossomClient = new BlossomClient(targetServer, signEventTemplate);
const mirrorAuth = await blossomClient.getMirrorAuth(hash, 'Upload Blob');
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
const res = await axios.put<BlobDescriptor>(
`${targetServer}/mirror`,
{ url: sourceUrl },
{
headers: mirrorAuth
? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(mirrorAuth) }
: headers,
}
);
return res.data;
};

View File

@ -45,6 +45,17 @@ export async function fetchNip96ServerConfig(serverUrl: string): Promise<Nip96Se
return response.json();
}
type Nip96UploadResult = {
status: 'success' | 'error';
message: string;
processing_url?: string;
nip94_event?: {
tags: string[][];
content: string;
};
content: string;
};
const tenMinutesFromNow = () => dayjs().unix() + 10 * 60;
async function createNip98UploadAuthToken(
@ -67,6 +78,8 @@ async function createNip98UploadAuthToken(
return btoa(JSON.stringify(signedEvent));
}
const getValueByTag = (tags: string[][] | undefined, t: string) => tags && tags.find(v => v[0] == t)?.[1];
export async function fetchNip96List(
server: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
@ -82,8 +95,6 @@ export async function fetchNip96List(
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 =>
({
@ -96,3 +107,132 @@ export async function fetchNip96List(
}) as BlobDescriptor
);
}
/*
Upload
POST $api_url as multipart/form-data.
AUTH required
List of form fields:
file: REQUIRED the file to upload
caption: RECOMMENDED loose description;
expiration: UNIX timestamp in seconds. Empty string if file should be stored forever. The server isn't required to honor this.
size: File byte size. This is just a value the server can use to reject early if the file size exceeds the server limits.
alt: RECOMMENDED strict description text for visibility-impaired users.
media_type: "avatar" or "banner". Informs the server if the file will be used as an avatar or banner. If absent, the server will interpret it as a normal upload, without special treatment.
content_type: mime type such as "image/jpeg". This is just a value the server can use to reject early if the mime type isn't supported.
no_transform: "true" asks server not to transform the file and serve the uploaded file as is, may be rejected.
Others custom form data fields may be used depending on specific server support. The server isn't required to store any metadata sent by clients.
The filename embedded in the file may not be honored by the server, which could internally store just the SHA-256 hash value as the file name, ignoring extra metadata. The hash is enough to uniquely identify a file, that's why it will be used on the download and delete routes.
The server MUST link the user's pubkey string as the owner of the file so to later allow them to delete the file.
no_transform can be used to replicate a file to multiple servers for redundancy, clients can use the server list to find alternative servers which might contain the same file. When uploading a file and requesting no_transform clients should check that the hash matches in the response in order to detect if the file was modified.
*/
export async function uploadNip96File(
server: Server,
file: File,
caption: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
): Promise<BlobDescriptor> {
const formData = new FormData();
formData.append('file', file);
formData.append('caption', caption || ''); // RECOMMENDED TODO ADD
//formData.append('expiration', server.expiration || '');
formData.append('size', file.size.toString());
//formData.append('alt', server.alt || ''); // RECOMMENDED
//formData.append('media_type', // avatar / banner
formData.append('content_type', file.type || '');
formData.append('no_transform', 'true');
const baseUrl = server.nip96?.api_url || server.url;
const response = await fetch(baseUrl, {
method: 'POST',
headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(baseUrl, 'POST', signEventTemplate)}` },
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload file: ${response.statusText}`);
}
const result = (await response.json()) as Nip96UploadResult;
console.log(result);
const x = getValueByTag(result.nip94_event?.tags, 'x') || getValueByTag(result.nip94_event?.tags, 'ox');
if (!x) {
throw new Error('Failed to upload file: no sha256');
}
return {
created: dayjs().unix(), // todo fix
uploaded: dayjs().unix(), // todo fix
type: getValueByTag(result.nip94_event?.tags, 'm'),
sha256: x,
size: parseInt(getValueByTag(result.nip94_event?.tags, 'size') || '0', 10),
url:
getValueByTag(result.nip94_event?.tags, 'url') || baseUrl + '/' + getValueByTag(result.nip94_event?.tags, 'ox'),
};
}
/*
Deletion
DELETE $api_url/<sha256-hash>(.ext)
AUTH required
Note that the /<sha256-hash> part is from the original file, not from the transformed file if the uploaded file went through any server transformation.
The extension is optional as the file hash is the only needed file identification.
The server should reject deletes from users other than the original uploader with the appropriate http response code (403 Forbidden).
It should be noted that more than one user may have uploaded the same file (with the same hash). In this case, a delete must not really delete the file but just remove the user's pubkey from the file owners list (considering the server keeps just one copy of the same file, because multiple uploads of the same file results in the same file hash).
The successful response is a 200 OK one with just basic JSON fields:
{
status: "success",
message: "File deleted."
}
*/
export async function deleteNip96File(
server: Server,
sha256: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
) {
const baseUrl = server.nip96?.api_url || server.url;
const url = `${baseUrl}/${sha256}`;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
const auth = await createNip98UploadAuthToken(url, 'DELETE', signEventTemplate);
const response = await fetch(url, {
method: 'DELETE',
headers: {
...headers,
authorization: `Nostr ${auth}`,
},
});
if (!response.ok) {
throw new Error(`Failed to delete file: ${response.statusText}`);
}
const result = await response.json();
if (result.status !== 'success') {
throw new Error(`Failed to delete file: ${result.message}`);
}
return result.message;
}

View File

@ -1,65 +1,6 @@
import axios, { AxiosProgressEvent } from 'axios';
import { BlobDescriptor, BlossomClient, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { extractHashFromUrl } from './blossom';
export const uploadBlob = async (
server: string,
file: File,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
) => {
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
const headers = {
Accept: 'application/json',
'Content-Type': file.type,
};
const res = await axios.put<BlobDescriptor>(`${server}/upload`, file, {
headers: uploadAuth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(uploadAuth) } : headers,
onUploadProgress,
});
return res.data;
};
export const downloadBlob = async (url: string, onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void) => {
const response = await axios.get(url, {
responseType: 'blob',
onDownloadProgress,
});
return { data: response.data, type: response.headers['Content-Type']?.toString() };
};
export const mirrordBlob = async (
targetServer: string,
sourceUrl: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
) => {
console.log({ sourceUrl });
const hash = extractHashFromUrl(sourceUrl);
if (!hash) throw 'The soureUrl does not contain a blossom hash.';
const blossomClient = new BlossomClient(targetServer, signEventTemplate);
const mirrorAuth = await blossomClient.getMirrorAuth(hash, 'Upload Blob');
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
const res = await axios.put<BlobDescriptor>(
`${targetServer}/mirror`,
{ url: sourceUrl },
{
headers: mirrorAuth
? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(mirrorAuth) }
: headers,
}
);
return res.data;
};
import { AxiosProgressEvent } from 'axios';
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { downloadBlossomBlob, mirrordBlossomBlob, uploadBlossomBlob } from './blossom';
async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
const response = await fetch(blobUrl);
@ -68,6 +9,7 @@ async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
return new File([blob], fileName, fileOptions);
}
// TODO support nip96
export const transferBlob = async (
sourceUrl: string,
targetServer: string,
@ -78,18 +20,18 @@ export const transferBlob = async (
if (sourceUrl.startsWith('blob:')) {
const file = await blobUrlToFile(sourceUrl, 'cover.jpg');
return await uploadBlob(targetServer, file, signEventTemplate, onUploadProgress);
return await uploadBlossomBlob(targetServer, file, signEventTemplate, onUploadProgress);
} else {
const blob = await mirrordBlob(targetServer, sourceUrl, signEventTemplate);
const blob = await mirrordBlossomBlob(targetServer, sourceUrl, signEventTemplate);
if (blob) return blob;
console.log('Mirror failed. Using download + upload instead.');
const result = await downloadBlob(sourceUrl, onUploadProgress);
const result = await downloadBlossomBlob(sourceUrl, onUploadProgress);
const fileName = sourceUrl.replace(/.*\//, '');
const file = new File([result.data], fileName, { type: result.type, lastModified: new Date().getTime() });
return await uploadBlob(targetServer, file, signEventTemplate, onUploadProgress);
return await uploadBlossomBlob(targetServer, file, signEventTemplate, onUploadProgress);
}
};

View File

@ -1,10 +1,9 @@
import { useMemo, useState } from 'react';
import { useQueries } from '@tanstack/react-query';
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
import { BlobDescriptor } from 'blossom-client-sdk';
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';

View File

@ -19,7 +19,6 @@ export type Server = {
const USER_NIP96_SERVER_LIST_KIND = 10096;
export const useUserServers = (): Server[] => {
const { user } = useNDK();
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type