Upload to multiple servers, delete from servers when removing in dialog
Introduces a dedicated blossom service to tie everything together
This commit is contained in:
166
app/services/blossom.js
Normal file
166
app/services/blossom.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
|
||||
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
|
||||
|
||||
function bufferToHex(buffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getBlossomUrl(serverUrl, path) {
|
||||
let url = serverUrl || DEFAULT_BLOSSOM_SERVER;
|
||||
if (url.endsWith('/')) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`;
|
||||
}
|
||||
|
||||
export default class BlossomService extends Service {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
|
||||
get servers() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
}
|
||||
|
||||
async _getAuthHeader(action, hash, serverUrl) {
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const serverHostname = new URL(serverUrl).hostname;
|
||||
|
||||
const authTemplate = {
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||
tags: [
|
||||
['t', action],
|
||||
['x', hash],
|
||||
['expiration', String(now + 3600)],
|
||||
['server', serverHostname],
|
||||
],
|
||||
};
|
||||
|
||||
const authEvent = await factory.sign(authTemplate);
|
||||
const base64 = btoa(JSON.stringify(authEvent));
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `Nostr ${base64url}`;
|
||||
}
|
||||
|
||||
async _uploadToServer(file, hash, serverUrl) {
|
||||
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
||||
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
||||
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'X-SHA-256': hash,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async upload(file) {
|
||||
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const payloadHash = bufferToHex(hashBuffer);
|
||||
|
||||
const servers = this.servers;
|
||||
const mainServer = servers[0];
|
||||
const fallbackServers = servers.slice(1);
|
||||
|
||||
// Start all uploads concurrently
|
||||
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
|
||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||
this._uploadToServer(file, payloadHash, serverUrl)
|
||||
);
|
||||
|
||||
// Main server MUST succeed
|
||||
const mainResult = await mainPromise;
|
||||
|
||||
// Fallback servers can fail, but we log the warnings
|
||||
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||
const fallbackUrls = [];
|
||||
|
||||
for (let i = 0; i < fallbackResults.length; i++) {
|
||||
const result = fallbackResults[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
fallbackUrls.push(result.value.url);
|
||||
} else {
|
||||
console.warn(
|
||||
`Fallback upload to ${fallbackServers[i]} failed:`,
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: mainResult.url,
|
||||
fallbackUrls,
|
||||
hash: payloadHash,
|
||||
type: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
async _deleteFromServer(hash, serverUrl) {
|
||||
const deleteUrl = getBlossomUrl(serverUrl, hash);
|
||||
const authHeader = await this._getAuthHeader('delete', hash, serverUrl);
|
||||
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(hash) {
|
||||
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||
|
||||
const servers = this.servers;
|
||||
const mainServer = servers[0];
|
||||
const fallbackServers = servers.slice(1);
|
||||
|
||||
const mainPromise = this._deleteFromServer(hash, mainServer);
|
||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||
this._deleteFromServer(hash, serverUrl)
|
||||
);
|
||||
|
||||
// Main server MUST succeed
|
||||
await mainPromise;
|
||||
|
||||
// Fallback servers can fail, log warnings
|
||||
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||
for (let i = 0; i < fallbackResults.length; i++) {
|
||||
const result = fallbackResults[i];
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(
|
||||
`Fallback delete from ${fallbackServers[i]} failed:`,
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user