WIP Add Nostr auth
This commit is contained in:
179
app/components/place-photo-upload.gjs
Normal file
179
app/components/place-photo-upload.gjs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
|
||||||
|
export default class PlacePhotoUpload extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrRelay;
|
||||||
|
|
||||||
|
@tracked photoUrl = '';
|
||||||
|
@tracked osmId = '';
|
||||||
|
@tracked geohash = '';
|
||||||
|
@tracked status = '';
|
||||||
|
@tracked error = '';
|
||||||
|
|
||||||
|
@action
|
||||||
|
async login() {
|
||||||
|
try {
|
||||||
|
this.error = '';
|
||||||
|
await this.nostrAuth.login();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async uploadPhoto(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.error = '';
|
||||||
|
this.status = 'Uploading...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock upload
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
this.photoUrl =
|
||||||
|
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
|
||||||
|
this.status = 'Photo uploaded! Ready to publish.';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Upload failed: ' + e.message;
|
||||||
|
this.status = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async publish() {
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.error = 'You must connect Nostr first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.photoUrl || !this.osmId || !this.geohash) {
|
||||||
|
this.error = 'Please provide an OSM ID, Geohash, and upload a photo.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'Publishing event...';
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
|
||||||
|
// NIP-XX draft Place Photo event
|
||||||
|
const template = {
|
||||||
|
kind: 360,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['i', `osm:node:${this.osmId}`],
|
||||||
|
['g', this.geohash],
|
||||||
|
[
|
||||||
|
'imeta',
|
||||||
|
`url ${this.photoUrl}`,
|
||||||
|
'm image/jpeg',
|
||||||
|
'dim 600x400',
|
||||||
|
'alt A photo of a place',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure created_at is present before signing
|
||||||
|
if (!template.created_at) {
|
||||||
|
template.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
await this.nostrRelay.publish(event);
|
||||||
|
|
||||||
|
this.status = 'Published successfully!';
|
||||||
|
// Reset form
|
||||||
|
this.photoUrl = '';
|
||||||
|
this.osmId = '';
|
||||||
|
this.geohash = '';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to publish: ' + e.message;
|
||||||
|
this.status = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action updateOsmId(e) {
|
||||||
|
this.osmId = e.target.value;
|
||||||
|
}
|
||||||
|
@action updateGeohash(e) {
|
||||||
|
this.geohash = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-photo-upload">
|
||||||
|
<h2>Add Place Photo</h2>
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{this.error}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.status}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{this.status}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<div class="connected-status">
|
||||||
|
<strong>Connected:</strong>
|
||||||
|
{{this.nostrAuth.pubkey}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form {{on "submit" this.uploadPhoto}}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
OSM Node ID
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={{this.osmId}}
|
||||||
|
{{on "input" this.updateOsmId}}
|
||||||
|
placeholder="e.g. 123456"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
Geohash
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={{this.geohash}}
|
||||||
|
{{on "input" this.updateGeohash}}
|
||||||
|
placeholder="e.g. thrrn5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.photoUrl}}
|
||||||
|
<div class="preview-group">
|
||||||
|
<p>Photo Preview:</p>
|
||||||
|
<img src={{this.photoUrl}} alt="Preview" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
{{on "click" this.publish}}
|
||||||
|
>
|
||||||
|
Publish Event (kind: 360)
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
Mock Upload Photo
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<button type="button" class="btn btn-primary" {{on "click" this.login}}>
|
||||||
|
Connect Nostr Extension
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service osmAuth;
|
@service osmAuth;
|
||||||
|
|
||||||
|
@service nostrAuth;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connectRS() {
|
connectRS() {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
|
|||||||
this.osmAuth.logout();
|
this.osmAuth.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectNostr() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.login();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
disconnectNostr() {
|
||||||
|
this.nostrAuth.logout();
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-menu-popover">
|
<div class="user-menu-popover">
|
||||||
<ul class="account-list">
|
<ul class="account-list">
|
||||||
@@ -91,15 +108,34 @@ export default class UserMenuComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="account-item disabled">
|
<li class="account-item">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
<Icon @name="zap" @size={{18}} />
|
<Icon @name="zap" @size={{18}} />
|
||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.disconnectNostr}}
|
||||||
|
>Disconnect</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectNostr}}
|
||||||
|
>Connect</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
Coming soon
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<strong title={{this.nostrAuth.pubkey}}>
|
||||||
|
{{this.nostrAuth.pubkey}}
|
||||||
|
</strong>
|
||||||
|
{{else}}
|
||||||
|
Not connected
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
60
app/services/nostr-auth.js
Normal file
60
app/services/nostr-auth.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { ExtensionSigner } from 'applesauce-signers';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marco:nostr_pubkey';
|
||||||
|
|
||||||
|
export default class NostrAuthService extends Service {
|
||||||
|
@tracked pubkey = null;
|
||||||
|
signer = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
this.pubkey = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return !!this.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login() {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.signer) {
|
||||||
|
this.signer = new ExtensionSigner();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.pubkey = await this.signer.getPublicKey();
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get public key from extension:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event) {
|
||||||
|
if (!this.signer) {
|
||||||
|
if (this.pubkey && typeof window.nostr !== 'undefined') {
|
||||||
|
this.signer = new ExtensionSigner();
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
'Not connected or extension missing. Please connect Nostr again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this.signer.signEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.pubkey = null;
|
||||||
|
this.signer = null;
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/services/nostr-relay.js
Normal file
25
app/services/nostr-relay.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
|
export default class NostrRelayService extends Service {
|
||||||
|
pool = new RelayPool();
|
||||||
|
|
||||||
|
// For Phase 1, we hardcode the local relay
|
||||||
|
relays = ['ws://127.0.0.1:7777'];
|
||||||
|
|
||||||
|
async publish(event) {
|
||||||
|
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||||
|
// and automatically handles reconnecting and retrying.
|
||||||
|
const responses = await this.pool.publish(this.relays, event);
|
||||||
|
|
||||||
|
// Check if at least one relay accepted the event
|
||||||
|
const success = responses.some((res) => res.ok);
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,8 +103,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@waysidemapping/pinhead": "^15.20.0",
|
"@waysidemapping/pinhead": "^15.20.0",
|
||||||
|
"applesauce-core": "^5.2.0",
|
||||||
|
"applesauce-factory": "^4.0.0",
|
||||||
|
"applesauce-relay": "^5.2.0",
|
||||||
|
"applesauce-signers": "^5.2.0",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.0.0",
|
||||||
"oauth2-pkce": "^2.1.3"
|
"oauth2-pkce": "^2.1.3",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
802
pnpm-lock.yaml
generated
802
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user