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
1 change: 1 addition & 0 deletions docs/guide/caching/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ holds for every response.
| The body is empty | There is nothing to replay. |
| The status is not `2xx` | Errors and redirects are not the answer to the question that was asked. |
| The body is a stream | A stream can be consumed once. A stored copy cannot be replayed, and the entry would poison every later request for that key. |
| The body is a `Buffer` | Entries are stored as JSON, which cannot carry raw bytes or the `Content-Type`. A replayed file or CSV would arrive as a JSON array of byte values. |
| The response sets `Set-Cookie` | A cookie is issued to one caller. Replaying it hands that caller's session, CSRF token or consent state to everybody sharing the key. |
| `Cache-Control` says `no-store` or `private` | An explicit instruction from the handler not to store the response. |
| A purge for the key started while the response was being read | The body may predate the write that triggered the purge. See [A purge ends the wait](#a-purge-ends-the-wait). |
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ Independently of configuration, a response is not stored when:
- the body is empty, or the status is not `2xx`;
- the body is a stream — it can only be consumed once, so a stored copy could
not be replayed;
- the body is a `Buffer` — entries are stored as JSON, which cannot carry
raw bytes or their `Content-Type`;
- the response sets a `Set-Cookie` — replaying it would hand one caller's
session to everybody sharing the key;
- the response says `Cache-Control: no-store` or `private`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export const isCacheable = function (ctx: Context): CacheabilityVerdict {
return { cacheable: false, reason: 'streamed body' };
}

// Entries are stored as JSON.stringify({ data }), and a Buffer stringifies
// to { type: "Buffer", data: [...] }, which JSON.parse never turns back into
// a Buffer. Nor is the Content-Type stored. So every HIT served a JSON array
// of byte values in place of the file or CSV the handler sent. Koa sends only
// a Buffer as raw bytes - any other typed array it serialises as JSON itself,
// which round-trips - so a Buffer is the one binary body refused here.
if (Buffer.isBuffer(ctx.body)) {
return { cacheable: false, reason: 'binary (Buffer) body' };
}

// Set-Cookie is issued to one caller. Replaying it hands that caller's
// session, CSRF token or consent state to everybody else who shares the key.
const setCookie = ctx.response.get('Set-Cookie');
Expand Down
1 change: 1 addition & 0 deletions shared/config/cache-strategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ module.exports = ({ env }) => ({
{ path: "/api/categories/probe/raw", method: "GET" },
{ path: "/api/categories/probe/stream", method: "GET" },
{ path: "/api/categories/probe/with-cookie", method: "GET" },
{ path: "/api/categories/probe/buffer", method: "GET" },
{ path: "/api/categories/probe/throwing-body", method: "GET" },
// No maxAge, hitpass or keys of its own: all three must come from
// this content type, not from the strategy. See
Expand Down
6 changes: 6 additions & 0 deletions shared/src/api/category/controllers/category.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ module.exports = createCoreController(
ctx.body = Readable.from([JSON.stringify({ streamed: true })]);
},

/** A Buffer body with its own content type, e.g. a proxied file. */
async buffer(ctx) {
ctx.type = "text/csv";
ctx.body = Buffer.from("a,b\n1,2\n");
},

/** A response carrying a Set-Cookie, which must never be shared. */
async withCookie(ctx) {
await originDelay(ctx);
Expand Down
5 changes: 5 additions & 0 deletions shared/src/api/category/routes/custom-category.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ module.exports = {
path: "/categories/probe/stream",
handler: "category.stream",
},
{
method: "GET",
path: "/categories/probe/buffer",
handler: "category.buffer",
},
{
method: "GET",
path: "/categories/probe/throwing-body",
Expand Down
1 change: 1 addition & 0 deletions shared/src/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ async function importSeedData() {
"findOne",
"raw",
"stream",
"buffer",
"withCookie",
"throwingBody",
"withCacheControl",
Expand Down
58 changes: 58 additions & 0 deletions shared/tests/buffer-body.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use strict";

/**
* A Buffer body must reach every caller as the bytes the handler sent.
*
* cacheStore.set serialises every body with JSON.stringify({ data }). A Buffer
* stringifies to { type: "Buffer", data: [...] }, and JSON.parse never turns
* that back into a Buffer, so every HIT used to serve that JSON object instead
* of the original bytes - and without the Content-Type the handler set, which
* is not stored either. A CSV export or a proxied file became a JSON array of
* byte values for everyone after the first caller.
*
* Such a body is now refused by isCacheable, like a stream: returned as normal,
* never stored.
*/

const { setup, teardown, agent } = require("./helpers/strapi");

jest.setTimeout(60000);

process.env.STRAPI_DISABLE_UPDATE_NOTIFICATION = true;
process.env.STRAPI_HIDE_STARTUP_MESSAGE = true;
process.env.STRAPI_TELEMETRY_DISABLED = true;

const store = () => strapi.plugin("rest-cache").service("cacheStore");

describe("Buffer bodies", () => {
beforeAll(async () => {
process.env.KEYS_PREFIX = undefined;
await setup();
});
afterAll(async () => await teardown());

beforeEach(() => store().reset());

it("serves the same bytes and type to a second caller", async () => {
const first = await agent().get("/api/categories/probe/buffer").buffer(true);
const second = await agent().get("/api/categories/probe/buffer").buffer(true);

expect(first.status).toBe(200);
expect(first.get("x-cache")).toBe("MISS");
expect(first.text).toBe("a,b\n1,2\n");
expect(first.get("content-type")).toMatch(/^text\/csv/);

expect(second.status).toBe(200);
expect(second.text).toBe(first.text);
expect(second.get("content-type")).toBe(first.get("content-type"));
// Refused rather than stored, so the second caller reaches the origin.
expect(second.get("x-cache")).toBe("MISS");
});

it("does not store anything for it", async () => {
await agent().get("/api/categories/probe/buffer");

const keys = await store().keys();
expect(keys.filter((k) => k.includes("/categories/probe/buffer"))).toHaveLength(0);
});
});
Loading