Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/endpoint-microsub/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 110 additions & 0 deletions packages/endpoint-microsub/lib/controllers/channels.js
Original file line number Diff line number Diff line change
@@ -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 changes: 86 additions & 0 deletions packages/endpoint-microsub/lib/controllers/microsub.js
Original file line number Diff line number Diff line change
@@ -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 changes: 119 additions & 0 deletions packages/endpoint-microsub/lib/controllers/timeline.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading