Add blob-sanitize.mjs with stripBlobMetadata() (canvas re-encode for JPEG/PNG/WebP, byte-level PDF /Author stripper, pass-through for audio/video) and hasImageMetadata() (lightweight EXIF/XMP detection). Wire into uploadToServer() before calculateSHA256 so the client-signed hash commits to cleaned bytes (preserves BUD-01 content addressing). Add skipSanitize opt-out for mirror/verbatim uploads. On 415 + X-Reason: exif_detected, retry once with forced stricter re-encode. Update post-composer status text and surface metadata-rejection errors. See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
291 lines
9.4 KiB
JavaScript
291 lines
9.4 KiB
JavaScript
import { getPubkey } from './init-ndk.mjs';
|
|
import { getBlossomServers } from './blossom-ui.mjs';
|
|
import { stripBlobMetadata } from './blob-sanitize.mjs';
|
|
|
|
const normalizeUrl = (url = '') => url.trim().replace(/\/$/, '');
|
|
|
|
export const isLocalServer = (serverUrl = '') => {
|
|
const normalized = normalizeUrl(serverUrl);
|
|
return normalized.includes('127.0.0.1') || normalized.includes('localhost');
|
|
};
|
|
|
|
export const calculateSHA256 = async (file) => {
|
|
const buffer = await file.arrayBuffer();
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
|
|
export const createBlossomAuth = async (action, sha256Hash = null, content = null) => {
|
|
const currentPubkey = await getPubkey();
|
|
if (!currentPubkey) {
|
|
throw new Error('No Nostr keys available. Please login first.');
|
|
}
|
|
|
|
const event = {
|
|
kind: 24242,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['t', action],
|
|
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
|
|
],
|
|
content: content || `${action.charAt(0).toUpperCase() + action.slice(1)} blob`,
|
|
pubkey: currentPubkey
|
|
};
|
|
|
|
if (sha256Hash) {
|
|
event.tags.push(['x', sha256Hash]);
|
|
}
|
|
|
|
const signedEvent = await window.nostr.signEvent(event);
|
|
event.id = signedEvent.id;
|
|
event.sig = signedEvent.sig;
|
|
event.pubkey = signedEvent.pubkey;
|
|
|
|
return btoa(JSON.stringify(event));
|
|
};
|
|
|
|
export const listUserBlobs = async (serverUrl, pubkey, since = 0) => {
|
|
const cleanUrl = normalizeUrl(serverUrl);
|
|
let url = `${cleanUrl}/list/${pubkey}`;
|
|
if (since > 0) {
|
|
url += `?since=${since}`;
|
|
}
|
|
|
|
const headers = {};
|
|
try {
|
|
const authToken = await createBlossomAuth('list');
|
|
headers.Authorization = `Nostr ${authToken}`;
|
|
} catch (error) {
|
|
console.warn('[blossom-api] Could not create list auth token:', error?.message || error);
|
|
}
|
|
|
|
const response = await fetch(url, { headers });
|
|
|
|
if (response.ok) {
|
|
const blobs = await response.json();
|
|
return Array.isArray(blobs) ? blobs : [];
|
|
}
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error('Authentication required to list blobs');
|
|
}
|
|
|
|
throw new Error(`Failed to list blobs: ${response.status}`);
|
|
};
|
|
|
|
export const uploadToServer = async (file, serverUrl, opts = {}) => {
|
|
const cleanUrl = normalizeUrl(serverUrl);
|
|
const currentPubkey = await getPubkey();
|
|
if (!currentPubkey) {
|
|
throw new Error('Please login with your Nostr keys first');
|
|
}
|
|
|
|
// Privacy-metadata stripping (Phase 1). Strip before hashing so the
|
|
// client-signed SHA-256 commits to the *cleaned* bytes — this preserves
|
|
// BUD-01 content addressing (the server stores exactly what we signed).
|
|
// skipSanitize: true is the opt-out for verbatim/mirror uploads where the
|
|
// bytes are already content-addressed on another blossom server.
|
|
const cleanFile = opts.skipSanitize ? file : await stripBlobMetadata(file);
|
|
|
|
const sha256 = await calculateSHA256(cleanFile);
|
|
|
|
const event = {
|
|
kind: 24242,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['t', 'upload'],
|
|
['x', sha256],
|
|
['size', String(cleanFile.size)],
|
|
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
|
|
],
|
|
content: `Upload ${cleanFile.name}`,
|
|
pubkey: currentPubkey
|
|
};
|
|
|
|
const signedEvent = await window.nostr.signEvent(event);
|
|
event.id = signedEvent.id;
|
|
event.sig = signedEvent.sig;
|
|
const authToken = btoa(JSON.stringify(event));
|
|
|
|
const response = await fetch(`${cleanUrl}/upload`, {
|
|
method: 'PUT',
|
|
mode: 'cors',
|
|
cache: 'no-cache',
|
|
headers: {
|
|
'Content-Type': cleanFile.type || 'application/octet-stream',
|
|
Authorization: `Nostr ${authToken}`
|
|
},
|
|
body: cleanFile
|
|
});
|
|
|
|
// Server-side metadata backstop: if the server still detected forbidden
|
|
// metadata, retry once with a forced stricter re-encode before giving up.
|
|
if (response.status === 415 && !opts.skipSanitize && !opts._retried) {
|
|
const reason = response.headers.get('X-Reason') || '';
|
|
if (reason.includes('exif_detected')) {
|
|
console.warn('[blossom-api] Server rejected upload (metadata detected), retrying with forced re-encode:', reason);
|
|
const forced = await stripBlobMetadata(cleanFile, { force: true, quality: 0.85 });
|
|
return uploadToServer(forced, serverUrl, { ...opts, _retried: true });
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const reason = response.headers.get('X-Reason') || '';
|
|
if (reason.includes('exif_detected')) {
|
|
throw new Error(`Upload rejected: privacy metadata could not be removed (${reason})`);
|
|
}
|
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const blobDescriptor = await response.json();
|
|
return { blobDescriptor, sha256, serverUrl: cleanUrl };
|
|
};
|
|
|
|
export const uploadToAllServers = async (file) => {
|
|
const servers = getBlossomServers();
|
|
const allUploadServers = servers.filter((server) => server.upload && server.healthy);
|
|
|
|
if (allUploadServers.length === 0) {
|
|
throw new Error('No healthy servers selected for upload.');
|
|
}
|
|
|
|
const localUploadServers = allUploadServers.filter((server) => isLocalServer(server.url));
|
|
const remoteUploadServers = allUploadServers.filter((server) => !isLocalServer(server.url));
|
|
|
|
const uploadedServers = [];
|
|
let blobDescriptor = null;
|
|
let sha256 = null;
|
|
|
|
const uploadOne = async (server) => {
|
|
const result = await uploadToServer(file, server.url);
|
|
if (!blobDescriptor) blobDescriptor = result.blobDescriptor;
|
|
if (!sha256) sha256 = result.sha256;
|
|
uploadedServers.push(normalizeUrl(server.url));
|
|
return result;
|
|
};
|
|
|
|
if (localUploadServers.length > 0) {
|
|
await uploadOne(localUploadServers[0]);
|
|
}
|
|
|
|
for (let i = 1; i < localUploadServers.length; i++) {
|
|
await uploadOne(localUploadServers[i]);
|
|
}
|
|
|
|
if (remoteUploadServers.length > 0) {
|
|
await uploadOne(remoteUploadServers[0]);
|
|
}
|
|
|
|
for (let i = 1; i < remoteUploadServers.length; i++) {
|
|
await uploadOne(remoteUploadServers[i]);
|
|
}
|
|
|
|
if (!blobDescriptor || !sha256) {
|
|
throw new Error('Failed to upload to any server');
|
|
}
|
|
|
|
return { blobDescriptor, sha256, uploadedServers };
|
|
};
|
|
|
|
export const mirrorBlobToServers = async (sha256, filename = 'blob') => {
|
|
const servers = getBlossomServers();
|
|
const mirrorServers = servers.filter((server) => server.mirror && server.healthy);
|
|
if (mirrorServers.length === 0) return;
|
|
|
|
const sourceServers = servers.filter((server) => server.upload && server.healthy);
|
|
|
|
for (const mirrorServer of mirrorServers) {
|
|
let blobData = null;
|
|
|
|
for (const sourceServer of sourceServers) {
|
|
try {
|
|
const sourceResponse = await fetch(`${normalizeUrl(sourceServer.url)}/${sha256}`, { mode: 'cors' });
|
|
if (sourceResponse.ok) {
|
|
blobData = await sourceResponse.blob();
|
|
break;
|
|
}
|
|
} catch {
|
|
// try next source
|
|
}
|
|
}
|
|
|
|
if (!blobData) continue;
|
|
|
|
try {
|
|
await fetch(`${normalizeUrl(mirrorServer.url)}/upload`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': blobData.type || 'application/octet-stream',
|
|
Authorization: `Nostr ${await createBlossomAuth('upload', sha256, `Upload ${filename}`)}`
|
|
},
|
|
body: blobData
|
|
});
|
|
} catch {
|
|
// continue to next mirror server
|
|
}
|
|
}
|
|
};
|
|
|
|
export const deleteFromAllServers = async (sha256, filename = 'blob') => {
|
|
const currentPubkey = await getPubkey();
|
|
if (!currentPubkey) {
|
|
throw new Error('Please login with your Nostr keys first');
|
|
}
|
|
|
|
const authToken = await createBlossomAuth('delete', sha256, `Delete ${filename}`);
|
|
const servers = getBlossomServers().filter((server) => server.healthy && (server.upload || server.mirror));
|
|
|
|
let deleteSuccess = false;
|
|
for (const server of servers) {
|
|
try {
|
|
const response = await fetch(`${normalizeUrl(server.url)}/${sha256}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Authorization: `Nostr ${authToken}`
|
|
}
|
|
});
|
|
if (response.ok) deleteSuccess = true;
|
|
} catch {
|
|
// continue to next server
|
|
}
|
|
}
|
|
|
|
return deleteSuccess;
|
|
};
|
|
|
|
/**
|
|
* Get the default blossom server URL for constructing blob URLs.
|
|
* Per BUD-03, the first server in the user's kind 10063 event is the most trusted.
|
|
* Prefers the first healthy upload server, falls back to first healthy, then first in list.
|
|
* @returns {string|null} The server base URL, or null if no servers configured
|
|
*/
|
|
export function getDefaultBlossomServer() {
|
|
const servers = getBlossomServers();
|
|
|
|
// BUD-03: first server is most trusted; prefer first healthy upload server
|
|
const firstHealthyUpload = servers.find((s) => s.healthy && s.upload);
|
|
if (firstHealthyUpload) return firstHealthyUpload.url;
|
|
|
|
// Fallback: first healthy server of any kind
|
|
const firstHealthy = servers.find((s) => s.healthy);
|
|
if (firstHealthy) return firstHealthy.url;
|
|
|
|
// Last resort: first server in list (even if unhealthy)
|
|
return servers.length > 0 ? servers[0].url : null;
|
|
}
|
|
|
|
/**
|
|
* Construct a full blob URL using the default blossom server.
|
|
* @param {string} sha256 - The SHA256 hash of the blob
|
|
* @param {string} [ext=''] - Optional file extension (without dot)
|
|
* @returns {string} The full blob URL
|
|
* @throws {Error} If no blossom server is available
|
|
*/
|
|
export function getBlobUrl(sha256, ext = '') {
|
|
const server = getDefaultBlossomServer();
|
|
if (!server) throw new Error('No blossom server available');
|
|
const suffix = ext ? `.${ext.replace(/^\./, '')}` : '';
|
|
return `${server}/${sha256}${suffix}`;
|
|
}
|