diff --git a/README.md b/README.md index 8104907..84eb204 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ It shows through the end of `until` (Eastern time), then switches itself off wit ## About card (home page) `components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in `data/xp.mjs` (`education`, `experience` and `achievements`). Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`). -Kudos: in plain English, each XP entry has a ๐Ÿ‘ button with a live count shared by every visitor (`composables/useKudos.js`, refreshed every 15 seconds while the list is showing). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). It counts only known entries, once per visitor per entry per day (by a hash of their connection, which isn't stored; daily records are cleared after a day or two), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show. +Kudos: in plain English, each XP entry has a ๐Ÿ‘ button with a live count shared by every visitor (`components/KudosButton.vue` and `composables/useKudos.js`, refreshed every 15 seconds while the buttons are on screen). Blog posts (at the end of each post) and Curiosity Feed entries have one too, with the id `post-`. Next to it, โ†— Share (`components/ShareButton.vue`, `utils/share.js`) opens the device's share sheet with the post's title and link, or copies the link where there's no share sheet (and says "Link copied"). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). Posts aren't a fixed list (dev.to posts arrive at build time), so a post id must be a plain slug (the dev.to mirror's rule) and the function checks the deployed site has that post (its prerendered `/blog//_payload.json` answers 200; kept for 10 minutes) before its first kudos. It counts only known entries, once per visitor per entry per day (by a hash of their connection, which isn't stored; daily records are cleared after a day or two), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show. The social links under the card are in `components/Socials.vue`. Tapping Instagram opens a small menu with both accounts: hansel.rocks (main) and hansel.codes (code and software engineering only). A pill gets a menu from its `choices` list (`components/SocialFlipLink.vue`). The addresses are in `utils/outbound.js`. On phones and tablets each link opens a new tab through `/out/` (`pages/out/[id].vue`), which goes on to the link; when the phone hands the link to an app instead, that tab closes itself once you come back, so no blank tab is left behind. diff --git a/components/AboutCard.vue b/components/AboutCard.vue index bda64a6..11e56dc 100644 --- a/components/AboutCard.vue +++ b/components/AboutCard.vue @@ -126,20 +126,7 @@ Entry {{ i + 1 }} of {{ yearEntries.length }}, {{ entry.category }}: {{ entry.label }} ยท - - +

{{ yearJson }}

@@ -949,34 +936,6 @@ cite { line-height: 1.55; } -.kudos { - display: inline-flex; - align-items: center; - min-height: 24px; - min-width: 24px; - margin-left: 0.4rem; - padding: 0 0.5rem; - border: 1px solid #475569; - border-radius: 999px; - background: transparent; - color: #e2e8f0; - font-size: 0.75rem; - line-height: 1.4; - white-space: nowrap; - cursor: pointer; - touch-action: manipulation; -} - -.kudos:hover, -.kudos:focus-visible { - border-color: #94a3b8; -} - -.kudos--given { - border-color: #22c55e; - cursor: default; -} - .edu-list li + li { margin-top: 0.45rem; } diff --git a/components/KudosButton.vue b/components/KudosButton.vue new file mode 100644 index 0000000..c8531eb --- /dev/null +++ b/components/KudosButton.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/components/ShareButton.vue b/components/ShareButton.vue new file mode 100644 index 0000000..2db1411 --- /dev/null +++ b/components/ShareButton.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/composables/useKudos.js b/composables/useKudos.js index e7fd98c..70be32f 100644 --- a/composables/useKudos.js +++ b/composables/useKudos.js @@ -1,29 +1,30 @@ -// Kudos for the About card's XP entries (netlify/functions/kudos.mjs). Counts are shared by -// everyone and refreshed while the list is on screen; which entries this browser has already -// given kudos to is remembered locally, so the button shows it. Where the function isn't there -// (local preview), `available` stays false and the buttons don't show. +// Kudos (netlify/functions/kudos.mjs) for the About card's XP entries, blog posts and Curiosity +// Feed entries. Counts are shared by everyone, kept once for the whole app (useState) and +// refreshed while a page's buttons are on screen; which ones this browser has already given +// kudos to is remembered locally, so the button shows it. Where the function isn't there (local +// preview), `available` stays false and the buttons don't show. const GIVEN_KEY = 'kudos:given' const REFRESH_MS = 15_000 const readGiven = () => { try { const list = JSON.parse(localStorage.getItem(GIVEN_KEY) ?? '[]') - return new Set(Array.isArray(list) ? list.filter(id => typeof id === 'string') : []) + return Array.isArray(list) ? list.filter(id => typeof id === 'string') : [] } catch { - return new Set() + return [] } } const saveGiven = given => { try { - localStorage.setItem(GIVEN_KEY, JSON.stringify([...given])) + localStorage.setItem(GIVEN_KEY, JSON.stringify(given)) } catch {} } export const useKudos = () => { - const available = ref(false) - const counts = ref({}) - const given = ref(new Set()) - let timer = null + const available = useState('kudos:available', () => false) + const counts = useState('kudos:counts', () => ({})) + const given = useState('kudos:given', () => []) + const hasGiven = id => given.value.includes(id) const refresh = async () => { try { @@ -36,10 +37,10 @@ export const useKudos = () => { } const give = async id => { - if (given.value.has(id)) return + if (hasGiven(id)) return const before = counts.value[id] ?? 0 counts.value = { ...counts.value, [id]: before + 1 } - given.value = new Set([...given.value, id]) + given.value = [...given.value, id] try { const res = await fetch('/api/kudos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) if (!res.ok) throw new Error(String(res.status)) @@ -48,17 +49,16 @@ export const useKudos = () => { saveGiven(given.value) } catch { counts.value = { ...counts.value, [id]: before } - const rest = new Set(given.value) - rest.delete(id) - given.value = rest + given.value = given.value.filter(g => g !== id) } } - const tick = () => { - if (!document.hidden) refresh() - } - // Live while `active` (the plain-English XP list is showing) + // Live while `active` is true (the buttons are on screen); one timer per caller const watchActive = active => { + let timer = null + const tick = () => { + if (!document.hidden) refresh() + } watch( active, on => { @@ -74,5 +74,5 @@ export const useKudos = () => { onBeforeUnmount(() => clearInterval(timer)) } - return { available, counts, given, give, watchActive } + return { available, counts, given, hasGiven, give, watchActive } } diff --git a/netlify/functions/kudos.mjs b/netlify/functions/kudos.mjs index b970c4c..bc32fed 100644 --- a/netlify/functions/kudos.mjs +++ b/netlify/functions/kudos.mjs @@ -1,33 +1,68 @@ -// GET /api/kudos -> { counts: { [xpId]: n } }: kudos for the About card's XP entries -// POST /api/kudos { id } -> { id, count, counted }: one kudos for one entry (data/xp.mjs) +// GET /api/kudos -> { counts: { [id]: n } }: kudos for the About card's XP entries, blog +// posts and Curiosity Feed entries +// POST /api/kudos { id } -> { id, count, counted }: one kudos for one of them. An id is an XP +// entry's (data/xp.mjs) or `post-` for a post on the site. +// +// Posts aren't a fixed list (dev.to posts are mirrored in at build time), so a post id has to +// be a plain slug (the dev.to mirror's rule) for a page the deployed site really has: its +// prerendered /blog//_payload.json answers 200. The answer is kept for 10 minutes. // // The counts are one JSON document ("counts") in the "kudos" Netlify Blobs store, kept apart // from the site's own data. Each visitor counts once per entry per day: a marker keyed by an // HMAC of their network (an IPv4 address, or an IPv6 /64: one home or phone connection), the // entry and the day (so no address is stored) is written with onlyIfNew, and only a write that -// creates it adds to the count. Markers older than yesterday are swept as new kudos come in. The count itself is updated -// with onlyIfMatch on its ETag and retried, so kudos sent at the same moment aren't lost. +// creates it adds to the count. Markers older than yesterday are swept as new kudos come in. +// The count itself is updated with onlyIfMatch on its ETag and retried, so kudos sent at the same +// moment aren't lost. import { createHmac } from 'node:crypto' import { getStore } from '@netlify/blobs' import { XP_IDS } from '../../data/xp.mjs' +import { SLUG } from '../../scripts/lib/devto.mjs' const MAX_BODY = 1024 const MAX_TRIES = 6 const SWEEP_MAX = 300 // old markers deleted per counted kudos, at most const COUNTS = 'counts' +const POST = 'post-' +const POST_TTL_MS = 10 * 60_000 const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex' } const json = (body, status = 200, extra = {}) => new Response(JSON.stringify(body), { status, headers: { ...HEADERS, ...extra } }) -// Only known entries, with whole non-negative counts: whatever else the stored document holds +const postSlug = id => (id.startsWith(POST) && SLUG.test(id.slice(POST.length)) ? id.slice(POST.length) : null) +// The shape of an id we count: an XP entry, or a post id with a plain slug (whether that post +// exists is checked before its first kudos, so only real posts' ids are ever stored) +const knownShape = id => XP_IDS.has(id) || postSlug(id) !== null + +// Only known entries, with whole positive counts: whatever else the stored document holds const cleanCounts = data => { const counts = {} - for (const id of XP_IDS) { - const n = data && typeof data === 'object' && Object.hasOwn(data, id) ? data[id] : 0 - if (Number.isSafeInteger(n) && n > 0) counts[id] = n + if (!data || typeof data !== 'object' || Array.isArray(data)) return counts + for (const id of Object.keys(data)) { + const n = data[id] + if (knownShape(id) && Number.isSafeInteger(n) && n > 0) counts[id] = n } return counts } +// Whether the deployed site has the post: its prerendered payload answers 200 (no redirects) +const checkPost = async (slug, siteUrl) => { + try { + const res = await fetch(new URL(`/blog/${slug}/_payload.json`, siteUrl), { redirect: 'manual' }) + return res.status === 200 + } catch { + return false + } +} +const seenPosts = new Map() +export const cachedPostExists = async (slug, siteUrl, now = Date.now(), check = checkPost) => { + const hit = seenPosts.get(slug) + if (hit && now - hit.at < POST_TTL_MS) return hit.exists + const exists = await check(slug, siteUrl) + if (seenPosts.size > 1000) seenPosts.clear() + seenPosts.set(slug, { exists, at: now }) + return exists +} + const addOne = async (store, id) => { for (let i = 0; i < MAX_TRIES; i++) { const current = await store.getWithMetadata(COUNTS, { type: 'json' }) @@ -75,7 +110,7 @@ const sweepOld = async (store, now) => { } } -export const handleKudos = async (req, { store, ip = '', secret = '', now = Date.now(), origins = [] }) => { +export const handleKudos = async (req, { store, ip = '', secret = '', now = Date.now(), origins = [], siteUrl = new URL(req.url).origin, postExists = slug => cachedPostExists(slug, siteUrl, now) }) => { if (req.method === 'GET') { const current = await store.get(COUNTS, { type: 'json' }) return json({ counts: cleanCounts(current) }) @@ -94,7 +129,9 @@ export const handleKudos = async (req, { store, ip = '', secret = '', now = Date } catch { return json({ error: 'Bad request' }, 400) } - if (typeof id !== 'string' || !XP_IDS.has(id)) return json({ error: 'Unknown entry' }, 404) + if (typeof id !== 'string' || !knownShape(id)) return json({ error: 'Unknown entry' }, 404) + const slug = postSlug(id) + if (slug !== null && !(await postExists(slug))) return json({ error: 'Unknown entry' }, 404) const day = new Date(now).toISOString().slice(0, 10) const visitor = createHmac('sha256', `kudos:${secret}`).update(`${visitorNet(ip)}\n${id}\n${day}`).digest('hex') @@ -120,6 +157,8 @@ export default async (req, context) => store: getStore({ name: 'kudos', consistency: 'strong' }), ip: context?.ip ?? '', secret: process.env.GITHUB_CLIENT_SECRET ?? '', + // This deploy's own address (a preview checks its own posts), else the site's + siteUrl: process.env.DEPLOY_URL || process.env.URL || new URL(req.url).origin, // The site's own addresses (the request's own origin is always allowed) origins: [process.env.URL, process.env.DEPLOY_URL, process.env.DEPLOY_PRIME_URL].filter(Boolean).map(u => new URL(u).origin) }) diff --git a/pages/blog/[slug].vue b/pages/blog/[slug].vue index a3fd34a..caf3831 100644 --- a/pages/blog/[slug].vue +++ b/pages/blog/[slug].vue @@ -19,6 +19,11 @@ dev.to.

+

+ Enjoyed this post? + + +

@@ -44,6 +49,11 @@ useHead({ link: post.value.canonical ? [{ rel: 'canonical', href: post.value.canonical }] : [] }) +// Kudos for the post (components/KudosButton.vue), live while it's open; sharing is +// components/ShareButton.vue +const kudos = useKudos() +kudos.watchActive(() => true) + const formatDate = iso => new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) @@ -71,6 +81,15 @@ const formatDate = iso => } } +.post-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 2rem; + color: #cbd5e1; +} + .back, .mirror a { color: #c084fc; diff --git a/pages/blog/feed/index.vue b/pages/blog/feed/index.vue index 5c113c7..29b754e 100644 --- a/pages/blog/feed/index.vue +++ b/pages/blog/feed/index.vue @@ -47,6 +47,10 @@ ๐Ÿ”— {{ hostname(entry.link) }} +

+ + +

@@ -58,6 +62,9 @@ import { hashtag } from '~/utils/chips.js' useHead({ title: 'Curiosity Feed ยท Hansel Wei' }) const { data: entries } = await useFetch('/api/blog/feed', { default: () => [] }) +// Kudos on each entry (components/KudosButton.vue), live while the feed is open +const kudos = useKudos() +kudos.watchActive(() => true) const activeTag = ref(null) const activeSeries = ref(null) @@ -183,6 +190,14 @@ const hostname = url => { height: auto; } +.entry__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0.5rem 0 0; +} + .entry__link { display: inline-block; margin-top: 0.5rem; diff --git a/tests/app/share.test.mjs b/tests/app/share.test.mjs new file mode 100644 index 0000000..c00a9ab --- /dev/null +++ b/tests/app/share.test.mjs @@ -0,0 +1,40 @@ +// Sharing a post (utils/share.js, components/ShareButton.vue): the share sheet where there is +// one, else the link is copied +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { shareLink } from '../../utils/share.js' + +const link = { title: 'Hello World!', url: 'https://www.hanselwei.dev/blog/hello-world' } +const clipboard = () => { + const copied = [] + return { copied, writeText: async text => void copied.push(text) } +} + +test('the share sheet gets the title and link when the device has one', async () => { + const shared = [] + const cb = clipboard() + assert.equal(await shareLink(link, { share: async data => void shared.push(data), clipboard: cb }), 'shared') + assert.deepEqual(shared, [link]) + assert.deepEqual(cb.copied, []) +}) + +test('closing the share sheet is not an error, and copies nothing', async () => { + const cb = clipboard() + const abort = Object.assign(new Error('closed'), { name: 'AbortError' }) + assert.equal(await shareLink(link, { share: async () => { throw abort }, clipboard: cb }), 'cancelled') + assert.deepEqual(cb.copied, []) +}) + +test('without a share sheet, or when it refuses, the link is copied', async () => { + const cb = clipboard() + assert.equal(await shareLink(link, { clipboard: cb }), 'copied') + const refused = Object.assign(new Error('no'), { name: 'NotAllowedError' }) + assert.equal(await shareLink(link, { share: async () => { throw refused }, clipboard: cb }), 'copied') + assert.deepEqual(cb.copied, [link.url, link.url]) +}) + +test('when nothing works it says so instead of throwing', async () => { + assert.equal(await shareLink(link, {}), 'failed') + assert.equal(await shareLink(link, undefined), 'failed') + assert.equal(await shareLink(link, { clipboard: { writeText: async () => { throw new Error('denied') } } }), 'failed') +}) diff --git a/tests/e2e/a11y.spec.mjs b/tests/e2e/a11y.spec.mjs index 43a9ddf..36ba711 100644 --- a/tests/e2e/a11y.spec.mjs +++ b/tests/e2e/a11y.spec.mjs @@ -76,17 +76,34 @@ test('the Instagram menu has none when open', async ({ page }) => { // Kudos on the plain-English XP list (netlify/functions/kudos.mjs, stood in for here): the // buttons have no violations, and a tap counts without flipping the card back to JSON. The // service worker is off so the stand-in sees the requests. -test.describe('XP kudos', () => { +// A stand-in for /api/kudos: no counts yet, and each kudos counts as the first +const standInKudos = async (page, sent) => { + await page.route('**/api/kudos', async route => { + const request = route.request() + if (request.method() === 'POST') sent.push(request.postDataJSON().id) + const body = request.method() === 'POST' ? { id: sent.at(-1), count: 1, counted: true } : { counts: {} } + await route.fulfill({ status: 200, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) }) + }) +} + +// A kudos button: no violations before or after a tap, and the tap counts for `id` +const checkKudosTap = async (page, button, sent, id, what) => { + await expect(button).toBeVisible({ timeout: 5000 }) + await check(page, what) + await button.click() + await expect(button).toHaveClass(/kudos--given/) + await expect(button).toContainText('1') + expect(sent).toEqual([id]) + await settled(page) + await check(page, `${what} after a kudos`) +} + +test.describe('Kudos', () => { test.use({ serviceWorkers: 'block' }) test('the kudos buttons have none, and a tap counts without leaving plain English', async ({ page }) => { const sent = [] - await page.route('**/api/kudos', async route => { - const request = route.request() - if (request.method() === 'POST') sent.push(request.postDataJSON().id) - const body = request.method() === 'POST' ? { id: sent.at(-1), count: 1, counted: true } : { counts: {} } - await route.fulfill({ status: 200, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) }) - }) + await standInKudos(page, sent) await open(page, '/#xp') await page.locator('.about-card [role="tabpanel"]').click({ modifiers: ['Alt'] }) await expect(page.locator('.poof-word')).toHaveCount(0, { timeout: 5000 }) @@ -102,4 +119,35 @@ test.describe('XP kudos', () => { await settled(page) await check(page, 'the XP list after a kudos') }) + + test('a blog post has a kudos button with none, and a tap counts for that post', async ({ page }) => { + const sent = [] + await standInKudos(page, sent) + await open(page, '/blog/hello-world') + await checkKudosTap(page, page.locator('.post-actions .kudos'), sent, 'post-hello-world', 'a blog post with kudos') + }) + + test('each Curiosity Feed entry has a kudos button with none, and a tap counts for that entry', async ({ page }) => { + const sent = [] + await standInKudos(page, sent) + await open(page, '/blog/feed') + const entry = page.locator('.entry').first() + const slug = await entry.getAttribute('id') + await checkKudosTap(page, entry.locator('.kudos'), sent, `post-${slug}`, 'the Curiosity Feed with kudos') + }) + + test('sharing a post copies its link where there is no share sheet, and says so', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + // No share sheet, so the copy path runs the same on every device + await page.addInitScript(() => Object.defineProperty(Navigator.prototype, 'share', { value: undefined })) + await open(page, '/blog/hello-world') + const button = page.locator('.post-actions .share__button') + await expect(button).toHaveAttribute('aria-label', 'Share: Hello World!') + await button.click() + await expect(button).toContainText('Link copied') + await expect(page.locator('.post-actions [role="status"]')).toHaveText('Link copied') + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(new URL('/blog/hello-world', page.url()).href) + await settled(page) + await check(page, 'a blog post after sharing') + }) }) diff --git a/tests/security/kudos.test.mjs b/tests/security/kudos.test.mjs index ac11ddf..2666f8e 100644 --- a/tests/security/kudos.test.mjs +++ b/tests/security/kudos.test.mjs @@ -3,9 +3,9 @@ // and kudos sent at the same moment are all counted import { test } from 'node:test' import assert from 'node:assert/strict' -import { handleKudos, visitorNet } from '../../netlify/functions/kudos.mjs' +import { cachedPostExists, handleKudos, visitorNet } from '../../netlify/functions/kudos.mjs' import { XP_IDS } from '../../data/xp.mjs' -import { KUDOS_BODIES, KUDOS_IPS, PROTOTYPE_KEYS, XSS_HTML } from './payloads.mjs' +import { KUDOS_BODIES, KUDOS_IPS, KUDOS_POST_IDS, PROTOTYPE_KEYS, XSS_HTML } from './payloads.mjs' // An in-memory stand-in for a Netlify Blobs store, with its conditional writes const memoryStore = () => { @@ -77,8 +77,9 @@ test('kudos sent at the same moment are all counted', async () => { test('hostile bodies and unknown ids are refused and never stored', async () => { const store = memoryStore() + const postExists = async () => assert.fail('a hostile id should never be looked up') for (const body of [...KUDOS_BODIES, ...XSS_HTML.map(id => JSON.stringify({ id })), ...PROTOTYPE_KEYS.map(id => JSON.stringify({ id }))]) { - const res = await handleKudos(post(body), { store, ip: '1.1.1.1' }) + const res = await handleKudos(post(body), { store, ip: '1.1.1.1', postExists }) assert.ok([400, 404].includes(res.status), `${body.slice(0, 50)} -> ${res.status}`) assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8') await res.json() @@ -106,6 +107,46 @@ test('stored counts are cleaned: unknown keys, prototype keys and non-counts are assert.equal({}.polluted, undefined) }) +test('a post gets kudos once the site is found to have it; a made-up one never does', async () => { + const store = memoryStore() + const asked = [] + const postExists = async slug => (asked.push(slug), slug === 'hello-world') + const send = id => handleKudos(post(JSON.stringify({ id })), { store, ip: '1.1.1.1', postExists }) + assert.deepEqual(await (await send('post-hello-world')).json(), { id: 'post-hello-world', count: 1, counted: true }) + assert.equal((await send('post-not-a-post')).status, 404) + assert.deepEqual(asked, ['hello-world', 'not-a-post']) + assert.deepEqual(await (await handleKudos(get(), { store })).json(), { counts: { 'post-hello-world': 1 } }) + // Nothing for the made-up post: no count, no marker + assert.equal([...store.blobs.keys()].filter(k => k.startsWith('seen/')).length, 1) +}) + +test('hostile post ids are refused before the site is asked about them', async () => { + const store = memoryStore() + const postExists = async slug => assert.fail(`looked up ${JSON.stringify(slug)}`) + for (const id of KUDOS_POST_IDS) { + const res = await handleKudos(post(JSON.stringify({ id })), { store, ip: '1.1.1.1', postExists }) + assert.equal(res.status, 404, JSON.stringify(id).slice(0, 60)) + } + assert.equal(store.blobs.size, 0) +}) + +test('whether a post exists is asked once, then kept for ten minutes', async () => { + let calls = 0 + const check = async slug => (calls++, slug === 'kept-post') + const t = Date.UTC(2026, 9, 25) + assert.equal(await cachedPostExists('kept-post', 'https://x.example', t, check), true) + assert.equal(await cachedPostExists('kept-post', 'https://x.example', t + 60_000, check), true) + assert.equal(calls, 1) + assert.equal(await cachedPostExists('kept-post', 'https://x.example', t + 11 * 60_000, check), true) + assert.equal(calls, 2) +}) + +test('stored post counts keep only plain slugs', async () => { + const store = memoryStore() + store.blobs.set('counts', { etag: '"x"', text: JSON.stringify({ 'post-hello-world': 3, 'post-../counts': 5, 'post-Hello': 2, 'post-': 1, 'post-x': 0 }) }) + assert.deepEqual((await (await handleKudos(get(), { store })).json()).counts, { 'post-hello-world': 3 }) +}) + test('kudos only from the site itself: no Origin, or another one, is refused', async () => { const store = memoryStore() const send = headers => handleKudos(post(JSON.stringify({ id: ID }), headers), { store, ip: '1.1.1.1', origins: ['https://hansel.netlify.app'] }) diff --git a/tests/security/payloads.mjs b/tests/security/payloads.mjs index f12eecb..5029c7b 100644 --- a/tests/security/payloads.mjs +++ b/tests/security/payloads.mjs @@ -173,6 +173,24 @@ export const KUDOS_BODIES = [ '{id:"education-ytt200"}' ] +// Post ids for kudos (`post-`): only a plain slug, and only for a post the site has +export const KUDOS_POST_IDS = [ + 'post-', + 'post--x', + 'post-../counts', + 'post-hello-world/../x', + 'post-hello-world/', + 'post-Hello-World', + 'post-hello world', + 'post-hello%2Fworld', + 'post-hello.world', + 'post-hello-world\u0000', + 'post-__proto__', + `post-${'a'.repeat(300)}`, + 'POST-hello-world', + 'posthello-world' +] + // Bearer tokens for /api/gear/pending (GitHub Actions' sign-in, netlify/lib/githubOidc.mjs) that // aren't a token at all; the ones that look right but are signed or aimed wrong are made in // tests/security/github-oidc.test.mjs diff --git a/utils/share.js b/utils/share.js new file mode 100644 index 0000000..5b53fdb --- /dev/null +++ b/utils/share.js @@ -0,0 +1,20 @@ +// Share a link: the device's own share sheet where it has one (phones, most desktops), else +// copy the link. Returns what happened: 'shared', 'cancelled' (the sheet was closed), 'copied' +// or 'failed'. +export const shareLink = async ({ title, url }, nav = globalThis.navigator) => { + if (typeof nav?.share === 'function') { + try { + await nav.share({ title, url }) + return 'shared' + } catch (err) { + if (err?.name === 'AbortError') return 'cancelled' + // Not allowed here (or not for this link): copy it instead + } + } + try { + await nav.clipboard.writeText(url) + return 'copied' + } catch { + return 'failed' + } +}