From 7607f27013da6649ce8747d536fbfc7452fb8ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 20 Apr 2026 13:55:13 +0400 Subject: [PATCH] Upload photos to user's Blossom server --- app/components/place-photo-upload.gjs | 150 ++++++++++++++++++++++---- app/styles/app.css | 4 + 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index 5617df3..1acc8b3 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -6,13 +6,23 @@ import { on } from '@ember/modifier'; import { EventFactory } from 'applesauce-core'; import Geohash from 'latlon-geohash'; +function bufferToHex(buffer) { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + export default class PlacePhotoUpload extends Component { @service nostrAuth; + @service nostrData; @service nostrRelay; @tracked photoUrl = ''; + @tracked photoType = 'image/jpeg'; + @tracked photoDim = ''; @tracked status = ''; @tracked error = ''; + @tracked isUploading = false; get place() { return this.args.place || {}; @@ -22,21 +32,105 @@ export default class PlacePhotoUpload extends Component { return this.place.title || 'this place'; } + get blossomServer() { + return this.nostrData.blossomServers[0] || 'https://nostr.build'; + } + @action - async uploadPhoto(event) { - event.preventDefault(); + async handleFileSelected(event) { + const file = event.target.files[0]; + if (!file) return; + this.error = ''; - this.status = 'Uploading...'; + this.status = 'Preparing upload...'; + this.isUploading = true; + this.photoType = file.type; try { - // Mock upload - await new Promise((resolve) => setTimeout(resolve, 1000)); - this.photoUrl = - 'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo'; + if (!this.nostrAuth.isConnected) { + throw new Error('You must connect Nostr first.'); + } + + // 1. Get image dimensions + const dim = await new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(`${img.width}x${img.height}`); + }; + img.onerror = () => resolve(''); + img.src = url; + }); + this.photoDim = dim; + + // 2. Read file & compute hash + this.status = 'Computing hash...'; + const buffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const payloadHash = bufferToHex(hashBuffer); + + // 3. Create BUD-11 Auth Event + this.status = 'Signing auth event...'; + 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}`; + + // 4. Upload to Blossom + this.status = `Uploading to ${serverUrl}...`; + // 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.photoUrl = result.url; this.status = 'Photo uploaded! Ready to publish.'; } catch (e) { - this.error = 'Upload failed: ' + e.message; + this.error = e.message; this.status = ''; + } finally { + this.isUploading = false; + if (event && event.target) { + event.target.value = ''; + } } } @@ -75,13 +169,18 @@ export default class PlacePhotoUpload extends Component { tags.push(['g', Geohash.encode(lat, lon, 9)]); } - tags.push([ + const imeta = [ 'imeta', `url ${this.photoUrl}`, - 'm image/jpeg', - 'dim 600x400', + `m ${this.photoType}`, 'alt A photo of a place', - ]); + ]; + + if (this.photoDim) { + imeta.splice(3, 0, `dim ${this.photoDim}`); + } + + tags.push(imeta); // NIP-XX draft Place Photo event const template = { @@ -90,7 +189,6 @@ export default class PlacePhotoUpload extends Component { tags, }; - // Ensure created_at is present before signing if (!template.created_at) { template.created_at = Math.floor(Date.now() / 1000); } @@ -99,7 +197,6 @@ export default class PlacePhotoUpload extends Component { await this.nostrRelay.publish(event); this.status = 'Published successfully!'; - // Reset form this.photoUrl = ''; } catch (e) { this.error = 'Failed to publish: ' + e.message; @@ -123,11 +220,15 @@ export default class PlacePhotoUpload extends Component { {{/if}} -
+
{{#if this.photoUrl}}

Photo Preview:

- Preview + Preview
+ + + {{#if this.isUploading}} +

Uploading...

+ {{/if}} {{/if}} - +
} diff --git a/app/styles/app.css b/app/styles/app.css index d5891b3..6488124 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -200,6 +200,10 @@ body { object-fit: cover; flex-shrink: 0; } + +.photo-preview-img { + max-width: 100%; + height: auto; } /* User Menu Popover */