diff --git a/lib/controllers/block.js b/lib/controllers/block.js index 13fd09e..c3572a6 100644 --- a/lib/controllers/block.js +++ b/lib/controllers/block.js @@ -54,7 +54,7 @@ export async function block(request, response) { await collection.insertOne({ userId, url, - createdAt: new Date(), + createdAt: new Date().toISOString(), }); } diff --git a/lib/controllers/follow.js b/lib/controllers/follow.js index 7493e1f..ef79e7d 100644 --- a/lib/controllers/follow.js +++ b/lib/controllers/follow.js @@ -14,6 +14,7 @@ import { getFeedsForChannel, } from "../storage/feeds.js"; import { getUserId } from "../utils/auth.js"; +import { notifyBlogroll } from "../utils/blogroll-notify.js"; import { createFeedResponse } from "../utils/jf2.js"; import { validateChannel, validateUrl } from "../utils/validation.js"; import { @@ -78,6 +79,17 @@ export async function follow(request, response) { console.error(`[Microsub] Error fetching new feed ${url}:`, error.message); }); + // Notify blogroll plugin (fire-and-forget) + notifyBlogroll(application, "follow", { + url, + title: feed.title, + channelName: channelDocument.name, + feedId: feed._id.toString(), + channelId: channelDocument._id.toString(), + }).catch((error) => { + console.error(`[Microsub] Blogroll notify error:`, error.message); + }); + response.status(201).json(createFeedResponse(feed)); } @@ -122,6 +134,11 @@ export async function unfollow(request, response) { throw new IndiekitError("Feed not found", { status: 404 }); } + // Notify blogroll plugin (fire-and-forget) + notifyBlogroll(application, "unfollow", { url }).catch((error) => { + console.error(`[Microsub] Blogroll notify error:`, error.message); + }); + response.json({ result: "ok" }); } diff --git a/lib/controllers/mute.js b/lib/controllers/mute.js index ea9efd4..1d15f96 100644 --- a/lib/controllers/mute.js +++ b/lib/controllers/mute.js @@ -85,7 +85,7 @@ export async function mute(request, response) { userId, channelId, url, - createdAt: new Date(), + createdAt: new Date().toISOString(), }); } diff --git a/lib/storage/channels.js b/lib/storage/channels.js index a40173a..d972503 100644 --- a/lib/storage/channels.js +++ b/lib/storage/channels.js @@ -74,8 +74,8 @@ export async function createChannel(application, { name, userId }) { excludeTypes: [], excludeRegex: undefined, }, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; await collection.insertOne(channel); @@ -185,7 +185,7 @@ export async function updateChannel(application, uid, updates, userId) { { $set: { ...updates, - updatedAt: new Date(), + updatedAt: new Date().toISOString(), }, }, { returnDocument: "after" }, @@ -242,7 +242,7 @@ export async function reorderChannels(application, channelUids, userId) { const operations = channelUids.map((uid, index) => ({ updateOne: { filter: userId ? { uid, userId } : { uid }, - update: { $set: { order: index, updatedAt: new Date() } }, + update: { $set: { order: index, updatedAt: new Date().toISOString() } }, }, })); @@ -298,8 +298,8 @@ export async function ensureNotificationsChannel(application, userId) { excludeTypes: [], excludeRegex: undefined, }, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; await collection.insertOne(channel); diff --git a/lib/storage/feeds.js b/lib/storage/feeds.js index 8472494..b65dfdc 100644 --- a/lib/storage/feeds.js +++ b/lib/storage/feeds.js @@ -45,11 +45,11 @@ export async function createFeed( photo: photo || undefined, tier: 1, // Start at tier 1 (2 minutes) unmodified: 0, - nextFetchAt: new Date(), // Fetch immediately + nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility) lastFetchedAt: undefined, websub: undefined, // Will be populated if hub is discovered - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; await collection.insertOne(feed); @@ -114,7 +114,7 @@ export async function updateFeed(application, id, updates) { { $set: { ...updates, - updatedAt: new Date(), + updatedAt: new Date().toISOString(), }, }, { returnDocument: "after" }, @@ -227,15 +227,15 @@ export async function updateFeedAfterFetch( updateData = { tier, unmodified, - nextFetchAt, - lastFetchedAt: new Date(), - updatedAt: new Date(), + nextFetchAt, // Kept as Date for query compatibility + lastFetchedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; } else { updateData = { ...extra, - lastFetchedAt: new Date(), - updatedAt: new Date(), + lastFetchedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; } @@ -280,7 +280,7 @@ export async function updateFeedWebsub(application, id, websub) { { $set: { websub: websubData, - updatedAt: new Date(), + updatedAt: new Date().toISOString(), }, }, { returnDocument: "after" }, @@ -314,12 +314,12 @@ export async function updateFeedStatus(application, id, status) { const objectId = typeof id === "string" ? new ObjectId(id) : id; const updateFields = { - updatedAt: new Date(), + updatedAt: new Date().toISOString(), }; if (status.success) { updateFields.status = "active"; - updateFields.lastSuccessAt = new Date(); + updateFields.lastSuccessAt = new Date().toISOString(); updateFields.consecutiveErrors = 0; updateFields.lastError = undefined; updateFields.lastErrorAt = undefined; @@ -330,7 +330,7 @@ export async function updateFeedStatus(application, id, status) { } else { updateFields.status = "error"; updateFields.lastError = status.error; - updateFields.lastErrorAt = new Date(); + updateFields.lastErrorAt = new Date().toISOString(); } // Use $set for most fields, $inc for consecutiveErrors on failure diff --git a/lib/storage/items.js b/lib/storage/items.js index 6896807..5f46773 100644 --- a/lib/storage/items.js +++ b/lib/storage/items.js @@ -49,8 +49,8 @@ export async function addItem(application, { channelId, feedId, uid, item }) { name: item.name || undefined, content: item.content || undefined, summary: item.summary || undefined, - published: item.published ? new Date(item.published) : new Date(), - updated: item.updated ? new Date(item.updated) : undefined, + published: item.published ? new Date(item.published) : new Date(), // Keep as Date for query compatibility + updated: item.updated ? new Date(item.updated) : undefined, // Keep as Date for query compatibility author: item.author || undefined, category: item.category || [], photo: item.photo || [], @@ -62,7 +62,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) { inReplyTo: item["in-reply-to"] || item.inReplyTo || [], source: item._source || undefined, readBy: [], // Array of user IDs who have read this item - createdAt: new Date(), + createdAt: new Date().toISOString(), }; await collection.insertOne(document); @@ -182,7 +182,7 @@ function transformToJf2(item, userId) { type: item.type, uid: item.uid, url: item.url, - published: item.published?.toISOString(), + published: item.published?.toISOString(), // Convert Date to ISO string _id: item._id.toString(), _is_read: userId ? item.readBy?.includes(userId) : false, }; @@ -191,7 +191,7 @@ function transformToJf2(item, userId) { if (item.name) jf2.name = item.name; if (item.content) jf2.content = item.content; if (item.summary) jf2.summary = item.summary; - if (item.updated) jf2.updated = item.updated.toISOString(); + if (item.updated) jf2.updated = item.updated.toISOString(); // Convert Date to ISO string if (item.author) jf2.author = normalizeAuthor(item.author); if (item.category?.length > 0) jf2.category = item.category; diff --git a/lib/utils/blogroll-notify.js b/lib/utils/blogroll-notify.js new file mode 100644 index 0000000..67d3d6d --- /dev/null +++ b/lib/utils/blogroll-notify.js @@ -0,0 +1,119 @@ +/** + * Notify blogroll plugin of Microsub follow/unfollow events + * @module utils/blogroll-notify + */ + +/** + * Notify blogroll of a feed subscription change + * Fire-and-forget — errors are logged but don't block the response + * @param {object} application - Application instance + * @param {string} action - "follow" or "unfollow" + * @param {object} data - Feed data + * @param {string} data.url - Feed URL + * @param {string} [data.title] - Feed title + * @param {string} [data.channelName] - Channel name + * @param {string} [data.feedId] - Microsub feed ID + * @param {string} [data.channelId] - Microsub channel ID + */ +export async function notifyBlogroll(application, action, data) { + // Check if blogroll plugin is installed + if (typeof application.getBlogrollDb !== "function") { + return; + } + + const db = application.getBlogrollDb(); + if (!db) { + return; + } + + const collection = db.collection("blogrollBlogs"); + const now = new Date(); + + if (action === "follow") { + // Skip if this feed was explicitly deleted by the user + const deleted = await collection.findOne({ + feedUrl: data.url, + status: "deleted", + }); + if (deleted) { + console.log( + `[Microsub→Blogroll] Skipping follow for ${data.url} — previously deleted by user`, + ); + return; + } + + // Upsert the blog entry + await collection.updateOne( + { feedUrl: data.url }, + { + $set: { + title: data.title || extractDomain(data.url), + siteUrl: extractSiteUrl(data.url), + feedType: "rss", + category: data.channelName || "Microsub", + source: "microsub", + microsubFeedId: data.feedId || null, + microsubChannelId: data.channelId || null, + microsubChannelName: data.channelName || null, + skipItemFetch: true, + status: "active", + updatedAt: now, + }, + $setOnInsert: { + description: null, + tags: [], + photo: null, + author: null, + lastFetchAt: null, + lastError: null, + itemCount: 0, + pinned: false, + hidden: false, + notes: null, + createdAt: now, + }, + }, + { upsert: true }, + ); + + console.log(`[Microsub→Blogroll] Added/updated feed ${data.url}`); + } else if (action === "unfollow") { + // Soft-delete the blog entry if it came from microsub + const result = await collection.updateOne( + { + feedUrl: data.url, + source: "microsub", + status: { $ne: "deleted" }, + }, + { + $set: { + status: "deleted", + hidden: true, + deletedAt: now, + updatedAt: now, + }, + }, + ); + + if (result.modifiedCount > 0) { + console.log(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`); + } + } +} + +function extractDomain(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +function extractSiteUrl(feedUrl) { + try { + const parsed = new URL(feedUrl); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return ""; + } +} diff --git a/lib/webmention/processor.js b/lib/webmention/processor.js index ae74487..1cca234 100644 --- a/lib/webmention/processor.js +++ b/lib/webmention/processor.js @@ -61,10 +61,10 @@ export async function processWebmention(application, source, target, userId) { url: verification.url, published: verification.published ? new Date(verification.published) - : new Date(), + : new Date(), // Keep as Date for query compatibility verified: true, readBy: [], - updatedAt: new Date(), + updatedAt: new Date().toISOString(), }; if (existing) { @@ -73,7 +73,7 @@ export async function processWebmention(application, source, target, userId) { notification._id = existing._id; } else { // Insert new notification - notification.createdAt = new Date(); + notification.createdAt = new Date().toISOString(); await collection.insertOne(notification); } @@ -190,7 +190,7 @@ function transformNotification(notification, userId) { type: "entry", uid: notification._id?.toString(), url: notification.url || notification.source, - published: notification.published?.toISOString(), + published: notification.published?.toISOString(), // Convert Date to ISO string author: notification.author, content: notification.content, _source: notification.source, diff --git a/package.json b/package.json index 0c36869..72fd383 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-microsub", - "version": "1.0.26", + "version": "1.0.28", "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", "keywords": [ "indiekit",