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:
2026-04-20 15:22:17 +04:00
parent d9ba73559e
commit a2a61b0fec
3 changed files with 200 additions and 77 deletions

View File

@@ -2,31 +2,17 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-core';
import Icon from '#components/icon'; import Icon from '#components/icon';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
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('');
}
export default class PlacePhotoItem extends Component { export default class PlacePhotoItem extends Component {
@service nostrAuth; @service blossom;
@service nostrData;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked error = ''; @tracked error = '';
@tracked isUploaded = false; @tracked isUploaded = false;
get blossomServer() {
return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
}
constructor() { constructor() {
super(...arguments); super(...arguments);
if (this.args.file) { if (this.args.file) {
@@ -45,8 +31,6 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => { uploadTask = task(async (file) => {
this.error = ''; this.error = '';
try { try {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const dim = await new Promise((resolve) => { const dim = await new Promise((resolve) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(`${img.width}x${img.height}`); img.onload = () => resolve(`${img.width}x${img.height}`);
@@ -54,65 +38,17 @@ export default class PlacePhotoItem extends Component {
img.src = this.thumbnailUrl; img.src = this.thumbnailUrl;
}); });
const buffer = await file.arrayBuffer(); const result = await this.blossom.upload(file);
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const payloadHash = bufferToHex(hashBuffer);
let serverUrl = this.blossomServer;
if (serverUrl.endsWith('/')) {
serverUrl = serverUrl.slice(0, -1);
}
const uploadUrl = `${serverUrl}/upload`;
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: 'Upload photo for place',
tags: [
['t', 'upload'],
['x', payloadHash],
['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(/=+$/, '');
const authHeader = `Nostr ${base64url}`;
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: authHeader,
'X-SHA-256': payloadHash,
},
body: file,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed (${response.status}): ${text}`);
}
const result = await response.json();
this.isUploaded = true; this.isUploaded = true;
if (this.args.onSuccess) { if (this.args.onSuccess) {
this.args.onSuccess({ this.args.onSuccess({
file, file,
url: result.url, url: result.url,
type: file.type, fallbackUrls: result.fallbackUrls,
type: result.type,
dim, dim,
hash: payloadHash, hash: result.hash,
}); });
} }
} catch (e) { } catch (e) {

View File

@@ -4,6 +4,7 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash'; import Geohash from 'latlon-geohash';
import PlacePhotoItem from './place-photo-item'; import PlacePhotoItem from './place-photo-item';
import Icon from '#components/icon'; import Icon from '#components/icon';
@@ -12,6 +13,8 @@ import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component { export default class PlacePhotoUpload extends Component {
@service nostrAuth; @service nostrAuth;
@service nostrRelay; @service nostrRelay;
@service blossom;
@service toast;
@tracked files = []; @tracked files = [];
@tracked uploadedPhotos = []; @tracked uploadedPhotos = [];
@@ -74,11 +77,25 @@ export default class PlacePhotoUpload extends Component {
@action @action
removeFile(fileToRemove) { removeFile(fileToRemove) {
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
this.files = this.files.filter((f) => f !== fileToRemove); this.files = this.files.filter((f) => f !== fileToRemove);
this.uploadedPhotos = this.uploadedPhotos.filter( this.uploadedPhotos = this.uploadedPhotos.filter(
(p) => p.file !== fileToRemove (p) => p.file !== fileToRemove
); );
if (photoData && photoData.hash && photoData.url) {
this.deletePhotoTask.perform(photoData);
} }
}
deletePhotoTask = task(async (photoData) => {
try {
if (!photoData.hash) return;
await this.blossom.delete(photoData.hash);
} catch (e) {
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
}
});
@action @action
async publish() { async publish() {
@@ -117,17 +134,21 @@ export default class PlacePhotoUpload extends Component {
} }
for (const photo of this.uploadedPhotos) { for (const photo of this.uploadedPhotos) {
const imeta = [ const imeta = ['imeta', `url ${photo.url}`];
'imeta',
`url ${photo.url}`,
`m ${photo.type}`,
'alt A photo of a place',
];
if (photo.dim) { if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
imeta.splice(3, 0, `dim ${photo.dim}`); for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`url ${fallbackUrl}`);
}
} }
imeta.push(`m ${photo.type}`);
if (photo.dim) {
imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
tags.push(imeta); tags.push(imeta);
} }

166
app/services/blossom.js Normal file
View 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
);
}
}
}
}