Upload photos to user's Blossom server

This commit is contained in:
2026-04-20 13:55:13 +04:00
parent 8cc579e271
commit 7607f27013
2 changed files with 134 additions and 20 deletions

View File

@@ -6,13 +6,23 @@ import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash'; 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 { export default class PlacePhotoUpload extends Component {
@service nostrAuth; @service nostrAuth;
@service nostrData;
@service nostrRelay; @service nostrRelay;
@tracked photoUrl = ''; @tracked photoUrl = '';
@tracked photoType = 'image/jpeg';
@tracked photoDim = '';
@tracked status = ''; @tracked status = '';
@tracked error = ''; @tracked error = '';
@tracked isUploading = false;
get place() { get place() {
return this.args.place || {}; return this.args.place || {};
@@ -22,21 +32,105 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place'; return this.place.title || 'this place';
} }
get blossomServer() {
return this.nostrData.blossomServers[0] || 'https://nostr.build';
}
@action @action
async uploadPhoto(event) { async handleFileSelected(event) {
event.preventDefault(); const file = event.target.files[0];
if (!file) return;
this.error = ''; this.error = '';
this.status = 'Uploading...'; this.status = 'Preparing upload...';
this.isUploading = true;
this.photoType = file.type;
try { try {
// Mock upload if (!this.nostrAuth.isConnected) {
await new Promise((resolve) => setTimeout(resolve, 1000)); throw new Error('You must connect Nostr first.');
this.photoUrl = }
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
// 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.'; this.status = 'Photo uploaded! Ready to publish.';
} catch (e) { } catch (e) {
this.error = 'Upload failed: ' + e.message; this.error = e.message;
this.status = ''; 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(['g', Geohash.encode(lat, lon, 9)]);
} }
tags.push([ const imeta = [
'imeta', 'imeta',
`url ${this.photoUrl}`, `url ${this.photoUrl}`,
'm image/jpeg', `m ${this.photoType}`,
'dim 600x400',
'alt A photo of a place', '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 // NIP-XX draft Place Photo event
const template = { const template = {
@@ -90,7 +189,6 @@ export default class PlacePhotoUpload extends Component {
tags, tags,
}; };
// Ensure created_at is present before signing
if (!template.created_at) { if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000); template.created_at = Math.floor(Date.now() / 1000);
} }
@@ -99,7 +197,6 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(event); await this.nostrRelay.publish(event);
this.status = 'Published successfully!'; this.status = 'Published successfully!';
// Reset form
this.photoUrl = ''; this.photoUrl = '';
} catch (e) { } catch (e) {
this.error = 'Failed to publish: ' + e.message; this.error = 'Failed to publish: ' + e.message;
@@ -123,11 +220,15 @@ export default class PlacePhotoUpload extends Component {
</div> </div>
{{/if}} {{/if}}
<form {{on "submit" this.uploadPhoto}}> <div>
{{#if this.photoUrl}} {{#if this.photoUrl}}
<div class="preview-group"> <div class="preview-group">
<p>Photo Preview:</p> <p>Photo Preview:</p>
<img src={{this.photoUrl}} alt="Preview" /> <img
src={{this.photoUrl}}
alt="Preview"
class="photo-preview-img"
/>
</div> </div>
<button <button
type="button" type="button"
@@ -137,11 +238,20 @@ export default class PlacePhotoUpload extends Component {
Publish Event (kind: 360) Publish Event (kind: 360)
</button> </button>
{{else}} {{else}}
<button type="submit" class="btn btn-secondary"> <label for="photo-upload-input">Select Photo</label>
Mock Upload Photo <input
</button> id="photo-upload-input"
type="file"
accept="image/*"
class="file-input"
disabled={{this.isUploading}}
{{on "change" this.handleFileSelected}}
/>
{{#if this.isUploading}}
<p>Uploading...</p>
{{/if}}
{{/if}} {{/if}}
</form> </div>
</div> </div>
</template> </template>
} }

View File

@@ -200,6 +200,10 @@ body {
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
} }
.photo-preview-img {
max-width: 100%;
height: auto;
} }
/* User Menu Popover */ /* User Menu Popover */