From ba26b92973f15d551498a3532d7e8ac4e6e3f6e0 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Wed, 2 Apr 2025 11:51:02 -0300 Subject: [PATCH] get rid of nip96 and unnecessary dependencies. --- .eslintrc.json | 2 +- nip96.test.ts | 654 ------------------------------------------------- nip96.ts | 561 ------------------------------------------ package.json | 4 - 4 files changed, 1 insertion(+), 1220 deletions(-) delete mode 100644 nip96.test.ts delete mode 100644 nip96.ts diff --git a/.eslintrc.json b/.eslintrc.json index a210ef2..34616bc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,7 @@ "extends": ["prettier"], "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "babel"], + "plugins": ["@typescript-eslint"], "parserOptions": { "ecmaVersion": 9, diff --git a/nip96.test.ts b/nip96.test.ts deleted file mode 100644 index 239f820..0000000 --- a/nip96.test.ts +++ /dev/null @@ -1,654 +0,0 @@ -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 a proper error if response status is 413', async () => { - // setup mock server - const handler = http.post('http://example.com/upload', () => { - return new HttpResponse(null, { status: 413 }) - }) - 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('File too large!') - - // cleanup mock server - server.resetHandlers() - server.close() - }) - - it('should throw a proper error if response status is 400', 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( - 'Bad request! Some fields are missing or invalid!', - ) - - // cleanup mock server - server.resetHandlers() - server.close() - }) - - it('should throw a proper error if response status is 403', async () => { - // setup mock server - const handler = http.post('http://example.com/upload', () => { - return new HttpResponse(null, { status: 403 }) - }) - 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( - 'Forbidden! Payload tag does not match the requested file!', - ) - - // cleanup mock server - server.resetHandlers() - server.close() - }) - - it('should throw a proper error if response status is 402', async () => { - // setup mock server - const handler = http.post('http://example.com/upload', () => { - return new HttpResponse(null, { status: 402 }) - }) - 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('Payment required!') - - // cleanup mock server - server.resetHandlers() - server.close() - }) - - it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => { - // setup mock server - const handler = http.post('http://example.com/upload', () => { - return new HttpResponse(null, { status: 500 }) - }) - 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( - 'Unknown error in uploading file!', - ) - - // 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 deleted file mode 100644 index 05b23cf..0000000 --- a/nip96.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { sha256 } from '@noble/hashes/sha256' -import { EventTemplate } from './core.ts' -import { FileServerPreference } from './kinds.ts' -import { bytesToHex } from '@noble/hashes/utils' - -/** - * 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: any = 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 (!['success', 'error', 'processing'].includes(response.status)) { - return false - } - - if (typeof response.message !== 'string') { - return false - } - - if (response.status === 'processing' && !response.processing_url) { - return false - } - - if (response.processing_url && typeof response.processing_url !== 'string') { - return false - } - - if (response.status === 'success' && !response.nip94_event) { - return false - } - - if (response.nip94_event) { - const tags = response.nip94_event.tags as string[][] - - if (!Array.isArray(tags)) { - return false - } - - if (tags.some(t => t.length < 2 || t.some(x => typeof x !== 'string'))) { - return false - } - - if (!tags.some(t => t[0] === 'url')) { - return false - } - - if (!tags.some(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 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, - }, - 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!') - } - - // unknown error - throw new Error('Unknown error in uploading file!') - } - - const parsedResponse = await response.json() - - if (!validateFileUploadResponse(parsedResponse)) { - throw new Error('Failed to validate upload response!') - } - - return parsedResponse -} - -/** - * 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 - const parsedResponse = await response.json() - - // 201 Created: Indicates the processing is over. - if (response.status === 201) { - if (!validateFileUploadResponse(parsedResponse)) { - throw new Error('Failed to validate upload response!') - } - - return parsedResponse as FileUploadResponse - } - - // 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!') -} - -/** - * 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 { - return bytesToHex(sha256(new Uint8Array(await file.arrayBuffer()))) -} diff --git a/package.json b/package.json index 2f179ce..8100fcb 100644 --- a/package.json +++ b/package.json @@ -263,15 +263,11 @@ "@typescript-eslint/parser": "^6.5.0", "bun-types": "^1.0.18", "esbuild": "0.16.9", - "esbuild-plugin-alias": "^0.2.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-babel": "^5.3.1", - "esm-loader-typescript": "^1.0.3", "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", "typescript": "^5.8.2"