mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
Restores complete implementation from feat/endpoint-microsub branch: - Reader UI with views (reader.njk, channel.njk, feeds.njk, etc.) - Feed polling, parsing, and normalization - WebSub subscriber - SSE realtime updates - Redis caching - Search indexing - Media proxy - Webmention processing
164 lines
4.5 KiB
JavaScript
164 lines
4.5 KiB
JavaScript
/**
|
|
* WebSub callback handler
|
|
* @module websub/handler
|
|
*/
|
|
|
|
import { parseFeed } from "../feeds/parser.js";
|
|
import { processFeed } from "../polling/processor.js";
|
|
import { getFeedBySubscriptionId, updateFeedWebsub } from "../storage/feeds.js";
|
|
|
|
import { verifySignature } from "./subscriber.js";
|
|
|
|
/**
|
|
* Verify WebSub subscription
|
|
* GET /microsub/websub/:id
|
|
* @param {object} request - Express request
|
|
* @param {object} response - Express response
|
|
*/
|
|
export async function verify(request, response) {
|
|
const { id } = request.params;
|
|
const {
|
|
"hub.topic": topic,
|
|
"hub.challenge": challenge,
|
|
"hub.lease_seconds": leaseSeconds,
|
|
} = request.query;
|
|
|
|
if (!challenge) {
|
|
return response.status(400).send("Missing hub.challenge");
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const feed = await getFeedBySubscriptionId(application, id);
|
|
|
|
if (!feed) {
|
|
return response.status(404).send("Subscription not found");
|
|
}
|
|
|
|
// Verify topic matches (allow both feed URL and topic URL)
|
|
const expectedTopic = feed.websub?.topic || feed.url;
|
|
if (topic !== feed.url && topic !== expectedTopic) {
|
|
return response.status(400).send("Topic mismatch");
|
|
}
|
|
|
|
// Update lease seconds if provided
|
|
if (leaseSeconds) {
|
|
const seconds = Number.parseInt(leaseSeconds, 10);
|
|
if (seconds > 0) {
|
|
await updateFeedWebsub(application, id, {
|
|
hub: feed.websub?.hub,
|
|
topic: expectedTopic,
|
|
leaseSeconds: seconds,
|
|
secret: feed.websub?.secret,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Mark subscription as active (not pending)
|
|
if (feed.websub?.pending) {
|
|
await updateFeedWebsub(application, id, {
|
|
hub: feed.websub?.hub,
|
|
topic: expectedTopic,
|
|
secret: feed.websub?.secret,
|
|
leaseSeconds: feed.websub?.leaseSeconds,
|
|
pending: false,
|
|
});
|
|
}
|
|
|
|
console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
|
|
|
|
// Return challenge to verify subscription
|
|
response.type("text/plain").send(challenge);
|
|
}
|
|
|
|
/**
|
|
* Receive WebSub notification
|
|
* POST /microsub/websub/:id
|
|
* @param {object} request - Express request
|
|
* @param {object} response - Express response
|
|
*/
|
|
export async function receive(request, response) {
|
|
const { id } = request.params;
|
|
const { application } = request.app.locals;
|
|
|
|
const feed = await getFeedBySubscriptionId(application, id);
|
|
if (!feed) {
|
|
return response.status(404).send("Subscription not found");
|
|
}
|
|
|
|
// Verify X-Hub-Signature if we have a secret
|
|
if (feed.websub?.secret) {
|
|
const signature =
|
|
request.headers["x-hub-signature-256"] ||
|
|
request.headers["x-hub-signature"];
|
|
|
|
if (!signature) {
|
|
return response.status(401).send("Missing signature");
|
|
}
|
|
|
|
// Get raw body for signature verification
|
|
const rawBody =
|
|
typeof request.body === "string"
|
|
? request.body
|
|
: JSON.stringify(request.body);
|
|
|
|
if (!verifySignature(signature, rawBody, feed.websub.secret)) {
|
|
console.warn(`[Microsub] Invalid WebSub signature for ${feed.url}`);
|
|
return response.status(401).send("Invalid signature");
|
|
}
|
|
}
|
|
|
|
// Acknowledge receipt immediately
|
|
response.status(200).send("OK");
|
|
|
|
// Process pushed content in background
|
|
setImmediate(async () => {
|
|
try {
|
|
await processWebsubContent(
|
|
application,
|
|
feed,
|
|
request.headers["content-type"],
|
|
request.body,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
`[Microsub] Error processing WebSub content for ${feed.url}: ${error.message}`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process WebSub pushed content
|
|
* @param {object} application - Indiekit application
|
|
* @param {object} feed - Feed document
|
|
* @param {string} contentType - Content-Type header
|
|
* @param {string|object} body - Request body
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function processWebsubContent(application, feed, contentType, body) {
|
|
// Convert body to string if needed
|
|
const content = typeof body === "string" ? body : JSON.stringify(body);
|
|
|
|
try {
|
|
// Parse the pushed content
|
|
const parsed = await parseFeed(content, feed.url, { contentType });
|
|
|
|
console.log(
|
|
`[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
|
|
);
|
|
|
|
// Process like a normal feed fetch but with pre-parsed content
|
|
// This reuses the existing feed processing logic
|
|
await processFeed(application, {
|
|
...feed,
|
|
_websubContent: parsed,
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`[Microsub] Failed to parse WebSub content for ${feed.url}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export const websubHandler = { verify, receive };
|