feat: enhance Mastodon API layer with media upload, status editing, and UI improvements

- Fix status delete bug (undefined objectId → item._id)
- Add media upload endpoints (POST /api/v1/media, /api/v2/media) proxying to Micropub media endpoint
- Wire media_ids into status creation for photo/video/audio attachments
- Add Idempotency-Key header support for status creation
- Add status edit (PUT /api/v1/statuses/:id) with edit history
- Add accounts/search endpoint for @mention autocomplete
- Add dedicated status card endpoint (GET /api/v1/statuses/:id/card)
- Replace favourited_by/reblogged_by stubs with real interaction data
- Add domain blocks CRUD (POST/DELETE /api/v1/domain_blocks)
- Upgrade AP compose UI with EasyMDE editor, file-input, and tag-input components
- Add hashtag follow/unfollow endpoints
- Implement filters v2 CRUD (/api/v2/filters)
- Add update_credentials endpoint (PATCH, text fields)
- Add MongoDB collections: ap_media, ap_status_edits, ap_idempotency, ap_filters, ap_filter_keywords
- Bump version to 3.11.0
This commit is contained in:
Ricardo
2026-03-29 16:02:50 +02:00
parent af2d4b8a0f
commit 7c9318fa08
13 changed files with 1281 additions and 52 deletions
+18
View File
@@ -959,6 +959,15 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_markers");
// Tombstones for soft-deleted posts (FEP-4f05)
Indiekit.addCollection("ap_tombstones");
// Media attachments (Mastodon API upload)
Indiekit.addCollection("ap_media");
// Status edit history
Indiekit.addCollection("ap_status_edits");
// Idempotency keys for Mastodon API
Indiekit.addCollection("ap_idempotency");
// Filters and filter keywords
Indiekit.addCollection("ap_filters");
Indiekit.addCollection("ap_filter_keywords");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -997,6 +1006,15 @@ export default class ActivityPubEndpoint {
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
ap_markers: indiekitCollections.get("ap_markers"),
ap_tombstones: indiekitCollections.get("ap_tombstones"),
// Media attachments (Mastodon API upload)
ap_media: indiekitCollections.get("ap_media"),
// Status edit history
ap_status_edits: indiekitCollections.get("ap_status_edits"),
// Idempotency keys for Mastodon API
ap_idempotency: indiekitCollections.get("ap_idempotency"),
// Filters and filter keywords
ap_filters: indiekitCollections.get("ap_filters"),
ap_filter_keywords: indiekitCollections.get("ap_filter_keywords"),
get posts() {
return indiekitCollections.get("posts");
},
+17 -1
View File
@@ -144,6 +144,7 @@ export function composeController(mountPath, plugin) {
syndicationTargets,
csrfToken,
mountPath,
mediaEndpoint: application.mediaEndpoint || "",
});
} catch (error) {
next(error);
@@ -167,7 +168,7 @@ export function submitComposeController(mountPath, plugin) {
}
const { application } = request.app.locals;
const { content, visibility, summary } = request.body;
const { content, visibility, summary, photo, category } = request.body;
const cwEnabled = request.body["cw-enabled"];
const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-to"];
@@ -228,6 +229,21 @@ export function submitComposeController(mountPath, plugin) {
}
}
// Photo (from file-input component — already a URL from media endpoint)
if (photo && photo.trim()) {
micropubData.append("photo", photo.trim());
}
// Tags / categories
if (category) {
const tags = Array.isArray(category)
? category
: category.split(",").map((t) => t.trim()).filter(Boolean);
for (const tag of tags) {
micropubData.append("category[]", tag);
}
}
console.info(
`[ActivityPub] Compose Micropub submission:`,
JSON.stringify({
+32
View File
@@ -250,6 +250,38 @@ export function createIndexes(collections, options) {
{ url: 1 },
{ unique: true, background: true },
);
// Media attachments (Mastodon API upload)
collections.ap_media?.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400, background: true },
);
// Status edit history
collections.ap_status_edits?.createIndex(
{ statusId: 1, editedAt: 1 },
{ background: true },
);
// Idempotency keys (auto-expire after 1 hour)
collections.ap_idempotency?.createIndex(
{ key: 1 },
{ unique: true, background: true },
);
collections.ap_idempotency?.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 3600, background: true },
);
// Filters
collections.ap_filters?.createIndex(
{ createdAt: 1 },
{ background: true },
);
collections.ap_filter_keywords?.createIndex(
{ filterId: 1 },
{ background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
+1 -1
View File
@@ -246,7 +246,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
/**
* Serialize a linkPreview object as a Mastodon PreviewCard.
*/
function serializeCard(preview) {
export function serializeCard(preview) {
if (!preview) return null;
return {
+2
View File
@@ -20,6 +20,7 @@ import timelinesRouter from "./routes/timelines.js";
import notificationsRouter from "./routes/notifications.js";
import searchRouter from "./routes/search.js";
import mediaRouter from "./routes/media.js";
import filtersRouter from "./routes/filters.js";
import stubsRouter from "./routes/stubs.js";
// Rate limiters for different endpoint categories.
@@ -118,6 +119,7 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
router.use(notificationsRouter);
router.use(searchRouter);
router.use(mediaRouter);
router.use(filtersRouter);
router.use(stubsRouter);
// ─── Catch-all for unimplemented endpoints ──────────────────────────────
+95
View File
@@ -153,6 +153,61 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
}
});
// ─── GET /api/v1/accounts/search ────────────────────────────────────────────
// Used by clients for @mention autocomplete in compose box.
router.get("/api/v1/accounts/search", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const query = req.query.q?.trim();
const limit = Math.min(Number.parseInt(req.query.limit, 10) || 10, 40);
if (!query) {
return res.json([]);
}
// Escape regex special characters
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escaped, "i");
const results = new Map(); // dedupe by URL
// Search followers
if (collections.ap_followers) {
const followers = await collections.ap_followers
.find({
$or: [{ name: regex }, { handle: regex }, { actorUrl: regex }],
})
.limit(limit)
.toArray();
for (const f of followers) results.set(f.actorUrl, f);
}
// Search following
if (results.size < limit && collections.ap_following) {
const following = await collections.ap_following
.find({
$or: [{ name: regex }, { handle: regex }, { actorUrl: regex }],
})
.limit(limit - results.size)
.toArray();
for (const f of following) results.set(f.actorUrl, f);
}
const { serializeAccount } = await import("../entities/account.js");
const accounts = [...results.values()]
.slice(0, limit)
.map((actor) =>
serializeAccount(actor, { baseUrl, isLocal: false }),
);
res.json(accounts);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id
@@ -228,6 +283,46 @@ router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired("
res.json(ids.map((id) => ({ id, accounts: [] })));
});
// ─── PATCH /api/v1/accounts/update_credentials ──────────────────────────────
router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired("write", "write:accounts"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
const update = {};
if (req.body.display_name !== undefined) update.name = req.body.display_name;
if (req.body.note !== undefined) update.summary = req.body.note;
if (req.body.fields_attributes) {
update.attachments = Object.values(req.body.fields_attributes).map(
(f) => ({
name: f.name,
value: f.value,
}),
);
}
if (Object.keys(update).length > 0 && collections.ap_profile) {
await collections.ap_profile.updateOne({}, { $set: update });
}
// Return updated credential account
const profile = collections.ap_profile
? await collections.ap_profile.findOne({})
: {};
const { serializeCredentialAccount } = await import(
"../entities/account.js"
);
res.json(
await serializeCredentialAccount(profile, { baseUrl, collections }),
);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
+216
View File
@@ -0,0 +1,216 @@
/**
* Filter endpoints for Mastodon Client API v2.
*/
import express from "express";
import { ObjectId } from "mongodb";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
/**
* Serialize a filter document with its keywords.
*/
function serializeFilter(filter, keywords = []) {
return {
id: filter._id.toString(),
title: filter.title || "",
context: filter.context || [],
filter_action: filter.filterAction || "warn",
expires_at: filter.expiresAt || null,
keywords: keywords.map((kw) => ({
id: kw._id.toString(),
keyword: kw.keyword,
whole_word: kw.wholeWord ?? true,
})),
statuses: [],
};
}
// ─── GET /api/v2/filters ────────────────────────────────────────────────────
router.get("/api/v2/filters", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
if (!collections.ap_filters) return res.json([]);
const filters = await collections.ap_filters.find({}).toArray();
const result = [];
for (const filter of filters) {
const keywords = collections.ap_filter_keywords
? await collections.ap_filter_keywords
.find({ filterId: filter._id })
.toArray()
: [];
result.push(serializeFilter(filter, keywords));
}
res.json(result);
} catch (error) {
next(error);
}
});
// ─── POST /api/v2/filters ───────────────────────────────────────────────────
router.post("/api/v2/filters", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
if (!collections.ap_filters) {
return res.status(500).json({ error: "Filters not available" });
}
const {
title,
context,
filter_action: filterAction = "warn",
expires_in: expiresIn,
keywords_attributes: keywordsAttributes,
} = req.body;
if (!title) {
return res.status(422).json({ error: "title is required" });
}
const expiresAt = expiresIn
? new Date(Date.now() + Number.parseInt(expiresIn, 10) * 1000).toISOString()
: null;
const filterDoc = {
title,
context: Array.isArray(context) ? context : [context].filter(Boolean),
filterAction,
expiresAt,
createdAt: new Date().toISOString(),
};
const result = await collections.ap_filters.insertOne(filterDoc);
filterDoc._id = result.insertedId;
// Insert keywords if provided
const keywords = [];
if (keywordsAttributes && collections.ap_filter_keywords) {
const attrs = Array.isArray(keywordsAttributes)
? keywordsAttributes
: Object.values(keywordsAttributes);
for (const attr of attrs) {
if (attr.keyword) {
const kwDoc = {
filterId: filterDoc._id,
keyword: attr.keyword,
wholeWord: attr.whole_word !== "false" && attr.whole_word !== false,
};
const kwResult = await collections.ap_filter_keywords.insertOne(kwDoc);
kwDoc._id = kwResult.insertedId;
keywords.push(kwDoc);
}
}
}
res.json(serializeFilter(filterDoc, keywords));
} catch (error) {
next(error);
}
});
// ─── GET /api/v2/filters/:id ────────────────────────────────────────────────
router.get("/api/v2/filters/:id", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
let filter;
try {
filter = await collections.ap_filters?.findOne({
_id: new ObjectId(req.params.id),
});
} catch { /* invalid ObjectId */ }
if (!filter) {
return res.status(404).json({ error: "Record not found" });
}
const keywords = collections.ap_filter_keywords
? await collections.ap_filter_keywords
.find({ filterId: filter._id })
.toArray()
: [];
res.json(serializeFilter(filter, keywords));
} catch (error) {
next(error);
}
});
// ─── PUT /api/v2/filters/:id ────────────────────────────────────────────────
router.put("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
let filter;
try {
filter = await collections.ap_filters?.findOne({
_id: new ObjectId(req.params.id),
});
} catch { /* invalid ObjectId */ }
if (!filter) {
return res.status(404).json({ error: "Record not found" });
}
const update = {};
if (req.body.title !== undefined) update.title = req.body.title;
if (req.body.context !== undefined) {
update.context = Array.isArray(req.body.context)
? req.body.context
: [req.body.context].filter(Boolean);
}
if (req.body.filter_action !== undefined) update.filterAction = req.body.filter_action;
if (req.body.expires_in !== undefined) {
update.expiresAt = req.body.expires_in
? new Date(Date.now() + Number.parseInt(req.body.expires_in, 10) * 1000).toISOString()
: null;
}
if (Object.keys(update).length > 0) {
await collections.ap_filters.updateOne(
{ _id: filter._id },
{ $set: update },
);
Object.assign(filter, update);
}
const keywords = collections.ap_filter_keywords
? await collections.ap_filter_keywords
.find({ filterId: filter._id })
.toArray()
: [];
res.json(serializeFilter(filter, keywords));
} catch (error) {
next(error);
}
});
// ─── DELETE /api/v2/filters/:id ─────────────────────────────────────────────
router.delete("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
let filterId;
try {
filterId = new ObjectId(req.params.id);
} catch {
return res.status(404).json({ error: "Record not found" });
}
await collections.ap_filters?.deleteOne({ _id: filterId });
await collections.ap_filter_keywords?.deleteMany({ filterId });
res.json({});
} catch (error) {
next(error);
}
});
export default router;
+233 -19
View File
@@ -1,45 +1,259 @@
/**
* Media endpoints for Mastodon Client API.
*
* POST /api/v2/media upload media attachment (stub returns 422 until storage is configured)
* POST /api/v1/media legacy upload endpoint (redirects to v2)
* GET /api/v1/media/:id get media attachment status
* POST /api/v2/media upload media attachment via Micropub media endpoint
* POST /api/v1/media legacy upload (same as v2)
* GET /api/v1/media/:id get media attachment metadata
* PUT /api/v1/media/:id update media metadata (description/focus)
*/
import express from "express";
import multer from "multer";
import { ObjectId } from "mongodb";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 40 * 1024 * 1024 },
});
/**
* Determine Mastodon media type from MIME type.
*/
function mediaType(mimeType) {
if (mimeType?.startsWith("image/")) return "image";
if (mimeType?.startsWith("video/")) return "video";
if (mimeType?.startsWith("audio/")) return "audio";
return "unknown";
}
/**
* Serialize an ap_media document to a Mastodon MediaAttachment object.
*/
function serializeMediaAttachment(doc) {
return {
id: doc._id.toString(),
type: mediaType(doc.mimeType),
url: doc.url,
preview_url: doc.url,
remote_url: null,
text_url: null,
meta: doc.focus
? {
focus: {
x: Number.parseFloat(doc.focus.split(",")[0]) || 0,
y: Number.parseFloat(doc.focus.split(",")[1]) || 0,
},
}
: null,
description: doc.description || "",
blurhash: null,
};
}
/**
* Upload file to the Micropub media endpoint.
* Returns the URL from the Location header.
*/
async function uploadToMediaEndpoint(file, application, token) {
const mediaEndpoint = application.mediaEndpoint;
if (!mediaEndpoint) {
throw new Error("Media endpoint not configured");
}
const mediaUrl = mediaEndpoint.startsWith("http")
? mediaEndpoint
: new URL(mediaEndpoint, application.url).href;
const formData = new FormData();
const blob = new Blob([file.buffer], { type: file.mimetype });
formData.append("file", blob, file.originalname);
const response = await fetch(mediaUrl, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Media endpoint returned ${response.status}: ${body}`);
}
const location = response.headers.get("Location");
if (!location) {
throw new Error("Media endpoint did not return a Location header");
}
return location;
}
// ─── POST /api/v2/media ─────────────────────────────────────────────────────
router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
// Media upload requires multer/multipart handling + storage backend.
// For now, return 422 so clients show a user-friendly error.
res.status(422).json({
error: "Media uploads are not yet supported on this server",
});
});
router.post(
"/api/v2/media",
tokenRequired,
scopeRequired("write", "write:media"),
upload.single("file"),
async (req, res, next) => {
try {
const { application } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const token =
req.session?.access_token || req.mastodonToken?.accessToken;
if (!req.file) {
return res.status(422).json({ error: "No file provided" });
}
if (!token) {
return res
.status(401)
.json({ error: "Authentication required for media upload" });
}
const fileUrl = await uploadToMediaEndpoint(
req.file,
application,
token,
);
const doc = {
url: fileUrl,
description: req.body.description || "",
focus: req.body.focus || null,
mimeType: req.file.mimetype,
createdAt: new Date(),
};
const result = await collections.ap_media.insertOne(doc);
doc._id = result.insertedId;
res.json(serializeMediaAttachment(doc));
} catch (error) {
next(error);
}
},
);
// ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(422).json({
error: "Media uploads are not yet supported on this server",
});
});
router.post(
"/api/v1/media",
tokenRequired,
scopeRequired("write", "write:media"),
upload.single("file"),
async (req, res, next) => {
try {
const { application } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const token =
req.session?.access_token || req.mastodonToken?.accessToken;
if (!req.file) {
return res.status(422).json({ error: "No file provided" });
}
if (!token) {
return res
.status(401)
.json({ error: "Authentication required for media upload" });
}
const fileUrl = await uploadToMediaEndpoint(
req.file,
application,
token,
);
const doc = {
url: fileUrl,
description: req.body.description || "",
focus: req.body.focus || null,
mimeType: req.file.mimetype,
createdAt: new Date(),
};
const result = await collections.ap_media.insertOne(doc);
doc._id = result.insertedId;
res.json(serializeMediaAttachment(doc));
} catch (error) {
next(error);
}
},
);
// ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => {
res.status(404).json({ error: "Record not found" });
router.get(
"/api/v1/media/:id",
tokenRequired,
scopeRequired("read", "read:statuses"),
async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
let doc;
try {
doc = await collections.ap_media.findOne({
_id: new ObjectId(req.params.id),
});
} catch {
/* invalid ObjectId */
}
if (!doc) {
return res.status(404).json({ error: "Record not found" });
}
res.json(serializeMediaAttachment(doc));
} catch (error) {
next(error);
}
},
);
// ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(404).json({ error: "Record not found" });
router.put(
"/api/v1/media/:id",
tokenRequired,
scopeRequired("write", "write:media"),
async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
let doc;
try {
doc = await collections.ap_media.findOne({
_id: new ObjectId(req.params.id),
});
} catch {
/* invalid ObjectId */
}
if (!doc) {
return res.status(404).json({ error: "Record not found" });
}
const update = {};
if (req.body.description !== undefined)
update.description = req.body.description;
if (req.body.focus !== undefined) update.focus = req.body.focus;
if (Object.keys(update).length > 0) {
await collections.ap_media.updateOne(
{ _id: doc._id },
{ $set: update },
);
Object.assign(doc, update);
}
res.json(serializeMediaAttachment(doc));
} catch (error) {
next(error);
}
},
);
export default router;
+351 -9
View File
@@ -4,7 +4,9 @@
* GET /api/v1/statuses/:id single status
* GET /api/v1/statuses/:id/context thread context (ancestors + descendants)
* POST /api/v1/statuses create post via Micropub pipeline
* PUT /api/v1/statuses/:id edit an existing post
* DELETE /api/v1/statuses/:id delete post via Micropub pipeline
* GET /api/v1/statuses/:id/history edit history
* POST /api/v1/statuses/:id/favourite like a post
* POST /api/v1/statuses/:id/unfavourite unlike a post
* POST /api/v1/statuses/:id/reblog boost a post
@@ -142,6 +144,19 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
// Idempotency-Key support — prevent duplicate posts on client retry
const idempotencyKey = req.headers["idempotency-key"];
if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto");
const key = createHash("sha256")
.update(`${baseUrl}:${idempotencyKey}`)
.digest("hex");
const cached = await collections.ap_idempotency.findOne({ key });
if (cached) {
return res.json(cached.response);
}
}
const {
status: statusText,
spoiler_text: spoilerText,
@@ -165,6 +180,28 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
}
}
// Resolve media_ids to URLs from ap_media collection
const mediaUrls = [];
if (mediaIds && mediaIds.length > 0 && collections.ap_media) {
const { ObjectId: MediaObjectId } = await import("mongodb");
for (const mediaId of Array.isArray(mediaIds) ? mediaIds : [mediaIds]) {
try {
const media = await collections.ap_media.findOne({
_id: new MediaObjectId(mediaId),
});
if (media) {
mediaUrls.push({
url: media.url,
alt: media.description || "",
type: media.mimeType,
});
}
} catch {
/* invalid ObjectId, skip */
}
}
}
// Build JF2 properties for the Micropub pipeline.
// Provide both text and html — linkify URLs since Micropub's markdown-it
// doesn't have linkify enabled. Mentions are preserved as plain text;
@@ -204,6 +241,20 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
// Add media from media_ids
for (const m of mediaUrls) {
if (m.type?.startsWith("image/")) {
if (!jf2.photo) jf2.photo = [];
jf2.photo.push({ value: m.url, alt: m.alt });
} else if (m.type?.startsWith("video/")) {
if (!jf2.video) jf2.video = [];
jf2.video.push(m.url);
} else if (m.type?.startsWith("audio/")) {
if (!jf2.audio) jf2.audio = [];
jf2.audio.push(m.url);
}
}
// Create post via Micropub pipeline (same internal functions)
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
@@ -220,7 +271,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
res.json({
const statusResponse = {
id: String(Date.now()),
created_at: new Date().toISOString(),
content: `<p>${contentHtml}</p>`,
@@ -263,7 +314,20 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
})),
tags: [],
emojis: [],
});
};
// Cache response for idempotency
if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto");
const key = createHash("sha256")
.update(`${baseUrl}:${idempotencyKey}`)
.digest("hex");
await collections.ap_idempotency
.insertOne({ key, response: statusResponse, createdAt: new Date() })
.catch(() => {});
}
res.json(statusResponse);
} catch (error) {
next(error);
}
@@ -319,7 +383,7 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri
}
// Delete from timeline
await collections.ap_timeline.deleteOne({ _id: objectId });
await collections.ap_timeline.deleteOne({ _id: item._id });
// Clean up interactions
if (collections.ap_interactions && item.uid) {
@@ -332,18 +396,271 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri
}
});
// ─── PUT /api/v1/statuses/:id ───────────────────────────────────────────────
// Edit an existing status. Stores the previous version for history.
router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
const localPublicationUrl = publication?.me || pluginOptions.publicationUrl || application?.url;
const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Verify ownership — only the local author can edit
if (!item.author?.url || item.author.url !== localPublicationUrl) {
return res.status(403).json({ error: "This action is not allowed" });
}
const {
status: statusText,
spoiler_text: spoilerText,
sensitive,
language,
} = req.body;
// Store current version in edit history
if (collections.ap_status_edits) {
await collections.ap_status_edits.insertOne({
statusId: req.params.id,
content: item.content || {},
summary: item.summary || "",
sensitive: item.sensitive || false,
media: [
...(item.photo || []),
...(item.video || []),
...(item.audio || []),
],
editedAt: new Date().toISOString(),
});
}
// Send update via Micropub
const postUrl = item.uid || item.url;
if (postUrl && application.micropubEndpoint) {
const micropubUrl = application.micropubEndpoint.startsWith("http")
? application.micropubEndpoint
: new URL(application.micropubEndpoint, application.url).href;
const token =
req.session?.access_token || req.mastodonToken?.accessToken;
if (token) {
const updatePayload = {
action: "update",
url: postUrl,
replace: {},
};
if (statusText !== undefined) {
updatePayload.replace.content = [statusText];
}
try {
await fetch(micropubUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(updatePayload),
});
} catch (err) {
console.warn(
`[Mastodon API] Micropub update failed: ${err.message}`,
);
}
}
}
// Update timeline item directly
const updateFields = {};
if (statusText !== undefined) {
const contentHtml = statusText
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(
/(https?:\/\/[^\s<>&"')\]]+)/g,
'<a href="$1">$1</a>',
)
.replace(/\n/g, "<br>");
updateFields["content.text"] = statusText;
updateFields["content.html"] = contentHtml;
}
if (spoilerText !== undefined) updateFields.summary = spoilerText;
if (sensitive !== undefined)
updateFields.sensitive = sensitive === "true" || sensitive === true;
if (language !== undefined) updateFields.language = language;
updateFields.updatedAt = new Date().toISOString();
await collections.ap_timeline.updateOne(
{ _id: item._id },
{ $set: updateFields },
);
// Reload and serialize
const updated = await collections.ap_timeline.findOne({
_id: item._id,
});
const { serializeStatus, setLocalIdentity } = await import(
"../entities/status.js"
);
const handle = pluginOptions.actor?.handle || "";
setLocalIdentity(localPublicationUrl, handle);
const serialized = serializeStatus(updated, { baseUrl });
res.json(serialized);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/history ───────────────────────────────────────
router.get("/api/v1/statuses/:id/history", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const edits = collections.ap_status_edits
? await collections.ap_status_edits
.find({ statusId: req.params.id })
.sort({ editedAt: 1 })
.toArray()
: [];
const { serializeAccount } = await import("../entities/account.js");
const localPublicationUrl = pluginOptions.publicationUrl || baseUrl;
const handle = pluginOptions.actor?.handle || "";
const accountObj = item.author
? serializeAccount(item.author, {
baseUrl,
isLocal: item.author.url === localPublicationUrl,
handle,
})
: null;
// Build history: each edit snapshot + current version as latest
const history = edits.map((edit) => ({
content: edit.content?.html || edit.content?.text || "",
spoiler_text: edit.summary || "",
sensitive: edit.sensitive || false,
created_at: edit.editedAt,
account: accountObj,
media_attachments: [],
emojis: [],
}));
// Add current version as the latest entry
history.push({
content: item.content?.html || item.content?.text || "",
spoiler_text: item.summary || "",
sensitive: item.sensitive || false,
created_at: item.updatedAt || item.published || item.createdAt,
account: accountObj,
media_attachments: [],
emojis: [],
});
res.json(history);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who favourited remotely
res.json([]);
router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(
collections.ap_timeline,
req.params.id,
);
if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]);
const interactions = await collections.ap_interactions
.find({ objectUrl: uid, type: "like" })
.limit(40)
.toArray();
const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions
.filter((i) => i.actorUrl || i.actorName)
.map((i) =>
serializeAccount(
{
url: i.actorUrl,
name: i.actorName || "",
handle: i.actorHandle || "",
},
{ baseUrl, isLocal: false },
),
);
res.json(accounts);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who boosted remotely
res.json([]);
router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(
collections.ap_timeline,
req.params.id,
);
if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]);
const interactions = await collections.ap_interactions
.find({ objectUrl: uid, type: "boost" })
.limit(40)
.toArray();
const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions
.filter((i) => i.actorUrl || i.actorName)
.map((i) =>
serializeAccount(
{
url: i.actorUrl,
name: i.actorName || "",
handle: i.actorHandle || "",
},
{ baseUrl, isLocal: false },
),
);
res.json(accounts);
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
@@ -493,6 +810,31 @@ router.post("/api/v1/statuses/:id/unbookmark", tokenRequired, scopeRequired("wri
}
});
// ─── GET /api/v1/statuses/:id/card ──────────────────────────────────────────
router.get("/api/v1/statuses/:id/card", async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const item = await findTimelineItemById(
collections.ap_timeline,
req.params.id,
);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
const { serializeCard } = await import("../entities/status.js");
const card = serializeCard(item.linkPreviews?.[0]);
if (!card) {
return res.json({});
}
res.json(card);
} catch (error) {
next(error);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
+104 -10
View File
@@ -22,6 +22,8 @@ import express from "express";
import { serializeStatus } from "../entities/status.js";
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
import { getFollowedTagsWithState } from "../../storage/followed-tags.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
@@ -110,16 +112,6 @@ router.get("/api/v1/custom_emojis", (req, res) => {
res.json([]);
});
// ─── Filters (v2) ───────────────────────────────────────────────────────────
router.get("/api/v2/filters", (req, res) => {
res.json([]);
});
router.get("/api/v1/filters", (req, res) => {
res.json([]);
});
// ─── Lists ──────────────────────────────────────────────────────────────────
router.get("/api/v1/lists", (req, res) => {
@@ -302,6 +294,66 @@ router.get("/api/v1/followed_tags", async (req, res, next) => {
}
});
// ─── GET /api/v1/tags/:id ───────────────────────────────────────────────────
router.get("/api/v1/tags/:id", async (req, res) => {
const collections = req.app.locals.mastodonCollections;
const tag = req.params.id.toLowerCase().replace(/^#/, "");
let following = false;
if (collections.ap_followed_tags) {
const doc = await collections.ap_followed_tags.findOne({ tag });
following = !!doc;
}
res.json({
name: tag,
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
history: [],
following,
});
});
// ─── POST /api/v1/tags/:id/follow ──────────────────────────────────────────
router.post("/api/v1/tags/:id/follow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => {
const collections = req.app.locals.mastodonCollections;
const tag = req.params.id.toLowerCase().replace(/^#/, "");
if (collections.ap_followed_tags) {
await collections.ap_followed_tags.updateOne(
{ tag },
{ $setOnInsert: { tag, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
res.json({
name: tag,
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
history: [],
following: true,
});
});
// ─── POST /api/v1/tags/:id/unfollow ────────────────────────────────────────
router.post("/api/v1/tags/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => {
const collections = req.app.locals.mastodonCollections;
const tag = req.params.id.toLowerCase().replace(/^#/, "");
if (collections.ap_followed_tags) {
await collections.ap_followed_tags.deleteOne({ tag });
}
res.json({
name: tag,
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
history: [],
following: false,
});
});
// ─── Suggestions ────────────────────────────────────────────────────────────
router.get("/api/v2/suggestions", (req, res) => {
@@ -347,6 +399,48 @@ router.get("/api/v1/domain_blocks", async (req, res) => {
}
});
// ─── POST /api/v1/domain_blocks ─────────────────────────────────────────────
router.post("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const domain = req.body.domain?.trim();
if (!domain) {
return res.status(422).json({ error: "domain is required" });
}
if (collections.ap_blocked_servers) {
await collections.ap_blocked_servers.updateOne(
{ hostname: domain },
{ $setOnInsert: { hostname: domain, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
res.json({});
} catch (error) {
next(error);
}
});
// ─── DELETE /api/v1/domain_blocks ───────────────────────────────────────────
router.delete("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const domain = req.body.domain?.trim();
if (domain && collections.ap_blocked_servers) {
await collections.ap_blocked_servers.deleteOne({ hostname: domain });
}
res.json({});
} catch (error) {
next(error);
}
});
// ─── Endorsements ───────────────────────────────────────────────────────────
router.get("/api/v1/endorsements", (req, res) => {
+173 -2
View File
@@ -1,21 +1,23 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.9.4",
"version": "3.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.9.4",
"version": "3.11.0",
"license": "MIT",
"dependencies": {
"@fedify/debugger": "^2.1.0",
"@fedify/fedify": "^2.1.0",
"@fedify/redis": "^2.1.0",
"@js-temporal/polyfill": "^0.5.0",
"@rmdes/indiekit-startup-gate": "^1.0.0",
"express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"multer": "^2.1.1",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
},
@@ -1396,6 +1398,12 @@
"node": ">=22"
}
},
"node_modules/@rmdes/indiekit-startup-gate": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rmdes/indiekit-startup-gate/-/indiekit-startup-gate-1.0.0.tgz",
"integrity": "sha512-LrfSjTN9Ay4RiJH5xSvsvOEs7Zqw/GCC9+FhF7S6Ij8eDXpJOKQeHshAhzsqSmP/wksAyq0TIhqXZAPJXM+Tcg==",
"license": "MIT"
},
"node_modules/@sindresorhus/slugify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
@@ -1510,6 +1518,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1572,6 +1586,23 @@
"node": ">=20.19.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/byte-encodings": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/byte-encodings/-/byte-encodings-1.0.11.tgz",
@@ -1720,6 +1751,21 @@
"node": ">= 6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -2968,6 +3014,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -3294,6 +3402,20 @@
"node": ">=18"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@ -3331,6 +3453,26 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -3572,6 +3714,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/structured-field-values": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/structured-field-values/-/structured-field-values-2.0.4.tgz",
@@ -3623,6 +3782,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typo-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz",
@@ -3713,6 +3878,12 @@
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.10.6",
"version": "3.11.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -38,13 +38,14 @@
},
"dependencies": {
"@fedify/debugger": "^2.1.0",
"@rmdes/indiekit-startup-gate": "^1.0.0",
"@fedify/fedify": "^2.1.0",
"@fedify/redis": "^2.1.0",
"@js-temporal/polyfill": "^0.5.0",
"@rmdes/indiekit-startup-gate": "^1.0.0",
"express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"multer": "^2.1.1",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
},
+34 -6
View File
@@ -1,6 +1,9 @@
{% extends "layouts/ap-reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "textarea/macro.njk" import textarea with context %}
{% from "file-input/macro.njk" import fileInput with context %}
{% from "tag-input/macro.njk" import tagInput with context %}
{% block readercontent %}
{# Reply context — show the post being replied to #}
@@ -21,7 +24,7 @@
</div>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form">
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{% if replyTo %}
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
@@ -39,12 +42,37 @@
style="display: none">
</div>
{# Content textarea #}
{# Rich content editor (EasyMDE with media browser) #}
<div class="ap-compose__editor">
<textarea name="content" class="ap-compose__textarea"
rows="6"
placeholder="{{ __('activitypub.compose.placeholder') }}"
required></textarea>
{{ textarea({
id: "content",
name: "content",
label: { text: __("activitypub.compose.contentLabel") if __("activitypub.compose.contentLabel") else "Content" },
attributes: {
rows: "8",
required: "true"
},
endpoint: mediaEndpoint
}) }}
</div>
{# Featured image #}
<div class="ap-compose__media">
{{ fileInput({
id: "photo",
name: "photo",
label: { text: __("activitypub.compose.photoLabel") if __("activitypub.compose.photoLabel") else "Photo" },
endpoint: mediaEndpoint
}) }}
</div>
{# Tags / categories #}
<div class="ap-compose__tags">
{{ tagInput({
id: "category",
name: "category",
label: { text: __("activitypub.compose.tagsLabel") if __("activitypub.compose.tagsLabel") else "Tags" }
}) }}
</div>
{# Visibility #}