diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs index c9845bc..4c0069e 100644 --- a/app/components/place-photo-item.gjs +++ b/app/components/place-photo-item.gjs @@ -2,31 +2,17 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { task } from 'ember-concurrency'; -import { EventFactory } from 'applesauce-core'; import Icon from '#components/icon'; import { on } from '@ember/modifier'; 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 { - @service nostrAuth; - @service nostrData; + @service blossom; @tracked thumbnailUrl = ''; @tracked error = ''; @tracked isUploaded = false; - get blossomServer() { - return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER; - } - constructor() { super(...arguments); if (this.args.file) { @@ -45,8 +31,6 @@ export default class PlacePhotoItem extends Component { uploadTask = task(async (file) => { this.error = ''; try { - if (!this.nostrAuth.isConnected) throw new Error('Not connected'); - const dim = await new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(`${img.width}x${img.height}`); @@ -54,65 +38,17 @@ export default class PlacePhotoItem extends Component { img.src = this.thumbnailUrl; }); - const buffer = await file.arrayBuffer(); - 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(); + const result = await this.blossom.upload(file); this.isUploaded = true; if (this.args.onSuccess) { this.args.onSuccess({ file, url: result.url, - type: file.type, + fallbackUrls: result.fallbackUrls, + type: result.type, dim, - hash: payloadHash, + hash: result.hash, }); } } catch (e) { diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index 94ef85d..c3bb442 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -4,6 +4,7 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { on } from '@ember/modifier'; import { EventFactory } from 'applesauce-core'; +import { task } from 'ember-concurrency'; import Geohash from 'latlon-geohash'; import PlacePhotoItem from './place-photo-item'; import Icon from '#components/icon'; @@ -12,6 +13,8 @@ import { or, not } from 'ember-truth-helpers'; export default class PlacePhotoUpload extends Component { @service nostrAuth; @service nostrRelay; + @service blossom; + @service toast; @tracked files = []; @tracked uploadedPhotos = []; @@ -74,12 +77,26 @@ export default class PlacePhotoUpload extends Component { @action removeFile(fileToRemove) { + const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove); this.files = this.files.filter((f) => f !== fileToRemove); this.uploadedPhotos = this.uploadedPhotos.filter( (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 async publish() { if (!this.nostrAuth.isConnected) { @@ -117,17 +134,21 @@ export default class PlacePhotoUpload extends Component { } for (const photo of this.uploadedPhotos) { - const imeta = [ - 'imeta', - `url ${photo.url}`, - `m ${photo.type}`, - 'alt A photo of a place', - ]; + const imeta = ['imeta', `url ${photo.url}`]; - if (photo.dim) { - imeta.splice(3, 0, `dim ${photo.dim}`); + if (photo.fallbackUrls && photo.fallbackUrls.length > 0) { + 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); } diff --git a/app/services/blossom.js b/app/services/blossom.js new file mode 100644 index 0000000..b1e9ed9 --- /dev/null +++ b/app/services/blossom.js @@ -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 + ); + } + } + } +}