From e550457e326eeb0f1a4c1653ecac52f94a893e2d Mon Sep 17 00:00:00 2001 From: godavid Date: Fri, 23 Oct 2026 12:08:33 +0200 Subject: [PATCH 1/4] fix(api): reject calendar-invalid dates with 400 datum, since, until, tol and ig accepted shape-valid but non-existent days (2026-13-01, 2026-02-30, 2026-10-00) and picked a time state by string comparison. A shared ervenyesDatum() round-trip check now backs the REST/MCP zod schema and the API-level checks. Adds a node:test runner for apps/web (test files excluded from the Next typecheck). Fixes #28 Co-Authored-By: GLM 5.3 (z.ai) Co-Authored-By: Claude Opus 5.5 --- apps/web/lib/api/datum.test.ts | 23 +++++++++++++++++++++++ apps/web/lib/api/datum.ts | 14 ++++++++++++++ apps/web/lib/api/diff.ts | 6 ++++-- apps/web/lib/api/kozos.ts | 2 -- apps/web/lib/api/semak.ts | 6 +++++- apps/web/lib/api/szakasz.ts | 5 +++-- apps/web/lib/api/valtozasok.ts | 5 +++-- apps/web/package.json | 3 ++- apps/web/tsconfig.json | 3 ++- 9 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 apps/web/lib/api/datum.test.ts create mode 100644 apps/web/lib/api/datum.ts diff --git a/apps/web/lib/api/datum.test.ts b/apps/web/lib/api/datum.test.ts new file mode 100644 index 0000000..f55447a --- /dev/null +++ b/apps/web/lib/api/datum.test.ts @@ -0,0 +1,23 @@ +// `ervenyesDatum` egységteszt — a modul import nélküli tiszta függvény, ezért +// node:test-tel közvetlenül futtatható: node --test lib/api/datum.test.ts + +import test from "node:test"; +import assert from "node:assert/strict"; +import { ervenyesDatum } from "./datum.ts"; + +test("valódi naptári dátum igaz", () => { + assert.equal(ervenyesDatum("2026-10-01"), true); + assert.equal(ervenyesDatum("2024-02-29"), true); // szökőév +}); + +test("alakilag jó, de nem létező nap hamis", () => { + for (const d of ["2026-13-01", "2026-02-30", "2026-10-00", "2025-02-29", "2026-04-31"]) { + assert.equal(ervenyesDatum(d), false, d); + } +}); + +test("rossz alak hamis", () => { + for (const d of ["2024-2-1", "", "2026-10-01T00:00:00Z"]) { + assert.equal(ervenyesDatum(d), false, d); + } +}); diff --git a/apps/web/lib/api/datum.ts b/apps/web/lib/api/datum.ts new file mode 100644 index 0000000..f520b1c --- /dev/null +++ b/apps/web/lib/api/datum.ts @@ -0,0 +1,14 @@ +// Dátum-ellenőrzés: az alak (YYYY-MM-DD) mellett a naptárban is létező nap +// kell — a „2026-02-30" alakilag jó, de nem valódi dátum. Nincs import, hogy +// tiszta függvényként tesztelhető legyen. + +const DATUM_MINTA = /^\d{4}-\d{2}-\d{2}$/; + +/** igaz, ha `d` YYYY-MM-DD alakú és valódi naptári dátum */ +export function ervenyesDatum(d: string): boolean { + if (!DATUM_MINTA.test(d)) return false; + // UTC oda-vissza alakítás: a túlcsorduló napot (febr. 30) a Date átrollozza, + // a képtelen hónapot Invalid Date-ként adja — mindkettő elbukik az összehasonlításon. + const t = new Date(`${d}T00:00:00Z`); + return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === d; +} diff --git a/apps/web/lib/api/diff.ts b/apps/web/lib/api/diff.ts index ba074f0..1b07a03 100644 --- a/apps/web/lib/api/diff.ts +++ b/apps/web/lib/api/diff.ts @@ -8,7 +8,8 @@ import { type SzakaszValtozas, } from "@gitjog/szoveg"; import { getAllapotokSlug, getSzovegAt } from "@/lib/adat"; -import { ApiHiba, DATUM_MINTA, diffUrl, jogszabalyMeta, jogszabalyVagy404, MEGJEGYZES } from "./kozos"; +import { ApiHiba, diffUrl, jogszabalyMeta, jogszabalyVagy404, MEGJEGYZES } from "./kozos"; +import { ervenyesDatum } from "./datum"; export interface DiffParam { slug: string; @@ -37,7 +38,8 @@ export async function diffApi(p: DiffParam): Promise { ["tol", p.tol], ["ig", p.ig], ] as const) { - if (!DATUM_MINTA.test(ertek)) throw new ApiHiba(400, `A \`${mezo}\` alakja YYYY-MM-DD`, mezo); + if (!ervenyesDatum(ertek)) + throw new ApiHiba(400, `A \`${mezo}\` alakja YYYY-MM-DD (valódi naptári dátum)`, mezo); } if (p.tol >= p.ig) throw new ApiHiba(400, "A `tol` legyen korábbi az `ig`-nél", "tol"); diff --git a/apps/web/lib/api/kozos.ts b/apps/web/lib/api/kozos.ts index a55556c..2ab3e19 100644 --- a/apps/web/lib/api/kozos.ts +++ b/apps/web/lib/api/kozos.ts @@ -94,8 +94,6 @@ export function jogszabalyMeta(t: JogszabalyTetel): JogszabalyMeta { }; } -export const DATUM_MINTA = /^\d{4}-\d{2}-\d{2}$/; - /** legfeljebb `n` párhuzamos feldolgozás — a raw GitHub 429-e ellen */ export async function korlatozottParhuzam( tetelek: T[], diff --git a/apps/web/lib/api/semak.ts b/apps/web/lib/api/semak.ts index e98aac8..42e4e1d 100644 --- a/apps/web/lib/api/semak.ts +++ b/apps/web/lib/api/semak.ts @@ -2,6 +2,7 @@ // REST query-string mindent szövegként — ezért két alak, közös leírásokkal. import { z } from "zod"; +import { ervenyesDatum } from "./datum"; import { KORLAT } from "./kozos"; const L = { @@ -31,7 +32,10 @@ const MAX = 200; const szoveg = () => z.string().max(MAX); const cursor = z.string().max(40); -const datum = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD"); +const datum = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD") + .refine(ervenyesDatum, "valódi naptári dátum"); const restBool = z .enum(["true", "false", "1", "0"]) .transform((v) => v === "true" || v === "1"); diff --git a/apps/web/lib/api/szakasz.ts b/apps/web/lib/api/szakasz.ts index b4f8ccf..e5b684f 100644 --- a/apps/web/lib/api/szakasz.ts +++ b/apps/web/lib/api/szakasz.ts @@ -3,9 +3,9 @@ import { paragrafusKivag, szakaszKeres, szakaszokraBont } from "@gitjog/szoveg"; import { getAllapotokSlug, getSzoveg, getSzovegAt, type Allapot } from "@/lib/adat"; +import { ervenyesDatum } from "./datum"; import { ApiHiba, - DATUM_MINTA, jogszabalyMeta, jogszabalyUrl, jogszabalyVagy404, @@ -46,7 +46,8 @@ export interface JogszabalyValasz extends JogszabalyMeta { export function idoallapotValaszt(allapotok: Allapot[], datum?: string): Allapot { if (allapotok.length === 0) throw new ApiHiba(404, "A jogszabálynak nincs időállapota"); if (!datum) return allapotok.at(-1)!; - if (!DATUM_MINTA.test(datum)) throw new ApiHiba(400, "A `datum` alakja YYYY-MM-DD", "datum"); + if (!ervenyesDatum(datum)) + throw new ApiHiba(400, "A `datum` alakja YYYY-MM-DD (valódi naptári dátum)", "datum"); const talalt = [...allapotok].reverse().find((a) => a.datum <= datum); if (!talalt) { throw new ApiHiba( diff --git a/apps/web/lib/api/valtozasok.ts b/apps/web/lib/api/valtozasok.ts index 9a202cc..bd94790 100644 --- a/apps/web/lib/api/valtozasok.ts +++ b/apps/web/lib/api/valtozasok.ts @@ -18,7 +18,6 @@ import { import { ApiHiba, csonkol, - DATUM_MINTA, diffUrl, jogszabalyTerkep, jogszabalyUrl, @@ -27,6 +26,7 @@ import { MEGJEGYZES, normalizal, } from "./kozos"; +import { ervenyesDatum } from "./datum"; export interface ValtozasokParam { felveve_utan?: string; @@ -188,7 +188,8 @@ export async function valtozasokApi(p: ValtozasokParam): Promise Date: Fri, 23 Oct 2026 12:08:33 +0200 Subject: [PATCH 2/4] fix(api): short CDN cache for szakasz with today or future datum Only a datum before today (UTC) is final and keeps s-maxage 86400; today or later gets 900 s like valtozasok, so the daily delta shows up within minutes. Fixes #27 Co-Authored-By: GLM 5.3 (z.ai) Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/v1/szakasz/route.ts | 6 ++++-- apps/web/lib/api/cache-ido.test.ts | 24 ++++++++++++++++++++++++ apps/web/lib/api/cache-ido.ts | 12 ++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/api/cache-ido.test.ts create mode 100644 apps/web/lib/api/cache-ido.ts diff --git a/apps/web/app/api/v1/szakasz/route.ts b/apps/web/app/api/v1/szakasz/route.ts index 8c2d8f3..2011ba3 100644 --- a/apps/web/app/api/v1/szakasz/route.ts +++ b/apps/web/app/api/v1/szakasz/route.ts @@ -2,8 +2,10 @@ import { szakaszApi } from "@/lib/api/szakasz"; import { REST } from "@/lib/api/semak"; import { apiRoute } from "@/lib/api/valasz"; +import { szakaszCacheMp } from "@/lib/api/cache-ido"; export const dynamic = "force-dynamic"; -// konkrét dátum = múltbeli, változatlan állapot → egy napig cache-elhető -export const GET = apiRoute(REST.szakasz, szakaszApi, (p) => (p.datum ? 86_400 : 3_600)); +// csak a mai napnál korábbi dátum végleges → egy napig cache-elhető; a mai/jövőbeli +// datumot még érintheti a napi delta, ezért rövid cache (lásd cache-ido.ts) +export const GET = apiRoute(REST.szakasz, szakaszApi, (p) => szakaszCacheMp(p.datum)); diff --git a/apps/web/lib/api/cache-ido.test.ts b/apps/web/lib/api/cache-ido.test.ts new file mode 100644 index 0000000..d9308fb --- /dev/null +++ b/apps/web/lib/api/cache-ido.test.ts @@ -0,0 +1,24 @@ +// `szakaszCacheMp` egységteszt — a modul import nélküli tiszta függvény, ezért +// node:test-tel közvetlenül futtatható: node --test lib/api/cache-ido.test.ts + +import test from "node:test"; +import assert from "node:assert/strict"; +import { szakaszCacheMp } from "./cache-ido.ts"; + +test("datum nélkül közepes cache", () => { + assert.equal(szakaszCacheMp(undefined, "2026-10-23"), 3_600); +}); + +test("múltbeli datum egy napig cache-elhető", () => { + assert.equal(szakaszCacheMp("2026-10-22", "2026-10-23"), 86_400); + assert.equal(szakaszCacheMp("2024-01-01", "2026-10-23"), 86_400); +}); + +test("mai datum rövid cache — a napi delta még módosíthatja", () => { + assert.equal(szakaszCacheMp("2026-10-23", "2026-10-23"), 900); +}); + +test("jövőbeli datum rövid cache", () => { + assert.equal(szakaszCacheMp("2026-10-24", "2026-10-23"), 900); + assert.equal(szakaszCacheMp("2027-01-01", "2026-10-23"), 900); +}); diff --git a/apps/web/lib/api/cache-ido.ts b/apps/web/lib/api/cache-ido.ts new file mode 100644 index 0000000..858dbda --- /dev/null +++ b/apps/web/lib/api/cache-ido.ts @@ -0,0 +1,12 @@ +// Szakasz-válasz cache-ideje (mp). Csak a mai napnál korábbi datum végleges +// (napi delta már nem érinti) → egy napig cache-elhető; a mai vagy jövőbeli +// datumra a napi delta még módosíthat, ezért ugyanaz a rövid 15 perc, +// amit a valtozasok route is használ. + +export function szakaszCacheMp( + datum: string | undefined, + ma: string = new Date().toISOString().slice(0, 10), +): number { + if (!datum) return 3_600; + return datum < ma ? 86_400 : 900; +} From 439a12a0bd143e3c3ff3690efaa4e4e00db559b0 Mon Sep 17 00:00:00 2001 From: godavid Date: Fri, 23 Oct 2026 12:08:33 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(szoveg):=20accept=20a=20paragraph=20ref?= =?UTF-8?q?erence=20without=20a=20dot=20before=20=C2=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit szam() required the dot, so "18 §" returned 404 on heading-less laws while hivatkozas.ts already treats the dot as optional. Fixes #29 Co-Authored-By: GLM 5.3 (z.ai) Co-Authored-By: Claude Opus 5.5 --- packages/szoveg/src/paragrafus.ts | 4 ++-- packages/szoveg/test/paragrafus.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/szoveg/src/paragrafus.ts b/packages/szoveg/src/paragrafus.ts index 9471718..f0abbab 100644 --- a/packages/szoveg/src/paragrafus.ts +++ b/packages/szoveg/src/paragrafus.ts @@ -20,9 +20,9 @@ export interface ParagrafusReszlet { const PARAGRAFUS_SOR = /^(\d+(?::\d+)?(?:\/[A-Z])?)\. §(?=\s|$)/; const HEADING = /^#{2,4} (.+)$/; -/** „18. §" → „18"; a szövegbeli sor elejével vetjük össze */ +/** „18. §", „18 §" → „18"; a szövegbeli sor elejével vetjük össze */ function szam(paragrafus: string): string { - return paragrafus.replace(/\.\s*§\s*$/, "").trim().toUpperCase(); + return paragrafus.replace(/\.?\s*§\s*$/, "").trim().toUpperCase(); } export function paragrafusKivag(md: string, paragrafus: string): ParagrafusReszlet | undefined { diff --git a/packages/szoveg/test/paragrafus.test.ts b/packages/szoveg/test/paragrafus.test.ts index 1ca00b7..29954e1 100644 --- a/packages/szoveg/test/paragrafus.test.ts +++ b/packages/szoveg/test/paragrafus.test.ts @@ -29,6 +29,13 @@ describe("paragrafusKivag", () => { expect(paragrafusKivag(MD, "18/A. §")?.szoveg).toBe("18/A. § Betűs paragrafus."); expect(paragrafusKivag(MD, "20. §")?.horgony).toBe("7-hatosagi-jovahagyas"); }); + it("a § előtti pont opcionális: „18 §” és „18.§” ugyanaz, mint „18. §”", () => { + const kanonikus = paragrafusKivag(MD, "18. §")?.szoveg; + expect(kanonikus?.startsWith("18. § (1) A föld eladása")).toBe(true); + expect(paragrafusKivag(MD, "18 §")?.szoveg).toBe(kanonikus); + expect(paragrafusKivag(MD, "18.§")?.szoveg).toBe(kanonikus); + expect(paragrafusKivag(MD, "18/A §")?.szoveg).toBe("18/A. § Betűs paragrafus."); + }); it("hiányzó §-ra undefined; a 1. § nem illeszkedik a 18. §-ra", () => { expect(paragrafusKivag(MD, "1. §")).toBeUndefined(); expect(paragrafusKivag(MD, "8. §")).toBeUndefined(); From d85942bbd0c7e3d4b021f5d84ec6b77fb5706e1e Mon Sep 17 00:00:00 2001 From: godavid Date: Fri, 23 Oct 2026 16:12:51 +0200 Subject: [PATCH 4/4] fix(szoveg): parse the paragraph number without a polynomial regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL js/polynomial-redos flagged szam(): the optional dot made /\.?\s*§\s*$/ quadratic on inputs with many spaces. Strip the trailing § and dot with string operations instead; a 100k-space input now runs in linear time. Co-Authored-By: Claude Opus 5.5 --- packages/szoveg/src/paragrafus.ts | 12 ++++++++++-- packages/szoveg/test/paragrafus.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/szoveg/src/paragrafus.ts b/packages/szoveg/src/paragrafus.ts index f0abbab..c96c77d 100644 --- a/packages/szoveg/src/paragrafus.ts +++ b/packages/szoveg/src/paragrafus.ts @@ -20,9 +20,17 @@ export interface ParagrafusReszlet { const PARAGRAFUS_SOR = /^(\d+(?::\d+)?(?:\/[A-Z])?)\. §(?=\s|$)/; const HEADING = /^#{2,4} (.+)$/; -/** „18. §", „18 §" → „18"; a szövegbeli sor elejével vetjük össze */ +/** + * „18. §", „18 §" → „18"; a szövegbeli sor elejével vetjük össze. Regex helyett + * sztringművelet: a `\.?\s*§` minta sok szóközön polinomiális (ReDoS). + */ function szam(paragrafus: string): string { - return paragrafus.replace(/\.?\s*§\s*$/, "").trim().toUpperCase(); + let s = paragrafus.trim(); + if (s.endsWith("§")) { + s = s.slice(0, -1).trimEnd(); + if (s.endsWith(".")) s = s.slice(0, -1); + } + return s.trim().toUpperCase(); } export function paragrafusKivag(md: string, paragrafus: string): ParagrafusReszlet | undefined { diff --git a/packages/szoveg/test/paragrafus.test.ts b/packages/szoveg/test/paragrafus.test.ts index 29954e1..532f526 100644 --- a/packages/szoveg/test/paragrafus.test.ts +++ b/packages/szoveg/test/paragrafus.test.ts @@ -36,6 +36,12 @@ describe("paragrafusKivag", () => { expect(paragrafusKivag(MD, "18.§")?.szoveg).toBe(kanonikus); expect(paragrafusKivag(MD, "18/A §")?.szoveg).toBe("18/A. § Betűs paragrafus."); }); + it("sok szóközös bemenet lineáris időben fut (nincs ReDoS)", () => { + const kezdet = performance.now(); + expect(paragrafusKivag(MD, `18${" ".repeat(100_000)}x`)).toBeUndefined(); + expect(paragrafusKivag(MD, `18${" ".repeat(100_000)}§`)?.paragrafus).toBe("18. §"); + expect(performance.now() - kezdet).toBeLessThan(500); + }); it("hiányzó §-ra undefined; a 1. § nem illeszkedik a 18. §-ra", () => { expect(paragrafusKivag(MD, "1. §")).toBeUndefined(); expect(paragrafusKivag(MD, "8. §")).toBeUndefined();