diff --git a/packages/endpoint-microsub/index.js b/packages/endpoint-microsub/index.js new file mode 100644 index 000000000..a4e1d0248 --- /dev/null +++ b/packages/endpoint-microsub/index.js @@ -0,0 +1,63 @@ +import express from "express"; + +import { microsubController } from "./lib/controllers/microsub.js"; +import { createIndexes } from "./lib/storage/items.js"; + +const defaults = { + mountPath: "/microsub", +}; +const router = express.Router(); + +export default class MicrosubEndpoint { + name = "Microsub endpoint"; + + /** + * @param {object} options - Plugin options + * @param {string} [options.mountPath] - Path to mount Microsub endpoint + */ + constructor(options = {}) { + this.options = { ...defaults, ...options }; + this.mountPath = this.options.mountPath; + } + + /** + * Microsub API routes (authenticated) + * @returns {import("express").Router} Express router + */ + get routes() { + // Main Microsub endpoint - dispatches based on action parameter + router.get("/", microsubController.get); + router.post("/", microsubController.post); + + return router; + } + + /** + * Initialize plugin + * @param {object} indiekit - Indiekit instance + */ + init(indiekit) { + console.info("[Microsub] Initializing endpoint-microsub plugin"); + + // Register MongoDB collections + indiekit.addCollection("microsub_channels"); + indiekit.addCollection("microsub_items"); + + console.info("[Microsub] Registered MongoDB collections"); + + // Register endpoint + indiekit.addEndpoint(this); + + // Set microsub endpoint URL in config + if (!indiekit.config.application.microsubEndpoint) { + indiekit.config.application.microsubEndpoint = this.mountPath; + } + + // Create indexes for optimal performance (runs in background) + if (indiekit.database) { + createIndexes(indiekit).catch((error) => { + console.warn("[Microsub] Index creation failed:", error.message); + }); + } + } +} diff --git a/packages/endpoint-microsub/lib/controllers/channels.js b/packages/endpoint-microsub/lib/controllers/channels.js new file mode 100644 index 000000000..be861b0ad --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/channels.js @@ -0,0 +1,110 @@ +/** + * Channel management controller + * @module controllers/channels + */ + +import { IndiekitError } from "@indiekit/error"; + +import { + getChannels, + createChannel, + updateChannel, + deleteChannel, + reorderChannels, +} from "../storage/channels.js"; +import { getUserId } from "../utils/auth.js"; +import { + validateChannel, + validateChannelName, + parseArrayParameter, +} from "../utils/validation.js"; + +/** + * List all channels + * GET ?action=channels + * @param {object} request - Express request + * @param {object} response - Express response + */ +export async function list(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + + const channels = await getChannels(application, userId); + + response.json({ channels }); +} + +/** + * Handle channel actions (create, update, delete, order) + * POST ?action=channels + * @param {object} request - Express request + * @param {object} response - Express response + * @returns {Promise} + */ +export async function action(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { method, name, uid } = request.body; + + // Delete channel + if (method === "delete") { + validateChannel(uid); + + const deleted = await deleteChannel(application, uid, userId); + if (!deleted) { + throw new IndiekitError("Channel not found or cannot be deleted", { + status: 404, + }); + } + + return response.json({ deleted: uid }); + } + + // Reorder channels + if (method === "order") { + const channelUids = parseArrayParameter(request.body, "channels"); + if (channelUids.length === 0) { + throw new IndiekitError("Missing channels[] parameter", { + status: 400, + }); + } + + await reorderChannels(application, channelUids, userId); + + const channels = await getChannels(application, userId); + return response.json({ channels }); + } + + // Update existing channel + if (uid) { + validateChannel(uid); + + if (name) { + validateChannelName(name); + } + + const channel = await updateChannel(application, uid, { name }, userId); + if (!channel) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + return response.json({ + uid: channel.uid, + name: channel.name, + }); + } + + // Create new channel + validateChannelName(name); + + const channel = await createChannel(application, { name, userId }); + + response.status(201).json({ + uid: channel.uid, + name: channel.name, + }); +} + +export const channelsController = { list, action }; diff --git a/packages/endpoint-microsub/lib/controllers/microsub.js b/packages/endpoint-microsub/lib/controllers/microsub.js new file mode 100644 index 000000000..a25fd67dc --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/microsub.js @@ -0,0 +1,86 @@ +/** + * Main Microsub action router + * @module controllers/microsub + */ + +import { IndiekitError } from "@indiekit/error"; + +import { validateAction } from "../utils/validation.js"; + +import { list as listChannels, action as channelAction } from "./channels.js"; +import { get as getTimeline, action as timelineAction } from "./timeline.js"; + +/** + * Route GET requests to appropriate action handler + * @param {object} request - Express request + * @param {object} response - Express response + * @param {Function} next - Express next function + * @returns {Promise} + */ +export async function get(request, response, next) { + try { + const { action } = request.query; + + if (!action) { + // Return basic endpoint info + return response.json({ + type: "microsub", + actions: ["channels", "timeline"], + }); + } + + validateAction(action); + + switch (action) { + case "channels": { + return listChannels(request, response); + } + + case "timeline": { + return getTimeline(request, response); + } + + default: { + throw new IndiekitError(`Unsupported GET action: ${action}`, { + status: 400, + }); + } + } + } catch (error) { + next(error); + } +} + +/** + * Route POST requests to appropriate action handler + * @param {object} request - Express request + * @param {object} response - Express response + * @param {Function} next - Express next function + * @returns {Promise} + */ +export async function post(request, response, next) { + try { + const action = request.body.action || request.query.action; + validateAction(action); + + switch (action) { + case "channels": { + return channelAction(request, response); + } + + case "timeline": { + return timelineAction(request, response); + } + + default: { + throw new IndiekitError(`Unsupported POST action: ${action}`, { + status: 400, + }); + } + } + } catch (error) { + next(error); + } +} + +export const microsubController = { get, post }; diff --git a/packages/endpoint-microsub/lib/controllers/timeline.js b/packages/endpoint-microsub/lib/controllers/timeline.js new file mode 100644 index 000000000..8419d9a05 --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/timeline.js @@ -0,0 +1,119 @@ +/** + * Timeline controller + * @module controllers/timeline + */ + +import { IndiekitError } from "@indiekit/error"; + +import { getChannel } from "../storage/channels.js"; +import { + getTimelineItems, + markItemsRead, + markItemsUnread, + removeItems, +} from "../storage/items.js"; +import { getUserId } from "../utils/auth.js"; +import { + validateChannel, + validateEntries, + parseArrayParameter, +} from "../utils/validation.js"; + +/** + * Get timeline items for a channel + * GET ?action=timeline&channel= + * @param {object} request - Express request + * @param {object} response - Express response + */ +export async function get(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { channel, before, after, limit } = request.query; + + validateChannel(channel); + + // Verify channel exists + const channelDocument = await getChannel(application, channel, userId); + if (!channelDocument) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + const timeline = await getTimelineItems(application, channelDocument._id, { + before, + after, + limit, + userId, + }); + + response.json(timeline); +} + +/** + * Handle timeline actions (mark_read, mark_unread, remove) + * POST ?action=timeline + * @param {object} request - Express request + * @param {object} response - Express response + * @returns {Promise} + */ +export async function action(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { method, channel } = request.body; + + validateChannel(channel); + + // Verify channel exists + const channelDocument = await getChannel(application, channel, userId); + if (!channelDocument) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + // Get entry IDs from request + const entries = parseArrayParameter(request.body, "entry"); + + switch (method) { + case "mark_read": { + validateEntries(entries); + const count = await markItemsRead( + application, + channelDocument._id, + entries, + userId, + ); + return response.json({ result: "ok", updated: count }); + } + + case "mark_unread": { + validateEntries(entries); + const count = await markItemsUnread( + application, + channelDocument._id, + entries, + userId, + ); + return response.json({ result: "ok", updated: count }); + } + + case "remove": { + validateEntries(entries); + const count = await removeItems( + application, + channelDocument._id, + entries, + ); + return response.json({ result: "ok", removed: count }); + } + + default: { + throw new IndiekitError(`Invalid timeline method: ${method}`, { + status: 400, + }); + } + } +} + +export const timelineController = { get, action }; diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js new file mode 100644 index 000000000..477f657f5 --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -0,0 +1,253 @@ +/** + * Channel storage operations + * @module storage/channels + */ + +import { generateChannelUid } from "../utils/uid.js"; + +/** + * Get channels collection from application + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getCollection(application) { + return application.collections.get("microsub_channels"); +} + +/** + * Get items collection for unread counts + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getItemsCollection(application) { + return application.collections.get("microsub_items"); +} + +/** + * Create a new channel + * @param {object} application - Indiekit application + * @param {object} data - Channel data + * @param {string} data.name - Channel name + * @param {string} [data.userId] - User ID + * @returns {Promise} Created channel + */ +export async function createChannel(application, { name, userId }) { + const collection = getCollection(application); + + // Generate unique UID with retry on collision + let uid; + let attempts = 0; + const maxAttempts = 5; + + while (attempts < maxAttempts) { + uid = generateChannelUid(); + const existing = await collection.findOne({ uid }); + if (!existing) break; + attempts++; + } + + if (attempts >= maxAttempts) { + throw new Error("Failed to generate unique channel UID"); + } + + // Get max order for user + const maxOrderResult = await collection + .find({ userId }) + // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method + .sort({ order: -1 }) + .limit(1) + .toArray(); + + const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0; + + const channel = { + uid, + name, + userId, + order, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await collection.insertOne(channel); + + return channel; +} + +/** + * Get all channels for a user + * @param {object} application - Indiekit application + * @param {string} [userId] - User ID (optional for single-user mode) + * @returns {Promise} Array of channels with unread counts + */ +export async function getChannels(application, userId) { + const collection = getCollection(application); + const itemsCollection = getItemsCollection(application); + + const filter = userId ? { userId } : {}; + // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB methods + const channels = await collection.find(filter).sort({ order: 1 }).toArray(); + + // Get unread counts for each channel + const channelsWithCounts = await Promise.all( + channels.map(async (channel) => { + const unreadCount = await itemsCollection.countDocuments({ + channelId: channel._id, + readBy: { $ne: userId }, + }); + + return { + uid: channel.uid, + name: channel.name, + unread: unreadCount > 0 ? unreadCount : false, + }; + }), + ); + + // Always include notifications channel first + const notificationsChannel = channelsWithCounts.find( + (c) => c.uid === "notifications", + ); + const otherChannels = channelsWithCounts.filter( + (c) => c.uid !== "notifications", + ); + + if (notificationsChannel) { + return [notificationsChannel, ...otherChannels]; + } + + return channelsWithCounts; +} + +/** + * Get a single channel by UID + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {string} [userId] - User ID + * @returns {Promise} Channel or null + */ +export async function getChannel(application, uid, userId) { + const collection = getCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + return collection.findOne(query); +} + +/** + * Update a channel + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {object} updates - Fields to update + * @param {string} [userId] - User ID + * @returns {Promise} Updated channel + */ +export async function updateChannel(application, uid, updates, userId) { + const collection = getCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + const result = await collection.findOneAndUpdate( + query, + { + $set: { + ...updates, + updatedAt: new Date(), + }, + }, + { returnDocument: "after" }, + ); + + return result; +} + +/** + * Delete a channel and all its items + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {string} [userId] - User ID + * @returns {Promise} True if deleted + */ +export async function deleteChannel(application, uid, userId) { + const collection = getCollection(application); + const itemsCollection = getItemsCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + // Don't allow deleting notifications channel + if (uid === "notifications") { + return false; + } + + // Find the channel first to get its ObjectId + const channel = await collection.findOne(query); + if (!channel) { + return false; + } + + // Delete all items in channel + const itemsDeleted = await itemsCollection.deleteMany({ + channelId: channel._id, + }); + console.info( + `[Microsub] Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`, + ); + + const result = await collection.deleteOne({ _id: channel._id }); + return result.deletedCount > 0; +} + +/** + * Reorder channels + * @param {object} application - Indiekit application + * @param {Array} channelUids - Ordered array of channel UIDs + * @param {string} [userId] - User ID + * @returns {Promise} + */ +export async function reorderChannels(application, channelUids, userId) { + const collection = getCollection(application); + + // Update order for each channel + const operations = channelUids.map((uid, index) => ({ + updateOne: { + filter: userId ? { uid, userId } : { uid }, + update: { $set: { order: index, updatedAt: new Date() } }, + }, + })); + + if (operations.length > 0) { + await collection.bulkWrite(operations); + } +} + +/** + * Ensure notifications channel exists + * @param {object} application - Indiekit application + * @param {string} [userId] - User ID + * @returns {Promise} Notifications channel + */ +export async function ensureNotificationsChannel(application, userId) { + const collection = getCollection(application); + + const existing = await collection.findOne({ + uid: "notifications", + ...(userId && { userId }), + }); + + if (existing) { + return existing; + } + + // Create notifications channel + const channel = { + uid: "notifications", + name: "Notifications", + userId, + order: -1, // Always first + createdAt: new Date(), + updatedAt: new Date(), + }; + + await collection.insertOne(channel); + return channel; +} diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js new file mode 100644 index 000000000..b80296a7d --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -0,0 +1,260 @@ +/** + * Timeline item storage operations + * @module storage/items + */ + +import { ObjectId } from "mongodb"; + +import { + buildPaginationQuery, + buildPaginationSort, + generatePagingCursors, + parseLimit, +} from "../utils/pagination.js"; + +/** + * Get items collection from application + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getCollection(application) { + return application.collections.get("microsub_items"); +} + +/** + * Get timeline items for a channel + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object} options - Query options + * @param {string} [options.before] - Before cursor + * @param {string} [options.after] - After cursor + * @param {number} [options.limit] - Items per page + * @param {string} [options.userId] - User ID for read state + * @returns {Promise} Timeline with items and paging + */ +export async function getTimelineItems(application, channelId, options = {}) { + const collection = getCollection(application); + const objectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + const limit = parseLimit(options.limit); + + const baseQuery = { channelId: objectId }; + + const query = buildPaginationQuery({ + before: options.before, + after: options.after, + baseQuery, + }); + + const sort = buildPaginationSort(options.before); + + // Fetch one extra to check if there are more + const items = await collection + // eslint-disable-next-line unicorn/no-array-callback-reference -- MongoDB query object + .find(query) + // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method + .sort(sort) + .limit(limit + 1) + .toArray(); + + const hasMore = items.length > limit; + if (hasMore) { + items.pop(); + } + + // Transform to jf2 format + const jf2Items = items.map((item) => transformToJf2(item, options.userId)); + + // Generate paging cursors + const paging = generatePagingCursors(items, limit, hasMore, options.before); + + return { + items: jf2Items, + paging, + }; +} + +/** + * Transform database item to jf2 format + * @param {object} item - Database item + * @param {string} [userId] - User ID for read state + * @returns {object} jf2 item + */ +function transformToJf2(item, userId) { + const jf2 = { + type: item.type, + uid: item.uid, + url: item.url, + published: item.published?.toISOString(), + _id: item._id.toString(), + _is_read: userId ? item.readBy?.includes(userId) : false, + }; + + // Optional fields + 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.author) jf2.author = item.author; + if (item.category?.length > 0) jf2.category = item.category; + if (item.photo?.length > 0) jf2.photo = item.photo; + if (item.video?.length > 0) jf2.video = item.video; + if (item.audio?.length > 0) jf2.audio = item.audio; + + // Interaction types + if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf; + if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf; + if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf; + if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo; + + // Source + if (item.source) jf2._source = item.source; + + return jf2; +} + +/** + * Mark items as read + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to mark as read + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsRead(application, channelId, entryIds, userId) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Handle "last-read-entry" special value + if (entryIds.includes("last-read-entry")) { + const result = await collection.updateMany( + { channelId: channelObjectId }, + { $addToSet: { readBy: userId } }, + ); + return result.modifiedCount; + } + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.updateMany( + { + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $addToSet: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Mark items as unread + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to mark as unread + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsUnread( + application, + channelId, + entryIds, + userId, +) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.updateMany( + { + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $pull: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Remove items from channel + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to remove + * @returns {Promise} Number of items removed + */ +export async function removeItems(application, channelId, entryIds) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.deleteMany({ + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }); + + return result.deletedCount; +} + +/** + * Create indexes for efficient queries + * @param {object} application - Indiekit application + * @returns {Promise} + */ +export async function createIndexes(application) { + const collection = getCollection(application); + + // Primary query indexes + await collection.createIndex({ channelId: 1, published: -1 }); + await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true }); + + // URL matching index for mark_read operations + await collection.createIndex({ channelId: 1, url: 1 }); +} diff --git a/packages/endpoint-microsub/lib/utils/auth.js b/packages/endpoint-microsub/lib/utils/auth.js new file mode 100644 index 000000000..f052df42b --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/auth.js @@ -0,0 +1,35 @@ +/** + * Authentication utilities for Microsub + * @module utils/auth + */ + +/** + * Get the user ID from request context + * + * In Indiekit, the userId can come from: + * 1. request.session.userId (if explicitly set) + * 2. request.session.me (from token introspection) + * 3. application.publication.me (single-user fallback) + * @param {object} request - Express request + * @returns {string} User ID + */ +export function getUserId(request) { + // Check session for explicit userId + if (request.session?.userId) { + return request.session.userId; + } + + // Check session for me URL from token introspection + if (request.session?.me) { + return request.session.me; + } + + // Fall back to publication me URL (single-user mode) + const { application } = request.app.locals; + if (application?.publication?.me) { + return application.publication.me; + } + + // Final fallback: use "default" as user ID for single-user instances + return "default"; +} diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js new file mode 100644 index 000000000..96cc3dcfa --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -0,0 +1,148 @@ +/** + * Cursor-based pagination utilities for Microsub + * @module utils/pagination + */ + +import { ObjectId } from "mongodb"; + +/** + * Default pagination limit + */ +export const DEFAULT_LIMIT = 20; + +/** + * Maximum pagination limit + */ +export const MAX_LIMIT = 100; + +/** + * Encode a cursor from timestamp and ID + * @param {Date} timestamp - Item timestamp + * @param {string} id - Item ID + * @returns {string} Base64-encoded cursor + */ +export function encodeCursor(timestamp, id) { + const data = { + t: timestamp instanceof Date ? timestamp.toISOString() : timestamp, + i: id.toString(), + }; + return Buffer.from(JSON.stringify(data)).toString("base64url"); +} + +/** + * Decode a cursor string + * @param {string} cursor - Base64-encoded cursor + * @returns {object|undefined} Decoded cursor with timestamp and id + */ +export function decodeCursor(cursor) { + if (!cursor) return; + + try { + const decoded = Buffer.from(cursor, "base64url").toString("utf8"); + const data = JSON.parse(decoded); + return { + timestamp: new Date(data.t), + id: data.i, + }; + } catch { + return; + } +} + +/** + * Build MongoDB query for cursor-based pagination + * @param {object} options - Pagination options + * @param {string} [options.before] - Before cursor + * @param {string} [options.after] - After cursor + * @param {object} [options.baseQuery] - Base query to extend + * @returns {object} MongoDB query object + */ +export function buildPaginationQuery({ before, after, baseQuery = {} }) { + const query = { ...baseQuery }; + + if (before) { + const cursor = decodeCursor(before); + if (cursor) { + // Items newer than cursor (for scrolling up) + query.$or = [ + { published: { $gt: cursor.timestamp } }, + { + published: cursor.timestamp, + _id: { $gt: new ObjectId(cursor.id) }, + }, + ]; + } + } else if (after) { + const cursor = decodeCursor(after); + if (cursor) { + // Items older than cursor (for scrolling down) + query.$or = [ + { published: { $lt: cursor.timestamp } }, + { + published: cursor.timestamp, + _id: { $lt: new ObjectId(cursor.id) }, + }, + ]; + } + } + + return query; +} + +/** + * Build sort options for cursor pagination + * @param {string} [before] - Before cursor (ascending order) + * @returns {object} MongoDB sort object + */ +export function buildPaginationSort(before) { + if (before) { + return { published: 1, _id: 1 }; + } + return { published: -1, _id: -1 }; +} + +/** + * Generate pagination cursors from items + * @param {Array} items - Array of items + * @param {number} limit - Items per page + * @param {boolean} hasMore - Whether more items exist + * @param {string} [before] - Original before cursor + * @returns {object} Pagination object with before/after cursors + */ +export function generatePagingCursors(items, limit, hasMore, before) { + if (!items || items.length === 0) { + return {}; + } + + const paging = {}; + + if (before) { + items.reverse(); + paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); + if (hasMore) { + paging.before = encodeCursor(items[0].published, items[0]._id); + } + } else { + if (hasMore) { + paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); + } + if (items.length > 0) { + paging.before = encodeCursor(items[0].published, items[0]._id); + } + } + + return paging; +} + +/** + * Parse and validate limit parameter + * @param {string|number} limit - Requested limit + * @returns {number} Validated limit + */ +export function parseLimit(limit) { + const parsed = Number.parseInt(limit, 10); + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_LIMIT; + } + return Math.min(parsed, MAX_LIMIT); +} diff --git a/packages/endpoint-microsub/lib/utils/uid.js b/packages/endpoint-microsub/lib/utils/uid.js new file mode 100644 index 000000000..1b4eecd47 --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/uid.js @@ -0,0 +1,17 @@ +/** + * UID generation utilities for Microsub + * @module utils/uid + */ + +/** + * Generate a random channel UID + * @returns {string} 24-character random string + */ +export function generateChannelUid() { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let index = 0; index < 24; index++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js new file mode 100644 index 000000000..ca1049b0c --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -0,0 +1,129 @@ +/** + * Input validation utilities for Microsub + * @module utils/validation + */ + +import { IndiekitError } from "@indiekit/error"; + +/** + * Valid Microsub actions (PR 1: channels and timeline only) + */ +export const VALID_ACTIONS = ["channels", "timeline"]; + +/** + * Validate action parameter + * @param {string} action - Action to validate + * @throws {IndiekitError} If action is invalid + */ +export function validateAction(action) { + if (!action) { + throw new IndiekitError("Missing required parameter: action", { + status: 400, + }); + } + + if (!VALID_ACTIONS.includes(action)) { + throw new IndiekitError(`Invalid action: ${action}`, { + status: 400, + }); + } +} + +/** + * Validate channel UID + * @param {string} channel - Channel UID to validate + * @param {boolean} [required] - Whether channel is required + * @throws {IndiekitError} If channel is invalid + */ +export function validateChannel(channel, required = true) { + if (required && !channel) { + throw new IndiekitError("Missing required parameter: channel", { + status: 400, + }); + } + + if (channel && typeof channel !== "string") { + throw new IndiekitError("Invalid channel parameter", { + status: 400, + }); + } +} + +/** + * Validate entry/entries parameter + * @param {string|Array} entry - Entry ID(s) to validate + * @returns {Array} Array of entry IDs + * @throws {IndiekitError} If entry is invalid + */ +export function validateEntries(entry) { + if (!entry) { + throw new IndiekitError("Missing required parameter: entry", { + status: 400, + }); + } + + // Normalize to array + const entries = Array.isArray(entry) ? entry : [entry]; + + if (entries.length === 0) { + throw new IndiekitError("Entry parameter cannot be empty", { + status: 400, + }); + } + + return entries; +} + +/** + * Validate channel name + * @param {string} name - Channel name to validate + * @throws {IndiekitError} If name is invalid + */ +export function validateChannelName(name) { + if (!name || typeof name !== "string") { + throw new IndiekitError("Missing required parameter: name", { + status: 400, + }); + } + + if (name.length > 100) { + throw new IndiekitError("Channel name must be 100 characters or less", { + status: 400, + }); + } +} + +/** + * Parse array parameter from request + * Handles both array[] and array[0], array[1] formats + * @param {object} body - Request body + * @param {string} parameterName - Parameter name + * @returns {Array} Parsed array + */ +export function parseArrayParameter(body, parameterName) { + // Direct array + if (Array.isArray(body[parameterName])) { + return body[parameterName]; + } + + // Single value + if (body[parameterName]) { + return [body[parameterName]]; + } + + // Indexed values (param[0], param[1], ...) + const result = []; + let index = 0; + while (body[`${parameterName}[${index}]`] !== undefined) { + result.push(body[`${parameterName}[${index}]`]); + index++; + } + + // Array notation (param[]) + if (body[`${parameterName}[]`]) { + const values = body[`${parameterName}[]`]; + return Array.isArray(values) ? values : [values]; + } + + return result; +} diff --git a/packages/endpoint-microsub/locales/en.json b/packages/endpoint-microsub/locales/en.json new file mode 100644 index 000000000..9d1c0edbf --- /dev/null +++ b/packages/endpoint-microsub/locales/en.json @@ -0,0 +1,15 @@ +{ + "microsub": { + "title": "Microsub", + "channels": { + "title": "Channels" + }, + "timeline": { + "title": "Timeline" + }, + "error": { + "channelNotFound": "Channel not found", + "invalidAction": "Invalid action" + } + } +} diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json new file mode 100644 index 000000000..e8632f5ce --- /dev/null +++ b/packages/endpoint-microsub/package.json @@ -0,0 +1,51 @@ +{ + "name": "@indiekit/endpoint-microsub", + "version": "1.0.0-alpha.1", + "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "microsub", + "reader", + "social-reader" + ], + "homepage": "https://getindiekit.com", + "author": { + "name": "Paul Robert Lloyd", + "url": "https://paulrobertlloyd.com" + }, + "contributors": [ + { + "name": "Ricardo Mendes", + "url": "https://rmendes.net" + } + ], + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "files": [ + "lib", + "locales", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/endpoint-microsub" + }, + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "express": "^5.0.0", + "mongodb": "^6.0.0" + }, + "publishConfig": { + "access": "public" + } +}