Skip to content
Open
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
5 changes: 5 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ DB_MAX_SOCKETS=512
# Sync tolerance in milliseconds
SYNC_TOLERANCE=1000

# Brotli quality (0-11) for compressed responses, default 6. Higher is smaller but costs CPU
# that is shared with change-request image processing. Only applies when the API compresses
# itself — a reverse proxy that compresses instead is configured on its own side.
COMPRESS_BROTLI_QUALITY=5

# Maximum `limit` accepted on a POST /query request (enforced for all query identifiers).
# Requests above this are rejected with 400. Guards against huge result-set requests.
QUERY_MAX_LIMIT=500
Expand Down
5 changes: 5 additions & 0 deletions api/src/dto/MongoQueryDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ export class MongoQueryDto {
/** Custom field indicating if expired content documents should be included in sync results.
* Used during update syncs (APP mode only) so offline clients receive expiry changes on published docs. */
includeExpired?: boolean;

/** Custom field naming document fields to drop from each returned doc, so a caller that never
* reads a heavy field (e.g. `text`, `fts`) does not pay to download it. Applied server-side
* after the find; the fields the server itself reads are protected by validateQuery. */
omitFields?: string[];
}
140 changes: 140 additions & 0 deletions api/src/endpoints/query.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,146 @@ describe("QueryService", () => {
expect(res.docs[0]).not.toHaveProperty("fts");
});

it("omits the caller-nominated fields from returned docs", async () => {
const access = {
[DocType.Post]: ["gp1"],
[DocType.Language]: ["lang-g1"],
} as any;
(permissions.PermissionSystem.accessMapToGroups as jest.Mock).mockReturnValueOnce(
access,
);
(service as any).languages = [{ _id: "lang-eng", memberOf: ["lang-g1"] }];

const query = makeQuery((s) => {
(s as any).type = DocType.Content;
(s as any).parentType = DocType.Post;
});
(query as any).omitFields = ["fts", "ftsTokenCount", "text", "memberOf"];

dbService.executeFindQuery.mockResolvedValueOnce({
docs: [
{
_id: "c1",
type: DocType.Content,
status: PublishStatus.Published,
updatedTimeUtc: 5,
memberOf: ["gp1"],
language: "lang-eng",
title: "a title",
text: "<p>a very long body</p>",
fts: ["abc:1"],
ftsTokenCount: 3,
},
],
});

const res = await service.query(query, mockUser);

expect(res.docs[0]).toHaveProperty("title", "a title");
expect(res.docs[0]).toHaveProperty("updatedTimeUtc", 5);
expect(res.docs[0]).not.toHaveProperty("text");
expect(res.docs[0]).not.toHaveProperty("fts");
expect(res.docs[0]).not.toHaveProperty("ftsTokenCount");
expect(res.docs[0]).not.toHaveProperty("memberOf");
});

it("omits fields for cms:true responses too", async () => {
const access = {
[DocType.Post]: ["gp1"],
[DocType.Language]: ["lang-g1"],
} as any;
(permissions.PermissionSystem.accessMapToGroups as jest.Mock).mockReturnValueOnce(
access,
);
(service as any).languages = [{ _id: "lang-eng", memberOf: ["lang-g1"] }];

const query = makeQuery((s) => {
(s as any).type = DocType.Content;
(s as any).parentType = DocType.Post;
});
(query as any).cms = true;
(query as any).omitFields = ["text"];

dbService.executeFindQuery.mockResolvedValueOnce({
docs: [
{
_id: "c1",
type: DocType.Content,
status: PublishStatus.Draft,
title: "a title",
text: "<p>a very long body</p>",
},
],
});

const res = await service.query(query, mockUser);

expect(res.docs[0]).toHaveProperty("title", "a title");
expect(res.docs[0]).not.toHaveProperty("text");
});

it("does not forward omitFields to CouchDB", async () => {
const access = { [DocType.Post]: ["gp1"] } as any;
(permissions.PermissionSystem.accessMapToGroups as jest.Mock).mockReturnValueOnce(
access,
);

const query = makeQuery((s) => {
(s as any).type = DocType.Post;
});
(query as any).omitFields = ["text"];

dbService.executeFindQuery.mockResolvedValueOnce({ docs: [] });

await service.query(query, mockUser);

expect(dbService.executeFindQuery.mock.calls[0][0]).not.toHaveProperty("omitFields");
});

it("keeps the expired-content stub intact when omitFields is also set", async () => {
const access = {
[DocType.Post]: ["gp1"],
[DocType.Language]: ["lang-g1"],
} as any;
(permissions.PermissionSystem.accessMapToGroups as jest.Mock).mockReturnValueOnce(
access,
);
(service as any).languages = [{ _id: "lang-eng", memberOf: ["lang-g1"] }];

const query = makeQuery((s) => {
(s as any).type = DocType.Content;
(s as any).parentType = DocType.Post;
});
(query as any).includeExpired = true;
// memberOf is on the stub's keep-list; the stub wins, so the client can still
// route/prune the doc it is being told to drop.
(query as any).omitFields = ["text", "memberOf"];

dbService.executeFindQuery.mockResolvedValueOnce({
docs: [
{
_id: "c1",
type: DocType.Content,
status: PublishStatus.Published,
expiryDate: 1,
updatedTimeUtc: 5,
memberOf: ["gp1"],
language: "lang-eng",
title: "secret title",
text: "<p>secret body</p>",
},
],
});

const res = await service.query(query, mockUser);

expect(res.docs[0]).toEqual(
expect.objectContaining({ _id: "c1", expiryDate: 1, memberOf: ["gp1"] }),
);
expect(res.docs[0]).not.toHaveProperty("title");
expect(res.docs[0]).not.toHaveProperty("text");
});

it("does NOT strip expired Content for cms:true responses", async () => {
const access = {
[DocType.Post]: ["gp1"],
Expand Down
29 changes: 25 additions & 4 deletions api/src/endpoints/query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,11 @@ export class QueryService {
viewGroups = userViewGroups[type as DocType] || [];
}

const omitFields = Array.isArray(query.omitFields) ? query.omitFields : [];

delete query.cms;
delete query.includeExpired;
delete query.omitFields;

// For content queries without parentType the per-parentType $or above already injected
// memberOf scoping; otherwise apply the single global memberOf filter here.
Expand Down Expand Up @@ -277,15 +280,33 @@ export class QueryService {
// returned purely so the client can prune its stale copy — never to display. Strip the body
// so it never crosses the wire. CMS (cms:true / CmsView-validated) responses keep full docs.
// See util/stripExpiredContent.ts; the Socket.io base-room emit applies the same projection.
if (!isCms && Array.isArray(result?.docs)) {
result.docs = result.docs.map((doc: any) =>
isExpiredContent(doc, now) ? stripExpiredContent(doc) : doc,
);
//
// The caller's `omitFields` projection runs in the same pass, after executeFindQuery so the
// blockStart/blockEnd cursor is already computed from the full docs. An expired-content stub
// is already minimal, so it needs no further projection.
if ((!isCms || omitFields.length) && Array.isArray(result?.docs)) {
result.docs = result.docs.map((doc: any) => {
if (!isCms && isExpiredContent(doc, now)) return stripExpiredContent(doc);
return omitDocFields(doc, omitFields);
});
}
return result;
}
}

/**
* Drop the caller-nominated fields from a returned doc. Returns the doc untouched when there is
* nothing to omit, so an unprojected query allocates nothing.
*/
function omitDocFields<T extends Record<string, any>>(doc: T, fields: string[]): T {
if (!fields.length || !doc) return doc;
if (!fields.some((f) => doc[f] !== undefined)) return doc;

const projected: Record<string, any> = { ...doc };
for (const field of fields) delete projected[field];
return projected as T;
}

/**
* Extract memberOf groups from the top-level $and array.
* (After expansion, memberOf will always be a condition in the $and array.
Expand Down
26 changes: 26 additions & 0 deletions api/src/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { PermissionSystem } from "./permissions/permissions.service";
import { upgradeDbSchema } from "./db/db.upgrade";
import { reconcileLanguageTranslationSeeds } from "./db/languageSeedReconciliation";
import { bootstrap } from "./main";
import { constants } from "zlib";

describe("bootstrap", () => {
let mockApp: any;
Expand Down Expand Up @@ -76,6 +77,31 @@ describe("bootstrap", () => {
expect(mockApp.listen).toHaveBeenCalledWith("3000", "0.0.0.0");
});

it("should register compression with an explicit Brotli quality", async () => {
process.argv = ["node", "main.js"];
process.env.COMPRESS_BROTLI_QUALITY = "7";

await bootstrap();

const [, options] = mockApp.register.mock.calls.find(
([, opts]: [unknown, any]) => opts?.encodings,
);
expect(options.encodings).toEqual(["br", "gzip", "deflate"]);
expect(options.brotliOptions.params[constants.BROTLI_PARAM_QUALITY]).toBe(7);
});

it("should default the Brotli quality above the plugin's own default", async () => {
process.argv = ["node", "main.js"];
delete process.env.COMPRESS_BROTLI_QUALITY;

await bootstrap();

const [, options] = mockApp.register.mock.calls.find(
([, opts]: [unknown, any]) => opts?.encodings,
);
expect(options.brotliOptions.params[constants.BROTLI_PARAM_QUALITY]).toBe(6);
});

it("should seed and exit when 'seed' argument is provided", async () => {
process.argv = ["node", "main.js", "seed"];
// process.exit never returns in reality, so the mock must actually halt bootstrap() here
Expand Down
12 changes: 11 additions & 1 deletion api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PermissionSystem } from "./permissions/permissions.service";
import { upgradeDbSchema } from "./db/db.upgrade";
import { ValidationPipe } from "@nestjs/common";
import compress from "@fastify/compress";
import { constants } from "zlib";
import multipart from "@fastify/multipart";
import { AllExceptionsFilter } from "./exceptions/allExceptions.filter";
import { S3Service } from "./s3/s3.service";
Expand All @@ -31,9 +32,18 @@ export async function bootstrap() {
},
});

// Register compression plugin (Brotli/gzip) for the query endpoint
// Register compression plugin (Brotli/gzip) for the query endpoint. The plugin's own Brotli
// default is quality 4; 6 is the knee of the size/CPU curve on realistic /query bodies
// (~7% smaller for ~2ms more, where 11 costs ~200ms). Tune per deployment — the API shares
// CPU with change-request image processing.
await app.register(compress, {
encodings: ["br", "gzip", "deflate"],
brotliOptions: {
params: {
[constants.BROTLI_PARAM_QUALITY]:
parseInt(process.env.COMPRESS_BROTLI_QUALITY, 10) || 6,
},
},
});

const dbService = app.get(DbService);
Expand Down
40 changes: 40 additions & 0 deletions api/src/validation/query/validateQuery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,46 @@ describe("validateQuery", () => {
});
});

describe("omitFields (response projection)", () => {
it("accepts a projection of heavy, client-unread fields", () => {
const q: any = validHybridQuery();
q.omitFields = ["fts", "ftsTokenCount", "text", "memberOf", "_rev"];
expect(validateQuery(q)).toEqual({ valid: true, error: "" });
});

it("rejects a non-array omitFields", () => {
const q: any = validHybridQuery();
q.omitFields = "text";
expect(validateQuery(q).error).toMatch(/'omitFields' must be an array/);
});

it("rejects non-string / empty members", () => {
const q1: any = validHybridQuery();
q1.omitFields = ["text", 7];
expect(validateQuery(q1).error).toMatch(/'omitFields' must contain non-empty strings/);
const q2: any = validHybridQuery();
q2.omitFields = [""];
expect(validateQuery(q2).valid).toBe(false);
});

it("rejects omitting a field the server itself reads after the find", () => {
// updatedTimeUtc drives blockStart/blockEnd; the rest drive the expired-content strip.
for (const field of ["_id", "type", "updatedTimeUtc", "status", "expiryDate"]) {
const q: any = validHybridQuery();
q.omitFields = ["text", field];
expect(validateQuery(q).error).toMatch(
new RegExp(`'omitFields' may not omit '${field}'`),
);
}
});

it("rejects an implausibly long projection", () => {
const q: any = validHybridQuery();
q.omitFields = Array.from({ length: 33 }, (_, i) => `field${i}`);
expect(validateQuery(q).error).toMatch(/'omitFields' exceeds maximum length/);
});
});

describe("limit cap", () => {
it("rejects a limit above the default maximum", () => {
const q: any = validHybridQuery();
Expand Down
31 changes: 31 additions & 0 deletions api/src/validation/query/validateQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,20 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([
"use_index",
"cms",
"includeExpired",
"omitFields",
]);

/**
* Fields `omitFields` may never drop, because the server itself reads them after the find:
* `updatedTimeUtc` produces the `blockStart`/`blockEnd` sync cursor (db.service `calcBlockStartEnd`)
* and `_id`/`type`/`status`/`expiryDate` drive the expired-Content strip (util/stripExpiredContent).
* Rejected rather than silently kept, so a client bug surfaces at the boundary.
*/
const OMIT_FIELDS_PROTECTED = new Set(["_id", "type", "updatedTimeUtc", "status", "expiryDate"]);

/** Bound on `omitFields` length — a projection list far longer than a document is a client bug. */
const MAX_OMIT_FIELDS = 32;

/**
* The `identifier` label is client-supplied and lands in structured logs, so it is
* constrained to a known set to bound log cardinality and keep arbitrary client text
Expand All @@ -113,6 +125,7 @@ export const ALLOWED_IDENTIFIERS = new Set(["sync", "hybridQuery", "ssgDrain", "
* - an operator policy (no `$regex` / `$where`; `$elemMatch` only on array fields;
* no `null` member in an `$in` / `$nin` / `$all` array — it crashes CouchDB's
* `_find` with an unhandled `function_clause`),
* - an `omitFields` check (bounded, never dropping a field the server reads post-find),
* - selector depth / clause-count caps,
* - a per-request language cap for NON-CMS queries (guards query cost; CMS is exempt as it
* syncs all languages). Enforced here, before query.service injects the permission-language
Expand Down Expand Up @@ -169,6 +182,24 @@ export function validateQuery(query: any, options: ValidateQueryOptions = {}): V
return fail("'includeExpired' must be a boolean");
}

// omitFields — optional projection. It can only ever narrow a permission-scoped response, so
// the entries need no allowlist; the checks below only stop it breaking the server's own
// post-find reads.
if (query.omitFields !== undefined) {
if (!Array.isArray(query.omitFields)) return fail("'omitFields' must be an array");
if (query.omitFields.length > MAX_OMIT_FIELDS) {
return fail(`'omitFields' exceeds maximum length (${MAX_OMIT_FIELDS})`);
}
for (const field of query.omitFields) {
if (typeof field !== "string" || field.length === 0) {
return fail("'omitFields' must contain non-empty strings");
}
if (OMIT_FIELDS_PROTECTED.has(field)) {
return fail(`'omitFields' may not omit '${field}'`);
}
}
}

// use_index — optional; must be a known Mango index name.
if (query.use_index !== undefined) {
if (typeof query.use_index !== "string") return fail("'use_index' must be a string");
Expand Down
Loading
Loading