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:
@@ -959,6 +959,15 @@ export default class ActivityPubEndpoint {
|
|||||||
Indiekit.addCollection("ap_markers");
|
Indiekit.addCollection("ap_markers");
|
||||||
// Tombstones for soft-deleted posts (FEP-4f05)
|
// Tombstones for soft-deleted posts (FEP-4f05)
|
||||||
Indiekit.addCollection("ap_tombstones");
|
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)
|
// Store collection references (posts resolved lazily)
|
||||||
const indiekitCollections = Indiekit.collections;
|
const indiekitCollections = Indiekit.collections;
|
||||||
@@ -997,6 +1006,15 @@ export default class ActivityPubEndpoint {
|
|||||||
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
|
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
|
||||||
ap_markers: indiekitCollections.get("ap_markers"),
|
ap_markers: indiekitCollections.get("ap_markers"),
|
||||||
ap_tombstones: indiekitCollections.get("ap_tombstones"),
|
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() {
|
get posts() {
|
||||||
return indiekitCollections.get("posts");
|
return indiekitCollections.get("posts");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export function composeController(mountPath, plugin) {
|
|||||||
syndicationTargets,
|
syndicationTargets,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
mountPath,
|
mountPath,
|
||||||
|
mediaEndpoint: application.mediaEndpoint || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -167,7 +168,7 @@ export function submitComposeController(mountPath, plugin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
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 cwEnabled = request.body["cw-enabled"];
|
||||||
const inReplyTo = request.body["in-reply-to"];
|
const inReplyTo = request.body["in-reply-to"];
|
||||||
const syndicateTo = request.body["mp-syndicate-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(
|
console.info(
|
||||||
`[ActivityPub] Compose Micropub submission:`,
|
`[ActivityPub] Compose Micropub submission:`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -250,6 +250,38 @@ export function createIndexes(collections, options) {
|
|||||||
{ url: 1 },
|
{ url: 1 },
|
||||||
{ unique: true, background: true },
|
{ 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 {
|
} catch {
|
||||||
// Index creation failed — collections not yet available.
|
// Index creation failed — collections not yet available.
|
||||||
// Indexes already exist from previous startups; non-fatal.
|
// Indexes already exist from previous startups; non-fatal.
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
|
|||||||
/**
|
/**
|
||||||
* Serialize a linkPreview object as a Mastodon PreviewCard.
|
* Serialize a linkPreview object as a Mastodon PreviewCard.
|
||||||
*/
|
*/
|
||||||
function serializeCard(preview) {
|
export function serializeCard(preview) {
|
||||||
if (!preview) return null;
|
if (!preview) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import timelinesRouter from "./routes/timelines.js";
|
|||||||
import notificationsRouter from "./routes/notifications.js";
|
import notificationsRouter from "./routes/notifications.js";
|
||||||
import searchRouter from "./routes/search.js";
|
import searchRouter from "./routes/search.js";
|
||||||
import mediaRouter from "./routes/media.js";
|
import mediaRouter from "./routes/media.js";
|
||||||
|
import filtersRouter from "./routes/filters.js";
|
||||||
import stubsRouter from "./routes/stubs.js";
|
import stubsRouter from "./routes/stubs.js";
|
||||||
|
|
||||||
// Rate limiters for different endpoint categories.
|
// Rate limiters for different endpoint categories.
|
||||||
@@ -118,6 +119,7 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
|
|||||||
router.use(notificationsRouter);
|
router.use(notificationsRouter);
|
||||||
router.use(searchRouter);
|
router.use(searchRouter);
|
||||||
router.use(mediaRouter);
|
router.use(mediaRouter);
|
||||||
|
router.use(filtersRouter);
|
||||||
router.use(stubsRouter);
|
router.use(stubsRouter);
|
||||||
|
|
||||||
// ─── Catch-all for unimplemented endpoints ──────────────────────────────
|
// ─── Catch-all for unimplemented endpoints ──────────────────────────────
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────
|
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
|
||||||
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id
|
// 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: [] })));
|
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 ────────────────────────────────────────────────
|
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
|
router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
+235
-21
@@ -1,45 +1,259 @@
|
|||||||
/**
|
/**
|
||||||
* Media endpoints for Mastodon Client API.
|
* Media endpoints for Mastodon Client API.
|
||||||
*
|
*
|
||||||
* POST /api/v2/media — upload media attachment (stub — returns 422 until storage is configured)
|
* POST /api/v2/media — upload media attachment via Micropub media endpoint
|
||||||
* POST /api/v1/media — legacy upload endpoint (redirects to v2)
|
* POST /api/v1/media — legacy upload (same as v2)
|
||||||
* GET /api/v1/media/:id — get media attachment status
|
* GET /api/v1/media/:id — get media attachment metadata
|
||||||
* PUT /api/v1/media/:id — update media metadata (description/focus)
|
* PUT /api/v1/media/:id — update media metadata (description/focus)
|
||||||
*/
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
import { tokenRequired } from "../middleware/token-required.js";
|
import { tokenRequired } from "../middleware/token-required.js";
|
||||||
import { scopeRequired } from "../middleware/scope-required.js";
|
import { scopeRequired } from "../middleware/scope-required.js";
|
||||||
|
|
||||||
const router = express.Router(); // eslint-disable-line new-cap
|
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 ─────────────────────────────────────────────────────
|
// ─── POST /api/v2/media ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
|
router.post(
|
||||||
// Media upload requires multer/multipart handling + storage backend.
|
"/api/v2/media",
|
||||||
// For now, return 422 so clients show a user-friendly error.
|
tokenRequired,
|
||||||
res.status(422).json({
|
scopeRequired("write", "write:media"),
|
||||||
error: "Media uploads are not yet supported on this server",
|
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) ────────────────────────────────────────────
|
// ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
|
||||||
|
|
||||||
router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
|
router.post(
|
||||||
res.status(422).json({
|
"/api/v1/media",
|
||||||
error: "Media uploads are not yet supported on this server",
|
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 ──────────────────────────────────────────────────
|
// ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => {
|
router.get(
|
||||||
res.status(404).json({ error: "Record not found" });
|
"/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 ──────────────────────────────────────────────────
|
// ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
|
||||||
|
|
||||||
router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
|
router.put(
|
||||||
res.status(404).json({ error: "Record not found" });
|
"/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;
|
export default router;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* GET /api/v1/statuses/:id — single status
|
* GET /api/v1/statuses/:id — single status
|
||||||
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
|
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
|
||||||
* POST /api/v1/statuses — create post via Micropub pipeline
|
* 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
|
* 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/favourite — like a post
|
||||||
* POST /api/v1/statuses/:id/unfavourite — unlike a post
|
* POST /api/v1/statuses/:id/unfavourite — unlike a post
|
||||||
* POST /api/v1/statuses/:id/reblog — boost 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 pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
||||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
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 {
|
const {
|
||||||
status: statusText,
|
status: statusText,
|
||||||
spoiler_text: spoilerText,
|
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.
|
// Build JF2 properties for the Micropub pipeline.
|
||||||
// Provide both text and html — linkify URLs since Micropub's markdown-it
|
// Provide both text and html — linkify URLs since Micropub's markdown-it
|
||||||
// doesn't have linkify enabled. Mentions are preserved as plain text;
|
// 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;
|
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
||||||
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
|
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)
|
// Create post via Micropub pipeline (same internal functions)
|
||||||
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
||||||
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.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 profile = await collections.ap_profile.findOne({});
|
||||||
const handle = pluginOptions.handle || "user";
|
const handle = pluginOptions.handle || "user";
|
||||||
|
|
||||||
res.json({
|
const statusResponse = {
|
||||||
id: String(Date.now()),
|
id: String(Date.now()),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
content: `<p>${contentHtml}</p>`,
|
content: `<p>${contentHtml}</p>`,
|
||||||
@@ -263,7 +314,20 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|||||||
})),
|
})),
|
||||||
tags: [],
|
tags: [],
|
||||||
emojis: [],
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -319,7 +383,7 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete from timeline
|
// Delete from timeline
|
||||||
await collections.ap_timeline.deleteOne({ _id: objectId });
|
await collections.ap_timeline.deleteOne({ _id: item._id });
|
||||||
|
|
||||||
// Clean up interactions
|
// Clean up interactions
|
||||||
if (collections.ap_interactions && item.uid) {
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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 ─────────────────────────────────
|
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
|
router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
|
||||||
// Stub — we don't track who favourited remotely
|
try {
|
||||||
res.json([]);
|
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 ──────────────────────────────────
|
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
|
router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
|
||||||
// Stub — we don't track who boosted remotely
|
try {
|
||||||
res.json([]);
|
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 ────────────────────────────────────
|
// ─── 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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+104
-10
@@ -22,6 +22,8 @@ import express from "express";
|
|||||||
import { serializeStatus } from "../entities/status.js";
|
import { serializeStatus } from "../entities/status.js";
|
||||||
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
|
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
|
||||||
import { getFollowedTagsWithState } from "../../storage/followed-tags.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
|
const router = express.Router(); // eslint-disable-line new-cap
|
||||||
|
|
||||||
@@ -110,16 +112,6 @@ router.get("/api/v1/custom_emojis", (req, res) => {
|
|||||||
res.json([]);
|
res.json([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Filters (v2) ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.get("/api/v2/filters", (req, res) => {
|
|
||||||
res.json([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/api/v1/filters", (req, res) => {
|
|
||||||
res.json([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Lists ──────────────────────────────────────────────────────────────────
|
// ─── Lists ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/lists", (req, res) => {
|
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 ────────────────────────────────────────────────────────────
|
// ─── Suggestions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v2/suggestions", (req, res) => {
|
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 ───────────────────────────────────────────────────────────
|
// ─── Endorsements ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/endorsements", (req, res) => {
|
router.get("/api/v1/endorsements", (req, res) => {
|
||||||
|
|||||||
Generated
+173
-2
@@ -1,21 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "3.9.4",
|
"version": "3.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "3.9.4",
|
"version": "3.11.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fedify/debugger": "^2.1.0",
|
"@fedify/debugger": "^2.1.0",
|
||||||
"@fedify/fedify": "^2.1.0",
|
"@fedify/fedify": "^2.1.0",
|
||||||
"@fedify/redis": "^2.1.0",
|
"@fedify/redis": "^2.1.0",
|
||||||
"@js-temporal/polyfill": "^0.5.0",
|
"@js-temporal/polyfill": "^0.5.0",
|
||||||
|
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"ioredis": "^5.9.3",
|
"ioredis": "^5.9.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
"unfurl.js": "^6.4.0"
|
"unfurl.js": "^6.4.0"
|
||||||
},
|
},
|
||||||
@@ -1396,6 +1398,12 @@
|
|||||||
"node": ">=22"
|
"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": {
|
"node_modules/@sindresorhus/slugify": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
|
||||||
@@ -1510,6 +1518,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@@ -1572,6 +1586,23 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/byte-encodings": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/byte-encodings/-/byte-encodings-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/byte-encodings/-/byte-encodings-1.0.11.tgz",
|
||||||
@@ -1720,6 +1751,21 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
@@ -2968,6 +3014,68 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -3294,6 +3402,20 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/redis-errors": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
@@ -3331,6 +3453,26 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -3572,6 +3714,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/structured-field-values": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/structured-field-values/-/structured-field-values-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/structured-field-values/-/structured-field-values-2.0.4.tgz",
|
||||||
@@ -3623,6 +3782,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/typo-js": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz",
|
||||||
@@ -3713,6 +3878,12 @@
|
|||||||
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
|
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
@@ -38,13 +38,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fedify/debugger": "^2.1.0",
|
"@fedify/debugger": "^2.1.0",
|
||||||
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
|
||||||
"@fedify/fedify": "^2.1.0",
|
"@fedify/fedify": "^2.1.0",
|
||||||
"@fedify/redis": "^2.1.0",
|
"@fedify/redis": "^2.1.0",
|
||||||
"@js-temporal/polyfill": "^0.5.0",
|
"@js-temporal/polyfill": "^0.5.0",
|
||||||
|
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"ioredis": "^5.9.3",
|
"ioredis": "^5.9.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
"unfurl.js": "^6.4.0"
|
"unfurl.js": "^6.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{% extends "layouts/ap-reader.njk" %}
|
{% extends "layouts/ap-reader.njk" %}
|
||||||
|
|
||||||
{% from "heading/macro.njk" import heading with context %}
|
{% 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 %}
|
{% block readercontent %}
|
||||||
{# Reply context — show the post being replied to #}
|
{# Reply context — show the post being replied to #}
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 }}">
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||||
{% if replyTo %}
|
{% if replyTo %}
|
||||||
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
||||||
@@ -39,12 +42,37 @@
|
|||||||
style="display: none">
|
style="display: none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Content textarea #}
|
{# Rich content editor (EasyMDE with media browser) #}
|
||||||
<div class="ap-compose__editor">
|
<div class="ap-compose__editor">
|
||||||
<textarea name="content" class="ap-compose__textarea"
|
{{ textarea({
|
||||||
rows="6"
|
id: "content",
|
||||||
placeholder="{{ __('activitypub.compose.placeholder') }}"
|
name: "content",
|
||||||
required></textarea>
|
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>
|
</div>
|
||||||
|
|
||||||
{# Visibility #}
|
{# Visibility #}
|
||||||
|
|||||||
Reference in New Issue
Block a user