Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/"
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions PortfolioCMS.Admin/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion PortfolioCMS.Admin/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,26 @@
client => client.Timeout = TimeSpan.FromSeconds(10));
builder.Services.AddSingleton<CloudflareAccessKeyStore>();

// 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<ClientErrorOptions>(
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<RateLimitOptions>() ?? 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();

Expand Down
7 changes: 7 additions & 0 deletions PortfolioCMS.Admin/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"Redis": ""
},
"SeedMode": "prod",
"RateLimit": {
"PerClientPermitLimit": 0,
"PerPathPermitLimit": 0
},
"ClientErrors": {
"Enabled": true
},
"Attachments": {
"OrphanGraceDays": 7
},
Expand Down
118 changes: 118 additions & 0 deletions PortfolioCMS.Core/Controllers/ClientErrorsController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Ingestion for client-side failures. The server side has always had error tracking (Serilog, the
/// request log, an <see cref="ErrorId"/> 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: <c>docker compose logs</c> is where they were always going to be read.
/// <para>
/// 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
/// <c>connect-src</c> in the admin CSP, in exchange for nothing — an admin-side exception is
/// exactly as invisible today as a public one.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
[ApiController]
[Route("api/activity")]
public class ClientErrorsController(
IOptions<ClientErrorOptions> options,
ILogger<ClientErrorsController> logger) : ControllerBase
{
/// <summary>
/// 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.
/// </summary>
private static readonly HashSet<string> 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 <id>` 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();
}

/// <summary>
/// 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.
/// </summary>
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;
}
}
21 changes: 21 additions & 0 deletions PortfolioCMS.Core/Dtos/AnalyticsDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ namespace PortfolioCMS.Core.Dtos;

public record RecordViewRequest([Required] string Section, string? Slug, string? Lang);

/// <summary>
/// One client-side failure, as portfoliocms.shared/utils/errorReporter.ts sends it.
/// <para>
/// Every field is nullable and none carries a validation attribute, unlike
/// <see cref="RecordViewRequest"/>. 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.
/// </para>
/// </summary>
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(
Expand Down
Loading
Loading