123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165// Feed subscriptions live in SQL, with stable IDs shared by their items. The reader can fetch// only sites the user allowed: subscribing asks Lattice for the feed's site// (lattice.net.requestSite), and refreshing never asks.import { parseFeed } from"./rss.js";
// Newest items imported from a feed per check.constMAX_ITEMS = 100;
// Opening the reader checks feeds not checked for this long.exportconstSTALE_MS = 30 * 60 * 1000;
constMAX_REDIRECTS = 3;
let feeds = [];
const listeners = newSet();
let queue = Promise.resolve();
exportconst feedList = {
subscribe(listener) {
listeners.add(listener);
return() => listeners.delete(listener);
},
getSnapshot: () => feeds,
};
exportasyncfunctionloadFeeds(store) {
await store.ready;
awaitreadFeeds();
}
asyncfunctionreadFeeds() {
feeds = awaitwindow.lattice.db.query("SELECT * FROM feeds WHERE subscribed=1 ORDER BY id");
listeners.forEach((listener) =>listener());
}
// Serialize subscription edits and publish a fresh SQL snapshot after each write.functionchange(operation) {
const run = queue.then(async () => {
const result = awaitoperation();
awaitreadFeeds();
return result;
});
queue = run.catch(() => {});
return run;
}
exportfunctionfeedURL(input) {
let value = input.trim();
if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) value = `https://${value}`;
let url;
try {
url = newURL(value);
} catch {
thrownewError(
"Enter a feed address such as https://example.com/feed.xml.",
);
}
if (url.protocol === "http:") url.protocol = "https:";
if (url.protocol !== "https:")
thrownewError("Enter an https feed address.");
return url.href;
}
exportfunctionsiteName(url) {
try {
returnnewURL(url).host;
} catch {
return url;
}
}
asyncfunctionallow(url) {
const { granted, site } = awaitwindow.lattice.net.requestSite(url);
if (!granted) thrownewError(`You did not allow ${site}.`);
}
// GET a feed, following redirects to https addresses. With `ask`, each new// site is put to the user first; without it, a site that is not allowed// fails the check.asyncfunctiondownload(url, ask) {
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (ask) awaitallow(current);
let response;
try {
response = awaitwindow.lattice.net.fetch(current);
} catch (error) {
const message = String(error.message || error);
thrownewError(
message.includes("not in the allowlist")
? `${siteName(current)} is not allowed. Subscribe to the feed again to allow it.`
: message,
);
}
const location = response.headers?.location;
if (response.status >= 300 && response.status < 400 && location) {
current = feedURL(newURL(location, current).href);
continue;
}
if (response.status !== 200)
thrownewError(
`${siteName(current)} answered with status ${response.status}.`,
);
const body = response.body;
return {
url: current,
xml: typeof body === "string" ? body : JSON.stringify(body),
};
}
thrownewError("The feed address redirects too many times.");
}
// RSS <source> identifies original publication, independently of the subscription.exportfunctionfeedRows(feedId, feed) {
return feed.items.slice(0, MAX_ITEMS).map((item) => ({ ...item, feed_id: feedId }));
}
asyncfunctionsaveSubscription(url, title) {
returnchange(async () => {
const [entry] = awaitwindow.lattice.db.query(
`INSERT INTO feeds (url,title,subscribed) VALUES (?,?,1)
ON CONFLICT(url) DO UPDATE SET title=excluded.title,subscribed=1 RETURNING *`,
[url, title || siteName(url)]);
return entry;
});
}
exportasyncfunctionsubscribe(store, input) {
await store.ready;
const { url, xml } = awaitdownload(feedURL(input), true);
const feed = parseFeed(xml);
const entry = awaitsaveSubscription(url, feed.title);
const counts = await store.importItems(feedRows(entry.id, feed));
awaitchange(() =>window.lattice.db.exec(
"UPDATE feeds SET checked_at=?,error=NULL WHERE id=?", [newDate().toISOString(), entry.id]));
return { title: entry.title, added: counts.added };
}
// Keep the subscription row so saved items still identify their feed.exportfunctionunsubscribe(url) {
returnchange(() =>window.lattice.db.exec("UPDATE feeds SET subscribed=0 WHERE url=?", [url]));
}
exportasyncfunctionrefreshFeeds(store, { olderThan = 0 } = {}) {
awaitloadFeeds(store);
const due = feeds.filter(
(feed) =>Date.now() - (Date.parse(feed.checked_at) || 0) >= olderThan,
);
let added = 0, failed = 0;
for (const entry of due) {
try {
const feed = parseFeed((awaitdownload(entry.url, false)).xml);
// Update the visible title before importing so emitted events use it too.awaitchange(() =>window.lattice.db.exec(
"UPDATE feeds SET title=? WHERE id=?", [feed.title || entry.title, entry.id]));
added += (await store.importItems(feedRows(entry.id, feed))).added;
awaitchange(() =>window.lattice.db.exec(
"UPDATE feeds SET checked_at=?,error=NULL WHERE id=?", [newDate().toISOString(), entry.id]));
} catch (error) {
failed++;
awaitchange(() =>window.lattice.db.exec(
"UPDATE feeds SET checked_at=?,error=? WHERE id=?",
[newDate().toISOString(), String(error.message || error), entry.id]));
}
}
return { checked: due.length, added, failed };
}