get rid of nip96 and unnecessary dependencies.

This commit is contained in:
fiatjaf 2025-04-02 11:51:02 -03:00
parent aec8ff5946
commit ba26b92973
4 changed files with 1 additions and 1220 deletions

View File

@ -3,7 +3,7 @@
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"],
"plugins": ["@typescript-eslint"],
"parserOptions": {
"ecmaVersion": 9,

View File

@ -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<FileUploadResponse, 'status'> = {
// 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<FileUploadResponse, 'message'> = {
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<DelayedProcessingResponse, 'status'> = {
// missing status
message: 'test',
percentage: 50,
}
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
status: 'processing',
// missing message
percentage: 50,
}
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
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')
})
})

561
nip96.ts
View File

@ -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<ServerConfiguration> {
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<FileUploadResponse> {
// 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<any> {
// 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<FileUploadResponse | DelayedProcessingResponse> {
// 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<string> {
return bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
}

View File

@ -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"