From 72abf8d380b80066c06dd53ba4f23565149dccdc Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 5 Sep 2026 12:56:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(endpoint-posts):=20look=20up=20posts=20by?= =?UTF-8?q?=20id=20so=20older=20posts=20don=E2=80=99t=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The posts UI found a post by listing `?q=source` and searching the page for its uid. That page is capped by the cursor’s default limit of 40, so any older post returned 404 when viewed, edited or deleted. Read the posts collection by `_id` instead. Invalid ids and a missing database still return false, so the not-found path is unchanged. Fixes #924 --- helpers/mock-agent/endpoint-posts.js | 31 ----------- .../lib/middleware/post-data.js | 6 +-- packages/endpoint-posts/lib/utils.js | 37 +++++++------ .../test/integration/200-get-delete.js | 29 +++++++++-- .../integration/200-post-not-in-latest-40.js | 52 +++++++++++++++++++ .../test/integration/200-post.js | 31 +++++++++-- .../test/integration/302-get-delete.js | 29 +++++++++-- .../test/integration/302-post-delete.js | 29 +++++++++-- .../401-post-delete-unauthorized.js | 27 ++++++++-- packages/endpoint-posts/test/unit/utils.js | 52 ++++++++++++++++++- 10 files changed, 252 insertions(+), 71 deletions(-) create mode 100644 packages/endpoint-posts/test/integration/200-post-not-in-latest-40.js diff --git a/helpers/mock-agent/endpoint-posts.js b/helpers/mock-agent/endpoint-posts.js index ebbf1d76d..040f65a72 100644 --- a/helpers/mock-agent/endpoint-posts.js +++ b/helpers/mock-agent/endpoint-posts.js @@ -35,37 +35,6 @@ export function mockClient() { }) .persist(); - // Get source information for all items from external micropub endpoint - agent - .get(micropubEndpointOrigin) - .intercept({ - path: "/?q=source", - }) - .reply(200, { - items: [ - { - type: ["h-entry"], - properties: { - uid: ["123"], - name: ["Foobar"], - "post-type": ["note"], - published: ["2024-12-21"], - url: [postOrigin], - }, - }, - { - type: ["h-entry"], - properties: { - uid: ["401"], - name: ["401"], - "post-type": ["note"], - url: [postBadOrigin], - }, - }, - ], - }) - .persist(); - // Upload file to external micropub endpoint agent .get(micropubEndpointOrigin) diff --git a/packages/endpoint-posts/lib/middleware/post-data.js b/packages/endpoint-posts/lib/middleware/post-data.js index 9ce94d2a5..cce69abb1 100644 --- a/packages/endpoint-posts/lib/middleware/post-data.js +++ b/packages/endpoint-posts/lib/middleware/post-data.js @@ -54,11 +54,7 @@ export const postData = { const { action, uid } = request.params; const { access_token, scope } = request.session; - const properties = await getPostProperties( - uid, - application.micropubEndpoint, - access_token, - ); + const properties = await getPostProperties(uid, application); if (!properties) { throw IndiekitError.notFound(response.locals.__("NotFoundError.page")); diff --git a/packages/endpoint-posts/lib/utils.js b/packages/endpoint-posts/lib/utils.js index 4c8a1eead..79e0cc452 100644 --- a/packages/endpoint-posts/lib/utils.js +++ b/packages/endpoint-posts/lib/utils.js @@ -1,10 +1,8 @@ import { Buffer } from "node:buffer"; -import { sanitise, ISO_6709_RE } from "@indiekit/util"; -import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2"; +import { getObjectId, sanitise, ISO_6709_RE } from "@indiekit/util"; import formatcoords from "formatcoords"; -import { endpoint } from "./endpoint.js"; import { statusTypes } from "./status-types.js"; /** @@ -151,25 +149,30 @@ export const getPostName = (publication, properties) => { }; /** - * Query Micropub endpoint for post data - * @param {string} uid - Item UID - * @param {string} micropubEndpoint - Micropub endpoint - * @param {string} accessToken - Access token - * @returns {Promise} JF2 properties + * Get post properties + * @param {string} uid - Post ID + * @param {object} application - Application configuration + * @returns {Promise} JF2 properties, else false if not found */ -export const getPostProperties = async (uid, micropubEndpoint, accessToken) => { - const micropubUrl = new URL(micropubEndpoint); - micropubUrl.searchParams.append("q", "source"); +export const getPostProperties = async (uid, application) => { + const postsCollection = application?.collections?.get("posts"); + if (!postsCollection) { + return false; + } - const micropubResponse = await endpoint.get(micropubUrl.href, accessToken); + let postData; + try { + postData = await postsCollection.findOne({ _id: getObjectId(uid) }); + } catch { + // Not a valid ObjectId + return false; + } - if (micropubResponse?.items?.length > 0) { - const jf2 = mf2tojf2(micropubResponse); - const items = jf2.children || [jf2]; - return items.find((item) => item.uid === uid); + if (!postData?.properties) { + return false; } - return false; + return { ...postData.properties, uid: postData._id.toString() }; }; /** diff --git a/packages/endpoint-posts/test/integration/200-get-delete.js b/packages/endpoint-posts/test/integration/200-get-delete.js index 65fdf1304..623cf80a6 100644 --- a/packages/endpoint-posts/test/integration/200-get-delete.js +++ b/packages/endpoint-posts/test/integration/200-get-delete.js @@ -1,22 +1,41 @@ import { strict as assert } from "node:assert"; import { after, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; import { mockAgent } from "@indiekit-test/mock-agent"; +import { postData } from "@indiekit-test/post-data"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import { JSDOM } from "jsdom"; import supertest from "supertest"; await mockAgent("endpoint-posts"); +const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ - application: { micropubEndpoint: "https://micropub-endpoint.example" }, + application: { + micropubEndpoint: "https://micropub-endpoint.example", + mongodbUrl: mongoUri, + }, }); const request = supertest.agent(server); +const { insertedId } = await client + .db("indiekit") + .collection("posts") + .insertOne({ + ...postData, + properties: { + ...postData.properties, + name: "Foobar", + url: "https://website.example/foobar", + }, + }); +const uid = insertedId.toString(); + describe("endpoint-posts GET /posts/:uid/delete", () => { it("Gets delete confirmation page", async () => { const response = await request - .get(`/posts/123/delete`) + .get(`/posts/${uid}/delete`) .set("cookie", testCookie()); const dom = new JSDOM(response.text); const result = dom.window.document.querySelector("title").textContent; @@ -27,5 +46,9 @@ describe("endpoint-posts GET /posts/:uid/delete", () => { ); }); - after(() => server.close()); + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); }); diff --git a/packages/endpoint-posts/test/integration/200-post-not-in-latest-40.js b/packages/endpoint-posts/test/integration/200-post-not-in-latest-40.js new file mode 100644 index 000000000..237aa4fec --- /dev/null +++ b/packages/endpoint-posts/test/integration/200-post-not-in-latest-40.js @@ -0,0 +1,52 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import { JSDOM } from "jsdom"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, +}); +const request = supertest.agent(server); + +// Cursor pagination returns 40 posts by default; the oldest post here is +// the 41st most recent, so it never appears in a `?q=source` listing +const { insertedIds } = await client + .db("indiekit") + .collection("posts") + .insertMany( + Array.from({ length: 41 }, (_, index) => ({ + path: `post-${index}.md`, + properties: { + name: `Post ${index}`, + "post-status": "published", + "post-type": "note", + published: new Date(2020, 0, 1 + index).toISOString(), + url: `https://website.example/post-${index}`, + }, + })), + ); +const oldestUid = insertedIds[0].toString(); + +describe("endpoint-posts GET /posts/:uid", () => { + it("Returns post that is not among the 40 most recent", async () => { + const response = await request + .get(`/posts/${oldestUid}`) + .set("cookie", testCookie()); + const dom = new JSDOM(response.text); + const result = dom.window.document.querySelector("title").textContent; + + assert.equal(response.status, 200); + assert.equal(result, "Post 0 - Test configuration"); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-posts/test/integration/200-post.js b/packages/endpoint-posts/test/integration/200-post.js index f2cf0b26d..b353d97aa 100644 --- a/packages/endpoint-posts/test/integration/200-post.js +++ b/packages/endpoint-posts/test/integration/200-post.js @@ -1,28 +1,51 @@ import { strict as assert } from "node:assert"; import { after, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; import { mockAgent } from "@indiekit-test/mock-agent"; +import { postData } from "@indiekit-test/post-data"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import { JSDOM } from "jsdom"; import supertest from "supertest"; await mockAgent("endpoint-posts"); +const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ - application: { micropubEndpoint: "https://micropub-endpoint.example" }, + application: { + micropubEndpoint: "https://micropub-endpoint.example", + mongodbUrl: mongoUri, + }, }); const request = supertest.agent(server); +const { insertedId } = await client + .db("indiekit") + .collection("posts") + .insertOne({ + ...postData, + properties: { + ...postData.properties, + name: "Foobar", + url: "https://website.example/foobar", + }, + }); +const uid = insertedId.toString(); + describe("endpoint-posts GET /posts/:uid", () => { it("Returns published post", async () => { const response = await request - .get(`/posts/123`) + .get(`/posts/${uid}`) .set("cookie", testCookie()); const dom = new JSDOM(response.text); const result = dom.window.document.querySelector("title").textContent; - assert.equal(result, `Foobar - Test configuration`); + assert.equal(result, "Foobar - Test configuration"); }); - after(() => server.close()); + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); }); diff --git a/packages/endpoint-posts/test/integration/302-get-delete.js b/packages/endpoint-posts/test/integration/302-get-delete.js index 157a13317..7f3f122ad 100644 --- a/packages/endpoint-posts/test/integration/302-get-delete.js +++ b/packages/endpoint-posts/test/integration/302-get-delete.js @@ -1,26 +1,49 @@ import { strict as assert } from "node:assert"; import { after, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; import { mockAgent } from "@indiekit-test/mock-agent"; +import { postData } from "@indiekit-test/post-data"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; await mockAgent("endpoint-posts"); +const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ - application: { micropubEndpoint: "https://micropub-endpoint.example" }, + application: { + micropubEndpoint: "https://micropub-endpoint.example", + mongodbUrl: mongoUri, + }, }); const request = supertest.agent(server); +const { insertedId } = await client + .db("indiekit") + .collection("posts") + .insertOne({ + ...postData, + properties: { + ...postData.properties, + name: "Foobar", + url: "https://website.example/foobar", + }, + }); +const uid = insertedId.toString(); + describe("endpoint-posts GET /posts/:uid/delete", () => { it("Redirects to post page if no delete permissions", async () => { const result = await request - .get(`/posts/123/delete`) + .get(`/posts/${uid}/delete`) .set("cookie", testCookie({ scope: "create" })); assert.equal(result.status, 302); assert.match(result.text, /Found. Redirecting to \/posts\/(.*)/); }); - after(() => server.close()); + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); }); diff --git a/packages/endpoint-posts/test/integration/302-post-delete.js b/packages/endpoint-posts/test/integration/302-post-delete.js index 5ba54e2b8..ef00dd750 100644 --- a/packages/endpoint-posts/test/integration/302-post-delete.js +++ b/packages/endpoint-posts/test/integration/302-post-delete.js @@ -1,21 +1,40 @@ import { strict as assert } from "node:assert"; import { after, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; import { mockAgent } from "@indiekit-test/mock-agent"; +import { postData } from "@indiekit-test/post-data"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; await mockAgent("endpoint-posts"); +const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ - application: { micropubEndpoint: "https://micropub-endpoint.example" }, + application: { + micropubEndpoint: "https://micropub-endpoint.example", + mongodbUrl: mongoUri, + }, }); const request = supertest.agent(server); +const { insertedId } = await client + .db("indiekit") + .collection("posts") + .insertOne({ + ...postData, + properties: { + ...postData.properties, + name: "Foobar", + url: "https://website.example/foobar", + }, + }); +const uid = insertedId.toString(); + describe("endpoint-posts POST /posts/:uid/delete", () => { it("Deletes file and redirects to posts page", async () => { const result = await request - .post(`/posts/123/delete`) + .post(`/posts/${uid}/delete`) .set("cookie", testCookie()) .send({ url: "https://website.example/foobar" }); @@ -23,5 +42,9 @@ describe("endpoint-posts POST /posts/:uid/delete", () => { assert.match(result.text, /Found. Redirecting to \/posts\?success/); }); - after(() => server.close()); + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); }); diff --git a/packages/endpoint-posts/test/integration/401-post-delete-unauthorized.js b/packages/endpoint-posts/test/integration/401-post-delete-unauthorized.js index d30ef5f44..576581a7c 100644 --- a/packages/endpoint-posts/test/integration/401-post-delete-unauthorized.js +++ b/packages/endpoint-posts/test/integration/401-post-delete-unauthorized.js @@ -1,22 +1,37 @@ import { strict as assert } from "node:assert"; import { after, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; import { mockAgent } from "@indiekit-test/mock-agent"; +import { postData } from "@indiekit-test/post-data"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import { JSDOM } from "jsdom"; import supertest from "supertest"; await mockAgent("endpoint-posts"); +const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ - application: { micropubEndpoint: "https://micropub-endpoint.example" }, + application: { + micropubEndpoint: "https://micropub-endpoint.example", + mongodbUrl: mongoUri, + }, }); const request = supertest.agent(server); -describe("endpoint-files POST /posts/:uid/delete", () => { +const { insertedId } = await client + .db("indiekit") + .collection("posts") + .insertOne({ + ...postData, + properties: { ...postData.properties, url: "https://website.example/401" }, + }); +const uid = insertedId.toString(); + +describe("endpoint-posts POST /posts/:uid/delete", () => { it("Returns 401 error deleting post", async () => { const response = await request - .post(`/posts/401/delete`) + .post(`/posts/${uid}/delete`) .set("cookie", testCookie()) .send({ url: "https://website.example/401" }); const dom = new JSDOM(response.text); @@ -28,5 +43,9 @@ describe("endpoint-files POST /posts/:uid/delete", () => { assert.match(result, /Unauthorized/); }); - after(() => server.close()); + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); }); diff --git a/packages/endpoint-posts/test/unit/utils.js b/packages/endpoint-posts/test/unit/utils.js index 478d5b812..c037f721a 100644 --- a/packages/endpoint-posts/test/unit/utils.js +++ b/packages/endpoint-posts/test/unit/utils.js @@ -1,6 +1,8 @@ import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; +import { after, before, describe, it } from "node:test"; +import { testDatabase } from "@indiekit-test/database"; +import { postData } from "@indiekit-test/post-data"; import { mockResponse } from "mock-req-res"; import { @@ -10,6 +12,7 @@ import { getLocationProperty, getPhotoUrl, getPostName, + getPostProperties, getPostStatusBadges, getPostUrl, getSyndicateToItems, @@ -259,6 +262,53 @@ describe("endpoint-posts/lib/utils", () => { ]); }); + describe("getPostProperties", () => { + let client; + let mongoServer; + let application; + let uid; + + before(async () => { + ({ client, mongoServer } = await testDatabase()); + const posts = client.db().collection("posts"); + application = { collections: new Map([["posts", posts]]) }; + ({ insertedId: uid } = await posts.insertOne({ ...postData })); + }); + + it("Gets post properties by ID", async () => { + const result = await getPostProperties(uid.toString(), application); + + assert.equal(result.name, "note"); + assert.equal(result.uid, uid.toString()); + }); + + it("Returns false if no post with ID", async () => { + const result = await getPostProperties( + "000000000000000000000000", + application, + ); + + assert.equal(result, false); + }); + + it("Returns false if ID is not an ObjectId", async () => { + const result = await getPostProperties("123", application); + + assert.equal(result, false); + }); + + it("Returns false if no database", async () => { + const result = await getPostProperties(uid.toString(), {}); + + assert.equal(result, false); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + }); + }); + it("Gets post URL", () => { assert.equal( getPostUrl("aHR0cHM6Ly93ZWJzaXRlLmV4YW1wbGUvZm9vYmFy"),