mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 07:34:56 +02:00
feat: initial commit - Microsub endpoint for Indiekit
Fork of @indiekit/endpoint-microsub with customizations. Enables subscribing to feeds and reading content using the Microsub protocol. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
63
index.js
Normal file
63
index.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
110
lib/controllers/channels.js
Normal file
110
lib/controllers/channels.js
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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 };
|
||||
86
lib/controllers/microsub.js
Normal file
86
lib/controllers/microsub.js
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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 };
|
||||
119
lib/controllers/timeline.js
Normal file
119
lib/controllers/timeline.js
Normal file
@@ -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=<uid>
|
||||
* @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<void>}
|
||||
*/
|
||||
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 };
|
||||
253
lib/storage/channels.js
Normal file
253
lib/storage/channels.js
Normal file
@@ -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<object>} 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>} 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<object|null>} 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<object|null>} 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<boolean>} 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<void>}
|
||||
*/
|
||||
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<object>} 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;
|
||||
}
|
||||
260
lib/storage/items.js
Normal file
260
lib/storage/items.js
Normal file
@@ -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<object>} 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>} 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>} 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>} 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<void>}
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
35
lib/utils/auth.js
Normal file
35
lib/utils/auth.js
Normal file
@@ -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";
|
||||
}
|
||||
148
lib/utils/pagination.js
Normal file
148
lib/utils/pagination.js
Normal file
@@ -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);
|
||||
}
|
||||
17
lib/utils/uid.js
Normal file
17
lib/utils/uid.js
Normal file
@@ -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;
|
||||
}
|
||||
129
lib/utils/validation.js
Normal file
129
lib/utils/validation.js
Normal file
@@ -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;
|
||||
}
|
||||
15
locales/en.json
Normal file
15
locales/en.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"microsub": {
|
||||
"title": "Microsub",
|
||||
"channels": {
|
||||
"title": "Channels"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline"
|
||||
},
|
||||
"error": {
|
||||
"channelNotFound": "Channel not found",
|
||||
"invalidAction": "Invalid action"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||
"version": "1.0.11",
|
||||
"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://github.com/rmdes/indiekit-endpoint-microsub",
|
||||
"author": {
|
||||
"name": "Ricardo Mendes",
|
||||
"url": "https://rmendes.net"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Paul Robert Lloyd",
|
||||
"url": "https://paulrobertlloyd.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"locales",
|
||||
"index.js"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/rmdes/indiekit-endpoint-microsub/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rmdes/indiekit-endpoint-microsub.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@indiekit/error": "^1.0.0-beta.25",
|
||||
"express": "^5.0.0",
|
||||
"mongodb": "^6.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user