Browse Source

implement anon signing and nsec encryption+saving

fixed commentform
master
Silberengel 1 month ago
parent
commit
030c513751
  1. 16
      README.md
  2. 2
      README_SETUP.md
  3. 12
      src/app.css
  4. 14
      src/app.html
  5. 4
      src/lib/components/EventMenu.svelte
  6. 2
      src/lib/components/content/MarkdownRenderer.svelte
  7. 4
      src/lib/components/layout/Header.svelte
  8. 2
      src/lib/components/preferences/UserPreferences.svelte
  9. 643
      src/lib/components/write/CreateEventForm.svelte
  10. 511
      src/lib/modules/comments/CommentForm.svelte
  11. 2
      src/lib/modules/reactions/FeedReactionButtons.svelte
  12. 2
      src/lib/modules/reactions/ReactionButtons.svelte
  13. 10
      src/lib/services/auth/anonymous-signer.ts
  14. 89
      src/lib/services/auth/nsec-signer.ts
  15. 57
      src/lib/services/auth/session-manager.ts
  16. 37
      src/lib/services/cache/anonymous-key-store.ts
  17. 131
      src/lib/services/cache/nsec-key-store.ts
  18. 2
      src/lib/services/content/opengraph-fetcher.ts
  19. 195
      src/lib/services/nostr/auth-handler.ts
  20. 13
      src/lib/services/nostr/nostr-client.ts
  21. 104
      src/lib/services/security/key-management.ts
  22. 6
      src/routes/+layout.svelte
  23. 506
      src/routes/login/+page.svelte
  24. 2
      src/routes/rss/[pubkey]/+server.ts
  25. 3
      tailwind.config.js

16
README.md

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
# Aitherboard Specification
# aitherboard Specification
A decentralized messageboard built on the Nostr protocol. This document defines the complete specification for implementation.
@ -146,7 +146,7 @@ aitherboard/ @@ -146,7 +146,7 @@ aitherboard/
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "reply", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["root", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
@ -170,7 +170,7 @@ aitherboard/ @@ -170,7 +170,7 @@ aitherboard/
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "wss://relay.example.com"],
["k", "1"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
@ -194,7 +194,7 @@ aitherboard/ @@ -194,7 +194,7 @@ aitherboard/
["imeta", "url https://example.com/image1.jpg", "m image/jpeg", "x 1920", "y 1080"],
["imeta", "url https://example.com/video1.mp4", "m video/mp4", "x 1920", "y 1080", "dim 1920x1080x30"],
["file", "https://example.com/document.pdf", "application/pdf", "size 1048576"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"created_at": 1679673265,
@ -224,7 +224,7 @@ aitherboard/ @@ -224,7 +224,7 @@ aitherboard/
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["k", "11"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
@ -253,7 +253,7 @@ aitherboard/ @@ -253,7 +253,7 @@ aitherboard/
["p", "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9"],
["e", "9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb"],
["k", "1"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322",
"created_at": 1679673265,
@ -429,7 +429,7 @@ aitherboard/ @@ -429,7 +429,7 @@ aitherboard/
"content": "Working on a new project",
"tags": [
["d", "general"],
["client", "Aitherboard"]
["client", "aitherboard"]
],
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
@ -520,7 +520,7 @@ aitherboard/ @@ -520,7 +520,7 @@ aitherboard/
### Event Publishing
**REQUIREMENTS** (applies to all published events):
- **NIP-89 client tag**: Add `["client", "Aitherboard"]` tag to all published events when checkbox is selected
- **NIP-89 client tag**: Add `["client", "aitherboard"]` tag to all published events when checkbox is selected
- Checkbox "Include client tag." displayed in all publish forms
- Checkbox selected by default
- Only include client tag if checkbox is selected (not deselected)

2
README_SETUP.md

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
# Aitherboard Setup Guide
# aitherboard Setup Guide
## Prerequisites

12
src/app.css

@ -70,6 +70,11 @@ body { @@ -70,6 +70,11 @@ body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
}
/* Apply monospace font to all elements globally */
* {
font-family: inherit;
}
/* Secret supercoder vibe - subtle terminal aesthetic */
body::before {
content: '';
@ -148,11 +153,8 @@ img.emoji-inline { @@ -148,11 +153,8 @@ img.emoji-inline {
}
/* Ensure normal Unicode emojis (text characters) are displayed correctly */
/* Use emoji-friendly fonts and ensure they're not filtered */
body, .markdown-content, .post-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
/* Normal emojis are text, not images, so no filter should apply */
}
/* Monospace font is already applied globally via body and * { font-family: inherit } */
/* Emojis will still display correctly in monospace fonts */
/* Emoji picker buttons - apply grayscale filter to buttons that open the picker */
/* But NOT to emojis inside the picker drawer or rendered content */

14
src/app.html

@ -9,8 +9,8 @@ @@ -9,8 +9,8 @@
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.ico" />
<!-- Primary Meta Tags -->
<title>Aitherboard - Decentralized Messageboard on Nostr</title>
<meta name="title" content="Aitherboard - Decentralized Messageboard on Nostr" />
<title>aitherboard - Decentralized Messageboard on Nostr</title>
<meta name="title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta name="description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta name="keywords" content="nostr, decentralized, messageboard, forum, social media, censorship resistant" />
<meta name="author" content="silberengel@gitcitadel.com" />
@ -18,22 +18,22 @@ @@ -18,22 +18,22 @@
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://aitherboard.imwald.eu/" />
<meta property="og:title" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta property="og:image" content="%sveltekit.assets%/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:site_name" content="Aitherboard" />
<meta property="og:image:alt" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:site_name" content="aitherboard" />
<meta property="og:locale" content="en_US" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://aitherboard.imwald.eu/" />
<meta name="twitter:title" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta name="twitter:title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta name="twitter:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta name="twitter:image" content="%sveltekit.assets%/og-image.png" />
<meta name="twitter:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta name="twitter:image:alt" content="aitherboard - Decentralized Messageboard on Nostr" />
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#f1f5f9" />

4
src/lib/components/EventMenu.svelte

@ -215,7 +215,7 @@ @@ -215,7 +215,7 @@
}
}
async function shareWithAitherboard() {
async function shareWithaitherboard() {
try {
const url = `${window.location.origin}/event/${event.id}`;
await navigator.clipboard.writeText(url);
@ -328,7 +328,7 @@ @@ -328,7 +328,7 @@
<button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}>
{broadcasting ? 'Broadcasting...' : 'Broadcast event'}
</button>
<button class="menu-item" onclick={shareWithAitherboard}>
<button class="menu-item" onclick={shareWithaitherboard}>
Share with aitherboard
{#if copied === 'share'}
<span class="copied-indicator"></span>

2
src/lib/components/content/MarkdownRenderer.svelte

@ -777,7 +777,7 @@ @@ -777,7 +777,7 @@
/* Ensure normal Unicode emojis are displayed correctly */
:global(.markdown-content) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
/* Ensure emojis are not filtered or hidden */
}

4
src/lib/components/layout/Header.svelte

@ -23,7 +23,7 @@ @@ -23,7 +23,7 @@
<div class="max-w-7xl mx-auto h-full">
<img
src="/aither.png"
alt="Aitherboard banner"
alt="aitherboard banner"
class="w-full h-full object-cover opacity-90 dark:opacity-70"
/>
<!-- Overlay gradient for text readability -->
@ -35,7 +35,7 @@ @@ -35,7 +35,7 @@
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm font-mono">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Discussions</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Feed</a>
{#if isLoggedIn}

2
src/lib/components/preferences/UserPreferences.svelte

@ -290,7 +290,7 @@ @@ -290,7 +290,7 @@
<div class="about-section">
<h3 class="about-title">About</h3>
<p class="about-text">
Aitherboard is a decentralized discussion board built on Nostr.
aitherboard is a decentralized discussion board built on Nostr.
Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="about-link">Silberengel</a>.
</p>
</div>

643
src/lib/components/write/CreateEventForm.svelte

@ -1,11 +1,17 @@ @@ -1,11 +1,17 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { signAndPublish, signHttpAuth } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import GifPicker from '../content/GifPicker.svelte';
import EmojiPicker from '../content/EmojiPicker.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
const SUPPORTED_KINDS = [
{ value: 1, label: '1 - Short Text Note' },
@ -38,6 +44,14 @@ @@ -38,6 +44,14 @@
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
// Sync selectedKind when initialKind prop changes
$effect(() => {
@ -386,6 +400,207 @@ @@ -386,6 +400,207 @@
tags = newTags;
}
function getEventJson(): string {
const session = sessionManager.getSession();
if (!session) return '{}';
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] && t[1])];
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: allTags,
content: contentWithUrls.trim()
};
return JSON.stringify(event, null, 2);
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise<{ url: string; tags: string[][] }> {
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// Try NIP-96 discovery
let uploadUrl: string | null = null;
try {
const nip96Url = `${mediaServer}/.well-known/nostr/nip96.json`;
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
}
} catch (error) {
console.log('[CreateEventForm] NIP-96 discovery failed, will try direct endpoints:', error);
}
const endpoints = uploadUrl
? [uploadUrl]
: [
`${mediaServer}/api/upload`,
`${mediaServer}/upload`,
`${mediaServer}/api/v1/upload`,
`${mediaServer}/api/v2/upload`,
mediaServer
];
const formData = new FormData();
formData.append('file', file);
for (const endpoint of endpoints) {
try {
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[CreateEventForm] Failed to sign HTTP auth, trying without:', authError);
}
const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response;
try {
// Extract tags from NIP-94 response (like jumble)
const tags: string[][] = Array.isArray(data?.nip94_event?.tags)
? data.nip94_event.tags
: [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
const url = urlTag?.[1] || data?.url || (typeof data === 'string' ? data : null);
if (url) {
resolve({ url, tags });
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
console.log(`[CreateEventForm] Upload failed for ${endpoint}:`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
// Insert GIF URL as plain text
insertTextAtCursor(textareaRef, gifUrl);
showGifPicker = false;
}
function handleEmojiSelect(emoji: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert('Please select an image, video, or audio file');
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
try {
// Upload file to media server (like jumble)
const { url, tags } = await uploadFileToServer(file);
console.log(`[CreateEventForm] Uploaded ${file.name} to ${url}`);
// Build imeta tag from upload response tags (like jumble)
// Format: ['imeta', 'url <url>', 'm <mime>', ...]
const imetaTag: string[] = ['imeta', ...tags.map(([n, v]) => `${n} ${v}`)];
// Store file with imeta tag
uploadedFiles.push({
url,
imetaTag
});
// Insert file URL into textarea
if (textareaRef) {
if (isImage) {
insertTextAtCursor(textareaRef, `![${file.name}](${url})\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${url}\n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${url}\n`);
}
}
} catch (error) {
console.error('[CreateEventForm] File upload failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload file: ${errorMessage}`);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
async function publish() {
const session = sessionManager.getSession();
if (!session) {
@ -401,12 +616,31 @@ @@ -401,12 +616,31 @@
publishing = true;
try {
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] && t[1])];
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']);
}
const eventTemplate = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
content
tags: allTags,
content: contentWithUrls.trim()
};
const signedEvent = await session.signer(eventTemplate);
@ -422,6 +656,8 @@ @@ -422,6 +656,8 @@
publicationModalOpen = true;
if (results.success.length > 0) {
content = '';
uploadedFiles = []; // Clear uploaded files after successful publish
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
@ -523,13 +759,80 @@ @@ -523,13 +759,80 @@
{#if !isKind30040 && !isKind10895}
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<div class="textarea-wrapper">
<textarea
id="content-textarea"
bind:this={textareaRef}
bind:value={content}
class="content-input"
class="content-input has-buttons"
rows="10"
placeholder="Event content..."
disabled={publishing}
></textarea>
<div class="textarea-buttons">
<button
type="button"
onclick={() => {
showGifPicker = !showGifPicker;
showEmojiPicker = false;
}}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
disabled={publishing}
>
GIF
</button>
<button
type="button"
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
disabled={publishing}
>
😀
</button>
</div>
</div>
<div class="content-buttons">
<button
type="button"
onclick={() => showJsonModal = true}
class="content-button"
disabled={publishing}
title="View JSON"
>
View JSON
</button>
<button
type="button"
onclick={() => showPreviewModal = true}
class="content-button"
disabled={publishing}
title="Preview"
>
Preview
</button>
<input
type="file"
bind:this={fileInputRef}
accept="image/*,video/*,audio/*"
onchange={handleFileUpload}
class="hidden"
id="write-file-upload"
disabled={publishing || uploading}
/>
<label
for="write-file-upload"
class="content-button upload-label"
title="Upload file (image, video, or audio)"
>
📤
</label>
</div>
</div>
{/if}
@ -584,6 +887,97 @@ @@ -584,6 +887,97 @@
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
<!-- JSON View Modal -->
{#if showJsonModal}
<div
class="modal-overlay"
onclick={() => showJsonModal = false}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showJsonModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div
class="modal-content"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="none"
>
<div class="modal-header">
<h2>Event JSON</h2>
<button onclick={() => showJsonModal = false} class="close-button">×</button>
</div>
<div class="modal-body">
<pre class="json-preview">{getEventJson()}</pre>
</div>
<div class="modal-footer">
<button onclick={() => {
navigator.clipboard.writeText(getEventJson());
alert('JSON copied to clipboard');
}}>Copy</button>
<button onclick={() => showJsonModal = false}>Close</button>
</div>
</div>
</div>
{/if}
<!-- Preview Modal -->
{#if showPreviewModal}
<div
class="modal-overlay"
onclick={() => showPreviewModal = false}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showPreviewModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div
class="modal-content preview-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="none"
>
<div class="modal-header">
<h2>Preview</h2>
<button onclick={() => showPreviewModal = false} class="close-button">×</button>
</div>
<div class="modal-body preview-body">
{#if content.trim() || uploadedFiles.length > 0}
{@const previewContent = (() => {
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
return contentWithUrls.trim();
})()}
<MarkdownRenderer content={previewContent} />
{:else}
<p class="text-muted">No content to preview</p>
{/if}
</div>
<div class="modal-footer">
<button onclick={() => showPreviewModal = false}>Close</button>
</div>
</div>
</div>
{/if}
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}
<div class="republish-section">
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p>
@ -839,7 +1233,9 @@ @@ -839,7 +1233,9 @@
}
.content-input {
width: 100%;
padding: 0.75rem;
padding-bottom: 2.5rem; /* Always have padding for buttons */
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
@ -847,6 +1243,7 @@ @@ -847,6 +1243,7 @@
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
box-sizing: border-box;
}
:global(.dark) .content-input {
@ -1016,4 +1413,242 @@ @@ -1016,4 +1413,242 @@
opacity: 0.6;
cursor: not-allowed;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea-buttons {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
.content-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-start;
}
.content-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
:global(.dark) .content-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.content-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .content-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.content-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.upload-label {
user-select: none;
filter: grayscale(100%);
}
.hidden {
display: none;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 8px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: #1e293b;
border-color: #475569;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #cbd5e1;
}
:global(.dark) .modal-header {
border-bottom-color: #475569;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #64748b;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background: #e2e8f0;
color: #1e2937;
}
:global(.dark) .close-button {
color: #94a3b8;
}
:global(.dark) .close-button:hover {
background: #334155;
color: #f1f5f9;
}
.modal-body {
padding: 1rem;
max-height: 60vh;
overflow: auto;
}
.json-preview {
background: #1e293b;
color: #f1f5f9;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.preview-modal {
max-width: 900px;
}
.preview-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #cbd5e1;
}
:global(.dark) .modal-footer {
border-top-color: #475569;
}
.modal-footer button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
background: #ffffff;
color: #1e2937;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.modal-footer button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
:global(.dark) .modal-footer button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .modal-footer button:hover {
background: #475569;
border-color: #64748b;
}
</style>

511
src/lib/modules/comments/CommentForm.svelte

@ -1,16 +1,18 @@ @@ -1,16 +1,18 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { signAndPublish, signHttpAuth } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { fetchRelayLists } from '../../services/user-data.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import GifPicker from '../../components/content/GifPicker.svelte';
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
interface Props {
threadId: string; // The root event ID
@ -28,7 +30,12 @@ @@ -28,7 +30,12 @@
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments)
@ -114,7 +121,20 @@ @@ -114,7 +121,20 @@
}
if (shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
tags.push(['client', 'aitherboard']);
}
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
tags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
@ -122,7 +142,7 @@ @@ -122,7 +142,7 @@
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
content: contentWithUrls.trim()
};
// Get target's inbox if replying to someone
@ -162,6 +182,7 @@ @@ -162,6 +182,7 @@
if (result.success.length > 0) {
content = '';
uploadedFiles = []; // Clear uploaded files after successful publish
onPublished?.();
}
} catch (error) {
@ -190,6 +211,204 @@ @@ -190,6 +211,204 @@
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
}
function getEventJson(): string {
const replyKind = getReplyKind();
const tags: string[][] = [];
if (replyKind === 1) {
tags.push(['e', threadId]);
if (rootEvent) {
tags.push(['p', rootEvent.pubkey]);
}
if (parentEvent) {
tags.push(['e', parentEvent.id, '', 'reply']);
tags.push(['p', parentEvent.pubkey]);
}
} else {
const rootKind = rootEvent?.kind || '1';
tags.push(['K', String(rootKind)]);
tags.push(['E', threadId]);
if (rootEvent) {
tags.push(['P', rootEvent.pubkey]);
}
if (parentEvent) {
tags.push(['e', parentEvent.id]);
tags.push(['k', String(parentEvent.kind)]);
tags.push(['p', parentEvent.pubkey]);
tags.push(['E', parentEvent.id]);
tags.push(['P', parentEvent.pubkey]);
}
}
if (shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
};
return JSON.stringify(event, null, 2);
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise<{ url: string; tags: string[][] }> {
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// Try NIP-96 discovery
let uploadUrl: string | null = null;
try {
const nip96Url = `${mediaServer}/.well-known/nostr/nip96.json`;
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
}
} catch (error) {
console.log('[CommentForm] NIP-96 discovery failed, will try direct endpoints:', error);
}
const endpoints = uploadUrl
? [uploadUrl]
: [
`${mediaServer}/api/upload`,
`${mediaServer}/upload`,
`${mediaServer}/api/v1/upload`,
`${mediaServer}/api/v2/upload`,
mediaServer
];
const formData = new FormData();
formData.append('file', file);
for (const endpoint of endpoints) {
try {
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[CommentForm] Failed to sign HTTP auth, trying without:', authError);
}
const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response;
try {
// Extract tags from NIP-94 response (like jumble)
const tags: string[][] = Array.isArray(data?.nip94_event?.tags)
? data.nip94_event.tags
: [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
const url = urlTag?.[1] || data?.url || (typeof data === 'string' ? data : null);
if (url) {
resolve({ url, tags });
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
console.log(`[CommentForm] Upload failed for ${endpoint}:`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert('Please select an image, video, or audio file');
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
try {
// Upload file to media server (like jumble)
const { url, tags } = await uploadFileToServer(file);
console.log(`[CommentForm] Uploaded ${file.name} to ${url}`);
// Build imeta tag from upload response tags (like jumble)
// Format: ['imeta', 'url <url>', 'm <mime>', ...]
const imetaTag: string[] = ['imeta', ...tags.map(([n, v]) => `${n} ${v}`)];
// Store file with imeta tag
uploadedFiles.push({
url,
imetaTag
});
// Insert file URL into textarea
if (textareaRef) {
if (isImage) {
insertTextAtCursor(textareaRef, `![${file.name}](${url})\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${url}\n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${url}\n`);
}
}
} catch (error) {
console.error('[CommentForm] File upload failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload file: ${errorMessage}`);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
</script>
{#if isLoggedIn}
@ -199,7 +418,7 @@ @@ -199,7 +418,7 @@
bind:this={textareaRef}
bind:value={content}
placeholder={parentEvent ? 'Write a reply...' : 'Write a comment...'}
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text {showGifButton ? 'has-buttons' : ''}"
rows="4"
disabled={publishing}
></textarea>
@ -233,7 +452,43 @@ @@ -233,7 +452,43 @@
{/if}
</div>
<div class="flex items-center justify-end mt-2">
<div class="flex items-center justify-between mt-2">
<div class="flex gap-2">
<button
type="button"
onclick={() => showJsonModal = true}
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50"
disabled={publishing}
title="View JSON"
>
View JSON
</button>
<button
type="button"
onclick={() => showPreviewModal = true}
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50"
disabled={publishing}
title="Preview"
>
Preview
</button>
<input
type="file"
bind:this={fileInputRef}
accept="image/*,video/*,audio/*"
onchange={handleFileUpload}
class="hidden"
id="comment-file-upload"
disabled={publishing || uploading}
/>
<label
for="comment-file-upload"
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50 cursor-pointer inline-block upload-label"
title="Upload file (image, video, or audio)"
>
📤
</label>
</div>
<div class="flex gap-2">
{#if onCancel}
<button
@ -260,6 +515,94 @@ @@ -260,6 +515,94 @@
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
{/if}
<!-- JSON View Modal -->
{#if showJsonModal}
<div
class="modal-overlay"
onclick={() => showJsonModal = false}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showJsonModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div
class="modal-content"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="none"
>
<div class="modal-header">
<h2>Event JSON</h2>
<button onclick={() => showJsonModal = false} class="close-button">×</button>
</div>
<div class="modal-body">
<pre class="json-preview">{getEventJson()}</pre>
</div>
<div class="modal-footer">
<button onclick={() => {
navigator.clipboard.writeText(getEventJson());
alert('JSON copied to clipboard');
}}>Copy</button>
<button onclick={() => showJsonModal = false}>Close</button>
</div>
</div>
</div>
{/if}
<!-- Preview Modal -->
{#if showPreviewModal}
<div
class="modal-overlay"
onclick={() => showPreviewModal = false}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showPreviewModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div
class="modal-content preview-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="none"
>
<div class="modal-header">
<h2>Preview</h2>
<button onclick={() => showPreviewModal = false} class="close-button">×</button>
</div>
<div class="modal-body preview-body">
{#if content.trim() || uploadedFiles.length > 0}
{@const previewContent = (() => {
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
return contentWithUrls.trim();
})()}
<MarkdownRenderer content={previewContent} />
{:else}
<p class="text-muted">No content to preview</p>
{/if}
</div>
<div class="modal-footer">
<button onclick={() => showPreviewModal = false}>Close</button>
</div>
</div>
</div>
{/if}
</div>
{/if}
@ -277,6 +620,11 @@ @@ -277,6 +620,11 @@
min-height: 100px;
}
/* Add padding to bottom when buttons are visible to prevent text overlap */
textarea.has-buttons {
padding-bottom: 2.5rem;
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
@ -322,4 +670,157 @@ @@ -322,4 +670,157 @@
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 8px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: #1e293b;
border-color: #475569;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #cbd5e1;
}
:global(.dark) .modal-header {
border-bottom-color: #475569;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #64748b;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background: #e2e8f0;
color: #1e2937;
}
:global(.dark) .close-button {
color: #94a3b8;
}
:global(.dark) .close-button:hover {
background: #334155;
color: #f1f5f9;
}
.modal-body {
padding: 1rem;
max-height: 60vh;
overflow: auto;
}
.json-preview {
background: #1e293b;
color: #f1f5f9;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.preview-modal {
max-width: 900px;
}
.preview-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #cbd5e1;
}
:global(.dark) .modal-footer {
border-top-color: #475569;
}
.modal-footer button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
background: #ffffff;
color: #1e2937;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.modal-footer button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
:global(.dark) .modal-footer button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .modal-footer button:hover {
background: #475569;
border-color: #64748b;
}
label[for="comment-file-upload"],
.upload-label {
user-select: none;
}
.upload-label {
filter: grayscale(100%);
}
</style>

2
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -483,7 +483,7 @@ @@ -483,7 +483,7 @@
];
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
tags.push(['client', 'aitherboard']);
}
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {

2
src/lib/modules/reactions/ReactionButtons.svelte

@ -123,7 +123,7 @@ @@ -123,7 +123,7 @@
];
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
tags.push(['client', 'aitherboard']);
}
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {

10
src/lib/services/auth/anonymous-signer.ts

@ -42,15 +42,17 @@ export async function signEventWithAnonymous( @@ -42,15 +42,17 @@ export async function signEventWithAnonymous(
pubkey: string,
password: string
): Promise<NostrEvent> {
// Retrieve and decrypt key - NEVER log the nsec or password
const nsec = await getStoredAnonymousKey(pubkey, password);
if (!nsec) {
throw new Error('Anonymous key not found');
}
// For anonymous keys, we need the ncryptsec format
// This is simplified - in practice we'd store ncryptsec and decrypt it
// For now, assume we have the plain nsec after decryption
return signEventWithNsec(event, nsec, password);
// Encrypt to ncryptsec format for signing
// NEVER log the nsec or password
const { encryptPrivateKey } = await import('../security/key-management.js');
const ncryptsec = await encryptPrivateKey(nsec, password);
return signEventWithNsec(event, ncryptsec, password);
}
/**

89
src/lib/services/auth/nsec-signer.ts

@ -3,71 +3,62 @@ @@ -3,71 +3,62 @@
*/
import { decryptPrivateKey } from '../security/key-management.js';
import { getPublicKey, finalizeEvent } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
if (hex.length !== 64) {
throw new Error('Invalid hex string: must be 64 characters (32 bytes)');
}
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
const hexByte = hex.slice(i * 2, i * 2 + 2);
bytes[i] = parseInt(hexByte, 16);
}
return bytes;
}
/**
* Sign event with nsec (private key)
* This is a placeholder - full implementation requires:
* - secp256k1 cryptography library
* - Event ID computation (SHA256)
* - Signature computation
* Uses proper secp256k1 cryptography via nostr-tools finalizeEvent
*/
export async function signEventWithNsec(
event: Omit<NostrEvent, 'sig' | 'id'>,
ncryptsec: string,
password: string
): Promise<NostrEvent> {
// Decrypt private key
// Decrypt private key - NEVER log the nsec, password, or ncryptsec
const nsec = await decryptPrivateKey(ncryptsec, password);
// Compute event ID (SHA256 of serialized event)
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
// Convert hex string to Uint8Array for finalizeEvent
const privkey = hexToBytes(nsec);
const encoder = new TextEncoder();
const data = encoder.encode(serialized);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const id = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
// TEMPORARY: Generate a deterministic signature-like string
// This is NOT a valid secp256k1 signature but has the correct length
// Production code MUST compute actual secp256k1 signature
const sigData = encoder.encode(nsec + id);
const sigHashBuffer = await crypto.subtle.digest('SHA-256', sigData);
const sigHashArray = Array.from(new Uint8Array(sigHashBuffer));
const sigHash = sigHashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
// Double the hash to get 128 chars (64 * 2)
const sig = (sigHash + sigHash).slice(0, 128);
return {
...event,
id,
sig
};
// Use nostr-tools finalizeEvent to properly sign the event
// This computes the event ID (SHA256) and secp256k1 signature
return finalizeEvent(event, privkey);
}
/**
* Get public key from private key
*
* TEMPORARY: Uses SHA256 hash of private key to generate a deterministic pubkey.
* This is NOT a valid secp256k1 public key derivation but provides unique pubkeys.
* Production code MUST use proper secp256k1 point multiplication.
* Get public key from private key (hex string)
* Uses proper secp256k1 cryptography via nostr-tools
*/
export async function getPublicKeyFromNsec(nsec: string): Promise<string> {
// TEMPORARY: Generate deterministic pubkey from private key hash
// This ensures each private key gets a unique (but not cryptographically valid) pubkey
const encoder = new TextEncoder();
const data = encoder.encode(nsec);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
// Double the hash to get 64 chars (32 * 2)
return (hash + hash).slice(0, 64);
// Convert hex string to Uint8Array
// nsec is a 64-character hex string representing 32 bytes
if (nsec.length !== 64) {
throw new Error('Invalid nsec: must be 64 hex characters (32 bytes)');
}
// Convert hex string to bytes
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
const hexByte = nsec.slice(i * 2, i * 2 + 2);
bytes[i] = parseInt(hexByte, 16);
}
// Use nostr-tools to get the proper public key
return getPublicKey(bytes);
}

57
src/lib/services/auth/session-manager.ts

@ -11,6 +11,9 @@ export interface UserSession { @@ -11,6 +11,9 @@ export interface UserSession {
method: AuthMethod;
signer: (event: Omit<NostrEvent, 'sig' | 'id'>) => Promise<NostrEvent>;
createdAt: number;
// Store password in memory for nsec/anonymous sessions (never persisted)
// This allows signing without re-prompting, but password is cleared on logout
password?: string; // Only for 'nsec' and 'anonymous' methods
}
// Simple store implementation for Svelte reactivity
@ -45,6 +48,7 @@ class SessionManager { @@ -45,6 +48,7 @@ class SessionManager {
this.currentSession = session;
this.session.set(session);
// Store in localStorage for persistence
// NEVER store password in localStorage - it's only kept in memory
if (typeof window !== 'undefined') {
const sessionData: any = {
pubkey: session.pubkey,
@ -55,6 +59,7 @@ class SessionManager { @@ -55,6 +59,7 @@ class SessionManager {
if (metadata) {
sessionData.metadata = metadata;
}
// Password is never persisted - only kept in memory
localStorage.setItem('aitherboard_session', JSON.stringify(sessionData));
}
}
@ -93,8 +98,14 @@ class SessionManager { @@ -93,8 +98,14 @@ class SessionManager {
/**
* Clear session
* Also clears password from memory for security
*/
clearSession(): void {
// Clear password from memory if it exists
if (this.currentSession?.password) {
// Overwrite password in memory (though JS doesn't guarantee this)
this.currentSession.password = '';
}
this.currentSession = null;
this.session.set(null);
if (typeof window !== 'undefined') {
@ -105,10 +116,16 @@ class SessionManager { @@ -105,10 +116,16 @@ class SessionManager {
/**
* Restore session from localStorage
* This will attempt to restore the session based on the auth method
* Only restores if there's no active session (to avoid overwriting sessions with passwords)
*/
async restoreSession(): Promise<boolean> {
if (typeof window === 'undefined') return false;
// Don't restore if there's already an active session (especially one with a password)
if (this.currentSession) {
return true; // Session already exists, consider it restored
}
const stored = localStorage.getItem('aitherboard_session');
if (!stored) return false;
@ -146,26 +163,20 @@ class SessionManager { @@ -146,26 +163,20 @@ class SessionManager {
case 'anonymous': {
// For anonymous, we can restore if the encrypted key is stored
// The key is stored in IndexedDB, we just need to verify it exists
const { getStoredAnonymousKey } = await import('./anonymous-signer.js');
// We can't restore without password, but we can check if key exists
// For now, we'll just restore the pubkey and let signer fail if password is wrong
// In practice, user would need to re-enter password
if (pubkey) {
// Check if key exists in storage
try {
// This will fail without password, but we can still restore session
// The signer will need password on first use
const { signEventWithAnonymous } = await import('./anonymous-signer.js');
// Check if key exists by trying to list keys (we can't decrypt without password)
// For now, restore session but signer will require password
this.setSession({
pubkey,
method: 'anonymous',
signer: async (event) => {
// This will fail without password - user needs to re-authenticate
signer: async () => {
throw new Error('Anonymous session requires password. Please log in again.');
},
createdAt: data.createdAt || Date.now()
});
// Note: This session won't work until user re-authenticates
// Note: This session won't work until user re-authenticates with password
return true;
} catch {
return false;
@ -174,8 +185,30 @@ class SessionManager { @@ -174,8 +185,30 @@ class SessionManager {
return false;
}
case 'nsec': {
// nsec can't be restored without password (security)
// Clear the stored session
// For nsec, we can restore the session but signing will require password
// The encrypted key is stored in IndexedDB, but we need password to decrypt
// For now, restore session but signer will fail until user re-enters password
if (pubkey) {
try {
const { hasNsecKey } = await import('../cache/nsec-key-store.js');
const keyExists = await hasNsecKey(pubkey);
if (keyExists) {
// Restore session but signer will require password
this.setSession({
pubkey,
method: 'nsec',
signer: async () => {
throw new Error('Nsec session requires password. Please log in again.');
},
createdAt: data.createdAt || Date.now()
});
return true;
}
} catch {
return false;
}
}
// Clear the stored session if key doesn't exist
localStorage.removeItem('aitherboard_session');
return false;
}

37
src/lib/services/cache/anonymous-key-store.ts vendored

@ -10,6 +10,7 @@ export interface StoredAnonymousKey { @@ -10,6 +10,7 @@ export interface StoredAnonymousKey {
ncryptsec: string; // NIP-49 encrypted key
pubkey: string; // Public key for identification
created_at: number;
keyType?: 'nsec' | 'anonymous'; // Distinguish between nsec and anonymous keys
}
/**
@ -26,7 +27,8 @@ export async function storeAnonymousKey( @@ -26,7 +27,8 @@ export async function storeAnonymousKey(
id: pubkey,
ncryptsec,
pubkey,
created_at: Date.now()
created_at: Date.now(),
keyType: 'anonymous'
};
await db.put('keys', stored);
}
@ -43,20 +45,45 @@ export async function getAnonymousKey( @@ -43,20 +45,45 @@ export async function getAnonymousKey(
if (!stored) return null;
const key = stored as StoredAnonymousKey;
return decryptPrivateKey(key.ncryptsec, password);
if (!key.ncryptsec || typeof key.ncryptsec !== 'string') {
throw new Error('Stored anonymous key has invalid ncryptsec format - key may be corrupted');
}
// Validate ncryptsec format before attempting decryption
if (!key.ncryptsec.startsWith('ncryptsec1')) {
throw new Error('Stored anonymous key has invalid encryption format - key may need to be re-saved');
}
// NEVER log the password or ncryptsec - they are sensitive
try {
return await decryptPrivateKey(key.ncryptsec, password);
} catch (error) {
// Provide helpful error without exposing sensitive data
if (error instanceof Error && error.message.includes('Invalid password')) {
throw error; // Re-throw password errors
}
throw new Error('Failed to decrypt stored anonymous key - the key may be corrupted or the password is incorrect');
}
}
/**
* List all stored anonymous keys (pubkeys only)
*/
export async function listAnonymousKeys(): Promise<string[]> {
export async function listAnonymousKeys(): Promise<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>> {
const db = await getDB();
const keys: string[] = [];
const keys: Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }> = [];
const tx = db.transaction('keys', 'readonly');
for await (const cursor of tx.store.iterate()) {
const key = cursor.value as StoredAnonymousKey;
keys.push(key.pubkey);
// Only include anonymous keys
if (!key.keyType || key.keyType === 'anonymous') {
keys.push({
pubkey: key.pubkey,
created_at: key.created_at,
keyType: key.keyType || 'anonymous'
});
}
}
await tx.done;

131
src/lib/services/cache/nsec-key-store.ts vendored

@ -0,0 +1,131 @@ @@ -0,0 +1,131 @@
/**
* Nsec key storage (NIP-49 encrypted)
* Stores encrypted nsec keys in IndexedDB
*/
import { getDB } from './indexeddb-store.js';
import { encryptPrivateKey, decryptPrivateKey } from '../security/key-management.js';
export interface StoredNsecKey {
id: string; // pubkey
ncryptsec: string; // NIP-49 encrypted key
pubkey: string; // Public key for identification
created_at: number;
keyType?: 'nsec' | 'anonymous'; // Distinguish between nsec and anonymous keys
}
/**
* Store an nsec key (encrypted)
* NEVER log the nsec or password - they are sensitive
*/
export async function storeNsecKey(
nsec: string,
password: string,
pubkey: string
): Promise<void> {
// Encrypt the private key - never store plaintext
const ncryptsec = await encryptPrivateKey(nsec, password);
const db = await getDB();
// Check if there's an existing key with this pubkey
// If so, verify it matches before overwriting
const existing = await db.get('keys', pubkey);
if (existing) {
const existingKey = existing as StoredNsecKey;
// If the existing key is an nsec key, verify it matches
if (existingKey.keyType === 'nsec' && existingKey.pubkey === pubkey) {
// Key exists and matches - we'll overwrite it
// This is fine, we're updating with the same pubkey
}
}
const stored: StoredNsecKey = {
id: pubkey,
ncryptsec,
pubkey,
created_at: Date.now(),
keyType: 'nsec'
};
// Store encrypted key - never log the nsec or password
await db.put('keys', stored);
// Note: Verification is done in authenticateWithNsec after storage
// to ensure the key is committed to IndexedDB and can be retrieved
}
/**
* Retrieve and decrypt an nsec key
* NEVER log the password or decrypted nsec
*/
export async function getNsecKey(
pubkey: string,
password: string
): Promise<string | null> {
const db = await getDB();
const stored = await db.get('keys', pubkey);
if (!stored) return null;
const key = stored as StoredNsecKey;
if (!key.ncryptsec || typeof key.ncryptsec !== 'string') {
throw new Error('Stored nsec key has invalid ncryptsec format - key may be corrupted');
}
// Validate ncryptsec format before attempting decryption
if (!key.ncryptsec.startsWith('ncryptsec1')) {
throw new Error('Stored nsec key has invalid encryption format - key may need to be re-saved');
}
// Decrypt and return - never log the result
try {
return await decryptPrivateKey(key.ncryptsec, password);
} catch (error) {
// Provide helpful error without exposing sensitive data
if (error instanceof Error && error.message.includes('Invalid password')) {
throw error; // Re-throw password errors
}
throw new Error('Failed to decrypt stored nsec key - the key may be corrupted or the password is incorrect');
}
}
/**
* Check if an nsec key exists for a pubkey
*/
export async function hasNsecKey(pubkey: string): Promise<boolean> {
const db = await getDB();
const stored = await db.get('keys', pubkey);
return stored !== undefined;
}
/**
* Delete an nsec key
*/
export async function deleteNsecKey(pubkey: string): Promise<void> {
const db = await getDB();
await db.delete('keys', pubkey);
}
/**
* List all stored nsec keys (pubkeys only)
*/
export async function listNsecKeys(): Promise<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>> {
const db = await getDB();
const keys: Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }> = [];
const tx = db.transaction('keys', 'readonly');
for await (const cursor of tx.store.iterate()) {
const key = cursor.value as StoredNsecKey;
// Only include nsec keys (not anonymous, which are handled separately)
if (!key.keyType || key.keyType === 'nsec') {
keys.push({
pubkey: key.pubkey,
created_at: key.created_at,
keyType: key.keyType || 'nsec'
});
}
}
await tx.done;
return keys;
}

2
src/lib/services/content/opengraph-fetcher.ts

@ -35,7 +35,7 @@ export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null> @@ -35,7 +35,7 @@ export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null>
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (compatible; Aitherboard/1.0)'
'User-Agent': 'Mozilla/5.0 (compatible; aitherboard/1.0)'
},
mode: 'cors',
cache: 'no-cache'

195
src/lib/services/nostr/auth-handler.ts

@ -45,25 +45,179 @@ export async function authenticateWithNIP07(): Promise<string> { @@ -45,25 +45,179 @@ export async function authenticateWithNIP07(): Promise<string> {
}
/**
* Authenticate with nsec
* Authenticate with existing stored nsec key (password only)
* NEVER logs the password - it is sensitive
*/
export async function authenticateWithNsec(
ncryptsec: string,
export async function authenticateWithStoredNsec(
pubkey: string,
password: string
): Promise<string> {
// Decrypt the encrypted private key
const nsec = await decryptPrivateKey(ncryptsec, password);
// Verify the key exists and password is correct by trying to decrypt
const { getNsecKey } = await import('../cache/nsec-key-store.js');
const decryptedNsec = await getNsecKey(pubkey, password);
if (!decryptedNsec) {
throw new Error('Invalid password or key not found');
}
// Create session with signer that uses stored encrypted key
// Store password in memory (never persisted) so signing works without re-prompting
sessionManager.setSession({
pubkey,
method: 'nsec',
password, // Store in memory for signing - never persisted to localStorage
signer: async (event) => {
// Retrieve and decrypt key when signing - never log it
const session = sessionManager.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
const { getNsecKey } = await import('../cache/nsec-key-store.js');
const decryptedNsec = await getNsecKey(pubkey, session.password);
if (!decryptedNsec) {
throw new Error('Stored nsec key not found');
}
// Encrypt to ncryptsec format for signing
const { encryptPrivateKey } = await import('../security/key-management.js');
const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password);
return signEventWithNsec(event, ncryptsec, session.password);
},
createdAt: Date.now()
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted)
await loadUserPreferences(pubkey);
// Fetch and cache user's own profile (background-update if already cached)
fetchProfile(pubkey).catch(() => {
// Silently fail - profile fetch errors shouldn't break login
});
// Derive public key from private key
return pubkey;
}
/**
* Authenticate with nsec (new key - will be stored)
* NEVER logs the nsec, password, or ncryptsec - they are sensitive
*/
export async function authenticateWithNsec(
nsec: string,
password: string
): Promise<string> {
// Derive public key from private key - NEVER log the nsec
const pubkey = await getPublicKeyFromNsec(nsec);
// Encrypt and store the nsec key in IndexedDB
// NEVER log the nsec or password
const { storeNsecKey } = await import('../cache/nsec-key-store.js');
await storeNsecKey(nsec, password, pubkey);
// Verify the key was stored correctly by trying to retrieve it
// This ensures the key is committed to IndexedDB before we proceed
const { getNsecKey } = await import('../cache/nsec-key-store.js');
try {
const verifyNsec = await getNsecKey(pubkey, password);
if (!verifyNsec) {
throw new Error('Failed to retrieve stored nsec key');
}
// Compare hex strings (case-insensitive) to handle any case differences
const nsecLower = nsec.toLowerCase().trim();
const verifyLower = verifyNsec.toLowerCase().trim();
if (nsecLower !== verifyLower) {
throw new Error('Stored nsec key does not match original - key may be corrupted');
}
} catch (error) {
// If verification fails, provide helpful error
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to verify stored nsec key - please try again');
}
// Create session with signer that uses stored encrypted key
// Store password in memory (never persisted) so signing works without re-prompting
// The signer will retrieve and decrypt when needed, but never log the key
sessionManager.setSession({
pubkey,
method: 'nsec',
signer: async (event) => signEventWithNsec(event, ncryptsec, password),
password, // Store in memory for signing - never persisted to localStorage
signer: async (event) => {
// Retrieve and decrypt key when signing - never log it
const session = sessionManager.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
const { getNsecKey } = await import('../cache/nsec-key-store.js');
try {
const decryptedNsec = await getNsecKey(pubkey, session.password);
if (!decryptedNsec) {
throw new Error('Stored nsec key not found');
}
// Verify the decrypted nsec matches the expected pubkey
const { getPublicKeyFromNsec } = await import('../auth/nsec-signer.js');
const derivedPubkey = await getPublicKeyFromNsec(decryptedNsec);
if (derivedPubkey !== pubkey) {
throw new Error('Stored nsec key does not match the expected pubkey - key may be corrupted');
}
// Encrypt to ncryptsec format for signing
const { encryptPrivateKey } = await import('../security/key-management.js');
const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password);
return signEventWithNsec(event, ncryptsec, session.password);
} catch (error) {
// Provide better error message without exposing sensitive data
if (error instanceof Error) {
// Re-throw with more context if it's a decryption error
if (error.message.includes('decrypt') || error.message.includes('password')) {
throw new Error('Failed to decrypt stored nsec key. Please log in again with your password.');
}
throw error;
}
throw new Error('Failed to retrieve nsec key for signing');
}
},
createdAt: Date.now()
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted)
await loadUserPreferences(pubkey);
// Fetch and cache user's own profile (background-update if already cached)
fetchProfile(pubkey).catch(() => {
// Silently fail - profile fetch errors shouldn't break login
});
return pubkey;
}
/**
* Authenticate with existing stored anonymous key (password only)
* NEVER logs the password - it is sensitive
*/
export async function authenticateWithStoredAnonymous(
pubkey: string,
password: string
): Promise<string> {
// Verify the key exists and password is correct by trying to decrypt
const { getStoredAnonymousKey } = await import('../auth/anonymous-signer.js');
const decryptedNsec = await getStoredAnonymousKey(pubkey, password);
if (!decryptedNsec) {
throw new Error('Invalid password or key not found');
}
// Store password in memory (never persisted) so signing works without re-prompting
// Create session with signer that retrieves and decrypts when needed
sessionManager.setSession({
pubkey,
method: 'anonymous',
password, // Store in memory for signing - never persisted to localStorage
signer: async (event) => {
// Retrieve and decrypt key when signing - never log it
const session = sessionManager.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
return signEventWithAnonymous(event, pubkey, session.password);
},
createdAt: Date.now()
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted)
await loadUserPreferences(pubkey);
// Fetch and cache user's own profile (background-update if already cached)
@ -75,22 +229,37 @@ export async function authenticateWithNsec( @@ -75,22 +229,37 @@ export async function authenticateWithNsec(
}
/**
* Authenticate as anonymous
* Authenticate as anonymous (new key - will be generated and stored)
* Generates a new key, encrypts it, and stores it in IndexedDB
* NEVER logs the generated nsec or password
*/
export async function authenticateAsAnonymous(password: string): Promise<string> {
// Generate new anonymous key - never log the nsec
const { pubkey, nsec } = await generateAnonymousKey(password);
// Store the key for later use
// In practice, we'd need to store the ncryptsec and decrypt when needed
// For now, this is simplified
// Key is already stored encrypted in IndexedDB by generateAnonymousKey
// Store password in memory (never persisted) so signing works without re-prompting
// Create session with signer that retrieves and decrypts when needed
sessionManager.setSession({
pubkey,
method: 'anonymous',
password, // Store in memory for signing - never persisted to localStorage
signer: async (event) => {
// Simplified - would decrypt and sign
return signEventWithAnonymous(event, pubkey, password);
// Retrieve and decrypt key when signing - never log it
const session = sessionManager.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
return signEventWithAnonymous(event, pubkey, session.password);
},
createdAt: Date.now()
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted)
await loadUserPreferences(pubkey);
// Fetch and cache user's own profile (background-update if already cached)
fetchProfile(pubkey).catch(() => {
// Silently fail - profile fetch errors shouldn't break login
});
return pubkey;

13
src/lib/services/nostr/nostr-client.ts

@ -122,7 +122,13 @@ class NostrClient { @@ -122,7 +122,13 @@ class NostrClient {
// Only log if it's not the "no challenge" error (which is expected for relays that don't require auth)
const errorMessage = error instanceof Error ? error.message : String(error);
if (!errorMessage.includes('no challenge was received')) {
console.debug(`[nostr-client] Failed to authenticate with relay ${url}:`, errorMessage);
// Log authentication failures but don't expose sensitive data
// Never log passwords, nsec, or ncryptsec values
const safeMessage = errorMessage
.replace(/password/gi, '[password]')
.replace(/nsec/gi, '[nsec]')
.replace(/ncryptsec/gi, '[ncryptsec]');
console.warn(`[nostr-client] Failed to authenticate with relay ${url}:`, safeMessage);
}
return false;
}
@ -1049,6 +1055,11 @@ class NostrClient { @@ -1049,6 +1055,11 @@ class NostrClient {
console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`);
}
// Log connection status for single relay queries
if (relays.length === 1 && connectedRelays.length === 1) {
console.log(`[nostr-client] Successfully connected to relay ${relays[0]}, fetching events...`);
}
// Process relays sequentially with throttling to avoid overload
const events: Map<string, NostrEvent> = new Map();

104
src/lib/services/security/key-management.ts

@ -1,57 +1,95 @@ @@ -1,57 +1,95 @@
/**
* Key management with NIP-49 encryption
* All private keys MUST be encrypted before storage
*
* Uses nostr-tools/nip49 for proper NIP-49 compliant encryption/decryption
*/
import * as nip49 from 'nostr-tools/nip49';
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
if (hex.length !== 64) {
throw new Error('Invalid hex string: must be 64 characters (32 bytes)');
}
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
const hexByte = hex.slice(i * 2, i * 2 + 2);
bytes[i] = parseInt(hexByte, 16);
}
return bytes;
}
/**
* Convert Uint8Array to hex string
*/
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Encrypt a private key using NIP-49 (password-based encryption)
* This is a placeholder - full implementation requires:
* - scrypt for key derivation
* - AES-256-GCM for encryption
* - Base64 encoding
*
* WARNING: Current implementation stores keys in plaintext format.
* This is insecure and should only be used for development.
* Production code MUST implement proper NIP-49 encryption.
* Implements NIP-49 using nostr-tools:
* - Unicode NFKC normalization of password
* - scrypt key derivation
* - XChaCha20-Poly1305 encryption
* - bech32 encoding as ncryptsec
*
* @param nsec - Private key as hex string (64 characters)
* @param password - Password for encryption (will be NFKC normalized)
* @returns Encrypted private key in ncryptsec bech32 format
*/
export async function encryptPrivateKey(nsec: string, password: string): Promise<string> {
// TEMPORARY: Store as base64-encoded plaintext with a marker
// This allows the system to function while proper crypto is implemented
// Full NIP-49 implementation would:
// 1. Derive key from password using scrypt
// 2. Generate random salt and nonce
// 3. Encrypt nsec with AES-256-GCM
// 4. Encode as ncryptsec format
const encoded = btoa(JSON.stringify({ nsec, password }));
return `ncryptsec1${encoded}`;
// Convert hex string to Uint8Array (32 bytes)
const privkey = hexToBytes(nsec);
// Use nostr-tools/nip49 to encrypt (handles NFKC normalization, scrypt, XChaCha20-Poly1305)
// Returns ncryptsec bech32 string
return nip49.encrypt(privkey, password);
}
/**
* Decrypt a private key using NIP-49
*
* WARNING: Current implementation reads plaintext keys.
* This is insecure and should only be used for development.
* Production code MUST implement proper NIP-49 decryption.
* Implements NIP-49 using nostr-tools:
* - Unicode NFKC normalization of password
* - scrypt key derivation
* - XChaCha20-Poly1305 decryption
*
* @param ncryptsec - Encrypted private key in ncryptsec bech32 format
* @param password - Password for decryption (will be NFKC normalized)
* @returns Decrypted private key as hex string (64 characters)
*/
export async function decryptPrivateKey(ncryptsec: string, password: string): Promise<string> {
// TEMPORARY: Decode from base64 plaintext format
// This allows the system to function while proper crypto is implemented
// Full NIP-49 implementation would:
// 1. Decode ncryptsec format
// 2. Derive key from password using scrypt
// 3. Decrypt with AES-256-GCM
// 4. Return plain nsec
if (!ncryptsec || typeof ncryptsec !== 'string') {
throw new Error('Invalid ncryptsec: must be a non-empty string');
}
if (!ncryptsec.startsWith('ncryptsec1')) {
throw new Error('Invalid ncryptsec format');
throw new Error('Invalid ncryptsec format: must start with "ncryptsec1"');
}
try {
const decoded = JSON.parse(atob(ncryptsec.slice(11)));
if (decoded.password !== password) {
throw new Error('Invalid password');
}
return decoded.nsec;
// Use nostr-tools/nip49 to decrypt (handles NFKC normalization, scrypt, XChaCha20-Poly1305)
// Returns Uint8Array (32 bytes)
const privkey = nip49.decrypt(ncryptsec, password);
// Convert Uint8Array to hex string
return bytesToHex(privkey);
} catch (error) {
throw new Error('Failed to decrypt private key: ' + (error instanceof Error ? error.message : 'Unknown error'));
// Provide more specific error messages without exposing sensitive data
if (error instanceof Error) {
// Check for common error types
if (error.message.includes('password') || error.message.includes('decrypt')) {
throw new Error('Invalid password or corrupted encrypted key');
}
throw new Error('Failed to decrypt private key: ' + error.message);
}
throw new Error('Failed to decrypt private key: Unknown error');
}
}

6
src/routes/+layout.svelte

@ -3,10 +3,14 @@ @@ -3,10 +3,14 @@
import { sessionManager } from '../lib/services/auth/session-manager.js';
import { onMount } from 'svelte';
// Restore session on app load
// Restore session on app load (only if no session exists)
onMount(async () => {
try {
// Only restore if there's no active session
// This prevents overwriting sessions that were just created during login
if (!sessionManager.isLoggedIn()) {
await sessionManager.restoreSession();
}
} catch (error) {
console.error('Failed to restore session:', error);
}

506
src/routes/login/+page.svelte

@ -1,16 +1,62 @@ @@ -1,16 +1,62 @@
<script lang="ts">
import { authenticateWithNIP07 } from '../../lib/services/nostr/auth-handler.js';
import { authenticateWithNIP07, authenticateWithNsec, authenticateAsAnonymous, authenticateWithStoredNsec, authenticateWithStoredAnonymous } from '../../lib/services/nostr/auth-handler.js';
import { isNIP07Available } from '../../lib/services/auth/nip07-signer.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { nip19 } from 'nostr-tools';
import { listNsecKeys } from '../../lib/services/cache/nsec-key-store.js';
import { listAnonymousKeys } from '../../lib/services/cache/anonymous-key-store.js';
onMount(async () => {
await nostrClient.initialize();
await loadStoredKeys();
});
let error = $state<string | null>(null);
let loading = $state(false);
let activeTab = $state<'nip07' | 'nsec' | 'anonymous'>('nip07');
// Stored keys
let storedNsecKeys = $state<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>>([]);
let storedAnonymousKeys = $state<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>>([]);
// Nsec form state
let nsecInput = $state('');
let nsecPassword = $state('');
let nsecPasswordConfirm = $state('');
let selectedNsecKey = $state<string | null>(null);
let showNewNsecForm = $state(false);
// Anonymous form state
let anonymousPassword = $state('');
let anonymousPasswordConfirm = $state('');
let selectedAnonymousKey = $state<string | null>(null);
let showNewAnonymousForm = $state(false);
async function loadStoredKeys() {
try {
storedNsecKeys = await listNsecKeys();
storedAnonymousKeys = await listAnonymousKeys();
// If we have stored keys, default to showing them (not the new form)
if (storedNsecKeys.length > 0) {
showNewNsecForm = false;
}
if (storedAnonymousKeys.length > 0) {
showNewAnonymousForm = false;
}
} catch (err) {
console.error('Error loading stored keys:', err);
}
}
function formatNpub(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey.slice(0, 16) + '...';
}
}
async function loginWithNIP07() {
if (!isNIP07Available()) {
@ -30,18 +76,251 @@ @@ -30,18 +76,251 @@
loading = false;
}
}
async function loginWithStoredNsec() {
if (!selectedNsecKey) {
error = 'Please select a stored key';
return;
}
if (!nsecPassword) {
error = 'Please enter your password';
return;
}
loading = true;
error = null;
try {
// NEVER log the password - it is sensitive
await authenticateWithStoredNsec(selectedNsecKey, nsecPassword);
// Clear sensitive data from memory
nsecPassword = '';
selectedNsecKey = null;
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
// Clear sensitive data on error
nsecPassword = '';
} finally {
loading = false;
}
}
function validateNsec(input: string): { valid: boolean; nsec?: string; error?: string } {
const trimmed = input.trim();
if (!trimmed) {
return { valid: false, error: 'Please enter your nsec key' };
}
// Check if it's a bech32 nsec
if (trimmed.startsWith('nsec1')) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nsec') {
// Convert Uint8Array to hex string
const nsecBytes = decoded.data as Uint8Array;
const hex = Array.from(nsecBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return { valid: true, nsec: hex };
} else {
return { valid: false, error: 'Invalid nsec format: must be a private key, not a public key' };
}
} catch (err) {
return { valid: false, error: 'Invalid nsec bech32 format' };
}
}
// Check if it's a hex private key (64 hex characters)
const hexPattern = /^[0-9a-fA-F]{64}$/;
if (hexPattern.test(trimmed)) {
return { valid: true, nsec: trimmed };
}
// Check if it looks like an npub (reject it)
if (trimmed.startsWith('npub1')) {
return { valid: false, error: 'This is a public key (npub), not a private key (nsec). Please enter your private key.' };
}
// Check if it looks like other bech32 formats (reject them)
if (trimmed.match(/^n(profile|event|addr|relay)1/)) {
return { valid: false, error: 'This is not a private key. Please enter your nsec (private key) in hex or bech32 format.' };
}
return { valid: false, error: 'Invalid format. Please enter your nsec key in hex (64 characters) or bech32 (nsec1...) format.' };
}
async function loginWithNsec() {
if (!nsecPassword) {
error = 'Please enter a password to encrypt your key';
return;
}
if (nsecPassword !== nsecPasswordConfirm) {
error = 'Passwords do not match';
return;
}
if (nsecPassword.length < 8) {
error = 'Password must be at least 8 characters';
return;
}
// Validate nsec format
const validation = validateNsec(nsecInput);
if (!validation.valid) {
error = validation.error || 'Invalid nsec format';
return;
}
loading = true;
error = null;
try {
// NEVER log the nsec or password - they are sensitive
await authenticateWithNsec(validation.nsec!, nsecPassword);
// Clear sensitive data from memory
nsecInput = '';
nsecPassword = '';
nsecPasswordConfirm = '';
// Reload stored keys
await loadStoredKeys();
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
// Clear sensitive data on error
nsecInput = '';
nsecPassword = '';
nsecPasswordConfirm = '';
} finally {
loading = false;
}
}
async function loginWithStoredAnonymous() {
if (!selectedAnonymousKey) {
error = 'Please select a stored key';
return;
}
if (!anonymousPassword) {
error = 'Please enter your password';
return;
}
loading = true;
error = null;
try {
// NEVER log the password - it is sensitive
await authenticateWithStoredAnonymous(selectedAnonymousKey, anonymousPassword);
// Clear sensitive data from memory
anonymousPassword = '';
selectedAnonymousKey = null;
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
// Clear sensitive data on error
anonymousPassword = '';
} finally {
loading = false;
}
}
async function loginAsAnonymous() {
if (!anonymousPassword) {
error = 'Please enter a password to encrypt your key';
return;
}
if (anonymousPassword !== anonymousPasswordConfirm) {
error = 'Passwords do not match';
return;
}
if (anonymousPassword.length < 8) {
error = 'Password must be at least 8 characters';
return;
}
loading = true;
error = null;
try {
// NEVER log the password - it is sensitive
await authenticateAsAnonymous(anonymousPassword);
// Clear sensitive data from memory
anonymousPassword = '';
anonymousPasswordConfirm = '';
// Reload stored keys
await loadStoredKeys();
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
// Clear sensitive data on error
anonymousPassword = '';
anonymousPasswordConfirm = '';
} finally {
loading = false;
}
}
</script>
<main class="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Login</h1>
{#if error}
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 px-4 py-3 rounded mb-4">
{error}
</div>
{/if}
<!-- Tab Navigation -->
<div class="flex gap-2 mb-4 border-b border-fog-border dark:border-fog-dark-border">
<button
onclick={() => { activeTab = 'nip07'; error = null; }}
class="px-4 py-2 {activeTab === 'nip07' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent text-fog-accent dark:text-fog-dark-accent' : 'text-fog-text-light dark:text-fog-dark-text-light'} transition-colors"
>
NIP-07
</button>
<button
onclick={() => {
activeTab = 'nsec';
error = null;
// If there are stored keys, show them by default
showNewNsecForm = storedNsecKeys.length === 0;
selectedNsecKey = null;
}}
class="px-4 py-2 {activeTab === 'nsec' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent text-fog-accent dark:text-fog-dark-accent' : 'text-fog-text-light dark:text-fog-dark-text-light'} transition-colors"
>
Nsec {#if storedNsecKeys.length > 0}({storedNsecKeys.length}){/if}
</button>
<button
onclick={() => {
activeTab = 'anonymous';
error = null;
// If there are stored keys, show them by default
showNewAnonymousForm = storedAnonymousKeys.length === 0;
selectedAnonymousKey = null;
}}
class="px-4 py-2 {activeTab === 'anonymous' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent text-fog-accent dark:text-fog-dark-accent' : 'text-fog-text-light dark:text-fog-dark-text-light'} transition-colors"
>
Anonymous {#if storedAnonymousKeys.length > 0}({storedAnonymousKeys.length}){/if}
</button>
</div>
<div class="space-y-4">
{#if activeTab === 'nip07'}
<div class="space-y-4">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Login using a Nostr browser extension (Alby, nos2x, etc.)
</p>
<button
onclick={loginWithNIP07}
disabled={loading}
@ -49,9 +328,230 @@ @@ -49,9 +328,230 @@
>
{loading ? 'Connecting...' : 'Login with NIP-07'}
</button>
</div>
{:else if activeTab === 'nsec'}
<div class="space-y-4">
{#if storedNsecKeys.length > 0 && !showNewNsecForm}
<!-- Show stored keys list -->
<div class="space-y-3">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Select a stored key or add a new one:
</p>
<!-- Stored keys list -->
<div class="space-y-2">
{#each storedNsecKeys as key}
<button
onclick={() => { selectedNsecKey = key.pubkey; nsecPassword = ''; }}
class="w-full text-left px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors {selectedNsecKey === key.pubkey ? 'ring-2 ring-fog-accent dark:ring-fog-dark-accent' : ''}"
>
<div class="font-mono text-sm break-all">{formatNpub(key.pubkey)}</div>
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
Created {new Date(key.created_at).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{#if selectedNsecKey}
<div class="space-y-2">
<label for="stored-nsec-password" class="block text-sm font-medium text-fog-text dark:text-fog-dark-text">
Password
</label>
<input
id="stored-nsec-password"
type="password"
bind:value={nsecPassword}
placeholder="Enter your password"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
<button
onclick={loginWithStoredNsec}
disabled={loading || !nsecPassword}
class="w-full px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white hover:opacity-90 disabled:opacity-50 transition-colors rounded"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</div>
{/if}
<button
onclick={() => { showNewNsecForm = true; selectedNsecKey = null; }}
class="w-full px-4 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors rounded"
>
Add New Nsec Key
</button>
</div>
{:else}
<!-- New nsec form -->
<div class="space-y-4">
{#if storedNsecKeys.length > 0}
<button
onclick={() => { showNewNsecForm = false; selectedNsecKey = null; }}
class="text-sm text-fog-accent dark:text-fog-dark-accent hover:underline"
>
← Back to stored keys
</button>
{/if}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Enter your nsec key. It will be encrypted and stored securely in your browser.
</p>
<div>
<label for="nsec-input" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
Nsec Key
</label>
<input
id="nsec-input"
type="password"
bind:value={nsecInput}
placeholder="nsec1..."
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
</div>
<div>
<label for="nsec-password" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
Encryption Password
</label>
<input
id="nsec-password"
type="password"
bind:value={nsecPassword}
placeholder="Enter a password (min 8 characters)"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
</div>
<div>
<label for="nsec-password-confirm" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
Confirm Password
</label>
<input
id="nsec-password-confirm"
type="password"
bind:value={nsecPasswordConfirm}
placeholder="Confirm your password"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
<p class="text-xs text-fog-text-light dark:text-fog-dark-text-light mt-1">
This password encrypts your key. You'll need it each time you sign events.
</p>
</div>
<button
onclick={loginWithNsec}
disabled={loading}
class="w-full px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white hover:opacity-90 disabled:opacity-50 transition-colors rounded"
>
{loading ? 'Encrypting and storing...' : 'Login with Nsec'}
</button>
</div>
{/if}
</div>
{:else if activeTab === 'anonymous'}
<div class="space-y-4">
{#if storedAnonymousKeys.length > 0 && !showNewAnonymousForm}
<div class="space-y-3">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Select a stored anonymous key or generate a new one:
</p>
<!-- Stored keys list -->
<div class="space-y-2">
{#each storedAnonymousKeys as key}
<button
onclick={() => { selectedAnonymousKey = key.pubkey; anonymousPassword = ''; }}
class="w-full text-left px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors {selectedAnonymousKey === key.pubkey ? 'ring-2 ring-fog-accent dark:ring-fog-dark-accent' : ''}"
>
<div class="font-mono text-sm break-all">{formatNpub(key.pubkey)}</div>
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
Created {new Date(key.created_at).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{#if selectedAnonymousKey}
<div class="space-y-2">
<label for="stored-anonymous-password" class="block text-sm font-medium text-fog-text dark:text-fog-dark-text">
Password
</label>
<input
id="stored-anonymous-password"
type="password"
bind:value={anonymousPassword}
placeholder="Enter your password"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
<button
onclick={loginWithStoredAnonymous}
disabled={loading || !anonymousPassword}
class="w-full px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white hover:opacity-90 disabled:opacity-50 transition-colors rounded"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</div>
{/if}
<button
onclick={() => { showNewAnonymousForm = true; selectedAnonymousKey = null; }}
class="w-full px-4 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors rounded"
>
Generate New Anonymous Key
</button>
</div>
{:else}
<!-- New anonymous form -->
<div class="space-y-4">
{#if storedAnonymousKeys.length > 0}
<button
onclick={() => { showNewAnonymousForm = false; selectedAnonymousKey = null; }}
class="text-sm text-fog-accent dark:text-fog-dark-accent hover:underline"
>
← Back to stored keys
</button>
{/if}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Other authentication methods (nsec, anonymous) coming soon...
Generate a new anonymous key. It will be encrypted and stored securely in your browser.
</p>
<div>
<label for="anonymous-password" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
Encryption Password
</label>
<input
id="anonymous-password"
type="password"
bind:value={anonymousPassword}
placeholder="Enter a password (min 8 characters)"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
</div>
<div>
<label for="anonymous-password-confirm" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
Confirm Password
</label>
<input
id="anonymous-password-confirm"
type="password"
bind:value={anonymousPasswordConfirm}
placeholder="Confirm your password"
class="w-full px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text rounded"
disabled={loading}
/>
</div>
<button
onclick={loginAsAnonymous}
disabled={loading}
class="w-full px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white hover:opacity-90 disabled:opacity-50 transition-colors rounded"
>
{loading ? 'Generating key...' : 'Generate Anonymous Key'}
</button>
</div>
{/if}
</div>
{/if}
</div>
</main>

2
src/routes/rss/[pubkey]/+server.ts

@ -67,7 +67,7 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -67,7 +67,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
<description>${escapeXml(profileAbout || `Nostr feed for ${profileName}`)}</description>
<language>en</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<generator>Aitherboard</generator>
<generator>aitherboard</generator>
${profilePicture ? `<image><url>${escapeXml(profilePicture)}</url><title>${escapeXml(profileName)}</title><link>${baseUrl}/profile/${pubkey}</link></image>` : ''}
${sortedEvents.map(event => {
const title = getEventTitle(event);

3
tailwind.config.js

@ -29,7 +29,8 @@ export default { @@ -29,7 +29,8 @@ export default {
}
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'sans-serif']
sans: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'monospace'],
mono: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'monospace']
}
}
},

Loading…
Cancel
Save