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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
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