Files
indiekit-endpoint-blogroll/lib/controllers/api.js
Ricardo 4d56d58703 feat: add lastItemAt field — tracks newest item publish date per blog
Stores the most recent item's published date on the blog document
during feed sync. Exposed in API response alongside lastFetchAt.
Enables sorting/displaying blogs by content freshness rather than
last fetch time.
2026-02-17 17:43:44 +01:00

360 lines
9.0 KiB
JavaScript

/**
* Public API controller
* @module controllers/api
*/
import { ObjectId } from "mongodb";
import { getBlogs, countBlogs, getBlog, getCategories } from "../storage/blogs.js";
import { getItems, getItemsForBlog } from "../storage/items.js";
import { getSyncStatus } from "../sync/scheduler.js";
import { generateOpml } from "../sync/opml.js";
import { discoverFeeds } from "../utils/feed-discovery.js";
import { handleMicrosubWebhook, isMicrosubAvailable } from "../sync/microsub.js";
/**
* List blogs with optional filtering
* GET /api/blogs
*/
async function listBlogs(request, response) {
const { application } = request.app.locals;
const { category, source, sort, limit = 100, offset = 0 } = request.query;
try {
const blogs = await getBlogs(application, {
category,
source,
sort,
limit: Number(limit),
offset: Number(offset),
});
const total = await countBlogs(application, { category, source });
response.json({
items: blogs.map(sanitizeBlog),
total,
hasMore: Number(offset) + blogs.length < total,
});
} catch (error) {
console.error("[Blogroll API] listBlogs error:", error);
response.status(500).json({ error: "Failed to fetch blogs" });
}
}
/**
* Get single blog with recent items
* GET /api/blogs/:id
*/
async function getBlogDetail(request, response) {
const { application } = request.app.locals;
const { id } = request.params;
try {
if (!ObjectId.isValid(id)) {
return response.status(400).json({ error: "Invalid blog ID" });
}
const blog = await getBlog(application, id);
if (!blog) {
return response.status(404).json({ error: "Blog not found" });
}
// Pass blog to getItemsForBlog to avoid duplicate lookup
// This handles both regular and Microsub-sourced blogs transparently
const items = await getItemsForBlog(application, blog._id, 20, blog);
response.json({
...sanitizeBlog(blog),
items: items.map(sanitizeItem),
});
} catch (error) {
console.error("[Blogroll API] getBlog error:", error);
response.status(500).json({ error: "Failed to fetch blog" });
}
}
/**
* List items across all blogs
* GET /api/items
*/
async function listItems(request, response) {
const { application } = request.app.locals;
const { blog, category, limit = 50, offset = 0 } = request.query;
try {
const result = await getItems(application, {
blogId: blog,
category,
limit: Number(limit),
offset: Number(offset),
});
response.json({
items: result.items.map((item) => ({
...sanitizeItem(item),
blog: item.blog
? {
id: item.blog._id.toString(),
title: item.blog.title,
siteUrl: item.blog.siteUrl,
category: item.blog.category,
photo: item.blog.photo,
}
: null,
})),
hasMore: result.hasMore,
});
} catch (error) {
console.error("[Blogroll API] listItems error:", error);
response.status(500).json({ error: "Failed to fetch items" });
}
}
/**
* List categories
* GET /api/categories
*/
async function listCategories(request, response) {
const { application } = request.app.locals;
try {
const categories = await getCategories(application);
response.json({
items: categories.map((c) => ({ name: c._id, count: c.count })),
});
} catch (error) {
console.error("[Blogroll API] listCategories error:", error);
response.status(500).json({ error: "Failed to fetch categories" });
}
}
/**
* Status endpoint
* GET /api/status
*/
async function status(request, response) {
const { application } = request.app.locals;
try {
const syncStatus = await getSyncStatus(application);
response.json(syncStatus);
} catch (error) {
console.error("[Blogroll API] status error:", error);
response.status(500).json({ error: "Failed to fetch status" });
}
}
/**
* Export OPML
* GET /api/opml
*/
async function exportOpml(request, response) {
const { application } = request.app.locals;
try {
const blogs = await getBlogs(application, { limit: 1000 });
const opml = generateOpml(blogs, "Blogroll");
response.set("Content-Type", "text/x-opml");
response.set("Content-Disposition", 'attachment; filename="blogroll.opml"');
response.send(opml);
} catch (error) {
console.error("[Blogroll API] exportOpml error:", error);
response.status(500).json({ error: "Failed to export OPML" });
}
}
/**
* Export OPML for category
* GET /api/opml/:category
*/
async function exportOpmlCategory(request, response) {
const { application } = request.app.locals;
const { category } = request.params;
try {
const blogs = await getBlogs(application, { category, limit: 1000 });
const opml = generateOpml(blogs, `Blogroll - ${category}`);
response.set("Content-Type", "text/x-opml");
response.set(
"Content-Disposition",
`attachment; filename="blogroll-${encodeURIComponent(category)}.opml"`
);
response.send(opml);
} catch (error) {
console.error("[Blogroll API] exportOpmlCategory error:", error);
response.status(500).json({ error: "Failed to export OPML" });
}
}
/**
* Discover feeds from a website URL
* GET /api/discover?url=...
*/
async function discover(request, response) {
const { url } = request.query;
if (!url) {
return response.status(400).json({ error: "URL parameter required" });
}
try {
const result = await discoverFeeds(url);
response.json(result);
} catch (error) {
console.error("[Blogroll API] discover error:", error);
response.status(500).json({ error: "Failed to discover feeds" });
}
}
// Helper functions
/**
* Sanitize blog for API response
* @param {object} blog - Blog document
* @returns {object} Sanitized blog
*/
function sanitizeBlog(blog) {
const sanitized = {
id: blog._id.toString(),
title: blog.title,
description: blog.description,
feedUrl: blog.feedUrl,
siteUrl: blog.siteUrl,
feedType: blog.feedType,
category: blog.category,
tags: blog.tags,
photo: blog.photo,
author: blog.author,
status: blog.status,
itemCount: blog.itemCount,
pinned: blog.pinned,
lastFetchAt: blog.lastFetchAt,
lastItemAt: blog.lastItemAt || null,
source: blog.source || null,
};
// Include Microsub metadata if applicable
if (blog.source === "microsub") {
sanitized.microsubChannel = blog.microsubChannelName;
}
return sanitized;
}
/**
* Sanitize item for API response
* @param {object} item - Item document
* @returns {object} Sanitized item
*/
function sanitizeItem(item) {
const published = item.published ? new Date(item.published) : null;
return {
id: item._id.toString(),
url: item.url,
title: item.title,
summary: item.summary,
published: item.published,
isFuture: published ? published > new Date() : false,
author: item.author,
photo: item.photo,
categories: item.categories,
};
}
/**
* Microsub webhook handler
* Receives subscription change notifications from Microsub
* POST /api/microsub-webhook
*/
async function microsubWebhook(request, response) {
const { application } = request.app.locals;
try {
// Verify Microsub is available
if (!isMicrosubAvailable(application)) {
return response.status(503).json({
ok: false,
error: "Microsub integration not available",
});
}
const { action, url, channelName, title } = request.body;
if (!action || !url) {
return response.status(400).json({
ok: false,
error: "Missing required fields: action and url",
});
}
const result = await handleMicrosubWebhook(application, {
action,
url,
channelName,
title,
});
response.json(result);
} catch (error) {
console.error("[Blogroll API] microsubWebhook error:", error);
response.status(500).json({
ok: false,
error: "Webhook processing failed",
});
}
}
/**
* Check Microsub integration status
* GET /api/microsub-status
*/
async function microsubStatus(request, response) {
const { application } = request.app.locals;
try {
const available = isMicrosubAvailable(application);
if (!available) {
return response.json({
available: false,
message: "Microsub plugin not installed or collections not available",
});
}
// Get count of microsub-sourced blogs
const db = application.getBlogrollDb();
const microsubBlogCount = await db.collection("blogrollBlogs").countDocuments({
source: { $regex: /^microsub/ },
});
response.json({
available: true,
blogs: microsubBlogCount,
});
} catch (error) {
console.error("[Blogroll API] microsubStatus error:", error);
response.status(500).json({
available: false,
error: error.message,
});
}
}
export const apiController = {
listBlogs,
getBlog: getBlogDetail,
listItems,
listCategories,
status,
exportOpml,
exportOpmlCategory,
discover,
microsubWebhook,
microsubStatus,
};