Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "p0dcasters",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
Expand Down Expand Up @@ -29,7 +29,8 @@
"ioredis": "^6.0.0",
"next": "^16.4.3",
"react": "^19.3.1",
"react-dom": "^19.3.1"
"react-dom": "^19.3.1",
"tldts": "^7.4.19"
},
"devDependencies": {
"@types/node": "^26.6.5",
Expand Down
15 changes: 15 additions & 0 deletions src/lib/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getDomain } from "tldts";

/**
* The registrable domain (eTLD+1) of a host, public-suffix aware:
* rss.libsyn.com and feeds.libsyn.com -> libsyn.com, foo.co.uk -> foo.co.uk.
* Private suffixes count as suffixes (alice.github.io stays alice.github.io),
* so two people's subdomains on a shared host are never one owner.
* A host with no registrable part (an IP, localhost, a bare suffix) comes back
* as itself, lowercased.
*/
export function registrableDomain(host: string | null | undefined): string {
const h = (host ?? "").trim().toLowerCase().replace(/\.$/, "");
if (!h) return "";
return getDomain(h, { allowPrivateDomains: true }) ?? h;
}
18 changes: 13 additions & 5 deletions src/lib/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { env, now, secret, sha256, token } from "@/lib/auth/crypto";
import { normalizeLang } from "@/lib/format";
import { notifyReviewers } from "@/lib/review";
import { isCrawlerSource } from "@/lib/crawler-submit";
import { registrableDomain } from "@/lib/domain";

/**
* Feed submission, the site-specific half.
Expand Down Expand Up @@ -410,14 +411,21 @@ async function existing(url: string): Promise<{ slug: string; title: string; fee
return row ? { slug: row.slug, title: row.title, feedUrl: row.feed_url } : null;
}

/** A listed show with this title on one of these hosts. */
/**
* A listed show with this title on the same registrable domain as one of these
* hosts. The dump's `host` is the registrable domain (libsyn.com) while a
* submitted feed keeps its full host (rss.libsyn.com), and a publisher's feed
* moves between a platform's subdomains, so comparing exact hosts listed the
* same show twice as title and title-2.
*/
async function sameShow(title: string, hosts: string[]): Promise<{ slug: string; title: string; feedUrl: string } | null> {
const hs = hosts.filter(Boolean);
if (!title || !hs.length) return null;
const ds = [...new Set(hosts.map(registrableDomain).filter(Boolean))];
if (!title || !ds.length) return null;
const where = ds.map(() => "(host = ? OR host LIKE ?)").join(" OR ");
const row = await one<{ slug: string; title: string; feed_url: string }>(
`SELECT slug, title, feed_url FROM podcasts
WHERE lower(title) = lower(?) AND host IN (${hs.map(() => "?").join(",")}) LIMIT 1`,
[title.trim(), ...hs],
WHERE lower(title) = lower(?) AND (${where}) ORDER BY id LIMIT 1`,
[title.trim(), ...ds.flatMap((d) => [d, `%.${d}`])],
);
return row ? { slug: row.slug, title: row.title, feedUrl: row.feed_url } : null;
}
Expand Down
26 changes: 26 additions & 0 deletions test/domain.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { registrableDomain } from '../src/lib/domain.ts';

test('platform subdomains fold to the registrable domain', () => {
assert.equal(registrableDomain('rss.libsyn.com'), 'libsyn.com');
assert.equal(registrableDomain('feeds.libsyn.com'), 'libsyn.com');
assert.equal(registrableDomain('libsyn.com'), 'libsyn.com');
assert.equal(registrableDomain('FEEDS.Buzzsprout.com.'), 'buzzsprout.com');
});

test('multi-label public suffixes are respected', () => {
assert.equal(registrableDomain('podcast.example.co.uk'), 'example.co.uk');
assert.equal(registrableDomain('www.abc.net.au'), 'abc.net.au');
});

test('private suffixes keep owners apart', () => {
assert.equal(registrableDomain('alice.github.io'), 'alice.github.io');
assert.notEqual(registrableDomain('alice.github.io'), registrableDomain('bob.github.io'));
});

test('hosts with no registrable part come back as themselves', () => {
assert.equal(registrableDomain('127.0.0.1'), '127.0.0.1');
assert.equal(registrableDomain(''), '');
assert.equal(registrableDomain(null), '');
});
Loading