diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts
index 783d446e..44309ba9 100644
--- a/e2e/specs/admin.spec.ts
+++ b/e2e/specs/admin.spec.ts
@@ -291,6 +291,8 @@ test("US-094 invite people to a space", async ({seed, as}) => {
await expect(page.getByRole("alert")).toContainText("Members have successfully been added!")
+ // Adding members opened over the invite, so saving goes back to it.
+ await invite.getByRole("button", {name: "Done"}).click()
await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click()
await expect(memberCard(page, "Nadia Newcomer")).toBeVisible()
diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts
index 8e040f38..2f6ba8dc 100644
--- a/e2e/specs/community.spec.ts
+++ b/e2e/specs/community.spec.ts
@@ -782,20 +782,18 @@ test("US-054 curate the library", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible()
await expect(page.getByText("#reading")).toBeVisible()
- // Several links go in at once, each as a card of its own.
+ // Each link goes in on its own and becomes a card of its own.
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
- await page.getByRole("button", {name: "Add a link"}).click()
+ for (const link of ["https://handbook.test/style-guide", `nostr:${nevent}`]) {
+ await page.getByRole("button", {name: "Add a link"}).click()
- const links = dialog(page, "Add a Link")
+ const links = dialog(page, "Add a Link")
- await links
- .getByPlaceholder("URL or nevent...")
- .fill(`https://handbook.test/style-guide\nnostr:${nevent}`)
- // A title and a description are about one link, so a batch is offered neither.
- await expect(links.getByPlaceholder("Optional title")).toHaveCount(0)
-
- await links.getByRole("button", {name: "Add 2 links"}).click()
+ await links.getByLabel("Link", {exact: true}).fill(link)
+ await links.getByRole("button", {name: "Add link"}).click()
+ await expect(links).toHaveCount(0)
+ }
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible()
@@ -810,7 +808,7 @@ test("US-054 curate the library", async ({seed, as}) => {
const linkEdit = dialog(page, "Edit Link")
- await linkEdit.getByPlaceholder("Optional title").fill("House style guide")
+ await linkEdit.getByLabel("Title", {exact: true}).fill("House style guide")
await linkEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("alert")).toContainText("Link updated!")
@@ -835,7 +833,7 @@ test("US-054 curate the library", async ({seed, as}) => {
const fromPoll = dialog(page, "Add a Link")
- await fromPoll.getByPlaceholder("Optional title").fill("Standup poll")
+ await fromPoll.getByLabel("Title", {exact: true}).fill("Standup poll")
await fromPoll.getByRole("button", {name: "Add link"}).click()
await expect(page.getByRole("alert")).toContainText("Link added!")
diff --git a/package.json b/package.json
index eee21583..3c595a7c 100644
--- a/package.json
+++ b/package.json
@@ -98,16 +98,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
- "@welshman/app": "^0.12.2",
- "@welshman/content": "^0.12.2",
- "@welshman/domain": "^0.12.2",
- "@welshman/editor": "^0.12.2",
- "@welshman/feeds": "^0.12.2",
- "@welshman/lib": "^0.12.2",
- "@welshman/net": "^0.12.2",
- "@welshman/signer": "^0.12.2",
- "@welshman/store": "^0.12.2",
- "@welshman/util": "^0.12.2",
+ "@welshman/app": "^0.12.3",
+ "@welshman/content": "^0.12.3",
+ "@welshman/domain": "^0.12.3",
+ "@welshman/editor": "^0.12.3",
+ "@welshman/feeds": "^0.12.3",
+ "@welshman/lib": "^0.12.3",
+ "@welshman/net": "^0.12.3",
+ "@welshman/signer": "^0.12.3",
+ "@welshman/store": "^0.12.3",
+ "@welshman/util": "^0.12.3",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f00f49c..05238271 100644
Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ
diff --git a/server/preview.js b/server/preview.js
index 35e71ad8..73cf3270 100644
--- a/server/preview.js
+++ b/server/preview.js
@@ -3,7 +3,13 @@ import {Hono} from "hono"
import {load} from "cheerio"
import {ParsedType, parse, renderAsText, summarize} from "@welshman/content"
import {MINUTE, ellipsize, first, identity, int, ms, sleep, tryCatch} from "@welshman/lib"
-import {displayRelayUrl, getIdFilters, normalizeRelayUrl} from "@welshman/util"
+import {
+ displayRelayUrl,
+ getIdFilters,
+ isIPAddress,
+ isShareableRelayUrl,
+ normalizeRelayUrl,
+} from "@welshman/util"
import {
Article,
Classified,
@@ -13,6 +19,7 @@ import {
ZapGoal,
displayPubkey,
} from "@welshman/domain"
+import {LOCAL_RELAY_URL, MockAdapter} from "@welshman/net"
import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app"
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
@@ -21,7 +28,29 @@ const fromCsv = value => (value || "").split(",").filter(identity)
const defaultRelays = fromCsv(process.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
+// Anyone can put any host in a url or an event's tags, so the server only reaches public relays.
+const isPublicRelayUrl = url => isShareableRelayUrl(url) && !isIPAddress(url)
+
+const toRelayUrl = value => {
+ const url = tryCatch(() => normalizeRelayUrl(value))
+
+ return url && isPublicRelayUrl(url) ? url : undefined
+}
+
+// Every socket goes through here, so a relay that isn't public refuses rather than being dialed.
+const refuseRelay = url => {
+ const adapter = new MockAdapter(url, ([verb, id]) => {
+ if (verb === "REQ") {
+ setTimeout(() => adapter.receive(["CLOSED", id, "blocked: not a public relay"]))
+ }
+ })
+
+ return adapter
+}
+
const app = createApp({
+ getAdapter: url =>
+ url === LOCAL_RELAY_URL || isPublicRelayUrl(url) ? undefined : refuseRelay(url),
config: {
getDefaultRelays: () => defaultRelays,
getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl),
@@ -200,14 +229,17 @@ const remember = async (key, describe) => {
return cached.metadata
}
- const metadata = await describe()
+ // A relay that stalls leaves the page to go out without a preview rather than hold it open.
+ const metadata = await Promise.race([describe(), sleep(ms(4))])
- if (cache.size > 500) {
- cache.clear()
+ if (metadata) {
+ if (cache.size > 500) {
+ cache.clear()
+ }
+
+ cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))})
}
- cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))})
-
return metadata
}
@@ -234,7 +266,7 @@ export const createPreview = template => {
// A space relay answers nothing without auth, so most of these fall back to its nip-11 profile.
const renderSpace = describe =>
render(async (url, params) => {
- const relayUrl = tryCatch(() => normalizeRelayUrl(params.relay))
+ const relayUrl = toRelayUrl(params.relay)
if (relayUrl) {
const space = await loadSpace(relayUrl)
@@ -248,7 +280,7 @@ export const createPreview = template => {
preview.get(
"/join",
render(async url => {
- const relayUrl = tryCatch(() => normalizeRelayUrl(url.searchParams.get("r")))
+ const relayUrl = toRelayUrl(url.searchParams.get("r"))
if (relayUrl) {
const space = await loadSpace(relayUrl)
diff --git a/server/test/preview.test.mjs b/server/test/preview.test.mjs
index 70a7f718..1444b94d 100644
--- a/server/test/preview.test.mjs
+++ b/server/test/preview.test.mjs
@@ -68,6 +68,12 @@ describe("preview metadata", () => {
assert.equal(image, "https://app.flotilla.social/maskable-icon-512x512.png")
})
+ test("won't reach a relay on a private or local host", async () => {
+ for (const path of ["/join?r=localhost:8080", "/spaces/127.0.0.1", "/spaces/printer.local"]) {
+ assert.equal((await request(path)).title, "Flotilla")
+ }
+ })
+
test("leaves the platform's own pages alone", async () => {
for (const path of ["/", "/settings/profile", "/spaces/create", "/people/not-an-npub"]) {
assert.equal((await request(path)).title, "Flotilla")
diff --git a/src/app/components/CallControlBar.svelte b/src/app/components/CallControlBar.svelte
index 78cde20f..6d973b68 100644
--- a/src/app/components/CallControlBar.svelte
+++ b/src/app/components/CallControlBar.svelte
@@ -175,7 +175,7 @@
diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte
index 752d0527..1998db19 100644
--- a/src/app/components/CommentCompose.svelte
+++ b/src/app/components/CommentCompose.svelte
@@ -1,7 +1,7 @@
@@ -176,88 +136,76 @@
Link
{/snippet}
{#snippet info()}
-
- A URL or a nostr link (note, nevent, naddr, npub, or nprofile). Drop files here to upload
- them. Paste several links, one per line, to file them all at once.
-
+ {#if value.trim() && !pinReference}
+ Please enter a valid URL or nostr link.
+ {:else}
+ A URL or a nostr link, or upload a file.
+ {/if}
{/snippet}
{#snippet input()}
-
-
- {#if !pin}
-
-
- {/if}
+
+
+
{/snippet}
- {#if references.length > 1}
-
- {references.length} links, each filed on its own. A title and a description describe one link,
- so add them from its menu once it is on the shelf.
-