diff --git a/kinds.ts b/kinds.ts index f5b3113..8594400 100644 --- a/kinds.ts +++ b/kinds.ts @@ -71,6 +71,7 @@ export const BlockedRelaysList = 10006 export const SearchRelaysList = 10007 export const InterestsList = 10015 export const UserEmojiList = 10030 +export const FileServerPreference = 10096 export const NWCWalletInfo = 13194 export const LightningPubRPC = 21000 export const ClientAuth = 22242 diff --git a/nip96.test.ts b/nip96.test.ts new file mode 100644 index 0000000..7830abd --- /dev/null +++ b/nip96.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, it } from 'bun:test' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' + +import { FileServerPreference } from './kinds.ts' +import { + calculateFileHash, + checkFileProcessingStatus, + deleteFile, + generateDownloadUrl, + generateFSPEventTemplate, + readServerConfig, + uploadFile, + validateDelayedProcessingResponse, + validateFileUploadResponse, + validateServerConfiguration, + type DelayedProcessingResponse, + type FileUploadResponse, + type ServerConfiguration, +} from './nip96.ts' + +describe('validateServerConfiguration', () => { + it("should return true if 'api_url' is valid URL", () => { + const config: ServerConfiguration = { + api_url: 'http://example.com', + } + + expect(validateServerConfiguration(config)).toBe(true) + }) + + it("should return false if 'api_url' is empty", () => { + const config: ServerConfiguration = { + api_url: '', + } + + expect(validateServerConfiguration(config)).toBe(false) + }) + + it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => { + const config: ServerConfiguration = { + api_url: 'http://example.com', + delegated_to_url: 'http://example.com', + } + + expect(validateServerConfiguration(config)).toBe(false) + }) +}) + +describe('readServerConfig', () => { + it('should return a valid ServerConfiguration object', async () => { + // setup mock server + const HTTPROUTE = '/.well-known/nostr/nip96.json' as const + const validConfig: ServerConfiguration = { + api_url: 'http://example.com', + } + const handler = http.get(`http://example.com${HTTPROUTE}`, () => { + return HttpResponse.json(validConfig) + }) + const server = setupServer(handler) + server.listen() + + const result = await readServerConfig('http://example.com/') + + expect(result).toEqual(validConfig) + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if response is not valid', async () => { + // setup mock server + const HTTPROUTE = '/.well-known/nostr/nip96.json' as const + const invalidConfig = { + // missing api_url + } + const handler = http.get(`http://example.com${HTTPROUTE}`, () => { + return HttpResponse.json(invalidConfig) + }) + const server = setupServer(handler) + server.listen() + + expect(readServerConfig('http://example.com/')).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if response is not proper json', async () => { + // setup mock server + const HTTPROUTE = '/.well-known/nostr/nip96.json' as const + const handler = http.get(`http://example.com${HTTPROUTE}`, () => { + return HttpResponse.json(null) + }) + const server = setupServer(handler) + server.listen() + + expect(readServerConfig('http://example.com/')).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if response status is not 200', async () => { + // setup mock server + const HTTPROUTE = '/.well-known/nostr/nip96.json' as const + const handler = http.get(`http://example.com${HTTPROUTE}`, () => { + return new HttpResponse(null, { status: 400 }) + }) + const server = setupServer(handler) + server.listen() + + expect(readServerConfig('http://example.com/')).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if input url is not valid', async () => { + expect(readServerConfig('invalid-url')).rejects.toThrow() + }) +}) + +describe('validateFileUploadResponse', () => { + it('should return true if response is valid', () => { + const mockResponse: FileUploadResponse = { + status: 'error', + message: 'File uploaded failed', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(true) + }) + + it('should return false if status is undefined', () => { + const mockResponse: Omit = { + // status: 'error', + message: 'File upload failed', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if message is undefined', () => { + const mockResponse: Omit = { + status: 'error', + // message: 'message', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if status is not valid', () => { + const mockResponse = { + status: 'something else', + message: 'message', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if "message" is not a string', () => { + const mockResponse = { + status: 'error', + message: 123, + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if status is "processing" and "processing_url" is undefined', () => { + const mockResponse = { + status: 'processing', + message: 'message', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if status is "processing" and "processing_url" is not a string', () => { + const mockResponse = { + status: 'processing', + message: 'message', + processing_url: 123, + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if status is "success" and "nip94_event" is undefined', () => { + const mockResponse = { + status: 'success', + message: 'message', + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if "nip94_event" tags are invalid', () => { + const mockResponse = { + status: 'success', + message: 'message', + nip94_event: { + tags: [ + // missing url + ['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'], + ], + }, + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return false if "nip94_event" tags are empty', () => { + const mockResponse = { + status: 'success', + message: 'message', + nip94_event: { + tags: [], + }, + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(false) + }) + + it('should return true if "nip94_event" tags are valid', () => { + const mockResponse = { + status: 'success', + message: 'message', + nip94_event: { + tags: [ + ['url', 'http://example.com'], + ['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'], + ], + }, + } + + const result = validateFileUploadResponse(mockResponse) + + expect(result).toBe(true) + }) +}) + +describe('uploadFile', () => { + it('should return a valid FileUploadResponse object', async () => { + // setup mock server + const validFileUploadResponse: FileUploadResponse = { + status: 'success', + message: 'message', + nip94_event: { + content: '', + tags: [ + ['url', 'http://example.com'], + ['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'], + ], + }, + } + const handler = http.post('http://example.com/upload', () => { + return HttpResponse.json(validFileUploadResponse, { status: 200 }) + }) + const server = setupServer(handler) + server.listen() + + const file = new File(['hello world'], 'hello.txt') + const serverUploadUrl = 'http://example.com/upload' + const nip98AuthorizationHeader = 'Nostr abcabc' + + const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader) + + expect(result).toEqual(validFileUploadResponse) + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if response status is not ok', async () => { + // setup mock server + const handler = http.post('http://example.com/upload', () => { + return new HttpResponse(null, { status: 400 }) + }) + const server = setupServer(handler) + server.listen() + + const file = new File(['hello world'], 'hello.txt') + const serverUploadUrl = 'http://example.com/upload' + const nip98AuthorizationHeader = 'Nostr abcabc' + + expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) +}) + +describe('generateDownloadUrl', () => { + it('should generate a download URL without file extension', () => { + const fileHash = 'abc123' + const serverDownloadUrl = 'http://example.com/download' + const expectedUrl = 'http://example.com/download/abc123' + + const result = generateDownloadUrl(fileHash, serverDownloadUrl) + + expect(result).toBe(expectedUrl) + }) + + it('should generate a download URL with file extension', () => { + const fileHash = 'abc123' + const serverDownloadUrl = 'http://example.com/download' + const fileExtension = '.jpg' + const expectedUrl = 'http://example.com/download/abc123.jpg' + + const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension) + + expect(result).toBe(expectedUrl) + }) +}) + +describe('deleteFile', () => { + it('should return a basic json response for successful delete', async () => { + // setup mock server + const handler = http.delete('http://example.com/delete/abc123', () => { + return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 }) + }) + const server = setupServer(handler) + server.listen() + + const fileHash = 'abc123' + const serverDeleteUrl = 'http://example.com/delete' + const nip98AuthorizationHeader = 'Nostr abcabc' + + const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader) + + expect(result).toEqual({ status: 'success', message: 'File deleted.' }) + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error for unsuccessful delete', async () => { + // setup mock server + const handler = http.delete('http://example.com/delete/abc123', () => { + return new HttpResponse(null, { status: 400 }) + }) + const server = setupServer(handler) + server.listen() + + const fileHash = 'abc123' + const serverDeleteUrl = 'http://example.com/delete' + const nip98AuthorizationHeader = 'Nostr abcabc' + + expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) +}) + +describe('validateDelayedProcessingResponse', () => { + it('should return false for non-object input', () => { + expect(validateDelayedProcessingResponse('not an object')).toBe(false) + }) + + it('should return false for null input', () => { + expect(validateDelayedProcessingResponse(null)).toBe(false) + }) + + it('should return false for object missing required properties', () => { + const missingStatus: Omit = { + // missing status + message: 'test', + percentage: 50, + } + const missingMessage: Omit = { + status: 'processing', + // missing message + percentage: 50, + } + const missingPercentage: Omit = { + status: 'processing', + message: 'test', + // missing percentage + } + + expect(validateDelayedProcessingResponse(missingStatus)).toBe(false) + expect(validateDelayedProcessingResponse(missingMessage)).toBe(false) + expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false) + }) + + it('should return false for invalid status', () => { + expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false) + }) + + it('should return false for non-string message', () => { + expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false) + }) + + it('should return false for non-number percentage', () => { + expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false) + }) + + it('should return false for percentage out of range', () => { + expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false) + }) + + it('should return true for valid input', () => { + expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true) + }) +}) + +describe('checkFileProcessingStatus', () => { + it('should throw an error if response is not ok', async () => { + // setup mock server + const handler = http.get('http://example.com/status/abc123', () => { + return new HttpResponse(null, { status: 400 }) + }) + const server = setupServer(handler) + server.listen() + + const processingUrl = 'http://example.com/status/abc123' + + expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should throw an error if response is not a valid json', async () => { + // setup mock server + const handler = http.get('http://example.com/status/abc123', () => { + return HttpResponse.text('not a json', { status: 200 }) + }) + const server = setupServer(handler) + server.listen() + + const processingUrl = 'http://example.com/status/abc123' + + expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow() + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should return a valid DelayedProcessingResponse object if response status is 200', async () => { + // setup mock server + const validDelayedProcessingResponse: DelayedProcessingResponse = { + status: 'processing', + message: 'test', + percentage: 50, + } + const handler = http.get('http://example.com/status/abc123', () => { + return HttpResponse.json(validDelayedProcessingResponse, { status: 200 }) + }) + const server = setupServer(handler) + server.listen() + + const processingUrl = 'http://example.com/status/abc123' + + const result = await checkFileProcessingStatus(processingUrl) + + expect(result).toEqual(validDelayedProcessingResponse) + + // cleanup mock server + server.resetHandlers() + server.close() + }) + + it('should return a valid FileUploadResponse object if response status is 201', async () => { + // setup mock server + const validFileUploadResponse: FileUploadResponse = { + status: 'success', + message: 'message', + nip94_event: { + content: '', + tags: [ + ['url', 'http://example.com'], + ['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'], + ], + }, + } + const handler = http.get('http://example.com/status/abc123', () => { + return HttpResponse.json(validFileUploadResponse, { status: 201 }) + }) + const server = setupServer(handler) + server.listen() + + const processingUrl = 'http://example.com/status/abc123' + + const result = await checkFileProcessingStatus(processingUrl) + + expect(result).toEqual(validFileUploadResponse) + + // cleanup mock server + server.resetHandlers() + server.close() + }) +}) + +describe('generateFSPEventTemplate', () => { + it('should generate FSP event template', () => { + const serverUrls = ['http://example.com', 'https://example.org'] + const eventTemplate = generateFSPEventTemplate(serverUrls) + + expect(eventTemplate.kind).toBe(FileServerPreference) + expect(eventTemplate.content).toBe('') + expect(eventTemplate.tags).toEqual([ + ['server', 'http://example.com'], + ['server', 'https://example.org'], + ]) + expect(typeof eventTemplate.created_at).toBe('number') + }) + + it('should filter invalid server URLs', () => { + const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org'] + const eventTemplate = generateFSPEventTemplate(serverUrls) + + expect(eventTemplate.tags).toEqual([ + ['server', 'http://example.com'], + ['server', 'https://example.org'], + ]) + }) + + it('should handle empty server URLs', () => { + const serverUrls: string[] = [] + const eventTemplate = generateFSPEventTemplate(serverUrls) + + expect(eventTemplate.tags).toEqual([]) + }) +}) + +describe('calculateFileHash', () => { + it('should calculate file hash', async () => { + const file = new File(['hello world'], 'hello.txt') + const hash = await calculateFileHash(file) + + expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9') + }) + + it('should calculate file hash with empty file', async () => { + const file = new File([], 'empty.txt') + const hash = await calculateFileHash(file) + + expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) +}) diff --git a/nip96.ts b/nip96.ts new file mode 100644 index 0000000..bdf204f --- /dev/null +++ b/nip96.ts @@ -0,0 +1,587 @@ +import { EventTemplate } from './core' +import { FileServerPreference } from './kinds' + +/** + * Represents the configuration for a server compliant with NIP-96. + */ +export type ServerConfiguration = { + /** + * The base URL from which file upload and deletion operations are served. + * Also used for downloads if "download_url" is not specified. + */ + api_url: string + + /** + * Optional. The base URL from which files are downloaded. + * Used if different from the "api_url". + */ + download_url?: string + + /** + * Optional. URL of another HTTP file storage server's configuration. + * Used by nostr relays to delegate to another server. + * In this case, "api_url" must be an empty string. + */ + delegated_to_url?: string + + /** + * Optional. An array of NIP numbers that this server supports. + */ + supported_nips?: number[] + + /** + * Optional. URL to the server's Terms of Service. + */ + tos_url?: string + + /** + * Optional. An array of MIME types supported by the server. + */ + content_types?: string[] + + /** + * Optional. Defines various storage plans offered by the server. + */ + plans?: { + [planKey: string]: { + /** + * The name of the storage plan. + */ + name: string + + /** + * Optional. Indicates whether NIP-98 is required for uploads in this plan. + */ + is_nip98_required?: boolean + + /** + * Optional. URL to a landing page providing more information about the plan. + */ + url?: string + + /** + * Optional. The maximum file size allowed under this plan, in bytes. + */ + max_byte_size?: number + + /** + * Optional. Defines the range of file expiration in days. + * The first value indicates the minimum expiration time, and the second value indicates the maximum. + * A value of 0 indicates no expiration. + */ + file_expiration?: [number, number] + + /** + * Optional. Specifies the types of media transformations supported under this plan. + * Currently, only image transformations are considered. + */ + media_transformations?: { + /** + * Optional. An array of supported image transformation types. + */ + image?: string[] + } + } + } +} + +/** + * Represents the optional form data fields for file upload in accordance with NIP-96. + */ +export type OptionalFormDataFields = { + /** + * Specifies the desired expiration time of the file on the server. + * It should be a string representing a UNIX timestamp in seconds. + * An empty string indicates that the file should be stored indefinitely. + */ + expiration?: string + + /** + * Indicates the size of the file in bytes. + * This field can be used by the server to pre-validate the file size before processing the upload. + */ + size?: string + + /** + * Provides a strict description of the file for accessibility purposes, + * particularly useful for visibility-impaired users. + */ + alt?: string + + /** + * A loose, more descriptive caption for the file. + * This can be used for additional context or commentary about the file. + */ + caption?: string + + /** + * Specifies the intended use of the file. + * Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner. + * Absence of this field suggests standard file upload without special treatment. + */ + media_type?: 'avatar' | 'banner' + + /** + * The MIME type of the file being uploaded. + * This can be used for early rejection by the server if the file type isn't supported. + */ + content_type?: string + + /** + * Other custom form data fields. + */ + [key: string]: string | undefined +} + +/** + * Type representing the response from a NIP-96 compliant server after a file upload request. + */ +export type FileUploadResponse = { + /** + * The status of the upload request. + * - 'success': Indicates the file was successfully uploaded. + * - 'error': Indicates there was an error in the upload process. + * - 'processing': Indicates the file is still being processed (used in cases of delayed processing). + */ + status: 'success' | 'error' | 'processing' + + /** + * A message provided by the server, which could be a success message, error description, or processing status. + */ + message: string + + /** + * Optional. A URL provided by the server where the upload processing status can be checked. + * This is relevant in cases where the file upload involves delayed processing. + */ + processing_url?: string + + /** + * Optional. An event object conforming to NIP-94, which includes details about the uploaded file. + * This object is typically provided in the response for a successful upload and contains + * essential information such as the download URL and file metadata. + */ + nip94_event?: { + /** + * A collection of key-value pairs (tags) providing metadata about the uploaded file. + * Standard tags include: + * - 'url': The URL where the file can be accessed. + * - 'ox': The SHA-256 hash of the original file before any server-side transformations. + * Additional optional tags might include file dimensions, MIME type, etc. + */ + tags: Array<[string, string]> + + /** + * A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure. + */ + content: string + } +} + +/** + * Type representing the response from a NIP-96 compliant server after a delayed processing request. + */ +export type DelayedProcessingResponse = { + /** + * The status of the delayed processing request. + * - 'processing': Indicates the file is still being processed. + * - 'error': Indicates there was an error in the processing. + */ + status: 'processing' | 'error' + + /** + * A message provided by the server, which could be a success message or error description. + */ + message: string + + /** + * The percentage of the file that has been processed. This is a number between 0 and 100. + */ + percentage: number +} + +/** + * Validates the server configuration. + * + * @param config - The server configuration object. + * @returns True if the configuration is valid, false otherwise. + */ +export function validateServerConfiguration(config: ServerConfiguration): boolean { + if (Boolean(config.api_url) == false) { + return false + } + + if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) { + return false + } + + return true +} + +/** + * Fetches, parses, and validates the server configuration from the given URL. + * + * @param serverUrl The URL of the server. + * @returns The server configuration, or an error if the configuration could not be fetched or parsed. + */ +export async function readServerConfig(serverUrl: string): Promise { + const HTTPROUTE = '/.well-known/nostr/nip96.json' as const + let fetchUrl = '' + + try { + const { origin } = new URL(serverUrl) + fetchUrl = origin + HTTPROUTE + } catch (error) { + throw new Error('Invalid URL') + } + + try { + const response = await fetch(fetchUrl) + + if (!response.ok) { + throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data) { + throw new Error('No data') + } + + if (!validateServerConfiguration(data)) { + throw new Error('Invalid configuration data') + } + + return data + } catch (_) { + throw new Error(`Error fetching.`) + } +} + +/** + * Validates if the given object is a valid FileUploadResponse. + * + * @param response - The object to validate. + * @returns true if the object is a valid FileUploadResponse, otherwise false. + */ +export function validateFileUploadResponse(response: any): response is FileUploadResponse { + if (typeof response !== 'object' || response === null) return false + + if (!response.status || !response.message) { + return false + } + + if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') { + return false + } + + if (typeof response.message !== 'string') { + return false + } + + if (response.status === 'processing' && !response.processing_url) { + return false + } + + if (response.processing_url) { + if (typeof response.processing_url !== 'string') { + return false + } + } + + if (response.status === 'success' && !response.nip94_event) { + return false + } + + if (response.nip94_event) { + if ( + !response.nip94_event.tags || + !Array.isArray(response.nip94_event.tags) || + response.nip94_event.tags.length === 0 + ) { + return false + } + + for (const tag of response.nip94_event.tags) { + if (!Array.isArray(tag) || tag.length !== 2) return false + + if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false + } + + if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) { + return false + } + + if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) { + return false + } + } + + return true +} + +/** + * Uploads a file to a NIP-96 compliant server. + * + * @param file - The file to be uploaded. + * @param serverApiUrl - The API URL of the server, retrieved from the server's configuration. + * @param nip98AuthorizationHeader - The authorization header from NIP-98. + * @param optionalFormDataFields - Optional form data fields. + * @returns A promise that resolves to the server's response. + */ +export async function uploadFile( + file: File, + serverApiUrl: string, + nip98AuthorizationHeader: string, + optionalFormDataFields?: OptionalFormDataFields, +): Promise { + // Create FormData object + const formData = new FormData() + + // Append the authorization header to HTML Form Data + formData.append('Authorization', nip98AuthorizationHeader) + + // Append optional fields to FormData + optionalFormDataFields && + Object.entries(optionalFormDataFields).forEach(([key, value]) => { + if (value) { + formData.append(key, value) + } + }) + + // Append the file to FormData as the last field + formData.append('file', file) + + // Make the POST request to the server + const response = await fetch(serverApiUrl, { + method: 'POST', + headers: { + Authorization: nip98AuthorizationHeader, + 'Content-Type': 'multipart/form-data', + }, + body: formData, + }) + + if (response.ok === false) { + // 413 Payload Too Large + if (response.status === 413) { + throw new Error('File too large!') + } + + // 400 Bad Request + if (response.status === 400) { + throw new Error('Bad request! Some fields are missing or invalid!') + } + + // 403 Forbidden + if (response.status === 403) { + throw new Error('Forbidden! Payload tag does not match the requested file!') + } + + // 402 Payment Required + if (response.status === 402) { + throw new Error('Payment required!') + } + } + + try { + const parsedResponse = await response.json() + + if (!validateFileUploadResponse(parsedResponse)) { + throw new Error('Invalid response from the server!') + } + + return parsedResponse + } catch (error) { + throw new Error('Error parsing JSON response!') + } +} + +/** + * Generates the URL for downloading a file from a NIP-96 compliant server. + * + * @param fileHash - The SHA-256 hash of the original file. + * @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration. + * @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png'). + * @returns A string representing the complete URL to download the file. + * + */ +export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string { + // Construct the base download URL using the file hash + let downloadUrl = `${serverDownloadUrl}/${fileHash}` + + // Append the file extension if provided + if (fileExtension) { + downloadUrl += fileExtension + } + + return downloadUrl +} + +/** + * Sends a request to delete a file from a NIP-96 compliant server. + * + * @param fileHash - The SHA-256 hash of the original file. + * @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration. + * @param nip98AuthorizationHeader - The authorization header from NIP-98. + * @returns A promise that resolves to the server's response to the deletion request. + * + */ +export async function deleteFile( + fileHash: string, + serverApiUrl: string, + nip98AuthorizationHeader: string, +): Promise { + // make sure the serverApiUrl ends with a slash + if (!serverApiUrl.endsWith('/')) { + serverApiUrl += '/' + } + + // Construct the URL for the delete request + const deleteUrl = `${serverApiUrl}${fileHash}` + + // Send the DELETE request + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + Authorization: nip98AuthorizationHeader, + }, + }) + + // Handle the response + if (!response.ok) { + throw new Error('Error deleting file!') + } + + // Return the response from the server + try { + return await response.json() + } catch (error) { + throw new Error('Error parsing JSON response!') + } +} + +/** + * Validates the server's response to a delayed processing request. + * + * @param response - The server's response to a delayed processing request. + * @returns A boolean indicating whether the response is valid. + */ +export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse { + if (typeof response !== 'object' || response === null) return false + + if (!response.status || !response.message || !response.percentage) { + return false + } + + if (response.status !== 'processing' && response.status !== 'error') { + return false + } + + if (typeof response.message !== 'string') { + return false + } + + if (typeof response.percentage !== 'number') { + return false + } + + if (Number(response.percentage) < 0 || Number(response.percentage) > 100) { + return false + } + + return true +} + +/** + * Checks the processing status of a file when delayed processing is used. + * + * @param processingUrl - The URL provided by the server where the processing status can be checked. + * @returns A promise that resolves to an object containing the processing status and other relevant information. + */ +export async function checkFileProcessingStatus( + processingUrl: string, +): Promise { + // Make the GET request to the processing URL + const response = await fetch(processingUrl) + + // Handle the response + if (!response.ok) { + throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`) + } + + // Parse the response + try { + const parsedResponse = await response.json() + + // 201 Created: Indicates the processing is over. + if (response.status === 201) { + // Validate the response + if (!validateFileUploadResponse(parsedResponse)) { + throw new Error('Invalid response from the server!') + } + + return parsedResponse + } + + // 200 OK: Indicates the processing is still ongoing. + if (response.status === 200) { + // Validate the response + if (!validateDelayedProcessingResponse(parsedResponse)) { + throw new Error('Invalid response from the server!') + } + + return parsedResponse + } + + throw new Error('Invalid response from the server!') + } catch (error) { + throw new Error('Error parsing JSON response!') + } +} + +/** + * Generates an event template to indicate a user's File Server Preferences. + * This event is of kind 10096 and is used to specify one or more preferred servers for file uploads. + * + * @param serverUrls - An array of URLs representing the user's preferred file storage servers. + * @returns An object representing a Nostr event template for setting file server preferences. + */ +export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate { + serverUrls = serverUrls.filter(serverUrl => { + try { + new URL(serverUrl) + return true + } catch (error) { + return false + } + }) + + return { + kind: FileServerPreference, + content: '', + tags: serverUrls.map(serverUrl => ['server', serverUrl]), + created_at: Math.floor(Date.now() / 1000), + } +} + +/** + * Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations, + * such as file upload, download, and deletion, to uniquely identify files. + * + * @param file - The file for which the SHA-256 hash needs to be calculated. + * @returns A promise that resolves to the SHA-256 hash of the file. + */ +export async function calculateFileHash(file: Blob): Promise { + // Read the file as an ArrayBuffer + const buffer = await file.arrayBuffer() + + // Calculate the SHA-256 hash of the file + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + + // Convert the hash to a hexadecimal string + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + + return hashHex +} diff --git a/package.json b/package.json index 7cfb3f1..d9fb228 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,7 @@ "events": "^3.3.0", "mitata": "^0.1.6", "mock-socket": "^9.3.1", + "msw": "^2.1.4", "node-fetch": "^2.6.9", "prettier": "^3.0.3", "tsd": "^0.22.0",