mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +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