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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions helpers/mock-agent/endpoint-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,60 @@ export function mockClient() {
})
.persist();

// Get source information for all items from external media endpoint
// A default-sized listing page (the shape `q=source` returns with no
// `uid`/`url` and no explicit `limit`) that does not include the file
// fetched by uid below — it's older than the newest page of uploads.
const items = Array.from({ length: 40 }, (_, index) => ({
uid: `other-${index}`,
url: `https://website.example/other-${index}.jpg`,
}));

agent
.get(mediaEndpointOrigin)
.intercept({
path: "/?q=source",
})
.reply(200, { items })
.persist();

// Get source information for a single file by uid from external media endpoint
agent
.get(mediaEndpointOrigin)
.intercept({
path: "/",
query: { q: "source", uid: "123" },
})
.reply(200, {
items: [
{
uid: "123",
url: photoOrigin,
},
{
uid: "401",
url: photoBadOrigin,
},
],
uid: "123",
"media-type": "photo",
url: photoOrigin,
})
.persist();

agent
.get(mediaEndpointOrigin)
.intercept({
path: "/",
query: { q: "source", uid: "401" },
})
.reply(200, {
uid: "401",
"media-type": "photo",
url: photoBadOrigin,
})
.persist();

// Get source information for a file older than the newest page of uploads
agent
.get(mediaEndpointOrigin)
.intercept({
path: "/",
query: { q: "source", uid: "target-uid" },
})
.reply(200, {
uid: "target-uid",
"media-type": "photo",
url: "https://website.example/target.jpg",
})
.persist();

Expand Down
51 changes: 28 additions & 23 deletions helpers/mock-agent/endpoint-posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,39 @@ export function mockClient() {
})
.persist();

// Get source information for all items from external micropub endpoint
// Get source information for a single post by uid from external micropub endpoint
agent
.get(micropubEndpointOrigin)
.intercept({
path: "/?q=source",
path: "/",
query: { q: "source", uid: "123" },
})
.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],
},
},
],
type: ["h-entry"],
properties: {
uid: ["123"],
name: ["Foobar"],
"post-type": ["note"],
published: ["2024-12-21"],
url: [postOrigin],
},
})
.persist();

agent
.get(micropubEndpointOrigin)
.intercept({
path: "/",
query: { q: "source", uid: "401" },
})
.reply(200, {
type: ["h-entry"],
properties: {
uid: ["401"],
name: ["401"],
"post-type": ["note"],
url: [postBadOrigin],
},
})
.persist();

Expand Down
23 changes: 17 additions & 6 deletions packages/endpoint-files/lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import { Buffer } from "node:buffer";

import { IndiekitError } from "@indiekit/error";

import { endpoint } from "./endpoint.js";

/**
* Query Micropub media endpoint for file data
* @param {string} uid - Item UID
* @param {string} mediaEndpoint - Micropub media endpoint
* @param {string} accessToken - Access token
* @returns {Promise<object>} JF2 properties
* @returns {Promise<object|boolean>} JF2 properties, or false if not found
*/
export const getFileProperties = async (uid, mediaEndpoint, accessToken) => {
const mediaUrl = new URL(mediaEndpoint);
mediaUrl.searchParams.append("q", "source");
mediaUrl.searchParams.append("uid", uid);

const mediaResponse = await endpoint.get(mediaUrl.href, accessToken);
try {
// `q=source&uid=` returns properties for a single file, already flat
// JF2 (unlike the Micropub equivalent, the media endpoint has no mf2
// to convert), so there’s nothing to unwrap before returning it.
return await endpoint.get(mediaUrl.href, accessToken);
} catch (error) {
// `endpoint.get` throws on any error response. A file that is simply
// gone is the caller’s own not-found page, not an error to show the
// reader; anything else is a real failure and must keep travelling.
if (error instanceof IndiekitError && error.status === 404) {
return false;
}

if (mediaResponse?.items?.length > 0) {
return mediaResponse.items.find((item) => item.uid === uid);
throw error;
}

return false;
};

/**
Expand Down
17 changes: 16 additions & 1 deletion packages/endpoint-files/test/unit/utils.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";

import { getFileName, getFileUrl } from "../../lib/utils.js";
import { mockAgent } from "@indiekit-test/mock-agent";
import { testToken } from "@indiekit-test/token";

import { getFileName, getFileProperties, getFileUrl } from "../../lib/utils.js";

await mockAgent("endpoint-files");

describe("endpoint-files/lib/utils", () => {
it("Gets file name from a URL", () => {
Expand All @@ -15,4 +20,14 @@ describe("endpoint-files/lib/utils", () => {
"https://website.example/foobar",
);
});

it("Fetches a file that isn’t on the media endpoint’s first page of results", async () => {
const result = await getFileProperties(
"target-uid",
"https://media-endpoint.example",
testToken(),
);

assert.equal(result.uid, "target-uid");
});
});
25 changes: 16 additions & 9 deletions packages/endpoint-media/lib/controllers/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getMediaProperties } from "../utils.js";
* @property {string} [before] - Return items before this item ID
* @property {string} [limit] - Number of items to return
* @property {string} [q] - Query
* @property {string} [uid] - UID of file to return
* @property {string} [url] - URL of post to return
*/

Expand All @@ -22,7 +23,7 @@ export const queryController = async (request, response, next) => {

try {
const limit = Number(request.query.limit) || 0;
const { after, before, q, url } = request.query;
const { after, before, q, uid, url } = request.query;

if (!q) {
throw IndiekitError.badRequest(
Expand All @@ -32,20 +33,26 @@ export const queryController = async (request, response, next) => {

switch (q) {
case "source": {
if (url) {
// Return properties for a given URL
if (url || uid) {
// Return properties for a given file. `url` is what the
// Micropub specification defines; `uid` is an extension, and
// the only identifier the files interface holds.
let mediaData;

if (mediaCollection) {
mediaData = await mediaCollection.findOne({
"properties.url": url,
});
mediaData = await mediaCollection.findOne(
url ? { "properties.url": url } : { "properties.uid": uid },
);
}

if (!mediaData) {
throw IndiekitError.badRequest(
response.locals.__("BadRequestError.missingResource", "file"),
);
throw url
? IndiekitError.badRequest(
response.locals.__("BadRequestError.missingResource", "file"),
)
: IndiekitError.notFound(
response.locals.__("NotFoundError.record", "file"),
);
}

return response.json(getMediaProperties(mediaData));
Expand Down
18 changes: 15 additions & 3 deletions packages/endpoint-media/lib/media-data.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomUUIDv7 } from "node:crypto";
Comment thread
rmdes marked this conversation as resolved.

import { IndiekitError } from "@indiekit/error";
import { getCanonicalUrl } from "@indiekit/util";
import makeDebug from "debug";
Expand Down Expand Up @@ -57,12 +59,22 @@ export const mediaData = {
const urlPathSegment = properties.url.split("/");
properties.filename = urlPathSegment.at(-1);

const mediaData = { path, properties };

// Add data to media collection (or replace existing if present)
const mediaCollection = application?.collections?.get("media");
const query = { "properties.url": properties.url };

// Keep `uid` stable when media already exists at this URL: it is
// meant to be a durable identifier for the media item, so rotating it
// on every update would invalidate identifiers already handed out
// once something starts relying on it staying the same.
const existing = await mediaCollection?.findOne(query, {
projection: { "properties.uid": 1 },
});
properties.uid = existing?.properties?.uid || randomUUIDv7();

const mediaData = { path, properties };

if (mediaCollection) {
const query = { "properties.url": properties.url };
await mediaCollection.replaceOne(query, mediaData, { upsert: true });
}

Expand Down
2 changes: 1 addition & 1 deletion packages/endpoint-media/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { mediaTypeCount } from "./media-type-count.js";
*/
export const getMediaProperties = (mediaData) => {
return {
uid: mediaData._id,
uid: mediaData.properties.uid,
"content-type": mediaData.properties["content-type"],
"media-type": mediaData.properties["media-type"],
published: mediaData.properties.published,
Expand Down
52 changes: 52 additions & 0 deletions packages/endpoint-media/test/integration/200-query-source-uid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { strict as assert } from "node:assert";
import { after, before, describe, it } from "node:test";

import { testDatabase } from "@indiekit-test/database";
import { getFixture } from "@indiekit-test/fixtures";
import { testServer } from "@indiekit-test/server";
import { testToken } from "@indiekit-test/token";
import supertest from "supertest";

const { client, mongoServer, mongoUri } = await testDatabase();
const server = await testServer({
application: { mongodbUrl: mongoUri },
});
const request = supertest.agent(server);

describe("endpoint-media GET /media?q=source&uid=*", () => {
let seededUid;

before(async () => {
await request
.post("/media")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.attach("file", getFixture("file-types/photo.jpg", false), "photo.jpg");

const result = await request
.get("/media")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.query({ q: "source" });

seededUid = result.body.items[0].uid;
});

it("Returns properties for a file by uid", async () => {
const result = await request
.get("/media")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.query({ q: "source" })
.query({ uid: seededUid });

assert.equal(result.status, 200);
assert.equal(result.body.uid, seededUid);
});

after(async () => {
await client.close();
await mongoServer.stop();
server.close((error) => process.exit(error ? 1 : 0));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { strict as assert } from "node:assert";
import { after, describe, it } from "node:test";

import { testServer } from "@indiekit-test/server";
import { testToken } from "@indiekit-test/token";
import supertest from "supertest";

const server = await testServer();
const request = supertest.agent(server);

describe("endpoint-media GET /media?q=source&uid=*", () => {
it("Returns 404 error uid not found", async () => {
const result = await request
.get("/media")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.query({ q: "source" })
.query({ uid: "unknown-uid" });

assert.equal(result.status, 404);
assert.equal(
result.body.error_description,
"No database record found for file",
);
});

after(() => server.close());
});
6 changes: 6 additions & 0 deletions packages/endpoint-media/test/unit/media-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,10 @@ describe("endpoint-media/lib/media-data", async () => {
message: "No media data to delete",
});
});

it("Stores a UUIDv7 uid", async () => {
const result = await mediaData.create(application, publication, file);

assert.match(result.properties.uid, /^[\da-f]{8}-[\da-f]{4}-7/);
});
});
Loading
Loading