Browse Source

render publication content

imwald
Silberengel 1 week ago
parent
commit
5baad05463
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 27
      src/lib/index-relay-http.test.ts
  4. 8
      src/lib/index-relay-http.ts
  5. 10
      src/lib/library-index-idb-cache.ts
  6. 14
      src/lib/library-publication-index.ts
  7. 30
      src/lib/publication-index.test.ts
  8. 19
      src/lib/publication-index.ts
  9. 2
      src/services/client-events.service.ts
  10. 34
      src/services/indexed-db.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.21.0",
"version": "23.21.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.21.0",
"version": "23.21.1",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "imwald",
"version": "23.21.0",
"version": "23.21.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",

27
src/lib/index-relay-http.test.ts

@ -5,7 +5,7 @@ import { @@ -5,7 +5,7 @@ import {
isIndexRelayTransportFailure,
rawToIndexRelayEvent
} from '@/lib/index-relay-http'
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools'
import { describe, expect, it, beforeEach } from 'vitest'
describe('isIndexRelayTransportFailure', () => {
@ -50,5 +50,30 @@ describe('rawToIndexRelayEvent', () => { @@ -50,5 +50,30 @@ describe('rawToIndexRelayEvent', () => {
const parsed = rawToIndexRelayEvent(mercuryRow)
expect(parsed?.content).toBe('')
expect(parsed?.kind).toBe(30040)
expect(verifyEvent(parsed!)).toBe(true)
})
it('rejects kind 30040 when tags do not match id/sig', () => {
const sk = generateSecretKey()
const pubkey = getPublicKey(sk)
const verified = finalizeEvent(
{
kind: 30040,
created_at: 1_700_000_000,
tags: [
['d', 'book'],
['title', 'Test Book'],
['a', `30041:${pubkey}:chapter-1`],
['a', `30041:${pubkey}:chapter-2`]
],
content: ''
},
sk
)
const scrambled = {
...verified,
tags: [...verified.tags].reverse()
} as unknown as Record<string, unknown>
expect(rawToIndexRelayEvent(scrambled)).toBeNull()
})
})

8
src/lib/index-relay-http.ts

@ -16,7 +16,7 @@ import { @@ -16,7 +16,7 @@ import {
normalizeHttpRelayUrl
} from '@/lib/url'
import type { Filter, Event as NEvent } from 'nostr-tools'
import { validateEvent, verifyEvent } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools'
function trimSlash(base: string): string {
return base.replace(/\/+$/, '')
@ -223,7 +223,7 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null { @@ -223,7 +223,7 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null {
/**
* Parse HTTP index relay rows for Library discovery. Kind 30040 content is always normalized to `''`.
* When verify fails (some index mirrors store stale id/sig), accept structurally valid 30040 rows.
* Signature must verify tag order is part of the signed event (NKBIP-01 reading order).
*/
export function rawToIndexRelayEvent(raw: Record<string, unknown>): NEvent | null {
try {
@ -255,9 +255,7 @@ export function rawToIndexRelayEvent(raw: Record<string, unknown>): NEvent | nul @@ -255,9 +255,7 @@ export function rawToIndexRelayEvent(raw: Record<string, unknown>): NEvent | nul
content,
sig
} as NEvent
if (verifyEvent(ev)) return ev
if (kind === ExtendedKind.PUBLICATION && validateEvent(ev)) return ev
return null
return verifyEvent(ev) ? ev : null
} catch {
return null
}

10
src/lib/library-index-idb-cache.ts

@ -4,12 +4,20 @@ import { @@ -4,12 +4,20 @@ import {
getLibraryIndexCacheBudget
} from '@/lib/library-index-cache-config'
import logger from '@/lib/logger'
import { isVerifiedPublicationIndex } from '@/lib/publication-index'
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try {
return await indexedDb.getLibraryPublicationIndexCacheEvents()
const cached = await indexedDb.getLibraryPublicationIndexCacheEvents()
const verified = cached.filter(isVerifiedPublicationIndex)
if (verified.length < cached.length) {
void indexedDb
.pruneUnverifiedLibraryPublicationIndexCacheEvents()
.catch(() => {})
}
return verified
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] index IDB read failed', {

14
src/lib/library-publication-index.ts

@ -20,6 +20,7 @@ import { @@ -20,6 +20,7 @@ import {
hydrateNestedIndexEvents
} from '@/lib/publication-index'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { verifyEvent } from 'nostr-tools'
import { isEventInPinList } from '@/lib/replaceable-list-latest'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
@ -215,7 +216,18 @@ function dedupeEventsById(events: Event[]): Event[] { @@ -215,7 +216,18 @@ function dedupeEventsById(events: Event[]): Event[] {
const byId = new Map<string, Event>()
for (const ev of events) {
const prev = byId.get(ev.id)
if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev)
if (!prev) {
byId.set(ev.id, ev)
continue
}
const prevVerified = verifyEvent(prev)
const nextVerified = verifyEvent(ev)
if (nextVerified && !prevVerified) {
byId.set(ev.id, ev)
continue
}
if (prevVerified && !nextVerified) continue
if (ev.created_at > prev.created_at) byId.set(ev.id, ev)
}
return [...byId.values()]
}

30
src/lib/publication-index.test.ts

@ -9,19 +9,21 @@ import { @@ -9,19 +9,21 @@ import {
getTopLevelIndexEvents
} from '@/lib/publication-index'
import type { Event } from 'nostr-tools'
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
const PK = 'a'.repeat(64)
const sk = generateSecretKey()
const PK = getPublicKey(sk)
function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event {
return {
id,
kind: ExtendedKind.PUBLICATION,
pubkey: PK,
created_at: 100,
content: '',
tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])],
sig: 'c'.repeat(128)
}
function indexEvent(d: string, aTags: string[]): Event {
return finalizeEvent(
{
kind: ExtendedKind.PUBLICATION,
created_at: 100,
content: '',
tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])]
},
sk
)
}
function contentEvent(d: string, id = d.padEnd(64, '1').slice(0, 64)): Event {
@ -50,7 +52,7 @@ describe('publication-index', () => { @@ -50,7 +52,7 @@ describe('publication-index', () => {
it('getTopLevelIndexEvents excludes nested 30040 children', () => {
const childAddr = `30040:${PK}:part-1`
const root = indexEvent('book', [childAddr, `30041:${PK}:intro`])
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64))
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`])
const top = getTopLevelIndexEvents([root, child])
expect(top).toHaveLength(1)
expect(eventTagAddress(top[0])).toBe(`30040:${PK}:book`)
@ -60,7 +62,7 @@ describe('publication-index', () => { @@ -60,7 +62,7 @@ describe('publication-index', () => {
const childAddr = `30040:${PK}:part-1`
const leafAddr = `30041:${PK}:chapter-1`
const root = indexEvent('book', [childAddr, `30041:${PK}:intro`])
const child = indexEvent('part-1', [leafAddr], '2'.repeat(64))
const child = indexEvent('part-1', [leafAddr])
const indexByAddress = buildIndexByAddress([root, child])
const reachable = collectReachableAddressesCached(root, indexByAddress)
@ -74,7 +76,7 @@ describe('publication-index', () => { @@ -74,7 +76,7 @@ describe('publication-index', () => {
it('fetchMissingIndex resolves nested index not in initial cache', async () => {
const childAddr = `30040:${PK}:part-1`
const root = indexEvent('book', [childAddr])
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64))
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`])
const indexByAddress = buildIndexByAddress([root])
const reachable = await collectReachableAddresses(root, indexByAddress, async (addr) => {

19
src/lib/publication-index.ts

@ -5,6 +5,23 @@ import { @@ -5,6 +5,23 @@ import {
type PublicationSectionRef
} from '@/lib/publication-section-fetch'
import type { Event } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools'
/** Normalize kind-30040 rows before signature check (NKBIP-01 empty content; lowercase hex). */
export function publicationIndexForVerify(event: Event): Event {
return {
...event,
id: event.id.toLowerCase(),
pubkey: event.pubkey.toLowerCase(),
content: event.content ?? ''
}
}
/** True when the event is a kind-30040 index and the signature matches the tag array. */
export function isVerifiedPublicationIndex(event: Event): boolean {
if (event.kind !== ExtendedKind.PUBLICATION) return false
return verifyEvent(publicationIndexForVerify(event))
}
export function eventTagAddress(event: Event): string | null {
const d = event.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1]
@ -23,7 +40,7 @@ export function filterValidIndexEvents(events: Event[]): Event[] { @@ -23,7 +40,7 @@ export function filterValidIndexEvents(events: Event[]): Event[] {
const hasD = event.tags.some((t) => (t[0] || '').trim().toLowerCase() === 'd' && t[1])
const hasA = event.tags.some((t) => t[0] === 'a' && t[1])
const hasE = event.tags.some((t) => t[0] === 'e' && t[1])
return hasTitle && hasD && (hasA || hasE)
return hasTitle && hasD && (hasA || hasE) && isVerifiedPublicationIndex(event)
})
}

2
src/services/client-events.service.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { isVerifiedPublicationIndex } from '@/lib/publication-index'
import {
AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS,
ExtendedKind,
@ -670,6 +671,7 @@ export class EventService { @@ -670,6 +671,7 @@ export class EventService {
*/
addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions): void {
if (shouldDropEventOnIngest(event, ingestOpts)) return
if (event.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(event)) return
const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses
// REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids.

34
src/services/indexed-db.service.ts

@ -27,6 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' @@ -27,6 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { isVerifiedPublicationIndex } from '@/lib/publication-index'
import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import {
@ -3691,6 +3692,39 @@ class IndexedDbService { @@ -3691,6 +3692,39 @@ class IndexedDbService {
}
}
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0
const toDelete: string[] = []
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve()
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
if (row?.value?.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(row.value)) {
toDelete.push(cursor.key as string)
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
return toDelete.length
}
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []

Loading…
Cancel
Save