fix: ignore invalid event id and invalid pubkey

This commit is contained in:
Shusui MOYATANI
2023-05-14 03:26:25 +09:00
parent dd64ef1724
commit 1b15989faf
11 changed files with 258 additions and 199 deletions

View File

@@ -1,4 +1,6 @@
import { createSignal, onCleanup, createEffect, For, type Component, type JSX } from 'solid-js';
import { For, type Component, type JSX } from 'solid-js';
import Popup, { PopupRef } from '@/components/utils/Popup';
export type MenuItem = {
content: () => JSX.Element;
@@ -32,60 +34,24 @@ const MenuItemDisplay: Component<MenuItemDisplayProps> = (props) => {
};
const ContextMenu: Component<ContextMenuProps> = (props) => {
let menuRef: HTMLUListElement | undefined;
let popupRef: PopupRef | undefined;
const [isOpen, setIsOpen] = createSignal(false);
const handleClickOutside = (ev: MouseEvent) => {
const target = ev.target as HTMLElement;
if (target != null && !menuRef?.contains(target)) {
setIsOpen(false);
}
};
const addClickOutsideHandler = () => {
document.addEventListener('mousedown', handleClickOutside);
};
const removeClickOutsideHandler = () => {
document.removeEventListener('mousedown', handleClickOutside);
};
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
if (menuRef == null) return;
const buttonRect = ev.currentTarget.getBoundingClientRect();
// const menuRect = menuRef.getBoundingClientRect();
menuRef.style.left = `${buttonRect.left - buttonRect.width}px`;
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
open();
};
createEffect(() => {
if (isOpen()) {
addClickOutsideHandler();
} else {
removeClickOutsideHandler();
}
});
onCleanup(() => removeClickOutsideHandler());
const close = () => popupRef?.close();
return (
<div>
<button onClick={handleClick}>{props.children}</button>
<ul
ref={menuRef}
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md"
classList={{ hidden: !isOpen(), block: isOpen() }}
>
<Popup
ref={(e) => {
popupRef = e;
}}
button={props.children}
position="bottom"
>
<ul class="min-w-[96px] rounded border bg-white shadow-md">
<For each={props.menu.filter((e) => e.when == null || e.when())}>
{(item: MenuItem) => <MenuItemDisplay item={item} onClose={close} />}
</For>
</ul>
</div>
</Popup>
);
};

View File

@@ -18,14 +18,14 @@ export type ProfileEditProps = {
};
const LNURLRegexString = '(LNURL1[AC-HJ-NP-Z02-9]+|lnurl1[ac-hj-np-z02-9]+)';
const InternetIdentiferRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+';
const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`;
const InternetIdentifierRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+';
const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentifierRegexString})$`;
const LNURLRegex = new RegExp(`^${LNURLRegexString}$`);
const InternetIdentiferRegex = new RegExp(`^${InternetIdentiferRegexString}$`);
const InternetIdentifierRegex = new RegExp(`^${InternetIdentifierRegexString}$`);
const isLNURL = (s: string) => LNURLRegex.test(s);
const isInternetIdentifier = (s: string) => InternetIdentiferRegex.test(s);
const isInternetIdentifier = (s: string) => InternetIdentifierRegex.test(s);
const ProfileEdit: Component<ProfileEditProps> = (props) => {
const pubkey = usePubkey();
@@ -260,7 +260,7 @@ const ProfileEdit: Component<ProfileEditProps> = (props) => {
name="nip05"
value={nip05()}
placeholder="yourname@domain.example.com"
pattern={InternetIdentiferRegex.source}
pattern={InternetIdentifierRegex.source}
disabled={disabled()}
onChange={(ev) => setNIP05(ev.currentTarget.value)}
onKeyDown={ignoreEnter}

View File

@@ -10,6 +10,7 @@ import useModalState from '@/hooks/useModalState';
import eventWrapper from '@/nostr/event';
import useEvent from '@/nostr/useEvent';
import useProfile from '@/nostr/useProfile';
import ensureNonNull from '@/utils/ensureNonNull';
type ReactionProps = {
event: NostrEvent;
@@ -18,14 +19,18 @@ type ReactionProps = {
const Reaction: Component<ReactionProps> = (props) => {
const { showProfile } = useModalState();
const event = () => eventWrapper(props.event);
const eventId = () => event().taggedEvents()[0].id;
const eventId = () => event().lastTaggedEventId();
const { profile } = useProfile(() => ({
pubkey: props.event.pubkey,
}));
const { event: reactedEvent, query: reactedEventQuery } = useEvent(() => ({
eventId: eventId(),
}));
const { event: reactedEvent, query: reactedEventQuery } = useEvent(() =>
ensureNonNull([eventId()] as const)(([eventIdNonNull]) => ({
eventId: eventIdNonNull,
})),
);
const isRemoved = () => reactedEventQuery.isSuccess && reactedEvent() == null;
return (
@@ -67,7 +72,7 @@ const Reaction: Component<ReactionProps> = (props) => {
<div class="notification-event py-1">
<Show
when={reactedEvent()}
fallback={<div class="truncate">loading {eventId()}</div>}
fallback={<div class="truncate"> {eventId()}</div>}
keyed
>
{(ev) => <TextNoteDisplay event={ev} />}

View File

@@ -37,8 +37,52 @@ export type TextNoteDisplayProps = {
actions?: boolean;
};
type EmojiReactionsProps = {
reactionsGroupedByContent: Map<string, NostrEvent[]>;
onReaction: (emoji: string) => void;
};
const { noteEncode } = nip19;
const EmojiReactions: Component<EmojiReactionsProps> = (props) => {
const { config } = useConfig();
const pubkey = usePubkey();
return (
<div class="flex gap-2 pt-1">
<For each={[...props.reactionsGroupedByContent.entries()]}>
{([content, events]) => {
const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
return (
<button
class="flex items-center rounded border px-1"
classList={{
'text-zinc-400': !isReactedByMeWithThisContent,
'bg-rose-50': isReactedByMeWithThisContent,
'border-rose-200': isReactedByMeWithThisContent,
'text-rose-400': isReactedByMeWithThisContent,
}}
type="button"
onClick={() => props.onReaction(content)}
>
<Show when={content === '+'} fallback={<span class="text-xs">{content}</span>}>
<span class="inline-block h-3 w-3 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Show>
<Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span>
</Show>
</button>
);
}}
</For>
</div>
);
};
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
let contentRef: HTMLDivElement | undefined;
@@ -349,40 +393,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</Show>
<Show when={actions()}>
<Show when={config().showEmojiReaction && reactions().length > 0}>
<div class="flex gap-2 pt-1">
<For each={[...reactionsGroupedByContent().entries()]}>
{([content, events]) => {
const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
return (
<button
class="flex items-center rounded border px-1"
classList={{
'text-zinc-400': !isReactedByMeWithThisContent,
'bg-rose-50': isReactedByMeWithThisContent,
'border-rose-200': isReactedByMeWithThisContent,
'text-rose-400': isReactedByMeWithThisContent,
}}
type="button"
onClick={() => doReaction(content)}
>
<Show
when={content === '+'}
fallback={<span class="text-xs">{content}</span>}
>
<span class="inline-block h-3 w-3 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Show>
<Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span>
</Show>
</button>
);
}}
</For>
</div>
<EmojiReactions
reactionsGroupedByContent={reactionsGroupedByContent()}
onReaction={doReaction}
/>
</Show>
<div class="actions flex w-48 items-center justify-between gap-8 pt-1">
<button
@@ -419,26 +433,15 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
'text-rose-400': isReactedByMe() || publishReactionMutation.isLoading,
}}
>
<Show
when={!config().useEmojiReaction}
fallback={
<EmojiPicker onEmojiSelect={(emoji) => doReaction(emoji)}>
<span class="inline-block h-4 w-4">
<Plus />
</span>
</EmojiPicker>
}
<button
class="h-4 w-4"
onClick={handleReaction}
disabled={publishReactionMutation.isLoading}
>
<button
class="h-4 w-4"
onClick={handleReaction}
disabled={publishReactionMutation.isLoading}
>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
</Show>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<Show
when={
!config().hideCount && !config().showEmojiReaction && reactions().length > 0
@@ -447,6 +450,15 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<Show when={config().useEmojiReaction}>
<div class="shrink-0">
<EmojiPicker onEmojiSelect={(emoji) => doReaction(emoji)}>
<span class="inline-block h-4 w-4">
<Plus />
</span>
</EmojiPicker>
</div>
</Show>
<div>
<ContextMenu menu={menu}>
<span class="inline-block h-4 w-4 text-zinc-400">

View File

@@ -27,12 +27,16 @@ const Popup: Component<PopupProps> = (props) => {
let popupRef: HTMLDivElement | undefined;
const [isOpen, setIsOpen] = createSignal(false);
const [style, setStyle] = createSignal<JSX.CSSProperties>({});
const resolvedChildren = children(() => props.children);
const close = () => setIsOpen(false);
const toggleOpened = () => setIsOpen((current) => !current);
const handleClickOutside = (ev: MouseEvent) => {
const target = ev.target as HTMLElement;
if (target != null && !popupRef?.contains(target)) {
setIsOpen(false);
close();
}
};
@@ -43,10 +47,31 @@ const Popup: Component<PopupProps> = (props) => {
document.removeEventListener('mousedown', handleClickOutside);
};
const close = () => setIsOpen(false);
const toggle = () => setIsOpen((current) => !current);
const adjustPosition = () => {
if (buttonRef == null || popupRef == null) return;
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => toggle();
const buttonRect = buttonRef?.getBoundingClientRect();
const popupRect = popupRef?.getBoundingClientRect();
let { top, left } = buttonRect;
if (props.position === 'left') {
left -= buttonRect.width;
} else if (props.position === 'right') {
left += buttonRect.width;
} else if (props.position === 'top') {
top -= buttonRect.height;
left -= buttonRect.left + buttonRect.width / 2;
} else {
top += buttonRect.height;
left += buttonRect.width / 2;
}
top = Math.min(top, window.innerHeight - popupRect.height);
left = Math.min(left, window.innerWidth - popupRect.width);
setStyle({ left: `${left}px`, top: `${top}px` });
};
createEffect(() => {
if (isOpen()) {
@@ -60,34 +85,16 @@ const Popup: Component<PopupProps> = (props) => {
createEffect(
on(resolvedChildren, () => {
if (buttonRef == null || popupRef == null) return;
const buttonRect = buttonRef?.getBoundingClientRect();
const popupRect = popupRef?.getBoundingClientRect();
let { top, left } = buttonRect;
if (props.position === 'left') {
left -= buttonRect.width;
} else if (props.position === 'right') {
left += buttonRect.width;
} else if (props.position === 'top') {
top -= buttonRect.height;
left -= buttonRect.left + buttonRect.width / 2;
} else {
top += buttonRect.height;
left += buttonRect.width / 2;
}
top = Math.min(top, window.innerHeight - popupRect.height);
left = Math.min(left, window.innerWidth - popupRect.width);
console.log(popupRect);
popupRef.style.left = `${left}px`;
popupRef.style.top = `${top}px`;
adjustPosition();
}),
);
createEffect(() => {
if (isOpen()) {
adjustPosition();
}
});
onMount(() => {
props.ref?.({ close });
});
@@ -96,10 +103,22 @@ const Popup: Component<PopupProps> = (props) => {
return (
<div>
<button ref={buttonRef} class="flex items-center" onClick={handleClick}>
<button
ref={buttonRef}
class="flex items-center"
onClick={() => {
toggleOpened();
adjustPosition();
}}
>
{props.button}
</button>
<div ref={popupRef} class="absolute z-20" classList={{ hidden: !isOpen(), block: isOpen() }}>
<div
ref={popupRef}
class="absolute z-20"
classList={{ hidden: !isOpen(), block: isOpen() }}
style={style()}
>
{resolvedChildren()}
</div>
</div>

23
src/nostr/event.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import assert from 'assert';
import { describe, it } from 'vitest';
import { isValidId } from '@/nostr/event';
describe('isValidId', () => {
it.each([
'59cd96d24acd0679080679330a06ae9dcb5026ba080528fb93762c49050be8c9',
'005c079e4c7c103168e0cb359270ac96a6a46e5ff4ce8f4643e0831f6d1c2450',
])('should return true if valid id is given (%s)', (id) => {
assert.deepStrictEqual(true, isValidId(id));
});
it.each([
'd9553d75bb38da004d380168221fa8cb7c5c55f5242397c1459c7013a2a1264z',
'a8e947afb422bbc44577a107147f684ab5c97538b505d2699181e8815160950z',
'a8e947a',
'',
])('should return false if invalid id is given (%s)', (id) => {
assert.deepStrictEqual(false, isValidId(id));
});
});

View File

@@ -1,22 +1,37 @@
import uniq from 'lodash/uniq';
import type { Event as NostrEvent } from 'nostr-tools';
import { Kind, Event as NostrEvent } from 'nostr-tools';
export type EventMarker = 'reply' | 'root' | 'mention';
export type TaggedEvent = {
// NIP-10
export type MarkedEventTag = {
id: string;
relayUrl?: string;
index: number;
marker?: EventMarker;
};
export type ContactPubkeyTag = {
pubkey: string;
relayUrl?: string;
petname?: string;
};
export type ContentWarning = {
contentWarning: boolean;
reason?: string;
};
const IdRegex = /^[0-9a-fA-f]{64}$/;
export const isValidId = (s: string): boolean => {
const result = typeof s === 'string' && s.length === 64 && IdRegex.test(s);
if (!result) console.warn('invalid id is ignored: ', s);
return result;
};
const eventWrapper = (event: NostrEvent) => {
let memoizedMarkedEventTags: MarkedEventTag[] | undefined;
return {
get rawEvent(): NostrEvent {
return event;
@@ -33,22 +48,36 @@ const eventWrapper = (event: NostrEvent) => {
get content(): string {
return event.content;
},
get tags(): string[][] {
return event.tags;
},
createdAtAsDate(): Date {
return new Date(event.created_at * 1000);
},
taggedUsers(): string[] {
const pubkeys = new Set<string>();
event.tags.forEach(([tagName, pubkey]) => {
if (tagName === 'p') {
pubkeys.add(pubkey);
}
});
return Array.from(pubkeys);
pTags(): string[][] {
return event.tags.filter(([tagName, pubkey]) => tagName === 'p' && isValidId(pubkey));
},
taggedEvents(): TaggedEvent[] {
eTags(): string[][] {
return event.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
},
taggedEventIds(): string[] {
return this.eTags().map(([, eventId]) => eventId);
},
lastTaggedEventId(): string | undefined {
// for compatibility. some clients include additional event ids for reaction (kind:7).
const ids = this.taggedEventIds();
if (ids.length === 0) return undefined;
return ids[ids.length - 1];
},
markedEventTags(): MarkedEventTag[] {
if (event.kind !== Kind.Text) throw new Error('kind should be 1');
if (memoizedMarkedEventTags != null) return memoizedMarkedEventTags;
// 'eTags' cannot be used here because it does not preserve originalIndex.
const events = event.tags
.map((tag, originalIndex) => [tag, originalIndex] as const)
.filter(([[tagName]]) => tagName === 'e');
.filter(([[tagName, eventId]]) => tagName === 'e' && isValidId(eventId));
// NIP-10: Positional "e" tags (DEPRECATED)
const positionToMarker = (marker: string, index: number): EventMarker | undefined => {
@@ -68,24 +97,28 @@ const eventWrapper = (event: NostrEvent) => {
return 'mention';
};
return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
id: eventId,
relayUrl,
marker: positionToMarker(marker, eTagIndex),
index: originalIndex,
}));
memoizedMarkedEventTags = events.map(
([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
id: eventId,
relayUrl,
marker: positionToMarker(marker, eTagIndex),
index: originalIndex,
}),
);
return memoizedMarkedEventTags;
},
replyingToEvent(): TaggedEvent | undefined {
return this.taggedEvents().find(({ marker }) => marker === 'reply');
replyingToEvent(): MarkedEventTag | undefined {
return this.markedEventTags().find(({ marker }) => marker === 'reply');
},
rootEvent(): TaggedEvent | undefined {
return this.taggedEvents().find(({ marker }) => marker === 'root');
rootEvent(): MarkedEventTag | undefined {
return this.markedEventTags().find(({ marker }) => marker === 'root');
},
mentionedEvents(): TaggedEvent[] {
return this.taggedEvents().filter(({ marker }) => marker === 'mention');
mentionedEvents(): MarkedEventTag[] {
return this.markedEventTags().filter(({ marker }) => marker === 'mention');
},
mentionedPubkeys(): string[] {
return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1]));
return uniq(this.pTags().map(([, pubkey]) => pubkey));
},
mentionedPubkeysWithoutAuthor(): string[] {
return this.mentionedPubkeys().filter((pubkey) => pubkey !== event.pubkey);

View File

@@ -231,7 +231,7 @@ describe('resolveTagReference', () => {
content: '',
tags: [
['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'],
['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212', '', 'reply'],
['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2', '', 'reply'],
],
created_at: 1678377182,
pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972',
@@ -242,7 +242,7 @@ describe('resolveTagReference', () => {
tagIndex: 1,
marker: 'reply',
content: '#[1]',
eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212',
eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2',
};
assert.deepStrictEqual(result, expected);
});

View File

@@ -1,6 +1,6 @@
import { nip19, type Event as NostrEvent } from 'nostr-tools';
import eventWrapper from '@/nostr/event';
import eventWrapper, { isValidId } from '@/nostr/event';
type ProfilePointer = nip19.ProfilePointer;
type EventPointer = nip19.EventPointer;
@@ -125,7 +125,7 @@ const parseTextNote = (textNoteContent: string) => {
};
result.push(bech32Entity);
} catch (e) {
console.warn(`failed to parse Bech32 entity (NIP-19): ${match[0]}`);
console.warn(`ignored invalid bech32 entity: ${match[0]}`);
pushPlainText(index + match[0].length);
}
} else if (match.groups?.hashtag) {
@@ -157,7 +157,7 @@ export const resolveTagReference = (
const tagName = tag[0];
if (tagName === 'p') {
if (tagName === 'p' && isValidId(tag[1])) {
return {
type: 'MentionedUser',
tagIndex,
@@ -166,9 +166,9 @@ export const resolveTagReference = (
} satisfies MentionedUser;
}
if (tagName === 'e') {
if (tagName === 'e' && isValidId(tag[1])) {
const mention = eventWrapper(event)
.taggedEvents()
.markedEventTags()
.find((ev) => ev.index === tagIndex);
return {

View File

@@ -237,24 +237,23 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const registeredTasks = textNoteTasks.get(event.id) ?? [];
resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Reaction) {
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
const registeredTasks = reactionsTasks.get(taggedEventId) ?? [];
// Use the last event id
const id = eventWrapper(event).lastTaggedEventId();
if (id != null) {
const registeredTasks = reactionsTasks.get(id) ?? [];
resolveTasks(registeredTasks, event);
});
}
} else if ((event.kind as number) === 6) {
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
// Use the last event id
const id = eventWrapper(event).lastTaggedEventId();
if (id != null) {
const registeredTasks = repostsTasks.get(id) ?? [];
resolveTasks(registeredTasks, event);
});
}
} else if (event.kind === Kind.Zap) {
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
const eTags = eventWrapper(event).eTags();
eTags.forEach(([, id]) => {
const registeredTasks = repostsTasks.get(id) ?? [];
resolveTasks(registeredTasks, event);
});
} else if (event.kind === Kind.Contacts) {
@@ -298,7 +297,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
try {
queryClient.setQueryData(queryKey, latestEvent());
} catch (err) {
console.error('updating profile error: ', err);
console.error('error occurred while updating profile cache: ', err);
}
});
return latestEvent();
@@ -472,7 +471,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
try {
queryClient.setQueryData(queryKey, latestEvent());
} catch (err) {
console.error('updating followings error: ', err);
console.error('error occurred while updating followings cache: ', err);
}
});
return latestEvent();
@@ -491,14 +490,12 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
const followings = () => {
if (query.data == null) return [];
const event = query.data;
const result: Following[] = [];
event.tags.forEach((tag) => {
// TODO zodにする
const [tagName, followingPubkey, mainRelayUrl, petname] = tag;
if (!tag.every((e) => typeof e === 'string')) return;
if (tagName !== 'p') return;
// TODO zodにする
const event = eventWrapper(query.data);
event.pTags().forEach((tag) => {
const [, followingPubkey, mainRelayUrl, petname] = tag;
const following: Following = { pubkey: followingPubkey, petname };
if (mainRelayUrl != null && mainRelayUrl.length > 0) {

View File

@@ -1,4 +1,4 @@
import { createEffect, onMount, type Component } from 'solid-js';
import { createEffect, onMount, type Component, onError } from 'solid-js';
import { useNavigate } from '@solidjs/router';
@@ -44,6 +44,10 @@ const Home: Component = () => {
}
});
onError((err) => {
console.error('uncaught error: ', err);
});
return (
<div class="absolute inset-0 flex w-screen touch-manipulation flex-row overflow-hidden">
<SideBar />