diff --git a/.dockerignore b/.dockerignore index 02eaf28..a5774c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,6 @@ README.md !.git/packed-refs !.git/refs/heads/** **/backups +# Nothing in the images comes from the e2e project, and the .NET stage does a `COPY . .` — leaving +# it in would invalidate that layer every time a spec changes. +e2e diff --git a/.env.example b/.env.example index 18955cc..f406d6b 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,27 @@ ANALYTICS_HASH_SECRET=change-me-to-a-random-string # How long page views are kept, in days. 0 keeps everything (the table then grows forever). ANALYTICS_RETENTION_DAYS=365 +# Request-budget overrides for the rate limiter. LEAVE THESE AT 0 IN PRODUCTION — 0 means the limit +# each host has built in (240 per client per minute on the public site, 300 on the admin, 60 per +# client and path on both), and those are the values the live site is tuned for. +# +# They exist for an environment that does NOT have Cloudflare in front of it. In production the edge +# caches the hashed assets, so the origin sees one document plus a handful of API calls per visit. +# Without that cache every page load costs the origin the full asset set, and a browser-driven test +# suite of a couple of dozen pages crosses the budget inside a single window — which shows up as a +# 429 on a chunk request and a blank SPA, i.e. a failure that looks like anything except a rate +# limit. The e2e job in ci.yml raises them for exactly that reason. +RATE_LIMIT_PER_CLIENT=0 +RATE_LIMIT_PER_PATH=0 + +# Whether the two SPAs' error reports (POST /api/activity/error) are written to the log. Both hosts +# serve the endpoint, and both read this variable. There is nothing to size — a report goes straight +# into the Serilog stream, so `docker compose logs web | grep "Client error"` is the whole readout. +# Set it to false to silence the endpoint without building a new image: the reporter caps itself at +# five reports per page load and the endpoint at ten per client per minute, but a bug that fires on +# every page load across every visitor can still be more log than the 30 MB rotation window holds. +CLIENT_ERRORS_ENABLED=true + # Grace period in days before an attachment no content references is deleted. The image is # uploaded when it is picked but only becomes referenced when the form is saved, so the grace # period is what keeps an open editing session safe. 0 disables the sweep, and every replaced diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4e89de..927de07 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,6 +39,18 @@ updates: patterns: ["*"] update-types: ["major"] + # Az e2e csomag külön bejegyzés, nem a fenti csoportban: nem kerül belőle semmi egyik appba sem, + # tehát a check-dep-sync.mjs sem nézi, és nincs ok hozzákötni a három frontend léptetéséhez. A + # Playwright verziója viszont fontos — a böngésző-letöltés és a csomag verziója együtt jár. + - package-ecosystem: npm + directory: "/e2e" + schedule: + interval: weekly + day: monday + groups: + e2e: + patterns: ["*"] + # A workflow action verziók (actions/checkout, appleboy/ssh-action, docker/*). - package-ecosystem: github-actions directory: "/" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2cf2b2..80d2e0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,118 @@ jobs: working-directory: portfoliocms.shared run: npm test + e2e: + name: E2E (Playwright) + runs-on: ubuntu-latest + # A compose a fájl EGÉSZÉT interpolálja, tehát a `:?` változóknak akkor is kell érték, ha az + # adott szolgáltatás nem indul el — ezért van itt CF_ACCESS_* is, pedig az admin nem indul. + # Az admin folyamataihoz Cloudflare Access assertion kellene, az az első körből kimarad. + env: + COMPOSE_FILE: docker-compose.yml + POSTGRES_PASSWORD: e2e-postgres + # A publikus host nem indul el 16 byte alatti sóval. + ANALYTICS_HASH_SECRET: e2e-analytics-hash-secret-32-byte + CF_ACCESS_TEAM_DOMAIN: https://e2e.cloudflareaccess.example + CF_ACCESS_AUD: e2e-not-used + # A Cloudflare mindig-átmenő Turnstile teszt kulcspárja: a widget magát megoldja, a + # szerveroldali siteverify pedig elfogadja — így a kapcsolati űrlap teljes köre végigfut + # interakció nélkül. Ez a két érték nyilvános, a Cloudflare dokumentációjából van. + TURNSTILE_SITE_KEY: 1x00000000000000000000AA + TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA + # A demo tartalom, hogy a tesztek ismert slugokra állhassanak (PortfolioCMS.Core/Data/SeedData.cs). + SEED_MODE: dev + PUBLIC_SITE_URL: http://127.0.0.1:8080 + # A compose WEB_IMAGE defaultja, tehát a lentebb betöltött image-et használja build nélkül. + WEB_IMAGE: portfoliocms:local + # Itt nincs Cloudflare az origin előtt, tehát minden teszt friss böngésző-context-tel a teljes + # asset-készletet lekéri az origintől — a 240/perc default egy ablakon belül elfogy, és a + # SPA egy 429-elt chunkon hasal el. Ez volt az első futás theme-flake-je. Lásd RateLimitOptions. + RATE_LIMIT_PER_CLIENT: "20000" + RATE_LIMIT_PER_PATH: "5000" + steps: + - uses: actions/checkout@v4 + + # A cache csak akkor kapcsol be, ha van commitolt lock: a setup-node npm cache-e lock fájlra + # hasít kulcsot, és üres `cache` bemenet mellett egyszerűen nem gyorsítótáraz. Ez a két lépés + # így magától átáll, amint egy `npm install` az e2e/-ben elkészíti a lockot — a workflowt + # nem kell hozzá szerkeszteni. A futás nagyobb részét amúgy is a böngésző-letöltés adja. + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: ${{ hashFiles('e2e/package-lock.json') != '' && 'npm' || '' }} + cache-dependency-path: e2e/package-lock.json + + - name: Csomagok + working-directory: e2e + run: | + if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi + + # A Playwright esbuilddel fordítja a specekt és egyáltalán nem típusellenőriz, tehát egy spec + # lehet típus-értelmetlen és mégis lefut. Ez a lépés az, ami ezt megfogja — és a stack + # felhúzása előtt, mert egy fordítási hibáért nem érdemes konténereket indítani. + - name: Típusellenőrzés + working-directory: e2e + run: npm run type-check + + # Csak chromium: a suite a szerver és az SPA közötti illesztéseket nézi, nem a böngészők + # közötti különbségeket — három motor megháromszorozná a futást egy másik kérdésért. + - name: Playwright böngésző + working-directory: e2e + run: npx playwright install --with-deps chromium + + - uses: docker/setup-buildx-action@v3 + + # A `build` job cache-éből épül (ugyanaz a scope), de nem ír bele: két job ugyanabba a + # scope-ba írva egymást fejelné le. Deployból hívva a build job kimarad, ilyenkor a cache az, + # amit az utolsó PR hagyott — legrosszabb esetben hideg build, nem hiba. + - name: Web image + uses: docker/build-push-action@v6 + with: + context: . + file: PortfolioCMS.Server/Dockerfile + push: false + load: true + tags: portfoliocms:local + cache-from: type=gha,scope=web + + # Csak a web (és a depends_on miatt a db + redis). Az `--wait` a healthcheckre vár, tehát az + # `up -d` önmagában semmit nem bizonyító visszatérése helyett itt hasal el egy nem induló host. + - name: Stack indítása + run: docker compose up -d --wait --wait-timeout 180 web + + - name: E2E + working-directory: e2e + run: npm test + + # A ContentCache fail-open ága: Redis nélkül is ki kell szolgálni a tartalmat. Ezt egy spec + # nem teheti meg magával anélkül, hogy a suite többi részét eltörné, ezért a konténer itt áll + # meg, és a @needs-outage tesztek külön futásban mennek. + - name: E2E cache nélkül + run: | + docker compose stop redis + cd e2e && npm run test:outage + + - name: Redis vissza + if: always() + run: docker compose start redis + + # Enélkül a CI-ban elhasalt e2e nem debuggolható: a trace tartalmazza a DOM-ot, a hálózati + # naplót és a képernyőképeket a hiba pillanatában. + - name: Trace és képernyőképek + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + retention-days: 7 + if-no-files-found: ignore + + - name: Konténer logok + if: failure() + run: docker compose logs --tail=300 + nuget-audit: name: NuGet sebezhetőségek runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9be432b..8434b99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -100,6 +100,7 @@ jobs: SEED_MODE: ${{ secrets.SEED_MODE }} ALLOWED_HOSTS: ${{ secrets.ALLOWED_HOSTS }} ANALYTICS_RETENTION_DAYS: ${{ secrets.ANALYTICS_RETENTION_DAYS }} + CLIENT_ERRORS_ENABLED: ${{ secrets.CLIENT_ERRORS_ENABLED }} ATTACHMENT_ORPHAN_GRACE_DAYS: ${{ secrets.ATTACHMENT_ORPHAN_GRACE_DAYS }} CONTACT_RETENTION_DAYS: ${{ secrets.CONTACT_RETENTION_DAYS }} CONTACT_KEEP_UNREAD: ${{ secrets.CONTACT_KEEP_UNREAD }} @@ -117,7 +118,7 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} port: ${{ secrets.SSH_PORT }} - envs: DEPLOY_SHA,WEB_IMAGE,ADMIN_IMAGE,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD,ANALYTICS_HASH_SECRET,ANALYTICS_RETENTION_DAYS,ATTACHMENT_ORPHAN_GRACE_DAYS,CONTACT_RETENTION_DAYS,CONTACT_KEEP_UNREAD,PUBLIC_SITE_URL,SEED_MODE,ALLOWED_HOSTS,CF_ACCESS_TEAM_DOMAIN,CF_ACCESS_AUD,TURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEY,BACKUP_RETENTION_DAYS,BACKUP_AGE_RECIPIENT,BACKUP_RCLONE_REMOTE,GHCR_USER,GHCR_TOKEN + envs: DEPLOY_SHA,WEB_IMAGE,ADMIN_IMAGE,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD,ANALYTICS_HASH_SECRET,ANALYTICS_RETENTION_DAYS,CLIENT_ERRORS_ENABLED,ATTACHMENT_ORPHAN_GRACE_DAYS,CONTACT_RETENTION_DAYS,CONTACT_KEEP_UNREAD,PUBLIC_SITE_URL,SEED_MODE,ALLOWED_HOSTS,CF_ACCESS_TEAM_DOMAIN,CF_ACCESS_AUD,TURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEY,BACKUP_RETENTION_DAYS,BACKUP_AGE_RECIPIENT,BACKUP_RCLONE_REMOTE,GHCR_USER,GHCR_TOKEN script: | set -e cd /opt/portfoliocms @@ -138,6 +139,7 @@ jobs: SEED_MODE=${SEED_MODE} ALLOWED_HOSTS=${ALLOWED_HOSTS:-*} ANALYTICS_RETENTION_DAYS=${ANALYTICS_RETENTION_DAYS:-365} + CLIENT_ERRORS_ENABLED=${CLIENT_ERRORS_ENABLED:-true} ATTACHMENT_ORPHAN_GRACE_DAYS=${ATTACHMENT_ORPHAN_GRACE_DAYS:-7} CONTACT_RETENTION_DAYS=${CONTACT_RETENTION_DAYS:-365} CONTACT_KEEP_UNREAD=${CONTACT_KEEP_UNREAD:-true} diff --git a/PortfolioCMS.Admin/Dockerfile b/PortfolioCMS.Admin/Dockerfile index d789084..e401e81 100644 --- a/PortfolioCMS.Admin/Dockerfile +++ b/PortfolioCMS.Admin/Dockerfile @@ -38,6 +38,8 @@ COPY . . # Into wwwroot before the publish, not into the published output after it: MapStaticAssets serves # from the build-time asset manifest, so anything copied in later would 404. COPY --from=client /src/portfoliocms.admin.client/dist ./PortfolioCMS.Admin/wwwroot +# The maps are emitted but not shipped — see the same step in PortfolioCMS.Server/Dockerfile. +RUN find ./PortfolioCMS.Admin/wwwroot -name '*.map' -type f -delete RUN dotnet publish "./PortfolioCMS.Admin/PortfolioCMS.Admin.csproj" \ -c $BUILD_CONFIGURATION --no-restore -p:IncludeSpa=false -p:UseAppHost=false -o /app/publish diff --git a/PortfolioCMS.Admin/Program.cs b/PortfolioCMS.Admin/Program.cs index f70f93f..6086f33 100644 --- a/PortfolioCMS.Admin/Program.cs +++ b/PortfolioCMS.Admin/Program.cs @@ -52,9 +52,26 @@ client => client.Timeout = TimeSpan.FromSeconds(10)); builder.Services.AddSingleton(); + // Client-side error reports from the dashboard SPA (ClientErrorsController, in Core so both + // hosts serve it — the admin app reports to its own origin). + builder.Services.Configure( + builder.Configuration.GetSection(ClientErrorOptions.SectionName)); + + // Only an environment without Cloudflare in front of it needs to raise these; unset means the + // default below. See RateLimitOptions. + var rateLimits = builder.Configuration.GetSection(RateLimitOptions.SectionName) + .Get() ?? new RateLimitOptions(); + // The dashboard loads several endpoints per screen, so the aggregate budget is higher here // than on the public site. The chain itself is shared — see RateLimitPolicies. - builder.Services.AddRateLimiter(options => options.UseChainedGlobalLimiter(300)); + builder.Services.AddRateLimiter(options => + { + options.UseChainedGlobalLimiter(300, rateLimits); + + // Required here too: the endpoint is served by this host as well, and a policy the host + // never registered throws when the endpoint runs, not at startup. + options.AddClientErrorPolicy(); + }); var app = builder.Build(); diff --git a/PortfolioCMS.Admin/appsettings.json b/PortfolioCMS.Admin/appsettings.json index f789eb9..5af5531 100644 --- a/PortfolioCMS.Admin/appsettings.json +++ b/PortfolioCMS.Admin/appsettings.json @@ -13,6 +13,13 @@ "Redis": "" }, "SeedMode": "prod", + "RateLimit": { + "PerClientPermitLimit": 0, + "PerPathPermitLimit": 0 + }, + "ClientErrors": { + "Enabled": true + }, "Attachments": { "OrphanGraceDays": 7 }, diff --git a/PortfolioCMS.Core/Controllers/ClientErrorsController.cs b/PortfolioCMS.Core/Controllers/ClientErrorsController.cs new file mode 100644 index 0000000..10e718c --- /dev/null +++ b/PortfolioCMS.Core/Controllers/ClientErrorsController.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using PortfolioCMS.Core.Dtos; +using PortfolioCMS.Core.Extensions; +using PortfolioCMS.Core.Services; + +namespace PortfolioCMS.Core.Controllers; + +/// +/// Ingestion for client-side failures. The server side has always had error tracking (Serilog, the +/// request log, an the visitor can quote); the browser side had none, so a +/// JavaScript exception, a chunk that failed to load or a mishandled API response left no trace +/// anywhere. This writes them into the same Serilog stream, which is why there is no new service +/// and no new storage: docker compose logs is where they were always going to be read. +/// +/// In this assembly and not in PortfolioCMS.Server, so BOTH hosts serve it. The dashboard is a +/// separate origin; having it post to the public host would need CORS there and a widened +/// connect-src in the admin CSP, in exchange for nothing — an admin-side exception is +/// exactly as invisible today as a public one. +/// +/// +/// Route prefix is "activity" for the same reason the view beacon's is: adblocker filter lists +/// (EasyPrivacy) block URL patterns that look like telemetry, and the report that gets blocked is +/// the one from the visitors most likely to be hitting a bug. +/// +/// +[ApiController] +[Route("api/activity")] +public class ClientErrorsController( + IOptions options, + ILogger logger) : ControllerBase +{ + /// + /// What the reporter is allowed to say. Anything else is recorded as "other" rather than + /// rejected: this field decides how a log line reads, not whether it is written. + /// + private static readonly HashSet Kinds = + ["error", "unhandledrejection", "vue", "chunk", "api"]; + + // Clamped here rather than by validation attributes: the caller is a browser on a + // fire-and-forget path that never reads the response, so a 400 would only lose the report. The + // limits match the reporter's own — those are for the honest client, these for the other kind. + private const int MaxMessage = 300; + private const int MaxSource = 300; + private const int MaxStack = 2000; + private const int MaxPath = 200; + private const int MaxTraceId = 64; + private const int MaxApp = 16; + + [HttpPost("error")] + [EnableRateLimiting(RateLimitPolicies.ClientErrorPolicy)] + public IActionResult RecordError(RecordClientErrorRequest request) + { + // 204 in every case below, the dropped ones included. The caller cannot act on a verdict + // and must not retry, and a distinguishable refusal would tell a prober what the filters + // are. Same contract as the view beacon. + if (!options.Value.Enabled) return NoContent(); + + // Bots run the bundle too, and at scale they are most of the volume — a headless crawler's + // exception is not a visitor's bug report. Same test the view beacon uses. + if (BotDetection.IsBot(Request.Headers.UserAgent)) return NoContent(); + + var message = Clamp(request.Message, MaxMessage); + if (message.Length == 0) return NoContent(); + + var kind = request.Kind is not null && Kinds.Contains(request.Kind) ? request.Kind : "other"; + var app = Clamp(request.App, MaxApp); + + // Warning and not Error: a client-side exception is a real defect, but it is not this + // process failing, and Error is the level an alert would key on. + // + // The trace id is spelled out in the message text and not only attached as a property, for + // the same reason ErrorId is: `docker compose logs | grep ` has to find both this line + // and the server-side line for the request that failed. + logger.LogWarning( + "Client error [{Kind}] in the {App} app at {ClientPath} — {ClientMessage} " + + "(source {ClientSource}:{ClientLine}:{ClientColumn}, traceId {ClientTraceId}) " + + "stack: {ClientStack}", + kind, + app.Length > 0 ? app : "unknown", + Clamp(request.Path, MaxPath), + message, + Clamp(request.Source, MaxSource), + request.Line ?? 0, + request.Column ?? 0, + Clamp(request.TraceId, MaxTraceId), + Clamp(request.Stack, MaxStack)); + + return NoContent(); + } + + /// + /// Trims to one line and caps the length. Every field of a report is attacker-controlled text, + /// and a line break in the middle of one is a forged second log entry — so the breaks become + /// " | " instead of being kept or the value rejected. That separator is also what keeps a stack + /// trace's frames apart, which is the one field where the breaks carried meaning. + /// + private static string Clamp(string? value, int max) + { + if (string.IsNullOrWhiteSpace(value)) return ""; + + var flattened = string.Join(" | ", value.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + // Tabs and anything else non-printable would do the same to a log line as a newline. + var cleaned = string.Create(flattened.Length, flattened, static (span, source) => + { + for (var i = 0; i < source.Length; i++) + { + span[i] = char.IsControl(source[i]) ? ' ' : source[i]; + } + }); + + return cleaned.Length > max ? cleaned[..max] : cleaned; + } +} diff --git a/PortfolioCMS.Core/Dtos/AnalyticsDtos.cs b/PortfolioCMS.Core/Dtos/AnalyticsDtos.cs index fb9f402..7381d01 100644 --- a/PortfolioCMS.Core/Dtos/AnalyticsDtos.cs +++ b/PortfolioCMS.Core/Dtos/AnalyticsDtos.cs @@ -4,6 +4,27 @@ namespace PortfolioCMS.Core.Dtos; public record RecordViewRequest([Required] string Section, string? Slug, string? Lang); +/// +/// One client-side failure, as portfoliocms.shared/utils/errorReporter.ts sends it. +/// +/// Every field is nullable and none carries a validation attribute, unlike +/// . That is deliberate: this arrives on a fire-and-forget path that +/// never reads the response, so a 400 does not correct anything — it only loses the report, and the +/// reports most worth having are the ones from a client already misbehaving. The endpoint validates +/// and clamps every field itself and answers 204 either way. +/// +/// +public record RecordClientErrorRequest( + string? Kind, + string? Message, + string? Source, + int? Line, + int? Column, + string? Stack, + string? Path, + string? App, + string? TraceId); + public record CountPair(int Views, int Uniques); public record AnalyticsSummaryResponse( diff --git a/PortfolioCMS.Core/Extensions/HostPipelineExtensions.cs b/PortfolioCMS.Core/Extensions/HostPipelineExtensions.cs index 5aa13b1..5581255 100644 --- a/PortfolioCMS.Core/Extensions/HostPipelineExtensions.cs +++ b/PortfolioCMS.Core/Extensions/HostPipelineExtensions.cs @@ -37,6 +37,10 @@ public static class HostPipelineExtensions "style-src 'self' 'unsafe-inline'", // data: fonts: the @fontsource subsets small enough for Vite to inline in the CSS. "font-src 'self' data:", + // Covers the API, the view beacon and the client error reports: every one of them goes to + // the host's own origin. It is why the error reporter posts to /api/activity/error here + // instead of to an external collector — the alternative was widening this directive, and + // the report that gets blocked by a CSP is exactly the one nobody would ever notice. "connect-src 'self'", "form-action 'none'", ]; diff --git a/PortfolioCMS.Core/Extensions/RateLimitPolicies.cs b/PortfolioCMS.Core/Extensions/RateLimitPolicies.cs index 60e6be6..23dc605 100644 --- a/PortfolioCMS.Core/Extensions/RateLimitPolicies.cs +++ b/PortfolioCMS.Core/Extensions/RateLimitPolicies.cs @@ -1,5 +1,6 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +using PortfolioCMS.Core.Services; namespace PortfolioCMS.Core.Extensions; @@ -13,6 +14,17 @@ public static class RateLimitPolicies /// Per client and path, so a single endpoint cannot be hammered. public const int PerPathPermitLimit = 60; + /// + /// The chain with this host's defaults, each overridden only where configuration sets it. + /// A 0 (the default) keeps the value baked in here — see for why + /// an override exists at all, and why production is not supposed to use it. + /// + public static void UseChainedGlobalLimiter( + this RateLimiterOptions options, int perClientDefault, RateLimitOptions configured) => + options.UseChainedGlobalLimiter( + configured.PerClientPermitLimit > 0 ? configured.PerClientPermitLimit : perClientDefault, + configured.PerPathPermitLimit > 0 ? configured.PerPathPermitLimit : PerPathPermitLimit); + public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); /// @@ -31,7 +43,14 @@ public static string ClientKey(HttpContext context) => /// The aggregate budget of one client per , across every path. Differs per /// host: the admin dashboard loads more endpoints per screen than the public site does. /// - public static void UseChainedGlobalLimiter(this RateLimiterOptions options, int perClientPermitLimit) + /// + /// The budget of one client on one path per . Defaults to + /// . + /// + public static void UseChainedGlobalLimiter( + this RateLimiterOptions options, + int perClientPermitLimit, + int perPathPermitLimit = PerPathPermitLimit) { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; @@ -41,13 +60,30 @@ public static void UseChainedGlobalLimiter(this RateLimiterOptions options, int ClientKey(context), _ => FixedWindow(perClientPermitLimit))), PartitionedRateLimiter.Create(context => RateLimitPartition.GetFixedWindowLimiter( - $"{ClientKey(context)}:{context.Request.Path}", _ => FixedWindow(PerPathPermitLimit)))); + $"{ClientKey(context)}:{context.Request.Path}", _ => FixedWindow(perPathPermitLimit)))); } /// A per-endpoint budget, for the actions that need a tighter one than the global. public static RateLimitPartition ClientFixedWindow(HttpContext context, int permitLimit) => RateLimitPartition.GetFixedWindowLimiter(ClientKey(context), _ => FixedWindow(permitLimit)); + /// The policy name the client error endpoint asks for by attribute. + public const string ClientErrorPolicy = "client-error"; + + /// + /// Registers . Both hosts have to call this, not just the public + /// one: the endpoint lives in this assembly, so both serve it, and an endpoint whose policy the + /// host never registered throws at request time rather than at startup. + /// + /// Ten per minute per client. The reporter already caps itself at five per page load, so a + /// well-behaved browser never comes near it; the budget is there for the one that is not, and + /// it is deliberately far below the view beacon's 30 — a flood here writes log lines, and the + /// log is rotation-capped at 30 MB per container. + /// + /// + public static void AddClientErrorPolicy(this RateLimiterOptions options) => + options.AddPolicy(ClientErrorPolicy, context => ClientFixedWindow(context, 10)); + private static FixedWindowRateLimiterOptions FixedWindow(int permitLimit) => new() { PermitLimit = permitLimit, diff --git a/PortfolioCMS.Core/Services/ClientErrorOptions.cs b/PortfolioCMS.Core/Services/ClientErrorOptions.cs new file mode 100644 index 0000000..b558eda --- /dev/null +++ b/PortfolioCMS.Core/Services/ClientErrorOptions.cs @@ -0,0 +1,13 @@ +namespace PortfolioCMS.Core.Services; + +public class ClientErrorOptions +{ + public const string SectionName = "ClientErrors"; + + /// + /// Whether the client error endpoint records what it receives (env: ClientErrors__Enabled). + /// Off, the endpoint still answers 204 and simply drops the report — a kill switch for a + /// reporting loop or an abusive caller that does not need a new image, only a restart. + /// + public bool Enabled { get; set; } = true; +} diff --git a/PortfolioCMS.Core/Services/RateLimitOptions.cs b/PortfolioCMS.Core/Services/RateLimitOptions.cs new file mode 100644 index 0000000..27fe6d0 --- /dev/null +++ b/PortfolioCMS.Core/Services/RateLimitOptions.cs @@ -0,0 +1,30 @@ +namespace PortfolioCMS.Core.Services; + +/// +/// Overrides for the request budgets in RateLimitPolicies. Both values default to 0, which +/// means "keep the host's own limit" — production runs on the defaults and should keep doing so. +/// +/// This exists for an environment that does not have Cloudflare in front of it. In production the +/// edge caches the hashed assets, so the origin sees one document plus a handful of API calls per +/// visit and the per-client budget is generous. Without that cache every page load costs the +/// origin the full asset set, and a browser-driven test suite of a couple of dozen pages crosses +/// the budget inside one window — which surfaces as a 429 on a chunk request and a blank SPA, +/// i.e. a failure that looks like anything except a rate limit. +/// +/// +public class RateLimitOptions +{ + public const string SectionName = "RateLimit"; + + /// + /// Aggregate budget of one client per window, across every path + /// (env: RateLimit__PerClientPermitLimit). 0 keeps the host's default. + /// + public int PerClientPermitLimit { get; set; } + + /// + /// Budget of one client on one path per window (env: RateLimit__PerPathPermitLimit). + /// 0 keeps the PerPathPermitLimit constant in RateLimitPolicies. + /// + public int PerPathPermitLimit { get; set; } +} diff --git a/PortfolioCMS.Server/Dockerfile b/PortfolioCMS.Server/Dockerfile index 345135b..72b51b2 100644 --- a/PortfolioCMS.Server/Dockerfile +++ b/PortfolioCMS.Server/Dockerfile @@ -39,6 +39,11 @@ COPY . . # Into wwwroot before the publish, not into the published output after it: MapStaticAssets serves # from the build-time asset manifest, so anything copied in later would 404. COPY --from=client /src/portfoliocms.client/dist ./PortfolioCMS.Server/wwwroot +# The build emits source maps (vite.config.ts, build.sourcemap: 'hidden') so a client error report's +# stack can be resolved back to source. They must not be served from the public origin, though — a +# map is the unminified source. Deleted before the publish, so they never reach the asset manifest +# or the image; `npm run build-only` at the same commit reproduces them for a lookup. +RUN find ./PortfolioCMS.Server/wwwroot -name '*.map' -type f -delete RUN dotnet publish "./PortfolioCMS.Server/PortfolioCMS.Server.csproj" \ -c $BUILD_CONFIGURATION --no-restore -p:IncludeSpa=false -p:UseAppHost=false -o /app/publish diff --git a/PortfolioCMS.Server/Program.cs b/PortfolioCMS.Server/Program.cs index b8df676..7cf4598 100644 --- a/PortfolioCMS.Server/Program.cs +++ b/PortfolioCMS.Server/Program.cs @@ -58,15 +58,28 @@ "Turnstile:SiteKey and Turnstile:SecretKey must be configured outside Development (set the Turnstile__SiteKey and Turnstile__SecretKey environment variables)."); } + // Client-side error reports (ClientErrorsController, in Core so both hosts serve it). Only a + // kill switch: the reports go straight into the Serilog stream, so there is nothing to size. + builder.Services.Configure( + builder.Configuration.GetSection(ClientErrorOptions.SectionName)); + + // Only an environment without Cloudflare in front of it needs to raise these; unset means the + // defaults below. See RateLimitOptions. + var rateLimits = builder.Configuration.GetSection(RateLimitOptions.SectionName) + .Get() ?? new RateLimitOptions(); + builder.Services.AddRateLimiter(options => { // The chain is shared with the admin host — see RateLimitPolicies. - options.UseChainedGlobalLimiter(240); + options.UseChainedGlobalLimiter(240, rateLimits); // Both of these write, and both are reachable without any authentication, so they get a // budget far below the global one on top of it. options.AddPolicy("contact", context => RateLimitPolicies.ClientFixedWindow(context, 5)); options.AddPolicy("analytics", context => RateLimitPolicies.ClientFixedWindow(context, 30)); + + // Tighter still, and registered on both hosts — see AddClientErrorPolicy. + options.AddClientErrorPolicy(); }); var app = builder.Build(); diff --git a/PortfolioCMS.Server/appsettings.json b/PortfolioCMS.Server/appsettings.json index 394376a..713ee35 100644 --- a/PortfolioCMS.Server/appsettings.json +++ b/PortfolioCMS.Server/appsettings.json @@ -12,10 +12,17 @@ "Default": "", "Redis": "" }, + "RateLimit": { + "PerClientPermitLimit": 0, + "PerPathPermitLimit": 0 + }, "Analytics": { "HashSecret": "", "RetentionDays": 365 }, + "ClientErrors": { + "Enabled": true + }, "Turnstile": { "SiteKey": "", "SecretKey": "" diff --git a/PortfolioCMS.Tests/ClientErrorApiTests.cs b/PortfolioCMS.Tests/ClientErrorApiTests.cs new file mode 100644 index 0000000..d35de82 --- /dev/null +++ b/PortfolioCMS.Tests/ClientErrorApiTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PortfolioCMS.Core.Controllers; +using PortfolioCMS.Core.Extensions; +using PortfolioCMS.Core.Services; + +namespace PortfolioCMS.Tests; + +/// +/// The client error endpoint. No database and no Redis are involved — the reports go straight into +/// the log — so these run without Docker, unlike the rest of the API tests. +/// +/// What is asserted is the log LINE and not a return value, because the line is the whole product: +/// there is no table and no dashboard, only what a person reads out of +/// docker compose logs. +/// +/// +public class ClientErrorApiTests +{ + private static Task ServerAsync( + List log, bool enabled = true, bool rateLimited = false) => + new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + // Warning, because the endpoint logs at Warning and everything MVC and the rate + // limiter say about a request is below it — the assertions are about one line. + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.SetMinimumLevel(LogLevel.Warning); + logging.AddProvider(new ListLoggerProvider(log)); + }); + services.Configure(options => options.Enabled = enabled); + services.AddControllers() + .AddApplicationPart(typeof(ClientErrorsController).Assembly); + if (rateLimited) + { + // Wired the way a host wires it, chain included. Registering the named + // policy alone would leave RejectionStatusCode at the framework default of + // 503 — UseChainedGlobalLimiter is the only place the 429 is set, so a test + // host without it would be asserting against behaviour neither host has. + // The chain's own budget is far above what these cases spend, so what + // rejects here is the per-endpoint policy. + services.AddRateLimiter(options => + { + options.UseChainedGlobalLimiter(1000); + options.AddClientErrorPolicy(); + }); + } + }) + .Configure(app => + { + app.UseRouting(); + if (rateLimited) app.UseRateLimiter(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + })) + .StartAsync(); + + /// Only the endpoint's own lines: the framework may add its own at any level. + private static List Reports(List log) => + log.Where(line => line.StartsWith("Client error", StringComparison.Ordinal)).ToList(); + + private static Task PostAsync( + IHost host, object body, string userAgent = "Mozilla/5.0 (Windows NT 10.0) Firefox/140.0") + { + var client = host.GetTestClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/activity/error") + { + Content = new StringContent( + JsonSerializer.Serialize(body, ApiHost.Json), Encoding.UTF8, "application/json"), + }; + request.Headers.TryAddWithoutValidation("User-Agent", userAgent); + return client.SendAsync(request); + } + + private static object Report( + string kind = "error", + string? message = "TypeError: x is not a function", + string? source = "https://kellner.dev/assets/index-a1b2c3.js", + string? stack = null, + string? path = "/munka/valami", + string? app = "public", + string? traceId = null) => + new + { + kind, + message, + source, + line = 42, + column = 7, + stack, + path, + app, + traceId, + }; + + [Fact] + public async Task A_report_is_logged_with_everything_needed_to_find_it() + { + var log = new List(); + using var server = await ServerAsync(log); + + var response = await PostAsync(server, Report( + traceId: "0af7651916cd43dd8448eb211c80319c", + stack: "at HomeView.setup (index-a1b2c3.js:42:7)")); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var line = Assert.Single(Reports(log)); + Assert.Contains("[error]", line); + Assert.Contains("public", line); + Assert.Contains("/munka/valami", line); + Assert.Contains("TypeError: x is not a function", line); + Assert.Contains("index-a1b2c3.js", line); + Assert.Contains("42:7", line); + // The one field that ties this to the server-side line for the same request. Spelled out in + // the text, not only attached as a property, so a grep for it finds both. + Assert.Contains("0af7651916cd43dd8448eb211c80319c", line); + Assert.Contains("at HomeView.setup", line); + } + + /// + /// The beacon contract: the caller cannot act on a verdict, must not retry, and a + /// distinguishable refusal would tell a prober what the filters are. + /// + [Theory] + [InlineData("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")] + [InlineData("curl/8.5.0")] + [InlineData("")] + public async Task A_bot_is_answered_the_same_way_and_recorded_nowhere(string userAgent) + { + var log = new List(); + using var server = await ServerAsync(log); + + var response = await PostAsync(server, Report(), userAgent); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(Reports(log)); + } + + [Fact] + public async Task The_kill_switch_drops_the_report_without_changing_the_answer() + { + var log = new List(); + using var server = await ServerAsync(log, enabled: false); + + var response = await PostAsync(server, Report()); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(Reports(log)); + } + + /// + /// Nothing here is validated into a 400: the report arrives on a path that never reads the + /// response, so a rejection would only lose it. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task A_report_with_no_message_is_dropped_rather_than_refused(string? message) + { + var log = new List(); + using var server = await ServerAsync(log); + + var response = await PostAsync(server, Report(message: message)); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(Reports(log)); + } + + [Fact] + public async Task An_unknown_kind_is_recorded_as_other_rather_than_refused() + { + var log = new List(); + using var server = await ServerAsync(log); + + var response = await PostAsync(server, Report(kind: "'; DROP TABLE --")); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Contains("[other]", Assert.Single(Reports(log))); + } + + /// + /// Every field of a report is attacker-controlled text, and a line break in the middle of one + /// would read as a second log entry — the thing that makes a log untrustworthy. + /// + [Fact] + public async Task A_newline_in_the_report_cannot_forge_a_second_log_line() + { + var log = new List(); + using var server = await ServerAsync(log); + + await PostAsync(server, Report( + message: "harmless\n2026-08-22 10:00:00 [FTL] the database is gone", + stack: "at a (x.js:1:1)\nat b (x.js:2:2)")); + + var line = Assert.Single(Reports(log)); + Assert.DoesNotContain("\n", line); + Assert.DoesNotContain("\r", line); + // The frames are still told apart — the separator is what replaces the break. + Assert.Contains("at a (x.js:1:1) | at b (x.js:2:2)", line); + } + + [Fact] + public async Task Oversized_fields_are_clamped_instead_of_filling_the_log() + { + var log = new List(); + using var server = await ServerAsync(log); + + await PostAsync(server, Report( + message: new string('m', 5000), + stack: new string('s', 50_000))); + + var line = Assert.Single(Reports(log)); + Assert.DoesNotContain(new string('m', 301), line); + Assert.Contains(new string('m', 300), line); + Assert.DoesNotContain(new string('s', 2001), line); + } + + /// + /// Ten a minute per client, well under the view beacon's thirty: a flood here writes log lines, + /// and the log is rotation-capped at 30 MB per container. + /// + [Fact] + public async Task The_endpoint_has_a_budget_of_its_own() + { + var log = new List(); + using var server = await ServerAsync(log, rateLimited: true); + + for (var i = 0; i < 10; i++) + { + var allowed = await PostAsync(server, Report(message: $"Boom {i}")); + Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); + } + + var refused = await PostAsync(server, Report(message: "Boom 11")); + + Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode); + Assert.Equal(10, Reports(log).Count); + } +} diff --git a/PortfolioCMS.Tests/RateLimitPoliciesTests.cs b/PortfolioCMS.Tests/RateLimitPoliciesTests.cs index e5a180a..a128146 100644 --- a/PortfolioCMS.Tests/RateLimitPoliciesTests.cs +++ b/PortfolioCMS.Tests/RateLimitPoliciesTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PortfolioCMS.Core.Extensions; +using PortfolioCMS.Core.Services; namespace PortfolioCMS.Tests; @@ -18,7 +19,12 @@ namespace PortfolioCMS.Tests; /// public class RateLimitPoliciesTests { - private static Task ServerAsync(int perClientPermitLimit) => + /// + /// The configuration override, or null to call the plain overload the way a host without any + /// override would. + /// + private static Task ServerAsync( + int perClientPermitLimit, RateLimitOptions? configured = null) => new HostBuilder() .ConfigureWebHost(web => web .UseTestServer() @@ -26,7 +32,10 @@ private static Task ServerAsync(int perClientPermitLimit) => { services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.None)); services.AddRateLimiter(options => - options.UseChainedGlobalLimiter(perClientPermitLimit)); + { + if (configured is null) options.UseChainedGlobalLimiter(perClientPermitLimit); + else options.UseChainedGlobalLimiter(perClientPermitLimit, configured); + }); }) .Configure(app => { @@ -88,4 +97,49 @@ public async Task A_rejected_request_answers_429() Assert.Equal(HttpStatusCode.TooManyRequests, (await client.GetAsync("/api/works")).StatusCode); } + + /// + /// The configuration override exists for an environment without a CDN in front of it, and its + /// unset value has to be the host's own limit — a 0 that took effect literally would refuse the + /// first request of every deploy. + /// + [Fact] + public async Task An_unset_override_keeps_the_hosts_own_limit() + { + using var server = await ServerAsync(perClientPermitLimit: 2, configured: new RateLimitOptions()); + var client = server.GetTestClient(); + + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/a")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/b")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.GetAsync("/api/c")).StatusCode); + } + + [Fact] + public async Task A_configured_override_replaces_the_hosts_limit() + { + using var server = await ServerAsync( + perClientPermitLimit: 1, configured: new RateLimitOptions { PerClientPermitLimit = 3 }); + var client = server.GetTestClient(); + + for (var i = 0; i < 3; i++) + { + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/path-{i}")).StatusCode); + } + + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.GetAsync("/api/path-4")).StatusCode); + } + + [Fact] + public async Task A_configured_per_path_override_replaces_the_default() + { + using var server = await ServerAsync( + perClientPermitLimit: 1000, configured: new RateLimitOptions { PerPathPermitLimit = 2 }); + var client = server.GetTestClient(); + + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/works")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/works")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.GetAsync("/api/works")).StatusCode); + // A different path keeps its own partition, so the tightened budget is not site-wide. + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/skills")).StatusCode); + } } diff --git a/README.md b/README.md index 6ece86d..a1b0f5a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ portfoliocms.shared a két SPA közös kódja: API kliens, content stor típusellenőrizhető és tesztelhető. A két appba nem npm függőségként, hanem a `@shared` aliasból épül be. +e2e/ Playwright end-to-end a összeállított stacken (docker compose), + a publikus oldalra. Saját package.json — nem kerül belőle semmi + egyik appba sem. + deploy/ üzemeltetés: runbook, mentés/visszaállítás, smoke teszt, cloudflared példakonfiguráció, systemd unitok ``` @@ -183,6 +187,38 @@ egymáshoz, ezért a CI külön ellenőrzi: node scripts/check-dep-sync.mjs ``` +**End-to-end (`e2e/`).** Playwright a összeállított stacken, a publikus oldalra. Azt fedi, amit a +fenti teszttípusok egyike sem ér el: a szerveroldali head-írás (`SpaShell`) és a kliensoldali +`useDocumentMeta` együtt, a valódi 404 vs. SPA fallback (státuszkód **és** a megjelenített panel), +a kapcsolati űrlap teljes köre, a nyelvváltás és a témaválasztás megmaradása, a `?notrack=1` +analytics-kizárás, és a Redis leállítása mellett is működő oldal. + +Előbb fel kell húzni a stacket seedelt tartalommal (`.env` a `.env.example` alapján, benne +`SEED_MODE=dev` és a Cloudflare mindig-átmenő Turnstile teszt kulcspárja — a widget nélkül a +küldés-teszt nem tud végigmenni): + +```bash +docker compose -f docker-compose.yml up -d --wait --wait-timeout 180 web +``` + +```bash +cd e2e +npm install +npx playwright install --with-deps chromium +npm test +``` + +A cache-kimaradás tesztjei külön futnak, mert leállított Redist kérnek — egy spec ezt nem teheti +meg magával anélkül, hogy a suite többi részét eltörné: + +```bash +docker compose -f docker-compose.yml stop redis && npm run test:outage; docker compose -f docker-compose.yml start redis +``` + +A `deploy/smoke-test.sh` marad a mai, gyors HTTP-ellenőrzés: az e2e a PR-t védi, a smoke a +kiadást. Hibánál a CI feltölti a trace-t és a képernyőképeket `playwright-report` artifactként. +Az admin folyamatai kimaradnak az első körből: azokhoz Cloudflare Access assertion kell. + ### 5. NuGet csomagok A négy .NET projekt feloldott csomaggráfja `packages.lock.json`-ban van kikötve, és az image build @@ -205,17 +241,24 @@ host el sem indul: | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | a publikus host — üres secret csendben kikapcsolná a captchát | | `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | az admin host — enélkül hitelesítés nélkül írható lenne | -A többi (`PUBLIC_SITE_URL`, `ALLOWED_HOSTS`, `SEED_MODE`, retention és mentés beállítások) -defaultolódik. `Development`-ben a captcha és az Access értékek elhagyhatók. +A többi (`PUBLIC_SITE_URL`, `ALLOWED_HOSTS`, `SEED_MODE`, `CLIENT_ERRORS_ENABLED`, retention és +mentés beállítások) defaultolódik. `Development`-ben a captcha és az Access értékek elhagyhatók. + +A `RATE_LIMIT_PER_CLIENT` / `RATE_LIMIT_PER_PATH` **élesben maradjon 0-n**: 0 = a hostba épített +limit (240/perc kliensenként a publikus oldalon, 300 az adminban, 60 kliens+útvonal páronként), és +az éles oldal ezekre van hangolva. Azért vannak, hogy egy CDN nélküli környezet — az e2e stack — +feljebb vihesse őket: Cloudflare mögött az edge cache-eli a hash-elt asseteket, e nélkül viszont +minden oldalbetöltés a teljes asset-készletbe kerül az originnél. ## Deploy A `master`-re való push indítja a [`deploy.yml`](.github/workflows/deploy.yml)-t: 1. **ci** — a [`ci.yml`](.github/workflows/ci.yml) meghívása: backend tesztek, mindkét frontend - típusellenőrzése, lintje, `npm audit`-ja és tesztjei, a shared csomag ugyanez, valamint a - NuGet sebezhetőség-ellenőrzés. Egy közvetlen master push sem deployol tesztek nélkül. - Az image build (és vele a Trivy szkennelés) itt kimarad: azt a következő job végzi. + típusellenőrzése, lintje, `npm audit`-ja és tesztjei, a shared csomag ugyanez, a Playwright + e2e a felhúzott stacken, valamint a NuGet sebezhetőség-ellenőrzés. Egy közvetlen master push + sem deployol tesztek nélkül. Az image build (és vele a Trivy szkennelés) itt kimarad: azt a + következő job végzi — az e2e viszont fut, a saját, cache-ből épülő image-ével. 2. **build-and-push** — a két image (`portfoliocms`, `portfoliocms-admin`) buildje és pusholása GHCR-be, `latest` és `sha-` taggel. 3. **deploy** — `docker-compose.yml` és a `deploy/` szkriptek másolása a VPS `/opt/portfoliocms` @@ -231,8 +274,8 @@ Ehhez szükséges repository secretek: - **SSH:** `SSH_HOST`, `SSH_USER`, `SSH_KEY`, `SSH_PORT` - **GHCR (a VPS oldali pullhoz):** `GHCR_USER`, `GHCR_TOKEN` - **alkalmazás:** `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `ANALYTICS_HASH_SECRET`, - `ANALYTICS_RETENTION_DAYS`, `ATTACHMENT_ORPHAN_GRACE_DAYS`, `CONTACT_RETENTION_DAYS`, - `CONTACT_KEEP_UNREAD`, `PUBLIC_SITE_URL`, `SEED_MODE`, + `ANALYTICS_RETENTION_DAYS`, `CLIENT_ERRORS_ENABLED`, `ATTACHMENT_ORPHAN_GRACE_DAYS`, + `CONTACT_RETENTION_DAYS`, `CONTACT_KEEP_UNREAD`, `PUBLIC_SITE_URL`, `SEED_MODE`, `ALLOWED_HOSTS`, `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` - **mentés:** `BACKUP_RETENTION_DAYS`, `BACKUP_AGE_RECIPIENT`, `BACKUP_RCLONE_REMOTE` @@ -265,8 +308,8 @@ Amit a kódból nem lehet látni, mert a hoszton él, a [`deploy/RUNBOOK.md`](de van: a forgalom útja, a cloudflared konfiguráció ([példa](deploy/cloudflared/config.example.yml)), a Cloudflare Access alkalmazás és AUD, az ellenőrzőlista, a deploy és a visszalépés menete, az adatbázis mentése (titkosítás, offsite -tároló, systemd timer) és visszaállítása, a csatolmányok életciklusa, valamint az -erőforrás-korlátok és a logrotáció. +tároló, systemd timer) és visszaállítása, a csatolmányok életciklusa, az erőforrás-korlátok és a +logrotáció, valamint a kliensoldali hibák kiolvasása a logból és a stack trace visszafejtése. ## Megjegyzés a dokumentációhoz diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 9b4f3ce..afc2253 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -421,3 +421,78 @@ docker inspect --format '{{.Name}} {{.HostConfig.LogConfig.Type}} {{.HostConfig. Ha egy konténer OOM-ra állt meg, a `docker inspect` `State.OOMKilled` mezője `true`, és a `docker compose logs` végén nincs rendes leállási sor — ilyenkor nem a limitet kell azonnal emelni, hanem megnézni, mi fogyasztott. + +## 12. Kliensoldali hibák + +A böngészőben történt hibák (JavaScript kivétel, elhasalt chunk-betöltés, hibás API-válasz) a +`POST /api/activity/error`-on érkeznek, és ugyanabba a Serilog-folyamba íródnak, mint minden más +— nincs külön szolgáltatás és nincs külön tábla. Mindkét host kiszolgálja az endpointot, tehát az +admin dashboard is a saját originjára jelent. + +```bash +docker compose -f docker-compose.yml logs web --since 24h | grep "Client error" +``` + +```bash +docker compose -f docker-compose.yml logs admin --since 24h | grep "Client error" +``` + +Egy bejegyzés **egy sor** (itt csak az olvashatóság kedvéért van tördelve). A formátum a Serilog +konzol sink alapértelmezése — `[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}` —, nem a .NET +beépített logger formátuma, mert a `AddPortfolioCore` a Serilogot teszi meg logging providernek: + +``` +[14:22:07 WRN] Client error [chunk] in the public app at /munka/valami — TypeError: Failed to +fetch dynamically imported module (source https://kellner.dev/assets/ArticleView-9f3a1c.js:1:0, +traceId ) stack: at n (index-a1b2c3.js:12:3401) | at r (index-a1b2c3.js:12:2088) +``` + +Amit érdemes tudni róla: + +- A `[...]` a hiba fajtája: `error` (`window.onerror`), `unhandledrejection`, `vue` (komponens + hiba), `chunk` (a kért, hash-elt chunk már nincs az originen — deploy volt a fül betöltése óta, + nem kódhiba), `api` (a `http.ts` nem várt válasza), `other` (a kliens ismeretlen fajtát küldött). +- A `traceId` csak `api` fajtánál van kitöltve, és a szerveroldali logban ugyanarra a kérésre + ugyanez az azonosító szerepel — egy `grep ` mindkét sort megtalálja. Ez a kapcsolat a + kliensoldali tünet és a szerveroldali ok között. +- A jelentés nem tartalmaz query stringet, hash-t és beírt űrlap-tartalmat, a POST `Referer` + fejléce pedig `referrerPolicy: 'origin'` miatt csak az origint viszi. Ami nem szűrhető: egy + kivétel *szövege* idézhet felhasználói bemenetet — ezt a 300 karakteres korlát határolja, nem + szünteti meg. +- Bot user agentről érkező jelentés nem íródik ki (ugyanaz a `BotDetection`, mint a látogatás- + beaconnál), és a bővítmény-eredetű hibákat már a kliens kiszűri. +- Korlátok: a reporter oldalbetöltésenként 5 jelentést küld (azonosakat egyszer), az endpoint + kliensenként 10-et percenként. Ha ez is kevés, a `CLIENT_ERRORS_ENABLED=false` elhallgattatja az + endpointot image build nélkül: + +```bash +sed -i 's/^CLIENT_ERRORS_ENABLED=.*/CLIENT_ERRORS_ENABLED=false/' /opt/portfoliocms/.env && docker compose -f /opt/portfoliocms/docker-compose.yml up -d +``` + + A `.env`-et viszont minden deploy újraírja a repository secretekből, tehát ez azonnali + elhallgattatás, nem tartós beállítás — utóbbihoz a `CLIENT_ERRORS_ENABLED` secretet kell + `false`-ra állítani (különben a következő master push visszakapcsolja). + +### Stack trace visszafejtése + +A `stack` a minifikált fájlra hivatkozik. A buildhez tartozó source map elkészül +(`build.sourcemap: 'hidden'`), de **nem** kerül be az image-be: a map maga a forráskód, azt a +publikus origin nem szolgálja ki. A visszafejtéshez ugyanazon a commiton kell újraépíteni a +frontendet — a stackben szereplő fájlnév hash-e (`index-a1b2c3.js`) mondja meg, melyik build volt: + +```bash +git checkout +cd portfoliocms.client && npm ci && npm run build-only +ls dist/assets/*.map +``` + +Ha a keletkezett fájlnevek egyeznek a stackben látott hash-ekkel, a `dist/assets/.js.map` az +a map, ami a `sor:oszlop` párost forrássorra fordítja (`npx source-map-cli resolve +` vagy bármely böngésző devtools "Add source map" funkciója). + +### Uptime figyelés + +Ide tartozik, de külön tétel: a `/health` endpoint megvan mindkét hoston (a +[4. szekció](#4-ellenőrzőlista-a-vps-en--kívülről-futtatva) ellenőrzőlistája használja), csak +nincs semmi, ami kívülről, folyamatosan nézné. Amíg nincs, egy leállás akkor derül ki, amikor +valaki megnyitja az oldalt. diff --git a/docker-compose.yml b/docker-compose.yml index 0b3affc..907d036 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,6 +119,10 @@ services: Analytics__HashSecret: ${ANALYTICS_HASH_SECRET:?set ANALYTICS_HASH_SECRET in .env} # Latogatas-esemenyek megtartasa napokban (0 = orokre). Analytics__RetentionDays: ${ANALYTICS_RETENTION_DAYS:-365} + # Kliensoldali hibajelentesek (POST /api/activity/error) naplozasa. A jelentesek a Serilog + # folyamba mennek, tehat nincs mit meretezni - ez csak kapcsolo, ha zajossa vagy + # visszaelesszeruve valik a bejelentes. + ClientErrors__Enabled: ${CLIENT_ERRORS_ENABLED:-true} # Turnstile captcha a kapcsolati urlapon. A site key publikus (a bongeszo megkapja a # /api/config-rol), a secret key nem hagyja el a szervert. Ures ertekre a host nem indul el. Turnstile__SiteKey: ${TURNSTILE_SITE_KEY:?set TURNSTILE_SITE_KEY in .env} @@ -129,6 +133,13 @@ services: # Host header szűrés. Cloudflare átadja az eredeti hostot, így a valós domainre szűkítve # a szerver IP-jére küldött, tetszőleges Host-tal érkező kérés 400-at kap. AllowedHosts: ${ALLOWED_HOSTS:-*} + # Kérés-budget felülírás. 0 = a hostba épített érték, és élesben ez a helyes: a Cloudflare + # cache-eli a hash-elt asseteket, tehát az origin egy látogatásból egy dokumentumot és néhány + # API-hívást lát. CDN nélküli környezetben (e2e) viszont minden oldalbetöltés a teljes + # asset-készletbe kerül, és egy böngészős teszt-suite egy ablakon belül átlépi a budgetet. + # Lásd RateLimitOptions. + RateLimit__PerClientPermitLimit: ${RATE_LIMIT_PER_CLIENT:-0} + RateLimit__PerPathPermitLimit: ${RATE_LIMIT_PER_PATH:-0} healthcheck: test: [ @@ -178,6 +189,9 @@ services: ConnectionStrings__Default: "Host=db;Port=5432;Database=${POSTGRES_DB:-portfoliocms};Username=${POSTGRES_USER:-portfolio};Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}" ConnectionStrings__Redis: "redis:6379" SeedMode: ${SEED_MODE:-prod} + # A dashboard SPA kliensoldali hibajelentesei. Ugyanaz a kapcsolo, mint a web szolgaltatasnal: + # az endpoint a Core-ban van, tehat mindket host kiszolgalja a sajat originjan. + ClientErrors__Enabled: ${CLIENT_ERRORS_ENABLED:-true} # Turelmi ido napokban, ami utan a semmilyen tartalom altal nem hivatkozott csatolmany # torlodik (0 = a takaritas kikapcsolva, minden lecserelt kep a tablaban marad). # A turelmi ido azert kell, mert a kep a kivalasztaskor tolodik fel, de csak a form @@ -191,6 +205,9 @@ services: Contact__KeepUnread: ${CONTACT_KEEP_UNREAD:-true} # A publikus oldalra mutató linkek (adminból "Oldal megtekintése", slug-linkek). PublicSiteUrl: ${PUBLIC_SITE_URL:-http://localhost:8080} + # Lásd a web szolgáltatásnál: 0 = a hostba épített érték. + RateLimit__PerClientPermitLimit: ${RATE_LIMIT_PER_CLIENT:-0} + RateLimit__PerPathPermitLimit: ${RATE_LIMIT_PER_PATH:-0} # Az Access alkalmazás, aminek az assertionjét elfogadjuk. Nélküle a host el sem indul. CloudflareAccess__TeamDomain: ${CF_ACCESS_TEAM_DOMAIN:?set CF_ACCESS_TEAM_DOMAIN in .env} CloudflareAccess__Audience: ${CF_ACCESS_AUD:?set CF_ACCESS_AUD in .env} diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..dc25d7f --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +blob-report/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..dc74e01 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "@portfoliocms/e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@portfoliocms/e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^24.13.2", + "typescript": "~6.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.12.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..5b6523b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,22 @@ +{ + "name": "@portfoliocms/e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Playwright end-to-end fust az osszeallitott stackre (docker compose): a publikus oldal azon viselkedesei, amiket a unit- es komponens-tesztek nem erik el.", + "scripts": { + "type-check": "tsc --noEmit", + "test": "playwright test --grep-invert @needs-outage", + "test:outage": "playwright test --grep @needs-outage", + "test:all": "playwright test", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^24.13.2", + "typescript": "~6.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.12.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..4bc66d8 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test' + +// The published port of the `web` service in docker-compose.yml. Overridable so the same suite can +// be pointed at a staging origin without editing anything. +const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8080' + +export default defineConfig({ + testDir: './tests', + + // Serial on purpose, and this is not a performance oversight. + // + // Every request in the run comes from one client IP, and the host rate-limits per client: 240 + // requests a minute in total and 60 per path (RateLimitPolicies). A fresh page load is the + // document plus half a dozen API calls, so a parallel suite would start answering 429 and the + // failures would look like anything except a rate limit. Keep the suite small for the same + // reason — this is the budget it has to fit in. + workers: 1, + fullyParallel: false, + + // A .only left in a spec silently reduces CI to one test. + forbidOnly: !!process.env.CI, + // One retry: the suite talks to a real browser over a real network, and a single retry separates + // a flake from a defect without hiding a test that fails every time. + retries: process.env.CI ? 1 : 0, + + reporter: process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : [['list']], + + use: { + baseURL, + // On failure only. A trace per test is minutes of CI time and hundreds of megabytes, and the + // trace already contains the screenshots, the DOM and the network log — which is the whole + // point: an e2e that fails in CI and cannot be inspected afterwards is not worth having. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + // Chromium only. The suite is here for the seams between the server and the SPA, not for browser + // differences — those would triple the run and the rate-limit budget for a different question. + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + expect: { timeout: 10_000 }, + timeout: 60_000, +}) diff --git a/e2e/tests/analytics.spec.ts b/e2e/tests/analytics.spec.ts new file mode 100644 index 0000000..3aec362 --- /dev/null +++ b/e2e/tests/analytics.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, type Page } from '@playwright/test' +import { seed } from './seed' + +/** + * The self-view exclusion. The dashboard lives on another origin, so it cannot share any state with + * the public site — instead it links here with ?notrack=1, which sets a flag in this origin's + * localStorage once and suppresses the beacon from then on. + * + * There is nothing to unit test here: the mechanism IS the browser storage plus the navigation, and + * the failure mode is the owner quietly counting as a visitor in their own statistics. + */ +test.describe('the visit beacon', () => { + const BEACON = '/api/activity/view' + + /** Collects every beacon the page fires, in order. */ + function watchBeacons(page: Page) { + const seen: string[] = [] + page.on('request', (request) => { + if (request.url().includes(BEACON)) seen.push(request.url()) + }) + return seen + } + + test('a normal page load is counted', async ({ page }) => { + const beacons = watchBeacons(page) + + await page.goto('/') + + await expect.poll(() => beacons.length).toBeGreaterThan(0) + }) + + test('?notrack=1 suppresses it, and keeps suppressing it after navigation', async ({ page }) => { + const beacons = watchBeacons(page) + + // The assertion is a negative one, so it needs an upper bound on when the beacon WOULD have + // fired. trackView runs in router.afterEach, i.e. before the content API calls even resolve — + // so content being on screen is past that point with room to spare. + // + // Deliberately not waitForLoadState('networkidle'): the Turnstile widget on the contact form + // keeps the page busy, so idle never arrives and the wait just burns the test timeout. + await page.goto('/?notrack=1') + await expect(page.locator(`a[href="/munka/${seed.work.slug}"]`).first()).toBeVisible() + expect(beacons).toHaveLength(0) + + // The flag lives in localStorage, so it outlives the query parameter that set it. + await page.goto(`/munka/${seed.work.slug}`) + await expect(page.getByRole('heading', { level: 1 })).toContainText(seed.work.title) + expect(beacons).toHaveLength(0) + expect(await page.evaluate(() => localStorage.getItem('pf_notrack'))).toBe('1') + }) +}) diff --git a/e2e/tests/cache-outage.spec.ts b/e2e/tests/cache-outage.spec.ts new file mode 100644 index 0000000..325e20f --- /dev/null +++ b/e2e/tests/cache-outage.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test' +import { seed } from './seed' + +/** + * ContentCache is fail-open by design: Redis holds nothing that cannot be read back from the + * database, so a cache outage is supposed to cost latency and nothing else. That branch is the kind + * that is written once, never exercised, and discovered to be broken during the outage it exists + * for — and a unit test cannot exercise it, because what has to fail is a real connection. + * + * Tagged @needs-outage: the run for these has Redis stopped, which is not something a spec can do + * to itself without leaving the stack broken for the rest of the suite. The e2e job in ci.yml stops + * the container, runs `npm run test:outage`, and starts it again in an always() step. + */ +test.describe('with the cache down', () => { + // Latency is the whole point of the fail-open trade-off, so these cases get room for it rather + // than asserting the site is as quick without the cache as with it. + test.describe.configure({ timeout: 120_000 }) + + /** + * The FIRST request after the cache disappears pays for the discovery: the Redis client has to + * notice the connection is gone before ContentCache can fall through to the database, and until + * it does, requests sit waiting on it. Measured in CI, a page load issued during that window + * never got its four content calls answered at all. + * + * What this file is about is whether the site works DURING an outage, not whether it is instantly + * fast at the moment one begins — so the discovery happens once, here, and the cases below then + * see the steady state. A worker-scoped context because beforeAll cannot use the per-test + * `request` fixture. + */ + test.beforeAll(async ({ playwright }) => { + const api = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }) + await api.get('/api/works', { timeout: 60_000 }) + await api.dispose() + }) + + test('the home page still serves its content @needs-outage', async ({ page }) => { + const response = await page.goto('/') + + expect(response?.status()).toBe(200) + await expect(page.locator(`a[href="/munka/${seed.work.slug}"]`).first()).toBeVisible({ + timeout: 30_000, + }) + }) + + test('the API answers from the database @needs-outage', async ({ request }) => { + const response = await request.get('/api/works') + + expect(response.status()).toBe(200) + expect((await response.json()).length).toBeGreaterThan(0) + }) + + test('the route index falls open rather than 404-ing every page @needs-outage', async ({ + page, + }) => { + // PublicRouteIndex is cached, and the 404 verdict depends on it. Fail-CLOSED here would be far + // worse than the soft 404 it replaced: every page on the site would answer 404 during a cache + // outage. + const response = await page.goto(`/munka/${seed.work.slug}`) + + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { level: 1 })).toContainText(seed.work.title, { + timeout: 30_000, + }) + }) +}) diff --git a/e2e/tests/contact.spec.ts b/e2e/tests/contact.spec.ts new file mode 100644 index 0000000..4a57d74 --- /dev/null +++ b/e2e/tests/contact.spec.ts @@ -0,0 +1,71 @@ +import { expect, test, type Page } from '@playwright/test' + +/** + * The contact form, end to end: the client-side validation, the Turnstile widget, the POST, the + * server-to-server siteverify call and the confirmation panel. Every one of those has a unit or + * component test; the round trip has none, and it is the one flow on the site where a visitor can + * lose work. + * + * The stack under test runs with Cloudflare's always-passing Turnstile test key pair + * (1x00000000000000000000AA / 1x0000000000000000000000000000000AA — see the e2e job in ci.yml), so + * the widget solves itself and the server's verification succeeds without any interaction. + */ +test.describe('the contact form', () => { + /** + * Waits until the captcha is genuinely solved. + * + * The signal is the hidden input Turnstile writes the token into — its documented contract, and + * the only thing here that does not depend on how the widget happens to render. With the + * always-passing test key it produces no iframe at all, just this input carrying a dummy token, + * so anything that waited for the widget's own markup would wait forever. + * + * Two waits, and both are needed: `captchaRequired` starts false and only turns true once + * /api/config hands over the site key, so for a moment after mount the submit button is enabled + * with no token at all. Attached-then-non-empty is monotonic in both steps, so neither is an + * intermediate state the poller could race past — and the attachment check comes first because a + * `not.toHaveValue('')` on a missing element would pass vacuously. + */ + async function waitForSolvedCaptcha(page: Page) { + const token = page.locator('form.form input[name="cf-turnstile-response"]') + await expect(token).toBeAttached({ timeout: 30_000 }) + await expect(token).not.toHaveValue('', { timeout: 30_000 }) + await expect(page.locator('form.form button.send')).toBeEnabled({ timeout: 30_000 }) + } + + test('an empty submit shows the field errors and posts nothing', async ({ page }) => { + const posts: string[] = [] + page.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/api/contact')) { + posts.push(request.url()) + } + }) + + await page.goto('/') + await waitForSolvedCaptcha(page) + + const form = page.locator('form.form') + await form.locator('button.send').click() + + // One per required field. They appear on the first submit attempt, not while the visitor is + // still typing into an empty form. + await expect(form.locator('.field-error')).toHaveCount(3) + expect(posts).toHaveLength(0) + }) + + test('a filled form is accepted and the confirmation replaces it', async ({ page }) => { + await page.goto('/') + const form = page.locator('form.form') + + await form.locator('input[name="name"]').fill('Playwright E2E') + await form.locator('input[name="email"]').fill('e2e@example.com') + await form.locator('textarea[name="message"]').fill('Üzenet az e2e futásból.') + + await waitForSolvedCaptcha(page) + await form.locator('button.send').click() + + // The form is replaced by the confirmation, which is also what moves the focus — a screen + // reader user would otherwise be left on with no clue that anything happened. + await expect(page.locator('.sent')).toBeVisible() + await expect(form).toHaveCount(0) + }) +}) diff --git a/e2e/tests/head.spec.ts b/e2e/tests/head.spec.ts new file mode 100644 index 0000000..6669974 --- /dev/null +++ b/e2e/tests/head.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test' +import { missingWorkPath, seed, siteName } from './seed' + +/** + * The head of a page is written twice: once by the server into the served index.html + * (SpaShell/PageMetaIndex, so a crawler or a link unfurler that runs no scripts sees the page's own + * tags) and once by the SPA after mount (useDocumentMeta). Which one wins, and whether they agree, + * is not visible from either side alone — and the failure mode is silent: every shared link + * unfurling as "Portfólió" while the browser tab looks perfectly correct. + */ +test.describe('the served head and the head after hydration', () => { + test('the server writes the page its own tags, not the built-in placeholder', async ({ + request, + }) => { + const html = await (await request.get(`/munka/${seed.work.slug}`)).text() + + expect(html).toContain(`${seed.work.title} — ${siteName}`) + expect(html).toContain(`content="${seed.work.title} — ${siteName}"`) + // The static fallback in index.html deliberately carries neither, because the bundle is built + // without knowing the public host. Their presence is therefore proof that the server templated + // this response rather than serving the file as it is. + expect(html).toContain('rel="canonical"') + expect(html).toContain('property="og:url"') + // And the markers themselves are consumed, not shipped. + expect(html).not.toContain('') + }) + + test('the SPA keeps the same title after mount instead of overwriting it', async ({ page }) => { + await page.goto(`/munka/${seed.work.slug}`) + + // Waits for the client's own useDocumentMeta pass: if the two disagreed, this is where the + // title would flip to the placeholder or to a half-loaded state. + await expect(page).toHaveTitle(`${seed.work.title} — ${siteName}`) + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + 'href', + new RegExp(`/munka/${seed.work.slug}$`), + ) + }) + + test('a missing page is served noindex by the server as well', async ({ request }) => { + const response = await request.get(missingWorkPath) + + expect(response.status()).toBe(404) + const html = await response.text() + expect(html).toContain('noindex') + // The 404 verdict depends on content that can change at any minute, so it must not be cached. + expect(response.headers()['cache-control']).toContain('no-store') + }) + + test('the home page is templated from the About record, and the SPA agrees', async ({ + page, + request, + }) => { + // Not the bare site name: PageMetaIndex builds the home title from the About row, and HomeView + // builds the same string client-side. If the two ever diverged, this is the assertion that + // would notice — the served head and the head after mount have to be the same document. + const expected = `${seed.about.name} — ${siteName}` + + const response = await request.get('/') + expect(response.status()).toBe(200) + expect(await response.text()).toContain(`${expected}`) + + await page.goto('/') + await expect(page).toHaveTitle(expected) + }) +}) diff --git a/e2e/tests/language.spec.ts b/e2e/tests/language.spec.ts new file mode 100644 index 0000000..963f9cc --- /dev/null +++ b/e2e/tests/language.spec.ts @@ -0,0 +1,41 @@ +import { expect, test, type Page } from '@playwright/test' +import { seed } from './seed' + +/** + * The language toggle writes localStorage and the html lang attribute, and every piece of copy is + * resolved through tr() at render time. Whether the choice actually survives a navigation is a + * property of the whole app — the composable's module scope, the store and the router together — so + * a component test cannot see it. + */ +test.describe('the language choice', () => { + const englishToggle = (page: Page) => + page.getByRole('button', { name: 'EN', exact: true }).first() + + test('starts as Hungarian', async ({ page }) => { + await page.goto('/') + + await expect(page.locator('html')).toHaveAttribute('lang', 'hu') + }) + + test('switching to English changes the page and survives a navigation', async ({ page }) => { + await page.goto('/') + await englishToggle(page).click() + + await expect(page.locator('html')).toHaveAttribute('lang', 'en') + // A nav label that only exists in the English half of the i18n object. + await expect(page.getByRole('link', { name: 'Work', exact: true })).toBeVisible() + + // A client-side navigation, then a full reload: the first proves the choice is reactive across + // views, the second that it was persisted and re-read at module import time. + await page.locator(`a[href="/munka/${seed.work.slug}"]`).first().click() + await expect(page.locator('html')).toHaveAttribute('lang', 'en') + + await page.reload() + await expect(page.locator('html')).toHaveAttribute('lang', 'en') + + // The key itself, not only its effect: /error.js reads pf_lang so a rate-limited or 5xx visitor + // keeps their language on a page that loads no bundle, and that page cannot be reached from a + // test without provoking a 429. + expect(await page.evaluate(() => localStorage.getItem('pf_lang'))).toBe('en') + }) +}) diff --git a/e2e/tests/pages.spec.ts b/e2e/tests/pages.spec.ts new file mode 100644 index 0000000..c80a37e --- /dev/null +++ b/e2e/tests/pages.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test' +import { missingWorkPath, seed } from './seed' + +/** + * The status code a path answers with, and whether the page a visitor then sees agrees with it. + * + * This is the seam nothing else can reach: the SPA cannot set a status code, so the server decides + * it from its own copy of the route table (PublicRoutes), and the browser decides what to render. + * A unit test can check either half; only a browser against the assembled stack checks that the two + * agree — and the failure mode when they do not is a soft 404 that a crawler indexes as a page. + */ +test.describe('status codes and the page behind them', () => { + test('the home page answers 200 and renders the seeded content', async ({ page }) => { + const response = await page.goto('/') + + expect(response?.status()).toBe(200) + // The five sections the nav links to; their ids are what those links target. + for (const id of ['about', 'skills', 'experience', 'work', 'contact']) { + await expect(page.locator(`#${id}`)).toBeAttached() + } + // Content from the database, not from the bundle: this is what proves the API round trip and + // the seeding both happened. + await expect(page.locator(`a[href="/munka/${seed.work.slug}"]`).first()).toBeVisible() + }) + + test('a real project page answers 200', async ({ page }) => { + const response = await page.goto(`/munka/${seed.work.slug}`) + + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { level: 1 })).toContainText(seed.work.title) + }) + + // Only the status code is in question here, and a document request is one request against the + // host's rate limit where a browser navigation is a dozen. + test('an English alias of the same page also answers 200', async ({ request }) => { + const response = await request.get(`/work/${seed.work.slug}`) + + expect(response.status()).toBe(200) + }) + + test('a project slug with nothing behind it answers 404 and shows the 404 panel', async ({ + page, + }) => { + const response = await page.goto(missingWorkPath) + + // Both halves matter. The status is for the crawler, the panel is for the visitor, and the bug + // this guards against is exactly the case where only one of them is right. + expect(response?.status()).toBe(404) + await expect(page.locator('.panel .code')).toHaveText('404') + await expect(page).toHaveTitle(/^404 —/) + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + 'content', + /noindex/, + ) + }) + + test.describe('a path that looks like a file', () => { + // A scanner probe. Handing out index.html with 200 would make every probe look like a valid + // page and let it cache-fill the CDN with HTML. + for (const path of ['/wp-login.php', '/.env', '/assets/nincs-ilyen.js']) { + test(`${path} answers a bare 404`, async ({ request }) => { + const response = await request.get(path) + + expect(response.status()).toBe(404) + expect(await response.text()).toBe('') + }) + } + }) + + test('an unmatched API path is a 404 and not the SPA shell', async ({ request }) => { + const response = await request.get('/api/nincs-ilyen-endpoint') + + expect(response.status()).toBe(404) + expect(await response.text()).not.toContain('') + }) + + test('the admin write endpoints do not exist on the public host', async ({ request }) => { + // Not a 401 or a 403: they are not deployed here at all, and that is the point. + const response = await request.post('/api/works', { + data: { slug: 'nem-szabad' }, + failOnStatusCode: false, + }) + + expect(response.status()).toBe(404) + }) +}) diff --git a/e2e/tests/seed.ts b/e2e/tests/seed.ts new file mode 100644 index 0000000..37fc473 --- /dev/null +++ b/e2e/tests/seed.ts @@ -0,0 +1,32 @@ +/** + * The slugs and titles SeedMode=dev writes (PortfolioCMS.Core/Data/SeedData.cs). Kept here rather + * than inlined in the specs, so a change to the demo content breaks in one place instead of six. + * + * Slugs and not display text wherever a test has a choice: a slug is an address and an identifier, + * the surrounding copy is design that is allowed to change without a test failing. + */ +export const seed = { + work: { + slug: 'vue-portfolio-cms', + title: 'Vue Portfolio CMS', + }, + experience: { + slug: 'technova', + }, + skill: { + slug: 'frontend', + }, + /** A tag page's slug is derived by the SPA from a label — "Vue 3" in the work's stack. */ + tag: { + slug: 'vue-3', + }, + about: { + name: 'Kovács Bence', + }, +} as const + +/** A path that matches a public route but has no content behind it — a real 404, not a soft one. */ +export const missingWorkPath = '/munka/nincs-ilyen-projekt' + +/** The site name PageMeta appends to every title. */ +export const siteName = 'Portfólió' diff --git a/e2e/tests/theme.spec.ts b/e2e/tests/theme.spec.ts new file mode 100644 index 0000000..2700019 --- /dev/null +++ b/e2e/tests/theme.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Page } from '@playwright/test' + +/** + * The colour theme has three moving parts that only meet in a browser: base.css decides the palette + * from a media query and a data-theme attribute, /theme.js writes that attribute before the + * stylesheet is applied, and useTheme owns the value and the theme-color meta tag. A unit test can + * check the composable; only this can check that the attribute the composable writes is the one the + * stylesheet reacts to. + */ +test.describe('the colour theme', () => { + const themeButton = (page: Page, label: string) => + page.getByRole('button', { name: label, exact: true }).first() + + // The dark and light --bg from base.css. Spelled out rather than read from a token, because the + // question here is whether the palette was applied at all. + const darkBg = 'rgb(19, 19, 22)' + const lightBg = 'rgb(251, 251, 250)' + + test('follows the system preference with no attribute written', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto('/') + + // Absent on purpose: that is what leaves the decision to the media query, so the OS setting + // changing while the page is open re-colours it with no JavaScript involved. + await expect(page.locator('html')).not.toHaveAttribute('data-theme', /.+/) + await expect(page.locator('body')).toHaveCSS('background-color', darkBg) + }) + + test('an explicit light choice beats a dark system setting', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto('/') + + await themeButton(page, 'Világos').click() + + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await expect(page.locator('body')).toHaveCSS('background-color', lightBg) + }) + + test('an explicit dark choice survives a reload', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto('/') + + await themeButton(page, 'Sötét').click() + + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#131316') + + await page.reload() + + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('body')).toHaveCSS('background-color', darkBg) + expect(await page.evaluate(() => localStorage.getItem('pf_theme'))).toBe('dark') + }) + + // No browser needed, and that is the point: a static file with a dot in its name is exactly what + // the SPA fallback answers with a bare 404, so reaching it at all proves it is in the build-time + // asset manifest. It is what applies the stored theme before the first paint — without it the + // attribute would still arrive, but only after the stylesheet had painted the other palette once. + test('the bootstrap script is served and needs nothing from the bundle', async ({ request }) => { + const response = await request.get('/theme.js') + + expect(response.status()).toBe(200) + expect(await response.text()).toContain('pf_theme') + }) +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..794c96b --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,21 @@ +// Playwright transpiles the specs with esbuild and does not type-check them at all, so a spec can +// be type-nonsense and still run — which is why `npm run type-check` is a step of its own here and +// in the CI job. The options mirror the frontend ones so a spec cannot pass here and fail there. +{ + "include": ["**/*.ts"], + "exclude": ["node_modules", "playwright-report", "test-results"], + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/portfoliocms.admin.client/index.html b/portfoliocms.admin.client/index.html index 6aa57c1..07587e3 100644 --- a/portfoliocms.admin.client/index.html +++ b/portfoliocms.admin.client/index.html @@ -2,9 +2,16 @@ + + + + + + Admin — Portfólió diff --git a/portfoliocms.admin.client/public/theme.js b/portfoliocms.admin.client/public/theme.js new file mode 100644 index 0000000..51bbef9 --- /dev/null +++ b/portfoliocms.admin.client/public/theme.js @@ -0,0 +1,20 @@ +// Applies the stored colour theme before the stylesheet is, so an explicit choice does not flash +// the other palette first. Loaded from above the bundle: a separate file rather than an +// inline script because the CSP is script-src 'self' with no nonce. +// +// Only the attribute is set here. Everything else about the theme — the value, the toggle, the +// theme-color meta tag — belongs to portfoliocms.shared/composables/useTheme.ts, which reads the +// same 'pf_theme' key; this file is the paint-critical half of it and nothing more. +// +// No stored value, or "system": the attribute stays off and the @media (prefers-color-scheme) +// block in base.css decides, so the common case needs no JavaScript at all. +(function () { + try { + var stored = localStorage.getItem('pf_theme'); + if (stored === 'dark' || stored === 'light') { + document.documentElement.setAttribute('data-theme', stored); + } + } catch { + /* private mode: the system preference stands */ + } +})(); diff --git a/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue b/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue index d075249..b620cbe 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue @@ -133,7 +133,7 @@ async function remove(m: ContactMessageListItem) { .stat { padding: 20px; - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; } @@ -151,7 +151,7 @@ async function remove(m: ContactMessageListItem) { } .activity { - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; overflow: hidden; @@ -200,7 +200,7 @@ async function remove(m: ContactMessageListItem) { } .status.new { - color: #fff; + color: var(--on-accent); background: var(--accent); } diff --git a/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue b/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue index 12cb574..7e19d8a 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue @@ -72,7 +72,7 @@ const rows = (items: (typeof content.experiences)[number][]): AdminRow[] => } .group { - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; overflow: hidden; @@ -115,7 +115,7 @@ const rows = (items: (typeof content.experiences)[number][]): AdminRow[] => .add { font-size: 13px; font-weight: 500; - color: #fff; + color: var(--on-accent); background: var(--accent); border: none; border-radius: 8px; diff --git a/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue b/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue index f73def9..02f56df 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue @@ -47,7 +47,7 @@ const config = useConfigStore() diff --git a/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue b/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue index ea3470a..68f9dbd 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue @@ -5,6 +5,7 @@ import { useLang } from '@shared/composables/useLang' import { ui } from '@/i18n/ui' import { useContentStore } from '@shared/stores/content' import { useConfigStore } from '@/stores/config' +import ThemeToggle from './ThemeToggle.vue' export type AdminTab = 'dash' | 'stats' | 'about' | 'skills' | 'experience' | 'works' | 'contact' @@ -60,6 +61,7 @@ const items = computed(() => [
+ ↗ {{ tr(ui.viewSite) }} @@ -71,7 +73,7 @@ const items = computed(() => [ .sidebar { border-right: 1px solid var(--border); padding: 22px 16px; - background: #fff; + background: var(--surface); display: flex; flex-direction: column; } @@ -94,7 +96,7 @@ const items = computed(() => [ align-items: center; justify-content: center; font-weight: 600; - color: #fff; + color: var(--on-accent); } .brand-title { @@ -156,7 +158,7 @@ const items = computed(() => [ } .count.active { - background: #fff; + background: var(--surface); } .foot { diff --git a/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue b/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue index b692f29..a3afce0 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue @@ -258,7 +258,7 @@ async function save() { diff --git a/portfoliocms.admin.client/src/components/admin/WorldMap.vue b/portfoliocms.admin.client/src/components/admin/WorldMap.vue index de0d570..a1b09c1 100644 --- a/portfoliocms.admin.client/src/components/admin/WorldMap.vue +++ b/portfoliocms.admin.client/src/components/admin/WorldMap.vue @@ -117,7 +117,7 @@ svg { position: absolute; pointer-events: none; background: var(--ink); - color: #fff; + color: var(--on-ink); font-size: 12px; padding: 4px 9px; border-radius: 6px; diff --git a/portfoliocms.admin.client/src/main.ts b/portfoliocms.admin.client/src/main.ts index 63d3e21..93b0a38 100644 --- a/portfoliocms.admin.client/src/main.ts +++ b/portfoliocms.admin.client/src/main.ts @@ -10,14 +10,28 @@ import './assets/admin.css' import { createApp } from 'vue' import { createPinia } from 'pinia' +import { + installErrorReporter, + reportCaughtError, + vueErrorHandler, +} from '@shared/utils/errorReporter' import App from './App.vue' import router from './router' import { useConfigStore } from './stores/config' +// Before createApp, so an exception thrown while the app is being put together is reported too. +// Reports go to this host's own /api/activity/error — the dashboard is a separate origin, and +// posting to the public one would need CORS and a widened connect-src for no gain. +installErrorReporter({ app: 'admin' }) + const app = createApp(App) +// Vue catches component errors itself, so they never reach window.onerror. +app.config.errorHandler = vueErrorHandler app.use(createPinia()) app.use(router) +router.onError((error) => reportCaughtError(error, 'router')) + // The public site is on another origin, so its URL has to come from the server at runtime. // Fire-and-forget: links fall back to a disabled state until it arrives. useConfigStore().load() diff --git a/portfoliocms.admin.client/src/utils/imageEdit.ts b/portfoliocms.admin.client/src/utils/imageEdit.ts index db7712c..326a14d 100644 --- a/portfoliocms.admin.client/src/utils/imageEdit.ts +++ b/portfoliocms.admin.client/src/utils/imageEdit.ts @@ -20,6 +20,8 @@ export function cropImageToBase64( if (!ctx) throw new Error('Canvas 2D context unavailable') // JPEG has no alpha channel — transparent source pixels would render black. The formats that // do have one are left transparent: a white plate under a logo is just as wrong. + // Deliberately a literal and not a theme token: this ends up baked into a stored image every + // visitor sees, so it must not depend on which theme the editor happened to be using. if (mime === 'image/jpeg') { ctx.fillStyle = '#fff' ctx.fillRect(0, 0, output.width, output.height) diff --git a/portfoliocms.admin.client/src/views/AdminView.vue b/portfoliocms.admin.client/src/views/AdminView.vue index ce57f90..6ac595e 100644 --- a/portfoliocms.admin.client/src/views/AdminView.vue +++ b/portfoliocms.admin.client/src/views/AdminView.vue @@ -308,7 +308,7 @@ h1 { .add { font-size: 14px; font-weight: 500; - color: #fff; + color: var(--on-accent); background: var(--accent); border: none; border-radius: 9px; diff --git a/portfoliocms.admin.client/vite.config.ts b/portfoliocms.admin.client/vite.config.ts index 7e73c43..acb8bfb 100644 --- a/portfoliocms.admin.client/vite.config.ts +++ b/portfoliocms.admin.client/vite.config.ts @@ -55,6 +55,10 @@ const useHttp = env.DEV_SERVER_HTTP === '1' // https://vitejs.dev/config/ export default defineConfig(({ command }) => ({ plugins: [plugin()], + build: { + // Same reasoning as the public app — see portfoliocms.client/vite.config.ts. + sourcemap: 'hidden' + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/portfoliocms.client/index.html b/portfoliocms.client/index.html index 94af114..ec4ac99 100644 --- a/portfoliocms.client/index.html +++ b/portfoliocms.client/index.html @@ -4,10 +4,19 @@ + + + + + + + + diff --git a/portfoliocms.client/src/components/common/DetailNav.vue b/portfoliocms.client/src/components/common/DetailNav.vue index d384e6a..6a75ef8 100644 --- a/portfoliocms.client/src/components/common/DetailNav.vue +++ b/portfoliocms.client/src/components/common/DetailNav.vue @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useLang } from '@shared/composables/useLang' import { ui } from '@/i18n/ui' import LangToggle from './LangToggle.vue' +import ThemeToggle from './ThemeToggle.vue' withDefaults(defineProps<{ showLang?: boolean }>(), { showLang: true }) @@ -27,6 +28,9 @@ function copyLink() {