Browse Source

Persist signer logins

imwald
Nuša Pukšič 3 months ago
parent
commit
071d863258
  1. 9
      assets/controllers/amber_connect_controller.js
  2. 18
      assets/controllers/nostr_comment_controller.js
  3. 28
      assets/controllers/nostr_index_sign_controller.js
  4. 19
      assets/controllers/nostr_single_sign_controller.js
  5. 17
      assets/controllers/reading_list_dropdown_controller.js
  6. 58
      assets/controllers/signer_manager.js

9
assets/controllers/amber_connect_controller.js

@ -1,6 +1,7 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { getPublicKey, SimplePool } from 'nostr-tools'; import { getPublicKey, SimplePool } from 'nostr-tools';
import { BunkerSigner } from "nostr-tools/nip46"; import { BunkerSigner } from "nostr-tools/nip46";
import { setRemoteSignerSession, clearRemoteSignerSession } from './signer_manager.js';
export default class extends Controller { export default class extends Controller {
static targets = ['qr', 'status']; static targets = ['qr', 'status'];
@ -19,6 +20,7 @@ export default class extends Controller {
disconnect() { disconnect() {
try { this._signer?.close?.(); } catch (_) {} try { this._signer?.close?.(); } catch (_) {}
try { this._pool?.close?.([]); } catch (_) {} try { this._pool?.close?.([]); } catch (_) {}
clearRemoteSignerSession();
} }
async _init() { async _init() {
@ -98,6 +100,13 @@ export default class extends Controller {
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) } headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) }
}); });
if (resp.ok) { if (resp.ok) {
// Persist remote signer session for reuse after reload
setRemoteSignerSession({
privkey: this._localSecretKey,
uri: this._uri,
relays: this._relays,
secret: this._secret
});
this._setStatus('Authenticated. Reloading…'); this._setStatus('Authenticated. Reloading…');
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} else { } else {

18
assets/controllers/nostr_comment_controller.js

@ -1,4 +1,5 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
// NIP-22 Comment Publishing Controller // NIP-22 Comment Publishing Controller
// Usage: Attach to a form with data attributes for root/parent context // Usage: Attach to a form with data attributes for root/parent context
@ -28,8 +29,11 @@ export default class extends Controller {
this.showError('Missing CSRF token'); this.showError('Missing CSRF token');
return; return;
} }
if (!window.nostr) { let signer;
this.showError('Nostr extension not found'); try {
signer = await getSigner();
} catch (e) {
this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.');
return; return;
} }
@ -52,10 +56,10 @@ export default class extends Controller {
} }
// Create NIP-22 event // Create NIP-22 event
const nostrEvent = await this.createNip22Event(formData); const nostrEvent = await this.createNip22Event(formData, signer);
this.showStatus('Requesting signature from Nostr extension...'); this.showStatus('Requesting signature from Nostr signer...');
const signedEvent = await window.nostr.signEvent(nostrEvent); const signedEvent = await signer.signEvent(nostrEvent);
this.showStatus('Publishing comment...'); this.showStatus('Publishing comment...');
await this.sendToBackend(signedEvent, formData); await this.sendToBackend(signedEvent, formData);
@ -98,9 +102,9 @@ export default class extends Controller {
return !(/[<>\*\_\`\[\]#]/.test(text)); return !(/[<>\*\_\`\[\]#]/.test(text));
} }
async createNip22Event({ content, root, parent }) { async createNip22Event({ content, root, parent }, signer) {
// Get user's public key // Get user's public key
const pubkey = await window.nostr.getPublicKey(); const pubkey = await signer.getPublicKey();
const created_at = Math.floor(Date.now() / 1000); const created_at = Math.floor(Date.now() / 1000);
// Build tags according to NIP-22 // Build tags according to NIP-22
const tags = []; const tags = [];

28
assets/controllers/nostr_index_sign_controller.js

@ -1,4 +1,5 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
export default class extends Controller { export default class extends Controller {
static targets = ['status', 'publishButton', 'computedPreview']; static targets = ['status', 'publishButton', 'computedPreview'];
@ -50,63 +51,53 @@ export default class extends Controller {
async signAndPublish(event) { async signAndPublish(event) {
event.preventDefault(); event.preventDefault();
if (!window.nostr) { let signer;
this.showError('Nostr extension not found'); try {
signer = await getSigner();
} catch (e) {
this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.');
return; return;
} }
if (!this.publishUrlValue || !this.csrfTokenValue) { if (!this.publishUrlValue || !this.csrfTokenValue) {
this.showError('Missing config'); this.showError('Missing config');
return; return;
} }
this.publishButtonTarget.disabled = true; this.publishButtonTarget.disabled = true;
try { try {
const pubkey = await window.nostr.getPublicKey(); const pubkey = await signer.getPublicKey();
const catSkeletons = JSON.parse(this.categoryEventsValue || '[]'); const catSkeletons = JSON.parse(this.categoryEventsValue || '[]');
const magSkeleton = JSON.parse(this.magazineEventValue || '{}'); const magSkeleton = JSON.parse(this.magazineEventValue || '{}');
const categoryCoordinates = []; const categoryCoordinates = [];
// 1) Publish each category index // 1) Publish each category index
for (let i = 0; i < catSkeletons.length; i++) { for (let i = 0; i < catSkeletons.length; i++) {
const evt = catSkeletons[i]; const evt = catSkeletons[i];
this.ensureCreatedAt(evt); this.ensureCreatedAt(evt);
this.ensureContent(evt); this.ensureContent(evt);
evt.pubkey = pubkey; evt.pubkey = pubkey;
const slug = this.extractSlug(evt.tags); const slug = this.extractSlug(evt.tags);
if (!slug) throw new Error('Category missing slug (d tag)'); if (!slug) throw new Error('Category missing slug (d tag)');
this.showStatus(`Signing category ${i + 1}/${catSkeletons.length}`); this.showStatus(`Signing category ${i + 1}/${catSkeletons.length}`);
const signed = await window.nostr.signEvent(evt); const signed = await signer.signEvent(evt);
this.showStatus(`Publishing category ${i + 1}/${catSkeletons.length}`); this.showStatus(`Publishing category ${i + 1}/${catSkeletons.length}`);
await this.publishSigned(signed); await this.publishSigned(signed);
// Coordinate for the category index (kind:pubkey:slug) // Coordinate for the category index (kind:pubkey:slug)
const coord = `30040:${pubkey}:${slug}`; const coord = `30040:${pubkey}:${slug}`;
categoryCoordinates.push(coord); categoryCoordinates.push(coord);
} }
// 2) Build magazine event with 'a' tags referencing cats // 2) Build magazine event with 'a' tags referencing cats
this.showStatus('Preparing magazine index…'); this.showStatus('Preparing magazine index…');
this.ensureCreatedAt(magSkeleton); this.ensureCreatedAt(magSkeleton);
this.ensureContent(magSkeleton); this.ensureContent(magSkeleton);
magSkeleton.pubkey = pubkey; magSkeleton.pubkey = pubkey;
// Remove any pre-existing 'a' to avoid duplicates, then add new ones // Remove any pre-existing 'a' to avoid duplicates, then add new ones
magSkeleton.tags = (magSkeleton.tags || []).filter(t => t[0] !== 'a'); magSkeleton.tags = (magSkeleton.tags || []).filter(t => t[0] !== 'a');
categoryCoordinates.forEach(c => magSkeleton.tags.push(['a', c])); categoryCoordinates.forEach(c => magSkeleton.tags.push(['a', c]));
// 3) Sign and publish magazine // 3) Sign and publish magazine
this.showStatus('Signing magazine index…'); this.showStatus('Signing magazine index…');
const signedMag = await window.nostr.signEvent(magSkeleton); const signedMag = await signer.signEvent(magSkeleton);
this.showStatus('Publishing magazine index…'); this.showStatus('Publishing magazine index…');
await this.publishSigned(signedMag); await this.publishSigned(signedMag);
this.showSuccess('Published magazine and categories successfully'); this.showSuccess('Published magazine and categories successfully');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
this.showError(e.message || 'Publish failed'); this.showError(e.message || 'Publish failed');
@ -163,4 +154,3 @@ export default class extends Controller {
} }
} }
} }

19
assets/controllers/nostr_single_sign_controller.js

@ -1,4 +1,5 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
export default class extends Controller { export default class extends Controller {
static targets = ['status', 'publishButton', 'computedPreview']; static targets = ['status', 'publishButton', 'computedPreview'];
@ -18,9 +19,10 @@ export default class extends Controller {
try { try {
const skeleton = JSON.parse(this.eventValue || '{}'); const skeleton = JSON.parse(this.eventValue || '{}');
let pubkey = '<pubkey>'; let pubkey = '<pubkey>';
if (window.nostr && typeof window.nostr.getPublicKey === 'function') { try {
try { pubkey = await window.nostr.getPublicKey(); } catch (_) {} const signer = await getSigner();
} pubkey = await signer.getPublicKey();
} catch (_) {}
const preview = JSON.parse(JSON.stringify(skeleton)); const preview = JSON.parse(JSON.stringify(skeleton));
preview.pubkey = pubkey; preview.pubkey = pubkey;
// Update content from textarea if present // Update content from textarea if present
@ -37,8 +39,11 @@ export default class extends Controller {
async signAndPublish(event) { async signAndPublish(event) {
event.preventDefault(); event.preventDefault();
if (!window.nostr) { let signer;
this.showError('Nostr extension not found'); try {
signer = await getSigner();
} catch (e) {
this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.');
return; return;
} }
if (!this.publishUrlValue || !this.csrfTokenValue) { if (!this.publishUrlValue || !this.csrfTokenValue) {
@ -48,7 +53,7 @@ export default class extends Controller {
this.publishButtonTarget.disabled = true; this.publishButtonTarget.disabled = true;
try { try {
const pubkey = await window.nostr.getPublicKey(); const pubkey = await signer.getPublicKey();
const skeleton = JSON.parse(this.eventValue || '{}'); const skeleton = JSON.parse(this.eventValue || '{}');
// Update content from textarea before signing // Update content from textarea before signing
const textarea = this.element.querySelector('textarea'); const textarea = this.element.querySelector('textarea');
@ -60,7 +65,7 @@ export default class extends Controller {
skeleton.pubkey = pubkey; skeleton.pubkey = pubkey;
this.showStatus('Signing feedback…'); this.showStatus('Signing feedback…');
const signed = await window.nostr.signEvent(skeleton); const signed = await signer.signEvent(skeleton);
this.showStatus('Publishing…'); this.showStatus('Publishing…');
await this.publishSigned(signed); await this.publishSigned(signed);

17
assets/controllers/reading_list_dropdown_controller.js

@ -1,4 +1,5 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
export default class extends Controller { export default class extends Controller {
static targets = ['dropdown', 'status', 'menu']; static targets = ['dropdown', 'status', 'menu'];
@ -64,24 +65,24 @@ export default class extends Controller {
const slug = event.currentTarget.dataset.slug; const slug = event.currentTarget.dataset.slug;
const title = event.currentTarget.dataset.title; const title = event.currentTarget.dataset.title;
if (!window.nostr) { let signer;
this.showError('Nostr extension not found. Please install a Nostr signer extension.'); try {
signer = await getSigner();
} catch (e) {
this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.');
return; return;
} }
try { try {
this.showStatus(`Adding to "${title}"...`); this.showStatus(`Adding to "${title}"...`);
// Parse the existing lists data // Build the event skeleton for the updated reading list
const lists = JSON.parse(this.listsValue || '[]'); const lists = JSON.parse(this.listsValue || '[]');
const selectedList = lists.find(l => l.slug === slug); const selectedList = lists.find(l => l.slug === slug);
if (!selectedList) { if (!selectedList) {
this.showError('Reading list not found'); this.showError('Reading list not found');
return; return;
} }
// Check if article is already in the list
if (selectedList.articles && selectedList.articles.includes(this.coordinateValue)) { if (selectedList.articles && selectedList.articles.includes(this.coordinateValue)) {
this.showSuccess(`Already in "${title}"`); this.showSuccess(`Already in "${title}"`);
setTimeout(() => { setTimeout(() => {
@ -90,13 +91,11 @@ export default class extends Controller {
}, 2000); }, 2000);
return; return;
} }
// Build the event skeleton for the updated reading list
const eventSkeleton = await this.buildReadingListEvent(selectedList); const eventSkeleton = await this.buildReadingListEvent(selectedList);
// Sign the event // Sign the event
this.showStatus(`Signing update to "${title}"...`); this.showStatus(`Signing update to "${title}"...`);
const signedEvent = await window.nostr.signEvent(eventSkeleton); const signedEvent = await signer.signEvent(eventSkeleton);
// Publish the event // Publish the event
this.showStatus(`Publishing update...`); this.showStatus(`Publishing update...`);

58
assets/controllers/signer_manager.js

@ -0,0 +1,58 @@
// Shared signer manager for Nostr signers (remote and extension)
import { SimplePool } from 'nostr-tools';
import { BunkerSigner } from 'nostr-tools/nip46';
const REMOTE_SIGNER_KEY = 'amber_remote_signer';
let remoteSigner = null;
let remoteSignerPromise = null;
let remoteSignerPool = null;
export async function getSigner() {
// If remote signer session is active, use it
const session = getRemoteSignerSession();
if (session) {
if (remoteSigner) return remoteSigner;
if (remoteSignerPromise) return remoteSignerPromise;
remoteSignerPromise = createRemoteSigner(session).then(signer => {
remoteSigner = signer;
return signer;
});
return remoteSignerPromise;
}
// Fallback to browser extension
if (window.nostr && typeof window.nostr.signEvent === 'function') {
return window.nostr;
}
throw new Error('No signer available');
}
export function setRemoteSignerSession(session) {
localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session));
}
export function clearRemoteSignerSession() {
localStorage.removeItem(REMOTE_SIGNER_KEY);
remoteSigner = null;
remoteSignerPromise = null;
if (remoteSignerPool) {
try { remoteSignerPool.close?.([]); } catch (_) {}
remoteSignerPool = null;
}
}
export function getRemoteSignerSession() {
const raw = localStorage.getItem(REMOTE_SIGNER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function createRemoteSigner(session) {
remoteSignerPool = new SimplePool();
return await BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool });
}
Loading…
Cancel
Save