feat: upload nostr.build with NIP-96

This commit is contained in:
Shusui MOYATANI
2024-07-07 21:27:42 +09:00
parent ef120f531d
commit 117e20b37e
3 changed files with 122 additions and 80 deletions

View File

@@ -16,10 +16,11 @@ import useEmojiComplete from '@/hooks/useEmojiComplete';
import { useTranslation } from '@/i18n/useTranslation';
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
import { textNote } from '@/nostr/event';
import Tags from '@/nostr/event/Tags';
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
import { uploadNostrBuild } from '@/utils/imageUpload';
// import usePersistStatus from '@/hooks/usePersistStatus';
type NotePostFormProps = {
@@ -149,23 +150,47 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const uploadFilesMutation = createMutation(() => ({
mutationKey: ['uploadFiles'] as const,
mutationFn: async (files: File[]) => {
const uploadResults = await uploadFiles(uploadNostrBuild)(files);
const failed: File[] = [];
const uploadResults = await uploadNostrBuild(files);
const urls: string[] = [];
const failed: [File, string][] = [];
uploadResults.forEach((result, i) => {
if (result.status === 'fulfilled') {
appendText(result.value.imageUrl);
resizeTextArea();
} else {
failed.push(files[i]);
}
const { status, nip94_event: nip94Event } = result.value;
if ((status === 'success' || status === 'processing') && nip94Event != null) {
// TODO support delayed processing
const tags = new Tags(nip94Event.tags);
const urlTag = tags.findFirstTagByName('url');
if (urlTag == null || urlTag.length < 2) {
failed.push([files[i], 'url not found']);
return;
}
const url = urlTag[1];
urls.push(url);
} else if (result.value.status === 'error') {
failed.push([files[i], result.value.message]);
}
} else if (result.reason instanceof Error) {
failed.push([files[i], result.reason.message]);
} else {
failed.push([files[i], 'failed']);
}
});
if (urls.length > 0) {
appendText(urls.join(' '));
resizeTextArea();
}
if (failed.length > 0) {
const filenames = failed.map((f) => f.name).join(', ');
const filenames = failed.map(([f, reason]) => `${f.name}: ${reason}`).join('\n');
window.alert(i18n.t('posting.failedToUploadFile', { filenames }));
}
},
onError: (err) => {
console.error(err);
window.alert(err);
},
}));
const taggedPubkeysWithoutMe = createMemo(() => {
@@ -299,8 +324,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
if (uploadFilesMutation.isPending) return;
// if (!ensureUploaderAgreement()) return;
const files = [...(ev.currentTarget.files ?? [])];
uploadFilesMutation.mutate(files);
const {files} = ev.currentTarget;
if (files == null || files.length === 0) return;
uploadFilesMutation.mutate([...files]);
// eslint-disable-next-line no-param-reassign
ev.currentTarget.value = '';
};
@@ -309,17 +337,21 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
ev.preventDefault();
if (uploadFilesMutation.isPending) return;
// if (!ensureUploaderAgreement()) return;
const files = [...(ev?.dataTransfer?.files ?? [])];
uploadFilesMutation.mutate(files);
const files = ev?.dataTransfer?.files;
if (files == null || files.length === 0) return;
uploadFilesMutation.mutate([...files]);
};
const handlePaste: JSX.EventHandler<HTMLTextAreaElement, ClipboardEvent> = (ev) => {
if (uploadFilesMutation.isPending) return;
const items = [...(ev?.clipboardData?.items ?? [])];
const items = ev?.clipboardData?.items;
if (items == null || items.length === 0) return;
const files: File[] = [];
items.forEach((item) => {
Array.from(items).forEach((item) => {
if (item.kind === 'file') {
ev.preventDefault();
const file = item.getAsFile();

View File

@@ -1,12 +1,12 @@
// The original code was published under the public domain license (CC0-1.0).
// https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55
import { type UnsignedEvent, type Event as NostrEvent } from 'nostr-tools/pure';
import { type EventTemplate, type Event as NostrEvent } from 'nostr-tools/pure';
type NostrAPI = {
/** returns a public key as hex */
getPublicKey(): Promise<string>;
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
signEvent(event: UnsignedEvent): Promise<NostrEvent>;
signEvent(event: EventTemplate): Promise<NostrEvent>;
// Optional

View File

@@ -1,74 +1,84 @@
export type UploadResult = {
imageUrl: string;
};
import { readServerConfig, type FileUploadResponse } from 'nostr-tools/nip96';
import { getToken } from 'nostr-tools/nip98';
import { type EventTemplate } from 'nostr-tools/pure';
export type Uploader = {
export type ServerDefinition = {
id: string;
name: string;
tos: string;
upload: (file: File) => Promise<UploadResult>;
upload: (files: File[]) => Promise<PromiseSettledResult<FileUploadResponse>[]>;
};
export type NostrBuildResult = {
status: 'success' | 'error';
message: string;
data: {
input_name: string;
name: string;
url: string;
thumbnail?: string;
blurhash?: string;
sha256: string;
type: 'image' | 'video' | 'profile' | 'other';
mime: string;
size: number;
metadata?: Record<string, string>;
dimensions?: {
width: number;
height: number;
};
responsive?: {
'240p': string;
'360p': string;
'480p': string;
'720p': string;
'1080p': string;
};
}[];
export type UploadFileStorageProps = {
files: File[];
serverUrl: string;
};
export const uploadNostrBuild = async (blob: Blob): Promise<UploadResult> => {
const form = new FormData();
form.set('file', blob);
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
headers: {
Accept: 'application/json',
},
mode: 'cors',
body: form,
});
if (!res.ok) throw new Error('failed to post image: status code was not 2xx');
const result = (await res.json()) as NostrBuildResult;
if (result.status !== 'success') throw new Error(`failed to post image: ${result.message}`);
return { imageUrl: result.data[0].url };
export type UploadFileProps = {
file: File;
authorizationHeader?: string;
media_type?: 'avatar' | 'banner';
};
export const uploaders = {
export const getAuthorizationHeader = (uploadApiUrl: string): Promise<string> => {
const windowNostr = window.nostr;
if (windowNostr == null) throw new Error('NIP-07 implementation not found');
const method = 'POST';
const signEvent = (ev: EventTemplate) => windowNostr.signEvent(ev);
const includeAuthorizationScheme = true;
return getToken(uploadApiUrl, method, signEvent, includeAuthorizationScheme);
};
export const uploadFile = async (
uploadApiUrl: string,
props: UploadFileProps,
): Promise<FileUploadResponse> => {
const body = new FormData();
body.set('file', props.file);
body.set('content_type', props.file.type);
body.set('size', props.file.size.toString(10));
const headers = new Headers();
if (props.authorizationHeader != null) {
headers.set('Authorization', props.authorizationHeader);
}
const response = await fetch(uploadApiUrl, { method: 'POST', headers, body });
// TODO validate event
const json = (await response.json()) as FileUploadResponse;
if (!response.ok) {
throw new Error(`failed to upload: ${response.status} ${json.message}`);
}
return json;
};
export const uploadFileStorage = async (
props: UploadFileStorageProps,
): Promise<PromiseSettledResult<FileUploadResponse>[]> => {
const serverConfig = await readServerConfig(props.serverUrl);
if (serverConfig.api_url.length === 0 || serverConfig.delegated_to_url != null) {
throw new Error('delegated_to_url is not supported');
}
const uploadApiUrl = serverConfig.api_url;
const authorizationHeader = await getAuthorizationHeader(uploadApiUrl);
const promises = Array.from(props.files).map(async (file) => uploadFile(uploadApiUrl, { authorizationHeader, file }));
return Promise.allSettled(promises);
};
export const uploadNostrBuild = (files: File[]) =>
uploadFileStorage({ files, serverUrl: 'https://nostr.build' });
export const servers: Record<string, ServerDefinition> = {
nostrBuild: {
id: 'nostrBuild',
name: 'nostr.build',
tos: 'https://nostr.build/tos/',
upload: uploadNostrBuild,
} satisfies Uploader,
} as const;
export type UploaderIds = keyof typeof uploaders;
export const uploadFiles =
<T>(uploadFn: (file: Blob) => Promise<T>) =>
(files: File[]): Promise<PromiseSettledResult<Awaited<T>>[]> =>
Promise.allSettled(files.map((file) => uploadFn(file)));
},
};