fix: store dates as ISO strings instead of Date objects
Prevents dateString.split crash when Nunjucks | date filter receives Date objects from MongoDB. Audit timestamps (createdAt, updatedAt, lastFetchedAt, etc.) now use .toISOString(). Query-used fields (published, nextFetchAt) kept as Date objects for MongoDB compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ export async function block(request, response) {
|
|||||||
await collection.insertOne({
|
await collection.insertOne({
|
||||||
userId,
|
userId,
|
||||||
url,
|
url,
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getFeedsForChannel,
|
getFeedsForChannel,
|
||||||
} from "../storage/feeds.js";
|
} from "../storage/feeds.js";
|
||||||
import { getUserId } from "../utils/auth.js";
|
import { getUserId } from "../utils/auth.js";
|
||||||
|
import { notifyBlogroll } from "../utils/blogroll-notify.js";
|
||||||
import { createFeedResponse } from "../utils/jf2.js";
|
import { createFeedResponse } from "../utils/jf2.js";
|
||||||
import { validateChannel, validateUrl } from "../utils/validation.js";
|
import { validateChannel, validateUrl } from "../utils/validation.js";
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +79,17 @@ export async function follow(request, response) {
|
|||||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
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));
|
response.status(201).json(createFeedResponse(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +134,11 @@ export async function unfollow(request, response) {
|
|||||||
throw new IndiekitError("Feed not found", { status: 404 });
|
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" });
|
response.json({ result: "ok" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function mute(request, response) {
|
|||||||
userId,
|
userId,
|
||||||
channelId,
|
channelId,
|
||||||
url,
|
url,
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export async function createChannel(application, { name, userId }) {
|
|||||||
excludeTypes: [],
|
excludeTypes: [],
|
||||||
excludeRegex: undefined,
|
excludeRegex: undefined,
|
||||||
},
|
},
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await collection.insertOne(channel);
|
await collection.insertOne(channel);
|
||||||
@@ -185,7 +185,7 @@ export async function updateChannel(application, uid, updates, userId) {
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
...updates,
|
...updates,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ returnDocument: "after" },
|
{ returnDocument: "after" },
|
||||||
@@ -242,7 +242,7 @@ export async function reorderChannels(application, channelUids, userId) {
|
|||||||
const operations = channelUids.map((uid, index) => ({
|
const operations = channelUids.map((uid, index) => ({
|
||||||
updateOne: {
|
updateOne: {
|
||||||
filter: userId ? { uid, userId } : { uid },
|
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: [],
|
excludeTypes: [],
|
||||||
excludeRegex: undefined,
|
excludeRegex: undefined,
|
||||||
},
|
},
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await collection.insertOne(channel);
|
await collection.insertOne(channel);
|
||||||
|
|||||||
+13
-13
@@ -45,11 +45,11 @@ export async function createFeed(
|
|||||||
photo: photo || undefined,
|
photo: photo || undefined,
|
||||||
tier: 1, // Start at tier 1 (2 minutes)
|
tier: 1, // Start at tier 1 (2 minutes)
|
||||||
unmodified: 0,
|
unmodified: 0,
|
||||||
nextFetchAt: new Date(), // Fetch immediately
|
nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility)
|
||||||
lastFetchedAt: undefined,
|
lastFetchedAt: undefined,
|
||||||
websub: undefined, // Will be populated if hub is discovered
|
websub: undefined, // Will be populated if hub is discovered
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await collection.insertOne(feed);
|
await collection.insertOne(feed);
|
||||||
@@ -114,7 +114,7 @@ export async function updateFeed(application, id, updates) {
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
...updates,
|
...updates,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ returnDocument: "after" },
|
{ returnDocument: "after" },
|
||||||
@@ -227,15 +227,15 @@ export async function updateFeedAfterFetch(
|
|||||||
updateData = {
|
updateData = {
|
||||||
tier,
|
tier,
|
||||||
unmodified,
|
unmodified,
|
||||||
nextFetchAt,
|
nextFetchAt, // Kept as Date for query compatibility
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date().toISOString(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
updateData = {
|
updateData = {
|
||||||
...extra,
|
...extra,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date().toISOString(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ export async function updateFeedWebsub(application, id, websub) {
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
websub: websubData,
|
websub: websubData,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ returnDocument: "after" },
|
{ returnDocument: "after" },
|
||||||
@@ -314,12 +314,12 @@ export async function updateFeedStatus(application, id, status) {
|
|||||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||||
|
|
||||||
const updateFields = {
|
const updateFields = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status.success) {
|
if (status.success) {
|
||||||
updateFields.status = "active";
|
updateFields.status = "active";
|
||||||
updateFields.lastSuccessAt = new Date();
|
updateFields.lastSuccessAt = new Date().toISOString();
|
||||||
updateFields.consecutiveErrors = 0;
|
updateFields.consecutiveErrors = 0;
|
||||||
updateFields.lastError = undefined;
|
updateFields.lastError = undefined;
|
||||||
updateFields.lastErrorAt = undefined;
|
updateFields.lastErrorAt = undefined;
|
||||||
@@ -330,7 +330,7 @@ export async function updateFeedStatus(application, id, status) {
|
|||||||
} else {
|
} else {
|
||||||
updateFields.status = "error";
|
updateFields.status = "error";
|
||||||
updateFields.lastError = 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
|
// Use $set for most fields, $inc for consecutiveErrors on failure
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
|
|||||||
name: item.name || undefined,
|
name: item.name || undefined,
|
||||||
content: item.content || undefined,
|
content: item.content || undefined,
|
||||||
summary: item.summary || undefined,
|
summary: item.summary || undefined,
|
||||||
published: item.published ? new Date(item.published) : new Date(),
|
published: item.published ? new Date(item.published) : new Date(), // Keep as Date for query compatibility
|
||||||
updated: item.updated ? new Date(item.updated) : undefined,
|
updated: item.updated ? new Date(item.updated) : undefined, // Keep as Date for query compatibility
|
||||||
author: item.author || undefined,
|
author: item.author || undefined,
|
||||||
category: item.category || [],
|
category: item.category || [],
|
||||||
photo: item.photo || [],
|
photo: item.photo || [],
|
||||||
@@ -62,7 +62,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
|
|||||||
inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
|
inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
|
||||||
source: item._source || undefined,
|
source: item._source || undefined,
|
||||||
readBy: [], // Array of user IDs who have read this item
|
readBy: [], // Array of user IDs who have read this item
|
||||||
createdAt: new Date(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await collection.insertOne(document);
|
await collection.insertOne(document);
|
||||||
@@ -182,7 +182,7 @@ function transformToJf2(item, userId) {
|
|||||||
type: item.type,
|
type: item.type,
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
published: item.published?.toISOString(),
|
published: item.published?.toISOString(), // Convert Date to ISO string
|
||||||
_id: item._id.toString(),
|
_id: item._id.toString(),
|
||||||
_is_read: userId ? item.readBy?.includes(userId) : false,
|
_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.name) jf2.name = item.name;
|
||||||
if (item.content) jf2.content = item.content;
|
if (item.content) jf2.content = item.content;
|
||||||
if (item.summary) jf2.summary = item.summary;
|
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.author) jf2.author = normalizeAuthor(item.author);
|
||||||
if (item.category?.length > 0) jf2.category = item.category;
|
if (item.category?.length > 0) jf2.category = item.category;
|
||||||
|
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,10 +61,10 @@ export async function processWebmention(application, source, target, userId) {
|
|||||||
url: verification.url,
|
url: verification.url,
|
||||||
published: verification.published
|
published: verification.published
|
||||||
? new Date(verification.published)
|
? new Date(verification.published)
|
||||||
: new Date(),
|
: new Date(), // Keep as Date for query compatibility
|
||||||
verified: true,
|
verified: true,
|
||||||
readBy: [],
|
readBy: [],
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -73,7 +73,7 @@ export async function processWebmention(application, source, target, userId) {
|
|||||||
notification._id = existing._id;
|
notification._id = existing._id;
|
||||||
} else {
|
} else {
|
||||||
// Insert new notification
|
// Insert new notification
|
||||||
notification.createdAt = new Date();
|
notification.createdAt = new Date().toISOString();
|
||||||
await collection.insertOne(notification);
|
await collection.insertOne(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ function transformNotification(notification, userId) {
|
|||||||
type: "entry",
|
type: "entry",
|
||||||
uid: notification._id?.toString(),
|
uid: notification._id?.toString(),
|
||||||
url: notification.url || notification.source,
|
url: notification.url || notification.source,
|
||||||
published: notification.published?.toISOString(),
|
published: notification.published?.toISOString(), // Convert Date to ISO string
|
||||||
author: notification.author,
|
author: notification.author,
|
||||||
content: notification.content,
|
content: notification.content,
|
||||||
_source: notification.source,
|
_source: notification.source,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
"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.",
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
Reference in New Issue
Block a user