Only trust content from trusted relays by default #85
@@ -10,8 +10,10 @@ import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
uniqNormalizedRelays,
|
||||
} from '../../../utils/nostr';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
|
||||
import tooltip from '../../../modifiers/tooltip';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
@@ -49,6 +51,10 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get trustedRelaySet() {
|
||||
return new Set(this.nostrData.trustedRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get requiredWriteRelaySet() {
|
||||
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||
}
|
||||
@@ -78,6 +84,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredReadRelaySet.has(url),
|
||||
isTrusted: this.trustedRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -182,6 +189,26 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
this.settings.update('nostrReadRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleRelayTrust(url) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (!normalized) return;
|
||||
// Required relays are always trusted and cannot be untrusted.
|
||||
if (this.requiredReadRelaySet.has(normalized)) return;
|
||||
|
||||
const current = uniqNormalizedRelays(
|
||||
this.settings.nostrTrustedRelays || []
|
||||
);
|
||||
const idx = current.indexOf(normalized);
|
||||
let next;
|
||||
if (idx >= 0) {
|
||||
next = current.filter((r) => r !== normalized);
|
||||
} else {
|
||||
next = [...current, normalized];
|
||||
}
|
||||
this.settings.update('nostrTrustedRelays', next.length > 0 ? next : null);
|
||||
}
|
||||
|
||||
@action
|
||||
addWriteRelay() {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
@@ -251,7 +278,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive no-unsupported-role-attributes }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="zap" @size={{20}} />
|
||||
@@ -260,21 +287,56 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<p class="relay-list-hint">
|
||||
Mark relays as trusted to show
|
||||
<strong>all</strong>
|
||||
photos and reviews from them by default (risk of spam).
|
||||
</p>
|
||||
<ul class="relay-list">
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-trust-relay {{if relay.isTrusted 'is-trusted'}}"
|
||||
aria-label="Toggle trust"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relay — always trusted"
|
||||
(if
|
||||
relay.isTrusted
|
||||
"Trusted: show all content from this relay"
|
||||
"Untrusted: hide content from outside of my circles"
|
||||
)
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.toggleRelayTrust relay.url)}}
|
||||
>
|
||||
<Icon
|
||||
@name="shield"
|
||||
@size={{14}}
|
||||
@color="currentColor"
|
||||
@filled={{relay.isTrusted}}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -311,17 +373,23 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
@@ -113,6 +113,17 @@ export default class PlaceDetails extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
get hasUntrustedContent() {
|
||||
return (
|
||||
this.nostrData.untrustedContentCount > 0 &&
|
||||
!this.nostrData.showUntrustedContent
|
||||
);
|
||||
}
|
||||
|
||||
get hasMultipleUntrusted() {
|
||||
return this.nostrData.untrustedContentCount > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
@@ -618,6 +629,23 @@ export default class PlaceDetails extends Component {
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{#if this.hasUntrustedContent}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="shield" />
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
{{on "click" this.nostrData.toggleShowUntrustedContent}}
|
||||
>
|
||||
Show
|
||||
{{this.nostrData.untrustedContentCount}}
|
||||
hidden photo{{if this.hasMultipleUntrusted "s" ""}}
|
||||
(untrusted relays)
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element) => {
|
||||
let tooltipEl = null;
|
||||
const portal = document.getElementById('modal-portal') || document.body;
|
||||
|
||||
const show = () => {
|
||||
const text = element.getAttribute('aria-description');
|
||||
if (!text || tooltipEl) return;
|
||||
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'tooltip';
|
||||
tooltipEl.setAttribute('role', 'tooltip');
|
||||
tooltipEl.textContent = text;
|
||||
portal.appendChild(tooltipEl);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tipRect = tooltipEl.getBoundingClientRect();
|
||||
const margin = 6;
|
||||
let top = rect.top - tipRect.height - margin;
|
||||
if (top < margin) top = rect.bottom + margin;
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
left = Math.max(
|
||||
margin,
|
||||
Math.min(left, window.innerWidth - tipRect.width - margin)
|
||||
);
|
||||
|
||||
tooltipEl.style.top = `${top}px`;
|
||||
tooltipEl.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
tooltipEl?.remove();
|
||||
tooltipEl = null;
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', show);
|
||||
element.addEventListener('mouseleave', hide);
|
||||
element.addEventListener('focus', show);
|
||||
element.addEventListener('blur', hide);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mouseenter', show);
|
||||
element.removeEventListener('mouseleave', hide);
|
||||
element.removeEventListener('focus', show);
|
||||
element.removeEventListener('blur', hide);
|
||||
hide();
|
||||
};
|
||||
});
|
||||
+219
-53
@@ -1,17 +1,20 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EMPTY, from } from 'rxjs';
|
||||
import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { RelayGroup } from 'applesauce-relay';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
relayUrlMatches,
|
||||
uniqNormalizedRelays,
|
||||
} from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
@@ -24,12 +27,30 @@ const DIRECTORY_RELAYS = [
|
||||
];
|
||||
|
||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||
const DEFAULT_WRITE_RELAYS = [];
|
||||
const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco
|
||||
|
||||
// Content event kinds subject to relay-trust filtering. Adding `30360`
|
||||
// (place reviews) here is the only change needed to extend the feature.
|
||||
const TRUSTED_CONTENT_KINDS = [360];
|
||||
|
||||
// localforage store name for persisted relay provenance.
|
||||
const PROVENANCE_STORE = 'event-relay-provenance';
|
||||
|
||||
// Replicates applesauce-relay's `completeWhen`: completes the source when the
|
||||
// `operator` emits a truthy value. `completeWhen` is used internally by
|
||||
// RelayGroup but not re-exported, so we recreate it from rxjs primitives.
|
||||
function completeWhen(operator) {
|
||||
return connect((shared$) => {
|
||||
const complete$ = shared$.pipe(operator, filter(Boolean), take(1));
|
||||
return shared$.pipe(takeUntil(complete$));
|
||||
});
|
||||
}
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@@ -41,11 +62,26 @@ export default class NostrDataService extends Service {
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
// Relay-provenance trust state. `_eventRelays` maps eventId -> Set<relayUrl>
|
||||
// and accumulates every relay an event was seen on (unconditionally, so an
|
||||
// event first seen on an untrusted relay can be upgraded to trusted when it
|
||||
// later arrives from a trusted one). Hydrated from IDB at init so cached
|
||||
// events render with correct trust state instantly.
|
||||
_eventRelays = new Map();
|
||||
_provenanceReady = null;
|
||||
|
||||
// Session-only reveal toggle for untrusted content. Not persisted.
|
||||
@tracked showUntrustedContent = false;
|
||||
// Count of currently-hidden (untrusted) place photos for the selected place.
|
||||
@tracked untrustedContentCount = 0;
|
||||
_allPlacePhotos = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
@@ -109,6 +145,25 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
});
|
||||
|
||||
// Hydrate relay provenance from IDB so cached events can be trust-checked
|
||||
// instantly without waiting for relay connections.
|
||||
this._provenanceReady = this._hydrateProvenance();
|
||||
|
||||
// Centralized kind-5 deletion handling: when a deletion event enters the
|
||||
// store, drop the provenance entries for the events it deletes so the
|
||||
// trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store
|
||||
.timeline([{ kinds: [5] }])
|
||||
.subscribe((events) => {
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
this._removeProvenance(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||
@@ -174,6 +229,130 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Relays treated as "moderated" for content-trust filtering. Always includes
|
||||
// the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays`
|
||||
// adds user-marked custom relays on top. Default (null) = required relays
|
||||
// only, so the app only surfaces content verified by those relays.
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an event should be considered trusted:
|
||||
* - authored by the connected user (own uploads), OR
|
||||
* - seen on at least one trusted (moderated) relay.
|
||||
*/
|
||||
isTrustedEvent(event) {
|
||||
if (!event) return false;
|
||||
const myPubkey = this.nostrAuth?.pubkey;
|
||||
if (myPubkey && event.pubkey === myPubkey) return true;
|
||||
|
||||
const relays = this._eventRelays.get(event.id);
|
||||
if (!relays || relays.size === 0) return false;
|
||||
const trusted = this.trustedRelays;
|
||||
for (const url of relays) {
|
||||
if (relayUrlMatches(url, trusted)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits content events into trusted / untrusted buckets. Non-content kinds
|
||||
* (e.g. kind 5 deletions) are always treated as trusted so they pass through.
|
||||
*/
|
||||
partitionByTrust(events) {
|
||||
const trusted = [];
|
||||
const untrusted = [];
|
||||
for (const event of events) {
|
||||
if (
|
||||
!TRUSTED_CONTENT_KINDS.includes(event.kind) ||
|
||||
this.isTrustedEvent(event)
|
||||
) {
|
||||
trusted.push(event);
|
||||
} else {
|
||||
untrusted.push(event);
|
||||
}
|
||||
}
|
||||
return { trusted, untrusted };
|
||||
}
|
||||
|
||||
// Hydrate the in-memory provenance map from the IDB store.
|
||||
async _hydrateProvenance() {
|
||||
try {
|
||||
await this.localForage.iterate(PROVENANCE_STORE, (value, key) => {
|
||||
this._eventRelays.set(key, new Set(value || []));
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[nostr-data] Failed to hydrate relay provenance', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Record that `eventId` was seen on `relayUrl`. Merges into the existing
|
||||
// set (accumulates across sightings) and persists fire-and-forget.
|
||||
_recordProvenance(eventId, relayUrl) {
|
||||
if (!eventId || !relayUrl) return;
|
||||
let set = this._eventRelays.get(eventId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._eventRelays.set(eventId, set);
|
||||
}
|
||||
if (set.has(relayUrl)) return;
|
||||
set.add(relayUrl);
|
||||
const urls = Array.from(set);
|
||||
// Fire-and-forget persistence; never blocks the render path.
|
||||
this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to persist relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove provenance for a deleted event (called on kind-5 processing).
|
||||
_removeProvenance(eventId) {
|
||||
if (!eventId) return;
|
||||
if (!this._eventRelays.delete(eventId)) return;
|
||||
this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to remove relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
* This mirrors `RelayGroup.request()` completion semantics (wait for the
|
||||
* first relay EOSE + 5s grace period, or all relays EOSE; 30s hard
|
||||
* timeout) but uses `pool.req()` so each `EVENT` message retains its
|
||||
* `from` relay URL. Every sighting is recorded in the provenance map
|
||||
* (unconditionally, including duplicates) so an event first seen on an
|
||||
* untrusted relay can be upgraded to trusted when it later arrives from
|
||||
* a trusted one. Events are always added to the store regardless of trust
|
||||
* status; filtering happens at presentation time.
|
||||
*/
|
||||
_requestContentWithProvenance(relays, filters, errorLabel) {
|
||||
const complete = RelayGroup.completeOnAny(
|
||||
RelayGroup.completeAfterFirstRelay(5_000),
|
||||
RelayGroup.completeOnAllEose()
|
||||
);
|
||||
return this.nostrRelay.pool
|
||||
.req(relays, filters)
|
||||
.pipe(
|
||||
completeWhen(complete),
|
||||
timeout({ first: 30_000 }),
|
||||
// Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR.
|
||||
// We deliberately do NOT dedupe so we see every relay's copy and can
|
||||
// accumulate full provenance across relays.
|
||||
filter((message) => message.type === 'EVENT')
|
||||
)
|
||||
.subscribe({
|
||||
next: (message) => {
|
||||
this._recordProvenance(message.event.id, message.from);
|
||||
this.store.add(message.event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(errorLabel, err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
@@ -212,25 +391,13 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request for new prefixes
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
'#g': missingPrefixes,
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos by geohash:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// Fire network request for new prefixes (captures relay provenance for
|
||||
// trust filtering; events are added to the store regardless of trust).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360], '#g': missingPrefixes }],
|
||||
'[nostr-data] Error fetching place photos by geohash:'
|
||||
);
|
||||
|
||||
for (const p of missingPrefixes) {
|
||||
this.loadedGeohashPrefixes.add(p);
|
||||
@@ -277,7 +444,8 @@ export default class NostrDataService extends Service {
|
||||
},
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
this._allPlacePhotos = events;
|
||||
this._updatePlacePhotos();
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
this._scheduleZapReceiptRefresh(events);
|
||||
@@ -305,25 +473,28 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request specifically for this place
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos for place:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// Fire network request specifically for this place (captures provenance).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360, 5], '#i': [entityId] }],
|
||||
'[nostr-data] Error fetching place photos for place:'
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter
|
||||
// and the session-only reveal toggle. Called whenever the timeline emits or
|
||||
// the user flips `showUntrustedContent`.
|
||||
_updatePlacePhotos() {
|
||||
const events = this._allPlacePhotos;
|
||||
const { trusted, untrusted } = this.partitionByTrust(events);
|
||||
this.untrustedContentCount = untrusted.length;
|
||||
this.placePhotos = this.showUntrustedContent ? events : trusted;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleShowUntrustedContent() {
|
||||
this.showUntrustedContent = !this.showUntrustedContent;
|
||||
this._updatePlacePhotos();
|
||||
}
|
||||
|
||||
async loadMyContributions(pubkey) {
|
||||
@@ -363,18 +534,13 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request fresh events from the network in the background
|
||||
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching my contribution events:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// 3. Request fresh events from the network in the background (captures
|
||||
// provenance; own-author events bypass trust filtering anyway).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
filters,
|
||||
'[nostr-data] Error fetching my contribution events:'
|
||||
);
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
|
||||
@@ -14,6 +14,10 @@ const DEFAULT_SETTINGS = {
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
// Relays treated as "moderated" for content-trust filtering. When null,
|
||||
// the app falls back to the required read relays (e.g. nostr.kosmos.org),
|
||||
// so default behavior only surfaces content verified by those relays.
|
||||
nostrTrustedRelays: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
@@ -30,6 +34,7 @@ export default class SettingsService extends Service {
|
||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||
@tracked nostrWriteRelayExclusions =
|
||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||
@tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays;
|
||||
@tracked experimentalEnablePhotoDeletion =
|
||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -123,6 +128,7 @@ export default class SettingsService extends Service {
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||
this.nostrTrustedRelays = finalSettings.nostrTrustedRelays;
|
||||
this.experimentalEnablePhotoDeletion =
|
||||
finalSettings.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -142,6 +148,7 @@ export default class SettingsService extends Service {
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
nostrTrustedRelays: this.nostrTrustedRelays,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
+86
-3
@@ -13,6 +13,10 @@
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
--secondary-text-color: #898989;
|
||||
--border-color: #d8d8d8;
|
||||
--success-color: #2e7d32;
|
||||
--secondary-background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -611,14 +615,28 @@ body {
|
||||
|
||||
.relay-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0.25rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.relay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.relay-list-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -639,12 +657,19 @@ body {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
.btn-remove-relay:hover:not(:disabled),
|
||||
.btn-remove-relay:active:not(:disabled) {
|
||||
background-color: var(--danger-color);
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay:disabled {
|
||||
border-color: var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -655,6 +680,64 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-trust-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-trust-relay svg {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-trust-relay.is-trusted {
|
||||
border-color: var(--success-color);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:hover,
|
||||
.btn-trust-relay:active {
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--body-text-color);
|
||||
color: var(--primary-background-color);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
animation: tooltip-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -34,6 +34,7 @@ import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import shield from 'feather-icons/dist/icons/shield.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||
@@ -295,6 +296,7 @@ const ICONS = {
|
||||
'castle-keep': castleKeep,
|
||||
x,
|
||||
zap,
|
||||
shield,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,27 @@ export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `relayUrl` matches any entry in `trustedRelays`.
|
||||
*
|
||||
* Comparison is performed on normalized relay URLs (lowercased, `wss://`
|
||||
* scheme ensured, trailing slashes stripped) via `normalizeRelayUrl`, so
|
||||
* callers may pass raw `wss://` URLs received from relays or bare hostnames.
|
||||
*
|
||||
* @param {string} relayUrl The relay URL a content event was seen on
|
||||
* @param {Array<string>} trustedRelays List of trusted relay URLs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function relayUrlMatches(relayUrl, trustedRelays = []) {
|
||||
if (!relayUrl) return false;
|
||||
const target = normalizeRelayUrl(relayUrl);
|
||||
if (!target) return false;
|
||||
const trustedSet = new Set(
|
||||
uniqNormalizedRelays(trustedRelays || []).filter(Boolean)
|
||||
);
|
||||
return trustedSet.has(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Relay Trust in Marco
|
||||
|
||||
## Purpose
|
||||
|
||||
Marco fetches Nostr content (currently kind 360 place photos, later kind 30360 reviews) from the user's read relays. Because anyone can run a relay and publish spam, Marco only displays content by default when it was seen on a **trusted relay** — a relay the user (or Marco's defaults) treats as sufficiently moderated.
|
||||
|
||||
## Trust Model
|
||||
|
||||
| Rule | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Required relays** | Always trusted (e.g. `wss://nostr.kosmos.org`). Cannot be untrusted. |
|
||||
| **Custom relays** | Untrusted by default. Users toggle them to trusted in **Settings → Nostr → Read Relays**. |
|
||||
| **Own content** | Events authored by the connected user's pubkey are always trusted. |
|
||||
| **Non-content kinds** | Kind 5 deletions and metadata always pass through unfiltered. |
|
||||
| **Provenance upgrades** | If an event is first seen on an untrusted relay and later on a trusted one, it becomes visible. |
|
||||
| **Hiding, not blocking** | Untrusted content is cached and stored; it is filtered at presentation time so it can be revealed on demand. |
|
||||
|
||||
## How It Works Under the Hood
|
||||
|
||||
### 1. Provenance Capture
|
||||
|
||||
- Instead of `pool.request()` (which drops relay origin via an internal `map(m => m.event)`), Marco uses `RelayPool.req()` from `applesauce-relay`.
|
||||
- Each `EVENT` message includes `from: relayUrl`.
|
||||
- Every sighting is recorded in an in-memory map: `eventId → Set<relayUrl>`.
|
||||
- Duplicate deliveries are _not_ deduplicated at the stream level, so full provenance across all responding relays is preserved.
|
||||
|
||||
### 2. Persistence
|
||||
|
||||
- Provenance is persisted to IndexedDB via `localForage` under the store `event-relay-provenance`.
|
||||
- On app start, it is hydrated into memory so cached photos render instantly with correct trust state before relay connections are established.
|
||||
|
||||
### 3. Trust Evaluation
|
||||
|
||||
- `trustedRelays` = required read relays + `settings.nostrTrustedRelays` (user-marked custom relays).
|
||||
- An event is trusted if any relay URL in its provenance set matches `trustedRelays` (compared via normalized URLs).
|
||||
- Trust is evaluated when presenting the photo timeline for a place.
|
||||
|
||||
### 4. Presentation Filtering
|
||||
|
||||
- `NostrDataService.placePhotos` contains only trusted events.
|
||||
- `untrustedContentCount` tracks how many are hidden.
|
||||
- A session-only toggle `showUntrustedContent` reveals them via the "Show N hidden photo(s) (untrusted relays)" button in place details.
|
||||
|
||||
### 5. Deletions
|
||||
|
||||
- When a kind 5 deletion event enters the store, provenance entries for the referenced event IDs are removed from memory and IndexedDB.
|
||||
|
||||
## Key Code Paths
|
||||
|
||||
| File | What it does |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/services/nostr-data.js` | `_requestContentWithProvenance()`, `isTrustedEvent()`, `partitionByTrust()`, `trustedRelays` getter, provenance hydration/persistence |
|
||||
| `app/services/settings.js` | `nostrTrustedRelays` setting (null = required relays only) |
|
||||
| `app/components/app-menu/settings/nostr.gjs` | Per-relay shield toggle in read-relay list |
|
||||
| `app/components/place-details.gjs` | Hidden-content reveal link in meta section |
|
||||
| `app/utils/nostr.js` | `relayUrlMatches()` normalization/matching helper |
|
||||
| `app/utils/icons.js` | `shield` icon used for trusted state |
|
||||
| `app/styles/app.css` | `.btn-trust-relay` styles in relay settings |
|
||||
|
||||
## Extending the Feature
|
||||
|
||||
- **Add kind 30360 reviews:** Add `30360` to `TRUSTED_CONTENT_KINDS` in `app/services/nostr-data.js`. Reuse `partitionByTrust` in the reviews timeline.
|
||||
- **Followee trust:** Extend `isTrustedEvent()` to also check `event.pubkey` against a contact/follow list.
|
||||
- **Spam eviction from cache:** Provenance data already supports this; future work can purge cached events from `nostr-idb` if they only appear on relays marked untrusted.
|
||||
- **Provenance UI:** The stored `eventId → relayUrls` map can power a "Seen on these relays" tooltip or details pane.
|
||||
|
||||
## Known Limitations / Future Work
|
||||
|
||||
- Provenance store is currently unbounded; add LRU/TTL cleanup.
|
||||
- Untrusting a relay hides its content immediately, but does not purge the `nostr-idb` event cache.
|
||||
- Relay settings UI is currently the only way to manage trust; no global "trust all" toggle.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
@@ -59,6 +59,11 @@ class MockNostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
@@ -108,8 +113,17 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||
|
||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||
const requiredRemoveBtn = requiredRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(requiredRemoveBtn).exists();
|
||||
assert.true(requiredRemoveBtn.disabled, 'required relay cannot be removed');
|
||||
assert.strictEqual(
|
||||
requiredRemoveBtn.getAttribute('aria-description'),
|
||||
'Required relays cannot be removed'
|
||||
);
|
||||
|
||||
const mailboxRemoveBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(mailboxRemoveBtn).exists();
|
||||
assert.false(mailboxRemoveBtn.disabled);
|
||||
});
|
||||
|
||||
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||
@@ -217,4 +231,76 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.exists('fallback toggle visible with multiple media servers');
|
||||
});
|
||||
|
||||
test('required read relay is trusted and its trust toggle is disabled', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||
|
||||
const trustBtn = requiredRow.querySelector('.btn-trust-relay');
|
||||
assert.dom(trustBtn).exists('trust toggle renders for required relay');
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
assert.true(trustBtn.disabled, 'required relay toggle is disabled');
|
||||
});
|
||||
|
||||
test('toggling trust on a custom read relay marks it trusted', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
assert.false(trustBtn.classList.contains('is-trusted'), 'starts untrusted');
|
||||
assert.strictEqual(this.settings.nostrTrustedRelays, null);
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.deepEqual(this.settings.nostrTrustedRelays, [
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
const updatedBtn = rowByText(
|
||||
readRows(element),
|
||||
'custom.example.com'
|
||||
).querySelector('.btn-trust-relay');
|
||||
assert.true(updatedBtn.classList.contains('is-trusted'), 'now trusted');
|
||||
});
|
||||
|
||||
test('toggling trust off removes relay from trusted list', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
this.settings.update('nostrTrustedRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.strictEqual(
|
||||
this.settings.nostrTrustedRelays,
|
||||
null,
|
||||
'empty list collapses to null'
|
||||
);
|
||||
});
|
||||
|
||||
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
const removeBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
|
||||
assert.dom('.tooltip', document.body).doesNotExist('no tooltip initially');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
|
||||
assert.dom('.tooltip', document.body).hasText('Remove relay');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert
|
||||
.dom('.tooltip', document.body)
|
||||
.doesNotExist('tooltip removed on mouseleave');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
parsePlacePhotos,
|
||||
relayUrlMatches,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
|
||||
@@ -324,4 +325,44 @@ module('Unit | Utility | nostr', function () {
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('relayUrlMatches returns true for normalized trusted relays', function (assert) {
|
||||
const trusted = ['wss://nostr.kosmos.org', 'wss://relay.damus.io'];
|
||||
|
||||
assert.true(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', trusted),
|
||||
'exact match'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('wss://nostr.kosmos.org/', trusted),
|
||||
'trailing slash normalized'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('WSS://Nostr.Kosmos.org/', trusted),
|
||||
'case-insensitive'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('nostr.kosmos.org', trusted),
|
||||
'bare hostname gets wss:// prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('relayUrlMatches returns false for non-trusted relays', function (assert) {
|
||||
const trusted = ['wss://nostr.kosmos.org'];
|
||||
|
||||
assert.false(
|
||||
relayUrlMatches('wss://spam.example.com', trusted),
|
||||
'untrusted relay'
|
||||
);
|
||||
assert.false(relayUrlMatches('', trusted), 'empty url');
|
||||
assert.false(relayUrlMatches(null, trusted), 'null url');
|
||||
assert.false(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', []),
|
||||
'empty trust list'
|
||||
);
|
||||
assert.false(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', null),
|
||||
'null trust list'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user