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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Full Circuit is a web application for exploring PC components and planning a cus
- [What is set up on the VM](#what-is-set-up-on-the-vm)
- [Working with it](#working-with-it)
- [Run the backend locally](#run-the-backend-locally)
- [Abuse protection and request limits](#abuse-protection-and-request-limits)
- [Current prototype](#current-prototype)
- [PC builder](#pc-builder)
- [Navigation and shared build state](#navigation-and-shared-build-state)
Expand Down Expand Up @@ -146,6 +147,21 @@ npm ci
npm run dev
```

### Abuse protection and request limits

Publishing stays anonymous, so the backend limits how much a single client can do. The limits are set in `backend/server/src/config.ts`.

| Limit | Value | When exceeded |
| --- | --- | --- |
| Publishing builds (`publishBuild`) | 5 attempts per client IP in 10 minutes. | HTTP `429` with a `Retry-After` header and error code `RATE_LIMITED`. Nothing is written to the database. |
| Query depth | At most 8 levels of nested fields. | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. |
| Query size | At most 100 selected fields per operation (fragments and aliases are counted). | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. |
| Request body | At most 100 KB. | HTTP `413`, error code `PAYLOAD_TOO_LARGE`. |

Every publishing attempt counts, including invalid ones, and each alias of `publishBuild` in one request counts separately. Reading data is not rate limited, and introspection queries are not counted towards the depth and size limits. The counters are kept in the server's memory, so they reset when the backend restarts, and they are per backend process.

The limit is per IP address, so the backend must see the visitor's real address. On the VM, Apache forwards requests to the backend, so `TRUST_PROXY=1` must be set in `backend/server/.env`. Without it, all visitors share the proxy's address and one limit.

## Current prototype

The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog supports search, filtering, sorting, and switching between list and grid layouts. It loads 40 components at a time through infinite scrolling or the **Load more** button, and handles loading, error, and empty states. Recent catalogue responses are cached for one minute. Previous cards remain visible while new results load, and unchanged cards can skip rendering through memoization.
Expand Down Expand Up @@ -218,6 +234,12 @@ npm run test

See [frontend/src/test/README.md](frontend/src/test/README.md) for the test setup and covered scenarios.

The backend has automated tests using Vitest, covering the GraphQL resolvers and build publishing validation. They mock the database, so no running PostgreSQL is needed. Run them from `backend/server/`:

```bash
npm test
```

## Run the prototype locally

You need Node.js and npm installed. From the repository root, run:
Expand Down
6 changes: 5 additions & 1 deletion backend/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ PORT=3001
# Comma-separated list of browser origins allowed to read API responses, or * for any.
# On the VM this is the address the frontend is served from.
CORS_ORIGIN=http://it2810-15.idi.ntnu.no
# Number of reverse proxies in front of the backend. On the VM, Apache forwards requests to the
# backend, so this must be 1 for the rate limit to count each visitor separately by IP address.
TRUST_PROXY=1

# --- Running backend and frontend locally, with the database on the VM ---
# Needs NTNU network or VPN, and the VM's Postgres must accept outside connections.
# Use these two lines in your own .env instead of the ones above:
# Use these three lines in your own .env instead of the ones above (there is no proxy locally):
# DATABASE_URL=postgresql://buildapc:<password>@it2810-15.idi.ntnu.no:5432/buildapc
# CORS_ORIGIN=http://localhost:5173
# TRUST_PROXY=0
16 changes: 15 additions & 1 deletion backend/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"cors": "^2.8.6",
"dataloader": "^2.2.3",
"dotenv": "^18.0.7",
"express": "^5.3.0",
"graphql": "^16.14.2",
"pg": "^8.23.1"
"pg": "^8.23.1",
"rate-limiter-flexible": "^11.4.1"
},
"overrides": {
"minimatch": "^11.0.1"
Expand Down
11 changes: 11 additions & 0 deletions backend/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,15 @@ export const config = {
corsOrigins: (process.env["CORS_ORIGIN"] ?? "*")
.split(",")
.map((origin) => origin.trim()),
/** Number of reverse proxies (for example Apache) in front of the server. 0 means none. */
trustProxy: Number(process.env["TRUST_PROXY"] ?? 0),
limits: {
/** Build publishing: at most this many attempts per client IP in each time window. */
publishMax: 5,
publishWindowSeconds: 600,
/** GraphQL request limits. */
maxBodySize: "100kb",
maxQueryDepth: 8,
maxQueryFields: 100,
},
};
51 changes: 48 additions & 3 deletions backend/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,27 @@ import express from "express";
import path from "node:path";
import { config } from "./config.js";
import { pool } from "./db.js";
import { resolvers } from "./resolvers.js";
import { limitOperationSize } from "./limits.js";
import {
createBuildPartsLoader,
resolvers,
type Context,
} from "./resolvers.js";
import { typeDefs } from "./schema.js";

const app = express();
const apollo = new ApolloServer({ typeDefs, resolvers });
// Behind a reverse proxy, trust its forwarded address so each visitor is rate limited by their own IP.
app.set("trust proxy", config.trustProxy);
const apollo = new ApolloServer<Context>({
typeDefs,
resolvers,
validationRules: [
limitOperationSize({
maxDepth: config.limits.maxQueryDepth,
maxFields: config.limits.maxQueryFields,
}),
],
});
await apollo.start();

app.use(
Expand All @@ -32,7 +48,36 @@ app.get("/health", async (_req, res) => {
}
});

app.use("/graphql", express.json(), expressMiddleware(apollo));
app.use(
"/graphql",
express.json({ limit: config.limits.maxBodySize }),
expressMiddleware(apollo, {
context: async ({ req }) => ({
ip: req.ip ?? "unknown",
buildParts: createBuildPartsLoader(),
}),
}),
);

// Oversized request bodies are rejected by express.json before they reach GraphQL.
app.use(
(
error: { type?: string },
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (error.type !== "entity.too.large") return next(error);
res.status(413).json({
errors: [
{
message: "Request body too large",
extensions: { code: "PAYLOAD_TOO_LARGE" },
},
],
});
},
);

app.listen(config.port, () => {
console.log(
Expand Down
66 changes: 66 additions & 0 deletions backend/server/src/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
GraphQLError,
Kind,
type SelectionSetNode,
type ValidationRule,
} from "graphql";

interface Limits {
maxDepth: number;
maxFields: number;
}

/**
* Validation rule that rejects operations that are nested too deeply or select too many
* fields. Fragments are expanded, so they cannot be used to hide the real size, and
* introspection fields (__schema, __type) are not counted.
*/
export function limitOperationSize({
maxDepth,
maxFields,
}: Limits): ValidationRule {
return (context) => ({
OperationDefinition(operation) {
let fields = 0;
const spreading = new Set<string>();

// Returns an error message as soon as a limit is passed, so huge operations are cheap to reject.
function check(
selectionSet: SelectionSetNode,
depth: number,
): string | null {
for (const selection of selectionSet.selections) {
if (selection.kind === Kind.FIELD) {
if (selection.name.value.startsWith("__")) continue;
if (depth > maxDepth)
return `Query is nested too deeply (maximum depth is ${maxDepth}).`;
if (++fields > maxFields)
return `Query selects too many fields (maximum is ${maxFields}).`;
if (selection.selectionSet) {
const error = check(selection.selectionSet, depth + 1);
if (error) return error;
}
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
const error = check(selection.selectionSet, depth);
if (error) return error;
} else {
const name = selection.name.value;
const fragment = context.getFragment(name);
// Fragment cycles are reported by another rule; just do not loop here.
if (!fragment || spreading.has(name)) continue;
spreading.add(name);
const error = check(fragment.selectionSet, depth);
spreading.delete(name);
if (error) return error;
}
}
return null;
}

const message = check(operation.selectionSet, 1);
if (message) {
context.reportError(new GraphQLError(message, { nodes: [operation] }));
}
},
});
}
38 changes: 38 additions & 0 deletions backend/server/src/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GraphQLError } from "graphql";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { config } from "./config.js";

const publishLimiter = new RateLimiterMemory({
points: config.limits.publishMax,
duration: config.limits.publishWindowSeconds,
});

/**
* Counts one build publishing attempt for the client and throws a rate limit error
* once the client has used up its attempts. Must run before anything is written.
*/
export async function consumePublishAttempt(ip: string): Promise<void> {
try {
await publishLimiter.consume(ip);
} catch (rejection) {
// The limiter rejects with its result object when the limit is reached.
const retryAfter = Math.max(
1,
Math.ceil(
((rejection as { msBeforeNext?: number }).msBeforeNext ?? 0) / 1000,
),
);
throw new GraphQLError(
`Too many builds published. Try again in ${retryAfter} seconds.`,
{
extensions: {
code: "RATE_LIMITED",
http: {
status: 429,
headers: new Map([["Retry-After", String(retryAfter)]]),
},
},
},
);
}
}
69 changes: 52 additions & 17 deletions backend/server/src/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import DataLoader from "dataloader";
import { GraphQLError, GraphQLScalarType } from "graphql";
import path from "node:path";
import { pool } from "./db.js";
import { consumePublishAttempt } from "./rateLimit.js";

/** Per-request values made available to every resolver. */
export interface Context {
ip: string;
buildParts: ReturnType<typeof createBuildPartsLoader>;
}

const MAX_PAGE_SIZE = 100;

Expand Down Expand Up @@ -386,7 +394,10 @@ async function publishBuild(
}: {
input: { tittel: string; comment?: string | null; partIds: string[] };
},
{ ip }: Context,
) {
// Counted first, so blocked or invalid attempts never reach the database.
await consumePublishAttempt(ip);
const tittel = input.tittel.trim();
const comment = input.comment?.trim() || null;
const invalid = (message: string) =>
Expand Down Expand Up @@ -447,6 +458,42 @@ async function build(_: unknown, { id }: { id: string }) {
return rows[0] ?? null;
}

const PART_ID_COLUMNS = [
"cpu_id",
"gpu_id",
"ram_id",
"storage_id",
"motherboard_id",
"psu_id",
"case_id",
"cooler_id",
] as const;

/**
* Creates a loader for the parts of builds. All builds requested in the same tick are
* fetched with one query, instead of one query per build. Create one per request.
*/
export function createBuildPartsLoader() {
return new DataLoader<Record<string, unknown>, PartRow[], unknown>(
async (builds) => {
const idsOf = (build: Record<string, unknown>) =>
PART_ID_COLUMNS.map((column) => build[column]).filter(Boolean);
const allIds = [...new Set(builds.flatMap(idsOf))];
if (!allIds.length) return builds.map(() => []);

const { rows } = await pool.query<PartRow>(
`SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`,
[allIds],
);
return builds.map((build) => {
const ids = new Set(idsOf(build));
return rows.filter((part) => ids.has(part.id));
});
},
{ cacheKeyFn: (build) => build["id"] },
);
}

/** Passes arbitrary JSON (the specs column) through unchanged. */
const JSONScalar = new GraphQLScalarType({
name: "JSON",
Expand Down Expand Up @@ -489,23 +536,11 @@ export const resolvers = {
},
Build: {
createdAt: (row: { created_at: Date }) => row.created_at.toISOString(),
parts: async (row: Record<string, unknown>) => {
const ids = [
row.cpu_id,
row.gpu_id,
row.ram_id,
row.storage_id,
row.motherboard_id,
row.psu_id,
row.case_id,
row.cooler_id,
].filter(Boolean);
const { rows } = await pool.query<PartRow>(
`SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`,
[ids],
);
return rows;
},
parts: (
row: Record<string, unknown>,
_args: unknown,
{ buildParts }: Context,
) => buildParts.load(row),
},
Part: {
priceEur: (row: PartRow) => row.price_eur,
Expand Down
Loading