Upload photos to user's Blossom server
This commit is contained in:
@@ -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}}
|
||||||
</form>
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user